mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -6,7 +6,7 @@ import { createHash, randomBytes } from 'crypto';
6
6
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
7
7
  import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
8
8
  import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
9
- import { loadSession, saveSessionAsync } from '../store.mjs';
9
+ import { loadSession, readSessionLifecycleStateFromDisk, saveSessionAsync } from '../store.mjs';
10
10
  import { isDeliveredCompletion, logDuplicateSkip } from './delivered-completions.mjs';
11
11
 
12
12
  const _sessionPendingMessages = new Map();
@@ -40,7 +40,128 @@ const _pendingPersistTails = new Map();
40
40
  const _inDeliveryPendingIds = new Map();
41
41
  const _ackedPendingIds = new Map();
42
42
  const _pendingHydrations = new Map();
43
+ // Claim ledger for the in-delivery ids: the exact entries a drain handed to a
44
+ // turn. A drain consumes the not-yet-flushed persist buffer, so without this
45
+ // copy a failed turn could release ids whose payload exists nowhere (memory,
46
+ // spool, replay) any more. Claims are dropped on ack (delivered) or on
47
+ // release (restored below). A release may only restore while the session
48
+ // record still accepts pending state — a tombstoned session refuses it however
49
+ // old the claim is.
50
+ const _claimedPendingMessages = new Map();
43
51
  let _pendingPersistImmediate = null;
52
+ // Lifecycle token stamped on the exact entry object a drain/enqueue observed.
53
+ // Symbol-keyed so it never reaches the spool (JSON.stringify ignores symbols)
54
+ // and never changes the entry's public shape.
55
+ const LIFECYCLE_TOKEN = Symbol('pendingLifecycleToken');
56
+ // Deterministic test seams for the check→mutate windows (close/detach racing
57
+ // an enqueue publish, a spool commit, or a multi-step hydrate cleanup).
58
+ let _pendingTestHooks = null;
59
+ function runPendingTestHook(name, payload) {
60
+ const hook = _pendingTestHooks?.[name];
61
+ if (typeof hook !== 'function') return;
62
+ try { hook(payload); } catch { /* test seam is best-effort */ }
63
+ }
64
+
65
+ // Authoritative pending-state lifecycle gate. The DURABLE record is the only
66
+ // authority: readSessionLifecycleStateFromDisk reports absence / open / closed
67
+ // / unreadable straight from disk, bypassing every live/pending snapshot, so a
68
+ // stale open live copy (e.g. an id under _droppedSaveIds) can never mask a
69
+ // tombstone. Only an explicit create/reopen/new-generation record — never an
70
+ // ordinary enqueue — makes a session writable again.
71
+ // 'absent' → never saved: open and writable (generation 0)
72
+ // 'open' → durable record at this generation
73
+ // 'closed' → tombstone: refuses every write
74
+ // 'unreadable' → IO error / malformed / foreign record: FAIL CLOSED, and a
75
+ // live snapshot may only ADD refusal, never open it
76
+ function pendingSessionLifecycle(sessionId) {
77
+ let disk = null;
78
+ try { disk = readSessionLifecycleStateFromDisk(sessionId); } catch { disk = null; }
79
+ const state = disk?.state;
80
+ if (state === 'open' || state === 'closed') {
81
+ return { source: state, closed: state === 'closed', generation: Number(disk.generation) || 0 };
82
+ }
83
+ if (state === 'absent') return { source: 'absent', closed: false, generation: 0 };
84
+ // Unreadable/corrupt/foreign (or the read itself threw): never writable.
85
+ return { source: 'unreadable', closed: true, generation: 0 };
86
+ }
87
+
88
+ // Epoch token: the exact lifecycle a claim/enqueue/hydration was taken under.
89
+ function pendingLifecycleToken(lifecycle) {
90
+ return `${lifecycle.source}:${lifecycle.generation}`;
91
+ }
92
+
93
+ function currentPendingLifecycleToken(sessionId) {
94
+ return pendingLifecycleToken(pendingSessionLifecycle(sessionId));
95
+ }
96
+
97
+ function stampLifecycleToken(entry, token) {
98
+ if (!entry || typeof entry !== 'object' || !token) return entry;
99
+ try {
100
+ Object.defineProperty(entry, LIFECYCLE_TOKEN, {
101
+ value: token, enumerable: false, configurable: true, writable: true,
102
+ });
103
+ } catch { /* frozen entry: the claim map still carries the token */ }
104
+ return entry;
105
+ }
106
+
107
+ function entryLifecycleToken(entry) {
108
+ const token = entry && typeof entry === 'object' ? entry[LIFECYCLE_TOKEN] : null;
109
+ return typeof token === 'string' && token ? token : null;
110
+ }
111
+
112
+ // Clone/normalize boundary helper. Every normalized copy of an accepted entry
113
+ // MUST inherit the source entry's IMMUTABLE lifecycle token: a copy that loses
114
+ // it is later restamped with whatever epoch happens to be current, which
115
+ // legitimizes an old-generation entry (drain → claim → release/ack against a
116
+ // reopened owner). Symbols are dropped by object spread and JSON, so every
117
+ // constructor of a derived entry routes through here.
118
+ function carryLifecycleToken(target, source) {
119
+ const token = entryLifecycleToken(source);
120
+ return token ? stampLifecycleToken(target, token) : target;
121
+ }
122
+
123
+ // Pure epoch comparison against an already-read lifecycle (no IO), so a caller
124
+ // that judges many entries reads the durable record once.
125
+ function lifecycleTokenStale(now, sinceToken) {
126
+ if (sinceToken === null || sinceToken === undefined) return false;
127
+ const separator = String(sinceToken).indexOf(':');
128
+ const sinceSource = String(sinceToken).slice(0, separator);
129
+ const sinceGeneration = Number(String(sinceToken).slice(separator + 1)) || 0;
130
+ // Only close/detach moves the durable generation, so a differing one means
131
+ // the session was closed/detached/reopened since: refuse.
132
+ if (now.generation !== sinceGeneration) return true;
133
+ // A record that was DURABLE at claim/hydration start must still be durable:
134
+ // an unreadable record must never be mistaken for an open session. The
135
+ // reverse (never-saved → first durable save at the same generation) is the
136
+ // ordinary lifecycle of a fresh session and stays valid.
137
+ if (sinceSource === 'open' && now.source !== 'open') return true;
138
+ return false;
139
+ }
140
+
141
+ // True when pending state for this session must NOT be (re)created/published:
142
+ // tombstoned, or the durable lifecycle epoch moved since `sinceToken`.
143
+ function pendingLifecycleInvalidated(sessionId, sinceToken = null) {
144
+ const now = pendingSessionLifecycle(sessionId);
145
+ if (now.closed) return true;
146
+ return lifecycleTokenStale(now, sinceToken);
147
+ }
148
+
149
+ // Epoch check for DESTRUCTIVE spool/ledger mutations (ack, prune, clear,
150
+ // foreign drain). Deliberately tombstone-TOLERANT: deleting the rows of the
151
+ // epoch that just closed is legitimate (the close path itself does it), while
152
+ // a generation move — the reopened/detached owner taking over — means those
153
+ // rows are no longer ours to touch. Callers re-evaluate this INSIDE the spool
154
+ // transaction, immediately before mutating.
155
+ function pendingLifecycleEpochMoved(sessionId, sinceToken = null) {
156
+ if (sinceToken === null || sinceToken === undefined) return false;
157
+ const now = pendingSessionLifecycle(sessionId);
158
+ const separator = String(sinceToken).indexOf(':');
159
+ const sinceGeneration = Number(String(sinceToken).slice(separator + 1)) || 0;
160
+ if (now.generation !== sinceGeneration) return true;
161
+ // An unreadable/foreign record is never authority for a deletion.
162
+ if (now.source === 'unreadable') return true;
163
+ return false;
164
+ }
44
165
 
