feat(coderd): bump workspace deadline on AI agent activity (#21584)

AI agents report status via patchWorkspaceAgentAppStatus, but this wasn't
extending workspace deadlines. This prevented proper task auto-pause behavior,
causing tasks to pause mid-execution when there were no human connections.

Now we call ActivityBumpWorkspace when agents report status, using the same
logic as SSH/IDE connections. We bump when transitioning to or from the working
state.

Closes coder/internal#1251
This commit is contained in:
Mathias Fredriksson
2026-01-22 13:52:32 +02:00
committed by GitHub
parent 5dcc9dd8ab
commit 4c7844ad3d
3 changed files with 286 additions and 0 deletions
+168
View File
@@ -425,6 +425,174 @@ func TestWorkspaceAgentAppStatus(t *testing.T) {
})
}
func TestWorkspaceAgentAppStatus_ActivityBump(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)
tests := []struct {
name string
prevState *codersdk.WorkspaceAppStatusState // nil means no previous state
newState codersdk.WorkspaceAppStatusState
shouldBump bool
}{
{
name: "FirstStatusBumps",
prevState: nil,
newState: codersdk.WorkspaceAppStatusStateWorking,
shouldBump: true,
},
{
name: "WorkingToIdleBumps",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateWorking),
newState: codersdk.WorkspaceAppStatusStateIdle,
shouldBump: true,
},
{
name: "WorkingToCompleteBumps",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateWorking),
newState: codersdk.WorkspaceAppStatusStateComplete,
shouldBump: true,
},
{
name: "CompleteToIdleNoBump",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateComplete),
newState: codersdk.WorkspaceAppStatusStateIdle,
shouldBump: false,
},
{
name: "CompleteToCompleteNoBump",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateComplete),
newState: codersdk.WorkspaceAppStatusStateComplete,
shouldBump: false,
},
{
name: "FailureToIdleNoBump",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateFailure),
newState: codersdk.WorkspaceAppStatusStateIdle,
shouldBump: false,
},
{
name: "FailureToFailureNoBump",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateFailure),
newState: codersdk.WorkspaceAppStatusStateFailure,
shouldBump: false,
},
{
name: "CompleteToWorkingBumps",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateComplete),
newState: codersdk.WorkspaceAppStatusStateWorking,
shouldBump: true,
},
{
name: "FailureToCompleteNoBump",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateFailure),
newState: codersdk.WorkspaceAppStatusStateComplete,
shouldBump: false,
},
{
name: "WorkingToFailureBumps",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateWorking),
newState: codersdk.WorkspaceAppStatusStateFailure,
shouldBump: true,
},
{
name: "IdleToIdleNoBump",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateIdle),
newState: codersdk.WorkspaceAppStatusStateIdle,
shouldBump: false,
},
{
name: "IdleToWorkingBumps",
prevState: ptr.Ref(codersdk.WorkspaceAppStatusStateIdle),
newState: codersdk.WorkspaceAppStatusStateWorking,
shouldBump: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Create workspace with agent and app.
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
}).WithAgent(func(a []*proto.Agent) []*proto.Agent {
a[0].Apps = []*proto.App{{Slug: "test-app"}}
return a
}).Do()
ctx := testutil.Context(t, testutil.WaitMedium)
// Configure template with activity_bump to enable deadline bumping.
_, err := client.UpdateTemplateMeta(ctx, r.Template.ID, codersdk.UpdateTemplateMeta{
ActivityBumpMillis: time.Hour.Milliseconds(),
})
require.NoError(t, err)
// Set the workspace build deadline to the past to ensure the 5%
// threshold is met for activity bumping.
pastDeadline := dbtime.Now().Add(-30 * time.Minute)
err = db.UpdateWorkspaceBuildDeadlineByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceBuildDeadlineByIDParams{
ID: r.Build.ID,
UpdatedAt: dbtime.Now(),
Deadline: pastDeadline,
MaxDeadline: time.Time{},
})
require.NoError(t, err)
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken))
// If there's a previous state, report it first.
if tt.prevState != nil {
err := agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
AppSlug: "test-app",
State: *tt.prevState,
Message: "previous state",
})
require.NoError(t, err)
// Reset deadline to past again to meet 5% threshold for next bump.
err = db.UpdateWorkspaceBuildDeadlineByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceBuildDeadlineByIDParams{
ID: r.Build.ID,
UpdatedAt: dbtime.Now(),
Deadline: pastDeadline,
MaxDeadline: time.Time{},
})
require.NoError(t, err)
}
// Get the deadline before the new status report.
beforeBuild, err := db.GetWorkspaceBuildByID(dbauthz.AsSystemRestricted(ctx), r.Build.ID)
require.NoError(t, err)
beforeDeadline := beforeBuild.Deadline
// Report the new state.
err = agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
AppSlug: "test-app",
State: tt.newState,
Message: "new state",
})
require.NoError(t, err)
// Check if deadline changed.
afterBuild, err := db.GetWorkspaceBuildByID(dbauthz.AsSystemRestricted(ctx), r.Build.ID)
require.NoError(t, err)
afterDeadline := afterBuild.Deadline
didBump := afterDeadline.After(beforeDeadline)
if tt.shouldBump {
require.True(t, didBump, "wanted deadline to bump but it didn't")
} else {
require.False(t, didBump, "wanted deadline not to bump but it did")
}
})
}
}
func TestWorkspaceAgentConnectRPC(t *testing.T) {
t.Parallel()