From 6d099cbd801f8136fd341721bf33939eb61a6b40 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Mon, 10 Nov 2025 12:02:32 +0000 Subject: [PATCH 01/10] fix: set codersdk.Task current_state during task initialization --- coderd/aitasks.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index d5cca9e4f0b3f..345c19bc39d5e 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -302,6 +302,35 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod } } + // When the task is initializing and there's no app status yet, provide a + // default current_state with an appropriate message. + if currentState == nil && codersdk.TaskStatus(dbTask.Status) == codersdk.TaskStatusInitializing { + message := "Initializing workspace" + + switch { + case ws.LatestBuild.Status == codersdk.WorkspaceStatusPending: + message = "Workspace build is pending" + case ws.LatestBuild.Status == codersdk.WorkspaceStatusStarting: + message = "Starting workspace" + case taskAgentLifecycle != nil: + switch *taskAgentLifecycle { + case codersdk.WorkspaceAgentLifecycleCreated: + message = "Agent is connecting" + case codersdk.WorkspaceAgentLifecycleStarting: + message = "Agent is starting" + default: + message = "Initializing workspace agent" + } + } + + currentState = &codersdk.TaskStateEntry{ + Timestamp: ws.LatestBuild.CreatedAt, + State: codersdk.TaskStateWorking, + Message: message, + URI: "", + } + } + return codersdk.Task{ ID: dbTask.ID, OrganizationID: dbTask.OrganizationID, From 7191e4b424b09661dc067322f985c10e3644ceb7 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Tue, 11 Nov 2025 14:30:00 +0000 Subject: [PATCH 02/10] fix: TestTasks/Get test --- coderd/aitasks.go | 2 +- coderd/aitasks_test.go | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 345c19bc39d5e..e9af1a82c76b1 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -303,7 +303,7 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod } // When the task is initializing and there's no app status yet, provide a - // default current_state with an appropriate message. + // CurrentState with an appropriate message. if currentState == nil && codersdk.TaskStatus(dbTask.Status) == codersdk.TaskStatusInitializing { message := "Initializing workspace" diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 0151d77c1961a..77a282bd1ff42 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -239,14 +239,18 @@ func TestTasks(t *testing.T) { assert.NotNil(t, updated.CurrentState, "current state should not be nil") assert.Equal(t, "all done", updated.CurrentState.Message) assert.Equal(t, codersdk.TaskStateComplete, updated.CurrentState.State) + previousCurrentState := updated.CurrentState // Start the workspace again coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart) - // Verify that the status from the previous build is no longer present + // Verify that the status from the previous build has been cleared + // and replaced by the agent initialization status. updated, err = exp.TaskByID(ctx, task.ID) require.NoError(t, err) - assert.Nil(t, updated.CurrentState, "current state should be nil") + assert.NotEqual(t, previousCurrentState, updated.CurrentState) + assert.Equal(t, codersdk.TaskStateWorking, updated.CurrentState.State) + assert.Equal(t, "Agent is connecting", updated.CurrentState.Message) }) t.Run("Delete", func(t *testing.T) { From d39162655307940a8def52607fbd1355d3cca31a Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Tue, 11 Nov 2025 18:26:10 +0000 Subject: [PATCH 03/10] test: add InitializingAgentState tests --- coderd/aitasks.go | 4 +- coderd/aitasks_test.go | 124 +++++++++++++++++++++++++++++++ coderd/database/dbfake/dbfake.go | 14 +++- 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index e9af1a82c76b1..8494456132d2d 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -302,8 +302,8 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod } } - // When the task is initializing and there's no app status yet, provide a - // CurrentState with an appropriate message. + // If no valid agent state was found for the current build and the task is initializing, + // provide a descriptive initialization message. if currentState == nil && codersdk.TaskStatus(dbTask.Status) == codersdk.TaskStatusInitializing { message := "Initializing workspace" diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index e9e59deb1d455..fdd5feec54e6e 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -254,6 +254,130 @@ func TestTasks(t *testing.T) { assert.Equal(t, "Agent is connecting", updated.CurrentState.Message) }) + t.Run("InitializingAgentState", func(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + setupBuild func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse) dbfake.WorkspaceResponse + expectedMessage string + }{ + { + name: "WorkspaceBuildPending", + setupBuild: func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse) dbfake.WorkspaceResponse { + return dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithTask(database.TaskTable{ + Prompt: "test workspace pending", + }, &proto.App{ + Id: uuid.NewString(), + Slug: "ccw", + }).Pending().Do() + }, + expectedMessage: "Workspace build is pending", + }, + { + name: "WorkspaceStarting", + setupBuild: func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse) dbfake.WorkspaceResponse { + return dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithTask(database.TaskTable{ + Prompt: "test workspace starting", + }, &proto.App{ + Id: uuid.NewString(), + Slug: "ccw", + }).Starting().Do() + }, + expectedMessage: "Starting workspace", + }, + { + name: "AgentConnecting", + setupBuild: func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse) dbfake.WorkspaceResponse { + wb := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithTask(database.TaskTable{ + Prompt: "test agent connecting", + }, &proto.App{ + Id: uuid.NewString(), + Slug: "ccw", + }).Do() + + require.True(t, wb.Task.WorkspaceAgentID.Valid) + require.NotEqual(t, uuid.Nil, wb.Task.WorkspaceAgentID.UUID) + + // nolint:gocritic // System restricted operation to update agent lifecycle to "created" + err := db.UpdateWorkspaceAgentLifecycleStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: wb.Task.WorkspaceAgentID.UUID, + LifecycleState: database.WorkspaceAgentLifecycleStateCreated, + }) + require.NoError(t, err) + + return wb + }, + expectedMessage: "Agent is connecting", + }, + { + name: "AgentStarting", + setupBuild: func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse) dbfake.WorkspaceResponse { + wb := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithTask(database.TaskTable{ + Prompt: "test agent starting", + }, &proto.App{ + Id: uuid.NewString(), + Slug: "ccw", + }).Do() + + require.True(t, wb.Task.WorkspaceAgentID.Valid) + require.NotEqual(t, uuid.Nil, wb.Task.WorkspaceAgentID.UUID) + + // nolint:gocritic // System restricted operation to update agent lifecycle to "created" + err := db.UpdateWorkspaceAgentLifecycleStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: wb.Task.WorkspaceAgentID.UUID, + LifecycleState: database.WorkspaceAgentLifecycleStateStarting, + }) + require.NoError(t, err) + + return wb + }, + expectedMessage: "Agent is starting", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + ctx = testutil.Context(t, testutil.WaitShort) + user = coderdtest.CreateFirstUser(t, client) + exp = codersdk.NewExperimentalClient(client) + ) + + workspaceBuild := tc.setupBuild(t, ctx, db, user) + + // Get the task + task, err := exp.TaskByID(ctx, workspaceBuild.Task.ID) + require.NoError(t, err) + + // Verify the task is initializing with appropriate current_state + assert.Equal(t, codersdk.TaskStatusInitializing, task.Status) + require.NotNil(t, task.CurrentState) + assert.Equal(t, codersdk.TaskStateWorking, task.CurrentState.State) + assert.Equal(t, tc.expectedMessage, task.CurrentState.Message) + assert.NotZero(t, task.CurrentState.Timestamp) + assert.Empty(t, task.CurrentState.URI) + }) + } + }) + t.Run("Delete", func(t *testing.T) { t.Parallel() diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 45a441bbe9486..f1a84d7eef9cb 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -361,12 +361,20 @@ func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse { require.Fail(b.t, "task app not configured but workspace is a task workspace") } - app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID) + workspaceAgentID := uuid.NullUUID{} + workspaceAppID := uuid.NullUUID{} + // Workspace agent and app are only properly set upon job completion + if b.jobStatus != database.ProvisionerJobStatusPending && b.jobStatus != database.ProvisionerJobStatusRunning { + app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID) + workspaceAgentID = uuid.NullUUID{UUID: app.AgentID, Valid: true} + workspaceAppID = uuid.NullUUID{UUID: app.ID, Valid: true} + } + _, err = b.db.UpsertTaskWorkspaceApp(ownerCtx, database.UpsertTaskWorkspaceAppParams{ TaskID: task.ID, WorkspaceBuildNumber: resp.Build.BuildNumber, - WorkspaceAgentID: uuid.NullUUID{UUID: app.AgentID, Valid: true}, - WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + WorkspaceAgentID: workspaceAgentID, + WorkspaceAppID: workspaceAppID, }) require.NoError(b.t, err, "upsert task workspace app") b.logger.Debug(context.Background(), "linked task to workspace build", From 306296a4025367c282dcebc1613ec4a90dfa9876 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Wed, 12 Nov 2025 10:52:10 +0000 Subject: [PATCH 04/10] fix: minor fix in TestTasks test --- coderd/aitasks_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index fdd5feec54e6e..ae8012fe83101 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -251,7 +251,7 @@ func TestTasks(t *testing.T) { require.NoError(t, err) assert.NotEqual(t, previousCurrentState, updated.CurrentState) assert.Equal(t, codersdk.TaskStateWorking, updated.CurrentState.State) - assert.Equal(t, "Agent is connecting", updated.CurrentState.Message) + assert.NotEqual(t, "all done", updated.CurrentState.Message) }) t.Run("InitializingAgentState", func(t *testing.T) { From f07e3d8a32bd1895fc91f2591dc8a349f48dddc0 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Wed, 12 Nov 2025 17:40:02 +0000 Subject: [PATCH 05/10] chore: address comments --- coderd/aitasks.go | 71 ++++++++++++++++++++++++------------------ coderd/aitasks_test.go | 6 ++-- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 8494456132d2d..7d067b1893471 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -286,11 +286,46 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod } } + currentState := deriveTaskCurrentState(dbTask, ws, taskAgentLifecycle) + + return codersdk.Task{ + ID: dbTask.ID, + OrganizationID: dbTask.OrganizationID, + OwnerID: dbTask.OwnerID, + OwnerName: dbTask.OwnerUsername, + OwnerAvatarURL: dbTask.OwnerAvatarUrl, + Name: dbTask.Name, + TemplateID: ws.TemplateID, + TemplateVersionID: dbTask.TemplateVersionID, + TemplateName: ws.TemplateName, + TemplateDisplayName: ws.TemplateDisplayName, + TemplateIcon: ws.TemplateIcon, + WorkspaceID: dbTask.WorkspaceID, + WorkspaceName: ws.Name, + WorkspaceBuildNumber: dbTask.WorkspaceBuildNumber.Int32, + WorkspaceStatus: ws.LatestBuild.Status, + WorkspaceAgentID: dbTask.WorkspaceAgentID, + WorkspaceAgentLifecycle: taskAgentLifecycle, + WorkspaceAgentHealth: taskAgentHealth, + WorkspaceAppID: dbTask.WorkspaceAppID, + InitialPrompt: dbTask.Prompt, + Status: codersdk.TaskStatus(dbTask.Status), + CurrentState: currentState, + CreatedAt: dbTask.CreatedAt, + UpdatedAt: ws.UpdatedAt, + } +} + +// deriveTaskCurrentState determines the current state of a task based on the +// workspace's latest app status and initialization phase. +// Returns nil if no valid state can be determined. +func deriveTaskCurrentState(dbTask database.Task, ws codersdk.Workspace, taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle) *codersdk.TaskStateEntry { + var currentState *codersdk.TaskStateEntry + // Ignore 'latest app status' if it is older than the latest build and the // latest build is a 'start' transition. This ensures that you don't show a // stale app status from a previous build. For stop transitions, there is // still value in showing the latest app status. - var currentState *codersdk.TaskStateEntry if ws.LatestAppStatus != nil { if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) { currentState = &codersdk.TaskStateEntry{ @@ -308,10 +343,9 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod message := "Initializing workspace" switch { - case ws.LatestBuild.Status == codersdk.WorkspaceStatusPending: - message = "Workspace build is pending" - case ws.LatestBuild.Status == codersdk.WorkspaceStatusStarting: - message = "Starting workspace" + case ws.LatestBuild.Status == codersdk.WorkspaceStatusPending || + ws.LatestBuild.Status == codersdk.WorkspaceStatusStarting: + message = fmt.Sprintf("Workspace is %s", ws.LatestBuild.Status) case taskAgentLifecycle != nil: switch *taskAgentLifecycle { case codersdk.WorkspaceAgentLifecycleCreated: @@ -331,32 +365,7 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod } } - return codersdk.Task{ - ID: dbTask.ID, - OrganizationID: dbTask.OrganizationID, - OwnerID: dbTask.OwnerID, - OwnerName: dbTask.OwnerUsername, - OwnerAvatarURL: dbTask.OwnerAvatarUrl, - Name: dbTask.Name, - TemplateID: ws.TemplateID, - TemplateVersionID: dbTask.TemplateVersionID, - TemplateName: ws.TemplateName, - TemplateDisplayName: ws.TemplateDisplayName, - TemplateIcon: ws.TemplateIcon, - WorkspaceID: dbTask.WorkspaceID, - WorkspaceName: ws.Name, - WorkspaceBuildNumber: dbTask.WorkspaceBuildNumber.Int32, - WorkspaceStatus: ws.LatestBuild.Status, - WorkspaceAgentID: dbTask.WorkspaceAgentID, - WorkspaceAgentLifecycle: taskAgentLifecycle, - WorkspaceAgentHealth: taskAgentHealth, - WorkspaceAppID: dbTask.WorkspaceAppID, - InitialPrompt: dbTask.Prompt, - Status: codersdk.TaskStatus(dbTask.Status), - CurrentState: currentState, - CreatedAt: dbTask.CreatedAt, - UpdatedAt: ws.UpdatedAt, - } + return currentState } // @Summary List AI tasks diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index ae8012fe83101..c2f6703741607 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -275,7 +275,7 @@ func TestTasks(t *testing.T) { Slug: "ccw", }).Pending().Do() }, - expectedMessage: "Workspace build is pending", + expectedMessage: "Workspace is pending", }, { name: "WorkspaceStarting", @@ -290,7 +290,7 @@ func TestTasks(t *testing.T) { Slug: "ccw", }).Starting().Do() }, - expectedMessage: "Starting workspace", + expectedMessage: "Workspace is starting", }, { name: "AgentConnecting", @@ -308,7 +308,6 @@ func TestTasks(t *testing.T) { require.True(t, wb.Task.WorkspaceAgentID.Valid) require.NotEqual(t, uuid.Nil, wb.Task.WorkspaceAgentID.UUID) - // nolint:gocritic // System restricted operation to update agent lifecycle to "created" err := db.UpdateWorkspaceAgentLifecycleStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ ID: wb.Task.WorkspaceAgentID.UUID, LifecycleState: database.WorkspaceAgentLifecycleStateCreated, @@ -335,7 +334,6 @@ func TestTasks(t *testing.T) { require.True(t, wb.Task.WorkspaceAgentID.Valid) require.NotEqual(t, uuid.Nil, wb.Task.WorkspaceAgentID.UUID) - // nolint:gocritic // System restricted operation to update agent lifecycle to "created" err := db.UpdateWorkspaceAgentLifecycleStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ ID: wb.Task.WorkspaceAgentID.UUID, LifecycleState: database.WorkspaceAgentLifecycleStateStarting, From b1cb7fee56d395c12b508f43858cbcd3ec403e3b Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Fri, 14 Nov 2025 17:26:15 +0000 Subject: [PATCH 06/10] test: create internal test file for unit test --- coderd/aitasks.go | 40 +++++- coderd/aitasks_internal_test.go | 224 ++++++++++++++++++++++++++++++++ coderd/aitasks_test.go | 122 ----------------- 3 files changed, 257 insertions(+), 129 deletions(-) create mode 100644 coderd/aitasks_internal_test.go diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 7d067b1893471..3a771697556e2 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -270,9 +270,11 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) codersdk.Task { var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle var taskAgentHealth *codersdk.WorkspaceAgentHealth + var taskAppHealth *codersdk.WorkspaceAppHealth - // If we have an agent ID from the task, find the agent details in the - // workspace. + // If we have an agent ID from the task, find the agent details in the workspace. + // TODO(ssncferreira): Use 'workspace_agent_lifecycle_state' and 'workspace_app_health' + // from new 'tasks_with_status' query if dbTask.WorkspaceAgentID.Valid { findTaskAgentLoop: for _, resource := range ws.LatestBuild.Resources { @@ -280,13 +282,24 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod if agent.ID == dbTask.WorkspaceAgentID.UUID { taskAgentLifecycle = &agent.LifecycleState taskAgentHealth = &agent.Health + + // Get task app health if it exists + if dbTask.WorkspaceAppID.Valid { + for _, app := range agent.Apps { + if app.ID == dbTask.WorkspaceAppID.UUID { + taskAppHealth = &app.Health + break + } + } + } + break findTaskAgentLoop } } } } - currentState := deriveTaskCurrentState(dbTask, ws, taskAgentLifecycle) + currentState := deriveTaskCurrentState(dbTask, ws, taskAgentLifecycle, taskAppHealth) return codersdk.Task{ ID: dbTask.ID, @@ -319,7 +332,12 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod // deriveTaskCurrentState determines the current state of a task based on the // workspace's latest app status and initialization phase. // Returns nil if no valid state can be determined. -func deriveTaskCurrentState(dbTask database.Task, ws codersdk.Workspace, taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle) *codersdk.TaskStateEntry { +func deriveTaskCurrentState( + dbTask database.Task, + ws codersdk.Workspace, + taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle, + taskAppHealth *codersdk.WorkspaceAppHealth, +) *codersdk.TaskStateEntry { var currentState *codersdk.TaskStateEntry // Ignore 'latest app status' if it is older than the latest build and the @@ -347,11 +365,19 @@ func deriveTaskCurrentState(dbTask database.Task, ws codersdk.Workspace, taskAge ws.LatestBuild.Status == codersdk.WorkspaceStatusStarting: message = fmt.Sprintf("Workspace is %s", ws.LatestBuild.Status) case taskAgentLifecycle != nil: - switch *taskAgentLifecycle { - case codersdk.WorkspaceAgentLifecycleCreated: + switch { + case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleCreated: message = "Agent is connecting" - case codersdk.WorkspaceAgentLifecycleStarting: + case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleStarting: message = "Agent is starting" + case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleReady: + if taskAppHealth != nil && *taskAppHealth == codersdk.WorkspaceAppHealthInitializing { + message = "App is initializing" + } else { + // In case the workspace app is not initializing, + // the overall task status should be updated accordingly + message = "Initializing workspace applications" + } default: message = "Initializing workspace agent" } diff --git a/coderd/aitasks_internal_test.go b/coderd/aitasks_internal_test.go new file mode 100644 index 0000000000000..447a01ef67472 --- /dev/null +++ b/coderd/aitasks_internal_test.go @@ -0,0 +1,224 @@ +package coderd + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/util/ptr" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" +) + +func TestDeriveTaskCurrentState_Unit(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + task database.Task + agentLifecycle *codersdk.WorkspaceAgentLifecycle + appHealth *codersdk.WorkspaceAppHealth + latestAppStatus *codersdk.WorkspaceAppStatus + latestBuild codersdk.WorkspaceBuild + expectCurrentState bool + expectedTimestamp time.Time + expectedState codersdk.TaskState + expectedMessage string + }{ + { + name: "NoAppStatus", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusActive, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Transition: codersdk.WorkspaceTransitionStart, + CreatedAt: now, + }, + expectCurrentState: false, + }, + { + name: "BuildStartTransition_AppStatus_NewerThanBuild", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusActive, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: &codersdk.WorkspaceAppStatus{ + State: codersdk.WorkspaceAppStatusStateWorking, + Message: "Task is working", + CreatedAt: now.Add(1 * time.Minute), + }, + latestBuild: codersdk.WorkspaceBuild{ + Transition: codersdk.WorkspaceTransitionStart, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now.Add(1 * time.Minute), + expectedState: codersdk.TaskState(codersdk.WorkspaceAppStatusStateWorking), + expectedMessage: "Task is working", + }, + { + name: "BuildStartTransition_StaleAppStatus_OlderThanBuild", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusActive, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: &codersdk.WorkspaceAppStatus{ + State: codersdk.WorkspaceAppStatusStateComplete, + Message: "Previous task completed", + CreatedAt: now.Add(-1 * time.Minute), + }, + latestBuild: codersdk.WorkspaceBuild{ + Transition: codersdk.WorkspaceTransitionStart, + CreatedAt: now, + }, + expectCurrentState: false, + }, + { + name: "BuildStopTransition", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusActive, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: &codersdk.WorkspaceAppStatus{ + State: codersdk.WorkspaceAppStatusStateComplete, + Message: "Task completed before stop", + CreatedAt: now.Add(-1 * time.Minute), + }, + latestBuild: codersdk.WorkspaceBuild{ + Transition: codersdk.WorkspaceTransitionStop, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now.Add(-1 * time.Minute), + expectedState: codersdk.TaskState(codersdk.WorkspaceAppStatusStateComplete), + expectedMessage: "Task completed before stop", + }, + { + name: "TaskInitializing_WorkspacePending", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusInitializing, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusPending, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now, + expectedState: codersdk.TaskStateWorking, + expectedMessage: "Workspace is pending", + }, + { + name: "TaskInitializing_WorkspaceStarting", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusInitializing, + }, + agentLifecycle: nil, + appHealth: nil, + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusStarting, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now, + expectedState: codersdk.TaskStateWorking, + expectedMessage: "Workspace is starting", + }, + { + name: "TaskInitializing_AgentConnecting", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusInitializing, + }, + agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleCreated), + appHealth: nil, + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now, + expectedState: codersdk.TaskStateWorking, + expectedMessage: "Agent is connecting", + }, + { + name: "TaskInitializing_AgentStarting", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusInitializing, + }, + agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleStarting), + appHealth: nil, + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now, + expectedState: codersdk.TaskStateWorking, + expectedMessage: "Agent is starting", + }, + { + name: "TaskInitializing_AppInitializing", + task: database.Task{ + ID: uuid.New(), + Status: database.TaskStatusInitializing, + }, + agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), + appHealth: ptr.Ref(codersdk.WorkspaceAppHealthInitializing), + latestAppStatus: nil, + latestBuild: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + }, + expectCurrentState: true, + expectedTimestamp: now, + expectedState: codersdk.TaskStateWorking, + expectedMessage: "App is initializing", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ws := codersdk.Workspace{ + LatestBuild: tt.latestBuild, + LatestAppStatus: tt.latestAppStatus, + } + + currentState := deriveTaskCurrentState(tt.task, ws, tt.agentLifecycle, tt.appHealth) + + if tt.expectCurrentState { + require.NotNil(t, currentState) + assert.Equal(t, tt.expectedTimestamp.UTC(), currentState.Timestamp.UTC()) + assert.Equal(t, tt.expectedState, currentState.State) + assert.Equal(t, tt.expectedMessage, currentState.Message) + } else { + assert.Nil(t, currentState) + } + }) + } +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index c2f6703741607..8582765c01174 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -254,128 +254,6 @@ func TestTasks(t *testing.T) { assert.NotEqual(t, "all done", updated.CurrentState.Message) }) - t.Run("InitializingAgentState", func(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - setupBuild func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse) dbfake.WorkspaceResponse - expectedMessage string - }{ - { - name: "WorkspaceBuildPending", - setupBuild: func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse) dbfake.WorkspaceResponse { - return dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithTask(database.TaskTable{ - Prompt: "test workspace pending", - }, &proto.App{ - Id: uuid.NewString(), - Slug: "ccw", - }).Pending().Do() - }, - expectedMessage: "Workspace is pending", - }, - { - name: "WorkspaceStarting", - setupBuild: func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse) dbfake.WorkspaceResponse { - return dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithTask(database.TaskTable{ - Prompt: "test workspace starting", - }, &proto.App{ - Id: uuid.NewString(), - Slug: "ccw", - }).Starting().Do() - }, - expectedMessage: "Workspace is starting", - }, - { - name: "AgentConnecting", - setupBuild: func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse) dbfake.WorkspaceResponse { - wb := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithTask(database.TaskTable{ - Prompt: "test agent connecting", - }, &proto.App{ - Id: uuid.NewString(), - Slug: "ccw", - }).Do() - - require.True(t, wb.Task.WorkspaceAgentID.Valid) - require.NotEqual(t, uuid.Nil, wb.Task.WorkspaceAgentID.UUID) - - err := db.UpdateWorkspaceAgentLifecycleStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ - ID: wb.Task.WorkspaceAgentID.UUID, - LifecycleState: database.WorkspaceAgentLifecycleStateCreated, - }) - require.NoError(t, err) - - return wb - }, - expectedMessage: "Agent is connecting", - }, - { - name: "AgentStarting", - setupBuild: func(t *testing.T, ctx context.Context, db database.Store, user codersdk.CreateFirstUserResponse) dbfake.WorkspaceResponse { - wb := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithTask(database.TaskTable{ - Prompt: "test agent starting", - }, &proto.App{ - Id: uuid.NewString(), - Slug: "ccw", - }).Do() - - require.True(t, wb.Task.WorkspaceAgentID.Valid) - require.NotEqual(t, uuid.Nil, wb.Task.WorkspaceAgentID.UUID) - - err := db.UpdateWorkspaceAgentLifecycleStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ - ID: wb.Task.WorkspaceAgentID.UUID, - LifecycleState: database.WorkspaceAgentLifecycleStateStarting, - }) - require.NoError(t, err) - - return wb - }, - expectedMessage: "Agent is starting", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - var ( - client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - }) - ctx = testutil.Context(t, testutil.WaitShort) - user = coderdtest.CreateFirstUser(t, client) - exp = codersdk.NewExperimentalClient(client) - ) - - workspaceBuild := tc.setupBuild(t, ctx, db, user) - - // Get the task - task, err := exp.TaskByID(ctx, workspaceBuild.Task.ID) - require.NoError(t, err) - - // Verify the task is initializing with appropriate current_state - assert.Equal(t, codersdk.TaskStatusInitializing, task.Status) - require.NotNil(t, task.CurrentState) - assert.Equal(t, codersdk.TaskStateWorking, task.CurrentState.State) - assert.Equal(t, tc.expectedMessage, task.CurrentState.Message) - assert.NotZero(t, task.CurrentState.Timestamp) - assert.Empty(t, task.CurrentState.URI) - }) - } - }) - t.Run("Delete", func(t *testing.T) { t.Parallel() From 23bd2152c7c4febe7c888f6681dd0d743c06d72a Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Fri, 14 Nov 2025 17:45:33 +0000 Subject: [PATCH 07/10] chore: minor fixes --- coderd/aitasks.go | 2 +- coderd/aitasks_internal_test.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 3a771697556e2..c637d9dd4e0ea 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -274,7 +274,7 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod // If we have an agent ID from the task, find the agent details in the workspace. // TODO(ssncferreira): Use 'workspace_agent_lifecycle_state' and 'workspace_app_health' - // from new 'tasks_with_status' query + // from new 'tasks_with_status' view (PR https://github.com/coder/coder/pull/20683) if dbTask.WorkspaceAgentID.Valid { findTaskAgentLoop: for _, resource := range ws.LatestBuild.Resources { diff --git a/coderd/aitasks_internal_test.go b/coderd/aitasks_internal_test.go index 447a01ef67472..0c087c653befd 100644 --- a/coderd/aitasks_internal_test.go +++ b/coderd/aitasks_internal_test.go @@ -8,9 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/coder/v2/coderd/util/ptr" - "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" ) From 9e9b80f1f84900a98109590908dfdb30a333ff70 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Mon, 17 Nov 2025 11:28:06 +0000 Subject: [PATCH 08/10] chore: use agent lifecycle and app health from dbTask --- coderd/aitasks.go | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index c637d9dd4e0ea..022ddc30a9bfe 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -13,6 +13,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -23,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/taskname" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" @@ -272,27 +274,20 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod var taskAgentHealth *codersdk.WorkspaceAgentHealth var taskAppHealth *codersdk.WorkspaceAppHealth - // If we have an agent ID from the task, find the agent details in the workspace. - // TODO(ssncferreira): Use 'workspace_agent_lifecycle_state' and 'workspace_app_health' - // from new 'tasks_with_status' view (PR https://github.com/coder/coder/pull/20683) + if dbTask.WorkspaceAgentLifecycleState.Valid { + taskAgentLifecycle = ptr.Ref(codersdk.WorkspaceAgentLifecycle(dbTask.WorkspaceAgentLifecycleState.WorkspaceAgentLifecycleState)) + } + if dbTask.WorkspaceAppHealth.Valid { + taskAppHealth = ptr.Ref(codersdk.WorkspaceAppHealth(dbTask.WorkspaceAppHealth.WorkspaceAppHealth)) + } + + // If we have an agent ID from the task, find the agent health info if dbTask.WorkspaceAgentID.Valid { findTaskAgentLoop: for _, resource := range ws.LatestBuild.Resources { for _, agent := range resource.Agents { if agent.ID == dbTask.WorkspaceAgentID.UUID { - taskAgentLifecycle = &agent.LifecycleState taskAgentHealth = &agent.Health - - // Get task app health if it exists - if dbTask.WorkspaceAppID.Valid { - for _, app := range agent.Apps { - if app.ID == dbTask.WorkspaceAppID.UUID { - taskAppHealth = &app.Health - break - } - } - } - break findTaskAgentLoop } } From 979e3707e71da1fe43f810df6184e804f5ba9b8d Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Mon, 17 Nov 2025 11:29:59 +0000 Subject: [PATCH 09/10] chore: address comments --- coderd/aitasks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 022ddc30a9bfe..fbbd6c49b6d12 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -352,7 +352,7 @@ func deriveTaskCurrentState( // If no valid agent state was found for the current build and the task is initializing, // provide a descriptive initialization message. - if currentState == nil && codersdk.TaskStatus(dbTask.Status) == codersdk.TaskStatusInitializing { + if currentState == nil && dbTask.Status == database.TaskStatusInitializing { message := "Initializing workspace" switch { From 677339d94352f0357db53a58719519e28507d601 Mon Sep 17 00:00:00 2001 From: Susana Cardoso Ferreira Date: Mon, 17 Nov 2025 12:50:29 +0000 Subject: [PATCH 10/10] chore: address comments --- coderd/aitasks.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index fbbd6c49b6d12..ccd8b2dfac5b7 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -374,6 +374,8 @@ func deriveTaskCurrentState( message = "Initializing workspace applications" } default: + // In case the workspace agent is not initializing, + // the overall task status should be updated accordingly message = "Initializing workspace agent" } }