diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 9a2c23ec2..2139a844d 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -616,6 +616,17 @@ mode. Unset means unlimited, zero blocks new launches, and running sessions remain alive when a limit is lowered. Total, provider and exact model limits are checked for each launch; counts have no hard-coded policy ceiling. +Token limits (row 125) are `limits.tokens.instances.NAME`, `providers.NAME`, +`models.PROVIDER.MODEL` and `keys.NAME`, each `{ "limit": N }`. The writer adds +and keeps `since`. `scripts/mosaic limits reset WORD... --business ID` (for +example `reset tokens providers anthropic`) moves one limit's `since` to now, +logged with its actor. A token limit is enforced only by a switched-on +`limit-check` plug-in (`token-limit` ships): it refuses a launch at the limit +and stops the session at the end of the turn that reaches it. Otherwise the +read shows it as `not enforced`; a failing check refuses and stops (fail +closed). Without `--json` the read prints JSON without `tokens` followed by +`token limits:` text lines, so scripts must pass `--json`. + Convert an old organization file with `scripts/mosaic limits migrate --file ORGANIZATION --models MAPPING --roles-dir roles > CONVERTED`. It needs no bus, never edits the input or system config and has no write flag. Inherited models diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index cf362407c..8c3889e1f 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -132,7 +132,7 @@ const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9 // A git commit id, SHA-1 or SHA-256. const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x); // Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept. -export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances']; +export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset']; // Row 123 (#1617): role.end never reaches the assistant (the user's seat) or the caller's own run. // Row 139 (#1633), Jason's ruling Q169 (PRD 1.6): only the chief of staff, or a decision the human // resolved through the CLI, ends or revokes one (#protectedEnd). A raise to revoke one is a hard wait. @@ -183,9 +183,12 @@ export class Broker { #organization; #project; #usage; + #limits; + // Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state. + #stops = new Map(); #clock = 0; // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). - constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null }) { + constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null }) { if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); this.#businesses = structuredClone(businesses); this.#store = store; @@ -194,6 +197,7 @@ export class Broker { this.#organization = organization; this.#project = project; this.#usage = usage; + this.#limits = limits; // Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here. this.#requirements = loadRequirements(); for (const table of [ @@ -453,8 +457,13 @@ export class Broker { const h = this.#own(session); const released = h?.op === 'claim' && h.holder_run === session.run; if (released) this.#claimEnd(session, h, 'release', null); - this.#event(session, 'session.ended', { reason: record.reason, exitCode, released }, session.run); - return { released }; + // Row 125 (#1619, REQ-LIMIT-4): a run a limit stopped carries the claim row's reason. + const limit = this.#store.get( + `SELECT reason FROM ${session.thread ? 'thread_claims' : 'role_claims'} WHERE business=? AND holder_run=? AND by='limit-check' ORDER BY seq DESC LIMIT 1`, + session.business, session.run)?.reason; + this.#stops.delete(key); + this.#event(session, 'session.ended', { reason: record.reason, exitCode, released, ...(limit ? { limit } : {}) }, session.run); + return { released, ...(limit ? { limit } : {}) }; }); } // Trusted launcher API: records a refusal the host decided itself (instance list, capacity, @@ -1125,6 +1134,8 @@ export class Broker { this.#secretCheck(args); if (request.verb === 'usage.begin' || request.verb === 'usage.end') return this.#requestUsage(s, request.verb, structuredClone(args)); + if ((request.verb === 'message.receive' || request.verb === 'state') && this.#stops.has(s.business + ':' + s.run)) + this.#limitStop(s); if (request.verb === 'spec.read' || request.verb === 'record.write' || (request.verb === 'decision.raise' && ('spec_path' in args || 'spec_commit' in args))) return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) }); @@ -1189,9 +1200,45 @@ export class Broker { fail('not-recipient'); }); if (!this.#usage) fail('usage-not-configured'); - return await this.#usage.collect(s, verb === 'usage.begin' ? 'begin' : 'end', args.turn); + const phase = verb === 'usage.begin' ? 'begin' : 'end'; + try { + return await this.#usage.collect(s, phase, args.turn); + } finally { + if (phase === 'end') await this.#limitCheck(s); + } } catch (e) { throw this.#refused(s, e, { verb, args }); } } + // Row 125 (#1619, REQ-LIMIT-4): after a turn's usage end, ask the limit checks whether the run + // stops. A stop is kept until the run's next message.receive or state, so the turn's RESULT and + // message.read land first. An answer that isn't a plain go stops (fail closed, decision 150). + async #limitCheck(s) { + if (!this.#limits) return; + let answer; + try { + answer = await this.#limits.mustStop(s.business, { instance: s.role, provider: s.provider ?? null, model: s.model ?? null }); + } catch { + answer = { stop: true, check: null, code: 'check-threw' }; + } + if (answer?.stop === false) return; + this.#stops.set(s.business + ':' + s.run, answer?.stop === true ? answer : { stop: true, check: null, code: 'malformed-answer' }); + } + // End the stopped run's claim with by='limit-check' and the reason, then refuse not-holder: the + // runner treats that as the session gone and exits, and the launcher ends the run. A role claim + // is revoked, so the run can't claim it again; a thread claim is released, as an idle stop does. + #limitStop(s) { + const key = s.business + ':' + s.run; + const stop = this.#stops.get(key); + const reason = stop.hit + ? `limit: ${stop.check} ${stop.hit.path.join('.')} limit ${stop.hit.limit} count ${stop.hit.count}` + : `limit: ${stop.check ?? 'bus'} failed ${stop.code}`; + this.#store.transaction(() => { + const h = this.#own(s); + if (h?.op === 'claim' && h.holder_run === s.run) + this.#claimEnd(s, h, s.thread ? 'release' : 'revoke', null, reason, 'limit-check'); + }); + this.#stops.delete(key); + fail('not-holder'); + } // Git and the record writer are async effects: never hold a SQL transaction across an await. async #requestProject(s, request) { const { verb, args } = request; @@ -2238,11 +2285,12 @@ export class Broker { return { ...c, state, working, inputNeeded, stranded }; } // Row 123 (#1617): a role.end revoke carries its reason in role_claims.reason and no decision. - // A thread claim is never revoked (row 121), so thread_claims has no reason. - #claimEnd(s, h, op, decision, reason = null) { + // Row 125 (#1619): a limit stop ends a thread claim with a reason too, and either claim with + // by='limit-check'. + #claimEnd(s, h, op, decision, reason = null, by = s.run ?? s.human) { if (h.thread) return this.#store.run( - 'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,address,by,decision) VALUES(?,?,?,?,?,?,?,?,?)', + 'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?)', this.#now(), s.business, h.thread, @@ -2250,8 +2298,9 @@ export class Broker { h.holder_run, h.harness, h.address, - s.run ?? s.human, + by, decision, + reason, ); this.#store.run( 'INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?)', @@ -2262,7 +2311,7 @@ export class Broker { h.holder_run, h.harness, h.address, - s.run ?? s.human, + by, decision, reason, ); diff --git a/packages/bus/src/limits.mjs b/packages/bus/src/limits.mjs new file mode 100644 index 000000000..f80c7237e --- /dev/null +++ b/packages/bus/src/limits.mjs @@ -0,0 +1,189 @@ +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): core's side of the limit checks. It loads only the +// `limit-check` modules the organization file switches on, one loader per business, and asks +// them through the loader alone: mayLaunch before a launch, mustStop after each turn's usage end, +// report for Settings and `mosaic limits`. Each check gets a copy of the business's launch limits +// and its per-turn usage records (usage.mjs turnRecords). +// +// Fail closed (lead decision 150). A check that throws, answers in the wrong shape, answers with +// a promise, or is switched on but not loaded (a refused load keeps the old modules) refuses the +// launch, stops the session at the end of its turn, and shows every limit as `check failed`. An +// organization file that can't be read refuses and stops the same way, with check null; report +// then throws, as the verb's own read does. +// +// refresh, mayLaunch and mustStop are async: they re-read the file and reload first. report is +// sync, so the organization verbs stay sync; it uses the modules the last refresh loaded. +import { createLoader } from '../../plugins/src/loader.mjs'; +import { turnRecords } from './usage.mjs'; + +const KIND = 'limit-check'; +const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead']; +const CODE = /^[a-z][a-z0-9-]*$/; +const own = (value, key) => value !== null && typeof value === 'object' && !Array.isArray(value) && Object.hasOwn(value, key); +const plainObject = value => value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype; +const exact = (value, keys) => plainObject(value) && Object.keys(value).length === keys.length && keys.every(k => Object.hasOwn(value, k)); +const amount = value => (Number.isSafeInteger(value) && value >= 0) || (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)); +const turns = value => Number.isSafeInteger(value) && value >= 0; +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +// Every token limit in launch.limits, in file order, at its schema depth only: tokens.instances.X, +// tokens.providers.X, tokens.keys.X and tokens.models.P.M. The agent limits (total, providers, +// models) are never checks' limits, whatever their names, so a provider called `limit` there isn't +// taken for one. The settings writer (business launch-settings.mjs) walks the same depths. +const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2 }; +export function limitLeaves(limits) { + const out = []; + const walk = (value, depth, path) => { + if (!plainObject(value)) return; + for (const [key, entry] of Object.entries(value)) { + if (depth > 1) walk(entry, depth - 1, [...path, key]); + else if (plainObject(entry)) out.push({ path: [...path, key], limit: entry.limit, since: entry.since }); + } + }; + const tokens = plainObject(limits) ? limits.tokens : undefined; + if (plainObject(tokens)) { + for (const [kind, value] of Object.entries(tokens)) if (Object.hasOwn(DEPTH, kind)) walk(value, DEPTH[kind], ['tokens', kind]); + } + return out; +} + +class Failure extends Error { + constructor(code) { super(code); this.code = code; } +} + +// makeLoader is the plug-in loader's factory; tests pass a scratch tree's copy to load fixture checks. +export function createLimits({ store, read, log, makeLoader = createLoader }) { + if (typeof read !== 'function' || typeof log !== 'function') throw new TypeError('createLimits needs read and log'); + const loaders = new Map(); + const loaderFor = business => { + if (!loaders.has(business)) loaders.set(business, makeLoader({ log })); + return loaders.get(business); + }; + + // What one business has now: its limits, its records, and its switched-on checks. A check the + // file switches on that the loader doesn't have (a refused load) is not loaded. + function state(business, b = undefined) { + if (b === undefined) { + try { b = read(business); } catch { throw new Failure('organization-unreadable'); } + } + const setting = b?.plugins?.[KIND] ?? []; + const loader = loaderFor(business); + // The loader seeds every kind with an empty list, so a kind never loaded, or refused on its + // first load, has nothing switched on. + const names = loader.loaded()[KIND]; + const modules = loader.modules(KIND); + let records; + try { records = turnRecords(store, business); } catch { throw new Failure('records-unreadable'); } + return { + limits: b?.launch?.limits ?? {}, + records, + checks: same(setting, names) ? names.map((name, i) => ({ name, module: modules[i] })) : null, + missing: setting.find(name => !names.includes(name)) ?? setting[0] ?? null, + }; + } + + async function refresh(business) { + let b; + try { b = read(business); } catch { return; } + await loaderFor(business).load({ [KIND]: b?.plugins?.[KIND] ?? [] }); + } + + // Ask one check one export. The answer comes back, or a code: the check's own code if it threw + // one in the plug-in code shape, check-threw for any other throw, malformed-answer otherwise. + function ask(business, check, name, args, valid) { + try { + const answer = check.module[name](structuredClone(args)); + if (answer !== null && (typeof answer === 'object' || typeof answer === 'function') && typeof answer.then === 'function') { + Promise.resolve(answer).catch(() => {}); + throw new Failure('malformed-answer'); + } + if (!valid(answer)) throw new Failure('malformed-answer'); + return { answer }; + } catch (e) { + const code = e instanceof Failure ? e.code : typeof e?.code === 'string' && CODE.test(e.code) ? e.code : 'check-threw'; + log({ event: 'limit-check-failed', business, check: check.name, export: name, code }); + return { code }; + } + } + + const validHit = (hit, leaves) => + exact(hit, ['path', 'limit', 'count', 'unknownTurns']) && + leaves.some(l => same(l.path, hit.path) && l.limit === hit.limit) && amount(hit.count) && turns(hit.unknownTurns); + + // mayLaunch and mustStop: every switched-on check is asked in order; the first refusal or + // stop wins. key is the answer's boolean (allow, stop) and go its value when nothing is hit. + async function decide(business, subject, name, key, go, halt) { + await refresh(business); + let st; + try { st = state(business); } catch (e) { + log({ event: 'limit-check-failed', business, check: null, export: name, code: e.code }); + return halt({ check: null, code: e.code }); + } + if (!st.checks) { + log({ event: 'limit-check-failed', business, check: st.missing, export: name, code: 'not-loaded' }); + return halt({ check: st.missing, code: 'not-loaded' }); + } + const leaves = limitLeaves(st.limits); + const args = { business, limits: st.limits, subject, records: st.records, now: new Date().toISOString() }; + for (const check of st.checks) { + const { answer, code } = ask(business, check, name, args, a => + exact(a, [key]) ? a[key] === go : exact(a, [key, 'hit']) && a[key] === !go && validHit(a.hit, leaves)); + if (code) return halt({ check: check.name, code }); + if (answer[key] !== go) { + log({ event: 'limit-reached', business, check: check.name, export: name, subject, ...answer.hit }); + return halt({ check: check.name, hit: answer.hit }); + } + } + return { [key]: go }; + } + + const subjectOf = ({ instance, provider = null, model = null, account = null }) => ({ instance, provider, model, account }); + + // { allow: true } | { allow: false, check, hit } | { allow: false, check, code } + const mayLaunch = (business, subject) => + decide(business, subjectOf(subject), 'mayLaunch', 'allow', true, why => ({ allow: false, ...why })); + // { stop: false } | { stop: true, check, hit } | { stop: true, check, code } + const mustStop = (business, subject) => + decide(business, subjectOf(subject), 'mustStop', 'stop', false, why => ({ stop: true, ...why })); + + const validEntry = (e, leaves) => { + const leaf = plainObject(e) && Array.isArray(e.path) && leaves.find(l => same(l.path, e.path)); + if (!leaf || e.limit !== leaf.limit || e.since !== leaf.since) return false; + if (exact(e, ['path', 'limit', 'since', 'notEnforced'])) return typeof e.notEnforced === 'string' && e.notEnforced.length > 0; + return exact(e, ['path', 'limit', 'since', 'count', 'kinds', 'unknownTurns']) && amount(e.count) && + exact(e.kinds, KINDS) && KINDS.every(k => amount(e.kinds[k])) && turns(e.unknownTurns); + }; + + // One entry per limit, in file order: + // { path, limit, since, status: 'counted', check, count, kinds, unknownTurns } + // { path, limit, since, status: 'not-enforced', reason } (reason null when no check names it) + // { path, limit, since, status: 'check-failed', check, code } + // A failed check fails every limit, so a count never shows beside a check that couldn't answer. + function report(business) { + // An unreadable file fails the report, as it fails the verb's own read. + const b = read(business); + const leaves = limitLeaves(b?.launch?.limits ?? {}); + const failedAll = (check, code) => leaves.map(({ path, limit, since }) => ({ path, limit, since, status: 'check-failed', check, code })); + let st; + try { st = state(business, b); } catch (e) { return failedAll(null, e.code); } + if (!st.checks) return failedAll(st.missing, 'not-loaded'); + const answers = []; + for (const check of st.checks) { + const { answer, code } = ask(business, check, 'report', { business, limits: st.limits, records: st.records, now: new Date().toISOString() }, + a => Array.isArray(a) && a.every(e => validEntry(e, leaves)) && new Set(a.map(e => JSON.stringify(e.path))).size === a.length); + if (code) return failedAll(check.name, code); + answers.push({ check: check.name, entries: answer }); + } + return leaves.map(({ path, limit, since }) => { + const found = answers.flatMap(({ check, entries }) => entries.filter(e => same(e.path, path)).map(e => ({ check, e }))); + const counted = found.find(({ e }) => Object.hasOwn(e, 'count')); + if (counted) { + const { count, kinds, unknownTurns } = counted.e; + return { path, limit, since, status: 'counted', check: counted.check, count, kinds, unknownTurns }; + } + const named = found.find(({ e }) => Object.hasOwn(e, 'notEnforced')); + return { path, limit, since, status: 'not-enforced', reason: named ? named.e.notEnforced : null }; + }); + } + + return { refresh, mayLaunch, mustStop, report }; +} diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs index 7e2549c40..ceaa38e40 100644 --- a/packages/bus/src/process.mjs +++ b/packages/bus/src/process.mjs @@ -17,6 +17,7 @@ const LAUNCH_OPS = new Set([ 'landHeld', 'threadsState', 'threadIdle', + 'mayLaunch', ]); function launchOp(m) { if (m.op === 'identity') return runtime.identity(m.cap); @@ -35,6 +36,8 @@ function launchOp(m) { // Row 121 (#1615): the launcher's view of the business's threads, and its idle release. if (m.op === 'threadsState') return runtime.threadsState(m.business); if (m.op === 'threadIdle') return runtime.threadIdle(m.record); + // Row 125 (#1619, REQ-LIMIT-4): the limit checks' answer before a launch (async). + if (m.op === 'mayLaunch') return runtime.mayLaunch(m); return runtime.credentialStatus(m.business, m.role); } // The host's request id comes back on the reply, so a late reply can't @@ -72,7 +75,7 @@ if (!process.send) { // S6 launcher ops, one reply each, like bindLaunch. if (LAUNCH_OPS.has(message?.op) && runtime) { try { - process.send(tag(message, { ok: true, result: launchOp(message) })); + process.send(tag(message, { ok: true, result: await launchOp(message) })); } catch (e) { process.send(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'launch-op-refused' })); } diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 985ac7953..ae4a3070e 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -12,6 +12,7 @@ import { pluginSettings } from '../../business/src/plugin-settings.mjs'; import { webService } from './web.mjs'; import { verifyHuman } from './human.mjs'; import { createUsage } from './usage.mjs'; +import { createLimits } from './limits.mjs'; import { verifyTester } from './tester.mjs'; // Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them. // Human limits writes use the shared organization writer behind the verified transport. @@ -67,8 +68,15 @@ export async function startBroker({ const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot)}):null; const usage = await createUsage({ store, dataRoot, businesses, log: entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`) }); + // Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at + // each ask, so a limit, a reset or a switched-on check takes effect without a restart. + const limitLog = entry => process.stderr.write(`limits: ${JSON.stringify(entry)}\n`); + const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog }); + for (const id of Object.keys(businesses)) await limits.refresh(id); + const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) }); const broker = new Broker({ usage, + limits, store, businesses, project: { @@ -79,7 +87,16 @@ export async function startBroker({ writeFile: context => projectModule.writeHumanFile({ ...context, dataRoot }), ProjectError: projectModule.ProjectError, }, - organization:settings?(id,verb,args,actor)=>verb==='limits'?settings.read(id):verb==='limits.set'?settings.set(id,args,actor):verb==='plugins'?plugins.read(id):plugins.set(id,args,actor):null, + // limits and limits.set (organization.set is limits.set) carry each token limit's count; a + // plugins.set reloads the business's limit checks in the background. + organization:settings?(id,verb,args,actor)=>{ + if(verb==='limits')return withTokens(id,settings.read(id)); + if(verb==='limits.set')return withTokens(id,settings.set(id,args,actor)); + if(verb==='plugins')return plugins.read(id); + const result=plugins.set(id,args,actor); + limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message})); + return result; + }:null, secretCheck: (value) => credentials.assertClean(value), credentialStatus: () => credentials.status(), }); @@ -166,6 +183,8 @@ export async function startBroker({ endLaunch: (r) => broker.endLaunch(r), threadsState: (business) => broker.threadsState(business), threadIdle: (r) => broker.threadIdle(r), + // Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch. + mayLaunch: ({ business, instance, provider, model }) => limits.mayLaunch(business, { instance, provider, model }), identity: (cap) => broker.identity(cap), authorizeLaunch, checkPush, diff --git a/packages/bus/src/usage.mjs b/packages/bus/src/usage.mjs index d1118dd20..f6a183675 100644 --- a/packages/bus/src/usage.mjs +++ b/packages/bus/src/usage.mjs @@ -33,6 +33,25 @@ function delta(before, after) { } return added ? { status: 'known', ...sum } : unknown('no-new-usage'); } +// One record per (run, turn, source): the latest row, an end row winning. A turn is known for a +// source only when that row is an end row with status known; a begin row alone is unknown. +// view() and the limit checks (row 125, packages/bus/src/limits.mjs) count the same records. +export function turnRecords(store, business) { + const turns = new Map(); + for (const row of store.all(`SELECT * FROM usage_records WHERE business=? ORDER BY seq`, business)) { + const key = JSON.stringify([row.run, row.turn, row.source]); + if (row.phase === 'end' || !turns.has(key)) turns.set(key, row); + } + return [...turns.values()].map(row => { + const known = row.phase === 'end' && row.status === 'known'; + return { + at: row.at, run: row.run, turn: row.turn, source: row.source, instance: row.instance, + provider: row.provider, model: row.model, account: row.account, status: known ? 'known' : 'unknown', + input: known ? row.input : null, output: known ? row.output : null, + cacheWrite: known ? row.cache_write : null, cacheRead: known ? row.cache_read : null, + }; + }); +} export async function createUsage({ store, dataRoot, businesses, log }) { const loaders = new Map(); for (const [id, b] of Object.entries(businesses)) { @@ -91,19 +110,13 @@ export async function createUsage({ store, dataRoot, businesses, log }) { return op.finally(() => { if (pending.get(key) === tail) pending.delete(key); }); } function view(business) { - const rows = store.all(`SELECT * FROM usage_records WHERE business=? ORDER BY seq`, business); - const turns = new Map(); - for (const row of rows) { - const key = JSON.stringify([row.run, row.turn, row.source]); - if (row.phase === 'end' || !turns.has(key)) turns.set(key, row); - } const groups = { byRun: new Map(), byInstance: new Map(), byProvider: new Map(), byModel: new Map(), byAccount: new Map() }; const total = { ...zero(), unknown: new Set() }; const add = (target, row) => { - if (row.phase !== 'end' || row.status !== 'known') target.unknown.add(row.run + ':' + row.turn); - else for (const [key, column] of [['input', 'input'], ['output', 'output'], ['cacheWrite', 'cache_write'], ['cacheRead', 'cache_read']]) target[key] = BigInt(target[key]) + BigInt(row[column]); + if (row.status !== 'known') target.unknown.add(row.run + ':' + row.turn); + else for (const key of KINDS) target[key] = BigInt(target[key]) + BigInt(row[key]); }; - for (const row of turns.values()) { + for (const row of turnRecords(store, business)) { add(total, row); for (const [name, identity] of Object.entries({ byRun: { run: row.run }, byInstance: { instance: row.instance }, byProvider: { provider: row.provider }, byModel: { provider: row.provider, model: row.model }, byAccount: { account: row.account } })) { diff --git a/packages/bus/src/views.mjs b/packages/bus/src/views.mjs index f8d1386c6..1ac9cddbf 100644 --- a/packages/bus/src/views.mjs +++ b/packages/bus/src/views.mjs @@ -2,6 +2,7 @@ export function views(client) { return Object.freeze({ usage: () => client.call('usage'), + limits: () => client.call('limits'), inbox: () => client.call('inbox'), tasks: () => client.call('tasks'), agents: () => client.call('agents'), diff --git a/packages/bus/tests/limit-checks.test.mjs b/packages/bus/tests/limit-checks.test.mjs new file mode 100644 index 000000000..c4f247469 --- /dev/null +++ b/packages/bus/tests/limit-checks.test.mjs @@ -0,0 +1,326 @@ +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): core's side of the limit checks (limits.mjs). Fixture +// checks load from a scratch plug-in tree through its own loader; the shipped token-limit loads +// through the real one. Records are usage_records rows in a real store. +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 { Store } from '../src/store.mjs'; +import { createLimits, limitLeaves } from '../src/limits.mjs'; +import { tree, LIMIT_V1 } from '../../plugins/tests/helpers.mjs'; + +const SINCE = '2026-10-18T00:00:00.000Z'; +const CODER = ['tokens', 'instances', 'coder']; +const KEY = ['tokens', 'keys', 'k1']; +const LIMITS = { agents: 4, tokens: { instances: { coder: { limit: 100, since: SINCE } }, keys: { k1: { limit: 5, since: SINCE } } } }; +const SUBJECT = { instance: 'coder', provider: 'anthropic', model: 'opus' }; + +// One fixture check per behaviour. hit(a) names the coder limit with the limit the file holds. +const FIXTURE = (mayLaunch, mustStop, report) => `export const interfaceVersion = 2; +const hit = (a, over = {}) => ({ path: ['tokens', 'instances', 'coder'], limit: a.limits.tokens.instances.coder.limit, count: 7, unknownTurns: 1, ...over }); +const entry = (a, path, rest) => { let v = a.limits; for (const k of path) v = v[k]; return { path, limit: v.limit, since: v.since, ...rest }; }; +export function mayLaunch(a) { ${mayLaunch} } +export function mustStop(a) { ${mustStop} } +export function report(a) { ${report} } +`; +const FIXTURES = { + allow: FIXTURE('return { allow: true };', 'return { stop: false };', 'return [];'), + refuse: FIXTURE('return { allow: false, hit: hit(a) };', 'return { stop: true, hit: hit(a) };', + "return [entry(a, ['tokens', 'instances', 'coder'], { notEnforced: 'fixture leaves it' })];"), + counts: FIXTURE('return { allow: true };', 'return { stop: false };', + "return [entry(a, ['tokens', 'instances', 'coder'], { count: '12', kinds: { input: 1, output: 2, cacheWrite: 3, cacheRead: '6' }, unknownTurns: 2 })];"), + echo: FIXTURE('globalThis.__limitArgs = a; a.limits.tokens = null; a.records.length = 0; return { allow: true };', + 'globalThis.__limitArgs = a; return { stop: false };', 'globalThis.__limitArgs = a; return [];'), + throws: FIXTURE("throw new Error('boom');", "throw new Error('boom');", "throw new Error('boom');"), + coded: FIXTURE("throw Object.assign(new Error('x'), { code: 'quota-gone' });", + "throw Object.assign(new Error('x'), { code: 'quota-gone' });", "throw Object.assign(new Error('x'), { code: 'quota-gone' });"), + badcode: FIXTURE("throw { code: 'Not A Code' };", "throw 'text';", "throw { code: 7 };"), + malformed: FIXTURE("return { allow: 'yes' };", "return 'no';", 'return {};'), + extra: FIXTURE('return { allow: true, hit: hit(a) };', 'return { stop: false, why: 1 };', + "return [entry(a, ['tokens', 'instances', 'coder'], { notEnforced: '' })];"), + thenable: FIXTURE('return Promise.resolve({ allow: true });', 'return { then() {} };', 'return Promise.resolve([]);'), + // An async check that fails: core must handle the rejection, or the process sees it unhandled. + rejects: FIXTURE("return Promise.reject(new Error('late'));", "return Promise.reject(new Error('late'));", + "return Promise.reject(new Error('late'));"), + notleaf: FIXTURE("return { allow: false, hit: hit(a, { path: ['tokens', 'instances'] }) };", + 'return { stop: true, hit: hit(a, { limit: 999 }) };', + "const e = entry(a, ['tokens', 'instances', 'coder'], { notEnforced: 'twice' }); return [e, e];"), + badcount: FIXTURE('return { allow: false, hit: hit(a, { count: -1 }) };', 'return { stop: true, hit: { ...hit(a), extra: 1 } };', + "return [entry(a, ['tokens', 'instances', 'coder'], { notEnforced: 'x', count: 1 })];"), + stale: FIXTURE('return { allow: true };', 'return { stop: false };', + "return [{ ...entry(a, ['tokens', 'instances', 'coder'], { notEnforced: 'x' }), since: '2020-01-01T00:00:00.000Z' }];"), +}; + +async function setup(t, { real = false } = {}) { + const root = mkdtempSync(join(tmpdir(), 'limit-checks-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const logged = []; + const org = { demo: { id: 'demo', plugins: { 'limit-check': [] }, launch: { limits: structuredClone(LIMITS) } } }; + let unreadable = false; + const read = id => { + if (unreadable || !Object.hasOwn(org, id)) throw new Error(`can't read ${id}`); + return structuredClone(org[id]); + }; + let makeLoader; + if (!real) { + const plugins = tree(t); + for (const [name, body] of Object.entries(FIXTURES)) plugins.write('limit-check', name, body); + plugins.write('limit-check', 'old', LIMIT_V1('old')); + ({ createLoader: makeLoader } = await import(plugins.loaderUrl)); + } + const limits = createLimits({ store, read, log: e => logged.push(e), ...(makeLoader ? { makeLoader } : {}) }); + const use = (...names) => { org.demo.plugins['limit-check'] = names; }; + let seq = 0; + const record = ({ run = 'r1', turn = `t${++seq}`, source = 'pi-session', instance = 'coder', provider = 'anthropic', model = 'opus', + status = 'known', counts = [10, 20, 30, 40], phase = 'end', at = '2026-10-18T06:00:00.000Z', business = 'demo' } = {}) => { + // A known begin row holds zero counts; an unknown row holds none and a code. + const isKnown = status === 'known'; + const values = isKnown ? (phase === 'end' ? counts : [0, 0, 0, 0]) : [null, null, null, null]; + store.run(`INSERT INTO usage_records(at,business,run,instance,provider,model,account,turn,source,phase,status,code,cursor,input,output,cache_write,cache_read) + VALUES (?,?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?)`, at, business, run, instance, provider, model, turn, source, phase, + status, isKnown ? null : 'no-source', null, ...values); + return turn; + }; + return { store, limits, logged, org, use, record, unreadable: v => { unreadable = v; } }; +} + +const failed = (check, code) => [CODER, KEY].map((path, i) => ({ path, limit: i ? 5 : 100, since: SINCE, status: 'check-failed', check, code })); +const notEnforced = (reasons = [null, null]) => [CODER, KEY].map((path, i) => ({ path, limit: i ? 5 : 100, since: SINCE, status: 'not-enforced', reason: reasons[i] })); + +test('REQ-LIMIT-3 limitLeaves finds every limit object and skips the agent numbers', () => { + assert.deepEqual(limitLeaves(LIMITS), [ + { path: CODER, limit: 100, since: SINCE }, + { path: KEY, limit: 5, since: SINCE }, + ]); + assert.deepEqual(limitLeaves(null), []); + assert.deepEqual(limitLeaves({ tokens: { models: { anthropic: { 'claude/opus.5': { limit: 1, since: SINCE } } } } }), + [{ path: ['tokens', 'models', 'anthropic', 'claude/opus.5'], limit: 1, since: SINCE }]); +}); + +test('REQ-LIMIT-3 limitLeaves takes token limits at schema depth only, whatever their names', () => { + const leaf = limit => ({ limit, since: SINCE }); + assert.deepEqual(limitLeaves({ + providers: { limit: 3 }, models: { limit: { limit: 2 }, anthropic: { limit: 2 } }, + tokens: { providers: { limit: leaf(5) }, models: { limit: { limit: leaf(6) } }, keys: { limit: leaf(7) } }, + }), [ + { path: ['tokens', 'providers', 'limit'], limit: 5, since: SINCE }, + { path: ['tokens', 'models', 'limit', 'limit'], limit: 6, since: SINCE }, + { path: ['tokens', 'keys', 'limit'], limit: 7, since: SINCE }, + ]); + assert.deepEqual(limitLeaves({ providers: { limit: 3 }, models: { limit: { m: 2 } } }), []); +}); + +test('REQ-LIMIT-3 with no check switched on a launch is allowed, a turn never stops, and every limit is not enforced', async t => { + const c = await setup(t); + assert.deepEqual(c.limits.report('demo'), notEnforced()); // before any refresh, too + await c.limits.refresh('demo'); + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: true }); + assert.deepEqual(await c.limits.mustStop('demo', SUBJECT), { stop: false }); + assert.deepEqual(c.limits.report('demo'), notEnforced()); + assert.deepEqual(c.logged, []); +}); + +test('REQ-LIMIT-3 a fixture check that refuses refuses the launch and stops the turn, logged with the limit and the count', async t => { + const c = await setup(t); + c.use('allow', 'refuse', 'throws'); + const hit = { path: CODER, limit: 100, count: 7, unknownTurns: 1 }; + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: false, check: 'refuse', hit }); + assert.deepEqual(await c.limits.mustStop('demo', SUBJECT), { stop: true, check: 'refuse', hit }); + // The first refusal wins: the check after it isn't asked, so nothing logs a failure. + assert.deepEqual(c.logged, ['mayLaunch', 'mustStop'].map(name => ({ event: 'limit-reached', business: 'demo', check: 'refuse', export: name, + subject: { ...SUBJECT, account: null }, ...hit }))); + // A limit change reaches the check on the next ask. + c.org.demo.launch.limits.tokens.instances.coder.limit = 300; + assert.equal((await c.limits.mayLaunch('demo', SUBJECT)).hit.limit, 300); +}); + +test('REQ-LIMIT-3 a report that omits a limit shows it not enforced; a counted entry and a reason come through', async t => { + const c = await setup(t); + c.use('allow'); + await c.limits.refresh('demo'); + assert.deepEqual(c.limits.report('demo'), notEnforced()); + c.use('allow', 'refuse'); + await c.limits.refresh('demo'); + assert.deepEqual(c.limits.report('demo'), notEnforced(['fixture leaves it', null])); + c.use('refuse', 'counts'); + await c.limits.refresh('demo'); + // A count from any check wins over another check's reason. + assert.deepEqual(c.limits.report('demo'), [ + { path: CODER, limit: 100, since: SINCE, status: 'counted', check: 'counts', count: '12', + kinds: { input: 1, output: 2, cacheWrite: 3, cacheRead: '6' }, unknownTurns: 2 }, + notEnforced()[1], + ]); +}); + +test('REQ-LIMIT-4 each check gets the business, a copy of the limits, the subject, the records and the time', async t => { + const c = await setup(t); + c.record({ turn: 'a' }); + c.record({ turn: 'b', business: 'other' }); + c.use('echo'); + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: true }); + const a = globalThis.__limitArgs; + assert.deepEqual(Object.keys(a).sort(), ['business', 'limits', 'now', 'records', 'subject']); + assert.equal(a.business, 'demo'); + assert.deepEqual(a.subject, { ...SUBJECT, account: null }); + assert.ok(!Number.isNaN(Date.parse(a.now))); + // The check mutated its copy; core's view and the next ask are untouched. + await c.limits.mustStop('demo', SUBJECT); + const b = globalThis.__limitArgs; + assert.deepEqual(b.limits, LIMITS); + assert.deepEqual(b.records.map(r => [r.turn, r.status, r.input]), [['a', 'known', 10]]); + await c.limits.refresh('demo'); + c.limits.report('demo'); + assert.deepEqual(Object.keys(globalThis.__limitArgs).sort(), ['business', 'limits', 'now', 'records']); + // Within one ask each check gets its own copy: echo nulls its tokens, and refuse after it still reads limit 100. + c.use('echo', 'refuse'); + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: false, check: 'refuse', hit: { path: CODER, limit: 100, count: 7, unknownTurns: 1 } }); + delete globalThis.__limitArgs; +}); + +test('REQ-LIMIT-4 fail closed: a throw refuses, stops and fails the report, naming the check and the code', async t => { + const c = await setup(t); + for (const [name, codes] of [['throws', ['check-threw', 'check-threw', 'check-threw']], ['coded', ['quota-gone', 'quota-gone', 'quota-gone']], + ['badcode', ['check-threw', 'check-threw', 'check-threw']]]) { + c.use(name); + c.logged.length = 0; + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: false, check: name, code: codes[0] }, name); + assert.deepEqual(await c.limits.mustStop('demo', SUBJECT), { stop: true, check: name, code: codes[1] }, name); + assert.deepEqual(c.limits.report('demo'), failed(name, codes[2]), name); + assert.deepEqual(c.logged, ['mayLaunch', 'mustStop', 'report'].map((exp, i) => + ({ event: 'limit-check-failed', business: 'demo', check: name, export: exp, code: codes[i] })), name); + } +}); + +test('REQ-LIMIT-4 fail closed: a malformed answer, a promise, a rejected promise, a non-boolean or a bad hit is malformed-answer', async t => { + const c = await setup(t); + for (const name of ['malformed', 'extra', 'thenable', 'rejects', 'notleaf', 'badcount', 'stale']) { + c.use(name); + const may = await c.limits.mayLaunch('demo', SUBJECT); + const stop = await c.limits.mustStop('demo', SUBJECT); + const malformed = { check: name, code: 'malformed-answer' }; + // stale answers mayLaunch and mustStop well; only its report is wrong (a since the file doesn't hold). + if (name === 'stale') { + assert.deepEqual(may, { allow: true }); + assert.deepEqual(stop, { stop: false }); + } else { + assert.deepEqual(may, { allow: false, ...malformed }, name); + assert.deepEqual(stop, { stop: true, ...malformed }, name); + } + assert.deepEqual(c.limits.report('demo'), failed(name, 'malformed-answer'), name); + } +}); + +test('REQ-LIMIT-4 fail closed: a check switched on but not loaded refuses, stops and fails every limit as not-loaded', async t => { + const c = await setup(t); + // Refused on its first load: nothing is switched on, though the file names it. + c.use('old'); + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: false, check: 'old', code: 'not-loaded' }); + assert.deepEqual(await c.limits.mustStop('demo', SUBJECT), { stop: true, check: 'old', code: 'not-loaded' }); + assert.deepEqual(c.limits.report('demo'), failed('old', 'not-loaded')); + // A version 1 module lacks report, and the loader checks exports before the version. + assert.ok(c.logged.some(e => e.event === 'plugin-refused' && e.name === 'old' && e.code === 'missing-export')); + // A refused load keeps the last good modules, but the file now names one more: still not loaded. + c.use('allow'); + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: true }); + c.use('allow', 'missing-check'); + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: false, check: 'missing-check', code: 'not-loaded' }); + assert.deepEqual(c.limits.report('demo'), failed('missing-check', 'not-loaded')); +}); + +// The state() fix: the loader's modules(kind) throws unknown-kind for a kind it doesn't hold, so +// state() reads loaded() first. Without the guard both cases fail check null, code unknown-kind. +test('REQ-LIMIT-4 state(), never-loaded case: before any load the report shows not enforced, or not-loaded for a switched-on check', async t => { + const c = await setup(t); + assert.deepEqual(c.limits.report('demo'), notEnforced()); + c.use('refuse'); + assert.deepEqual(c.limits.report('demo'), failed('refuse', 'not-loaded')); + // report never loads: nothing was loaded or refused. + assert.deepEqual(c.logged, []); +}); + +test('REQ-LIMIT-4 state(), first-load-refused case: a check refused on its first load is not-loaded in mayLaunch, mustStop and report', async t => { + const c = await setup(t); + c.use('old'); + await c.limits.refresh('demo'); + assert.ok(c.logged.some(e => e.event === 'plugin-refused' && e.name === 'old')); + assert.deepEqual(c.limits.report('demo'), failed('old', 'not-loaded')); + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: false, check: 'old', code: 'not-loaded' }); + assert.deepEqual(await c.limits.mustStop('demo', SUBJECT), { stop: true, check: 'old', code: 'not-loaded' }); +}); + +test('REQ-LIMIT-4 fail closed: an unreadable organization file refuses and stops with check null; the report throws', async t => { + const c = await setup(t); + c.use('allow'); + await c.limits.refresh('demo'); + c.unreadable(true); + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: false, check: null, code: 'organization-unreadable' }); + assert.deepEqual(await c.limits.mustStop('demo', SUBJECT), { stop: true, check: null, code: 'organization-unreadable' }); + assert.throws(() => c.limits.report('demo'), /can't read demo/); + assert.deepEqual(c.logged.at(-1), { event: 'limit-check-failed', business: 'demo', check: null, export: 'mustStop', code: 'organization-unreadable' }); +}); + +test('REQ-LIMIT-4 fail closed: unreadable records refuse and stop with check null and fail the report', async () => { + const store = { all() { throw new Error('disk gone'); } }; + const limits = createLimits({ store, read: () => ({ plugins: {}, launch: { limits: LIMITS } }), log: () => {} }); + assert.deepEqual(await limits.mayLaunch('demo', SUBJECT), { allow: false, check: null, code: 'records-unreadable' }); + assert.deepEqual(await limits.mustStop('demo', SUBJECT), { stop: true, check: null, code: 'records-unreadable' }); + assert.deepEqual(limits.report('demo'), failed(null, 'records-unreadable')); +}); + +test('REQ-LIMIT-3 the shipped token-limit, through the real loader, counts known records only since the limit was set', async t => { + const c = await setup(t, { real: true }); + c.use('token-limit'); + // A known turn: its begin row, then its end with counts (100 tokens). + const t1 = c.record({ phase: 'begin' }); + c.record({ turn: t1 }); + // A turn with no usage source switched on: the '@none' unknown record, begin and end. + const t2 = c.record({ source: '@none', status: 'unknown', phase: 'begin' }); + c.record({ turn: t2, source: '@none', status: 'unknown' }); + // A turn begun and never ended counts as unknown. + c.record({ phase: 'begin' }); + // Before the limit's since, and another instance's: neither counts. + c.record({ at: '2026-10-17T23:59:59.999Z' }); + c.record({ instance: 'tester' }); + await c.limits.refresh('demo'); + assert.deepEqual(c.limits.report('demo'), [ + { path: CODER, limit: 100, since: SINCE, status: 'counted', check: 'token-limit', count: 100, + kinds: { input: 10, output: 20, cacheWrite: 30, cacheRead: 40 }, unknownTurns: 2 }, + { path: KEY, limit: 5, since: SINCE, status: 'not-enforced', reason: 'no records name a key' }, + ]); + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: false, check: 'token-limit', + hit: { path: CODER, limit: 100, count: 100, unknownTurns: 2 } }); + assert.deepEqual(await c.limits.mustStop('demo', { ...SUBJECT, instance: 'tester' }), { stop: false }); + // Unknown turns alone never reach a limit: with every turn unknown the count is 0. + c.org.demo.launch.limits.tokens.instances.coder.since = '2026-10-18T07:00:00.000Z'; + c.record({ source: '@none', status: 'unknown', at: '2026-10-18T08:00:00.000Z' }); + assert.deepEqual(await c.limits.mayLaunch('demo', SUBJECT), { allow: true }); + await c.limits.refresh('demo'); + assert.equal(c.limits.report('demo')[0].count, 0); +}); + +test('REQ-LIMIT-3 a token limit for a provider named limit is counted and refuses, beside an agent limit of the same name', async t => { + const c = await setup(t, { real: true }); + c.use('token-limit'); + c.org.demo.launch.limits = { providers: { limit: 1 }, tokens: { providers: { limit: { limit: 50, since: SINCE } } } }; + const t1 = c.record({ provider: 'limit', phase: 'begin' }); + c.record({ turn: t1, provider: 'limit' }); + const path = ['tokens', 'providers', 'limit']; + assert.deepEqual(await c.limits.mayLaunch('demo', { ...SUBJECT, provider: 'limit' }), { allow: false, check: 'token-limit', + hit: { path, limit: 50, count: 100, unknownTurns: 0 } }); + assert.deepEqual(await c.limits.mustStop('demo', SUBJECT), { stop: false }); + assert.deepEqual(c.limits.report('demo'), [{ path, limit: 50, since: SINCE, status: 'counted', check: 'token-limit', count: 100, + kinds: { input: 10, output: 20, cacheWrite: 30, cacheRead: 40 }, unknownTurns: 0 }]); + assert.deepEqual(c.logged.filter(e => e.event === 'limit-check-failed'), []); +}); + +test('REQ-LIMIT-3 core reaches limit checks only through the plug-in loader', () => { + const src = readFileSync(new URL('../src/limits.mjs', import.meta.url), 'utf8'); + const imports = [...src.matchAll(/^import .* from '([^']+)';$/gm)].map(m => m[1]); + assert.deepEqual(imports, ['../../plugins/src/loader.mjs', './usage.mjs']); + assert.doesNotMatch(src, /token-limit|import\(/); + for (const file of ['../src/runtime.mjs', '../src/broker.mjs', '../src/process.mjs']) { + assert.doesNotMatch(readFileSync(new URL(file, import.meta.url), 'utf8'), /limit-check\/|token-limit/, file); + } +}); diff --git a/packages/bus/tests/limit-stop.test.mjs b/packages/bus/tests/limit-stop.test.mjs new file mode 100644 index 000000000..1b97f338c --- /dev/null +++ b/packages/bus/tests/limit-stop.test.mjs @@ -0,0 +1,151 @@ +// Row 125 (#1619, REQ-LIMIT-4): the broker's end-of-turn stop. After a turn's usage end the broker +// asks the limit checks (a stub here) whether the run stops; the turn's RESULT and read receipt +// land first, then the run's next receive or state ends its claim with by='limit-check' and fails +// not-holder, which the runner treats as the session gone. +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 businesses = () => ({ demo: { + id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, + roles: { + coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } }, + assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, + }, + definitions: { coder: {}, assistant: { messaging: shipped('assistant').messaging } }, + projects: { stack: { root: '/nonexistent' } }, +} }); +const HIT = { path: ['tokens', 'instances', 'coder'], limit: 100, count: 120, unknownTurns: 1 }; + +function setup(t, answers) { + const root = mkdtempSync(join(tmpdir(), 'limit-stop-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const asked = []; + const limits = { mustStop: async (business, subject) => { + asked.push({ business, subject }); + const next = answers.shift(); + if (next instanceof Error) throw next; + return next ?? { stop: false }; + } }; + const broker = new Broker({ store, businesses: businesses(), limits, usage: { collect: async () => ({ recorded: true }), view: () => ({}) } }); + const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + return { store, broker, human, asked }; +} + +// One turn the way the runner takes it: receive, usage begin and end, RESULT, read receipt. +async function turn(broker, human, cap, send) { + const { id } = send(); + broker.request(cap, { verb: 'message.receive' }); + await broker.request(cap, { verb: 'usage.begin', args: { turn: id } }); + await broker.request(cap, { verb: 'usage.end', args: { turn: id } }); + return id; +} + +test('REQ-LIMIT-4 a role run at its limit stops at the end of its turn: RESULT and read land first, the claim is revoked by limit-check', async t => { + const { store, broker, human, asked } = setup(t, [{ stop: false }, { stop: true, check: 'token-limit', hit: HIT }]); + const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'anthropic', model: 'opus' }); + broker.request(coder, { verb: 'role.claim' }); + const send = () => broker.request(human, { verb: 'message.send', args: { to: 'coder', body: 'work' } }); + // Under the limit: the next receive goes on as usual. + await turn(broker, human, coder, send); + assert.deepEqual(broker.request(coder, { verb: 'message.receive' }), []); + // At the limit: the turn's reply and read receipt still land. + const id = await turn(broker, human, coder, send); + assert.deepEqual(asked, [1, 2].map(() => ({ business: 'demo', subject: { instance: 'coder', provider: 'anthropic', model: 'opus' } }))); + const reply = broker.request(coder, { verb: 'message.send', args: { to: 'human', body: 'done', class: 'RESULT', in_reply_to: id } }); + assert.ok(reply.id); + broker.request(coder, { verb: 'message.read', args: { id } }); + assert.ok(store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", id)); + assert.equal(store.get("SELECT op FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").op, 'claim'); + // Then the next receive ends the claim and refuses not-holder. + assert.throws(() => broker.request(coder, { verb: 'message.receive' }), { code: 'not-holder' }); + const row = store.get("SELECT op, by, reason, holder_run FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1"); + assert.deepEqual({ ...row }, { op: 'revoke', by: 'limit-check', holder_run: 'run1', + reason: 'limit: token-limit tokens.instances.coder limit 100 count 120' }); + assert.match(row.reason, /^limit: /); + assert.ok(store.get("SELECT 1 FROM events WHERE kind='action.refused' AND actor_run='run1' AND json_extract(body,'$.code')='not-holder'")); + // A revoked role can't be claimed again by the run. + assert.throws(() => broker.request(coder, { verb: 'role.claim' })); + // The launcher's end record reads the limit from the claim row. + const ended = broker.endLaunch({ business: 'demo', run: 'run1', reason: 'exited', exitCode: 22 }); + assert.deepEqual(ended, { released: false, limit: 'limit: token-limit tokens.instances.coder limit 100 count 120' }); + const event = JSON.parse(store.get("SELECT body FROM events WHERE kind='session.ended' AND subject='run1'").body); + assert.equal(event.limit, ended.limit); +}); + +test('REQ-LIMIT-4 the stop also lands on state; a run with no stop ends without a limit', async t => { + const { store, broker, human } = setup(t, [{ stop: true, check: 'token-limit', hit: HIT }]); + const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'pi' }); + broker.request(coder, { verb: 'role.claim' }); + await turn(broker, human, coder, () => broker.request(human, { verb: 'message.send', args: { to: 'coder', body: 'work' } })); + assert.throws(() => broker.request(coder, { verb: 'state' }), { code: 'not-holder' }); + assert.equal(store.get("SELECT by FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").by, 'limit-check'); + // The stop is spent: the run is refused as any non-holder is, without a second claim row. + const rows = store.get("SELECT count(*) n FROM role_claims WHERE role='coder'").n; + assert.throws(() => broker.request(coder, { verb: 'message.receive' }), { code: 'not-holder' }); + assert.equal(store.get("SELECT count(*) n FROM role_claims WHERE role='coder'").n, rows); + // Another run that was never stopped ends with no limit. + const other = setup(t, []); + const run2 = other.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run2', harness: 'pi' }); + other.broker.request(run2, { verb: 'role.claim' }); + await turn(other.broker, other.human, run2, () => other.broker.request(other.human, { verb: 'message.send', args: { to: 'coder', body: 'w' } })); + assert.deepEqual(other.broker.endLaunch({ business: 'demo', run: 'run2', reason: 'stopped', exitCode: 0 }), { released: true }); + assert.equal(JSON.parse(other.store.get("SELECT body FROM events WHERE kind='session.ended' AND subject='run2'").body).limit, undefined); +}); + +test('REQ-LIMIT-4 fail closed: a mustStop throw or a malformed answer stops the run, naming the bus and the code', async t => { + for (const [answer, reason] of [ + [new Error('boom'), 'limit: bus failed check-threw'], + [{ stop: 'yes' }, 'limit: bus failed malformed-answer'], + [undefined, null], // no answer means { stop: false } from the stub + [{ stop: true, check: 'token-limit', code: 'not-loaded' }, 'limit: token-limit failed not-loaded'], + [{ stop: true, check: null, code: 'organization-unreadable' }, 'limit: bus failed organization-unreadable'], + ]) { + const { store, broker, human } = setup(t, answer === undefined ? [] : [answer]); + const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'pi' }); + broker.request(coder, { verb: 'role.claim' }); + await turn(broker, human, coder, () => broker.request(human, { verb: 'message.send', args: { to: 'coder', body: 'work' } })); + if (reason === null) { + assert.deepEqual(broker.request(coder, { verb: 'message.receive' }), []); + continue; + } + assert.throws(() => broker.request(coder, { verb: 'message.receive' }), { code: 'not-holder' }, reason); + assert.equal(store.get("SELECT reason FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").reason, reason); + } +}); + +test('REQ-LIMIT-4 a thread run at its limit is released with the reason, and its end record names the limit', async t => { + const { store, broker, human } = setup(t, [{ stop: true, check: 'token-limit', hit: { ...HIT, path: ['tokens', 'instances', 'assistant'] } }]); + const project = broker.request(human, { verb: 'project.create', args: { name: 'stack work', repo: 'stack' } }).id; + const thread = broker.request(human, { verb: 'thread.create', args: { project, name: 'alpha' } }).id; + const a = broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'a-run', harness: 'pi', thread }); + broker.request(a, { verb: 'role.claim' }); + await turn(broker, human, a, () => broker.request(human, { verb: 'thread.send', args: { thread, body: 'look' } })); + assert.throws(() => broker.request(a, { verb: 'message.receive' }), { code: 'not-holder' }); + const row = store.get('SELECT op, by, reason FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread); + const reason = 'limit: token-limit tokens.instances.assistant limit 100 count 120'; + assert.deepEqual({ ...row }, { op: 'release', by: 'limit-check', reason }); + assert.deepEqual(broker.endLaunch({ business: 'demo', run: 'a-run', reason: 'exited', exitCode: 22 }), { released: false, limit: reason }); + // The thread is free for a new run. + const a2 = broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'a2-run', harness: 'pi', thread }); + assert.deepEqual(broker.request(a2, { verb: 'role.claim' }), { role: 'assistant', run: 'a2-run', thread }); +}); + +test('REQ-LIMIT-4 a broker with no limit checks configured never stops a run', async t => { + const root = mkdtempSync(join(tmpdir(), 'limit-stop-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const broker = new Broker({ store, businesses: businesses(), usage: { collect: async () => ({ recorded: true }), view: () => ({}) } }); + const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'pi' }); + broker.request(coder, { verb: 'role.claim' }); + await turn(broker, human, coder, () => broker.request(human, { verb: 'message.send', args: { to: 'coder', body: 'work' } })); + assert.deepEqual(broker.request(coder, { verb: 'message.receive' }), []); +}); diff --git a/packages/bus/tests/limits-runtime.test.mjs b/packages/bus/tests/limits-runtime.test.mjs new file mode 100644 index 000000000..eca43f407 --- /dev/null +++ b/packages/bus/tests/limits-runtime.test.mjs @@ -0,0 +1,95 @@ +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the runtime wires the limit checks. The limits verb +// carries each token limit's count, a reset moves since and the count follows, and the host's +// mayLaunch asks the shipped token-limit through the real loader. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +const SINCE = '2026-10-18T07:00:00.000Z'; +const CODER = ['tokens', 'instances', 'coder']; + +async function setup(t, plugins, limit = 0) { + const { startBroker } = await import('../src/runtime.mjs'); + const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs'); + const root = mkdtempSync(join(tmpdir(), 'mosaic-limits-tokens-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo'); + doc.plugins = { 'limit-check': plugins }; + doc.launch.limits = { ...doc.launch.limits, tokens: { instances: { coder: { limit, since: SINCE } } } }; + writeJson(join(root, 'businesses/demo.json'), doc); + mkdirSync(join(root, 'data'), { mode: 0o700 }); + const bus = await startBroker({ dataRoot: join(root, 'data'), + businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, + roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } }, + organization: { dir: root, rolesDir, system: systemFor(root) } }); + t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); + return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) }; +} + +test('REQ-LIMIT-3 REQ-LIMIT-4 runtime: limits carries the token count, mayLaunch refuses at the limit, a reset moves since', async t => { + const { bus, human } = await setup(t, ['token-limit']); + const view = bus.broker.request(human, { verb: 'limits' }); + assert.equal(view.tokens.length, 1); + assert.deepEqual({ ...view.tokens[0], count: Number(view.tokens[0].count) }, + { path: CODER, limit: 0, since: SINCE, status: 'counted', check: 'token-limit', count: 0, + kinds: view.tokens[0].kinds, unknownTurns: 0 }); + assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'zai', model: 'glm-5.3-flash' }), + { allow: false, check: 'token-limit', hit: { path: CODER, limit: 0, count: view.tokens[0].count, unknownTurns: 0 } }); + // An instance with no limit launches. + assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'reviewer', provider: 'zai', model: 'glm-5.3-flash' }), { allow: true }); + const done = bus.broker.request(human, { verb: 'limits.set', args: { revision: view.revision, reset: [CODER] } }); + assert.ok(done.tokens[0].since > SINCE, done.tokens[0].since); + assert.equal(done.tokens[0].status, 'counted'); +}); + +test('REQ-LIMIT-3 runtime: with no limit check switched on, the limit shows not enforced and every launch is allowed', async t => { + const { bus, human } = await setup(t, []); + assert.deepEqual(bus.broker.request(human, { verb: 'limits' }).tokens, + [{ path: CODER, limit: 0, since: SINCE, status: 'not-enforced', reason: null }]); + assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'zai', model: 'glm-5.3-flash' }), { allow: true }); +}); + +test('REQ-LIMIT-4 runtime: the broker asks the shipped token-limit after a turn, and the run at its limit stops on its next receive', async t => { + const { bus, human } = await setup(t, ['token-limit']); + const { broker } = bus; + const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'zai', model: 'glm-5.3-flash' }); + broker.request(coder, { verb: 'role.claim' }); + const { id } = broker.request(human, { verb: 'message.send', args: { to: 'coder', body: 'work' } }); + broker.request(coder, { verb: 'message.receive' }); + await broker.request(coder, { verb: 'usage.begin', args: { turn: id } }); + await broker.request(coder, { verb: 'usage.end', args: { turn: id } }); + assert.throws(() => broker.request(coder, { verb: 'message.receive' }), { code: 'not-holder' }); + // endLaunch reads the reason from the claim row the stop wrote with by='limit-check'. + assert.deepEqual(bus.endLaunch({ business: 'demo', run: 'run1', reason: 'exited', exitCode: 22 }), + { released: false, limit: 'limit: token-limit tokens.instances.coder limit 0 count 0' }); +}); + +test('REQ-LIMIT-4 runtime: with no usage source a turn is unknown, counts zero and stops nothing under a limit of 1', async t => { + const { bus, human } = await setup(t, ['token-limit'], 1); + const { broker } = bus; + const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'zai', model: 'glm-5.3-flash' }); + broker.request(coder, { verb: 'role.claim' }); + const { id } = broker.request(human, { verb: 'message.send', args: { to: 'coder', body: 'work' } }); + broker.request(coder, { verb: 'message.receive' }); + await broker.request(coder, { verb: 'usage.begin', args: { turn: id } }); + await broker.request(coder, { verb: 'usage.end', args: { turn: id } }); + broker.request(coder, { verb: 'message.receive' }); + const [row] = broker.request(human, { verb: 'limits' }).tokens; + assert.deepEqual({ count: row.count, unknownTurns: row.unknownTurns, status: row.status }, { count: 0, unknownTurns: 1, status: 'counted' }); + assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'zai', model: 'glm-5.3-flash' }), { allow: true }); +}); + +test('REQ-LIMIT-3 runtime: switching a limit check on with plugins.set loads it without a launch or a turn', async t => { + const { bus, human } = await setup(t, []); + const { revision } = bus.broker.request(human, { verb: 'plugins' }); + bus.broker.request(human, { verb: 'plugins.set', args: { revision, plugins: { 'limit-check': ['token-limit'] } } }); + let row; + for (let i = 0; i < 100; i++) { + [row] = bus.broker.request(human, { verb: 'limits' }).tokens; + if (row.status === 'counted') break; + await delay(20); + } + assert.equal(row.status, 'counted', JSON.stringify(row)); + assert.equal(row.check, 'token-limit'); +}); diff --git a/packages/bus/tests/process.test.mjs b/packages/bus/tests/process.test.mjs index efdb5af26..8e9a7820f 100644 --- a/packages/bus/tests/process.test.mjs +++ b/packages/bus/tests/process.test.mjs @@ -146,6 +146,24 @@ test('trusted host registers later launches; socket clients never have a registr child.send({ op: 'close' }); assert.equal((await exit)[0], 0); }); +test('REQ-LIMIT-4 the broker process answers the launcher\'s mayLaunch op with the limits\' awaited decision', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'bus-may-launch-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const { child } = launch(t); + const ready = await boot(child, { dataRoot: root, businesses, launches: [] }); + assert.equal(ready.ok, true); + const ask = async (m) => { + const message = once(child, 'message'); + child.send(m); + return (await message)[0]; + }; + // No limit check is switched on for demo, so the launch is allowed. + assert.deepEqual(await ask({ op: 'mayLaunch', id: 7, business: 'demo', instance: 'cto', provider: 'p', model: 'm' }), + { ok: true, result: { allow: true }, id: 7 }); + const exit = once(child, 'exit'); + child.send({ op: 'close' }); + assert.equal((await exit)[0], 0); +}); test('runtime excludes declared project roots even when host supplies no repoRoots', async (t) => { const root = mkdtempSync(join(tmpdir(), 'bus-project-token-')); t.after(() => rmSync(root, { recursive: true, force: true })); diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs index 630486662..5a13c80ef 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -132,6 +132,7 @@ function checkLaunch(launch, roles, definitions, file) { }, { nonEmpty: true }); const models=checkModels(launch.models,`${where}.models`); const limits=checkLimits(launch.limits===undefined?{}:launch.limits,`${where}.limits`); + for(const instance of Object.keys(limits.tokens?.instances??{}))if(!Object.hasOwn(roles,instance))refuse(`${where}.limits.tokens.instances names ${instance}, which the business file doesn't declare`); for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`); // No upper bound: limits have no ceiling in code (Q150). const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`); diff --git a/packages/business/src/launch-settings.mjs b/packages/business/src/launch-settings.mjs index fb5887291..ff4a58de8 100644 --- a/packages/business/src/launch-settings.mjs +++ b/packages/business/src/launch-settings.mjs @@ -5,9 +5,71 @@ import { resolveInstance } from "./resolve.mjs"; import { usageCounts } from "./launch.mjs"; import { requireObject, rejectUnknownKeys } from "./util.mjs"; -export function launchSettings({ dir, rolesDir, system, sessions = () => [] }) { +// Every key a limits update takes. The broker's and the harness tool's +// ORGANIZATION_KEYS must match it (packages/harness/tests/tools.test.mjs). +export const SETTINGS_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset"]); + +const own = (value, key) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key); +const plain = value => value !== null && typeof value === "object" && !Array.isArray(value); + +// Row 125 (#1619, REQ-LIMIT-3): token limits sit only at their schema depth, tokens.instances.X, +// tokens.providers.X, tokens.keys.X and tokens.models.P.M. Nothing else in launch.limits is one, +// so a concurrency provider or model named `limit`, or a token limit's subject named `limit`, is +// never mistaken for a leaf. The bus core's limitLeaves walks the same depths. +const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2 }; +function tokenLeaves(limits) { + const out = []; + const walk = (value, depth, path) => { + if (!plain(value)) return; + for (const [key, entry] of Object.entries(value)) { + if (depth > 1) walk(entry, depth - 1, [...path, key]); + else out.push({ path: [...path, key], entry }); + } + }; + const tokens = own(limits, "tokens") ? limits.tokens : undefined; + if (plain(tokens)) { + for (const [kind, value] of Object.entries(tokens)) if (Object.hasOwn(DEPTH, kind)) walk(value, DEPTH[kind], ["tokens", kind]); + } + return out; +} +const at = (value, path) => path.reduce((v, part) => (own(v, part) ? v[part] : undefined), value); + +// The writer, not the caller, sets a token limit's `since`. A new limit gets now; a changed or +// unchanged one keeps its stored time; a caller-supplied time that differs from the stored one is +// refused, so only a reset moves it. An entry that isn't an object is left for the schema to refuse. +function stamp(next, old, now) { + for (const { path, entry } of tokenLeaves(next)) { + if (!plain(entry)) continue; + const before = at(old, path); + const kept = plain(before) ? before.since : undefined; + if (own(entry, "since") && entry.since !== kept) throw new OrganizationError("invalid-request"); + entry.since = kept ?? now; + } +} + +// A reset names token limit paths, e.g. ["tokens", "instances", "coder-1"]; each gets since = now. +// Any other path, a concurrency limit's included, is refused. +function reset(limits, paths, now) { + if (!Array.isArray(paths) || !paths.length) throw new OrganizationError("invalid-request"); + const leaves = tokenLeaves(limits); + for (const path of paths) { + if (!Array.isArray(path) || !path.length || path.some(part => typeof part !== "string")) { + throw new OrganizationError("invalid-request"); + } + const leaf = leaves.find(l => l.path.length === path.length && l.path.every((part, i) => part === path[i])); + if (!leaf || !plain(leaf.entry)) throw new OrganizationError("invalid-request"); + leaf.entry.since = now; + } +} + +export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString() }) { const writer = organizationWriter({ dir, rolesDir }); + // The validated business as the file stands now (row 125: the limit checks read it at each ask). + function current(id) { + return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir }); + } + function view(id) { const { document, revision } = writer.read(id); const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir }); @@ -31,17 +93,22 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [] }) { function set(id, args, actor) { requireObject(args, "limits update"); - rejectUnknownKeys(args, ["revision", "limits", "models", "instances"], "limits update"); + rejectUnknownKeys(args, SETTINGS_KEYS, "limits update"); if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) { throw new OrganizationError("invalid-request"); } - if (!["limits", "models", "instances"].some(key => Object.hasOwn(args, key))) { + if (!["limits", "models", "instances", "reset"].some(key => Object.hasOwn(args, key))) { throw new OrganizationError("invalid-request"); } const old = writer.read(id); const next = structuredClone(old.document); if (!next.launch) throw new OrganizationError("launch-not-configured"); - if (Object.hasOwn(args, "limits")) next.launch.limits = args.limits; + const at = now(); + if (Object.hasOwn(args, "limits")) { + next.launch.limits = structuredClone(args.limits); + stamp(next.launch.limits, old.document.launch.limits, at); + } + if (Object.hasOwn(args, "reset")) reset(next.launch.limits, args.reset, at); if (Object.hasOwn(args, "models")) next.launch.models = args.models; if (Object.hasOwn(args, "instances")) { requireObject(args.instances, "instance model selections"); @@ -64,5 +131,5 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [] }) { return { ...view(id), transaction: receipt.transaction }; } - return { writer, read: view, set }; + return { writer, read: view, set, current }; } diff --git a/packages/business/src/launch.mjs b/packages/business/src/launch.mjs index 313d8ecd3..6aaa8f5eb 100644 --- a/packages/business/src/launch.mjs +++ b/packages/business/src/launch.mjs @@ -23,9 +23,40 @@ export function checkModels(value, where = "launch.models") { })); } +// Row 125 (#1619, REQ-LIMIT-3): one token limit, a count of 0 or more and the +// canonical time it was set or last reset. The settings writer stamps `since`. +const SINCE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const tokenLimit = (value, at) => { + requireObject(value, at); + rejectUnknownKeys(value, ["limit", "since"], at); + const since = value.since; + if (typeof since !== "string" || !SINCE.test(since) || Number.isNaN(Date.parse(since)) || + new Date(since).toISOString() !== since) refuse(`${at}.since must be a canonical timestamp`); + return { limit: count(value.limit, `${at}.limit`), since }; +}; +const tokenMap = (value, at, key, each) => { + requireObject(value, at); + return Object.fromEntries(Object.entries(value).map(([k, v]) => [key(k, at), each(v, `${at}.${k}`)])); +}; + +// launch.limits.tokens: instances, providers, models (per provider) and keys, each optional. +export function checkTokenLimits(value, where = "launch.limits.tokens") { + requireObject(value, where); + rejectUnknownKeys(value, ["instances", "providers", "models", "keys"], where); + const out = {}; + if (Object.hasOwn(value, "instances")) out.instances = tokenMap(value.instances, `${where}.instances`, requireId, tokenLimit); + if (Object.hasOwn(value, "providers")) out.providers = tokenMap(value.providers, `${where}.providers`, name, tokenLimit); + if (Object.hasOwn(value, "models")) { + out.models = tokenMap(value.models, `${where}.models`, name, (models, at) => tokenMap(models, at, name, tokenLimit)); + } + // A key is a name until row 126 names the accounts. + if (Object.hasOwn(value, "keys")) out.keys = tokenMap(value.keys, `${where}.keys`, requireId, tokenLimit); + return out; +} + export function checkLimits(value = {}, where = "launch.limits") { requireObject(value, where); - rejectUnknownKeys(value, ["total", "providers", "models"], where); + rejectUnknownKeys(value, ["total", "providers", "models", "tokens"], where); const out = {}; if (Object.hasOwn(value, "total")) out.total = count(value.total, `${where}.total`); if (Object.hasOwn(value, "providers")) { @@ -44,6 +75,7 @@ export function checkLimits(value = {}, where = "launch.limits") { }))]; })); } + if (Object.hasOwn(value, "tokens")) out.tokens = checkTokenLimits(value.tokens, `${where}.tokens`); return out; } diff --git a/packages/business/tests/token-limit-settings.test.mjs b/packages/business/tests/token-limit-settings.test.mjs new file mode 100644 index 000000000..8ac3e953e --- /dev/null +++ b/packages/business/tests/token-limit-settings.test.mjs @@ -0,0 +1,103 @@ +// Row 125 (#1619, REQ-LIMIT-3): token limits in the organization file. The schema, the writer's +// `since` stamp, and the reset, which moves `since` to now and is logged with its actor. +import test,{after} from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync,rmSync} from 'node:fs'; +import {join} from 'node:path'; +import {businessDoc,rolesCopy,tmp as makeRoot,writeJson} from './helpers.mjs'; +import {validateBusinessDocument} from '../src/business.mjs'; +import {launchSettings} from '../src/launch-settings.mjs'; + +const roots=[];after(()=>roots.forEach(root=>rmSync(root,{recursive:true,force:true}))); +const T0='2026-10-18T07:00:00.000Z',T1='2026-10-18T08:00:00.000Z',T2='2026-10-18T09:00:00.000Z'; +function fixture(){ + const root=makeRoot();roots.push(root);const rolesDir=rolesCopy(root),doc=businessDoc(root,'acme'); + for(const r of Object.values(doc.roles))r.vars={...r.vars,model:'sonnet'}; + doc.launch={by:'chief',instances:['coder','reviewer'],models:{sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},limits:{}}; + const file=join(root,'businesses/acme.json');writeJson(file,doc); + const system={environment:'development',dataRoot:join(root,'data'),'execution.backend':'docker','execution.provider':'anthropic','execution.model':'sonnet','execution.adapter':'pi'}; + let clock=T0;const settings=launchSettings({dir:root,rolesDir,system,now:()=>clock}); + return {root,rolesDir,doc,file,settings,tick:at=>{clock=at;}}; +} +const stored=f=>JSON.parse(readFileSync(f.file,'utf8')).launch.limits.tokens; +const lastLog=f=>JSON.parse(readFileSync(f.settings.writer.logFile('acme'),'utf8').trim().split('\n').at(-1)); + +test('REQ-LIMIT-3 schema: each subject kind, limit 0 and no ceiling are accepted; bad limits, times, keys and undeclared instances refuse',()=>{ + const f=fixture(),check=tokens=>{const d=structuredClone(f.doc);d.launch.limits={tokens};return validateBusinessDocument(d,f.file,{rolesDir:f.rolesDir}).launch.limits.tokens;}; + const leaf={limit:0,since:T0},big={limit:Number.MAX_SAFE_INTEGER,since:T0}; + const all={instances:{coder:leaf},providers:{anthropic:big},models:{anthropic:{'claude-sonnet-5-5':leaf}},keys:{k1:leaf}}; + assert.deepEqual(check(all),all); + assert.deepEqual(check({}),{}); + for(const bad of [ + {instances:{coder:{limit:-1,since:T0}}},{instances:{coder:{limit:1.5,since:T0}}},{instances:{coder:{limit:'5',since:T0}}}, + {instances:{coder:{limit:5}}},{instances:{coder:{limit:5,since:'2026-10-18'}}},{instances:{coder:{limit:5,since:'2026-10-18T07:00:00Z'}}}, + {instances:{coder:{limit:5,since:'2026-02-30T07:00:00.000Z'}}},{instances:{coder:{limit:5,since:T0,extra:1}}}, + {accounts:{}},{models:{anthropic:{limit:5,since:T0}}},{instances:[]},{instances:{ghost:{limit:5,since:T0}}}, + ])assert.throws(()=>check(bad),JSON.stringify(bad)); +}); + +test('REQ-LIMIT-3 the writer stamps since: a new limit gets now, a changed one keeps its time, a different time is refused',()=>{ + const f=fixture(); + let view=f.settings.read('acme'); + view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{instances:{coder:{limit:100}}}}},'jason'); + assert.deepEqual(stored(f),{instances:{coder:{limit:100,since:T0}}}); + // A changed limit keeps its since, and a new one beside it gets the new now. + f.tick(T1); + view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{instances:{coder:{limit:50}},providers:{anthropic:{limit:9}}}}},'jason'); + assert.deepEqual(stored(f),{instances:{coder:{limit:50,since:T0}},providers:{anthropic:{limit:9,since:T1}}}); + // A since equal to the stored one is accepted; any other is refused and changes nothing. + view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{instances:{coder:{limit:50,since:T0}},providers:{anthropic:{limit:9}}}}},'jason'); + const bytes=readFileSync(f.file,'utf8'); + assert.throws(()=>f.settings.set('acme',{revision:view.revision,limits:{tokens:{instances:{coder:{limit:50,since:T2}}}}},'jason'),{code:'invalid-request'}); + assert.throws(()=>f.settings.set('acme',{revision:view.revision,limits:{tokens:{keys:{k1:{limit:1,since:T2}}}}},'jason'),{code:'invalid-request'}); + assert.equal(readFileSync(f.file,'utf8'),bytes); + // A limit removed and set again starts from now. + f.tick(T2); + view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{providers:{anthropic:{limit:9}}}}},'jason'); + view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{instances:{coder:{limit:50}},providers:{anthropic:{limit:9}}}}},'jason'); + assert.equal(stored(f).instances.coder.since,T2); +}); + +test('REQ-LIMIT-3 only tokens.* at schema depth holds token limits: agent limits named `limit` save as sent, and a token limit named `limit` is stamped and reset',()=>{ + const f=fixture(),full=()=>JSON.parse(readFileSync(f.file,'utf8')).launch.limits; + let view=f.settings.read('acme'); + // Agent (concurrency) limits that 19fe85c3e saves (Filbert, row 125 r1 change 1). + for(const limits of [{providers:{anthropic:3}},{providers:{limit:3}},{models:{limit:{m:2}}},{models:{anthropic:{limit:2}}}]){ + view=f.settings.set('acme',{revision:view.revision,limits},'jason'); + assert.deepEqual(full(),limits,JSON.stringify(limits)); + } + // Token limits whose provider, model or key is named `limit`, beside agent limits named the same. + const agents={providers:{limit:3},models:{limit:{limit:2}}}; + view=f.settings.set('acme',{revision:view.revision,limits:{...agents,tokens:{providers:{limit:{limit:5}},models:{limit:{limit:{limit:6}}},keys:{limit:{limit:7}}}}},'jason'); + assert.deepEqual(full(),{...agents,tokens:{providers:{limit:{limit:5,since:T0}},models:{limit:{limit:{limit:6,since:T0}}},keys:{limit:{limit:7,since:T0}}}}); + f.tick(T1); + view=f.settings.set('acme',{revision:view.revision,reset:[['tokens','providers','limit'],['tokens','models','limit','limit']]},'chief'); + assert.deepEqual(full(),{...agents,tokens:{providers:{limit:{limit:5,since:T1}},models:{limit:{limit:{limit:6,since:T1}}},keys:{limit:{limit:7,since:T0}}}}); + // An agent limit, or a path above a token limit, is never reset. + const bytes=readFileSync(f.file,'utf8'); + for(const reset of [[['providers','limit']],[['models','limit','limit']],[['tokens','models','limit']],[['tokens','providers']]]) + assert.throws(()=>f.settings.set('acme',{revision:view.revision,reset},'jason'),{code:'invalid-request'},JSON.stringify(reset)); + assert.equal(readFileSync(f.file,'utf8'),bytes); +}); + +test('REQ-LIMIT-3 a reset moves since to now, is logged with its actor, and refuses a path that is not a limit',()=>{ + const f=fixture(); + let view=f.settings.read('acme'); + view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{instances:{coder:{limit:100}},models:{anthropic:{'claude-sonnet-5-5':{limit:7}}}}}},'jason'); + f.tick(T1); + view=f.settings.set('acme',{revision:view.revision,reset:[['tokens','models','anthropic','claude-sonnet-5-5']]},'chief'); + assert.deepEqual(stored(f),{instances:{coder:{limit:100,since:T0}},models:{anthropic:{'claude-sonnet-5-5':{limit:7,since:T1}}}}); + const log=lastLog(f); + assert.equal(log.actor,'chief'); + assert.ok(log.changes.some(c=>c.path==='launch.limits.tokens.models.anthropic.claude-sonnet-5-5.since'&&c.old===T0&&c.new===T1),JSON.stringify(log.changes)); + // A user reset of two limits at once, both to the same now. + f.tick(T2); + view=f.settings.set('acme',{revision:view.revision,reset:[['tokens','instances','coder'],['tokens','models','anthropic','claude-sonnet-5-5']]},'jason'); + assert.equal(stored(f).instances.coder.since,T2); + assert.equal(stored(f).models.anthropic['claude-sonnet-5-5'].since,T2); + assert.equal(lastLog(f).actor,'jason'); + const bytes=readFileSync(f.file,'utf8'); + for(const reset of [[],[[]],[['tokens','instances']],[['tokens','instances','ghost']],[['tokens','instances',1]],'tokens',[['tokens','instances','coder','limit']]]) + assert.throws(()=>f.settings.set('acme',{revision:view.revision,reset},'jason'),{code:'invalid-request'},JSON.stringify(reset)); + assert.equal(readFileSync(f.file,'utf8'),bytes); +}); diff --git a/packages/cli/README.md b/packages/cli/README.md index b02cee8e2..7802d72f0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -800,6 +800,37 @@ Lowering one leaves running sessions alive and counted against their recorded provider/model, including the PM. Capacity refusals log the scope, configured limit and running count; launches log harness, provider and external model. +`limits` replaces the whole object, token limits included. Token limits +(row 125) sit under `limits.tokens`: per role instance at `instances.NAME`, +per provider at `providers.NAME`, per model at `models.PROVIDER.MODEL` and per +key at `keys.NAME`. Each is `{ "limit": N }`, a token count of 0 or more. The +writer adds `since`, the time the limit was set, and keeps it on later saves. +A patch that sends a different `since` is refused. Only these paths are token +limits; the agent limits above are never read as one, whatever their names. + +`mosaic limits reset WORD... [--business ID]` moves one token limit's `since` +to now, so its count restarts. The path is one word per level, as the plain +output prints it: `mosaic limits reset tokens models anthropic claude-opus-5-5`. +A path that isn't a token limit is refused. The reset is logged with its actor +in `changes.jsonl`, like any limit change. + +A token limit is counted and enforced only by a switched-on `limit-check` +plug-in. The one that ships is `token-limit` +(`packages/plugins/limit-check/token-limit/README.md`). A limit that no +switched-on check counts reads `not enforced`, and so does every limit when +no check is switched on. An enforced limit refuses a launch whose subject is +at or over it, and stops a running session at the end of the turn that +reaches it. A check that fails, or that is switched on but not loaded, +refuses launches, stops sessions at the end of their turn and shows every +limit as `check failed` (fail closed). + +Without `--json`, `mosaic limits`, `set` and `reset` print the read as JSON +without `tokens`, then a `token limits:` section with one line per token +limit: its path, limit and `since`, then either its count (with input, output, +cacheWrite, cacheRead and unknown turns), `not enforced` and any reason, or +`check failed` with the code and check. That output is not JSON as a whole; +scripts use `--json`, which keeps `tokens` as a list. + `mosaic limits migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]` needs no bus. If a role inherits its model, it reads and validates the system config and requires the mapping to preserve both provider and exact model. diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index 3a19900e6..deaae7840 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -19,11 +19,13 @@ // 7. its model names an exact provider/model table entry // 8. total, provider and provider/model have room, including the launcher // (capacity-full) -// 9. the broker authorizes role.launch for the caller (its own codes: +// 9. every switched-on limit check allows it (limit-reached, +// limit-check-failed), asked through the bus's plug-in loader only +// 10. the broker authorizes role.launch for the caller (its own codes: // launch-revoked, gated, ...) // -// Steps 2 to 8 refuse through the broker's refuse(), so every refusal is an -// action.refused event; step 9's refusals the broker records itself. Then +// Steps 2 to 9 refuse through the broker's refuse(), so every refusal is an +// action.refused event; step 10's refusals the broker records itself. Then // the host makes sure the workspace has a clone of each project // (packages/seat/src/checkout.mjs): one it can't make refuses // checkout-failed, one it fetches but doesn't fast-forward is a @@ -189,10 +191,10 @@ export function createLauncher({ return run; } - // Steps 2 to 8. Returns what the launch needs. Only `--pm` may name + // Steps 2 to 9. Returns what the launch needs. Only `--pm` may name // launch.by itself. A thread instance launches only for a thread, and // nothing else launches for one. - function check(instance, { pm = false, thread = null } = {}) { + async function check(instance, { pm = false, thread = null } = {}) { try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);} if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`); if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed"); @@ -216,6 +218,18 @@ export function createLauncher({ if (unavailable) throw new Refusal("sandbox-unavailable", unavailable); const hit=limitHit(registry.sessions,b.id,b.launch.limits,model); if(hit){const error=new Refusal("capacity-full",JSON.stringify(hit));error.limit=hit;throw error;} + // Row 125 (#1619, REQ-LIMIT-4): the limit checks. An answer that isn't an allow refuses, + // and so does a bus that can't answer (fail closed, lead decision 150). + let may; + try { + may = await host.op({ op: "mayLaunch", business: b.id, instance, provider: model.provider, model: model.model }); + } catch (e) { + throw new Refusal("limit-check-failed", `bus: ${e.code ?? "launch-op-refused"}`); + } + if (may?.allow !== true) { + if (may?.hit) { const error = new Refusal("limit-reached", JSON.stringify(may.hit)); error.limit = { check: may.check, ...may.hit }; throw error; } + throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`); + } return { resolved, model }; } @@ -250,7 +264,7 @@ export function createLauncher({ if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason }); else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason }); } - ({resolved,model}=check(instance,{pm:instance===b.launch.by,thread})); + ({resolved,model}=await check(instance,{pm:instance===b.launch.by,thread})); const { run, runDir } = newRun(dataRoot, b.id); const sessionDir = store ? store.session : join(runDir, "session"); if (!store) mkdirSync(sessionDir, { mode: 0o700 }); @@ -338,9 +352,10 @@ export function createLauncher({ exited.then(async ({ code, signal }) => { const reason = endReason(code, signal); try { - await endRun(run, reason, signal ? null : code); + const ended = await endRun(run, reason, signal ? null : code); registry.remove(b.id, run); - record({ event: "end", instance, ...(thread ? { thread } : {}), run, reason, exitCode: code, signal }); + // Row 125 (#1619, REQ-LIMIT-4): a run a limit check stopped names the limit, from the claim row. + record({ event: "end", instance, ...(thread ? { thread } : {}), run, reason, exitCode: code, signal, ...(ended?.limit ? { limit: ended.limit } : {}) }); log(`run ${run} (${instance}) ended: ${reason}`); } catch (e) { log(`run ${run}: ${e.message}`); @@ -401,7 +416,7 @@ export function createLauncher({ await serialize(async () => { if (closed || registry.running(b.id, t.instance, t.thread)) return; try { - await start({ instance: t.instance, launchedBy: b.human, thread: t.thread, ...check(t.instance, { thread: t.thread }) }); + await start({ instance: t.instance, launchedBy: b.human, thread: t.thread, ...(await check(t.instance, { thread: t.thread })) }); startedFor.set(t.thread, signature); refusedThreads.delete(t.thread); } catch (e) { @@ -419,11 +434,13 @@ export function createLauncher({ } } + // endLaunch's answer ({ released, limit? }), or null when it refused. async function endRun(run, reason, exitCode) { try { - await host.op({ op: "endLaunch", record: { business: b.id, run, reason, exitCode } }); + return await host.op({ op: "endLaunch", record: { business: b.id, run, reason, exitCode } }); } catch (e) { log(`run ${run}: endLaunch refused: ${e.code ?? e.message}`); + return null; } } @@ -444,7 +461,7 @@ export function createLauncher({ let checked; try { if (typeof instance !== "string") throw new Refusal("invalid-request"); - checked = check(instance); + checked = await check(instance); } catch (e) { if (!(e instanceof Refusal)) throw e; await host.op({ op: "refuse", cap, code: e.code }).catch(() => {}); @@ -474,7 +491,7 @@ export function createLauncher({ return serialize(async () => { if (!b.launch) throw new CliError(`business ${b.id} has no launch block, so --pm has nothing to launch`, 3); try { - return await start({ instance: b.launch.by, launchedBy: b.human, ...check(b.launch.by, { pm: true }) }); + return await start({ instance: b.launch.by, launchedBy: b.human, ...(await check(b.launch.by, { pm: true })) }); } catch (e) { if (!(e instanceof Refusal)) throw e; record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) }); diff --git a/packages/cli/src/limits.mjs b/packages/cli/src/limits.mjs index 1858541c9..829609608 100644 --- a/packages/cli/src/limits.mjs +++ b/packages/cli/src/limits.mjs @@ -7,7 +7,23 @@ import { CliError } from "./errors.mjs"; import { migrateLaunch } from "../../business/src/migrate-launch.mjs"; export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] | set --file PATCH [--business ID] | " + - "migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]"; + "reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]"; + +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): one line per token limit, its path in the words +// `mosaic limits reset` takes. A limit no switched-on check counts reads "not enforced". +export function formatTokens(tokens) { + if (!Array.isArray(tokens) || tokens.length === 0) return "token limits: none\n"; + const lines = tokens.map(t => { + const head = `${t.path.join(" ")}: limit ${t.limit} since ${t.since}`; + if (t.status === "counted") { + const kinds = Object.entries(t.kinds).map(([k, n]) => `${k} ${n}`).join(", "); + return `${head}, count ${t.count} (${kinds}), unknown turns ${t.unknownTurns}`; + } + if (t.status === "not-enforced") return `${head}, not enforced${t.reason ? `: ${t.reason}` : ""}`; + return `${head}, check failed: ${t.code}${t.check ? ` (${t.check})` : ""}`; + }); + return `token limits:\n${lines.map(l => ` ${l}`).join("\n")}\n`; +} export function parseLimits(args) { const out = { positional: [], flags: new Set(), values: {} }; @@ -27,7 +43,7 @@ export async function limitsCommand(args, io, callFor) { const parsed = parseLimits(args); const verb = parsed.positional[0] ?? "view"; const values = parsed.values; - if (parsed.positional.length > 1) throw new CliError(LIMITS_USAGE, 4); + if (parsed.positional.length > 1 && verb !== "reset") throw new CliError(LIMITS_USAGE, 4); try { if (verb === "migrate") { if (!values["--file"] || !values["--models"] || values["--business"]) { @@ -43,15 +59,26 @@ export async function limitsCommand(args, io, callFor) { io.stdout.write(JSON.stringify(converted, null, 2) + "\n"); return; } - if (!["view", "set"].includes(verb) || values["--models"] || values["--roles-dir"] || - (verb === "view" && values["--file"]) || (verb === "set" && !values["--file"])) { + if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] || + (verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) || + (verb === "reset" && parsed.positional.length < 2)) { throw new CliError(LIMITS_USAGE, 4); } const call = callFor(parsed); - const result = verb === "set" - ? await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8"))) - : await call("limits", {}); - io.stdout.write(JSON.stringify(result, null, 2) + "\n"); + // Row 125 (#1619, REQ-LIMIT-3): `reset tokens providers anthropic` restarts that limit's count + // from now. The path is one word per level, so a model name may hold dots or slashes. The + // result's `tokens` lists each token limit: counted, not-enforced or check-failed. + let result; + if (verb === "set") result = await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8"))); + else if (verb === "reset") { + const { revision } = await call("limits", {}); + result = await call("limits.set", { revision, reset: [parsed.positional.slice(1)] }); + } else result = await call("limits", {}); + if (parsed.flags.has("--json")) io.stdout.write(JSON.stringify(result, null, 2) + "\n"); + else { + const { tokens, ...rest } = result ?? {}; + io.stdout.write(JSON.stringify(rest, null, 2) + "\n" + formatTokens(tokens)); + } } catch (error) { if (error instanceof CliError) throw error; throw new CliError(error.message, 2); diff --git a/packages/cli/tests/launch-limit-checks.test.mjs b/packages/cli/tests/launch-limit-checks.test.mjs new file mode 100644 index 000000000..68f1384fd --- /dev/null +++ b/packages/cli/tests/launch-limit-checks.test.mjs @@ -0,0 +1,77 @@ +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the launcher asks the bus's limit checks before +// every launch, after the agent limits. A stub host answers the mayLaunch op; no sandbox, Docker +// or session process. Any answer that isn't an allow refuses (fail closed, lead decision 150). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {createLauncher} from '../src/launcher.mjs'; +import {fixture,tmp} from './helpers.mjs'; +import {launchLogFile} from '../../seat/src/session.mjs'; + +const HIT={path:['tokens','instances','coder'],limit:100,count:120,unknownTurns:1}; + +// answer is what the mayLaunch op returns, or an Error it throws. +function setup(t,answer){ + const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),ops=[]; + const host={business:'acme',beforeClose:()=>{},op:async x=>{ + ops.push(x); + if(x.op==='identity')return {business:'acme',role:'pm'}; + if(x.op==='mayLaunch'){if(answer instanceof Error)throw answer;return answer;} + if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'}); + return {}; + }}; + const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}}); + const last=()=>JSON.parse(readFileSync(launchLogFile(f.dataRoot,'acme'),'utf8').trim().split('\n').at(-1)); + return {launcher,ops,last}; +} + +test('REQ-LIMIT-4 the launcher asks mayLaunch with the instance, provider and model, and goes on to authorization on an allow',async t=>{ + const {launcher,ops}=setup(t,{allow:true}); + assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy'); + assert.deepEqual(ops.find(x=>x.op==='mayLaunch'),{op:'mayLaunch',business:'acme',instance:'coder',provider:'zai',model:'claude-sonnet-5-5'}); + assert.ok(ops.findIndex(x=>x.op==='mayLaunch')x.op==='authorizeLaunch')); +}); + +test('REQ-LIMIT-3 REQ-LIMIT-4 a launch whose subject is at its limit is refused limit-reached and logged with the check, limit and count',async t=>{ + const {launcher,ops,last}=setup(t,{allow:false,check:'token-limit',hit:HIT}); + assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'limit-reached'}); + const event=last(); + assert.equal(event.event,'refused'); + assert.equal(event.code,'limit-reached'); + assert.deepEqual(event.limit,{check:'token-limit',...HIT}); + assert.deepEqual(JSON.parse(event.why),HIT); + assert.ok(ops.find(x=>x.op==='refuse'&&x.code==='limit-reached')); + assert.equal(ops.find(x=>x.op==='authorizeLaunch'),undefined); + assert.deepEqual(launcher.registry.sessions,[]); +}); + +test('REQ-LIMIT-4 fail closed: a refused op, a check failure or a malformed answer refuses limit-check-failed, naming the check and the code',async t=>{ + for(const [answer,why] of [ + [Object.assign(Error('op refused'),{code:'quota-gone'}),'bus: quota-gone'], + [Error('no code'),'bus: launch-op-refused'], + [{allow:false,check:'token-limit',code:'not-loaded'},'token-limit: not-loaded'], + [{allow:false,check:'token-limit',code:'check-threw'},'token-limit: check-threw'], + [{allow:false,check:null,code:'organization-unreadable'},'bus: organization-unreadable'], + [{allow:'yes'},'bus: malformed-answer'], + [{},'bus: malformed-answer'], + [null,'bus: malformed-answer'], + [undefined,'bus: malformed-answer'], + ]){ + const {launcher,ops,last}=setup(t,answer); + assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'limit-check-failed'},why); + const event=last(); + assert.equal(event.code,'limit-check-failed',why); + assert.equal(event.why,why); + assert.equal(event.limit,undefined,why); + assert.equal(ops.find(x=>x.op==='authorizeLaunch'),undefined,why); + } +}); + +test('REQ-LIMIT-4 the launcher reaches token-limit only through the bus: launcher.mjs imports no limit check',()=>{ + const src=readFileSync(fileURLToPath(new URL('../src/launcher.mjs',import.meta.url)),'utf8'); + assert.doesNotMatch(src,/token-limit/); + assert.doesNotMatch(src,/limit-check\//); + assert.doesNotMatch(src,/plugins\/src\/loader/); + assert.doesNotMatch(src,/\bimport\s*\(/); +}); diff --git a/packages/cli/tests/launch-limits.test.mjs b/packages/cli/tests/launch-limits.test.mjs index 4d46ba416..0d8183cdd 100644 --- a/packages/cli/tests/launch-limits.test.mjs +++ b/packages/cli/tests/launch-limits.test.mjs @@ -9,7 +9,7 @@ import {systemFor,writeJson} from '../../business/tests/helpers.mjs'; import {launchLogFile} from '../../seat/src/session.mjs'; test('each launch rereads limits and model selections; lowered limits keep sessions and log exact hit',async t=>{ const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),file=join(root,'config/businesses/acme.json'),ops=[]; - const host={business:'acme',beforeClose:()=>{},op:async x=>{ops.push(x);if(x.op==='identity')return {business:'acme',role:'pm'};if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});return {};}}; + const host={business:'acme',beforeClose:()=>{},op:async x=>{ops.push(x);if(x.op==='identity')return {business:'acme',role:'pm'};if(x.op==='mayLaunch')return {allow:true};if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});return {};}}; const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}}); const session={business:'acme',instance:'reviewer',run:'fixture-existing',harness:'pi',provider:'zai',model:'claude-sonnet-5-5'};launcher.registry.add(session); const update=limits=>{f.doc.launch.limits=limits;writeJson(file,f.doc);}; diff --git a/packages/cli/tests/limits-tokens.test.mjs b/packages/cli/tests/limits-tokens.test.mjs new file mode 100644 index 000000000..b2dc19fd5 --- /dev/null +++ b/packages/cli/tests/limits-tokens.test.mjs @@ -0,0 +1,56 @@ +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): `mosaic limits` shows each token limit with its +// count, or why it isn't counted, and `mosaic limits reset PATH...` restarts a limit's count. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {main} from '../src/cli.mjs'; +import {formatTokens} from '../src/limits.mjs'; +import {io,tmp} from './helpers.mjs'; + +const SINCE='2026-10-18T07:00:00.000Z'; +const TOKENS=[ + {path:['tokens','instances','coder'],limit:100,since:SINCE,status:'counted',check:'token-limit',count:120,kinds:{input:10,output:20,cacheWrite:40,cacheRead:50},unknownTurns:2}, + {path:['tokens','keys','k1'],limit:5,since:SINCE,status:'not-enforced',reason:'no records name a key'}, + {path:['tokens','providers','zai'],limit:9,since:SINCE,status:'not-enforced',reason:null}, + {path:['tokens','models','anthropic','claude-opus-5-5'],limit:7,since:SINCE,status:'check-failed',check:'token-limit',code:'check-threw'}, + {path:['tokens','instances','pm'],limit:1,since:SINCE,status:'check-failed',check:null,code:'records-unreadable'}, +]; + +test('REQ-LIMIT-3 REQ-LIMIT-4 formatTokens shows each limit counted, not enforced, or check failed, in reset path words',()=>{ + assert.equal(formatTokens(TOKENS),'token limits:\n'+[ + ` tokens instances coder: limit 100 since ${SINCE}, count 120 (input 10, output 20, cacheWrite 40, cacheRead 50), unknown turns 2`, + ` tokens keys k1: limit 5 since ${SINCE}, not enforced: no records name a key`, + ` tokens providers zai: limit 9 since ${SINCE}, not enforced`, + ` tokens models anthropic claude-opus-5-5: limit 7 since ${SINCE}, check failed: check-threw (token-limit)`, + ` tokens instances pm: limit 1 since ${SINCE}, check failed: records-unreadable`, + ].join('\n')+'\n'); + for(const none of [[],undefined])assert.equal(formatTokens(none),'token limits: none\n'); +}); + +test('REQ-LIMIT-3 the view prints the limits then the token lines; --json keeps tokens in the result',async t=>{ + const root=tmp(t),deps={system:{dataRoot:root},transport:()=>()=>({revision:'b'.repeat(64),limits:{total:3},tokens:TOKENS})}; + const x=io();await main(['limits','--business','acme'],x,deps); + const [json,lines]=x.out.text.split('token limits:\n'); + assert.deepEqual(JSON.parse(json),{revision:'b'.repeat(64),limits:{total:3}}); + assert.match(lines,/tokens keys k1: limit 5 since \S+, not enforced: no records name a key/); + const y=io();await main(['limits','--business','acme','--json'],y,deps); + assert.deepEqual(JSON.parse(y.out.text).tokens,TOKENS); +}); + +test('REQ-LIMIT-3 reset reads the revision, then sends limits.set with one path per limit, a word per level',async t=>{ + const root=tmp(t),calls=[]; + const deps={system:{dataRoot:root},transport:business=>(verb,args)=>{calls.push({business,verb,args});return {revision:'c'.repeat(64),limits:{},tokens:[]};}}; + await main(['limits','reset','tokens','models','anthropic','claude-opus-5-5','--business','acme'],io(),deps); + assert.deepEqual(calls,[ + {business:'acme',verb:'limits',args:{}}, + {business:'acme',verb:'limits.set',args:{revision:'c'.repeat(64),reset:[['tokens','models','anthropic','claude-opus-5-5']]}}, + ]); +}); + +test('REQ-LIMIT-3 reset with no path, or with --file, is refused before the bus',async t=>{ + const root=tmp(t),calls=[]; + const deps={system:{dataRoot:root},transport:()=>verb=>{calls.push(verb);return {};}}; + await assert.rejects(main(['limits','reset','--business','acme'],io(),deps),/mosaic limits/); + await assert.rejects(main(['limits','reset','tokens','--file','x.json','--business','acme'],io(),deps),/mosaic limits/); + await assert.rejects(main(['limits','view','extra','--business','acme'],io(),deps),/mosaic limits/); + assert.deepEqual(calls,[]); +}); diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs index 76f3e7528..e018cea44 100644 --- a/packages/cli/tests/thread-launch.test.mjs +++ b/packages/cli/tests/thread-launch.test.mjs @@ -47,14 +47,14 @@ function gitIn(cwd, ...args) { } // The assistant is the thread instance; sonnet is its model's limit. -function prepare(t, { sonnet = 2, idle = 1 } = {}) { +function prepare(t, { sonnet = 2, idle = 1, edit = (doc) => doc } = {}) { const root = tmp(t, "mosaic-thread-"); const f = fixture(root, "acme", (doc) => { doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" }; doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5" } }; doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet }); doc.launch.threadIdleSeconds = idle; - return doc; + return edit(doc); }); for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k]; mkdirSync(f.dataRoot, { mode: 0o700 }); @@ -84,6 +84,7 @@ async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) { credentialStatus: (m) => runtime.credentialStatus(m.business, m.role), threadsState: (m) => runtime.threadsState(m.business), threadIdle: (m) => runtime.threadIdle(m.record), + mayLaunch: (m) => runtime.mayLaunch(m), landHeld: (m) => runtime.landHeld(m.record), ...replace?.(runtime), }; @@ -368,3 +369,51 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t assert.deepEqual(readSessions(f.dataRoot), []); assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]); }); + +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the +// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the +// bus's plug-in loader. +const SINCE = "2026-10-18T00:00:00.000Z"; +const atLimit = (doc) => { + doc.plugins = { "limit-check": ["token-limit"] }; + doc.launch.limits.tokens = { instances: { assistant: { limit: 0, since: SINCE } } }; + return doc; +}; +const ASSISTANT_HIT = { path: ["tokens", "instances", "assistant"], limit: 0, count: 0, unknownTurns: 0 }; + +test("REQ-LIMIT-3 REQ-LIMIT-4 a thread start whose instance is at its token limit is refused, logged once with the limit and the count", { skip, timeout: 60000 }, async (t) => { + const prepared = prepare(t, { edit: atLimit }); + const { f } = prepared; + const host = await start(t, prepared); + const project = host.call("project.create", { name: "stack work", repo: "stack" }).id; + const A = host.call("thread.create", { project, name: "alpha" }).id; + host.call("thread.send", { thread: A, body: "for A" }); + const refused = await until(() => launchLog(f).find((e) => e.event === "refused" && e.thread === A)); + assert.equal(refused.code, "limit-reached"); + assert.equal(refused.instance, "assistant"); + assert.deepEqual(refused.limit, { check: "token-limit", ...ASSISTANT_HIT }); + assert.deepEqual(JSON.parse(refused.why), ASSISTANT_HIT); + await host.launcher.tick(); + assert.deepEqual(threadRuns(f, A), []); + assert.equal(launchLog(f).filter((e) => e.event === "launch").length, 0); + assert.equal(launchLog(f).filter((e) => e.event === "refused" && e.thread === A).length, 1); +}); + +test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: the reply lands, and the end record names the limit", { skip, timeout: 60000 }, async (t) => { + const prepared = prepare(t, { edit: atLimit }); + const { f } = prepared; + // The launch is allowed, as one started before the limit was lowered would have been; the + // stop after the turn comes from the real check through the bus. + const host = await start(t, prepared, { ops: () => ({ mayLaunch: () => ({ allow: true }) }) }); + const project = host.call("project.create", { name: "stack work", repo: "stack" }).id; + const A = host.call("thread.create", { project, name: "alpha" }).id; + const { reply } = await ask(host.call, A, "one turn"); + assert.equal(reply.class, "RESULT"); + const run = launchLog(f).find((e) => e.event === "launch" && e.thread === A).run; + const end = await until(() => launchLog(f).find((e) => e.event === "end" && e.run === run)); + assert.equal(end.limit, "limit: token-limit tokens.instances.assistant limit 0 count 0"); + assert.equal(end.exitCode, 22); + assert.ok(!launchLog(f).find((e) => e.event === "thread-idle" && e.run === run), "a limit stop is not an idle stop"); + assert.deepEqual(threadRuns(f, A), []); + assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null); +}); diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index b2d085d56..a3d31f18d 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -23,8 +23,9 @@ const TASK_REF = str("Task reference, as list_tasks shows it"); const TASK_PAGE = 50; const TASK_PAGE_MAX = 200; // The keys the broker's `organization.set` accepts (ORGANIZATION_KEYS in packages/bus/src/broker.mjs; -// a test holds the two lists equal). Kept here so the session's tool server doesn't load the broker. -export const ORGANIZATION_KEYS = Object.freeze(["revision", "limits", "models", "instances"]); +// a test holds both equal to SETTINGS_KEYS in packages/business/src/launch-settings.mjs). Kept here +// so the session's tool server doesn't load the broker. +export const ORGANIZATION_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset"]); // The longest web_search query (SEARCH_MAX_QUERY_CHARS in packages/discord/src/web.mjs, the one // limit the broker's web service and the search modules apply; a test holds them equal). Kept here // so the session's tool server doesn't load the Discord package. @@ -169,11 +170,12 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project }, ["role", "reason"])); } if (held.has("organization.change")) { - add("change_organization", "organization.set", "organization.change", "Change the organization file: the launch limits, the model table, and the model each instance runs. revision is the file's current revision; the broker refuses a stale one with revision-conflict. Send only the keys you change; each replaces what the file holds for it.", obj({ + add("change_organization", "organization.set", "organization.change", "Change the organization file: the launch limits, the model table, and the model each instance runs, or reset token limits so they count from now. revision is the file's current revision; the broker refuses a stale one with revision-conflict. Send only the keys you change; each replaces what the file holds for it.", obj({ revision: str("The organization file's current revision", { pattern: "^[a-f0-9]{64}$" }), limits: { type: "object", description: "The launch limits, replacing the file's launch limits" }, models: { type: "object", description: "The model table, replacing the file's model table" }, instances: { type: "object", description: "Role instance to model key, for the instances that change", additionalProperties: { type: "string" } }, + reset: { type: "array", minItems: 1, description: "Token limits to restart counting from now, each a path such as [\"tokens\",\"providers\",\"anthropic\"]", items: { type: "array", minItems: 1, items: { type: "string" } } }, }, ["revision"])); } // REQ-HARN-2: the broker-served web tools, only for a role whose file grants "web". The broker diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs index 85fd3ba9c..887cad131 100644 --- a/packages/harness/tests/runner.test.mjs +++ b/packages/harness/tests/runner.test.mjs @@ -90,11 +90,11 @@ test("stateBlock names the launch state with its time and event, and the open ta assert.match(capped, /Open tasks: 9, the first 1 of cap 1\./); }); -async function setup(t, { session = {}, policy = {}, tools, as = "coder", organization = null, launchBy, usage = null } = {}) { +async function setup(t, { session = {}, policy = {}, tools, as = "coder", organization = null, launchBy, usage = null, limits = null } = {}) { const dir = scratch(t); const store = new Store(dir); const demo = launchBy ? { ...businesses.demo, launch: { ...businesses.demo.launch, by: launchBy } } : businesses.demo; - const broker = new Broker({ store, businesses: { demo }, organization, usage }); + const broker = new Broker({ store, businesses: { demo }, organization, usage, limits }); const brokerSocket = join(dir, "bus.sock"); const server = await serve({ broker, path: brokerSocket }); t.after(async () => { @@ -582,3 +582,37 @@ test("REQ-LIMIT-4 runner brackets its actual harness turn before the reply and r { phase: 'end', turn: sent.id, run: 'coder-run', started: true }, ]); }); + +// Row 125 (#1619, REQ-LIMIT-4, decision 150 Q4): a run at its limit stops at the end of its turn. +// The turn's RESULT and read receipt land first; the next receive is refused not-holder, the +// runner exits as the session gone, and the claim row names the limit. +test("REQ-LIMIT-4 a runner at its limit stops at the end of its turn: RESULT and read first, then exit 22 and a limit-check revoke", async t => { + const asked = []; + const hit = { path: ["tokens", "instances", "coder"], limit: 100, count: 120, unknownTurns: 0 }; + const ctx = await setup(t, { + usage: { collect: async () => ({ recorded: true }) }, + limits: { mustStop: async (business, subject) => { + asked.push({ business, subject }); + return { stop: true, check: "token-limit", hit }; + } }, + }); + ctx.inbox = []; + const runner = startRunner(ctx.runDir, ctx.cap); + t.after(() => runner.child.kill("SIGKILL")); + await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'")); + const sent = ctx.call(ctx.pm, "message.send", { to: "coder", body: "one turn", class: "REQUEST" }); + const out = await runner.exited; + assert.equal(out.code, EXIT.session, out.stderr); + assert.match(out.stderr, /runner: session stopped working: not-holder/); + const [reply] = await until(replies(ctx)); + assert.equal(reply.class, "RESULT"); + assert.equal(reply.in_reply_to, sent.id); + assert.ok(ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id)); + assert.deepEqual(asked, [{ business: "demo", subject: { instance: "coder", provider: null, model: null } }]); + const row = ctx.store.get("SELECT op, by, reason, holder_run FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1"); + assert.deepEqual({ ...row }, { op: "revoke", by: "limit-check", holder_run: "coder-run", + reason: "limit: token-limit tokens.instances.coder limit 100 count 120" }); + // The RESULT and the read receipt are both older than the revoke. + const revokedAt = ctx.store.get("SELECT at FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1").at; + assert.ok(ctx.store.get("SELECT at FROM messages WHERE id=?", reply.id).at <= revokedAt); +}); diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index ffe5d3d17..3958e0f56 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { callTool, ORGANIZATION_KEYS, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs"; import { ORGANIZATION_KEYS as BROKER_ORGANIZATION_KEYS, TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs"; +import { SETTINGS_KEYS } from "../../business/src/launch-settings.mjs"; import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs"; const names = (tools) => tools.map((t) => t.name); @@ -96,8 +97,11 @@ test("end_run sends role and reason, no decision; change_organization sends revi }); // Row 132 (#1628): the key list is the broker's. -test("the organization keys change_organization takes are the broker's ORGANIZATION_KEYS", () => { +// Row 125 (#1619, REQ-LIMIT-3): and both are the keys launch settings write, reset included. +test("the organization keys change_organization takes are the broker's ORGANIZATION_KEYS and the settings' SETTINGS_KEYS", () => { assert.deepEqual([...ORGANIZATION_KEYS], BROKER_ORGANIZATION_KEYS); + assert.deepEqual([...ORGANIZATION_KEYS], [...SETTINGS_KEYS]); + assert.ok(ORGANIZATION_KEYS.includes("reset")); }); // Row 132 (#1628): the grants are the offer; a role without them gets neither tool. diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 97703951f..944d78d15 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -17,16 +17,18 @@ loader. | Kind | Interface | Switch | Exports | |---|---|---|---| | `usage-source` | 1 | list | `interfaceVersion`, `readTurn` | -| `limit-check` | 1 | list | `interfaceVersion`, `mayLaunch`, `mustStop` | +| `limit-check` | 2 | list | `interfaceVersion`, `mayLaunch`, `mustStop`, `report` | | `search-source` | 1 | one | `interfaceVersion`, `search` | Every module exports `interfaceVersion`, equal to its kind's interface version, plus the kind's functions. PRD REQ-LIMIT-4 says what the two kinds do: a usage source reads what a session used after each turn, and a limit -check answers whether a launch may start (`mayLaunch`) and whether a running -session must stop at the end of its turn (`mustStop`). The arguments and -return values of these functions are set by the rows that call them (row 124 -converts the first usage source). The transcript usage source ships with row 124; no limit-check module ships yet. +check answers whether a launch may start (`mayLaunch`), whether a running +session must stop at the end of its turn (`mustStop`), and what each limit +has counted (`report`). Row 124 set the usage source's arguments and ships +the transcript source. Row 125 set the limit check's at interface 2 (lead +decision 150) and ships `token-limit`; a module built for interface 1 is +refused with `interface-version`. A `search-source` answers the broker's `web.search` (REQ-HARN-2, REQ-PLUG-2, decision 0b81b0c5): `search(settings, query, deps, { signal })` resolves `{ query, total, results: [{ title, url, snippet }] }` or throws a `WebRefusal`; `signal` is an `AbortSignal` the broker fires at its deadline, and a module must stop its request when it does. The kind switches on one module, and the module's name is the source: `searxng-url` queries the operator's SearXNG at the business var `web.searxng`; `searxng-bundled` queries the SearXNG the Stack installs, at a fixed loopback address (the install is REQ-INST-1's first component); `harness-builtin` is served by the harness itself: a granted claude-code role gets WebSearch and WebFetch, the broker serves no web verbs, and `search` only refuses. The choice and every change to it are the `plugins` setting, logged in `changes.jsonl` by the plug-in setter. @@ -120,14 +122,72 @@ Limits of the checks: writer racing the host as the same user. - Node caches a module by URL, so a changed `index.mjs` is not re-read until the process restarts. -- The loader is not wired into any host process yet. The rows that call - the first two kinds (124, 125, 126) wire it. +- The bus host wires the loader: row 124 for usage sources, row 125 for + limit checks. ## Transcript usage source (row 124) `usage-source/transcript` now ships interface 1's `readTurn({harness, root})`. The bus brackets each actual harness turn, reads all sources through the loader, and appends baselines and outcomes. See that module's README for JSONL formats, -security/resource bounds and the best-reading limitation. No limit check ships -with this row. Enable transcript through the human plug-in settings writer; -a restart/re-pin remains a separate authorized window. +security/resource bounds and the best-reading limitation. Enable transcript +through the human plug-in settings writer; a restart/re-pin remains a +separate authorized window. + +## Limit checks (row 125) + +Interface 2. Core calls each switched-on check through the loader only, in +the order the `plugins` setting lists them, with a copy of its arguments: + +``` +mayLaunch({business, limits, subject, records, now}) → {allow: true} | {allow: false, hit} +mustStop({business, limits, subject, records, now}) → {stop: false} | {stop: true, hit} +report({business, limits, records, now}) → + [{path, limit, since, count, kinds, unknownTurns} | {path, limit, since, notEnforced}] +``` + +- `limits` is the business's `launch.limits`. `subject` is + `{instance, provider, model, account}`. `records` are the per-turn usage + records the usage sources wrote (`usage.mjs` `turnRecords`), each + `known` with its four kinds or `unknown`. `now` is an ISO time. +- `hit` is `{path, limit, count, unknownTurns}`. Its path must name a limit + in `limits` with that limit. A count is a safe integer or a digit string. +- The launcher asks `mayLaunch` before every launch, through the bus + (`mayLaunch` launch op). A refusal is `limit-reached` with the hit as its + message, or `limit-check-failed`. The bus asks `mustStop` after each + turn's usage end. A stop lets the turn's result and reads land, then + revokes the role claim (or releases the thread claim) with by + `limit-check` and a reason `limit: limit count `; + `session.ended` and the launcher's end record carry that reason. +- `report` feeds Settings and `mosaic limits`. Core composes one entry per + limit: `counted`, `not-enforced` (no check reported a count; a check's + `notEnforced` text is the reason) or `check-failed`. + +Fail closed (lead decision 150): a check that throws, answers in the wrong +shape, answers with a promise, or is switched on but not loaded refuses the +launch, stops the session at the end of its turn, and shows every limit as +`check failed: `. The code is the check's own thrown `code` when it +matches the plug-in code shape, else `check-threw` or `malformed-answer`. +Both refusals and stops are logged with the check and the code or hit. + +### token-limit + +`limit-check/token-limit` enforces `launch.limits.tokens`: + +```json +"tokens": { + "instances": {"coder": {"limit": 5000000, "since": "…"}}, + "providers": {"anthropic": {"limit": 50000000, "since": "…"}}, + "models": {"anthropic": {"claude-opus-5-5": {"limit": 20000000, "since": "…"}}}, + "keys": {"team-key": {"limit": 10000000, "since": "…"}} +} +``` + +A count is the sum of all four kinds over the subject's known records since +`since`; unknown turns add nothing and are counted apart. The limits writer +sets `since` when a limit is added; a changed limit keeps it, a caller's +`since` that differs is refused, and only a `reset` update (a list of paths, +`mosaic limits reset tokens providers anthropic`, Settings, or the chief's +`limits.set`) moves it to now. There is no ceiling (Q150). A key matches a +record's account; while no record names one, a key limit reports as not +enforced. See `limit-check/token-limit/README.md`. diff --git a/packages/plugins/limit-check/token-limit/README.md b/packages/plugins/limit-check/token-limit/README.md new file mode 100644 index 000000000..1d263e9b0 --- /dev/null +++ b/packages/plugins/limit-check/token-limit/README.md @@ -0,0 +1,30 @@ +# Token limit check + +Row 125 (#1619), REQ-LIMIT-3 and REQ-LIMIT-4, limit-check interface 2. Enable +with `"plugins": { "limit-check": ["token-limit"] }` in the organization file, +keeping its other plug-in kinds. Human changes use `mosaic plugins set` and the +current revision. + +It enforces `launch.limits.tokens`: per role instance (`instances`), provider +(`providers`), provider and model (`models..`) and key +(`keys`). Each limit is `{limit, since}`. + +- A count is `input + output + cacheWrite + cacheRead` summed over the known + per-turn records whose subject matches and whose `at` is at or after `since`. + An unknown turn adds nothing; `unknownTurns` counts the distinct unknown + turns apart, so a count is a floor, not proof. +- With no usage source switched on, every turn's record is unknown (source + `@none`), so every limit counts zero. +- `mayLaunch` and `mustStop` answer the first limit, in the order above, that + covers the subject and whose count is at or over it. A limit of 0 refuses + every launch of its subject. +- A key matches a record's `account`. Until accounts are named (row 126), + every record's account is null, so a key limit matches nothing and `report` + shows it as `not enforced: no records name a key` (lead decision 150, Q1). +- Counts are summed as BigInt; one past `Number.MAX_SAFE_INTEGER` is reported + as a digit string. + +The module is pure: it reads no file and no store. A bad `since` or record +`at` throws `bad-time`, a bad amount `bad-record`, and records that aren't a +list `bad-records`; core turns any throw into a refusal, a stop, or `check +failed` (fail closed). diff --git a/packages/plugins/limit-check/token-limit/index.mjs b/packages/plugins/limit-check/token-limit/index.mjs new file mode 100644 index 000000000..67e11493c --- /dev/null +++ b/packages/plugins/limit-check/token-limit/index.mjs @@ -0,0 +1,82 @@ +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): token limits per role instance, +// provider, provider and model, and key. Pure: core passes the business's +// launch limits and its per-turn usage records, and nothing here reads a file +// or a store. A count is every kind summed over the known records since the +// limit's `since`; an unknown turn adds nothing and is counted on its own. +export const interfaceVersion = 2; + +const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead']; +const SAFE = BigInt(Number.MAX_SAFE_INTEGER); +const refuse = code => { throw Object.assign(new Error(code), { code }); }; +const plain = n => n <= SAFE ? Number(n) : String(n); +const time = value => { + const t = typeof value === 'string' ? Date.parse(value) : NaN; + if (Number.isNaN(t)) refuse('bad-time'); + return t; +}; +const amount = value => Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : refuse('bad-record'); + +// Every token limit in launch.limits.tokens, in a fixed order, with the rule +// that says which records and subjects it covers. +function leaves(limits) { + const tokens = limits?.tokens ?? {}; + const out = []; + for (const [n, entry] of Object.entries(tokens.instances ?? {})) + out.push({ path: ['tokens', 'instances', n], entry, match: x => x.instance === n }); + for (const [p, entry] of Object.entries(tokens.providers ?? {})) + out.push({ path: ['tokens', 'providers', p], entry, match: x => x.provider === p }); + for (const [p, models] of Object.entries(tokens.models ?? {})) + for (const [m, entry] of Object.entries(models)) + out.push({ path: ['tokens', 'models', p, m], entry, match: x => x.provider === p && x.model === m }); + // A key is the record's account. Accounts are null until row 126 names them, + // so a key limit matches nothing until then. + for (const [k, entry] of Object.entries(tokens.keys ?? {})) + out.push({ path: ['tokens', 'keys', k], entry, match: x => x.account === k, key: true }); + return out; +} + +function tally(leaf, records) { + const since = time(leaf.entry.since); + const kinds = Object.fromEntries(KINDS.map(k => [k, 0n])); + const unknown = new Set(); + for (const r of records) { + if (!leaf.match(r) || time(r.at) < since) continue; + if (r.status === 'known') for (const k of KINDS) kinds[k] += amount(r[k]); + else unknown.add(JSON.stringify([r.run, r.turn])); + } + return { count: KINDS.reduce((sum, k) => sum + kinds[k], 0n), kinds, unknownTurns: unknown.size }; +} + +// The first limit covering the subject whose count is at or over it. +function hitFor({ limits, subject, records }) { + if (!Array.isArray(records)) refuse('bad-records'); + for (const leaf of leaves(limits)) { + if (!leaf.match(subject)) continue; + const { count, unknownTurns } = tally(leaf, records); + if (count >= BigInt(leaf.entry.limit)) return { path: leaf.path, limit: leaf.entry.limit, count: plain(count), unknownTurns }; + } + return null; +} + +export function mayLaunch(args) { + const hit = hitFor(args); + return hit ? { allow: false, hit } : { allow: true }; +} + +export function mustStop(args) { + const hit = hitFor(args); + return hit ? { stop: true, hit } : { stop: false }; +} + +// Every token limit with its count. A key limit is reported as not enforced +// while no record names an account (lead decision 150, Q1). +export function report({ limits, records }) { + if (!Array.isArray(records)) refuse('bad-records'); + const named = records.some(r => r.account !== null && r.account !== undefined); + return leaves(limits).map(leaf => { + const { path, entry: { limit, since } } = leaf; + if (leaf.key && !named) return { path, limit, since, notEnforced: 'no records name a key' }; + const { count, kinds, unknownTurns } = tally(leaf, records); + return { path, limit, since, count: plain(count), kinds: Object.fromEntries(KINDS.map(k => [k, plain(kinds[k])])), unknownTurns }; + }); +} diff --git a/packages/plugins/src/kinds.mjs b/packages/plugins/src/kinds.mjs index f5679ad3f..af42c09f2 100644 --- a/packages/plugins/src/kinds.mjs +++ b/packages/plugins/src/kinds.mjs @@ -11,9 +11,15 @@ export const KINDS = Object.freeze({ "usage-source": Object.freeze({ kind: "usage-source", interfaceVersion: 1, exports: Object.freeze(["readTurn"]), switch: "list", }), - // Decides whether a launch may start and whether a running session must stop. + // Decides whether a launch may start, whether a running session must stop, + // and what each limit has used (REQ-LIMIT-3; version 2 since row 125). Core + // calls each with the business's limits and usage rows, never a store: + // mayLaunch({ business, limits, subject, records, now }) -> { allow: true } | { allow: false, hit } + // mustStop({ business, limits, subject, records, now }) -> { stop: false } | { stop: true, hit } + // report({ business, limits, records, now }) -> [{ path, limit, since, count, kinds, unknownTurns }] + // with hit = { path, limit, count, unknownTurns } and subject = { instance, provider, model, account }. "limit-check": Object.freeze({ - kind: "limit-check", interfaceVersion: 1, exports: Object.freeze(["mayLaunch", "mustStop"]), switch: "list", + kind: "limit-check", interfaceVersion: 2, exports: Object.freeze(["mayLaunch", "mustStop", "report"]), switch: "list", }), // Answers web_search for a role's web tool (REQ-HARN-2, REQ-PLUG-2). The organization file names // one source, and the module's name is the choice: harness-builtin, searxng-bundled or diff --git a/packages/plugins/tests/helpers.mjs b/packages/plugins/tests/helpers.mjs index e74e875ee..79467900c 100644 --- a/packages/plugins/tests/helpers.mjs +++ b/packages/plugins/tests/helpers.mjs @@ -11,7 +11,13 @@ export const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..", "src") export const USAGE = name => `export const interfaceVersion = 1; export function readTurn() { return ${JSON.stringify(name)}; } `; -export const LIMIT = name => `export const interfaceVersion = 1; +export const LIMIT = name => `export const interfaceVersion = 2; +export function mayLaunch() { return ${JSON.stringify(name)}; } +export function mustStop() { return false; } +export function report() { return []; } +`; +// A version 1 limit check (before row 125): the loader refuses it. +export const LIMIT_V1 = name => `export const interfaceVersion = 1; export function mayLaunch() { return ${JSON.stringify(name)}; } export function mustStop() { return false; } `; diff --git a/packages/plugins/tests/kinds.test.mjs b/packages/plugins/tests/kinds.test.mjs index 9bed0dee8..bbbdb5f97 100644 --- a/packages/plugins/tests/kinds.test.mjs +++ b/packages/plugins/tests/kinds.test.mjs @@ -5,12 +5,12 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs"; const code = expected => e => e instanceof PluginError && e.code === expected; -test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source and limit-check at interface version 1, each a list", () => { +test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => { assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source"]); assert.deepEqual({ ...KINDS["usage-source"], exports: [...KINDS["usage-source"].exports] }, { kind: "usage-source", interfaceVersion: 1, exports: ["readTurn"], switch: "list" }); assert.deepEqual({ ...KINDS["limit-check"], exports: [...KINDS["limit-check"].exports] }, - { kind: "limit-check", interfaceVersion: 1, exports: ["mayLaunch", "mustStop"], switch: "list" }); + { kind: "limit-check", interfaceVersion: 2, exports: ["mayLaunch", "mustStop", "report"], switch: "list" }); assert.deepEqual({ ...KINDS["search-source"], exports: [...KINDS["search-source"].exports] }, { kind: "search-source", interfaceVersion: 1, exports: ["search"], switch: "one" }); assert.ok(Object.isFrozen(KINDS) && Object.isFrozen(KINDS["usage-source"]) && Object.isFrozen(KINDS["limit-check"].exports)); diff --git a/packages/plugins/tests/loader.test.mjs b/packages/plugins/tests/loader.test.mjs index 2cd0068ed..32e6e37cc 100644 --- a/packages/plugins/tests/loader.test.mjs +++ b/packages/plugins/tests/loader.test.mjs @@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process"; import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { LIMIT, USAGE, tree } from "./helpers.mjs"; +import { LIMIT, LIMIT_V1, USAGE, tree } from "./helpers.mjs"; import { businessDoc, rolesCopy, tmp as businessRoot, writeJson } from "../../business/tests/helpers.mjs"; import { validateBusinessDocument } from "../../business/src/business.mjs"; @@ -192,10 +192,11 @@ test("REQ-PLUG-1 a missing export refuses and keeps the old setting", async t => await refusesKeepingOld(s, ["no-version"], "missing-export", "no-version", /doesn't export interfaceVersion/); s.write("usage-source", "not-fn", "export const interfaceVersion = 1;\nexport const readTurn = 1;\n"); await refusesKeepingOld(s, ["not-fn"], "missing-export", "not-fn", /readTurn isn't a function/); - // limit-check needs both of its exports. - s.write("limit-check", "half", "export const interfaceVersion = 1;\nexport function mayLaunch() {}\n"); + // limit-check needs all three of its exports; report is version 2's (row 125). + s.write("limit-check", "half", "export const interfaceVersion = 2;\nexport function mayLaunch() {}\nexport function mustStop() {}\n"); const result = await s.loader.load({ "usage-source": ["meter"], "limit-check": ["cap", "half"] }); assert.deepEqual(result.refused.map(e => [e.kind, e.name, e.code]), [["limit-check", "half", "missing-export"]]); + assert.match(result.refused[0].message, /doesn't export report/); assert.deepEqual(s.loader.loaded(), GOOD_LOADED); }); @@ -207,6 +208,23 @@ test("REQ-PLUG-1 a different interface version refuses and keeps the old setting await refusesKeepingOld(s, ["v-string"], "interface-version", "v-string"); }); +test("REQ-PLUG-1 REQ-LIMIT-3 a version 1 limit check refuses after the bump to 2, and the old setting stays", async t => { + const s = await loadedTree(t); + // A module written for version 1 lacks report; exports are checked before the version. + s.write("limit-check", "old", LIMIT_V1("old")); + // One that adds report but still says 1 refuses on the version. + s.write("limit-check", "old-plus", `${LIMIT_V1("old-plus")}export function report() { return []; }\n`); + for (const [name, code, message] of [["old", "missing-export", /doesn't export report/], + ["old-plus", "interface-version", /implements interface 1, not 2/]]) { + const result = await s.loader.load({ "usage-source": ["meter"], "limit-check": [name] }); + assert.deepEqual(result.refused.map(e => [e.kind, e.name, e.code]), [["limit-check", name, code]]); + assert.match(result.refused[0].message, message); + assert.deepEqual(result.refused[0].kept, ["cap"]); + assert.deepEqual(s.loader.loaded(), GOOD_LOADED); + assert.equal(s.loader.modules("limit-check")[0].mayLaunch(), "cap"); + } +}); + test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and the log names kind and name", async t => { const s = tree(t); s.write("usage-source", "meter", USAGE("meter")); diff --git a/packages/plugins/tests/token-limit.test.mjs b/packages/plugins/tests/token-limit.test.mjs new file mode 100644 index 000000000..451a29a54 --- /dev/null +++ b/packages/plugins/tests/token-limit.test.mjs @@ -0,0 +1,143 @@ +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check on its own. It is pure, so +// these tests pass it limits and records and read its answers; no loader, store or file. +import test from "node:test"; +import assert from "node:assert/strict"; +import * as check from "../limit-check/token-limit/index.mjs"; + +const SINCE = "2026-10-18T00:00:00.000Z"; +const NOW = "2026-10-18T12:00:00.000Z"; +const subject = (over = {}) => ({ instance: "coder", provider: "anthropic", model: "opus", account: null, ...over }); +let seq = 0; +const known = (over = {}, counts = [10, 20, 30, 40]) => { + const [input, output, cacheWrite, cacheRead] = counts; + return { at: "2026-10-18T06:00:00.000Z", run: "r1", turn: `t${++seq}`, source: "pi-session", instance: "coder", + provider: "anthropic", model: "opus", account: null, status: "known", input, output, cacheWrite, cacheRead, ...over }; +}; +const unknown = (over = {}) => ({ ...known(over), status: "unknown", input: null, output: null, cacheWrite: null, cacheRead: null }); +const limits = tokens => ({ tokens }); +const args = (tokens, records, over = {}) => ({ business: "demo", limits: limits(tokens), subject: subject(), records, now: NOW, ...over }); + +test("REQ-LIMIT-3 token-limit exports interface version 2 with mayLaunch, mustStop and report", () => { + assert.equal(check.interfaceVersion, 2); + for (const name of ["mayLaunch", "mustStop", "report"]) assert.equal(typeof check[name], "function"); +}); + +test("REQ-LIMIT-3 each subject kind refuses at its limit and allows under it", () => { + const records = [known(), known()]; // 100 tokens each, 200 in all + const cases = [ + { tokens: { instances: { coder: { limit: 200, since: SINCE } } }, path: ["tokens", "instances", "coder"], subject: {} }, + { tokens: { providers: { anthropic: { limit: 200, since: SINCE } } }, path: ["tokens", "providers", "anthropic"], subject: {} }, + { tokens: { models: { anthropic: { opus: { limit: 200, since: SINCE } } } }, path: ["tokens", "models", "anthropic", "opus"], subject: {} }, + { tokens: { keys: { k1: { limit: 200, since: SINCE } } }, path: ["tokens", "keys", "k1"], subject: { account: "k1" }, + records: records.map(r => ({ ...r, account: "k1" })) }, + ]; + for (const c of cases) { + const recs = c.records ?? records; + const at = args(c.tokens, recs, { subject: subject(c.subject) }); + const hit = { path: c.path, limit: 200, count: 200, unknownTurns: 0 }; + assert.deepEqual(check.mayLaunch(at), { allow: false, hit }, c.path.join(".")); + assert.deepEqual(check.mustStop(at), { stop: true, hit }, c.path.join(".")); + const over = JSON.parse(JSON.stringify(c.tokens).replace('"limit":200', '"limit":201')); + assert.deepEqual(check.mayLaunch(args(over, recs, { subject: subject(c.subject) })), { allow: true }, c.path.join(".")); + assert.deepEqual(check.mustStop(args(over, recs, { subject: subject(c.subject) })), { stop: false }, c.path.join(".")); + } +}); + +test("REQ-LIMIT-3 a limit covers only its own subject's records and launches", () => { + const tokens = { instances: { coder: { limit: 150, since: SINCE } }, models: { anthropic: { opus: { limit: 150, since: SINCE } } } }; + const records = [known({ instance: "tester", model: "sonnet" }), known({ instance: "tester", model: "sonnet" })]; + // Another instance's and another model's records count toward neither limit. + assert.deepEqual(check.mayLaunch(args(tokens, records)), { allow: true }); + // A launch of another subject isn't covered by a limit that is over. + const full = [known(), known()]; + assert.deepEqual(check.mayLaunch(args(tokens, full, { subject: subject({ instance: "tester", model: "sonnet" }) })), { allow: true }); + // A model limit is per provider: the same model name under another provider doesn't match it. + assert.deepEqual(check.mayLaunch(args({ models: { anthropic: { opus: { limit: 150, since: SINCE } } } }, + full.map(r => ({ ...r, provider: "other" })), { subject: subject({ provider: "other" }) })), { allow: true }); + // A provider limit counts only that provider's records and covers only its launches. + const byProvider = { providers: { anthropic: { limit: 150, since: SINCE } } }; + const other = full.map(r => ({ ...r, provider: "other" })); + assert.deepEqual(check.mayLaunch(args(byProvider, other)), { allow: true }); + assert.equal(check.report(args(byProvider, other))[0].count, 0); + assert.deepEqual(check.mayLaunch(args(byProvider, full, { subject: subject({ provider: "other" }) })), { allow: true }); + assert.equal(check.mayLaunch(args(byProvider, full)).allow, false); +}); + +test("REQ-LIMIT-4 only known records count; an unknown turn adds nothing and is counted apart", () => { + const tokens = { instances: { coder: { limit: 100, since: SINCE } } }; + const u = unknown({ run: "r2", turn: "u1" }); + const records = [known({}, [1, 2, 3, 4]), u, { ...u, source: "other-source" }, unknown({ run: "r2", turn: "u2" })]; + assert.deepEqual(check.mayLaunch(args(tokens, records)), { allow: true }); + const [entry] = check.report(args(tokens, records)); + assert.deepEqual(entry, { path: ["tokens", "instances", "coder"], limit: 100, since: SINCE, count: 10, + kinds: { input: 1, output: 2, cacheWrite: 3, cacheRead: 4 }, unknownTurns: 2 }); + // With no usage source, every turn is the '@none' unknown record: the count is 0. + const none = [unknown({ source: "@none" }), unknown({ source: "@none" })]; + const [empty] = check.report(args(tokens, none)); + assert.equal(empty.count, 0); + assert.equal(empty.unknownTurns, 2); + assert.deepEqual(check.mayLaunch(args(tokens, none)), { allow: true }); +}); + +test("REQ-LIMIT-3 a reset moves since: records before it no longer count", () => { + const tokens = { instances: { coder: { limit: 100, since: "2026-10-18T07:00:00.000Z" } } }; + const records = [known({ at: "2026-10-18T06:59:59.999Z" }), known({ at: "2026-10-18T07:00:00.000Z" }, [1, 1, 1, 1])]; + assert.deepEqual(check.mayLaunch(args(tokens, records)), { allow: true }); + assert.equal(check.report(args(tokens, records))[0].count, 4); + const before = { instances: { coder: { limit: 100, since: SINCE } } }; + assert.equal(check.mayLaunch(args(before, records)).allow, false); +}); + +test("REQ-LIMIT-3 limit 0 refuses with no records; the first limit hit in file order is named", () => { + assert.deepEqual(check.mayLaunch(args({ instances: { coder: { limit: 0, since: SINCE } } }, [])), + { allow: false, hit: { path: ["tokens", "instances", "coder"], limit: 0, count: 0, unknownTurns: 0 } }); + const tokens = { instances: { coder: { limit: 50, since: SINCE } }, providers: { anthropic: { limit: 50, since: SINCE } } }; + assert.deepEqual(check.mustStop(args(tokens, [known()])).hit.path, ["tokens", "instances", "coder"]); +}); + +test("REQ-LIMIT-3 a key limit is not enforced while every record's account is null (decision 150 Q1)", () => { + const tokens = { keys: { k1: { limit: 1, since: SINCE } }, instances: { coder: { limit: 1000, since: SINCE } } }; + const report = check.report(args(tokens, [known()])); + assert.deepEqual(report, [ + { path: ["tokens", "instances", "coder"], limit: 1000, since: SINCE, count: 100, + kinds: { input: 10, output: 20, cacheWrite: 30, cacheRead: 40 }, unknownTurns: 0 }, + { path: ["tokens", "keys", "k1"], limit: 1, since: SINCE, notEnforced: "no records name a key" }, + ]); + // A launch whose subject has no account isn't covered by a key limit. + assert.deepEqual(check.mayLaunch(args(tokens, [known()])), { allow: true }); + // Once a record names an account, the key limit is counted. + const named = check.report(args(tokens, [known({ account: "k1" })])); + assert.equal(named[1].count, 100); +}); + +test("REQ-LIMIT-3 counts past the safe integer range come back as digit strings", () => { + const big = Number.MAX_SAFE_INTEGER; + const tokens = { instances: { coder: { limit: 1, since: SINCE } } }; + const [entry] = check.report(args(tokens, [known({}, [big, big, 0, 0])])); + assert.equal(entry.count, (2n * BigInt(big)).toString()); + assert.equal(entry.kinds.input, big); + assert.equal(check.mustStop(args(tokens, [known({}, [big, big, 0, 0])])).hit.count, (2n * BigInt(big)).toString()); +}); + +test("REQ-LIMIT-4 bad records or times throw a code, and the check reads nothing but its arguments", () => { + const tokens = { instances: { coder: { limit: 1, since: SINCE } } }; + for (const fn of ["mayLaunch", "mustStop", "report"]) { + assert.throws(() => check[fn](args(tokens, null)), { code: "bad-records" }, fn); + assert.throws(() => check[fn](args(tokens, [known({ at: "yesterday" })])), { code: "bad-time" }, fn); + assert.throws(() => check[fn](args(tokens, [known({}, [-1, 0, 0, 0])])), { code: "bad-record" }, fn); + assert.throws(() => check[fn](args({ instances: { coder: { limit: 1, since: "never" } } }, [known()])), { code: "bad-time" }, fn); + } + const input = args(tokens, [known()]); + const copy = structuredClone(input); + check.mayLaunch(input); check.mustStop(input); check.report(input); + assert.deepEqual(input, copy); +}); + +test("REQ-LIMIT-3 no token limits: every launch is allowed and the report is empty", () => { + for (const l of [{}, { tokens: {} }, { agents: 3 }]) { + const a = { business: "demo", limits: l, subject: subject(), records: [known()], now: NOW }; + assert.deepEqual(check.mayLaunch(a), { allow: true }); + assert.deepEqual(check.mustStop(a), { stop: false }); + assert.deepEqual(check.report(a), []); + } +}); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 4a2d0c5fe..58e8800bb 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -575,6 +575,8 @@ } function settingDraft(form){const values={};for(const el of form.querySelectorAll('[data-setting]'))values[el.dataset.setting]=el.value;return values;} function settingPayload(){const v=settingsSnapshot, limits={providers:Object.create(null),models:Object.create(null)},models=structuredClone(v.models),instances={};for(const [encoded,text]of Object.entries(settingsInputs)){const [kind,p,m]=JSON.parse(encoded);if(kind==='table'){models[p][m]=text;continue;}if(kind==='instances'){instances[p]=text;continue;}if(text==='')continue;if(!/^\d+$/.test(text)||!Number.isSafeInteger(Number(text)))throw Error('Limits need whole numbers of zero or more.');const n=Number(text);if(kind==='total')limits.total=n;else if(kind==='providers')limits.providers[p]=n;else {limits.models[p]??=Object.create(null);limits.models[p][m]=n;}} + // Row 125 (#1619): the form doesn't edit token limits; a save keeps them as read. + if(v.limits?.tokens)limits.tokens=structuredClone(v.limits.tokens); return {revision:v.revision,limits,models,instances}; } document.addEventListener('input',e=>{const form=e.target.closest?.('#launch-settings-form');if(form)settingsInputs=settingDraft(form);}); @@ -585,6 +587,28 @@ try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}} catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);} }); + // Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): each token limit as the bus's checks report it. A + // limit no switched-on check counts reads "not enforced". Reset counts it again from now. + let resetReceipt=''; + function tokenLimits(rows, refused) { + const title = '

