@songsid/agend 2.1.7-beta.4 → 2.1.7-beta.5

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 (76) 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 +11 -0
  12. package/dist/backend/codex.js +115 -7
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/types.d.ts +12 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/channel/mcp-tools.js +2 -0
  17. package/dist/channel/mcp-tools.js.map +1 -1
  18. package/dist/cli.js +68 -2
  19. package/dist/cli.js.map +1 -1
  20. package/dist/daemon.d.ts +11 -0
  21. package/dist/daemon.js +173 -23
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/delivery-outbox.d.ts +90 -6
  24. package/dist/delivery-outbox.js +301 -39
  25. package/dist/delivery-outbox.js.map +1 -1
  26. package/dist/delivery-outbox.test.js +268 -14
  27. package/dist/delivery-outbox.test.js.map +1 -1
  28. package/dist/delivery-queue-evidence.d.ts +15 -0
  29. package/dist/delivery-queue-evidence.js +35 -0
  30. package/dist/delivery-queue-evidence.js.map +1 -0
  31. package/dist/delivery-queue-evidence.test.d.ts +1 -0
  32. package/dist/delivery-queue-evidence.test.js +23 -0
  33. package/dist/delivery-queue-evidence.test.js.map +1 -0
  34. package/dist/delivery-reconciliation.d.ts +49 -0
  35. package/dist/delivery-reconciliation.js +296 -0
  36. package/dist/delivery-reconciliation.js.map +1 -0
  37. package/dist/delivery-reconciliation.test.d.ts +1 -0
  38. package/dist/delivery-reconciliation.test.js +411 -0
  39. package/dist/delivery-reconciliation.test.js.map +1 -0
  40. package/dist/fleet-manager.d.ts +2 -1
  41. package/dist/fleet-manager.js +61 -32
  42. package/dist/fleet-manager.js.map +1 -1
  43. package/dist/instance-lifecycle.d.ts +13 -0
  44. package/dist/instance-lifecycle.js +85 -10
  45. package/dist/instance-lifecycle.js.map +1 -1
  46. package/dist/instructions.js +1 -0
  47. package/dist/instructions.js.map +1 -1
  48. package/dist/locale.js +6 -0
  49. package/dist/locale.js.map +1 -1
  50. package/dist/outbound-handlers.d.ts +3 -0
  51. package/dist/outbound-handlers.js +38 -1
  52. package/dist/outbound-handlers.js.map +1 -1
  53. package/dist/outbound-schemas.d.ts +7 -0
  54. package/dist/outbound-schemas.js +14 -0
  55. package/dist/outbound-schemas.js.map +1 -1
  56. package/dist/pane-write-lock.d.ts +2 -0
  57. package/dist/pane-write-lock.js +19 -0
  58. package/dist/pane-write-lock.js.map +1 -1
  59. package/dist/scheduler/scheduler.d.ts +5 -1
  60. package/dist/scheduler/scheduler.js +60 -20
  61. package/dist/scheduler/scheduler.js.map +1 -1
  62. package/dist/scheduler/scheduler.test.js +41 -2
  63. package/dist/scheduler/scheduler.test.js.map +1 -1
  64. package/dist/scheduler/types.d.ts +1 -1
  65. package/dist/tmux-manager.d.ts +3 -3
  66. package/dist/tmux-manager.js +6 -6
  67. package/dist/tmux-manager.js.map +1 -1
  68. package/dist/tool-permissions.js +6 -6
  69. package/dist/tool-permissions.js.map +1 -1
  70. package/dist/transcript-monitor.d.ts +3 -1
  71. package/dist/transcript-monitor.js +15 -0
  72. package/dist/transcript-monitor.js.map +1 -1
  73. package/dist/transcript-sources.d.ts +9 -0
  74. package/dist/transcript-sources.js +14 -0
  75. package/dist/transcript-sources.js.map +1 -1
  76. package/package.json +1 -1
@@ -1,14 +1,16 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import type { QueueResumePolicy } from "./delivery-queue-evidence.js";
2
3
  export declare const DURABLE_DELIVERY_MAX_ATTEMPTS = 8;
3
4
  export declare const DURABLE_DELIVERY_MAX_AGE_MS: number;
4
5
  export declare const DURABLE_DELIVERY_ABORT_BACKOFF_MS = 5000;
5
6
  export interface DaemonDeliveryPort {
6
- begin(deliveryId: string, targetBootId: string, attemptNo: number): "begun" | "duplicate" | "stale";
7
+ begin(deliveryId: string, targetBootId: string, attemptNo: number, evidence?: DeliveryAttemptEvidence): "begun" | "duplicate" | "stale";
8
+ markEnterStarted(deliveryId: string, targetBootId: string, attemptNo: number): boolean;
7
9
  abort(deliveryId: string, targetBootId: string, attemptNo: number, reason: string): boolean;
8
10
  complete(deliveryId: string, targetBootId: string, attemptNo: number, outcome: "delivered" | "failed" | "uncertain", evidence?: string): boolean;
9
11
  retryBeforeBegin(deliveryId: string, targetBootId: string, attemptNo: number, reason: string, delayMs?: number): boolean;
10
12
  }
