Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions agents/process-group.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { NOT_STARTED, type DockerOutcome } from './docker.ts';

/** A subprocess's process group, reported as soon as it exists so the caller can record it durably. */
export interface ProcessGroup {
/** The group ID, which is the leader's PID. */
readonly pgid: number;
/** `Date.now()` right after the spawn; with the ID, it tells this group from a later one that reuses the ID. */
/** `Date.now()` right after the spawn, retained for lifecycle timing and diagnostics. */
readonly startedAt: number;
/** Linux boot ID plus kernel start ticks. Null means crash recovery must not signal this group automatically. */
readonly identity: string | null;
}

/** A kernel-backed identity that changes across both PID reuse and host reboot. */
export function processIdentity(pid: number): string | null {
if (process.platform !== 'linux') return null;
try {
const boot = readFileSync('/proc/sys/kernel/random/boot_id', 'ascii').trim();
const stat = readFileSync(`/proc/${pid}/stat`, 'ascii'), close = stat.lastIndexOf(')');
const fields = close < 0 ? [] : stat.slice(close + 2).trim().split(/ +/);
const ticks = fields[19]; // field 22 overall; fields starts with field 3 (`state`).
if (!/^[0-9a-f-]{36}$/.test(boot) || !ticks || !/^\d+$/.test(ticks)) return null;
return `linux:${boot}:${ticks}`;
} catch { return null; }
}
export interface ProcessGroupOptions {
readonly env: NodeJS.ProcessEnv;
Expand All @@ -20,6 +36,8 @@ export interface ProcessGroupOptions {
readonly graceMs?: number;
/** Bytes kept of each of stdout and stderr; more stops the group. Default 16 MiB. */
readonly maxBuffer?: number;
/** Keep only the bounded prefix instead of stopping on excess output. Use only when exit status/state decides meaning. */
readonly discardExcessOutput?: boolean;
/**
* Written to the leader's stdin, which is then closed; without it stdin is not connected. A leader that exits before
* reading it all is not an error: what it did is in its status and output.
Expand Down Expand Up @@ -112,17 +130,18 @@ export function runInProcessGroup(file: string, args: readonly string[],
// If the caller cannot record the group, the child must not outlive this call: it is killed at once below, and
// the call still settles only after the group has exited, with the caller's error.
let unrecorded: unknown;
try { options.onProcessGroup?.(Object.freeze({ pgid, startedAt: Date.now() })); }
try { options.onProcessGroup?.(Object.freeze({ pgid, startedAt: Date.now(), identity: processIdentity(pgid) })); }
catch (error) { unrecorded = error; }
// Output past the limit stops a running group; once the leader has exited it only drops the excess. Either way the
// output is incomplete, so the call settles as ENOBUFS, never as a success.
// Normally output past the limit stops the group and settles as ENOBUFS. A caller that classifies from durable state
// may explicitly keep a bounded prefix and let the process finish instead.
let truncated = false;
const collect = (chunks: Buffer[], add: (bytes: number) => number) => (chunk: Buffer) => {
if (add(chunk.length) > maxBuffer) { truncated = true; stop('output-limit'); }
else chunks.push(chunk);
const collect = (chunks: Buffer[], used: () => number, add: (bytes: number) => number) => (chunk: Buffer) => {
const room = Math.max(0, maxBuffer - used());
if (room > 0) chunks.push(chunk.subarray(0, room));
if (add(chunk.length) > maxBuffer) { truncated = true; if (!options.discardExcessOutput) stop('output-limit'); }
};
child.stdout!.on('data', collect(out, bytes => (outBytes += bytes)));
child.stderr!.on('data', collect(err, bytes => (errBytes += bytes)));
child.stdout!.on('data', collect(out, () => outBytes, bytes => (outBytes += bytes)));
child.stderr!.on('data', collect(err, () => errBytes, bytes => (errBytes += bytes)));
const onAbort = () => stop('cancelled');
options.signal?.addEventListener('abort', onAbort, { once: true });
if (unrecorded !== undefined) { stopped = 'unrecorded'; signalGroup(pgid, 'SIGKILL'); }
Expand Down Expand Up @@ -178,7 +197,7 @@ export function runInProcessGroup(file: string, args: readonly string[],
{ name: 'AbortError', code: 'ABORT_ERR' }) });
return;
}
if (truncated && !stopped) {
if (truncated && !stopped && !options.discardExcessOutput) {
resolve({ status: null, stdout, stderr, error: Object.assign(
new Error(`${file} ${args[0] ?? ''} exceeded its output limit.`), { code: 'ENOBUFS' }) });
return;
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ The diagram shows the designed transitions. With the `runner` block, running, ne

### Stored data

All state is in one SQLite file, opened with WAL and full synchronization. Each write takes an immediate write lock. The schema version is `PRAGMA user_version`, and migrations run in explicit steps (version 15 today); an unknown version fails.
All state is in one SQLite file, opened with WAL and full synchronization. Each write takes an immediate write lock. The schema version is `PRAGMA user_version`, and migrations run in explicit steps (version 16 today); an unknown version fails.

| Group | Tables | Notes |
|---|---|---|
Expand Down
31 changes: 31 additions & 0 deletions docs/implementation/pre-merge-rebase.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Pre-merge rebase foundation

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

## 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 }
```

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.

`processGroup` is null only while no Git call owns work. Immediately before spawn it becomes `spawning`; the synchronous spawn callback replaces that with `{ pgid, startedAt, identity }`. On Linux, `identity` combines the host boot ID with the leader's `/proc` start ticks. After that exact group is confirmed settled, a compare-and-swap clears it. A group that cannot be drained remains recorded, and a descendant that escapes the group leaves `unsettled`; restart recovery fails closed on either unknown state. A duplicate spawn or late settlement therefore cannot replace or erase another live process owner.

`GitRebaser` works only in `<runner.root>/<runner owner>/rebases/<attempt UUID>`, beside the runner-owned bare repository. It accepts at most 500 commits and requires the old history to be complete, linear, descended from the recorded base, and byte-for-byte equal to the ordered history in the durable marker. Git runs with user/system configuration, hooks, replacement objects, lazy fetches, maintenance, and submodule recursion disabled. Before and after replay, a bounded verifier reads the index as NUL-delimited bytes, checks path spellings against raw directory entries, and compares file modes, symlink bytes, and path-filtered file hashes before requiring the index tree to equal the expected commit tree. It never enters a gitlink or trusts Git's stat cache. One monotonic deadline covers history inspection, worktree creation, replay, validation, ref retention, process-group teardown, and cleanup, with explicit budget reserved for teardown and cleanup. Rebase diagnostics retain only a bounded prefix without stopping Git; conflict classification reads the unmerged index instead of diagnostic text. Other abnormal process outcomes retain their timeout, cancellation, output-limit, or unsettled-group reason. If cleanup also fails, both failures are retained with the original as the cause. The rewrite uses one commit for every old commit, including empty or already-applied commits, so ledger mappings stay one-to-one. A successful head is retained at `refs/codeboost/rebases/<attempt UUID>`; its mutable worktree is removed before the call returns.

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.

## Restart recovery

Startup validates every stored marker before touching its resource. If its recorded process group is still alive, recovery re-reads the kernel identity immediately before signalling and requires the boot ID and `/proc` start ticks to match; a legacy or current marker with a null identity fails closed instead of signalling a numeric ID. Signal grace and post-kill settlement use monotonic bounded deadlines. If the group has disappeared, recovery independently proves that no process still has the rebase workspace open before clearing that group owner; Linux performs that potentially blocking `/proc` walk in an owned, killable subprocess whose settlement is inside the overall deadline. The production recovery adapter accepts only the configured plan key, then removes the matching worktree. It deletes a result ref only with Git's exact-old-value guard using the durably recorded `resultHead`; absence is already-clean success. A `prepared` marker preserves a different pre-existing value, while a `ready` marker fails closed if its ref changed. A legacy, pre-result, or reused-attempt marker without an intended value preserves any existing ref. Only after cleanup succeeds does `Store.abortRebase` compare-and-swap the same attempt ID to clear the marker. A missing adapter, malformed marker, wrong plan, process or cleanup failure, workspace user, or changed marker blocks startup and retains the durable ownership evidence.

This ordering is intentionally local-only. The later push integration must extend the durable lifecycle before its first external write and reconcile an ambiguous push outcome; it must not reuse this local completion point as proof that GitHub moved.

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