Summary
The agent collapses the outcome of every run_on_start script into a single agent-wide lifecycle state, without considering each script's start_blocks_login. A single non-essential script that exceeds its own timeout therefore puts the whole agent into start_timeout, even when that script explicitly declares start_blocks_login = false.
Because start_timeout is terminal for that build, the agent never reaches ready, and clients that gate on the lifecycle state leave the workspace permanently presented as still starting — even though the build is running, the agent is connected, and SSH works fine.
Filing this as a design question rather than asserting a particular fix, since changing the rollup has compatibility implications.
Current behaviour
Runner.Execute in agent/agentscripts/agentscripts.go selects start scripts purely on RunOnStart:
runScript := (option == ExecuteStartScripts && script.RunOnStart) ||
(option == ExecuteStopScripts && script.RunOnStop) ||
(option == ExecuteCronScripts && script.Cron != "") ||
option == ExecuteAllScripts
It then runs them in an errgroup and returns eg.Wait(), i.e. the first non-nil error from any script. StartBlocksLogin is not consulted. The ordered path via lifecycleExecutor.execute likewise returns a single aggregate error.
agent/agent.go maps that one error onto the agent's lifecycle:
if err != nil {
a.logger.Warn(ctx, "startup script(s) failed", slog.Error(err))
if errors.Is(err, agentscripts.ErrTimeout) {
a.setLifecycle(codersdk.WorkspaceAgentLifecycleStartTimeout)
} else {
a.setLifecycle(codersdk.WorkspaceAgentLifecycleStartError)
}
} else {
a.setLifecycle(codersdk.WorkspaceAgentLifecycleReady)
}
So start_blocks_login only ends up affecting:
It has no bearing on the lifecycle state itself.
Why this matters
The lifecycle state is the readiness signal clients actually consume. A non-blocking script is, by definition, one the template author has said should not hold up login — but its failure still degrades the only readiness signal those clients have.
Observed in the field: a workspace built from a template with an inline blocking startup_script plus a separate module-provided coder_script with start_blocks_login = false and timeout = 120. The module script hung on a network call and timed out. Result:
{
"status": "running",
"agents": [
{
"status": "connected",
"lifecycle_state": "start_timeout"
}
]
}
The agent accepted SSH normally (coder ssh --wait=no worked), but the JetBrains Toolbox plugin presented the environment as Starting indefinitely and would not open a session. Tracked on the client side in coder/coder-jetbrains-toolbox#380; the plugin has its own bug there, but the ambiguous lifecycle state is the upstream cause and would mislead any client that trusts it.
The failure is also hard to attribute. The agent log reports the timeout against the script's fallback log path:
/tmp/coder-script-<log-source-id>.log script failed log_source_id=<uuid> log_path=/tmp/coder-script-<uuid>.log script_data_dir=/tmp/coder-script-data/<uuid> execution_time=1m59.998714s exit_code=255 error="script timed out"
There is nothing in the aggregate startup script(s) failed line, or in the lifecycle state, indicating whether the script that failed was one that blocks login.
Steps to reproduce
- Template with a
coder_agent whose inline startup_script completes normally
- Add a
coder_script with run_on_start = true, start_blocks_login = false, a short timeout, and a body that blocks past it
- Start the workspace
- Observe
latest_build.status == "running", agent status == "connected", and agent lifecycle_state == "start_timeout"
Expected: a script declared not to block login does not prevent the agent from reaching ready. Its failure surfaces per-script, which it already does — WorkspaceAgentScript carries status and exit_code, and the build timings endpoint reports timed_out for that script specifically.
Actual: the agent-wide lifecycle state becomes start_timeout and never recovers for that build.
Discussion points
- Should
setLifecycle consider only the outcome of scripts with start_blocks_login = true, so non-blocking failures are reported per-script without degrading agent readiness?
- If that changes the meaning of
start_timeout / start_error too much for existing consumers, is there room for a separate signal that distinguishes "a blocking script failed" from "some start script failed"? Per-script status and exit_code already exist on the agent's scripts, so clients have a richer source available — but the single lifecycle field is what they use today.
- Should
Execute return per-script results rather than a single eg.Wait() error, so the caller can weigh them?
- Independent of the above: a
start_timeout or start_error agent is still fully SSH-capable. Is the current client guidance that these states are connectable? If so, it may be worth documenting, since at least one first-party client treats them as not-ready.
Created on behalf of @MoeRT09