feat(babysitter): fixer task modes for the merge-train agent (P2)

Merged
KH
4d ago
+1497-117

P2 of the merge-train Babysitter lane (scope confirmed by the cloud merge-train owner): Cloud's merge train launches the same pinned fixer artifact with a typed task, so the Babysitter resolves the holds it can, on the PR branch, without merging.

What changes

  • task.ts parses babysitter.task strictly: fix_ci (1–20 checks with ≤ 6,000-char log tails), answer_threads (1–50 distinct thread ids), resolve_conflict (40-hex trunk sha). It also renders the per-task instructions appended to the agent task. Only fixed text and validated values go there; log tails stay in the untrusted "What changed" data.
  • task-admission.ts admits a task run in admit's order:
    • It accepts only event = { provider: "merge_train", eventType: "merge_train.task", deliveryId } for the bound PR.
    • It requires origin context, and the live head must equal the claimed head.
    • A closed or merged PR declines, and enforced agent scope is required.
    • The task replaces the review-feedback requirement and the Garden/babysit scope.
    • A malformed task ends needs_human before any live read.
    • It is a separate module, so admission.ts, origin.ts and the diagnose artifact are unchanged (still 580bab1d…, as pinned in cloud#4393).
  • merge.ts handles resolve_conflict before the agent:
    • It fetches the trunk commit with the same header-only token as the checkout, then runs git merge --no-commit --no-ff.
    • Conflicts are left in place for the agent.
    • A conflict under packages/web/drizzle/meta/ stops the run, since migrations are renumbered deterministically, never by an agent.
  • fix.ts proposeChanges gains proposal schema 2 with mergeParent (below), and the authored-diff rules for merges. A merge over the caps publishes no code, and the summary says why. Every proposal, task or not, now refuses agent changes to drizzle metadata.
  • fixer.ts routes to task admission when a task is present. It merges first for resolve_conflict, re-checks lifecycle-only scope after the agent for task runs, and passes mergeParent.
    • The four literal f.agent calls stay in the default body. The requirement scan and the shipped-source audit read the body's own source; moving them into a helper dropped codex/claude from REQUIRES, which I caught with flows check and reverted.

Without a task, the run is unchanged. The no-task tests in fixer.test.ts pass unmodified, apart from passing the new meta limit, which propose() always supplies in production.

New fixer digest

c9bd933e888479e10bcb6c19579c4984efffdaae6bf61887946e53481e41fe9f (was 23ad283e…). Cloud must pin it (P3) before a task run can launch.

Proposal v2: the contract Cloud P3 implements

The full text is in examples/babysitter/FIXER.md, "Merge-train task modes (P2)". In short:

  • schemaVersion is 2 only when mergeParent is present. Every other proposal stays schema 1, so today's Cloud parser is unaffected.
  • mergeParent is the task's trunkSha, which becomes the merge commit's second parent. baseHead, files and patch are as in schema 1: the patch goes from the bound head to the merged tree, so it carries trunk's changes.
  • Over the caps: a schema-1 proposal with patch: "", files: [], no mergeParent, and unpublished: "<why>", plus a summary that explains. The run ends needs_human.
  • P3 must:
    1. Accept v2 only when mergeParent equals the trunk sha from its own task record.
    2. Commit with parents [boundHead, mergeParent] and tree boundHead + patch, then update the ref with force: false.
    3. Re-check refused paths and drizzle metadata on the tree delta against mergeParent, confine that delta to the PR's own files, and apply the caps to the patch.
    4. Treat unpublished as summary-only.
    5. Refuse drizzle metadata in every proposal.

Evidence (captured; files under examples/babysitter/evidence/p2-task-modes/)

Full Babysitter suite (02-all-babysitter-green.txt)

$ (cd examples/babysitter && npm test)
ℹ tests 160
ℹ pass 153
ℹ fail 0
ℹ todo 7

The 15 new tests in tests/task.test.ts:

✔ a task is parsed strictly: exact keys, bounded values, a full trunk sha (2.737727ms)
✔ a malformed task stops the run before any live read or agent (1.831617ms)
✔ a task run is admitted only from a merge-train event for the bound PR, and a merge-train event never runs without a task (1.540263ms)
✔ fix_ci admits a merge-train PR with no review feedback and no opt-in label, and gives the agent the failing checks (3.128667ms)
✔ answer_threads addresses only the requested threads and replies only to them (1.552757ms)
✔ answer_threads declines without an agent when the requested threads are no longer open (3.091978ms)
✔ a task run still declines a closed or merged PR, and one whose head moved (1.1309ms)
✔ resolve_conflict merges trunk before the agent and proposes with trunk as the merge parent (5.113941ms)
✔ resolve_conflict stops without an agent when drizzle metadata conflicts, and still stashes the checkout (1.218884ms)
✔ a merge too large to publish ends needs_human with an explanation instead of a patch (1.255412ms)
✔ the merge step leaves the conflicts for the agent and names any in drizzle metadata (801.807317ms)
✔ a resolved merge proposes schema 2: trunk as merge parent, patch from the head to the merged tree (547.437021ms)
✔ a merge that changes trunk outside the PR's own files, or leaves conflict markers, is refused (844.940734ms)
✔ a resolved merge over the patch cap publishes no code and says why (388.343828ms)
✔ an agent change to drizzle metadata is refused for every proposal (304.532004ms)
ℹ tests 15
ℹ pass 15
ℹ fail 0

Mutation verification (01-mutations.txt: revert, red, restore byte-for-byte (sha256), green)

== M1 task admission disabled (always feedback admission): mutated fixer.ts (s/const a: FixerAdmitted | undefined = hasTask(value)/const a: FixerAdmitted | undefined = false/)
$ node --experimental-strip-types --test --test-name-pattern="fix_ci admits" tests/task.test.ts
✖ fix_ci admits a merge-train PR with no review feedback and no opt-in label, and gives the agent the failing checks (14.039371ms)
ℹ pass 0
ℹ fail 1
✖ failing tests:
✖ fix_ci admits a merge-train PR with no review feedback and no opt-in label, and gives the agent the failing checks (14.039371ms)
-- restored byte-for-byte (sha256 matches the pre-mutation file)
$ node --experimental-strip-types --test --test-name-pattern="fix_ci admits" tests/task.test.ts
✔ fix_ci admits a merge-train PR with no review feedback and no opt-in label, and gives the agent the failing checks (17.23942ms)
ℹ pass 1
ℹ fail 0

== M2 drizzle metadata refusal removed: mutated fix.ts (s/  if (meta.length) return refuse(/  if (false) return refuse(/)
$ node --experimental-strip-types --test --test-name-pattern="drizzle metadata is refused" tests/task.test.ts
✖ an agent change to drizzle metadata is refused for every proposal (1788.511021ms)
ℹ pass 0
ℹ fail 1
✖ failing tests:
✖ an agent change to drizzle metadata is refused for every proposal (1788.511021ms)
-- restored byte-for-byte (sha256 matches the pre-mutation file)
$ node --experimental-strip-types --test --test-name-pattern="drizzle metadata is refused" tests/task.test.ts
✔ an agent change to drizzle metadata is refused for every proposal (1108.778066ms)
ℹ pass 1
ℹ fail 0

== M3 merge outside-PR-files rule removed: mutated fix.ts (s/    if (outside.length) return refuse(/    if (false) return refuse(/)
$ node --experimental-strip-types --test --test-name-pattern="outside the PR" tests/task.test.ts
✖ a merge that changes trunk outside the PR's own files, or leaves conflict markers, is refused (1526.93851ms)
ℹ pass 0
ℹ fail 1
✖ failing tests:
✖ a merge that changes trunk outside the PR's own files, or leaves conflict markers, is refused (1526.93851ms)
-- restored byte-for-byte (sha256 matches the pre-mutation file)
$ node --experimental-strip-types --test --test-name-pattern="outside the PR" tests/task.test.ts
✔ a merge that changes trunk outside the PR's own files, or leaves conflict markers, is refused (2477.198594ms)
ℹ pass 1
ℹ fail 0

Artifact (03-artifact-check.txt)

$ node examples/babysitter/build-standalone.mjs --check
Checked sha256:580bab1d31bfe05b2be4cbdfa74f8669efdb1067ba4a4bf126f871b432d7f834 (babysitter-standalone)
Checked sha256:c9bd933e888479e10bcb6c19579c4984efffdaae6bf61887946e53481e41fe9f (babysitter-fixer)

flows check on the new artifact (04-flows-check.txt)

$ 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

Shipped-source model audit (05-sdk-shipped-source.txt)

$ (cd packages/sdk && ./node_modules/.bin/vitest run tests/shipped-source-models.test.ts)
 ✓ tests/shipped-source-models.test.ts (2 tests) 167282ms
   ✓ first-party shipped source model pins > gives every TypeScript agent and LLM an explicit supported pair or a pinned named-agent declaration 167249ms
 Test Files  1 passed (1)
      Tests  2 passed (2)

Typecheck, same filter as F4 (06-typecheck.txt)

$ (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

Not in this PR

  • Cloud P1 (server-authored task launch, lineage from session_pull_requests), P3 (publish v2, pin c9bd933e…) and P4 (sweeper wiring).
  • Migration renumbering: a deterministic Cloud sweeper action (P5), never the agent.
  • answer_threads only sees threads newer than Babysitter's last report, because it uses the existing readSignals reader. This is documented in FIXER.md; widening it is a follow-up if the merge train needs older threads.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


[!NOTE]
High Risk
Changes agent admission, git merge/conflict publishing, and proposal validation on the merge-train path; mistakes could publish wrong trees or bypass scope guards, though behavior without a task is unchanged and heavily tested.

Overview
Adds merge-train task modes so Cloud can launch the Babysitter fixer with a server-authored babysitter.task (fix_ci, answer_threads, resolve_conflict) on a merge_train.task event instead of GitHub webhook admission.

Admission and agent scope: task-admission.ts validates tasks strictly (malformed → needs_human before any live read), requires origin context and head match, and swaps review-feedback / Garden label scope for the task payload. fix_ci injects failing checks; answer_threads filters live inline feedback by thread root and fails needs_human if any requested thread lacks a reply.

Conflict resolution: merge.ts fetches trunk and runs git merge --no-commit --no-ff before the agent; drizzle metadata conflicts stop the run. proposeChanges gains schema 2 proposals with mergeParent, authored-diff rules vs trunk (PR-only files, no markers, no packages/web/drizzle/meta/), refusal of marker-less binary conflicts, and unpublished summary-only proposals when merge patches exceed caps. Non-task behavior stays the same when no task is present.

Docs (FIXER.md), bundled artifact digest update, and tests/task.test.ts with mutation evidence cover the new paths.

Reviewed by Cursor Bugbot for commit c442ef488caa50781b757d2c4b5b2a734a148bd3. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Adds typed task modes to the Babysitter fixer so Cloud's merge train can launch it with a server-authored task (fix_ci, answer_threads, or resolve_conflict) instead of a GitHub delivery.

  • Tasks are parsed strictly; a malformed task stops the run before any live read or agent.
  • Task runs are admitted only from a merge_train.task event for the bound PR, replacing the review-feedback requirement and the Garden/babysit scope; origin context, a live head matching the claimed head, and an open PR are still required.
  • resolve_conflict merges the named trunk commit into the checkout before the agent (header-only token, no commit), stops on drizzle metadata conflicts, and proposes schema 2 with a mergeParent; the authored diff must stay within the PR's own files with no conflict markers.
  • answer_threads matches a requested thread by its root so a still-open follow-up in it is acted on, and ends needs_human without a proposal when a requested thread gets no reply.
  • The merge refuses when git never started the merge (an untracked file in the way) and refuses an unresolved marker-less conflict before staging when either stage blob is binary or the path is binary via attributes; a text conflict kept as the PR's side stays the agent's choice, and a conflict blob too large to read is refused the same way instead of crashing the run.
  • Merges over the size caps publish no code and end needs_human with an explanation kept within the summary limit; check names with control characters are refused and no longer appear in the trusted task section.
  • Every proposal now refuses agent changes to packages/web/drizzle/meta/.
  • New fixer digest 469baf18…; Cloud must pin it before a task run can launch. The diagnose artifact is unchanged.

Migration

  • Cloud P3 must accept schema 2 only when mergeParent matches the task's trunk sha, commit with parents [boundHead, mergeParent], and re-check the authored-diff rules against mergeParent.

Tests

  • Full Babysitter suite: 162 pass, 0 fail; 24 new task-mode tests plus mutation verification for the admission, drizzle-metadata, outside-PR-files, and big-blob rules.

Agent Relay sessions

  • claude session a2e8e267 · opened via gh pr create · last active 2026-10-10
  • claude session 5863fddb · contributor · ran gh pr commands · last active 2026-10-10