Skip to content

Commit 2fcf3e5

Browse files
committed
Close conflict lifecycle gaps
1 parent 05aabc7 commit 2fcf3e5

6 files changed

Lines changed: 31 additions & 4 deletions

File tree

‎agents/process-group.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ const DRAIN_LIMIT_MS = 10_000;
6060
// Once the group is gone, how long to wait for stdout and stderr to reach end of file. Only a process outside the group
6161
// (one that called setsid but kept the inherited pipe) can hold them open after that, and it must not hold this call.
6262
const STDIO_CLOSE_MS = 1_000;
63+
/** Worst-case default SIGTERM grace plus post-exit group and pipe settlement. */
64+
export const DEFAULT_PROCESS_SETTLEMENT_MS = DEFAULT_GRACE_MS + DRAIN_LIMIT_MS + STDIO_CLOSE_MS;
6365
const pause = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
6466
const notStarted = (message: string): DockerOutcome =>
6567
({ status: null, stdout: '', stderr: '', error: Object.assign(new Error(message), { code: NOT_STARTED }) });

‎docs/implementation/pre-merge-rebase.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ After replay settles and before its first result-ref write, the rebaser supplies
2020

2121
For each conflict, classification uses only the supplied trusted ledger. A missing entry and an explicit `{ owner: null, origin: "foreign" }` entry take the foreign branch; trailers are never read. An owned entry is refused until the separate owned-commit resolver exists. A gitlink conflict is also refused for manual resolution before the file resolver runs; the boundary never enters a nested repository. The injected foreign resolver receives the source commit and exact non-gitlink conflicting path set as a read-only string array. Raw Git path streams use the same explicit 32 MiB and 262,144-entry bounds as checkout verification, travel between processes as base64, and undergo fatal UTF-8 decoding so malformed names fail closed without rejecting a valid literal replacement character. Before and after the resolver runs, one killable helper batches the index scan and hashes every non-conflict changed path against the same pinned `HEAD`; separate bounded helpers hash every index-only gitlink entry and the byte-exact rebase control directory without following links. These helpers run inside the operation deadline instead of blocking the server event loop or spawning once per path. After the resolver settles, the rebaser proves both `HEAD` and `REBASE_HEAD` are unchanged, rejects every tracked or untracked edit outside the conflict set, refuses any change to the remaining rebase operation, stages only literal NUL-delimited path data on stdin, then rechecks the gitlink index so an allowed file cannot become an embedded repository. It also refuses any remaining unmerged entry. A rejection or invocation failure aborts and removes the partial rewrite.
2222

23-
Before any clone or Docker operation, the production adapter records a unique child attempt plus distinct storage and network allocation IDs in `conflict`. It clones the pinned pre-conflict `HEAD`, copies only the exact conflict leaves without following ancestor links, safely recreating missing plain parents for nested modify/delete conflicts, and allocates ordinary bounded task storage. The path set is capped at 32 KiB before ownership is claimed, keeping the provider prompt below the operating system's single-argument limit. Every individual Git or Docker client—validation, execution, and cleanup included—moves the durable marker through `spawning`, its exact process group, and settled or retained ownership; a retained client prevents every later subprocess from starting. Each child subprocess budget leaves 13 seconds for group and pipe settlement, and every asynchronous child stage shares the parent's rebase-work boundary. If a stage cannot settle by that boundary, the resolver returns with durable ownership retained instead of consuming the parent's 30-second cleanup reserve. A specialized pre-launch tree check allows the expected dirty conflict paths but rejects repository metadata, commits, outside paths, unsafe types, links, populated gitlinks, and unmountable gitlink layouts. The ordinary `fix` profile then runs with no command capability. Its host-side input snapshot is created beneath the durably known conflict staging directory, so retaining the parent workspace also retains every reported directory resource for restart cleanup. On success, the adapter audits the complete manifest, exports only the exact approved leaves with a 32 MiB raw-content budget, and copies them back without exposing the host worktree or shared Git metadata to the agent. Storage and staging cleanup complete before the marker clears; an unreleased resource keeps the marker and parent workspace durable. Startup recovery removes agent/network leftovers through lane D, aborts the parent worktree (including profile snapshots), removes an exactly matching child storage handle, and only then clears child and parent ownership.
23+
Before any clone or Docker operation, the production adapter records a unique child attempt plus distinct storage and network allocation IDs in `conflict`. It clones the pinned pre-conflict `HEAD`, copies only the exact conflict leaves without following ancestor links, safely recreating missing plain parents for nested modify/delete conflicts, and allocates ordinary bounded task storage. The path set is capped at 32 KiB before ownership is claimed, keeping the provider prompt below the operating system's single-argument limit. Every individual Git or Docker client—validation, execution, and cleanup included—moves the durable marker through `spawning`, its exact process group, and settled or retained ownership; a retained client prevents every later subprocess from starting. Each child subprocess budget leaves 17 seconds for the default 5-second stop grace, 10-second group drain, 1-second pipe drain, and durable settlement, and every asynchronous child stage shares the parent's rebase-work boundary. If a stage cannot settle by that boundary, the resolver returns with durable ownership retained instead of consuming the parent's 30-second cleanup reserve. A specialized pre-launch tree check allows the expected dirty conflict paths but rejects repository metadata, commits, outside paths, unsafe types, links, populated gitlinks, and unmountable gitlink layouts. The ordinary `fix` profile then runs with no command capability. Its host-side input snapshot is created beneath the durably known conflict staging directory, so retaining the parent workspace also retains every reported directory resource for restart cleanup. On success, the adapter audits the complete manifest, exports only the exact approved leaves with a 32 MiB raw-content budget, and copies them back without exposing the host worktree or shared Git metadata to the agent. Storage and staging cleanup complete before the marker clears; an unreleased resource keeps the marker and parent workspace durable. Startup recovery removes agent/network leftovers through lane D, aborts the parent worktree (including profile snapshots), removes an exactly matching child storage handle, and only then clears child and parent ownership.
2424

