@staix/agent-hub 0.12.30 → 0.12.32

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +29 -9
  3. package/docs/agent-notes/adapters.md +9 -5
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -0
  6. package/docs/agent-notes/daemon.md +14 -8
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +6 -2
  10. package/docs/cooperbench.md +1 -1
  11. package/docs/gsd-collaboration-analysis.md +1 -1
  12. package/docs/memory.md +2 -2
  13. package/docs/operations.md +236 -37
  14. package/docs/quickstart.md +24 -5
  15. package/docs/security.md +42 -10
  16. package/docs/smoke.md +34 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
  19. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  20. package/docs/verification/2026-10-17-418-probes.md +21 -0
  21. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  22. package/docs/verified.json +20 -15
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +1361 -44
  26. package/src/adapters/acp.ts +5 -6
  27. package/src/adapters/claude-channel.ts +29 -11
  28. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  29. package/src/adapters/definitions/kimi-native.ts +4 -0
  30. package/src/adapters/definitions/qwen-native.ts +24 -0
  31. package/src/adapters/definitions/qwen.ts +71 -0
  32. package/src/adapters/local-worker.ts +2 -2
  33. package/src/adapters/pi.ts +11 -2
  34. package/src/adapters/registry-types.ts +174 -0
  35. package/src/adapters/registry.ts +36 -0
  36. package/src/cli/attention.ts +5 -1
  37. package/src/cli/console-state.ts +130 -17
  38. package/src/cli/console.ts +54 -8
  39. package/src/cli/help.ts +3 -1
  40. package/src/cli/identity.ts +16 -5
  41. package/src/cli/jcode-mcp.ts +1 -1
  42. package/src/cli/launch.ts +1 -6
  43. package/src/cli/main.ts +208 -40
  44. package/src/cli/output.ts +15 -2
  45. package/src/cli/preview.ts +8 -5
  46. package/src/cli/recovery-guidance.ts +137 -0
  47. package/src/cli/recovery-presentation-options.ts +8 -0
  48. package/src/cli/recovery-session-file.ts +18 -0
  49. package/src/cli/terminal-recovery.ts +48 -0
  50. package/src/cli/upgrade-interactive.ts +129 -74
  51. package/src/cli/upgrade-labels.ts +30 -0
  52. package/src/cli/upgrade-presentation-data.ts +41 -0
  53. package/src/cli/upgrade-presentation-flags.ts +31 -0
  54. package/src/cli/upgrade-presentation.ts +217 -0
  55. package/src/cli/upgrade-restore-state.ts +137 -0
  56. package/src/cli/upgrade-runtime.ts +430 -175
  57. package/src/cli/upgrade.ts +325 -49
  58. package/src/hub/board.ts +82 -24
  59. package/src/hub/bus.ts +89 -29
  60. package/src/hub/check-runner.ts +514 -0
  61. package/src/hub/child-process.ts +1 -0
  62. package/src/hub/cohorts.ts +2 -2
  63. package/src/hub/config-apply.ts +242 -0
  64. package/src/hub/config-reload.ts +120 -0
  65. package/src/hub/config-trust.ts +11 -2
  66. package/src/hub/control-client.ts +5 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +931 -209
  69. package/src/hub/delivery-hints.ts +30 -0
  70. package/src/hub/delivery-journal.ts +185 -12
  71. package/src/hub/delivery-superseded.ts +52 -0
  72. package/src/hub/envelope.ts +1 -1
  73. package/src/hub/events.ts +2 -2
  74. package/src/hub/host-executable.ts +11 -0
  75. package/src/hub/hub-tools.ts +9 -1
  76. package/src/hub/manager.ts +1 -1
  77. package/src/hub/peer-catalog.ts +13 -13
  78. package/src/hub/peers.ts +12 -2
  79. package/src/hub/permission-mode.ts +5 -3
  80. package/src/hub/recovery-continuity.ts +152 -0
  81. package/src/hub/recovery-store.ts +10 -4
  82. package/src/hub/routing.ts +35 -16
  83. package/src/hub/sdlc.ts +5 -1
  84. package/src/hub/settings.ts +52 -37
  85. package/src/hub/settled-roles.ts +35 -0
  86. package/src/hub/spec-read.ts +120 -0
  87. package/src/hub/supervision.ts +24 -8
  88. package/src/hub/task-sweep.ts +7 -6
  89. package/src/hub/tasks.ts +199 -40
  90. package/src/hub/ui.ts +1 -1
  91. package/src/memory/brief.ts +6 -5
  92. package/src/memory/capture.ts +2 -1
  93. package/src/pi/session.ts +23 -4
  94. package/src/ui/index.html +35 -16
  95. package/src/ui/needs.ts +7 -5
  96. package/src/ui/office-model.ts +4 -1
  97. package/src/ui/task-board.ts +1 -1
  98. package/src/ui/task-progress.ts +6 -2
  99. package/templates/AGENTS.block.md +1 -1
  100. package/templates/profiles/general.json +1 -1
  101. package/templates/profiles/research.json +1 -1
  102. package/templates/profiles/writing.json +1 -1
