@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.
- package/CHANGELOG.md +33 -0
- package/README.md +29 -9
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +5 -0
- package/docs/agent-notes/daemon.md +14 -8
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +2 -2
- package/docs/operations.md +236 -37
- package/docs/quickstart.md +24 -5
- package/docs/security.md +42 -10
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -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/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verified.json +20 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1361 -44
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/definitions/builtin-metadata.ts +121 -0
- package/src/adapters/definitions/kimi-native.ts +4 -0
- package/src/adapters/definitions/qwen-native.ts +24 -0
- package/src/adapters/definitions/qwen.ts +71 -0
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +11 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +130 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +16 -5
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +208 -40
- package/src/cli/output.ts +15 -2
- package/src/cli/preview.ts +8 -5
- package/src/cli/recovery-guidance.ts +137 -0
- package/src/cli/recovery-presentation-options.ts +8 -0
- package/src/cli/recovery-session-file.ts +18 -0
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -74
- package/src/cli/upgrade-labels.ts +30 -0
- package/src/cli/upgrade-presentation-data.ts +41 -0
- package/src/cli/upgrade-presentation-flags.ts +31 -0
- package/src/cli/upgrade-presentation.ts +217 -0
- package/src/cli/upgrade-restore-state.ts +137 -0
- package/src/cli/upgrade-runtime.ts +430 -175
- package/src/cli/upgrade.ts +325 -49
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +89 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/control-client.ts +5 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +931 -209
- 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/events.ts +2 -2
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +13 -13
- package/src/hub/peers.ts +12 -2
- package/src/hub/permission-mode.ts +5 -3
- package/src/hub/recovery-continuity.ts +152 -0
- package/src/hub/recovery-store.ts +10 -4
- package/src/hub/routing.ts +35 -16
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +52 -37
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +24 -8
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +199 -40
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/memory/capture.ts +2 -1
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +35 -16
- 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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
834
|
+
try { await peer.steer(out, deliveryId); }
|
|
791
835
|
catch { this.uncertain(id, deliveryId, "steering outcome is uncertain"); }
|
|
792
|
-
} catch {
|
|
793
|
-
|
|
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
|
-
|
|
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,
|
|
839
|
-
this.journal!.
|
|
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,
|
|
849
|
-
this.memoryDelivery(id, deliveryId,
|
|
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
|
|
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
|
-
|
|
876
|
-
|
|
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
|
-
|
|
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);
|