fix(agent-jobs): recovery cron skips already-terminal jobs

This commit is contained in:
Gabriel Brown
2026-07-10 18:27:53 -04:00
parent 60dd5d3836
commit 697fb7286d
2 changed files with 38 additions and 2 deletions
+1
View File
@@ -1218,6 +1218,7 @@ export const recoverStaleJobs = internalMutation({
.withIndex('by_status', (q) => q.eq('status', status)) .withIndex('by_status', (q) => q.eq('status', status))
.collect(); .collect();
for (const job of jobs) { for (const job of jobs) {
if (isTerminalJob(job)) continue;
const last = job.lastHeartbeatAt ?? job.claimedAt ?? job.createdAt; const last = job.lastHeartbeatAt ?? job.claimedAt ?? job.createdAt;
if (last > threshold) continue; if (last > threshold) continue;
const now = Date.now(); const now = Date.now();
@@ -15,6 +15,10 @@ const seedJob = async (
status?: ActiveStatus; status?: ActiveStatus;
claimedAt?: number; claimedAt?: number;
lastHeartbeatAt?: number; lastHeartbeatAt?: number;
workspaceStatus?: string;
completedAt?: number;
threadStatus?: 'running' | 'resolved' | 'ignored' | 'waiting_for_user';
threadResolvedAt?: number;
}, },
) => ) =>
await t.mutation(async (ctx) => { await t.mutation(async (ctx) => {
@@ -55,9 +59,12 @@ const seedJob = async (
spoonId, spoonId,
title: 'Recover the worker job', title: 'Recover the worker job',
source: 'user_request', source: 'user_request',
status: 'running', status: args.threadStatus ?? 'running',
priority: 'normal', priority: 'normal',
relatedAgentRequestId: requestId, relatedAgentRequestId: requestId,
...(args.threadResolvedAt === undefined
? {}
: { resolvedAt: args.threadResolvedAt }),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@@ -69,7 +76,7 @@ const seedJob = async (
status: args.status ?? 'running', status: args.status ?? 'running',
prompt: 'Recover the worker job', prompt: 'Recover the worker job',
runtime: 'opencode', runtime: 'opencode',
workspaceStatus: 'active', workspaceStatus: args.workspaceStatus ?? 'active',
baseBranch: 'main', baseBranch: 'main',
workBranch: 'spoon/recover-worker-job', workBranch: 'spoon/recover-worker-job',
forkOwner: 'team', forkOwner: 'team',
@@ -85,6 +92,9 @@ const seedJob = async (
...(args.lastHeartbeatAt === undefined ...(args.lastHeartbeatAt === undefined
? {} ? {}
: { lastHeartbeatAt: args.lastHeartbeatAt }), : { lastHeartbeatAt: args.lastHeartbeatAt }),
...(args.completedAt === undefined
? {}
: { completedAt: args.completedAt }),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@@ -126,6 +136,31 @@ describe('recoverStaleJobs', () => {
expect(state.thread?.resolvedAt).toBeTypeOf('number'); 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 () => { test('leaves a running job with a fresh heartbeat untouched', async () => {
const t = convexTest(schema, modules); const t = convexTest(schema, modules);
const ids = await seedJob(t, { lastHeartbeatAt: Date.now() }); const ids = await seedJob(t, { lastHeartbeatAt: Date.now() });