From 697fb7286df026979583b58060a9c758d9abf98a Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 10 Jul 2026 18:27:53 -0400 Subject: [PATCH] fix(agent-jobs): recovery cron skips already-terminal jobs --- packages/backend/convex/agentJobs.ts | 1 + .../tests/unit/recover-stale-jobs.test.ts | 39 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/backend/convex/agentJobs.ts b/packages/backend/convex/agentJobs.ts index 8cddadd..54a79af 100644 --- a/packages/backend/convex/agentJobs.ts +++ b/packages/backend/convex/agentJobs.ts @@ -1218,6 +1218,7 @@ export const recoverStaleJobs = internalMutation({ .withIndex('by_status', (q) => q.eq('status', status)) .collect(); for (const job of jobs) { + if (isTerminalJob(job)) continue; const last = job.lastHeartbeatAt ?? job.claimedAt ?? job.createdAt; if (last > threshold) continue; const now = Date.now(); diff --git a/packages/backend/tests/unit/recover-stale-jobs.test.ts b/packages/backend/tests/unit/recover-stale-jobs.test.ts index 89db9ff..f253add 100644 --- a/packages/backend/tests/unit/recover-stale-jobs.test.ts +++ b/packages/backend/tests/unit/recover-stale-jobs.test.ts @@ -15,6 +15,10 @@ const seedJob = async ( status?: ActiveStatus; claimedAt?: number; lastHeartbeatAt?: number; + workspaceStatus?: string; + completedAt?: number; + threadStatus?: 'running' | 'resolved' | 'ignored' | 'waiting_for_user'; + threadResolvedAt?: number; }, ) => await t.mutation(async (ctx) => { @@ -55,9 +59,12 @@ const seedJob = async ( spoonId, title: 'Recover the worker job', source: 'user_request', - status: 'running', + status: args.threadStatus ?? 'running', priority: 'normal', relatedAgentRequestId: requestId, + ...(args.threadResolvedAt === undefined + ? {} + : { resolvedAt: args.threadResolvedAt }), createdAt: now, updatedAt: now, }); @@ -69,7 +76,7 @@ const seedJob = async ( status: args.status ?? 'running', prompt: 'Recover the worker job', runtime: 'opencode', - workspaceStatus: 'active', + workspaceStatus: args.workspaceStatus ?? 'active', baseBranch: 'main', workBranch: 'spoon/recover-worker-job', forkOwner: 'team', @@ -85,6 +92,9 @@ const seedJob = async ( ...(args.lastHeartbeatAt === undefined ? {} : { lastHeartbeatAt: args.lastHeartbeatAt }), + ...(args.completedAt === undefined + ? {} + : { completedAt: args.completedAt }), createdAt: now, updatedAt: now, }); @@ -126,6 +136,31 @@ describe('recoverStaleJobs', () => { expect(state.thread?.resolvedAt).toBeTypeOf('number'); }); + test('leaves a terminal-workspace maintenance job untouched', async () => { + const t = convexTest(schema, modules); + const resolvedAt = Date.now() - 5 * 60 * 1000; + const ids = await seedJob(t, { + status: 'running', + workspaceStatus: 'stopped', + completedAt: resolvedAt, + threadStatus: 'resolved', + threadResolvedAt: resolvedAt, + // stale heartbeat: the worker stopped its heartbeat after applying the + // maintenance decision, so a naive cron would time this job out + claimedAt: Date.now() - 10 * 60 * 1000, + lastHeartbeatAt: Date.now() - 10 * 60 * 1000, + }); + + const result = await t.mutation(internal.agentJobs.recoverStaleJobs, {}); + const state = await readState(t, ids); + + expect(result).toEqual({ recovered: 0 }); + expect(state.job?.status).toBe('running'); + expect(state.job?.workspaceStatus).toBe('stopped'); + expect(state.thread?.status).toBe('resolved'); + expect(state.thread?.resolvedAt).toBe(resolvedAt); + }); + test('leaves a running job with a fresh heartbeat untouched', async () => { const t = convexTest(schema, modules); const ids = await seedJob(t, { lastHeartbeatAt: Date.now() });