Files

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 timer accessSweep (:1039), a user's request (:4542), access.reload (:4561) and threadsState (:1107 and the access-expiry sweep at :1113).
  • The launcher learns of the loss from threadsState's revoked: true row, records thread-revoked and 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 op revoke, so the check at :1069 fails. :1070 answers launcher-released only for a run in #launcherReleased (:431, row 302), and not-holder otherwise.
  • The runner's ended() (packages/harness/src/runner.mjs:488-500) counts not-holder as a gone session (SESSION_GONE, :55). It sets code = EXIT.session and the run exits 22 with session-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(), keyed business:run, with a comment naming row 306.
  • #revokeLost adds each ended claim's h.business + ':' + h.holder_run. It adds them after the transaction at :1018-1026 commits, by iterating ended, so a transaction that throws leaves the Set unchanged. This is the only place the Set grows. No other #claimEnd caller adds to it: assistant.stop (:6078), role.revoke/role.end (:5333, :5338), instance.fire (:4780), the limit stop (:4949), threadIdle (:1133) and landRelease do not.
  • #agent's refusal at :1070 becomes three-way, and only on the existing failure branch: launcher-released if the run is in #launcherReleased, else thread-revoked if it is in #accessRevoked, else not-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 for threadIdle or landRelease to release (both refuse not-holder first, :1131 and :4237).
  • Where the memory ends. endLaunch deletes 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 revoke with reason access-revoked. It would survive a broker restart. But reason is caller text at :4780, :5333 and :5338, so a human's role.revoke with reason access-revoked would turn that run's not-holder into thread-revoked, a stopped exit. by doesn't separate the cases either: access.reload passes 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.claim keeps its order: launcher-released (:5252), then run-revoked for a run with any revoke row in its session (:5257-5258), then thread-revoked from the file (:5259). bindLaunch keeps refusing thread-revoked for a new run of a lost thread (:1371). The limit stop's not-holder (:4952) is unchanged.

D2: the runner maps thread-revoked from a call to stopped

  • runner.mjs gets THREAD_REVOKED = "thread-revoked" next to LAUNCHER_RELEASED (:58), with a comment naming row 306.

  • In ended() (:491-500), thread-revoked takes the launcher-released branch:

    • it logs its own line, runner: thread revoked by the access file;
    • it sets released = true and returns true;
    • code stays EXIT.stopped.

    released also skips the exit-time role.release (:596-602), which is right because the sweep already ended the claim.

  • The refusal of the run's first role.claim (:459-467) is unchanged. thread-revoked there stays EXIT.claim (21), because the brief maps it only "from a call" after the claim. Mostly bindLaunch refuses such a run before it starts.

  • ended() covers every call site in the loop: landing state (:521), message.receive (:543), the turn's state (:561) and the reply's message.send/message.read (:589). The harness's tool calls go through the tool server and return their error to the harness, as they do for launcher-released today. The run stops at its next own call.

D3: docs

  • packages/bus/README.md:328: "the run's next bus call refuses not-holder" becomes thread-revoked, until the run ends (row 306).
  • packages/harness/README.md:296: exit 0 adds thread-revoked next to launcher-released.
  • The #revokeLost comment 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-revoked on message.receive, state, message.read and message.send, and each answer is logged as action.refused with code thread-revoked. Its role.claim still refuses run-revoked. The same holds when the trigger is a user's request or access.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.stop ended answers not-holder;
    • a third run with a pending limit stop answers not-holder on its first message.receive, and then not-holder again 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.

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 threadsState op calls runtime.threadsState. That call's sweep ends A's claim. When the rows include A as revoked, and before returning them to the launcher, the op has the human assistant.stop the 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's end, 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-revoked event for A's run (r2, Dewey C1). The launcher removes A from the registry before it records end (packages/cli/src/launcher.mjs:643-646), so when the held op returns, registry.running at :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 working and no role.release:, and ends with exiting 0;
      • the store's action.refused codes for A include thread-revoked and not not-holder.
    • 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.
    • 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 gets not-holder and 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-holder becomes thread-revoked in 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 --numstat for it reads 7 7. Dewey checks that in the manifest round. At fe4ee8c7b those are the only seven not-holder lines 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 stays not-holder/22. M3 would turn it red too.
    • A git grep of packages/*/tests/* finds not-holder after a revoke only in access-revoke.test.mjs.

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-*.sh under timeout -k 10 at 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-holder and exits 22, as every revoked run does today. Seeding the Set from the store at a rebind would need a mark only #revokeLost writes (D1's alternative shows reason and by aren't one). I'd leave it unless Dewey wants that mark.
  • N2. A revoked run that also has a pending limit stop gets #limitStop's not-holder (:4572-4573, :4952) on message.receive and state, 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-revoked launch-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 #claimEnd is :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-holder where thread-revoked is expected. T2 green.
  • cli revoke-window T3 red: A's verdict is session stopped working: not-holder, its end session-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.