package/src/hub/board.ts CHANGED
@@ -8,7 +8,14 @@ import type { SdlcReceipt } from "./sdlc.ts";
8
8
 
9
9
  export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
10
10
  export type TaskClass = (typeof CLASSES)[number];
11
- export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "approved" | "changes_requested";
11
+ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "approved" | "changes_requested" | "cancelled";
12
+
13
+ export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
14
+ export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
15
+ export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
16
+ export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable"] as const;
17
+ export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
18
+ export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency"; peer?: string; dependency?: number; code: TaskStallCode; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
12
19
 
13
20
  export interface TaskRefs {
14
21
  repo?: string;
@@ -31,6 +38,7 @@ export interface HistoryEntry {
31
38
  by: PeerId;
32
39
  event: string; // proposed | assigned | accepted | declined | done | approved | changes_requested | escalated | reassigned
33
40
  note?: string;
41
+ supersededBy?: string;
34
42
  /** The owner an event that set it left the task with (#67); absent in rows written before 0.11. */
35
43
  owner?: PeerId | null;
36
44
  /** On a decline or an escalation: the owner it was declined for or taken from (#207); later reroutes leave it out. */
@@ -46,6 +54,8 @@ export interface HistoryEntry {
46
54
  planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
47
55
  }
48
56
  export interface Task extends PlanningFields {
57
+ offer?: TaskOffer;
58
+ cancelled?: TaskCancellation;
49
59
  external?: GitHubSource;
50
60
  planningRevision?: number;
51
61
  id: number;
@@ -91,12 +101,13 @@ export function planningUndoEntry(task: Pick<Task, "history">): HistoryEntry | u
91
101
 
92
102
  /** The moves the state machine allows. `accept` and `done` are transitions, not states: they carried no behaviour of their own. */
93
103
  const MOVES: Record<TaskState, TaskState[]> = {
94
- backlog: ["proposed"],
95
- proposed: ["in_progress", "backlog"],
96
- in_progress: ["in_review", "approved", "proposed"], // approved: class without a reviewer; proposed: owner declined or was taken off
97
- in_review: ["approved", "changes_requested"],
98
- changes_requested: ["in_progress", "proposed"], // proposed: declined redo waits for reassignment
104
+ backlog: ["proposed", "cancelled"],
105
+ proposed: ["in_progress", "backlog", "cancelled"],
106
+ in_progress: ["in_review", "approved", "proposed", "cancelled"], // approved: class without a reviewer; proposed: owner declined or was taken off
107
+ in_review: ["approved", "changes_requested", "cancelled"],
108
+ changes_requested: ["in_progress", "proposed", "cancelled"], // proposed: declined redo waits for reassignment
99
109
  approved: [],
110
+ cancelled: [],
100
111
  };
101
112
 
102
113
  const JSON_COLS = ["refs", "plan", "deps", "signals", "history", "sdlc"] as const;
@@ -123,6 +134,19 @@ export class Board {
123
134
  return result;
124
135
  }
125
136
 
137
+ /** Keep the generation and business write atomic without changing Bun's reported row count. */
138
+ private taskWrite<T extends { changes: number }>(write: () => T): T {
139
+ return this.db.transaction(() => {
140
+ const result = { ...write() };
141
+ if (result.changes) this.db.run("UPDATE board_status_revision SET revision=revision+1 WHERE id=1");
142
+ return result;
143
+ })();
144
+ }
145
+
146
+ statusRevision(): number {
147
+ return (this.db.query("SELECT revision FROM board_status_revision WHERE id=1").get() as { revision: number }).revision;
148
+ }
149
+
126
150
  constructor(path: string) {
127
151
  this.db = new Database(path, { create: true });
128
152
  this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
@@ -145,6 +169,11 @@ export class Board {
145
169
  if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT NOT NULL DEFAULT '${empty}'`);
146
170
  }
147
171
  // And from before issue #207: existing rows reserve nobody.
172
+ for (const col of ["offer", "cancelled"]) if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT`);
173
+ this.db.run("CREATE TABLE IF NOT EXISTS task_stalls (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
174
+ // Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
175
+ this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
176
+ this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
148
177
  if (!have.has("reserved")) this.db.run("ALTER TABLE tasks ADD COLUMN reserved TEXT");
149
178
  if (!have.has("external_repo")) this.db.run("ALTER TABLE tasks ADD COLUMN external_repo TEXT");
150
179
  if (!have.has("external_issue")) this.db.run("ALTER TABLE tasks ADD COLUMN external_issue INTEGER");
@@ -168,9 +197,9 @@ export class Board {
168
197
  propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; profile?: import("./profiles.ts").ProfileName; profileClass?: string; customProfile?: true; role?: string; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[]; backlog?: boolean; external?: GitHubSource }): Task {
169
198
  const now = Date.now();
170
199
  const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed" }];
171
- const { lastInsertRowid } = this.db
200
+ const { lastInsertRowid } = this.taskWrite(() => this.db
172
201
  .query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, reserved, signals, history, created, updated, project_profile, task_role, external_repo, external_issue) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
173
- .run(t.title, t.detail ?? "", t.class, t.backlog ? "backlog" : "proposed", JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now, t.profile && (t.profile !== "dev" || t.customProfile || t.profileClass !== undefined && t.profileClass !== t.class) ? JSON.stringify({ profile: t.profile, profileClass: t.profileClass ?? t.class }) : null, t.role ?? null, t.external?.repo.toLowerCase() ?? null, t.external?.issue ?? null);
202
+ .run(t.title, t.detail ?? "", t.class, t.backlog ? "backlog" : "proposed", JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now, t.profile && (t.profile !== "dev" || t.customProfile || t.profileClass !== undefined && t.profileClass !== t.class) ? JSON.stringify({ profile: t.profile, profileClass: t.profileClass ?? t.class }) : null, t.role ?? null, t.external?.repo.toLowerCase() ?? null, t.external?.issue ?? null));
174
203
  const task = this.get(Number(lastInsertRowid))!;
175
204
  this.changed(task, history[0]!);
176
205
  return task;
@@ -193,18 +222,46 @@ export class Board {
193
222
 
194
223
  /** Structural status facts only; avoid decoding text, plans and growing history on each status write. */
195
224
  statusTasks(): Pick<Task, "id" | "state" | "owner" | "reviewer">[] {
196
- return this.db.query("SELECT id, state, owner, reviewer FROM tasks WHERE state != 'approved' ORDER BY id").all() as Pick<Task, "id" | "state" | "owner" | "reviewer">[];
225
+ return this.db.query("SELECT id, state, owner, reviewer FROM tasks WHERE state NOT IN ('approved','cancelled') ORDER BY id").all() as Pick<Task, "id" | "state" | "owner" | "reviewer">[];
197
226
  }
198
227
  statusReservations(): string[] {
199
- return (this.db.query("SELECT DISTINCT reserved FROM tasks WHERE state NOT IN ('approved', 'backlog') AND reserved IS NOT NULL ORDER BY reserved").all() as { reserved: string }[]).map(row => row.reserved);
228
+ return (this.db.query("SELECT DISTINCT reserved FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND reserved IS NOT NULL ORDER BY reserved").all() as { reserved: string }[]).map(row => row.reserved);
200
229
  }
201
230
  plannerWip(peer: string, except?: number): number {
202
- const row = this.db.query(`SELECT COUNT(*) AS n FROM tasks WHERE state NOT IN ('approved', 'backlog') AND (? IS NULL OR id != ?) AND
231
+ const row = this.db.query(`SELECT COUNT(*) AS n FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND (? IS NULL OR id != ?) AND
203
232
  (owner = ? OR (owner IS NULL AND state = 'proposed' AND reserved = ? AND
204
233
  json_extract((SELECT value FROM json_each(tasks.history) WHERE json_extract(value, '$.event') IN ('backlog pulled', 'backlog returned') ORDER BY CAST(key AS INTEGER) DESC LIMIT 1), '$.planning.pull') = 1))`).get(except ?? null, except ?? null, peer, peer) as { n: number };
205
234
  return row.n;
206
235
  }
207
236
 
237
+ recordTaskOffer(id: number, offer: TaskOffer, expectedFlowRevision: number): void {
238
+ const task = this.get(id);
239
+ if (!task || task.owner !== offer.owner || isClosedTaskState(task.state) || task.history.length !== expectedFlowRevision || offer.assignmentFlowRevision !== expectedFlowRevision) throw new Error("task offer changed");
240
+ const changed = this.taskWrite(() => this.db.query("UPDATE tasks SET offer=? WHERE id=? AND owner=? AND history=?").run(JSON.stringify(offer), id, offer.owner, JSON.stringify(task.history)));
241
+ if (!changed.changes) throw new Error("task offer changed");
242
+ }
243
+ recordStall(stall: TaskStall): boolean {
244
+ const old = this.stalls().find(row => row.task === stall.task);
245
+ if (old?.signature === stall.signature && old.replanRevision === stall.replanRevision) return false;
246
+ this.taskWrite(() => this.db.query("INSERT INTO task_stalls(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(stall.task, JSON.stringify(stall)));
247
+ return true;
248
+ }
249
+ clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
250
+ stalls(): TaskStall[] {
251
+ return (this.db.query("SELECT payload FROM task_stalls ORDER BY task").all() as { payload: string }[]).flatMap(row => {
252
+ try {
253
+ const value = JSON.parse(row.payload) as TaskStall, cause = value.cause;
254
+ const peer = (id: unknown) => typeof id === "string" && /^[a-z][a-z0-9_-]{0,63}$/i.test(id);
255
+ if (Object.keys(value).some(key => !["task", "signature", "replanRevision", "cause", "at"].includes(key)) || !Number.isSafeInteger(value.task) || value.task < 1 || !Number.isSafeInteger(value.replanRevision) || value.replanRevision < 0 || !Number.isFinite(value.at)
256
+ || !cause || !["reservation", "no-eligible-peer", "cancelled-dependency"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
257
+ || cause.peer !== undefined && !peer(cause.peer) || cause.dependency !== undefined && (!Number.isSafeInteger(cause.dependency) || cause.dependency < 1)
258
+ || cause.refusals !== undefined && (!Array.isArray(cause.refusals) || cause.refusals.length > 64 || cause.refusals.some(row => Object.keys(row).some(key => !["peer", "code"].includes(key)) || !peer(row.peer) || !TASK_STALL_CODES.includes(row.code)))
259
+ || Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals"].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
260
+ return [value];
261
+ } catch { return []; }
262
+ });
263
+ }
264
+
208
265
  /** Tasks per state, without parsing a single row: `status.json` is rewritten on every bus event and the board only grows. */
209
266
  counts(): Record<string, number> {
210
267
  const rows = this.db.query("SELECT state, COUNT(*) AS n FROM tasks GROUP BY state").all() as { state: string; n: number }[];
@@ -213,7 +270,7 @@ export class Board {
213
270
 
214
271
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
215
272
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
216
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld"> = {}, expectedFlowRevision?: number): Task {
273
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "withheld"> = {}, expectedFlowRevision?: number): Task {
217
274
  const task = this.get(id);
218
275
  if (!task) throw new Error(`no task #${id}`);
219
276
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -224,10 +281,10 @@ export class Board {
224
281
  if (next.state === "backlog" && (next.owner || next.reviewer)) throw new Error(`task #${id}: backlog cannot have an owner or reviewer`);
225
282
  if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
226
283
  if (patch.state === "backlog" && taskHasStarted(task)) throw new Error(`task #${id}: started work cannot return to backlog`);
227
- const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}) }];
228
- this.db
229
- .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, updated = ? WHERE id = ?")
230
- .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, Date.now(), id);
284
+ const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}) }];
285
+ this.taskWrite(() => this.db
286
+ .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
287
+ .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
231
288
  const updated = this.get(id)!;
232
289
  this.changed(updated, history.at(-1)!);
233
290
  return updated;
@@ -243,8 +300,8 @@ export class Board {
243
300
  if (next.release != null && !this.release(next.release)) throw new Error("no such release");
244
301
  const fields = Object.keys(patch) as (keyof PlanningFields)[], previous = Object.fromEntries(fields.map(key => [key, task[key] ?? (key === "pinned" ? false : key === "deps" ? [] : null)])) as PlanningFields;
245
302
  const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "planned", planning: { revision: revision + 1, fields, previous }, ...(note ? { note } : {}), ...(withheld && note ? { withheld: true as const } : {}) };
246
- const result = this.db.query(`UPDATE tasks SET rank=?, priority=?, "release"=?, planned_start=?, due=?, pinned=?, deps=?, reserved=?, planning_revision=planning_revision+1, history=?, updated=? WHERE id=? AND planning_revision=?`)
247
- .run(next.rank ?? null, next.priority ?? null, next.release ?? null, next.plannedStart ?? null, next.due ?? null, next.pinned ? 1 : 0, JSON.stringify(next.deps ?? []), next.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision);
303
+ const result = this.taskWrite(() => this.db.query(`UPDATE tasks SET rank=?, priority=?, "release"=?, planned_start=?, due=?, pinned=?, deps=?, reserved=?, planning_revision=planning_revision+1, history=?, updated=? WHERE id=? AND planning_revision=?`)
304
+ .run(next.rank ?? null, next.priority ?? null, next.release ?? null, next.plannedStart ?? null, next.due ?? null, next.pinned ? 1 : 0, JSON.stringify(next.deps ?? []), next.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision));
248
305
  if (result.changes !== 1) throw new Error("planning revision changed");
249
306
  const updated = this.get(id)!; this.changed(updated, entry); return updated;
250
307
  }