11
- export type OutboxState = "queued" | "delivering" | "submission_started" | "retry_wait" | "delivered" | "failed" | "uncertain" | "cancelled";
13
+ export type OutboxState = "queued" | "delivering" | "submission_started" | "reconciliation_pending" | "retry_wait" | "delivered" | "failed" | "uncertain" | "cancelled";
12
14
  export interface NewOutboxDelivery {
13
15
  operationId: string;
14
16
  sourceKey: string;
@@ -39,6 +41,72 @@ export interface OutboxDelivery {
39
41
  responseDeliveredAt: string | null;
40
42
  nextAttemptAt: string | null;
41
43
  lastError: string | null;
44
+ reconciliationPending: boolean;
45
+ }
46
+ export type DeliveryStatusSelector = {
47
+ deliveryId: string;
48
+ operationId?: never;
49
+ correlationId?: never;
50
+ limit?: never;
51
+ cursor?: never;
52
+ } | {
53
+ operationId: string;
54
+ deliveryId?: never;
55
+ correlationId?: never;
56
+ limit?: number;
57
+ cursor?: string;
58
+ } | {
59
+ correlationId: string;
60
+ deliveryId?: never;
61
+ operationId?: never;
62
+ limit?: number;
63
+ cursor?: string;
64
+ };
65
+ export interface DeliveryStatusItem {
66
+ delivery_id: string;
67
+ operation_id: string;
68
+ correlation_id: string | null;
69
+ source_instance: string;
70
+ target_instance: string;
71
+ kind: string;
72
+ state: OutboxState;
73
+ attempt_no: number;
74
+ created_at: string;
75
+ updated_at: string;
76
+ status_summary: string;
77
+ error_summary: string | null;
78
+ safe_to_retry: boolean;
79
+ }
80
+ export interface DeliveryStatusPage {
81
+ items: DeliveryStatusItem[];
82
+ next_cursor: string | null;
83
+ }
84
+ export type DurableSubmissionMode = "idle_submit" | "native_queue_handoff" | "steer" | "raw_paste";
85
+ /** Checkpoint committed with begin, before any pane paste can occur. */
86
+ export interface DeliveryAttemptEvidence {
87
+ backend: string;
88
+ backendVersion?: string | null;
89
+ windowId: string | null;
90
+ transcriptPath: string | null;
91
+ transcriptOffset: number | null;
92
+ transcriptSessionId: string | null;
93
+ submissionMode: DurableSubmissionMode;
94
+ queueResumePolicy?: QueueResumePolicy;
95
+ }
96
+ export interface DeliveryReconciliationCandidate extends OutboxDelivery {
97
+ attempt: {
98
+ targetDaemonBootId: string;
99
+ attemptNo: number;
100
+ backend: string | null;
101
+ backendVersion: string | null;
102
+ windowId: string | null;
103
+ transcriptPath: string | null;
104
+ transcriptOffset: number | null;
105
+ transcriptSessionId: string | null;
106
+ submissionMode: DurableSubmissionMode | null;
107
+ queueResumePolicy: QueueResumePolicy | null;
108
+ enterStartedAt: string | null;
109
+ };
42
110
  }
43
111
  export interface ClaimedOutboxDelivery extends OutboxDelivery {
44
112
  targetDaemonBootId: string;
@@ -48,6 +116,7 @@ export declare class DeliveryOutbox extends EventEmitter {
48
116
  private readonly db;
49
117
  readonly managerBootId: string;
50
118
  constructor(dbPath: string, managerBootId?: string);
119
+ private ensureColumn;
51
120
  /** Commit admission before returning success. Duplicate source keys return their original row. */
52
121
  admit(input: NewOutboxDelivery): {
53
122
  delivery: OutboxDelivery;
@@ -55,19 +124,29 @@ export declare class DeliveryOutbox extends EventEmitter {
55
124
  };
56
125
  get(deliveryId: string): OutboxDelivery | undefined;
57
126
  getByOperation(sourceInstance: string, operationId: string): OutboxDelivery[];
127
+ /**
128
+ * Query only rows owned by the authenticated instance as source or target.
129
+ * This method accepts caller identity separately from user arguments so a
130
+ * caller cannot widen its own visibility with a forged source name.
131
+ */
132
+ queryStatusForInstance(callerInstance: string, selector: DeliveryStatusSelector): DeliveryStatusPage;
133
+ /** Open the existing outbox without migrations or writes for the operator CLI. */
134
+ static queryStatusReadOnly(dbPath: string, selector: DeliveryStatusSelector): DeliveryStatusPage;
58
135
  listPending(): OutboxDelivery[];
59
136
  nextRetryAt(): string | null;
60
137
  /** Claim the earliest eligible target row; one caller owns each target lane. */
61
138
  claimNext(managerBootId: string, targetBootIdFor: (target: string) => string | null, blockedTargets: ReadonlySet<string>): ClaimedOutboxDelivery | undefined;
62
139
  /** Idempotent begin permit, committed immediately before the pane side effect. */
63
- begin(deliveryId: string, targetBootId: string, attemptNo: number): "begun" | "duplicate" | "stale";
140
+ begin(deliveryId: string, targetBootId: string, attemptNo: number, evidence?: DeliveryAttemptEvidence): "begun" | "duplicate" | "stale";
141
+ /** Write-ahead fence: commit before tmux is allowed to receive Enter. */
142
+ markEnterStarted(deliveryId: string, targetBootId: string, attemptNo: number): boolean;
64
143
  /** Safe only when the caller can prove no pane write occurred for this attempt. */
65
144
  abort(deliveryId: string, targetBootId: string, attemptNo: number, reason: string): boolean;
66
145
  complete(deliveryId: string, targetBootId: string, attemptNo: number, outcome: "delivered" | "failed" | "uncertain", evidence?: string): boolean;
67
146
  /** Transient pre-submit failure: state is retryable and the same row keeps its FIFO position. */
68
147
  retryBeforeBegin(deliveryId: string, targetBootId: string, attemptNo: number, reason: string, delayMs?: number): boolean;
69
148
  failBeforeBegin(deliveryId: string, reason: string): boolean;
70
- /** Expire unavailable/pre-submit rows instead of leaving them queued forever. */
149
+ /** Bound both unavailable rows and unresolved reconciliation without stealing a live lease. */
71
150
  expireStale(nowMs?: number, maxAgeMs?: number): number;
72
151
  /** Next queued/retry expiry for bounded maintenance scheduling. */
73
152
  nextExpiryAt(): string | null;
@@ -75,14 +154,19 @@ export declare class DeliveryOutbox extends EventEmitter {
75
154
  /** Old process leases are recoverable; an old submission attempt needs evidence reconciliation. */
76
155
  recoverForBoot(managerBootId: string): {
77
156
  queued: number;
78
- uncertain: number;
157
+ reconciliationPending: number;
79
158
  };
80
159
  /** Atomically create a post-restart notice for an accepted operation whose response was not recorded. */
81
160
  admitPostRestartOutcomeNotice(parent: OutboxDelivery, currentSourceBootId: string): OutboxDelivery | undefined;
82
161
  recoverTargetGeneration(targetInstance: string, currentTargetBootId: string): {
83
162
  queued: number;
84
- uncertain: number;
163
+ reconciliationPending: number;
85
164
  };
165
+ /** Fence in-flight writes before an in-process target stop begins. */
166
+ markTargetReconciliationPending(targetInstance: string, targetBootId: string): number;
167
+ getReconciliationCandidates(targetInstance: string): DeliveryReconciliationCandidate[];
168
+ /** Atomically settle one old attempt using only its boot/attempt fence. */
169
+ reconcileAttempt(deliveryId: string, targetBootId: string, attemptNo: number, outcome: "delivered" | "retry_wait" | "uncertain", evidence: string): boolean;
86
170
  getUnansweredAccepted(sourceInstance: string, currentBootId: string): OutboxDelivery[];
87
171
  close(): void;
88
172
  /** Must be called inside the same SQLite transaction as the terminal transition. */
@@ -19,13 +19,115 @@ function mapRow(row) {
19
19
  kind: row.kind,
20
20
  correlationId: row.correlation_id,
21
21
  payload: JSON.parse(row.payload_json),
22
- state: row.state,
22
+ state: row.state === "submission_started" && row.reconciliation_pending === 1
23
+ ? "reconciliation_pending"
24
+ : row.state,
23
25
  attemptNo: row.attempt_no,
24
26
  createdSeq: row.created_seq,
25
27
  managerBootId: row.manager_boot_id,
26
28
  responseDeliveredAt: row.response_delivered_at,
27
29
  nextAttemptAt: row.next_attempt_at,
28
30
  lastError: row.last_error,
31
+ reconciliationPending: row.reconciliation_pending === 1,
32
+ };
33
+ }
34
+ function safeErrorSummary(error) {
35
+ if (!error)
36
+ return null;
37
+ const normalized = error.toLowerCase();
38
+ if (normalized.includes("ttl expired"))
39
+ return "Delivery expired before the target became available.";
40
+ if (normalized.includes("attempt limit"))
41
+ return "Delivery exhausted its bounded retry attempts.";
42
+ if (normalized.includes("reconcil") || normalized.includes("uncertain") || normalized.includes("restart")) {
43
+ return "Submission may have occurred; do not resend until the outcome is reconciled.";
44
+ }
45
+ return "A delivery error was recorded; details are omitted for safety.";
46
+ }
47
+ function statusSummary(state) {
48
+ switch (state) {
49
+ case "queued": return "Accepted and waiting for delivery.";
50
+ case "delivering": return "Waiting for the target to become ready; submission has not started.";
51
+ case "retry_wait": return "Waiting for a bounded retry; submission has not started for the next attempt.";
52
+ case "submission_started":
53
+ case "reconciliation_pending": return "Submission may have occurred; do not resend.";
54
+ case "delivered": return "Delivery was confirmed; this does not mean the agent finished processing it.";
55
+ case "failed": return "Delivery failed after bounded retries or expiry.";
56
+ case "uncertain": return "Delivery may have occurred; do not resend blindly.";
57
+ case "cancelled": return "Delivery was cancelled.";
58
+ }
59
+ }
60
+ function safeToRetry(state) {
61
+ return state === "queued" || state === "delivering" || state === "retry_wait" || state === "failed";
62
+ }
63
+ function queryStatusPage(db, selector, callerInstance) {
64
+ const where = [];
65
+ const params = [];
66
+ if (selector.deliveryId) {
67
+ where.push("d.delivery_id = ?");
68
+ params.push(selector.deliveryId);
69
+ }
70
+ else if (selector.operationId) {
71
+ where.push("d.operation_id = ?");
72
+ params.push(selector.operationId);
73
+ }
74
+ else {
75
+ where.push("d.correlation_id = ?");
76
+ params.push(selector.correlationId);
77
+ }
78
+ if (callerInstance !== null) {
79
+ where.push("(d.source_instance = ? OR d.target_instance = ?)");
80
+ params.push(callerInstance, callerInstance);
81
+ }
82
+ if (selector.cursor) {
83
+ const cursorScope = ["c.delivery_id = ?"];
84
+ const cursorParams = [selector.cursor];
85
+ if (selector.operationId) {
86
+ cursorScope.push("c.operation_id = ?");
87
+ cursorParams.push(selector.operationId);
88
+ }
89
+ else {
90
+ cursorScope.push("c.correlation_id = ?");
91
+ cursorParams.push(selector.correlationId);
92
+ }
93
+ if (callerInstance !== null) {
94
+ cursorScope.push("(c.source_instance = ? OR c.target_instance = ?)");
95
+ cursorParams.push(callerInstance, callerInstance);
96
+ }
97
+ where.push(`d.created_seq > (SELECT c.created_seq FROM deliveries c WHERE ${cursorScope.join(" AND ")} LIMIT 1)`);
98
+ params.push(...cursorParams);
99
+ }
100
+ const limit = selector.deliveryId ? 1 : Math.max(1, Math.min(100, selector.limit ?? 20));
101
+ const rows = db.prepare(`
102
+ SELECT d.delivery_id,d.operation_id,d.correlation_id,d.source_instance,d.target_instance,
103
+ d.kind,
104
+ CASE WHEN d.state='submission_started' AND d.reconciliation_pending=1
105
+ THEN 'reconciliation_pending' ELSE d.state END AS state,
106
+ d.attempt_no,d.created_at,d.updated_at,d.last_error
107
+ FROM deliveries d
108
+ WHERE ${where.join(" AND ")}
109
+ ORDER BY d.created_seq
110
+ LIMIT ?
111
+ `).all(...params, limit + 1);
112
+ const hasMore = rows.length > limit;
113
+ const visible = rows.slice(0, limit);
114
+ return {
115
+ items: visible.map(row => ({
116
+ delivery_id: row.delivery_id,
117
+ operation_id: row.operation_id,
118
+ correlation_id: row.correlation_id,
119
+ source_instance: row.source_instance,
120
+ target_instance: row.target_instance,
121
+ kind: row.kind,
122
+ state: row.state,
123
+ attempt_no: row.attempt_no,
124
+ created_at: row.created_at,
125
+ updated_at: row.updated_at,
126
+ status_summary: statusSummary(row.state),
127
+ error_summary: safeErrorSummary(row.last_error),
128
+ safe_to_retry: safeToRetry(row.state),
129
+ })),
130
+ next_cursor: hasMore ? visible.at(-1)?.delivery_id ?? null : null,
29
131
  };
30
132
  }
31
133
  /** Durable admission and fenced state transitions for the fleet delivery queue. */
@@ -63,6 +165,7 @@ export class DeliveryOutbox extends EventEmitter {
63
165
  created_seq INTEGER NOT NULL UNIQUE,
64
166
  manager_boot_id TEXT,
65
167
  response_delivered_at TEXT,
168
+ reconciliation_pending INTEGER NOT NULL DEFAULT 0,
66
169
  next_attempt_at TEXT,
67
170
  last_error TEXT,
68
171
  created_at TEXT NOT NULL,
@@ -77,6 +180,10 @@ export class DeliveryOutbox extends EventEmitter {
77
180
  ON deliveries(target_instance, state, created_seq);
78
181
  CREATE INDEX IF NOT EXISTS idx_delivery_operation_source
79
182
  ON deliveries(source_instance, operation_id);
183
+ CREATE INDEX IF NOT EXISTS idx_delivery_operation_seq
184
+ ON deliveries(operation_id, created_seq);
185
+ CREATE INDEX IF NOT EXISTS idx_delivery_correlation_seq
186
+ ON deliveries(correlation_id, created_seq);
80
187
 
81
188
  CREATE TABLE IF NOT EXISTS delivery_attempts (
82
189
  delivery_id TEXT NOT NULL REFERENCES deliveries(delivery_id),
@@ -87,6 +194,15 @@ export class DeliveryOutbox extends EventEmitter {
87
194
  abort_at TEXT,
88
195
  finished_at TEXT,
89
196
  evidence TEXT,
197
+ backend TEXT,
198
+ window_id TEXT,
199
+ transcript_path TEXT,
200
+ transcript_offset INTEGER,
201
+ transcript_session_id TEXT,
202
+ submission_mode TEXT,
203
+ backend_version TEXT,
204
+ queue_resume_policy TEXT,
205
+ enter_started_at TEXT,
90
206
  PRIMARY KEY (delivery_id, target_daemon_boot_id, attempt_no)
91
207
  );
92
208
 
@@ -104,8 +220,26 @@ export class DeliveryOutbox extends EventEmitter {
104
220
  notice_delivery_id TEXT NOT NULL UNIQUE,
105
221
  created_at TEXT NOT NULL
106
222
  );
107
- PRAGMA user_version = 1;
108
223
  `);
224
+ // Additive migration: existing Phase 1 databases keep their row identities,
225
+ // foreign keys, and WAL while gaining reconciliation evidence columns.
226
+ this.ensureColumn("deliveries", "reconciliation_pending", "INTEGER NOT NULL DEFAULT 0");
227
+ this.ensureColumn("delivery_attempts", "backend", "TEXT");
228
+ this.ensureColumn("delivery_attempts", "window_id", "TEXT");
229
+ this.ensureColumn("delivery_attempts", "transcript_path", "TEXT");
230
+ this.ensureColumn("delivery_attempts", "transcript_offset", "INTEGER");
231
+ this.ensureColumn("delivery_attempts", "transcript_session_id", "TEXT");
232
+ this.ensureColumn("delivery_attempts", "submission_mode", "TEXT");
233
+ this.ensureColumn("delivery_attempts", "backend_version", "TEXT");
234
+ this.ensureColumn("delivery_attempts", "queue_resume_policy", "TEXT");
235
+ this.ensureColumn("delivery_attempts", "enter_started_at", "TEXT");
236
+ this.db.pragma("user_version = 3");
237
+ }
238
+ ensureColumn(table, column, definition) {
239
+ const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
240
+ if (columns.some(item => item.name === column))
241
+ return;
242
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
109
243
  }
110
244
  /** Commit admission before returning success. Duplicate source keys return their original row. */
111
245
  admit(input) {
@@ -157,6 +291,26 @@ export class DeliveryOutbox extends EventEmitter {
157
291
  `).all(sourceInstance, operationId);
158
292
  return rows.map(mapRow);
159
293
  }
294
+ /**
295
+ * Query only rows owned by the authenticated instance as source or target.
296
+ * This method accepts caller identity separately from user arguments so a
297
+ * caller cannot widen its own visibility with a forged source name.
298
+ */
299
+ queryStatusForInstance(callerInstance, selector) {
300
+ return queryStatusPage(this.db, selector, callerInstance);
301
+ }
302
+ /** Open the existing outbox without migrations or writes for the operator CLI. */
303
+ static queryStatusReadOnly(dbPath, selector) {
304
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
305
+ try {
306
+ db.pragma("busy_timeout = 1000");
307
+ db.pragma("query_only = ON");
308
+ return queryStatusPage(db, selector, null);
309
+ }
310
+ finally {
311
+ db.close();
312
+ }
313
+ }
160
314
  listPending() {
161
315
  const rows = this.db.prepare(`
162
316
  SELECT * FROM deliveries
@@ -182,6 +336,10 @@ export class DeliveryOutbox extends EventEmitter {
182
336
  // The oldest pending row owns its target lane even while it is in retry
183
337
  // backoff or the target is temporarily unavailable. Later rows must not
184
338
  // pass it merely because they happen to be eligible first.
339
+ const reconciliationTargets = new Set(this.db.prepare(`
340
+ SELECT DISTINCT target_instance FROM deliveries
341
+ WHERE state='submission_started' AND reconciliation_pending=1
342
+ `).all().map(item => item.target_instance));
185
343
  const visitedTargets = new Set();
186
344
  for (const row of rows) {
187
345
  if (visitedTargets.has(row.target_instance))
@@ -189,6 +347,10 @@ export class DeliveryOutbox extends EventEmitter {
189
347
  visitedTargets.add(row.target_instance);
190
348
  if (blockedTargets.has(row.target_instance))
191
349
  continue;
350
+ // A recovered submission owns only its target lane while its evidence is
351
+ // classified; independent targets can keep dispatching.
352
+ if (reconciliationTargets.has(row.target_instance))
353
+ continue;
192
354
  if (row.state === "retry_wait" && row.next_attempt_at && row.next_attempt_at > now)
193
355
  continue;
194
356
  const targetBootId = targetBootIdFor(row.target_instance);
@@ -210,7 +372,7 @@ export class DeliveryOutbox extends EventEmitter {
210
372
  return undefined;
211
373
  }
212
374
  /** Idempotent begin permit, committed immediately before the pane side effect. */
213
- begin(deliveryId, targetBootId, attemptNo) {
375
+ begin(deliveryId, targetBootId, attemptNo, evidence) {
214
376
  const now = new Date().toISOString();
215
377
  const transaction = this.db.transaction(() => {
216
378
  const row = this.db.prepare("SELECT * FROM deliveries WHERE delivery_id=?").get(deliveryId);
@@ -239,9 +401,12 @@ export class DeliveryOutbox extends EventEmitter {
239
401
  return { outcome: "stale", failed: true };
240
402
  }
241
403
  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);
404
+ INSERT INTO delivery_attempts(
405
+ delivery_id,target_daemon_boot_id,attempt_no,state,begin_ack_at,
406
+ backend,window_id,transcript_path,transcript_offset,transcript_session_id,submission_mode,
407
+ backend_version,queue_resume_policy
408
+ ) VALUES (?,?,?,'begun',?,?,?,?,?,?,?,?,?)
409
+ `).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);
245
410
  this.db.prepare(`
246
411
  UPDATE deliveries SET state='submission_started', submitted_at=?, updated_at=?
247
412
  WHERE delivery_id=? AND state='delivering'
@@ -255,6 +420,19 @@ export class DeliveryOutbox extends EventEmitter {
255
420
  this.emit("state", { deliveryId, state: "failed" });
256
421
  return result.outcome;
257
422
  }
423
+ /** Write-ahead fence: commit before tmux is allowed to receive Enter. */
424
+ markEnterStarted(deliveryId, targetBootId, attemptNo) {
425
+ const now = new Date().toISOString();
426
+ const changed = this.db.prepare(`
427
+ UPDATE delivery_attempts SET enter_started_at=COALESCE(enter_started_at,?)
428
+ WHERE delivery_id=? AND target_daemon_boot_id=? AND attempt_no=? AND state='begun'
429
+ AND EXISTS (
430
+ SELECT 1 FROM deliveries WHERE delivery_id=? AND target_daemon_boot_id=?
431
+ AND attempt_no=? AND state='submission_started'
432
+ )
433
+ `).run(now, deliveryId, targetBootId, attemptNo, deliveryId, targetBootId, attemptNo).changes;
434
+ return changed === 1;
435
+ }
258
436
  /** Safe only when the caller can prove no pane write occurred for this attempt. */
259
437
  abort(deliveryId, targetBootId, attemptNo, reason) {
260
438
  const now = new Date().toISOString();
@@ -281,7 +459,7 @@ export class DeliveryOutbox extends EventEmitter {
281
459
  if (updateAttempt.changes !== 1)
282
460
  return false;
283
461
  const updateDelivery = this.db.prepare(`
284
- UPDATE deliveries SET state='retry_wait',updated_at=?,last_error=?,next_attempt_at=?
462
+ UPDATE deliveries SET state='retry_wait',reconciliation_pending=0,updated_at=?,last_error=?,next_attempt_at=?
285
463
  WHERE delivery_id=? AND state='submission_started' AND target_daemon_boot_id=? AND attempt_no=?
286
464
  `).run(now, reason.slice(0, 300), new Date(Date.now() + DURABLE_DELIVERY_ABORT_BACKOFF_MS).toISOString(), deliveryId, targetBootId, attemptNo);
287
465
  if (updateDelivery.changes !== 1)
@@ -298,7 +476,7 @@ export class DeliveryOutbox extends EventEmitter {
298
476
  const transaction = this.db.transaction(() => {
299
477
  const parent = this.db.prepare("SELECT * FROM deliveries WHERE delivery_id=?").get(deliveryId);
300
478
  const result = this.db.prepare(`
301
- UPDATE deliveries SET state=?,updated_at=?,finished_at=?,last_error=?
479
+ UPDATE deliveries SET state=?,reconciliation_pending=0,updated_at=?,finished_at=?,last_error=?
302
480
  WHERE delivery_id=? AND target_daemon_boot_id=? AND attempt_no=? AND state='submission_started'
303
481
  `).run(outcome, now, now, outcome === "delivered" ? null : (evidence ?? outcome).slice(0, 300), deliveryId, targetBootId, attemptNo);
304
482
  if (result.changes !== 1) {
@@ -368,7 +546,7 @@ export class DeliveryOutbox extends EventEmitter {
368
546
  this.emit("state", { deliveryId, state: "failed" });
369
547
  return changed;
370
548
  }
371
- /** Expire unavailable/pre-submit rows instead of leaving them queued forever. */
549
+ /** Bound both unavailable rows and unresolved reconciliation without stealing a live lease. */
372
550
  expireStale(nowMs = Date.now(), maxAgeMs = DURABLE_DELIVERY_MAX_AGE_MS) {
373
551
  const cutoff = new Date(nowMs - maxAgeMs).toISOString();
374
552
  const now = new Date(nowMs).toISOString();
@@ -383,23 +561,46 @@ export class DeliveryOutbox extends EventEmitter {
383
561
  WHERE delivery_id=? AND state IN ('queued','retry_wait')
384
562
  `);
385
563
  let changed = 0;
564
+ let uncertain = 0;
386
565
  for (const row of stale) {
387
566
  if (update.run(now, now, row.delivery_id).changes !== 1)
388
567
  continue;
389
568
  this.insertFailureNotice(row, "failed", "delivery TTL expired before target became available", now);
390
569
  changed++;
391
570
  }
392
- return changed;
571
+ const unresolved = this.db.prepare(`
572
+ SELECT * FROM deliveries
573
+ WHERE state='submission_started' AND reconciliation_pending=1 AND created_at<=?
574
+ ORDER BY created_seq
575
+ `).all(cutoff);
576
+ const markUncertain = this.db.prepare(`
577
+ UPDATE deliveries SET state='uncertain',reconciliation_pending=0,updated_at=?,finished_at=?,
578
+ last_error='reconciliation TTL expired without sufficient evidence'
579
+ WHERE delivery_id=? AND state='submission_started' AND reconciliation_pending=1
580
+ `);
581
+ for (const row of unresolved) {
582
+ if (markUncertain.run(now, now, row.delivery_id).changes !== 1)
583
+ continue;
584
+ this.db.prepare(`
585
+ UPDATE delivery_attempts SET state='uncertain',finished_at=?,evidence='reconciliation TTL expired without sufficient evidence'
586
+ WHERE delivery_id=? AND target_daemon_boot_id=? AND attempt_no=? AND state='begun'
587
+ `).run(now, row.delivery_id, row.target_daemon_boot_id, row.attempt_no);
588
+ this.insertFailureNotice(row, "uncertain", "reconciliation TTL expired without sufficient evidence", now);
589
+ changed++;
590
+ uncertain++;
591
+ }
592
+ return { changed, uncertain };
393
593
  });
394
- const changed = transaction();
395
- if (changed)
396
- this.emit("expired", { count: changed });
397
- return changed;
594
+ const result = transaction();
595
+ if (result.changed)
596
+ this.emit("expired", { count: result.changed, uncertain: result.uncertain });
597
+ return result.changed;
398
598
  }
399
599
  /** Next queued/retry expiry for bounded maintenance scheduling. */
400
600
  nextExpiryAt() {
401
601
  const row = this.db.prepare(`
402
- SELECT MIN(created_at) AS created_at FROM deliveries WHERE state IN ('queued','retry_wait')
602
+ SELECT MIN(created_at) AS created_at FROM deliveries
603
+ WHERE state IN ('queued','retry_wait') OR (state='submission_started' AND reconciliation_pending=1)
403
604
  `).get();
404
605
  return row.created_at ? new Date(Date.parse(row.created_at) + DURABLE_DELIVERY_MAX_AGE_MS).toISOString() : null;
405
606
  }
@@ -414,23 +615,17 @@ export class DeliveryOutbox extends EventEmitter {
414
615
  recoverForBoot(managerBootId) {
415
616
  const now = new Date().toISOString();
416
617
  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
618
  const queued = this.db.prepare(`
421
619
  UPDATE deliveries SET state='queued',manager_boot_id=NULL,target_daemon_boot_id=NULL,next_attempt_at=NULL,updated_at=?
422
620
  WHERE manager_boot_id IS NOT NULL AND manager_boot_id<>? AND state IN ('delivering','retry_wait')
423
621
  `).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'
622
+ // Keep the submission fence and target lane while bounded pane/transcript
623
+ // evidence is captured. Never replay an in-flight row blindly.
624
+ const reconciliationPending = this.db.prepare(`
625
+ UPDATE deliveries SET reconciliation_pending=1,updated_at=?,last_error='process restarted during submission; reconciliation required'
428
626
  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 };
627
+ `).run(now, managerBootId).changes;
628
+ return { queued, reconciliationPending };
434
629
  });
435
630
  return transaction();
436
631
  }
@@ -491,34 +686,101 @@ export class DeliveryOutbox extends EventEmitter {
491
686
  recoverTargetGeneration(targetInstance, currentTargetBootId) {
492
687
  const now = new Date().toISOString();
493
688
  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
689
  const queued = this.db.prepare(`
499
690
  UPDATE deliveries SET state='queued',manager_boot_id=NULL,target_daemon_boot_id=NULL,next_attempt_at=NULL,updated_at=?
500
691
  WHERE target_instance=? AND target_daemon_boot_id IS NOT NULL AND target_daemon_boot_id<>?
501
692
  AND state IN ('delivering','retry_wait')
502
693
  `).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'
694
+ const reconciliationPending = this.db.prepare(`
695
+ UPDATE deliveries SET reconciliation_pending=1,updated_at=?,last_error='target daemon generation changed during submission; reconciliation required'
505
696
  WHERE target_instance=? AND target_daemon_boot_id IS NOT NULL AND target_daemon_boot_id<>?
506
697
  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 };
698
+ `).run(now, targetInstance, currentTargetBootId).changes;
699
+ return { queued, reconciliationPending };
512
700
  });
513
701
  const result = transaction();
514
- if (result.queued + result.uncertain > 0)
702
+ if (result.queued + result.reconciliationPending > 0)
515
703
  this.emit("generation_recovered", { targetInstance, ...result });
516
704
  return result;
517
705
  }
