From 64000b77a1f55867ce166286fec125d9d82560c4 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 10 Nov 2025 11:34:09 +0000 Subject: [PATCH 1/2] fix(coderd): gate AI task notifications on agent ready state Prevents spammy notifications during agent startup by checking that the agent lifecycle state is 'ready' before sending notifications. This ensures notifications are only sent when the agent is fully operational, not during initialization when status may fluctuate. Changes: - Add agent lifecycle check to enqueueAITaskStateNotification - Only send notifications when agent.LifecycleState == Ready - Add debug logging when notifications are skipped - Add test coverage for agent lifecycle states (starting, created, ready) - Update existing tests to explicitly set ready state when expecting notifications Fixes spammy notification reports where users receive alerts before the agent has finished starting up. --- coderd/aitasks_test.go | 67 +++++++++++++++++++++++++++++++++++++++ coderd/workspaceagents.go | 18 +++++++++-- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 0151d77c1961a..eaac973b159e6 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "context" + "database/sql" "encoding/json" "io" "net/http" @@ -1184,6 +1185,7 @@ func TestTasksNotification(t *testing.T) { isNotificationSent bool notificationTemplate uuid.UUID taskPrompt string + agentLifecycle database.WorkspaceAgentLifecycleState }{ // Should not send a notification when the agent app is not an AI task. { @@ -1231,6 +1233,7 @@ func TestTasksNotification(t *testing.T) { isNotificationSent: true, notificationTemplate: notifications.TemplateTaskIdle, taskPrompt: "InitialTemplateTaskIdle", + agentLifecycle: database.WorkspaceAgentLifecycleStateReady, }, // Should send TemplateTaskWorking when the AI task transitions to 'Working' from 'Idle'. { @@ -1244,6 +1247,7 @@ func TestTasksNotification(t *testing.T) { isNotificationSent: true, notificationTemplate: notifications.TemplateTaskWorking, taskPrompt: "TemplateTaskWorkingFromIdle", + agentLifecycle: database.WorkspaceAgentLifecycleStateReady, }, // Should send TemplateTaskIdle when the AI task transitions to 'Idle'. { @@ -1254,6 +1258,7 @@ func TestTasksNotification(t *testing.T) { isNotificationSent: true, notificationTemplate: notifications.TemplateTaskIdle, taskPrompt: "TemplateTaskIdle", + agentLifecycle: database.WorkspaceAgentLifecycleStateReady, }, // Long task prompts should be truncated to 160 characters. { @@ -1264,6 +1269,7 @@ func TestTasksNotification(t *testing.T) { isNotificationSent: true, notificationTemplate: notifications.TemplateTaskIdle, taskPrompt: "This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + agentLifecycle: database.WorkspaceAgentLifecycleStateReady, }, // Should send TemplateTaskCompleted when the AI task transitions to 'Complete'. { @@ -1274,6 +1280,7 @@ func TestTasksNotification(t *testing.T) { isNotificationSent: true, notificationTemplate: notifications.TemplateTaskCompleted, taskPrompt: "TemplateTaskCompleted", + agentLifecycle: database.WorkspaceAgentLifecycleStateReady, }, // Should send TemplateTaskFailed when the AI task transitions to 'Failure'. { @@ -1284,6 +1291,7 @@ func TestTasksNotification(t *testing.T) { isNotificationSent: true, notificationTemplate: notifications.TemplateTaskFailed, taskPrompt: "TemplateTaskFailed", + agentLifecycle: database.WorkspaceAgentLifecycleStateReady, }, // Should send TemplateTaskCompleted when the AI task transitions from 'Idle' to 'Complete'. { @@ -1294,6 +1302,7 @@ func TestTasksNotification(t *testing.T) { isNotificationSent: true, notificationTemplate: notifications.TemplateTaskCompleted, taskPrompt: "TemplateTaskCompletedFromIdle", + agentLifecycle: database.WorkspaceAgentLifecycleStateReady, }, // Should send TemplateTaskFailed when the AI task transitions from 'Idle' to 'Failure'. { @@ -1304,6 +1313,7 @@ func TestTasksNotification(t *testing.T) { isNotificationSent: true, notificationTemplate: notifications.TemplateTaskFailed, taskPrompt: "TemplateTaskFailedFromIdle", + agentLifecycle: database.WorkspaceAgentLifecycleStateReady, }, // Should NOT send notification when transitioning from 'Complete' to 'Complete' (no change). { @@ -1323,6 +1333,37 @@ func TestTasksNotification(t *testing.T) { isNotificationSent: false, taskPrompt: "NoNotificationFailureToFailure", }, + // Should NOT send notification when agent is in 'starting' lifecycle state (agent startup). + { + name: "AgentStarting_NoNotification", + latestAppStatuses: nil, + newAppStatus: codersdk.WorkspaceAppStatusStateIdle, + isAITask: true, + isNotificationSent: false, + taskPrompt: "AgentStarting_NoNotification", + agentLifecycle: database.WorkspaceAgentLifecycleStateStarting, + }, + // Should NOT send notification when agent is in 'created' lifecycle state (agent not started). + { + name: "AgentCreated_NoNotification", + latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking}, + newAppStatus: codersdk.WorkspaceAppStatusStateIdle, + isAITask: true, + isNotificationSent: false, + taskPrompt: "AgentCreated_NoNotification", + agentLifecycle: database.WorkspaceAgentLifecycleStateCreated, + }, + // Should send notification when agent is in 'ready' lifecycle state (agent fully started). + { + name: "AgentReady_SendNotification", + latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking}, + newAppStatus: codersdk.WorkspaceAppStatusStateIdle, + isAITask: true, + isNotificationSent: true, + notificationTemplate: notifications.TemplateTaskIdle, + taskPrompt: "AgentReady_SendNotification", + agentLifecycle: database.WorkspaceAgentLifecycleStateReady, + }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -1367,6 +1408,32 @@ func TestTasksNotification(t *testing.T) { } workspaceBuild := workspaceBuilder.Do() + // Given: set the agent lifecycle state if specified + if tc.agentLifecycle != "" { + workspace := coderdtest.MustWorkspace(t, client, workspaceBuild.Workspace.ID) + agentID := workspace.LatestBuild.Resources[0].Agents[0].ID + + var ( + startedAt sql.NullTime + readyAt sql.NullTime + ) + if tc.agentLifecycle == database.WorkspaceAgentLifecycleStateReady { + startedAt = sql.NullTime{Time: dbtime.Now(), Valid: true} + readyAt = sql.NullTime{Time: dbtime.Now(), Valid: true} + } else if tc.agentLifecycle == database.WorkspaceAgentLifecycleStateStarting { + startedAt = sql.NullTime{Time: dbtime.Now(), Valid: true} + } + + // nolint:gocritic // This is a system restricted operation for test setup. + err := db.UpdateWorkspaceAgentLifecycleStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agentID, + LifecycleState: tc.agentLifecycle, + StartedAt: startedAt, + ReadyAt: readyAt, + }) + require.NoError(t, err) + } + // Given: the workspace agent app has previous statuses agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(workspaceBuild.AgentToken)) if len(tc.latestAppStatuses) > 0 { diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 1374d92dc4d12..8bf18d06d0feb 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -428,7 +428,7 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req }) // Notify on state change to Working/Idle for AI tasks - api.enqueueAITaskStateNotification(ctx, app.ID, latestAppStatus, req.State, workspace) + api.enqueueAITaskStateNotification(ctx, app.ID, latestAppStatus, req.State, workspace, workspaceAgent) httpapi.Write(ctx, rw, http.StatusOK, nil) } @@ -437,13 +437,15 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req // transitions to Working or Idle. // No-op if: // - the workspace agent app isn't configured as an AI task, -// - the new state equals the latest persisted state. +// - the new state equals the latest persisted state, +// - the workspace agent is not ready (still starting up). func (api *API) enqueueAITaskStateNotification( ctx context.Context, appID uuid.UUID, latestAppStatus []database.WorkspaceAppStatus, newAppStatus codersdk.WorkspaceAppStatusState, workspace database.Workspace, + agent database.WorkspaceAgent, ) { // Select notification template based on the new state var notificationTemplate uuid.UUID @@ -466,6 +468,18 @@ func (api *API) enqueueAITaskStateNotification( return } + // Only send notifications if the agent is ready. This prevents spammy + // notifications during agent startup when the screen state might be + // fluctuating as startup scripts execute and apps initialize. + if agent.LifecycleState != database.WorkspaceAgentLifecycleStateReady { + api.Logger.Debug(ctx, "skipping AI task notification because agent is not ready", + slog.F("agent_id", agent.ID), + slog.F("lifecycle_state", agent.LifecycleState), + slog.F("new_app_status", newAppStatus), + ) + return + } + task, err := api.Database.GetTaskByID(ctx, workspace.TaskID.UUID) if err != nil { api.Logger.Warn(ctx, "failed to get task", slog.Error(err)) From 1cc60d1ab319f7d303d7caae3073ae910c9089e4 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 10 Nov 2025 14:18:09 +0000 Subject: [PATCH 2/2] Modify comment --- coderd/workspaceagents.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 8bf18d06d0feb..eced6ff6d3124 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -468,9 +468,9 @@ func (api *API) enqueueAITaskStateNotification( return } - // Only send notifications if the agent is ready. This prevents spammy - // notifications during agent startup when the screen state might be - // fluctuating as startup scripts execute and apps initialize. + // Only send notifications when the agent is ready. We want to skip + // any state transitions that occur whilst the workspace is starting + // up as it doesn't make sense to receive them. if agent.LifecycleState != database.WorkspaceAgentLifecycleStateReady { api.Logger.Debug(ctx, "skipping AI task notification because agent is not ready", slog.F("agent_id", agent.ID),