@@ -254,8 +311,8 @@ export class Board {
254
311
  const task = this.get(id);
255
312
  if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || taskHasStarted(task)) throw new Error("planner pull changed or already started");
256
313
  const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog pulled", planning: { revision: revision + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null }, pull: true }, note, ...(withheld ? { withheld: true as const } : {}) };
257
- const result = this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
258
- .run(reserved, JSON.stringify([...task.history, entry]), now, id, revision);
314
+ const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
315
+ .run(reserved, JSON.stringify([...task.history, entry]), now, id, revision));
259
316
  if (result.changes !== 1) throw new Error("planner pull changed");
260
317
  const updated = this.get(id)!; this.changed(updated, entry); return updated;
261
318
  }
@@ -265,8 +322,8 @@ export class Board {
265
322
  const task = this.get(id), pull = task ? planningUndoEntry(task)?.planning : undefined;
266
323
  if (!task || task.state !== "proposed" || task.owner || taskHasStarted(task) || (task.planningRevision ?? 0) !== revision || !pull?.pull || pull.revision !== revision) throw new Error("planner pull undo changed or already offered");
267
324
  const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog returned", note: `undo planner pull revision ${revision}` };
268
- const result = this.db.query("UPDATE tasks SET state='backlog',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='proposed' AND owner IS NULL")
269
- .run(pull.previous.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision);
325
+ const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='backlog',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='proposed' AND owner IS NULL")
326
+ .run(pull.previous.reserved ?? null, JSON.stringify([...task.history, entry]), now, id, revision));
270
327
  if (result.changes !== 1) throw new Error("planner pull undo changed");
