The decision is spent only at recordPublish, after the push, so two checkPublish calls on one decision could both push and land the commit on two fork refs. checkPublish now refuses publish-busy while an open ticket holds the same business and decision. A refusal drops the ticket and leaves the decision unspent. Broker test and a socket race test with exactly one git-receive-pack. Residuals 1-3 in BUILD.md; residual 3 (checkLand) is a follow-up row. Built by Megavolt (megavolt/row237e42415935, duckworth-0068 N1), reviewed by Duckworth (approve, comment 29810, manifest bb777cb0). Sage's gate on816d48b03plus these paths: M1 killed, every package suite and every scripts/test-*.sh green.
4.3 KiB
Row 237: one decision publishes once (#1743)
Megavolt, 2026-10-21 (from date -u). Brief: docs/plans/2026-10-20_agent-credentials.md
§ "row 199 follow-up N1", blob dca67d310e. Source: Duckworth's row 199 round 1 note N1
(duckworth-0068). Base: cb5fdb064 (row 199's landing). Reviewer Duckworth, gate owner Sage.
The defect
checkPublish opens a ticket after checking the decision is unspent. The decision is spent only at
recordPublish, which runs after the bus process has pushed. Two checkPublish calls on one decision
therefore both get tickets, and both push. With different branches, the commit lands on two fork refs:
refs/heads/<user>/feat and refs/heads/<user>/other. The second recordPublish then refuses
decision-consumed, but by then its push has already reached the fork.
The fix, and why this option
checkPublish refuses publish-busy when an open ticket in #publishes holds the same business and
decision (packages/bus/src/broker.mjs:1038-1040). It runs after the authority and fact checks, so a
spent decision still refuses decision-consumed first.
The brief's other option, rechecking #spent in publishTarget, doesn't close the race. Both tickets
reach publishTarget before either push ends, and nothing is spent until recordPublish. So both
rechecks pass and both pushes run. The only state that exists while the first push runs is the open
ticket, so the check reads that.
A refusal (publishRefused) drops the ticket and leaves the decision unspent, so a retry on the same
decision gets a new ticket. That is row 199's behaviour, unchanged. A within-role publish has no
decision and isn't affected.
Tests
packages/bus/tests/publish.test.mjs:186. A second ticket refusespublish-busy, on the same branch and on another. AfterpublishRefusedthe retry gets a ticket and a third refuses busy. AfterrecordPublishthere is oneaction.allowedfor git.push.remote and the next call refusesdecision-consumed. Another agent's decision on the same task isn't held.packages/cli/tests/publish-socket.test.mjs:269. End to end through the git socket, the bus runtime and the TLS fake Gitea. While the first publish holds its ticket, a second publish on the same decision to branchothergetspublish-busy. The fake Gitea gets exactly onegit-receive-pack, the fork has onlyrefs/heads/coder/feat, and there is oneaction.allowed.
Runs on this branch, single files:
- publish.test.mjs: 13/13.
- publish-socket.test.mjs: 8/8.
Mutant
M1 removes the publish-busy line. Both tests fail:
- The bus test fails at the first busy assert.
- The cli test fails because the second publish answers
ok: truewithref: refs/heads/coder/other. That is the defect: two refs on the fork.
Logs are in /mnt/storage/scratch/megavolt-r237/ (m1-bus.log, m1b-cli.log). cmp confirmed
broker.mjs matched the saved copy after the restore. The first cli mutant run (m1-cli.log) was against
a hook that recursed on every checkPublish. I fixed the hook to fire only for branch feat and reran
it (m1b-cli.log).
Residuals, not fixed here
- A ticket that never ends holds its decision until the bus restarts.
#publishesis in memory. That happens when the host dies between checkPublish and recordPublish or publishRefused. It fails closed (publish-busy), and a restart clears it. - publish-timeout (sage-1440). The socket files
publishRefusedand drops the ticket, but the bus process's push may still be running. A retry on the same decision can then open a ticket while that push is live. This is the same residual the socket already logs ("the bus process's push may still reach the fork"). Closing it means the runtime'sremotesmust keep the decision busy until its push settles, which reaches into runtime.mjs. That is beyond the brief's two options. - checkLand has the same shape.
#landingstakes a ticket per call and the decision is spent atrecordLand. Whether two landings on one decision can both reach main isn't checked here. That's a follow-up for Sage to row or dismiss.
Gate
Per the brief: Duckworth approves the manifest, the bus suite and every scripts/test-*.sh are green
on Sage's gate, and M1 is killed. This row also changes a cli test file, so the cli suite (or that file
alone) should run on the gate too.