diff --git a/docs/implementation/pre-merge-rebase.md b/docs/implementation/pre-merge-rebase.md index fb515fe4..157f6732 100644 --- a/docs/implementation/pre-merge-rebase.md +++ b/docs/implementation/pre-merge-rebase.md @@ -1,6 +1,6 @@ -# Pre-merge rebase and foreign-conflict boundary +# Pre-merge rebase and sandboxed conflict boundary -**Issue and lane:** #22, lanes F3-F4. This is the trusted local rewrite, recovery, and foreign-conflict engine boundary. The `rebase-fix` child adapter is assembled inside the production rebaser through lane D with durable resource ownership and bounded exact-path copy-in/copy-out. No production request starts that rebaser yet: F5-F6 must own admission plus the current base/head refresh before invoking it, then push the rewritten head, run plan `cmd:` checks, wait for GitHub checks, and hand the validated pair to the merge coordinator. Exposing the raw rebaser earlier would bypass those required guards. +**Issue and lane:** #22, lanes F3-F4. This is the trusted local rewrite, recovery, and sandboxed conflict engine boundary. The `rebase-fix` child adapter is assembled inside the production rebaser through lane D with durable resource ownership and bounded exact-path copy-in/copy-out. No production request starts that rebaser yet: F5-F6 must own admission plus the current base/head refresh before invoking it, then push the rewritten head, run plan `cmd:` checks, wait for GitHub checks, and hand the validated pair to the merge coordinator. Exposing the raw rebaser earlier would bypass those required guards. ## Ownership and lifecycle @@ -18,11 +18,11 @@ Admission checks the current plan revision, snapshot, review version, task state After replay settles and before its first result-ref write, the rebaser supplies the Store's preparation hook only the ordered rewritten history. The Store zips that history by position with the independently captured `oldHistory`, then durably binds the resulting complete mapping and exact intended ref value as `prepared`. The atomic create outcome advances it to `ready`, `uncertain`, or `refused`; an already-current no-op uses `prepared` then `ready` without a ref write. `Store.finishRebase` accepts only a `ready`, byte-for-byte mapping from the same attempt while the captured plan, snapshot, review version, task version, old base, old head, and target base are unchanged. A non-empty mapping must end at both the captured old head and the committed rewritten head; an empty mapping is valid only when the captured history is empty and the result equals the new base. The snapshot, rewrite map, and owned/foreign ledger provenance commit atomically, and the marker clears in that transaction. A stale result changes nothing and leaves its marker for exact-attempt cleanup. -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. +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 `{ owner: Px, origin: "owned" }` entry takes the owned branch and binds the invocation to the exact Px definition from one captured plan revision. That complete invocation context is rechecked immediately before launch and again after the final asynchronous export, before any output is copied back, so a concurrent plan, snapshot, assignment, referenced-code, or state-generation change cannot run or apply stale instructions. A gitlink conflict is refused for manual resolution before either file branch runs; the boundary never enters a nested repository. The injected resolver receives the source commit, trusted owner or null, the pinned pre-conflict head, and the exact non-gitlink conflicting path set as a read-only string array. Its bounded prompt names both commit identities; for owned work the existing captured `/run/codeboost-input/schema.json` envelope carries the exact plan item in a `planItem` property, while foreign work is explicitly told not to infer ownership from commit content. 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. 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`, snapshots only the exact conflict leaves without following ancestor links, and allocates ordinary bounded task storage from that still-clean clone. Only after seeding succeeds does a no-network stdin-backed importer write the bounded snapshot into the work volume; metadata stays read-only. The importer owns a monotonic deadline before base64/JSON serialization and rechecks it before any Docker probe. 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. The resolver carries that same monotonic budget through vendor-network creation, profile validation, and supervisor launch; those stages do not re-read the transported wall deadline. 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 canonical spelling of 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. -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. +The Store records each resolved foreign 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, a foreign 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.” An owned mapped commit instead retains its Px owner and source SHA without foreign-resolution provenance; its preserved message keeps the original plan trailers, and the rewritten mapping lets review recompute whether Px's approval became stale. Every replay preserves the original commit author. All other conflicts are a review outcome (`RebaseConflict`). Cancellation and failures settle the Git process group before cleanup. Cleanup removes only the validated UUID workspace. A partially created but unregistered worktree is removed only after Git's own worktree list proves it is not registered. Result refs are created atomically from the absent state; a reused attempt ID cannot overwrite or delete a previously retained result, while an ambiguous create is reconciled against the exact attempted value. @@ -34,4 +34,4 @@ This ordering is intentionally local-only. The later push integration must exten ## Regression evidence -`test/runner-rebase.test.ts` covers clean one-to-one replay, the already-current no-op, process-group ownership, the whole-operation deadline, foreign conflict resolution, clean sibling files, gitlink-pointer auditing and manual gitlink-conflict routing, embedded-repository refusal, literal replacement-character paths, invalid ledger classification, outside-file, resolver-commit and rebase-control refusal, owned-conflict refusal, cancellation before and during Git, exact cleanup, retained refs, and a read-only source checkout. `test/runner-rebase-conflict.test.ts` covers claim-before-prepare ordering, distinct allocation identities, bounded no-follow host snapshots, nested modify/delete copy-in, copy-back audit, child settlement deadlines, cancellation settlement, cleanup ordering, and retained ownership for unreleased resources. `test/runner-lifecycle-store.test.ts` covers admission, durable process and child ownership, compare-and-swap races, resolved-source validation, and owned/foreign provenance. `test/runner-recovery.test.ts` covers marker validation, live-group fail-closed recovery, exact child-storage recovery, partial-match refusal, and restart ordering. `test/agent-process-group.test.ts` covers delayed-signal suppression after exit and retained ownership for an orphaned group. The real-container suite covers dirty-conflict admission and bounded exact-path export. `test/history.test.ts` and `test/browser/review.spec.ts` cover Unplanned attribution and the visible conflict-resolution label. `test/runner-production.test.ts` covers exact-plan recovery routing and adapter construction. +`test/runner-rebase.test.ts` covers clean one-to-one replay, the already-current no-op, process-group ownership, the whole-operation deadline, owned and foreign conflict resolution, clean sibling files, gitlink-pointer auditing and manual gitlink-conflict routing, embedded-repository refusal, literal replacement-character paths, invalid ledger classification, outside-file, resolver-commit and rebase-control refusal, cancellation before and during Git, exact cleanup, retained refs, and a read-only source checkout. `test/runner-rebase-conflict.test.ts` covers owned-item prompt binding, plan-revision races before launch and copy-back, claim-before-prepare ordering, distinct allocation identities, bounded no-follow host snapshots, nested modify/delete copy-in, copy-back audit, child settlement deadlines, cancellation settlement, cleanup ordering, and retained ownership for unreleased resources. `test/runner-lifecycle-store.test.ts` covers admission, durable process and child ownership, compare-and-swap races, resolved-source validation, and owned/foreign provenance. `test/runner-recovery.test.ts` covers marker validation, live-group fail-closed recovery, exact child-storage recovery, partial-match refusal, and restart ordering. `test/agent-process-group.test.ts` covers delayed-signal suppression after exit and retained ownership for an orphaned group. The real-container suite covers dirty-conflict admission and bounded exact-path export. `test/history.test.ts` and `test/browser/review.spec.ts` cover Unplanned attribution and the visible conflict-resolution label. `test/runner-production.test.ts` covers exact-plan recovery routing and adapter construction. diff --git a/runner/rebase-conflict.ts b/runner/rebase-conflict.ts index 5c440591..ffe9894f 100644 --- a/runner/rebase-conflict.ts +++ b/runner/rebase-conflict.ts @@ -12,6 +12,7 @@ import { exportTaskPaths, importTaskPaths, prepareTaskFilesystemsAsync, removeTa import { DEFAULT_PROCESS_SETTLEMENT_MS, type ProcessGroup } from '../agents/process-group.ts'; import { runTrackedProcess, type ProcessGroupLifecycle } from '../agents/tracked-docker.ts'; import type { PlanIdentity } from '../core/identity.ts'; +import type { PlanItem } from '../core/plan.ts'; import { createTaskCloneAsync } from '../git/clone.ts'; import { sameContext } from './lifecycle.ts'; import { RebaseResourcesUnsettled, type RebaseConflictInput } from './rebase.ts'; @@ -23,6 +24,8 @@ export const MAX_CONFLICT_PATH_BYTES = 32 * 1024; // Reserve the tracked process's complete default stop/drain/pipe budget, plus one second for durable settlement writes. export const CONFLICT_PROCESS_SETTLEMENT_RESERVE_MS = DEFAULT_PROCESS_SETTLEMENT_MS + 1_000; const SCHEMA = '{"$schema":"https://json-schema.org/draft/2020-12/schema","title":"codeboost conflict resolution summary","type":"string"}\n'; +const ownedInput = (item: PlanItem): string => + `${JSON.stringify({ resolutionSchema: JSON.parse(SCHEMA) as unknown, planItem: item })}\n`; interface ResolverDeps { clone(options: Parameters[0]): Promise; @@ -50,7 +53,7 @@ const defaults: ResolverDeps = { }, }; -export interface ForeignConflictResolverOptions { +export interface ConflictResolverOptions { readonly store: Store; readonly identity: PlanIdentity; readonly planKey: string; @@ -62,6 +65,7 @@ export interface ForeignConflictResolverOptions { readonly limits: TaskStorageLimits; readonly deps?: Partial; } +export type ForeignConflictResolverOptions = ConflictResolverOptions; const assertRelativePath = (path: string): string[] => { if (!path || path.startsWith('/') || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cn}]/u.test(path) @@ -193,17 +197,30 @@ function assertResolvedManifest(manifest: TaskChangeManifest, files: readonly st const stopReason = (signal: AbortSignal) => (signal.reason as { code?: unknown } | undefined)?.code === 'ETIMEDOUT' ? 'timeout' as const : 'cancelled' as const; -export function createForeignConflictResolver(options: ForeignConflictResolverOptions): (input: RebaseConflictInput) => Promise { +export function createConflictResolver(options: ConflictResolverOptions): (input: RebaseConflictInput) => Promise { const deps = { ...defaults, ...options.deps }; return async input => { assertConflictPathSet(input.files); if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(input.baseHead)) throw new Error('Conflict base must be a full commit ID.'); + const context = options.store.currentContext(options.identity); + const assertCurrentContext = () => { + if (!sameContext(options.store.currentContext(options.identity), context)) + throw new Error('The conflict resolution context changed. Reload before launching or applying it.'); + }; + const item = input.owner === null ? null + : options.store.getPlan(options.identity, context.planRevision).items.find(value => value.id === input.owner); + if (input.owner !== null && !item) throw new Error('The owned conflict does not name a current plan item.'); const wallRemaining = input.deadline - Date.now(); if (!Number.isSafeInteger(input.deadline) || !Number.isSafeInteger(wallRemaining) || wallRemaining < 1) throw new Error('Conflict deadline has passed.'); // Convert the caller's wall-clock transport value once. Every later stage shares this monotonic boundary even if // the system clock moves; wall time is reconstructed only for the child invocation contract. const deadline = performance.now() + wallRemaining; + // Serialize the bounded plan item after establishing the deadline but before claiming resources. A synchronous + // serialization overrun must not create a child whose remaining lifecycle cannot fit the caller's budget. + const schemaInput = item ? ownedInput(item) : SCHEMA; + if (performance.now() >= deadline) throw new Error('Conflict deadline has passed.'); + assertCurrentContext(); // A pre-cancelled request owns nothing: check immediately before the first durable child claim. input.signal?.throwIfAborted(); const childAttemptId = randomUUID(), allocationId = randomUUID(), networkAllocationId = randomUUID(); @@ -291,14 +308,18 @@ export function createForeignConflictResolver(options: ForeignConflictResolverOp timeoutMs: operationBudget() })); const inputDirectory = join(staging, 'input'); mkdirSync(inputDirectory, { mode: 0o755 }); - writeFileSync(join(inputDirectory, 'schema.json'), SCHEMA, { mode: 0o444, flag: 'wx' }); + writeFileSync(join(inputDirectory, 'schema.json'), schemaInput, { mode: 0o444, flag: 'wx' }); chmodSync(join(inputDirectory, 'schema.json'), 0o444); - const context = options.store.currentContext(options.identity); const invocation = captureInvocation({ clone, phase: 'fix', vendor: 'claude', approvedArgv: [], deadline: Date.now() + operationBudget(), attemptId: childAttemptId, runnerOwner: options.runnerOwner, context }); - const prompt = `Resolve the in-progress rebase conflict in exactly these paths: ${JSON.stringify(input.files)}. ` + const ownership = item + ? `The conflicted commit is owned by plan item ${JSON.stringify(item.id)}. Read its exact definition from the planItem property in /run/codeboost-input/schema.json. ` + : 'The conflicted commit is foreign/unowned; do not infer an owner from its message or trailers. '; + const prompt = `Resolve source commit ${JSON.stringify(input.commit)} while it is replayed onto base commit ${JSON.stringify(input.baseHead)}. ` + + ownership + `Resolve the in-progress conflict in exactly these paths: ${JSON.stringify(input.files)}. ` + 'Edit only those paths. Do not create commits or change repository metadata. Preserve the intent of both sides and leave each path in its final resolved form.'; + assertCurrentContext(); handle = deps.start({ invocation, filesystems, inputDirectory, imageId, prompt, networkAllocationId, treeCheck, cleanupRoot: staging, processLifecycle }, options.token, { invocationBudget: operationBudget }); @@ -336,6 +357,7 @@ export function createForeignConflictResolver(options: ForeignConflictResolverOp exportedBytes += Buffer.byteLength(entry.path) + (entry.content?.length ?? 0); if (exportedBytes > MAX_CONFLICT_SNAPSHOT_BYTES) throw new Error('The conflict export exceeded its byte limit.'); } + assertCurrentContext(); for (const entry of entries) { if (performance.now() >= deadline) throw deadlineError(); writeEntry(input.repository, entry); @@ -373,3 +395,6 @@ export function createForeignConflictResolver(options: ForeignConflictResolverOp if (cleanup.length) throw cleanup[0]; }; } + +/** Compatibility export for the existing F4 production assembly. */ +export const createForeignConflictResolver = createConflictResolver; diff --git a/runner/rebase.ts b/runner/rebase.ts index 16642e4b..b914d5ed 100644 --- a/runner/rebase.ts +++ b/runner/rebase.ts @@ -20,6 +20,8 @@ export interface RebaseMapping { oldSha: string; newSha: string } export interface RebaseConflictInput { readonly attemptId: string; readonly commit: string; + /** Trusted ledger owner for an owned commit, or null for a foreign/unowned commit. */ + readonly owner: string | null; /** The pinned HEAD whose tree the resolver snapshot is based on. */ readonly baseHead: string; readonly files: readonly string[]; @@ -47,7 +49,9 @@ export interface GitRebaserOptions { onResultPrepared: (attemptId: string, intendedHead: string | null, rewrittenHistory: readonly string[], resolvedConflicts: readonly string[]) => void; onResultState: (attemptId: string, state: 'uncertain' | 'refused' | 'ready') => void; - /** F4's sandboxed resolver. It must settle all owned resources before resolving or rejecting. */ + /** F4's sandboxed resolver for owned and foreign conflicts. It must settle all resources before returning. */ + resolveConflict?: (input: RebaseConflictInput) => Promise; + /** Compatibility name used by the existing F4 production assembly. */ resolveForeignConflict?: (input: RebaseConflictInput) => Promise; } interface CallScope { attemptId: string; signal?: AbortSignal; deadline: number; workDeadline: number } @@ -166,10 +170,9 @@ export class GitRebaser { if (!old.includes(commit)) throw new RebaseConflict('The conflicted commit is outside the captured rebase history.'); const entry = ledger.get(commit); // Missing ledger entries and explicit foreign entries share the foreign branch. A forged trailer is never read. - if (entry && (entry.origin !== 'foreign' || entry.owner !== null)) - throw new RebaseConflict('An owned commit conflict needs its plan-item resolver.'); - if (!this.#options.resolveForeignConflict) - throw new RebaseConflict('A foreign commit conflict needs review.'); + const owner = entry?.origin === 'owned' ? entry.owner : null; + if (!this.#conflictResolver()) + throw new RebaseConflict(`${owner === null ? 'A foreign' : 'An owned'} commit conflict needs review.`); const files = Object.freeze(await this.#paths(path, 'conflicts', scope)); if (!files.length) throw new RebaseConflict('Git reported a conflict without any conflicted files.'); if (await this.#hasUnmergedGitlink(path, scope)) @@ -180,7 +183,7 @@ export class GitRebaser { const outsideBefore = await this.#outsideConflictState(path, allowed, priorHead, scope); const operationBefore = await this.#rebaseState(path, scope); this.#assertResultCurrent(scope); - await this.#resolveConflict({ attemptId, commit, baseHead: priorHead, files, repository: path, + await this.#resolveConflict({ attemptId, commit, owner, baseHead: priorHead, files, repository: path, deadline: Date.now() + Math.max(0, Math.floor(scope.workDeadline - performance.now())) }, scope); this.#assertResultCurrent(scope); if (await this.#git(path, ['rev-parse', '--verify', 'REBASE_HEAD^{commit}'], scope) !== commit) @@ -203,7 +206,9 @@ export class GitRebaser { if (unresolved.status === 1) throw new RebaseConflict('The conflict resolver left unresolved files.'); throw this.#failure('diff', unresolved); } - resolvedConflicts.push(commit); + // Only foreign resolutions receive the visible agent-resolution provenance. Owned commits retain their + // plan-item owner; the rewritten mapping lets review recompute whether that item's approval became stale. + if (owner === null) resolvedConflicts.push(commit); outcome = await this.#call(path, ['rebase', '--continue'], scope, false, true); } const head = await this.#git(path, ['rev-parse', '--verify', 'HEAD^{commit}'], scope); @@ -411,7 +416,7 @@ export class GitRebaser { } async #resolveConflict(input: Omit, scope: CallScope): Promise { - const resolveConflict = this.#options.resolveForeignConflict!; + const resolveConflict = this.#conflictResolver()!; const controller = new AbortController(); const abort = () => controller.abort(scope.signal?.reason ?? new Error('The rebase was cancelled.')); if (scope.signal?.aborted) abort(); @@ -444,6 +449,10 @@ export class GitRebaser { } } + #conflictResolver(): ((input: RebaseConflictInput) => Promise) | undefined { + return this.#options.resolveConflict ?? this.#options.resolveForeignConflict; + } + async #git(repository: string, args: readonly string[], scope: CallScope, cleanup = false, input?: Buffer): Promise { const outcome = await this.#call(repository, args, scope, cleanup, false, input); if (!cleanup) this.#throwIfCancelled(outcome, scope); diff --git a/test/runner-rebase-conflict.test.ts b/test/runner-rebase-conflict.test.ts index da334a03..6f7a945b 100644 --- a/test/runner-rebase-conflict.test.ts +++ b/test/runner-rebase-conflict.test.ts @@ -45,6 +45,7 @@ type ProcessOptions = { processLifecycle?: ProcessGroupLifecycle; timeoutMs?: nu function deps(f: ReturnType, over: Record = {}) { const events: string[] = []; let request: AgentAdapterRequest | undefined; + let schemaInput: string | undefined; let storageAllocationId: string | undefined; let imported: readonly ExportedTaskPath[] | undefined; const storage = { keeper: 'keeper' } as TaskFilesystems; @@ -83,6 +84,7 @@ function deps(f: ReturnType, over: Record = {}) }, start: (value: typeof request) => { request = value; events.push('start'); + schemaInput = readFileSync(join(value!.inputDirectory, 'schema.json'), 'utf8').trim(); return { attemptId: value!.invocation.attemptId, cancel: vi.fn(), settled: Promise.resolve({ attemptId: value!.invocation.attemptId, context: value!.invocation.context, exitCode: 0, signal: null, stdout: '', stderr: '', } satisfies InvocationResult) } satisfies InvocationHandle; @@ -94,13 +96,13 @@ function deps(f: ReturnType, over: Record = {}) ...over, }; return { d, events, storage, get request() { return request; }, get storageAllocationId() { return storageAllocationId; }, - get imported() { return imported; } }; + get imported() { return imported; }, get schemaInput() { return schemaInput; } }; } const input = (f: ReturnType, signal?: AbortSignal): RebaseConflictInput => ({ attemptId: f.marker.attemptId, - commit: oid(2), baseHead: oid(2), files: ['conflict.txt'], repository: f.repository, deadline: Date.now() + 60_000, signal }); + commit: oid(2), owner: null, baseHead: oid(2), files: ['conflict.txt'], repository: f.repository, deadline: Date.now() + 60_000, signal }); -describe('production foreign conflict resolver', () => { +describe('production rebase conflict resolver', () => { it('reserves the complete default tracked-process settlement budget plus its durable-write margin', () => { expect(CONFLICT_PROCESS_SETTLEMENT_RESERVE_MS).toBe(DEFAULT_PROCESS_SETTLEMENT_MS + 1_000); expect(DEFAULT_PROCESS_SETTLEMENT_MS).toBe(16_000); @@ -221,6 +223,30 @@ describe('production foreign conflict resolver', () => { expect(f.store.getTask(identity).rebaseInProgress).toMatchObject({ conflict: null }); }); + it('rejects an unknown owned item before claiming child resources', async () => { + const f = fixture(), x = deps(f); + const resolve = createForeignConflictResolver({ store: f.store, identity, planKey: f.planKey, + repository: { path: join(f.root, 'bare.git') } as RunnerRepository, runnerOwner: 'a'.repeat(32), + image: () => 'sha256:' + 'b'.repeat(64), token: 'secret', + limits: { workBytes: 1, workInodes: 1, metadataBytes: 1, metadataInodes: 1 }, deps: x.d as never }); + await expect(resolve({ ...input(f), owner: 'P2' })).rejects.toThrow(/current plan item/); + expect(x.events).toEqual([]); + expect(f.store.getTask(identity).rebaseInProgress).toMatchObject({ conflict: null, processGroup: null }); + }); + + it('does not claim resources when owned-item serialization consumes the deadline', async () => { + const f = fixture(), x = deps(f), realNow = performance.now.bind(performance); + let reads = 0; + vi.spyOn(performance, 'now').mockImplementation(() => ++reads === 1 ? realNow() : Number.MAX_SAFE_INTEGER); + const resolve = createForeignConflictResolver({ store: f.store, identity, planKey: f.planKey, + repository: { path: join(f.root, 'bare.git') } as RunnerRepository, runnerOwner: 'a'.repeat(32), + image: () => 'sha256:' + 'b'.repeat(64), token: 'secret', + limits: { workBytes: 1, workInodes: 1, metadataBytes: 1, metadataInodes: 1 }, deps: x.d as never }); + await expect(resolve({ ...input(f), owner: 'P1' })).rejects.toThrow(/deadline has passed/); + expect(x.events).toEqual([]); + expect(f.store.getTask(identity).rebaseInProgress).toMatchObject({ conflict: null, processGroup: null }); + }); + it('allocates from the clean clone before importing the conflict snapshot, then copies back only the audited path', async () => { const f = fixture(), x = deps(f); const resolve = createForeignConflictResolver({ store: f.store, identity, planKey: f.planKey, @@ -236,6 +262,62 @@ describe('production foreign conflict resolver', () => { expect(f.store.getTask(identity).rebaseInProgress).toMatchObject({ conflict: null, processGroup: null }); }); + it('gives an owned conflict child the exact plan item and rebase commit identities', async () => { + const f = fixture(), x = deps(f); + const resolve = createForeignConflictResolver({ store: f.store, identity, planKey: f.planKey, + repository: { path: join(f.root, 'bare.git') } as RunnerRepository, runnerOwner: 'a'.repeat(32), + image: () => 'sha256:' + 'b'.repeat(64), token: 'secret', + limits: { workBytes: 1, workInodes: 1, metadataBytes: 1, metadataInodes: 1 }, deps: x.d as never }); + await resolve({ ...input(f), owner: 'P1', baseHead: oid(9) }); + expect(JSON.parse(x.schemaInput!).planItem).toEqual(plan.items[0]); + expect(x.request?.prompt).toContain('/run/codeboost-input/schema.json'); + expect(x.request?.prompt).toContain(oid(2)); + expect(x.request?.prompt).toContain(oid(9)); + }); + + it('does not launch an owned conflict child after its captured plan revision changes', async () => { + const f = fixture(), x = deps(f), cloneEntered = Promise.withResolvers(), releaseClone = Promise.withResolvers(); + const clone = x.d.clone; + x.d.clone = async (...args: Parameters) => { + const result = await clone(...args); + cloneEntered.resolve(); + await releaseClone.promise; + return result; + }; + const resolve = createForeignConflictResolver({ store: f.store, identity, planKey: f.planKey, + repository: { path: join(f.root, 'bare.git') } as RunnerRepository, runnerOwner: 'a'.repeat(32), + image: () => 'sha256:' + 'b'.repeat(64), token: 'secret', + limits: { workBytes: 1, workInodes: 1, metadataBytes: 1, metadataInodes: 1 }, deps: x.d as never }); + const running = resolve({ ...input(f), owner: 'P1' }); + await cloneEntered.promise; + f.store.importRevision(JSON.stringify({ ...plan, items: [{ ...plan.items[0]!, title: 'Changed' }] }), 'json', context, 1); + releaseClone.resolve(); + await expect(running).rejects.toThrow(/context changed/); + expect(x.events).not.toContain('start'); + expect(readFileSync(join(f.repository, 'conflict.txt'), 'utf8')).toBe('conflicted\n'); + expect(f.store.getTask(identity).rebaseInProgress).toMatchObject({ conflict: null, processGroup: null }); + }); + + it('does not apply owned conflict output after its captured plan revision changes', async () => { + const f = fixture(), inspectEntered = Promise.withResolvers(), releaseInspect = Promise.withResolvers(); + const x = deps(f, { inspect: async () => { + inspectEntered.resolve(); + await releaseInspect.promise; + return manifest(); + } }); + const resolve = createForeignConflictResolver({ store: f.store, identity, planKey: f.planKey, + repository: { path: join(f.root, 'bare.git') } as RunnerRepository, runnerOwner: 'a'.repeat(32), + image: () => 'sha256:' + 'b'.repeat(64), token: 'secret', + limits: { workBytes: 1, workInodes: 1, metadataBytes: 1, metadataInodes: 1 }, deps: x.d as never }); + const running = resolve({ ...input(f), owner: 'P1' }); + await inspectEntered.promise; + f.store.importRevision(JSON.stringify({ ...plan, items: [{ ...plan.items[0]!, title: 'Changed' }] }), 'json', context, 1); + releaseInspect.resolve(); + await expect(running).rejects.toThrow(/context changed/); + expect(readFileSync(join(f.repository, 'conflict.txt'), 'utf8')).toBe('conflicted\n'); + expect(f.store.getTask(identity).rebaseInProgress).toMatchObject({ conflict: null, processGroup: null }); + }); + it('canonicalizes a cleanup root whose configured spelling traverses a symlink', async () => { const f = fixture(), aliasParent = join(f.root, 'root-alias'), alias = join(aliasParent, 'rebase'); symlinkSync(f.root, aliasParent); diff --git a/test/runner-rebase.test.ts b/test/runner-rebase.test.ts index d711c3a3..7727ae1b 100644 --- a/test/runner-rebase.test.ts +++ b/test/runner-rebase.test.ts @@ -39,7 +39,8 @@ async function setup(conflict: boolean | 'foreign' = false, conflictPath = 'a.tx } const base = commit(source, conflictPath, 'base\n', 'base'); git(source, 'switch', '-qc', 'feature'); - const owned = commit(source, conflict === 'foreign' ? 'owned.txt' : conflictPath, 'feature\n', 'owned'); + const owned = commit(source, conflict === 'foreign' ? 'owned.txt' : conflictPath, 'feature\n', + 'owned\n\nPlan-Item: P1\nPlan-Revision: r1'); if (conflict === 'foreign') { git(source, 'config', 'user.name', 'Collaborator'); git(source, 'config', 'user.email', 'collaborator@example.invalid'); } const foreign = commit(source, conflict === 'foreign' ? conflictPath : 'feature.txt', 'collaborator\n', conflict === 'foreign' ? 'foreign\n\nPlan-Item: P1' : 'foreign'); @@ -374,12 +375,12 @@ describe('trusted pre-merge rebase', () => { const s = await setup('foreign'), attemptId = randomUUID(); const seen: unknown[] = []; const runner = createRebaser(s, { resolveForeignConflict: async input => { - seen.push({ commit: input.commit, files: input.files }); + seen.push({ commit: input.commit, owner: input.owner, files: input.files }); writeFileSync(join(input.repository, 'a.txt'), 'resolved foreign change\n'); } }); const result = await runner.run({ attemptId, oldBase: s.base, oldHead: s.foreign, oldHistory: s.history, onto: s.onto, ledger: [{ sha: s.owned, owner: 'P1', origin: 'owned' }] }); - expect(seen).toEqual([{ commit: s.foreign, files: ['a.txt'] }]); + expect(seen).toEqual([{ commit: s.foreign, owner: null, files: ['a.txt'] }]); expect(result.resolvedConflicts).toEqual([s.foreign]); const rewritten = result.mappings.find(entry => entry.oldSha === s.foreign)!.newSha; expect(git(s.repository.path, 'show', '-s', '--format=%an <%ae>', rewritten)).toBe('Collaborator '); @@ -660,13 +661,22 @@ describe('trusted pre-merge rebase', () => { expect(existsSync(join(s.runnerRoot, OWNER, 'rebases', attemptId))).toBe(true); }); - it('never sends an owned conflict through the foreign resolver', async () => { - const s = await setup(true), attemptId = randomUUID(), resolveForeignConflict = vi.fn(async () => {}); - const runner = createRebaser(s, { resolveForeignConflict }); - await expect(runner.run({ attemptId, oldBase: s.base, oldHead: s.foreign, oldHistory: s.history, onto: s.onto, - ledger: [{ sha: s.owned, owner: 'P1', origin: 'owned' }, { sha: s.foreign, owner: null, origin: 'foreign' }] })) - .rejects.toThrow(/owned commit conflict/); - expect(resolveForeignConflict).not.toHaveBeenCalled(); + it('routes an owned conflict through the sandbox while preserving its owned provenance', async () => { + const s = await setup(true), attemptId = randomUUID(), seen: unknown[] = []; + const resolveConflict = vi.fn(async input => { + seen.push({ commit: input.commit, owner: input.owner, files: input.files }); + writeFileSync(join(input.repository, 'a.txt'), 'resolved owned change\n'); + }); + const runner = createRebaser(s, { resolveConflict }); + const result = await runner.run({ attemptId, oldBase: s.base, oldHead: s.foreign, oldHistory: s.history, onto: s.onto, + ledger: [{ sha: s.owned, owner: 'P1', origin: 'owned' }, { sha: s.foreign, owner: null, origin: 'foreign' }] }); + expect(seen).toEqual([{ commit: s.owned, owner: 'P1', files: ['a.txt'] }]); + expect(result.resolvedConflicts).toEqual([]); + expect(git(s.repository.path, 'show', `${result.head}:a.txt`)).toBe('resolved owned change'); + const rewritten = result.mappings.find(entry => entry.oldSha === s.owned)!.newSha; + expect(rewritten).not.toBe(s.owned); + expect(git(s.repository.path, 'show', '-s', '--format=%an <%ae>', rewritten)).toBe('Source '); + expect(git(s.repository.path, 'show', '-s', '--format=%B', rewritten)).toContain('Plan-Item: P1\nPlan-Revision: r1'); }); it('refuses an inconsistent trusted ledger before creating a rebase workspace', async () => {