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,13 @@
6
6
  // _runtimeState map (owned by manager.mjs) to read the in-memory session and
7
7
  // flag usageMetricsTurnIncremental. manager.mjs wires it via configureUsageMetricsRuntime().
8
8
  import { providerInputExcludesCache } from '../../providers/registry.mjs';
9
- import { loadSession, saveSessionAsync, _saveSessionSync } from '../store.mjs';
9
+ import {
10
+ loadSession,
11
+ saveSessionAsync,
12
+ parkSessionSnapshotForDrain,
13
+ unparkSessionSnapshotForDrain,
14
+ registerSessionPurgeHook,
15
+ } from '../store.mjs';
10
16
 
11
17
  // Per-session idempotency tracking: sessionId → Set of seen
12
18
  // turn:epoch:iteration:source keys.
@@ -24,46 +30,135 @@ const _metricSaveState = new Map();
24
30
  // flush. Only populated for the window between "delta applied, save not yet
25
31
  // posted to the worker" and the actual saveSessionAsync call — once a save
26
32
  // is posted, store.mjs's own _saveWorkerPending covers process-exit drain.
33
+ // While parked, the snapshot is ALSO registered with the store's own deferred
34
+ // map (parkSessionSnapshotForDrain), so the canonical drain — not a second
35
+ // exit hook — persists it under the shared epoch/lock/fence ordering.
27
36
  const _pendingMetricsFlush = new Map();
28
37
  const METRICS_SAVE_THROTTLE_MS = 500;
29
38
 
30
39
  function _metricsSaveState(sessionId) {
31
40
  let state = _metricSaveState.get(sessionId);
41
+ if (state && state.closed) {
42
+ // A CLOSED state belongs to a previous incarnation of this id (hard
43
+ // delete or close). It is never reused — and it must not block a
44
+ // re-created session: drop it and mint a fresh one. The old object
45
+ // stays non-current, so its in-flight callbacks remain inert.
46
+ _metricSaveState.delete(sessionId);
47
+ state = null;
48
+ }
32
49
  if (!state) {
33
- state = { inFlight: false, dirty: false, lastFlushAt: 0, timer: null, closed: false, failCount: 0 };
50
+ state = { inFlight: false, dirty: false, lastFlushAt: 0, timer: null, closed: false, failCount: 0, drainHandle: null };
34
51
  _metricSaveState.set(sessionId, state);
35
52
  }
36
53
  return state;
37
54
  }
38
55
 
