@@ -12,7 +12,7 @@ import { commandCheckDeps } from '../runner/checks.ts';
1212import { RunnerCoordinator } from '../runner/coordinator.ts' ;
1313import type { InvocationHandle , InvocationResult } from '../agents/contract.ts' ;
1414import type { TaskWorkspace , WorkspaceRef } from '../runner/execution.ts' ;
15- import { RebaseResourcesUnsettled } from '../runner/rebase.ts' ;
15+ import { MIN_REBASE_TIMEOUT_MS , RebaseResourcesUnsettled } from '../runner/rebase.ts' ;
1616
1717const roots : string [ ] = [ ] , services : ReviewService [ ] = [ ] ;
1818vi . setConfig ( { testTimeout : 15_000 } ) ;
@@ -49,6 +49,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options:
4949 commandWaitsForCancellation ?: boolean ;
5050 releaseFails ?: boolean ;
5151 unsettledRebase ?: boolean ;
52+ rebaseFailure ?: Error ;
5253 runnerCommitted ?: boolean ;
5354 operationTimeoutMs ?: number ;
5455 authorize ?: ( signal : AbortSignal ) => Promise < PreMergeAuthorization > ;
@@ -85,17 +86,22 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options:
8586 view = service . load ( ) ; view = service . act ( { action : 'approve' , item : 'P1' , token : view . token } ) ;
8687 let coordinator ! : PreMergeCoordinator , rebaseRuns = 0 ;
8788 let rebaseAborts = 0 ;
88- const rebaser = { run : async ( input : { attemptId : string ; signal : AbortSignal } ) => {
89+ const rebaseBudgets : Array < number | undefined > = [ ] , abortBudgets : Array < number | undefined > = [ ] ;
90+ const rebaser = { run : async ( input : { attemptId : string ; signal : AbortSignal ; timeoutMs ?: number } ) => {
8991 rebaseRuns ++ ;
92+ rebaseBudgets . push ( input . timeoutMs ) ;
9093 options . duringRebase ?.( service , coordinator ) ;
9194 input . signal . throwIfAborted ( ) ;
9295 if ( options . unsettledRebase ) throw new RebaseResourcesUnsettled ( 'Conflict resources remain owned.' ) ;
96+ if ( options . rebaseFailure ) throw options . rebaseFailure ;
9397 const planKey = service . store . getTask ( identity ) . planKey ;
9498 service . store . prepareRebaseResult ( planKey , input . attemptId , rebased , [ rebased ] ) ;
9599 service . store . completeRebaseResult ( planKey , input . attemptId ) ;
96100 return { oldHead : head , base : onto , head : rebased ,
97101 mappings : [ { oldSha : head , newSha : rebased } ] , resolvedConflicts : [ ] } ;
98- } , abort : async ( ) => { rebaseAborts ++ ; } } as never ;
102+ } , abort : async ( _attemptId : string , _head ?: string , _state ?: string , timeoutMs ?: number ) => {
103+ rebaseAborts ++ ; abortBudgets . push ( timeoutMs ) ;
104+ } } as never ;
99105 const checkedHeads : string [ ] = [ ] ;
100106 const workspace : TaskWorkspace = {
101107 async materialize ( attempt , checkedHead ) {
@@ -155,7 +161,7 @@ async function rebaseFixture(rebasedText: string, commandExit?: number, options:
155161 const result = await coordinator . start ( { stateVersion : task . stateVersion ,
156162 reviewVersion : view . expected . reviewVersion ! , snapshotId : view . snapshot . id , base : view . snapshot . base , head : view . snapshot . head } ) ;
157163 return { service, coordinator, runner, result, rebased, checkedHeads, cancelReasons,
158- rebaseRuns : ( ) => rebaseRuns , rebaseAborts : ( ) => rebaseAborts } ;
164+ rebaseRuns : ( ) => rebaseRuns , rebaseAborts : ( ) => rebaseAborts , rebaseBudgets , abortBudgets } ;
159165}
160166
161167it ( 'preserves an approval when a moved base leaves its attributed fingerprint unchanged' , async ( ) => {
@@ -205,7 +211,8 @@ it('revalidates authorization before starting the delayed local rebase', async (
205211 expect ( fixture . result ) . toMatchObject ( { state : 'failed' , reason : 'Issue trust was revoked.' } ) ;
206212 expect ( calls ) . toEqual ( [ 'read' , 'validate' ] ) ;
207213 expect ( fixture . rebaseRuns ( ) ) . toBe ( 0 ) ;
208- expect ( fixture . rebaseAborts ( ) ) . toBe ( 1 ) ;
214+ // Refused authorization precedes the first durable rebase claim, so there is no cleanup lifecycle to start.
215+ expect ( fixture . rebaseAborts ( ) ) . toBe ( 0 ) ;
209216 expect ( fixture . service . store . getTask ( fixture . service . config . identity ) . rebaseInProgress ) . toBeNull ( ) ;
210217 expect ( fixture . coordinator . last ) . toMatchObject ( { state : 'failed' , stale : false } ) ;
211218 await fixture . coordinator . close ( ) ; await fixture . runner . close ( ) ;
@@ -365,7 +372,8 @@ it('preserves shutdown as the reason that stops an active command check', async
365372
366373it ( 'stops an active command check when the operation-wide deadline expires' , async ( ) => {
367374 const fixture = await rebaseFixture ( 'feature\n' , 0 ,
368- { commandWaitsForCancellation : true , operationTimeoutMs : 3_000 , reserves : { processMs : 1_000 , commandMs : 200 } } ) ;
375+ { commandWaitsForCancellation : true , operationTimeoutMs : MIN_REBASE_TIMEOUT_MS + 3_000 ,
376+ reserves : { processMs : MIN_REBASE_TIMEOUT_MS + 1_000 , commandMs : 200 } } ) ;
369377 expect ( fixture . result ) . toMatchObject ( { state : 'failed' , reason : 'Pre-merge preparation deadline exceeded.' } ) ;
370378 const check = fixture . service . store . getAttempts ( fixture . service . config . identity ) . find ( attempt => attempt . kind === 'check' ) ;
371379 expect ( check ) . toMatchObject ( { state : 'failed' , firstReason : null , stopReason : 'timeout' , diagnostic : 'Timed out.' } ) ;
@@ -523,6 +531,20 @@ it('aborts remote work early enough to reserve bounded subprocess settlement', a
523531 await coordinator . close ( ) ;
524532} ) ;
525533
534+ it ( 'budgets live rebase work and its follow-up cleanup inside the preparation deadline' , async ( ) => {
535+ const operationTimeoutMs = 60_000 ;
536+ const fixture = await rebaseFixture ( 'feature\n' , undefined ,
537+ { operationTimeoutMs, rebaseFailure : new Error ( 'rebase failed' ) } ) ;
538+ expect ( fixture . result ) . toMatchObject ( { state : 'failed' , reason : 'rebase failed' } ) ;
539+ expect ( fixture . rebaseBudgets ) . toHaveLength ( 1 ) ;
540+ expect ( fixture . abortBudgets ) . toHaveLength ( 1 ) ;
541+ for ( const budget of [ ...fixture . rebaseBudgets , ...fixture . abortBudgets ] ) {
542+ expect ( budget ) . toBeTypeOf ( 'number' ) ;
543+ expect ( budget ) . toBeGreaterThan ( 0 ) ;
544+ expect ( budget ) . toBeLessThanOrEqual ( operationTimeoutMs ) ;
545+ }
546+ } ) ;
547+
526548it ( 'makes a preparation action resendable when its terminal storage write fails' , async ( ) => {
527549 const root = mkdtempSync ( join ( tmpdir ( ) , 'codeboost-pre-merge-settlement-failure-' ) ) ; roots . push ( root ) ;
528550 const config = createDemo ( join ( root , 'demo' ) ) , service = new ReviewService ( config ) ; services . push ( service ) ;
@@ -582,7 +604,7 @@ it('settles a failed preparation even when the fallback snapshot read would fail
582604} ) ;
583605
584606it ( 'threads the remaining operation budget through every synchronous review reload' , async ( ) => {
585- const operationTimeoutMs = 30_000 , processMs = 10_000 ;
607+ const operationTimeoutMs = MIN_REBASE_TIMEOUT_MS + 10_000 , processMs = 10_000 ;
586608 const fixture = await rebaseFixture ( 'feature\n' , undefined , { operationTimeoutMs, reserves : { processMs, commandMs : 0 } } ) ;
587609 const view = fixture . service . load ( ) , task = fixture . service . store . getTask ( fixture . service . config . identity ) ;
588610 const original = fixture . service . load . bind ( fixture . service ) ; const budgets : number [ ] = [ ] ;
0 commit comments