45
166
  function pendingIdSet(map, sessionId) {
46
167
  let ids = map.get(sessionId);
@@ -228,7 +349,8 @@ function normalizePendingMessageEntry(entry) {
228
349
  out = fallback ? { content: fallback, text: text || fallback } : null;
229
350
  }
230
351
  if (!out) return null;
231
- return marker ? { ...out, ...identity, ...marker } : { ...out, ...identity };
352
+ // The lifecycle token rides along every derived copy (see carryLifecycleToken).
353
+ return carryLifecycleToken(marker ? { ...out, ...identity, ...marker } : { ...out, ...identity }, entry);
232
354
  }
233
355
 
234
356
  function pendingMessageText(entry) {
@@ -247,7 +369,7 @@ function pendingMessageQueueEntry(entry) {
247
369
  ? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: normalized.enqueuedAt }
248
370
  : null;
249
371
  const base = { ...identity, content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
250
- return marker ? { ...base, ...marker } : base;
372
+ return carryLifecycleToken(marker ? { ...base, ...marker } : base, entry);
251
373
  }
252
374
 
253
375
  // Canonical persisted-queue entry: a plain string for user/steering messages,
@@ -285,8 +407,16 @@ function normalizePersistedEntry(entry, options = {}) {
285
407
 
286
408
  function persistPendingMessages(sessionId, messages) {
287
409
  if (!isValidPendingSessionId(sessionId)) return 0;
288
- const persistedMessages = (Array.isArray(messages) ? messages : [messages])
289
- .map(normalizePersistedEntry)
410
+ const sourceMessages = Array.isArray(messages) ? messages : [messages];
411
+ const persistedMessages = sourceMessages
412
+ .map((entry) => {
413
+ const normalized = normalizePersistedEntry(entry);
414
+ if (!normalized) return null;
415
+ // Carry the lifecycle token observed when this entry was accepted
416
+ // so the commit below can drop it if the session moved on.
417
+ const token = entryLifecycleToken(entry);
418
+ return token ? stampLifecycleToken(normalized, token) : normalized;
419
+ })
290
420
  .filter(Boolean);
291
421
  if (persistedMessages.length === 0) return 0;
292
422
  // Async lock wait: this runs on the lead/TUI main process (tool-exec +
@@ -295,9 +425,27 @@ function persistPendingMessages(sessionId, messages) {
295
425
  // Best-effort: the returned promise is fire-and-forget; depth is reported
296
426
  // optimistically from the buffered batch length.
297
427
  const operation = updateJsonAtomic(pendingMessagesPath(), (raw) => {
428
+ // Durable commit window: re-read the lifecycle INSIDE the spool lock.
429
+ // A cross-process close/detach between acceptance and this commit must
430
+ // drop the old-generation input without touching the new owner's rows.
431
+ runPendingTestHook('persist:beforeCommit', { sessionId });
432
+ const committable = persistedMessages.filter((entry) => !pendingLifecycleInvalidated(
433
+ sessionId,
434
+ entryLifecycleToken(entry),
435
+ ));
436
+ if (committable.length === 0) return undefined;
298
437
  const next = normalizePendingStore(raw);
299
438
  const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
300
- q.push(...persistedMessages);
439
+ // Idempotent by id: a restored claim (releasePendingMessages) may be
440
+ // durable already, and re-appending it would leave two spool rows for
441
+ // one queued input (double replay after a restart).
442
+ const existingIds = new Set(q.map(pendingMessageId).filter(Boolean));
443
+ const additions = committable.filter((entry) => {
444
+ const id = pendingMessageId(entry);
445
+ return !id || !existingIds.has(id);
446
+ });
447
+ if (additions.length === 0) return undefined;
448
+ q.push(...additions);
301
449
  next.sessions[sessionId] = q;
302
450
  const now = Date.now();
303
451
  next.updatedAt = now;
@@ -340,6 +488,8 @@ function schedulePendingMessagePersist(sessionId, message) {
340
488
  if (!isValidPendingSessionId(sessionId)) return 0;
341
489
  const persistedMessage = normalizePersistedEntry(message);
342
490
  if (!persistedMessage) return 0;
491
+ const token = entryLifecycleToken(message);
492
+ if (token) stampLifecycleToken(persistedMessage, token);
343
493
  const q = _pendingPersistBuffers.get(sessionId) || [];
344
494
  q.push(persistedMessage);
345
495
  _pendingPersistBuffers.set(sessionId, q);
@@ -360,13 +510,75 @@ function takeBufferedPendingMessages(sessionId) {
360
510
  return buffered.slice();
361
511
  }
362
512
 
363
- export function acknowledgePendingMessages(sessionId, deliveredEntries) {
364
- const ids = new Set((Array.isArray(deliveredEntries) ? deliveredEntries : [])
513
+ function claimPendingEntries(sessionId, entries) {
514
+ if (!Array.isArray(entries) || entries.length === 0) return;
515
+ const currentToken = currentPendingLifecycleToken(sessionId);
516
+ const prior = _claimedPendingMessages.get(sessionId);
517
+ // Claims are stamped with the lifecycle epoch observed at claim time; a
518
+ // claim from an older epoch is stale and never merges into the new one.
519
+ const claims = prior && prior.token === currentToken ? prior : { token: currentToken, entries: new Map() };
520
+ for (const entry of entries) {
521
+ const id = pendingMessageId(entry);
522
+ if (!id) continue;
523
+ // The token also rides on the delivered entry OBJECT: a later claim or
524
+ // ack may replace/remove this session's map state, and the old release
525
+ // must still judge itself by the epoch it was handed.
526
+ //
527
+ // NEVER restamp: the token an entry was ACCEPTED under is immutable.
528
+ // Overwriting it with the epoch current at claim time was exactly how
529
+ // a generation-0 entry that survived a generation-1 detach got
530
+ // legitimized (and then restored by a failed turn). Entries reaching
531
+ // here without a token were never stamped (legacy/foreign paths) and
532
+ // are claimed under the epoch observed now.
533
+ if (!entryLifecycleToken(entry)) stampLifecycleToken(entry, currentToken);
534
+ claims.entries.set(id, entry);
535
+ }
536
+ if (claims.entries.size > 0) _claimedPendingMessages.set(sessionId, claims);
537
+ }
538
+
539
+ function dropPendingClaims(sessionId, ids) {
540
+ const claims = _claimedPendingMessages.get(sessionId);
541
+ if (!claims) return;
542
+ for (const id of ids) claims.entries.delete(id);
543
+ if (claims.entries.size === 0) _claimedPendingMessages.delete(sessionId);
544
+ }
545
+
546
+ function pendingIdStillQueued(sessionId, id) {
547
+ for (const map of [_sessionPendingMessages, _pendingPersistBuffers, _hydratedPendingMessages]) {
548
+ const q = map.get(sessionId);
549
+ if (Array.isArray(q) && q.some((entry) => pendingMessageId(entry) === id)) return true;
550
+ }
551
+ return false;
552
+ }
553
+
554
+ export function acknowledgePendingMessages(sessionId, deliveredEntries, options = {}) {
555
+ const entries = Array.isArray(deliveredEntries) ? deliveredEntries : [];
556
+ // Expected lifecycle epoch of THIS acknowledgement: the immutable token of
557
+ // the delivered entries (explicit option for spool-sourced entries that
558
+ // never carried one), falling back to the epoch observed right now.
559
+ const explicitToken = typeof options?.expectedToken === 'string' && options.expectedToken
560
+ ? options.expectedToken
561
+ : null;
562
+ const expectedToken = explicitToken
563
+ || entries.map(entryLifecycleToken).find(Boolean)
564
+ || currentPendingLifecycleToken(sessionId);
565
+ // An entry from a DIFFERENT epoch is not part of this ack: its rows belong
566
+ // to another owner and must not be deleted by this transaction.
567
+ const ids = new Set(entries
568
+ .filter((entry) => {
569
+ const token = entryLifecycleToken(entry);
570
+ return !token || token === expectedToken;
571
+ })
365
572
  .map(pendingMessageId).filter(Boolean));
366
573
  if (ids.size === 0) return Promise.resolve(false);
574
+ // The epoch already moved before we touched anything: leave the reopened
575
+ // owner's memory, ledger and spool rows exactly as they are.
576
+ if (pendingLifecycleEpochMoved(sessionId, expectedToken)) return Promise.resolve(false);
367
577
  const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
368
578
  const acked = pendingIdSet(_ackedPendingIds, sessionId);
369
579
  for (const id of ids) { inDelivery.delete(id); acked.add(id); }
580
+ // Delivered for good: the claim copy is no longer a recovery source.
581
+ dropPendingClaims(sessionId, ids);
370
582
  const purgeMemory = () => {
371
583
  for (const map of [_pendingPersistBuffers, _sessionPendingMessages, _hydratedPendingMessages]) {
372
584
  const q = map.get(sessionId);
@@ -377,11 +589,22 @@ export function acknowledgePendingMessages(sessionId, deliveredEntries) {
377
589
  }
378
590
  };
379
591
  purgeMemory();
592
+ let epochMoved = false;
380
593
  const precedingPersist = _pendingPersistTails.get(sessionId) || Promise.resolve();
381
594
  const operation = precedingPersist.catch(() => {}).then(() => {
382
595
  // A failed preceding persist may have requeued after the first purge.
383
- purgeMemory();
596
+ // Skipped once the epoch moved: those queue entries are the new
597
+ // owner's (a hydrate can republish the very same durable ids).
598
+ if (!pendingLifecycleEpochMoved(sessionId, expectedToken)) purgeMemory();
384
599
  return updateJsonAtomic(pendingMessagesPath(), (raw) => {
600
+ // Atomic re-validation: we now hold the spool lock. A generation
601
+ // movement while we waited means the rows are the reopened owner's —
602
+ // delete nothing and report failure so no ledger prune follows.
603
+ runPendingTestHook('ack:beforeCommit', { sessionId });
604
+ if (pendingLifecycleEpochMoved(sessionId, expectedToken)) {
605
+ epochMoved = true;
606
+ return undefined;
607
+ }
385
608
  const next = normalizePendingStore(raw);
386
609
  const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
387
610
  const kept = q.filter((entry) => !ids.has(pendingMessageId(entry)));
@@ -397,11 +620,20 @@ export function acknowledgePendingMessages(sessionId, deliveredEntries) {
397
620
  }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
398
621
  });
399
622
  _pendingPersistTails.set(sessionId, operation);
400
- const reported = operation.then(() => true).catch((err) => {
623
+ const reported = operation.then(() => !epochMoved).catch((err) => {
401
624
  try { process.stderr.write(`[session] pending-message ack failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
402
625
  return false;
403
626
  }).then((ok) => {
404
627
  if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
628
+ // Refused because the epoch moved: this ack never happened. Drop the
629
+ // acked-id suppression again — those durable rows now belong to the
630
+ // reopened owner and its hydrate must be able to take them.
631
+ if (epochMoved) {
632
+ const movedAcked = pendingIdSet(_ackedPendingIds, sessionId);
633
+ for (const id of ids) movedAcked.delete(id);
634
+ if (movedAcked.size === 0) _ackedPendingIds.delete(sessionId);
635
+ return false;
636
+ }
405
637
  // Keep the acked ids when the spool cleanup FAILED: the durable entry
406
638
  // still exists, and forgetting that it was already delivered let the
407
639
  // foreign-injection drain re-take it as a "new" cross-surface submit
@@ -429,10 +661,16 @@ export function recordPendingMessageDelivery(session, deliveredEntries) {
429
661
  session.deliveredPendingMessageIds = [...new Set([...ledger, ...added])];
430
662
  }
431
663
 
432
- async function pruneCleanupConfirmedLedger(sessionId, confirmedEntries, session = null, persist = null) {
664
+ async function pruneCleanupConfirmedLedger(sessionId, confirmedEntries, session = null, persist = null, expectedToken = null) {
433
665
  const confirmedIds = new Set((Array.isArray(confirmedEntries) ? confirmedEntries : [])
434
666
  .map(pendingMessageId).filter(Boolean));
435
667
  if (confirmedIds.size === 0) return false;
668
+ // Same atomic ownership rule as the spool ack: the ledger of a session
669
+ // whose durable epoch moved belongs to the reopened owner.
670
+ const token = expectedToken
671
+ || (Array.isArray(confirmedEntries) ? confirmedEntries.map(entryLifecycleToken).find(Boolean) : null)
672
+ || null;
673
+ if (token && pendingLifecycleEpochMoved(sessionId, token)) return false;
436
674
  const target = session || loadSession(sessionId);
437
675
  if (!target) return false;
438
676
  const ledger = Array.isArray(target.deliveredPendingMessageIds)
@@ -443,6 +681,9 @@ async function pruneCleanupConfirmedLedger(sessionId, confirmedEntries, session
443
681
  // Confirmed IDs need no replay protection and are removed immediately
444
682
  // (therefore bounded below any finite confirmed-ID retention cap).
445
683
  // Unconfirmed IDs are never size-evicted.
684
+ // Re-checked immediately before the mutation: the load above is an await
685
+ // boundary for the caller's chain.
686
+ if (token && pendingLifecycleEpochMoved(sessionId, token)) return false;
446
687
  target.deliveredPendingMessageIds = kept;
447
688
  if (typeof persist === 'function') await persist();
448
689
  else await saveSessionAsync(target, { expectedGeneration: target.generation });
@@ -470,10 +711,47 @@ export function finalizePendingMessageDelivery(session, deliveredEntries, durabl
470
711
 
471
712
  export function releasePendingMessages(sessionId, deliveredEntries) {
472
713
  const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
714
+ const acked = pendingIdSet(_ackedPendingIds, sessionId);
715
+ const claim = _claimedPendingMessages.get(sessionId);
716
+ // Restoring is a WRITE of pending state: allowed only while the DURABLE
717
+ // lifecycle is still open AND still the exact epoch THIS delivery was
718
+ // claimed under. The token is read from the released entry itself and only
719
+ // falls back to the map when the two agree — a newer claim/ack that
720
+ // replaced or removed the map state can never re-open an old release.
721
+ const claimToken = (entry) => entryLifecycleToken(entry)
722
+ || (claim && claim.entries.has(pendingMessageId(entry)) ? claim.token : null);
723
+ const releasedIds = [];
724
+ const restored = [];
473
725
  for (const entry of Array.isArray(deliveredEntries) ? deliveredEntries : []) {
474
726
  const id = pendingMessageId(entry);
475
- if (id) inDelivery.delete(id);
727
+ if (!id) continue;
728
+ inDelivery.delete(id);
729
+ releasedIds.push(id);
730
+ // The turn did NOT deliver this input. Restore the claimed entry so it
731
+ // is queued (and durable) again: the drain that claimed it may have
732
+ // consumed the not-yet-flushed persist buffer, in which case clearing
733
+ // the id alone would lose the message from memory, spool and replay.
734
+ if (acked.has(id)) continue;
735
+ const token = claimToken(entry);
736
+ // No token at all (never claimed through a drain) → fail closed.
737
+ if (!token || pendingLifecycleInvalidated(sessionId, token)) continue;
738
+ if (pendingIdStillQueued(sessionId, id)) continue;
739
+ const claimed = claim?.entries.get(id) || pendingMessageQueueEntry(entry);
740
+ if (claimed) restored.push(stampLifecycleToken(claimed, token));
476
741
  }
742
+ if (restored.length > 0) {
743
+ const q = _sessionPendingMessages.get(sessionId) || [];
744
+ q.push(...restored);
745
+ // FIFO by original enqueue time — a restored entry predates anything
746
+ // queued while the failed turn was running.
747
+ q.sort((a, b) => (Number(a?.enqueuedAt) || 0) - (Number(b?.enqueuedAt) || 0));
748
+ _sessionPendingMessages.set(sessionId, q);
749
+ // Durability: persistPendingMessages is id-idempotent, so an entry that
750
+ // was already flushed keeps its single spool row while one that never
751
+ // reached disk gets written now (restart replays it exactly once).
752
+ for (const entry of restored) schedulePendingMessagePersist(sessionId, entry);
753
+ }
754
+ dropPendingClaims(sessionId, releasedIds);
477
755
  if (inDelivery.size === 0) _inDeliveryPendingIds.delete(sessionId);
478
756
  }
479
757
 
@@ -489,6 +767,15 @@ export function hydratePendingMessages(sessionId, options = {}) {
489
767
  let staleLedgerEntries = [];
490
768
  const staleUserEntries = [];
491
769
  const ledgerSession = loadSession(sessionId);
770
+ // Durable lifecycle epoch this hydration started under. Publishing claims the
771
+ // durable entries into THIS process's memory, so it must be revalidated
772
+ // after every await (spool IO, test beforePublish, ack/prune): a close
773
+ // (tombstone) or a close/detach generation bump in between means the
774
+ // entries belong to the next owner, not to us. Aborting publishes
775
+ // nothing and leaves the durable rows untouched for that owner.
776
+ const startLifecycle = pendingSessionLifecycle(sessionId);
777
+ const startToken = pendingLifecycleToken(startLifecycle);
778
+ if (startLifecycle.closed) return 0;
492
779
  try {
493
780
  const deliveredLedger = new Set(ledgerSession?.deliveredPendingMessageIds || []);
494
781
  const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
@@ -524,35 +811,51 @@ export function hydratePendingMessages(sessionId, options = {}) {
524
811
  // acknowledges these exact ids. A crash here therefore redelivers.
525
812
  return undefined;
526
813
  }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
814
+ if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
527
815
  await options.beforePublish?.(hydrated);
816
+ if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
528
817
  } catch (err) {
529
818
  try { process.stderr.write(`[session] pending-message hydrate failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
530
819
  return 0;
531
820
  }
532
821
  const cleanupConfirmed = staleLedgerEntries.slice();
533
822
  if (alreadyDelivered.length > 0) {
534
- const cleaned = await acknowledgePendingMessages(sessionId, alreadyDelivered);
823
+ const cleaned = await acknowledgePendingMessages(sessionId, alreadyDelivered, { expectedToken: startToken });
535
824
  if (cleaned) cleanupConfirmed.push(...alreadyDelivered);
825
+ // Every individual cleanup await is its own window: stop before the
826
+ // next mutation once the lifecycle moved (the remaining spool rows and
827
+ // the ledger belong to the new owner from here on).
828
+ runPendingTestHook('hydrate:betweenCleanups', { sessionId });
829
+ if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
536
830
  }
537
831
  if (staleUserEntries.length > 0) {
538
832
  try { process.stderr.write(`[session] dropped ${staleUserEntries.length} stale queued message(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
539
- const cleaned = await acknowledgePendingMessages(sessionId, staleUserEntries);
833
+ const cleaned = await acknowledgePendingMessages(sessionId, staleUserEntries, { expectedToken: startToken });
540
834
  if (cleaned) cleanupConfirmed.push(...staleUserEntries);
835
+ runPendingTestHook('hydrate:betweenCleanups', { sessionId });
836
+ if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
541
837
  }
542
838
  if (cleanupConfirmed.length > 0) {
543
839
  try {
544
840
  // One session save prunes both IDs whose replay spool was removed
545
841
  // now and IDs whose spool was already absent at hydration start.
546
- await pruneCleanupConfirmedLedger(sessionId, cleanupConfirmed, ledgerSession);
842
+ await pruneCleanupConfirmedLedger(sessionId, cleanupConfirmed, ledgerSession, null, startToken);
547
843
  } catch (err) {
548
844
  try { process.stderr.write(`[session] pending-message ledger prune failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
549
845
  }
846
+ if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
550
847
  }
551
848
  if (hydrated.length > 0) {
849
+ // Last revalidation: the ack/prune awaits above are another window.
850
+ if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
552
851
  const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
553
852
  for (const entry of hydrated) {
554
853
  const id = pendingMessageId(entry);
555
854
  if (id) inDelivery.add(id);
855
+ // Durable rows carry no symbol: stamp the epoch this hydration was
856
+ // validated under, so every later clone/claim/ack judges itself by
857
+ // that immutable token instead of the epoch current at the time.
858
+ stampLifecycleToken(entry, startToken);
556
859
  }
557
860
  const existing = _hydratedPendingMessages.get(sessionId) || [];
558
861
  existing.push(...hydrated);
@@ -578,7 +881,12 @@ function clearPersistedPendingMessages(sessionId) {
578
881
  // dropped synchronously by _dropPendingMessageState; only the durable
579
882
  // spool row lags behind, exactly like the ack path.
580
883
  const preceding = _pendingPersistTails.get(sessionId) || Promise.resolve();
884
+ // Epoch this clear was ordered under (normally the tombstone that just
885
+ // landed). Re-validated inside the lock: a reopen/detach that moves the
886
+ // generation while this async clear waits must leave the new owner's rows.
887
+ const epochToken = currentPendingLifecycleToken(sessionId);
581
888
  const operation = preceding.catch(() => {}).then(() => updateJsonAtomic(pendingMessagesPath(), (raw) => {
889
+ if (pendingLifecycleEpochMoved(sessionId, epochToken)) return undefined;
582
890
  const next = normalizePendingStore(raw);
583
891
  if (!Object.prototype.hasOwnProperty.call(next.sessions, sessionId)) return undefined;
584
892
  delete next.sessions[sessionId];
@@ -695,6 +1003,17 @@ export function enqueuePendingMessage(sessionId, message) {
695
1003
  // Caller-provided ids are never trusted across sessions/processes.
696
1004
  const entry = normalized ? { ...normalized, id: newPendingMessageId() } : null;
697
1005
  if (!sessionId || !entry) return 0;
1006
+ // A provider/tool completion that lands after the session was tombstoned
1007
+ // must NOT recreate memory/spool state (and never clears the tombstone):
1008
+ // only an explicit create/reopen record makes the session writable again.
1009
+ const token = currentPendingLifecycleToken(sessionId);
1010
+ if (pendingLifecycleInvalidated(sessionId)) return 0;
1011
+ stampLifecycleToken(entry, token);
1012
+ // Re-validate immediately before the memory publish: a close/detach landing
1013
+ // in this window must reject the input instead of recreating state for a
1014
+ // session the new owner already took over.
1015
+ runPendingTestHook('enqueue:beforePublish', { sessionId });
1016
+ if (pendingLifecycleInvalidated(sessionId, token)) return 0;
698
1017
  let q = _sessionPendingMessages.get(sessionId);
699
1018
  if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
700
1019
  q.push(entry);
@@ -712,9 +1031,19 @@ export function enqueuePendingMessage(sessionId, message) {
712
1031
  */
713
1032
  export function enqueueRemotePendingMessage(sessionId, message) {
714
1033
  if (!isValidPendingSessionId(sessionId)) return 0;
1034
+ // Same durable-lifecycle authority as the local enqueue: a cross-surface
1035
+ // submit that lands after the session was closed is refused before any
1036
+ // spool mutation.
1037
+ const token = currentPendingLifecycleToken(sessionId);
1038
+ if (pendingLifecycleInvalidated(sessionId)) return 0;
715
1039
  const normalized = pendingMessageQueueEntry(message);
716
1040
  if (!normalized) return 0;
717
- return persistPendingMessages(sessionId, [{ ...normalized, id: newPendingMessageId() }]);
1041
+ // The token rides into the async spool commit, which revalidates under the
1042
+ // spool lock (see persistPendingMessages).
1043
+ runPendingTestHook('enqueue:beforePublish', { sessionId });
1044
+ if (pendingLifecycleInvalidated(sessionId, token)) return 0;
1045
+ const remoteEntry = stampLifecycleToken({ ...normalized, id: newPendingMessageId() }, token);
1046
+ return persistPendingMessages(sessionId, [remoteEntry]);
718
1047
  }
719
1048
 
720
1049
  // Spool-file mtime gate so the owner's idle poller costs one stat per tick,
@@ -745,10 +1074,18 @@ function _rememberForeignSpoolScan(sessionId, mtime) {
745
1074
  */
746
1075
  export function drainForeignUserInjections(sessionId) {
747
1076
  if (!isValidPendingSessionId(sessionId)) return [];
1077
+ // DELIVERY path, not cleanup: this takes rows out of the spool and hands
1078
+ // them to a live turn. A tombstoned (or otherwise invalidated) session may
1079
+ // neither receive nor lose input, so the tombstone-TOLERANT epoch check
1080
+ // used by the explicit cleanup mutations (ack / clear) is wrong here — the
1081
+ // token is refused outright when it was captured from a closed session.
1082
+ // Checked BEFORE the mtime memo so a later reopen still sees this spool
1083
+ // state as unscanned.
1084
+ const epochToken = currentPendingLifecycleToken(sessionId);
1085
+ if (pendingLifecycleInvalidated(sessionId)) return [];
748
1086
  let mtime = 0;
749
1087
  try { mtime = statSync(pendingMessagesPath()).mtimeMs || 0; } catch { return []; }
750
1088
  if (_foreignSpoolScanMtimes.get(sessionId) === mtime) return [];
751
- _rememberForeignSpoolScan(sessionId, mtime);
752
1089
  const localIds = new Set();
753
1090
  for (const map of [_sessionPendingMessages, _pendingPersistBuffers, _hydratedPendingMessages]) {
754
1091
  for (const entry of map.get(sessionId) || []) {
@@ -770,8 +1107,25 @@ export function drainForeignUserInjections(sessionId) {
770
1107
  } catch { /* ledger unavailable — in-memory sets still guard */ }
771
1108
  const taken = [];
772
1109
  let droppedStale = 0;
1110
+ // The mtime memo may ONLY be armed by a scan that actually reached a
1111
+ // lifecycle-valid decision under the spool lock. Arming it upfront meant a
1112
+ // drain refused in-lock (a close/detach/reopen landing in the window) still
1113
+ // recorded this spool state as "seen": after the reopen the new owner
1114
+ // polled the same unchanged mtime and skipped it, stranding the rows until
1115
+ // some unrelated spool WRITE bumped the file. A refusal must leave the
1116
+ // spool immediately pollable again.
1117
+ let lifecycleDecided = false;
773
1118
  try {
774
1119
  updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
1120
+ // Same authority re-read INSIDE the spool lock: a close/detach
1121
+ // landing in this window must neither deliver to the old owner nor
1122
+ // remove the reopened owner's rows.
1123
+ runPendingTestHook('foreignDrain:beforeCommit', { sessionId });
1124
+ if (pendingLifecycleInvalidated(sessionId, epochToken)) return undefined;
1125
+ // Authoritative from here on: whatever this pass decides (take,
1126
+ // drop-stale, or "nothing foreign here") is a real answer for THIS
1127
+ // spool state, so the memo may skip re-scanning it.
1128
+ lifecycleDecided = true;
775
1129
  const next = normalizePendingStore(raw);
776
1130
  const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
777
1131
  if (q.length === 0) return undefined;
@@ -803,6 +1157,7 @@ export function drainForeignUserInjections(sessionId) {
803
1157
  try { process.stderr.write(`[session] foreign-injection drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
804
1158
  return [];
805
1159
  }
1160
+ if (lifecycleDecided) _rememberForeignSpoolScan(sessionId, mtime);
806
1161
  if (droppedStale > 0) {
807
1162
  try { process.stderr.write(`[session] dropped ${droppedStale} stale foreign submit(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
808
1163
  }
@@ -874,9 +1229,35 @@ export function drainPendingMessages(sessionId) {
874
1229
  const visible = modelVisiblePendingMessages(ordered
875
1230
  .filter(({ entry }) => !dropped.includes(entry))
876
1231
  .map(({ entry }) => entry));
1232
+ // Epoch gate. Normalization above rebuilds every entry, but each copy
1233
+ // inherits the IMMUTABLE token the entry was accepted under. An entry from
1234
+ // an older epoch (a cross-process close/detach moved the generation while
1235
+ // it sat in memory) belongs to the previous owner: it is REJECTED here,
1236
+ // never restamped with the current epoch — which would legitimize it and
1237
+ // let a failed turn restore it into the new owner's queue.
1238
+ const lifecycleNow = pendingSessionLifecycle(sessionId);
1239
+ const accepted = [];
1240
+ let rejected = 0;
1241
+ for (const entry of visible) {
1242
+ const token = entryLifecycleToken(entry);
1243
+ if (token && (lifecycleNow.closed || lifecycleTokenStale(lifecycleNow, token))) {
1244
+ rejected += 1;
1245
+ continue;
1246
+ }
1247
+ accepted.push(entry);
1248
+ }
1249
+ if (rejected > 0) {
1250
+ try {
1251
+ process.stderr.write(
1252
+ `[session] rejected ${rejected} queued message(s) from a superseded lifecycle epoch sessionId=${sessionId}\n`,
1253
+ );
1254
+ } catch { /* best-effort */ }
1255
+ }
877
1256
  const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
878
- for (const entry of visible) if (entry.id) inDelivery.add(entry.id);
879
- return visible;
1257
+ for (const entry of accepted) if (entry.id) inDelivery.add(entry.id);
1258
+ // Keep the exact claimed payloads until ack (delivered) or release.
1259
+ claimPendingEntries(sessionId, accepted);
1260
+ return accepted;
880
1261
  }
881
1262
 
882
1263
  // Snapshot queued entries without draining them. Compaction uses this to keep
@@ -913,6 +1294,12 @@ export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
913
1294
  try { persistPendingMessages(id, buffered); } catch { /* ignore */ }
914
1295
  }
915
1296
  }
1297
+ // Tombstoned close: the claim copies are dead (the session record itself
1298
+ // now refuses any restore). Detach keeps them so a still-in-flight ask can
1299
+ // release and restore its claimed entries after this cleanup runs.
1300
+ if (clearPersisted) {
1301
+ try { _claimedPendingMessages.delete(id); } catch { /* ignore */ }
1302
+ }
916
1303
  try { _sessionPendingMessages.delete(id); } catch { /* ignore */ }
917
1304
  try { _hydratedPendingMessages.delete(id); } catch { /* ignore */ }
918
1305
  try { _pendingPersistBuffers.delete(id); } catch { /* ignore */ }
@@ -924,12 +1311,86 @@ export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
924
1311
  }
925
1312
  }
926
1313
 
1314
+ // TEST-ONLY determinism seam (never called by runtime code): flush this
1315
+ // module's own buffered persist batch and await its own async spool tails
1316
+ // (persist / ack / clear) and in-flight hydrations, so tests can wait for the
1317
+ // asynchronous persistence tail instead of guessing a sleep duration. Tails
1318
+ // can re-chain (an ack chains behind a persist), so this loops until the maps
1319
+ // are empty, bounded by timeoutMs — a genuinely stuck write still fails loudly
1320
+ // instead of hanging forever.
1321
+ export async function _settlePendingMessageWrites({ timeoutMs = 5000 } = {}) {
1322
+ const budget = Math.max(0, Number(timeoutMs) || 0);
1323
+ const deadline = Date.now() + budget;
1324
+ const expired = () => new Error(
1325
+ `pending-message writes did not settle within ${budget}ms `
1326
+ + `(tails=${_pendingPersistTails.size}, hydrations=${_pendingHydrations.size}, buffers=${_pendingPersistBuffers.size})`,
1327
+ );
1328
+ let timer = null;
1329
+ const expiry = new Promise((_resolve, reject) => {
1330
+ timer = setTimeout(() => reject(expired()), Math.max(0, deadline - Date.now()));
1331
+ });
1332
+ // Deliberately NOT unref'd: while settle waits on a stuck tail there may be
1333
+ // no other handle, and an unref'd timer would let the loop drain (the wait
1334
+ // would then never reject). The finally below always clears it.
1335
+ expiry.catch(() => {});
1336
+ try {
1337
+ for (;;) {
1338
+ // Snapshot BEFORE flushing: persistPendingMessages OVERWRITES this
1339
+ // session's map entry with the new operation, so an in-flight tail
1340
+ // captured only after the flush would be silently dropped and never
1341
+ // awaited. Both the pre-flush tails and the newly chained ones must
1342
+ // be awaited, and the loop repeats because acks/clears chain further
1343
+ // tails behind them.
1344
+ const preFlushTails = [..._pendingPersistTails.values()];
1345
+ flushPendingMessagePersistsSync();
1346
+ const inflight = new Set([
1347
+ ...preFlushTails,
1348
+ ..._pendingPersistTails.values(),
1349
+ ..._pendingHydrations.values(),
1350
+ ]);
1351
+ if (inflight.size === 0 && _pendingPersistBuffers.size === 0) return;
1352
+ if (Date.now() > deadline) throw expired();
1353
+ // Raced against the absolute deadline: a never-settling tail must
1354
+ // reject within timeoutMs instead of blocking here forever.
1355
+ await Promise.race([Promise.allSettled([...inflight]), expiry]);
1356
+ await Promise.race([new Promise((resolve) => setImmediate(resolve)), expiry]);
1357
+ }
1358
+ } finally {
1359
+ if (timer) clearTimeout(timer);
1360
+ }
1361
+ }
1362
+
1363
+ // TEST-ONLY: publish a promise as this session's persist tail using the same
1364
+ // set + self-removal pattern as persistPendingMessages, so settle tests can
1365
+ // model a slow (or stuck) spool write deterministically. Runtime code never
1366
+ // calls this and behavior is unchanged.
1367
+ export function _setPendingPersistTailForTest(sessionId, promise) {
1368
+ const operation = Promise.resolve(promise).catch(() => {});
1369
+ _pendingPersistTails.set(sessionId, operation);
1370
+ operation.finally(() => {
1371
+ if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
1372
+ }).catch(() => {});
1373
+ return operation;
1374
+ }
1375
+
1376
+ // TEST-ONLY: deterministic seams for the check→mutate windows. Runtime code
1377
+ // never installs hooks; behavior is unchanged when none are set.
1378
+ // 'enqueue:beforePublish' — between the lifecycle check and the memory /
1379
+ // spool publish of a local/remote enqueue
1380
+ // 'persist:beforeCommit' — inside the spool lock, before the durable
1381
+ // commit re-validates each entry's token
1382
+ // 'ack:beforeCommit' — inside the spool lock, before the ack
1383
+ // re-validates its epoch and deletes rows
1384
+ // 'hydrate:betweenCleanups' — after each acknowledgement/prune await
1385
+ export function _setPendingTestHooks(hooks) {
1386
+ _pendingTestHooks = hooks && typeof hooks === 'object' ? hooks : null;
1387
+ return _pendingTestHooks;
1388
+ }
1389
+
927
1390
  setImmediate(() => {
928
- // Spool hygiene belongs to the lead/daemon process. Worker-mode children
929
- // (channels worker, agent shard) share the same spool file; a boot-time
930
- // SYNC sweep in every child contends on the cross-process lock against
931
- // the lead's writes (observed in the first live shard run: "atomic lock
932
- // timeout after 8000ms: session-pending-messages.json.lock") for zero
1391
+ // Spool hygiene belongs to the lead/daemon process. Channel workers share
1392
+ // the same spool file; a boot-time SYNC sweep in every child contends on
1393
+ // the cross-process lock against the lead's writes for zero
933
1394
  // benefit — the lead already sweeps.
934
1395
  if (process.env.MIXDOG_WORKER_MODE === '1') return;
935
1396
  try { sweepOrphanedPendingMessages(); } catch { /* ignore */ }