bus, cli, webui: token limits per instance, model, provider and key (row 125, #1619)

Adds token limits under launch.limits.tokens (instances, providers, keys,
models.P.M). Core (packages/bus/src/limits.mjs) reads the organization
file and the per-turn usage records row 124 writes, then asks every
switched-on limit-check plug-in; the first refusal wins. A limit no
switched-on check reports reads "not enforced". The limit-check kind is
interface 2 (mayLaunch, mustStop, report); a v1 check is refused at load.
The shipped token-limit check is pure and counts known end records only;
unknown turns add nothing and are shown as their own count.

The launcher refuses limit-reached through the host's mayLaunch op, or
limit-check-failed naming the check. The broker asks mustStop after a
usage end and stops the run at its next receive or state. Settings stamps
since on a new or changed limit and runs reset; launch.mjs and
business.mjs validate the shape. Readers: mosaic limits and mosaic limits
reset, the bus limits view, the Console /api/bus/limits route and the
Settings token table with Reset. Key limits stay "not enforced" until
row 126 names accounts on records.

Owner Dewey, design under lead decision 150.
Reviewer Filbert: r1 changes (comment 28965), r2 approve (28981), r3
approve (28996) on candidate manifest 8b71c405...fe56 after the rebase
onto row 164 (37 of 38 paths with identical +/- lines, TOOLS.md:621
bracket). Decision 123 go on the bus.js hunk from Mos (mos-0814). Sage
gate on b6827d19e: 25 steps, all rc 0 (15 packages, 10 script suites), 2295 node tests and 422 script selftests, 0 failed, 20:03:25Z to 20:08:08Z, network sage-r125_default removed.

Live stays as pinned. Not part of the 8e7ea89a1 window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 15:21:49 -05:00
co-authored by Claude Opus 5.5
parent b6827d19ea
commit aa625565f6
38 changed files with 1976 additions and 83 deletions
+11
View File
@@ -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
+59 -10
View File
@@ -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,
);
+189
View File
@@ -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 };
}
+4 -1
View File
@@ -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' }));
}
+20 -1
View File
@@ -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,
+22 -9
View File
@@ -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 } })) {
+1
View File
@@ -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'),
+326
View File
@@ -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);
}
});
+151
View File
@@ -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' }), []);
});
@@ -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');
});
+18
View File
@@ -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 }));
+1
View File
@@ -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`);
+72 -5
View File
@@ -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 };
}
+33 -1
View File
@@ -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;
}
@@ -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);
});
+31
View File
@@ -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.
+29 -12
View File
@@ -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}:{}) });
+35 -8
View File
@@ -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);
@@ -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')<ops.findIndex(x=>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*\(/);
});
+1 -1
View File
@@ -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);};
+56
View File
@@ -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,[]);
});
+51 -2
View File
@@ -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);
});
+5 -3
View File
@@ -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
+36 -2
View File
@@ -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);
});
+5 -1
View File
@@ -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.
+70 -10
View File
@@ -17,17 +17,19 @@ 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` |
| `channel` | 1 | list | `interfaceVersion`, `dispatch` |
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.
@@ -121,17 +123,75 @@ 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: <check> <path> limit <n> count <c>`;
`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: <code>`. 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`.
## Human project/thread channels
@@ -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.<provider>.<model>`) 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).
@@ -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 };
});
}
+8 -2
View File
@@ -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
+7 -1
View File
@@ -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; }
`;
+2 -2
View File
@@ -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", "channel"]);
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));
+21 -3
View File
@@ -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"));
+143
View File
@@ -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), []);
}
});
+27 -1
View File
@@ -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 = '<h2 id="token-limits-title">Token limits</h2>';
if (refused) return `<section class="panel" aria-labelledby="token-limits-title">${title}<p>Token limits unavailable. No report was read.</p></section>`;
if (!rows.length) return `<section class="panel" aria-labelledby="token-limits-title">${title}<p>No token limits are set.</p></section>`;
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 => `<tr><th scope="row">${txt((t.path || []).join(' / '))}</th><td>${txt(t.limit)}</td><td>${txt(t.since)}</td><td>${state(t)}</td><td><button type="button" data-limit-reset="${esc(JSON.stringify(t.path || []))}"${can ? '' : ' disabled'}>Reset</button></td></tr>`).join('');
return `<section class="panel" aria-labelledby="token-limits-title">${title}<p>A count is every token kind since the limit was set or reset. Unknown turns add nothing to it.</p><div class="table-wrap"><table id="token-limits-table"><thead><tr><th scope="col">Subject</th><th scope="col">Limit</th><th scope="col">Since</th><th scope="col">Count</th><th scope="col"><span class="sr-only">Reset</span></th></tr></thead><tbody>${body}</tbody></table></div><p class="small muted">${canSend ? 'A reset is logged with your identity.' : 'Resetting requires signed-in send mode.'}</p><p role="status" id="token-limits-receipt">${txt(resetReceipt)}</p></section>`;
}
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 '<section class="panel" aria-labelledby="token-usage-title"><h2 id="token-usage-title">Token usage</h2><p>Usage unavailable. No count was read.</p></section>';
const columns = row => ['input', 'output', 'cacheWrite', 'cacheRead', 'unknownTurns'].map(key => `<td>${txt(row[key])}</td>`).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'
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${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)}
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
}
+18 -4
View File
@@ -49,8 +49,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;
@@ -66,6 +67,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' });
@@ -183,11 +196,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);
@@ -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='<img src=x onerror="window.injected=true">';
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);}
});
@@ -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']]);
});
+5 -4
View File
@@ -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, /<button[^>]*>[^<]*(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('<script'), false);
});