# Row 116 (#1610) round 2: a resolved merge decision lands as a gated local commit Brief: docs/plans/2026-10-14_merge-path-for-approved-work.md (blob 7a610f379). Round 1's packet is agents/darkwing/work/queue-116/r1/ (82943bf61). Filbert's round 1 verdict was changes, comment 28464, queue rev 1165, for C1. Sage's sage-0207 asks for C1 as tests only and folds in D1. sage-0204 settles the base and tree question from round 1. BASE: 1223a0f37, unchanged. No candidate path has changed on refactor from 1223a0f37 to a2ee3a80f. Over packages/bus, scripts/test-conductor.sh and the webui pinned-copy test, the only change is still 7eb5ad0cb's requirement count in packages/bus/tests/requirements.test.mjs. The candidate is build.patch against BASE, and candidate-manifest.sha256 lists the same 6 files. `repro.sh` exports BASE with `git archive`, applies build.patch and checks every manifest digest: DIGESTS_OK. ## What changed in round 2 Two files changed. read.mjs, land-work.sh, fixture.mjs and CONDUCTOR.md are byte-identical to round 1, as Filbert ruled no change there; their digests in candidate-manifest.sha256 equal round 1's. | File | Round 2 change | |---|---| | scripts/test-land-work.sh | six cases for C1, and a canary file in the fixture target's node_modules. 84 cases, was 78 | | docs/TOOLS.md | D1: the usage line has `--attribution`, `--business` and `--project`, and the prose says what each does. The Maintenance row says 84 cases | ### C1: one case per condition Each case is a gen.mjs variant built through fixture.mjs like the rest. Each mutant is listed with the round 2 mutants below and was run against the full suite. | Filbert | Condition in read.mjs | Case | Mutant | Mutant result | |---|---|---|---|---| | f1 | the answer's `actor_role` is null | `answer-role`: the answer event has actor_role pm. Refuses `not-human` | r24-answer-role | killed, only this case fails | | f1 | the answer's `actor_run` is null | `answer-run`: the answer event has actor_run rfixture. Refuses `not-human` | r25-answer-run | killed, only this case fails | | f2 | the answer's choice equals the close's | `answer-choice`: the answer says `no`, the close says `yes`. Refuses `not-human` | r26-answer-choice | killed, only this case fails | | f3 | exactly one answer | `two-answers`: the same answer event twice. Refuses `not-human` | r27-one-answer | killed, only this case fails | | f4 | `raise.task_ref` agrees with the work ref | `raise-task-disagrees`: the raise cites vikunja:7/99, the ref is work/vikunja-7-42. Refuses `work-ref` | r28-raise-task-ref | killed, only this case fails | | f5 | `later` counts only later decisions | `earlier-merge`: a merge decision E on the same ref, raised before D and withdrawn. A dry run of D exits 0 | r29-later-only | killed, only this case fails | I split f1 into two cases and two mutants because read.mjs checks `actor_role` and `actor_run` as separate conditions, and a mutant that drops one leaves the other in place. r27 changes `answers.length === 1` to `>= 1`, the narrower form of round 1's r10, which also accepted no answer. The earlier-merge case goes as far as Filbert asked. E is a merge decision with the same task_ref and question, and a `withdrawn` close. fixture.mjs writes it before D, so its seq is lower. The dry run passes every check and runs the gate, then writes no ref. With r29, `later` picks E up and the case refuses `superseded`. ### The canary Filbert's s26 ruling suggested it. The suite's target node_modules was an empty directory, so "the target's node_modules is still there" couldn't see its contents being deleted. Now it holds a file named canary, and the case also checks that the file is there. s27 is the mutant that shows the case can fail: cleanup does `rm -rf "$WT/node_modules/"`, which follows the link and empties the target's directory. s26, which drops the explicit unlink, stays equivalent, as Filbert confirmed on git 2.55.0. ## Tests scripts/test-land-work.sh, 84 cases, all passing (log: test-land-work.log). The round 1 groups are unchanged except these: | Group | Round 2 addition | |---|---| | The decision | `answer-role`, `answer-run`, `answer-choice` and `two-answers` refuse `not-human`, and `raise-task-disagrees` refuses `work-ref`. 25 variants, was 20 | | A dry run | `earlier-merge` exits 0 with a withdrawn earlier merge decision on the same ref | | Cleanup | the target's node_modules is still there, with its canary file | ## Mutants mutants.py has 56 mutants, the 49 from round 1 plus r24 to r29 and s27. It applies each to the file, runs the whole suite under a 600 second timeout in its own process group, and restores the file from `.orig` with a sha256 check. I ran the seven new ones first, then all 56 against the 84-case suite. mutants-run.log has both runs and each mutant's failing cases. The full run ended `restored: True`, and 55 of 56 are killed. s26 survives, and Filbert ruled it equivalent in comment 28464. Four round 1 mutants now fail one more case than they did, each a new case: r10 also fails `two-answers`, r14 `raise-task-disagrees`, and r20 and s16 `earlier-merge`. | Mutant | Change | Result | |---|---|---| | r01-schema-digest | skip the schema digest compare | killed, 1 of 84 fail | | r02-schema-meta | skip the meta version and digest check | killed, 1 of 84 fail | | r03-read-only | open read-write | killed, 2 of 84 fail | | r04-business-scope | drop `business = ?` from every query | killed, 1 of 84 fail | | r05-one-raise | accept zero or several raises | killed, 1 of 84 fail | | r06-not-a-merge | skip the action check | killed, 1 of 84 fail | | r07-resolved-op | accept any single close op | killed, 1 of 84 fail | | r08-human-by | skip resolved-by == human | killed, 1 of 84 fail | | r09-human-via | skip via == cli | killed, 1 of 84 fail | | r10-human-answer | accept a missing human answer | killed, 2 of 84 fail | | r11-merge-option | skip choice == approvalChoice | killed, 1 of 84 fail | | r12-one-work-ref | accept several work refs | killed, 1 of 84 fail | | r13-work-ref-shape | loosen the work ref pattern | killed, 1 of 84 fail | | r14-task-ref-agrees | skip the task_ref agreement | killed, 2 of 84 fail | | r15-target | skip the target check | killed, 1 of 84 fail | | r16-later-supersedes | ignore `supersedes` | killed, 1 of 84 fail | | r17-later-task | ignore a later decision on the task | killed, 1 of 84 fail | | r18-later-question | ignore a later question naming the ref | killed, 1 of 84 fail | | r19-no-verdict | skip the no-verdict refusal | killed, 1 of 84 fail | | r20-latest-verdict | take the earliest verdict | killed, 31 of 84 fail | | r21-approve | skip verdict == approve | killed, 1 of 84 fail | | r22-verdict-task | skip the verdict body's task_ref | killed, 1 of 84 fail | | r23-full-oid | accept a short commit | killed, 1 of 84 fail | | r24-answer-role | skip the answer's actor_role check | killed, 1 of 84 fail | | r25-answer-run | skip the answer's actor_run check | killed, 1 of 84 fail | | r26-answer-choice | skip the answer's choice == the close's | killed, 1 of 84 fail | | r27-one-answer | accept several answers | killed, 1 of 84 fail | | r28-raise-task-ref | drop the raise's task_ref from the agreement | killed, 1 of 84 fail | | r29-later-only | `later` counts earlier decisions too | killed, 1 of 84 fail | | s01-base-early | skip the early `--base` check | killed, 1 of 84 fail | | s02-ref-moved | skip ref-moved | killed, 1 of 84 fail | | s03-merge-base | ignore a missing merge base | killed, 1 of 84 fail | | s04-empty-diff | accept an empty diff | killed, 1 of 84 fail | | s05-root-top-file | allow a top-level file | killed, 1 of 84 fail | | s06-root-new-dir | allow a new top-level directory | killed, 1 of 84 fail | | s07-root-src-v1 | allow `src/` and `v1/` | killed, 1 of 84 fail | | s08-dirty-changed | skip the changed-path check | killed, 3 of 84 fail | | s09-dirty-staged | skip the staged check | killed, 1 of 84 fail | | s10-dirty-untracked | skip the untracked check | killed, 1 of 84 fail | | s11-dirty-recheck | skip the recheck before the commit | killed, 2 of 84 fail | | s12-apply-conflict | ignore an apply failure | killed, 1 of 84 fail | | s13-patch-drift | skip the patch-id compare | killed, 1 of 84 fail | | s14-gate-node-modules | skip the node_modules check | killed, 1 of 84 fail | | s15-gate-step-fails | ignore a failing gate step | killed, 5 of 84 fail | | s16-gate-skips-queue | run the queue suite too | killed, 16 of 84 fail | | s17-gate-scripts | run no scripts/test-*.sh | killed, 8 of 84 fail | | s18-base-moved-cas | update-ref without the old value | killed, 2 of 84 fail | | s19-dry-run | ignore `--dry-run` | killed, 3 of 84 fail | | s20-checkout-sync | skip the checkout sync | killed, 1 of 84 fail | | s21-subject | a fixed subject | killed, 1 of 84 fail | | s22-attribution | drop the attribution line | killed, 2 of 84 fail | | s23-cleanup-worktree | keep the worktree | killed, 6 of 84 fail | | s24-cleanup-network | keep the Docker network | killed, 1 of 84 fail | | s25-cleanup-scratch | keep the scratch directory | killed, 6 of 84 fail | | s26-cleanup-link | skip the explicit node_modules unlink | survived, equivalent (Filbert, comment 28464) | | s27-cleanup-follows-link | cleanup follows the link and empties the target's node_modules | killed, 1 of 84 fail | ## Gate A targeted builder gate under #271 (gate.sh, logs in gate/), the same steps as round 1. Round 1 ran the webui pinned-copy test by hand beside gate.sh; this round gate.sh runs it as its fourth step. Sage's landing gate stays full. | Step | Result | |---|---| | packages/bus `node --test tests/*.test.mjs` | 156 pass, 0 fail | | scripts/test-land-work.sh | 84 passed, 0 failed | | scripts/test-conductor.sh | 17 passed, 0 failed | | packages/webui `node --test tests/pinned-copy.test.mjs` | 10 pass, 0 fail | GATE_PASSED (gate-run.log). No step failed, so the flake rule had nothing to count. The suite also passed 84 of 84 on its own before the mutants (test-land-work.log), and s26, the one survivor, passed all 84 in the full mutant run. ## Replay Round 1's replay stands, and I didn't rerun it. It ran read.mjs and land-work.sh, which are byte-identical in this candidate, against scratch copies of the live rows. Filbert checked it under sage-0204: at 1c9a4910d the landed diff is f7c8537b3's own diff and the dry run wrote no ref, and at 4bc908991 the tree equals `f7c8537b3^{tree}`. The commit stayed local in both. The evidence is in agents/darkwing/work/queue-116/r1/ (build.md's Replay section, replay-1c9a/, replay-4bc9/). ## Going live Nothing goes live. The tool is a conductor command run by hand. No service, role or session calls it. Push, the merge to next or main, re-pins and restarts stay out of this row.