diff --git a/packages/backend/convex/agentJobs.ts b/packages/backend/convex/agentJobs.ts index 019578c..8cddadd 100644 --- a/packages/backend/convex/agentJobs.ts +++ b/packages/backend/convex/agentJobs.ts @@ -1223,6 +1223,7 @@ export const recoverStaleJobs = internalMutation({ const now = Date.now(); await ctx.db.patch(job._id, { status: 'timed_out', + workspaceStatus: 'expired', error: job.error ?? 'The worker stopped reporting progress; the job timed out.', @@ -1512,12 +1513,15 @@ export const heartbeatWorkspace = mutation({ if (job?.claimedBy !== args.workerId) { throw new ConvexError('Agent job not claimed by this worker.'); } + if (isTerminalStatus(job.status)) { + return { success: true, cancelRequested: true }; + } await ctx.db.patch(args.jobId, { workspaceStatus: 'active', lastHeartbeatAt: Date.now(), updatedAt: Date.now(), }); - return { success: true, cancelRequested: job.status === 'cancelled' }; + return { success: true, cancelRequested: false }; }, }); diff --git a/packages/backend/tests/unit/recover-stale-jobs.test.ts b/packages/backend/tests/unit/recover-stale-jobs.test.ts index 33a4180..89db9ff 100644 --- a/packages/backend/tests/unit/recover-stale-jobs.test.ts +++ b/packages/backend/tests/unit/recover-stale-jobs.test.ts @@ -119,6 +119,7 @@ describe('recoverStaleJobs', () => { expect(result).toEqual({ recovered: 1 }); expect(state.job?.status).toBe('timed_out'); + expect(state.job?.workspaceStatus).toBe('expired'); expect(state.job?.completedAt).toBeTypeOf('number'); expect(state.request?.status).toBe('failed'); expect(state.thread?.status).toBe('failed'); diff --git a/packages/backend/tests/unit/update-status.test.ts b/packages/backend/tests/unit/update-status.test.ts index 691e5d9..5e19be4 100644 --- a/packages/backend/tests/unit/update-status.test.ts +++ b/packages/backend/tests/unit/update-status.test.ts @@ -24,7 +24,7 @@ const spoonInput = { const seedJob = async ( t: ReturnType, - status: 'cancelled' | 'running', + status: 'cancelled' | 'running' | 'timed_out', ) => await t.mutation(async (ctx) => { const now = Date.now(); @@ -109,7 +109,7 @@ describe('updateStatus terminal guard', () => { }); describe('heartbeatWorkspace cancellation propagation', () => { - test('reports cancellation while refreshing the workspace heartbeat', async () => { + test('requests teardown for a cancelled job without resurrecting it', async () => { const t = convexTest(schema, modules); const jobId = await seedJob(t, 'cancelled'); @@ -122,8 +122,25 @@ describe('heartbeatWorkspace cancellation propagation', () => { expect(result).toEqual({ success: true, cancelRequested: true }); const job = await t.run(async (ctx) => await ctx.db.get(jobId)); expect(job?.status).toBe('cancelled'); - expect(job?.workspaceStatus).toBe('active'); - expect(job?.lastHeartbeatAt).toBeTypeOf('number'); + expect(job?.workspaceStatus).not.toBe('active'); + expect(job?.lastHeartbeatAt).toBeUndefined(); + }); + + test('requests teardown for a timed_out job without resurrecting it', async () => { + const t = convexTest(schema, modules); + const jobId = await seedJob(t, 'timed_out'); + + const result = await t.mutation(api.agentJobs.heartbeatWorkspace, { + workerToken: 'test-worker-token', + workerId: 'worker-1', + jobId, + }); + + expect(result).toEqual({ success: true, cancelRequested: true }); + const job = await t.run(async (ctx) => await ctx.db.get(jobId)); + expect(job?.status).toBe('timed_out'); + expect(job?.workspaceStatus).not.toBe('active'); + expect(job?.lastHeartbeatAt).toBeUndefined(); }); test('reports no cancellation for an active job', async () => {