diff --git a/examples/babysitter/FIXER.md b/examples/babysitter/FIXER.md index 1fec57ab4..4deec23a6 100644 --- a/examples/babysitter/FIXER.md +++ b/examples/babysitter/FIXER.md @@ -30,6 +30,13 @@ from #4226, and Cloud publishes server-side. are removed: a previous agent turn may have written them. - An existing checkout (a reused sandbox) is fetched, reset to the head and cleaned in place. Ignored files, such as installed dependencies, survive. + - **Per-PR reuse (C2):** Cloud may hand a later wake the same sandbox. + Each run root is created anew, so the checkout lives in + `$HOME/.babysitter////checkout` between runs. It is + moved (renamed, dependencies included) into the run root before + `checkoutHead`, and moved back after the run, whatever its outcome, + before `f.done`. If the cache is on another filesystem, or damaged, the + run starts fresh. 2. **One agent** (`babysitter-fix`, the origin session's CLI, with a literal pinned model per CLI, ignoring the policy's `agentCli`) runs in that checkout. Its task opens with the origin's first prompt, verbatim and diff --git a/examples/babysitter/artifacts/babysitter-fixer.flow.ts b/examples/babysitter/artifacts/babysitter-fixer.flow.ts index b2ff5ec01..8bc589483 100644 --- a/examples/babysitter/artifacts/babysitter-fixer.flow.ts +++ b/examples/babysitter/artifacts/babysitter-fixer.flow.ts @@ -607,8 +607,9 @@ var SUMMARY_MAX_CHARS = 4e3; var REFUSED_PATHS = String.raw`^\.github/workflows/|(^|/)\.env($|\.)|\.(pem|key|p12|pfx|jks)$|(^|/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$|(^|/)\.(npmrc|netrc|pypirc)$|(^|/)secrets?/`; async function checkoutHead(c) { const { execFileSync } = await import("node:child_process"); - const { existsSync, mkdirSync, rmSync, writeFileSync } = await import("node:fs"); - const { resolve } = await import("node:path"); + const { createHash } = await import("node:crypto"); + const { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } = await import("node:fs"); + const { join, resolve } = await import("node:path"); if (!process.env.GH_TOKEN) throw new Error("GH_TOKEN required"); const dir = resolve("babysitter-checkout"); const auth = Buffer.from(`x-access-token:${process.env.GH_TOKEN}`).toString("base64"); @@ -626,21 +627,69 @@ async function checkoutHead(c) { GIT_CONFIG_VALUE_2: "false" }; const git = (...args) => String(execFileSync("git", ["-C", dir, ...args], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })).trim(); - const reused = existsSync(`${dir}/.git`); - if (!reused) { - mkdirSync(dir, { recursive: true }); - git("init", "-q"); - } - writeFileSync(`${dir}/.git/config`, "[core]\n repositoryformatversion = 0\n filemode = true\n bare = false\n"); - rmSync(`${dir}/.git/hooks`, { recursive: true, force: true }); - rmSync(`${dir}/.git/info/attributes`, { force: true }); - git("fetch", "-q", "--no-tags", "--depth=50", `https://github.com/${c.owner}/${c.repo}.git`, c.head); - git("checkout", "-q", "--force", "--detach", c.head); + const MANIFESTS = /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|requirements[\w.-]*\.txt|pyproject\.toml|poetry\.lock|Pipfile\.lock|uv\.lock|Cargo\.lock|go\.sum|Gemfile\.lock|composer\.lock)$/; + const SKIP = /* @__PURE__ */ new Set([".git", "node_modules", "vendor", "target", "dist", "build", ".venv", "venv"]); + const fingerprint = () => { + const hash = createHash("sha256"); + const walk = (at, rel, depth) => { + if (depth > 6) return; + for (const entry2 of readdirSync(at, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (entry2.isDirectory() && !SKIP.has(entry2.name)) walk(join(at, entry2.name), `${rel}${entry2.name}/`, depth + 1); + else if (entry2.isFile() && MANIFESTS.test(entry2.name)) hash.update(`${rel}${entry2.name}\0`).update(readFileSync(join(at, entry2.name))).update("\0"); + } + }; + walk(dir, "", 0); + return hash.digest("hex"); + }; + const reused = existsSync(dir); + mkdirSync(dir, { recursive: true }); + const before = fingerprint(); + rmSync(join(dir, ".git"), { recursive: true, force: true }); + git("init", "-q"); + git("fetch", "-q", "--no-tags", "--depth=50", c.origin ?? `https://github.com/${c.owner}/${c.repo}.git`, c.head); git("reset", "-q", "--hard", c.head); - git("clean", "-q", "-fd"); + git("clean", "-q", reused && fingerprint() !== before ? "-fdx" : "-fd"); if (git("rev-parse", "HEAD") !== c.head) throw new Error("Checkout is not at the bound head"); process.stdout.write(JSON.stringify({ dir, head: c.head, reused })); } +async function restoreCheckout(c) { + const { existsSync, renameSync, rmSync } = await import("node:fs"); + const { homedir } = await import("node:os"); + const { join, resolve } = await import("node:path"); + const cache = join(c.home ?? homedir(), ".babysitter", c.owner.toLowerCase(), c.repo.toLowerCase(), String(c.number), "checkout"); + const dir = resolve("babysitter-checkout"); + let restored = false; + try { + if (existsSync(cache) && !existsSync(dir)) { + renameSync(cache, dir); + restored = true; + } + } catch { + try { + rmSync(cache, { recursive: true, force: true }); + } catch { + } + } + process.stdout.write(JSON.stringify({ restored })); +} +async function stashCheckout(c) { + const { existsSync, mkdirSync, renameSync, rmSync } = await import("node:fs"); + const { homedir } = await import("node:os"); + const { dirname, join, resolve } = await import("node:path"); + const cache = join(c.home ?? homedir(), ".babysitter", c.owner.toLowerCase(), c.repo.toLowerCase(), String(c.number), "checkout"); + const dir = resolve("babysitter-checkout"); + let stashed = false; + try { + if (existsSync(dir)) { + rmSync(cache, { recursive: true, force: true }); + mkdirSync(dirname(cache), { recursive: true }); + renameSync(dir, cache); + stashed = true; + } + } catch { + } + process.stdout.write(JSON.stringify({ stashed })); +} async function proposeChanges(c) { const { execFileSync } = await import("node:child_process"); const { rmSync, writeFileSync } = await import("node:fs"); @@ -677,6 +726,12 @@ async function proposeChanges(c) { if (Buffer.byteLength(out) > c.limits.proposalBytes) return refuse("proposal exceeds the journal output bound"); process.stdout.write(out); } +async function restore(f, pr) { + return JSON.parse(await f.run(nodeCommand(restoreCheckout, { owner: pr.owner, repo: pr.repo, number: pr.number }), { timeout: "2m" })).restored === true; +} +async function stash(f, pr) { + await f.run(nodeCommand(stashCheckout, { owner: pr.owner, repo: pr.repo, number: pr.number }), { timeout: "2m" }); +} async function checkout(f, pr, head) { const value = JSON.parse(await f.run(nodeCommand(checkoutHead, { owner: pr.owner, repo: pr.repo, head }), { timeout: "5m" })); if (value.head !== head || typeof value.dir !== "string") throw new Error("Checkout did not report the bound head"); @@ -730,27 +785,36 @@ function createStandaloneFixer(policy, runtime = capabilities) { ); if (!a) return; const { pr, wake, origin, c, head, report } = a; - const work = await checkout(f, pr, head); - const task = agentTask(origin, `${pr.owner}/${pr.repo}#${pr.number}`, head, a.changed, "fix"); - const result = origin.source === "codex" ? await f.agent("babysitter-fix", { cli: "codex", model: "gpt-5.6-sol", cwd: work.dir, permissions: { accessPreset: "readwrite" }, task }) : await f.agent("babysitter-fix", { cli: "claude", model: "claude-sonnet-5", cwd: work.dir, permissions: { accessPreset: "readwrite" }, task }); - const final = await readState(f, c); - if (final.headSha !== head || outOfScope(final, c, configured.label)) { - await report(`${wake.id}: head moved or PR left scope while fixing; no proposal for ${head}`); - return f.done("declined"); - } - const outcome = parseOutcome(result.summary); - const proposal = await propose(f, { - dir: work.dir, - head, - pullRequest: { owner: pr.owner, repo: pr.repo, number: pr.number }, - summary: neutralise(outcome.summary, origin.firstPrompt).slice(0, SUMMARY_MAX_CHARS), - replies: threadReplies(a, outcome.replies) - }); - if (proposal.kind === "babysitter-refusal") { - await report(`${wake.id}: proposal refused: ${proposal.reason}`); - return f.done("needs_human", { detail: `Babysitter proposal refused: ${proposal.reason}` }); + await restore(f, pr); + let verdict; + try { + const work = await checkout(f, pr, head); + const task = agentTask(origin, `${pr.owner}/${pr.repo}#${pr.number}`, head, a.changed, "fix"); + const result = origin.source === "codex" ? await f.agent("babysitter-fix", { cli: "codex", model: "gpt-5.6-sol", cwd: work.dir, permissions: { accessPreset: "readwrite" }, task }) : await f.agent("babysitter-fix", { cli: "claude", model: "claude-sonnet-5", cwd: work.dir, permissions: { accessPreset: "readwrite" }, task }); + const final = await readState(f, c); + if (final.headSha !== head || outOfScope(final, c, configured.label)) { + await report(`${wake.id}: head moved or PR left scope while fixing; no proposal for ${head}`); + verdict = { reason: "declined" }; + } else { + const outcome = parseOutcome(result.summary); + const proposal = await propose(f, { + dir: work.dir, + head, + pullRequest: { owner: pr.owner, repo: pr.repo, number: pr.number }, + summary: neutralise(outcome.summary, origin.firstPrompt).slice(0, SUMMARY_MAX_CHARS), + replies: threadReplies(a, outcome.replies) + }); + if (proposal.kind === "babysitter-refusal") { + await report(`${wake.id}: proposal refused: ${proposal.reason}`); + verdict = { reason: "needs_human", detail: `Babysitter proposal refused: ${proposal.reason}` }; + } else { + verdict = { reason: "success", detail: `Babysitter proposal for ${head}: ${proposal.files.length} file(s)` }; + } + } + } finally { + await stash(f, pr); } - f.done("success", { detail: `Babysitter proposal for ${head}: ${proposal.files.length} file(s)` }); + f.done(verdict.reason, verdict.detail ? { detail: verdict.detail } : void 0); }; return subscriptions.reduce( (handle, subscription) => handle.on(subscription.trigger, body), diff --git a/examples/babysitter/artifacts/babysitter-fixer.manifest.json b/examples/babysitter/artifacts/babysitter-fixer.manifest.json index 8606c5770..6e7b7fde4 100644 --- a/examples/babysitter/artifacts/babysitter-fixer.manifest.json +++ b/examples/babysitter/artifacts/babysitter-fixer.manifest.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "artifact": "babysitter-fixer.flow.ts", - "bytes": 41908, - "sha256": "797ce37d24831fd4ed8091020ef18a5e3a77f1e18605f6648e315a096a1a6cc6", + "bytes": 44812, + "sha256": "d69e3e4f511cba1c668438101a2fb7540fc3fc97b31a0c0eb854fab1791a7487", "policy": "../standalone-policy.json", "policySha256": "e2fd08aaef4ea922146ad1330d7b7d7161f078f9c19e1499aa272636ae6e39a2", "runtime": { diff --git a/examples/babysitter/evidence/f3-checkout-cache/01-red.txt b/examples/babysitter/evidence/f3-checkout-cache/01-red.txt new file mode 100644 index 000000000..abe9c9b2b --- /dev/null +++ b/examples/babysitter/evidence/f3-checkout-cache/01-red.txt @@ -0,0 +1,63 @@ +# fix.ts/fixer.ts at 01149375; tests from the F3 commit. +$ node --experimental-strip-types --test tests/fixer.test.ts +✔ the fixer checks out the bound head, runs one agent there with fix rules, and journals one proposal (2.870232ms) +✔ the fixer never writes to GitHub itself: no push, merge, review, or comment calls (0.630366ms) +✔ thread replies go only to inline feedback this run was woken by, once each, marked and neutralised (0.535859ms) +✔ a head that moves while fixing declines without a proposal (0.317191ms) +✔ a refused proposal ends needs_human (0.397742ms) +✔ by default the fixer dispatches no agent and checks nothing out (gate 8 / #442) (0.938991ms) +✔ the agent outcome is read from a JSON message, a fenced JSON block, or falls back to prose (0.195023ms) +✔ a real proposal covers edits, new files and the agent's own commits, all against the bound head (72.917016ms) +✔ workflow, secret, oversized and too-wide proposals are refused whole (328.176771ms) +✔ an untouched checkout proposes an empty patch (replies and summary may still publish) (43.603598ms) +✔ the proposal script runs as f.run runs it: a stringified `node -e` command in a shell (75.060774ms) +✔ the proposal script lifted from the shipped artifact runs under `node -e` (72.127132ms) +✔ the proposal never runs code the agent planted in the checkout's git config, and its diff format is fixed (52.673283ms) +✔ paths git would have to quote are refused; a newline cannot split a name past the workflow rule (118.653171ms) +✔ a non-ASCII name is proposed unquoted, so Cloud parses the same path (42.946322ms) +✔ the fixer runs the origin session's own CLI with its literal pinned model (1.037575ms) +✖ a PR checkout survives between runs: restored into the run root, stashed back after (0.61106ms) +✖ the fixer restores the cached checkout before checking out, and stashes it even when the agent fails (1.443883ms) +ℹ tests 18 +ℹ suites 0 +ℹ pass 16 +ℹ fail 2 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 934.900586 + +✖ failing tests: + +test at tests/fixer.test.ts:267:1 +✖ a PR checkout survives between runs: restored into the run root, stashed back after (0.61106ms) + TypeError: restoreCheckout is not a function + at file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:280:40 + at capture (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:276:17) + at TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:280:26) + at async Test.run (node:internal/test_runner/test:1409:7) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) + +test at tests/fixer.test.ts:297:1 +✖ the fixer restores the cached checkout before checking out, and stashes it even when the agent fails (1.443883ms) + AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: + + actual - expected + + [ + - 'restoreCheckout', + 'checkoutHead', + 'proposeChanges', + - 'stashCheckout' + ] + + at TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:301:10) + at async Test.run (node:internal/test_runner/test:1409:7) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: [ 'checkoutHead', 'proposeChanges' ], + expected: [ 'restoreCheckout', 'checkoutHead', 'proposeChanges', 'stashCheckout' ], + operator: 'deepStrictEqual', + diff: 'simple' + } +exit=1 diff --git a/examples/babysitter/evidence/f3-checkout-cache/02-all-babysitter-green.txt b/examples/babysitter/evidence/f3-checkout-cache/02-all-babysitter-green.txt new file mode 100644 index 000000000..d572c6edd --- /dev/null +++ b/examples/babysitter/evidence/f3-checkout-cache/02-all-babysitter-green.txt @@ -0,0 +1,298 @@ +$ (cd examples/babysitter && node --experimental-strip-types --test tests/*.test.ts) +✔ the committed fixer artifact is exact, reproducible, and asserts the enforced agent scope (310.400844ms) +✔ scripts stringified into f.run never reach a bundler shim: `node -e` has no __require (15.590801ms) +✔ the fixer checks out the bound head, runs one agent there with fix rules, and journals one proposal (25.407478ms) +✔ the fixer never writes to GitHub itself: no push, merge, review, or comment calls (2.547413ms) +✔ thread replies go only to inline feedback this run was woken by, once each, marked and neutralised (1.862385ms) +✔ a head that moves while fixing declines without a proposal (1.753682ms) +✔ a refused proposal ends needs_human (2.580455ms) +✔ by default the fixer dispatches no agent and checks nothing out (gate 8 / #442) (4.629028ms) +✔ the agent outcome is read from a JSON message, a fenced JSON block, or falls back to prose (0.49287ms) +✔ a real proposal covers edits, new files and the agent's own commits, all against the bound head (434.276312ms) +✔ workflow, secret, oversized and too-wide proposals are refused whole (1167.048654ms) +✔ an untouched checkout proposes an empty patch (replies and summary may still publish) (92.370783ms) +✔ the proposal script runs as f.run runs it: a stringified `node -e` command in a shell (131.238096ms) +✔ the proposal script lifted from the shipped artifact runs under `node -e` (135.92991ms) +✔ the proposal never runs code the agent planted in the checkout's git config, and its diff format is fixed (112.889038ms) +✔ paths git would have to quote are refused; a newline cannot split a name past the workflow rule (223.55011ms) +✔ a non-ASCII name is proposed unquoted, so Cloud parses the same path (86.951091ms) +✔ the fixer runs the origin session's own CLI with its literal pinned model (1.414409ms) +✔ a PR checkout survives between runs: restored into the run root, stashed back after (1.571012ms) +✔ the fixer restores the cached checkout before checking out, and stashes it even when the agent fails (1.956089ms) +✔ restore and stash lifted from the shipped artifact run under `node -e` (82.540912ms) +✔ a reused checkout keeps its dependencies but none of the agent's git state, and lands exactly on the head (189.525474ms) +✔ a dependency manifest that changed between heads drops the cached dependencies (180.280544ms) +✔ a damaged cached .git is replaced, not reused (85.209502ms) +✔ cache moves never fail the run: an unwritable cache only means the next run starts fresh (0.777451ms) +✔ flows check still sees the fixer's agent requirement (0.240209ms) +✔ checkoutHead lifted from the shipped artifact runs under `node -e` (102.000852ms) +✔ only registered direct-probe providers receive generated model pins (4.043995ms) +✔ provider executable basenames retain generated model defaults (2.579132ms) +✔ custom reviewer wrappers require and preserve an explicit model (1.595387ms) +✔ legacy reviewer binds slash-relative wrappers before probing and dispatch (0.311832ms) +✔ modern reviewer binds slash-relative wrappers before probing and dispatch (0.512727ms) +✔ legacy reviewer dispatches the same resolved wrapper to host-owned preflight (3.000098ms) +✔ legacy reviewer leaves provider basenames for host-owned preflight resolution (1.93456ms) +✔ blank legacy reviewer overrides fail before GitHub or repository effects (1.699812ms) +✔ approval-only legacy wakes do not probe an unused reviewer pair (3.771086ms) +✔ malformed input makes zero effects; missing live state declines before agents (1.444525ms) +✔ duplicate delivery cannot bypass missing publication or agent-scope capability (3.340683ms) +✔ live-head review/check wakes evaluate merge but do not simulate an unavailable receipt (3.045092ms) +✔ authorized conflict wakes hand off unresolved semantic work; ordinary/unauthorized comments decline (3.949679ms) +✔ merge transport sends the bound live SHA and fails if provider does not confirm (1.562436ms) +⚠ live-head end-to-end review and merge acceptance remains RED (3.047947ms) # Requires enforced agent scopes and durable fenced publication; the passing merge transport unit test is not end-to-end acceptance +✔ malformed external inputs fail before shell or API (2.453869ms) +✔ live skip and author rules fail closed (0.706318ms) +✔ READY rejects missing pending red conflicts stale heads and missing state (0.608054ms) +✔ a later comment or pending review cannot hide a standing verdict (0.784714ms) +✔ merge requires opt in org and configured independent approver at exact SHA (0.465248ms) +✔ conflict command is explicit authorized and same repository only (0.416086ms) +✔ edits fail closed for semantic changes, fork, protected paths, tests and unresolved conflicts (1.085255ms) +✔ artifacts are nonempty schema validated exact head and reconcile deterministically (1.584907ms) +✔ publication refuses stale post race newer verdict spoof and absent atomic capability (1.043527ms) +✔ once per head keys and sticky CI attribution survive duplicate wake decisions (0.689447ms) +✔ one bounded infra retry only for 137/143 (0.438027ms) +✔ every hosted subscription reaches the same authoritative reread with pinned coordinates (11.069966ms) +✔ hosted delivery fields cannot redirect policy, coordinates or live-head binding (9.438962ms) +✔ wrong repository, PR, host, provider or subscription refuse before the live read (5.246499ms) +✔ case-insensitive Cloud coordinates work and policy is snapshotted at construction (1.105192ms) +✔ normalized comment wake rereads then holds for missing original repair directive (6.8439ms) +✔ invalid operator policy is refused when the hosted source loads (1.08218ms) +✔ Cloud's enriched pull request is a hint, not state: draft, labels, author and review cannot decide (2.995539ms) +✔ a delivered approved review cannot open the merge gate (1.542939ms) +✔ the manifest declares exactly the subscription contract, family by family (2.700038ms) +✔ merge-gate is a live-state predicate: no matching PR is a pass, a held gate throws the reason (1.634961ms) +✔ the manifest budget is the flow header budget, and the entry name is the file the flow lives in (0.784805ms) +⚠ atomicPrPublication (2.988877ms) # Provider per-PR serialization + head-guarded idempotent owned-comment upsert +⚠ durableCrossRunNotification (1.220287ms) # Durable per-head notification receipt and atomic delivery claim +⚠ journaledCiObservation (0.564594ms) # Journal-backed script-memory write/recall across runs (memory.learn currently refuses) +⚠ enforcedAgentWriteScope (0.378206ms) # Enforced readonly agent workspace and credential scopes (gate 8 / #442) +⚠ selectiveAgentExitRetry (0.273631ms) # Typed agent exit-code/retry policy in authored Step; AgentResult has summary/artifacts and success/timeout completionReason +⚠ durableSubscriptionLiveness (0.202268ms) # Durable cross-run wake records for the liveness sweep; a subscription that stops firing is currently indistinguishable from a quiet one +✔ a failing commit status is a failing check; a newer success on the same context clears an older failure (16.120239ms) +✔ reviews are paginated before standing verdicts are derived (6.169722ms) +✔ a report older than any comment window is still found; only comments after the last report are returned (7.006453ms) +✔ a marker in someone else's comment is not a report (4.762386ms) +✔ a head that moves during the read refuses (3.200401ms) +✔ failing check runs at the head are failing checks; running, passing and other-head runs are not (5.092853ms) +✔ settleReport keeps the earliest report for a head and deletes a later duplicate of its own (1.441229ms) +✔ a PR full of long reviews, comments and failing checks still fits the journal and keeps every actor (20.888747ms) +✔ only a report for the current head counts, and report history never grows the payload (12.20324ms) +✔ when shortened text still overflows, the oldest comments go first at the 200-char floor (20.503057ms) +✔ inline review comments from the author, members and allowlisted review bots are feedback; outsiders, other bots and this bot are not (16.236445ms) +✔ a thread Babysitter already answered, an outdated comment, and feedback before its last report are not feedback (4.382207ms) +✔ a commented review body is feedback; an empty one, an outsider's, or a change request (already a verdict) is not (3.715232ms) +✔ review feedback is shortened and, past the floor, dropped oldest first so the read still fits the journal (31.96859ms) +✔ our own agent accounts are never feedback, and their thread replies answer the thread (1.174973ms) +✔ the Garden sharing Babysitter's app login does not look like Babysitter: only marked comments move the window or answer threads (3.571874ms) +✔ file-level review comments are feedback; a line comment GitHub marked outdated (no diff position) is not (1.165274ms) +✔ a pending review comment counts from its review's submission, and feedback at the report's own second is kept (1.238201ms) +✔ the committed standalone artifact is exact, reproducible, and explicitly read-only (308.676276ms) +✔ without originContext the run ends needs_human with zero f.agent calls and no live read (5.915217ms) +✔ with originContext the agent task carries the first prompt verbatim, delimited as the task definition (2.402362ms) +✔ two deliveries with different ids and the same live state log the same decision key (3.098091ms) +✔ anything that is not the bound PR is refused before any effect (5.785605ms) +✔ a missing or malformed server-claimed head fails closed before live state or diagnosis (0.64323ms) +✔ a live head different from the server-claimed head declines before signals, diagnosis or comment (0.464717ms) +✔ live state decides: closed, draft, missing babysit label or nothing actionable decline without an agent (2.529449ms) +✔ a change request or an authorised directive is actionable; an outsider directive is not (1.882703ms) +✔ a head already reported by Babysitter declines before the agent (0.420054ms) +✔ the reader is asked for this bot's reports at the bound head (0.640445ms) +✔ withdrawing the opt-in label during diagnosis stops the report (0.406478ms) +✔ a push racing the comment marks the posted report superseded for the new head (1.747681ms) +✔ a head that moves while the agent runs declines instead of reporting on a stale head (0.381682ms) +✔ report is one owned PR comment naming the inherited session, never the prompt text, with mentions neutralised (1.07731ms) +✔ the agent CLI follows the origin session: Claude and Codex both work (3.577005ms) +✔ by default no agent is dispatched: untrusted PR content needs enforced write scope first (gate 8 / #442) (0.766339ms) +✔ invalid policy is refused when the source loads (0.883458ms) +✔ withdrawing the opt-in label while the comment posts marks the report withdrawn (0.727668ms) +✔ the agent echoing the original prompt does not publish it (0.527344ms) +✔ a concurrent run that already reported this head wins: the later comment removes itself (1.201622ms) +✔ unanswered review feedback alone is actionable and reaches the agent with its location (0.721105ms) +✔ the reader is told the live PR author and the policy's review bots (0.967154ms) +✔ review bots must be a list of [bot] logins (0.218688ms) +✔ Software Garden PRs (same-repo relayflow/* heads) are in scope by default; any other PR needs the opt-in label (0.805032ms) +✔ a Software Garden draft is babysat (Garden drafts failing PRs); a label opt-in draft is not (0.512907ms) +✔ an @babysitter directive from one of our own agents is not a directive (0.297164ms) +✔ file-level review feedback renders its path without a line (0.422479ms) +✔ the declared subscription contract is exactly the required resident set (4.398337ms) +✔ every declared subscription registers one handler bound to the one body (2.613998ms) +✔ each trigger filters to its own provider, type and action (3.6769ms) +✔ compatibility entry points still resolve to this one implementation (1.722234ms) +✔ subscription preflight passes the declared contract and has teeth (1.640271ms) +✔ preflight refuses an undeliverable subscription rather than deploying it (1.019262ms) +✔ a delivery is accepted as a hint; only routing is enforced (4.536404ms) +✔ a fork check_run with no PR attribution still wakes and rereads (2.900181ms) +✔ repository routing compares owner and repository case-insensitively (2.715657ms) +✔ a stale hinted head is recorded, never enforced, and never binds (0.844326ms) +✔ an operator pin constrains the run; it never substitutes for live state (0.739631ms) +✔ every wake rereads live state before it decides anything (5.603244ms) +✔ a payload that disagrees with live state loses: closed hint, open PR (0.96453ms) +✔ a payload that disagrees with live state loses: open hint, closed PR (0.788321ms) +✔ a label hint is re-read from live state in both directions (1.424628ms) +✔ a review hint cannot assert an approval live state does not show (1.146319ms) +✔ a check hint cannot assert a conclusion live state does not show (0.815342ms) +✔ duplicate delivery repeats a decision instead of adding one (2.907224ms) +✔ out-of-order delivery decides about the head that exists now (1.49553ms) +✔ the decision key names the subscription and the live head, never the hint (1.562015ms) +✔ each wake emits one deterministic observation line (1.792594ms) +✔ the sweep separates a quiet subscription from a dead one (1.787886ms) +✔ a young subscription is pending, an old silent one is never (1.573516ms) +✔ the sweep refuses impossible windows and clock-faulted records (1.047024ms) +✔ the sweep expects exactly the subscriptions the flow registers (0.615037ms) +ℹ tests 141 +ℹ suites 0 +ℹ pass 134 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 7 +ℹ duration_ms 3801.358984 + +✖ failing tests: + +test at tests/flow.test.ts:175:1 +⚠ live-head end-to-end review and merge acceptance remains RED (3.047947ms) # Requires enforced agent scopes and durable fenced publication; the passing merge transport unit test is not end-to-end acceptance + AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: + + actual - expected + + [ + + 'needs_human' + - 'success' + ] + + at TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/flow.test.ts:177:10) + at async Test.run (node:internal/test_runner/test:1409:7) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: [ 'needs_human' ], + expected: [ 'success' ], + operator: 'deepStrictEqual', + diff: 'simple' + } + +test at tests/platform.test.ts:14:3 +⚠ atomicPrPublication (2.988877ms) # Provider per-PR serialization + head-guarded idempotent owned-comment upsert + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + false !== true + + at TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/platform.test.ts:14:49) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.start (node:internal/test_runner/test:1262:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: false, + expected: true, + operator: 'strictEqual', + diff: 'simple' + } + +test at tests/platform.test.ts:14:3 +⚠ durableCrossRunNotification (1.220287ms) # Durable per-head notification receipt and atomic delivery claim + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + false !== true + + at TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/platform.test.ts:14:49) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.processPendingSubtests (node:internal/test_runner/test:974:18) + at Test.postRun (node:internal/test_runner/test:1542:19) + at Test.run (node:internal/test_runner/test:1467:12) + at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: false, + expected: true, + operator: 'strictEqual', + diff: 'simple' + } + +test at tests/platform.test.ts:14:3 +⚠ journaledCiObservation (0.564594ms) # Journal-backed script-memory write/recall across runs (memory.learn currently refuses) + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + false !== true + + at TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/platform.test.ts:14:49) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.processPendingSubtests (node:internal/test_runner/test:974:18) + at Test.postRun (node:internal/test_runner/test:1542:19) + at Test.run (node:internal/test_runner/test:1467:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: false, + expected: true, + operator: 'strictEqual', + diff: 'simple' + } + +test at tests/platform.test.ts:14:3 +⚠ enforcedAgentWriteScope (0.378206ms) # Enforced readonly agent workspace and credential scopes (gate 8 / #442) + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + false !== true + + at TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/platform.test.ts:14:49) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.processPendingSubtests (node:internal/test_runner/test:974:18) + at Test.postRun (node:internal/test_runner/test:1542:19) + at Test.run (node:internal/test_runner/test:1467:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: false, + expected: true, + operator: 'strictEqual', + diff: 'simple' + } + +test at tests/platform.test.ts:14:3 +⚠ selectiveAgentExitRetry (0.273631ms) # Typed agent exit-code/retry policy in authored Step; AgentResult has summary/artifacts and success/timeout completionReason + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + false !== true + + at TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/platform.test.ts:14:49) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.processPendingSubtests (node:internal/test_runner/test:974:18) + at Test.postRun (node:internal/test_runner/test:1542:19) + at Test.run (node:internal/test_runner/test:1467:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: false, + expected: true, + operator: 'strictEqual', + diff: 'simple' + } + +test at tests/platform.test.ts:14:3 +⚠ durableSubscriptionLiveness (0.202268ms) # Durable cross-run wake records for the liveness sweep; a subscription that stops firing is currently indistinguishable from a quiet one + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + false !== true + + at TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/platform.test.ts:14:49) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.processPendingSubtests (node:internal/test_runner/test:974:18) + at Test.postRun (node:internal/test_runner/test:1542:19) + at Test.run (node:internal/test_runner/test:1467:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: false, + expected: true, + operator: 'strictEqual', + diff: 'simple' + } +exit=0 diff --git a/examples/babysitter/evidence/f3-checkout-cache/03-artifact-check.txt b/examples/babysitter/evidence/f3-checkout-cache/03-artifact-check.txt new file mode 100644 index 000000000..d1de18216 --- /dev/null +++ b/examples/babysitter/evidence/f3-checkout-cache/03-artifact-check.txt @@ -0,0 +1,4 @@ +$ node examples/babysitter/build-standalone.mjs --check +Checked sha256:580bab1d31bfe05b2be4cbdfa74f8669efdb1067ba4a4bf126f871b432d7f834 (babysitter-standalone) +Checked sha256:d69e3e4f511cba1c668438101a2fb7540fc3fc97b31a0c0eb854fab1791a7487 (babysitter-fixer) +exit=0 diff --git a/examples/babysitter/evidence/f3-checkout-cache/04-flows-check.txt b/examples/babysitter/evidence/f3-checkout-cache/04-flows-check.txt new file mode 100644 index 000000000..158427875 --- /dev/null +++ b/examples/babysitter/evidence/f3-checkout-cache/04-flows-check.txt @@ -0,0 +1,5 @@ +$ node packages/sdk/dist/cli.js check examples/babysitter/artifacts/babysitter-fixer.flow.ts +WARNING [daemon_unvalidated] relayflowd: authored body. Daemon acceptance is unproven; an already attached daemon from another build is not checked. +REQUIRES github (on github pull_request), codex (agent "babysitter-fix"), claude (agent "babysitter-fix") +CHECK PASSED examples/babysitter/artifacts/babysitter-fixer.flow.ts [local only: authored body, so daemon acceptance is unproven] +exit=0 diff --git a/examples/babysitter/evidence/f3-checkout-cache/05-sdk-shipped-source.txt b/examples/babysitter/evidence/f3-checkout-cache/05-sdk-shipped-source.txt new file mode 100644 index 000000000..3b028e8ba --- /dev/null +++ b/examples/babysitter/evidence/f3-checkout-cache/05-sdk-shipped-source.txt @@ -0,0 +1,10 @@ +$ (cd packages/sdk && ./node_modules/.bin/vitest run tests/shipped-source-models.test.ts) + ✓ tests/shipped-source-models.test.ts (2 tests) 114597ms + ✓ first-party shipped source model pins > gives every TypeScript agent and LLM an explicit supported pair or a pinned named-agent declaration 114565ms + + Test Files 1 passed (1) + Tests 2 passed (2) + Start at 20:45:46 + Duration 115.49s (transform 216ms, setup 27ms, collect 515ms, tests 114.60s, environment 0ms, prepare 103ms) + +exit=0 diff --git a/examples/babysitter/evidence/f3-checkout-cache/06-typecheck.txt b/examples/babysitter/evidence/f3-checkout-cache/06-typecheck.txt new file mode 100644 index 000000000..5fae39fd6 --- /dev/null +++ b/examples/babysitter/evidence/f3-checkout-cache/06-typecheck.txt @@ -0,0 +1,2 @@ +$ (cd examples/babysitter && ../../packages/sdk/node_modules/.bin/tsc -p tsconfig.json) | grep 'error TS' | grep -v 'TS2345.*Ctx\|relay-helpers\|ai-hist' +filtered-count=0 diff --git a/examples/babysitter/evidence/f3-checkout-cache/07-review1-red.txt b/examples/babysitter/evidence/f3-checkout-cache/07-review1-red.txt new file mode 100644 index 000000000..7a456bc2f --- /dev/null +++ b/examples/babysitter/evidence/f3-checkout-cache/07-review1-red.txt @@ -0,0 +1,130 @@ +# fix.ts/fixer.ts at 2b9034b4 (before review-1 fixes); tests from the review-1 commit. +$ node --experimental-strip-types --test tests/fixer.test.ts +✔ the fixer checks out the bound head, runs one agent there with fix rules, and journals one proposal (5.517765ms) +✔ the fixer never writes to GitHub itself: no push, merge, review, or comment calls (1.738744ms) +✔ thread replies go only to inline feedback this run was woken by, once each, marked and neutralised (1.113037ms) +✔ a head that moves while fixing declines without a proposal (0.847853ms) +✔ a refused proposal ends needs_human (2.086322ms) +✔ by default the fixer dispatches no agent and checks nothing out (gate 8 / #442) (5.658759ms) +✔ the agent outcome is read from a JSON message, a fenced JSON block, or falls back to prose (0.583068ms) +✔ a real proposal covers edits, new files and the agent's own commits, all against the bound head (387.22251ms) +✔ workflow, secret, oversized and too-wide proposals are refused whole (1214.352415ms) +✔ an untouched checkout proposes an empty patch (replies and summary may still publish) (127.789373ms) +✔ the proposal script runs as f.run runs it: a stringified `node -e` command in a shell (199.225897ms) +✔ the proposal script lifted from the shipped artifact runs under `node -e` (232.221683ms) +✔ the proposal never runs code the agent planted in the checkout's git config, and its diff format is fixed (280.43281ms) +✔ paths git would have to quote are refused; a newline cannot split a name past the workflow rule (527.694788ms) +✔ a non-ASCII name is proposed unquoted, so Cloud parses the same path (132.478162ms) +✔ the fixer runs the origin session's own CLI with its literal pinned model (2.694147ms) +✔ a PR checkout survives between runs: restored into the run root, stashed back after (3.704602ms) +✔ the fixer restores the cached checkout before checking out, and stashes it even when the agent fails (2.979079ms) +✔ restore and stash lifted from the shipped artifact run under `node -e` (108.36021ms) +✖ a reused checkout keeps its dependencies but none of the agent's git state, and lands exactly on the head (391.243944ms) +✖ a dependency manifest that changed between heads drops the cached dependencies (403.409714ms) +✖ a damaged cached .git is replaced, not reused (239.153007ms) +✖ cache moves never fail the run: an unwritable cache only means the next run starts fresh (1.069556ms) +✖ flows check still sees the fixer's agent requirement (1.622277ms) +ℹ tests 24 +ℹ suites 0 +ℹ pass 19 +ℹ fail 5 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 4605.150809 + +✖ failing tests: + +test at tests/fixer.test.ts:347:1 +✖ a reused checkout keeps its dependencies but none of the agent's git state, and lands exactly on the head (391.243944ms) + Error: Command failed: git -C /tmp/babysitter-run-dSkRvc/babysitter-checkout fetch -q --no-tags --depth=50 https://github.com/acme/widgets.git 2dddb438f7a80eef1008744f8a3b8568244e7982 + fatal: could not read Username for 'https://github.com': terminal prompts disabled + + at genericNodeError (node:internal/errors:999:15) + at wrappedFn (node:internal/errors:544:14) + at checkExecSyncError (node:child_process:942:11) + at execFileSync (node:child_process:978:15) + at git (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/fix.ts:45:45) + at checkoutHead (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/fix.ts:53:3) + at async runCheckout (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:326:9) + at async TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:351:20) + at async Test.run (node:internal/test_runner/test:1409:7) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + status: 128, + signal: null, + output: [ null, '', "fatal: could not read Username for 'https://github.com': terminal prompts disabled\n" ], + pid: 1891645, + stdout: '', + stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled\n" + } + +test at tests/fixer.test.ts:368:1 +✖ a dependency manifest that changed between heads drops the cached dependencies (403.409714ms) + Error: Command failed: git -C /tmp/babysitter-run-Th1Dkg/babysitter-checkout fetch -q --no-tags --depth=50 https://github.com/acme/widgets.git 494c489e65e1721a48c1260d5784ec0a037acd68 + fatal: could not read Username for 'https://github.com': terminal prompts disabled + + at genericNodeError (node:internal/errors:999:15) + at wrappedFn (node:internal/errors:544:14) + at checkExecSyncError (node:child_process:942:11) + at execFileSync (node:child_process:978:15) + at git (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/fix.ts:45:45) + at checkoutHead (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/fix.ts:53:3) + at async runCheckout (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:326:9) + at async TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:372:3) + at async Test.run (node:internal/test_runner/test:1409:7) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + status: 128, + signal: null, + output: [ null, '', "fatal: could not read Username for 'https://github.com': terminal prompts disabled\n" ], + pid: 1891787, + stdout: '', + stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled\n" + } + +test at tests/fixer.test.ts:381:1 +✖ a damaged cached .git is replaced, not reused (239.153007ms) + Error: ENOTDIR: not a directory, open '/tmp/babysitter-run-G0VrQ9/babysitter-checkout/.git/config' + at writeFileSync (node:fs:2997:20) + at checkoutHead (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/fix.ts:50:3) + at async runCheckout (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:326:9) + at async TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:387:21) + at async Test.run (node:internal/test_runner/test:1409:7) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + errno: -20, + code: 'ENOTDIR', + syscall: 'open', + path: '/tmp/babysitter-run-G0VrQ9/babysitter-checkout/.git/config' + } + +test at tests/fixer.test.ts:391:1 +✖ cache moves never fail the run: an unwritable cache only means the next run starts fresh (1.069556ms) + Error: ENOTDIR: not a directory, mkdir '/tmp/babysitter-home-ch2QMP/home-is-a-file/.babysitter/acme/widgets/7' + at mkdirSync (node:fs:1734:26) + at stashCheckout (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/fix.ts:93:5) + at async TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:400:9) + at async Test.run (node:internal/test_runner/test:1409:7) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + errno: -20, + code: 'ENOTDIR', + syscall: 'mkdir', + path: '/tmp/babysitter-home-ch2QMP/home-is-a-file/.babysitter/acme/widgets/7' + } + +test at tests/fixer.test.ts:405:1 +✖ flows check still sees the fixer's agent requirement (1.622277ms) + AssertionError [ERR_ASSERTION]: agent calls stay in the registered body + at TestContext. (file:///home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-f2/examples/babysitter/tests/fixer.test.ts:409:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.processPendingSubtests (node:internal/test_runner/test:974:18) + at Test.postRun (node:internal/test_runner/test:1542:19) + at Test.run (node:internal/test_runner/test:1467:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 'function createStandaloneFixer(policy, runtime = capabilities) {\n const configured = parsePolicy(policy);\n const enforced = runtime.enforcedAgentWriteScope;\n const body = async (f, value) => {\n const a = await admit(\n f,\n value,\n configured,\n enforced,\n "Babysitter fix blocked: the agent would inherit push-capable repository credentials; needs enforced agent write scope (gate 8 / #442)."\n );\n if (!a) return;\n await restore(f, a.pr);\n let verdict;\n try {\n verdict = await fixAndPropose(f, a);\n } finally {\n await stash(f, a.pr);\n }\n f.done(verdict.reason, verdict.detail ? { detail: verdict.detail } : void 0);\n };\n async function fixAndPropose(f, a) {\n const { pr, wake, origin, c, head, report } = a;\n const work = await checkout(f, pr, head);\n const task = agentTask(origin, `${pr.owner}/${pr.repo}#${pr.number}`, head, a.changed, "fix");\n const result = origin.source === "codex" ? await f.agent("babysitter-fix", { cli: "codex", model: "gpt-5.6-sol", cwd: work.dir, permissions: { accessPreset: "readwrite" }, task }) : await f.agent("babysitter-fix", { cli: "claude", model: "claude-sonnet-5", cwd: work.dir, permissions: { accessPreset: "readwrite" }, task });\n const final = await readState(f, c);\n if (final.headSha !== head || outOfScope(final, c, configured.label)) {\n await report(`${wake.id}: head moved or PR left scope while fixing; no proposal for ${head}`);\n return { reason: "declined" };\n }\n const outcome = parseOutcome(result.summary);\n const proposal = await propose(f, {\n dir: work.dir,\n head,\n pullRequest: { owner: pr.owner, repo: pr.repo, number: pr.number },\n summary: neutralise(outcome.summary, origin.firstPrompt).slice(0, SUMMARY_MAX_CHARS),\n replies: threadReplies(a, outcome.replies)\n });\n if (proposal.kind === "babysitter-refusal") {\n await report(`${wake.id}: proposal refused: ${proposal.reason}`);\n return { reason: "needs_human", detail: `Babysitter proposal refused: ${proposal.reason}` };\n }\n return { reason: "success", detail: `Babysitter proposal for ${head}: ${proposal.files.length} file(s)` };\n }\n return subscriptions.reduce(\n (handle, subscription) => handle.on(subscription.trigger, body),\n flow("Babysitter fixer", { budget: { tokens: 1e6, dollars: 10, wallclock: "40m" } }, body)\n );', + expected: /async function fixAndPropose/, + operator: 'doesNotMatch', + diff: 'simple' + } +exit=1 diff --git a/examples/babysitter/fix.ts b/examples/babysitter/fix.ts index b1d9949b5..e6ea17b03 100644 --- a/examples/babysitter/fix.ts +++ b/examples/babysitter/fix.ts @@ -25,13 +25,19 @@ export const REFUSED_PATHS = String.raw`^\.github/workflows/|(^|/)\.env($|\.)|\. /** * Check out exactly `head` into `babysitter-checkout` under the run root, with * the run's read-only token sent as a header (never in argv or git config). - * An existing checkout (a reused sandbox) is fetched and reset in place; - * ignored files such as installed dependencies are kept. + * + * A checkout restored from a reused sandbox keeps only its working tree: + * `.git` is always rebuilt, so nothing a previous agent turn wrote there + * (config, hooks, attributes, a damaged repository) reaches a git command + * that carries the token. Ignored files such as installed dependencies are + * kept, unless a dependency manifest differs from the head, in which case + * they are dropped too. `origin` exists for tests; runs fetch from GitHub. */ -export async function checkoutHead(c: { owner: string; repo: string; head: string }): Promise { +export async function checkoutHead(c: { owner: string; repo: string; head: string; origin?: string }): Promise { const { execFileSync } = await import('node:child_process'); - const { existsSync, mkdirSync, rmSync, writeFileSync } = await import('node:fs'); - const { resolve } = await import('node:path'); + const { createHash } = await import('node:crypto'); + const { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } = await import('node:fs'); + const { join, resolve } = await import('node:path'); if (!process.env.GH_TOKEN) throw new Error('GH_TOKEN required'); const dir = resolve('babysitter-checkout'); const auth = Buffer.from(`x-access-token:${process.env.GH_TOKEN}`).toString('base64'); @@ -43,21 +49,76 @@ export async function checkoutHead(c: { owner: string; repo: string; head: strin GIT_CONFIG_KEY_2: 'advice.detachedHead', GIT_CONFIG_VALUE_2: 'false', }; const git = (...args: string[]) => String(execFileSync('git', ['-C', dir, ...args], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })).trim(); - const reused = existsSync(`${dir}/.git`); - if (!reused) { mkdirSync(dir, { recursive: true }); git('init', '-q'); } - // A previous agent turn could write .git: drop its config, hooks and - // attribute overrides before git runs with the token. - writeFileSync(`${dir}/.git/config`, '[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = false\n'); - rmSync(`${dir}/.git/hooks`, { recursive: true, force: true }); - rmSync(`${dir}/.git/info/attributes`, { force: true }); - git('fetch', '-q', '--no-tags', '--depth=50', `https://github.com/${c.owner}/${c.repo}.git`, c.head); - git('checkout', '-q', '--force', '--detach', c.head); + // Dependency manifests anywhere outside dependency and build directories. + const MANIFESTS = /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|requirements[\w.-]*\.txt|pyproject\.toml|poetry\.lock|Pipfile\.lock|uv\.lock|Cargo\.lock|go\.sum|Gemfile\.lock|composer\.lock)$/; + const SKIP = new Set(['.git', 'node_modules', 'vendor', 'target', 'dist', 'build', '.venv', 'venv']); + const fingerprint = (): string => { + const hash = createHash('sha256'); + const walk = (at: string, rel: string, depth: number) => { + if (depth > 6) return; + for (const entry of readdirSync(at, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isDirectory() && !SKIP.has(entry.name)) walk(join(at, entry.name), `${rel}${entry.name}/`, depth + 1); + else if (entry.isFile() && MANIFESTS.test(entry.name)) hash.update(`${rel}${entry.name}\0`).update(readFileSync(join(at, entry.name))).update('\0'); + } + }; + walk(dir, '', 0); + return hash.digest('hex'); + }; + const reused = existsSync(dir); + mkdirSync(dir, { recursive: true }); + const before = fingerprint(); + rmSync(join(dir, '.git'), { recursive: true, force: true }); + git('init', '-q'); + git('fetch', '-q', '--no-tags', '--depth=50', c.origin ?? `https://github.com/${c.owner}/${c.repo}.git`, c.head); git('reset', '-q', '--hard', c.head); - git('clean', '-q', '-fd'); + git('clean', '-q', reused && fingerprint() !== before ? '-fdx' : '-fd'); if (git('rev-parse', 'HEAD') !== c.head) throw new Error('Checkout is not at the bound head'); process.stdout.write(JSON.stringify({ dir, head: c.head, reused })); } +/** + * The sandbox is reused per pull request, but each run's root is created + * anew. The checkout therefore lives in a PR-scoped cache under $HOME between + * runs and is moved (renamed, so installed dependencies come along) into the + * run root for the agent, whose working directory must sit inside it. + */ +export interface CheckoutCache { owner: string; repo: string; number: number; home?: string } + +export async function restoreCheckout(c: CheckoutCache): Promise { + const { existsSync, renameSync, rmSync } = await import('node:fs'); + const { homedir } = await import('node:os'); + const { join, resolve } = await import('node:path'); + const cache = join(c.home ?? homedir(), '.babysitter', c.owner.toLowerCase(), c.repo.toLowerCase(), String(c.number), 'checkout'); + const dir = resolve('babysitter-checkout'); + let restored = false; + try { + if (existsSync(cache) && !existsSync(dir)) { renameSync(cache, dir); restored = true; } + } catch { + // Another filesystem (EXDEV) or an unreadable cache: start fresh rather than copy. + try { rmSync(cache, { recursive: true, force: true }); } catch { /* the next stash replaces it */ } + } + process.stdout.write(JSON.stringify({ restored })); +} + +export async function stashCheckout(c: CheckoutCache): Promise { + const { existsSync, mkdirSync, renameSync, rmSync } = await import('node:fs'); + const { homedir } = await import('node:os'); + const { dirname, join, resolve } = await import('node:path'); + const cache = join(c.home ?? homedir(), '.babysitter', c.owner.toLowerCase(), c.repo.toLowerCase(), String(c.number), 'checkout'); + const dir = resolve('babysitter-checkout'); + let stashed = false; + // Best effort: a cache that cannot be written only means the next run starts fresh. + try { + if (existsSync(dir)) { + rmSync(cache, { recursive: true, force: true }); + mkdirSync(dirname(cache), { recursive: true }); + renameSync(dir, cache); + stashed = true; + } + } catch { /* fall through: not stashed */ } + process.stdout.write(JSON.stringify({ stashed })); +} + export interface ProposalInput { dir: string; head: string; pullRequest: { owner: string; repo: string; number: number }; summary: string; replies: { commentId: number; body: string }[]; @@ -105,6 +166,14 @@ export async function proposeChanges(c: ProposalInput): Promise { process.stdout.write(out); } +export async function restore(f: Ctx, pr: { owner: string; repo: string; number: number }): Promise { + return JSON.parse(await f.run(nodeCommand(restoreCheckout, { owner: pr.owner, repo: pr.repo, number: pr.number }), { timeout: '2m' })).restored === true; +} + +export async function stash(f: Ctx, pr: { owner: string; repo: string; number: number }): Promise { + await f.run(nodeCommand(stashCheckout, { owner: pr.owner, repo: pr.repo, number: pr.number }), { timeout: '2m' }); +} + export async function checkout(f: Ctx, pr: { owner: string; repo: string }, head: string): Promise<{ dir: string; reused: boolean }> { const value = JSON.parse(await f.run(nodeCommand(checkoutHead, { owner: pr.owner, repo: pr.repo, head }), { timeout: '5m' })); if (value.head !== head || typeof value.dir !== 'string') throw new Error('Checkout did not report the bound head'); diff --git a/examples/babysitter/fixer.ts b/examples/babysitter/fixer.ts index e2bddd440..9c73d57a2 100644 --- a/examples/babysitter/fixer.ts +++ b/examples/babysitter/fixer.ts @@ -1,7 +1,7 @@ import { flow, type Ctx } from '@relayflows/surface'; import { admit, neutralise, outOfScope, parsePolicy, type Admitted } from './admission.ts'; import { capabilities } from './capabilities.ts'; -import { checkout, propose, REPLY_MAX_CHARS, SUMMARY_MAX_CHARS } from './fix.ts'; +import { checkout, propose, REPLY_MAX_CHARS, restore, stash, SUMMARY_MAX_CHARS } from './fix.ts'; import { readState } from './github.ts'; import { record } from './input.ts'; import { agentTask } from './origin.ts'; @@ -45,6 +45,8 @@ function threadReplies(a: Admitted, replies: { id: number; body: string }[]): { })); } +type Verdict = { reason: 'success' | 'declined' | 'needs_human'; detail?: string }; + export function createStandaloneFixer(policy: unknown, runtime: { enforcedAgentWriteScope: boolean } = capabilities) { const configured = parsePolicy(policy); const enforced = runtime.enforcedAgentWriteScope; @@ -52,35 +54,47 @@ export function createStandaloneFixer(policy: unknown, runtime: { enforcedAgentW const a = await admit(f, value, configured, enforced, 'Babysitter fix blocked: the agent would inherit push-capable repository credentials; needs enforced agent write scope (gate 8 / #442).'); if (!a) return; + // (5) The bound head, checked out where the agent works. A reused sandbox + // brings back the PR's previous checkout, with its dependencies. const { pr, wake, origin, c, head, report } = a; - // (5) The bound head, checked out where the agent works. - const work = await checkout(f, pr, head); - // (6) One agent carrying the original scope edits the working tree. It is - // the origin session's own CLI, with a literal pinned model per CLI so the - // shipped-source model audit resolves both pairs; the fixer ignores the - // policy's agentCli override. - const task = agentTask(origin, `${pr.owner}/${pr.repo}#${pr.number}`, head, a.changed, 'fix'); - const result = origin.source === 'codex' - ? await f.agent('babysitter-fix', { cli: 'codex', model: 'gpt-5.6-sol', cwd: work.dir, permissions: { accessPreset: 'readwrite' }, task }) - : await f.agent('babysitter-fix', { cli: 'claude', model: 'claude-sonnet-5', cwd: work.dir, permissions: { accessPreset: 'readwrite' }, task }); - // (7) Never propose against a head that no longer exists. - const final = await readState(f, c); - if (final.headSha !== head || outOfScope(final, c, configured.label)) { - await report(`${wake.id}: head moved or PR left scope while fixing; no proposal for ${head}`); - return f.done('declined'); - } - // (8) One journaled proposal. Cloud publishes it; this run never writes code to GitHub. - const outcome = parseOutcome(result.summary); - const proposal = await propose(f, { - dir: work.dir, head, pullRequest: { owner: pr.owner, repo: pr.repo, number: pr.number }, - summary: neutralise(outcome.summary, origin.firstPrompt).slice(0, SUMMARY_MAX_CHARS), - replies: threadReplies(a, outcome.replies), - }); - if (proposal.kind === 'babysitter-refusal') { - await report(`${wake.id}: proposal refused: ${proposal.reason}`); - return f.done('needs_human', { detail: `Babysitter proposal refused: ${proposal.reason}` }); + await restore(f, pr); + let verdict: Verdict; + try { + const work = await checkout(f, pr, head); + // (6) One agent carrying the original scope edits the working tree. It is + // the origin session's own CLI, with a literal pinned model per CLI so the + // shipped-source model audit resolves both pairs; the fixer ignores the + // policy's agentCli override. + const task = agentTask(origin, `${pr.owner}/${pr.repo}#${pr.number}`, head, a.changed, 'fix'); + const result = origin.source === 'codex' + ? await f.agent('babysitter-fix', { cli: 'codex', model: 'gpt-5.6-sol', cwd: work.dir, permissions: { accessPreset: 'readwrite' }, task }) + : await f.agent('babysitter-fix', { cli: 'claude', model: 'claude-sonnet-5', cwd: work.dir, permissions: { accessPreset: 'readwrite' }, task }); + // (7) Never propose against a head that no longer exists. + const final = await readState(f, c); + if (final.headSha !== head || outOfScope(final, c, configured.label)) { + await report(`${wake.id}: head moved or PR left scope while fixing; no proposal for ${head}`); + verdict = { reason: 'declined' }; + } else { + // (8) One journaled proposal. Cloud publishes it; this run never writes code to GitHub. + const outcome = parseOutcome(result.summary); + const proposal = await propose(f, { + dir: work.dir, head, pullRequest: { owner: pr.owner, repo: pr.repo, number: pr.number }, + summary: neutralise(outcome.summary, origin.firstPrompt).slice(0, SUMMARY_MAX_CHARS), + replies: threadReplies(a, outcome.replies), + }); + if (proposal.kind === 'babysitter-refusal') { + await report(`${wake.id}: proposal refused: ${proposal.reason}`); + verdict = { reason: 'needs_human', detail: `Babysitter proposal refused: ${proposal.reason}` }; + } else { + verdict = { reason: 'success', detail: `Babysitter proposal for ${head}: ${proposal.files.length} file(s)` }; + } + } + } finally { + // Back to the PR cache whatever happened, for the next wake on this box; + // before f.done, so the stash is a step of this run. + await stash(f, pr); } - f.done('success', { detail: `Babysitter proposal for ${head}: ${proposal.files.length} file(s)` }); + f.done(verdict.reason, verdict.detail ? { detail: verdict.detail } : undefined); }; return subscriptions.reduce>( (handle, subscription) => handle.on(subscription.trigger, body), diff --git a/examples/babysitter/tests/fixer.test.ts b/examples/babysitter/tests/fixer.test.ts index bf8d531e5..fd082db43 100644 --- a/examples/babysitter/tests/fixer.test.ts +++ b/examples/babysitter/tests/fixer.test.ts @@ -46,6 +46,8 @@ function context(o: { states?: Record[]; summary?: string; prop if (command.includes('readSignals')) return JSON.stringify(signals); if (command.includes('checkoutHead')) return JSON.stringify({ dir: '/run/babysitter-checkout', head, reused: false }); if (command.includes('proposeChanges')) return JSON.stringify(o.proposed ?? proposal); + if (command.includes('restoreCheckout')) return JSON.stringify({ restored: false }); + if (command.includes('stashCheckout')) return JSON.stringify({ stashed: true }); return ''; }, agent: async (name: string, options: Record) => { @@ -261,3 +263,157 @@ test('the fixer runs the origin session\'s own CLI with its literal pinned model await body()(claude.f, input()); assert.deepEqual([claude.agents[0]!.options.cli, claude.agents[0]!.options.model], ['claude', 'claude-sonnet-5']); }); + +test('a PR checkout survives between runs: restored into the run root, stashed back after', async () => { + const { restoreCheckout, stashCheckout } = await import('../fix.ts'); + const home = mkdtempSync(join(tmpdir(), 'babysitter-home-')); + const runRoot = mkdtempSync(join(tmpdir(), 'babysitter-run-')); + const pr = { owner: 'Acme', repo: 'Widgets', number: 7, home }; + const capture = async (fn: () => Promise) => { + const saved = process.stdout.write; let out = ''; + process.stdout.write = ((chunk: string) => { out += chunk; return true; }) as typeof process.stdout.write; + const cwd = process.cwd(); process.chdir(runRoot); + try { await fn(); } finally { process.stdout.write = saved; process.chdir(cwd); } + return JSON.parse(out); + }; + // First run: nothing cached. + assert.deepEqual(await capture(() => restoreCheckout(pr)), { restored: false }); + // The run builds a checkout with installed dependencies, then stashes it. + mkdirSync(join(runRoot, 'babysitter-checkout', '.git'), { recursive: true }); + mkdirSync(join(runRoot, 'babysitter-checkout', 'node_modules', 'dep'), { recursive: true }); + writeFileSync(join(runRoot, 'babysitter-checkout', 'node_modules', 'dep', 'index.js'), 'installed\n'); + assert.deepEqual(await capture(() => stashCheckout(pr)), { stashed: true }); + assert.throws(() => readFileSync(join(runRoot, 'babysitter-checkout', 'node_modules', 'dep', 'index.js')), /ENOENT/); + // Next run (fresh run root, same sandbox home): the checkout comes back with its dependencies. + const next = mkdtempSync(join(tmpdir(), 'babysitter-run-')); + const cwd = process.cwd(); process.chdir(next); + const saved = process.stdout.write; let out = ''; + process.stdout.write = ((chunk: string) => { out += chunk; return true; }) as typeof process.stdout.write; + try { await restoreCheckout(pr); } finally { process.stdout.write = saved; process.chdir(cwd); } + assert.deepEqual(JSON.parse(out), { restored: true }); + assert.equal(readFileSync(join(next, 'babysitter-checkout', 'node_modules', 'dep', 'index.js'), 'utf8'), 'installed\n'); +}); + +test('the fixer restores the cached checkout before checking out, and stashes it even when the agent fails', async () => { + const ok = context(); + await body()(ok.f, input()); + const order = ok.commands.map(c => ['restoreCheckout', 'checkoutHead', 'proposeChanges', 'stashCheckout'].find(n => c.includes(n))).filter(Boolean); + assert.deepEqual(order, ['restoreCheckout', 'checkoutHead', 'proposeChanges', 'stashCheckout']); + const failing = context(); + (failing.f as any).agent = async () => { throw new Error('agent crashed'); }; + await assert.rejects(body()(failing.f, input()), /agent crashed/); + assert.ok(failing.commands.some(c => c.includes('stashCheckout')), 'stashed after a failure'); +}); + +test('restore and stash lifted from the shipped artifact run under `node -e`', () => { + const home = mkdtempSync(join(tmpdir(), 'babysitter-home-')); + const runRoot = mkdtempSync(join(tmpdir(), 'babysitter-run-')); + mkdirSync(join(runRoot, 'babysitter-checkout', '.git'), { recursive: true }); + const run = (name: string) => JSON.parse(execFileSync('sh', ['-c', nodeCommand({ toString: () => shippedFunction(name) } as unknown as Function, + { owner: 'acme', repo: 'widgets', number: 7, home })], { cwd: runRoot, encoding: 'utf8' })); + assert.deepEqual(run('stashCheckout'), { stashed: true }); + assert.deepEqual(run('restoreCheckout'), { restored: true }); +}); + +/** Run checkoutHead as f.run would, fetching from a local repository instead of GitHub. */ +async function runCheckout(runRoot: string, origin: string, head: string): Promise { + const { checkoutHead } = await import('../fix.ts'); + const saved = { write: process.stdout.write, token: process.env.GH_TOKEN, cwd: process.cwd() }; + let out = ''; + process.stdout.write = ((chunk: string) => { out += chunk; return true; }) as typeof process.stdout.write; + process.env.GH_TOKEN = 'test-token'; + process.chdir(runRoot); + try { await checkoutHead({ owner: 'acme', repo: 'widgets', head, origin }); } + finally { process.stdout.write = saved.write; process.chdir(saved.cwd); if (saved.token === undefined) delete process.env.GH_TOKEN; else process.env.GH_TOKEN = saved.token; } + return JSON.parse(out); +} + +function upstream(): { dir: string; commit: (files: Record) => string } { + const dir = mkdtempSync(join(tmpdir(), 'babysitter-upstream-')); + const env = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + const git = (...a: string[]) => execFileSync('git', ['-C', dir, ...a], { env, encoding: 'utf8' }).trim(); + git('init', '-q'); + git('config', 'uploadpack.allowReachableSHA1InWant', 'true'); + return { + dir, + commit(files) { + for (const [path, content] of Object.entries(files)) { mkdirSync(join(dir, path, '..'), { recursive: true }); writeFileSync(join(dir, path), content); } + git('add', '-A'); git('commit', '-qm', 'c'); + return git('rev-parse', 'HEAD'); + }, + }; +} + +test('a reused checkout keeps its dependencies but none of the agent\'s git state, and lands exactly on the head', async () => { + const up = upstream(); + const first = up.commit({ '.gitignore': 'node_modules/\n', 'package.json': '{"name":"w"}\n', 'package-lock.json': '{"v":1}\n', 'src/a.ts': 'a\n' }); + const runRoot = mkdtempSync(join(tmpdir(), 'babysitter-run-')); + assert.deepEqual(await runCheckout(runRoot, up.dir, first), { dir: join(runRoot, 'babysitter-checkout'), head: first, reused: false }); + const dir = join(runRoot, 'babysitter-checkout'); + // The previous run installed dependencies, edited a tracked file, left junk, and planted git config. + mkdirSync(join(dir, 'node_modules', 'dep'), { recursive: true }); + writeFileSync(join(dir, 'node_modules', 'dep', 'index.js'), 'installed\n'); + writeFileSync(join(dir, 'src/a.ts'), 'agent edit\n'); + writeFileSync(join(dir, 'junk.txt'), 'x\n'); + const proof = join(tmpdir(), `planted-${Date.now()}`); + writeFileSync(join(dir, '.git', 'config'), `[core]\n\tfsmonitor = sh -c 'touch ${proof}'\n`); + const second = up.commit({ 'src/a.ts': 'b\n' }); + assert.deepEqual(await runCheckout(runRoot, up.dir, second), { dir, head: second, reused: true }); + assert.equal(readFileSync(join(dir, 'src/a.ts'), 'utf8'), 'b\n'); + assert.equal(readFileSync(join(dir, 'node_modules', 'dep', 'index.js'), 'utf8'), 'installed\n', 'dependencies survive an unchanged lockfile'); + assert.throws(() => readFileSync(join(dir, 'junk.txt')), /ENOENT/); + assert.throws(() => readFileSync(proof), /ENOENT/, 'agent-planted git config never ran'); +}); + +test('a dependency manifest that changed between heads drops the cached dependencies', async () => { + const up = upstream(); + const first = up.commit({ '.gitignore': 'node_modules/\n', 'package-lock.json': '{"v":1}\n' }); + const runRoot = mkdtempSync(join(tmpdir(), 'babysitter-run-')); + await runCheckout(runRoot, up.dir, first); + const dir = join(runRoot, 'babysitter-checkout'); + mkdirSync(join(dir, 'node_modules', 'dep'), { recursive: true }); + writeFileSync(join(dir, 'node_modules', 'dep', 'index.js'), 'old\n'); + const second = up.commit({ 'package-lock.json': '{"v":2}\n' }); + await runCheckout(runRoot, up.dir, second); + assert.throws(() => readFileSync(join(dir, 'node_modules', 'dep', 'index.js')), /ENOENT/); +}); + +test('a damaged cached .git is replaced, not reused', async () => { + const up = upstream(); + const head = up.commit({ 'a.txt': 'a\n' }); + const runRoot = mkdtempSync(join(tmpdir(), 'babysitter-run-')); + mkdirSync(join(runRoot, 'babysitter-checkout'), { recursive: true }); + writeFileSync(join(runRoot, 'babysitter-checkout', '.git'), 'gitdir: /nowhere\n'); + assert.deepEqual((await runCheckout(runRoot, up.dir, head)).head, head); + assert.equal(readFileSync(join(runRoot, 'babysitter-checkout', 'a.txt'), 'utf8'), 'a\n'); +}); + +test('cache moves never fail the run: an unwritable cache only means the next run starts fresh', async () => { + const { stashCheckout } = await import('../fix.ts'); + const runRoot = mkdtempSync(join(tmpdir(), 'babysitter-run-')); + mkdirSync(join(runRoot, 'babysitter-checkout', '.git'), { recursive: true }); + const blocker = join(mkdtempSync(join(tmpdir(), 'babysitter-home-')), 'home-is-a-file'); + writeFileSync(blocker, 'x'); + const saved = { write: process.stdout.write, cwd: process.cwd() }; let out = ''; + process.stdout.write = ((chunk: string) => { out += chunk; return true; }) as typeof process.stdout.write; + process.chdir(runRoot); + try { await stashCheckout({ owner: 'acme', repo: 'widgets', number: 7, home: blocker }); } + finally { process.stdout.write = saved.write; process.chdir(saved.cwd); } + assert.deepEqual(JSON.parse(out), { stashed: false }); +}); + +test('flows check still sees the fixer\'s agent requirement', () => { + const artifact = readFileSync(fileURLToPath(new URL('../artifacts/babysitter-fixer.flow.ts', import.meta.url)), 'utf8'); + const body = artifact.slice(artifact.indexOf('function createStandaloneFixer(')); + const fn = body.slice(0, body.indexOf('\n}\n')); + assert.doesNotMatch(fn, /async function fixAndPropose/, 'agent calls stay in the registered body'); +}); + +test('checkoutHead lifted from the shipped artifact runs under `node -e`', () => { + const up = upstream(); + const head = up.commit({ 'a.txt': 'a\n' }); + const runRoot = mkdtempSync(join(tmpdir(), 'babysitter-run-')); + const command = nodeCommand({ toString: () => shippedFunction('checkoutHead') } as unknown as Function, { owner: 'acme', repo: 'widgets', head, origin: up.dir }); + const out = JSON.parse(execFileSync('sh', ['-c', command], { cwd: runRoot, encoding: 'utf8', env: { ...process.env, GH_TOKEN: 'test-token' } })); + assert.deepEqual([out.head, out.reused], [head, false]); +});