// 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']); });