Files
jason.woltje d7d9431990 bus: one decision, one open publish; checkPublish refuses publish-busy while another ticket holds the decision (row 237, #1743)
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/row237 e42415935, duckworth-0068 N1), reviewed by Duckworth (approve, comment
29810, manifest bb777cb0). Sage's gate on 816d48b03 plus these paths: M1 killed, every package suite and every
scripts/test-*.sh green.
2026-10-20 20:45:08 -05:00

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 refuses publish-busy, on the same branch and on another. After publishRefused the retry gets a ticket and a third refuses busy. After recordPublish there is one action.allowed for git.push.remote and the next call refuses decision-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 branch other gets publish-busy. The fake Gitea gets exactly one git-receive-pack, the fork has only refs/heads/coder/feat, and there is one action.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: true with ref: 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

  1. A ticket that never ends holds its decision until the bus restarts. #publishes is in memory. That happens when the host dies between checkPublish and recordPublish or publishRefused. It fails closed (publish-busy), and a restart clears it.
  2. publish-timeout (sage-1440). The socket files publishRefused and 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's remotes must keep the decision busy until its push settles, which reaches into runtime.mjs. That is beyond the brief's two options.
  3. checkLand has the same shape. #landings takes a ticket per call and the decision is spent at recordLand. 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.