Token limits

'; + if (refused) return `
${title}

Token limits unavailable. No report was read.

`; + if (!rows.length) return `
${title}

No token limits are set.

`; + const can = canSend && !out() && !settingsSaving && /^[a-f0-9]{64}$/.test(settingsSnapshot?.revision ?? ''); + const state = t => t.status === 'counted' + ? `${txt(t.count)} (input ${txt(t.kinds?.input)}, output ${txt(t.kinds?.output)}, cache write ${txt(t.kinds?.cacheWrite)}, cache read ${txt(t.kinds?.cacheRead)}); unknown turns ${txt(t.unknownTurns)}` + : t.status === 'not-enforced' ? `not enforced${t.reason ? `: ${txt(t.reason)}` : ''}` + : `check failed: ${txt(t.code)}${t.check ? ` (${txt(t.check)})` : ''}`; + const body = rows.map(t => `${txt((t.path || []).join(' / '))}${txt(t.limit)}${txt(t.since)}${state(t)}`).join(''); + return `
${title}

A count is every token kind since the limit was set or reset. Unknown turns add nothing to it.

${body}
SubjectLimitSinceCountReset

${canSend ? 'A reset is logged with your identity.' : 'Resetting requires signed-in send mode.'}

${txt(resetReceipt)}

`; + } + document.addEventListener('click',async e=>{ + const path=e.target.dataset?.limitReset;if(path===undefined||!canSend||out()||settingsSaving)return; + const revision=settingsSnapshot?.revision;if(!/^[a-f0-9]{64}$/.test(revision??''))return; + settingsSaving=true;resetReceipt='';render(false);let reread=false; + try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){if(!settingsInputs)settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}} + catch{resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{settingsSaving=false;if(!out())render(reread);} + }); function tokenUsage(value) { if (!value?.total) return '

Token usage

Usage unavailable. No count was read.

'; const columns = row => ['input', 'output', 'cacheWrite', 'cacheRead', 'unknownTurns'].map(key => `${txt(row[key])}`).join(''); @@ -601,10 +625,12 @@ const s = await get.api('settings'), n = s.notifier || {}; let usage; try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } + let limitRows = [], limitsRefused = false; + try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } const notify = typeof n.binding === 'string' ? `
Binding
${txt(n.binding)}

