Skip to content

Commit f2a1a7e

Browse files
fix(coderd): gate AI task notifications on agent ready state (#20690)
Relates to coder/internal#1098 Currently AgentAPI waits for only 2 seconds worth of identical terminal screen snapshots before deciding a task has entered a "stable" state. We interpret this as becoming "idle", resulting in a notification being triggered. This behavior is not ideal and is ultimately the root cause of our spammy notifications. Unfortunately, until we move AgentAPI to either use the Claude Code SDK (or ACP wrapper around it), we are unable to easily fix the root cause. This PR instead waits until the agent is ready before it will send state change notifications. This will at least resolve _some_ of the complaints about task state notifications being too spammy. --- 🤖 PR was written by Claude Sonnet 4.5 using [Coder Mux](https://github.com/coder/cmux) and reviewed by a human 👩
1 parent 616db32 commit f2a1a7e

File tree

2 files changed

+83
-2
lines changed

2 files changed

+83
-2
lines changed

coderd/aitasks_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package coderd_test
22

33
import (
44
"context"
5+
"database/sql"
56
"encoding/json"
67
"io"
78
"net/http"
@@ -1184,6 +1185,7 @@ func TestTasksNotification(t *testing.T) {
11841185
isNotificationSent bool
11851186
notificationTemplate uuid.UUID
11861187
taskPrompt string
1188+
agentLifecycle database.WorkspaceAgentLifecycleState
11871189
}{
11881190
// Should not send a notification when the agent app is not an AI task.
11891191
{
@@ -1231,6 +1233,7 @@ func TestTasksNotification(t *testing.T) {
12311233
isNotificationSent: true,
12321234
notificationTemplate: notifications.TemplateTaskIdle,
12331235
taskPrompt: "InitialTemplateTaskIdle",
1236+
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
12341237
},
12351238
// Should send TemplateTaskWorking when the AI task transitions to 'Working' from 'Idle'.
12361239
{
@@ -1244,6 +1247,7 @@ func TestTasksNotification(t *testing.T) {
12441247
isNotificationSent: true,
12451248
notificationTemplate: notifications.TemplateTaskWorking,
12461249
taskPrompt: "TemplateTaskWorkingFromIdle",
1250+
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
12471251
},
12481252
// Should send TemplateTaskIdle when the AI task transitions to 'Idle'.
12491253
{
@@ -1254,6 +1258,7 @@ func TestTasksNotification(t *testing.T) {
12541258
isNotificationSent: true,
12551259
notificationTemplate: notifications.TemplateTaskIdle,
12561260
taskPrompt: "TemplateTaskIdle",
1261+
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
12571262
},
12581263
// Long task prompts should be truncated to 160 characters.
12591264
{
@@ -1264,6 +1269,7 @@ func TestTasksNotification(t *testing.T) {
12641269
isNotificationSent: true,
12651270
notificationTemplate: notifications.TemplateTaskIdle,
12661271
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.",
1272+
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
12671273
},
12681274
// Should send TemplateTaskCompleted when the AI task transitions to 'Complete'.
12691275
{
@@ -1274,6 +1280,7 @@ func TestTasksNotification(t *testing.T) {
12741280
isNotificationSent: true,
12751281
notificationTemplate: notifications.TemplateTaskCompleted,
12761282
taskPrompt: "TemplateTaskCompleted",
1283+
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
12771284
},
12781285
// Should send TemplateTaskFailed when the AI task transitions to 'Failure'.
12791286
{
@@ -1284,6 +1291,7 @@ func TestTasksNotification(t *testing.T) {
12841291
isNotificationSent: true,
12851292
notificationTemplate: notifications.TemplateTaskFailed,
12861293
taskPrompt: "TemplateTaskFailed",
1294+
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
12871295
},
12881296
// Should send TemplateTaskCompleted when the AI task transitions from 'Idle' to 'Complete'.
12891297
{
@@ -1294,6 +1302,7 @@ func TestTasksNotification(t *testing.T) {
12941302
isNotificationSent: true,
12951303
notificationTemplate: notifications.TemplateTaskCompleted,
12961304
taskPrompt: "TemplateTaskCompletedFromIdle",
1305+
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
12971306
},
12981307
// Should send TemplateTaskFailed when the AI task transitions from 'Idle' to 'Failure'.
12991308
{
@@ -1304,6 +1313,7 @@ func TestTasksNotification(t *testing.T) {
13041313
isNotificationSent: true,
13051314
notificationTemplate: notifications.TemplateTaskFailed,
13061315
taskPrompt: "TemplateTaskFailedFromIdle",
1316+
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
13071317
},
13081318
// Should NOT send notification when transitioning from 'Complete' to 'Complete' (no change).
13091319
{
@@ -1323,6 +1333,37 @@ func TestTasksNotification(t *testing.T) {
13231333
isNotificationSent: false,
13241334
taskPrompt: "NoNotificationFailureToFailure",
13251335
},
1336+
// Should NOT send notification when agent is in 'starting' lifecycle state (agent startup).
1337+
{
1338+
name: "AgentStarting_NoNotification",
1339+
latestAppStatuses: nil,
1340+
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
1341+
isAITask: true,
1342+
isNotificationSent: false,
1343+
taskPrompt: "AgentStarting_NoNotification",
1344+
agentLifecycle: database.WorkspaceAgentLifecycleStateStarting,
1345+
},
1346+
// Should NOT send notification when agent is in 'created' lifecycle state (agent not started).
1347+
{
1348+
name: "AgentCreated_NoNotification",
1349+
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
1350+
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
1351+
isAITask: true,
1352+
isNotificationSent: false,
1353+
taskPrompt: "AgentCreated_NoNotification",
1354+
agentLifecycle: database.WorkspaceAgentLifecycleStateCreated,
1355+
},
1356+
// Should send notification when agent is in 'ready' lifecycle state (agent fully started).
1357+
{
1358+
name: "AgentReady_SendNotification",
1359+
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
1360+
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
1361+
isAITask: true,
1362+
isNotificationSent: true,
1363+
notificationTemplate: notifications.TemplateTaskIdle,
1364+
taskPrompt: "AgentReady_SendNotification",
1365+
agentLifecycle: database.WorkspaceAgentLifecycleStateReady,
1366+
},
13261367
} {
13271368
t.Run(tc.name, func(t *testing.T) {
13281369
t.Parallel()
@@ -1367,6 +1408,32 @@ func TestTasksNotification(t *testing.T) {
13671408
}
13681409
workspaceBuild := workspaceBuilder.Do()
13691410

1411+
// Given: set the agent lifecycle state if specified
1412+
if tc.agentLifecycle != "" {
1413+
workspace := coderdtest.MustWorkspace(t, client, workspaceBuild.Workspace.ID)
1414+
agentID := workspace.LatestBuild.Resources[0].Agents[0].ID
1415+
1416+
var (
1417+
startedAt sql.NullTime
1418+
readyAt sql.NullTime
1419+
)
1420+
if tc.agentLifecycle == database.WorkspaceAgentLifecycleStateReady {
1421+
startedAt = sql.NullTime{Time: dbtime.Now(), Valid: true}
1422+
readyAt = sql.NullTime{Time: dbtime.Now(), Valid: true}
1423+
} else if tc.agentLifecycle == database.WorkspaceAgentLifecycleStateStarting {
1424+
startedAt = sql.NullTime{Time: dbtime.Now(), Valid: true}
1425+
}
1426+
1427+
// nolint:gocritic // This is a system restricted operation for test setup.
1428+
err := db.UpdateWorkspaceAgentLifecycleStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAgentLifecycleStateByIDParams{
1429+
ID: agentID,
1430+
LifecycleState: tc.agentLifecycle,
1431+
StartedAt: startedAt,
1432+
ReadyAt: readyAt,
1433+
})
1434+
require.NoError(t, err)
1435+
}
1436+
13701437
// Given: the workspace agent app has previous statuses
13711438
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(workspaceBuild.AgentToken))
13721439
if len(tc.latestAppStatuses) > 0 {

coderd/workspaceagents.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req
428428
})
429429

430430
// Notify on state change to Working/Idle for AI tasks
431-
api.enqueueAITaskStateNotification(ctx, app.ID, latestAppStatus, req.State, workspace)
431+
api.enqueueAITaskStateNotification(ctx, app.ID, latestAppStatus, req.State, workspace, workspaceAgent)
432432

433433
httpapi.Write(ctx, rw, http.StatusOK, nil)
434434
}
@@ -437,13 +437,15 @@ func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Req
437437
// transitions to Working or Idle.
438438
// No-op if:
439439
// - the workspace agent app isn't configured as an AI task,
440-
// - the new state equals the latest persisted state.
440+
// - the new state equals the latest persisted state,
441+
// - the workspace agent is not ready (still starting up).
441442
func (api *API) enqueueAITaskStateNotification(
442443
ctx context.Context,
443444
appID uuid.UUID,
444445
latestAppStatus []database.WorkspaceAppStatus,
445446
newAppStatus codersdk.WorkspaceAppStatusState,
446447
workspace database.Workspace,
448+
agent database.WorkspaceAgent,
447449
) {
448450
// Select notification template based on the new state
449451
var notificationTemplate uuid.UUID
@@ -466,6 +468,18 @@ func (api *API) enqueueAITaskStateNotification(
466468
return
467469
}
468470

471+
// Only send notifications when the agent is ready. We want to skip
472+
// any state transitions that occur whilst the workspace is starting
473+
// up as it doesn't make sense to receive them.
474+
if agent.LifecycleState != database.WorkspaceAgentLifecycleStateReady {
475+
api.Logger.Debug(ctx, "skipping AI task notification because agent is not ready",
476+
slog.F("agent_id", agent.ID),
477+
slog.F("lifecycle_state", agent.LifecycleState),
478+
slog.F("new_app_status", newAppStatus),
479+
)
480+
return
481+
}
482+
469483
task, err := api.Database.GetTaskByID(ctx, workspace.TaskID.UUID)
470484
if err != nil {
471485
api.Logger.Warn(ctx, "failed to get task", slog.Error(err))

0 commit comments

Comments
 (0)