Problem
conductor run --web-bg prints a dashboard URL and exits successfully even when the workflow never actually starts. The child process dies moments later and the URL points at a dashboard that is already gone.
Reproduced with a workflow that fails config validation:
❯ conductor run ship@conductor-workflows --web-bg --input issue=404
Dashboard: http://127.0.0.1:41234
…while the same workflow in the foreground fails immediately:
❯ conductor run ship@conductor-workflows --input issue=404
Loading workflow: /home/jason/src/conductor-workflows/workflows/ship/workflow.yaml
╭─────────────── ❌ ConfigurationError ───────────────╮
│ Configuration validation failed in │
│ '.../workflows/ship/workflow.yaml': │
╰─────────────────────────────────────────────────────╯
Root cause
Two independent gaps:
1. The dashboard binds the port before the workflow config is loaded.
In cli/run.py::run_workflow_async, await dashboard.start() happens before load_config(workflow_path):
if web:
dashboard = WebDashboard(...)
await dashboard.start() # <-- port is now listening
try:
verbose_log(f"Loading workflow: {workflow_path}")
config = load_config(workflow_path) # <-- ConfigurationError raised here
Note that resume_workflow_async in the same file already does the opposite — it constructs the dashboard early but defers dashboard.start() until after load_config and the replay seeding, so the run path is also inconsistent with resume.
2. The readiness probe only checks that a TCP port is open.
cli/bg_runner.py::_wait_for_server opens a socket to 127.0.0.1:<port> and returns True as soon as anything accepts. Combined with (1), "the dashboard is listening" is satisfied while the workflow has not been loaded, let alone started. _finalize_background_launch then writes the PID file and launch_background returns a BackgroundLaunch, so the parent reports success.
_wait_for_server also never checks proc.poll() inside its loop, so when the child does die before binding, the parent still burns the full 15-second timeout before reporting anything.
Proposed fix
Confirm the workflow is actually running — not merely that a socket is open — before --web-bg returns:
- Add a workflow-start probe.
GET /api/info on the dashboard already returns {} until a workflow_started event lands, and a populated {run_id, workflow_name, started_at, ...} once it does — a ready-made readiness signal with no new endpoint. After the port probe succeeds, poll it until workflow_started is observed.
- Watch for early child exit throughout. Check
proc.poll() on every iteration of both the port wait and the new workflow-start wait, and fail immediately with the exit code instead of waiting out the timeout.
- Surface the actual error. Include a tail of the captured child stderr log in the
RuntimeError message so the user sees the ConfigurationError inline rather than having to open $TMPDIR/conductor/conductor-<name>-<ts>-<runid>.bg.stderr.log.
- Bound the wait sensibly. Startup legitimately includes plugin source prefetch (git clone), MCP server construction, and provider connection validation, so the workflow-start probe needs a generous timeout (~30s, ideally env-overridable). If the deadline passes but the child is still alive, proceed and report the URL with a "still initializing" note rather than failing — the goal is to catch dead children, not to penalise slow ones.
- Move
dashboard.start() after load_config in run_workflow_async, matching resume_workflow_async. This makes config errors fail before the port is ever bound, and is a parity fix in its own right.
A failure that happens after workflow_started (a provider error, a failing first agent) should still return the URL — the dashboard genuinely renders that failure and is useful. The bug is specifically about failures before the workflow ever starts.
Notes for implementation
BackgroundLaunch would need a field (e.g. workflow_started: bool) so cli/app.py can print the "still initializing" note in the timeout case.
- Roughly 20 test sites across
tests/test_cli/test_bg_runner.py, test_web_flags.py, test_resume_command.py, and tests/test_config/test_instructions.py patch conductor.cli.bg_runner._wait_for_server directly; they will need the new probe patched alongside it, otherwise they will run the real probe against a non-listening port and wait out the timeout.
- Both
launch_background and launch_background_resume share _spawn_bg_child → _finalize_background_launch, so the fix lands in one place and applies to run and resume alike.
Problem
conductor run --web-bgprints a dashboard URL and exits successfully even when the workflow never actually starts. The child process dies moments later and the URL points at a dashboard that is already gone.Reproduced with a workflow that fails config validation:
…while the same workflow in the foreground fails immediately:
Root cause
Two independent gaps:
1. The dashboard binds the port before the workflow config is loaded.
In
cli/run.py::run_workflow_async,await dashboard.start()happens beforeload_config(workflow_path):Note that
resume_workflow_asyncin the same file already does the opposite — it constructs the dashboard early but defersdashboard.start()until afterload_configand the replay seeding, so therunpath is also inconsistent withresume.2. The readiness probe only checks that a TCP port is open.
cli/bg_runner.py::_wait_for_serveropens a socket to127.0.0.1:<port>and returnsTrueas soon as anything accepts. Combined with (1), "the dashboard is listening" is satisfied while the workflow has not been loaded, let alone started._finalize_background_launchthen writes the PID file andlaunch_backgroundreturns aBackgroundLaunch, so the parent reports success._wait_for_serveralso never checksproc.poll()inside its loop, so when the child does die before binding, the parent still burns the full 15-second timeout before reporting anything.Proposed fix
Confirm the workflow is actually running — not merely that a socket is open — before
--web-bgreturns:GET /api/infoon the dashboard already returns{}until aworkflow_startedevent lands, and a populated{run_id, workflow_name, started_at, ...}once it does — a ready-made readiness signal with no new endpoint. After the port probe succeeds, poll it untilworkflow_startedis observed.proc.poll()on every iteration of both the port wait and the new workflow-start wait, and fail immediately with the exit code instead of waiting out the timeout.RuntimeErrormessage so the user sees theConfigurationErrorinline rather than having to open$TMPDIR/conductor/conductor-<name>-<ts>-<runid>.bg.stderr.log.dashboard.start()afterload_configinrun_workflow_async, matchingresume_workflow_async. This makes config errors fail before the port is ever bound, and is a parity fix in its own right.A failure that happens after
workflow_started(a provider error, a failing first agent) should still return the URL — the dashboard genuinely renders that failure and is useful. The bug is specifically about failures before the workflow ever starts.Notes for implementation
BackgroundLaunchwould need a field (e.g.workflow_started: bool) socli/app.pycan print the "still initializing" note in the timeout case.tests/test_cli/test_bg_runner.py,test_web_flags.py,test_resume_command.py, andtests/test_config/test_instructions.pypatchconductor.cli.bg_runner._wait_for_serverdirectly; they will need the new probe patched alongside it, otherwise they will run the real probe against a non-listening port and wait out the timeout.launch_backgroundandlaunch_background_resumeshare_spawn_bg_child→_finalize_background_launch, so the fix lands in one place and applies torunandresumealike.