@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
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
|
|
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
|
|
|
@@ -262,6 +264,7 @@ export class Bus {
|
|
|
262
264
|
/** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
|
|
263
265
|
setRecoveryHold(held: boolean): void {
|
|
264
266
|
this.recoveryHeld = held;
|
|
267
|
+
if (!held) this.onQueues?.(); // restored holds need classification even when no peer is attached
|
|
265
268
|
if (!held) for (const id of this.peers.keys()) void this.drain(id);
|
|
266
269
|
}
|
|
267
270
|
|
|
@@ -360,16 +363,41 @@ export class Bus {
|
|
|
360
363
|
|
|
361
364
|
queueShow(id: string): JournalDelivery | undefined { return this.journal?.get(id) ?? this.queueList().find((d) => d.id === id); }
|
|
362
365
|
|
|
363
|
-
|
|
366
|
+
heldDeliveryHints(peer: string) { return this.storageError ? undefined : this.journal?.heldDeliveryHints(peer); }
|
|
367
|
+
|
|
368
|
+
/** Consent is for this peer's held row only, never authority to complete/discard or release a pause. */
|
|
369
|
+
retryDelivery(peer: string, id: string): void {
|
|
370
|
+
const refuse = () => { throw new PreEffectToolRefusal("recipient retry unavailable; inspect the held delivery or ask the person"); };
|
|
371
|
+
if (this.closed || this.recoveryHeld || this.storageError || peer === "user" || peer === HUB) return refuse();
|
|
372
|
+
const row = this.queueShow(id);
|
|
373
|
+
if (!row || row.peer !== peer || row.state !== "needs_review" || !this.journal || this.journal.retryEvidence(peer, row.originals) !== "unused") return refuse();
|
|
374
|
+
this.resolveDelivery(id, row.revision, "retry", "recipient requested one uncertain redelivery", `peer:${peer}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private retryOut(peer: string, originals: Envelope[], out: Envelope[]): Envelope[] {
|
|
378
|
+
if (!out.length || !this.journal || !this.journal.retryWarning(peer, originals)) return out;
|
|
379
|
+
return out.map((env, index) => {
|
|
380
|
+
if (index !== 0) return env;
|
|
381
|
+
const prefix = `${DELIVERY_RETRY_WARNING}\n\n`, marker = "\n[cut: rendered body exceeds delivery limit]";
|
|
382
|
+
if (prefix.length + env.body.length <= MAX_ENVELOPE_BODY) return { ...env, body: prefix + env.body };
|
|
383
|
+
let end = MAX_ENVELOPE_BODY - prefix.length - marker.length;
|
|
384
|
+
// The journal cap uses UTF-16 length; never cut between a Unicode surrogate pair.
|
|
385
|
+
if (Array.from(env.body.slice(end - 1, end + 1))[0]?.length === 2) end--;
|
|
386
|
+
return { ...env, body: prefix + env.body.slice(0, end) + marker };
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string, actor: string = "user"): void {
|
|
364
391
|
if (!this.journal || this.storageError) throw new Error("delivery journal unavailable");
|
|
365
392
|
const record = this.queueShow(id);
|
|
366
393
|
if (!record) throw new Error("unknown delivery");
|
|
367
394
|
const previous = this.journal.resolution(id);
|
|
368
395
|
if (previous) {
|
|
369
|
-
this.journal.resolve(id, revision, action, reason); // verifies identical request; no second enqueue
|
|
396
|
+
this.journal.resolve(id, revision, action, reason, revision, actor); // verifies identical request; no second enqueue
|
|
370
397
|
return;
|
|
371
398
|
}
|
|
372
|
-
|
|
399
|
+
const recipientConsent = actor === `peer:${record.peer}` && action === "retry" && record.state === "needs_review";
|
|
400
|
+
if (["dispatching", "accepted"].includes(record.state) || (this.peers.get(record.peer)?.state === "busy" && !recipientConsent)) throw new Error("active delivery cannot be resolved");
|
|
373
401
|
if (record.revision !== revision) throw new Error("stale delivery revision");
|
|
374
402
|
if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
|
|
375
403
|
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 +409,17 @@ export class Bus {
|
|
|
381
409
|
this.journal.transaction(() => {
|
|
382
410
|
if (!this.journal!.get(id)) this.journal!.createDelivery({ ...record });
|
|
383
411
|
const current = this.journal!.get(id)!;
|
|
384
|
-
this.journal!.resolve(id, current.revision, action, reason, revision);
|
|
412
|
+
this.journal!.resolve(id, current.revision, action, reason, revision, actor);
|
|
385
413
|
if (record.state === "queued") {
|
|
386
414
|
const ids = new Set(record.originals.map((env) => env.id));
|
|
387
415
|
queue.splice(0, queue.length, ...queue.filter((env) => !ids.has(env.id)));
|
|
388
416
|
}
|
|
389
|
-
if (action === "retry") {
|
|
417
|
+
if (action === "retry" || action === "completed" && actor === "hub") {
|
|
390
418
|
for (const env of record.originals) {
|
|
391
419
|
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);
|
|
420
|
+
else if (action === "retry" && !queue.some((old) => old.id === env.id)) queue.push(env);
|
|
393
421
|
}
|
|
394
|
-
this.queues.set(record.peer, queue);
|
|
422
|
+
if (action === "retry") this.queues.set(record.peer, queue);
|
|
395
423
|
}
|
|
396
424
|
this.persist();
|
|
397
425
|
});
|
|
@@ -436,7 +464,8 @@ export class Bus {
|
|
|
436
464
|
const before = this.journal.get(id);
|
|
437
465
|
const state: JournalDeliveryState = receipt.state === "accepted" ? "accepted" : receipt.state === "completed" ? "completed" : receipt.state === "failed_safe" ? "failed" : "needs_review";
|
|
438
466
|
if (state === "failed" || state === "needs_review") this.deliveryFailed(peer);
|
|
439
|
-
this.journal.
|
|
467
|
+
if (state === "completed") this.journal.completeDelivered(id, peer, "adapter", receipt.reason);
|
|
468
|
+
else this.journal.transition(id, state, receipt.reason);
|
|
440
469
|
if (before?.state === "dispatching" && (state === "accepted" || state === "completed")) {
|
|
441
470
|
this.memoryDelivery(peer, id, before.out, "delivered", before.originals);
|
|
442
471
|
this.delivered(peer, before.originals);
|
|
@@ -463,8 +492,8 @@ export class Bus {
|
|
|
463
492
|
for (const [id, owner] of this.activeDeliveries) {
|
|
464
493
|
if (owner !== peer) continue;
|
|
465
494
|
const row = this.journal.get(id);
|
|
466
|
-
if (row?.out.some((e) => e.id === envelopeId)) {
|
|
467
|
-
this.journal.
|
|
495
|
+
if (row?.peer === peer && ["dispatching", "accepted"].includes(row.state) && row.out.some((e) => e.id === envelopeId)) {
|
|
496
|
+
this.journal.completeDelivered(id, peer, "reply", "correlated reply");
|
|
468
497
|
if (row.state === "dispatching") this.memoryDelivery(peer, id, row.out, "delivered", row.originals);
|
|
469
498
|
this.activeDeliveries.delete(id);
|
|
470
499
|
this.refreshRecoveryHold(peer);
|
|
@@ -783,14 +812,20 @@ export class Bus {
|
|
|
783
812
|
if (index < 0) return;
|
|
784
813
|
this.steering++;
|
|
785
814
|
const deliveryId = crypto.randomUUID();
|
|
815
|
+
let removed = false, handedOff = false;
|
|
786
816
|
try {
|
|
817
|
+
const out = this.retryOut(id, [env], [env]);
|
|
787
818
|
queue.splice(index, 1);
|
|
788
|
-
|
|
819
|
+
removed = true;
|
|
820
|
+
this.durableHandoff(id, deliveryId, [env], out);
|
|
821
|
+
handedOff = true;
|
|
789
822
|
this.activeDeliveries.set(deliveryId, id);
|
|
790
|
-
try { await peer.steer(
|
|
823
|
+
try { await peer.steer(out, deliveryId); }
|
|
791
824
|
catch { this.uncertain(id, deliveryId, "steering outcome is uncertain"); }
|
|
792
|
-
} catch {
|
|
793
|
-
|
|
825
|
+
} catch {
|
|
826
|
+
if (removed && !handedOff) queue.splice(index, 0, env);
|
|
827
|
+
this.storageError = "delivery journal unavailable";
|
|
828
|
+
} finally { this.steering--; this.onQueues?.(); }
|
|
794
829
|
}
|
|
795
830
|
|
|
796
831
|
/** 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 +864,16 @@ export class Bus {
|
|
|
829
864
|
if (!preface && !queue.length) return [];
|
|
830
865
|
const queued = [...queue];
|
|
831
866
|
const batch = [...(preface ? [preface] : []), ...this.take(id, queue)];
|
|
832
|
-
|
|
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);
|
|
867
|
+
let out: Envelope[] = [];
|
|
835
868
|
const deliveryId = crypto.randomUUID();
|
|
836
869
|
try {
|
|
870
|
+
out = this.retryOut(id, batch, batch);
|
|
871
|
+
this.prefaces.delete(id);
|
|
872
|
+
// Before the checkpoint, as in a drain, so the persisted snapshot carries it; a failed policy read rolls back too.
|
|
873
|
+
this.remember(out);
|
|
837
874
|
this.journal?.transaction(() => {
|
|
838
|
-
this.durableHandoff(id, deliveryId, batch,
|
|
839
|
-
this.journal!.
|
|
875
|
+
this.durableHandoff(id, deliveryId, batch, out, false); // observe only after this outer transaction commits
|
|
876
|
+
this.journal!.completeDelivered(deliveryId, id, "pull", "read through hub_inbox");
|
|
840
877
|
});
|
|
841
878
|
} catch (error) {
|
|
842
879
|
queue.splice(0, queue.length, ...queued);
|
|
@@ -845,12 +882,12 @@ export class Bus {
|
|
|
845
882
|
throw error;
|
|
846
883
|
}
|
|
847
884
|
if (this.journal) {
|
|
848
|
-
this.memoryDelivery(id, deliveryId,
|
|
849
|
-
this.memoryDelivery(id, deliveryId,
|
|
885
|
+
this.memoryDelivery(id, deliveryId, out, "rendered", batch);
|
|
886
|
+
this.memoryDelivery(id, deliveryId, out, "delivered", batch);
|
|
850
887
|
}
|
|
851
888
|
this.failureStreak.delete(id); this.lastFailure.delete(id); // read, as a completed push receipt clears them
|
|
852
889
|
this.onQueues?.();
|
|
853
|
-
return
|
|
890
|
+
return out;
|
|
854
891
|
}
|
|
855
892
|
|
|
856
893
|
private async drain(id: PeerId): Promise<void> {
|
|
@@ -872,8 +909,11 @@ export class Bus {
|
|
|
872
909
|
const delivery = preface ? [preface, ...batch] : batch;
|
|
873
910
|
const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
|
|
874
911
|
this.condensing += mayCondense ? 1 : 0;
|
|
875
|
-
|
|
876
|
-
|
|
912
|
+
let out: Envelope[];
|
|
913
|
+
try {
|
|
914
|
+
const condensed = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
|
|
915
|
+
out = this.retryOut(id, delivery, condensed);
|
|
916
|
+
} finally { this.condensing -= mayCondense ? 1 : 0; }
|
|
877
917
|
// A session that cannot show pushes may have taken the peer while this was condensed (issue #205).
|
|
878
918
|
if (!this.deliverable(id)) {
|
|
879
919
|
if (!this.journal) { if (preface) this.restorePreface(id, preface); queue.unshift(...batch.filter((e) => !this.withdrawn.has(e.id))); }
|
|
@@ -885,16 +925,25 @@ export class Bus {
|
|
|
885
925
|
this.dropIrrelevant(id, queue);
|
|
886
926
|
continue;
|
|
887
927
|
}
|
|
928
|
+
const deliveryId = crypto.randomUUID();
|
|
888
929
|
if (this.journal) {
|
|
889
930
|
if (this.prefaces.get(id) !== preface || batch.some((e) => !queue.some((item) => item.id === e.id))) continue;
|
|
931
|
+
const queued = [...queue], seen = [...this.seen];
|
|
890
932
|
for (const env of batch) queue.splice(queue.findIndex((item) => item.id === env.id), 1);
|
|
891
933
|
this.prefaces.delete(id);
|
|
892
|
-
|
|
893
|
-
|
|
934
|
+
this.remember(out); // the checkpoint must retain rendered reply ids across a restart
|
|
935
|
+
try { this.durableHandoff(id, deliveryId, delivery, out); }
|
|
936
|
+
catch (error) {
|
|
937
|
+
queue.splice(0, queue.length, ...queued);
|
|
938
|
+
if (preface) this.prefaces.set(id, preface);
|
|
939
|
+
this.seen.clear();
|
|
940
|
+
for (const [key, env] of seen) this.seen.set(key, env);
|
|
941
|
+
throw error;
|
|
942
|
+
}
|
|
943
|
+
this.activeDeliveries.set(deliveryId, id);
|
|
944
|
+
} else this.remember(out);
|
|
894
945
|
this.lastDelivery.set(id, { out, originals: delivery });
|
|
895
946
|
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
947
|
try {
|
|
899
948
|
try { this.onDeliver?.(id, delivery); } catch { /* observation must never affect delivery */ }
|
|
900
949
|
if (this.journal) await peer.deliver(out, deliveryId);
|