@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.
Files changed (98) hide show
  1. package/README.md +2 -0
  2. package/README.zh-TW.md +2 -0
  3. package/dist/agent-cli-instructions.md +5 -0
  4. package/dist/agent-cli.js +13 -0
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.js +1 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/backend/codex-session-lookup.d.ts +59 -0
  9. package/dist/backend/codex-session-lookup.js +139 -0
  10. package/dist/backend/codex-session-lookup.js.map +1 -0
  11. package/dist/backend/codex.d.ts +37 -9
  12. package/dist/backend/codex.js +204 -38
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/grok.js +51 -0
  15. package/dist/backend/grok.js.map +1 -1
  16. package/dist/backend/types.d.ts +12 -0
  17. package/dist/backend/types.js.map +1 -1
  18. package/dist/channel/mcp-tools.js +2 -0
  19. package/dist/channel/mcp-tools.js.map +1 -1
  20. package/dist/cli.js +137 -124
  21. package/dist/cli.js.map +1 -1
  22. package/dist/completion-install.d.ts +32 -0
  23. package/dist/completion-install.js +125 -1
  24. package/dist/completion-install.js.map +1 -1
  25. package/dist/config-validator.js +4 -0
  26. package/dist/config-validator.js.map +1 -1
  27. package/dist/daemon.d.ts +12 -1
  28. package/dist/daemon.js +186 -27
  29. package/dist/daemon.js.map +1 -1
  30. package/dist/delivery-outbox.d.ts +199 -6
  31. package/dist/delivery-outbox.js +602 -44
  32. package/dist/delivery-outbox.js.map +1 -1
  33. package/dist/delivery-outbox.test.js +268 -14
  34. package/dist/delivery-outbox.test.js.map +1 -1
  35. package/dist/delivery-queue-evidence.d.ts +15 -0
  36. package/dist/delivery-queue-evidence.js +35 -0
  37. package/dist/delivery-queue-evidence.js.map +1 -0
  38. package/dist/delivery-queue-evidence.test.d.ts +1 -0
  39. package/dist/delivery-queue-evidence.test.js +23 -0
  40. package/dist/delivery-queue-evidence.test.js.map +1 -0
  41. package/dist/delivery-reconciliation.d.ts +49 -0
  42. package/dist/delivery-reconciliation.js +296 -0
  43. package/dist/delivery-reconciliation.js.map +1 -0
  44. package/dist/delivery-reconciliation.test.d.ts +1 -0
  45. package/dist/delivery-reconciliation.test.js +411 -0
  46. package/dist/delivery-reconciliation.test.js.map +1 -0
  47. package/dist/fleet-manager.d.ts +10 -1
  48. package/dist/fleet-manager.js +109 -32
  49. package/dist/fleet-manager.js.map +1 -1
  50. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +10 -0
  51. package/dist/instance-lifecycle.d.ts +13 -0
  52. package/dist/instance-lifecycle.js +105 -21
  53. package/dist/instance-lifecycle.js.map +1 -1
  54. package/dist/instructions.d.ts +6 -0
  55. package/dist/instructions.js +9 -0
  56. package/dist/instructions.js.map +1 -1
  57. package/dist/locale.js +8 -0
  58. package/dist/locale.js.map +1 -1
  59. package/dist/ls-rows.d.ts +50 -0
  60. package/dist/ls-rows.js +152 -0
  61. package/dist/ls-rows.js.map +1 -0
  62. package/dist/outbound-handlers.d.ts +3 -0
  63. package/dist/outbound-handlers.js +38 -2
  64. package/dist/outbound-handlers.js.map +1 -1
  65. package/dist/outbound-schemas.d.ts +8 -0
  66. package/dist/outbound-schemas.js +15 -0
  67. package/dist/outbound-schemas.js.map +1 -1
  68. package/dist/pane-write-lock.d.ts +2 -0
  69. package/dist/pane-write-lock.js +19 -0
  70. package/dist/pane-write-lock.js.map +1 -1
  71. package/dist/quickstart.js +9 -3
  72. package/dist/quickstart.js.map +1 -1
  73. package/dist/remote-credential-check.d.ts +26 -7
  74. package/dist/remote-credential-check.js +157 -10
  75. package/dist/remote-credential-check.js.map +1 -1
  76. package/dist/scheduler/scheduler.d.ts +5 -1
  77. package/dist/scheduler/scheduler.js +60 -20
  78. package/dist/scheduler/scheduler.js.map +1 -1
  79. package/dist/scheduler/scheduler.test.js +41 -2
  80. package/dist/scheduler/scheduler.test.js.map +1 -1
  81. package/dist/scheduler/types.d.ts +1 -1
  82. package/dist/tmux-manager.d.ts +3 -3
  83. package/dist/tmux-manager.js +6 -6
  84. package/dist/tmux-manager.js.map +1 -1
  85. package/dist/tool-permissions.js +6 -6
  86. package/dist/tool-permissions.js.map +1 -1
  87. package/dist/transcript-monitor.d.ts +3 -1
  88. package/dist/transcript-monitor.js +15 -0
  89. package/dist/transcript-monitor.js.map +1 -1
  90. package/dist/transcript-sources.d.ts +9 -0
  91. package/dist/transcript-sources.js +14 -0
  92. package/dist/transcript-sources.js.map +1 -1
  93. package/dist/types.d.ts +6 -0
  94. package/dist/ui/dashboard.html +7 -5
  95. package/dist/ui/view.html +138 -45
  96. package/dist/usage/usage-api.js +30 -0
  97. package/dist/usage/usage-api.js.map +1 -1
  98. package/package.json +1 -1
@@ -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(delivery_id,target_daemon_boot_id,attempt_no,state,begin_ack_at)
243
- VALUES (?,?,?,'begun',?)
244
- `).run(deliveryId, targetBootId, attemptNo, now);
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
- /** Expire unavailable/pre-submit rows instead of leaving them queued forever. */
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
- return changed;
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 changed = transaction();
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 WHERE state IN ('queued','retry_wait')
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
- // Until transcript reconciliation is enabled, never blindly replay a row
425
- // whose pane side effect may already have happened.
426
- const uncertain = this.db.prepare(`
427
- UPDATE deliveries SET state='uncertain',updated_at=?,finished_at=?,last_error='process restarted during submission; reconciliation required'
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, now, managerBootId).changes;
430
- for (const row of uncertainRows) {
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 uncertain = this.db.prepare(`
504
- UPDATE deliveries SET state='uncertain',updated_at=?,finished_at=?,last_error='target daemon generation changed during submission; reconciliation required'
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, now, targetInstance, currentTargetBootId).changes;
508
- for (const row of uncertainRows) {
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.uncertain > 0)
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}`;