Skip to content

Commit 16b8e60

Browse files
authored
fix: set codersdk.Task current_state during task initialization (#20692)
## Problem With the new tasks data model, a task starts with an `initializing` status. However, the API returns `current_state: null` to represent the agent state, causing the frontend to display "No message available". This PR updates `codersdk.Task` to return a `current_state` when the task is initializing with meaningful messages about what's happening during task initialization. **Previous message** <img width="2764" height="288" alt="Screenshot 2025-11-07 at 09 06 13" src="/api/flow.js?q=https%3A%2F%2Fgithub.com%2Fcoder%2Fcoder%2Fcommit%2F%253Ca%2520href%3D"https://github.com/user-attachments/assets/feec9f15-91ca-4378-8565-5f9de062d11a">https://github.com/user-attachments/assets/feec9f15-91ca-4378-8565-5f9de062d11a" /> **New message** <img width="2726" height="226" alt="Screenshot 2025-11-12 at 11 00 15" src="/api/flow.js?q=https%3A%2F%2Fgithub.com%2Fcoder%2Fcoder%2Fcommit%2F%253Ca%2520href%3D"https://github.com/user-attachments/assets/2f9bee3e-7ac4-4382-b1c3-1d06bbc2906e">https://github.com/user-attachments/assets/2f9bee3e-7ac4-4382-b1c3-1d06bbc2906e" /> ## Changes - Populate `current_state` with descriptive initialization messages when task status is `initializing` and no valid app status exists for the current build - **dbfake**: Fix `WorkspaceBuild` builder to properly handle pending/running jobs by linking tasks without requiring agent/app resources **Note:** UI Storybook changes to reflect these new messages will be addressed in a follow-up PR. Closes: coder/internal#1063
1 parent 3551500 commit 16b8e60

File tree

4 files changed

+319
-23
lines changed

4 files changed

+319
-23
lines changed

coderd/aitasks.go

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"golang.org/x/xerrors"
1414

1515
"cdr.dev/slog"
16+
1617
"github.com/coder/coder/v2/coderd/audit"
1718
"github.com/coder/coder/v2/coderd/database"
1819
"github.com/coder/coder/v2/coderd/database/dbtime"
@@ -23,6 +24,7 @@ import (
2324
"github.com/coder/coder/v2/coderd/rbac/policy"
2425
"github.com/coder/coder/v2/coderd/searchquery"
2526
"github.com/coder/coder/v2/coderd/taskname"
27+
"github.com/coder/coder/v2/coderd/util/ptr"
2628
"github.com/coder/coder/v2/coderd/util/slice"
2729
"github.com/coder/coder/v2/codersdk"
2830

@@ -270,37 +272,29 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
270272
func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) codersdk.Task {
271273
var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle
272274
var taskAgentHealth *codersdk.WorkspaceAgentHealth
275+
var taskAppHealth *codersdk.WorkspaceAppHealth
276+
277+
if dbTask.WorkspaceAgentLifecycleState.Valid {
278+
taskAgentLifecycle = ptr.Ref(codersdk.WorkspaceAgentLifecycle(dbTask.WorkspaceAgentLifecycleState.WorkspaceAgentLifecycleState))
279+
}
280+
if dbTask.WorkspaceAppHealth.Valid {
281+
taskAppHealth = ptr.Ref(codersdk.WorkspaceAppHealth(dbTask.WorkspaceAppHealth.WorkspaceAppHealth))
282+
}
273283

274-
// If we have an agent ID from the task, find the agent details in the
275-
// workspace.
284+
// If we have an agent ID from the task, find the agent health info
276285
if dbTask.WorkspaceAgentID.Valid {
277286
findTaskAgentLoop:
278287
for _, resource := range ws.LatestBuild.Resources {
279288
for _, agent := range resource.Agents {
280289
if agent.ID == dbTask.WorkspaceAgentID.UUID {
281-
taskAgentLifecycle = &agent.LifecycleState
282290
taskAgentHealth = &agent.Health
283291
break findTaskAgentLoop
284292
}
285293
}
286294
}
287295
}
288296

289-
// Ignore 'latest app status' if it is older than the latest build and the
290-
// latest build is a 'start' transition. This ensures that you don't show a
291-
// stale app status from a previous build. For stop transitions, there is
292-
// still value in showing the latest app status.
293-
var currentState *codersdk.TaskStateEntry
294-
if ws.LatestAppStatus != nil {
295-
if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) {
296-
currentState = &codersdk.TaskStateEntry{
297-
Timestamp: ws.LatestAppStatus.CreatedAt,
298-
State: codersdk.TaskState(ws.LatestAppStatus.State),
299-
Message: ws.LatestAppStatus.Message,
300-
URI: ws.LatestAppStatus.URI,
301-
}
302-
}
303-
}
297+
currentState := deriveTaskCurrentState(dbTask, ws, taskAgentLifecycle, taskAppHealth)
304298

305299
return codersdk.Task{
306300
ID: dbTask.ID,
@@ -330,6 +324,73 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod
330324
}
331325
}
332326

