Skip to content

Commit 3458a84

Browse files
committed
Bound final merge and rebase admission
1 parent f94501b commit 3458a84

5 files changed

Lines changed: 87 additions & 23 deletions

File tree

‎runner/merge.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -410,10 +410,18 @@ export class MergeCoordinator {
410410
commandStatus = authorized.status;
411411
}
412412
if (commandStatus.remote.mergeQueue) {
413-
// The stored cursor is the final external read: events produced by another actor during authorization or the
414-
// status checks precede this boundary and therefore cannot be adopted as this attempt's queue lifecycle.
413+
// Events produced by another actor during authorization or the preceding status checks come before this
414+
// boundary and cannot be adopted as this attempt's lifecycle. The following fresh status read then validates
415+
// the PR identity and queue mode that this cursor will be stored with.
415416
queueWatermark = await (this.gateway as QueueGateway).queueWatermark(commandStatus.remote.head,
416417
{ signal, timeoutMs: 6_000, pullRequest: commandStatus.remote.pullRequest });
418+
const boundary = await this.#statusForMerge(view, signal);
419+
if (boundary.status.remote.base !== commandStatus.remote.base || boundary.status.remote.head !== commandStatus.remote.head
420+
|| boundary.status.remote.mergeQueue !== commandStatus.remote.mergeQueue || !samePullRequest(boundary))
421+
throw new Error('The pull request changed after queue correlation. Refresh before merging.');
422+
if (!boundary.status.ready)
423+
throw new Error(`Merge requirements changed after queue correlation. ${boundary.status.blockers[0]!.message}`);
424+
commandStatus = boundary.status;
417425
}
418426
// The external authorization read completed before the final PR inspection and queue boundary. Re-read local
419427
// trust synchronously after those awaits so neither remote identity nor local authorization can race admission.

‎runner/pre-merge.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { DEFAULT_PROCESS_SETTLEMENT_MS } from '../agents/process-group.ts';
33
import type { PlanIdentity } from '../core/identity.ts';
44
import type { RunnerCoordinator } from './coordinator.ts';
55
import { GuardRefusal, MERGEABLE_STATUSES, settleWith, type ShutdownCapability } from './lifecycle.ts';
6-
import { RebaseResourcesUnsettled, type GitRebaser } from './rebase.ts';
6+
import { MAX_REBASE_TIMEOUT_MS, MIN_REBASE_CLEANUP_TIMEOUT_MS, MIN_REBASE_TIMEOUT_MS,
7+
RebaseResourcesUnsettled, type GitRebaser } from './rebase.ts';
78
import type { ReviewService } from './review.ts';
89
import type { PreMergeReadiness, RebaseMarker } from './store.ts';
910

