Skip to content

Commit 6dd57c8

Browse files
authored
Add foreign rebase conflict engine boundary (#138)
* Add foreign rebase conflict boundary * Fix foreign conflict resolver audit * Audit gitlinks during conflict resolution * Harden conflict path auditing * Freeze rebase control state during resolution * Bound outside conflict auditing * Bound rebase conflict admission * Exercise conflict deadline admission * Harden resolver audit boundaries * Complete conflict scope auditing * Bound index record retention
1 parent 7a9abd3 commit 6dd57c8

19 files changed

Lines changed: 952 additions & 70 deletions

‎core/approvals.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export function stable(value: unknown): string {
99
if (value !== null && typeof value === 'object') return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, val]) => `${JSON.stringify(key)}:${stable(val)}`).join(',')}}`;
1010
return JSON.stringify(value);
1111
}
12-
const contentKey = (s: Segment) => stable({ path: s.path, oldPath: s.oldPath, kind: s.kind, operation: s.operation, content: s.content });
12+
const contentKey = (s: Segment) => stable({ path: s.path, oldPath: s.oldPath, kind: s.kind, operation: s.operation,
13+
content: s.content, ...(s.conflictResolved ? { conflictResolved: true } : {}) });
1314
export interface SegmentChoice { key: string; action: 'assign' | 'accept'; item: string | null }
1415
/** Position among identical segments and total copies prevent approval transfer. */
1516
export function choiceKeys(segments: readonly Segment[], identity: PlanIdentity): string[] {
@@ -37,6 +38,7 @@ export interface Approval { item: string; fingerprint: string }
3738
export const reviewedSegment = (s: Segment) => ({
3839
path: s.path, oldPath: s.oldPath, kind: s.kind, operation: s.operation,
3940
content: s.content, context: s.context, owners: [...s.owners].sort(),
41+
...(s.conflictResolved ? { conflictResolved: true } : {}),
4042
});
4143
export function fingerprint(item: PlanItem, segments: readonly Segment[], identity: PlanIdentity): string {
4244
return stable({ identity: identityKey(identity), item, segments: segments.filter(s => s.row === item.id).map(reviewedSegment) });

‎core/linking.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ export interface Segment {
1717
row: string; scope: 'in-scope' | 'out-of-scope' | 'unplanned' | 'ambiguous';
1818
oldLine: number | null; newLine: number | null; operation: '+' | '-' | null;
1919
content: string; context: string; hunk: number; sharesHunkWith: string[];
20+
/** At least one contributing foreign commit was resolved by the sandboxed rebase agent. */
21+
conflictResolved?: boolean;
2022
}
21-
interface Evidence { owners: (string | null)[]; outOfScope: string[] }
23+
interface Evidence { owners: (string | null)[]; outOfScope: string[]; conflictResolved: boolean }
2224
interface TrackedLine { text: string; evidence: Evidence; origins: string[]; moved: Evidence }
2325
interface TrackedFile { lines: TrackedLine[]; metadata: Evidence; metadataPaths: string[] }
2426
const unique = <T>(values: T[]): T[] => [...new Set(values)];
@@ -30,7 +32,7 @@ function metadataChange(delta: FileDelta): boolean {
3032
return delta.oldPath !== delta.newPath || delta.before?.mode !== delta.after?.mode ||
3133
!textFile(delta.before) || !textFile(delta.after);
3234
}
33-
const empty = (): Evidence => ({ owners: [], outOfScope: [] });
35+
const empty = (): Evidence => ({ owners: [], outOfScope: [], conflictResolved: false });
3436
function classify(evidence: Evidence): Pick<Segment, 'row' | 'scope'> {
3537
const { owners, outOfScope } = evidence;
3638
if (!owners.length || owners.includes(null)) return { row: 'Unplanned', scope: 'unplanned' };
@@ -42,7 +44,8 @@ function classify(evidence: Evidence): Pick<Segment, 'row' | 'scope'> {
4244
export interface LinkingLimits { maxLines?: number; maxSegments?: number; maxReferences?: number; maxDurationMs?: number }
4345

4446
/** Replays a linear history. Commit messages and Plan-Item trailers are never trusted. */
45-
export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<string, string | null>, pathKey: (path: string) => string, limits: LinkingLimits = {}): Segment[] {
47+
export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<string, string | null>, pathKey: (path: string) => string,
48+
limits: LinkingLimits = {}, resolvedConflicts: ReadonlySet<string> = new Set()): Segment[] {
4649
if (typeof pathKey !== 'function') throw new Error('Known checkout path identity is required.');
4750
const budget = (value: number | undefined, ceiling: number, name: string) => {
4851
const limit = value ?? ceiling;
@@ -87,7 +90,7 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
8790
for (const owner of entry.owners) owners.add(owner);
8891
for (const owner of entry.outOfScope) outOfScope.add(owner);
8992
}
90-
return { owners: [...owners], outOfScope: [...outOfScope] };
93+
return { owners: [...owners], outOfScope: [...outOfScope], conflictResolved: evidence.some(entry => entry.conflictResolved) };
9194
};
9295
const mergeOrigins = (tracked: readonly TrackedLine[]): string[] => {
9396
const origins = new Set<string>();
@@ -111,7 +114,8 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
111114
const item = plan.items.find(item => item.id === owner);
112115
const declared = new Set(item?.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey));
113116
const touched = [delta.oldPath, delta.newPath].filter((path): path is string => path !== null);
114-
const current: Evidence = { owners: [owner], outOfScope: owner !== null && touched.some(path => !declared.has(pathKey(path))) ? [owner] : [] };
117+
const current: Evidence = { owners: [owner], outOfScope: owner !== null && touched.some(path => !declared.has(pathKey(path))) ? [owner] : [],
118+
conflictResolved: owner === null && resolvedConflicts.has(commit.sha) };
115119
let previous = oldPath ? files.get(oldPath) : undefined;
116120
if (!previous) previous = {
117121
lines: lines(textFile(delta.before) ? delta.before!.text : '').map((text, i) => {
@@ -170,7 +174,8 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
170174
const push = (part: Omit<Segment, 'row' | 'scope' | 'sharesHunkWith' | 'owners'>, evidence: Evidence) => {
171175
remaining();
172176
if (++segmentCount > maxSegments) throw new Error('Linking exceeds its cumulative segment budget.');
173-
fileSegments.push({ ...part, owners: evidence.owners, ...classify(evidence), sharesHunkWith: [] });
177+
fileSegments.push({ ...part, owners: evidence.owners, ...classify(evidence), sharesHunkWith: [],
178+
...(evidence.conflictResolved ? { conflictResolved: true } : {}) });
174179
};
175180
if (metadataChange(delta)) {
176181
const evidence = combine(affectedPaths.map(path => metadata.get(path) ?? empty()));
@@ -213,6 +218,7 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
213218
const last = grouped.at(-1);
214219
if (last && part.kind === 'text' && last.kind === 'text' && last.hunk === part.hunk &&
215220
last.operation === part.operation && last.context === part.context && last.scope === part.scope &&
221+
!!last.conflictResolved === !!part.conflictResolved &&
216222
(part.operation === '+' ? last.newLine! + groupedLineCount === part.newLine : last.oldLine! + groupedLineCount === part.oldLine) &&
217223
JSON.stringify(last.owners) === JSON.stringify(part.owners)) { last.content += part.content; groupedLineCount++; }
218224
else { grouped.push({ ...part }); groupedLineCount = part.kind === 'text' ? 1 : 0; }
Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
# Pre-merge rebase foundation
1+
# Pre-merge rebase and foreign-conflict boundary
22

3-
**Issue and lane:** #22, lane F3. This is the trusted local rewrite and recovery boundary. It does not yet push the rewritten head, run plan `cmd:` checks, wait for GitHub checks, or hand a pair to the merge coordinator; those remain F4-F6 work in #22.
3+
**Issue and lane:** #22, lanes F3-F4. This is the trusted local rewrite, recovery, and foreign-conflict engine boundary. The production `rebase-fix` child-attempt adapter is not wired yet: the runner must not invoke this boundary until that adapter owns its container and storage durably. Pushing the rewritten head, running plan `cmd:` checks, waiting for GitHub checks, and handing a pair to the merge coordinator remain F5-F6 work in #22.
44

55
## Ownership and lifecycle
66

77
Before the first Git process, `Store.beginRebase` records one marker in `tasks.rebase_in_progress`:
88

99
```text
10-
{ attemptId, oldBase, oldHead, onto, oldHistory, startedAt, resultState, resultHead, resultMappings, processGroup }
10+
{ attemptId, oldBase, oldHead, onto, oldHistory, startedAt, resultState, resultHead, resultMappings, resolvedConflicts, processGroup }
1111
```
1212

1313
Admission checks the current plan revision, snapshot, review version, task state version, mergeable task status, cancellation state, active runner attempt, active merge, existing rebase, both ends of the reviewed snapshot, and the complete ordered old history captured by review. Recording the marker advances the task state version. While it exists, another rebase, the legacy rewrite path, merge admission, runner admission, reassignment, and ordinary status changes fail closed. Cancellation remains pending until the owned rebase has settled and exact-attempt cleanup clears the marker; that cleanup then closes the task.
@@ -18,7 +18,11 @@ Admission checks the current plan revision, snapshot, review version, task state
1818

1919
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.
2020

21-
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.
21+
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. The production adapter must expose the rebase worktree only through lane D's bounded task storage; this engine callback is trusted orchestration, not agent code, and is not permission to mount the host worktree or its shared Git metadata directly.
22+
23+
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.
24+
25+
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.
2226

2327
## Restart recovery
2428

@@ -28,4 +32,4 @@ This ordering is intentionally local-only. The later push integration must exten
2832

2933
## Regression evidence
3034

31-
`test/runner-rebase.test.ts` covers clean one-to-one replay, the already-current no-op, process-group ownership, the whole-operation deadline, conflicts, cancellation before and during Git, exact cleanup, retained refs, and a read-only source checkout. `test/runner-lifecycle-store.test.ts` covers admission, durable process ownership, and compare-and-swap races plus owned/foreign provenance. `test/runner-recovery.test.ts` covers process termination and restart ordering plus fail-closed absence of a recovery implementation. `test/runner-production.test.ts` covers exact-plan recovery routing.
35+
`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-lifecycle-store.test.ts` covers admission, durable process ownership, compare-and-swap races, resolved-source validation, and owned/foreign provenance. `test/runner-recovery.test.ts` covers marker validation, process termination and restart ordering plus fail-closed absence of a recovery implementation. `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.

0 commit comments

Comments
 (0)