327+
// deriveTaskCurrentState determines the current state of a task based on the
328+
// workspace's latest app status and initialization phase.
329+
// Returns nil if no valid state can be determined.
330+
func deriveTaskCurrentState(
331+
dbTask database.Task,
332+
ws codersdk.Workspace,
333+
taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle,
334+
taskAppHealth *codersdk.WorkspaceAppHealth,
335+
) *codersdk.TaskStateEntry {
336+
var currentState *codersdk.TaskStateEntry
337+
338+
// Ignore 'latest app status' if it is older than the latest build and the
339+
// latest build is a 'start' transition. This ensures that you don't show a
340+
// stale app status from a previous build. For stop transitions, there is
341+
// still value in showing the latest app status.
342+
if ws.LatestAppStatus != nil {
343+
if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) {
344+
currentState = &codersdk.TaskStateEntry{
345+
Timestamp: ws.LatestAppStatus.CreatedAt,
346+
State: codersdk.TaskState(ws.LatestAppStatus.State),
347+
Message: ws.LatestAppStatus.Message,
348+
URI: ws.LatestAppStatus.URI,
349+
}
350+
}
351+
}
352+
353+
// If no valid agent state was found for the current build and the task is initializing,
354+
// provide a descriptive initialization message.
355+
if currentState == nil && dbTask.Status == database.TaskStatusInitializing {
356+
message := "Initializing workspace"
357+
358+
switch {
359+
case ws.LatestBuild.Status == codersdk.WorkspaceStatusPending ||
360+
ws.LatestBuild.Status == codersdk.WorkspaceStatusStarting:
361+
message = fmt.Sprintf("Workspace is %s", ws.LatestBuild.Status)
362+
case taskAgentLifecycle != nil:
363+
switch {
364+
case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleCreated:
365+
message = "Agent is connecting"
366+
case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleStarting:
367+
message = "Agent is starting"
368+
case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleReady:
369+
if taskAppHealth != nil && *taskAppHealth == codersdk.WorkspaceAppHealthInitializing {
370+
message = "App is initializing"
371+
} else {
372+
// In case the workspace app is not initializing,
373+
// the overall task status should be updated accordingly
374+
message = "Initializing workspace applications"
375+
}
376+
default:
377+
// In case the workspace agent is not initializing,
378+
// the overall task status should be updated accordingly
379+
message = "Initializing workspace agent"
380+
}
381+
}
382+
383+
currentState = &codersdk.TaskStateEntry{
384+
Timestamp: ws.LatestBuild.CreatedAt,
385+
State: codersdk.TaskStateWorking,
386+
Message: message,
387+
URI: "",
388+
}
389+
}
390+
391+
return currentState
392+
}
393+
333394
// @Summary List AI tasks
334395
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
335396
// @ID list-tasks