@@ -189,10 +190,10 @@ export class PreMergeCoordinator {
189190
}
190191
return null;
191192
}
192-
async #cleanupRebase(identity: PlanIdentity, marker: RebaseMarker): Promise<void> {
193+
async #cleanupRebase(identity: PlanIdentity, marker: RebaseMarker, timeoutMs: number): Promise<void> {
193194
const current = this.service.store.getTask(identity).rebaseInProgress as RebaseMarker | null;
194195
if (current?.attemptId !== marker.attemptId) throw new GuardRefusal('This rebase attempt no longer owns cleanup.');
195-
await this.rebaser.abort(marker.attemptId, current.resultHead ?? undefined, current.resultState);
196+
await this.rebaser.abort(marker.attemptId, current.resultHead ?? undefined, current.resultState, timeoutMs);
196197
if (!this.settle(() => this.service.store.abortRebase(this.service.store.getTask(identity).planKey, marker.attemptId)))
197198
throw new GuardRefusal('This rebase attempt no longer owns its durable marker.');
198199
}
@@ -217,6 +218,13 @@ export class PreMergeCoordinator {
217218
{ code: 'ETIMEDOUT' });
218219
return value;
219220
};
221+
const rebaseBudget = (cleanupOnly = false) => {
222+
const value = Math.min(MAX_REBASE_TIMEOUT_MS, remaining());
223+
if (value < (cleanupOnly ? MIN_REBASE_CLEANUP_TIMEOUT_MS : MIN_REBASE_TIMEOUT_MS))
224+
throw Object.assign(new Error(`Pre-merge preparation deadline exceeded before rebase ${cleanupOnly ? 'cleanup' : 'work and cleanup'} could be reserved.`),
225+
{ code: 'ETIMEDOUT' });
226+
return value;
227+
};
220228
const authorize = async () => {
221229
if (!this.authorize) return;
222230
const authorization = await this.authorize(signal);
@@ -268,19 +276,21 @@ export class PreMergeCoordinator {
268276
const reviewed = { revision: view.expected.revision, snapshotId: view.expected.snapshotId,
269277
reviewVersion: view.expected.reviewVersion! };
270278
const oldBase = view.snapshot.base, oldHead = view.snapshot.head;
279+
await authorize();
280+
signal.throwIfAborted();
281+
const rebaseTimeoutMs = rebaseBudget();
271282
const marker = this.service.store.beginRebase(identity, reviewed, task.stateVersion,
272283
{ oldBase: view.snapshot.base, oldHead: view.snapshot.head, oldHistory: old, onto: initial.base });
273284
try {
274-
await authorize();
275285
const result = await this.rebaser.run({ attemptId: marker.attemptId, oldBase,
276286
oldHead, oldHistory: old, onto: initial.base,
277-
ledger: this.service.store.getLedger(identity), signal });
287+
ledger: this.service.store.getLedger(identity), signal, timeoutMs: rebaseTimeoutMs });
278288
signal.throwIfAborted();
279289
this.service.store.finishRebase(identity, reviewed, task.stateVersion, marker.attemptId,
280290
result.base, result.head, result.mappings);
281291
} catch (error) {
282292
if (error instanceof RebaseResourcesUnsettled) throw error;
283-
try { await this.#cleanupRebase(identity, marker); }
293+
try { await this.#cleanupRebase(identity, marker, rebaseBudget(true)); }
284294
catch (cleanup) { throw new AggregateError([error, cleanup], error instanceof Error ? error.message : 'Rebase failed.', { cause: error }); }
285295
// beginRebase and successful cleanup both advance task state. Bind the actionable failure to that settled
286296
// state without rebuilding history; if a read itself fails, retaining the older binding fails closed as stale.

‎runner/rebase.ts‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ const MAX_REBASE_COMMITS = 500;
1414
const PROCESS_SETTLEMENT_RESERVE_MS = 13_000;
1515
// Cleanup receives an operation-wide 30 s: 13 s to settle a cleanup process group and 17 s for Git and follow-up calls.
1616
const CLEANUP_RESERVE_MS = 30_000;
17-
const MIN_REBASE_TIMEOUT_MS = CLEANUP_RESERVE_MS + PROCESS_SETTLEMENT_RESERVE_MS + 1;
17+
export const MIN_REBASE_CLEANUP_TIMEOUT_MS = PROCESS_SETTLEMENT_RESERVE_MS + 1;
18+
export const MIN_REBASE_TIMEOUT_MS = CLEANUP_RESERVE_MS + PROCESS_SETTLEMENT_RESERVE_MS + 1;
19+
export const MAX_REBASE_TIMEOUT_MS = 120_000;
1820

1921
export interface RebaseMapping { oldSha: string; newSha: string }
2022
export interface RebaseConflictInput {
@@ -101,7 +103,7 @@ export class GitRebaser {
101103
if (!options.committer.name || !options.committer.email || /[<>\n\r\0]/.test(options.committer.name + options.committer.email))
102104
throw new Error('A valid rebase committer is required.');
103105
if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) ||
104-
options.timeoutMs < MIN_REBASE_TIMEOUT_MS || options.timeoutMs > 120_000))
106+
options.timeoutMs < MIN_REBASE_TIMEOUT_MS || options.timeoutMs > MAX_REBASE_TIMEOUT_MS))
105107
throw new Error('Invalid rebase deadline.');
106108
const git = configuredGit();
107109
this.#options = options;
@@ -111,7 +113,8 @@ export class GitRebaser {
111113
}
112114

