@staix/agent-hub 0.12.2 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/docs/cooperbench.md +76 -0
- package/docs/events.md +5 -3
- package/docs/smoke.md +16 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +37 -0
- package/docs/verification/2026-10-02-0.12.3.md +44 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +21 -7
- package/src/adapters/claude-channel.ts +17 -4
- package/src/adapters/local-worker.ts +109 -16
- package/src/adapters/pi.ts +106 -21
- package/src/cli/launch.ts +18 -1
- package/src/cli/main.ts +13 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/bus.ts +17 -3
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +77 -7
- package/src/hub/events.ts +3 -1
- package/src/hub/execution-budget.ts +138 -0
- package/src/hub/report.ts +85 -1
- package/src/hub/tasks.ts +22 -0
- package/src/hub/usage.ts +55 -0
- package/src/local/sandbox.ts +6 -2
- package/src/local/tools.ts +7 -2
- package/src/models/relay.ts +12 -4
- package/src/omniroute/client.ts +14 -3
- package/src/omniroute/usage.ts +38 -0
- package/src/pi/extension.ts +44 -8
package/src/hub/daemon.ts
CHANGED
|
@@ -6,7 +6,9 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import { childEnv } from "./child-process.ts";
|
|
7
7
|
import { runCheck } from "./checks.ts";
|
|
8
8
|
import { stripUntrusted } from "./config-trust.ts";
|
|
9
|
-
import { eventLog, tokenDeltas } from "./events.ts";
|
|
9
|
+
import { eventLog, readEvents, tokenDeltas } from "./events.ts";
|
|
10
|
+
import { ExecutionBudget } from "./execution-budget.ts";
|
|
11
|
+
import { readClaudeTranscriptUsage } from "./usage.ts";
|
|
10
12
|
import type { ServerWebSocket } from "bun";
|
|
11
13
|
import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
|
|
12
14
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
@@ -218,6 +220,8 @@ type Sock = ServerWebSocket<Client>;
|
|
|
218
220
|
|
|
219
221
|
/** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
|
|
220
222
|
class WsPeer extends BasePeer {
|
|
223
|
+
private generation = crypto.randomUUID();
|
|
224
|
+
private delivered = new Set<string>();
|
|
221
225
|
private sock: Sock | undefined;
|
|
222
226
|
private claimed: Sock | undefined;
|
|
223
227
|
/** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
|
|
@@ -231,8 +235,11 @@ class WsPeer extends BasePeer {
|
|
|
231
235
|
}
|
|
232
236
|
attach(sock: Sock): void {
|
|
233
237
|
if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
|
|
238
|
+
if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
|
|
234
239
|
this.sock?.close(4000, "replaced");
|
|
235
240
|
this.sock = sock;
|
|
241
|
+
this.generation = crypto.randomUUID();
|
|
242
|
+
this.delivered.clear();
|
|
236
243
|
this.setState("idle");
|
|
237
244
|
}
|
|
238
245
|
detach(sock: Sock): void {
|
|
@@ -242,9 +249,11 @@ class WsPeer extends BasePeer {
|
|
|
242
249
|
}
|
|
243
250
|
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
244
251
|
if (!this.sock) throw new Error(`${this.id} is offline`);
|
|
245
|
-
this.
|
|
252
|
+
if (deliveryId) this.delivered.add(deliveryId);
|
|
253
|
+
this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
|
|
246
254
|
}
|
|
247
255
|
owns(sock: Sock): boolean { return this.sock === sock; }
|
|
256
|
+
ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
|
|
248
257
|
async start(): Promise<void> {}
|
|
249
258
|
async stop(): Promise<void> {
|
|
250
259
|
this.sock?.close();
|
|
@@ -441,6 +450,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
441
450
|
};
|
|
442
451
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
443
452
|
startupCleanup.push(() => board.close());
|
|
453
|
+
const executionBudget = new ExecutionBudget(join(opts.stateDir, "hub.db"));
|
|
454
|
+
startupCleanup.push(() => executionBudget.close());
|
|
444
455
|
// Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
|
|
445
456
|
const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => (CLASSES as readonly string[]).includes(k) && typeof v === "string" && v.trim())) as Record<string, string>;
|
|
446
457
|
const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && !(k in checkCommands));
|
|
@@ -456,6 +467,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
456
467
|
const tasks = new Tasks({
|
|
457
468
|
board,
|
|
458
469
|
bus,
|
|
470
|
+
executionBudget,
|
|
459
471
|
routing: () => currentRouting(opts.cwd, log), // routing.toml is edited while the hub runs: re-read on change, last good parse kept
|
|
460
472
|
cwd: opts.cwd,
|
|
461
473
|
project: chain.at(-1)!,
|
|
@@ -807,6 +819,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
807
819
|
const config = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
808
820
|
return existsSync(join(config, "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`));
|
|
809
821
|
};
|
|
822
|
+
// Optional native usage: only the instance-fenced explicit Claude session is read.
|
|
823
|
+
// IDs persist in telemetry, so a restart/resume never counts the same native message twice.
|
|
824
|
+
const claudeNativeUsageSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).filter(e => e.type === "usage" && e.source === "claude_transcript").map(e => (e as { id: string }).id));
|
|
825
|
+
const collectClaudeUsage = () => {
|
|
826
|
+
const session = claudeSession();
|
|
827
|
+
if (!session.sessionId || !session.transcriptPath) return;
|
|
828
|
+
for (const record of readClaudeTranscriptUsage(session.sessionId, session.transcriptPath)) {
|
|
829
|
+
if (claudeNativeUsageSeen.has(record.id)) continue;
|
|
830
|
+
claudeNativeUsageSeen.add(record.id);
|
|
831
|
+
event({ type: "usage", peer: "claude", source: "claude_transcript", id: record.id, ...record.usage, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
|
|
835
|
+
claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
|
|
810
836
|
const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
|
|
811
837
|
const metadata = peer.recoveryMetadata?.() ?? {};
|
|
812
838
|
const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
|
|
@@ -855,7 +881,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
855
881
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
856
882
|
codexProxyPort: opts.codexProxyPort,
|
|
857
883
|
peers: Object.fromEntries(
|
|
858
|
-
bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); return [id, { state: bus.stateOf(id), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
|
|
884
|
+
bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); return [id, { state: bus.stateOf(id), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
|
|
859
885
|
),
|
|
860
886
|
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
861
887
|
...(modelRelay ? { models: modelRelay.status() } : {}),
|
|
@@ -1297,7 +1323,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1297
1323
|
const mode = args.mode ?? "headless";
|
|
1298
1324
|
const backend = args.backend ?? config.pi.backend;
|
|
1299
1325
|
if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
|
|
1300
|
-
modelRelay ??= await startModelRelay({ omni, dgxMaxInputTokens: currentRouting(opts.cwd, log).pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast" });
|
|
1326
|
+
modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens: currentRouting(opts.cwd, log).pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast" });
|
|
1301
1327
|
piReceipts ??= new PiToolReceipts(join(opts.stateDir, "hub.db"));
|
|
1302
1328
|
let piReply: Envelope | undefined;
|
|
1303
1329
|
const ctx: ToolContext = {
|
|
@@ -1316,14 +1342,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1316
1342
|
cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
|
|
1317
1343
|
model: args.model,
|
|
1318
1344
|
sessionId: args.sessionId, sessionFile: args.sessionFile,
|
|
1345
|
+
admitBudget: async (envs, unit) => unit === "model_calls" ? [] : tasks.admitExecutionEnvelopes(envs, "pi", unit),
|
|
1319
1346
|
relay: { url: modelRelay.url, token: modelRelay.token, models: modelRelay.models.map((id) => ({ id, contextWindow: id.startsWith("mlx/") ? Math.min(routing.pi.mlx_max_context_tokens, config.mlx.provider === "ollama" ? (config.mlx.contextWindow ?? 8192) : routing.pi.mlx_max_context_tokens) : routing.pi.dgx_max_context_tokens, maxTokens: id.startsWith("mlx/") ? (config.mlx.maxTokens ?? 2048) : 8192 })) },
|
|
1320
1347
|
tools: [...TOOL_SCHEMAS.map((t) => t.function), ...TASK_TOOLS.map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }))],
|
|
1321
|
-
executeTool: async (name, raw, callId, sessionId) => {
|
|
1348
|
+
executeTool: async (name, raw, callId, sessionId, signal) => {
|
|
1349
|
+
if (signal?.aborted) return "error: turn cancelled before tool effects";
|
|
1322
1350
|
if (stopping || (recoveryActive() && recoveryPhase !== "preparing")) return "error: recovery is holding tool effects";
|
|
1323
1351
|
return piReceipts!.execute(sessionId ?? "", callId, name, raw, async () => {
|
|
1324
1352
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
|
|
1325
1353
|
if (TASK_TOOLS.some((t) => t.name === name)) return taskOp("pi", name, raw as Record<string, unknown>, true);
|
|
1326
|
-
return runTool(name, JSON.stringify(raw), ctx
|
|
1354
|
+
return runTool(name, JSON.stringify(raw), { ...ctx, signal, permit: async title => {
|
|
1355
|
+
if (!signal) return ctx.permit(title);
|
|
1356
|
+
if (signal.aborted) return false;
|
|
1357
|
+
return new Promise<boolean>((resolve, reject) => {
|
|
1358
|
+
const finish = (allowed: boolean) => { signal.removeEventListener("abort", aborted); resolve(allowed && !signal.aborted); };
|
|
1359
|
+
const aborted = () => finish(false); signal.addEventListener("abort", aborted, { once: true });
|
|
1360
|
+
Promise.resolve(ctx.permit(title)).then(finish, error => { signal.removeEventListener("abort", aborted); reject(error); });
|
|
1361
|
+
});
|
|
1362
|
+
} });
|
|
1327
1363
|
});
|
|
1328
1364
|
},
|
|
1329
1365
|
selectModel: async (envs) => {
|
|
@@ -1401,6 +1437,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1401
1437
|
const local = new LocalPeer("local", {
|
|
1402
1438
|
cwd: opts.cwd,
|
|
1403
1439
|
omni,
|
|
1440
|
+
onUsage: record => event({ type: "usage", peer: "local", source: "omniroute", id: record.id, ...record.usage, requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
|
|
1441
|
+
admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
|
|
1404
1442
|
...(sidecar && route ? { sidecar, route } : {}),
|
|
1405
1443
|
fixedModel: args.model ?? routing.local.fixed_model,
|
|
1406
1444
|
tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, permit },
|
|
@@ -1425,6 +1463,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1425
1463
|
return { ok: false, error: `unknown peer "${peer}"` };
|
|
1426
1464
|
}
|
|
1427
1465
|
|
|
1466
|
+
async function admitPiRequest(): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
|
|
1467
|
+
const current = bus.peers.get("pi");
|
|
1468
|
+
if (!(current instanceof PiPeer)) return { allowed: false, reason: "Pi owner is not active" };
|
|
1469
|
+
const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, "pi", "model_calls");
|
|
1470
|
+
const denied = decisions.find(d => !d.allowed);
|
|
1471
|
+
const stop = denied ? current.recordBudgetStop(denied) : undefined;
|
|
1472
|
+
const deadline = decisions.filter(d => d.unit === "elapsed_ms" && d.remaining !== null).map(d => d.remaining!);
|
|
1473
|
+
return { allowed: !denied, ...(denied ? { reason: stop?.reason ?? `execution budget ${denied.reason}: ${denied.scope}` } : {}), ...(deadline.length ? { remainingMs: Math.min(...deadline) } : {}) };
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1428
1476
|
async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
|
|
1429
1477
|
const routing = currentRouting(opts.cwd, log);
|
|
1430
1478
|
if (route && !routing.routes[route]) throw new Error(`routing.toml has no route "${route}"`);
|
|
@@ -1729,10 +1777,30 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1729
1777
|
}
|
|
1730
1778
|
case "delivery_receipt": {
|
|
1731
1779
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
1732
|
-
if (c.role !== "peer" || !(peer instanceof WsPeer) ||
|
|
1780
|
+
if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
|
|
1733
1781
|
bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state === "needs_review" ? { reason: "Claude bridge could not confirm notification delivery" } : {}) });
|
|
1734
1782
|
return void reply({ ok: true });
|
|
1735
1783
|
}
|
|
1784
|
+
case "delivery_complete": {
|
|
1785
|
+
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
1786
|
+
if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
|
|
1787
|
+
return void reply(bus.completeDelivery(peer.id, msg.deliveryId) ? { ok: true } : { ok: false, error: "delivery is not live and accepted; inspect ahub queue show before resolving" });
|
|
1788
|
+
}
|
|
1789
|
+
case "execution_budget": {
|
|
1790
|
+
if (c.role !== "console") return void reply({ ok: false, error: "execution budgets are console-only" });
|
|
1791
|
+
if (msg.op === "status") return void reply({ ok: true, budgets: tasks.executionBudgetStatus(msg.id) });
|
|
1792
|
+
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding budget mutations" });
|
|
1793
|
+
try {
|
|
1794
|
+
if (msg.op === "configure") {
|
|
1795
|
+
const cfg = msg.config;
|
|
1796
|
+
if (!cfg || !Array.isArray(cfg.peers) || cfg.peers.some((p: unknown) => p !== "local" && p !== "pi")) throw new Error("strict execution budgets support local and Pi only; native Codex/Claude calls are not instrumented");
|
|
1797
|
+
if (cfg.kind === "task" && !board.get(cfg.taskId)) throw new Error("task not found");
|
|
1798
|
+
return void reply({ ok: true, budget: tasks.configureExecutionBudget(cfg) });
|
|
1799
|
+
}
|
|
1800
|
+
if (msg.op === "disable" && typeof msg.id === "string") return void reply({ ok: true, disabled: tasks.disableExecutionBudget(msg.id) });
|
|
1801
|
+
return void reply({ ok: false, error: "unknown execution budget operation" });
|
|
1802
|
+
} catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
|
|
1803
|
+
}
|
|
1736
1804
|
case "recovery":
|
|
1737
1805
|
if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
|
|
1738
1806
|
void recoveryOp(msg).then(reply, (error) => reply(recoveryError((error as Error)?.message ?? "recovery operation failed")));
|
|
@@ -1899,7 +1967,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1899
1967
|
await modelRelay?.close();
|
|
1900
1968
|
await egress?.close();
|
|
1901
1969
|
await piReceipts?.close();
|
|
1970
|
+
collectClaudeUsage();
|
|
1902
1971
|
budget.close();
|
|
1972
|
+
executionBudget.close();
|
|
1903
1973
|
board.close();
|
|
1904
1974
|
turnLog?.close();
|
|
1905
1975
|
server.stop(true);
|
package/src/hub/events.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type HubEvent =
|
|
|
14
14
|
| { type: "turn_start"; peer: string; turn: string }
|
|
15
15
|
| { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
|
|
16
16
|
| { type: "tokens"; peer: string; n: number }
|
|
17
|
+
| { type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number }
|
|
17
18
|
| { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
|
|
18
19
|
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|
|
19
20
|
| { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
|
|
@@ -75,7 +76,8 @@ export function readEvents(file: string, since = 0): StampedEvent[] {
|
|
|
75
76
|
if (!line.trim()) return [];
|
|
76
77
|
try {
|
|
77
78
|
const e = JSON.parse(line) as StampedEvent;
|
|
78
|
-
|
|
79
|
+
const measuredAt = e.type === "usage" && typeof e.measuredAt === "string" ? e.measuredAt : e.at;
|
|
80
|
+
return Date.parse(measuredAt) >= since ? [e] : [];
|
|
79
81
|
} catch {
|
|
80
82
|
return [];
|
|
81
83
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
export type ExecutionUnit = "model_calls" | "tool_calls" | "elapsed_ms" | "tokens";
|
|
4
|
+
export type ExecutionBudgetLimits = Partial<Record<ExecutionUnit, number>>;
|
|
5
|
+
export interface ExecutionBudgetConfig {
|
|
6
|
+
id: string;
|
|
7
|
+
kind: "task" | "run";
|
|
8
|
+
taskId?: number;
|
|
9
|
+
peers: string[];
|
|
10
|
+
limits: ExecutionBudgetLimits;
|
|
11
|
+
}
|
|
12
|
+
export interface ExecutionBudgetStatus extends ExecutionBudgetConfig {
|
|
13
|
+
used: Partial<Record<ExecutionUnit, number>>;
|
|
14
|
+
units: Partial<Record<ExecutionUnit, { used: number; limit: number; remaining: number; reason?: "exhausted" | "unknown_usage" }>>;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
updatedAt: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ExecutionBudgetDecision {
|
|
19
|
+
allowed: boolean;
|
|
20
|
+
scope: string;
|
|
21
|
+
unit: ExecutionUnit;
|
|
22
|
+
used: number;
|
|
23
|
+
limit: number | null;
|
|
24
|
+
remaining: number | null;
|
|
25
|
+
reason?: "exhausted" | "unknown_usage" | "not_eligible";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const UNITS = new Set<ExecutionUnit>(["model_calls", "tool_calls", "elapsed_ms", "tokens"]);
|
|
29
|
+
const validLimits = (raw: ExecutionBudgetLimits): ExecutionBudgetLimits => {
|
|
30
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("execution budget limits must be an object");
|
|
31
|
+
const out: ExecutionBudgetLimits = {};
|
|
32
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
33
|
+
if (!UNITS.has(key as ExecutionUnit)) throw new Error(`unsupported execution budget unit: ${key}`);
|
|
34
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`execution budget ${key} limit must be a nonnegative integer`);
|
|
35
|
+
out[key as ExecutionUnit] = value;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Persistent, cross-turn execution meter. Each admission and increment is one SQLite transaction. */
|
|
41
|
+
export class ExecutionBudget {
|
|
42
|
+
private readonly db: Database;
|
|
43
|
+
constructor(path: string) {
|
|
44
|
+
this.db = new Database(path, { create: true });
|
|
45
|
+
this.db.run("PRAGMA journal_mode = WAL");
|
|
46
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS execution_budgets (
|
|
47
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, task_id INTEGER, peers TEXT NOT NULL,
|
|
48
|
+
limits TEXT NOT NULL, used TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
configure(input: ExecutionBudgetConfig): ExecutionBudgetStatus {
|
|
52
|
+
if (!input.id || input.id.length > 200) throw new Error("execution budget id is invalid");
|
|
53
|
+
if (input.kind !== "task" && input.kind !== "run") throw new Error("execution budget kind must be task or run");
|
|
54
|
+
if (input.kind === "task" && (!Number.isSafeInteger(input.taskId) || input.taskId! < 1)) throw new Error("task budget requires a positive task id");
|
|
55
|
+
const peers = [...new Set(input.peers.filter((p) => typeof p === "string" && p.length > 0))].sort();
|
|
56
|
+
if (!peers.length) throw new Error("execution budget requires at least one peer");
|
|
57
|
+
const unsupported = peers.filter((peer) => peer !== "pi" && peer !== "local");
|
|
58
|
+
if (unsupported.length) throw new Error(`execution budgets require instrumented peers (pi, local); unsupported: ${unsupported.join(", ")}`);
|
|
59
|
+
const limits = validLimits(input.limits ?? {});
|
|
60
|
+
if (!Object.keys(limits).length) throw new Error("execution budget requires at least one supported limit");
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const existing = this.db.query("SELECT kind,task_id FROM execution_budgets WHERE id=?").get(input.id) as { kind: string; task_id: number | null } | undefined;
|
|
63
|
+
if (existing && (existing.kind !== input.kind || existing.task_id !== (input.taskId ?? null))) throw new Error("execution budget id is already bound to another scope");
|
|
64
|
+
this.db.query(`INSERT INTO execution_budgets (id,kind,task_id,peers,limits,used,created_at,updated_at)
|
|
65
|
+
VALUES (?,?,?,?,?,'{}',?,?) ON CONFLICT(id) DO UPDATE SET kind=excluded.kind,task_id=excluded.task_id,
|
|
66
|
+
peers=excluded.peers,limits=excluded.limits,updated_at=excluded.updated_at`).run(
|
|
67
|
+
input.id, input.kind, input.taskId ?? null, JSON.stringify(peers), JSON.stringify(limits), now, now,
|
|
68
|
+
);
|
|
69
|
+
return this.status(input.id) as ExecutionBudgetStatus;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
disable(id: string): boolean { return this.db.query("DELETE FROM execution_budgets WHERE id=?").run(id).changes > 0; }
|
|
73
|
+
|
|
74
|
+
status(id?: string): ExecutionBudgetStatus | ExecutionBudgetStatus[] | undefined {
|
|
75
|
+
const rows = (id
|
|
76
|
+
? this.db.query("SELECT * FROM execution_budgets WHERE id=?").all(id)
|
|
77
|
+
: this.db.query("SELECT * FROM execution_budgets ORDER BY created_at,id").all()) as BudgetRow[];
|
|
78
|
+
const values = rows.map(fromRow);
|
|
79
|
+
return id ? values[0] : values;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
admitTask(taskId: number | undefined, peer: string, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] {
|
|
83
|
+
return this.admitTasks(taskId === undefined ? [] : [taskId], peer, unit, amount);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
admitTasks(taskIds: number[], peer: string, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] {
|
|
87
|
+
if (!UNITS.has(unit) || !Number.isSafeInteger(amount) || amount <= 0) throw new Error("execution budget admission is invalid");
|
|
88
|
+
const transaction = this.db.transaction(() => {
|
|
89
|
+
// Read scopes only after BEGIN IMMEDIATE so a concurrent configure/admission cannot evade a scope or overdraw it.
|
|
90
|
+
const ids = [...new Set(taskIds.filter((id) => Number.isSafeInteger(id) && id > 0))];
|
|
91
|
+
const taskRows = ids.length
|
|
92
|
+
? this.db.query(`SELECT * FROM execution_budgets WHERE kind='task' AND task_id IN (${ids.map(() => "?").join(",")}) AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)`).all(...ids, peer) as BudgetRow[]
|
|
93
|
+
: [];
|
|
94
|
+
const runRows = this.db.query("SELECT * FROM execution_budgets WHERE kind='run' AND EXISTS (SELECT 1 FROM json_each(peers) WHERE value=?)").all(peer) as BudgetRow[];
|
|
95
|
+
const rows = [...taskRows, ...runRows].sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
|
|
96
|
+
const pending: { cfg: ExecutionBudgetStatus; updates: Partial<Record<ExecutionUnit, number>>; decisions: ExecutionBudgetDecision[] }[] = rows.map((row) => {
|
|
97
|
+
const cfg = fromRow(row), updates: Partial<Record<ExecutionUnit, number>> = {}, decisions: ExecutionBudgetDecision[] = [];
|
|
98
|
+
const now = Date.now(), elapsedLimit = cfg.limits.elapsed_ms;
|
|
99
|
+
if (elapsedLimit !== undefined) {
|
|
100
|
+
const elapsed = Math.max(cfg.used.elapsed_ms ?? 0, now - cfg.createdAt);
|
|
101
|
+
const decision: ExecutionBudgetDecision = { allowed: elapsed < elapsedLimit, scope: cfg.id, unit: "elapsed_ms", used: elapsed, limit: elapsedLimit, remaining: Math.max(0, elapsedLimit - elapsed), ...(elapsed >= elapsedLimit ? { reason: "exhausted" } : {}) };
|
|
102
|
+
decisions.push(decision); updates.elapsed_ms = elapsed;
|
|
103
|
+
}
|
|
104
|
+
const limit = cfg.limits[unit], current = cfg.used[unit] ?? 0;
|
|
105
|
+
if (unit === "tokens" || (unit === "model_calls" && cfg.limits.tokens !== undefined)) {
|
|
106
|
+
decisions.push({ allowed: false, scope: cfg.id, unit: "tokens", used: cfg.used.tokens ?? 0, limit: cfg.limits.tokens ?? null, remaining: cfg.limits.tokens === undefined ? null : Math.max(0, cfg.limits.tokens - (cfg.used.tokens ?? 0)), reason: "unknown_usage" });
|
|
107
|
+
} else if (limit === undefined) decisions.push({ allowed: true, scope: cfg.id, unit, used: current, limit: null, remaining: null });
|
|
108
|
+
else if (current + amount > limit) decisions.push({ allowed: false, scope: cfg.id, unit, used: current, limit, remaining: Math.max(0, limit - current), reason: "exhausted" });
|
|
109
|
+
else {
|
|
110
|
+
updates[unit] = current + amount;
|
|
111
|
+
decisions.push({ allowed: true, scope: cfg.id, unit, used: current + amount, limit, remaining: Math.max(0, limit - current - amount) });
|
|
112
|
+
}
|
|
113
|
+
return { cfg, updates, decisions };
|
|
114
|
+
});
|
|
115
|
+
if (pending.some((item) => item.decisions.some((decision) => !decision.allowed))) return pending.flatMap((item) => item.decisions);
|
|
116
|
+
for (const item of pending) if (Object.keys(item.updates).length) {
|
|
117
|
+
item.cfg.used = { ...item.cfg.used, ...item.updates };
|
|
118
|
+
this.db.query("UPDATE execution_budgets SET used=?,updated_at=? WHERE id=?").run(JSON.stringify(item.cfg.used), Date.now(), item.cfg.id);
|
|
119
|
+
}
|
|
120
|
+
return pending.flatMap((item) => item.decisions);
|
|
121
|
+
});
|
|
122
|
+
return transaction.immediate();
|
|
123
|
+
}
|
|
124
|
+
close(): void { this.db.close(); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface BudgetRow { id: string; kind: "task"|"run"; task_id: number | null; peers: string; limits: string; used: string; created_at: number; updated_at: number }
|
|
128
|
+
function fromRow(row: BudgetRow): ExecutionBudgetStatus {
|
|
129
|
+
const limits = JSON.parse(row.limits) as ExecutionBudgetLimits;
|
|
130
|
+
const used = JSON.parse(row.used) as Partial<Record<ExecutionUnit, number>>;
|
|
131
|
+
if (limits.elapsed_ms !== undefined) used.elapsed_ms = Math.max(used.elapsed_ms ?? 0, Date.now() - row.created_at);
|
|
132
|
+
const units: ExecutionBudgetStatus["units"] = {};
|
|
133
|
+
for (const [unit, limit] of Object.entries(limits) as [ExecutionUnit, number][]) {
|
|
134
|
+
const current = used[unit] ?? 0, remaining = Math.max(0, limit - current);
|
|
135
|
+
units[unit] = { used: current, limit, remaining, ...((unit === "tokens" && remaining > 0) ? { reason: "unknown_usage" as const } : (remaining === 0 ? { reason: "exhausted" as const } : {})) };
|
|
136
|
+
}
|
|
137
|
+
return { id: row.id, kind: row.kind, ...(row.task_id === null ? {} : { taskId: row.task_id }), peers: JSON.parse(row.peers), limits, used, units, createdAt: row.created_at, updatedAt: row.updated_at };
|
|
138
|
+
}
|
package/src/hub/report.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface Report {
|
|
|
4
4
|
from?: string;
|
|
5
5
|
to?: string;
|
|
6
6
|
peers: Record<string, { turns: number; busyMinutes: number; tokens: number }>;
|
|
7
|
+
usage: { peers: Record<string, UsageCoverage>; totals: UsageTotals; unknownPeers: string[]; completeCoverage: boolean; estimatedUsd?: number; measuredSpendUsd?: number };
|
|
7
8
|
messages: { total: number; dropped: Record<string, number>; overflow: number; undeliverable: number; perTask: number };
|
|
8
9
|
overlaps: { warnings: number; pairs: number };
|
|
9
10
|
/** Files one agent changed after another owner's open task had changed them (issue #32). */
|
|
@@ -12,12 +13,82 @@ export interface Report {
|
|
|
12
13
|
quota: { readings: number; hard: number };
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export interface UsageCoverage {
|
|
17
|
+
records: number;
|
|
18
|
+
withUsage: number;
|
|
19
|
+
withoutUsage: number;
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
inputRecords: number;
|
|
22
|
+
outputTokens: number;
|
|
23
|
+
outputRecords: number;
|
|
24
|
+
cacheReadTokens: number;
|
|
25
|
+
cacheReadRecords: number;
|
|
26
|
+
cacheWriteTokens: number;
|
|
27
|
+
cacheWriteRecords: number;
|
|
28
|
+
totalTokens: number;
|
|
29
|
+
totalRecords: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type UsageTotals = Omit<UsageCoverage, "records" | "withUsage" | "withoutUsage">;
|
|
33
|
+
|
|
34
|
+
const emptyUsage = (): UsageCoverage => ({ records: 0, withUsage: 0, withoutUsage: 0, inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 });
|
|
35
|
+
|
|
36
|
+
function recordUsage(coverage: UsageCoverage, e: Extract<StampedEvent, { type: "usage" }>): void {
|
|
37
|
+
coverage.records++;
|
|
38
|
+
const fields = [e.inputTokens, e.outputTokens, e.cacheReadTokens, e.cacheWriteTokens, e.totalTokens];
|
|
39
|
+
if (fields.some((n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0)) coverage.withUsage++;
|
|
40
|
+
else coverage.withoutUsage++;
|
|
41
|
+
const add = (field: keyof UsageTotals, n: number | undefined, countField: keyof UsageTotals) => {
|
|
42
|
+
if (typeof n === "number" && Number.isSafeInteger(n) && n >= 0) {
|
|
43
|
+
coverage[field] += n;
|
|
44
|
+
coverage[countField]++;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
add("inputTokens", e.inputTokens, "inputRecords");
|
|
48
|
+
add("outputTokens", e.outputTokens, "outputRecords");
|
|
49
|
+
add("cacheReadTokens", e.cacheReadTokens, "cacheReadRecords");
|
|
50
|
+
add("cacheWriteTokens", e.cacheWriteTokens, "cacheWriteRecords");
|
|
51
|
+
add("totalTokens", e.totalTokens, "totalRecords");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function finalizeUsage(report: Report): void {
|
|
55
|
+
for (const id of Object.keys(report.peers)) {
|
|
56
|
+
if (!report.usage.peers[id]) {
|
|
57
|
+
report.usage.peers[id] = emptyUsage();
|
|
58
|
+
report.usage.unknownPeers.push(id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const p of Object.values(report.usage.peers)) {
|
|
62
|
+
const out = report.usage.totals;
|
|
63
|
+
for (const field of ["inputTokens", "inputRecords", "outputTokens", "outputRecords", "cacheReadTokens", "cacheReadRecords", "cacheWriteTokens", "cacheWriteRecords", "totalTokens", "totalRecords"] as const) out[field] += p[field];
|
|
64
|
+
if (p.withoutUsage || p.inputRecords < p.withUsage || p.outputRecords < p.withUsage || p.cacheReadRecords < p.withUsage || p.cacheWriteRecords < p.withUsage || p.totalRecords < p.withUsage) report.usage.completeCoverage = false;
|
|
65
|
+
}
|
|
66
|
+
if (report.usage.unknownPeers.length) report.usage.completeCoverage = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatUsage(usage: Report["usage"]): string[] {
|
|
70
|
+
const lines: string[] = [];
|
|
71
|
+
for (const [id, u] of Object.entries(usage.peers).sort(([a], [b]) => a.localeCompare(b))) {
|
|
72
|
+
const coverage = `${u.withUsage}/${u.records} records with usage`;
|
|
73
|
+
const value = (name: string, count: number, records: number) => records ? `${name} ${count} (${records} known)` : `${name} unknown (0 known)`;
|
|
74
|
+
const known = [value("input", u.inputTokens, u.inputRecords), value("output", u.outputTokens, u.outputRecords), value("cache read", u.cacheReadTokens, u.cacheReadRecords), value("cache write", u.cacheWriteTokens, u.cacheWriteRecords), value("reported total", u.totalTokens, u.totalRecords)].join(", ");
|
|
75
|
+
const incomplete = u.withoutUsage || u.inputRecords < u.withUsage || u.outputRecords < u.withUsage || u.cacheReadRecords < u.withUsage || u.cacheWriteRecords < u.withUsage || u.totalRecords < u.withUsage;
|
|
76
|
+
lines.push(`usage ${id}: ${u.records ? coverage : "no usage records"}; ${known}; coverage ${incomplete ? "incomplete" : u.records ? "complete for recorded calls" : "unknown"}`);
|
|
77
|
+
}
|
|
78
|
+
const total = usage.totals.totalRecords ? `${usage.totals.totalTokens} reported tokens (${usage.totals.totalRecords} known records)` : "reported token total unknown (0 known records)";
|
|
79
|
+
const coverage = usage.completeCoverage ? "recorded-call coverage complete" : `incomplete or unknown peer coverage${usage.unknownPeers.length ? ` (${usage.unknownPeers.join(", ")})` : ""}`;
|
|
80
|
+
lines.push(`usage team totals: ${total}; ${coverage}; estimated price unknown; measured spend unknown`);
|
|
81
|
+
return lines;
|
|
82
|
+
}
|
|
83
|
+
|
|
15
84
|
/** The numbers `ahub report` prints, from `events.jsonl` alone (issue #40). */
|
|
16
85
|
export function summarize(events: StampedEvent[]): Report {
|
|
17
|
-
const r: Report = { peers: {}, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
|
|
86
|
+
const r: Report = { peers: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
|
|
18
87
|
const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
|
|
88
|
+
const usagePeer = (id: string) => (r.usage.peers[id] ??= emptyUsage());
|
|
19
89
|
const pairs = new Set<string>();
|
|
20
90
|
const taskMessages = new Map<string, number>();
|
|
91
|
+
const seenUsage = new Set<string>();
|
|
21
92
|
for (const e of events) {
|
|
22
93
|
r.from ??= e.at;
|
|
23
94
|
r.to = e.at;
|
|
@@ -35,9 +106,19 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
35
106
|
peer(e.peer).turns++;
|
|
36
107
|
peer(e.peer).busyMinutes += e.ms / 60_000;
|
|
37
108
|
break;
|
|
109
|
+
case "state":
|
|
110
|
+
peer(e.peer);
|
|
111
|
+
break;
|
|
38
112
|
case "tokens":
|
|
39
113
|
peer(e.peer).tokens += e.n;
|
|
40
114
|
break;
|
|
115
|
+
case "usage": {
|
|
116
|
+
const key = `${e.peer}\0${e.source}\0${e.id}`;
|
|
117
|
+
if (seenUsage.has(key)) break;
|
|
118
|
+
seenUsage.add(key);
|
|
119
|
+
recordUsage(usagePeer(e.peer), e);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
41
122
|
case "task":
|
|
42
123
|
r.tasks[e.event] = (r.tasks[e.event] ?? 0) + 1;
|
|
43
124
|
break;
|
|
@@ -55,6 +136,8 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
55
136
|
}
|
|
56
137
|
}
|
|
57
138
|
r.overlaps.pairs = pairs.size;
|
|
139
|
+
finalizeUsage(r);
|
|
140
|
+
// Pricing is intentionally absent: a token count is neither an estimated price nor provider-reported spend.
|
|
58
141
|
r.messages.perTask = taskMessages.size ? Number(([...taskMessages.values()].reduce((a, b) => a + b, 0) / taskMessages.size).toFixed(1)) : 0;
|
|
59
142
|
for (const p of Object.values(r.peers)) p.busyMinutes = Number(p.busyMinutes.toFixed(1));
|
|
60
143
|
return r;
|
|
@@ -63,6 +146,7 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
63
146
|
export function formatReport(r: Report): string[] {
|
|
64
147
|
const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
|
|
65
148
|
for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) lines.push(`peer ${id}: ${p.turns} turn${p.turns === 1 ? "" : "s"}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : id === "pi" ? "tokens not reported" : "- tokens"}`);
|
|
149
|
+
if (Object.keys(r.usage.peers).length) lines.push(...formatUsage(r.usage));
|
|
66
150
|
const dropped = Object.entries(r.messages.dropped).map(([k, n]) => `${n} ${k}`).join(", ");
|
|
67
151
|
lines.push(`messages: ${r.messages.total} (dropped: ${dropped || "none"}; overflow ${r.messages.overflow}; undeliverable ${r.messages.undeliverable}); ${r.messages.perTask} per task that had any`);
|
|
68
152
|
lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}; edit conflicts: ${r.conflicts}`);
|
package/src/hub/tasks.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, type Board, type Task, type TaskC
|
|
|
4
4
|
import type { Bus } from "./bus.ts";
|
|
5
5
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
6
6
|
import { assign, detectSignals, LOCAL, PI, type Assignment, type Routing } from "./routing.ts";
|
|
7
|
+
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
7
8
|
|
|
8
9
|
export interface TasksDeps {
|
|
9
10
|
board: Board;
|
|
@@ -27,6 +28,8 @@ export interface TasksDeps {
|
|
|
27
28
|
recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
|
|
28
29
|
/** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
|
|
29
30
|
quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
|
|
31
|
+
/** Optional persisted task/run execution limits (issue #102). */
|
|
32
|
+
executionBudget?: ExecutionBudget;
|
|
30
33
|
/** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
|
|
31
34
|
review?: { adaptive: boolean; min_reviews: number };
|
|
32
35
|
/** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
|
|
@@ -108,6 +111,25 @@ export class Tasks {
|
|
|
108
111
|
|
|
109
112
|
isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
110
113
|
|
|
114
|
+
configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
|
|
115
|
+
if (!this.d.executionBudget) return undefined;
|
|
116
|
+
if (config.kind === "task" && (!config.taskId || !this.d.board.get(config.taskId))) throw new Error(`task #${config.taskId ?? "?"} does not exist`);
|
|
117
|
+
return this.d.executionBudget.configure(config);
|
|
118
|
+
};
|
|
119
|
+
disableExecutionBudget = (id: string): boolean => this.d.executionBudget?.disable(id) ?? false;
|
|
120
|
+
executionBudgetStatus = (id?: string): ExecutionBudgetStatus | ExecutionBudgetStatus[] | undefined => this.d.executionBudget?.status(id);
|
|
121
|
+
/** Charge only budgets matching the task actually delivered to this peer, plus active run budgets. */
|
|
122
|
+
admitExecution = (taskId: number | undefined, peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => this.d.executionBudget?.admitTask(taskId, peer, unit, amount) ?? [];
|
|
123
|
+
/** A digest may include several tasks. Charge each matching task budget and each run budget once, atomically. */
|
|
124
|
+
admitExecutionEnvelopes = (envs: Envelope[], peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => {
|
|
125
|
+
const ids = [...new Set(envs.flatMap((e) => {
|
|
126
|
+
const value = e.refs?.task;
|
|
127
|
+
const id = typeof value === "string" ? Number(value) : NaN;
|
|
128
|
+
return Number.isSafeInteger(id) && id > 0 ? [id] : [];
|
|
129
|
+
}))];
|
|
130
|
+
return this.d.executionBudget?.admitTasks(ids, peer, unit, amount) ?? [];
|
|
131
|
+
};
|
|
132
|
+
|
|
111
133
|
/** What peers other than the owner, the console stream and the log may see of a task. */
|
|
112
134
|
publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
|
|
113
135
|
|
package/src/hub/usage.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { normalizeUsage, safeModelLabel, type NormalizedUsage } from "../omniroute/usage.ts";
|
|
4
|
+
|
|
5
|
+
export interface UsageRecord {
|
|
6
|
+
id: string;
|
|
7
|
+
at?: string;
|
|
8
|
+
usage?: NormalizedUsage;
|
|
9
|
+
requestedModel?: string;
|
|
10
|
+
servedModel?: string;
|
|
11
|
+
provider?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function opaqueId(sessionId: string, messageId: string): string {
|
|
15
|
+
return createHash("sha256").update(sessionId).update("\0").update(messageId).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Documented successful Messages API values, including beta compaction, plus stop_sequence in native transcripts.
|
|
19
|
+
const COMPLETED_STOP_REASONS = new Set(["end_turn", "tool_use", "max_tokens", "stop_sequence", "pause_turn", "refusal", "model_context_window_exceeded", "compaction"]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read Claude's native JSONL usage without returning transcript text, paths or session identity.
|
|
23
|
+
* Repeated streaming records are collapsed by message.id, using only a completed assistant message.
|
|
24
|
+
* Malformed lines and unavailable files are ignored so telemetry cannot interrupt a Claude turn.
|
|
25
|
+
*/
|
|
26
|
+
export function readClaudeTranscriptUsage(sessionId: string, transcriptPath: string): UsageRecord[] {
|
|
27
|
+
try {
|
|
28
|
+
// Avoid repeatedly parsing an unbounded transcript on the daemon's polling path.
|
|
29
|
+
if (statSync(transcriptPath).size > 32 * 1024 * 1024) {
|
|
30
|
+
// Signal unavailable coverage with a stable opaque id; a repeated poll dedupes in the report.
|
|
31
|
+
return [{ id: opaqueId(sessionId, "transcript-too-large") }];
|
|
32
|
+
}
|
|
33
|
+
const latest = new Map<string, { usage?: NormalizedUsage; at?: string; servedModel?: string }>();
|
|
34
|
+
for (const line of readFileSync(transcriptPath, "utf8").split("\n")) {
|
|
35
|
+
if (!line.trim()) continue;
|
|
36
|
+
try {
|
|
37
|
+
const row = JSON.parse(line) as { type?: unknown; timestamp?: unknown; sessionId?: unknown; message?: unknown };
|
|
38
|
+
if (typeof row.sessionId === "string" && row.sessionId !== sessionId) continue;
|
|
39
|
+
if (row.type !== "assistant" || !row.message || typeof row.message !== "object") continue;
|
|
40
|
+
const message = row.message as { id?: unknown; stop_reason?: unknown; usage?: unknown; model?: unknown };
|
|
41
|
+
if (typeof message.id !== "string" || !message.id || typeof message.stop_reason !== "string" || !message.stop_reason) continue;
|
|
42
|
+
const supportedStopReason = COMPLETED_STOP_REASONS.has(message.stop_reason);
|
|
43
|
+
const usage = supportedStopReason ? normalizeUsage(message.usage) : undefined;
|
|
44
|
+
const at = typeof row.timestamp === "string" && Number.isFinite(Date.parse(row.timestamp)) ? new Date(row.timestamp).toISOString() : undefined;
|
|
45
|
+
const servedModel = safeModelLabel(message.model);
|
|
46
|
+
latest.set(message.id, { ...(usage ? { usage } : {}), ...(at ? { at } : {}), ...(servedModel ? { servedModel } : {}) });
|
|
47
|
+
} catch {
|
|
48
|
+
// A streaming or crash-truncated line is not a record.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [...latest].map(([messageId, value]) => ({ id: opaqueId(sessionId, messageId), ...value }));
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/local/sandbox.ts
CHANGED
|
@@ -118,8 +118,9 @@ export interface ExecResult {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
|
|
121
|
-
export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string
|
|
121
|
+
export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal }): Promise<ExecResult> {
|
|
122
122
|
if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
|
|
123
|
+
if (opts.signal?.aborted) return Promise.resolve({ code: null, output: "error: command cancelled before execution" });
|
|
123
124
|
return new Promise((resolve) => {
|
|
124
125
|
// A temp dir of its own (#63): the user's is shared with every other process, and what a command reads can reach
|
|
125
126
|
// an answer shown to cloud peers. Allowed after the profile's denies, and gone when the command ends.
|
|
@@ -141,6 +142,8 @@ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: stri
|
|
|
141
142
|
child.kill("SIGKILL");
|
|
142
143
|
}
|
|
143
144
|
};
|
|
145
|
+
const abort = () => killGroup();
|
|
146
|
+
opts.signal?.addEventListener("abort", abort, { once: true });
|
|
144
147
|
// Best effort, never in the way of the result: a command can mark its files immutable (`chflags uchg`), and then
|
|
145
148
|
// the plain remove throws. What still fails is left to the OS temp cleanup.
|
|
146
149
|
const dropOwn = () => {
|
|
@@ -154,11 +157,12 @@ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: stri
|
|
|
154
157
|
if (done) return;
|
|
155
158
|
done = true;
|
|
156
159
|
clearTimeout(timer);
|
|
160
|
+
opts.signal?.removeEventListener("abort", abort);
|
|
157
161
|
dropOwn();
|
|
158
162
|
const clipped = output.length >= OUTPUT_CAP ? `${output.slice(0, OUTPUT_CAP)}\n(output truncated)` : output;
|
|
159
163
|
resolve({ code, output: signal ? `${clipped}\n(killed: ${signal}, timeout?)` : clipped });
|
|
160
164
|
};
|
|
161
|
-
child.on("error", (e) => (done || ((done = true), dropOwn(), resolve({ code: null, output: `error: ${e.message}` }))));
|
|
165
|
+
child.on("error", (e) => (done || ((done = true), clearTimeout(timer), opts.signal?.removeEventListener("abort", abort), dropOwn(), resolve({ code: null, output: `error: ${e.message}` }))));
|
|
162
166
|
child.on("close", finish);
|
|
163
167
|
// `close` waits for every holder of the pipes; after `exit` give stragglers a moment, then stop waiting and reap them.
|
|
164
168
|
child.on("exit", (code, signal) => setTimeout(() => (killGroup(), finish(code, signal)), 500).unref());
|