12 KiB
Row 306: a revoked run's late bus call stops it instead of exiting 22 (#1813, 295 N1)
Owner Morgana. Reviewer Dewey. Gate owner Sage. Brief: docs/plans/2026-10-23_gate-env-rows.md, third
section (c16e943b9). Go: sage-3478 (Mos-1453 puts 306 next). Base: refactor fe4ee8c7b, after row 272
(d33989230) and row 307 (d243f6b25). Every line below is as it stands at fe4ee8c7b.
Design round 1 (no code yet), round 2 edits marked r2
The window today
#revokeLost(packages/bus/src/broker.mjs:1012-1029) ends each lost user thread's held claim in one transaction:#claimEnd(..., 'revoke', null, 'access-revoked', by)at :1022. The four callers are the timeraccessSweep(:1039), a user's request (:4542),access.reload(:4561) andthreadsState(:1107 and the access-expiry sweep at :1113).- The launcher learns of the loss from
threadsState'srevoked: truerow, recordsthread-revokedand sends SIGTERM (packages/cli/src/launcher.mjs:710-718). - A runner call between that claim end and the signal goes through
#agent(:1064-1072). The run's latest claim row is now oprevoke, so the check at :1069 fails. :1070 answerslauncher-releasedonly for a run in#launcherReleased(:431, row 302), andnot-holderotherwise. - The runner's
ended()(packages/harness/src/runner.mjs:488-500) countsnot-holderas a gone session (SESSION_GONE, :55). It setscode = EXIT.sessionand the run exits 22 withsession-invalid.
D1: the broker remembers the revoked run in memory, the same way as #launcherReleased
- A new field next to
#launcherReleased(:431):#accessRevoked = new Set(), keyedbusiness:run, with a comment naming row 306. #revokeLostadds each ended claim'sh.business + ':' + h.holder_run. It adds them after the transaction at :1018-1026 commits, by iteratingended, so a transaction that throws leaves the Set unchanged. This is the only place the Set grows. No other#claimEndcaller adds to it:assistant.stop(:6078),role.revoke/role.end(:5333, :5338),instance.fire(:4780), the limit stop (:4949),threadIdle(:1133) andlandReleasedo not.#agent's refusal at :1070 becomes three-way, and only on the existing failure branch:launcher-releasedif the run is in#launcherReleased, elsethread-revokedif it is in#accessRevoked, elsenot-holder. A run that still holds its claim never reaches the branch. The two sets don't overlap. A released run holds no claim for the sweep to end, and a revoked run holds none forthreadIdleorlandReleaseto release (both refusenot-holderfirst, :1131 and :4237).- Where the memory ends.
endLaunchdeletes the key next to#launcherReleased.delete(key)at :1394. That is also where every capability of the run is dropped (:1395-1396), so after it no call of the run reaches#agent. - The alternative I didn't take. Derive the answer from the store: the run's latest claim row is
op
revokewith reasonaccess-revoked. It would survive a broker restart. Butreasonis caller text at :4780, :5333 and :5338, so a human'srole.revokewith reasonaccess-revokedwould turn that run'snot-holderintothread-revoked, a stopped exit.bydoesn't separate the cases either:access.reloadpasses the human's name (:4561). The Set can't be spoofed that way, and it matches row 302's shape. The cost is N1 below. - Unchanged.
role.claimkeeps its order:launcher-released(:5252), thenrun-revokedfor a run with any revoke row in its session (:5257-5258), thenthread-revokedfrom the file (:5259).bindLaunchkeeps refusingthread-revokedfor a new run of a lost thread (:1371). The limit stop'snot-holder(:4952) is unchanged.
D2: the runner maps thread-revoked from a call to stopped
-
runner.mjs gets
THREAD_REVOKED = "thread-revoked"next toLAUNCHER_RELEASED(:58), with a comment naming row 306. -
In
ended()(:491-500),thread-revokedtakes the launcher-released branch:- it logs its own line,
runner: thread revoked by the access file; - it sets
released = trueand returns true; codestaysEXIT.stopped.
releasedalso skips the exit-timerole.release(:596-602), which is right because the sweep already ended the claim. - it logs its own line,
-
The refusal of the run's first
role.claim(:459-467) is unchanged.thread-revokedthere staysEXIT.claim(21), because the brief maps it only "from a call" after the claim. MostlybindLaunchrefuses such a run before it starts. -
ended()covers every call site in the loop: landingstate(:521),message.receive(:543), the turn'sstate(:561) and the reply'smessage.send/message.read(:589). The harness's tool calls go through the tool server and return their error to the harness, as they do forlauncher-releasedtoday. The run stops at its next own call.
D3: docs
- packages/bus/README.md:328: "the run's next bus call refuses
not-holder" becomesthread-revoked, until the run ends (row 306). - packages/harness/README.md:296: exit 0 adds
thread-revokednext tolauncher-released. - The
#revokeLostcomment at broker.mjs:1008 changes the same way as the bus README.
Tests (red first under mos-1272)
New file packages/bus/tests/revoke-window.test.mjs
It uses access-revoke.test.mjs's fixture shape, copied, because that file is an old test.
- T1. A run the timer sweep ended answers
thread-revokedonmessage.receive,state,message.readandmessage.send, and each answer is logged asaction.refusedwith codethread-revoked. Itsrole.claimstill refusesrun-revoked. The same holds when the trigger is a user's request oraccess.reload, one assert each, because all three go through#revokeLost. - T2, the control. In the same broker, after the sweep ended the first run:
- a second user thread's run whose claim the human's
assistant.stopended answersnot-holder; - a third run with a pending limit stop answers
not-holderon its firstmessage.receive, and thennot-holderagain on its next call (r2, Dewey C2). The first answer comes from#limitStop(:4952) and never reaches#agent, so only the second call checks the Set.
- a second user thread's run whose claim the human's
New file packages/cli/tests/revoke-window.test.mjs
thread-revoke.test.mjs's start(), copied the way idle-stop-race.test.mjs copied thread-launch's,
with the threadsState op replaced by the caller.
- T3, the forcing test.
- Setup: a user thread A owned by alice and a human-owned thread B. Both get a message and a RESULT, so both runs hold.
- Trigger: a hand edit drops alice's grant.
- The hold: the replaced
threadsStateop callsruntime.threadsState. That call's sweep ends A's claim. When the rows include A asrevoked, and before returning them to the launcher, the op has the humanassistant.stopthe instance's human-owned threads (B), then waits. It waits until A's runner has written its verdict line and the launch log has A'send, then the same for B. The launcher doesn't tick while a pass is running, so nothing signals either run while the op is held. - Asserts for A:
- the verdict is
thread revoked by the access file; - the end is
["stopped", 0, null]; - the launch log has no
thread-revokedevent for A's run (r2, Dewey C1). The launcher removes A from the registry before it recordsend(packages/cli/src/launcher.mjs:643-646), so when the held op returns,registry.runningat :705 finds nothing and :711-717 record and signal nothing. The runner stopped by itself before any SIGTERM; - the runner log has no
session stopped workingand norole.release:, and ends withexiting 0; - the store's
action.refusedcodes for A includethread-revokedand notnot-holder.
- the verdict is
- Asserts for B, the control:
- the verdict is
session stopped working: not-holder; - the end is
["session-invalid", 22, null]; - B's codes include
not-holder.
- the verdict is
- Every wait returns what it last saw, so a mutant fails on an assertion, not a timeout (sage-2707).
Mutants
| # | Edit | Must turn red |
|---|---|---|
| M1 | #agent drops the #accessRevoked check: the failure answers launcher-released or not-holder |
T1, T3 (A gets 22) |
| M2 | runner ended() maps thread-revoked to EXIT.session (it moves into SESSION_GONE) |
T3 (A exits 22) |
| M3 | every ended thread claim marks its run revoked: #claimEnd adds s.business:h.holder_run to #accessRevoked when h.thread (anchor if (h.thread), one hit at :6495) |
T2 (both halves, the limit half on its second call), T3 (B gets thread-revoked) |
Each anchor must hit exactly once. The kit prints the count, as row 295's M8 receipt did.
Expected first-run reds
- New tests (mos-1272). On a red-first run against
fe4ee8c7b, T1 and T3 are red: A getsnot-holderand exits 22. T2 is green on both sides. Any other first-run red in T1-T3 I place myself and report after. - Old test that changes: Mos GO (mos-1463, via sage-3483). In
packages/bus/tests/access-revoke.test.mjs (row 295),
not-holderbecomesthread-revokedin the six asserts at :103, :120, :140, :171, :201 and :262, and the :95 title's "refuses not-holder" becomes "refuses thread-revoked". Nothing else in the file changes;git diff --numstatfor it reads7 7. Dewey checks that in the manifest round. Atfe4ee8c7bthose are the only sevennot-holderlines in the file. - Checked and not expected to change.
- thread-revoke.test.mjs:180 asserts
["stopped", 0, null]for a run that only the SIGTERM ends. If its runner's poll happened to land in the window, it would get exit 0 now, where today it would get 22, so the assert holds either way. - idle-stop-race.test.mjs's control is a human
assistant.stop, not a revoke, so it staysnot-holder/22. M3 would turn it red too. - A
git grepofpackages/*/tests/*findsnot-holderafter a revoke only in access-revoke.test.mjs.
- thread-revoke.test.mjs:180 asserts
Gate (per the brief)
Dewey approves this design, then the manifest. On Sage's gate (mos-1455 D4, sage-3483/3484):
- the new files and access-revoke.test.mjs whole;
- the bus, cli, plugins, webui, business and harness suites;
- every
scripts/test-*.shundertimeout -k 10at three times its longest run (mos-1275); - M1-M3.
Every suite shows 0 failures. Any red follows mos-1272.
N items
Both stay out of this row (sage-3483); Sage adds them to DEFERRED at landing. Dewey agrees on N1 (dewey-0918).
- N1. The Set is lost on a broker restart. recover() rebinds an earlier host's run (:1366-1373),
and if that run makes a call before the launcher's SIGTERM, it gets
not-holderand exits 22, as every revoked run does today. Seeding the Set from the store at a rebind would need a mark only#revokeLostwrites (D1's alternative showsreasonandbyaren't one). I'd leave it unless Dewey wants that mark. - N2. A revoked run that also has a pending limit stop gets
#limitStop'snot-holder(:4572-4573, :4952) onmessage.receiveandstate, because that check runs before#agent. It's rare; I'd leave it and record it.
Round 2 (dewey-0918 CHANGES C1-C3, sage-3483)
- C1: T3 asserts A has no
thread-revokedlaunch-log event, in place of "end before the event". - C2: T2's limit-stopped run makes a second call, which carries M3.
- C3: the gate lists the six suites and every scripts/test-*.sh with 0 failures; mos-1463's bounds for access-revoke.test.mjs are in "Expected first-run reds".
- Cosmetic: threadIdle's
#claimEndis :1133.
Build (dewey-0920 APPROVE r2, sage-3487)
- Red-first commit
d38c5213a: packages/bus/tests/revoke-window.test.mjs (T1 two tests, T2) and packages/cli/tests/revoke-window.test.mjs (T3), no product code. - Build commit (this one): D1 in broker.mjs, D2 in runner.mjs, D3 in the two READMEs and the #revokeLost comment, and the mos-1463 edit to access-revoke.test.mjs (numstat 7 7).
- Nothing has run yet; every run is slot work.
Expected first-run reds, on d38c5213a (new tests, fe4ee8c7b product):
- bus revoke-window: both T1 tests red, A answers
not-holderwherethread-revokedis expected. T2 green. - cli revoke-window T3 red: A's verdict is
session stopped working: not-holder, its endsession-invalid/22. - access-revoke.test.mjs is unchanged at
d38c5213a, so it stays green there.
On the build commit every file above, and every gate suite, is expected green.