@staix/agent-hub 0.30.0 → 0.32.0
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 +52 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +11 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +178 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +23 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +355 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +24 -5
- package/docs/security.md +178 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-0.32.0.md +149 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +78 -17
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/room.ts +20 -10
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +511 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1576 -139
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/commands.ts +61 -16
- package/src/rooms/link.ts +338 -36
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +475 -35
- package/src/rooms/status.ts +8 -1
- package/src/rooms/store.ts +447 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +142 -9
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { dirname } from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
5
|
import type { Database } from "./db-schema.ts";
|
|
4
6
|
import { openHubDatabase } from "./db-schema.ts";
|
|
5
7
|
import type { BusSnapshot } from "./bus.ts";
|
|
6
8
|
import type { QueuePressure } from "./queue-backpressure.ts";
|
|
7
|
-
import { HUB, KINDS, USER, type Envelope, type Kind } from "./envelope.ts";
|
|
9
|
+
import { HUB, KINDS, USER, isInvocationKind, type Envelope, type Kind } from "./envelope.ts";
|
|
8
10
|
import { heldDeliveryHints } from "./delivery-hints.ts";
|
|
9
11
|
import { processLiveness } from "../pi/process-signature.ts";
|
|
10
12
|
import { parseRemotePeerId } from "../rooms/application.ts";
|
|
11
13
|
import { validateDeferredHub } from "./deferred-hub.ts";
|
|
14
|
+
import { isDeliveryFailureCause, type DeliveryFailureCause } from "./delivery-failure.ts";
|
|
12
15
|
|
|
13
16
|
export type JournalDeliveryState =
|
|
14
17
|
| "queued"
|
|
@@ -21,8 +24,11 @@ export type JournalDeliveryState =
|
|
|
21
24
|
export type JournalResolution = "completed" | "retry" | "discard";
|
|
22
25
|
export const MAX_ENVELOPE_BODY = 1_000_000;
|
|
23
26
|
export const TERMINAL_CAP = 2048;
|
|
27
|
+
export const ACCEPTED_SETTLE_TIMEOUT_REASON = "channel delivery confirmation timed out; inspect effects before resolving";
|
|
24
28
|
const SCHEMA_VERSION = 1,
|
|
25
29
|
MAX_ROWS = 100_000,
|
|
30
|
+
MAX_REVIEW_INPUTS = 4096,
|
|
31
|
+
REVIEW_INPUT_VERSION = 2,
|
|
26
32
|
MAX_JSON = 20 * 1024 * 1024;
|
|
27
33
|
const STATES = new Set<JournalDeliveryState>([
|
|
28
34
|
"queued",
|
|
@@ -47,9 +53,29 @@ export interface JournalDelivery {
|
|
|
47
53
|
originals: Envelope[];
|
|
48
54
|
out: Envelope[];
|
|
49
55
|
reason?: string;
|
|
56
|
+
failureCause?: DeliveryFailureCause;
|
|
50
57
|
attempt?: number;
|
|
51
58
|
previousId?: string;
|
|
52
59
|
}
|
|
60
|
+
export interface ReviewInputHold { peer: string; sessionHash: string; inputHash: string | null }
|
|
61
|
+
export interface ReviewInputProvenance { version: 2; instanceId: string; complete: boolean; coverageComplete: boolean; count: number; digest: string; holds: ReviewInputHold[]; incompletePeers: string[]; incompleteSessions: Array<{ peer: string; sessionHash: string }> }
|
|
62
|
+
export type ReviewInputState = { state: "clean" } | { state: "held"; inputHash: string } | { state: "unknown" };
|
|
63
|
+
export interface ReviewInputEvidence {
|
|
64
|
+
provenance: "available" | "corrupt";
|
|
65
|
+
coverageComplete: boolean;
|
|
66
|
+
incompletePeer: boolean;
|
|
67
|
+
incompleteSession: boolean;
|
|
68
|
+
sessionState: "clean" | "held" | "unknown";
|
|
69
|
+
retainedSessionHold: boolean;
|
|
70
|
+
retainedPeerSessionHold: boolean;
|
|
71
|
+
}
|
|
72
|
+
export type ReviewInputHealthCause = "legacy_coverage" | "incomplete_markers" | "provenance_incomplete" | "provenance_corrupt";
|
|
73
|
+
export interface ReviewInputHealth {
|
|
74
|
+
state: "healthy" | "incomplete" | "corrupt";
|
|
75
|
+
cause: ReviewInputHealthCause | null;
|
|
76
|
+
nextStep: string;
|
|
77
|
+
}
|
|
78
|
+
export interface AcceptedDeliveryCandidate { id: string; peer: string; revision: number; updatedAt: number; notifyConductor: boolean }
|
|
53
79
|
export interface DeliveryJournalOptions {
|
|
54
80
|
file: string;
|
|
55
81
|
projectRoot: string;
|
|
@@ -66,7 +92,9 @@ export interface JournalSnapshot {
|
|
|
66
92
|
bus: BusSnapshot;
|
|
67
93
|
deliveries: JournalDelivery[];
|
|
68
94
|
manualPaused: string[];
|
|
95
|
+
reviewInputProvenance?: ReviewInputProvenance;
|
|
69
96
|
}
|
|
97
|
+
export type JournalTransportSnapshot = Omit<JournalSnapshot, "reviewInputProvenance">;
|
|
70
98
|
|
|
71
99
|
function fail(message: string): never {
|
|
72
100
|
throw new Error(`invalid delivery journal: ${message}`);
|
|
@@ -81,6 +109,71 @@ function text(value: unknown, name: string, max = 4096): string {
|
|
|
81
109
|
fail(`${name} must be a non-empty string`);
|
|
82
110
|
return value;
|
|
83
111
|
}
|
|
112
|
+
function hashText(value: unknown, name: string): string {
|
|
113
|
+
const hash = text(value, name, 64);
|
|
114
|
+
if (!/^[a-f0-9]{64}$/.test(hash)) fail(`${name} is invalid`);
|
|
115
|
+
return hash;
|
|
116
|
+
}
|
|
117
|
+
function closedOwnDataFields(value: unknown, name: string, expected: readonly string[]): Record<string, unknown> {
|
|
118
|
+
try {
|
|
119
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) fail(`${name} must be a plain object`);
|
|
120
|
+
const keys = Reflect.ownKeys(value);
|
|
121
|
+
if (keys.length !== expected.length || !expected.every(key => keys.includes(key))) fail(`${name} has unexpected fields`);
|
|
122
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
123
|
+
if (keys.some(key => typeof key !== "string" || !descriptors[key]?.enumerable || !("value" in descriptors[key]!))) fail(`${name} must contain own data fields`);
|
|
124
|
+
return Object.fromEntries(keys.map(key => [key as string, descriptors[key as string]!.value]));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof Error && error.message.startsWith("invalid delivery journal:")) throw error;
|
|
127
|
+
fail(`${name} is unreadable`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function reviewInputHold(value: unknown, name: string): ReviewInputHold {
|
|
131
|
+
const fields = closedOwnDataFields(value, name, ["peer", "sessionHash", "inputHash"]);
|
|
132
|
+
const peer = text(fields.peer, `${name}.peer`, 256);
|
|
133
|
+
const sessionHash = hashText(fields.sessionHash, `${name}.sessionHash`);
|
|
134
|
+
const inputHash = fields.inputHash === null ? null : hashText(fields.inputHash, `${name}.inputHash`);
|
|
135
|
+
return { peer, sessionHash, inputHash };
|
|
136
|
+
}
|
|
137
|
+
function reviewInputDigest(instanceId: string, version: number, complete: boolean, coverageComplete: boolean, count: number, holds: readonly ReviewInputHold[], incompletePeers: readonly string[], incompleteSessions: readonly { peer: string; sessionHash: string }[]): string {
|
|
138
|
+
const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
|
|
139
|
+
const normalizedHolds = [...holds].map(hold => ({ peer: hold.peer, sessionHash: hold.sessionHash, inputHash: hold.inputHash }))
|
|
140
|
+
.sort((a, b) => compare(a.peer, b.peer) || compare(a.sessionHash, b.sessionHash));
|
|
141
|
+
const normalizedPeers = [...incompletePeers].sort();
|
|
142
|
+
const normalizedSessions = [...incompleteSessions].map(row => ({ peer: row.peer, sessionHash: row.sessionHash }))
|
|
143
|
+
.sort((a, b) => compare(a.peer, b.peer) || compare(a.sessionHash, b.sessionHash));
|
|
144
|
+
return createHash("sha256").update(JSON.stringify({ instanceId, version, complete, coverageComplete, count, holds: normalizedHolds, incompletePeers: normalizedPeers, incompleteSessions: normalizedSessions })).digest("hex");
|
|
145
|
+
}
|
|
146
|
+
function legacyReviewInputMarkerAbsent(meta: Record<string, unknown>): boolean {
|
|
147
|
+
return Number(meta.review_input_version ?? 0) === 0 && Number(meta.review_input_complete ?? 0) === 0
|
|
148
|
+
&& Number(meta.review_input_coverage_complete ?? 0) === 0 && Number(meta.review_input_count ?? 0) === 0
|
|
149
|
+
&& String(meta.review_input_digest ?? "") === "" && String(meta.review_input_instance_id ?? "") === "";
|
|
150
|
+
}
|
|
151
|
+
function validateReviewInputProvenance(value: unknown, name = "review input provenance"): ReviewInputProvenance {
|
|
152
|
+
const fields = closedOwnDataFields(value, name, ["version", "instanceId", "complete", "coverageComplete", "count", "digest", "holds", "incompletePeers", "incompleteSessions"]);
|
|
153
|
+
if (fields.version !== REVIEW_INPUT_VERSION || typeof fields.complete !== "boolean" || typeof fields.coverageComplete !== "boolean") fail(`${name} version or completeness is invalid`);
|
|
154
|
+
const instanceId = text(fields.instanceId, `${name}.instanceId`, 256);
|
|
155
|
+
if (Array.isArray(fields.holds) && fields.holds.length > MAX_REVIEW_INPUTS || Array.isArray(fields.incompletePeers) && fields.incompletePeers.length > MAX_REVIEW_INPUTS || Array.isArray(fields.incompleteSessions) && fields.incompleteSessions.length > MAX_REVIEW_INPUTS) fail(`${name} exceeds ${MAX_REVIEW_INPUTS}`);
|
|
156
|
+
const holds = list(fields.holds, `${name}.holds`, MAX_REVIEW_INPUTS).map((row, index) => reviewInputHold(row, `${name}.holds[${index}]`));
|
|
157
|
+
const incompletePeers = list(fields.incompletePeers, `${name}.incompletePeers`, MAX_REVIEW_INPUTS).map((peer, index) => text(peer, `${name}.incompletePeers[${index}]`, 256));
|
|
158
|
+
const incompleteSessions = list(fields.incompleteSessions, `${name}.incompleteSessions`, MAX_REVIEW_INPUTS).map((raw, index) => {
|
|
159
|
+
const row = closedOwnDataFields(raw, `${name}.incompleteSessions[${index}]`, ["peer", "sessionHash"]);
|
|
160
|
+
return { peer: text(row.peer, `${name}.incompleteSessions[${index}].peer`, 256), sessionHash: hashText(row.sessionHash, `${name}.incompleteSessions[${index}].sessionHash`) };
|
|
161
|
+
});
|
|
162
|
+
if (holds.length + incompletePeers.length + incompleteSessions.length > MAX_REVIEW_INPUTS) fail(`${name} exceeds ${MAX_REVIEW_INPUTS}`);
|
|
163
|
+
const keys = new Set<string>();
|
|
164
|
+
for (const hold of holds) {
|
|
165
|
+
const key = JSON.stringify([hold.peer, hold.sessionHash]);
|
|
166
|
+
if (keys.has(key)) fail(`${name} has duplicate sessions`);
|
|
167
|
+
keys.add(key);
|
|
168
|
+
}
|
|
169
|
+
if (new Set(incompletePeers).size !== incompletePeers.length) fail(`${name} has duplicate incomplete peers`);
|
|
170
|
+
const incompleteKeys = new Set(incompleteSessions.map(row => JSON.stringify([row.peer, row.sessionHash])));
|
|
171
|
+
if (incompleteKeys.size !== incompleteSessions.length) fail(`${name} has duplicate incomplete sessions`);
|
|
172
|
+
const count = integer(fields.count, `${name}.count`, 0, MAX_REVIEW_INPUTS);
|
|
173
|
+
const digest = hashText(fields.digest, `${name}.digest`);
|
|
174
|
+
if (count !== holds.length + incompletePeers.length + incompleteSessions.length || digest !== reviewInputDigest(instanceId, REVIEW_INPUT_VERSION, fields.complete, fields.coverageComplete, count, holds, incompletePeers, incompleteSessions)) fail(`${name} integrity does not match its rows`);
|
|
175
|
+
return { version: REVIEW_INPUT_VERSION, instanceId, complete: fields.complete, coverageComplete: fields.coverageComplete, count, digest, holds, incompletePeers, incompleteSessions };
|
|
176
|
+
}
|
|
84
177
|
function integer(
|
|
85
178
|
value: unknown,
|
|
86
179
|
name: string,
|
|
@@ -204,6 +297,11 @@ function validateBus(value: unknown, name = "bus"): BusSnapshot {
|
|
|
204
297
|
if (b.journal !== undefined) fail("nested journal snapshot is invalid");
|
|
205
298
|
return b as unknown as BusSnapshot;
|
|
206
299
|
}
|
|
300
|
+
function checkedFailureCause(value: unknown): DeliveryFailureCause | undefined {
|
|
301
|
+
if (value === undefined) return undefined;
|
|
302
|
+
if (!isDeliveryFailureCause(value)) fail("delivery.failureCause is invalid");
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
207
305
|
function validateDelivery(value: unknown, name = "delivery"): JournalDelivery {
|
|
208
306
|
const d = obj(value, name);
|
|
209
307
|
text(d.id, `${name}.id`, 256);
|
|
@@ -220,6 +318,7 @@ function validateDelivery(value: unknown, name = "delivery"): JournalDelivery {
|
|
|
220
318
|
validateEnvelope(e, `${name}.out[${i}]`),
|
|
221
319
|
);
|
|
222
320
|
if (d.reason !== undefined) text(d.reason, `${name}.reason`, 1_000_000);
|
|
321
|
+
const failureCause = checkedFailureCause(d.failureCause);
|
|
223
322
|
if (d.attempt !== undefined) integer(d.attempt, `${name}.attempt`, 0, 100);
|
|
224
323
|
if (d.previousId !== undefined) text(d.previousId, `${name}.previousId`, 256);
|
|
225
324
|
return {
|
|
@@ -232,6 +331,7 @@ function validateDelivery(value: unknown, name = "delivery"): JournalDelivery {
|
|
|
232
331
|
originals,
|
|
233
332
|
out,
|
|
234
333
|
...(d.reason === undefined ? {} : { reason: d.reason as string }),
|
|
334
|
+
...(failureCause === undefined ? {} : { failureCause }),
|
|
235
335
|
...(d.attempt === undefined ? {} : { attempt: d.attempt as number }),
|
|
236
336
|
...(d.previousId === undefined
|
|
237
337
|
? {}
|
|
@@ -259,6 +359,10 @@ export function validateJournalSnapshot(
|
|
|
259
359
|
if (ids.has(d.id)) fail(`duplicate delivery ${d.id}`);
|
|
260
360
|
ids.add(d.id);
|
|
261
361
|
}
|
|
362
|
+
if (Object.hasOwn(s, "reviewInputProvenance")) {
|
|
363
|
+
const provenance = validateReviewInputProvenance(s.reviewInputProvenance, "snapshot.reviewInputProvenance");
|
|
364
|
+
if (provenance.instanceId !== s.instanceId) fail("snapshot review input instance binding is invalid");
|
|
365
|
+
}
|
|
262
366
|
return true;
|
|
263
367
|
}
|
|
264
368
|
|
|
@@ -272,6 +376,11 @@ export class DeliveryJournal {
|
|
|
272
376
|
private queueMetadataTracking = false;
|
|
273
377
|
private queueMetadataInvalidated = false;
|
|
274
378
|
private pruningDeferred = 0;
|
|
379
|
+
private legacyReviewCandidateIds = new Set<string>();
|
|
380
|
+
private legacyReviewCandidatesCaptured = false;
|
|
381
|
+
private legacyReviewCandidatesOverflow = false;
|
|
382
|
+
private legacyReviewCandidateFenceIds: string[] | undefined;
|
|
383
|
+
private legacyReviewCandidateCaptureRevision = -1;
|
|
275
384
|
private readonly operationId?: string;
|
|
276
385
|
private readonly terminalHistoryLimit?: () => number;
|
|
277
386
|
constructor(options: DeliveryJournalOptions) {
|
|
@@ -288,29 +397,52 @@ export class DeliveryJournal {
|
|
|
288
397
|
this.db.run("PRAGMA synchronous = FULL");
|
|
289
398
|
this.createSchema();
|
|
290
399
|
const meta = this.readMeta();
|
|
291
|
-
|
|
400
|
+
let openDeliveryFenceIds: string[] | undefined;
|
|
401
|
+
if (!meta) {
|
|
402
|
+
const holds = this.reviewInputRows(), incompletePeers = this.reviewInputIncompletePeerRows(), incompleteSessions = this.reviewInputIncompleteSessionRows();
|
|
403
|
+
const legacyTable = this.db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='review_input_holds'").get();
|
|
404
|
+
const legacyCount = legacyTable ? Number((this.db.query("SELECT COUNT(*) AS n FROM review_input_holds WHERE project_id=?").get(this.projectId) as { n: number }).n) : 0;
|
|
405
|
+
const provenanceCount = holds.length + incompletePeers.length + incompleteSessions.length;
|
|
406
|
+
const complete = provenanceCount === 0 || legacyCount === provenanceCount, coverageComplete = complete && legacyCount === 0;
|
|
292
407
|
this.db
|
|
293
408
|
.query(
|
|
294
|
-
"INSERT INTO delivery_meta (project_id, project_root, revision, instance_id, operation_id, retry_history_cursor) VALUES (?, ?, 0, ?, ?, 0)",
|
|
409
|
+
"INSERT INTO delivery_meta (project_id, project_root, revision, instance_id, operation_id, retry_history_cursor, review_input_version, review_input_complete, review_input_coverage_complete, review_input_count, review_input_digest, review_input_instance_id) VALUES (?, ?, 0, ?, ?, 0, 2, ?, ?, ?, ?, ?)",
|
|
295
410
|
)
|
|
296
411
|
.run(
|
|
297
412
|
this.projectId,
|
|
298
413
|
this.projectRoot,
|
|
299
414
|
this.instanceId,
|
|
300
415
|
this.operationId ?? null,
|
|
416
|
+
complete ? 1 : 0,
|
|
417
|
+
coverageComplete ? 1 : 0,
|
|
418
|
+
provenanceCount,
|
|
419
|
+
reviewInputDigest(this.instanceId, REVIEW_INPUT_VERSION, true, coverageComplete, holds.length + incompletePeers.length + incompleteSessions.length, holds, incompletePeers, incompleteSessions),
|
|
420
|
+
this.instanceId,
|
|
301
421
|
);
|
|
302
|
-
else {
|
|
303
|
-
this.validateStored(meta);
|
|
422
|
+
} else {
|
|
423
|
+
openDeliveryFenceIds = this.validateStored(meta, true);
|
|
304
424
|
this.rejectLiveOwner(meta);
|
|
425
|
+
let reviewInput: ReviewInputProvenance | undefined;
|
|
426
|
+
try {
|
|
427
|
+
const prior = this.readReviewInputProvenance(meta);
|
|
428
|
+
reviewInput = { ...prior, instanceId: this.instanceId, digest: reviewInputDigest(this.instanceId, REVIEW_INPUT_VERSION, prior.complete, prior.coverageComplete, prior.count, prior.holds, prior.incompletePeers, prior.incompleteSessions) };
|
|
429
|
+
}
|
|
430
|
+
catch { /* corrupt provenance remains unknown; ordinary delivery state stays available */ }
|
|
305
431
|
this.db.transaction(() => {
|
|
306
432
|
this.db
|
|
307
433
|
.query(
|
|
308
|
-
"UPDATE delivery_meta SET project_root = ?, instance_id = ?, operation_id = COALESCE(?, operation_id) WHERE project_id = ?",
|
|
434
|
+
"UPDATE delivery_meta SET project_root = ?, instance_id = ?, operation_id = COALESCE(?, operation_id), review_input_version = ?, review_input_instance_id = ?, review_input_complete = ?, review_input_coverage_complete = ?, review_input_count = ?, review_input_digest = ? WHERE project_id = ?",
|
|
309
435
|
)
|
|
310
436
|
.run(
|
|
311
437
|
this.projectRoot,
|
|
312
438
|
this.instanceId,
|
|
313
439
|
this.operationId ?? null,
|
|
440
|
+
REVIEW_INPUT_VERSION,
|
|
441
|
+
this.instanceId,
|
|
442
|
+
reviewInput?.complete === true ? 1 : 0,
|
|
443
|
+
reviewInput?.coverageComplete === true ? 1 : 0,
|
|
444
|
+
reviewInput?.count ?? Number(meta.review_input_count ?? 0),
|
|
445
|
+
reviewInput?.digest ?? String(meta.review_input_digest ?? ""),
|
|
314
446
|
this.projectId,
|
|
315
447
|
);
|
|
316
448
|
// A new daemon cannot infer completion of an old process's handoffs, including
|
|
@@ -318,9 +450,10 @@ export class DeliveryJournal {
|
|
|
318
450
|
for (const row of this.db.query("SELECT * FROM task_offer_reads WHERE project_id = ?").all(this.projectId) as Record<string, unknown>[]) this.validateOfferRead(row);
|
|
319
451
|
this.recoverRetryHistory(meta);
|
|
320
452
|
this.recoverRetryEvidence();
|
|
321
|
-
this.recoverInterrupted();
|
|
453
|
+
this.recoverInterrupted(meta);
|
|
322
454
|
})();
|
|
323
455
|
}
|
|
456
|
+
this.captureLegacyReviewCandidates(false, openDeliveryFenceIds);
|
|
324
457
|
}
|
|
325
458
|
private harden(): void {
|
|
326
459
|
chmodSync(this.file, 0o600);
|
|
@@ -335,7 +468,7 @@ export class DeliveryJournal {
|
|
|
335
468
|
}
|
|
336
469
|
private createSchema(): void {
|
|
337
470
|
this.db.run(
|
|
338
|
-
`CREATE TABLE IF NOT EXISTS delivery_meta (project_id TEXT PRIMARY KEY, project_root TEXT, schema_version INTEGER NOT NULL DEFAULT 1, revision INTEGER NOT NULL, instance_id TEXT NOT NULL, operation_id TEXT, manual_paused TEXT NOT NULL DEFAULT '[]', bus_snapshot TEXT NOT NULL DEFAULT '{}')`,
|
|
471
|
+
`CREATE TABLE IF NOT EXISTS delivery_meta (project_id TEXT PRIMARY KEY, project_root TEXT, schema_version INTEGER NOT NULL DEFAULT 1, revision INTEGER NOT NULL, instance_id TEXT NOT NULL, operation_id TEXT, manual_paused TEXT NOT NULL DEFAULT '[]', bus_snapshot TEXT NOT NULL DEFAULT '{}', review_input_version INTEGER NOT NULL DEFAULT 0, review_input_complete INTEGER NOT NULL DEFAULT 0, review_input_coverage_complete INTEGER NOT NULL DEFAULT 0, review_input_count INTEGER NOT NULL DEFAULT 0, review_input_digest TEXT NOT NULL DEFAULT '', review_input_instance_id TEXT NOT NULL DEFAULT '')`,
|
|
339
472
|
);
|
|
340
473
|
for (const [column, definition] of [
|
|
341
474
|
["project_root", "TEXT"],
|
|
@@ -343,6 +476,12 @@ export class DeliveryJournal {
|
|
|
343
476
|
["operation_id", "TEXT"],
|
|
344
477
|
["retry_unknown_ids", "TEXT NOT NULL DEFAULT '{}'"],
|
|
345
478
|
["retry_history_cursor", "INTEGER NOT NULL DEFAULT 0"],
|
|
479
|
+
["review_input_version", "INTEGER NOT NULL DEFAULT 0"],
|
|
480
|
+
["review_input_complete", "INTEGER NOT NULL DEFAULT 0"],
|
|
481
|
+
["review_input_coverage_complete", "INTEGER NOT NULL DEFAULT 0"],
|
|
482
|
+
["review_input_count", "INTEGER NOT NULL DEFAULT 0"],
|
|
483
|
+
["review_input_digest", "TEXT NOT NULL DEFAULT ''"],
|
|
484
|
+
["review_input_instance_id", "TEXT NOT NULL DEFAULT ''"],
|
|
346
485
|
] as const) {
|
|
347
486
|
const columns = this.db
|
|
348
487
|
.query("PRAGMA table_info(delivery_meta)")
|
|
@@ -355,6 +494,43 @@ export class DeliveryJournal {
|
|
|
355
494
|
this.db.run(
|
|
356
495
|
`CREATE TABLE IF NOT EXISTS deliveries (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, peer TEXT NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, originals TEXT NOT NULL, out_json TEXT NOT NULL, reason TEXT, attempt INTEGER, previous_id TEXT)`,
|
|
357
496
|
);
|
|
497
|
+
const deliveryColumns = this.db.query("PRAGMA table_info(deliveries)").all() as { name: string }[];
|
|
498
|
+
if (!deliveryColumns.some(column => column.name === "failure_cause")) this.db.run("ALTER TABLE deliveries ADD COLUMN failure_cause TEXT");
|
|
499
|
+
this.db.run("CREATE TABLE IF NOT EXISTS review_input_sessions (project_id TEXT NOT NULL, peer TEXT NOT NULL, session_hash TEXT NOT NULL, input_hash TEXT, PRIMARY KEY(project_id,peer,session_hash))");
|
|
500
|
+
this.db.run("CREATE TABLE IF NOT EXISTS review_input_incomplete_peers (project_id TEXT NOT NULL, peer TEXT NOT NULL, PRIMARY KEY(project_id,peer))");
|
|
501
|
+
this.db.run("CREATE TABLE IF NOT EXISTS review_input_incomplete_sessions (project_id TEXT NOT NULL, peer TEXT NOT NULL, session_hash TEXT NOT NULL, PRIMARY KEY(project_id,peer,session_hash))");
|
|
502
|
+
const legacyColumns = this.db.query("PRAGMA table_info(review_input_holds)").all() as { name: string }[];
|
|
503
|
+
const meta = this.readMeta();
|
|
504
|
+
const hasCurrentProvenance = !!this.db.query("SELECT 1 FROM review_input_sessions WHERE project_id=? UNION ALL SELECT 1 FROM review_input_incomplete_peers WHERE project_id=? UNION ALL SELECT 1 FROM review_input_incomplete_sessions WHERE project_id=? LIMIT 1")
|
|
505
|
+
.get(this.projectId, this.projectId, this.projectId);
|
|
506
|
+
if (legacyColumns.length && (!meta || legacyReviewInputMarkerAbsent(meta) && !hasCurrentProvenance)) {
|
|
507
|
+
this.db.transaction(() => {
|
|
508
|
+
const hasSession = legacyColumns.some(column => column.name === "session_hash"), hasHash = legacyColumns.some(column => column.name === "input_hash");
|
|
509
|
+
if (hasSession && hasHash) {
|
|
510
|
+
const legacy = this.db.query("SELECT project_id,peer,session_hash,input_hash FROM review_input_holds WHERE project_id=? ORDER BY peer LIMIT ?").all(this.projectId, MAX_REVIEW_INPUTS) as Record<string, unknown>[];
|
|
511
|
+
for (const row of legacy) {
|
|
512
|
+
const project = this.projectId;
|
|
513
|
+
try {
|
|
514
|
+
const hold = reviewInputHold({ peer: row.peer, sessionHash: row.session_hash, inputHash: row.input_hash }, "legacy review input");
|
|
515
|
+
this.db.query("INSERT OR IGNORE INTO review_input_sessions(project_id,peer,session_hash,input_hash) VALUES(?,?,?,?)").run(project, hold.peer, hold.sessionHash, hold.inputHash);
|
|
516
|
+
} catch {
|
|
517
|
+
if (typeof row.peer === "string" && row.peer.length > 0 && row.peer.length <= 256) {
|
|
518
|
+
if (typeof row.session_hash === "string" && /^[a-f0-9]{64}$/.test(row.session_hash)) this.db.query("INSERT OR IGNORE INTO review_input_incomplete_sessions(project_id,peer,session_hash) VALUES(?,?,?)").run(project, row.peer, row.session_hash);
|
|
519
|
+
else this.db.query("INSERT OR IGNORE INTO review_input_incomplete_peers(project_id,peer) VALUES(?,?)").run(project, row.peer);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const holds = this.db.query("SELECT peer,session_hash,input_hash FROM review_input_sessions WHERE project_id=?").all(this.projectId) as Record<string, unknown>[];
|
|
525
|
+
const normalized = holds.map((row, index) => reviewInputHold({ peer: row.peer, sessionHash: row.session_hash, inputHash: row.input_hash }, `migrated review input ${index}`));
|
|
526
|
+
const incompletePeers = this.reviewInputIncompletePeerRows(), incompleteSessions = this.reviewInputIncompleteSessionRows();
|
|
527
|
+
if (meta) {
|
|
528
|
+
const instance = String(meta.instance_id), count = normalized.length + incompletePeers.length + incompleteSessions.length, digest = reviewInputDigest(instance, REVIEW_INPUT_VERSION, true, false, count, normalized, incompletePeers, incompleteSessions);
|
|
529
|
+
this.db.query("UPDATE delivery_meta SET review_input_version=?,review_input_complete=1,review_input_coverage_complete=0,review_input_count=?,review_input_digest=?,review_input_instance_id=? WHERE project_id=?")
|
|
530
|
+
.run(REVIEW_INPUT_VERSION, count, digest, instance, this.projectId);
|
|
531
|
+
}
|
|
532
|
+
})();
|
|
533
|
+
}
|
|
358
534
|
this.db.run("CREATE TABLE IF NOT EXISTS delivery_queue_meta_state (project_id TEXT PRIMARY KEY, revision INTEGER NOT NULL)");
|
|
359
535
|
this.db.run("CREATE TABLE IF NOT EXISTS delivery_queue_meta (project_id TEXT NOT NULL, peer TEXT NOT NULL, queued INTEGER NOT NULL, oldest_at INTEGER, unknown_age INTEGER NOT NULL, PRIMARY KEY(project_id,peer))");
|
|
360
536
|
this.db.run("CREATE TABLE IF NOT EXISTS task_offer_reads (project_id TEXT NOT NULL, peer TEXT NOT NULL, envelope_id TEXT NOT NULL, delivery_id TEXT NOT NULL, source TEXT NOT NULL CHECK(source IN ('adapter','reply','pull')), offer_revision INTEGER NOT NULL CHECK(offer_revision >= 1), PRIMARY KEY(project_id,peer,envelope_id))");
|
|
@@ -443,7 +619,7 @@ export class DeliveryJournal {
|
|
|
443
619
|
if (e instanceof Error && e.message.includes("owned by a live")) throw e;
|
|
444
620
|
}
|
|
445
621
|
}
|
|
446
|
-
private validateStored(meta: Record<string, unknown
|
|
622
|
+
private validateStored(meta: Record<string, unknown>, returnDeliveryIds = false): string[] | undefined {
|
|
447
623
|
if (
|
|
448
624
|
meta.project_root != null &&
|
|
449
625
|
String(meta.project_root) !== this.projectRoot
|
|
@@ -480,13 +656,204 @@ export class DeliveryJournal {
|
|
|
480
656
|
.get(this.projectId, MAX_ROWS)
|
|
481
657
|
)
|
|
482
658
|
fail("too many delivery rows");
|
|
659
|
+
return returnDeliveryIds ? rows.map(row => text(row.id, "stored delivery id", 256)) : undefined;
|
|
483
660
|
}
|
|
484
|
-
private
|
|
485
|
-
this.db
|
|
486
|
-
.
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
661
|
+
private reviewInputRows(): ReviewInputHold[] {
|
|
662
|
+
const rows = this.db.query("SELECT peer,session_hash,input_hash FROM review_input_sessions WHERE project_id=? ORDER BY peer,session_hash LIMIT ?")
|
|
663
|
+
.all(this.projectId, MAX_REVIEW_INPUTS + 1) as Record<string, unknown>[];
|
|
664
|
+
if (rows.length > MAX_REVIEW_INPUTS) fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
|
|
665
|
+
return rows.map((row, index) => reviewInputHold({ peer: row.peer, sessionHash: row.session_hash, inputHash: row.input_hash }, `stored review input ${index}`));
|
|
666
|
+
}
|
|
667
|
+
private reviewInputIncompletePeerRows(): string[] {
|
|
668
|
+
const rows = this.db.query("SELECT peer FROM review_input_incomplete_peers WHERE project_id=? ORDER BY peer LIMIT ?")
|
|
669
|
+
.all(this.projectId, MAX_REVIEW_INPUTS + 1) as { peer: unknown }[];
|
|
670
|
+
if (rows.length > MAX_REVIEW_INPUTS) fail(`too many incomplete review input peers (limit ${MAX_REVIEW_INPUTS})`);
|
|
671
|
+
return rows.map((row, index) => text(row.peer, `stored incomplete review input peer ${index}`, 256));
|
|
672
|
+
}
|
|
673
|
+
private reviewInputIncompleteSessionRows(): Array<{ peer: string; sessionHash: string }> {
|
|
674
|
+
const rows = this.db.query("SELECT peer,session_hash FROM review_input_incomplete_sessions WHERE project_id=? ORDER BY peer,session_hash LIMIT ?")
|
|
675
|
+
.all(this.projectId, MAX_REVIEW_INPUTS + 1) as Record<string, unknown>[];
|
|
676
|
+
if (rows.length > MAX_REVIEW_INPUTS) fail(`too many incomplete review input sessions (limit ${MAX_REVIEW_INPUTS})`);
|
|
677
|
+
return rows.map((row, index) => ({ peer: text(row.peer, `stored incomplete review input session peer ${index}`, 256), sessionHash: hashText(row.session_hash, `stored incomplete review input session hash ${index}`) }));
|
|
678
|
+
}
|
|
679
|
+
private readReviewInputProvenance(meta = this.readMeta()): ReviewInputProvenance {
|
|
680
|
+
if (!meta) throw new Error("delivery journal metadata missing");
|
|
681
|
+
const holds = this.reviewInputRows(), incompletePeers = this.reviewInputIncompletePeerRows(), incompleteSessions = this.reviewInputIncompleteSessionRows();
|
|
682
|
+
if (holds.length + incompletePeers.length + incompleteSessions.length > MAX_REVIEW_INPUTS) fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
|
|
683
|
+
const instanceId = text(String(meta.instance_id), "review input owner instance", 256), count = holds.length + incompletePeers.length + incompleteSessions.length;
|
|
684
|
+
const version = Number(meta.review_input_version ?? 0), completeRaw = Number(meta.review_input_complete ?? 0), coverageRaw = Number(meta.review_input_coverage_complete ?? 0);
|
|
685
|
+
if (version === 0 && completeRaw === 0 && coverageRaw === 0 && Number(meta.review_input_count ?? 0) === 0 && String(meta.review_input_digest ?? "") === "" && String(meta.review_input_instance_id ?? "") === "") {
|
|
686
|
+
if (count) fail("legacy review input marker is missing for stored provenance rows");
|
|
687
|
+
return { version: REVIEW_INPUT_VERSION, instanceId, complete: true, coverageComplete: false, count, digest: reviewInputDigest(instanceId, REVIEW_INPUT_VERSION, true, false, count, holds, incompletePeers, incompleteSessions), holds, incompletePeers, incompleteSessions };
|
|
688
|
+
}
|
|
689
|
+
if (version !== REVIEW_INPUT_VERSION || ![0, 1].includes(completeRaw)) fail("review input coverage metadata is invalid");
|
|
690
|
+
if (![0, 1].includes(coverageRaw)) fail("review input coverage metadata is invalid");
|
|
691
|
+
const storedInstance = text(String(meta.review_input_instance_id ?? ""), "review input coverage instance", 256);
|
|
692
|
+
const storedCount = integer(Number(meta.review_input_count), "review input coverage count", 0, MAX_REVIEW_INPUTS);
|
|
693
|
+
const storedDigest = hashText(meta.review_input_digest, "review input coverage digest");
|
|
694
|
+
if (storedCount !== count || storedDigest !== reviewInputDigest(storedInstance, version, completeRaw === 1, coverageRaw === 1, storedCount, holds, incompletePeers, incompleteSessions)) fail("review input coverage integrity mismatch");
|
|
695
|
+
const markerMatches = storedInstance === instanceId, complete = completeRaw === 1 && markerMatches, coverageComplete = coverageRaw === 1 && markerMatches;
|
|
696
|
+
const digest = reviewInputDigest(instanceId, REVIEW_INPUT_VERSION, complete, coverageComplete, count, holds, incompletePeers, incompleteSessions);
|
|
697
|
+
return { version: REVIEW_INPUT_VERSION, instanceId, complete, coverageComplete, count, digest, holds, incompletePeers, incompleteSessions };
|
|
698
|
+
}
|
|
699
|
+
/** Snapshot the pre-existing handed review rows once while legacy coverage is incomplete. */
|
|
700
|
+
// ponytail: reopen/import recaptures conservatively; if measured restart timing makes guarded rows prolong coverage, persist a bounded IDs-only candidate fence in recovery snapshots, never envelope bodies or a metadata-version change.
|
|
701
|
+
private captureLegacyReviewCandidates(force = false, importedFenceIds?: readonly string[]): void {
|
|
702
|
+
this.legacyReviewCandidateIds = new Set();
|
|
703
|
+
this.legacyReviewCandidatesCaptured = true;
|
|
704
|
+
this.legacyReviewCandidatesOverflow = false;
|
|
705
|
+
const revision = Number(this.readMeta()?.revision ?? -1);
|
|
706
|
+
this.legacyReviewCandidateCaptureRevision = revision;
|
|
707
|
+
let provenance: ReviewInputProvenance;
|
|
708
|
+
try {
|
|
709
|
+
provenance = this.readReviewInputProvenance();
|
|
710
|
+
} catch {
|
|
711
|
+
// Corrupt provenance is not repaired by retrying a coverage read.
|
|
712
|
+
this.legacyReviewCandidatesOverflow = true;
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (!provenance.complete) return;
|
|
716
|
+
if (provenance.coverageComplete && !force) {
|
|
717
|
+
this.legacyReviewCandidateFenceIds = undefined;
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (importedFenceIds !== undefined) {
|
|
721
|
+
this.legacyReviewCandidateFenceIds = [...importedFenceIds];
|
|
722
|
+
} else if (force || this.legacyReviewCandidateFenceIds === undefined) {
|
|
723
|
+
try {
|
|
724
|
+
const rows = this.db.query("SELECT id FROM deliveries WHERE project_id=? LIMIT ?").all(this.projectId, MAX_ROWS + 1) as { id: unknown }[];
|
|
725
|
+
if (rows.length > MAX_ROWS) { this.legacyReviewCandidatesOverflow = true; return; }
|
|
726
|
+
this.legacyReviewCandidateFenceIds = rows.map(row => text(row.id, "legacy delivery fence id", 256));
|
|
727
|
+
} catch {
|
|
728
|
+
// Without a frozen id fence, later reads cannot distinguish old rows from current writes.
|
|
729
|
+
this.legacyReviewCandidatesOverflow = true;
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const candidateIds = new Set<string>();
|
|
734
|
+
try {
|
|
735
|
+
const fenceIds = this.legacyReviewCandidateFenceIds ?? [];
|
|
736
|
+
for (let offset = 0; offset < fenceIds.length; offset += 256) {
|
|
737
|
+
const batch = fenceIds.slice(offset, offset + 256);
|
|
738
|
+
const rows = this.db.query(`WITH RECURSIVE roots(id) AS (
|
|
739
|
+
SELECT d.id FROM json_each(?) fence
|
|
740
|
+
JOIN deliveries d ON d.project_id=? AND d.id=fence.value
|
|
741
|
+
WHERE (d.state IN ('dispatching','accepted','needs_review')
|
|
742
|
+
OR (d.state='failed' AND EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id=d.project_id AND c.previous_id=d.id)))
|
|
743
|
+
AND EXISTS (SELECT 1 FROM json_each(d.originals) e
|
|
744
|
+
WHERE (json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review')
|
|
745
|
+
OR (json_extract(e.value,'$.from') NOT IN ('hub','user') AND json_extract(e.value,'$.kind') IN ('chat','status')
|
|
746
|
+
AND json_type(e.value,'$.refs.reviewResponse')='text'))
|
|
747
|
+
), lineage(id) AS (
|
|
748
|
+
SELECT id FROM roots
|
|
749
|
+
UNION
|
|
750
|
+
SELECT child.id FROM deliveries child JOIN lineage parent ON child.previous_id=parent.id
|
|
751
|
+
WHERE child.project_id=? AND (child.state IN ('queued','dispatching','accepted','needs_review')
|
|
752
|
+
OR (child.state='failed' AND EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id=child.project_id AND c.previous_id=child.id)))
|
|
753
|
+
)
|
|
754
|
+
SELECT id FROM lineage LIMIT ?`).all(JSON.stringify(batch), this.projectId, this.projectId, MAX_REVIEW_INPUTS + 1) as { id: unknown }[];
|
|
755
|
+
for (const row of rows) candidateIds.add(text(row.id, "legacy review delivery id", 256));
|
|
756
|
+
if (candidateIds.size > MAX_REVIEW_INPUTS) { this.legacyReviewCandidatesOverflow = true; return; }
|
|
757
|
+
}
|
|
758
|
+
} catch {
|
|
759
|
+
// Keep the frozen epoch fence and retry only after the journal advances.
|
|
760
|
+
this.legacyReviewCandidatesOverflow = true;
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
this.legacyReviewCandidateIds = candidateIds;
|
|
764
|
+
this.legacyReviewCandidateFenceIds = undefined;
|
|
765
|
+
}
|
|
766
|
+
/** Check only captured pre-upgrade rows; current guarded deliveries carry their own provenance. */
|
|
767
|
+
private hasUnsettledReviewInput(): boolean {
|
|
768
|
+
if (!this.legacyReviewCandidatesCaptured) this.captureLegacyReviewCandidates();
|
|
769
|
+
if (this.legacyReviewCandidatesOverflow && Number(this.readMeta()?.revision ?? -1) !== this.legacyReviewCandidateCaptureRevision) this.captureLegacyReviewCandidates();
|
|
770
|
+
if (this.legacyReviewCandidatesOverflow) return true;
|
|
771
|
+
const ids = [...this.legacyReviewCandidateIds];
|
|
772
|
+
for (let offset = 0; offset < ids.length; offset += 256) {
|
|
773
|
+
const batch = ids.slice(offset, offset + 256);
|
|
774
|
+
try {
|
|
775
|
+
const rows = this.db.query(`SELECT d.state FROM json_each(?) candidate
|
|
776
|
+
LEFT JOIN deliveries d ON d.project_id=? AND d.id=candidate.value`).all(JSON.stringify(batch), this.projectId) as { state: string | null }[];
|
|
777
|
+
if (rows.length !== batch.length || rows.some(row => !row.state || !["completed", "failed", "discarded"].includes(row.state))) return true;
|
|
778
|
+
} catch { return true; }
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
private markReviewInputCoverageIncomplete(prior: ReviewInputProvenance): void {
|
|
783
|
+
if (prior.coverageComplete) this.captureLegacyReviewCandidates(true);
|
|
784
|
+
this.transaction(() => {
|
|
785
|
+
this.bump();
|
|
786
|
+
this.writeReviewInputProvenance(prior.complete, false);
|
|
787
|
+
this.harden();
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
/** Complete legacy coverage after every handed review delivery is settled; then NULL means clean by absence. */
|
|
791
|
+
private refreshReviewInputCoverage(inTransaction = false): void {
|
|
792
|
+
let prior: ReviewInputProvenance;
|
|
793
|
+
try { prior = this.readReviewInputProvenance(); }
|
|
794
|
+
catch { return; } // Corrupt provenance stays held without blocking unrelated journal work.
|
|
795
|
+
if (!prior.complete) return;
|
|
796
|
+
const unsettled = !prior.coverageComplete && this.hasUnsettledReviewInput();
|
|
797
|
+
const cleanRows = prior.coverageComplete ? Number((this.db.query("SELECT COUNT(*) AS n FROM review_input_sessions WHERE project_id=? AND input_hash IS NULL").get(this.projectId) as { n: number }).n) : 0;
|
|
798
|
+
if (unsettled) return;
|
|
799
|
+
if (!prior.coverageComplete || cleanRows > 0) {
|
|
800
|
+
const update = () => {
|
|
801
|
+
this.bump();
|
|
802
|
+
if (!prior.coverageComplete) this.db.query("UPDATE delivery_meta SET review_input_coverage_complete=1 WHERE project_id=?").run(this.projectId);
|
|
803
|
+
if (!prior.coverageComplete || cleanRows > 0) this.db.query("DELETE FROM review_input_sessions WHERE project_id=? AND input_hash IS NULL").run(this.projectId);
|
|
804
|
+
this.writeReviewInputProvenance(prior.complete, true);
|
|
805
|
+
this.harden();
|
|
806
|
+
};
|
|
807
|
+
if (inTransaction) update(); else this.transaction(update);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
private writeReviewInputProvenance(complete: boolean, coverageComplete: boolean): void {
|
|
811
|
+
const holds = this.reviewInputRows(), incompletePeers = this.reviewInputIncompletePeerRows(), incompleteSessions = this.reviewInputIncompleteSessionRows();
|
|
812
|
+
if (holds.length + incompletePeers.length + incompleteSessions.length > MAX_REVIEW_INPUTS) fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
|
|
813
|
+
const count = holds.length + incompletePeers.length + incompleteSessions.length;
|
|
814
|
+
this.db.query("UPDATE delivery_meta SET review_input_version=?,review_input_complete=?,review_input_coverage_complete=?,review_input_count=?,review_input_digest=?,review_input_instance_id=? WHERE project_id=?")
|
|
815
|
+
.run(REVIEW_INPUT_VERSION, complete ? 1 : 0, coverageComplete ? 1 : 0, count, reviewInputDigest(this.instanceId, REVIEW_INPUT_VERSION, complete, coverageComplete, count, holds, incompletePeers, incompleteSessions), this.instanceId, this.projectId);
|
|
816
|
+
}
|
|
817
|
+
/** Persist the scoped unknown marker before changing this session's held/clear receipt. */
|
|
818
|
+
private writePendingReviewInputSession(peer: string, sessionHash: string, complete: boolean, coverageComplete: boolean, clearIncompletePeer = false): void {
|
|
819
|
+
this.transaction(() => {
|
|
820
|
+
this.bump();
|
|
821
|
+
this.db.query("INSERT OR IGNORE INTO review_input_incomplete_sessions(project_id,peer,session_hash) VALUES(?,?,?)")
|
|
822
|
+
.run(this.projectId, peer, sessionHash);
|
|
823
|
+
if (clearIncompletePeer) this.db.query("DELETE FROM review_input_incomplete_peers WHERE project_id=? AND peer=?").run(this.projectId, peer);
|
|
824
|
+
this.writeReviewInputProvenance(complete, coverageComplete);
|
|
825
|
+
this.harden();
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
private recoverInterrupted(meta: Record<string, unknown>): void {
|
|
829
|
+
const resumable = new Set<string>();
|
|
830
|
+
try {
|
|
831
|
+
const raw = JSON.parse(String(meta.bus_snapshot || "{}"));
|
|
832
|
+
const bus = raw && typeof raw === "object" && Object.keys(raw).length > 0 ? validateBus(raw) : undefined;
|
|
833
|
+
if (bus) {
|
|
834
|
+
const now = Date.now();
|
|
835
|
+
const active = this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND state IN ('dispatching', 'accepted')").all(this.projectId) as Record<string, unknown>[];
|
|
836
|
+
for (const stored of active) {
|
|
837
|
+
const row = parseDelivery(stored), peer = row.peer;
|
|
838
|
+
if (!parseRemotePeerId(peer) || row.originals.length !== 1) continue;
|
|
839
|
+
const original = row.originals[0]!;
|
|
840
|
+
const room = bus.remoteQueueRooms?.[peer], member = bus.remoteQueueMembers?.[peer];
|
|
841
|
+
const expiry = bus.remoteQueueExpires?.[peer]?.[original.id];
|
|
842
|
+
if (isInvocationKind(original) || original.to?.includes(peer) !== true ||
|
|
843
|
+
typeof room !== "string" ||
|
|
844
|
+
typeof member !== "string" || !/^[0-9a-f]{64}$/.test(member) ||
|
|
845
|
+
!Number.isSafeInteger(expiry) || expiry! <= now) continue;
|
|
846
|
+
resumable.add(row.id);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
} catch { /* Unreadable or unknown binding metadata keeps the conservative interruption rule. */ }
|
|
850
|
+
|
|
851
|
+
const now = Date.now();
|
|
852
|
+
const update = this.db.query(
|
|
853
|
+
"UPDATE deliveries SET state = 'needs_review', revision = revision + 1, updated_at = ?, reason = COALESCE(reason, 'daemon stopped during delivery'), failure_cause = COALESCE(failure_cause, 'turn aborted') WHERE project_id = ? AND id = ? AND state IN ('dispatching', 'accepted')",
|
|
854
|
+
);
|
|
855
|
+
const active = this.db.query("SELECT id FROM deliveries WHERE project_id = ? AND state IN ('dispatching', 'accepted')").all(this.projectId) as { id: string }[];
|
|
856
|
+
for (const row of active) if (!resumable.has(row.id)) update.run(now, this.projectId, row.id);
|
|
490
857
|
}
|
|
491
858
|
private ensureOpen(): void {
|
|
492
859
|
if (this.closed) throw new Error("delivery journal is closed");
|
|
@@ -571,13 +938,204 @@ export class DeliveryJournal {
|
|
|
571
938
|
transaction<T>(fn: () => T): T {
|
|
572
939
|
this.ensureOpen();
|
|
573
940
|
const invalidated = this.queueMetadataInvalidated;
|
|
941
|
+
const legacyReviewCandidateIds = this.legacyReviewCandidateIds;
|
|
942
|
+
const legacyReviewCandidatesCaptured = this.legacyReviewCandidatesCaptured;
|
|
943
|
+
const legacyReviewCandidatesOverflow = this.legacyReviewCandidatesOverflow;
|
|
944
|
+
const legacyReviewCandidateFenceIds = this.legacyReviewCandidateFenceIds;
|
|
945
|
+
const legacyReviewCandidateCaptureRevision = this.legacyReviewCandidateCaptureRevision;
|
|
574
946
|
try { return this.db.transaction(fn)(); }
|
|
575
|
-
catch (error) {
|
|
947
|
+
catch (error) {
|
|
948
|
+
this.queueMetadataInvalidated = invalidated;
|
|
949
|
+
this.legacyReviewCandidateIds = legacyReviewCandidateIds;
|
|
950
|
+
this.legacyReviewCandidatesCaptured = legacyReviewCandidatesCaptured;
|
|
951
|
+
this.legacyReviewCandidatesOverflow = legacyReviewCandidatesOverflow;
|
|
952
|
+
this.legacyReviewCandidateFenceIds = legacyReviewCandidateFenceIds;
|
|
953
|
+
this.legacyReviewCandidateCaptureRevision = legacyReviewCandidateCaptureRevision;
|
|
954
|
+
throw error;
|
|
955
|
+
}
|
|
576
956
|
}
|
|
577
|
-
|
|
957
|
+
holdReviewInput(value: unknown): void {
|
|
578
958
|
this.ensureOpen();
|
|
579
|
-
const
|
|
580
|
-
if (
|
|
959
|
+
const hold = reviewInputHold(value, "review input hold");
|
|
960
|
+
if (hold.inputHash === null) fail("review input hold requires a positive input hash");
|
|
961
|
+
const prior = this.readReviewInputProvenance();
|
|
962
|
+
if (!prior.complete) fail("review input provenance is incomplete");
|
|
963
|
+
const existing = prior.holds.find(row => row.peer === hold.peer && row.sessionHash === hold.sessionHash);
|
|
964
|
+
const pending = prior.incompleteSessions.some(row => row.peer === hold.peer && row.sessionHash === hold.sessionHash);
|
|
965
|
+
if (existing?.inputHash === hold.inputHash) return;
|
|
966
|
+
if (!existing && !pending && prior.count >= MAX_REVIEW_INPUTS) {
|
|
967
|
+
this.markReviewInputCoverageIncomplete(prior);
|
|
968
|
+
fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
|
|
969
|
+
}
|
|
970
|
+
try {
|
|
971
|
+
this.writePendingReviewInputSession(hold.peer, hold.sessionHash, prior.complete, prior.coverageComplete);
|
|
972
|
+
} catch (error) {
|
|
973
|
+
try { this.markReviewInputIncomplete(hold.peer, hold.sessionHash); } catch { /* a failed marker write still prevents the host handout */ }
|
|
974
|
+
throw error;
|
|
975
|
+
}
|
|
976
|
+
this.transaction(() => {
|
|
977
|
+
this.bump();
|
|
978
|
+
this.db.query("INSERT INTO review_input_sessions(project_id,peer,session_hash,input_hash) VALUES(?,?,?,?) ON CONFLICT(project_id,peer,session_hash) DO UPDATE SET input_hash=excluded.input_hash")
|
|
979
|
+
.run(this.projectId, hold.peer, hold.sessionHash, hold.inputHash);
|
|
980
|
+
this.db.query("DELETE FROM review_input_incomplete_sessions WHERE project_id=? AND peer=? AND session_hash=?")
|
|
981
|
+
.run(this.projectId, hold.peer, hold.sessionHash);
|
|
982
|
+
this.writeReviewInputProvenance(prior.complete, prior.coverageComplete);
|
|
983
|
+
this.harden();
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
reviewInputState(peerValue: string, sessionHashValue?: string): ReviewInputState {
|
|
987
|
+
try {
|
|
988
|
+
const peer = text(peerValue, "review input peer", 256);
|
|
989
|
+
const sessionHash = sessionHashValue === undefined ? undefined : hashText(sessionHashValue, "review input session hash");
|
|
990
|
+
const provenance = this.readReviewInputProvenance();
|
|
991
|
+
if (!provenance.complete || provenance.incompletePeers.includes(peer)) return { state: "unknown" };
|
|
992
|
+
const peerRows = provenance.holds.filter(row => row.peer === peer);
|
|
993
|
+
const incompleteSessions = provenance.incompleteSessions.filter(row => row.peer === peer);
|
|
994
|
+
if (sessionHash === undefined) return peerRows.length || incompleteSessions.length || !provenance.coverageComplete ? { state: "unknown" } : { state: "clean" };
|
|
995
|
+
const row = peerRows.find(value => value.sessionHash === sessionHash);
|
|
996
|
+
if (row?.inputHash !== null && row?.inputHash !== undefined) return { state: "held", inputHash: row.inputHash };
|
|
997
|
+
if (incompleteSessions.some(value => value.sessionHash === sessionHash)) return { state: "unknown" };
|
|
998
|
+
if (row?.inputHash === null) return { state: "clean" };
|
|
999
|
+
if (!provenance.coverageComplete) return { state: "unknown" };
|
|
1000
|
+
if (!row) return { state: "clean" };
|
|
1001
|
+
return { state: "unknown" };
|
|
1002
|
+
} catch { return { state: "unknown" }; }
|
|
1003
|
+
}
|
|
1004
|
+
/** Cause projection for host admission; preserves reviewInputState's stable public shape. */
|
|
1005
|
+
reviewInputEvidence(peerValue: string, sessionHashValue?: string): ReviewInputEvidence {
|
|
1006
|
+
this.ensureOpen();
|
|
1007
|
+
try {
|
|
1008
|
+
this.refreshReviewInputCoverage();
|
|
1009
|
+
const peer = text(peerValue, "review input peer", 256), sessionHash = sessionHashValue === undefined ? undefined : hashText(sessionHashValue, "review input session hash");
|
|
1010
|
+
const provenance = this.readReviewInputProvenance();
|
|
1011
|
+
if (!provenance.complete) return { provenance: "corrupt", coverageComplete: false, incompletePeer: false, incompleteSession: false, sessionState: "unknown", retainedSessionHold: false, retainedPeerSessionHold: false };
|
|
1012
|
+
const sessionState = this.reviewInputState(peer, sessionHash).state;
|
|
1013
|
+
return {
|
|
1014
|
+
provenance: "available",
|
|
1015
|
+
coverageComplete: provenance.coverageComplete,
|
|
1016
|
+
incompletePeer: provenance.incompletePeers.includes(peer),
|
|
1017
|
+
incompleteSession: sessionHash !== undefined && provenance.incompleteSessions.some(row => row.peer === peer && row.sessionHash === sessionHash),
|
|
1018
|
+
sessionState,
|
|
1019
|
+
retainedSessionHold: sessionHash !== undefined && provenance.holds.some(row => row.peer === peer && row.sessionHash === sessionHash && row.inputHash !== null),
|
|
1020
|
+
retainedPeerSessionHold: provenance.holds.some(row => row.peer === peer && row.inputHash !== null) || provenance.incompleteSessions.some(row => row.peer === peer),
|
|
1021
|
+
};
|
|
1022
|
+
} catch {
|
|
1023
|
+
return { provenance: "corrupt", coverageComplete: false, incompletePeer: false, incompleteSession: false, sessionState: "unknown", retainedSessionHold: false, retainedPeerSessionHold: false };
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/** Public-safe journal health; positive held receipts are normal, expected state. */
|
|
1027
|
+
reviewInputHealth(): ReviewInputHealth {
|
|
1028
|
+
this.ensureOpen();
|
|
1029
|
+
try {
|
|
1030
|
+
this.refreshReviewInputCoverage();
|
|
1031
|
+
const provenance = this.readReviewInputProvenance();
|
|
1032
|
+
if (!provenance.complete) return { state: "corrupt", cause: "provenance_incomplete", nextStep: "Keep affected peers held and inspect or repair only review-input provenance, preserving delivery and task state." };
|
|
1033
|
+
if (provenance.incompletePeers.length || provenance.incompleteSessions.length) return { state: "incomplete", cause: "incomplete_markers", nextStep: "Verify the peer's next ordinary input or confirmed session change to clear its incomplete marker." };
|
|
1034
|
+
if (!provenance.coverageComplete) return { state: "incomplete", cause: "legacy_coverage", nextStep: "Allow existing review-marked deliveries to settle; coverage clears when the journal confirms none remain." };
|
|
1035
|
+
return { state: "healthy", cause: null, nextStep: "No action required." };
|
|
1036
|
+
} catch {
|
|
1037
|
+
return { state: "corrupt", cause: "provenance_corrupt", nextStep: "Keep affected peers held and inspect or repair only review-input provenance, preserving delivery and task state." };
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/** Compatibility reader: only positive held hashes are returned; clean and unknown stay absent. */
|
|
1041
|
+
readReviewInput(peerValue: string, sessionHashValue: string): string | undefined {
|
|
1042
|
+
const state = this.reviewInputState(peerValue, sessionHashValue);
|
|
1043
|
+
return state.state === "held" ? state.inputHash : undefined;
|
|
1044
|
+
}
|
|
1045
|
+
clearReviewInput(peerValue: string, sessionHashValue: string): void {
|
|
1046
|
+
this.ensureOpen();
|
|
1047
|
+
const peer = text(peerValue, "review input peer", 256), sessionHash = hashText(sessionHashValue, "review input session hash");
|
|
1048
|
+
const prior = this.readReviewInputProvenance(), existing = prior.holds.find(row => row.peer === peer && row.sessionHash === sessionHash);
|
|
1049
|
+
if (!prior.complete) fail("review input provenance is incomplete");
|
|
1050
|
+
const pending = prior.incompleteSessions.some(row => row.peer === peer && row.sessionHash === sessionHash);
|
|
1051
|
+
const incompletePeer = prior.incompletePeers.includes(peer);
|
|
1052
|
+
const legacyConfirmation = !prior.coverageComplete && !existing && !pending;
|
|
1053
|
+
if ((!existing || existing.inputHash === null) && !pending && !legacyConfirmation && !incompletePeer) return;
|
|
1054
|
+
if (!existing && !pending && !incompletePeer && prior.count >= MAX_REVIEW_INPUTS) {
|
|
1055
|
+
this.markReviewInputCoverageIncomplete(prior);
|
|
1056
|
+
fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
|
|
1057
|
+
}
|
|
1058
|
+
this.writePendingReviewInputSession(peer, sessionHash, prior.complete, prior.coverageComplete, true);
|
|
1059
|
+
this.transaction(() => {
|
|
1060
|
+
this.bump();
|
|
1061
|
+
this.db.query("INSERT INTO review_input_sessions(project_id,peer,session_hash,input_hash) VALUES(?,?,?,NULL) ON CONFLICT(project_id,peer,session_hash) DO UPDATE SET input_hash=NULL")
|
|
1062
|
+
.run(this.projectId, peer, sessionHash);
|
|
1063
|
+
this.db.query("DELETE FROM review_input_incomplete_sessions WHERE project_id=? AND peer=? AND session_hash=?")
|
|
1064
|
+
.run(this.projectId, peer, sessionHash);
|
|
1065
|
+
this.db.query("DELETE FROM review_input_incomplete_peers WHERE project_id=? AND peer=?")
|
|
1066
|
+
.run(this.projectId, peer);
|
|
1067
|
+
this.writeReviewInputProvenance(prior.complete, prior.coverageComplete);
|
|
1068
|
+
this.harden();
|
|
1069
|
+
});
|
|
1070
|
+
this.refreshReviewInputCoverage();
|
|
1071
|
+
}
|
|
1072
|
+
/** A verified ordinary input or confirmed SID change clears only this peer-level incomplete marker. */
|
|
1073
|
+
clearReviewInputIncomplete(peerValue: string): void {
|
|
1074
|
+
this.ensureOpen();
|
|
1075
|
+
const peer = text(peerValue, "incomplete review input peer", 256), prior = this.readReviewInputProvenance();
|
|
1076
|
+
if (!prior.complete) fail("review input provenance is incomplete");
|
|
1077
|
+
if (!prior.incompletePeers.includes(peer)) return;
|
|
1078
|
+
this.transaction(() => {
|
|
1079
|
+
const current = this.readReviewInputProvenance();
|
|
1080
|
+
if (!current.incompletePeers.includes(peer)) return;
|
|
1081
|
+
this.bump();
|
|
1082
|
+
this.db.query("DELETE FROM review_input_incomplete_peers WHERE project_id=? AND peer=?").run(this.projectId, peer);
|
|
1083
|
+
this.writeReviewInputProvenance(current.complete, current.coverageComplete);
|
|
1084
|
+
this.harden();
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
/** Forget one closed ephemeral observer receipt only if its exact positive/clear hash still matches. */
|
|
1088
|
+
forgetClosedObserverInput(peerValue: string, sessionHashValue: string, expectedInputHash: string | null): boolean {
|
|
1089
|
+
this.ensureOpen();
|
|
1090
|
+
try {
|
|
1091
|
+
const peer = text(peerValue, "review input peer", 256), sessionHash = hashText(sessionHashValue, "review input session hash");
|
|
1092
|
+
if (expectedInputHash !== null) hashText(expectedInputHash, "expected review input hash");
|
|
1093
|
+
const prior = this.readReviewInputProvenance();
|
|
1094
|
+
if (!prior.complete || prior.incompletePeers.includes(peer) || prior.incompleteSessions.some(row => row.peer === peer && row.sessionHash === sessionHash)) return false;
|
|
1095
|
+
if (!prior.holds.some(row => row.peer === peer && row.sessionHash === sessionHash && row.inputHash === expectedInputHash)) return false;
|
|
1096
|
+
return this.transaction(() => {
|
|
1097
|
+
const current = this.readReviewInputProvenance();
|
|
1098
|
+
if (!current.complete || current.incompletePeers.includes(peer) || current.incompleteSessions.some(row => row.peer === peer && row.sessionHash === sessionHash)
|
|
1099
|
+
|| !current.holds.some(row => row.peer === peer && row.sessionHash === sessionHash && row.inputHash === expectedInputHash)) return false;
|
|
1100
|
+
this.bump();
|
|
1101
|
+
this.db.query("DELETE FROM review_input_sessions WHERE project_id=? AND peer=? AND session_hash=? AND input_hash IS ?")
|
|
1102
|
+
.run(this.projectId, peer, sessionHash, expectedInputHash);
|
|
1103
|
+
if (Number((this.db.query("SELECT changes() AS n").get() as { n: number }).n) !== 1) throw new Error("review input receipt changed during closed-session cleanup");
|
|
1104
|
+
this.writeReviewInputProvenance(current.complete, current.coverageComplete);
|
|
1105
|
+
this.harden();
|
|
1106
|
+
return true;
|
|
1107
|
+
});
|
|
1108
|
+
} catch { return false; }
|
|
1109
|
+
}
|
|
1110
|
+
markReviewInputIncomplete(peerValue: string, sessionHashValue?: string): void {
|
|
1111
|
+
this.ensureOpen();
|
|
1112
|
+
const peer = text(peerValue, "incomplete review input peer", 256);
|
|
1113
|
+
const prior = this.readReviewInputProvenance();
|
|
1114
|
+
if (!prior.complete) fail("review input provenance is incomplete");
|
|
1115
|
+
const sessionHash = sessionHashValue === undefined ? undefined : hashText(sessionHashValue, "incomplete review input session hash");
|
|
1116
|
+
if (sessionHash !== undefined && prior.incompleteSessions.some(row => row.peer === peer && row.sessionHash === sessionHash)) return;
|
|
1117
|
+
if (sessionHash === undefined && prior.incompletePeers.includes(peer)) return;
|
|
1118
|
+
if (prior.count >= MAX_REVIEW_INPUTS) {
|
|
1119
|
+
this.markReviewInputCoverageIncomplete(prior);
|
|
1120
|
+
fail(`too many review input provenance rows (limit ${MAX_REVIEW_INPUTS})`);
|
|
1121
|
+
}
|
|
1122
|
+
this.transaction(() => {
|
|
1123
|
+
this.bump();
|
|
1124
|
+
if (sessionHash === undefined) this.db.query("INSERT OR IGNORE INTO review_input_incomplete_peers(project_id,peer) VALUES(?,?)").run(this.projectId, peer);
|
|
1125
|
+
else this.db.query("INSERT OR IGNORE INTO review_input_incomplete_sessions(project_id,peer,session_hash) VALUES(?,?,?)").run(this.projectId, peer, sessionHash);
|
|
1126
|
+
this.writeReviewInputProvenance(prior.complete, prior.coverageComplete);
|
|
1127
|
+
this.harden();
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
hasReviewInputState(): boolean {
|
|
1131
|
+
this.ensureOpen();
|
|
1132
|
+
try {
|
|
1133
|
+
this.refreshReviewInputCoverage();
|
|
1134
|
+
const provenance = this.readReviewInputProvenance();
|
|
1135
|
+
return !provenance.complete || !provenance.coverageComplete || provenance.holds.some(row => row.inputHash !== null) || provenance.incompletePeers.length > 0 || provenance.incompleteSessions.length > 0;
|
|
1136
|
+
} catch { return true; }
|
|
1137
|
+
}
|
|
1138
|
+
private snapshotTransportBase(meta: Record<string, unknown>): JournalTransportSnapshot {
|
|
581
1139
|
this.validateStored(meta);
|
|
582
1140
|
const rows = this.db
|
|
583
1141
|
.query(
|
|
@@ -596,7 +1154,7 @@ export class DeliveryJournal {
|
|
|
596
1154
|
withdrawn: [],
|
|
597
1155
|
}
|
|
598
1156
|
: storedBus;
|
|
599
|
-
const result = {
|
|
1157
|
+
const result: JournalTransportSnapshot = {
|
|
600
1158
|
schemaVersion: SCHEMA_VERSION,
|
|
601
1159
|
revision: Number(meta.revision),
|
|
602
1160
|
instanceId: String(meta.instance_id),
|
|
@@ -607,6 +1165,23 @@ export class DeliveryJournal {
|
|
|
607
1165
|
validateJournalSnapshot(result);
|
|
608
1166
|
return result;
|
|
609
1167
|
}
|
|
1168
|
+
/** Transport restoration may proceed from the validated bus/delivery projection while review provenance remains unknown. */
|
|
1169
|
+
transportSnapshot(): JournalTransportSnapshot {
|
|
1170
|
+
this.ensureOpen();
|
|
1171
|
+
const meta = this.readMeta();
|
|
1172
|
+
if (!meta) throw new Error("delivery journal metadata missing");
|
|
1173
|
+
return this.snapshotTransportBase(meta);
|
|
1174
|
+
}
|
|
1175
|
+
/** Strict recovery snapshot: malformed or incomplete review provenance refuses recovery. */
|
|
1176
|
+
snapshot(): JournalSnapshot {
|
|
1177
|
+
this.ensureOpen();
|
|
1178
|
+
const meta = this.readMeta();
|
|
1179
|
+
if (!meta) throw new Error("delivery journal metadata missing");
|
|
1180
|
+
const base = this.snapshotTransportBase(meta);
|
|
1181
|
+
const result: JournalSnapshot = { ...base, reviewInputProvenance: this.readReviewInputProvenance(meta) };
|
|
1182
|
+
validateJournalSnapshot(result);
|
|
1183
|
+
return result;
|
|
1184
|
+
}
|
|
610
1185
|
persistBus(bus: unknown, manualPaused: string[], trackQueuePressure = false): number {
|
|
611
1186
|
this.ensureOpen();
|
|
612
1187
|
let checkedBus = this.queueMetadataSnapshot(validateBus(bus), trackQueuePressure);
|
|
@@ -632,6 +1207,7 @@ export class DeliveryJournal {
|
|
|
632
1207
|
this.projectId,
|
|
633
1208
|
);
|
|
634
1209
|
this.writeQueueMetadata(checkedBus, revision, trackQueuePressure);
|
|
1210
|
+
this.refreshReviewInputCoverage(true);
|
|
635
1211
|
this.harden();
|
|
636
1212
|
return revision;
|
|
637
1213
|
});
|
|
@@ -672,7 +1248,7 @@ export class DeliveryJournal {
|
|
|
672
1248
|
for (const d of deliveries)
|
|
673
1249
|
this.db
|
|
674
1250
|
.query(
|
|
675
|
-
"INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1251
|
+
"INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id, failure_cause) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
676
1252
|
)
|
|
677
1253
|
.run(
|
|
678
1254
|
d.id,
|
|
@@ -687,7 +1263,9 @@ export class DeliveryJournal {
|
|
|
687
1263
|
d.reason ?? null,
|
|
688
1264
|
d.attempt ?? null,
|
|
689
1265
|
d.previousId ?? null,
|
|
1266
|
+
checkedFailureCause(d.failureCause) ?? null,
|
|
690
1267
|
);
|
|
1268
|
+
this.refreshReviewInputCoverage(true);
|
|
691
1269
|
this.harden();
|
|
692
1270
|
return revision;
|
|
693
1271
|
});
|
|
@@ -737,7 +1315,7 @@ export class DeliveryJournal {
|
|
|
737
1315
|
this.writeQueueMetadata(bus, revision, trackQueuePressure);
|
|
738
1316
|
this.db
|
|
739
1317
|
.query(
|
|
740
|
-
"INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1318
|
+
"INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id, failure_cause) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
741
1319
|
)
|
|
742
1320
|
.run(
|
|
743
1321
|
delivery.id,
|
|
@@ -752,6 +1330,7 @@ export class DeliveryJournal {
|
|
|
752
1330
|
delivery.reason ?? null,
|
|
753
1331
|
delivery.attempt ?? null,
|
|
754
1332
|
delivery.previousId ?? null,
|
|
1333
|
+
checkedFailureCause(delivery.failureCause) ?? null,
|
|
755
1334
|
);
|
|
756
1335
|
this.harden();
|
|
757
1336
|
return { ...delivery, revision, updatedAt: now };
|
|
@@ -774,7 +1353,7 @@ export class DeliveryJournal {
|
|
|
774
1353
|
const updatedAt = input.updatedAt ?? Date.now();
|
|
775
1354
|
this.db
|
|
776
1355
|
.query(
|
|
777
|
-
"INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1356
|
+
"INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id, failure_cause) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
778
1357
|
)
|
|
779
1358
|
.run(
|
|
780
1359
|
input.id,
|
|
@@ -789,6 +1368,7 @@ export class DeliveryJournal {
|
|
|
789
1368
|
input.reason ?? null,
|
|
790
1369
|
input.attempt ?? null,
|
|
791
1370
|
input.previousId ?? null,
|
|
1371
|
+
checkedFailureCause(input.failureCause) ?? null,
|
|
792
1372
|
);
|
|
793
1373
|
this.pruneTerminal(input.state, input.id);
|
|
794
1374
|
return { ...input, revision, updatedAt };
|
|
@@ -804,7 +1384,7 @@ export class DeliveryJournal {
|
|
|
804
1384
|
this.ensureOpen(); text(deliveryId, "delivery id", 256); text(peer, "delivery peer", 256);
|
|
805
1385
|
if (peer === HUB || peer === USER || !["adapter", "reply", "pull"].includes(source)) throw new Error("invalid delivered completion source");
|
|
806
1386
|
if (reason !== undefined) text(reason, "delivery completion reason", 1_000_000);
|
|
807
|
-
return this.
|
|
1387
|
+
return this.transaction((): JournalDelivery => {
|
|
808
1388
|
const row = this.get(deliveryId);
|
|
809
1389
|
if (!row || row.peer !== peer || !["dispatching", "accepted"].includes(row.state)) throw new Error("delivery is not active for this peer");
|
|
810
1390
|
const completed = this.withPruningDeferred(() => {
|
|
@@ -816,7 +1396,37 @@ export class DeliveryJournal {
|
|
|
816
1396
|
// Proof is now durable in this transaction, and the return receipt is captured.
|
|
817
1397
|
this.pruneTerminal("completed", "");
|
|
818
1398
|
return completed;
|
|
819
|
-
})
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
/** A signed room receipt settles its matching remote row without native read evidence. */
|
|
1402
|
+
completeRemoteReceipt(deliveryId: string, peer: string, expectedRevision: number, reason: string): JournalDelivery {
|
|
1403
|
+
this.ensureOpen(); text(deliveryId, "delivery id", 256); text(peer, "delivery peer", 256);
|
|
1404
|
+
integer(expectedRevision, "observed revision"); text(reason, "delivery completion reason", 1_000_000);
|
|
1405
|
+
if (!parseRemotePeerId(peer)) throw new Error("invalid remote receipt peer");
|
|
1406
|
+
return this.transaction(() => {
|
|
1407
|
+
const row = this.get(deliveryId);
|
|
1408
|
+
if (!row || row.peer !== peer || !["needs_review", "dispatching", "accepted"].includes(row.state) || row.originals.length !== 1 || row.originals[0]?.to?.includes(peer) !== true) throw new Error("remote delivery is not unresolved for this peer");
|
|
1409
|
+
if (row.revision !== expectedRevision) throw new Error(`stale delivery revision for ${deliveryId}`);
|
|
1410
|
+
if (this.db.query("SELECT 1 FROM resolution_history WHERE project_id=? AND delivery_id=? LIMIT 1").get(this.projectId, deliveryId)) throw new Error("remote delivery already has a person disposition");
|
|
1411
|
+
const completed = this.transition(deliveryId, "completed", reason, expectedRevision);
|
|
1412
|
+
this.pruneTerminal("completed", "");
|
|
1413
|
+
return completed;
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
/** A signed room refusal is terminal transport evidence, not a person or native retry. */
|
|
1417
|
+
failRemoteReceipt(deliveryId: string, peer: string, expectedRevision: number, reason: string): JournalDelivery {
|
|
1418
|
+
this.ensureOpen(); text(deliveryId, "delivery id", 256); text(peer, "delivery peer", 256);
|
|
1419
|
+
integer(expectedRevision, "observed revision"); text(reason, "delivery refusal reason", 1_000_000);
|
|
1420
|
+
if (!parseRemotePeerId(peer)) throw new Error("invalid remote receipt peer");
|
|
1421
|
+
return this.transaction(() => {
|
|
1422
|
+
const row = this.get(deliveryId);
|
|
1423
|
+
if (!row || row.peer !== peer || !["needs_review", "dispatching", "accepted"].includes(row.state) || row.originals.length !== 1 || row.originals[0]?.to?.includes(peer) !== true) throw new Error("remote delivery is not unresolved for this peer");
|
|
1424
|
+
if (row.revision !== expectedRevision) throw new Error(`stale delivery revision for ${deliveryId}`);
|
|
1425
|
+
if (this.db.query("SELECT 1 FROM resolution_history WHERE project_id=? AND delivery_id=? LIMIT 1").get(this.projectId, deliveryId)) throw new Error("remote delivery already has a person disposition");
|
|
1426
|
+
const failed = this.transition(deliveryId, "failed", reason, expectedRevision, "adapter refused");
|
|
1427
|
+
this.pruneTerminal("failed", "");
|
|
1428
|
+
return failed;
|
|
1429
|
+
});
|
|
820
1430
|
}
|
|
821
1431
|
/** Exact immutable offer id, independent of queue snapshots or generic completed rows. */
|
|
822
1432
|
offerRead(peer: string, envelopeId: string): boolean {
|
|
@@ -830,23 +1440,28 @@ export class DeliveryJournal {
|
|
|
830
1440
|
state: JournalDeliveryState,
|
|
831
1441
|
reason?: string,
|
|
832
1442
|
expectedRevision?: number,
|
|
1443
|
+
failureCause?: DeliveryFailureCause,
|
|
833
1444
|
): JournalDelivery {
|
|
834
1445
|
this.ensureOpen();
|
|
835
1446
|
if (!STATES.has(state)) throw new Error("invalid delivery state");
|
|
836
|
-
|
|
1447
|
+
checkedFailureCause(failureCause);
|
|
1448
|
+
return this.transaction(() => {
|
|
837
1449
|
const old = this.get(id);
|
|
838
1450
|
if (!old) throw new Error(`unknown delivery ${id}`);
|
|
839
1451
|
if (expectedRevision !== undefined && old.revision !== expectedRevision)
|
|
840
1452
|
throw new Error(`stale delivery revision for ${id}`);
|
|
1453
|
+
// A repeated accepted receipt is not fresh evidence and must not renew the settle deadline.
|
|
1454
|
+
if (old.state === state && state === "accepted") return old;
|
|
841
1455
|
const revision = this.bump();
|
|
842
1456
|
this.db
|
|
843
1457
|
.query(
|
|
844
|
-
"UPDATE deliveries SET state = ?, revision = ?, updated_at = ?, reason = ? WHERE id = ? AND project_id = ?",
|
|
1458
|
+
"UPDATE deliveries SET state = ?, revision = ?, updated_at = ?, reason = ?, failure_cause = ? WHERE id = ? AND project_id = ?",
|
|
845
1459
|
)
|
|
846
|
-
.run(state, revision, Date.now(), reason ?? null, id, this.projectId);
|
|
1460
|
+
.run(state, revision, Date.now(), reason ?? null, failureCause ?? old.failureCause ?? null, id, this.projectId);
|
|
1461
|
+
this.refreshReviewInputCoverage(true);
|
|
847
1462
|
this.pruneTerminal(state, id);
|
|
848
1463
|
return this.get(id)!;
|
|
849
|
-
})
|
|
1464
|
+
});
|
|
850
1465
|
}
|
|
851
1466
|
get(id: string): JournalDelivery | undefined {
|
|
852
1467
|
this.ensureOpen();
|
|
@@ -855,12 +1470,53 @@ export class DeliveryJournal {
|
|
|
855
1470
|
.get(id, this.projectId) as Record<string, unknown> | null;
|
|
856
1471
|
return row ? parseDelivery(row) : undefined;
|
|
857
1472
|
}
|
|
1473
|
+
/** Explicit package recovery reads durable publication evidence, not the bounded in-memory reply cache. */
|
|
1474
|
+
reviewEnvelopeState(id: string, expected?: Envelope): JournalDeliveryState | undefined {
|
|
1475
|
+
this.ensureOpen();
|
|
1476
|
+
const published = this.db.query(`SELECT d.state AS state, e.value AS envelope FROM deliveries d, json_each(d.originals) e
|
|
1477
|
+
WHERE d.project_id=? AND json_extract(e.value,'$.id')=? AND json_extract(e.value,'$.from')='hub'
|
|
1478
|
+
AND json_extract(e.value,'$.kind')='review' AND json_extract(e.value,'$.refs.reviewPackage') GLOB 'review-*'
|
|
1479
|
+
ORDER BY d.updated_at DESC, d.revision DESC LIMIT 1`).get(this.projectId, id) as { state: JournalDeliveryState; envelope: string } | null;
|
|
1480
|
+
// A queued/deferred publication can precede its first physical delivery row.
|
|
1481
|
+
const saved = published ?? this.db.query(`SELECT e.value AS envelope FROM delivery_meta m, json_tree(m.bus_snapshot) e
|
|
1482
|
+
WHERE m.project_id=? AND e.type='object' AND json_extract(e.value,'$.id')=?
|
|
1483
|
+
AND json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review'
|
|
1484
|
+
AND json_extract(e.value,'$.refs.reviewPackage') GLOB 'review-*' LIMIT 1`).get(this.projectId, id) as { envelope: string } | null;
|
|
1485
|
+
if (saved && expected && !isDeepStrictEqual(validateEnvelope(JSON.parse(saved.envelope), "saved review package"), validateEnvelope(expected, "review package")))
|
|
1486
|
+
throw new Error("saved review envelope differs from its publication evidence; inspect the package and queue before retrying");
|
|
1487
|
+
return published?.state ?? (saved ? "queued" : undefined);
|
|
1488
|
+
}
|
|
1489
|
+
reviewEnvelopePublished(id: string, expected?: Envelope): boolean {
|
|
1490
|
+
return this.reviewEnvelopeState(id, expected) !== undefined;
|
|
1491
|
+
}
|
|
1492
|
+
/** Pending review recipients remain quote-bearing even when the task's current roles have moved. */
|
|
1493
|
+
hasUnsettledReviewScope(peer: string, task?: number): boolean {
|
|
1494
|
+
this.ensureOpen();
|
|
1495
|
+
const taskId = task === undefined ? null : String(task);
|
|
1496
|
+
return !!this.db.query(`SELECT 1 FROM deliveries d, json_each(d.originals) e
|
|
1497
|
+
WHERE d.project_id=?1 AND d.peer=?2 AND d.state IN ('queued','dispatching','accepted','needs_review')
|
|
1498
|
+
AND json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review'
|
|
1499
|
+
AND json_type(e.value,'$.refs.reviewPackage')='text' AND (?3 IS NULL OR json_extract(e.value,'$.refs.task')=?3)
|
|
1500
|
+
UNION ALL SELECT 1 FROM delivery_meta m, json_each(m.bus_snapshot,'$.queues') q, json_each(q.value) e
|
|
1501
|
+
WHERE m.project_id=?1 AND q.key=?2 AND json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review'
|
|
1502
|
+
AND json_type(e.value,'$.refs.reviewPackage')='text' AND (?3 IS NULL OR json_extract(e.value,'$.refs.task')=?3)
|
|
1503
|
+
UNION ALL SELECT 1 FROM delivery_meta m, json_each(m.bus_snapshot,'$.deferredHub') e, json_each(e.value,'$.to') recipient
|
|
1504
|
+
WHERE m.project_id=?1 AND recipient.value=?2 AND json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review'
|
|
1505
|
+
AND json_type(e.value,'$.refs.reviewPackage')='text' AND (?3 IS NULL OR json_extract(e.value,'$.refs.task')=?3)
|
|
1506
|
+
LIMIT 1`).get(this.projectId, peer, taskId);
|
|
1507
|
+
}
|
|
858
1508
|
/** Hot roster reads never materialize completed envelope history. */
|
|
859
1509
|
pendingPeers(): string[] {
|
|
860
1510
|
this.ensureOpen();
|
|
861
1511
|
const rows = this.db.query("SELECT DISTINCT peer FROM deliveries WHERE project_id = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY peer").all(this.projectId) as { peer: unknown }[];
|
|
862
1512
|
return rows.map(row => text(row.peer, "delivery peer", 256));
|
|
863
1513
|
}
|
|
1514
|
+
/** Status counts unresolved failures without reading envelope payloads or terminal history. */
|
|
1515
|
+
unresolvedFailedCounts(): Record<string, number> {
|
|
1516
|
+
this.ensureOpen();
|
|
1517
|
+
const rows = this.db.query("SELECT d.peer,COUNT(*) AS n FROM deliveries d WHERE d.project_id=? AND d.state='failed' AND NOT EXISTS (SELECT 1 FROM resolution_history r WHERE r.project_id=d.project_id AND r.delivery_id=d.id) GROUP BY d.peer").all(this.projectId) as { peer: unknown; n: unknown }[];
|
|
1518
|
+
return Object.fromEntries(rows.map(row => [text(row.peer, "delivery peer", 256), integer(row.n, "unresolved failed count")]));
|
|
1519
|
+
}
|
|
864
1520
|
/** The queue needs a current revision, not the recovery snapshot's payloads. */
|
|
865
1521
|
revision(): number {
|
|
866
1522
|
this.ensureOpen();
|
|
@@ -892,11 +1548,29 @@ export class DeliveryJournal {
|
|
|
892
1548
|
: this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(this.projectId, peer)) as Record<string, unknown>[];
|
|
893
1549
|
return rows.map(parseDelivery);
|
|
894
1550
|
}
|
|
895
|
-
heldDeliveryHints(peer: string) {
|
|
1551
|
+
heldDeliveryHints(peer: string, includeAccepted = false) {
|
|
1552
|
+
this.ensureOpen();
|
|
1553
|
+
const states = includeAccepted ? "('needs_review','accepted')" : "('needs_review')";
|
|
1554
|
+
const count = Number((this.db.query(`SELECT COUNT(*) AS n FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ${states}`).get(this.projectId, peer) as { n: number }).n);
|
|
1555
|
+
const rows = this.db.query(`SELECT d.id, d.revision, d.created_at, d.state, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.from')) FROM json_each(d.originals) env) AS senders, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.kind')) FROM json_each(d.originals) env) AS kinds FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state IN ${states} ORDER BY d.created_at, d.id LIMIT 33`).all(this.projectId, peer) as { id: string; revision: number; created_at: number; state: "accepted" | "needs_review"; senders: string; kinds: string }[];
|
|
1556
|
+
return heldDeliveryHints(rows.map(row => ({ id: row.id, revision: row.revision, createdAt: row.created_at, ...(includeAccepted ? { state: row.state } : {}), senders: JSON.parse(row.senders) as string[], kinds: JSON.parse(row.kinds) as Kind[] })), count);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/** Bounded indexed scan; the caller performs a revision-fenced state transition. */
|
|
1560
|
+
expiredAccepted(cutoff: number, peers: readonly string[], limit = 256): AcceptedDeliveryCandidate[] {
|
|
1561
|
+
this.ensureOpen(); integer(cutoff, "accepted cutoff"); integer(limit, "accepted expiry limit", 1, 1024);
|
|
1562
|
+
if (!peers.length) return [];
|
|
1563
|
+
if (peers.length > 4096) fail("accepted expiry peer set is too large");
|
|
1564
|
+
for (const peer of peers) text(peer, "accepted expiry peer", 256);
|
|
1565
|
+
const placeholders = peers.map(() => "?").join(",");
|
|
1566
|
+
const rows = this.db.query(`SELECT d.id,d.peer,d.revision,d.updated_at,EXISTS(SELECT 1 FROM json_each(d.originals) env WHERE json_extract(env.value,'$.priority')='important' OR json_extract(env.value,'$.kind') IN ('review','task')) AS notify_conductor FROM deliveries d WHERE d.project_id = ? AND d.state = 'accepted' AND d.updated_at <= ? AND d.peer IN (${placeholders}) ORDER BY d.updated_at,d.id LIMIT ?`).all(this.projectId, cutoff, ...peers, limit) as { id: unknown; peer: unknown; revision: unknown; updated_at: unknown; notify_conductor: unknown }[];
|
|
1567
|
+
return rows.map(row => ({ id: text(row.id, "accepted expiry id", 256), peer: text(row.peer, "accepted expiry peer", 256), revision: integer(row.revision, "accepted expiry revision"), updatedAt: integer(row.updated_at, "accepted expiry age"), notifyConductor: row.notify_conductor === 1 }));
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/** Cheap recovery guard for timeout holds; reads no delivery payloads. */
|
|
1571
|
+
hasAcceptedTimeoutHolds(): boolean {
|
|
896
1572
|
this.ensureOpen();
|
|
897
|
-
|
|
898
|
-
const rows = this.db.query("SELECT d.id, d.revision, d.created_at, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.from')) FROM json_each(d.originals) env) AS senders, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.kind')) FROM json_each(d.originals) env) AS kinds FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state = 'needs_review' ORDER BY d.created_at, d.id LIMIT 33").all(this.projectId, peer) as { id: string; revision: number; created_at: number; senders: string; kinds: string }[];
|
|
899
|
-
return heldDeliveryHints(rows.map(row => ({ id: row.id, revision: row.revision, createdAt: row.created_at, senders: JSON.parse(row.senders) as string[], kinds: JSON.parse(row.kinds) as Kind[] })), count);
|
|
1573
|
+
return !!this.db.query("SELECT 1 FROM deliveries WHERE project_id = ? AND state = 'needs_review' AND reason = ? LIMIT 1").get(this.projectId, ACCEPTED_SETTLE_TIMEOUT_REASON);
|
|
900
1574
|
}
|
|
901
1575
|
/** Explicit resolutions, not automatic safe failures, consume the immutable originals' retry lineage. */
|
|
902
1576
|
retryEvidence(peer: string, originals: readonly Envelope[]): "unused" | "used" | "unknown" {
|
|
@@ -971,12 +1645,13 @@ export class DeliveryJournal {
|
|
|
971
1645
|
reason: string,
|
|
972
1646
|
requestRevision = revision,
|
|
973
1647
|
actor: string = "user",
|
|
1648
|
+
allowAcceptedPeerDisposition = false,
|
|
974
1649
|
): JournalDelivery {
|
|
975
1650
|
this.ensureOpen();
|
|
976
1651
|
text(reason, "resolution reason", 1_000_000);
|
|
977
1652
|
integer(revision, "observed revision");
|
|
978
1653
|
if (!ACTIONS.has(action)) throw new Error("invalid resolution action");
|
|
979
|
-
return this.
|
|
1654
|
+
return this.transaction(() => {
|
|
980
1655
|
const resolved = this.resolution(id);
|
|
981
1656
|
if (resolved) {
|
|
982
1657
|
const current = this.get(id);
|
|
@@ -996,7 +1671,7 @@ export class DeliveryJournal {
|
|
|
996
1671
|
if (actor.startsWith("peer:")) {
|
|
997
1672
|
const peer = actor.slice(5);
|
|
998
1673
|
const recipientRetry = action === "retry" && current.peer === peer && current.state === "needs_review" && this.retryEvidence(current.peer, current.originals) === "unused";
|
|
999
|
-
const ownerDisposition = (action === "completed" || action === "discard") && current.peer === peer && current.state === "needs_review";
|
|
1674
|
+
const ownerDisposition = (action === "completed" || action === "discard") && current.peer === peer && (current.state === "needs_review" || current.state === "accepted" && allowAcceptedPeerDisposition === true);
|
|
1000
1675
|
if (!recipientRetry && !ownerDisposition) throw new Error("recipient delivery resolution unavailable; inspect the held delivery or ask the person");
|
|
1001
1676
|
}
|
|
1002
1677
|
if (current.revision !== revision)
|
|
@@ -1006,9 +1681,18 @@ export class DeliveryJournal {
|
|
|
1006
1681
|
actionState(action) !== current.state
|
|
1007
1682
|
)
|
|
1008
1683
|
throw new Error(`delivery ${id} is already terminal`);
|
|
1684
|
+
const retryChildId = action === "retry" ? `${id}:retry:${(current.attempt ?? 0) + 1}` : undefined;
|
|
1685
|
+
if (retryChildId && (this.legacyReviewCandidateIds.has(id) || this.legacyReviewCandidateFenceIds?.includes(id))
|
|
1686
|
+
&& !this.legacyReviewCandidateIds.has(retryChildId) && !this.legacyReviewCandidateFenceIds?.includes(retryChildId)) {
|
|
1687
|
+
if (this.legacyReviewCandidatesOverflow || this.legacyReviewCandidateIds.size >= MAX_REVIEW_INPUTS) {
|
|
1688
|
+
const fence = this.legacyReviewCandidateFenceIds ?? [...this.legacyReviewCandidateIds];
|
|
1689
|
+
if (fence.length < MAX_ROWS) this.legacyReviewCandidateFenceIds = [...fence, retryChildId];
|
|
1690
|
+
this.legacyReviewCandidatesOverflow = true;
|
|
1691
|
+
} else this.legacyReviewCandidateIds = new Set([...this.legacyReviewCandidateIds, retryChildId]);
|
|
1692
|
+
}
|
|
1009
1693
|
const result = this.transition(id, actionState(action), reason, revision);
|
|
1010
1694
|
if (action === "retry") {
|
|
1011
|
-
const childId =
|
|
1695
|
+
const childId = retryChildId!;
|
|
1012
1696
|
if (!this.get(childId))
|
|
1013
1697
|
this.createDelivery({
|
|
1014
1698
|
id: childId,
|
|
@@ -1040,7 +1724,7 @@ export class DeliveryJournal {
|
|
|
1040
1724
|
if (action === "retry") this.recordRetryEvidence(current.peer, current.originals, id, actor === `peer:${current.peer}`);
|
|
1041
1725
|
this.pruneTerminal(result.state, result.id);
|
|
1042
1726
|
return result;
|
|
1043
|
-
})
|
|
1727
|
+
});
|
|
1044
1728
|
}
|
|
1045
1729
|
/** #1096: the ids of every delivery an operator action (completed, retry, discard) already settled. One query, so a list of any length costs one read. */
|
|
1046
1730
|
resolvedIds(): Set<string> {
|
|
@@ -1060,6 +1744,10 @@ export class DeliveryJournal {
|
|
|
1060
1744
|
if (this.pruningDeferred) return;
|
|
1061
1745
|
const terminal = state === "completed" || state === "discarded" || state === "failed";
|
|
1062
1746
|
const terminalCap = terminal ? this.terminalHistoryLimit?.() ?? TERMINAL_CAP : TERMINAL_CAP;
|
|
1747
|
+
let protectedReviewCandidates: string[] = [];
|
|
1748
|
+
try {
|
|
1749
|
+
if (!this.readReviewInputProvenance().coverageComplete) protectedReviewCandidates = [...this.legacyReviewCandidateIds];
|
|
1750
|
+
} catch { protectedReviewCandidates = [...this.legacyReviewCandidateIds]; }
|
|
1063
1751
|
// Never delete the receipt this mutation/resolve is still using, at any target.
|
|
1064
1752
|
const count = Number(
|
|
1065
1753
|
(
|
|
@@ -1071,16 +1759,20 @@ export class DeliveryJournal {
|
|
|
1071
1759
|
).n,
|
|
1072
1760
|
);
|
|
1073
1761
|
if (count <= terminalCap) return;
|
|
1762
|
+
// ponytail: person-package receipts retain their terminal payload alongside the saved board package.
|
|
1763
|
+
// A future package archival transaction can replace both with an IDs-only publication tombstone.
|
|
1074
1764
|
this.db
|
|
1075
1765
|
.query(
|
|
1076
|
-
"DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND d.id != ? AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
|
|
1766
|
+
"DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND d.id != ? AND NOT EXISTS (SELECT 1 FROM json_each(?) protected WHERE protected.value=d.id) AND NOT EXISTS (SELECT 1 FROM json_each(d.originals) e WHERE json_extract(e.value,'$.from')='hub' AND json_extract(e.value,'$.kind')='review' AND json_extract(e.value,'$.refs.reviewPackage') GLOB 'review-*') AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
|
|
1077
1767
|
)
|
|
1078
|
-
.run(this.projectId, currentId, count - terminalCap);
|
|
1768
|
+
.run(this.projectId, currentId, JSON.stringify(protectedReviewCandidates), count - terminalCap);
|
|
1079
1769
|
}
|
|
1080
1770
|
importSnapshot(snapshot: JournalSnapshot, operationId: string, trackQueuePressure = false): boolean {
|
|
1081
1771
|
this.ensureOpen();
|
|
1082
1772
|
validateJournalSnapshot(snapshot);
|
|
1083
1773
|
const bus = this.queueMetadataSnapshot(snapshot.bus, trackQueuePressure);
|
|
1774
|
+
const importedReviewInput = snapshot.reviewInputProvenance ?? { version: REVIEW_INPUT_VERSION, instanceId: snapshot.instanceId, complete: true, coverageComplete: false, count: 0, digest: reviewInputDigest(snapshot.instanceId, REVIEW_INPUT_VERSION, true, false, 0, [], [], []), holds: [], incompletePeers: [], incompleteSessions: [] };
|
|
1775
|
+
const reviewInputProvenance: ReviewInputProvenance = { ...importedReviewInput, instanceId: this.instanceId, digest: reviewInputDigest(this.instanceId, REVIEW_INPUT_VERSION, importedReviewInput.complete, importedReviewInput.coverageComplete, importedReviewInput.count, importedReviewInput.holds, importedReviewInput.incompletePeers, importedReviewInput.incompleteSessions) };
|
|
1084
1776
|
text(operationId, "operationId", 256);
|
|
1085
1777
|
if (this.operationId !== undefined && this.operationId !== operationId)
|
|
1086
1778
|
throw new Error("recovery operation does not own delivery journal");
|
|
@@ -1097,23 +1789,29 @@ export class DeliveryJournal {
|
|
|
1097
1789
|
Object.keys(storedBus).length !== 0
|
|
1098
1790
|
)
|
|
1099
1791
|
return false;
|
|
1100
|
-
|
|
1792
|
+
const imported = this.queueMetadataTransaction(trackQueuePressure, () => {
|
|
1101
1793
|
this.db
|
|
1102
1794
|
.query(
|
|
1103
|
-
"UPDATE delivery_meta SET revision = ?, bus_snapshot = ?, manual_paused = ?, operation_id =
|
|
1795
|
+
"UPDATE delivery_meta SET revision = ?, bus_snapshot = ?, manual_paused = ?, operation_id = ?, review_input_version=?,review_input_complete=?,review_input_coverage_complete=?,review_input_count=?,review_input_digest=?,review_input_instance_id=? WHERE project_id = ?",
|
|
1104
1796
|
)
|
|
1105
1797
|
.run(
|
|
1106
1798
|
snapshot.revision,
|
|
1107
1799
|
JSON.stringify(bus),
|
|
1108
1800
|
JSON.stringify(snapshot.manualPaused),
|
|
1109
1801
|
operationId,
|
|
1802
|
+
REVIEW_INPUT_VERSION,
|
|
1803
|
+
reviewInputProvenance.complete ? 1 : 0,
|
|
1804
|
+
reviewInputProvenance.coverageComplete ? 1 : 0,
|
|
1805
|
+
reviewInputProvenance.count,
|
|
1806
|
+
reviewInputProvenance.digest,
|
|
1807
|
+
this.instanceId,
|
|
1110
1808
|
this.projectId,
|
|
1111
1809
|
);
|
|
1112
1810
|
this.writeQueueMetadata(bus, snapshot.revision, trackQueuePressure);
|
|
1113
1811
|
for (const row of snapshot.deliveries)
|
|
1114
1812
|
this.db
|
|
1115
1813
|
.query(
|
|
1116
|
-
"INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1814
|
+
"INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id, failure_cause) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1117
1815
|
)
|
|
1118
1816
|
.run(
|
|
1119
1817
|
row.id,
|
|
@@ -1128,11 +1826,21 @@ export class DeliveryJournal {
|
|
|
1128
1826
|
row.reason ?? null,
|
|
1129
1827
|
row.attempt ?? null,
|
|
1130
1828
|
row.previousId ?? null,
|
|
1829
|
+
checkedFailureCause(row.failureCause) ?? null,
|
|
1131
1830
|
);
|
|
1831
|
+
for (const raw of reviewInputProvenance.holds) {
|
|
1832
|
+
const hold = reviewInputHold(raw, "snapshot review input");
|
|
1833
|
+
this.db.query("INSERT INTO review_input_sessions(project_id,peer,session_hash,input_hash) VALUES(?,?,?,?)")
|
|
1834
|
+
.run(this.projectId, hold.peer, hold.sessionHash, hold.inputHash);
|
|
1835
|
+
}
|
|
1836
|
+
for (const peer of reviewInputProvenance.incompletePeers) this.db.query("INSERT INTO review_input_incomplete_peers(project_id,peer) VALUES(?,?)").run(this.projectId, peer);
|
|
1837
|
+
for (const row of reviewInputProvenance.incompleteSessions) this.db.query("INSERT INTO review_input_incomplete_sessions(project_id,peer,session_hash) VALUES(?,?,?)").run(this.projectId, row.peer, row.sessionHash);
|
|
1132
1838
|
// Snapshots deliberately omit resolution history; only these imported originals have unknown retry rights.
|
|
1133
1839
|
this.markRetryUnknown(snapshot.deliveries, bus);
|
|
1134
1840
|
return true;
|
|
1135
1841
|
});
|
|
1842
|
+
if (imported) this.captureLegacyReviewCandidates(false, snapshot.deliveries.map(row => row.id));
|
|
1843
|
+
return imported;
|
|
1136
1844
|
}
|
|
1137
1845
|
close(): void {
|
|
1138
1846
|
if (!this.closed) {
|
|
@@ -1160,6 +1868,7 @@ function parseDelivery(row: Record<string, unknown>): JournalDelivery {
|
|
|
1160
1868
|
originals: JSON.parse(String(row.originals)),
|
|
1161
1869
|
out: JSON.parse(String(row.out_json)),
|
|
1162
1870
|
...(row.reason == null ? {} : { reason: String(row.reason) }),
|
|
1871
|
+
...(row.failure_cause == null ? {} : { failureCause: row.failure_cause }),
|
|
1163
1872
|
...(row.attempt == null ? {} : { attempt: Number(row.attempt) }),
|
|
1164
1873
|
...(row.previous_id == null ? {} : { previousId: String(row.previous_id) }),
|
|
1165
1874
|
});
|