Skip to content

Commit 84e5a7f

Browse files
committed
fix: prevent savePlan from overwriting job status set by accept/retry paths
The accept and retry paths in mc_plan_approve called updatePlanJob() to set the job to ready_to_merge, then immediately called savePlan() with the stale in-memory plan object — overwriting the update and leaving the job stuck in failed state. Fix by updating the in-memory plan.jobs directly before savePlan(), removing the now-unnecessary updatePlanJob import. Also adds checkpointContext assertions to the orchestrator touchSet test.
1 parent 0bf88f3 commit 84e5a7f

3 files changed

Lines changed: 234 additions & 9 deletions

File tree

src/tools/plan-approve.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { tool, type ToolDefinition } from '@opencode-ai/plugin';
2-
import { loadPlan, savePlan, updatePlanJob } from '../lib/plan-state';
2+
import { loadPlan, savePlan } from '../lib/plan-state';
33
import { Orchestrator } from '../lib/orchestrator';
44
import { getSharedMonitor, getSharedNotifyCallback, setSharedOrchestrator } from '../lib/orchestrator-singleton';
55
import type { CheckpointType } from '../lib/plan-types';
@@ -105,7 +105,9 @@ export const mc_plan_approve: ToolDefinition = tool({
105105
}
106106
}
107107

108-
await updatePlanJob(plan.id, args.retry, { status: 'ready_to_merge', error: undefined });
108+
const retryJob = plan.jobs.find(j => j.name === args.retry)!;
109+
retryJob.status = 'ready_to_merge';
110+
retryJob.error = undefined;
109111