39
- function _flushMetricsSave(session, sessionId) {
40
- const state = _metricsSaveState(sessionId);
41
- if (state.closed) return;
56
+ // Park/unpark keep the module-local ref and the store's drain registration in
57
+ // lock-step. The park carries the generation observed when the delta was
58
+ // applied, so a lifecycle barrier landing later still fences it by ownership.
59
+ function _parkMetricsSnapshot(state, sessionId, session) {
60
+ _pendingMetricsFlush.set(sessionId, session);
61
+ state.drainHandle = parkSessionSnapshotForDrain(
62
+ session,
63
+ { expectedGeneration: session.generation },
64
+ state.drainHandle,
65
+ );
66
+ }
67
+
68
+ function _unparkMetricsSnapshot(state, sessionId) {
69
+ // The shared map belongs to whichever state currently OWNS the id: an old
70
+ // (closed) callback must never erase the snapshot a re-created session
71
+ // parked under the same id.
72
+ if (!_metricSaveState.has(sessionId) || _metricSaveState.get(sessionId) === state) {
73
+ _pendingMetricsFlush.delete(sessionId);
74
+ }
75
+ if (!state?.drainHandle) return;
76
+ unparkSessionSnapshotForDrain(state.drainHandle);
77
+ state.drainHandle = null;
78
+ }
79
+
80
+ /**
81
+ * Authoritative liveness of a save-state object. A hard delete (or a close)
82
+ * marks the CURRENT state closed and drops it from the registry, so a callback
83
+ * still holding that object must never re-park, re-arm a timer or re-flush —
84
+ * not even for one microtask, which would be enough to re-create the file the
85
+ * delete just unlinked.
86
+ */
87
+ function _isMetricsStateCurrent(sessionId, state) {
88
+ return !!state && state.closed !== true && _metricSaveState.get(sessionId) === state;
89
+ }
90
+
91
+ function _flushMetricsSave(session, sessionId, state = _metricSaveState.get(sessionId)) {
92
+ if (!_isMetricsStateCurrent(sessionId, state)) {
93
+ // Purged/closed while this flush was queued: leave no trace behind.
94
+ _unparkMetricsSnapshot(state, sessionId);
95
+ return;
96
+ }
42
97
  if (state.timer) { clearTimeout(state.timer); state.timer = null; }
43
- _pendingMetricsFlush.delete(sessionId);
98
+ // The park STAYS for the whole in-flight lifecycle and carries this
99
+ // snapshot's identity: the save — and every retry of it — runs under the
100
+ // epoch and guarded options (expectedGeneration included) assigned when the
101
+ // delta was first deferred. Minting fresh authority on a retry would let a
102
+ // stale payload outrank a session save that landed in the meantime.
103
+ _parkMetricsSnapshot(state, sessionId, session);
104
+ const identity = state.drainHandle;
44
105
  state.dirty = false;
45
106
  state.inFlight = true;
46
107
  state.lastFlushAt = Date.now();
47
- saveSessionAsync(session, { expectedGeneration: session.generation })
108
+ // Set when the store's exit drain settles this write: the drain already
109
+ // wrote the newest snapshot for the id and cleared its deferred handle, so
110
+ // that settlement is a TERMINAL ownership transfer for this identity.
111
+ let drainSettled = false;
112
+ saveSessionAsync(
113
+ session,
114
+ identity?.opts ?? { expectedGeneration: session.generation },
115
+ identity ? { epoch: identity.epoch, revision: identity.revision } : undefined,
116
+ )
48
117
  .then(() => {
118
+ if (!_isMetricsStateCurrent(sessionId, state)) return;
49
119
  state.failCount = 0;
120
+ // Durable — release the identity, but ONLY when no newer delta
121
+ // parked itself behind this write (that snapshot is not durable
122
+ // yet and must stay in the canonical drain).
123
+ if (!state.dirty) _unparkMetricsSnapshot(state, sessionId);
50
124
  })
51
125
  .catch((err) => {
52
126
  process.stderr.write(`[usage-metrics] iteration save failed: ${err?.message ?? err}\n`);
127
+ if (err?.sessionStoreDrained === true) {
128
+ // Drain owns durability now. Re-parking here would register a
129
+ // NEW identity (fresh epoch) for an old payload, which could
130
+ // then overwrite a save that lands after the drain; retrying
131
+ // would do the same. Release the identity and stay inert.
132
+ drainSettled = true;
133
+ state.dirty = false;
134
+ state.failCount = 0;
135
+ _unparkMetricsSnapshot(state, sessionId);
136
+ return;
137
+ }
138
+ // Hard delete / close settled this id while the write was in
139
+ // flight: a re-park here would put a deferred entry (and with it a
140
+ // drain-recreated file) back for a session that no longer exists.
141
+ if (!_isMetricsStateCurrent(sessionId, state)) return;
53
142
  // A rejected save must not silently drop the delta already
54
143
  // applied in-memory — re-park it so the process-exit drain
55
144
  // and/or the retry below still see it.
56
145
  state.failCount += 1;
57
146
  state.dirty = true;
58
- _pendingMetricsFlush.set(sessionId, session);
147
+ // NEVER let the rejected closure overwrite a newer snapshot that
148
+ // parked while this write was in flight: the latest parked ref
149
+ // wins, under the same preserved identity.
150
+ const latest = _pendingMetricsFlush.get(sessionId);
151
+ _parkMetricsSnapshot(state, sessionId, latest ?? session);
59
152
  })
60
153
  .finally(() => {
61
154
  state.inFlight = false;
62
- if (state.closed) {
63
- // dropMetricSeenState ran while this save was in flight —
64
- // do not resurrect per-session state/timers post-close.
65
- _metricSaveState.delete(sessionId);
66
- _pendingMetricsFlush.delete(sessionId);
155
+ if (drainSettled) return; // terminal: no repark, no retry, no timer
156
+ if (!_isMetricsStateCurrent(sessionId, state)) {
157
+ // dropMetricSeenState (close) or the hard-delete purge ran
158
+ // while this save was in flight — do not resurrect per-session
159
+ // state, timers or deferred entries.
160
+ _unparkMetricsSnapshot(state, sessionId);
161
+ if (_metricSaveState.get(sessionId) === state) _metricSaveState.delete(sessionId);
67
162
  return;
68
163
  }
69
164
  // A newer delta (or the rejection re-park above) landed while
@@ -78,7 +173,7 @@ function _flushMetricsSave(session, sessionId) {
78
173
  // drain still gets a shot at persisting it.
79
174
  if (state.dirty && state.failCount <= 3) {
80
175
  const latest = _pendingMetricsFlush.get(sessionId) ?? session;
81
- _flushMetricsSave(latest, sessionId);
176
+ _flushMetricsSave(latest, sessionId, state);
82
177
  }
83
178
  });
84
179
  }
@@ -95,22 +190,22 @@ function _scheduleMetricsSave(session, sessionId) {
95
190
  if (state.closed) return;
96
191
  if (state.inFlight) {
97
192
  state.dirty = true;
98
- _pendingMetricsFlush.set(sessionId, session);
193
+ _parkMetricsSnapshot(state, sessionId, session);
99
194
  return;
100
195
  }
101
196
  const elapsed = Date.now() - state.lastFlushAt;
102
197
  if (elapsed >= METRICS_SAVE_THROTTLE_MS) {
103
- _flushMetricsSave(session, sessionId);
198
+ _flushMetricsSave(session, sessionId, state);
104
199
  return;
105
200
  }
106
201
  state.dirty = true;
107
- _pendingMetricsFlush.set(sessionId, session);
202
+ _parkMetricsSnapshot(state, sessionId, session);
108
203
  if (!state.timer) {
109
204
  const t = setTimeout(() => {
110
205
  state.timer = null;
111
- if (!state.inFlight && !state.closed) {
206
+ if (!state.inFlight && _isMetricsStateCurrent(sessionId, state)) {
112
207
  const latest = _pendingMetricsFlush.get(sessionId) ?? session;
113
- _flushMetricsSave(latest, sessionId);
208
+ _flushMetricsSave(latest, sessionId, state);
114
209
  }
115
210
  }, METRICS_SAVE_THROTTLE_MS - elapsed);
116
211
  if (t.unref) t.unref();
@@ -118,18 +213,19 @@ function _scheduleMetricsSave(session, sessionId) {
118
213
  }
119
214
  }
120
215
 
121
- // Process exit can land inside the throttle window, before the trailing
122
- // timer above ever calls saveSessionAsync (which would otherwise register
123
- // with store.mjs's own drainSessionStore-covered worker queue). Sync-flush
124
- // any such still-deferred session here so a normal-completion turn that
125
- // happens to exit mid-throttle never loses the latest iteration delta.
126
- process.on('exit', () => {
127
- for (const [sessionId, session] of _pendingMetricsFlush) {
128
- try { _saveSessionSync(session, { expectedGeneration: session.generation }); }
129
- catch { /* best-effort: process is exiting */ }
130
- }
131
- _pendingMetricsFlush.clear();
132
- });
216
+ // NO second exit-flush path lives here on purpose. Process exit can land
217
+ // inside the throttle window, before the trailing timer ever calls
218
+ // saveSessionAsync — but the parked snapshot is registered with the store's
219
+ // own deferred map, so drainSessionStore() persists it in the canonical
220
+ // ordering (epoch rank, fresh cancellation guard, shared commit lock,
221
+ // ownership generation, landed-epoch fence, one global deadline). A
222
+ // sync-flush here would instead mint fresh write authority AFTER that drain
223
+ // and could overwrite a newer landed session or a lifecycle barrier.
224
+ //
225
+ // Hard delete is the mirror image: the store runs this module's own cleanup
226
+ // synchronously (timers cancelled, park dropped, state closed) before it
227
+ // unlinks the file, so no retry and no drain entry can resurrect it.
228
+ registerSessionPurgeHook((sessionId) => dropMetricSeenState(sessionId));
133
229
 
134
230
  // Injected accessor for manager's _runtimeState. Defaults to a no-op lookup so
135
231
  // the pure helpers remain usable (and unit-testable) before wiring.
@@ -155,7 +251,7 @@ export function dropMetricSeenState(sessionId) {
155
251
  // that write is still outstanding.
156
252
  if (!state.inFlight) _metricSaveState.delete(sessionId);
157
253
  }
158
- _pendingMetricsFlush.delete(sessionId);
254
+ _unparkMetricsSnapshot(state, sessionId);
159
255
  }
160
256
 
161
257
  /** Monotonic per-session ask/turn id for incremental usage idempotency. */
@@ -105,8 +105,10 @@ export {
105
105
  createSession,
106
106
  updateSessionRoute,
107
107
  prefetchSession,
108
+ prepareSessionProjection,
108
109
  resumeSession,
109
110
  isSessionOwnerGone,
111
+ recoverSessionAfterProcessRestart,
110
112
  } from './manager/session-lifecycle.mjs';
111
113
  export { askSession, _api_call_with_interrupt } from './manager/ask-session.mjs';
112
114
  export {
@@ -120,11 +122,13 @@ export {
120
122
  clearSessionMessages,
121
123
  compactSessionMessages,
122
124
  updateSessionStatus,
125
+ updateSessionGeneratedTitle,
126
+ updateSessionManualTitle,
123
127
  flushSessionMetrics,
124
128
  } from './manager/session-crud.mjs';
125
129
  export { deleteSession } from './store.mjs';
126
130
  // Read-only parsed-session access (desktop pane peek): no resume, no
127
131
  // ownership, no liveness side effects.
128
132
  export { loadSession } from './store.mjs';
129
- export { closeSession, abortSessionTurn } from './manager/session-close.mjs';
133
+ export { closeSession, abortSessionTurn, unloadSessionRuntime } from './manager/session-close.mjs';
130
134
  export { sweepTombstones, startIdleCleanup, stopIdleCleanup } from './manager/idle-cleanup.mjs';
@@ -25,12 +25,85 @@ try {
25
25
  } catch { /* best-effort: worker still functions with default stdio */ }
26
26
 
27
27
  import { _saveSessionSync } from './store.mjs';
28
+ import { applySessionSaveFault, _sessionStoreTestMode } from './store/save-fault.mjs';
29
+ import { SAVE_OUTCOME_SAVED } from './store/live-state.mjs';
28
30
 
29
- parentPort.on('message', ({ session, opts, reqId }) => {
31
+ // Per-id base transcripts for the parent's delta handoff (save-worker.mjs
32
+ // _buildWirePayload): a delta message carries only header + appended tail and
33
+ // is reconstructed against the messages written by this worker's previous
34
+ // save for that id. Bounded LRU — an evicted id answers deltaMiss and the
35
+ // parent retries with a full snapshot.
36
+ const _baseMessages = new Map(); // id → last successfully written messages (disk shape)
37
+ const BASE_MESSAGES_MAX = 8;
38
+
39
+ function _rememberBase(id, messages) {
40
+ _baseMessages.delete(id);
41
+ _baseMessages.set(id, messages);
42
+ while (_baseMessages.size > BASE_MESSAGES_MAX) {
43
+ const oldest = _baseMessages.keys().next().value;
44
+ if (oldest === undefined) break;
45
+ _baseMessages.delete(oldest);
46
+ }
47
+ }
48
+
49
+ parentPort.on('message', (message) => {
50
+ // Authoritative fault state pushed by the parent realm. It arrives before
51
+ // any write that may be affected by it, and `null` clears (the worker's
52
+ // inherited env snapshot must never outlive the parent's config).
53
+ if (message && Object.prototype.hasOwnProperty.call(message, '__fault')) {
54
+ applySessionSaveFault(message.__fault);
55
+ return;
56
+ }
57
+ // TEST-ONLY, STRUCTURALLY GATED: forget the delta base of ONE id so the
58
+ // next delta for it legitimately misses. Nothing else in this realm (or in
59
+ // the parent's baseline) is touched, and the worker keeps running.
60
+ if (message && Object.prototype.hasOwnProperty.call(message, '__evictBase')) {
61
+ if (_sessionStoreTestMode() && message.__evictBase) _baseMessages.delete(message.__evictBase);
62
+ return;
63
+ }
64
+ // TEST-ONLY, STRUCTURALLY GATED inspector: report whether this realm still
65
+ // holds a delta base for one id. Read-only; nothing is mutated.
66
+ if (message && Object.prototype.hasOwnProperty.call(message, '__baseProbe')) {
67
+ if (_sessionStoreTestMode()) {
68
+ parentPort.postMessage({
69
+ __baseProbeResult: { id: message.__baseProbe, hasBase: _baseMessages.has(message.__baseProbe) },
70
+ });
71
+ }
72
+ return;
73
+ }
74
+ const { session, delta, id, opts, reqId } = message || {};
30
75
  try {
31
- const saved = _saveSessionSync(session, opts);
32
- parentPort.postMessage({ ok: true, saved, reqId });
76
+ let full = session;
77
+ const sessionId = id ?? session?.id;
78
+ if (!full && delta) {
79
+ const base = _baseMessages.get(sessionId);
80
+ // baseCount pins the exact chain position: the single-worker FIFO
81
+ // guarantees per-id ordering, so a length mismatch can only mean a
82
+ // broken chain (restart/eviction) — never silently reconstruct.
83
+ if (!base || base.length !== delta.baseCount) {
84
+ parentPort.postMessage({ ok: true, saved: false, deltaMiss: true, reqId });
85
+ return;
86
+ }
87
+ full = { ...delta.header, messages: [...base, ...delta.tailMessages] };
88
+ }
89
+ const outcome = _saveSessionSync(full, opts);
90
+ const saved = outcome === SAVE_OUTCOME_SAVED;
91
+ if (saved) _rememberBase(sessionId, full.messages);
92
+ else _baseMessages.delete(sessionId);
93
+ // `outcome` tells the parent WHY a write did not land: an
94
+ // ownership/cancellation drop and a stale-epoch refusal have opposite
95
+ // consequences for live-state markers.
96
+ parentPort.postMessage({ ok: true, saved, outcome, reqId });
33
97
  } catch (err) {
34
- parentPort.postMessage({ ok: false, error: err?.message ?? String(err), reqId });
98
+ // Preserve the original failure identity across the thread boundary:
99
+ // the parent rebuilds an Error carrying the same code/injection marker
100
+ // so callers can branch on it exactly as in the sync path.
101
+ parentPort.postMessage({
102
+ ok: false,
103
+ error: err?.message ?? String(err),
104
+ errorCode: err?.code ?? null,
105
+ injectedSaveFault: err?.injectedSaveFault === true,
106
+ reqId,
107
+ });
35
108
  }
36
109
  });
@@ -6,6 +6,7 @@
6
6
  // try/catch it replaced.
7
7
  import { appendAgentTrace } from '../agent-trace.mjs';
8
8
  import { isContextOverflowError } from '../providers/retry-classifier.mjs';
9
+ import { readStreamOutcome } from '../providers/lib/stream-outcome.mjs';
9
10
  import { resolveWorkerCompactPolicy } from './loop/compact-policy.mjs';
10
11
  import { agentContextOverflowError } from './loop/context-overflow.mjs';
11
12
  import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
@@ -43,6 +44,12 @@ export async function sendWithRecovery(ctx) {
43
44
  try {
44
45
  response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
45
46
  } catch (sendErr) {
47
+ // Canonical stream outcome: ONE fail-closed read of what the
48
+ // provider stream actually produced (terminal vs continuation,
49
+ // observed text/reasoning, partial/complete/dispatched tool calls).
50
+ // Every branch below consumes it instead of re-inferring safety
51
+ // from provider-specific flags.
52
+ const outcome = readStreamOutcome(sendErr);
46
53
  // Gemini REST/SDK reports MAX_TOKENS by throwing a typed
47
54
  // ProviderIncompleteError after preserving the streamed candidate.
48
55
  // Normalize only that exact, safe no-tool output-limit shape into a
@@ -54,9 +61,11 @@ export async function sendWithRecovery(ctx) {
54
61
  && isOutputLimitStopReason(sendErr.finishReason)
55
62
  && typeof sendErr.partialContent === 'string'
56
63
  && sendErr.partialContent.trim().length > 0
57
- && sendErr.pendingToolUse !== true
58
- && sendErr.emittedToolCall !== true
59
- && !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
64
+ // Terminal observed (explicit finish reason) and no tool
65
+ // exposure at all: the only shape safe to promote to success.
66
+ && outcome.successEligible === true
67
+ && outcome.toolCallsStarted !== true
68
+ && outcome.sideEffectDispatched !== true
60
69
  ) {
61
70
  response = {
62
71
  content: sendErr.partialContent,
@@ -71,48 +80,46 @@ export async function sendWithRecovery(ctx) {
71
80
  };
72
81
  return { action: 'proceed', response };
73
82
  } else
74
- // Partial-final recovery (owner-notify fix): the recurring "worker
75
- // finished but the task hung / no result delivered" case is a FINAL,
76
- // no-tool summary stream that wedges (ping-only) AFTER all real tool
77
- // work completed in earlier iterations. The provider attaches its
78
- // partial stream state to the StreamStalledError. When the stall
79
- // carries streamed assistant text, has NO pending tool_use, and did
80
- // NOT emit a tool call this iteration, accept the partial as a
81
- // successful terminal response (deliver the summary we have) instead
82
- // of throwing — which would strand/notify-as-failure a turn whose
83
- // work actually succeeded. A stall WITH a pending/emitted tool call
84
- // is NOT recoverable (a tool whose input never completed must never
85
- // look done) and falls through to the normal error path.
83
+ // Exhausted no-tool stall: NOT a success. A FINAL, no-tool stream
84
+ // that wedges (ping-only) carries streamed assistant text on the
85
+ // StreamStalledError, but the turn never completed — the model may
86
+ // have been mid-sentence, and there is no completion signal that
87
+ // says the work is done. Promoting it to a normal terminal response
88
+ // silently reported truncated/aborted turns as finished. Keep it an
89
+ // explicit failure (like codex's no-completed stream handling and
90
+ // opencode's persisted partial-failure): the already-streamed text
91
+ // is preserved by the interruption/error persistence path
92
+ // (turn-interruption.mjs records the deltas and commits them as the
93
+ // partial assistant message alongside the error), so nothing visible
94
+ // is lost while the caller/owner-notify still sees a failed turn.
95
+ // A stall WITH complete parsed tool calls is a different, genuinely
96
+ // recoverable case and is handled by the next branch.
86
97
  if (
87
- sendErr?.streamStalled === true
88
- && sendErr.pendingToolUse !== true
89
- // NOT gated on unsafeToRetry: live-text stalls stamp
90
- // unsafeToRetry=true (replay would double-render), but
91
- // ACCEPTING the already-streamed partial is exactly the safe
92
- // move (CC rule). Only an emitted tool call blocks acceptance.
93
- && sendErr.emittedToolCall !== true
98
+ outcome.stallObserved === true
99
+ && outcome.terminalObserved !== true
94
100
  && typeof sendErr.partialContent === 'string'
95
101
  && sendErr.partialContent.trim().length > 0
96
- && !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
102
+ && outcome.toolCallsComplete === 0
97
103
  ) {
98
104
  try {
99
105
  process.stderr.write(
100
106
  `[loop] final stream stalled with partial text (sess=${sessionId || 'unknown'} `
101
107
  + `iter=${nextIteration} len=${sendErr.partialContent.length}); `
102
- + `accepting as partial-final success\n`,
108
+ + `failing the turn (partial preserved via interrupted-turn persistence)\n`,
103
109
  );
104
110
  } catch { /* best-effort */ }
105
- response = {
106
- content: sendErr.partialContent,
107
- model: sendErr.partialModel || model,
108
- toolCalls: undefined,
109
- usage: sendErr.partialUsage || undefined,
110
- stopReason: sendErr.partialStopReason || 'end_turn',
111
- hasThinkingContent: sendErr.partialHasThinking === true,
112
- providerMetadata: sendErr.providerMetadata,
113
- partialFinal: true,
114
- };
115
- return { action: 'proceed', response };
111
+ try {
112
+ appendAgentTrace({
113
+ kind: 'stall_partial_final_rejected',
114
+ sessionId: sessionId || null,
115
+ iteration: nextIteration,
116
+ partialContentLen: sendErr.partialContent.length,
117
+ pendingToolUse: outcome.pendingToolInput,
118
+ emittedToolCall: outcome.sideEffectDispatched,
119
+ terminalObserved: outcome.terminalObserved,
120
+ });
121
+ } catch { /* best-effort */ }
122
+ throw sendErr;
116
123
  } else
117
124
  // Partial tool-call recovery (agent-hang fix): a stream that stalls
118
125
  // AFTER fully-parsed tool calls were emitted used to lose the whole
@@ -128,8 +135,9 @@ export async function sendWithRecovery(ctx) {
128
135
  // once. providerState stays undefined so the next iteration resends
129
136
  // a full frame on a fresh stream.
130
137
  if (
131
- sendErr?.streamStalled === true
132
- && sendErr.pendingToolUse !== true
138
+ outcome.stallObserved === true
139
+ && outcome.pendingToolInput !== true
140
+ && outcome.toolCallsComplete > 0
133
141
  && Array.isArray(sendErr.partialToolCalls)
134
142
  && sendErr.partialToolCalls.length > 0
135
143
  ) {
@@ -156,6 +164,22 @@ export async function sendWithRecovery(ctx) {
156
164
  usage: sendErr.partialUsage || undefined,
157
165
  stopReason: 'tool_use',
158
166
  hasThinkingContent: sendErr.partialHasThinking === true,
167
+ // Ordered provider block state captured before the stall.
168
+ // Anthropic REQUIRES the verbatim thinking blocks (with
169
+ // their signatures) back ahead of tool_use on the
170
+ // continuation turn, and a native `server_tool_use` call is
171
+ // only valid immediately followed by its result block — so
172
+ // the recovered tool turn must replay exactly what a
173
+ // successful turn would have returned. The loop's committer
174
+ // already drops the redundant `thinkingBlocks` copy when
175
+ // `assistantBlocks` is present (which contains them
176
+ // verbatim), so blocks are neither lost nor doubled.
177
+ ...(Array.isArray(sendErr.partialThinkingBlocks) && sendErr.partialThinkingBlocks.length
178
+ ? { thinkingBlocks: sendErr.partialThinkingBlocks }
179
+ : {}),
180
+ ...(Array.isArray(sendErr.partialAssistantBlocks) && sendErr.partialAssistantBlocks.length
181
+ ? { assistantBlocks: sendErr.partialAssistantBlocks }
182
+ : {}),
159
183
  providerMetadata: sendErr.providerMetadata,
160
184
  partialToolRecovery: true,
161
185
  };
@@ -175,7 +199,12 @@ export async function sendWithRecovery(ctx) {
175
199
  throw sendErr;
176
200
  }
177
201
  const compactPolicyForRetry = resolveWorkerCompactPolicy(sessionRef, sendTools.length ? sendTools : tools);
178
- if (!contextOverflowRetryUsed && compactPolicyForRetry?.auto) {
202
+ // The reactive retry RE-SENDS the turn, so it is a replay: denied
203
+ // once visible text/reasoning was relayed or a tool call was
204
+ // dispatched (or ambiguously dispatched). A deterministic overflow
205
+ // refusal that exposed nothing may compact and re-send.
206
+ const replayPermitted = outcome.replaySafe === true;
207
+ if (!contextOverflowRetryUsed && compactPolicyForRetry?.auto && replayPermitted) {
179
208
  // Mark the next pre-send compact as REACTIVE (driven by a
180
209
  // provider overflow refusal) rather than the normal proactive
181
210
  // pressure trigger, so the compact event/telemetry the loop
@@ -192,7 +221,9 @@ export async function sendWithRecovery(ctx) {
192
221
  try {
193
222
  process.stderr.write(
194
223
  `[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
195
- `surfacing overflow after reactive compact retry messages=${messages.length}\n`,
224
+ `surfacing overflow (retryUsed=${contextOverflowRetryUsed === true} ` +
225
+ `replaySafe=${outcome.replaySafe === true} replayUnsafe=${outcome.replayUnsafe === true}) ` +
226
+ `messages=${messages.length}\n`,
196
227
  );
197
228
  } catch { /* best-effort */ }
198
229
  throw agentContextOverflowError({
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Existence probes that never conflate "not there" with "cannot look".
3
+ *
4
+ * `existsSync` returns false for EVERY stat failure — ENOENT, but also
5
+ * EACCES/EIO/EBUSY/EPERM/ELOOP. Every store decision keyed on that boolean
6
+ * therefore reads a temporarily unreadable file as a DELETED one: the sweep
7
+ * queues its summary row for removal, sidecar rebuilds drop it, sidecars get
8
+ * unlinked as orphans and in-memory state is allowed to stand in for it.
9
+ * Absence is a fact; a failed probe is an unknown, and an unknown may never
10
+ * authorize a deletion or a fallback.
11
+ */
12
+ import { readFileSync, statSync } from 'fs';
13
+
14
+ export const PROBE_PRESENT = 'present';
15
+ export const PROBE_ABSENT = 'absent';
16
+ export const PROBE_UNREADABLE = 'unreadable';
17
+
18
+ // ONLY these mean "nothing is at this path". Everything else means a file is
19
+ // very likely there and we simply cannot look at it (same classification the
20
+ // session load path uses).
21
+ const ABSENT_CODES = new Set(['ENOENT', 'ENOTDIR']);
22
+
23
+ // Deterministic fault seam (tests only): invoked with the path before every
24
+ // stat and every read (`phase` is 'stat' or 'read'); returning an Error makes
25
+ // that syscall fail. Structurally gated — without the explicit test-mode env
26
+ // the hook cannot be installed and an already installed one goes inert.
27
+ const PROBE_FAULT_ENV = 'MIXDOG_SESSION_LOAD_FAULT_HOOKS';
28
+ let _probeFaultHook = null;
29
+
30
+ export function _setStatProbeFaultHook(hook) {
31
+ if (process.env[PROBE_FAULT_ENV] !== '1') {
32
+ _probeFaultHook = null;
33
+ return false;
34
+ }
35
+ _probeFaultHook = typeof hook === 'function' ? hook : null;
36
+ return _probeFaultHook !== null;
37
+ }
38
+
39
+ function _probeFault(path, phase) {
40
+ if (!_probeFaultHook) return;
41
+ if (process.env[PROBE_FAULT_ENV] !== '1') {
42
+ _probeFaultHook = null; // gate revoked after install: stay inert
43
+ return;
44
+ }
45
+ const injected = _probeFaultHook(path, phase);
46
+ if (injected instanceof Error) throw injected;
47
+ }
48
+
49
+ /**
50
+ * `{ state, mtimeMs, size, code }` — state is exactly one of
51
+ * present / absent / unreadable.
52
+ */
53
+ export function probePath(path) {
54
+ try {
55
+ _probeFault(path, 'stat');
56
+ const info = statSync(path);
57
+ return { state: PROBE_PRESENT, mtimeMs: info.mtimeMs || 0, size: info.size, code: null };
58
+ } catch (err) {
59
+ const code = err?.code || 'EUNKNOWN';
60
+ if (ABSENT_CODES.has(code)) return { state: PROBE_ABSENT, mtimeMs: 0, size: 0, code };
61
+ return { state: PROBE_UNREADABLE, mtimeMs: 0, size: 0, code };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Read a text file with the SAME three-way classification.
67
+ * `{ state, text, code }` — 'present' carries the text, 'absent' means the
68
+ * file is provably gone, 'unreadable' means it very likely exists and could
69
+ * not be read (EACCES/EIO/EBUSY): the caller must retain whatever authority
70
+ * it already had instead of treating the content as missing.
71
+ */
72
+ export function readTextFile(path) {
73
+ try {
74
+ _probeFault(path, 'read');
75
+ return { state: PROBE_PRESENT, text: readFileSync(path, 'utf8'), code: null };
76
+ } catch (err) {
77
+ const code = err?.code || 'EUNKNOWN';
78
+ if (ABSENT_CODES.has(code)) return { state: PROBE_ABSENT, text: '', code };
79
+ return { state: PROBE_UNREADABLE, text: '', code };
80
+ }
81
+ }
82
+
83
+ /** True only for a positively observed file/dir. */
84
+ export function probeExists(path) {
85
+ return probePath(path).state === PROBE_PRESENT;
86
+ }
87
+
88
+ /** True only when the path is PROVEN absent (never for an unreadable probe). */
89
+ export function probeAbsent(path) {
90
+ return probePath(path).state === PROBE_ABSENT;
91
+ }