The binding name only. Its channels, server and user ids stay in the binding file.

` : empty('No notifier binding to show.', `${n.refused ? `${txt(n.refused)}: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`); - return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${tokenUsage(usage)} + return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}

Notifications

${notify}

About

Address
${txt(s.address) || 'unknown'}
Release
${s.release ? `${txt(s.release)}` : 'unknown: no RELEASE file was read'}
Business
${txt(s.business) || none}
Board
${txt(s.board) || 'unknown'}
`; } diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 7c5e74a3c..53d33391f 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -48,8 +48,9 @@ async function body(req, max = 4096) { // Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4). // Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60 // (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81 -// (#1575) adds `replies`: the messages answering one message, for the send receipt. -const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage']; +// (#1575) adds `replies`: the messages answering one message, for the send receipt. Row 125 +// (#1619, REQ-LIMIT-3) adds `limits`: only the token limits' report, never the document. +const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits']; const DECISION_LISTS = ['roles', 'recent']; async function busRead(res, bus, verb, search) { let arg; @@ -65,6 +66,18 @@ async function busRead(res, bus, verb, search) { return json(res, busStatus(code), { error: code, message: code }); } } + if (verb === 'limits') { + if (search) return json(res, 400, { error: 'invalid-request', message: 'limits takes no arguments' }); + if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' }); + try { + const value = await bus.limits(); + if (!value || !Array.isArray(value.tokens)) throw new BusReadError('invalid-response'); + return json(res, 200, { rows: value.tokens, at: new Date().toISOString() }); + } catch (err) { + const code = err instanceof BusReadError ? err.code : 'invalid-response'; + return json(res, busStatus(code), { error: code, message: code }); + } + } if (verb === 'trail') { arg = new URLSearchParams(search).get('subject'); if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' }); @@ -182,11 +195,12 @@ async function settingsSend(req, res, { settingsWrite, preview, log }) { } catch { return refuse(400, 'invalid-request', 'settings need a JSON object within 16 KiB'); } - const writable = ['limits', 'models', 'instances']; + // Row 125 (#1619, REQ-LIMIT-3): `reset` names token limit paths to count again from now. + const writable = ['limits', 'models', 'instances', 'reset']; if (Object.keys(value).some(key => !['revision', ...writable].includes(key)) || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) || !writable.some(key => Object.hasOwn(value, key))) { - return refuse(400, 'invalid-request', 'settings need a revision and limits, models or instance selections'); + return refuse(400, 'invalid-request', 'settings need a revision and limits, models, instance selections or a reset'); } try { const result = await settingsWrite(value); diff --git a/packages/webui/tests/limits-browser.test.mjs b/packages/webui/tests/limits-browser.test.mjs new file mode 100644 index 000000000..c34152bba --- /dev/null +++ b/packages/webui/tests/limits-browser.test.mjs @@ -0,0 +1,63 @@ +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): Settings shows each token limit as the bus's checks +// report it. A limit no switched-on check counts reads "not enforced"; Reset posts the limit's +// path alone; a form save keeps the token limits as read. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {browser} from './browser.mjs'; +import {startServer} from '../src/serve.mjs'; +import {close} from './fixture.mjs'; + +const SINCE='2026-10-18T07:00:00.000Z'; +const hostile=''; +const tokens={instances:{coder:{limit:100,since:SINCE}},keys:{k1:{limit:5,since:SINCE}},providers:{anthropic:{limit:9,since:SINCE}},models:{anthropic:{'claude-opus-5-5':{limit:7,since:SINCE}}}}; +const launch={business:'acme',revision:'a'.repeat(64),models:{opus:{provider:'anthropic',model:'claude-opus-5-5'}},limits:{total:4,tokens},instances:{coder:{modelKey:'opus'}},usage:{total:0,providers:{},models:{}}}; +const TOKENS=[ + {path:['tokens','instances','coder'],limit:100,since:SINCE,status:'counted',check:'token-limit',count:120,kinds:{input:10,output:20,cacheWrite:40,cacheRead:50},unknownTurns:2}, + {path:['tokens','keys','k1'],limit:5,since:SINCE,status:'not-enforced',reason:'no records name a key'}, + {path:['tokens','providers','anthropic'],limit:9,since:SINCE,status:'not-enforced',reason:null}, + {path:['tokens','models','anthropic','claude-opus-5-5'],limit:7,since:SINCE,status:'check-failed',check:hostile,code:'check-threw'}, +]; + +test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced and check failed; Reset posts one path; a save keeps the token limits',{timeout:90000},async()=>{ + let answer={revision:launch.revision,limits:launch.limits,tokens:TOKENS}; + const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},limits:async()=>answer}; + const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch})}}),b=await browser(); + try{ + await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.fixture=${JSON.stringify(launch)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify({...fixture,revision:'b'.repeat(64)}));}return original(url,options);};`}); + const port=web.address().port; + const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`); + const cells=()=>b.evaluate("[...document.querySelectorAll('#token-limits-table tbody tr')].map(row=>[...row.querySelectorAll('th,td')].map(c=>c.textContent))"); + // Read-only: every status as its words, and Reset disabled. + await b.navigate(`http://127.0.0.1:${port}/#/settings`); + await wait('!!document.querySelector("#token-limits-table")'); + assert.deepEqual(await cells(),[ + ['tokens / instances / coder','100',SINCE,'120 (input 10, output 20, cache write 40, cache read 50); unknown turns 2','Reset'], + ['tokens / keys / k1','5',SINCE,'not enforced: no records name a key','Reset'], + ['tokens / providers / anthropic','9',SINCE,'not enforced','Reset'], + ['tokens / models / anthropic / claude-opus-5-5','7',SINCE,`check failed: check-threw (${hostile})`,'Reset'], + ]); + assert.equal(await b.evaluate('[...document.querySelectorAll("[data-limit-reset]")].every(e=>e.disabled)'),true); + assert.equal(await b.evaluate('window.injected===true'),false); + // Signed-in send mode: Reset posts the revision and that one path, and nothing else. A draft + // typed before the reset stays, and so does the revision it was typed against. + await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings`); + await wait('document.querySelector("[data-limit-reset]")?.disabled===false'); + await b.evaluate(`(()=>{const e=[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(['total']))});e.value='3';e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`); + await b.evaluate('document.querySelectorAll("[data-limit-reset]")[1].click();true'); + await wait('document.querySelector("#token-limits-receipt")?.textContent.includes("Limit reset.")'); + assert.deepEqual(await b.evaluate('posts'),[{revision:'a'.repeat(64),reset:[['tokens','keys','k1']]}]); + // A form save sends the token limits as read, so it never drops or edits them. + assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-setting]')].find(e=>e.dataset.setting===${JSON.stringify(JSON.stringify(['total']))}).value`),'3'); + await b.evaluate('document.querySelector("#launch-settings-form").requestSubmit();true'); + await wait('posts.length===2'); + const [,save]=await b.evaluate('posts'); + assert.equal(save.revision,'a'.repeat(64)); + assert.equal(save.limits.total,3); + assert.deepEqual(save.limits.tokens,tokens); + // A limits answer the server refuses shows no rows and no count. + answer={revision:launch.revision,limits:launch.limits}; + await b.navigate(`http://127.0.0.1:${port}/#/settings`); + await wait('document.querySelector("#token-limits-title")?.parentElement.textContent.includes("Token limits unavailable")'); + assert.equal(await b.evaluate('document.querySelectorAll("#token-limits-table").length'),0); + }finally{await b.close();await close(web);} +}); diff --git a/packages/webui/tests/limits-route.test.mjs b/packages/webui/tests/limits-route.test.mjs new file mode 100644 index 000000000..ffbaf1045 --- /dev/null +++ b/packages/webui/tests/limits-route.test.mjs @@ -0,0 +1,69 @@ +// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the Console's token limit read and the Settings reset. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {request} from 'node:http'; +import {startServer} from '../src/serve.mjs'; +import {close} from './fixture.mjs'; +import {views} from '../../bus/src/views.mjs'; + +const SINCE = '2026-10-18T07:00:00.000Z'; +const TOKENS = [ + { path: ['tokens', 'instances', 'coder'], limit: 100, since: SINCE, status: 'counted', check: 'token-limit', count: 120, + kinds: { input: 10, output: 20, cacheWrite: 40, cacheRead: 50 }, unknownTurns: 2 }, + { path: ['tokens', 'keys', 'k1'], limit: 5, since: SINCE, status: 'not-enforced', reason: 'no records name a key' }, + { path: ['tokens', 'providers', 'zai'], limit: 9, since: SINCE, status: 'not-enforced', reason: null }, +]; + +test('REQ-LIMIT-3 limits read returns only the token rows; no query or write is accepted', async t => { + let calls = 0; + const server = await startServer({ port: 0, bus: { limits: async () => { calls++; return { revision: 'a'.repeat(64), limits: { total: 3 }, tokens: TOKENS }; } } }); + t.after(() => close(server)); + const url = `http://127.0.0.1:${server.address().port}/api/bus/limits`; + const response = await fetch(url), body = await response.json(); + assert.equal(response.status, 200); + assert.deepEqual(Object.keys(body).sort(), ['at', 'rows']); + assert.deepEqual(body.rows, TOKENS); + assert.equal((await fetch(url + '?business=other')).status, 400); + assert.equal((await fetch(url, { method: 'POST' })).status, 405); + assert.equal(calls, 1); +}); + +test('REQ-LIMIT-3 a limits answer without a token list is refused, not shown as no limits', async t => { + for (const answer of [{}, { tokens: null }, null]) { + const server = await startServer({ port: 0, bus: { limits: async () => answer } }); + t.after(() => close(server)); + assert.equal((await fetch(`http://127.0.0.1:${server.address().port}/api/bus/limits`)).status, 502, JSON.stringify(answer)); + } +}); + +const preview = { host: 'console.example', header: 'x-user', user: 'jason' }; +const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; +function post(port, data) { + return new Promise((resolve, reject) => { + const req = request({ hostname: '127.0.0.1', port, path: '/api/settings', method: 'POST', headers }, r => { + let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, text })); + }); + req.on('error', reject); + req.end(JSON.stringify(data)); + }); +} + +test('REQ-LIMIT-3 a Settings reset reaches the human write with its paths; a reset alone is enough', async t => { + const calls = []; + const server = await startServer({ port: 0, preview, send: async () => ({}), settingsWrite: async args => { calls.push(args); return { ...args, business: 'acme' }; } }); + t.after(() => new Promise(r => server.close(r))); + const port = server.address().port; + const reset = { revision: 'a'.repeat(64), reset: [['tokens', 'instances', 'coder']] }; + const r = await post(port, reset); + assert.equal(r.status, 200, r.text); + assert.deepEqual(calls, [reset]); + assert.equal((await post(port, { revision: 'a'.repeat(64), resets: [] })).status, 400); + assert.equal(calls.length, 1); +}); + +test('REQ-LIMIT-3 the Console reader asks the bus limits verb with no arguments', async () => { + const calls = []; + const v = views({ call: async (...args) => (calls.push(args), { tokens: [] }) }); + assert.deepEqual(await v.limits(), { tokens: [] }); + assert.deepEqual(calls, [['limits']]); +}); diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs index 8ff13bca7..4e1c0fcc2 100644 --- a/packages/webui/tests/shell.test.mjs +++ b/packages/webui/tests/shell.test.mjs @@ -50,12 +50,13 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () = ['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'], ['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'], ]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`); - // No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has three explicit writes: - // row 76's message to a role, row 122's human Settings update and row 148's spec and docs save. + // No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has four explicit writes: + // row 76's message to a role, row 122's human Settings update, row 125's token-limit reset (one path, + // to the same Settings route) and row 148's spec and docs save. assert.doesNotMatch(src, /]*>[^<]*(Resolve|Decide|Approve)/i); const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? []; - assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]); - assert.equal(read('bus.js').toString().split(/method:/).length - 1, 3); + assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]); + assert.equal(read('bus.js').toString().split(/method:/).length - 1, 4); assert.equal(read('icons.svg').toString().includes('