@staix/agent-hub 0.12.30 → 0.12.31
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 +21 -0
- package/README.md +21 -7
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +4 -0
- package/docs/agent-notes/daemon.md +5 -2
- package/docs/agent-notes/local-worker.md +2 -0
- package/docs/agent-notes/tasks.md +5 -0
- package/docs/agent-notes/tests.md +5 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +129 -22
- package/docs/quickstart.md +17 -2
- package/docs/security.md +30 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +37 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +66 -19
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +56 -12
- package/src/cli/console.ts +19 -6
- package/src/cli/identity.ts +3 -2
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.ts +8 -1
- package/src/cli/output.ts +13 -2
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +162 -58
- package/src/cli/upgrade.ts +49 -24
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +78 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +400 -67
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +173 -34
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +12 -9
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sanitize, type Kind } from "./envelope.ts";
|
|
2
|
+
|
|
3
|
+
export const DELIVERY_RETRY_WARNING = sanitize("Hub redelivery warning: An earlier attempt of this delivery is uncertain; check for its effects before acting.");
|
|
4
|
+
export interface HeldDeliveryHints {
|
|
5
|
+
count: number;
|
|
6
|
+
rows: { id: string; senders: string[]; kinds: Kind[]; createdAt: number }[];
|
|
7
|
+
truncated: boolean;
|
|
8
|
+
}
|
|
9
|
+
export function heldDeliveryHints(rows: readonly HeldDeliveryHints["rows"][number][], count = rows.length): HeldDeliveryHints {
|
|
10
|
+
const result: HeldDeliveryHints = { count, rows: [], truncated: count > rows.length };
|
|
11
|
+
for (const row of rows) {
|
|
12
|
+
const value = { ...row, senders: row.senders.map(sanitize) };
|
|
13
|
+
if (result.rows.length >= 32 || Buffer.byteLength(JSON.stringify({ ...result, rows: [...result.rows, value] }), "utf8") > 3500) { result.truncated = true; break; }
|
|
14
|
+
result.rows.push(value);
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
export function renderHeldHint(value: unknown, now = Date.now(), recipientRetry = true): string {
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
|
20
|
+
const held = value as HeldDeliveryHints;
|
|
21
|
+
const kinds = new Set(["chat", "task", "review", "status", "budget", "presence"]);
|
|
22
|
+
try {
|
|
23
|
+
if (!Number.isSafeInteger(held.count) || held.count < 1 || !Array.isArray(held.rows) || held.rows.length > 32 || held.rows.length > held.count || typeof held.truncated !== "boolean" || Buffer.byteLength(JSON.stringify(held), "utf8") > 4096) return "";
|
|
24
|
+
if (held.rows.some(row => !row || typeof row !== "object" || typeof row.id !== "string" || !row.id.length || row.id.length > 256 || !Number.isFinite(row.createdAt) || !Array.isArray(row.senders) || !Array.isArray(row.kinds) || row.senders.some(sender => typeof sender !== "string" || sender.length > 256) || row.kinds.some(kind => !kinds.has(kind)))) return "";
|
|
25
|
+
} catch { return ""; }
|
|
26
|
+
const rows = held.rows.map(row => `${sanitize(row.id)} from ${row.senders.map(sanitize).join(",")} kind ${row.kinds.join(",")} age ${Math.max(0, Math.floor((now - row.createdAt) / 1000))}s`);
|
|
27
|
+
const action = recipientRetry ? "Use hub_delivery_retry {delivery_id} for one retry of your own uncertain row; check for earlier effects. A repeated uncertain retry needs the person." : "Ask the person to resolve these held deliveries; this delivery-only peer cannot initiate recipient retry.";
|
|
28
|
+
const out = `Held hub deliveries: ${held.count}${held.truncated ? " (metadata truncated)" : ""}\n${rows.join("\n")}\n${action}`;
|
|
29
|
+
return Buffer.byteLength(out, "utf8") <= 4096 ? out : `Held hub deliveries: ${held.count} (metadata truncated; ask the person to inspect the queue).`;
|
|
30
|
+
}
|
|
@@ -2,7 +2,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { Database } from "bun:sqlite";
|
|
4
4
|
import type { BusSnapshot } from "./bus.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { HUB, USER, type Envelope, type Kind } from "./envelope.ts";
|
|
6
|
+
import { heldDeliveryHints } from "./delivery-hints.ts";
|
|
6
7
|
import { processLiveness } from "../pi/process-signature.ts";
|
|
7
8
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
8
9
|
|
|
@@ -15,6 +16,7 @@ export type JournalDeliveryState =
|
|
|
15
16
|
| "failed"
|
|
16
17
|
| "discarded";
|
|
17
18
|
export type JournalResolution = "completed" | "retry" | "discard";
|
|
19
|
+
export const MAX_ENVELOPE_BODY = 1_000_000;
|
|
18
20
|
const SCHEMA_VERSION = 1,
|
|
19
21
|
TERMINAL_CAP = 2048,
|
|
20
22
|
MAX_ROWS = 100_000,
|
|
@@ -29,6 +31,8 @@ const STATES = new Set<JournalDeliveryState>([
|
|
|
29
31
|
"discarded",
|
|
30
32
|
]);
|
|
31
33
|
const ACTIONS = new Set<JournalResolution>(["completed", "retry", "discard"]);
|
|
34
|
+
// Hub presence ids survive context merges; they are not immutable attempted work.
|
|
35
|
+
const retryOriginals = (originals: readonly Envelope[]): Envelope[] => originals.filter(env => env.from !== HUB || env.kind !== "presence");
|
|
32
36
|
|
|
33
37
|
export interface JournalDelivery {
|
|
34
38
|
id: string;
|
|
@@ -108,7 +112,7 @@ function validateEnvelope(value: unknown, name: string, seen = false): Envelope
|
|
|
108
112
|
text(e.trace, `${name}.trace`, 256);
|
|
109
113
|
text(e.from, `${name}.from`, 256);
|
|
110
114
|
integer(e.hop, `${name}.hop`, 0, seen ? Number.MAX_SAFE_INTEGER : 3);
|
|
111
|
-
if (typeof e.body !== "string" || e.body.length >
|
|
115
|
+
if (typeof e.body !== "string" || e.body.length > MAX_ENVELOPE_BODY) fail(`${name}.body is invalid`);
|
|
112
116
|
integer(e.ts, `${name}.ts`);
|
|
113
117
|
if (
|
|
114
118
|
!new Set(["chat", "task", "review", "status", "budget", "presence"]).has(
|
|
@@ -249,7 +253,7 @@ export class DeliveryJournal {
|
|
|
249
253
|
if (!meta)
|
|
250
254
|
this.db
|
|
251
255
|
.query(
|
|
252
|
-
"INSERT INTO delivery_meta (project_id, project_root, revision, instance_id, operation_id) VALUES (?, ?, 0, ?,
|
|
256
|
+
"INSERT INTO delivery_meta (project_id, project_root, revision, instance_id, operation_id, retry_history_cursor) VALUES (?, ?, 0, ?, ?, 0)",
|
|
253
257
|
)
|
|
254
258
|
.run(
|
|
255
259
|
this.projectId,
|
|
@@ -273,6 +277,9 @@ export class DeliveryJournal {
|
|
|
273
277
|
);
|
|
274
278
|
// A new daemon cannot infer completion of an old process's handoffs, including
|
|
275
279
|
// controlled restarts with silent native/bridge receipts still outstanding.
|
|
280
|
+
for (const row of this.db.query("SELECT * FROM task_offer_reads WHERE project_id = ?").all(this.projectId) as Record<string, unknown>[]) this.validateOfferRead(row);
|
|
281
|
+
this.recoverRetryHistory(meta);
|
|
282
|
+
this.recoverRetryEvidence();
|
|
276
283
|
this.recoverInterrupted();
|
|
277
284
|
})();
|
|
278
285
|
}
|
|
@@ -296,6 +303,8 @@ export class DeliveryJournal {
|
|
|
296
303
|
["project_root", "TEXT"],
|
|
297
304
|
["schema_version", "INTEGER NOT NULL DEFAULT 1"],
|
|
298
305
|
["operation_id", "TEXT"],
|
|
306
|
+
["retry_unknown_ids", "TEXT NOT NULL DEFAULT '{}'"],
|
|
307
|
+
["retry_history_cursor", "INTEGER NOT NULL DEFAULT 0"],
|
|
299
308
|
] as const) {
|
|
300
309
|
const columns = this.db
|
|
301
310
|
.query("PRAGMA table_info(delivery_meta)")
|
|
@@ -308,9 +317,58 @@ export class DeliveryJournal {
|
|
|
308
317
|
this.db.run(
|
|
309
318
|
`CREATE TABLE IF NOT EXISTS deliveries (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, peer TEXT NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, originals TEXT NOT NULL, out_json TEXT NOT NULL, reason TEXT, attempt INTEGER, previous_id TEXT)`,
|
|
310
319
|
);
|
|
320
|
+
this.db.run("CREATE TABLE IF NOT EXISTS task_offer_reads (project_id TEXT NOT NULL, peer TEXT NOT NULL, envelope_id TEXT NOT NULL, delivery_id TEXT NOT NULL, source TEXT NOT NULL CHECK(source IN ('adapter','reply','pull')), offer_revision INTEGER NOT NULL CHECK(offer_revision >= 1), PRIMARY KEY(project_id,peer,envelope_id))");
|
|
311
321
|
this.db.run(
|
|
312
322
|
`CREATE TABLE IF NOT EXISTS resolution_history (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, delivery_id TEXT NOT NULL, observed_revision INTEGER NOT NULL, action TEXT NOT NULL, reason TEXT NOT NULL, result_revision INTEGER NOT NULL, result_state TEXT NOT NULL, UNIQUE(project_id, delivery_id, observed_revision, action, reason))`,
|
|
313
323
|
);
|
|
324
|
+
this.db.run("CREATE TABLE IF NOT EXISTS delivery_retry_evidence (project_id TEXT NOT NULL, peer TEXT NOT NULL, original_id TEXT NOT NULL, proof_id TEXT NOT NULL, warning INTEGER NOT NULL CHECK(warning IN (0,1)), PRIMARY KEY(project_id, peer, original_id))");
|
|
325
|
+
this.db.transaction(() => {
|
|
326
|
+
const resolutionColumns = this.db.query("PRAGMA table_info(resolution_history)").all() as { name: string }[];
|
|
327
|
+
if (!resolutionColumns.some((column) => column.name === "actor")) this.db.run("ALTER TABLE resolution_history ADD COLUMN actor TEXT NOT NULL DEFAULT 'user'");
|
|
328
|
+
if (!resolutionColumns.some((column) => column.name === "peer")) this.db.run("ALTER TABLE resolution_history ADD COLUMN peer TEXT NOT NULL DEFAULT ''");
|
|
329
|
+
if (!resolutionColumns.some((column) => column.name === "original_ids")) this.db.run("ALTER TABLE resolution_history ADD COLUMN original_ids TEXT NOT NULL DEFAULT '[]'");
|
|
330
|
+
this.db.run("CREATE INDEX IF NOT EXISTS resolution_retry_peer ON resolution_history(project_id, peer, action)");
|
|
331
|
+
this.db.run("CREATE INDEX IF NOT EXISTS deliveries_peer ON deliveries(project_id, peer, state)");
|
|
332
|
+
})();
|
|
333
|
+
}
|
|
334
|
+
private recoverRetryHistory(meta: Record<string, unknown>): void {
|
|
335
|
+
const cursor = Number(meta.retry_history_cursor ?? 0);
|
|
336
|
+
const latest = Number((this.db.query("SELECT COALESCE(MAX(id), 0) AS n FROM resolution_history WHERE project_id = ?").get(this.projectId) as { n: number }).n);
|
|
337
|
+
if (latest > cursor) {
|
|
338
|
+
const legacy = this.db.query("SELECT r.id,d.peer,d.originals FROM resolution_history r LEFT JOIN deliveries d ON d.id = r.delivery_id AND d.project_id = r.project_id WHERE r.project_id = ? AND r.peer = '' AND r.id > ?").all(this.projectId, cursor) as { id: number; peer: string | null; originals: string | null }[];
|
|
339
|
+
for (const row of legacy) if (row.peer && row.originals) this.db.query("UPDATE resolution_history SET peer = ?, original_ids = ? WHERE project_id = ? AND id = ?").run(row.peer, JSON.stringify(retryOriginals(JSON.parse(row.originals) as Envelope[]).map(env => env.id)), this.projectId, row.id);
|
|
340
|
+
// A pruned pre-feature retry cannot be mapped back to its originals. Fence current legacy work only.
|
|
341
|
+
if (this.db.query("SELECT 1 FROM resolution_history WHERE project_id = ? AND action = 'retry' AND peer = '' AND id > ? LIMIT 1").get(this.projectId, cursor)) {
|
|
342
|
+
this.markRetryUnknown(this.list(), JSON.parse(String(meta.bus_snapshot || '{}')));
|
|
343
|
+
}
|
|
344
|
+
this.db.query("UPDATE delivery_meta SET retry_history_cursor = ? WHERE project_id = ?").run(latest, this.projectId);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
private validateRetryProof(row: Record<string, unknown>): void {
|
|
348
|
+
text(row.peer, "retry proof peer", 256); text(row.original_id, "retry proof original", 256); text(row.proof_id, "retry proof id", 256);
|
|
349
|
+
integer(row.warning, "retry proof warning", 0, 1);
|
|
350
|
+
}
|
|
351
|
+
private retryProof(peer: string, originals: readonly Envelope[]): { proof: string; warning: boolean } | undefined {
|
|
352
|
+
originals = retryOriginals(originals);
|
|
353
|
+
if (!originals.length) return undefined;
|
|
354
|
+
const rows = this.db.query("SELECT peer,original_id,proof_id,warning FROM delivery_retry_evidence WHERE project_id = ? AND peer = ? AND original_id IN (SELECT value FROM json_each(?))").all(this.projectId, peer, JSON.stringify(originals.map(env => env.id))) as Record<string, unknown>[];
|
|
355
|
+
for (const row of rows) this.validateRetryProof(row);
|
|
356
|
+
return rows.length ? { proof: String(rows[0]!.proof_id), warning: rows.some(row => row.warning === 1) } : undefined;
|
|
357
|
+
}
|
|
358
|
+
private recordRetryEvidence(peer: string, originals: readonly Envelope[], proof: string, warning: boolean): void {
|
|
359
|
+
const put = this.db.query("INSERT INTO delivery_retry_evidence (project_id,peer,original_id,proof_id,warning) VALUES (?,?,?,?,?) ON CONFLICT(project_id,peer,original_id) DO UPDATE SET warning = MAX(warning,excluded.warning)");
|
|
360
|
+
for (const id of new Set(retryOriginals(originals).map(env => env.id))) put.run(this.projectId, peer, id, proof, warning ? 1 : 0);
|
|
361
|
+
}
|
|
362
|
+
private recoverRetryEvidence(): void {
|
|
363
|
+
for (const row of this.db.query("SELECT peer,original_id,proof_id,warning FROM delivery_retry_evidence WHERE project_id = ?").all(this.projectId) as Record<string, unknown>[]) this.validateRetryProof(row);
|
|
364
|
+
// An older writer can append attempt-bearing rows while this writer is absent.
|
|
365
|
+
const rows = this.db.query("SELECT d.id,d.peer,d.originals FROM deliveries d WHERE d.project_id = ? AND d.attempt > 0 AND EXISTS (SELECT 1 FROM json_each(d.originals) env WHERE NOT EXISTS (SELECT 1 FROM delivery_retry_evidence e WHERE e.project_id = d.project_id AND e.peer = d.peer AND e.original_id = json_extract(env.value,'$.id')))").all(this.projectId) as { id: string; peer: string; originals: string }[];
|
|
366
|
+
for (const row of rows) {
|
|
367
|
+
const originals = retryOriginals(JSON.parse(row.originals) as Envelope[]);
|
|
368
|
+
if (!originals.length) continue;
|
|
369
|
+
const known = this.retryProof(row.peer, originals) || this.db.query("SELECT 1 FROM resolution_history r, json_each(r.original_ids) old, json_each(?) current WHERE r.project_id = ? AND r.peer = ? AND r.action = 'retry' AND old.value = current.value LIMIT 1").get(JSON.stringify(originals.map(env => env.id)), this.projectId, row.peer);
|
|
370
|
+
this.recordRetryEvidence(row.peer, originals, row.id, this.retryWarning(row.peer, originals) || !known);
|
|
371
|
+
}
|
|
314
372
|
}
|
|
315
373
|
private readMeta(): Record<string, unknown> | null {
|
|
316
374
|
return this.db
|
|
@@ -536,11 +594,20 @@ export class DeliveryJournal {
|
|
|
536
594
|
updatedAt: delivery.createdAt,
|
|
537
595
|
});
|
|
538
596
|
return this.db.transaction(() => {
|
|
539
|
-
const
|
|
597
|
+
const evidence = this.retryEvidence(delivery.peer, delivery.originals);
|
|
598
|
+
if (evidence === "used") {
|
|
599
|
+
// Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
|
|
600
|
+
this.recordRetryEvidence(delivery.peer, delivery.originals, delivery.id, this.retryWarning(delivery.peer, delivery.originals));
|
|
601
|
+
delivery = { ...delivery, attempt: Math.max(1, delivery.attempt ?? 0) };
|
|
602
|
+
} else if (evidence === "unknown") this.markRetryUnknown([{ ...delivery, revision: 0, updatedAt: delivery.createdAt }], {});
|
|
603
|
+
const ids = new Set(delivery.originals.map((env) => env.id)), immutableIds = new Set(retryOriginals(delivery.originals).map(env => env.id));
|
|
540
604
|
for (const row of this.list(delivery.peer)) {
|
|
541
605
|
if (row.state !== "queued" || !row.originals.some((env) => ids.has(env.id))) continue;
|
|
542
606
|
this.transition(row.id, "discarded", `grouped into delivery ${delivery.id}`);
|
|
543
|
-
|
|
607
|
+
if (retryOriginals(row.originals).some(env => immutableIds.has(env.id))) {
|
|
608
|
+
delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
|
|
609
|
+
if ((row.attempt ?? 0) > 0) delivery = { ...delivery, attempt: Math.max(delivery.attempt ?? 0, row.attempt!) };
|
|
610
|
+
}
|
|
544
611
|
}
|
|
545
612
|
const revision = this.bump();
|
|
546
613
|
const now = Date.now();
|
|
@@ -611,6 +678,32 @@ export class DeliveryJournal {
|
|
|
611
678
|
return { ...input, revision, updatedAt };
|
|
612
679
|
})();
|
|
613
680
|
}
|
|
681
|
+
private validateOfferRead(row: Record<string, unknown>): void {
|
|
682
|
+
text(row.peer, "offer read peer", 256); text(row.envelope_id, "offer read envelope", 256); text(row.delivery_id, "offer read delivery", 256);
|
|
683
|
+
integer(row.offer_revision, "offer read revision", 1);
|
|
684
|
+
if (!["adapter", "reply", "pull"].includes(String(row.source))) fail("offer read source is invalid");
|
|
685
|
+
}
|
|
686
|
+
/** Positive delivery provenance only; generic transitions and operator resolutions never call this. */
|
|
687
|
+
completeDelivered(deliveryId: string, peer: string, source: "adapter" | "reply" | "pull", reason?: string): JournalDelivery {
|
|
688
|
+
this.ensureOpen(); text(deliveryId, "delivery id", 256); text(peer, "delivery peer", 256);
|
|
689
|
+
if (peer === HUB || peer === USER || !["adapter", "reply", "pull"].includes(source)) throw new Error("invalid delivered completion source");
|
|
690
|
+
if (reason !== undefined) text(reason, "delivery completion reason", 1_000_000);
|
|
691
|
+
return this.db.transaction((): JournalDelivery => {
|
|
692
|
+
const row = this.get(deliveryId);
|
|
693
|
+
if (!row || row.peer !== peer || !["dispatching", "accepted"].includes(row.state)) throw new Error("delivery is not active for this peer");
|
|
694
|
+
const completed = this.transition(deliveryId, "completed", reason, row.revision);
|
|
695
|
+
const put = this.db.query("INSERT OR IGNORE INTO task_offer_reads (project_id,peer,envelope_id,delivery_id,source,offer_revision) VALUES (?,?,?,?,?,?)");
|
|
696
|
+
for (const env of row.originals) if (env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.taskOffer) && env.refs!.taskOffer! >= 1) put.run(this.projectId, peer, env.id, deliveryId, source, env.refs!.taskOffer!);
|
|
697
|
+
return completed;
|
|
698
|
+
})();
|
|
699
|
+
}
|
|
700
|
+
/** Exact immutable offer id, independent of queue snapshots or generic completed rows. */
|
|
701
|
+
offerRead(peer: string, envelopeId: string): boolean {
|
|
702
|
+
this.ensureOpen(); text(peer, "offer read peer", 256); text(envelopeId, "offer read envelope", 256);
|
|
703
|
+
const row = this.db.query("SELECT * FROM task_offer_reads WHERE project_id = ? AND peer = ? AND envelope_id = ?").get(this.projectId, peer, envelopeId) as Record<string, unknown> | null;
|
|
704
|
+
if (!row) return false;
|
|
705
|
+
this.validateOfferRead(row); return true;
|
|
706
|
+
}
|
|
614
707
|
transition(
|
|
615
708
|
id: string,
|
|
616
709
|
state: JournalDeliveryState,
|
|
@@ -663,12 +756,85 @@ export class DeliveryJournal {
|
|
|
663
756
|
const rows = this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(this.projectId, peer) as Record<string, unknown>[];
|
|
664
757
|
return rows.map(parseDelivery);
|
|
665
758
|
}
|
|
759
|
+
heldDeliveryHints(peer: string) {
|
|
760
|
+
this.ensureOpen();
|
|
761
|
+
const count = Number((this.db.query("SELECT COUNT(*) AS n FROM deliveries WHERE project_id = ? AND peer = ? AND state = 'needs_review'").get(this.projectId, peer) as { n: number }).n);
|
|
762
|
+
const rows = this.db.query("SELECT d.id, d.created_at, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.from')) FROM json_each(d.originals) env) AS senders, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.kind')) FROM json_each(d.originals) env) AS kinds FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state = 'needs_review' ORDER BY d.created_at, d.id LIMIT 33").all(this.projectId, peer) as { id: string; created_at: number; senders: string; kinds: string }[];
|
|
763
|
+
return heldDeliveryHints(rows.map(row => ({ id: row.id, createdAt: row.created_at, senders: JSON.parse(row.senders) as string[], kinds: JSON.parse(row.kinds) as Kind[] })), count);
|
|
764
|
+
}
|
|
765
|
+
/** Explicit resolutions, not automatic safe failures, consume the immutable originals' retry lineage. */
|
|
766
|
+
retryEvidence(peer: string, originals: readonly Envelope[]): "unused" | "used" | "unknown" {
|
|
767
|
+
this.ensureOpen();
|
|
768
|
+
originals = retryOriginals(originals);
|
|
769
|
+
if (!originals.length) return "unused";
|
|
770
|
+
const ids = JSON.stringify([...new Set(originals.map(env => env.id))]);
|
|
771
|
+
const proof = this.retryProof(peer, originals);
|
|
772
|
+
if (this.retryUnknown(peer, new Set(originals.map(env => env.id)))) return "unknown";
|
|
773
|
+
if (proof) return "used";
|
|
774
|
+
if (this.db.query("SELECT 1 FROM resolution_history r, json_each(r.original_ids) old, json_each(?) current WHERE r.project_id = ? AND r.peer = ? AND r.action = 'retry' AND old.value = current.value LIMIT 1").get(ids, this.projectId, peer)) return "used";
|
|
775
|
+
const wanted = new Set(originals.map(env => env.id));
|
|
776
|
+
type Ancestor = { id: string; peer: string; attempt: number | null; previous_id: string | null; originals: string };
|
|
777
|
+
const related = this.db.query("SELECT d.id, d.peer, d.attempt, d.previous_id, d.originals FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state IN ('queued','dispatching','accepted','needs_review') AND EXISTS (SELECT 1 FROM json_each(d.originals) env, json_each(?) ids WHERE json_extract(env.value, '$.id') = ids.value)").all(this.projectId, peer, ids) as Ancestor[];
|
|
778
|
+
const retried = (id: string) => !!this.db.query("SELECT 1 FROM resolution_history WHERE project_id = ? AND delivery_id = ? AND action = 'retry' LIMIT 1").get(this.projectId, id);
|
|
779
|
+
const inspected = new Set<string>();
|
|
780
|
+
let unknown = false;
|
|
781
|
+
for (const row of related) {
|
|
782
|
+
const chain = new Set<string>();
|
|
783
|
+
let current: Ancestor | undefined = row;
|
|
784
|
+
while (current) {
|
|
785
|
+
if (!retryOriginals(JSON.parse(current.originals) as Envelope[]).some(env => wanted.has(env.id))) break;
|
|
786
|
+
if (chain.has(current.id)) { unknown = true; break; }
|
|
787
|
+
if (inspected.has(current.id)) break;
|
|
788
|
+
chain.add(current.id); inspected.add(current.id);
|
|
789
|
+
if ((current.attempt ?? 0) > 0 || retried(current.id)) return "used";
|
|
790
|
+
if (!current.previous_id) break;
|
|
791
|
+
const parent = current.previous_id;
|
|
792
|
+
current = this.db.query("SELECT id, peer, attempt, previous_id, originals FROM deliveries WHERE project_id = ? AND id = ?").get(this.projectId, parent) as Ancestor | undefined;
|
|
793
|
+
if (!current || current.peer !== peer) { unknown = true; break; }
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return unknown || this.retryUnknown(peer, wanted) ? "unknown" : "unused";
|
|
797
|
+
}
|
|
798
|
+
/** Console retries consume the allowance too, but only recipient-requested/unknown lineage adds a warning. */
|
|
799
|
+
retryWarning(peer: string, originals: readonly Envelope[]): boolean {
|
|
800
|
+
this.ensureOpen();
|
|
801
|
+
originals = retryOriginals(originals);
|
|
802
|
+
if (!originals.length) return false;
|
|
803
|
+
const wanted = new Set(originals.map(env => env.id));
|
|
804
|
+
const ids = JSON.stringify([...wanted]);
|
|
805
|
+
if (this.retryProof(peer, originals)?.warning) return true;
|
|
806
|
+
if (this.db.query("SELECT 1 FROM resolution_history r, json_each(r.original_ids) old, json_each(?) current WHERE r.project_id = ? AND r.peer = ? AND r.action = 'retry' AND r.actor = ? AND old.value = current.value LIMIT 1").get(ids, this.projectId, peer, `peer:${peer}`)) return true;
|
|
807
|
+
return this.retryUnknown(peer, wanted) || this.retryEvidence(peer, originals) === "unknown";
|
|
808
|
+
}
|
|
809
|
+
private retryUnknown(peer: string, wanted: ReadonlySet<string>): boolean {
|
|
810
|
+
try {
|
|
811
|
+
const map = JSON.parse(String(this.readMeta()?.retry_unknown_ids ?? '{}')) as Record<string, string[]>;
|
|
812
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) return true;
|
|
813
|
+
const own = Object.hasOwn(map, peer) ? map[peer] : [];
|
|
814
|
+
return !Array.isArray(own) || own.some(id => wanted.has(id));
|
|
815
|
+
} catch { return true; }
|
|
816
|
+
}
|
|
817
|
+
private markRetryUnknown(deliveries: readonly JournalDelivery[], bus: Partial<BusSnapshot>): void {
|
|
818
|
+
const map: Record<string, Set<string>> = Object.create(null);
|
|
819
|
+
const existing = JSON.parse(String(this.readMeta()?.retry_unknown_ids ?? '{}')) as Record<string, unknown>;
|
|
820
|
+
if (!existing || typeof existing !== "object" || Array.isArray(existing)) throw new Error("retry lineage metadata unavailable");
|
|
821
|
+
for (const [peer, ids] of Object.entries(existing)) {
|
|
822
|
+
if (!Array.isArray(ids) || ids.some(id => typeof id !== "string")) throw new Error("retry lineage metadata unavailable");
|
|
823
|
+
map[peer] = new Set(ids as string[]);
|
|
824
|
+
}
|
|
825
|
+
const add = (peer: string, envs: readonly Envelope[]) => { for (const env of retryOriginals(envs)) (map[peer] ??= new Set()).add(env.id); };
|
|
826
|
+
for (const row of deliveries) add(row.peer, row.originals);
|
|
827
|
+
for (const [peer, envs] of Object.entries(bus.queues ?? {})) add(peer, envs);
|
|
828
|
+
for (const [peer, env] of Object.entries(bus.prefaces ?? {})) add(peer, [env]);
|
|
829
|
+
this.db.query("UPDATE delivery_meta SET retry_unknown_ids = ? WHERE project_id = ?").run(JSON.stringify(Object.fromEntries(Object.entries(map).map(([peer, ids]) => [peer, [...ids]]))), this.projectId);
|
|
830
|
+
}
|
|
666
831
|
resolve(
|
|
667
832
|
id: string,
|
|
668
833
|
revision: number,
|
|
669
834
|
action: JournalResolution,
|
|
670
835
|
reason: string,
|
|
671
836
|
requestRevision = revision,
|
|
837
|
+
actor: string = "user",
|
|
672
838
|
): JournalDelivery {
|
|
673
839
|
this.ensureOpen();
|
|
674
840
|
text(reason, "resolution reason", 1_000_000);
|
|
@@ -678,19 +844,20 @@ export class DeliveryJournal {
|
|
|
678
844
|
const resolved = this.resolution(id);
|
|
679
845
|
if (resolved) {
|
|
680
846
|
const current = this.get(id);
|
|
681
|
-
if (current && resolved.action === action && resolved.reason === reason && (requestRevision === resolved.observedRevision || requestRevision === current.revision)) return current;
|
|
847
|
+
if (current && resolved.action === action && resolved.reason === reason && resolved.actor === actor && (requestRevision === resolved.observedRevision || requestRevision === current.revision)) return current;
|
|
682
848
|
throw new Error("stale delivery revision or conflicting operator resolution");
|
|
683
849
|
}
|
|
684
850
|
const prior = this.db
|
|
685
851
|
.query(
|
|
686
|
-
"SELECT result_revision FROM resolution_history WHERE project_id = ? AND delivery_id = ? AND observed_revision = ? AND action = ? AND reason = ?",
|
|
852
|
+
"SELECT result_revision FROM resolution_history WHERE project_id = ? AND delivery_id = ? AND observed_revision = ? AND action = ? AND reason = ? AND actor = ?",
|
|
687
853
|
)
|
|
688
|
-
.get(this.projectId, id, requestRevision, action, reason) as {
|
|
854
|
+
.get(this.projectId, id, requestRevision, action, reason, actor) as {
|
|
689
855
|
result_revision: number;
|
|
690
856
|
} | null;
|
|
691
857
|
if (prior) return this.get(id)!;
|
|
692
858
|
const current = this.get(id);
|
|
693
859
|
if (!current) throw new Error(`unknown delivery ${id}`);
|
|
860
|
+
if (actor.startsWith("peer:") && (action !== "retry" || current.peer !== actor.slice(5) || current.state !== "needs_review" || this.retryEvidence(current.peer, current.originals) !== "unused")) throw new Error("recipient retry unavailable; inspect the held delivery or ask the person");
|
|
694
861
|
if (current.revision !== revision)
|
|
695
862
|
throw new Error(`stale delivery revision for ${id}`);
|
|
696
863
|
if (
|
|
@@ -715,7 +882,7 @@ export class DeliveryJournal {
|
|
|
715
882
|
}
|
|
716
883
|
this.db
|
|
717
884
|
.query(
|
|
718
|
-
"INSERT INTO resolution_history (project_id, delivery_id, observed_revision, action, reason, result_revision, result_state) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
885
|
+
"INSERT INTO resolution_history (project_id, delivery_id, observed_revision, action, reason, result_revision, result_state, actor, peer, original_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
719
886
|
)
|
|
720
887
|
.run(
|
|
721
888
|
this.projectId,
|
|
@@ -725,14 +892,18 @@ export class DeliveryJournal {
|
|
|
725
892
|
reason,
|
|
726
893
|
result.revision,
|
|
727
894
|
result.state,
|
|
895
|
+
actor,
|
|
896
|
+
current.peer,
|
|
897
|
+
JSON.stringify(retryOriginals(current.originals).map(env => env.id)),
|
|
728
898
|
);
|
|
899
|
+
if (action === "retry") this.recordRetryEvidence(current.peer, current.originals, id, actor === `peer:${current.peer}`);
|
|
729
900
|
this.pruneTerminal();
|
|
730
901
|
return result;
|
|
731
902
|
})();
|
|
732
903
|
}
|
|
733
|
-
resolution(id: string): { action: JournalResolution; reason: string; observedRevision: number } | undefined {
|
|
734
|
-
const row = this.db.query("SELECT action,reason,observed_revision FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number } | null;
|
|
735
|
-
return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision } : undefined;
|
|
904
|
+
resolution(id: string): { action: JournalResolution; reason: string; observedRevision: number; actor: string } | undefined {
|
|
905
|
+
const row = this.db.query("SELECT action,reason,observed_revision,actor FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number; actor: string } | null;
|
|
906
|
+
return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision, actor: row.actor } : undefined;
|
|
736
907
|
}
|
|
737
908
|
private pruneTerminal(): void {
|
|
738
909
|
const count = Number(
|
|
@@ -801,6 +972,8 @@ export class DeliveryJournal {
|
|
|
801
972
|
row.attempt ?? null,
|
|
802
973
|
row.previousId ?? null,
|
|
803
974
|
);
|
|
975
|
+
// Snapshots deliberately omit resolution history; only these imported originals have unknown retry rights.
|
|
976
|
+
this.markRetryUnknown(snapshot.deliveries, snapshot.bus);
|
|
804
977
|
return true;
|
|
805
978
|
})();
|
|
806
979
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Envelope } from "./envelope.ts";
|
|
2
|
+
import type { Task } from "./board.ts";
|
|
3
|
+
|
|
4
|
+
/** Evidence only, never task text: a mixed/unknown/live row requires a person's decision. */
|
|
5
|
+
export function supersededDeliveryReason(
|
|
6
|
+
originals: readonly Envelope[],
|
|
7
|
+
board: { get(id: number): Pick<Task, "id" | "state" | "owner" | "reviewer" | "cancelled"> | undefined },
|
|
8
|
+
recipient: string,
|
|
9
|
+
): string | undefined {
|
|
10
|
+
if (!originals.length) return undefined;
|
|
11
|
+
const reasons = new Set<string>();
|
|
12
|
+
for (const env of originals) {
|
|
13
|
+
if (env.from !== "hub") return undefined;
|
|
14
|
+
if (env.kind === "presence") { reasons.add("presence"); continue; }
|
|
15
|
+
if (env.kind !== "task" && env.kind !== "review") return undefined;
|
|
16
|
+
if (!env.refs?.task || !/^[1-9][0-9]*$/.test(env.refs.task)) return undefined;
|
|
17
|
+
const id = Number(env.refs.task);
|
|
18
|
+
if (!Number.isSafeInteger(id)) return undefined;
|
|
19
|
+
const task = board.get(id);
|
|
20
|
+
if (!task) return undefined;
|
|
21
|
+
if (task.state === "cancelled") {
|
|
22
|
+
if (typeof env.refs.taskCancelled === "number" && env.refs.taskCancelled === task.cancelled?.flowRevision) return undefined;
|
|
23
|
+
reasons.add(`task #${task.id} cancelled`);
|
|
24
|
+
} else if (task.state === "approved") reasons.add(`task #${task.id} approved`);
|
|
25
|
+
else if ((env.kind === "review" || env.refs.taskRecipient === "reviewer") && (task.state !== "in_review" || task.reviewer !== recipient)) reasons.add(`task #${task.id} review moved`);
|
|
26
|
+
else if (env.kind === "task" && env.refs.taskRecipient === "owner" && task.owner !== recipient) reasons.add(`task #${task.id} owner moved`);
|
|
27
|
+
else return undefined; // Untagged legacy task notices and planner coordination carry no proof of supersession.
|
|
28
|
+
}
|
|
29
|
+
return `superseded: ${[...reasons].join("; ")}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/** Informational context for every referenced task, including peer/user and mixed rows. Never authorizes settlement. */
|
|
34
|
+
export function deliveryTaskContext(
|
|
35
|
+
originals: readonly Envelope[],
|
|
36
|
+
board: { get(id: number): Pick<Task, "id" | "state" | "owner" | "reviewer" | "cancelled"> | undefined },
|
|
37
|
+
recipient: string,
|
|
38
|
+
): string | undefined {
|
|
39
|
+
const context = new Set<string>();
|
|
40
|
+
for (const env of originals) {
|
|
41
|
+
if (!env.refs?.task || !/^[1-9][0-9]*$/.test(env.refs.task)) continue;
|
|
42
|
+
const id = Number(env.refs.task);
|
|
43
|
+
if (!Number.isSafeInteger(id)) continue;
|
|
44
|
+
const task = board.get(id);
|
|
45
|
+
if (!task) continue;
|
|
46
|
+
if (task.state === "cancelled") context.add(`task #${task.id} cancelled`);
|
|
47
|
+
else if (task.state === "approved") context.add(`task #${task.id} approved`);
|
|
48
|
+
else if (task.owner !== recipient && task.reviewer !== recipient) context.add(`task #${task.id} moved`);
|
|
49
|
+
else if ((env.kind === "review" || env.refs.taskRecipient === "reviewer") && (task.state !== "in_review" || task.reviewer !== recipient)) context.add(`task #${task.id} review moved`);
|
|
50
|
+
}
|
|
51
|
+
return context.size ? [...context].join("; ") : undefined;
|
|
52
|
+
}
|
package/src/hub/envelope.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface Envelope {
|
|
|
14
14
|
kind: Kind;
|
|
15
15
|
priority: Priority;
|
|
16
16
|
body: string; // agent conclusions only, never tool noise
|
|
17
|
-
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
|
|
17
|
+
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
|
|
18
18
|
/** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
|
|
19
19
|
private?: boolean;
|
|
20
20
|
ts: number;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { isAbsolute, relative } from "node:path";
|
|
2
|
+
import { realPath } from "./project.ts";
|
|
3
|
+
|
|
4
|
+
/** A repo-writable PATH shim is not a host-owned authenticated capability. No caller-selected binary. */
|
|
5
|
+
export function hostExecutable(name: "gh" | "container" | "git", projectDir: string, path = process.env.PATH ?? ""): string {
|
|
6
|
+
const found = Bun.which(name, { PATH: path });
|
|
7
|
+
if (!found || !isAbsolute(found)) throw new Error(`${name} unavailable`);
|
|
8
|
+
const target = realPath(found), inside = relative(realPath(projectDir), target);
|
|
9
|
+
if (!inside || inside !== ".." && !inside.startsWith("../") && !isAbsolute(inside)) throw new Error(`${name} unavailable`);
|
|
10
|
+
return target;
|
|
11
|
+
}
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -41,7 +41,14 @@ const tool = (name: string, description: string, properties: Record<string, unkn
|
|
|
41
41
|
inputSchema: { type: "object", properties, required, additionalProperties: false },
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
export const BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
|
|
45
|
+
|
|
44
46
|
export const TASK_TOOLS: HubTool[] = [
|
|
47
|
+
tool("hub_task_spec", "Read complete assigned GitHub issue contracts through the bounded hub boundary. Owner/reviewer only; no repository or URL input. Refuses withheld or oversized specs.", { id }, ["id"]),
|
|
48
|
+
tool("hub_check_start", "Run an exact-head isolated Linux check for your in-progress task. Trusted opt-in runner only; no host shell or credentials. Tests require one to 32 test/*.test.ts paths. Receipt is Linux evidence.", { id, check: { type: "string", enum: ["test", "typecheck"] }, files: { type: "array", minItems: 1, maxItems: 32, items: str } }, ["id", "check"]),
|
|
49
|
+
tool("hub_check_result", "Read your current task-bound isolated check receipt. Stale ownership/generation refuses; output is policy screened.", { run: str }, ["run"]),
|
|
50
|
+
tool("hub_check_cancel", "Cancel your task-bound isolated check; only its owner or the person may cancel.", { run: str }, ["run"]),
|
|
51
|
+
tool("hub_delivery_retry", "Request one redelivery of your own needs_review delivery. An earlier attempt may have effects: inspect those effects before acting. Refuses another peer's row or any lineage already explicitly retried; a repeated uncertain row needs the person. Never completes/discards a delivery or changes task state.", { delivery_id: { type: "string", minLength: 1, maxLength: 256 } }, ["delivery_id"]),
|
|
45
52
|
tool("hub_backlog_list", "Read public backlog cards with planning revisions. Configured planners only; PII remains a stub.", {}),
|
|
46
53
|
tool("hub_backlog_plan", "Plan an unpinned backlog item outside frozen releases with the revision you read and a bounded reason. A person's rank is retained. Actual dates cannot be set.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", maxLength: 1000 }, fields: { type: "object", properties: { rank: { type: ["integer", "null"], minimum: 0 }, priority: { type: ["integer", "null"], minimum: 0, maximum: 3 }, release: { type: ["integer", "null"], minimum: 1 }, plannedStart: { type: ["integer", "null"], minimum: 1 }, due: { type: ["integer", "null"], minimum: 1 }, deps: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason", "fields"]),
|
|
47
54
|
tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
|
|
@@ -51,10 +58,11 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
51
58
|
|
|
52
59
|
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
|
|
53
60
|
tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan: acceptPlan }, ["id"]),
|
|
61
|
+
tool("hub_task_cancel", "Cancel unaccepted proposed/backlog work you proposed as its configured planner; accepted work is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
|
|
54
62
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
|
|
55
63
|
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
56
64
|
tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
|
|
57
|
-
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
65
|
+
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
58
66
|
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
59
67
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
|
|
60
68
|
tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
|
package/src/hub/peer-catalog.ts
CHANGED
|
@@ -26,3 +26,6 @@ const PEER_ROLES: Record<KnownPeer, readonly string[]> = {
|
|
|
26
26
|
export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
|
|
27
27
|
return Object.fromEntries(Object.entries(PEER_ROLES).filter(([peer]) => includeOptional || !(OPTIONAL_PEERS as readonly string[]).includes(peer)).map(([peer, roles]) => [peer, [...roles]]));
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
/** #455: who may hold the planner role, in the order automatic assignment tries them; the hub-sandboxed pi and local never plan. */
|
|
31
|
+
export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
|
package/src/hub/routing.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
4
|
+
import { isGitHubRepository, specRefs } from "./spec-read.ts";
|
|
5
|
+
|
|
6
|
+
export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; }
|
|
4
7
|
import type { PeerId, PeerState } from "./envelope.ts";
|
|
5
8
|
|
|
6
9
|
import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
|
|
@@ -231,7 +234,10 @@ export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, rou
|
|
|
231
234
|
/** A peer's entry in a peer-indexed map: own keys only, so a peer named `constructor` is never an inherited Object member. */
|
|
232
235
|
export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer: string): T | undefined => (map && Object.hasOwn(map, peer) ? map[peer] : undefined);
|
|
233
236
|
|
|
237
|
+
export type RoutingRefusalCode = "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
|
|
238
|
+
export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
|
|
234
239
|
export interface Assignment {
|
|
240
|
+
refusals?: RoutingRefusal[];
|
|
235
241
|
owner?: PeerId;
|
|
236
242
|
reviewer?: PeerId;
|
|
237
243
|
/** what `local` should ask for on this task: a Switchyard route id and the model to fall back to */
|
|
@@ -248,10 +254,12 @@ export interface Assignment {
|
|
|
248
254
|
* prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
|
|
249
255
|
*/
|
|
250
256
|
export function assign(
|
|
251
|
-
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state">>,
|
|
257
|
+
task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external">>,
|
|
252
258
|
states: Record<PeerId, PeerState>,
|
|
253
259
|
routing: Routing,
|
|
254
260
|
opts: {
|
|
261
|
+
capabilities?: Record<PeerId, PeerCapabilities>;
|
|
262
|
+
repository?: string;
|
|
255
263
|
exclude?: PeerId[];
|
|
256
264
|
/** Owner admission limits do not remove otherwise eligible reviewers. */
|
|
257
265
|
excludeOwners?: PeerId[];
|
|
@@ -280,6 +288,7 @@ export function assign(
|
|
|
280
288
|
roles?: Record<string, string[]>;
|
|
281
289
|
} = {},
|
|
282
290
|
): Assignment {
|
|
291
|
+
if (task.state === "cancelled" || task.state === "approved") return { trace: [`${task.state}: closed; not routed`] };
|
|
283
292
|
if (task.state === "backlog") return { trace: ["backlog: not routed; pull into To Do first"] };
|
|
284
293
|
const policy = routing.classes[task.class];
|
|
285
294
|
const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
|
|
@@ -292,29 +301,39 @@ export function assign(
|
|
|
292
301
|
if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`, ...(reserved ? [`reserved owner ${reserved}: offered first once it is ready`] : [])] };
|
|
293
302
|
const pii = task.signals.includes("pii_path") || (task.signals.includes("pii") && routing.constraints.pii === "local_only");
|
|
294
303
|
|
|
304
|
+
let needsSpec = false;
|
|
305
|
+
if (!pii) try { needsSpec = specRefs({ title: task.title ?? "", detail: task.detail ?? "", refs: task.refs ?? {}, external: task.external }, opts.repository ?? "", { bareTitle: isGitHubRepository(opts.repository) }).length > 0; }
|
|
306
|
+
catch { needsSpec = true; } // an invalid/foreign reference never grants a sandboxed peer spec access
|
|
307
|
+
const needsTests = ["implement", "test", "bulk_edit"].includes(task.class);
|
|
308
|
+
const refusals = new Map<string, RoutingRefusal>();
|
|
295
309
|
const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
|
|
296
|
-
|
|
310
|
+
const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
|
|
311
|
+
if (role === "owner" && opts.capabilities && !pii) {
|
|
312
|
+
const caps = peerEntry(opts.capabilities, peer);
|
|
313
|
+
if (needsTests && !caps?.tests) return deny("tests", `missing tests: ${caps?.testsReason ?? "capability unavailable"}`);
|
|
314
|
+
if (needsSpec && !caps?.spec) return deny("spec", `missing spec: ${caps?.specReason ?? "capability unavailable"}`);
|
|
315
|
+
}
|
|
316
|
+
if (opts.offRoster?.includes(peer)) return deny("roster", "off roster");
|
|
297
317
|
const named = role === "owner" ? opts.namedOwner ?? reserved : opts.namedReviewer;
|
|
298
|
-
if (opts.pullOnly?.includes(peer) && named !== peer) return "pull-only: takes work when named or when it claims it";
|
|
299
|
-
if (!Object.hasOwn(states, peer)) return "not attached";
|
|
300
|
-
if (opts.exclude?.includes(peer)) return "excluded (declined or replaced)";
|
|
301
|
-
if (role === "owner" && opts.excludeOwners?.includes(peer)) return "owner admission: WIP or delivery hold";
|
|
302
|
-
if (states[peer] === "offline") return "offline";
|
|
303
|
-
if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
|
|
318
|
+
if (opts.pullOnly?.includes(peer) && named !== peer) return deny("pull-only", "pull-only: takes work when named or when it claims it");
|
|
319
|
+
if (!Object.hasOwn(states, peer)) return deny("not-attached", "not attached");
|
|
320
|
+
if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
|
|
321
|
+
if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
|
|
322
|
+
if (states[peer] === "offline") return deny("offline", "offline");
|
|
323
|
+
if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
|
|
304
324
|
const failing = peerEntry(opts.failing, peer);
|
|
305
|
-
if (failing) return `failing: ${failing}
|
|
306
|
-
if (pii && peer !== LOCAL) return "pii: on-prem peers only";
|
|
307
|
-
if (peer === LOCAL && policy?.local_allowed === false) return "local_allowed = false for this class";
|
|
308
|
-
if (peer === PI && policy?.local_allowed === false) return "local_allowed = false also excludes pi for this class";
|
|
325
|
+
if (failing) return deny("failing", `failing: ${failing}`);
|
|
326
|
+
if (pii && peer !== LOCAL) return deny("pii", "pii: on-prem peers only");
|
|
327
|
+
if (peer === LOCAL && policy?.local_allowed === false) return deny("policy", "local_allowed = false for this class");
|
|
328
|
+
if (peer === PI && policy?.local_allowed === false) return deny("policy", "local_allowed = false also excludes pi for this class");
|
|
309
329
|
if (peer === PI && task.signals.includes("long_context")) {
|
|
310
330
|
const backend = policy?.pi_backend ?? "dgx";
|
|
311
331
|
const limit = backend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens;
|
|
312
|
-
if (routing.signals.long_context_tokens > limit) return `pi ${backend} capability limit ${limit} tokens
|
|
332
|
+
if (routing.signals.long_context_tokens > limit) return deny("policy", `pi ${backend} capability limit ${limit} tokens`);
|
|
313
333
|
}
|
|
314
|
-
if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return "long_context: skip local";
|
|
334
|
+
if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return deny("policy", "long_context: skip local");
|
|
315
335
|
return undefined;
|
|
316
336
|
};
|
|
317
|
-
|
|
318
337
|
/** Quota that resets soonest gets used first: headroom per hour left in the window, at least 15 min (a task assigned that close to a reset mostly runs after it). */
|
|
319
338
|
const drain = (p: PeerId): number | undefined => {
|
|
320
339
|
const q = peerEntry(opts.quota, p);
|
|
@@ -396,7 +415,7 @@ export function assign(
|
|
|
396
415
|
const piBackend = policy?.pi_backend;
|
|
397
416
|
if (owner === LOCAL) trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
398
417
|
if (owner === PI) trace.push(`pi decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
|
|
399
|
-
return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), trace };
|
|
418
|
+
return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
|
|
400
419
|
}
|
|
401
420
|
|
|
402
421
|
/**
|
package/src/hub/settings.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SUPPORTED_PEERS } from "./peer-catalog.ts";
|
|
1
|
+
import { PLANNER_PEERS, SUPPORTED_PEERS } from "./peer-catalog.ts";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -46,6 +46,7 @@ const perClass = (name: TaskClass): SettingDef[] => [
|
|
|
46
46
|
];
|
|
47
47
|
export const SETTINGS: readonly SettingDef[] = [
|
|
48
48
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "hub start", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
|
|
49
|
+
{ key: "planner", group: "Roles", label: "Planner (the one peer that plans)", type: "enum", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
|
|
49
50
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "peer start", risk: "safe", path: ["catalog_roles", peer] })),
|
|
50
51
|
{ key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
|
|
51
52
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
|