@songsid/agend 2.1.7-beta.4 → 2.1.7-beta.6
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/README.md +2 -0
- package/README.zh-TW.md +2 -0
- package/dist/agent-cli-instructions.md +5 -0
- package/dist/agent-cli.js +13 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +1 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/codex-session-lookup.d.ts +59 -0
- package/dist/backend/codex-session-lookup.js +139 -0
- package/dist/backend/codex-session-lookup.js.map +1 -0
- package/dist/backend/codex.d.ts +37 -9
- package/dist/backend/codex.js +204 -38
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/grok.js +51 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/types.d.ts +12 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +137 -124
- package/dist/cli.js.map +1 -1
- package/dist/completion-install.d.ts +32 -0
- package/dist/completion-install.js +125 -1
- package/dist/completion-install.js.map +1 -1
- package/dist/config-validator.js +4 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +12 -1
- package/dist/daemon.js +186 -27
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +199 -6
- package/dist/delivery-outbox.js +602 -44
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-outbox.test.js +268 -14
- package/dist/delivery-outbox.test.js.map +1 -1
- package/dist/delivery-queue-evidence.d.ts +15 -0
- package/dist/delivery-queue-evidence.js +35 -0
- package/dist/delivery-queue-evidence.js.map +1 -0
- package/dist/delivery-queue-evidence.test.d.ts +1 -0
- package/dist/delivery-queue-evidence.test.js +23 -0
- package/dist/delivery-queue-evidence.test.js.map +1 -0
- package/dist/delivery-reconciliation.d.ts +49 -0
- package/dist/delivery-reconciliation.js +296 -0
- package/dist/delivery-reconciliation.js.map +1 -0
- package/dist/delivery-reconciliation.test.d.ts +1 -0
- package/dist/delivery-reconciliation.test.js +411 -0
- package/dist/delivery-reconciliation.test.js.map +1 -0
- package/dist/fleet-manager.d.ts +10 -1
- package/dist/fleet-manager.js +109 -32
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +10 -0
- package/dist/instance-lifecycle.d.ts +13 -0
- package/dist/instance-lifecycle.js +105 -21
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +6 -0
- package/dist/instructions.js +9 -0
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +8 -0
- package/dist/locale.js.map +1 -1
- package/dist/ls-rows.d.ts +50 -0
- package/dist/ls-rows.js +152 -0
- package/dist/ls-rows.js.map +1 -0
- package/dist/outbound-handlers.d.ts +3 -0
- package/dist/outbound-handlers.js +38 -2
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +8 -0
- package/dist/outbound-schemas.js +15 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-write-lock.d.ts +2 -0
- package/dist/pane-write-lock.js +19 -0
- package/dist/pane-write-lock.js.map +1 -1
- package/dist/quickstart.js +9 -3
- package/dist/quickstart.js.map +1 -1
- package/dist/remote-credential-check.d.ts +26 -7
- package/dist/remote-credential-check.js +157 -10
- package/dist/remote-credential-check.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts +5 -1
- package/dist/scheduler/scheduler.js +60 -20
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/scheduler.test.js +41 -2
- package/dist/scheduler/scheduler.test.js.map +1 -1
- package/dist/scheduler/types.d.ts +1 -1
- package/dist/tmux-manager.d.ts +3 -3
- package/dist/tmux-manager.js +6 -6
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.js +6 -6
- package/dist/tool-permissions.js.map +1 -1
- package/dist/transcript-monitor.d.ts +3 -1
- package/dist/transcript-monitor.js +15 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +9 -0
- package/dist/transcript-sources.js +14 -0
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/ui/dashboard.html +7 -5
- package/dist/ui/view.html +138 -45
- package/dist/usage/usage-api.js +30 -0
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +1 -1
package/dist/delivery-outbox.js
CHANGED
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
4
|
import { chmodSync, mkdirSync } from "node:fs";
|
|
5
5
|
import { dirname } from "node:path";
|
|
6
6
|
export const DURABLE_DELIVERY_MAX_ATTEMPTS = 8;
|
|
7
7
|
export const DURABLE_DELIVERY_MAX_AGE_MS = 24 * 60 * 60_000;
|
|
8
8
|
export const DURABLE_DELIVERY_ABORT_BACKOFF_MS = 5_000;
|
|
9
|
+
/** The one mapping from validated tool/CLI arguments to a selector. */
|
|
10
|
+
export function deliveryStatusSelector(args) {
|
|
11
|
+
const page = { ...(args.limit !== undefined ? { limit: args.limit } : {}), ...(args.cursor ? { cursor: args.cursor } : {}) };
|
|
12
|
+
if (args.delivery_id)
|
|
13
|
+
return { deliveryId: args.delivery_id };
|
|
14
|
+
if (args.operation_id)
|
|
15
|
+
return { operationId: args.operation_id, ...page };
|
|
16
|
+
if (args.message_id)
|
|
17
|
+
return { messageId: args.message_id, ...page };
|
|
18
|
+
return { correlationId: args.correlation_id, ...page };
|
|
19
|
+
}
|
|
20
|
+
/** sha256 of a delivered text, as hex. The same function hashes admission and paste. */
|
|
21
|
+
export function deliveryContentDigest(text) {
|
|
22
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
23
|
+
}
|
|
24
|
+
/** The text a durable row delivers, for kinds whose payload carries one. */
|
|
25
|
+
function payloadContent(payload) {
|
|
26
|
+
const content = payload && typeof payload === "object" ? payload.content : undefined;
|
|
27
|
+
return typeof content === "string" ? content : null;
|
|
28
|
+
}
|
|
29
|
+
/** #926 reminders and overdue notices; never themselves the subject of a failure notice. */
|
|
30
|
+
export const REPLY_OBLIGATION_NOTICE_KIND = "reply_obligation_notice";
|
|
31
|
+
/** Kinds that carry a peer request or its answer. Broadcasts are one-to-many and deferred (#926 §6). */
|
|
32
|
+
const REPLY_OBLIGATION_KINDS = ["fleet_inbound", "steer"];
|
|
33
|
+
function payloadRequiresReply(payload) {
|
|
34
|
+
const meta = payload && typeof payload === "object" ? payload.meta : undefined;
|
|
35
|
+
return !!meta && typeof meta === "object" && meta.requires_reply === "true";
|
|
36
|
+
}
|
|
37
|
+
function payloadMessageId(payload) {
|
|
38
|
+
const meta = payload && typeof payload === "object" ? payload.meta : undefined;
|
|
39
|
+
const id = meta && typeof meta === "object" ? meta.message_id : undefined;
|
|
40
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
41
|
+
}
|
|
9
42
|
function mapRow(row) {
|
|
10
43
|
return {
|
|
11
44
|
deliveryId: row.delivery_id,
|
|
@@ -19,13 +52,168 @@ function mapRow(row) {
|
|
|
19
52
|
kind: row.kind,
|
|
20
53
|
correlationId: row.correlation_id,
|
|
21
54
|
payload: JSON.parse(row.payload_json),
|
|
22
|
-
state: row.state
|
|
55
|
+
state: row.state === "submission_started" && row.reconciliation_pending === 1
|
|
56
|
+
? "reconciliation_pending"
|
|
57
|
+
: row.state,
|
|
23
58
|
attemptNo: row.attempt_no,
|
|
24
59
|
createdSeq: row.created_seq,
|
|
25
60
|
managerBootId: row.manager_boot_id,
|
|
26
61
|
responseDeliveredAt: row.response_delivered_at,
|
|
27
62
|
nextAttemptAt: row.next_attempt_at,
|
|
28
63
|
lastError: row.last_error,
|
|
64
|
+
reconciliationPending: row.reconciliation_pending === 1,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function safeErrorSummary(error) {
|
|
68
|
+
if (!error)
|
|
69
|
+
return null;
|
|
70
|
+
const normalized = error.toLowerCase();
|
|
71
|
+
if (normalized.includes("ttl expired"))
|
|
72
|
+
return "Delivery expired before the target became available.";
|
|
73
|
+
if (normalized.includes("attempt limit"))
|
|
74
|
+
return "Delivery exhausted its bounded retry attempts.";
|
|
75
|
+
if (normalized.includes("reconcil") || normalized.includes("uncertain") || normalized.includes("restart")) {
|
|
76
|
+
return "Submission may have occurred; do not resend until the outcome is reconciled.";
|
|
77
|
+
}
|
|
78
|
+
return "A delivery error was recorded; details are omitted for safety.";
|
|
79
|
+
}
|
|
80
|
+
function statusSummary(state) {
|
|
81
|
+
switch (state) {
|
|
82
|
+
case "queued": return "Accepted and waiting for delivery.";
|
|
83
|
+
case "delivering": return "Waiting for the target to become ready; submission has not started.";
|
|
84
|
+
case "retry_wait": return "Waiting for a bounded retry; submission has not started for the next attempt.";
|
|
85
|
+
case "submission_started":
|
|
86
|
+
case "reconciliation_pending": return "Submission may have occurred; do not resend.";
|
|
87
|
+
case "delivered": return "Delivery was confirmed; this does not mean the agent finished processing it.";
|
|
88
|
+
case "failed": return "Delivery failed after bounded retries or expiry.";
|
|
89
|
+
case "uncertain": return "Delivery may have occurred; do not resend blindly.";
|
|
90
|
+
case "cancelled": return "Delivery was cancelled.";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function safeToRetry(state) {
|
|
94
|
+
return state === "queued" || state === "delivering" || state === "retry_wait" || state === "failed";
|
|
95
|
+
}
|
|
96
|
+
function hasTable(db, table) {
|
|
97
|
+
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table);
|
|
98
|
+
}
|
|
99
|
+
/** The obligation a request row opened, if any; absent on databases from before #926. */
|
|
100
|
+
function obligationFor(db, correlationId, requester, owner) {
|
|
101
|
+
if (!correlationId || !hasTable(db, "reply_obligations"))
|
|
102
|
+
return null;
|
|
103
|
+
const row = db.prepare(`
|
|
104
|
+
SELECT state, opened_at, last_asked_at, nudged_at, overdue_notified_at, answered_at FROM reply_obligations
|
|
105
|
+
WHERE correlation_id=? AND requester_instance=? AND owner_instance=?
|
|
106
|
+
`).get(correlationId, requester, owner);
|
|
107
|
+
return row ?? null;
|
|
108
|
+
}
|
|
109
|
+
function hasColumn(db, table, column) {
|
|
110
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === column);
|
|
111
|
+
}
|
|
112
|
+
/** Derived from the stored payload, so it is the same for pre-#856 rows. */
|
|
113
|
+
function rowContentEvidence(payloadJson, withContent) {
|
|
114
|
+
let payload = null;
|
|
115
|
+
try {
|
|
116
|
+
payload = JSON.parse(payloadJson);
|
|
117
|
+
}
|
|
118
|
+
catch { /* corrupt row: report no evidence */ }
|
|
119
|
+
const content = payloadContent(payload);
|
|
120
|
+
return {
|
|
121
|
+
message_id: payloadMessageId(payload),
|
|
122
|
+
content_sha256: content === null ? null : deliveryContentDigest(content),
|
|
123
|
+
...(withContent ? { content } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function queryStatusPage(db, selector, callerInstance) {
|
|
127
|
+
const where = [];
|
|
128
|
+
const params = [];
|
|
129
|
+
// An operator may read a database written before the column existed.
|
|
130
|
+
const messageIdColumn = hasColumn(db, "deliveries", "message_id")
|
|
131
|
+
? "d.message_id" : "json_extract(d.payload_json,'$.meta.message_id')";
|
|
132
|
+
if (selector.deliveryId) {
|
|
133
|
+
where.push("d.delivery_id = ?");
|
|
134
|
+
params.push(selector.deliveryId);
|
|
135
|
+
}
|
|
136
|
+
else if (selector.operationId) {
|
|
137
|
+
where.push("d.operation_id = ?");
|
|
138
|
+
params.push(selector.operationId);
|
|
139
|
+
}
|
|
140
|
+
else if (selector.messageId) {
|
|
141
|
+
where.push(`${messageIdColumn} = ?`);
|
|
142
|
+
params.push(selector.messageId);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
where.push("d.correlation_id = ?");
|
|
146
|
+
params.push(selector.correlationId);
|
|
147
|
+
}
|
|
148
|
+
if (callerInstance !== null) {
|
|
149
|
+
where.push("(d.source_instance = ? OR d.target_instance = ?)");
|
|
150
|
+
params.push(callerInstance, callerInstance);
|
|
151
|
+
}
|
|
152
|
+
if (selector.cursor) {
|
|
153
|
+
const cursorScope = ["c.delivery_id = ?"];
|
|
154
|
+
const cursorParams = [selector.cursor];
|
|
155
|
+
if (selector.operationId) {
|
|
156
|
+
cursorScope.push("c.operation_id = ?");
|
|
157
|
+
cursorParams.push(selector.operationId);
|
|
158
|
+
}
|
|
159
|
+
else if (selector.messageId) {
|
|
160
|
+
cursorScope.push(`${messageIdColumn.replace(/\bd\./g, "c.")} = ?`);
|
|
161
|
+
cursorParams.push(selector.messageId);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
cursorScope.push("c.correlation_id = ?");
|
|
165
|
+
cursorParams.push(selector.correlationId);
|
|
166
|
+
}
|
|
167
|
+
if (callerInstance !== null) {
|
|
168
|
+
cursorScope.push("(c.source_instance = ? OR c.target_instance = ?)");
|
|
169
|
+
cursorParams.push(callerInstance, callerInstance);
|
|
170
|
+
}
|
|
171
|
+
where.push(`d.created_seq > (SELECT c.created_seq FROM deliveries c WHERE ${cursorScope.join(" AND ")} LIMIT 1)`);
|
|
172
|
+
params.push(...cursorParams);
|
|
173
|
+
}
|
|
174
|
+
const limit = selector.deliveryId ? 1 : Math.max(1, Math.min(100, selector.limit ?? 20));
|
|
175
|
+
const rows = db.prepare(`
|
|
176
|
+
SELECT d.delivery_id,d.operation_id,d.correlation_id,d.source_instance,d.target_instance,
|
|
177
|
+
d.kind,
|
|
178
|
+
CASE WHEN d.state='submission_started' AND d.reconciliation_pending=1
|
|
179
|
+
THEN 'reconciliation_pending' ELSE d.state END AS state,
|
|
180
|
+
d.attempt_no,d.created_at,d.updated_at,d.last_error,d.payload_json
|
|
181
|
+
FROM deliveries d
|
|
182
|
+
WHERE ${where.join(" AND ")}
|
|
183
|
+
ORDER BY d.created_seq
|
|
184
|
+
LIMIT ?
|
|
185
|
+
`).all(...params, limit + 1);
|
|
186
|
+
const hasMore = rows.length > limit;
|
|
187
|
+
const visible = rows.slice(0, limit);
|
|
188
|
+
return {
|
|
189
|
+
items: visible.map(row => ({
|
|
190
|
+
delivery_id: row.delivery_id,
|
|
191
|
+
operation_id: row.operation_id,
|
|
192
|
+
correlation_id: row.correlation_id,
|
|
193
|
+
source_instance: row.source_instance,
|
|
194
|
+
target_instance: row.target_instance,
|
|
195
|
+
kind: row.kind,
|
|
196
|
+
state: row.state,
|
|
197
|
+
attempt_no: row.attempt_no,
|
|
198
|
+
created_at: row.created_at,
|
|
199
|
+
updated_at: row.updated_at,
|
|
200
|
+
status_summary: statusSummary(row.state),
|
|
201
|
+
error_summary: safeErrorSummary(row.last_error),
|
|
202
|
+
safe_to_retry: safeToRetry(row.state),
|
|
203
|
+
// Text only for the explicit verification query (#856), and only to the
|
|
204
|
+
// row's own source/target; every other query keeps #982's redaction.
|
|
205
|
+
...rowContentEvidence(row.payload_json, callerInstance !== null && !!selector.messageId),
|
|
206
|
+
reply_obligation: obligationFor(db, row.correlation_id, row.source_instance, row.target_instance),
|
|
207
|
+
})),
|
|
208
|
+
next_cursor: hasMore ? visible.at(-1)?.delivery_id ?? null : null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function mapObligation(row) {
|
|
212
|
+
return {
|
|
213
|
+
correlationId: row.correlation_id, requesterInstance: row.requester_instance, ownerInstance: row.owner_instance,
|
|
214
|
+
requestDeliveryId: row.request_delivery_id, openedAt: row.opened_at, lastAskedAt: row.last_asked_at, state: row.state,
|
|
215
|
+
nudgedAt: row.nudged_at, overdueNotifiedAt: row.overdue_notified_at, answeredAt: row.answered_at,
|
|
216
|
+
answeredDeliveryId: row.answered_delivery_id,
|
|
29
217
|
};
|
|
30
218
|
}
|
|
31
219
|
/** Durable admission and fenced state transitions for the fleet delivery queue. */
|
|
@@ -63,6 +251,7 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
63
251
|
created_seq INTEGER NOT NULL UNIQUE,
|
|
64
252
|
manager_boot_id TEXT,
|
|
65
253
|
response_delivered_at TEXT,
|
|
254
|
+
reconciliation_pending INTEGER NOT NULL DEFAULT 0,
|
|
66
255
|
next_attempt_at TEXT,
|
|
67
256
|
last_error TEXT,
|
|
68
257
|
created_at TEXT NOT NULL,
|
|
@@ -77,6 +266,10 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
77
266
|
ON deliveries(target_instance, state, created_seq);
|
|
78
267
|
CREATE INDEX IF NOT EXISTS idx_delivery_operation_source
|
|
79
268
|
ON deliveries(source_instance, operation_id);
|
|
269
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_operation_seq
|
|
270
|
+
ON deliveries(operation_id, created_seq);
|
|
271
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_correlation_seq
|
|
272
|
+
ON deliveries(correlation_id, created_seq);
|
|
80
273
|
|
|
81
274
|
CREATE TABLE IF NOT EXISTS delivery_attempts (
|
|
82
275
|
delivery_id TEXT NOT NULL REFERENCES deliveries(delivery_id),
|
|
@@ -87,6 +280,15 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
87
280
|
abort_at TEXT,
|
|
88
281
|
finished_at TEXT,
|
|
89
282
|
evidence TEXT,
|
|
283
|
+
backend TEXT,
|
|
284
|
+
window_id TEXT,
|
|
285
|
+
transcript_path TEXT,
|
|
286
|
+
transcript_offset INTEGER,
|
|
287
|
+
transcript_session_id TEXT,
|
|
288
|
+
submission_mode TEXT,
|
|
289
|
+
backend_version TEXT,
|
|
290
|
+
queue_resume_policy TEXT,
|
|
291
|
+
enter_started_at TEXT,
|
|
90
292
|
PRIMARY KEY (delivery_id, target_daemon_boot_id, attempt_no)
|
|
91
293
|
);
|
|
92
294
|
|
|
@@ -99,13 +301,225 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
99
301
|
created_at TEXT NOT NULL,
|
|
100
302
|
PRIMARY KEY (source_instance, source_daemon_boot_id, operation_id, target_instance)
|
|
101
303
|
);
|
|
304
|
+
CREATE TABLE IF NOT EXISTS reply_obligations (
|
|
305
|
+
correlation_id TEXT NOT NULL,
|
|
306
|
+
requester_instance TEXT NOT NULL,
|
|
307
|
+
owner_instance TEXT NOT NULL,
|
|
308
|
+
request_delivery_id TEXT NOT NULL,
|
|
309
|
+
opened_at TEXT NOT NULL,
|
|
310
|
+
last_asked_at TEXT NOT NULL,
|
|
311
|
+
state TEXT NOT NULL CHECK (state IN ('open','answered')),
|
|
312
|
+
nudged_at TEXT,
|
|
313
|
+
overdue_notified_at TEXT,
|
|
314
|
+
answered_at TEXT,
|
|
315
|
+
answered_delivery_id TEXT,
|
|
316
|
+
PRIMARY KEY (correlation_id, requester_instance, owner_instance)
|
|
317
|
+
);
|
|
318
|
+
CREATE INDEX IF NOT EXISTS idx_reply_obligation_owner_state ON reply_obligations(owner_instance, state);
|
|
102
319
|
CREATE TABLE IF NOT EXISTS failure_notices (
|
|
103
320
|
parent_delivery_id TEXT PRIMARY KEY,
|
|
104
321
|
notice_delivery_id TEXT NOT NULL UNIQUE,
|
|
105
322
|
created_at TEXT NOT NULL
|
|
106
323
|
);
|
|
107
|
-
PRAGMA user_version = 1;
|
|
108
324
|
`);
|
|
325
|
+
// Additive migration: existing Phase 1 databases keep their row identities,
|
|
326
|
+
// foreign keys, and WAL while gaining reconciliation evidence columns.
|
|
327
|
+
this.ensureColumn("deliveries", "reconciliation_pending", "INTEGER NOT NULL DEFAULT 0");
|
|
328
|
+
this.ensureColumn("delivery_attempts", "backend", "TEXT");
|
|
329
|
+
this.ensureColumn("delivery_attempts", "window_id", "TEXT");
|
|
330
|
+
this.ensureColumn("delivery_attempts", "transcript_path", "TEXT");
|
|
331
|
+
this.ensureColumn("delivery_attempts", "transcript_offset", "INTEGER");
|
|
332
|
+
this.ensureColumn("delivery_attempts", "transcript_session_id", "TEXT");
|
|
333
|
+
this.ensureColumn("delivery_attempts", "submission_mode", "TEXT");
|
|
334
|
+
this.ensureColumn("delivery_attempts", "backend_version", "TEXT");
|
|
335
|
+
this.ensureColumn("delivery_attempts", "queue_resume_policy", "TEXT");
|
|
336
|
+
this.ensureColumn("delivery_attempts", "enter_started_at", "TEXT");
|
|
337
|
+
// #856: a receiver can look a message up by the ID in its envelope, and
|
|
338
|
+
// admission vs pane-write digests tell transport rewrites apart from a
|
|
339
|
+
// receiver acting on a message nobody sent.
|
|
340
|
+
this.ensureColumn("deliveries", "message_id", "TEXT");
|
|
341
|
+
this.ensureColumn("deliveries", "content_sha256", "TEXT");
|
|
342
|
+
this.ensureColumn("delivery_attempts", "pasted_content_sha256", "TEXT");
|
|
343
|
+
this.ensureColumn("delivery_attempts", "pasted_bytes_sha256", "TEXT");
|
|
344
|
+
this.ensureColumn("delivery_attempts", "content_digest_mismatch", "INTEGER NOT NULL DEFAULT 0");
|
|
345
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_message_id ON deliveries(message_id)");
|
|
346
|
+
this.backfillMessageEvidence();
|
|
347
|
+
this.db.pragma("user_version = 4");
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* #926: a delivered `requires_reply` request opens (or, asked again,
|
|
351
|
+
* re-opens) the owner's obligation. Runs inside the delivered transition.
|
|
352
|
+
* An answer admitted before the request finished its submission proof —
|
|
353
|
+
* an owner can reply while the daemon is still confirming — counts.
|
|
354
|
+
*/
|
|
355
|
+
openReplyObligation(request, now) {
|
|
356
|
+
if (!REPLY_OBLIGATION_KINDS.includes(request.kind) || !request.correlation_id)
|
|
357
|
+
return;
|
|
358
|
+
let payload = null;
|
|
359
|
+
try {
|
|
360
|
+
payload = JSON.parse(request.payload_json);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (!payloadRequiresReply(payload))
|
|
366
|
+
return;
|
|
367
|
+
const earlyAnswer = this.db.prepare(`
|
|
368
|
+
SELECT delivery_id FROM deliveries
|
|
369
|
+
WHERE correlation_id=? AND source_instance=? AND target_instance=? AND created_seq > ?
|
|
370
|
+
AND kind IN (${REPLY_OBLIGATION_KINDS.map(() => "?").join(",")})
|
|
371
|
+
ORDER BY created_seq LIMIT 1
|
|
372
|
+
`).get(request.correlation_id, request.target_instance, request.source_instance, request.created_seq, ...REPLY_OBLIGATION_KINDS);
|
|
373
|
+
this.db.prepare(`
|
|
374
|
+
INSERT INTO reply_obligations(correlation_id, requester_instance, owner_instance, request_delivery_id,
|
|
375
|
+
opened_at, last_asked_at, state, answered_at, answered_delivery_id)
|
|
376
|
+
VALUES (@cid, @requester, @owner, @request, @now, @now, @state, @answeredAt, @answeredId)
|
|
377
|
+
ON CONFLICT(correlation_id, requester_instance, owner_instance) DO UPDATE SET
|
|
378
|
+
request_delivery_id=excluded.request_delivery_id, last_asked_at=excluded.last_asked_at,
|
|
379
|
+
state=excluded.state, nudged_at=NULL, overdue_notified_at=NULL,
|
|
380
|
+
answered_at=excluded.answered_at, answered_delivery_id=excluded.answered_delivery_id
|
|
381
|
+
`).run({
|
|
382
|
+
cid: request.correlation_id, requester: request.source_instance, owner: request.target_instance,
|
|
383
|
+
request: request.delivery_id, now,
|
|
384
|
+
state: earlyAnswer ? "answered" : "open",
|
|
385
|
+
answeredAt: earlyAnswer ? now : null, answeredId: earlyAnswer?.delivery_id ?? null,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/** The owner's message back to the requester on the same correlation_id answers it — at admission. */
|
|
389
|
+
answerReplyObligation(answer, now) {
|
|
390
|
+
if (!REPLY_OBLIGATION_KINDS.includes(answer.kind) || !answer.correlation_id)
|
|
391
|
+
return;
|
|
392
|
+
this.db.prepare(`
|
|
393
|
+
UPDATE reply_obligations SET state='answered', answered_at=?, answered_delivery_id=?
|
|
394
|
+
WHERE correlation_id=? AND owner_instance=? AND requester_instance=? AND state='open'
|
|
395
|
+
`).run(now, answer.delivery_id, answer.correlation_id, answer.source_instance, answer.target_instance);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* #926 safety net 1: remind an owner that has not answered. `turn-ended`
|
|
399
|
+
* reminds once per ask, after the grace (a reply seconds after the ask is
|
|
400
|
+
* normal); `restart` reminds once per restart, because the interrupted turn
|
|
401
|
+
* does not resume on its own. Marked in the same transaction as the notice.
|
|
402
|
+
*/
|
|
403
|
+
remindReplyObligations(ownerInstance, opts) {
|
|
404
|
+
const now = opts.now ?? new Date();
|
|
405
|
+
const nowIso = now.toISOString();
|
|
406
|
+
const graceCutoff = new Date(now.getTime() - opts.graceMs).toISOString();
|
|
407
|
+
const sinceIso = (opts.since ?? now).toISOString();
|
|
408
|
+
const inserted = this.db.transaction(() => {
|
|
409
|
+
const due = this.db.prepare(opts.reason === "turn-ended"
|
|
410
|
+
? "SELECT * FROM reply_obligations WHERE owner_instance=? AND state='open' AND nudged_at IS NULL AND last_asked_at <= ?"
|
|
411
|
+
: "SELECT * FROM reply_obligations WHERE owner_instance=? AND state='open' AND (nudged_at IS NULL OR nudged_at < ?)").all(ownerInstance, opts.reason === "turn-ended" ? graceCutoff : sinceIso);
|
|
412
|
+
let count = 0;
|
|
413
|
+
for (const row of due) {
|
|
414
|
+
const content = opts.reason === "turn-ended"
|
|
415
|
+
? `[system:reply-pending] Your turn ended without answering ${row.requester_instance} (correlation_id ${row.correlation_id}, asked ${row.last_asked_at}). Send your conclusion with report_result (correlation_id ${row.correlation_id}) now — text you write in the terminal does not reach them.`
|
|
416
|
+
: `[system:reply-pending] A restart interrupted your work on correlation_id ${row.correlation_id} from ${row.requester_instance} (asked ${row.last_asked_at}). Resume it and answer with report_result (correlation_id ${row.correlation_id}); terminal text does not reach them.`;
|
|
417
|
+
const key = `reply-pending:${opts.reason}:${row.correlation_id}:${row.requester_instance}:${row.owner_instance}:${opts.reason === "turn-ended" ? row.last_asked_at : sinceIso}`;
|
|
418
|
+
if (this.insertReplyObligationNotice(row.owner_instance, row.correlation_id, key, content, nowIso))
|
|
419
|
+
count++;
|
|
420
|
+
this.db.prepare(`
|
|
421
|
+
UPDATE reply_obligations SET nudged_at=? WHERE correlation_id=? AND requester_instance=? AND owner_instance=? AND state='open'
|
|
422
|
+
`).run(nowIso, row.correlation_id, row.requester_instance, row.owner_instance);
|
|
423
|
+
}
|
|
424
|
+
return count;
|
|
425
|
+
})();
|
|
426
|
+
if (inserted > 0)
|
|
427
|
+
this.emit("admitted");
|
|
428
|
+
return inserted;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* #926 safety net 2 (poll-on-timeout, in code): tell the requester once when
|
|
432
|
+
* the owner is not working and nothing has come back for `overdueMs` since
|
|
433
|
+
* the last ask or reminder. A still-working owner is never reported.
|
|
434
|
+
*/
|
|
435
|
+
notifyOverdueReplyObligations(opts) {
|
|
436
|
+
if (!(opts.overdueMs > 0))
|
|
437
|
+
return 0;
|
|
438
|
+
const now = opts.now ?? new Date();
|
|
439
|
+
const nowIso = now.toISOString();
|
|
440
|
+
const inserted = this.db.transaction(() => {
|
|
441
|
+
const open = this.db.prepare(`
|
|
442
|
+
SELECT * FROM reply_obligations WHERE state='open' AND overdue_notified_at IS NULL ORDER BY opened_at
|
|
443
|
+
`).all();
|
|
444
|
+
let count = 0;
|
|
445
|
+
for (const row of open) {
|
|
446
|
+
const quietSince = Math.max(Date.parse(row.last_asked_at), row.nudged_at ? Date.parse(row.nudged_at) : 0);
|
|
447
|
+
if (now.getTime() - quietSince < opts.overdueMs || !opts.ownerIdle(row.owner_instance))
|
|
448
|
+
continue;
|
|
449
|
+
const content = `[system:reply-overdue] ${row.owner_instance} has not answered correlation_id ${row.correlation_id} (asked ${row.last_asked_at}${row.nudged_at ? `, reminded ${row.nudged_at}` : ""}) and is not working on anything now. Check with describe_instance or delivery_status (correlation_id ${row.correlation_id}), or ask again.`;
|
|
450
|
+
const key = `reply-overdue:${row.correlation_id}:${row.requester_instance}:${row.owner_instance}:${row.last_asked_at}`;
|
|
451
|
+
if (this.insertReplyObligationNotice(row.requester_instance, row.correlation_id, key, content, nowIso))
|
|
452
|
+
count++;
|
|
453
|
+
this.db.prepare(`
|
|
454
|
+
UPDATE reply_obligations SET overdue_notified_at=? WHERE correlation_id=? AND requester_instance=? AND owner_instance=? AND state='open'
|
|
455
|
+
`).run(nowIso, row.correlation_id, row.requester_instance, row.owner_instance);
|
|
456
|
+
}
|
|
457
|
+
return count;
|
|
458
|
+
})();
|
|
459
|
+
if (inserted > 0)
|
|
460
|
+
this.emit("admitted");
|
|
461
|
+
return inserted;
|
|
462
|
+
}
|
|
463
|
+
/** A system notice as a durable row, like #929's outcome notices; the key makes it idempotent. */
|
|
464
|
+
insertReplyObligationNotice(target, correlationId, sourceKey, content, now) {
|
|
465
|
+
const noticeId = randomUUID();
|
|
466
|
+
const payload = {
|
|
467
|
+
type: "fleet_inbound",
|
|
468
|
+
content,
|
|
469
|
+
meta: {
|
|
470
|
+
user: "AgEnD delivery outbox", user_id: "agend-system", message_id: `reply-obligation-${noticeId}`,
|
|
471
|
+
chat_id: "", thread_id: "", source: "delivery-outbox",
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
return this.db.prepare(`
|
|
475
|
+
INSERT OR IGNORE INTO deliveries (
|
|
476
|
+
delivery_id,operation_id,source_key,source_instance,source_daemon_boot_id,
|
|
477
|
+
target_instance,target_session,target_daemon_boot_id,kind,correlation_id,payload_json,message_id,content_sha256,state,
|
|
478
|
+
attempt_no,created_seq,manager_boot_id,created_at,updated_at,accepted_at
|
|
479
|
+
) VALUES (?,?,?,'agend-system',?,?,NULL,NULL,?,?,?,?,?,'queued',0,
|
|
480
|
+
(SELECT COALESCE(MAX(created_seq),0)+1 FROM deliveries),NULL,?,?,?)
|
|
481
|
+
`).run(noticeId, `notice:${sourceKey}`, sourceKey, this.managerBootId, target, REPLY_OBLIGATION_NOTICE_KIND, correlationId, JSON.stringify(payload), payload.meta.message_id, deliveryContentDigest(content), now, now, now).changes === 1;
|
|
482
|
+
}
|
|
483
|
+
/** The owner's open obligations, oldest first (#926). */
|
|
484
|
+
openReplyObligations(ownerInstance) {
|
|
485
|
+
const rows = this.db.prepare(`
|
|
486
|
+
SELECT * FROM reply_obligations WHERE owner_instance=? AND state='open' ORDER BY opened_at, correlation_id
|
|
487
|
+
`).all(ownerInstance);
|
|
488
|
+
return rows.map(mapObligation);
|
|
489
|
+
}
|
|
490
|
+
getReplyObligation(correlationId, requesterInstance, ownerInstance) {
|
|
491
|
+
const row = this.db.prepare(`
|
|
492
|
+
SELECT * FROM reply_obligations WHERE correlation_id=? AND requester_instance=? AND owner_instance=?
|
|
493
|
+
`).get(correlationId, requesterInstance, ownerInstance);
|
|
494
|
+
return row ? mapObligation(row) : undefined;
|
|
495
|
+
}
|
|
496
|
+
/** Rows admitted before #856 get the same message_id / digest a new row would. */
|
|
497
|
+
backfillMessageEvidence() {
|
|
498
|
+
const rows = this.db.prepare(`
|
|
499
|
+
SELECT delivery_id, payload_json FROM deliveries WHERE message_id IS NULL AND content_sha256 IS NULL
|
|
500
|
+
`).all();
|
|
501
|
+
if (rows.length === 0)
|
|
502
|
+
return;
|
|
503
|
+
const update = this.db.prepare("UPDATE deliveries SET message_id=?, content_sha256=? WHERE delivery_id=?");
|
|
504
|
+
this.db.transaction(() => {
|
|
505
|
+
for (const row of rows) {
|
|
506
|
+
let payload = null;
|
|
507
|
+
try {
|
|
508
|
+
payload = JSON.parse(row.payload_json);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const content = payloadContent(payload);
|
|
514
|
+
update.run(payloadMessageId(payload), content === null ? null : deliveryContentDigest(content), row.delivery_id);
|
|
515
|
+
}
|
|
516
|
+
})();
|
|
517
|
+
}
|
|
518
|
+
ensureColumn(table, column, definition) {
|
|
519
|
+
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
520
|
+
if (columns.some(item => item.name === column))
|
|
521
|
+
return;
|
|
522
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
109
523
|
}
|
|
110
524
|
/** Commit admission before returning success. Duplicate source keys return their original row. */
|
|
111
525
|
admit(input) {
|
|
@@ -114,11 +528,11 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
114
528
|
const insert = this.db.prepare(`
|
|
115
529
|
INSERT OR IGNORE INTO deliveries (
|
|
116
530
|
delivery_id, operation_id, source_key, source_instance, source_daemon_boot_id,
|
|
117
|
-
target_instance, target_session, kind, correlation_id, payload_json, state,
|
|
531
|
+
target_instance, target_session, kind, correlation_id, payload_json, message_id, content_sha256, state,
|
|
118
532
|
attempt_no, created_seq, manager_boot_id, created_at, updated_at, accepted_at
|
|
119
533
|
) VALUES (
|
|
120
534
|
@delivery_id, @operation_id, @source_key, @source_instance, @source_daemon_boot_id,
|
|
121
|
-
@target_instance, @target_session, @kind, @correlation_id, @payload_json, 'queued',
|
|
535
|
+
@target_instance, @target_session, @kind, @correlation_id, @payload_json, @message_id, @content_sha256, 'queued',
|
|
122
536
|
0, (SELECT COALESCE(MAX(created_seq), 0) + 1 FROM deliveries), NULL,
|
|
123
537
|
@created_at, @updated_at, @accepted_at
|
|
124
538
|
)
|
|
@@ -136,6 +550,8 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
136
550
|
kind: input.kind,
|
|
137
551
|
correlation_id: input.correlationId ?? null,
|
|
138
552
|
payload_json: JSON.stringify(input.payload),
|
|
553
|
+
message_id: payloadMessageId(input.payload),
|
|
554
|
+
content_sha256: (content => content === null ? null : deliveryContentDigest(content))(payloadContent(input.payload)),
|
|
139
555
|
created_at: now,
|
|
140
556
|
updated_at: now,
|
|
141
557
|
accepted_at: now,
|
|
@@ -143,6 +559,8 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
143
559
|
const row = select.get(input.sourceKey);
|
|
144
560
|
if (!row)
|
|
145
561
|
throw new Error("outbox admission committed without a readable row");
|
|
562
|
+
if (result.changes === 1)
|
|
563
|
+
this.answerReplyObligation(row, now);
|
|
146
564
|
return { delivery: mapRow(row), inserted: result.changes === 1 };
|
|
147
565
|
});
|
|
148
566
|
return transaction();
|
|
@@ -157,6 +575,26 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
157
575
|
`).all(sourceInstance, operationId);
|
|
158
576
|
return rows.map(mapRow);
|
|
159
577
|
}
|
|
578
|
+
/**
|
|
579
|
+
* Query only rows owned by the authenticated instance as source or target.
|
|
580
|
+
* This method accepts caller identity separately from user arguments so a
|
|
581
|
+
* caller cannot widen its own visibility with a forged source name.
|
|
582
|
+
*/
|
|
583
|
+
queryStatusForInstance(callerInstance, selector) {
|
|
584
|
+
return queryStatusPage(this.db, selector, callerInstance);
|
|
585
|
+
}
|
|
586
|
+
/** Open the existing outbox without migrations or writes for the operator CLI. */
|
|
587
|
+
static queryStatusReadOnly(dbPath, selector) {
|
|
588
|
+
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
589
|
+
try {
|
|
590
|
+
db.pragma("busy_timeout = 1000");
|
|
591
|
+
db.pragma("query_only = ON");
|
|
592
|
+
return queryStatusPage(db, selector, null);
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
db.close();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
160
598
|
listPending() {
|
|
161
599
|
const rows = this.db.prepare(`
|
|
162
600
|
SELECT * FROM deliveries
|
|
@@ -182,6 +620,10 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
182
620
|
// The oldest pending row owns its target lane even while it is in retry
|
|
183
621
|
// backoff or the target is temporarily unavailable. Later rows must not
|
|
184
622
|
// pass it merely because they happen to be eligible first.
|
|
623
|
+
const reconciliationTargets = new Set(this.db.prepare(`
|
|
624
|
+
SELECT DISTINCT target_instance FROM deliveries
|
|
625
|
+
WHERE state='submission_started' AND reconciliation_pending=1
|
|
626
|
+
`).all().map(item => item.target_instance));
|
|
185
627
|
const visitedTargets = new Set();
|
|
186
628
|
for (const row of rows) {
|
|
187
629
|
if (visitedTargets.has(row.target_instance))
|
|
@@ -189,6 +631,10 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
189
631
|
visitedTargets.add(row.target_instance);
|
|
190
632
|
if (blockedTargets.has(row.target_instance))
|
|
191
633
|
continue;
|
|
634
|
+
// A recovered submission owns only its target lane while its evidence is
|
|
635
|
+
// classified; independent targets can keep dispatching.
|
|
636
|
+
if (reconciliationTargets.has(row.target_instance))
|
|
637
|
+
continue;
|
|
192
638
|
if (row.state === "retry_wait" && row.next_attempt_at && row.next_attempt_at > now)
|
|
193
639
|
continue;
|
|
194
640
|
const targetBootId = targetBootIdFor(row.target_instance);
|
|
@@ -210,7 +656,7 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
210
656
|
return undefined;
|
|
211
657
|
}
|
|
212
658
|
/** Idempotent begin permit, committed immediately before the pane side effect. */
|
|
213
|
-
begin(deliveryId, targetBootId, attemptNo) {
|
|
659
|
+
begin(deliveryId, targetBootId, attemptNo, evidence) {
|
|
214
660
|
const now = new Date().toISOString();
|
|
215
661
|
const transaction = this.db.transaction(() => {
|
|
216
662
|
const row = this.db.prepare("SELECT * FROM deliveries WHERE delivery_id=?").get(deliveryId);
|
|
@@ -228,6 +674,10 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
228
674
|
const started = this.db.prepare(`
|
|
229
675
|
SELECT COUNT(*) AS count FROM delivery_attempts WHERE delivery_id=?
|
|
230
676
|
`).get(deliveryId);
|
|
677
|
+
// Only a digest on BOTH sides can disagree: a pre-#856 row or a caller
|
|
678
|
+
// that could not hash is "unknown", never a mismatch.
|
|
679
|
+
const mismatch = !!row.content_sha256 && !!evidence?.pastedContentSha256
|
|
680
|
+
&& row.content_sha256 !== evidence.pastedContentSha256;
|
|
231
681
|
if (started.count >= DURABLE_DELIVERY_MAX_ATTEMPTS) {
|
|
232
682
|
const update = this.db.prepare(`
|
|
233
683
|
UPDATE deliveries SET state='failed',updated_at=?,finished_at=?,last_error='delivery submission attempt limit reached'
|
|
@@ -239,22 +689,41 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
239
689
|
return { outcome: "stale", failed: true };
|
|
240
690
|
}
|
|
241
691
|
this.db.prepare(`
|
|
242
|
-
INSERT INTO delivery_attempts(
|
|
243
|
-
|
|
244
|
-
|
|
692
|
+
INSERT INTO delivery_attempts(
|
|
693
|
+
delivery_id,target_daemon_boot_id,attempt_no,state,begin_ack_at,
|
|
694
|
+
backend,window_id,transcript_path,transcript_offset,transcript_session_id,submission_mode,
|
|
695
|
+
backend_version,queue_resume_policy,pasted_content_sha256,pasted_bytes_sha256,content_digest_mismatch
|
|
696
|
+
) VALUES (?,?,?,'begun',?,?,?,?,?,?,?,?,?,?,?,?)
|
|
697
|
+
`).run(deliveryId, targetBootId, attemptNo, now, evidence?.backend ?? null, evidence?.windowId ?? null, evidence?.transcriptPath ?? null, evidence?.transcriptOffset ?? null, evidence?.transcriptSessionId ?? null, evidence?.submissionMode ?? null, evidence?.backendVersion ?? null, evidence?.queueResumePolicy ?? null, evidence?.pastedContentSha256 ?? null, evidence?.pastedBytesSha256 ?? null, mismatch ? 1 : 0);
|
|
245
698
|
this.db.prepare(`
|
|
246
699
|
UPDATE deliveries SET state='submission_started', submitted_at=?, updated_at=?
|
|
247
700
|
WHERE delivery_id=? AND state='delivering'
|
|
248
701
|
`).run(now, now, deliveryId);
|
|
249
|
-
return { outcome: "begun", failed: false };
|
|
702
|
+
return { outcome: "begun", failed: false, mismatch };
|
|
250
703
|
});
|
|
251
704
|
const result = transaction();
|
|
705
|
+
if (result.mismatch) {
|
|
706
|
+
this.emit("content_digest_mismatch", { deliveryId, targetBootId, attemptNo, targetInstance: this.get(deliveryId)?.targetInstance });
|
|
707
|
+
}
|
|
252
708
|
if (result.outcome === "begun")
|
|
253
709
|
this.emit("state", { deliveryId, state: "submission_started" });
|
|
254
710
|
else if (result.failed)
|
|
255
711
|
this.emit("state", { deliveryId, state: "failed" });
|
|
256
712
|
return result.outcome;
|
|
257
713
|
}
|
|
714
|
+
/** Write-ahead fence: commit before tmux is allowed to receive Enter. */
|
|
715
|
+
markEnterStarted(deliveryId, targetBootId, attemptNo) {
|
|
716
|
+
const now = new Date().toISOString();
|
|
717
|
+
const changed = this.db.prepare(`
|
|
718
|
+
UPDATE delivery_attempts SET enter_started_at=COALESCE(enter_started_at,?)
|
|
719
|
+
WHERE delivery_id=? AND target_daemon_boot_id=? AND attempt_no=? AND state='begun'
|
|
720
|
+
AND EXISTS (
|
|
721
|
+
SELECT 1 FROM deliveries WHERE delivery_id=? AND target_daemon_boot_id=?
|
|
722
|
+
AND attempt_no=? AND state='submission_started'
|
|
723
|
+
)
|
|
724
|
+
`).run(now, deliveryId, targetBootId, attemptNo, deliveryId, targetBootId, attemptNo).changes;
|
|
725
|
+
return changed === 1;
|
|
726
|
+
}
|
|
258
727
|
/** Safe only when the caller can prove no pane write occurred for this attempt. */
|
|
259
728
|
abort(deliveryId, targetBootId, attemptNo, reason) {
|
|
260
729
|
const now = new Date().toISOString();
|
|
@@ -281,7 +750,7 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
281
750
|
if (updateAttempt.changes !== 1)
|
|
282
751
|
return false;
|
|
283
752
|
const updateDelivery = this.db.prepare(`
|
|
284
|
-
UPDATE deliveries SET state='retry_wait',updated_at=?,last_error=?,next_attempt_at=?
|
|
753
|
+
UPDATE deliveries SET state='retry_wait',reconciliation_pending=0,updated_at=?,last_error=?,next_attempt_at=?
|
|
285
754
|
WHERE delivery_id=? AND state='submission_started' AND target_daemon_boot_id=? AND attempt_no=?
|
|
286
755
|
`).run(now, reason.slice(0, 300), new Date(Date.now() + DURABLE_DELIVERY_ABORT_BACKOFF_MS).toISOString(), deliveryId, targetBootId, attemptNo);
|
|
287
756
|
if (updateDelivery.changes !== 1)
|
|
@@ -298,7 +767,7 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
298
767
|
const transaction = this.db.transaction(() => {
|
|
299
768
|
const parent = this.db.prepare("SELECT * FROM deliveries WHERE delivery_id=?").get(deliveryId);
|
|
300
769
|
const result = this.db.prepare(`
|
|
301
|
-
UPDATE deliveries SET state=?,updated_at=?,finished_at=?,last_error=?
|
|
770
|
+
UPDATE deliveries SET state=?,reconciliation_pending=0,updated_at=?,finished_at=?,last_error=?
|
|
302
771
|
WHERE delivery_id=? AND target_daemon_boot_id=? AND attempt_no=? AND state='submission_started'
|
|
303
772
|
`).run(outcome, now, now, outcome === "delivered" ? null : (evidence ?? outcome).slice(0, 300), deliveryId, targetBootId, attemptNo);
|
|
304
773
|
if (result.changes !== 1) {
|
|
@@ -312,6 +781,8 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
312
781
|
if (parent && (outcome === "failed" || outcome === "uncertain")) {
|
|
313
782
|
this.insertFailureNotice(parent, outcome, evidence ?? outcome, now);
|
|
314
783
|
}
|
|
784
|
+
if (parent && outcome === "delivered")
|
|
785
|
+
this.openReplyObligation(parent, now);
|
|
315
786
|
return { changed: true, accepted: true };
|
|
316
787
|
});
|
|
317
788
|
const result = transaction();
|
|
@@ -368,7 +839,7 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
368
839
|
this.emit("state", { deliveryId, state: "failed" });
|
|
369
840
|
return changed;
|
|
370
841
|
}
|
|
371
|
-
/**
|
|
842
|
+
/** Bound both unavailable rows and unresolved reconciliation without stealing a live lease. */
|
|
372
843
|
expireStale(nowMs = Date.now(), maxAgeMs = DURABLE_DELIVERY_MAX_AGE_MS) {
|
|
373
844
|
const cutoff = new Date(nowMs - maxAgeMs).toISOString();
|
|
374
845
|
const now = new Date(nowMs).toISOString();
|
|
@@ -383,23 +854,46 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
383
854
|
WHERE delivery_id=? AND state IN ('queued','retry_wait')
|
|
384
855
|
`);
|
|
385
856
|
let changed = 0;
|
|
857
|
+
let uncertain = 0;
|
|
386
858
|
for (const row of stale) {
|
|
387
859
|
if (update.run(now, now, row.delivery_id).changes !== 1)
|
|
388
860
|
continue;
|
|
389
861
|
this.insertFailureNotice(row, "failed", "delivery TTL expired before target became available", now);
|
|
390
862
|
changed++;
|
|
391
863
|
}
|
|
392
|
-
|
|
864
|
+
const unresolved = this.db.prepare(`
|
|
865
|
+
SELECT * FROM deliveries
|
|
866
|
+
WHERE state='submission_started' AND reconciliation_pending=1 AND created_at<=?
|
|
867
|
+
ORDER BY created_seq
|
|
868
|
+
`).all(cutoff);
|
|
869
|
+
const markUncertain = this.db.prepare(`
|
|
870
|
+
UPDATE deliveries SET state='uncertain',reconciliation_pending=0,updated_at=?,finished_at=?,
|
|
871
|
+
last_error='reconciliation TTL expired without sufficient evidence'
|
|
872
|
+
WHERE delivery_id=? AND state='submission_started' AND reconciliation_pending=1
|
|
873
|
+
`);
|
|
874
|
+
for (const row of unresolved) {
|
|
875
|
+
if (markUncertain.run(now, now, row.delivery_id).changes !== 1)
|
|
876
|
+
continue;
|
|
877
|
+
this.db.prepare(`
|
|
878
|
+
UPDATE delivery_attempts SET state='uncertain',finished_at=?,evidence='reconciliation TTL expired without sufficient evidence'
|
|
879
|
+
WHERE delivery_id=? AND target_daemon_boot_id=? AND attempt_no=? AND state='begun'
|
|
880
|
+
`).run(now, row.delivery_id, row.target_daemon_boot_id, row.attempt_no);
|
|
881
|
+
this.insertFailureNotice(row, "uncertain", "reconciliation TTL expired without sufficient evidence", now);
|
|
882
|
+
changed++;
|
|
883
|
+
uncertain++;
|
|
884
|
+
}
|
|
885
|
+
return { changed, uncertain };
|
|
393
886
|
});
|
|
394
|
-
const
|
|
395
|
-
if (changed)
|
|
396
|
-
this.emit("expired", { count: changed });
|
|
397
|
-
return changed;
|
|
887
|
+
const result = transaction();
|
|
888
|
+
if (result.changed)
|
|
889
|
+
this.emit("expired", { count: result.changed, uncertain: result.uncertain });
|
|
890
|
+
return result.changed;
|
|
398
891
|
}
|
|
399
892
|
/** Next queued/retry expiry for bounded maintenance scheduling. */
|
|
400
893
|
nextExpiryAt() {
|
|
401
894
|
const row = this.db.prepare(`
|
|
402
|
-
SELECT MIN(created_at) AS created_at FROM deliveries
|
|
895
|
+
SELECT MIN(created_at) AS created_at FROM deliveries
|
|
896
|
+
WHERE state IN ('queued','retry_wait') OR (state='submission_started' AND reconciliation_pending=1)
|
|
403
897
|
`).get();
|
|
404
898
|
return row.created_at ? new Date(Date.parse(row.created_at) + DURABLE_DELIVERY_MAX_AGE_MS).toISOString() : null;
|
|
405
899
|
}
|
|
@@ -414,23 +908,17 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
414
908
|
recoverForBoot(managerBootId) {
|
|
415
909
|
const now = new Date().toISOString();
|
|
416
910
|
const transaction = this.db.transaction(() => {
|
|
417
|
-
const uncertainRows = this.db.prepare(`
|
|
418
|
-
SELECT * FROM deliveries WHERE manager_boot_id IS NOT NULL AND manager_boot_id<>? AND state='submission_started'
|
|
419
|
-
`).all(managerBootId);
|
|
420
911
|
const queued = this.db.prepare(`
|
|
421
912
|
UPDATE deliveries SET state='queued',manager_boot_id=NULL,target_daemon_boot_id=NULL,next_attempt_at=NULL,updated_at=?
|
|
422
913
|
WHERE manager_boot_id IS NOT NULL AND manager_boot_id<>? AND state IN ('delivering','retry_wait')
|
|
423
914
|
`).run(now, managerBootId).changes;
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
const
|
|
427
|
-
UPDATE deliveries SET
|
|
915
|
+
// Keep the submission fence and target lane while bounded pane/transcript
|
|
916
|
+
// evidence is captured. Never replay an in-flight row blindly.
|
|
917
|
+
const reconciliationPending = this.db.prepare(`
|
|
918
|
+
UPDATE deliveries SET reconciliation_pending=1,updated_at=?,last_error='process restarted during submission; reconciliation required'
|
|
428
919
|
WHERE manager_boot_id IS NOT NULL AND manager_boot_id<>? AND state='submission_started'
|
|
429
|
-
`).run(now,
|
|
430
|
-
|
|
431
|
-
this.insertFailureNotice(row, "uncertain", "process restarted during submission; reconciliation required", now);
|
|
432
|
-
}
|
|
433
|
-
return { queued, uncertain };
|
|
920
|
+
`).run(now, managerBootId).changes;
|
|
921
|
+
return { queued, reconciliationPending };
|
|
434
922
|
});
|
|
435
923
|
return transaction();
|
|
436
924
|
}
|
|
@@ -491,34 +979,103 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
491
979
|
recoverTargetGeneration(targetInstance, currentTargetBootId) {
|
|
492
980
|
const now = new Date().toISOString();
|
|
493
981
|
const transaction = this.db.transaction(() => {
|
|
494
|
-
const uncertainRows = this.db.prepare(`
|
|
495
|
-
SELECT * FROM deliveries WHERE target_instance=? AND target_daemon_boot_id IS NOT NULL
|
|
496
|
-
AND target_daemon_boot_id<>? AND state='submission_started'
|
|
497
|
-
`).all(targetInstance, currentTargetBootId);
|
|
498
982
|
const queued = this.db.prepare(`
|
|
499
983
|
UPDATE deliveries SET state='queued',manager_boot_id=NULL,target_daemon_boot_id=NULL,next_attempt_at=NULL,updated_at=?
|
|
500
984
|
WHERE target_instance=? AND target_daemon_boot_id IS NOT NULL AND target_daemon_boot_id<>?
|
|
501
985
|
AND state IN ('delivering','retry_wait')
|
|
502
986
|
`).run(now, targetInstance, currentTargetBootId).changes;
|
|
503
|
-
const
|
|
504
|
-
UPDATE deliveries SET
|
|
987
|
+
const reconciliationPending = this.db.prepare(`
|
|
988
|
+
UPDATE deliveries SET reconciliation_pending=1,updated_at=?,last_error='target daemon generation changed during submission; reconciliation required'
|
|
505
989
|
WHERE target_instance=? AND target_daemon_boot_id IS NOT NULL AND target_daemon_boot_id<>?
|
|
506
990
|
AND state='submission_started'
|
|
507
|
-
`).run(now,
|
|
508
|
-
|
|
509
|
-
this.insertFailureNotice(row, "uncertain", "target daemon generation changed during submission; reconciliation required", now);
|
|
510
|
-
}
|
|
511
|
-
return { queued, uncertain };
|
|
991
|
+
`).run(now, targetInstance, currentTargetBootId).changes;
|
|
992
|
+
return { queued, reconciliationPending };
|
|
512
993
|
});
|
|
513
994
|
const result = transaction();
|
|
514
|
-
if (result.queued + result.
|
|
995
|
+
if (result.queued + result.reconciliationPending > 0)
|
|
515
996
|
this.emit("generation_recovered", { targetInstance, ...result });
|
|
516
997
|
return result;
|
|
517
998
|
}
|
|
999
|
+
/** Fence in-flight writes before an in-process target stop begins. */
|
|
1000
|
+
markTargetReconciliationPending(targetInstance, targetBootId) {
|
|
1001
|
+
const now = new Date().toISOString();
|
|
1002
|
+
return this.db.prepare(`
|
|
1003
|
+
UPDATE deliveries SET reconciliation_pending=1,updated_at=?,last_error='target daemon stopping; reconciliation required'
|
|
1004
|
+
WHERE target_instance=? AND target_daemon_boot_id=? AND state='submission_started'
|
|
1005
|
+
`).run(now, targetInstance, targetBootId).changes;
|
|
1006
|
+
}
|
|
1007
|
+
getReconciliationCandidates(targetInstance) {
|
|
1008
|
+
const rows = this.db.prepare(`
|
|
1009
|
+
SELECT d.*, a.target_daemon_boot_id AS attempt_target_boot_id,
|
|
1010
|
+
a.attempt_no AS attempt_attempt_no, a.backend AS attempt_backend,
|
|
1011
|
+
a.window_id AS attempt_window_id, a.transcript_path AS attempt_transcript_path,
|
|
1012
|
+
a.transcript_offset AS attempt_transcript_offset,
|
|
1013
|
+
a.transcript_session_id AS attempt_transcript_session_id,
|
|
1014
|
+
a.submission_mode AS attempt_submission_mode, a.backend_version AS attempt_backend_version,
|
|
1015
|
+
a.queue_resume_policy AS attempt_queue_resume_policy,
|
|
1016
|
+
a.enter_started_at AS attempt_enter_started_at
|
|
1017
|
+
FROM deliveries d JOIN delivery_attempts a ON a.delivery_id=d.delivery_id
|
|
1018
|
+
AND a.attempt_no=d.attempt_no AND a.target_daemon_boot_id=d.target_daemon_boot_id
|
|
1019
|
+
WHERE d.target_instance=? AND d.state='submission_started' AND d.reconciliation_pending=1
|
|
1020
|
+
ORDER BY d.created_seq
|
|
1021
|
+
`).all(targetInstance);
|
|
1022
|
+
return rows.map(row => ({
|
|
1023
|
+
...mapRow(row),
|
|
1024
|
+
attempt: {
|
|
1025
|
+
targetDaemonBootId: row.attempt_target_boot_id,
|
|
1026
|
+
attemptNo: row.attempt_attempt_no,
|
|
1027
|
+
backend: row.attempt_backend,
|
|
1028
|
+
backendVersion: row.attempt_backend_version,
|
|
1029
|
+
windowId: row.attempt_window_id,
|
|
1030
|
+
transcriptPath: row.attempt_transcript_path,
|
|
1031
|
+
transcriptOffset: row.attempt_transcript_offset,
|
|
1032
|
+
transcriptSessionId: row.attempt_transcript_session_id,
|
|
1033
|
+
submissionMode: row.attempt_submission_mode,
|
|
1034
|
+
queueResumePolicy: row.attempt_queue_resume_policy,
|
|
1035
|
+
enterStartedAt: row.attempt_enter_started_at,
|
|
1036
|
+
},
|
|
1037
|
+
}));
|
|
1038
|
+
}
|
|
1039
|
+
/** Atomically settle one old attempt using only its boot/attempt fence. */
|
|
1040
|
+
reconcileAttempt(deliveryId, targetBootId, attemptNo, outcome, evidence) {
|
|
1041
|
+
const now = new Date().toISOString();
|
|
1042
|
+
const transaction = this.db.transaction(() => {
|
|
1043
|
+
const parent = this.db.prepare("SELECT * FROM deliveries WHERE delivery_id=?").get(deliveryId);
|
|
1044
|
+
if (!parent || parent.state !== "submission_started" || parent.target_daemon_boot_id !== targetBootId
|
|
1045
|
+
|| parent.attempt_no !== attemptNo || parent.reconciliation_pending !== 1)
|
|
1046
|
+
return false;
|
|
1047
|
+
const terminal = outcome !== "retry_wait";
|
|
1048
|
+
const changed = this.db.prepare(`
|
|
1049
|
+
UPDATE deliveries SET state=?,reconciliation_pending=0,manager_boot_id=?,
|
|
1050
|
+
target_daemon_boot_id=?,updated_at=?,finished_at=?,last_error=?,next_attempt_at=?
|
|
1051
|
+
WHERE delivery_id=? AND state='submission_started' AND reconciliation_pending=1
|
|
1052
|
+
AND target_daemon_boot_id=? AND attempt_no=?
|
|
1053
|
+
`).run(outcome, outcome === "retry_wait" ? null : parent.manager_boot_id, outcome === "retry_wait" ? null : targetBootId, now, terminal ? now : null, outcome === "delivered" ? null : evidence.slice(0, 300), outcome === "retry_wait" ? new Date(Date.now() + DURABLE_DELIVERY_ABORT_BACKOFF_MS).toISOString() : null, deliveryId, targetBootId, attemptNo).changes;
|
|
1054
|
+
if (changed !== 1)
|
|
1055
|
+
return false;
|
|
1056
|
+
const attemptState = outcome === "retry_wait" ? "aborted" : outcome;
|
|
1057
|
+
this.db.prepare(`
|
|
1058
|
+
UPDATE delivery_attempts SET state=?,abort_at=?,finished_at=?,evidence=?
|
|
1059
|
+
WHERE delivery_id=? AND target_daemon_boot_id=? AND attempt_no=? AND state='begun'
|
|
1060
|
+
`).run(attemptState, outcome === "retry_wait" ? now : null, terminal ? now : null, evidence.slice(0, 300), deliveryId, targetBootId, attemptNo);
|
|
1061
|
+
if (outcome === "uncertain")
|
|
1062
|
+
this.insertFailureNotice(parent, "uncertain", evidence, now);
|
|
1063
|
+
if (outcome === "delivered")
|
|
1064
|
+
this.openReplyObligation(parent, now);
|
|
1065
|
+
return true;
|
|
1066
|
+
});
|
|
1067
|
+
const changed = transaction();
|
|
1068
|
+
if (changed)
|
|
1069
|
+
this.emit("state", { deliveryId, state: outcome });
|
|
1070
|
+
return changed;
|
|
1071
|
+
}
|
|
518
1072
|
getUnansweredAccepted(sourceInstance, currentBootId) {
|
|
519
1073
|
const rows = this.db.prepare(`
|
|
520
1074
|
SELECT * FROM deliveries
|
|
521
1075
|
WHERE source_instance=? AND source_daemon_boot_id<>? AND response_delivered_at IS NULL
|
|
1076
|
+
-- Scheduler admission has no waiting source-agent tool response; its
|
|
1077
|
+
-- stable run key and durable row are the recovery/lookup contract.
|
|
1078
|
+
AND kind NOT IN ('raw_paste')
|
|
522
1079
|
ORDER BY created_seq
|
|
523
1080
|
`).all(sourceInstance, currentBootId);
|
|
524
1081
|
return rows.map(mapRow);
|
|
@@ -528,7 +1085,8 @@ export class DeliveryOutbox extends EventEmitter {
|
|
|
528
1085
|
}
|
|
529
1086
|
/** Must be called inside the same SQLite transaction as the terminal transition. */
|
|
530
1087
|
insertFailureNotice(parent, outcome, reason, now) {
|
|
531
|
-
if (parent.kind === "delivery_outcome_notice" || parent.kind === "post_restart_outcome_notice"
|
|
1088
|
+
if (parent.kind === "delivery_outcome_notice" || parent.kind === "post_restart_outcome_notice"
|
|
1089
|
+
|| parent.kind === REPLY_OBLIGATION_NOTICE_KIND)
|
|
532
1090
|
return;
|
|
533
1091
|
const noticeId = randomUUID();
|
|
534
1092
|
const operationId = `notice:${parent.operation_id}:${parent.delivery_id}`;
|