Files
jason.woltje fc1cb856b0 publish: row 242 follow-up docs, the git socket comments name the hold and the late record; BUILD.md residual and :311 cite (Duckworth N1, N2, N4, #1748)
Megavolt's c5a125f4b on megavolt/row242-docs, parent 5fb736b92 (the row 242
landing). Comments and .md only: git-socket.mjs changes comment lines at
:39-44 and :433-435, BUILD.md records N2 and N4. No test pins either path's
hash. Landed without a gate under the doc-only rule.
2026-10-20 23:17:14 -05:00

6.1 KiB

Row 242 build: a retry after publish-timeout pushes once

Row 242, #1748, P19 440. Brief: docs/plans/2026-10-20_agent-credentials.md, "Brief: row 237 follow-up N1, a retry after publish-timeout pushes once" (9d165be8c). Base: origin/refactor 8fede5fc6 (row 210 landed f5753c0e8).

The defect

The git socket polls publishResult until publishDeadlineMs, then refuses publish-timeout (packages/cli/src/git-socket.mjs:433) while the bus process's push (runtime publishPush) can still be running. The socket then calls publishRefused, which went straight to broker.publishRefused: it filed the refusal and deleted the ticket. checkPublish no longer saw an open ticket on the decision, so a retry got a new ticket and pushed a second ref while the first push was still on its way to the fork.

Fix chosen: hold the decision until the push ends, then record a push that reached the fork

packages/bus/src/runtime.mjs, publishRefused and settle:

  • If the bus has no push for the ticket, publishRefused goes to the broker as before.
  • If the push is still running, the refusal code is held on the entry and the ticket stays open. checkPublish refuses another publish on the decision with publish-busy (row 237's check), the same as when the socket is still polling.
  • When the push ends, settle runs:
    • A push that reached the fork is recorded with broker.recordPublish, which spends the decision. The bus logs pushed after the git socket refused <code>; recorded.
    • A push that failed is filed as publishRefused with the socket's code.
    • If recordPublish refuses with a BusError, that code is filed instead.
  • If the push has already ended when the refusal arrives, it's settled at once. The sage-1440 test relies on this: its push fails fast, so publish-timeout is filed once, immediately, as before.

Why hold rather than kill. Killing the git client doesn't undo a push the fork has already received: receive-pack can update the ref after the client dies. After a kill the bus would have to ls-remote the fork to learn what landed, and the result could still race the server. Holding waits for the push's own outcome. Each git run in publishRemote already has its own limit (publishRemote defaults to checkout.mjs GIT_LIMITS), so a hold is bounded. Holding also reuses the existing publish-busy state, and the refusal text is unchanged.

Why record and not only hold. If the hold just filed publish-timeout once the push ended, the ticket would close and the decision would be free again with a ref on the fork. A retry would then push a second ref. Recording the late push spends the decision, so the retry gets decision-consumed.

The publish-timeout refusal text is unchanged (brief condition).

Test

packages/cli/tests/publish-socket.test.mjs:302, "row 242: a retry after publish-timeout refuses publish-busy while the bus process's push runs; the late push is recorded and the decision pushes once".

  • publishDeadlineMs 150 and a store token. The fork's pre-receive hook writes held, then loops until the test writes release.
  • The first publish gives publish-timeout. The test waits for held.
  • A retry to branch other gives publish-busy (:322). Refused events: ["publish-busy"]. No git.push.remote allowed event.
  • The test writes release and waits until there is exactly one allowed event, with ref refs/heads/coder/feat and the work commit.
  • The next retry gives decision-consumed (:330).
  • Exactly one git-receive-pack request reached the fake Gitea, and the fork has only refs/heads/coder/feat. Refused events: ["publish-busy", "decision-consumed"]. No TOKEN appears in events or logs.

Fail fast (sage-1681): the body is wrapped in try/finally, and the finally writes release. Under the first mutant run, the assert at the retry failed and the hook was never released. setup's teardown waits for the fake Gitea, which waited for the held push, so the file hung until I stopped it. Now a failed assert ends the hook and the run. Each wait is bounded at 10 s with an assert.fail.

Mutants

Script: /mnt/storage/scratch/megavolt-r242/mutants.sh. Each mutant: save, one-line sed, run publish-socket.test.mjs whole, restore, cmp.

Mutant Change Expected kill
M1 export publishRefused: (ticket, args) => broker.publishRefused(ticket, args) again (removes the fix) :302 at :322: the retry gets a new ticket and its own push, so publish-timeout, not publish-busy (seen in the stopped 03:06Z run)
M2 settle files publishRefused without trying recordPublish (hold only) :302 at the wait for one allowed event, then :330

Results (Sage's slot, sage-1686, 2026-10-21T03:35:00Z to 03:35:15Z): both killed, both restored (cmp ok).

  • M1: publish-socket 8/1, the row 242 test fails at :322 in 0.5 s.
  • M2: publish-socket 8/1, the row 242 test fails at :327, the wait for one allowed event, through the assert.fail inside until (:311) after its 10 s bound, in 10.3 s.

Suites

On Sage's slot (sage-1686), on base 8fede5fc6 plus this row:

  • bus node --test tests/*.test.mjs: 499/499, 03:35:20Z to 03:35:34Z. This is one fewer than Sage's 241 gate (500) because the base predates row 241's land.test case.
  • cli node --test tests/*.test.mjs: 566/566, 03:35:34Z to 03:43:47Z. That's 565 plus the row 242 test.

Singles before the slot: publish-socket.test.mjs 9/9, bus publish.test.mjs 13/13. The seat suite and scripts/test-*.sh are left for Sage's landing gate.

Residuals

  • The agent was told publish-timeout, but the push is recorded later and the run isn't notified. The allowed event and the bus log line are the only trace. A later publish on the decision gets decision-consumed, which tells the agent the decision was used. Whether the run should get a RESULT for a late record is a follow-up question for Sage.
  • A late push whose recordPublish refuses (Duckworth N2) is filed with that code: the ref stays on the fork and the decision is not spent, the same residual as an in-band push whose record refuses.
  • The hold lives in the bus process's memory, like the broker's open tickets. A bus restart during a hold drops both, the same as today for any publish in flight.