@@ -3,6 +3,7 @@ import * as planState from '../../src/lib/plan-state';
33import * as orchestrator from '../../src/lib/orchestrator' ;
44import * as config from '../../src/lib/config' ;
55import * as integration from '../../src/lib/integration' ;
6+ import * as mergeTrain from '../../src/lib/merge-train' ;
67
78const { 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