271
328
  const updated = this.get(id)!; this.changed(updated, entry); return updated;
272
329
  }
@@ -288,7 +345,7 @@ export class Board {
288
345
  const updated = this.plan(id, by, { deps }, revision, note, withheld);
289
346
  const entry = updated.history.at(-1)!;
290
347
  entry.planning!.split = { children: children.map(child => ({ id: child.id, revision: child.planningRevision ?? 0, history: child.history.length })) };
291
- this.db.query("UPDATE tasks SET history=? WHERE id=?").run(JSON.stringify(updated.history), id);
348
+ this.taskWrite(() => this.db.query("UPDATE tasks SET history=? WHERE id=?").run(JSON.stringify(updated.history), id));
292
349
  const notification = this.pendingChanges?.findLast(change => change.task.id === id && change.entry.planning?.revision === revision + 1);
293
350
  if (notification) { notification.task = updated; notification.entry = entry; }
294
351
  return { parent: this.get(id)!, children };
@@ -306,7 +363,7 @@ export class Board {
306
363
  }
307
364
  if (this.list().some(task => task.id !== id && !ids.has(task.id) && (task.deps?.some(dep => ids.has(dep)) || task.sdlc?.phase?.tasks.some(ref => ids.has(ref.id) || ref.after.some(dep => ids.has(dep)))))) throw new Error("split children are referenced by other work");
308
365
  const restored = this.plan(id, by, { deps: entry!.planning!.previous.deps ?? [] }, revision, `undo split revision ${revision}`);
309
- for (const child of ids) this.db.query("DELETE FROM tasks WHERE id=?").run(child);
366
+ for (const child of ids) this.taskWrite(() => this.db.query("DELETE FROM tasks WHERE id=?").run(child));
310
367
  return restored;
311
368
  });
