From 96695edfed05ad27624ce5d72e46b4196888669e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 5 Feb 2026 14:08:03 +0200 Subject: [PATCH] fix(coderd/database): correct task pending status logic (#21886) Previously, tasks with pending provisioner jobs (not yet picked up) were incorrectly reported as "initializing". Refs #21887 --- .../SnapshotWithSingleMessage.golden | 2 +- coderd/aitasks_test.go | 6 +- coderd/database/dump.sql | 2 +- .../000415_fix_task_pending_status.down.sql | 142 +++++++++++++++++ .../000415_fix_task_pending_status.up.sql | 145 ++++++++++++++++++ coderd/database/querier_test.go | 4 +- 6 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 coderd/database/migrations/000415_fix_task_pending_status.down.sql create mode 100644 coderd/database/migrations/000415_fix_task_pending_status.up.sql diff --git a/cli/testdata/Test_TaskLogs_Golden/SnapshotWithSingleMessage.golden b/cli/testdata/Test_TaskLogs_Golden/SnapshotWithSingleMessage.golden index 3f1013c673..db1fdcd473 100644 --- a/cli/testdata/Test_TaskLogs_Golden/SnapshotWithSingleMessage.golden +++ b/cli/testdata/Test_TaskLogs_Golden/SnapshotWithSingleMessage.golden @@ -1,4 +1,4 @@ -err: WARN: Task is initializing. Showing last 1 message from snapshot. +err: WARN: Task is pending. Showing last 1 message from snapshot. err: out: TYPE CONTENT out: input Single message diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 89d52d0c83..7037d92b2b 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -997,12 +997,12 @@ func TestTasks(t *testing.T) { wantErrStatusCode int }{ { - name: "TaskStatusInitializing", + name: "TaskStatusPending", // We want to disable the provisioner so that the task - // never gets provisioned (ensuring it stays in Initializing). + // never gets picked up (ensuring it stays in Pending). disableProvisioner: true, taskInput: "Valid prompt", - wantStatus: codersdk.TaskStatusInitializing, + wantStatus: codersdk.TaskStatusPending, wantErr: "Unable to update", wantErrStatusCode: http.StatusConflict, }, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7bca015b7f..9e5fff626c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2004,7 +2004,7 @@ CREATE VIEW tasks_with_status AS WHEN (latest_build_raw.job_status IS NULL) THEN 'pending'::task_status WHEN (latest_build_raw.job_status = ANY (ARRAY['failed'::provisioner_job_status, 'canceling'::provisioner_job_status, 'canceled'::provisioner_job_status])) THEN 'error'::task_status WHEN ((latest_build_raw.transition = ANY (ARRAY['stop'::workspace_transition, 'delete'::workspace_transition])) AND (latest_build_raw.job_status = 'succeeded'::provisioner_job_status)) THEN 'paused'::task_status - WHEN ((latest_build_raw.transition = 'start'::workspace_transition) AND (latest_build_raw.job_status = 'pending'::provisioner_job_status)) THEN 'initializing'::task_status + WHEN ((latest_build_raw.transition = 'start'::workspace_transition) AND (latest_build_raw.job_status = 'pending'::provisioner_job_status)) THEN 'pending'::task_status WHEN ((latest_build_raw.transition = 'start'::workspace_transition) AND (latest_build_raw.job_status = ANY (ARRAY['running'::provisioner_job_status, 'succeeded'::provisioner_job_status]))) THEN 'active'::task_status ELSE 'unknown'::task_status END AS status) build_status) diff --git a/coderd/database/migrations/000415_fix_task_pending_status.down.sql b/coderd/database/migrations/000415_fix_task_pending_status.down.sql new file mode 100644 index 0000000000..f05df3c5b8 --- /dev/null +++ b/coderd/database/migrations/000415_fix_task_pending_status.down.sql @@ -0,0 +1,142 @@ +-- Update task status in view. +DROP VIEW IF EXISTS tasks_with_status; + +CREATE VIEW + tasks_with_status +AS + SELECT + tasks.*, + -- Combine component statuses with precedence: build -> agent -> app. + CASE + WHEN tasks.workspace_id IS NULL THEN 'pending'::task_status + WHEN build_status.status != 'active' THEN build_status.status::task_status + WHEN agent_status.status != 'active' THEN agent_status.status::task_status + ELSE app_status.status::task_status + END AS status, + -- Attach debug information for troubleshooting status. + jsonb_build_object( + 'build', jsonb_build_object( + 'transition', latest_build_raw.transition, + 'job_status', latest_build_raw.job_status, + 'computed', build_status.status + ), + 'agent', jsonb_build_object( + 'lifecycle_state', agent_raw.lifecycle_state, + 'computed', agent_status.status + ), + 'app', jsonb_build_object( + 'health', app_raw.health, + 'computed', app_status.status + ) + ) AS status_debug, + task_app.*, + agent_raw.lifecycle_state AS workspace_agent_lifecycle_state, + app_raw.health AS workspace_app_health, + task_owner.* + FROM + tasks + CROSS JOIN LATERAL ( + SELECT + vu.username AS owner_username, + vu.name AS owner_name, + vu.avatar_url AS owner_avatar_url + FROM + visible_users vu + WHERE + vu.id = tasks.owner_id + ) task_owner + LEFT JOIN LATERAL ( + SELECT + task_app.workspace_build_number, + task_app.workspace_agent_id, + task_app.workspace_app_id + FROM + task_workspace_apps task_app + WHERE + task_id = tasks.id + ORDER BY + task_app.workspace_build_number DESC + LIMIT 1 + ) task_app ON TRUE + + -- Join the raw data for computing task status. + LEFT JOIN LATERAL ( + SELECT + workspace_build.transition, + provisioner_job.job_status, + workspace_build.job_id + FROM + workspace_builds workspace_build + JOIN + provisioner_jobs provisioner_job + ON provisioner_job.id = workspace_build.job_id + WHERE + workspace_build.workspace_id = tasks.workspace_id + AND workspace_build.build_number = task_app.workspace_build_number + ) latest_build_raw ON TRUE + LEFT JOIN LATERAL ( + SELECT + workspace_agent.lifecycle_state + FROM + workspace_agents workspace_agent + WHERE + workspace_agent.id = task_app.workspace_agent_id + ) agent_raw ON TRUE + LEFT JOIN LATERAL ( + SELECT + workspace_app.health + FROM + workspace_apps workspace_app + WHERE + workspace_app.id = task_app.workspace_app_id + ) app_raw ON TRUE + + -- Compute the status for each component. + CROSS JOIN LATERAL ( + SELECT + CASE + WHEN latest_build_raw.job_status IS NULL THEN 'pending'::task_status + WHEN latest_build_raw.job_status IN ('failed', 'canceling', 'canceled') THEN 'error'::task_status + WHEN + latest_build_raw.transition IN ('stop', 'delete') + AND latest_build_raw.job_status = 'succeeded' THEN 'paused'::task_status + WHEN + latest_build_raw.transition = 'start' + AND latest_build_raw.job_status = 'pending' THEN 'initializing'::task_status + -- Build is running or done, defer to agent/app status. + WHEN + latest_build_raw.transition = 'start' + AND latest_build_raw.job_status IN ('running', 'succeeded') THEN 'active'::task_status + ELSE 'unknown'::task_status + END AS status + ) build_status + CROSS JOIN LATERAL ( + SELECT + CASE + -- No agent or connecting. + WHEN + agent_raw.lifecycle_state IS NULL + OR agent_raw.lifecycle_state IN ('created', 'starting') THEN 'initializing'::task_status + -- Agent is running, defer to app status. + -- NOTE(mafredri): The start_error/start_timeout states means connected, but some startup script failed. + -- This may or may not affect the task status but this has to be caught by app health check. + WHEN agent_raw.lifecycle_state IN ('ready', 'start_timeout', 'start_error') THEN 'active'::task_status + -- If the agent is shutting down or turned off, this is an unknown state because we would expect a stop + -- build to be running. + -- This is essentially equal to: `IN ('shutting_down', 'shutdown_timeout', 'shutdown_error', 'off')`, + -- but we cannot use them because the values were added in a migration. + WHEN agent_raw.lifecycle_state NOT IN ('created', 'starting', 'ready', 'start_timeout', 'start_error') THEN 'unknown'::task_status + ELSE 'unknown'::task_status + END AS status + ) agent_status + CROSS JOIN LATERAL ( + SELECT + CASE + WHEN app_raw.health = 'initializing' THEN 'initializing'::task_status + WHEN app_raw.health = 'unhealthy' THEN 'error'::task_status + WHEN app_raw.health IN ('healthy', 'disabled') THEN 'active'::task_status + ELSE 'unknown'::task_status + END AS status + ) app_status + WHERE + tasks.deleted_at IS NULL; diff --git a/coderd/database/migrations/000415_fix_task_pending_status.up.sql b/coderd/database/migrations/000415_fix_task_pending_status.up.sql new file mode 100644 index 0000000000..9e0fe06ab0 --- /dev/null +++ b/coderd/database/migrations/000415_fix_task_pending_status.up.sql @@ -0,0 +1,145 @@ +-- Fix task status logic: pending provisioner job should give pending task status, not initializing. +-- A task is pending when the provisioner hasn't picked up the job yet. +-- A task is initializing when the provisioner is actively running the job. +DROP VIEW IF EXISTS tasks_with_status; + +CREATE VIEW + tasks_with_status +AS + SELECT + tasks.*, + -- Combine component statuses with precedence: build -> agent -> app. + CASE + WHEN tasks.workspace_id IS NULL THEN 'pending'::task_status + WHEN build_status.status != 'active' THEN build_status.status::task_status + WHEN agent_status.status != 'active' THEN agent_status.status::task_status + ELSE app_status.status::task_status + END AS status, + -- Attach debug information for troubleshooting status. + jsonb_build_object( + 'build', jsonb_build_object( + 'transition', latest_build_raw.transition, + 'job_status', latest_build_raw.job_status, + 'computed', build_status.status + ), + 'agent', jsonb_build_object( + 'lifecycle_state', agent_raw.lifecycle_state, + 'computed', agent_status.status + ), + 'app', jsonb_build_object( + 'health', app_raw.health, + 'computed', app_status.status + ) + ) AS status_debug, + task_app.*, + agent_raw.lifecycle_state AS workspace_agent_lifecycle_state, + app_raw.health AS workspace_app_health, + task_owner.* + FROM + tasks + CROSS JOIN LATERAL ( + SELECT + vu.username AS owner_username, + vu.name AS owner_name, + vu.avatar_url AS owner_avatar_url + FROM + visible_users vu + WHERE + vu.id = tasks.owner_id + ) task_owner + LEFT JOIN LATERAL ( + SELECT + task_app.workspace_build_number, + task_app.workspace_agent_id, + task_app.workspace_app_id + FROM + task_workspace_apps task_app + WHERE + task_id = tasks.id + ORDER BY + task_app.workspace_build_number DESC + LIMIT 1 + ) task_app ON TRUE + + -- Join the raw data for computing task status. + LEFT JOIN LATERAL ( + SELECT + workspace_build.transition, + provisioner_job.job_status, + workspace_build.job_id + FROM + workspace_builds workspace_build + JOIN + provisioner_jobs provisioner_job + ON provisioner_job.id = workspace_build.job_id + WHERE + workspace_build.workspace_id = tasks.workspace_id + AND workspace_build.build_number = task_app.workspace_build_number + ) latest_build_raw ON TRUE + LEFT JOIN LATERAL ( + SELECT + workspace_agent.lifecycle_state + FROM + workspace_agents workspace_agent + WHERE + workspace_agent.id = task_app.workspace_agent_id + ) agent_raw ON TRUE + LEFT JOIN LATERAL ( + SELECT + workspace_app.health + FROM + workspace_apps workspace_app + WHERE + workspace_app.id = task_app.workspace_app_id + ) app_raw ON TRUE + + -- Compute the status for each component. + CROSS JOIN LATERAL ( + SELECT + CASE + WHEN latest_build_raw.job_status IS NULL THEN 'pending'::task_status + WHEN latest_build_raw.job_status IN ('failed', 'canceling', 'canceled') THEN 'error'::task_status + WHEN + latest_build_raw.transition IN ('stop', 'delete') + AND latest_build_raw.job_status = 'succeeded' THEN 'paused'::task_status + -- Job is pending (not picked up by provisioner yet). + WHEN + latest_build_raw.transition = 'start' + AND latest_build_raw.job_status = 'pending' THEN 'pending'::task_status + -- Job is running or done, defer to agent/app status. + WHEN + latest_build_raw.transition = 'start' + AND latest_build_raw.job_status IN ('running', 'succeeded') THEN 'active'::task_status + ELSE 'unknown'::task_status + END AS status + ) build_status + CROSS JOIN LATERAL ( + SELECT + CASE + -- No agent or connecting. + WHEN + agent_raw.lifecycle_state IS NULL + OR agent_raw.lifecycle_state IN ('created', 'starting') THEN 'initializing'::task_status + -- Agent is running, defer to app status. + -- NOTE(mafredri): The start_error/start_timeout states means connected, but some startup script failed. + -- This may or may not affect the task status but this has to be caught by app health check. + WHEN agent_raw.lifecycle_state IN ('ready', 'start_timeout', 'start_error') THEN 'active'::task_status + -- If the agent is shutting down or turned off, this is an unknown state because we would expect a stop + -- build to be running. + -- This is essentially equal to: `IN ('shutting_down', 'shutdown_timeout', 'shutdown_error', 'off')`, + -- but we cannot use them because the values were added in a migration. + WHEN agent_raw.lifecycle_state NOT IN ('created', 'starting', 'ready', 'start_timeout', 'start_error') THEN 'unknown'::task_status + ELSE 'unknown'::task_status + END AS status + ) agent_status + CROSS JOIN LATERAL ( + SELECT + CASE + WHEN app_raw.health = 'initializing' THEN 'initializing'::task_status + WHEN app_raw.health = 'unhealthy' THEN 'error'::task_status + WHEN app_raw.health IN ('healthy', 'disabled') THEN 'active'::task_status + ELSE 'unknown'::task_status + END AS status + ) app_status + WHERE + tasks.deleted_at IS NULL; diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index e9ec935f84..967907b507 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -7093,8 +7093,8 @@ func TestTasksWithStatusView(t *testing.T) { name: "PendingStart", buildStatus: database.ProvisionerJobStatusPending, buildTransition: database.WorkspaceTransitionStart, - expectedStatus: database.TaskStatusInitializing, - description: "Workspace build is starting (pending)", + expectedStatus: database.TaskStatusPending, + description: "Workspace build pending (not yet picked up by provisioner)", expectBuildNumberValid: true, expectBuildNumber: 1, expectWorkspaceAgentValid: false,