@staix/agent-hub 0.12.21 → 0.12.23
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 +29 -0
- package/README.md +27 -12
- package/docs/agent-notes/adapters.md +15 -7
- package/docs/agent-notes/benchmarks.md +4 -1
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +12 -1
- package/docs/agent-notes/local-worker.md +4 -1
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +4 -0
- package/docs/events.md +17 -4
- package/docs/operations.md +478 -41
- package/docs/quickstart.md +14 -6
- package/docs/security.md +23 -11
- package/docs/smoke.md +133 -7
- package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verified.json +32 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +14 -3
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +105 -17
- package/src/adapters/codex-appserver.ts +70 -35
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/bench.ts +2 -1
- package/src/cli/console-state.ts +28 -22
- package/src/cli/console.ts +20 -10
- package/src/cli/help.ts +25 -19
- package/src/cli/identity.ts +3 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +409 -177
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +0 -28
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +80 -9
- package/src/cli/upgrade.ts +50 -15
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +15 -14
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +23 -11
- package/src/hub/config-trust.ts +16 -5
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +2 -1
- package/src/hub/daemon.ts +483 -83
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +2 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +5 -4
- package/src/hub/git.ts +32 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/manager.ts +18 -5
- package/src/hub/peers.ts +7 -5
- package/src/hub/permission-mode.ts +14 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +43 -4
- package/src/hub/registry.ts +2 -1
- package/src/hub/routing.ts +114 -13
- package/src/hub/settings.ts +392 -0
- package/src/hub/snapshots.ts +4 -2
- package/src/hub/start-mode.ts +48 -0
- package/src/hub/tasks.ts +158 -43
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +71 -2
- package/src/local/sandbox.ts +47 -6
- package/src/local/tools.ts +25 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +19 -2
- package/src/ui/index.html +172 -21
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
5
|
-
import { PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
6
|
+
import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
6
7
|
import { hubHome } from "../hub/project.ts";
|
|
7
8
|
import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
|
|
8
9
|
|
|
@@ -20,7 +21,8 @@ export interface Inspection {
|
|
|
20
21
|
version?: string;
|
|
21
22
|
protocol?: number;
|
|
22
23
|
peers: RecoveryPeer[];
|
|
23
|
-
|
|
24
|
+
/** `waiting` (#272): what the running hub says keeps it from being quiet (busy peers, pending approvals); display only. */
|
|
25
|
+
recovery?: { operationId?: string; phase?: string; ready?: boolean; waiting?: string[] };
|
|
24
26
|
blockers: string[];
|
|
25
27
|
/** #215: for a stopped runtime, the operation whose committed restart snapshot its state dir holds (unreleased). */
|
|
26
28
|
snapshot?: string;
|
|
@@ -81,6 +83,7 @@ export interface RecoveryOperation {
|
|
|
81
83
|
pluginInstalled?: boolean;
|
|
82
84
|
globalInstalled?: boolean;
|
|
83
85
|
error?: string;
|
|
86
|
+
failure?: { code: "database_busy"; retryable: true; site?: string[] };
|
|
84
87
|
/** #215: every human disposition, in order. Operator reasons only; never task or message text. */
|
|
85
88
|
audit?: { at: number; action: "fresh-session" | "stop-and-archive"; reason: string; peer?: string; projects: string[] }[];
|
|
86
89
|
disposition?: { choice: "stop-and-archive"; at: number; projects: Record<string, string> };
|
|
@@ -202,32 +205,48 @@ export function disposeRefusal(op: RecoveryOperation, live: Record<string, Inspe
|
|
|
202
205
|
return unsettled ? { project: unsettled.id, reads: live[unsettled.id]?.state ?? "not inspected" } : undefined;
|
|
203
206
|
}
|
|
204
207
|
|
|
208
|
+
/** #272: one entry of `next`. `text` is the line status prints; a screen runs `kind` through `recoveryArgv`. */
|
|
209
|
+
export interface NextChoice {
|
|
210
|
+
kind: "resume" | "abort" | "fresh" | "stop" | "wait";
|
|
211
|
+
/** `fresh` only: the peer that starts as a new session. */
|
|
212
|
+
peer?: string;
|
|
213
|
+
text: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
205
216
|
/**
|
|
206
217
|
* #215: what the person can do now, in the receipt table's order: resume (it also launches a failed peer again),
|
|
207
218
|
* abort where it can succeed, a fresh session for a failed Codex or Claude restoration, and stop-and-archive last.
|
|
208
|
-
* `status
|
|
219
|
+
* `status`, every error that names choices and the interactive screen (#272) use this one list.
|
|
209
220
|
*/
|
|
210
|
-
export function
|
|
221
|
+
export function nextChoices(receipt: RecoveryOperation, runner?: number | "unknown", live: Record<string, Inspection | undefined> = {}): NextChoice[] {
|
|
211
222
|
// A receipt read without its project lists (never written so by a coordinator) still gets the general choices.
|
|
212
223
|
const op: RecoveryOperation = receipt.projects ? receipt : { ...receipt, projects: [], plan: { ...receipt.plan, projects: receipt.plan?.projects ?? [] } };
|
|
213
224
|
if (op.phase === "completed" || op.phase === "cancelled") return [];
|
|
214
225
|
// claimRunner refuses an owner it cannot read, so resume, abort and dispose are all refused until the record reads.
|
|
215
|
-
if (runner === "unknown") return [`${recoveryCommand(op, "status")} again: whether a runner holds the operation could not be read (resume, abort and dispose are refused until it can)`];
|
|
216
|
-
if (runner) return [`wait: runner ${runner} is working; ${recoveryCommand(op, "status")}`];
|
|
226
|
+
if (runner === "unknown") return [{ kind: "wait", text: `${recoveryCommand(op, "status")} again: whether a runner holds the operation could not be read (resume, abort and dispose are refused until it can)` }];
|
|
227
|
+
if (runner) return [{ kind: "wait", text: `wait: runner ${runner} is working; ${recoveryCommand(op, "status")}` }];
|
|
217
228
|
const unsettled = disposeRefusal(op, live);
|
|
218
|
-
const stop = unsettled ? `wait until ${unsettled.project}'s hub settles, then ${recoveryCommand(op, "status")}` : recoveryCommand(op, "dispose", STOP);
|
|
219
|
-
if (op.disposition) return [unsettled ? stop : `rerun ${stop}`];
|
|
229
|
+
const stop: NextChoice = unsettled ? { kind: "wait", text: `wait until ${unsettled.project}'s hub settles, then ${recoveryCommand(op, "status")}` } : { kind: "stop", text: recoveryCommand(op, "dispose", STOP) };
|
|
230
|
+
if (op.disposition) return [unsettled ? stop : { kind: "stop", text: `rerun ${stop.text}` }];
|
|
220
231
|
const failed = [...new Set(op.projects.flatMap((p) => failedPeers(op, p, live)))];
|
|
221
232
|
const old = !!op.sourceRoot && !coordinatorCurrent(op.sourceRoot);
|
|
222
233
|
const abortable = !abortRefusal(op, live);
|
|
223
234
|
const stuck = resumeBlocked(op, live);
|
|
224
235
|
return [
|
|
225
|
-
...(stuck ? [] : [`${recoveryCommand(op, "resume")}${op.error ? " (after the next action in error)" : ""}${old ? ` (runs the coordinator that started this operation, which cannot re-prepare an expired hold: if it reports "source is no longer prepared", use ${abortable ? "abort or " : ""}stop-and-archive)` : ""}`]),
|
|
226
|
-
...(abortable ? [recoveryCommand(op, "abort")] : []),
|
|
227
|
-
...(targetReadsWaivers(op) && !stuck ? failed.map((peer) => recoveryCommand(op, "dispose", `--fresh-session ${peer} --reason <text>`)) : []),
|
|
236
|
+
...(stuck ? [] : [{ kind: "resume" as const, text: `${recoveryCommand(op, "resume")}${op.error ? " (after the next action in error)" : ""}${old ? ` (runs the coordinator that started this operation, which cannot re-prepare an expired hold: if it reports "source is no longer prepared", use ${abortable ? "abort or " : ""}stop-and-archive)` : ""}` }]),
|
|
237
|
+
...(abortable ? [{ kind: "abort" as const, text: recoveryCommand(op, "abort") }] : []),
|
|
238
|
+
...(targetReadsWaivers(op) && !stuck ? failed.map((peer) => ({ kind: "fresh" as const, peer, text: recoveryCommand(op, "dispose", `--fresh-session ${peer} --reason <text>`) })) : []),
|
|
228
239
|
stop,
|
|
229
240
|
];
|
|
230
241
|
}
|
|
242
|
+
export const nextActions = (receipt: RecoveryOperation, runner?: number | "unknown", live: Record<string, Inspection | undefined> = {}): string[] => nextChoices(receipt, runner, live).map((c) => c.text);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* #272: a runner that only waits for its source to get quiet can be stopped and the operation aborted: nothing was
|
|
246
|
+
* closed, stopped or asked to commit. Abort decides again on what is live, so a runner that moved on meanwhile is
|
|
247
|
+
* refused there, never here.
|
|
248
|
+
*/
|
|
249
|
+
export const cancellableWait = (op: RecoveryOperation): boolean => op.phase === "running" && op.step.startsWith("prepare:") && !op.disposition && !hasEffects(op) && !op.projects.some((p) => p.commitSent);
|
|
231
250
|
|
|
232
251
|
/**
|
|
233
252
|
* "next actions: ..." from nextActions, with the inspections the caller holds. Every error that lists choices ends with
|
|
@@ -271,6 +290,7 @@ export function registeredProjects(home = hubHome()): Project[] {
|
|
|
271
290
|
if (!existsSync(path)) return [];
|
|
272
291
|
const db = new Database(path, { readonly: true });
|
|
273
292
|
try {
|
|
293
|
+
db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
|
|
274
294
|
// #226: a registry no current release opened yet has no signature column, and this read never migrates.
|
|
275
295
|
const signed = (db.query("PRAGMA table_info(projects)").all() as { name: string }[]).some((c) => c.name === "pid_signature");
|
|
276
296
|
return (db.query(`SELECT ${PROJECT_COLUMNS}${signed ? ", pid_signature" : ""} FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
|
|
@@ -416,10 +436,11 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
416
436
|
if (op.disposition) {
|
|
417
437
|
// The stop-and-archive in progress keeps its own cause; resume never runs an operation being abandoned.
|
|
418
438
|
op.error ??= `a stop-and-archive of this operation is partway; ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
|
|
439
|
+
delete op.failure;
|
|
419
440
|
op.phase = "blocked"; save();
|
|
420
441
|
return op;
|
|
421
442
|
}
|
|
422
|
-
op.phase = "running"; delete op.error; save();
|
|
443
|
+
op.phase = "running"; delete op.error; delete op.failure; save();
|
|
423
444
|
step("stage");
|
|
424
445
|
const target = await driver.stage(op);
|
|
425
446
|
if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new FinalRefusal("staged target changed since operation started");
|
|
@@ -477,7 +498,13 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
477
498
|
await driver.commit(project, id, planned.source.instanceId!);
|
|
478
499
|
const deadline = driver.now() + 30_000;
|
|
479
500
|
do {
|
|
480
|
-
live = await driver.inspect(project);
|
|
501
|
+
try { live = await driver.inspect(project); }
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (!isDatabaseBusy(error)) throw error;
|
|
504
|
+
// A committed source may still be stopping. Contention is an
|
|
505
|
+
// unconfirmed read here, not a refusal; keep the existing bound.
|
|
506
|
+
live = { state: "unavailable", peers: [], blockers: ["database_busy"] };
|
|
507
|
+
}
|
|
481
508
|
if (live.state === "stopped") break;
|
|
482
509
|
if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
|
|
483
510
|
await driver.sleep(100);
|
|
@@ -539,6 +566,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
539
566
|
releaseRecoveryLock(id, home); op.phase = "completed"; step("completed");
|
|
540
567
|
} catch (error) {
|
|
541
568
|
// #215: every runner error ends with the choices status shows, read from every open source, never only this one.
|
|
569
|
+
if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
|
|
542
570
|
if (error instanceof FinalRefusal) op.final = true;
|
|
543
571
|
const next = nextActionsText(op, await liveProjects(op, driver.inspect));
|
|
544
572
|
op.phase = "blocked"; op.error = `${error instanceof Error ? error.message : "recovery failed"}; ${next}`; save();
|
|
@@ -558,7 +586,7 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
|
|
|
558
586
|
...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
|
|
559
587
|
pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
|
|
560
588
|
...(!targetReadsWaivers(op) && op.projects.some((p) => failedPeers(op, p).length) ? { freshSession: `not offered: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session` } : {}),
|
|
561
|
-
...(op.error ? { error: op.error } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
|
|
589
|
+
...(op.error ? { error: op.error } : {}), ...(op.failure ? { failure: op.failure } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
|
|
562
590
|
}
|
|
563
591
|
|
|
564
592
|
/** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
|
|
@@ -588,8 +616,12 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
588
616
|
const state = live[planned.project.id];
|
|
589
617
|
if (state?.recovery?.operationId === id && state.recovery.phase !== "released") await driver.abort(planned.project, id, planned.source.instanceId!);
|
|
590
618
|
}
|
|
619
|
+
delete op.error; delete op.failure;
|
|
591
620
|
op.phase = "cancelled"; op.step = "cancelled"; op.updatedAt = driver.now();
|
|
592
621
|
writeOperation(id, op, home); releaseRecoveryLock(id, home);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (isDatabaseBusy(error)) throw new RecoveryDatabaseBusy();
|
|
624
|
+
throw error;
|
|
593
625
|
} finally { releaseRunner(); }
|
|
594
626
|
}
|
|
595
627
|
|
|
@@ -641,6 +673,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
641
673
|
(progress.fresh ??= {})[peer] = { lost: binding.sessionId, reason, at };
|
|
642
674
|
}
|
|
643
675
|
(op.audit ??= []).push({ at, action: "fresh-session", reason, peer, projects: failed.map((p) => p.id) });
|
|
676
|
+
delete op.error; delete op.failure;
|
|
644
677
|
op.updatedAt = at; writeOperation(id, op, home);
|
|
645
678
|
return op;
|
|
646
679
|
}
|
|
@@ -670,6 +703,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
670
703
|
abandoned(op); // #227: in the same write as the disposition
|
|
671
704
|
(op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
|
|
672
705
|
// Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
|
|
706
|
+
delete op.failure;
|
|
673
707
|
op.updatedAt = driver.now(); writeOperation(id, op, home);
|
|
674
708
|
try {
|
|
675
709
|
for (const { planned, live, act } of acts) {
|
|
@@ -682,12 +716,13 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
682
716
|
op.updatedAt = driver.now(); writeOperation(id, op, home);
|
|
683
717
|
}
|
|
684
718
|
} catch (error) {
|
|
719
|
+
if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
|
|
685
720
|
op.phase = "blocked"; op.updatedAt = driver.now();
|
|
686
721
|
op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
|
|
687
722
|
writeOperation(id, op, home);
|
|
688
723
|
throw error;
|
|
689
724
|
}
|
|
690
|
-
delete op.error;
|
|
725
|
+
delete op.error; delete op.failure;
|
|
691
726
|
op.phase = "cancelled"; op.step = "disposed: stop-and-archive (abandoned, not completed)"; op.updatedAt = driver.now();
|
|
692
727
|
writeOperation(id, op, home); releaseRecoveryLock(id, home);
|
|
693
728
|
return op;
|
package/src/hub/board.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { PeerId } from "./envelope.ts";
|
|
3
3
|
import type { SweepRecord } from "./task-sweep.ts";
|
|
4
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
4
5
|
|
|
5
6
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
6
7
|
export type TaskClass = (typeof CLASSES)[number];
|
|
@@ -83,6 +84,7 @@ export class Board {
|
|
|
83
84
|
|
|
84
85
|
constructor(path: string) {
|
|
85
86
|
this.db = new Database(path, { create: true });
|
|
87
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
86
88
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
87
89
|
this.db.run(`CREATE TABLE IF NOT EXISTS tasks (
|
|
88
90
|
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', class TEXT NOT NULL,
|
package/src/hub/budget.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { PeerId } from "./envelope.ts";
|
|
3
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
3
4
|
|
|
4
5
|
export interface UsageWindow {
|
|
5
6
|
id: "5h" | "week" | "tokens";
|
|
@@ -80,10 +81,12 @@ const EXEMPT = new Set<PeerId>(["local", "user", "hub"]);
|
|
|
80
81
|
export class Budget {
|
|
81
82
|
private readonly db: Database;
|
|
82
83
|
private readonly readings = new Map<PeerId, Map<string, Reading>>();
|
|
83
|
-
private readonly pausing = new
|
|
84
|
+
private readonly pausing = new Map<PeerId, object>();
|
|
84
85
|
private readonly handingOff = new Set<PeerId>();
|
|
85
86
|
/** `ahub budget resume`: the owner overrode a pause; readings over the gate are ignored for that peer until then. */
|
|
86
87
|
private readonly ignoreUntil = new Map<PeerId, number>();
|
|
88
|
+
/** Chosen fallback bounds belong to a window, never to every window of the peer. */
|
|
89
|
+
private readonly resetBounds = new Map<PeerId, Map<string, { at: number; explicit: boolean }>>();
|
|
87
90
|
private closed = false;
|
|
88
91
|
private recoveryHeld = false;
|
|
89
92
|
private deferred = false;
|
|
@@ -96,6 +99,7 @@ export class Budget {
|
|
|
96
99
|
private readonly now: () => number = Date.now,
|
|
97
100
|
) {
|
|
98
101
|
this.db = new Database(dbPath, { create: true });
|
|
102
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
99
103
|
this.db.run(`CREATE TABLE IF NOT EXISTS budget_pauses (peer TEXT PRIMARY KEY, since INTEGER NOT NULL, resets_at INTEGER NOT NULL,
|
|
100
104
|
reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
|
|
101
105
|
}
|
|
@@ -153,6 +157,10 @@ export class Budget {
|
|
|
153
157
|
/** `at`: when the numbers were measured, for sources that arrive through a file (a leftover file is not a fresh reading). */
|
|
154
158
|
report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
|
|
155
159
|
if (EXEMPT.has(peer) || this.closed) return;
|
|
160
|
+
// An override ignores soft readings, not a provider that has actually refused another turn.
|
|
161
|
+
if (hard && this.ignoreUntil.delete(peer)) {
|
|
162
|
+
try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
|
|
163
|
+
}
|
|
156
164
|
try { this.hooks.reading?.(peer, windows, hard, at); } catch { /* telemetry never costs a reading */ }
|
|
157
165
|
const mine = this.readings.get(peer) ?? new Map<string, Reading>();
|
|
158
166
|
this.readings.set(peer, mine);
|
|
@@ -172,7 +180,10 @@ export class Budget {
|
|
|
172
180
|
const open = this.record(peer);
|
|
173
181
|
if (!open) return false;
|
|
174
182
|
this.ignoreUntil.set(peer, open.resetsAt);
|
|
183
|
+
const bounds = this.resetBounds.get(peer);
|
|
175
184
|
this.resume(peer, "overridden by the console user");
|
|
185
|
+
// A new refusal may end the override before this same window has reset.
|
|
186
|
+
if (bounds) this.resetBounds.set(peer, bounds);
|
|
176
187
|
return true;
|
|
177
188
|
}
|
|
178
189
|
|
|
@@ -181,8 +192,14 @@ export class Budget {
|
|
|
181
192
|
const fresh = this.fresh(peer);
|
|
182
193
|
const over = fresh.filter((r) => r.used >= this.cfg.gate);
|
|
183
194
|
const open = this.record(peer);
|
|
184
|
-
|
|
195
|
+
// An expired restored record may be waiting for attachment on an unpaused bus. A new hard refusal
|
|
196
|
+
// starts a fresh pause immediately, retaining its list of moved tasks for the eventual resume notice.
|
|
197
|
+
if (open && !(hard && this.now() >= open.resetsAt)) {
|
|
198
|
+
if (hard && over.length) this.hooks.pause(peer); // confirm the persisted pause on the live bus, too
|
|
185
199
|
// A later reading may finally carry the reset time the first one lacked.
|
|
200
|
+
const bounds = this.resetBounds.get(peer) ?? new Map<string, { at: number; explicit: boolean }>();
|
|
201
|
+
this.resetBounds.set(peer, bounds);
|
|
202
|
+
for (const reading of over) if (reading.resetsAt !== undefined) bounds.set(reading.id, { at: reading.resetsAt, explicit: true });
|
|
186
203
|
const known = Math.max(0, ...over.map((r) => r.resetsAt ?? 0));
|
|
187
204
|
if (known && known !== open.resetsAt) {
|
|
188
205
|
// A wait was decided against the old reset (#36). If the new one is past wait_max_min (a week window crossed
|
|
@@ -199,22 +216,36 @@ export class Budget {
|
|
|
199
216
|
if (newer.length && fresh.every((r) => r.used < this.cfg.gate - HYSTERESIS)) this.resume(peer, "usage is back under the gate");
|
|
200
217
|
return;
|
|
201
218
|
}
|
|
202
|
-
if (!over.length || this.pausing.has(peer) || this.now() < (this.ignoreUntil.get(peer) ?? 0)) return;
|
|
203
|
-
|
|
219
|
+
if (!over.length || (this.pausing.has(peer) && !hard) || this.now() < (this.ignoreUntil.get(peer) ?? 0)) return;
|
|
220
|
+
// A hard refusal supersedes a soft evaluation still awaiting its checkpoint.
|
|
221
|
+
const evaluation = {};
|
|
222
|
+
this.pausing.set(peer, evaluation);
|
|
204
223
|
try {
|
|
205
224
|
const worst = over.reduce((a, b) => (b.used > a.used ? b : a));
|
|
206
|
-
const
|
|
225
|
+
const bounds = this.resetBounds.get(peer) ?? new Map<string, { at: number; explicit: boolean }>();
|
|
226
|
+
this.resetBounds.set(peer, bounds);
|
|
227
|
+
const resetsAt = Math.max(...over.map((r) => {
|
|
228
|
+
const fallback = this.now() + (r.windowMins ?? FALLBACK_WINDOW_MINS) * 60_000;
|
|
229
|
+
const prior = bounds.get(r.id);
|
|
230
|
+
// Preserve an earlier future fallback for this window after an override.
|
|
231
|
+
// A provider's explicit reset, including a later one, remains authoritative.
|
|
232
|
+
const retained = prior !== undefined && prior.at > this.now() ? prior : undefined;
|
|
233
|
+
const reset = r.resetsAt ?? (retained ? retained.explicit ? retained.at : Math.min(retained.at, fallback) : fallback);
|
|
234
|
+
bounds.set(r.id, { at: reset, explicit: r.resetsAt !== undefined || retained?.explicit === true });
|
|
235
|
+
return reset;
|
|
236
|
+
}));
|
|
207
237
|
const reason = `${worst.id} window at ${Math.round(worst.used * 100)}% (${worst.source})`;
|
|
208
238
|
// Checkpoint first, pause second: a paused peer receives nothing, and a turn in flight is never cut by the coordinator.
|
|
209
239
|
const summary = hard ? undefined : await this.hooks.requestCheckpoint(peer).catch(() => undefined);
|
|
240
|
+
if (this.pausing.get(peer) !== evaluation) return; // a later hard evaluation owns this pause
|
|
210
241
|
if (this.recoveryHeld) { this.deferred = true; return; }
|
|
211
242
|
if (this.closed) return; // the hub is shutting down: nothing is recorded, the next run sees the reading again
|
|
212
|
-
this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary) VALUES (?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null);
|
|
243
|
+
this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary, moved) VALUES (?, ?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null, JSON.stringify(open?.moved ?? []));
|
|
213
244
|
this.hooks.pause(peer);
|
|
214
245
|
this.hooks.notify(`budget: ${peer} paused, ${reason}; resumes ${new Date(resetsAt).toLocaleTimeString()}${summary ? "; checkpoint received" : hard ? "; hard limit, no checkpoint" : "; no checkpoint"}`);
|
|
215
246
|
await this.finishHandoff(peer, summary);
|
|
216
247
|
} finally {
|
|
217
|
-
this.pausing.delete(peer);
|
|
248
|
+
if (this.pausing.get(peer) === evaluation) this.pausing.delete(peer);
|
|
218
249
|
}
|
|
219
250
|
}
|
|
220
251
|
|
|
@@ -285,6 +316,7 @@ export class Budget {
|
|
|
285
316
|
if (!record) return;
|
|
286
317
|
this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(peer);
|
|
287
318
|
this.readings.get(peer)?.clear(); // the old numbers describe the window that just ended
|
|
319
|
+
this.resetBounds.delete(peer);
|
|
288
320
|
// The notice is queued while the peer is still paused, so it leads the first delivery: the peer learns what moved
|
|
289
321
|
// before it touches anything that was waiting for it.
|
|
290
322
|
this.hooks.resumed(record);
|
package/src/hub/bus.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
|
|
2
|
-
import type { PeerAdapter } from "./peers.ts";
|
|
2
|
+
import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
|
|
3
3
|
import { DeliveryJournal, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
|
|
4
4
|
|
|
5
|
-
export
|
|
6
|
-
id: string;
|
|
7
|
-
state: "accepted" | "completed" | "failed_safe" | "needs_review";
|
|
8
|
-
reason?: string;
|
|
9
|
-
}
|
|
5
|
+
export type { DeliveryReceipt } from "./peers.ts";
|
|
10
6
|
|
|
11
7
|
export type BusEvent =
|
|
12
8
|
| { t: "envelope"; env: Envelope; dropped?: "hop" | "fyi" }
|
|
@@ -17,6 +13,8 @@ export type BusEvent =
|
|
|
17
13
|
| { t: "state"; peer: PeerId; state: PeerState };
|
|
18
14
|
|
|
19
15
|
export interface BusOptions {
|
|
16
|
+
/** Transient owner lifecycle fence: retain new work without changing console/budget pauses. */
|
|
17
|
+
deliveryHeld?: (peer: PeerId) => boolean;
|
|
20
18
|
/** Observe original envelopes immediately before a transport starts its turn. */
|
|
21
19
|
onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
|
|
22
20
|
retryMs: number;
|
|
@@ -193,7 +191,7 @@ export class Bus {
|
|
|
193
191
|
if (hushed.length) this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}`));
|
|
194
192
|
return heldBack ? hushed[0]!.reason : undefined;
|
|
195
193
|
};
|
|
196
|
-
peer.onFailed = (envs) => {
|
|
194
|
+
peer.onFailed = (envs, failure) => {
|
|
197
195
|
// The adapter got the condensed list; what has to come back is what that list replaced.
|
|
198
196
|
const last = this.lastDelivery.get(peer.id);
|
|
199
197
|
const same = !!last && envs.length === last.out.length && envs.every((e, i) => e.id === last.out[i]!.id);
|
|
@@ -205,7 +203,7 @@ export class Bus {
|
|
|
205
203
|
this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence" });
|
|
206
204
|
}
|
|
207
205
|
}
|
|
208
|
-
} else this.failed(peer.id, same ? last.originals : envs);
|
|
206
|
+
} else this.failed(peer.id, same ? last.originals : envs, undefined, failure?.quota === true);
|
|
209
207
|
};
|
|
210
208
|
peer.onState = () => {
|
|
211
209
|
if (this.closed) return;
|
|
@@ -421,7 +419,7 @@ export class Bus {
|
|
|
421
419
|
this.journal.transition(id, state, receipt.reason);
|
|
422
420
|
if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) this.delivered(peer, before.originals);
|
|
423
421
|
if (state === "failed") {
|
|
424
|
-
if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason);
|
|
422
|
+
if (before) this.failed(peer, before.originals, before.originals.some((e) => e.private) ? "private delivery failed" : receipt.reason, receipt.quota === true && before.state === "dispatching");
|
|
425
423
|
}
|
|
426
424
|
if (state === "completed") { this.failureStreak.delete(peer); this.lastFailure.delete(peer); }
|
|
427
425
|
if (state === "completed" || state === "failed" || state === "needs_review") this.activeDeliveries.delete(id);
|
|
@@ -548,7 +546,7 @@ export class Bus {
|
|
|
548
546
|
try { for (const id of targets) {
|
|
549
547
|
const peer = this.peers.get(id);
|
|
550
548
|
if (!peer) { this.enqueue(id, env); continue; }
|
|
551
|
-
if (!this.journal && !this.recoveryHeld && !this.recoveryHeldPeers.has(id) && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
|
|
549
|
+
if (!this.journal && !this.recoveryHeld && !this.recoveryHeldPeers.has(id) && !this.opts.deliveryHeld?.(id) && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
|
|
552
550
|
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
553
551
|
this.steering++;
|
|
554
552
|
const deliveryId = crypto.randomUUID();
|
|
@@ -568,7 +566,7 @@ export class Bus {
|
|
|
568
566
|
this.onQueues?.();
|
|
569
567
|
for (const id of targets) {
|
|
570
568
|
const peer = this.peers.get(id);
|
|
571
|
-
if (!this.recoveryHeld && !this.recoveryHeldPeers.has(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
|
|
569
|
+
if (!this.recoveryHeld && !this.recoveryHeldPeers.has(id) && !this.opts.deliveryHeld?.(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
|
|
572
570
|
else void this.drain(id);
|
|
573
571
|
}
|
|
574
572
|
}
|
|
@@ -732,7 +730,7 @@ export class Bus {
|
|
|
732
730
|
|
|
733
731
|
/** Holds on a peer's queue, for a drain and a pull alike: recovery, an uncertain delivery, a pause (console, budget, conductor). */
|
|
734
732
|
private held(id: PeerId): boolean {
|
|
735
|
-
return this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id);
|
|
733
|
+
return this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
|
|
736
734
|
}
|
|
737
735
|
|
|
738
736
|
/** What a peer was just handed stays resolvable for `reply_to`: moved to the newest end, the stored envelope kept. */
|
|
@@ -904,10 +902,12 @@ export class Bus {
|
|
|
904
902
|
return [...this.activeDeliveries.values()].includes(peer) || this.draining.has(peer);
|
|
905
903
|
}
|
|
906
904
|
|
|
907
|
-
private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail"): void {
|
|
905
|
+
private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail", quota = false): void {
|
|
908
906
|
if (!this.journal) this.deliveryFailed(id);
|
|
907
|
+
// A quota refusal ran no work. The budget pause fences retries until reset; it is not a poison attempt.
|
|
908
|
+
const quotaPaused = quota && this.paused.has(id);
|
|
909
909
|
this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
|
|
910
|
-
if (envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
|
|
910
|
+
if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
|
|
911
911
|
this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
|
|
912
912
|
}
|
|
913
913
|
const keep = envs.filter((env) => {
|
|
@@ -916,6 +916,7 @@ export class Bus {
|
|
|
916
916
|
this.restorePreface(id, env); // rides again on the next delivery, without counting as a failed envelope
|
|
917
917
|
return false;
|
|
918
918
|
}
|
|
919
|
+
if (quotaPaused) return true;
|
|
919
920
|
const key = `${id}:${env.id}`;
|
|
920
921
|
const n = (this.attempts.get(key) ?? 0) + 1;
|
|
921
922
|
if (n < MAX_ATTEMPTS) return this.attempts.set(key, n);
|
package/src/hub/child-process.ts
CHANGED
|
@@ -11,6 +11,17 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
|
|
|
11
11
|
return env;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* #269: the environment of a terminal the hub opens for a person: no agent marker and no hub-run flag, so `ahub`
|
|
16
|
+
* typed or started there has the console's identity, whatever started the hub.
|
|
17
|
+
*/
|
|
18
|
+
export function personEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
19
|
+
const env = childEnv(source);
|
|
20
|
+
// The Orca terminal the hub itself was started in is not this new terminal: a launch there must not record it as its own.
|
|
21
|
+
for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED", "ORCA_TERMINAL_HANDLE", "ORCA_WORKTREE_ID"]) delete env[name];
|
|
22
|
+
return env;
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
/** Every native child owns its marker; caller vendor markers must not leak across peers (#193). */
|
|
15
26
|
export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
16
27
|
const env = childEnv(source);
|
package/src/hub/conductor.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
|
4
4
|
import { HUB, USER } from "./envelope.ts";
|
|
5
5
|
import { OWNERSHIP_EVENTS } from "./tasks.ts";
|
|
6
6
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
7
|
+
import { isStartablePeer, type StartablePeer, type StartMode } from "./start-mode.ts";
|
|
7
8
|
import type { HubEvent } from "./events.ts";
|
|
8
9
|
import type { SupervisionFeed } from "./supervision.ts";
|
|
9
10
|
import type { Budget } from "./budget.ts";
|
|
11
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
10
12
|
|
|
11
13
|
/** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
|
|
12
14
|
export function conductorProgressSink(record: (event: HubEvent) => void, feed: Pick<SupervisionFeed, "milestone">): (event: HubEvent) => void {
|
|
@@ -65,6 +67,7 @@ export class ConductorHolds {
|
|
|
65
67
|
private readonly db: Database;
|
|
66
68
|
constructor(dbPath: string, private readonly now: () => number = Date.now) {
|
|
67
69
|
this.db = new Database(dbPath, { create: true });
|
|
70
|
+
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
68
71
|
this.db.run("CREATE TABLE IF NOT EXISTS conductor_holds (peer TEXT PRIMARY KEY, actor TEXT NOT NULL, since INTEGER NOT NULL)");
|
|
69
72
|
this.db.run("CREATE TABLE IF NOT EXISTS supervision_rounds (peer TEXT PRIMARY KEY, signature TEXT NOT NULL)");
|
|
70
73
|
}
|
|
@@ -100,13 +103,18 @@ export class ConductorHolds {
|
|
|
100
103
|
close(): void { this.db.close(); }
|
|
101
104
|
}
|
|
102
105
|
|
|
103
|
-
export type ConductorStart = { peer:
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
|
|
107
|
+
/**
|
|
108
|
+
* What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
|
|
109
|
+
* only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
|
|
110
|
+
*/
|
|
111
|
+
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: StartablePeer) => StartMode): ConductorStart {
|
|
112
|
+
const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
|
|
113
|
+
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
|
|
114
|
+
if (!isStartablePeer(peer)) throw refuse("only claude, codex, kimi, pi and local may be started by the conductor");
|
|
115
|
+
const own = startMode(peer);
|
|
116
|
+
if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
|
|
117
|
+
return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer, mode: "headless" };
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
@@ -151,7 +159,10 @@ export interface ConductorHooks {
|
|
|
151
159
|
assign(actor: string, id: number, peer: string): Promise<unknown>;
|
|
152
160
|
escalate(actor: string, id: number): Promise<unknown>;
|
|
153
161
|
preview(peer: "claude" | "codex" | "pi"): string;
|
|
154
|
-
start
|
|
162
|
+
/** The mode a start of this peer takes: a person's setting. */
|
|
163
|
+
startMode(peer: StartablePeer): StartMode;
|
|
164
|
+
/** Starts the peer in that mode: headless at once, or its fixed command in a terminal; throws with the command to run by hand when no terminal can be opened. */
|
|
165
|
+
start(peer: StartablePeer): Promise<unknown>;
|
|
155
166
|
known(peer: string): boolean;
|
|
156
167
|
pause(peer: string): void;
|
|
157
168
|
validateRelease?(peer: string): Promise<void>;
|
|
@@ -211,9 +222,10 @@ export class Conductor {
|
|
|
211
222
|
return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
|
|
212
223
|
}
|
|
213
224
|
if (tool === "hub_peer_start") {
|
|
214
|
-
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true);
|
|
215
|
-
|
|
216
|
-
|
|
225
|
+
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode);
|
|
226
|
+
const result = await this.hooks.start(start.peer);
|
|
227
|
+
const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
|
|
228
|
+
emit({ peer: start.peer }); return { ...start, ...opened };
|
|
217
229
|
}
|
|
218
230
|
const peer = peerId(args.peer, true);
|
|
219
231
|
if (!this.hooks.known(peer)) throw new PreEffectToolRefusal(`unknown peer: ${peer}`);
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { statSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { childEnv } from "./child-process.ts";
|
|
4
|
+
import { hubGitSync } from "./git.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Fields of the project config that choose what the hub runs, which files it sends as credentials, where task text
|
|
@@ -26,6 +26,7 @@ export const MACHINE_LOCAL = [
|
|
|
26
26
|
"local.read_allow",
|
|
27
27
|
"local.bash_network",
|
|
28
28
|
"local.network_allow", // the hosts the egress proxy opens (#65)
|
|
29
|
+
"terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
|
|
29
30
|
] as const;
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -35,17 +36,28 @@ export const MACHINE_LOCAL = [
|
|
|
35
36
|
* spellings git keeps apart (letter case, and folds such as "ſ" for "s").
|
|
36
37
|
*/
|
|
37
38
|
export function configRefusal(cwd: string, name: string): string | undefined {
|
|
38
|
-
const
|
|
39
|
-
|
|
39
|
+
const tracked = configTracked(cwd, name);
|
|
40
|
+
return tracked === undefined ? `git could not confirm that .agenthub/${name} is untracked` : tracked ? `.agenthub/${name} is committed to git` : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether git tracks `.agenthub/<name>` (or `.agenthub` itself); undefined when git cannot say. */
|
|
44
|
+
export function configTracked(cwd: string, name: string): boolean | undefined {
|
|
45
|
+
const r = hubGitSync(["-C", cwd, "ls-files", "-s", "-z", "--", ":(icase).agenthub"], { encoding: "utf8", env: childEnv(process.env) });
|
|
46
|
+
if (r.status !== 0) return undefined;
|
|
40
47
|
let opened: { dev: number; ino: number } | undefined;
|
|
41
48
|
try {
|
|
42
49
|
opened = statSync(join(cwd, ".agenthub", name));
|
|
43
50
|
} catch {
|
|
44
51
|
// not there: only the directory entry can still matter
|
|
45
52
|
}
|
|
46
|
-
|
|
53
|
+
return r.stdout.split("\0").filter(Boolean).some((row) => {
|
|
47
54
|
const path = row.slice(row.indexOf("\t") + 1);
|
|
48
55
|
if (path.toLowerCase() === ".agenthub") return true;
|
|
56
|
+
// A file that is not there has no identity to match: the index entry of this exact name is the one a write would
|
|
57
|
+
// change. Another spelling is another file where the disk keeps them apart.
|
|
58
|
+
// ponytail: on a disk that folds names, an entry tracked under another spelling and deleted from the work tree is
|
|
59
|
+
// not seen here; probe the disk's folding and compare folded names if that case ever matters.
|
|
60
|
+
if (!opened) return path === `.agenthub/${name}`;
|
|
49
61
|
try {
|
|
50
62
|
const s = statSync(join(cwd, path));
|
|
51
63
|
return !!opened && s.dev === opened.dev && s.ino === opened.ino;
|
|
@@ -53,7 +65,6 @@ export function configRefusal(cwd: string, name: string): string | undefined {
|
|
|
53
65
|
return false;
|
|
54
66
|
}
|
|
55
67
|
});
|
|
56
|
-
return tracked ? `.agenthub/${name} is committed to git` : undefined;
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
const valueAt = (o: unknown, path: string): unknown => path.split(".").reduce<unknown>((v, k) => (v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined), o);
|
package/src/hub/conflicts.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import type { Task } from "./board.ts";
|
|
4
4
|
import { samePlace } from "./tasks.ts";
|
|
5
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
5
6
|
|
|
6
7
|
/** A file an owner's open task changed, as the turn snapshots saw it (issue #32). */
|
|
7
8
|
export interface Touch {
|
|
@@ -39,6 +40,7 @@ export function pathWarnings(dbFile: string, peer: string, file: { project: stri
|
|
|
39
40
|
if (!existsSync(dbFile)) return [];
|
|
40
41
|
const db = new Database(dbFile, { readonly: true });
|
|
41
42
|
try {
|
|
43
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
42
44
|
const rows = db.query("SELECT * FROM tasks WHERE owner IS NOT NULL AND owner != ? AND state IN ('proposed', 'in_progress', 'in_review', 'changes_requested') ORDER BY id").all(peer) as Record<string, string | number | null>[];
|
|
43
45
|
let touches: Touch[] = [];
|
|
44
46
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Native active-context occupancy, separate from accumulated billable usage (#185). */
|
|
2
2
|
export interface ContextReading {
|
|
3
|
-
source: "claude_statusline" | "codex_token_usage";
|
|
3
|
+
source: "claude_statusline" | "codex_token_usage" | "acp_usage_update";
|
|
4
4
|
sessionId: string;
|
|
5
5
|
measuredAt: number;
|
|
6
6
|
tokens: number | null;
|
|
@@ -37,6 +37,13 @@ export function codexContext(value: unknown, sessionId: string, measuredAt: numb
|
|
|
37
37
|
const tokens = nonnegative(last.totalTokens) ? last.totalTokens : null;
|
|
38
38
|
return { source: "codex_token_usage", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
|
|
39
39
|
}
|
|
40
|
+
/** An ACP `usage_update` of the `{ used, size }` shape (Kimi 2.x): context occupancy, not consumption (#167, #285). */
|
|
41
|
+
export function acpContext(usage: { used: number; size: number }, sessionId: string, measuredAt: number): ContextReading {
|
|
42
|
+
const window = positive(usage.size) ? usage.size : null;
|
|
43
|
+
const tokens = nonnegative(usage.used) ? usage.used : null;
|
|
44
|
+
// If a report ever shows used > size, the fraction clamps so a bar renders; tokens keeps the reported number.
|
|
45
|
+
return { source: "acp_usage_update", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
|
|
46
|
+
}
|
|
40
47
|
export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
|
|
41
48
|
/** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
|
|
42
49
|
export class ContextWindows {
|
package/src/hub/crash.ts
CHANGED
|
@@ -72,7 +72,8 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
|
|
|
72
72
|
const fresh: Record<string, string> = {};
|
|
73
73
|
for (const k of ["backend", "model"] as const) if (str(launch[k])) fresh[k] = str(launch[k])!;
|
|
74
74
|
const sessionFile = str(meta.sessionFile) ?? str(launch.sessionFile);
|
|
75
|
-
|
|
75
|
+
// #269: a terminal Pi stays marked as one without a session file too, so nothing headless is started in its place.
|
|
76
|
+
if (!sessionFile) return { peer, fresh, ...(str(launch.mode) === "tui" ? { tui: true } : {}), how: "pi: no session file was recorded; start it again with ahub pi" };
|
|
76
77
|
// A terminal Pi is run by the CLI that launched it, not by the hub: nothing here could start it again.
|
|
77
78
|
if (str(launch.mode) === "tui") return { peer, fresh, tui: true, how: `pi: it ran in a terminal; start it again with ahub pi --mode tui --session-file ${sessionFile}` };
|
|
78
79
|
const args: Record<string, string> = { sessionFile };
|