2525
The Store records each resolved source SHA before the result ref write. Preparation refuses duplicates, sources outside the captured history, owned sources, and sources that did not actually rewrite. On publication, the mapped commit retains `owner: null`, `origin: foreign`, and `sourceSha`, plus `conflictResolved: true`. Linking therefore keeps its lines in Unplanned while the review UI adds the text label “conflict resolved by agent.” The commit author is preserved by Git's replay and no Plan-Item trailer is added.
2626

‎runner/rebase-conflict.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { checkConflictTree, inspectTaskChanges, MAXIMUM_DECLARED_LINKS, MAXIMUM_
99
type TaskChangeManifest, type TaskTreeCheck } from '../agents/container/changes.ts';
1010
import { exportTaskPaths, prepareTaskFilesystemsAsync, removeTaskFilesystemsAsync,
1111
type ExportedTaskPath, type PreparationOptions, type TaskFilesystems, type TaskStorageLimits } from '../agents/container/storage.ts';
12-
import type { ProcessGroup } from '../agents/process-group.ts';
12+
import { DEFAULT_PROCESS_SETTLEMENT_MS, type ProcessGroup } from '../agents/process-group.ts';
1313
import { runTrackedProcess, type ProcessGroupLifecycle } from '../agents/tracked-docker.ts';
1414
import type { PlanIdentity } from '../core/identity.ts';
1515
import { createTaskCloneAsync } from '../git/clone.ts';
@@ -20,8 +20,8 @@ import type { Store } from './store.ts';
2020

2121
export const MAX_CONFLICT_SNAPSHOT_BYTES = 32 * 1024 * 1024;
2222
export const MAX_CONFLICT_PATH_BYTES = 32 * 1024;
23-
// A timed-out process may need 1 s of graceful termination, 10 s to drain descendants and 1 s to close held pipes.
24-
export const CONFLICT_PROCESS_SETTLEMENT_RESERVE_MS = 13_000;
23+
// Reserve the tracked process's complete default stop/drain/pipe budget, plus one second for durable settlement writes.
24+
export const CONFLICT_PROCESS_SETTLEMENT_RESERVE_MS = DEFAULT_PROCESS_SETTLEMENT_MS + 1_000;
2525
const SCHEMA = '{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"codeboost conflict resolution summary","type":"string"}\n';
2626

