bus: row 241 one decision lands once; a broker test with two checkLand tickets on one decision, where the second recordLand refuses decision-consumed and records nothing (row 241, #1747, P19 441)
Megavolt's candidate3e3ed303eon megavolt/row241, manifest e8266864 (2 paths: agents/megavolt/work/queue-241/BUILD.md, packages/bus/tests/land.test.mjs). Tests and packet only, no source change. Duckworth approved (comment 29870, rev 2628). Sage's slot on bbf3b1ee8 (0444acd08plus these 2 paths): - mutants M1 (#spent recheck, broker.mjs:963), M2 (CAS old value, seat working.mjs:391), M3 (land-busy, cli git-socket.mjs:351): all killed - full gate: 15 packages 2797 pass 0 fail (bus 500, cli 565), 10 scripts/test-*.sh suites 429 pass 0 fail plus 156 node tests in the queue suite, 03:21:13Z to 03:34:16Z Row 232's landinge65a3ab4dsits between that base and this commit. Its 9 paths are disjoint from these 2 and touch no bus code. No live change.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Row 241: one decision lands once (#1747, P19 441)
|
||||
|
||||
Megavolt, 2026-10-21 (from `date -u`). Brief: `docs/plans/2026-10-20_agent-credentials.md`
|
||||
§ "Brief: row 237 follow-up, one decision lands once, with tests at the broker", c25785d3d. Source:
|
||||
row 237 residual 3, megavolt-0068, megavolt-0069 and sage-1651. Base: origin/refactor f5753c0e8, row 210's landing.
|
||||
Reviewer Duckworth, gate owner Sage. Tests only; no source changes.
|
||||
|
||||
## Why landing has no row 237 race
|
||||
|
||||
Row 237 fixed publish: two tickets on one decision both pushed, because the decision is spent only
|
||||
after the push, and a push to a remote fork can't be taken back. Landing has the same ticket shape
|
||||
(`checkLand` opens a ticket, `recordLand` spends the decision), but three guards keep one decision
|
||||
from landing twice:
|
||||
|
||||
1. **land-busy, the outer guard.** `doLand` in `packages/cli/src/git-socket.mjs:351` refuses
|
||||
`land-busy` while another landing runs. The slot is taken before the first await, and freed only
|
||||
when the landing ends. There is one git socket per launcher, and one host per dataRoot (host.lock).
|
||||
So through the socket two landings never overlap, and a later one stops at `checkLand` with
|
||||
`decision-consumed`. Even past that, applying the same reviewed patch to the new main fails
|
||||
`base-conflict` in `prepareLand`.
|
||||
2. **The CAS on main.** `finishLand` (`packages/seat/src/working.mjs:391`) moves main with
|
||||
`update-ref main <commit> <old>`, so a landing prepared on a tip that has since moved refuses
|
||||
`base-moved` and writes nothing.
|
||||
3. **The recordLand recheck.** `finishLand` calls `record()`, which is `recordLand`, under the same
|
||||
repository lock. `recordLand` runs `#landFacts` again (`packages/bus/src/broker.mjs:963`), which
|
||||
refuses `decision-consumed` once the decision is spent. When `record()` throws, `finishLand` moves
|
||||
main back.
|
||||
|
||||
Guards 2 and 3 can't be reached end to end, because guard 1 stops a second landing first. So they are
|
||||
pinned below the socket, and no test bypasses land-busy.
|
||||
|
||||
### The unrecorded-commit window
|
||||
|
||||
Between the `update-ref` and the end of `record()`, main names the squash, which isn't recorded yet.
|
||||
That window is under the repository's lock, so no other push, review or landing in this host sees
|
||||
it. If `record()` refuses, main moves back. If moving back fails, `finishLand` refuses
|
||||
`rollback-failed` and main keeps naming an unrecorded commit (tested at `working.test.mjs:555`, the
|
||||
main.lock case). That is existing behaviour, unchanged here.
|
||||
|
||||
## The test
|
||||
|
||||
`packages/bus/tests/land.test.mjs:312`. Two tickets pass `checkLand` on one decision before either
|
||||
records. `recordLand` on the first lands. `recordLand` on the second refuses `decision-consumed`
|
||||
(:319), and leaves one merge `action.allowed`, one RESULT and no `action.refused`. `landRefused`, as
|
||||
the socket would call it, then files the refusal and ends the ticket.
|
||||
|
||||
**The assert that pins the recordLand recheck is :319.** The `#spent` line is shared with
|
||||
`checkLand`, so dropping it also fails :144 (a `checkLand` after the decision is spent). Only :319
|
||||
shows the recheck at record time.
|
||||
|
||||
Single file on this branch: land.test.mjs 18/18.
|
||||
Whole suites on this branch before in-review: bus 500/500, seat 164/164, cli 565/565 (02:52 to 03:01Z).
|
||||
|
||||
## Mutants
|
||||
|
||||
| Mutant | Change | Killed by |
|
||||
|---|---|---|
|
||||
| M1 | drop the `#spent` line in `#landFacts` (broker.mjs:963) | land.test.mjs: :312 at :319, and :94 at :144 (16/2) |
|
||||
| M2 | drop `land.old` from the `update-ref` in `finishLand` (working.mjs:391) | working.test.mjs:555 at :567, the `base-moved` assert (25/1) |
|
||||
| M3 | drop the `land-busy` throw (git-socket.mjs:351) | git-socket.test.mjs:812 at :825, the `land-busy` assert (23/1) |
|
||||
|
||||
Each run was one test file on base f5753c0e8 (row 210's landing, which moved the `#spent` line from
|
||||
:945 to :963), 2026-10-21 02:51Z. Each assert failed with "Missing expected exception" or "rejection".
|
||||
`cmp` confirmed each source matched its saved copy after the restore. M2 is killed by the existing
|
||||
working.test:555, so per the brief working.test.mjs isn't changed. Logs: `/mnt/storage/scratch/megavolt-r241/`
|
||||
(m1.log to m3.log, with each diff).
|
||||
|
||||
## Gate
|
||||
|
||||
Per the brief: Duckworth approves the manifest; the bus, seat and cli suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate; the three mutants are killed.
|
||||
@@ -306,6 +306,28 @@ test('a ticket outlives the run that asked, and recordLand checks the task again
|
||||
assert.throws(() => w.b.landRefused(ticket, { code: 'gate-failed' }), /land-unknown/);
|
||||
});
|
||||
|
||||
// Row 241 (#1747): one decision lands once. Two tickets on one decision can't both reach
|
||||
// recordLand through the git socket (land-busy, git-socket.mjs), so this pins the broker's own
|
||||
// guard: recordLand runs #landFacts again, and a decision spent by the first refuses the second.
|
||||
test('two tickets on one decision: the first recordLand lands, the second refuses decision-consumed and records nothing', (t) => {
|
||||
const w = setup(t);
|
||||
const { d } = w.ready();
|
||||
const first = w.check({ task_ref: TASK, decision: d.id }).ticket;
|
||||
const second = w.check({ task_ref: TASK, decision: d.id }).ticket;
|
||||
assert.notEqual(first, second);
|
||||
assert.deepEqual(w.b.recordLand(first, w.land), { task_ref: TASK, commit: B });
|
||||
assert.throws(() => w.b.recordLand(second, { ...w.land, commit: C, previous: B }), /decision-consumed/);
|
||||
assert.equal(w.merges().length, 1);
|
||||
assert.equal(w.merges()[0].body.commit, B);
|
||||
assert.equal(w.results().length, 1);
|
||||
assert.deepEqual(w.events('action.refused'), []);
|
||||
// The git socket then ends the second ticket with the broker's code, as it does any record refusal.
|
||||
assert.equal(w.b.landRefused(second, { code: 'decision-consumed', notify: true }), 'decision-consumed');
|
||||
assert.deepEqual(w.events('action.refused').map((e) => e.body), [{ code: 'decision-consumed' }]);
|
||||
assert.equal(w.merges().length, 1);
|
||||
assert.throws(() => w.b.recordLand(second, w.land), /land-unknown/);
|
||||
});
|
||||
|
||||
test('landRefused files the code on the task, tells the role only with notify, and ends the ticket', (t) => {
|
||||
const w = setup(t);
|
||||
const { d } = w.ready();
|
||||
|
||||
Reference in New Issue
Block a user