110112
plan.status = 'running';
111113
plan.checkpoint = null;
@@ -131,7 +133,8 @@ export const mc_plan_approve: ToolDefinition = tool({
131133
if (ctx?.failureKind === 'touchset') {
132134
const job = plan.jobs.find(j => j.name === ctx.jobName);
133135
if (job && job.status === 'failed') {
134-
await updatePlanJob(plan.id, ctx.jobName, { status: 'ready_to_merge', error: undefined });
136+
job.status = 'ready_to_merge';
137+
job.error = undefined;
135138
}
136139
}
137140

tests/lib/orchestrator.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,14 @@ describe('orchestrator', () => {
636636
);
637637
expect(planState?.status).toBe('paused');
638638
expect(planState?.checkpoint).toBe('on_error');
639+
expect(planState?.checkpointContext).toEqual(
640+
expect.objectContaining({
641+
jobName: 'touch-violator',
642+
failureKind: 'touchset',
643+
touchSetViolations: expect.arrayContaining(['README.md']),
644+
touchSetPatterns: expect.arrayContaining(['src/**']),
645+
}),
646+
);
639647
});
640648

641649
it('should allow transition to ready_to_merge when touchSet is satisfied', async () => {

tests/tools/plan-approve.test.ts

Lines changed: 220 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as planState from '../../src/lib/plan-state';
33
import * as orchestrator from '../../src/lib/orchestrator';
44
import * as config from '../../src/lib/config';
55
import * as integration from '../../src/lib/integration';
6+
import * as mergeTrain from '../../src/lib/merge-train';
67

78
const { mc_plan_approve } = await import('../../src/tools/plan-approve');
89

@@ -65,6 +66,12 @@ describe('mc_plan_approve', () => {
6566
});
6667

6768
describe('retry validation', () => {
69+
it('should reject when both retry and relaunch are provided', async () => {
70+
expect(
71+
mc_plan_approve.execute({ checkpoint: 'on_error', retry: 'bad-job', relaunch: 'bad-job' }, mockContext),
72+
).rejects.toThrow('Cannot specify both "retry" and "relaunch"');
73+
});
74+
6875
it('should reset a failed job to ready_to_merge when retry is provided', async () => {
6976
spyOn(planState, 'loadPlan').mockResolvedValue({
7077
id: 'plan-1',
@@ -82,20 +89,224 @@ describe('mc_plan_approve', () => {
8289
});
8390

8491
const mockSavePlan = spyOn(planState, 'savePlan').mockResolvedValue(undefined);
85-
const mockUpdatePlanJob = spyOn(planState, 'updatePlanJob').mockResolvedValue(undefined);
8692
const mockResumePlan = mock().mockResolvedValue(undefined);
8793
spyOn(orchestrator.Orchestrator.prototype, 'resumePlan').mockImplementation(mockResumePlan);
8894
spyOn(orchestrator.Orchestrator.prototype, 'setPlanModelSnapshot').mockImplementation(() => {});
8995

9096
const result = await mc_plan_approve.execute({ checkpoint: 'on_error', retry: 'bad-job' }, mockContext);
9197

92-
expect(mockUpdatePlanJob).toHaveBeenCalledWith('plan-1', 'bad-job', { status: 'ready_to_merge', error: undefined });
98+
expect(mockSavePlan).toHaveBeenCalledWith(expect.objectContaining({
99+
status: 'running',
100+
jobs: expect.arrayContaining([
101+
expect.objectContaining({ name: 'bad-job', status: 'ready_to_merge' }),
102+
]),
103+
}));
93104
expect(result).toContain('bad-job');
94105
expect(result).toContain('ready_to_merge');
95-
expect(mockSavePlan).toHaveBeenCalled();
96106
expect(mockResumePlan).toHaveBeenCalled();
97107
});
98108

109+
it('should accept touchSet violations and move failed job to ready_to_merge', async () => {
110+
spyOn(planState, 'loadPlan').mockResolvedValue({
111+
id: 'plan-1',
112+
name: 'TouchSet Plan',
113+
mode: 'supervisor',
114+
status: 'paused',
115+
checkpoint: 'on_error',
116+
checkpointContext: {
117+
jobName: 'touch-job',
118+
failureKind: 'touchset',
119+
touchSetViolations: ['README.md'],
120+
touchSetPatterns: ['src/**'],
121+
},
122+
jobs: [
123+
{ id: 'j1', name: 'touch-job', prompt: 'fix files', status: 'failed', error: 'touchSet violation' },
124+
],
125+
integrationBranch: 'mc/integration/plan-1',
126+
baseCommit: 'abc123',
127+
createdAt: new Date().toISOString(),
128+
});
129+
130+
const mockSavePlan = spyOn(planState, 'savePlan').mockResolvedValue(undefined);
131+
const mockResumePlan = mock().mockResolvedValue(undefined);
132+
spyOn(orchestrator.Orchestrator.prototype, 'resumePlan').mockImplementation(mockResumePlan);
133+
spyOn(orchestrator.Orchestrator.prototype, 'setPlanModelSnapshot').mockImplementation(() => {});
134+
135+
const result = await mc_plan_approve.execute({ checkpoint: 'on_error' }, mockContext);
136+
137+
expect(mockSavePlan).toHaveBeenCalledWith(expect.objectContaining({
138+
status: 'running',
139+
checkpoint: null,
140+
checkpointContext: null,
141+
jobs: expect.arrayContaining([
142+
expect.objectContaining({ name: 'touch-job', status: 'ready_to_merge' }),
143+
]),
144+
}));
145+
expect(mockResumePlan).toHaveBeenCalled();
146+
expect(result).toContain('TouchSet violations for job "touch-job" accepted');
147+
});
148+
149+
it('should relaunch touchSet-failed job for correction', async () => {
150+
spyOn(planState, 'loadPlan').mockResolvedValue({
151+
id: 'plan-1',
152+
name: 'TouchSet Relaunch Plan',
153+
mode: 'supervisor',
154+
status: 'paused',
155+
checkpoint: 'on_error',
156+
checkpointContext: {
157+
jobName: 'touch-job',
158+
failureKind: 'touchset',
159+
touchSetViolations: ['README.md'],
160+
touchSetPatterns: ['src/**'],
161+
},
162+
jobs: [
163+
{
164+
id: 'j1',
165+
name: 'touch-job',
166+
prompt: 'fix files',
167+
status: 'failed',
168+
touchSet: ['src/**'],
169+
branch: 'mc/plan/plan-1/touch-job',
170+
},
171+
],
172+
integrationBranch: 'mc/integration/plan-1',
173+
baseCommit: 'abc123',
174+
createdAt: new Date().toISOString(),
175+
});
176+
177+
const mockSavePlan = spyOn(planState, 'savePlan').mockResolvedValue(undefined);
178+
const mockResumePlan = mock().mockResolvedValue(undefined);
179+
const relaunchSpy = mock().mockResolvedValue(undefined);
180+
spyOn(orchestrator.Orchestrator.prototype, 'resumePlan').mockImplementation(mockResumePlan);
181+
spyOn(orchestrator.Orchestrator.prototype, 'relaunchJobForCorrection').mockImplementation(relaunchSpy);
182+
spyOn(orchestrator.Orchestrator.prototype, 'setPlanModelSnapshot').mockImplementation(() => {});
183+
184+
const result = await mc_plan_approve.execute({ checkpoint: 'on_error', relaunch: 'touch-job' }, mockContext);
185+
186+
expect(mockSavePlan).toHaveBeenCalledWith(expect.objectContaining({
187+
status: 'running',
188+
checkpoint: null,
189+
checkpointContext: null,
190+
}));
191+
expect(relaunchSpy).toHaveBeenCalledWith('touch-job', ['README.md'], ['src/**']);
192+
expect(mockResumePlan).toHaveBeenCalled();
193+
expect(result).toContain('relaunched with correction prompt');
194+
});
195+
196+
it('should re-validate touchSet on retry before proceeding', async () => {
197+
spyOn(planState, 'loadPlan').mockResolvedValue({
198+
id: 'plan-1',
199+
name: 'TouchSet Retry Plan',
200+
mode: 'supervisor',
201+
status: 'paused',
202+
checkpoint: 'on_error',
203+
checkpointContext: {
204+
jobName: 'touch-job',
205+
failureKind: 'touchset',
206+
touchSetViolations: ['README.md'],
207+
touchSetPatterns: ['src/**'],
208+
},
209+
jobs: [
210+
{
211+
id: 'j1',
212+
name: 'touch-job',
213+
prompt: 'fix files',
214+
status: 'failed',
215+
touchSet: ['src/**'],
216+
branch: 'mc/plan/plan-1/touch-job',
217+
},
218+
],
219+
integrationBranch: 'mc/integration/plan-1',
220+
baseCommit: 'abc123',
221+
createdAt: new Date().toISOString(),
222+
});
223+
224+
const validateSpy = spyOn(mergeTrain, 'validateTouchSet').mockResolvedValue({
225+
valid: true,
226+
changedFiles: ['src/main.ts'],
227+
});
228+
const mockSavePlan = spyOn(planState, 'savePlan').mockResolvedValue(undefined);
229+
const mockResumePlan = mock().mockResolvedValue(undefined);
230+
spyOn(orchestrator.Orchestrator.prototype, 'resumePlan').mockImplementation(mockResumePlan);
231+
spyOn(orchestrator.Orchestrator.prototype, 'setPlanModelSnapshot').mockImplementation(() => {});
232+
233+
const result = await mc_plan_approve.execute({ checkpoint: 'on_error', retry: 'touch-job' }, mockContext);
234+
235+
expect(validateSpy).toHaveBeenCalledWith('mc/plan/plan-1/touch-job', 'mc/integration/plan-1', ['src/**']);
236+
expect(mockSavePlan).toHaveBeenCalledWith(expect.objectContaining({
237+
status: 'running',
238+
jobs: expect.arrayContaining([
239+
expect.objectContaining({ name: 'touch-job', status: 'ready_to_merge' }),
240+
]),
241+
}));
242+
expect(mockResumePlan).toHaveBeenCalled();
243+
expect(result).toContain('touch-job');
244+
expect(result).toContain('ready_to_merge');
245+
});
246+
247+
it('should reject retry when touchSet remains violated after manual fix', async () => {
248+
spyOn(planState, 'loadPlan').mockResolvedValue({
249+
id: 'plan-1',
250+
name: 'TouchSet Retry Plan',
251+
mode: 'supervisor',
252+
status: 'paused',
253+
checkpoint: 'on_error',
254+
checkpointContext: {
255+
jobName: 'touch-job',
256+
failureKind: 'touchset',
257+
touchSetViolations: ['README.md'],
258+
touchSetPatterns: ['src/**'],
259+
},
260+
jobs: [
261+
{
262+
id: 'j1',
263+
name: 'touch-job',
264+
prompt: 'fix files',
265+
status: 'failed',
266+
touchSet: ['src/**'],
267+
branch: 'mc/plan/plan-1/touch-job',
268+
},
269+
],
270+
integrationBranch: 'mc/integration/plan-1',
271+
baseCommit: 'abc123',
272+
createdAt: new Date().toISOString(),
273+
});
274+
275+
spyOn(mergeTrain, 'validateTouchSet').mockResolvedValue({
276+
valid: false,
277+
violations: ['README.md'],
278+
changedFiles: ['src/main.ts', 'README.md'],
279+
});
280+
281+
expect(
282+
mc_plan_approve.execute({ checkpoint: 'on_error', retry: 'touch-job' }, mockContext),
283+
).rejects.toThrow('still has touchSet violations after manual fix');
284+
});
285+
286+
it('should reject relaunch when failure is not touchSet-related', async () => {
287+
spyOn(planState, 'loadPlan').mockResolvedValue({
288+
id: 'plan-1',
289+
name: 'Non TouchSet Plan',
290+
mode: 'supervisor',
291+
status: 'paused',
292+
checkpoint: 'on_error',
293+
checkpointContext: {
294+
jobName: 'bad-job',
295+
failureKind: 'test_failure',
296+
},
297+
jobs: [
298+
{ id: 'j1', name: 'bad-job', prompt: 'fix', status: 'failed', error: 'tests failed' },
299+
],
300+
integrationBranch: 'mc/integration/plan-1',
301+
baseCommit: 'abc123',
302+
createdAt: new Date().toISOString(),
303+
});
304+
305+
expect(
306+
mc_plan_approve.execute({ checkpoint: 'on_error', relaunch: 'bad-job' }, mockContext),
307+
).rejects.toThrow('was not failed due to a touchSet violation');
308+
});
309+
99310
it('should allow retry of needs_rebase jobs', async () => {
100311
spyOn(planState, 'loadPlan').mockResolvedValue({
101312
id: 'plan-1',
@@ -111,18 +322,21 @@ describe('mc_plan_approve', () => {
111322
createdAt: new Date().toISOString(),
112323
});
113324

114-
const mockUpdatePlanJob = spyOn(planState, 'updatePlanJob').mockResolvedValue(undefined);
115325
const mockSavePlan = spyOn(planState, 'savePlan').mockResolvedValue(undefined);
116326
const mockResumePlan = mock().mockResolvedValue(undefined);
117327
spyOn(orchestrator.Orchestrator.prototype, 'resumePlan').mockImplementation(mockResumePlan);
118328
spyOn(orchestrator.Orchestrator.prototype, 'setPlanModelSnapshot').mockImplementation(() => {});
119329

120330
const result = await mc_plan_approve.execute({ checkpoint: 'on_error', retry: 'conflicting-job' }, mockContext);
121331

122-
expect(mockUpdatePlanJob).toHaveBeenCalledWith('plan-1', 'conflicting-job', { status: 'ready_to_merge', error: undefined });
332+
expect(mockSavePlan).toHaveBeenCalledWith(expect.objectContaining({
333+
status: 'running',
334+
jobs: expect.arrayContaining([
335+
expect.objectContaining({ name: 'conflicting-job', status: 'ready_to_merge' }),
336+
]),
337+
}));
123338
expect(result).toContain('Checkpoint');
124339
expect(result).toContain('ready_to_merge');
125-
expect(mockSavePlan).toHaveBeenCalled();
126340
expect(mockResumePlan).toHaveBeenCalled();
127341
});
128342

0 commit comments

Comments
 (0)