2727
interface ResolverDeps {
@@ -193,6 +193,8 @@ export function createForeignConflictResolver(options: ForeignConflictResolverOp
193193
assertConflictPathSet(input.files);
194194
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(input.baseHead)) throw new Error('Conflict base must be a full commit ID.');
195195
if (!Number.isSafeInteger(input.deadline) || input.deadline <= Date.now()) throw new Error('Conflict deadline has passed.');
196+
// A pre-cancelled request owns nothing: check immediately before the first durable child claim.
197+
input.signal?.throwIfAborted();
196198
const childAttemptId = randomUUID(), allocationId = randomUUID(), networkAllocationId = randomUUID();
197199
options.store.beginRebaseConflict(options.planKey, input.attemptId,
198200
{ attemptId: childAttemptId, allocationId, networkAllocationId, source: input.commit });

‎runner/store.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,8 @@ export class Store {
811811
const task = this.#task(planKey), marker = task.rebase_in_progress === null ? null : decode<RebaseMarker>(task.rebase_in_progress);
812812
if (marker?.attemptId !== attemptId || marker.conflict?.attemptId !== conflictAttemptId)
813813
throw new GuardRefusal('This conflict child no longer owns the rebase.');
814+
if (marker.processGroup !== null)
815+
throw new GuardRefusal('The conflict child process has not settled.');
814816
if (this.#run('UPDATE tasks SET rebase_in_progress=? WHERE plan_key=? AND rebase_in_progress=?',
815817
encode({ ...marker, conflict: null }), planKey, task.rebase_in_progress as string).changes !== 1)
816818
throw new GuardRefusal('This conflict child no longer owns the rebase.');

‎test/runner-lifecycle-store.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,10 @@ describe('pre-merge rebase ownership', () => {
588588
expect(() => store.prepareRebaseResult(planKey, marker.attemptId, oid(4), [oid(4)], [oid(2)])).toThrow(/child has not settled/);
589589
expect(store.abortRebase(planKey, marker.attemptId)).toBe(false);
590590
expect(() => store.clearRebaseConflict(planKey, marker.attemptId, randomUUID())).toThrow(/no longer owns/);
591+
store.setRebaseProcessGroup(planKey, marker.attemptId, null, 'spawning');
592+
expect(() => store.clearRebaseConflict(planKey, marker.attemptId, child)).toThrow(/process has not settled/);
593+
expect(store.getTask(identity).rebaseInProgress).toMatchObject({ conflict: { attemptId: child }, processGroup: 'spawning' });
594+
store.setRebaseProcessGroup(planKey, marker.attemptId, 'spawning', null);
591595
store.clearRebaseConflict(planKey, marker.attemptId, child);
592596
expect(store.getTask(identity).rebaseInProgress).toMatchObject({ conflict: null });
593597
expect(store.abortRebase(planKey, marker.attemptId)).toBe(true);

‎test/runner-rebase-conflict.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { AgentAdapterRequest } from '../agents/adapters/types.ts';
88
import type { TaskChangeManifest, TaskTreeCheck } from '../agents/container/changes.ts';
99
import type { ExportedTaskPath, TaskFilesystems } from '../agents/container/storage.ts';
1010
import type { ProcessGroupLifecycle } from '../agents/tracked-docker.ts';
11+
import { DEFAULT_PROCESS_SETTLEMENT_MS } from '../agents/process-group.ts';
1112
import type { Plan, PlanContext } from '../core/plan.ts';
1213
import type { AsyncCloneOptions } from '../git/clone.ts';
1314
import { CONFLICT_PROCESS_SETTLEMENT_RESERVE_MS, copyConflictPaths, createForeignConflictResolver,
@@ -94,6 +95,22 @@ const input = (f: ReturnType<typeof fixture>, signal?: AbortSignal): RebaseConfl
9495
commit: oid(2), baseHead: oid(2), files: ['conflict.txt'], repository: f.repository, deadline: Date.now() + 60_000, signal });
9596

9697
describe('production foreign conflict resolver', () => {
98+
it('reserves the complete default tracked-process settlement budget plus its durable-write margin', () => {
99+
expect(CONFLICT_PROCESS_SETTLEMENT_RESERVE_MS).toBe(DEFAULT_PROCESS_SETTLEMENT_MS + 1_000);
100+
expect(DEFAULT_PROCESS_SETTLEMENT_MS).toBe(16_000);
101+
});
102+
103+
it('does not claim a conflict child for an already-aborted request', async () => {
104+
const f = fixture(), x = deps(f), reason = new Error('cancelled before conflict admission');
105+
const resolve = createForeignConflictResolver({ store: f.store, identity, planKey: f.planKey,
106+
repository: { path: join(f.root, 'bare.git') } as RunnerRepository, runnerOwner: 'a'.repeat(32),
107+
image: vi.fn(() => 'sha256:' + 'b'.repeat(64)), token: 'secret',
108+
limits: { workBytes: 1, workInodes: 1, metadataBytes: 1, metadataInodes: 1 }, deps: x.d as never });
109+
await expect(resolve(input(f, AbortSignal.abort(reason)))).rejects.toBe(reason);
110+
expect(x.events).toEqual([]);
111+
expect(f.store.getTask(identity).rebaseInProgress).toMatchObject({ conflict: null, processGroup: null });
112+
});
113+
97114
it('bounds the complete host snapshot before changing the destination', () => {
98115
const root = mkdtempSync(join(tmpdir(), 'codeboost-conflict-copy-')); roots.push(root);
99116
const source = join(root, 'source'), destination = join(root, 'destination');

0 commit comments

Comments
 (0)