Megavolt'sc5a125f4bon megavolt/row242-docs, parent5fb736b92(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.
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,
settleruns:- 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.
- A push that reached the fork is recorded with broker.recordPublish, which spends the
decision. The bus logs
- 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 writesrelease. - The first publish gives publish-timeout. The test waits for
held. - A retry to branch
othergives publish-busy (:322). Refused events: ["publish-busy"]. No git.push.remote allowed event. - The test writes
releaseand 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.