From 00f96a126e3356fde8d50322295c35f0166b1ad1 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 10 Jul 2026 17:27:07 -0400 Subject: [PATCH] fix(agent-jobs): close maintenance jobs after decision --- apps/agent-worker/src/worker.ts | 4 + packages/backend/convex/agentJobs.ts | 6 + .../tests/unit/maintenance-decision.test.ts | 112 ++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 packages/backend/tests/unit/maintenance-decision.test.ts diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index a5f6f15..5426d6f 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -1765,6 +1765,10 @@ export const sendWorkspaceMessage = async ( contentType: 'application/json', }); await applyMaintenanceDecision(claim.job._id, decision); + await stopWorkspace(claim.job._id).catch((error: unknown) => { + console.error(error); + }); + return; } else { await updateStatus(claim.job._id, 'changes_ready', { summary: diff --git a/packages/backend/convex/agentJobs.ts b/packages/backend/convex/agentJobs.ts index 46537b1..f820f02 100644 --- a/packages/backend/convex/agentJobs.ts +++ b/packages/backend/convex/agentJobs.ts @@ -1540,6 +1540,12 @@ export const applyMaintenanceDecision = mutation({ } if (!job.threadId) return { success: true }; const now = Date.now(); + await ctx.db.patch(args.jobId, { + workspaceStatus: 'stopped', + summary: args.summary, + completedAt: job.completedAt ?? now, + updatedAt: now, + }); const outcome = args.decision === 'sync' ? 'sync_recommended' diff --git a/packages/backend/tests/unit/maintenance-decision.test.ts b/packages/backend/tests/unit/maintenance-decision.test.ts new file mode 100644 index 0000000..c8a7893 --- /dev/null +++ b/packages/backend/tests/unit/maintenance-decision.test.ts @@ -0,0 +1,112 @@ +import { convexTest } from 'convex-test'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; + +import { api } from '../../convex/_generated/api.js'; +import schema from '../../convex/schema'; + +const modules = import.meta.glob('../../convex/**/*.*s'); + +beforeAll(() => { + process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; +}); + +afterAll(() => { + delete process.env.SPOON_WORKER_TOKEN; +}); + +describe('applyMaintenanceDecision', () => { + test('resolves a low-risk sync and stops its maintenance job', async () => { + const t = convexTest(schema, modules); + const { jobId, threadId } = await t.mutation(async (ctx) => { + const now = Date.now(); + const ownerId = await ctx.db.insert('users', { + email: 'maintenance@example.com', + name: 'Maintenance Reviewer', + }); + const spoonId = await ctx.db.insert('spoons', { + ownerId, + name: 'Maintenance Spoon', + provider: 'gitea', + upstreamOwner: 'upstream', + upstreamRepo: 'maintenance', + upstreamDefaultBranch: 'main', + upstreamUrl: 'https://git.example.com/upstream/maintenance', + forkOwner: 'team', + forkRepo: 'maintenance-spoon', + forkUrl: 'https://git.example.com/team/maintenance-spoon', + visibility: 'private', + maintenanceMode: 'watch', + syncCadence: 'daily', + productionRefStrategy: 'default_branch', + status: 'active', + createdAt: now, + updatedAt: now, + }); + const requestId = await ctx.db.insert('agentRequests', { + spoonId, + ownerId, + prompt: 'Review upstream maintenance changes', + requestType: 'upstream_review', + status: 'running', + createdAt: now, + updatedAt: now, + }); + const threadId = await ctx.db.insert('threads', { + ownerId, + spoonId, + title: 'Upstream maintenance review', + source: 'upstream_update', + status: 'running', + priority: 'normal', + relatedAgentRequestId: requestId, + createdAt: now, + updatedAt: now, + }); + const jobId = await ctx.db.insert('agentJobs', { + spoonId, + ownerId, + agentRequestId: requestId, + threadId, + jobType: 'maintenance_review', + status: 'running', + workspaceStatus: 'active', + prompt: 'Review upstream maintenance changes', + runtime: 'opencode', + baseBranch: 'main', + workBranch: 'spoon/maintenance-review', + forkOwner: 'team', + forkRepo: 'maintenance-spoon', + forkUrl: 'https://git.example.com/team/maintenance-spoon', + upstreamOwner: 'upstream', + upstreamRepo: 'maintenance', + selectedSecretIds: [], + model: '', + reasoningEffort: 'medium', + claimedBy: 'w1', + createdAt: now, + updatedAt: now, + }); + return { jobId, threadId }; + }); + + await t.mutation(api.agentJobs.applyMaintenanceDecision, { + workerToken: 'test-worker-token', + workerId: 'w1', + jobId, + decision: 'sync', + risk: 'low', + summary: 'Safe to sync automatically.', + ignoredCommitShas: [], + recommendedAction: 'Sync the upstream changes.', + requiresUserApproval: false, + }); + + const { job, thread } = await t.run(async (ctx) => ({ + job: await ctx.db.get(jobId), + thread: await ctx.db.get(threadId), + })); + expect(thread?.status).toBe('resolved'); + expect(job?.workspaceStatus).toBe('stopped'); + expect(job?.completedAt).toEqual(expect.any(Number)); + }); +});