Files
stack/agents/darkwing/work/queue-116/r1/build.md
T

24 KiB

Row 116 (#1610) round 1: a resolved merge decision lands as a gated local commit

Brief: docs/plans/2026-10-14_merge-path-for-approved-work.md, round 2 at 68ce7f354 (blob 7a610f379, queue rev 1148). Sage's rulings sage-0195 (the read path and the rules) and sage-0196 (the digest check) apply where they differ from the brief's text, and are cited below where they do.

BASE: 1223a0f37. Nothing on refactor since then touches a candidate path. git diff --stat 1223a0f37 02494c3ed over scripts/, docs/TOOLS.md, docs/plans/CONDUCTOR.md and packages/bus/ shows one line: 7eb5ad0cb (PRD 1.2) changed the requirement count in packages/bus/tests/requirements.test.mjs from 30 to 38. packages/bus/src, which read.mjs imports, is unchanged. The candidate is build.patch against BASE, and candidate-manifest.sha256 lists the 6 files. repro.sh exports BASE with git archive, applies build.patch and checks every manifest digest: DIGESTS_OK.

What changed

All new except the two docs. Nothing under packages/bus/src, packages/harness or roles/ changes, and no broker verb was added.

File What
scripts/land-work.sh the tool
scripts/land-work/read.mjs every bus query, all SELECTs, and the schema check
scripts/land-work/fixture.mjs writes test rows through the real Store
scripts/test-land-work.sh the suite, 78 cases
docs/TOOLS.md a "Conductor (landing approved work)" entry and the Maintenance row
docs/plans/CONDUCTOR.md a "Landing approved work" section

I chose a new script over a land verb in an existing CLI. The brief allows either. scripts/mosaic verbs run as the human or a seat through the broker, and this tool is conductor-only and host-side, the same footing as scripts/conductor-apply.sh. A script also keeps the read path out of packages/bus.

The read, scripts/land-work/read.mjs

It opens <dataRoot>/bus/bus.sqlite with node:sqlite readOnly: true after an lstat (a regular file owned by the user, not a symlink), and every query lives in its queries object. It imports schemaDigest and SCHEMA_SHA256 from packages/bus/src/store.mjs.

The schema check follows sage-0196, which overrides the brief's sentence that schemaDigest(db) should equal SCHEMA_SHA256. Those two are different hashes (one of schema.sql's text, one of sqlite_master), so that sentence could never hold. Sage: "This message is the lead ruling Filbert can cite against the brief's literal sentence." The check is the Store's own:

  1. schema.sql is read and re-hashed against the imported SCHEMA_SHA256.
  2. It is executed into a :memory: database, and schemaDigest of that is the expected digest. store.mjs doesn't export its expected digest, so this builds it the way the Store does.
  3. schemaDigest(db) must equal it, and meta must say schema_version '3b' and schema_digest equal to it.

The '3b' is a copy of the Store's literal (read.mjs header names it). A schema bump changes the Store's value, and until someone changes read.mjs too, every read refuses bus-schema. That fails closed, and the header says where to look.

The rules follow sage-0195:

  • action git.merge.protected, on the decision and on its one raise event (action.allowed, operation decision.raise, subject the decision id);
  • exactly one close, and it is resolved;
  • resolved by the business file's human, via cli, with exactly one human.input answer whose actor role and run are null and whose choice matches;
  • the choice is the raise's approvalChoice;
  • the question names exactly one work ref, and it matches work/vikunja-<p>-<t>. The task is vikunja:<p>/<t>, and the decision's or the raise's task_ref, if set, must agree;
  • the raise's target is exactly refactor, and so is --base;
  • no later git.merge.protected decision in the business supersedes this one, has this task_ref, or names this work ref in its question;
  • the latest review.verdict for the task (by seq) is approve, its body's task_ref is the task, and its commit is a full object id.

Each query is scoped to the business, so one business's rows can't answer for another's. I dropped the "into " text check from the question. The target comes from the raise (sage-0195).

The subject is the merge option's text, first line (f71c73c9: "Squash-merge work/vikunja-19-265 into refactor"). The brief says "the decision title", and decisions have no title column. Sage agreed with the option text in sage-0196.

The tool, scripts/land-work.sh

