Compare commits

...
8 changed files with 603 additions and 18 deletions

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
# 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.
+3 -2
View File
@@ -325,8 +325,9 @@ starts such a thread and sends a running one SIGTERM.
Revocation (row 295, #1801, FED R4, federation design 3.7). A user thread is lost when no grant
in the access file covers it at collaborator level, so a downgrade to viewer is a loss. One
transaction ends the held claim of every session of every lost thread, op `revoke`, reason
`access-revoked`; the run's next bus call refuses `not-holder`, and a new run of the thread
refuses `thread-revoked` at `bindLaunch` and at `role.claim`. A rebind of an earlier host's run is
`access-revoked`; the run's bus calls refuse `thread-revoked` until it ends (row 306, so the runner
stops rather than exit as a lost session), and a new run of the thread refuses `thread-revoked` at
`bindLaunch` and at `role.claim`. A rebind of an earlier host's run is
allowed, so the launcher's recovery can end it. Four triggers end the claims, each with its `by`:
the runtime's timer, every 30 s (`access.intervalMs` sets it in process, as `librarySync.intervalMs`
does), which reads the file whatever its stamp (`access-timer`); a user's request or a
+12 -5
View File
@@ -429,6 +429,10 @@ export class Broker {
// landRelease release and then signal, so the run's own calls in between answer launcher-released,
// not not-holder, and the runner exits stopped instead of as a lost session.
#launcherReleased = new Set();
// Row 306 (#1813): runs whose claim #revokeLost ended, by business:run, until endLaunch. The launcher
// signals only after its threadsState op returns, so the run's own calls in between answer
// thread-revoked, not not-holder, and the runner exits stopped instead of as a lost session.
#accessRevoked = new Set();
#clock = 0;
#processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
@@ -1005,10 +1009,10 @@ export class Broker {
covers(g, { business: t.business, project: t.project, instance: t.instance }, now, { level: 'collaborator' }));
}
// Design 3.7: on a loss, one transaction ends the held claim of every session of every user thread
// the access file no longer covers, in every business. The run's next bus call refuses not-holder
// (F10), and a later claim refuses thread-revoked. Runs only when the read is new: a forced read,
// a stamp it hasn't swept, or no stamp at all. `doc` is a read the caller just made, so a request
// reads once. Returns the ended claims.
// the access file no longer covers, in every business. The run's bus calls refuse thread-revoked
// until it ends (row 306), and a later claim refuses thread-revoked. Runs only when the read is
// new: a forced read, a stamp it hasn't swept, or no stamp at all. `doc` is a read the caller just
// made, so a request reads once. Returns the ended claims.
#revokeLost(by, force = false, doc = this.#accessDoc(force)) {
const stamp = this.#accessSeen.stamp;
if (!force && stamp !== null && stamp === this.#accessSwept) return [];
@@ -1023,6 +1027,7 @@ export class Broker {
ended.push(h);
}
});
for (const h of ended) this.#accessRevoked.add(h.business + ':' + h.holder_run);
this.#accessSwept = stamp;
return ended;
}
@@ -1067,7 +1072,8 @@ export class Broker {
if (s.human) fail('agent-required');
const h = this.#own(s);
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run))
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released' : 'not-holder');
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released'
: this.#accessRevoked.has(s.business + ':' + s.run) ? 'thread-revoked' : 'not-holder');
return h;
}
// Row 264a (#1769, T2): the generation a thread run holds, its latest claim's in its session, or
@@ -1392,6 +1398,7 @@ export class Broker {
if (exitCode !== null && !Number.isSafeInteger(exitCode)) fail('invalid-request');
this.#ended.add(key);
this.#launcherReleased.delete(key);
this.#accessRevoked.delete(key);
for (const [cap, s] of this.#sessions)
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
return this.#store.transaction(() => {
+7 -7
View File
@@ -92,7 +92,7 @@ function setup(t, { runs = true, grants = [grant(), grant({ user: 'bob' })] } =
const withoutAlice = (gs) => gs.filter((g) => g.user !== 'alice');
test('R4: the timer sweep after a hand edit ends only the lost user\'s run; its next call refuses not-holder', (t) => {
test('R4: the timer sweep after a hand edit ends only the lost user\'s run; its next call refuses thread-revoked', (t) => {
const { b, code, edit, claim, A } = setup(t);
edit(withoutAlice);
const ended = b.accessSweep();
@@ -100,7 +100,7 @@ test('R4: the timer sweep after a hand edit ends only the lost user\'s run; its
assert.deepEqual({ ...ended[0], session: undefined }, { business: 'demo', thread: A, session: undefined, run: 'a-run' });
assert.equal(typeof ended[0].session, 'string');
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-timer' });
assert.equal(code('a', 'message.receive'), 'not-holder', 'the ended run');
assert.equal(code('a', 'message.receive'), 'thread-revoked', 'the ended run');
assert.equal(code('b', 'message.receive'), null, 'another user\'s run stays');
assert.equal(code('h', 'message.receive'), null, 'the human\'s run stays');
assert.deepEqual([claim('b-run').op, claim('h-run').op], ['claim', 'claim']);
@@ -117,7 +117,7 @@ test('R4: an expiry with no write and no request ends the run at the next timer
assert.equal(code('bob', 'thread.list'), null);
assert.equal(claim('a-run').op, 'claim');
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
assert.equal(code('a', 'message.receive'), 'not-holder');
assert.equal(code('a', 'message.receive'), 'thread-revoked');
assert.equal(code('b', 'message.receive'), null);
});
@@ -137,7 +137,7 @@ test('R4: another user\'s request after a hand edit ends the lost user\'s run, a
assert.equal(code('a', 'message.receive'), null, 'an agent request is not a trigger');
assert.equal(code('bob', 'thread.list'), null);
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-change' });
assert.equal(code('a', 'message.receive'), 'not-holder');
assert.equal(code('a', 'message.receive'), 'thread-revoked');
assert.equal(code('b', 'message.receive'), null);
assert.equal(code('h', 'message.receive'), null);
});
@@ -168,7 +168,7 @@ test('R4: access.reload ends the lost runs at once for the human; the tester, a
assert.deepEqual(ended.map((h) => [h.thread, h.run]), [[A, 'a-run']]);
assert.equal(typeof ended[0].session, 'string');
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'jason' });
assert.equal(code('a', 'message.receive'), 'not-holder');
assert.equal(code('a', 'message.receive'), 'thread-revoked');
assert.equal(code('b', 'message.receive'), null);
assert.deepEqual(b.accessSweep(), []);
});
@@ -198,7 +198,7 @@ test('R4: threadsState after an expiry with no write and no sweep ends the claim
const a = row(A);
assert.deepEqual([a.revoked, a.holder], [true, null]);
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-expiry' });
assert.equal(code('a', 'message.receive'), 'not-holder');
assert.equal(code('a', 'message.receive'), 'thread-revoked');
assert.deepEqual([row(B).holder, claim('b-run').op], ['b-run', 'claim']);
// Ended once: the next call writes no second revoke.
row(A);
@@ -259,7 +259,7 @@ test('R4 runtime: a hand edit ends the run at the first timer period, intervalMs
t.mock.timers.tick(ACCESS_SWEEP_MS - 1);
assert.equal(receive(), null, 'before the first period');
t.mock.timers.tick(1);
assert.equal(receive(), 'not-holder', 'at the first period');
assert.equal(receive(), 'thread-revoked', 'at the first period');
// After close the timer is gone: the sweep is never called again.
let sweeps = 0;
b.accessSweep = () => void sweeps++;
+140
View File
@@ -0,0 +1,140 @@
// Row 306 (#1813, row 295's N1): a run whose claim #revokeLost ended answers thread-revoked on every
// claim-needing call until the run ends, so the runner stops it instead of exiting session-invalid.
// A claim another cause ended still answers not-holder. access-revoke.test.mjs's fixture, copied, with
// a limit stub and a usage stub for the limit stop. BUILD.md (agents/morgana/work/queue-306) names
// the mutant each test kills.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const grant = (over = {}) => ({
id: `g-${over.user ?? 'alice'}`, user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
const BUSINESSES = {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, plugins: { harness: ['pi'] },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
};
const HIT = { path: ['tokens', 'instances', 'assistant'], limit: 100, count: 120, unknownTurns: 1 };
// Alice and Bob each hold a collaborator grant and own a thread; the human owns thread H. Each thread
// has a claimed run: a-run on A, b-run on B, h-run on H. `stops` are the limit checks' answers, in order.
function setup(t, { stops = [] } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-revoke-window-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [grant(), grant({ user: 'bob' })] }, rev: 1 };
const b = new Broker({
store,
businesses: BUSINESSES,
access: () => access.doc,
accessStamp: () => String(access.rev),
limits: { mustStop: async () => stops.shift() ?? { stop: false } },
usage: { collect: async () => ({ recorded: true }), view: () => ({}) },
});
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (who, verb, args = {}) => {
try {
call(who, verb, args);
} catch (e) {
return e.code;
}
return null;
};
const edit = (fn) => {
access.doc = { ...access.doc, revision: access.doc.revision + 1, grants: fn(access.doc.grants) };
access.rev++;
};
const launch = (name, thread) => {
caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
return call(name, 'role.claim');
};
const claim = (run) => ({ ...store.get('SELECT op, reason, by FROM thread_claims WHERE holder_run=? ORDER BY seq DESC LIMIT 1', run) });
// The codes of the run's refusals, as request() logs them.
const refused = (run) =>
store.all("SELECT body FROM events WHERE kind='action.refused' AND actor_run=? ORDER BY seq", run).map((e) => JSON.parse(e.body).code);
const P = call('human', 'project.create', { name: 'shared' }).id;
caps.alice = b.bindUser({ business: 'demo', user: 'alice' });
caps.bob = b.bindUser({ business: 'demo', user: 'bob' });
const A = call('alice', 'thread.create', { project: P, name: 'alice work' }).id;
const B = call('bob', 'thread.create', { project: P, name: 'bob work' }).id;
const H = call('human', 'thread.create', { project: P, name: 'owner work' }).id;
launch('a', A);
launch('b', B);
launch('h', H);
return { b, store, caps, call, code, edit, claim, refused, A, B, H };
}
const withoutAlice = (gs) => gs.filter((g) => g.user !== 'alice');
// The run's claim-needing calls after the sweep: each answers thread-revoked, and each is logged.
function lateCalls(code) {
return [
code('a', 'message.receive'),
code('a', 'state'),
code('a', 'message.read', { id: 'm-none' }),
code('a', 'message.send', { to: 'human', body: 'late' }),
];
}
test('row 306 T1: a run the timer sweep ended answers thread-revoked on every claim-needing call, logged, and its claim still refuses run-revoked', (t) => {
const { b, code, edit, claim, refused } = setup(t);
edit(withoutAlice);
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-timer' });
assert.deepEqual(lateCalls(code), Array(4).fill('thread-revoked'));
assert.deepEqual(refused('a-run'), Array(4).fill('thread-revoked'));
assert.equal(code('a', 'role.claim'), 'run-revoked', 'role.claim keeps its order');
assert.equal(code('b', 'message.receive'), null, 'another user\'s run stays');
assert.equal(code('h', 'message.receive'), null, 'the human\'s run stays');
});
test('row 306 T1: a user\'s request and access.reload end the run the same way, and it answers thread-revoked', (t) => {
for (const trigger of ['request', 'reload']) {
const { code, call, edit, claim } = setup(t);
edit(withoutAlice);
if (trigger === 'request') assert.equal(code('bob', 'thread.list'), null);
else assert.deepEqual(call('human', 'access.reload').ended.map((h) => h.run), ['a-run']);
assert.equal(claim('a-run').reason, 'access-revoked', trigger);
assert.equal(code('a', 'message.receive'), 'thread-revoked', trigger);
}
});
// The control (Dewey's C2 in dewey-0918): the first call after a pending limit stop answers from
// #limitStop and never reaches #agent, so only the run's next call checks how its claim ended.
test('row 306 T2: after the sweep, a run the human stopped and a run the limit stop ended still answer not-holder', async (t) => {
const { b, call, code, edit, claim, refused, B } = setup(t, { stops: [{ stop: true, check: 'token-limit', hit: HIT }] });
edit(withoutAlice);
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
// The human's stop takes the human's own thread only: H.
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }).stopped.map((s) => s.run), ['h-run']);
assert.equal(code('h', 'message.receive'), 'not-holder', 'stopped by the human');
assert.equal(code('h', 'state'), 'not-holder', 'stopped by the human, again');
// Bob's turn ends at the limit, and the run's next receive ends its claim.
const { id } = call('bob', 'thread.send', { thread: B, body: 'look' });
call('b', 'message.receive');
await call('b', 'usage.begin', { turn: id });
await call('b', 'usage.end', { turn: id });
assert.equal(code('b', 'message.receive'), 'not-holder', 'the limit stop');
assert.equal(claim('b-run').by, 'limit-check');
assert.equal(code('b', 'message.receive'), 'not-holder', 'the limit-stopped run\'s next call');
assert.equal(code('b', 'state'), 'not-holder');
assert.deepEqual(refused('h-run'), ['not-holder', 'not-holder']);
assert.deepEqual(refused('b-run'), ['not-holder', 'not-holder', 'not-holder']);
});
+228
View File
@@ -0,0 +1,228 @@
// Row 306 (#1813, row 295's N1): the access sweep ends a lost user thread's claim in the threadsState
// op, and the launcher sends SIGTERM only after the op returns. A runner call the broker answers in
// between used to get not-holder, and the run ended session-invalid/22 instead of stopped/0. This test
// holds the op open until a call has landed in that window and the runner has exited, so it doesn't
// depend on timing. The launcher skips a tick while one is running, so nothing else signals a run
// while the op is held.
//
// thread-revoke.test.mjs's host, with the access file, and idle-stop-race.test.mjs's short tick and
// poll, with threadsState replaced by the caller. BUILD.md (agents/morgana/work/queue-306) names the
// mutant each test kills.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
// A mutant's kill is an assertion failure, not a timeout (sage-2707): the wait returns what it last
// saw and the assertions after it decide.
async function settle(fn, ms = 20000) {
const end = Date.now() + ms;
let v;
while (Date.now() < end) {
v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
return v;
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
// The assistant is the thread instance. Alice holds a collaborator grant on it in the access file;
// `grant(false)` is the hand edit that removes it. The idle stop is an hour, so it never runs here.
function prepare(t) {
const root = tmp(t, "mosaic-revoke-window-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet: 2 });
doc.launch.threadIdleSeconds = 3600;
return doc;
});
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
const file = join(root, "access.json");
let revision = 0;
const grant = (on) =>
writeFileSync(file, JSON.stringify({
accessVersion: 1,
revision: ++revision,
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
grants: on ? [{ id: "g-alice", user: "alice", business: "acme", projects: ["*"], instances: ["assistant"], level: "collaborator" }] : [],
}), { mode: 0o600 });
grant(true);
const context = { providers: ["authentik"], peers: [], businesses: { acme: { human: f.doc.human, instances: Object.keys(f.doc.roles) } }, ssoSubjects: [] };
return { root, f, adapter, access: { file, context }, grant };
}
// A host and its launcher, ticking every 100 ms, with runners that poll every 100 ms, so a run's
// call lands in the held window.
async function start(t, { f, adapter, access }, threadsState) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker({ ...boot, access });
const ops = {
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => threadsState(runtime, m),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
sessionClears: (m) => runtime.sessionClears(m.business),
};
const refusing = (what, fn) => async (m) => {
try {
return await fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const launcher = createLauncher({
host,
system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [ADAPTER],
threadTickMs: 100,
log: () => {},
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
let alice = null;
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
const as = (verb, args = {}) => runtime.broker.request((alice ??= runtime.broker.bindUser({ business: "acme", user: "alice" })), { verb, args });
return { runtime, launcher, call, as, close };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
const runnerLog = (f, run) => {
const p = join(f.dataRoot, "launches", "acme", run, "runner.log");
return existsSync(p) ? readFileSync(p, "utf8") : "";
};
const ended = (f, run) => launchLog(f).find((e) => e.event === "end" && e.run === run);
const launched = (f, thread) => launchLog(f).find((e) => e.event === "launch" && e.thread === thread)?.run ?? null;
// The runner's own verdict on a refusal: the revoke line, the launcher-release line, or a lost session's.
const verdict = (f, run) =>
/runner: (thread revoked by the access file|session released by the launcher|session stopped working: [a-z-]+)\n/.exec(runnerLog(f, run))?.[1] ?? null;
// Send on the thread and wait for the run's RESULT, so the run holds the thread.
async function ask(send, read, thread, body) {
const sent = send("thread.send", { thread, body });
assert.ok(await settle(() => read("thread.messages", { thread }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT")), `no RESULT on ${thread}`);
}
test("row 306 REQ-LAUNCH-1 R4 a revoked run whose call lands before the SIGTERM ends stopped/0; a run the human stopped in the same window still ends session-invalid/22", { skip, timeout: 120000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
let A = null;
let window = null;
let host = null;
// The first pass that sees A revoked: the sweep in runtime.threadsState has ended A's claim. Inside
// the held window the human stops their own thread's run, which no pass can signal while the op is held.
const threadsState = async (runtime, m) => {
const rows = runtime.threadsState(m.business);
if (window || !rows.some((x) => x.thread === A && x.revoked)) return rows;
window = { run: launched(f, A) };
window.stopped = host.call("assistant.stop", { instance: "assistant" }).stopped;
// Both runners decide on their own before the op returns and the launcher can signal.
window.revoked = await settle(() => ended(f, window.run) && verdict(f, window.run));
window.control = window.stopped[0]?.run ?? null;
window.human = window.control ? await settle(() => ended(f, window.control) && verdict(f, window.control)) : null;
window.done = true;
return rows;
};
host = await start(t, prepared, threadsState);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
A = host.as("thread.create", { project, name: "alice work" }).id;
const B = host.call("thread.create", { project, name: "owner work" }).id;
await Promise.all([ask(host.as, host.as, A, "for A"), ask(host.call, host.call, B, "for B")]);
// The hand edit. No user request follows, so the launcher's threadsState is what sweeps.
prepared.grant(false);
await settle(() => window?.done, 60000);
assert.ok(window?.done, JSON.stringify(launchLog(f)));
const { run, control } = window;
assert.equal(run, launched(f, A));
assert.deepEqual(window.stopped.map((s) => [s.thread, s.run]), [[B, launched(f, B)]], "the human's stop took only the human's thread");
await settle(() => ended(f, run) && ended(f, control), 60000);
// The revoked run refused in the window and exited by itself before the op returned.
assert.equal(window.revoked, "thread revoked by the access file", runnerLog(f, run));
const log = launchLog(f);
const end = (r) => log.find((e) => e.event === "end" && e.run === r);
assert.deepEqual([end(run).reason, end(run).exitCode, end(run).signal], ["stopped", 0, null]);
// The launcher removed the run before it recorded the end, so the pass after the op had nothing to
// signal (Dewey's C1 in dewey-0918).
assert.deepEqual(log.filter((e) => e.event === "thread-revoked" && e.run === run), []);
assert.doesNotMatch(runnerLog(f, run), /session stopped working/);
// The sweep already ended the claim, so the runner doesn't release it at exit.
assert.doesNotMatch(runnerLog(f, run), /runner: role\.release:/);
assert.match(runnerLog(f, run), /runner: exiting 0\n/);
// The control: a real not-holder in the same window still ends 22, with no signal.
assert.equal(window.human, "session stopped working: not-holder", runnerLog(f, control));
assert.deepEqual([end(control).reason, end(control).exitCode, end(control).signal], ["session-invalid", 22, null]);
assert.match(runnerLog(f, control), /runner: exiting 22\n/);
await host.close();
const store = new Store(f.dataRoot);
t.after(() => store.close());
const codes = (r) => store.all("SELECT body FROM events WHERE kind='action.refused' AND actor_run=? ORDER BY seq", r).map((e) => JSON.parse(e.body).code);
assert.ok(codes(run).includes("thread-revoked"), JSON.stringify(codes(run)));
assert.ok(!codes(run).includes("not-holder"), JSON.stringify(codes(run)));
assert.ok(codes(control).includes("not-holder"), JSON.stringify(codes(control)));
});
+1 -1
View File
@@ -293,7 +293,7 @@ it never touches the disk and isn't in the environment or argv.
| Exit | Meaning | Launch end reason |
|---|---|---|
| 0 | stopped: by a signal, or the launcher's idle stop or land-relaunch released the claim (`launcher-released`) | `stopped` |
| 0 | stopped: by a signal, the launcher's idle stop or land-relaunch released the claim (`launcher-released`), or the access sweep ended a lost user thread's claim (`thread-revoked`, row 306) | `stopped` |
| 2 | bad run directory or stdin | `failed` |
| 20 | founder credentials | `founder-credentials` |
| 21 | `role.claim` refused | `claim-refused` |
+7 -3
View File
@@ -56,6 +56,9 @@ const SESSION_GONE = new Set(["unauthenticated", "not-holder", "run-ended"]);
// Row 302 (#1809): the launcher's idle stop or land-relaunch released the claim and will stop the run.
// That is a stop, not a lost session, so the run exits stopped even when its call lands before the signal.
const LAUNCHER_RELEASED = "launcher-released";
// Row 306 (#1813): the access sweep ended the claim of a thread whose user lost the grant, and the
// launcher will stop the run. That is a stop too, when the run's call lands before the signal.
const THREAD_REVOKED = "thread-revoked";
const BODY_MAX = 32000;
const DEFAULTS = Object.freeze({ turnTimeout: 900, pollInterval: 2000, brokerRetries: 30 });
@@ -485,12 +488,13 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
let code = EXIT.stopped;
let failures = 0;
// Whether a broker refusal ends the run, and how: a launcher release stops it, a gone session is 22.
// Whether a broker refusal ends the run, and how: a launcher release or an access revoke stops it,
// a gone session is 22. Either stop already ended the claim, so `released` skips the exit release.
// The reply site has logged its failure already, so it passes say=false.
let released = false;
const ended = (err, say = true) => {
if (err.code === LAUNCHER_RELEASED) {
log("runner: session released by the launcher");
if (err.code === LAUNCHER_RELEASED || err.code === THREAD_REVOKED) {
log(err.code === LAUNCHER_RELEASED ? "runner: session released by the launcher" : "runner: thread revoked by the access file");
released = true;
return true;
}