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 onb6827d19e: 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 the8e7ea89a1window. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' }));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } })) {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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}:{}) });
|
||||
|
||||
@@ -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*\(/);
|
||||
});
|
||||
@@ -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);};
|
||||
|
||||
@@ -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,[]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
`;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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), []);
|
||||
}
|
||||
});
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user