coderd/aitasks_internal_test.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package coderd
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/google/uuid"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/coder/coder/v2/coderd/database"
12+
"github.com/coder/coder/v2/coderd/util/ptr"
13+
"github.com/coder/coder/v2/codersdk"
14+
)
15+
16+
func TestDeriveTaskCurrentState_Unit(t *testing.T) {
17+
t.Parallel()
18+
19+
now := time.Now()
20+
tests := []struct {
21+
name string
22+
task database.Task
23+
agentLifecycle *codersdk.WorkspaceAgentLifecycle
24+
appHealth *codersdk.WorkspaceAppHealth
25+
latestAppStatus *codersdk.WorkspaceAppStatus
26+
latestBuild codersdk.WorkspaceBuild
27+
expectCurrentState bool
28+
expectedTimestamp time.Time
29+
expectedState codersdk.TaskState
30+
expectedMessage string
31+
}{
32+
{
33+
name: "NoAppStatus",
34+
task: database.Task{
35+
ID: uuid.New(),
36+
Status: database.TaskStatusActive,
37+
},
38+
agentLifecycle: nil,
39+
appHealth: nil,
40+
latestAppStatus: nil,
41+
latestBuild: codersdk.WorkspaceBuild{
42+
Transition: codersdk.WorkspaceTransitionStart,
43+
CreatedAt: now,
44+
},
45+
expectCurrentState: false,
46+
},
47+
{
48+
name: "BuildStartTransition_AppStatus_NewerThanBuild",
49+
task: database.Task{
50+
ID: uuid.New(),
51+
Status: database.TaskStatusActive,
52+
},
53+
agentLifecycle: nil,
54+
appHealth: nil,
55+
latestAppStatus: &codersdk.WorkspaceAppStatus{
56+
State: codersdk.WorkspaceAppStatusStateWorking,
57+
Message: "Task is working",
58+
CreatedAt: now.Add(1 * time.Minute),
59+
},
60+
latestBuild: codersdk.WorkspaceBuild{
61+
Transition: codersdk.WorkspaceTransitionStart,
62+
CreatedAt: now,
63+
},
64+
expectCurrentState: true,
65+
expectedTimestamp: now.Add(1 * time.Minute),
66+
expectedState: codersdk.TaskState(codersdk.WorkspaceAppStatusStateWorking),
67+
expectedMessage: "Task is working",
68+
},
69+
{
70+
name: "BuildStartTransition_StaleAppStatus_OlderThanBuild",
71+
task: database.Task{
72+
ID: uuid.New(),
73+
Status: database.TaskStatusActive,
74+
},
75+
agentLifecycle: nil,
76+
appHealth: nil,
77+
latestAppStatus: &codersdk.WorkspaceAppStatus{
78+
State: codersdk.WorkspaceAppStatusStateComplete,
79+
Message: "Previous task completed",
80+
CreatedAt: now.Add(-1 * time.Minute),
81+
},
82+
latestBuild: codersdk.WorkspaceBuild{
83+
Transition: codersdk.WorkspaceTransitionStart,
84+
CreatedAt: now,
85+
},
86+
expectCurrentState: false,
87+
},
88+
{
89+
name: "BuildStopTransition",
90+
task: database.Task{
91+
ID: uuid.New(),
92+
Status: database.TaskStatusActive,
93+
},
94+
agentLifecycle: nil,
95+
appHealth: nil,
96+
latestAppStatus: &codersdk.WorkspaceAppStatus{
97+
State: codersdk.WorkspaceAppStatusStateComplete,
98+
Message: "Task completed before stop",
99+
CreatedAt: now.Add(-1 * time.Minute),
100+
},
101+
latestBuild: codersdk.WorkspaceBuild{
102+
Transition: codersdk.WorkspaceTransitionStop,
103+
CreatedAt: now,
104+
},
105+
expectCurrentState: true,
106+
expectedTimestamp: now.Add(-1 * time.Minute),
107+
expectedState: codersdk.TaskState(codersdk.WorkspaceAppStatusStateComplete),
108+
expectedMessage: "Task completed before stop",
109+
},
110+
{
111+
name: "TaskInitializing_WorkspacePending",
112+
task: database.Task{
113+
ID: uuid.New(),
114+
Status: database.TaskStatusInitializing,
115+
},
116+
agentLifecycle: nil,
117+
appHealth: nil,
118+
latestAppStatus: nil,
119+
latestBuild: codersdk.WorkspaceBuild{
120+
Status: codersdk.WorkspaceStatusPending,
121+
CreatedAt: now,
122+
},
123+
expectCurrentState: true,
124+
expectedTimestamp: now,
125+
expectedState: codersdk.TaskStateWorking,
126+
expectedMessage: "Workspace is pending",
127+
},
128+
{
129+
name: "TaskInitializing_WorkspaceStarting",
130+
task: database.Task{
131+
ID: uuid.New(),
132+
Status: database.TaskStatusInitializing,
133+
},
134+
agentLifecycle: nil,
135+
appHealth: nil,
136+
latestAppStatus: nil,
137+
latestBuild: codersdk.WorkspaceBuild{
138+
Status: codersdk.WorkspaceStatusStarting,
139+
CreatedAt: now,
140+
},
141+
expectCurrentState: true,
142+
expectedTimestamp: now,
143+
expectedState: codersdk.TaskStateWorking,
144+
expectedMessage: "Workspace is starting",
145+
},
146+
{
147+
name: "TaskInitializing_AgentConnecting",
148+
task: database.Task{
149+
ID: uuid.New(),
150+
Status: database.TaskStatusInitializing,
151+
},
152+
agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleCreated),
153+
appHealth: nil,
154+
latestAppStatus: nil,
155+
latestBuild: codersdk.WorkspaceBuild{
156+
Status: codersdk.WorkspaceStatusRunning,
157+
CreatedAt: now,
158+
},
159+
expectCurrentState: true,
160+
expectedTimestamp: now,
161+
expectedState: codersdk.TaskStateWorking,
162+
expectedMessage: "Agent is connecting",
163+
},
164+
{
165+
name: "TaskInitializing_AgentStarting",
166+
task: database.Task{
167+
ID: uuid.New(),
168+
Status: database.TaskStatusInitializing,
169+
},
170+
agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleStarting),
171+
appHealth: nil,
172+
latestAppStatus: nil,
173+
latestBuild: codersdk.WorkspaceBuild{
174+
Status: codersdk.WorkspaceStatusRunning,
175+
CreatedAt: now,
176+
},
177+
expectCurrentState: true,
178+
expectedTimestamp: now,
179+
expectedState: codersdk.TaskStateWorking,
180+
expectedMessage: "Agent is starting",
181+
},
182+
{
183+
name: "TaskInitializing_AppInitializing",
184+
task: database.Task{
185+
ID: uuid.New(),
186+
Status: database.TaskStatusInitializing,
187+
},
188+
agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
189+
appHealth: ptr.Ref(codersdk.WorkspaceAppHealthInitializing),
190+
latestAppStatus: nil,
191+
latestBuild: codersdk.WorkspaceBuild{
192+
Status: codersdk.WorkspaceStatusRunning,
193+
CreatedAt: now,
194+
},
195+
expectCurrentState: true,
196+
expectedTimestamp: now,
197+
expectedState: codersdk.TaskStateWorking,
198+
expectedMessage: "App is initializing",
199+
},
200+
}
201+
202+
for _, tt := range tests {
203+
t.Run(tt.name, func(t *testing.T) {
204+
t.Parallel()
205+
206+
ws := codersdk.Workspace{
207+
LatestBuild: tt.latestBuild,
208+
LatestAppStatus: tt.latestAppStatus,
209+
}
210+
211+
currentState := deriveTaskCurrentState(tt.task, ws, tt.agentLifecycle, tt.appHealth)
212+
213+
if tt.expectCurrentState {
214+
require.NotNil(t, currentState)
215+
assert.Equal(t, tt.expectedTimestamp.UTC(), currentState.Timestamp.UTC())
216+
assert.Equal(t, tt.expectedState, currentState.State)
217+
assert.Equal(t, tt.expectedMessage, currentState.Message)
218+
} else {
219+
assert.Nil(t, currentState)
220+
}
221+
})
222+
}
223+
}

coderd/aitasks_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,14 +240,18 @@ func TestTasks(t *testing.T) {
240240
assert.NotNil(t, updated.CurrentState, "current state should not be nil")
241241
assert.Equal(t, "all done", updated.CurrentState.Message)
242242
assert.Equal(t, codersdk.TaskStateComplete, updated.CurrentState.State)
243+
previousCurrentState := updated.CurrentState
243244

244245
// Start the workspace again
245246
coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart)
246247

247-
// Verify that the status from the previous build is no longer present
248+
// Verify that the status from the previous build has been cleared
249+
// and replaced by the agent initialization status.
248250
updated, err = exp.TaskByID(ctx, task.ID)
249251
require.NoError(t, err)
250-
assert.Nil(t, updated.CurrentState, "current state should be nil")
252+
assert.NotEqual(t, previousCurrentState, updated.CurrentState)
253+
assert.Equal(t, codersdk.TaskStateWorking, updated.CurrentState.State)
254+
assert.NotEqual(t, "all done", updated.CurrentState.Message)
251255
})
252256

253257
t.Run("Delete", func(t *testing.T) {

0 commit comments

Comments
 (0)