Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6838332056 | ||
|
|
d38c5213a7 | ||
|
|
73fd4ac243 | ||
|
|
33d3844a09 |
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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` |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user