312
369
  }
@@ -374,6 +431,7 @@ export interface ReviewOutcome {
374
431
 
375
432
  function parse(row: Record<string, unknown>): Task {
376
433
  const out = { ...row } as Record<string, unknown>;
434
+ for (const key of ["offer", "cancelled"]) if (row[key] == null) delete out[key]; else out[key] = JSON.parse(String(row[key]));
377
435
  for (const col of JSON_COLS) {
378
436
  if (col === "sdlc" && row[col] == null) delete out[col];
379
437
  else out[col] = JSON.parse(String(row[col]));
package/src/hub/bus.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
- import { DeliveryJournal, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
3
+ import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
4
+ import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
5
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
4
6
 
5
7
  export type { DeliveryReceipt } from "./peers.ts";
6
8
 
@@ -160,6 +162,17 @@ export class Bus {
160
162
  }
161
163
  }
162
164
 
165
+ /** Apply future batching and admission limits without disturbing queued work or its existing deadline. */
166
+ configureScheduling(config: Pick<BusOptions, "batchMax" | "batchMs" | "queueCap">): void {
167
+ const { batchMax, batchMs, queueCap } = config;
168
+ if (!Number.isSafeInteger(batchMax) || batchMax < 1 || batchMax > 2_147_483_647 ||
169
+ !Number.isFinite(batchMs) || batchMs < 0 || batchMs > 2_147_483_647 ||
170
+ !Number.isSafeInteger(queueCap) || queueCap < 1 || queueCap > 2_147_483_647) {
171
+ throw new Error("invalid bus scheduling configuration");
172
+ }
173
+ Object.assign(this.opts, { batchMax, batchMs, queueCap });
174
+ }
175
+
163
176
  add(peer: PeerAdapter): void {
164
177
  this.peers.set(peer.id, peer);
165
178
  if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
@@ -262,6 +275,7 @@ export class Bus {
262
275
  /** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
263
276
  setRecoveryHold(held: boolean): void {
264
277
  this.recoveryHeld = held;
278
+ if (!held) this.onQueues?.(); // restored holds need classification even when no peer is attached
265
279
  if (!held) for (const id of this.peers.keys()) void this.drain(id);
266
280
  }
267
281
 
@@ -360,16 +374,41 @@ export class Bus {
360
374
 
361
375
  queueShow(id: string): JournalDelivery | undefined { return this.journal?.get(id) ?? this.queueList().find((d) => d.id === id); }
362
376
 
363
- resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string): void {
377
+ heldDeliveryHints(peer: string) { return this.storageError ? undefined : this.journal?.heldDeliveryHints(peer); }
378
+
379
+ /** Consent is for this peer's held row only, never authority to complete/discard or release a pause. */
380
+ retryDelivery(peer: string, id: string): void {
381
+ const refuse = () => { throw new PreEffectToolRefusal("recipient retry unavailable; inspect the held delivery or ask the person"); };
382
+ if (this.closed || this.recoveryHeld || this.storageError || peer === "user" || peer === HUB) return refuse();
383
+ const row = this.queueShow(id);
384
+ if (!row || row.peer !== peer || row.state !== "needs_review" || !this.journal || this.journal.retryEvidence(peer, row.originals) !== "unused") return refuse();
385
+ this.resolveDelivery(id, row.revision, "retry", "recipient requested one uncertain redelivery", `peer:${peer}`);
386
+ }
387
+
388
+ private retryOut(peer: string, originals: Envelope[], out: Envelope[]): Envelope[] {
389
+ if (!out.length || !this.journal || !this.journal.retryWarning(peer, originals)) return out;
390
+ return out.map((env, index) => {
391
+ if (index !== 0) return env;
392
+ const prefix = `${DELIVERY_RETRY_WARNING}\n\n`, marker = "\n[cut: rendered body exceeds delivery limit]";
393
+ if (prefix.length + env.body.length <= MAX_ENVELOPE_BODY) return { ...env, body: prefix + env.body };
394
+ let end = MAX_ENVELOPE_BODY - prefix.length - marker.length;
395
+ // The journal cap uses UTF-16 length; never cut between a Unicode surrogate pair.
396
+ if (Array.from(env.body.slice(end - 1, end + 1))[0]?.length === 2) end--;
397
+ return { ...env, body: prefix + env.body.slice(0, end) + marker };
398
+ });
399
+ }
400
+
401
+ resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string, actor: string = "user"): void {
364
402
  if (!this.journal || this.storageError) throw new Error("delivery journal unavailable");
365
403
  const record = this.queueShow(id);
366
404
  if (!record) throw new Error("unknown delivery");
367
405
  const previous = this.journal.resolution(id);
368
406
  if (previous) {
369
- this.journal.resolve(id, revision, action, reason); // verifies identical request; no second enqueue
407
+ this.journal.resolve(id, revision, action, reason, revision, actor); // verifies identical request; no second enqueue
370
408
  return;
371
409
  }
372
- if (["dispatching", "accepted"].includes(record.state) || this.peers.get(record.peer)?.state === "busy") throw new Error("active delivery cannot be resolved");
410
+ const recipientConsent = actor === `peer:${record.peer}` && action === "retry" && record.state === "needs_review";
411
+ if (["dispatching", "accepted"].includes(record.state) || (this.peers.get(record.peer)?.state === "busy" && !recipientConsent)) throw new Error("active delivery cannot be resolved");
373
412
  if (record.revision !== revision) throw new Error("stale delivery revision");
374
413
  if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
375
414
  if (action === "retry" && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
@@ -381,17 +420,17 @@ export class Bus {
381
420
  this.journal.transaction(() => {
382
421
  if (!this.journal!.get(id)) this.journal!.createDelivery({ ...record });
383
422
  const current = this.journal!.get(id)!;
384
- this.journal!.resolve(id, current.revision, action, reason, revision);
423
+ this.journal!.resolve(id, current.revision, action, reason, revision, actor);
385
424
  if (record.state === "queued") {
386
425
  const ids = new Set(record.originals.map((env) => env.id));
387
426
  queue.splice(0, queue.length, ...queue.filter((env) => !ids.has(env.id)));
388
427
  }
389
- if (action === "retry") {
428
+ if (action === "retry" || action === "completed" && actor === "hub") {
390
429
  for (const env of record.originals) {
391
430
  if (env.from === HUB && env.kind === "presence") this.restorePreface(record.peer, env);
392
- else if (!queue.some((old) => old.id === env.id)) queue.push(env);
431
+ else if (action === "retry" && !queue.some((old) => old.id === env.id)) queue.push(env);
393
432
  }
394
- this.queues.set(record.peer, queue);
433
+ if (action === "retry") this.queues.set(record.peer, queue);
395
434
  }
396
435
  this.persist();
397
436
  });
@@ -436,7 +475,8 @@ export class Bus {
436
475
  const before = this.journal.get(id);
437
476
  const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
438
477
  if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
439
- this.journal.transition(id, state, receipt.reason);
478
+ if (state === "completed") this.journal.completeDelivered(id, peer, "adapter", receipt.reason);
479
+ else this.journal.transition(id, state, receipt.reason);
440
480
  if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) {
441
481
  this.memoryDelivery(peer, id, before.out, "delivered", before.originals);
442
482
  this.delivered(peer, before.originals);
@@ -463,8 +503,8 @@ export class Bus {
463
503
  for (const [id, owner] of this.activeDeliveries) {
464
504
  if (owner !== peer) continue;
465
505
  const row = this.journal.get(id);
466
- if (row?.out.some((e) => e.id === envelopeId)) {
467
- this.journal.transition(id, "completed", "correlated reply");
506
+ if (row?.peer === peer && ["dispatching", "accepted"].includes(row.state) && row.out.some((e) => e.id === envelopeId)) {
507
+ this.journal.completeDelivered(id, peer, "reply", "correlated reply");
468
508
  if (row.state === "dispatching") this.memoryDelivery(peer, id, row.out, "delivered", row.originals);
469
509
  this.activeDeliveries.delete(id);
470
510
  this.refreshRecoveryHold(peer);
@@ -783,14 +823,20 @@ export class Bus {
783
823
  if (index < 0) return;
784
824
  this.steering++;
785
825
  const deliveryId = crypto.randomUUID();
826
+ let removed = false, handedOff = false;
786
827
  try {
828
+ const out = this.retryOut(id, [env], [env]);
787
829
  queue.splice(index, 1);
788
- this.durableHandoff(id, deliveryId, [env], [env]);
830
+ removed = true;
831
+ this.durableHandoff(id, deliveryId, [env], out);
832
+ handedOff = true;
789
833
  this.activeDeliveries.set(deliveryId, id);
790
- try { await peer.steer([env], deliveryId); }
834
+ try { await peer.steer(out, deliveryId); }
791
835
  catch { this.uncertain(id, deliveryId, "steering outcome is uncertain"); }
792
- } catch { this.storageError = "delivery journal unavailable"; }
793
- finally { this.steering--; this.onQueues?.(); }
836
+ } catch {
837
+ if (removed && !handedOff) queue.splice(index, 0, env);
838
+ this.storageError = "delivery journal unavailable";
839
+ } finally { this.steering--; this.onQueues?.(); }
794
840
  }
795
841
 
796
842
  /** Holds on a peer's queue, for a drain and a pull alike: off roster, recovery, an uncertain delivery, a pause (console, budget, conductor). */
@@ -829,14 +875,16 @@ export class Bus {
829
875
  if (!preface && !queue.length) return [];
830
876
  const queued = [...queue];
831
877
  const batch = [...(preface ? [preface] : []), ...this.take(id, queue)];
832
- this.prefaces.delete(id);
833
- // Before the checkpoint, as in a drain, so the persisted snapshot carries it; on a failure the bus stops anyway.
834
- this.remember(batch);
878
+ let out: Envelope[] = [];
835
879
  const deliveryId = crypto.randomUUID();
836
880
  try {
881
+ out = this.retryOut(id, batch, batch);
882
+ this.prefaces.delete(id);
883
+ // Before the checkpoint, as in a drain, so the persisted snapshot carries it; a failed policy read rolls back too.
884
+ this.remember(out);
837
885
  this.journal?.transaction(() => {
838
- this.durableHandoff(id, deliveryId, batch, batch, false); // observe only after this outer transaction commits
839
- this.journal!.transition(deliveryId, "completed", "read through hub_inbox");
886
+ this.durableHandoff(id, deliveryId, batch, out, false); // observe only after this outer transaction commits
887
+ this.journal!.completeDelivered(deliveryId, id, "pull", "read through hub_inbox");
840
888
  });
841
889
  } catch (error) {
842
890
  queue.splice(0, queue.length, ...queued);
@@ -845,12 +893,12 @@ export class Bus {
845
893
  throw error;
846
894
  }
847
895
  if (this.journal) {
848
- this.memoryDelivery(id, deliveryId, batch, "rendered");
849
- this.memoryDelivery(id, deliveryId, batch, "delivered");
896
+ this.memoryDelivery(id, deliveryId, out, "rendered", batch);
897
+ this.memoryDelivery(id, deliveryId, out, "delivered", batch);
850
898
  }
851
899
  this.failureStreak.delete(id); this.lastFailure.delete(id); // read, as a completed push receipt clears them
852
900
  this.onQueues?.();
853
- return batch;
901
+ return out;
854
902
  }
855
903
 
856
904
  private async drain(id: PeerId): Promise<void> {
@@ -872,8 +920,11 @@ export class Bus {
872
920
  const delivery = preface ? [preface, ...batch] : batch;
873
921
  const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
874
922
  this.condensing += mayCondense ? 1 : 0;
875
- const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
876
- this.condensing -= mayCondense ? 1 : 0;
923
+ let out: Envelope[];
924
+ try {
925
+ const condensed = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
926
+ out = this.retryOut(id, delivery, condensed);
927
+ } finally { this.condensing -= mayCondense ? 1 : 0; }
877
928
  // A session that cannot show pushes may have taken the peer while this was condensed (issue #205).
878
929
  if (!this.deliverable(id)) {
879
930
  if (!this.journal) { if (preface) this.restorePreface(id, preface); queue.unshift(...batch.filter((e) => !this.withdrawn.has(e.id))); }
@@ -885,16 +936,25 @@ export class Bus {
885
936
  this.dropIrrelevant(id, queue);
886
937
  continue;
887
938
  }
939
+ const deliveryId = crypto.randomUUID();
888
940
  if (this.journal) {
889
941
  if (this.prefaces.get(id) !== preface || batch.some((e) => !queue.some((item) => item.id === e.id))) continue;
942
+ const queued = [...queue], seen = [...this.seen];
890
943
  for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
891
944
  this.prefaces.delete(id);
892
- }
893
- this.remember(out);
945
+ this.remember(out); // the checkpoint must retain rendered reply ids across a restart
946
+ try { this.durableHandoff(id, deliveryId, delivery, out); }
947
+ catch (error) {
948
+ queue.splice(0, queue.length, ...queued);
949
+ if (preface) this.prefaces.set(id, preface);
950
+ this.seen.clear();
951
+ for (const [key, env] of seen) this.seen.set(key, env);
952
+ throw error;
953
+ }
954
+ this.activeDeliveries.set(deliveryId, id);
955
+ } else this.remember(out);
894
956
  this.lastDelivery.set(id, { out, originals: delivery });
895
957
  this.preparing.delete(id); // from here on the transport/journal owns settlement
896
- const deliveryId = crypto.randomUUID();
897
- if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
898
958
  try {
899
959
  try { this.onDeliver?.(id, delivery); } catch { /* observation must never affect delivery */ }
900
960
  if (this.journal) await peer.deliver(out, deliveryId);