fix(agent-jobs): stop heartbeat from resurrecting terminated jobs

This commit is contained in:
Gabriel Brown
2026-07-10 18:13:28 -04:00
parent ac68484059
commit 60dd5d3836
3 changed files with 27 additions and 5 deletions
+5 -1
View File
@@ -1223,6 +1223,7 @@ export const recoverStaleJobs = internalMutation({
const now = Date.now(); const now = Date.now();
await ctx.db.patch(job._id, { await ctx.db.patch(job._id, {
status: 'timed_out', status: 'timed_out',
workspaceStatus: 'expired',
error: error:
job.error ?? job.error ??
'The worker stopped reporting progress; the job timed out.', 'The worker stopped reporting progress; the job timed out.',
@@ -1512,12 +1513,15 @@ export const heartbeatWorkspace = mutation({
if (job?.claimedBy !== args.workerId) { if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.'); 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, { await ctx.db.patch(args.jobId, {
workspaceStatus: 'active', workspaceStatus: 'active',
lastHeartbeatAt: Date.now(), lastHeartbeatAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
return { success: true, cancelRequested: job.status === 'cancelled' }; return { success: true, cancelRequested: false };
}, },
}); });
@@ -119,6 +119,7 @@ describe('recoverStaleJobs', () => {
expect(result).toEqual({ recovered: 1 }); expect(result).toEqual({ recovered: 1 });
expect(state.job?.status).toBe('timed_out'); expect(state.job?.status).toBe('timed_out');
expect(state.job?.workspaceStatus).toBe('expired');
expect(state.job?.completedAt).toBeTypeOf('number'); expect(state.job?.completedAt).toBeTypeOf('number');
expect(state.request?.status).toBe('failed'); expect(state.request?.status).toBe('failed');
expect(state.thread?.status).toBe('failed'); expect(state.thread?.status).toBe('failed');
@@ -24,7 +24,7 @@ const spoonInput = {
const seedJob = async ( const seedJob = async (
t: ReturnType<typeof convexTest>, t: ReturnType<typeof convexTest>,
status: 'cancelled' | 'running', status: 'cancelled' | 'running' | 'timed_out',
) => ) =>
await t.mutation(async (ctx) => { await t.mutation(async (ctx) => {
const now = Date.now(); const now = Date.now();
@@ -109,7 +109,7 @@ describe('updateStatus terminal guard', () => {
}); });
describe('heartbeatWorkspace cancellation propagation', () => { 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 t = convexTest(schema, modules);
const jobId = await seedJob(t, 'cancelled'); const jobId = await seedJob(t, 'cancelled');
@@ -122,8 +122,25 @@ describe('heartbeatWorkspace cancellation propagation', () => {
expect(result).toEqual({ success: true, cancelRequested: true }); expect(result).toEqual({ success: true, cancelRequested: true });
const job = await t.run(async (ctx) => await ctx.db.get(jobId)); const job = await t.run(async (ctx) => await ctx.db.get(jobId));
expect(job?.status).toBe('cancelled'); expect(job?.status).toBe('cancelled');
expect(job?.workspaceStatus).toBe('active'); expect(job?.workspaceStatus).not.toBe('active');
expect(job?.lastHeartbeatAt).toBeTypeOf('number'); 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 () => { test('reports no cancellation for an active job', async () => {