113115
async run(input: { attemptId: string; oldBase: string; oldHead: string; oldHistory: readonly string[]; onto: string;
114-
ledger?: readonly { sha: string; owner: string | null; origin: 'owned' | 'foreign' }[]; signal?: AbortSignal }): Promise<RebaseResult> {
116+
ledger?: readonly { sha: string; owner: string | null; origin: 'owned' | 'foreign' }[]; signal?: AbortSignal;
117+
timeoutMs?: number }): Promise<RebaseResult> {
115118
const { attemptId, oldBase, oldHead, oldHistory, onto, signal } = input;
116119
rebaseRef(attemptId);
117120
for (const value of [oldBase, oldHead, onto]) if (!COMMIT_ID.test(value)) throw new Error('A full commit ID is required for rebasing.');
@@ -123,7 +126,7 @@ export class GitRebaser {
123126
ledger.set(entry.sha, { owner: entry.owner, origin: entry.origin });
124127
}
125128
signal?.throwIfAborted();
126-
const scope = this.#scope(attemptId, signal);
129+
const scope = this.#scope(attemptId, signal, input.timeoutMs);
127130
const ref = rebaseRef(attemptId);
128131
const existing = await this.#call(this.#options.repository.path, ['show-ref', '--verify', '--quiet', ref], scope);
129132
this.#throwIfCancelled(existing, scope);
@@ -260,10 +263,10 @@ export class GitRebaser {
260263

261264
/** Startup/live recovery for an attempt whose Store marker still exists. */
262265
async abort(attemptId: string, resultHead?: string,
263-
resultState: 'none' | 'prepared' | 'uncertain' | 'refused' | 'ready' = 'ready'): Promise<void> {
266+
resultState: 'none' | 'prepared' | 'uncertain' | 'refused' | 'ready' = 'ready', timeoutMs?: number): Promise<void> {
264267
const ref = rebaseRef(attemptId), path = this.#path(attemptId), stat = lstatSync(path, { throwIfNoEntry: false });
265268
if (resultHead !== undefined && !COMMIT_ID.test(resultHead)) throw new Error('A full retained result ID is required.');
266-
const scope = this.#scope(attemptId);
269+
const scope = this.#scope(attemptId, undefined, timeoutMs, true);
267270
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('The rebase workspace is not a plain directory.');
268271
await this.#remove(path, scope);
269272
if (resultState === 'refused') return;
@@ -548,8 +551,13 @@ export class GitRebaser {
548551
return outcome;
549552
}
550553

551-
#scope(attemptId: string, signal?: AbortSignal): CallScope {
552-
const timeout = this.#options.timeoutMs ?? 120_000, deadline = performance.now() + timeout;
554+
#scope(attemptId: string, signal?: AbortSignal, requestedTimeoutMs?: number, cleanupOnly = false): CallScope {
555+
const configured = this.#options.timeoutMs ?? MAX_REBASE_TIMEOUT_MS;
556+
const timeout = requestedTimeoutMs === undefined ? configured : Math.min(requestedTimeoutMs, configured);
557+
const minimum = cleanupOnly ? MIN_REBASE_CLEANUP_TIMEOUT_MS : MIN_REBASE_TIMEOUT_MS;
558+
if (!Number.isSafeInteger(timeout) || timeout < minimum || timeout > MAX_REBASE_TIMEOUT_MS)
559+
throw new Error('Invalid rebase deadline.');
560+
const deadline = performance.now() + timeout;
553561
return { attemptId, signal, deadline, workDeadline: deadline - CLEANUP_RESERVE_MS };
554562
}
555563

‎test/merge-task-pr.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,22 @@ describe('the merge gate with a runner block (#121)', () => {
188188
expect(store.getMergeAttempt(identity)).toMatchObject({ queueWatermark: 'CURSOR_after_authorization' });
189189
});
190190

191+
it('revalidates the pull request after capturing the queue event boundary', async () => {
192+
const { store } = published();
193+
let remoteBase = oid(1), watermarks = 0;
194+
const gh = github(store, () => ({ base: remoteBase, mergeQueue: true }));
195+
gh.client.queueWatermark = vi.fn(async () => {
196+
if (++watermarks === 2) remoteBase = oid(3);
197+
return 'CURSOR';
198+
});
199+
prepare(store);
200+
const merges = new MergeCoordinator(service(store), gh.client, undefined, undefined, PREPARED_PUBLISHED);
201+
202+
await expect(merges.merge('review-token')).rejects.toThrow(/pull request|requirements changed/i);
203+
expect(gh.merged).toEqual([]);
204+
expect(store.getMergeAttempt(identity)).toBeNull();
205+
});
206+
191207
it('revalidates local authorization after the final queue event boundary', async () => {
192208
const { store } = published(), gh = github(store, () => ({ mergeQueue: true }));
193209
let authorized = false, revoked = false;

‎test/pre-merge.test.ts‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { commandCheckDeps } from '../runner/checks.ts';
1212
import { RunnerCoordinator } from '../runner/coordinator.ts';
1313
import type { InvocationHandle, InvocationResult } from '../agents/contract.ts';
1414
import 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

1717
const roots: string[] = [], services: ReviewService[] = [];
1818
vi.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

161167
it('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

366373
it('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+
526548
it('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

584606
it('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

Comments
 (0)