706
+ /** Fence in-flight writes before an in-process target stop begins. */
707
+ markTargetReconciliationPending(targetInstance, targetBootId) {
708
+ const now = new Date().toISOString();
709
+ return this.db.prepare(`
710
+ UPDATE deliveries SET reconciliation_pending=1,updated_at=?,last_error='target daemon stopping; reconciliation required'
711
+ WHERE target_instance=? AND target_daemon_boot_id=? AND state='submission_started'
712
+ `).run(now, targetInstance, targetBootId).changes;
713
+ }
714
+ getReconciliationCandidates(targetInstance) {
715
+ const rows = this.db.prepare(`
716
+ SELECT d.*, a.target_daemon_boot_id AS attempt_target_boot_id,
717
+ a.attempt_no AS attempt_attempt_no, a.backend AS attempt_backend,
718
+ a.window_id AS attempt_window_id, a.transcript_path AS attempt_transcript_path,
719
+ a.transcript_offset AS attempt_transcript_offset,
720
+ a.transcript_session_id AS attempt_transcript_session_id,
721
+ a.submission_mode AS attempt_submission_mode, a.backend_version AS attempt_backend_version,
722
+ a.queue_resume_policy AS attempt_queue_resume_policy,
723
+ a.enter_started_at AS attempt_enter_started_at
724
+ FROM deliveries d JOIN delivery_attempts a ON a.delivery_id=d.delivery_id
725
+ AND a.attempt_no=d.attempt_no AND a.target_daemon_boot_id=d.target_daemon_boot_id
726
+ WHERE d.target_instance=? AND d.state='submission_started' AND d.reconciliation_pending=1
727
+ ORDER BY d.created_seq
728
+ `).all(targetInstance);
729
+ return rows.map(row => ({
730
+ ...mapRow(row),
731
+ attempt: {
732
+ targetDaemonBootId: row.attempt_target_boot_id,
733
+ attemptNo: row.attempt_attempt_no,
734
+ backend: row.attempt_backend,
735
+ backendVersion: row.attempt_backend_version,
736
+ windowId: row.attempt_window_id,
737
+ transcriptPath: row.attempt_transcript_path,
738
+ transcriptOffset: row.attempt_transcript_offset,
739
+ transcriptSessionId: row.attempt_transcript_session_id,
740
+ submissionMode: row.attempt_submission_mode,
741
+ queueResumePolicy: row.attempt_queue_resume_policy,
742
+ enterStartedAt: row.attempt_enter_started_at,
743
+ },
744
+ }));
745
+ }
746
+ /** Atomically settle one old attempt using only its boot/attempt fence. */
747
+ reconcileAttempt(deliveryId, targetBootId, attemptNo, outcome, evidence) {
748
+ const now = new Date().toISOString();
749
+ const transaction = this.db.transaction(() => {
750
+ const parent = this.db.prepare("SELECT * FROM deliveries WHERE delivery_id=?").get(deliveryId);
751
+ if (!parent || parent.state !== "submission_started" || parent.target_daemon_boot_id !== targetBootId
752
+ || parent.attempt_no !== attemptNo || parent.reconciliation_pending !== 1)
753
+ return false;
754
+ const terminal = outcome !== "retry_wait";
755
+ const changed = this.db.prepare(`
756
+ UPDATE deliveries SET state=?,reconciliation_pending=0,manager_boot_id=?,
757
+ target_daemon_boot_id=?,updated_at=?,finished_at=?,last_error=?,next_attempt_at=?
758
+ WHERE delivery_id=? AND state='submission_started' AND reconciliation_pending=1
759
+ AND target_daemon_boot_id=? AND attempt_no=?
760
+ `).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;
761
+ if (changed !== 1)
762
+ return false;
763
+ const attemptState = outcome === "retry_wait" ? "aborted" : outcome;
764
+ this.db.prepare(`
765
+ UPDATE delivery_attempts SET state=?,abort_at=?,finished_at=?,evidence=?
766
+ WHERE delivery_id=? AND target_daemon_boot_id=? AND attempt_no=? AND state='begun'
767
+ `).run(attemptState, outcome === "retry_wait" ? now : null, terminal ? now : null, evidence.slice(0, 300), deliveryId, targetBootId, attemptNo);
768
+ if (outcome === "uncertain")
769
+ this.insertFailureNotice(parent, "uncertain", evidence, now);
770
+ return true;
771
+ });
772
+ const changed = transaction();
773
+ if (changed)
774
+ this.emit("state", { deliveryId, state: outcome });
775
+ return changed;
776
+ }
518
777
  getUnansweredAccepted(sourceInstance, currentBootId) {
519
778
  const rows = this.db.prepare(`
520
779
  SELECT * FROM deliveries
521
780
  WHERE source_instance=? AND source_daemon_boot_id<>? AND response_delivered_at IS NULL
781
+ -- Scheduler admission has no waiting source-agent tool response; its
782
+ -- stable run key and durable row are the recovery/lookup contract.
783
+ AND kind NOT IN ('raw_paste')
522
784
  ORDER BY created_seq
523
785
  `).all(sourceInstance, currentBootId);
524
786
  return rows.map(mapRow);