The header lists every refusal in order. After the read:

  • ref-moved: refs/heads/<work ref> in <dataRoot>/working/<business>/<project>.git must be C. git runs there with no hooks, no fsmonitor and file transport only.
  • The target fetches C's objects with no refspec destination and no FETCH_HEAD, so no ref moves.
  • The reviewed diff is git diff $(merge-base refactor C) C in a fixed format (--no-ext-diff --no-textconv --binary --no-renames, no prefix overrides). No merge base or an empty diff refuses base-conflict.
  • root-file: an added top-level file, an added file in a top-level directory the base doesn't have, or one under src/ or v1/. The brief says "any added file at the repository root" and gives 010d30cbe's src/parse-requirement-id.mjs as the case, but src/ already exists at the root with legacy files, so a top-level-only rule would pass it. I count the legacy src/ and the archived v1/ as the root.
  • checkout-dirty: if refactor is checked out somewhere, the reviewed paths there must have no changes, nothing staged, and no untracked file at a path the commit adds. The check runs before the worktree and again right before the commit, because the landing rewrites those paths. An unrelated local edit stays.
  • The squash is git apply --index in a detached worktree at the base (base-conflict if it doesn't apply). patch-drift compares git patch-id --stable of the worktree's cached diff with the reviewed one.
  • The gate, then one git commit in the worktree and a compare-and-swap update-ref of refs/heads/refactor from OLD. A base that moved during the gate refuses base-moved and writes nothing. Where refactor is checked out, the reviewed paths are reset and checked out from the new commit.
  • It prints land-work: committed ... NOT pushed and the PM line, and sends nothing.

The gate is Sage's landing gate template: every packages/*/tests suite except queue, then every scripts/test-*.sh, each with pipefail and its own log in $TMPDIR/land-work-logs.*, plus gate.log. The first failure refuses gate-failed and names its log. There is no switch to skip or shorten it, and no flake case. I removed the MOSAIC_LAND_GATE override I had in an early draft, so the suite runs the real gate loop over its fixture repo's packages and scripts.

Cleanup runs from an EXIT trap on every path: the node_modules link, worktree remove --force (or rm -rf) and worktree prune, docker network rm <worktree name>_default when it exists, and the scratch directory. The log directory stays unless it is empty.

--subject and --attribution override the subject and the last line (Applied-under: scripts/land-work.sh (conductor)). --business and --project are needed only when the config has more than one business file or the business more than one project.

Things a reviewer should know

  • A readOnly open of a WAL database whose writer is closed creates bus.sqlite-shm and -wal beside it. The fixture db has no writer after fixture.mjs closes, so the suite sees this. The database file itself is unchanged (the landing case checks its sha256). On the live bus the writer keeps those files open anyway.
  • The gate inherits the caller's environment, as Sage's template does. Every scripts/test-*.sh that uses MOSAIC_CONFIG sets its own.
  • git patch-id --stable ignores whitespace and line numbers, so a whitespace-only drift can't be made with apply.whitespace=fix. The patch-drift case uses a PATH git wrapper that appends a line to README.md and stages it right after git -C <worktree> apply.
  • The gate inherits TMPDIR, and the cli suite builds unix socket paths under it (<TMPDIR>/mosaic-launch-XXXXXX/data/launches/<business>/r<12 hex>/tools.sock, refused over 107 bytes). My first replay used a 46-byte TMPDIR, and 8 cli tests failed with "data root path too long for a socket". land-work refused gate-failed, wrote no ref and cleaned up; replay-1/ has that run. It is deterministic, not a flake. The TOOLS.md entry says to keep TMPDIR short, and the replay now uses ~/darkwing-scratch/rt (33 bytes). I didn't add a length check to land-work: the limit belongs to the cli suite and could change with it.
  • The working repository has the coder's history. A work ref whose history carries an unlanded commit and its revert (19/265 carries 010d30cbe and its revert) still squashes to the reviewed diff, which is the point of squashing.

Tests

scripts/test-land-work.sh, 78 cases, all passing. It runs in a sandbox under $TMPDIR with a fake docker on PATH that logs its calls, and builds a fixture project repository, a working repository holding the coder's commits, and a bus written through the real Store by fixture.mjs, in the live rows' shape. Each case gets its own copy of all three. It never opens ~/.mosaic-dev or ~/.t3.

Group Cases
Arguments and config --base next and --base main refuse target-branch, before the config is read; a bad id, an unknown flag and a two-line --subject exit 4; a missing system config and a missing bus exit 3
The bus database a changed table and changed meta each refuse bus-schema; openBus opens read-only
The decision 20 variants: other business, no raise, not a merge, open, withdrawn, resolved by an agent, via webui, no human answer, chose no, two work refs, a bad ref, a disagreeing task_ref, target main, superseded by question, by task and by supersedes, no verdict, a later changes verdict, a short commit, a verdict body for another task; plus an unknown id
The work ref and the diff ref-moved; root-file for a top-level file, a new top-level directory and src/; base-conflict for unrelated history, an empty diff and an apply conflict; checkout-dirty for a changed, a staged and an untracked path, and for a path dirtied during the gate (no ref moves); patch-drift through the tamper wrapper
The gate a failing package suite and a failing scripts/test-*.sh each refuse gate-failed and name an existing log; no ref moves; a target without node_modules exits 4; base-moved keeps the base where it moved to
A dry run writes no ref, prints the commit with --subject and --attribution, runs the gate
A landing one commit on the old base; its diff is the reviewed diff by patch-id; its tree is the dry run's; the message has the option's subject, the citations and the attribution last; the PM line prints; the checked-out base gets the reviewed paths and keeps an unrelated edit; only refs/heads/refactor exists; the work ref and the bus file are unchanged; the gate ran every package suite but queue and every scripts/test-*.sh
Cleanup after a refusal past the worktree, patch-drift, gate-failed, no node_modules, a dry run and a landing: no worktree and no scratch; the Docker network is looked for and removed; the target's node_modules is still there

Mutants

mutants.py applies 49 mutants, one at a time, to a copy of the file, runs the whole suite under timeout 600 in its own process group, and restores the file from .orig with a sha256 check. The run ended restored: True. 48 of 49 are killed; mutants-run.log has each one's failing cases.

Mutant Change Result
r01-schema-digest skip the schema digest compare killed, 1 of 78 fail
r02-schema-meta skip the meta version and digest check killed, 1 of 78 fail
r03-read-only open read-write killed, 2 of 78 fail
r04-business-scope drop business = ? from every query killed, 1 of 78 fail
r05-one-raise accept zero or several raises killed, 1 of 78 fail
r06-not-a-merge skip the action check killed, 1 of 78 fail
r07-resolved-op accept any single close op killed, 1 of 78 fail
r08-human-by skip resolved-by == human killed, 1 of 78 fail
r09-human-via skip via == cli killed, 1 of 78 fail
r10-human-answer accept a missing human answer killed, 1 of 78 fail
r11-merge-option skip choice == approvalChoice killed, 1 of 78 fail
r12-one-work-ref accept several work refs killed, 1 of 78 fail
r13-work-ref-shape loosen the work ref pattern killed, 1 of 78 fail
r14-task-ref-agrees skip the task_ref agreement killed, 1 of 78 fail
r15-target skip the target check killed, 1 of 78 fail
r16-later-supersedes ignore supersedes killed, 1 of 78 fail
r17-later-task ignore a later decision on the task killed, 1 of 78 fail
r18-later-question ignore a later question naming the ref killed, 1 of 78 fail
r19-no-verdict skip the no-verdict refusal killed, 1 of 78 fail
r20-latest-verdict take the earliest verdict killed, 30 of 78 fail
r21-approve skip verdict == approve killed, 1 of 78 fail
r22-verdict-task skip the verdict body's task_ref killed, 1 of 78 fail
r23-full-oid accept a short commit killed, 1 of 78 fail
s01-base-early skip the early --base check killed, 1 of 78 fail
s02-ref-moved skip ref-moved killed, 1 of 78 fail
s03-merge-base ignore a missing merge base killed, 1 of 78 fail
s04-empty-diff accept an empty diff killed, 1 of 78 fail
s05-root-top-file allow a top-level file killed, 1 of 78 fail
s06-root-new-dir allow a new top-level directory killed, 1 of 78 fail
s07-root-src-v1 allow src/ and v1/ killed, 1 of 78 fail
s08-dirty-changed skip the changed-path check killed, 3 of 78 fail
s09-dirty-staged skip the staged check killed, 1 of 78 fail
s10-dirty-untracked skip the untracked check killed, 1 of 78 fail
s11-dirty-recheck skip the recheck before the commit killed, 2 of 78 fail
s12-apply-conflict ignore an apply failure killed, 1 of 78 fail
s13-patch-drift skip the patch-id compare killed, 1 of 78 fail
s14-gate-node-modules skip the node_modules check killed, 1 of 78 fail
s15-gate-step-fails ignore a failing gate step killed, 5 of 78 fail
s16-gate-skips-queue run the queue suite too killed, 15 of 78 fail
s17-gate-scripts run no scripts/test-*.sh killed, 8 of 78 fail
s18-base-moved-cas update-ref without the old value killed, 2 of 78 fail
s19-dry-run ignore --dry-run killed, 3 of 78 fail
s20-checkout-sync skip the checkout sync killed, 1 of 78 fail
s21-subject a fixed subject killed, 1 of 78 fail
s22-attribution drop the attribution line killed, 2 of 78 fail
s23-cleanup-worktree keep the worktree killed, 6 of 78 fail
s24-cleanup-network keep the Docker network killed, 1 of 78 fail
s25-cleanup-scratch keep the scratch directory killed, 6 of 78 fail
s26-cleanup-link skip the explicit node_modules unlink survived

s26 is an equivalent mutant. git worktree remove --force and the rm -rf fallback both delete the node_modules symlink without following it, so the target's node_modules survives with or without the explicit unlink, and the suite's "the target's node_modules is still there" case passes either way. I kept the unlink so cleanup never depends on how a git version treats a symlinked directory. r23 is killed by the wrong refusal: with a short commit, ref-moved fires in land-work.sh after read.mjs lets the verdict through, so the case gets exit 2 but not not-approved.

Gate

A targeted builder gate under #271 (pkt/gate.sh, logs in gate/). The candidate changes no package; read.mjs and fixture.mjs import packages/bus/src/store.mjs, so the bus package runs. test-conductor.sh is the conductor suite beside the new one. I searched every scripts/test-*.sh and packages/*/tests file for anything that reads scripts/ as a whole, land-work, TOOLS.md or CONDUCTOR.md; only packages/webui/tests/pinned-copy.test.mjs walks a scripts directory, so it ran too. Sage's landing gate stays full.

Step Result
packages/bus node --test tests/*.test.mjs 156 pass, 0 fail
scripts/test-land-work.sh 78 passed, 0 failed
scripts/test-conductor.sh 17 passed, 0 failed
packages/webui pinned-copy.test.mjs 10 pass, 0 fail

GATE_PASSED. No flake in this gate.

Replay

replay/replay.sh runs land-work from the candidate worktree against scratch copies, with bash replay.sh [BASE0]. It clones the canonical repository into a target with refactor at BASE0 and copies the live working repository (~/.mosaic-dev/working/mosaic-stack/stack.git) once per case. It loads Sage's dump of the live rows (agents/sage/work/row116/live-rows.json) into a fresh bus through fixture.mjs, which uses the real Store. The live bus and the canonical repository are only read. Each run's sandbox config points at its own data root, and TMPDIR is a short scratch path.

replay/rows.mjs leaves out the task.created rows. The schema's events_task_created_body trigger needs the human.input request each one cites, and the dump doesn't carry that request event (6870b3ca), so the Store refuses the insert. land-work never reads task.created, so the read sees the same decision, raise, close, answer and verdict rows the live bus has.

The brief's base and tree can't both hold

The acceptance line says base 1c9a4910d and a resulting tree equal to f7c8537b3^{tree}. f7c8537b3's parent is 4bc908991, not 1c9a4910d. Between them sit b3fca1f1a (my row 115 round 1 packet) and 4bc908991 (its SESSIONS line), 34 files under agents/darkwing/work/queue-115/r1/ and docs/SESSIONS.md. A squash on 1c9a4910d can't produce a tree that has those files, and nothing in the reviewed diff should add them. So I ran it twice:

  • at 1c9a4910d, the brief's base, where the check is that the landed diff equals git diff f7c8537b3^ f7c8537b3;
  • at 4bc908991, f7c8537b3's real parent, where the tree must match exactly.

I'm asking Sage for a ruling Filbert can cite, the same kind as sage-0196.

Run 1: TMPDIR too long (replay-1/)

The first attempt at 1c9a4910d used TMPDIR=~/darkwing-scratch/r116/replay/tmp (46 bytes). The dry-run's gate refused gate-failed: pkg-cli, rc 2, and wrote no ref. 8 cli tests failed with data root path too long for a socket. That suite builds <TMPDIR>/mosaic-launch-XXXXXX/data/launches/<business>/r<12 hex>/tools.sock, and a unix socket path holds 107 bytes. The gate inherits TMPDIR, so this was the environment and not the candidate. I kept the logs, moved the replay TMPDIR to ~/darkwing-scratch/rt-<base>, and added one sentence to the TOOLS.md entry. That edit is in the candidate (TOOLS.md +25, was +23), and build.patch and the manifest were rebuilt after it.

Run 2: base 1c9a4910d (replay-1c9a/)

Check Result
dry-run rc 0, gate GATE_PASSED (land-work-logs.2vWgxG)
dry-run wrote no ref (refs, reflog, status, worktrees, working repo refs, bus sha256) yes
dry-run tree 3ca4beda9, not f7c8537b3's, as explained above
landing rc 0, full gate GATE_PASSED (land-work-logs.QTBGn3)
new commit b295e3002, parent 1c9a4910d, tree equal to the dry-run tree
landed diff equals f7c8537b3's own diff, as text yes
canonical repository lacks the commit yes
working repo refs unchanged yes
bus.sqlite sha256 unchanged yes
target status after ?? node_modules only (the replay's own link)
worktrees after 1
19/229 (010d30cbe), fixture decision over verdict 1fbd1ed1 rc 2, refused: root-file: the reviewed diff adds src/parse-requirement-id.mjs src/parse-requirement-id.test.mjs
land-work scratch left in TMPDIR 0

The commit message is run 3's below with this base in its Base line and first paragraph.

The PM line it printed and did not send: work/vikunja-19-265 (vikunja:19/265) is merged to refactor as b295e3002; push pending.

Three corrections on this run:

  • replay-1c9a/replay.sh is the script as it ran. It had no diff check. I compared the diffs by hand after the run (diff <(git -C target diff --binary b295e3002^ b295e3002) <(git -C canonical diff --binary f7c8537b3^ f7c8537b3), no output), and the current replay.sh does it as a check.
  • Its line remote-tracking refs unchanged: NO was a bug in the check, not a change. It grepped the saved snapshot for ' refs/remotes/' with a leading space, but those lines begin with the ref name, so the before side was empty while the after side listed every remote ref. I compared the remote refs directly and they were identical, as was every target ref other than refactor. The current replay.sh checks "target refs other than refactor unchanged" instead.
  • My slip: while the landing's gate ran, I fetched 4bc908991 and f7c8537b3 into the replay's target clone by hand to look at the tree difference. That wrote FETCH_HEAD and objects into the target. No ref moved, and land-work had already fetched C and built its diff by then. The current replay.sh compares the diffs as text so the target never fetches from the canonical repository, and run 3 has no such fetch.

Run 3: base 4bc908991 (replay-4bc9/)

Same scripts, bash replay.sh 4bc908991, TMPDIR ~/darkwing-scratch/rt-4bc9. replay-4bc9/replay.sh is the script as it ran and equals replay/replay.sh.

Check Result
dry-run rc 0, full gate GATE_PASSED (land-work-logs.adiLIr)
dry-run tree cdd3a62c2, equal to f7c8537b3^{tree}
dry-run wrote no ref yes
landing rc 0, full gate GATE_PASSED (land-work-logs.ASEJp9, 13 package suites and 9 scripts)
new commit 1c0e5e723, parent 4bc908991
tree equals f7c8537b3^{tree} yes
landed diff equals f7c8537b3's own diff, as text yes
canonical repository lacks the commit yes
target refs other than refactor unchanged yes
working repo refs unchanged yes
bus.sqlite sha256 unchanged yes
target status after ?? node_modules only
worktrees after 1
19/229 rc 2, refused: root-file: the reviewed diff adds src/parse-requirement-id.mjs src/parse-requirement-id.test.mjs
land-work scratch left in TMPDIR 0

The commit message:

Squash-merge work/vikunja-19-265 into refactor

Squash of work/vikunja-19-265 (vikunja:19/265) at 8ae5a149152b35a6d8370924775725e5b36c7b92 onto refactor 4bc908991cacf77b04437cbab0a9c1c0d7eaa77c,
the reviewed diff 0c3826910edbd0b76ef60362b49ac6f8b9bab369..8ae5a149152b35a6d8370924775725e5b36c7b92. Resolved yes by jason via the CLI at 2026-10-14T16:35:32.002Z.

Decision: f71c73c9-c759-4aca-98d9-3e22e8c42b36
Verdict: d3f97f9d-f5c6-4f81-924d-d3c0698692f4
Work-ref: work/vikunja-19-265
Reviewed-commit: 8ae5a149152b35a6d8370924775725e5b36c7b92
Base: refactor 4bc908991cacf77b04437cbab0a9c1c0d7eaa77c
Patch-id: 26100825e4128d64e9a84690dba008b9549fe6a5
Gate: full, every package suite and every scripts/test-*.sh
Applied-under: scripts/land-work.sh (conductor)

The PM line: work/vikunja-19-265 (vikunja:19/265) is merged to refactor as 1c0e5e723; push pending.

Across runs 2 and 3, four full gates ran (two dry-runs, two landings), and none had a nonzero step, so the flake rule had nothing to count.

Leftovers outside land-work

After runs 2 and 3, TMPDIR held 347 and 344 entries that aren't land-work's (mosaic-business-*, mosaic-launch-*). The base suites the gate runs leave them, and land-work's own scratch count was 0. I removed each replay TMPDIR after saving the evidence. Not this row's to fix; I note it for whoever owns those suites.

Going live

Nothing goes live. The tool is a conductor command run by hand; no service, role or session calls it, and I ran it only against scratch copies. Push, the merge to next or main, re-pins and restarts stay out of this row.