Skip to content
Merged
4 changes: 3 additions & 1 deletion core/approvals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export function stable(value: unknown): string {
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(',')}}`;
return JSON.stringify(value);
}
const contentKey = (s: Segment) => stable({ path: s.path, oldPath: s.oldPath, kind: s.kind, operation: s.operation, content: s.content });
const contentKey = (s: Segment) => stable({ path: s.path, oldPath: s.oldPath, kind: s.kind, operation: s.operation,
content: s.content, ...(s.conflictResolved ? { conflictResolved: true } : {}) });
export interface SegmentChoice { key: string; action: 'assign' | 'accept'; item: string | null }
/** Position among identical segments and total copies prevent approval transfer. */
export function choiceKeys(segments: readonly Segment[], identity: PlanIdentity): string[] {
Expand Down Expand Up @@ -37,6 +38,7 @@ export interface Approval { item: string; fingerprint: string }
export const reviewedSegment = (s: Segment) => ({
path: s.path, oldPath: s.oldPath, kind: s.kind, operation: s.operation,
content: s.content, context: s.context, owners: [...s.owners].sort(),
...(s.conflictResolved ? { conflictResolved: true } : {}),
});
export function fingerprint(item: PlanItem, segments: readonly Segment[], identity: PlanIdentity): string {
return stable({ identity: identityKey(identity), item, segments: segments.filter(s => s.row === item.id).map(reviewedSegment) });
Expand Down
18 changes: 12 additions & 6 deletions core/linking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ export interface Segment {
row: string; scope: 'in-scope' | 'out-of-scope' | 'unplanned' | 'ambiguous';
oldLine: number | null; newLine: number | null; operation: '+' | '-' | null;
content: string; context: string; hunk: number; sharesHunkWith: string[];
/** At least one contributing foreign commit was resolved by the sandboxed rebase agent. */
conflictResolved?: boolean;
}
interface Evidence { owners: (string | null)[]; outOfScope: string[] }
interface Evidence { owners: (string | null)[]; outOfScope: string[]; conflictResolved: boolean }
interface TrackedLine { text: string; evidence: Evidence; origins: string[]; moved: Evidence }
interface TrackedFile { lines: TrackedLine[]; metadata: Evidence; metadataPaths: string[] }
const unique = <T>(values: T[]): T[] => [...new Set(values)];
Expand All @@ -30,7 +32,7 @@ function metadataChange(delta: FileDelta): boolean {
return delta.oldPath !== delta.newPath || delta.before?.mode !== delta.after?.mode ||
!textFile(delta.before) || !textFile(delta.after);
}
const empty = (): Evidence => ({ owners: [], outOfScope: [] });
const empty = (): Evidence => ({ owners: [], outOfScope: [], conflictResolved: false });
function classify(evidence: Evidence): Pick<Segment, 'row' | 'scope'> {
const { owners, outOfScope } = evidence;
if (!owners.length || owners.includes(null)) return { row: 'Unplanned', scope: 'unplanned' };
Expand All @@ -42,7 +44,8 @@ function classify(evidence: Evidence): Pick<Segment, 'row' | 'scope'> {
export interface LinkingLimits { maxLines?: number; maxSegments?: number; maxReferences?: number; maxDurationMs?: number }

/** Replays a linear history. Commit messages and Plan-Item trailers are never trusted. */
export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<string, string | null>, pathKey: (path: string) => string, limits: LinkingLimits = {}): Segment[] {
export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<string, string | null>, pathKey: (path: string) => string,
limits: LinkingLimits = {}, resolvedConflicts: ReadonlySet<string> = new Set()): Segment[] {
if (typeof pathKey !== 'function') throw new Error('Known checkout path identity is required.');
const budget = (value: number | undefined, ceiling: number, name: string) => {
const limit = value ?? ceiling;
Expand Down Expand Up @@ -87,7 +90,7 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
for (const owner of entry.owners) owners.add(owner);
for (const owner of entry.outOfScope) outOfScope.add(owner);
}
return { owners: [...owners], outOfScope: [...outOfScope] };
return { owners: [...owners], outOfScope: [...outOfScope], conflictResolved: evidence.some(entry => entry.conflictResolved) };
};
const mergeOrigins = (tracked: readonly TrackedLine[]): string[] => {
const origins = new Set<string>();
Expand All @@ -111,7 +114,8 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
const item = plan.items.find(item => item.id === owner);
const declared = new Set(item?.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey));
const touched = [delta.oldPath, delta.newPath].filter((path): path is string => path !== null);
const current: Evidence = { owners: [owner], outOfScope: owner !== null && touched.some(path => !declared.has(pathKey(path))) ? [owner] : [] };
const current: Evidence = { owners: [owner], outOfScope: owner !== null && touched.some(path => !declared.has(pathKey(path))) ? [owner] : [],
conflictResolved: owner === null && resolvedConflicts.has(commit.sha) };
let previous = oldPath ? files.get(oldPath) : undefined;
if (!previous) previous = {
lines: lines(textFile(delta.before) ? delta.before!.text : '').map((text, i) => {
Expand Down Expand Up @@ -170,7 +174,8 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
const push = (part: Omit<Segment, 'row' | 'scope' | 'sharesHunkWith' | 'owners'>, evidence: Evidence) => {
remaining();
if (++segmentCount > maxSegments) throw new Error('Linking exceeds its cumulative segment budget.');
fileSegments.push({ ...part, owners: evidence.owners, ...classify(evidence), sharesHunkWith: [] });
fileSegments.push({ ...part, owners: evidence.owners, ...classify(evidence), sharesHunkWith: [],
...(evidence.conflictResolved ? { conflictResolved: true } : {}) });
};
if (metadataChange(delta)) {
const evidence = combine(affectedPaths.map(path => metadata.get(path) ?? empty()));
Expand Down Expand Up @@ -213,6 +218,7 @@ export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<st
const last = grouped.at(-1);
if (last && part.kind === 'text' && last.kind === 'text' && last.hunk === part.hunk &&
last.operation === part.operation && last.context === part.context && last.scope === part.scope &&
!!last.conflictResolved === !!part.conflictResolved &&
(part.operation === '+' ? last.newLine! + groupedLineCount === part.newLine : last.oldLine! + groupedLineCount === part.oldLine) &&
JSON.stringify(last.owners) === JSON.stringify(part.owners)) { last.content += part.content; groupedLineCount++; }
else { grouped.push({ ...part }); groupedLineCount = part.kind === 'text' ? 1 : 0; }
Expand Down
14 changes: 9 additions & 5 deletions docs/implementation/pre-merge-rebase.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Pre-merge rebase foundation
# Pre-merge rebase and foreign-conflict boundary

**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.
**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.

## Ownership and lifecycle

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

```text
{ attemptId, oldBase, oldHead, onto, oldHistory, startedAt, resultState, resultHead, resultMappings, processGroup }
{ attemptId, oldBase, oldHead, onto, oldHistory, startedAt, resultState, resultHead, resultMappings, resolvedConflicts, processGroup }
```

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.
Expand All @@ -18,7 +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.

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

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.

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.

## Restart recovery

Expand All @@ -28,4 +32,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, 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.
`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.
Loading
Loading