mixdog 0.9.147 → 0.9.148

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 (255) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +2 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  49. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  50. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +17 -11
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +20 -7
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +132 -42
  55. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -3
  59. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  60. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  61. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  62. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  64. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  65. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  66. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  67. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  68. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  69. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  70. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  71. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  72. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  74. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  75. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  76. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  77. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  78. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  79. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  80. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  81. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +45 -8
  82. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  84. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  85. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  87. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  88. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  90. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +178 -29
  92. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  94. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  95. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  101. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  102. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  103. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  104. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  105. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  106. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  107. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  108. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  109. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  110. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  111. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  112. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  113. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  114. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  116. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  117. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  118. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  119. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  120. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  121. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  122. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  123. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  124. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  125. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  126. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  127. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  128. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  129. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  130. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  131. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  132. package/src/runtime/attachments/store.mjs +11 -3
  133. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  134. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  135. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  136. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  137. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  138. package/src/runtime/channels/lib/webhook.mjs +39 -20
  139. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  140. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  141. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  142. package/src/runtime/media/jobs.mjs +13 -0
  143. package/src/runtime/media/renditions.mjs +7 -1
  144. package/src/runtime/media/store.mjs +42 -22
  145. package/src/runtime/memory/index.mjs +4 -9
  146. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  147. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  148. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  149. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  150. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  151. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  152. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  153. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  154. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  155. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  156. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  157. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  158. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  159. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  160. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  161. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  162. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  163. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  164. package/src/runtime/memory/lib/memory.mjs +50 -12
  165. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  166. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  167. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  168. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  169. package/src/runtime/shared/background-tasks.mjs +15 -0
  170. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  171. package/src/runtime/shared/child-guardian.mjs +2 -10
  172. package/src/runtime/shared/pid-liveness.mjs +27 -0
  173. package/src/runtime/shared/runtime-root.mjs +1 -11
  174. package/src/runtime/shared/service-discovery.mjs +1 -17
  175. package/src/runtime/shared/singleton-owner.mjs +1 -11
  176. package/src/runtime/shared/staged-update.mjs +2 -12
  177. package/src/runtime/shared/tool-status.mjs +5 -1
  178. package/src/runtime/shared/tool-surface.mjs +15 -7
  179. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  180. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  181. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  182. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  183. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  184. package/src/runtime/web-search/lib/state.mjs +15 -15
  185. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  186. package/src/session-runtime/context-status.mjs +4 -12
  187. package/src/session-runtime/runtime-core.mjs +20 -1
  188. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  189. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  190. package/src/standalone/agent-tool/notify.mjs +3 -1
  191. package/src/standalone/agent-tool/spawn-flow.mjs +48 -66
  192. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  193. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  194. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  195. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  196. package/src/standalone/agent-tool.mjs +20 -55
  197. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  198. package/src/standalone/channel-client.mjs +47 -15
  199. package/src/standalone/channel-transport.mjs +44 -25
  200. package/src/standalone/daemon.mjs +31 -2
  201. package/src/standalone/hook-bus/config.mjs +25 -9
  202. package/src/standalone/hook-bus/handlers.mjs +16 -52
  203. package/src/standalone/hook-bus.mjs +29 -23
  204. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  205. package/src/standalone/session-client.mjs +86 -23
  206. package/src/standalone/session-protocol.mjs +0 -2
  207. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  208. package/src/standalone/session-runtime-host.mjs +101 -6
  209. package/src/standalone/session-runtime-record.mjs +1 -5
  210. package/src/standalone/session-runtime-worker.mjs +52 -57
  211. package/src/standalone/session-service.mjs +4 -1
  212. package/src/standalone/session-transport.mjs +56 -25
  213. package/src/tui/App.jsx +40 -49
  214. package/src/tui/app/app-view.jsx +14 -21
  215. package/src/tui/app/core-memory-picker.mjs +29 -16
  216. package/src/tui/app/create-app-pickers.mjs +15 -36
  217. package/src/tui/app/extension-pickers.mjs +60 -49
  218. package/src/tui/app/maintenance-pickers.mjs +100 -57
  219. package/src/tui/app/message-selector.mjs +6 -5
  220. package/src/tui/app/model-picker.mjs +30 -13
  221. package/src/tui/app/onboarding-steps.mjs +21 -12
  222. package/src/tui/app/panel-epoch.mjs +56 -0
  223. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  224. package/src/tui/app/panel-surface.mjs +98 -0
  225. package/src/tui/app/project-picker.mjs +30 -29
  226. package/src/tui/app/prompt-submit.mjs +192 -97
  227. package/src/tui/app/prompt-submit.test.mjs +206 -0
  228. package/src/tui/app/provider-setup-picker.mjs +74 -43
  229. package/src/tui/app/resume-picker.mjs +11 -8
  230. package/src/tui/app/route-pickers.mjs +45 -28
  231. package/src/tui/app/settings-picker.mjs +69 -18
  232. package/src/tui/app/shell-layout.mjs +1 -3
  233. package/src/tui/app/slash-commands.mjs +0 -2
  234. package/src/tui/app/slash-dispatch.mjs +4 -2
  235. package/src/tui/app/text-entry-policy.mjs +25 -0
  236. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  237. package/src/tui/app/usage-context-panels.mjs +21 -25
  238. package/src/tui/app/use-global-key-input.mjs +3 -2
  239. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  240. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  241. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  242. package/src/tui/components/Picker.jsx +1 -1
  243. package/src/tui/components/TextEntryPanel.jsx +7 -2
  244. package/src/tui/dist/index.mjs +652 -561
  245. package/src/tui/lib/voice-setup.mjs +1 -2
  246. package/src/tui/session/agent-job-feed.mjs +73 -11
  247. package/src/tui/session/session-api-ext.mjs +5 -6
  248. package/src/tui/session/session-api.mjs +1 -0
  249. package/src/tui/session/tool-approval.mjs +15 -5
  250. package/src/tui/session/tui-steering-persist.mjs +33 -19
  251. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  252. package/src/tui/session-local.mjs +0 -22
  253. package/scripts/.tmp-coverage-report.mjs +0 -96
  254. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  255. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -37,11 +37,14 @@ function webhookRoute(modelRef) {
37
37
  throw new Error('webhook run has no model: set one on the endpoint or configure maintenance.webhook');
38
38
  }
39
39
 
40
- export async function runWebhookSession({ name, model = null, prompt, cwd = null, workflow = null, attachments = null, delivery = null }) {
40
+ export async function runWebhookSession({ name, model = null, prompt, cwd = null, workflow = null, attachments = null, delivery = null, signal = null }) {
41
41
  const endpoint = String(name || '').trim();
42
42
  const body = String(prompt || '').trim();
43
43
  if (!endpoint) throw new Error('runWebhookSession: endpoint name required');
44
44
  if (!body) throw new Error(`webhook "${endpoint}" has no prompt body`);
45
+ // The dispatcher's cancellation (delivery timeout) has to reach the run
46
+ // itself: an already-cancelled dispatch must not even create a session.
47
+ if (signal?.aborted) throw signal.reason ?? new Error('webhook dispatch aborted');
45
48
  const route = webhookRoute(model);
46
49
  const projectCwd = cwd ? String(cwd) : null;
47
50
  const session = createSession({
@@ -65,6 +68,17 @@ export async function runWebhookSession({ name, model = null, prompt, cwd = null
65
68
  // read as the user-authored intent rather than payload noise.
66
69
  // Stored attachments ride along as composer-style content parts.
67
70
  const content = automationPromptContent(body, attachments);
68
- const result = await askSession(session.id, content, null, null, projectCwd || undefined);
71
+ // askOpts.signal is the parent-abort link: it is cascaded onto the turn
72
+ // controller, so aborting it stops the in-flight provider/tool work instead
73
+ // of leaving a timed-out delivery's session running with live side effects.
74
+ const result = await askSession(
75
+ session.id,
76
+ content,
77
+ null,
78
+ null,
79
+ projectCwd || undefined,
80
+ undefined,
81
+ signal ? { signal } : {},
82
+ );
69
83
  return { sessionId: session.id, result: String(result?.content || '') };
70
84
  }
@@ -303,16 +303,35 @@ function headersToCdpPairs(headers) {
303
303
  * Pinned fetch for a paused Chromium request: validate each hop, follow redirects,
304
304
  * return bytes for Fetch.fulfillRequest. Chromium never performs its own DNS/connect.
305
305
  */
306
+ const ENTITY_HEADERS = new Set([
307
+ 'content-type',
308
+ 'content-length',
309
+ 'content-encoding',
310
+ 'content-language',
311
+ 'content-location',
312
+ ])
313
+
314
+ function withoutEntityHeaders(headers) {
315
+ const out = {}
316
+ for (const [name, value] of Object.entries(headers || {})) {
317
+ if (ENTITY_HEADERS.has(name.toLowerCase())) continue
318
+ out[name] = value
319
+ }
320
+ return out
321
+ }
322
+
306
323
  export async function fetchPinnedForPausedRequest(url, { signal, method = 'GET', headers = {}, body } = {}) {
307
- const upperMethod = (method || 'GET').toUpperCase()
324
+ let currentMethod = (method || 'GET').toUpperCase()
325
+ let currentHeaders = { ...headers }
326
+ let currentBody = body
308
327
  let currentUrl = url
309
328
  for (let hops = 0; ; hops++) {
310
329
  assertPublicUrl(currentUrl)
311
330
  const response = await pinnedFetch(currentUrl, {
312
331
  signal,
313
- method: upperMethod,
314
- headers,
315
- body: hops === 0 ? body : undefined,
332
+ method: currentMethod,
333
+ headers: currentHeaders,
334
+ body: currentBody,
316
335
  redirect: 'manual',
317
336
  })
318
337
  if (REDIRECT_STATUSES.has(response.status)) {
@@ -325,6 +344,17 @@ export async function fetchPinnedForPausedRequest(url, { signal, method = 'GET',
325
344
  throw new Error(`Redirect ${response.status} without Location header`)
326
345
  }
327
346
  currentUrl = new URL(location, currentUrl).toString()
347
+ // Redirect method/body rewrite (fetch spec): 303 always, and 301/302 for
348
+ // anything other than GET/HEAD, become a bodyless GET. 307/308 preserve
349
+ // BOTH — so the body has to be replayed instead of dropped, which is what
350
+ // the previous `hops === 0` body gate did while keeping POST.
351
+ if (response.status === 303
352
+ || ((response.status === 301 || response.status === 302)
353
+ && currentMethod !== 'GET' && currentMethod !== 'HEAD')) {
354
+ currentMethod = 'GET'
355
+ currentBody = undefined
356
+ currentHeaders = withoutEntityHeaders(currentHeaders)
357
+ }
328
358
  continue
329
359
  }
330
360
  const respBody = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
@@ -14,10 +14,6 @@ function now() {
14
14
  function defaultState() {
15
15
  return {
16
16
  providers: {},
17
- routingCache: {
18
- rawBySite: {},
19
- scrapeByHost: {},
20
- },
21
17
  }
22
18
  }
23
19
 
@@ -74,15 +70,15 @@ export function loadUsageState() {
74
70
  if (_instance) return _instance
75
71
  const raw = readJson(USAGE_PATH, null)
76
72
  const def = defaultState()
77
- const state = raw && typeof raw === 'object' ? {
78
- ...def,
79
- ...raw,
80
- providers: { ...(raw.providers || {}) },
81
- routingCache: {
82
- rawBySite: { ...(def.routingCache.rawBySite), ...(raw.routingCache?.rawBySite || {}) },
83
- scrapeByHost: { ...(def.routingCache.scrapeByHost), ...(raw.routingCache?.scrapeByHost || {}) },
84
- },
85
- } : def
73
+ let state = def
74
+ if (raw && typeof raw === 'object') {
75
+ // Drop the legacy `routingCache` (rawBySite/scrapeByHost) instead of
76
+ // carrying it forward: it has no consumers, and a plain `...raw` kept
77
+ // rewriting it back to disk on every flush.
78
+ const { routingCache: legacyRoutingCache, ...rest } = raw
79
+ state = { ...def, ...rest, providers: { ...(raw.providers || {}) } }
80
+ if (legacyRoutingCache !== undefined) scheduleUsageFlush(state)
81
+ }
86
82
  _instance = state
87
83
  activeUsageState = state
88
84
  return state
@@ -157,13 +153,17 @@ const PROVIDER_DISABLE_TTL_MS = {
157
153
  unknown: 0,
158
154
  }
159
155
 
160
- export function noteProviderFailure(state, provider, errorMessage, errorKind) {
156
+ // `siteScoped` marks a failure that the FETCHED SITE produced (its 400/401/403/
157
+ // 429), not a fault of the provider/extractor itself. Those statuses say
158
+ // nothing about provider health, so they must never install the provider-wide
159
+ // cooldown that would disable the extractor for every other host for 24h.
160
+ export function noteProviderFailure(state, provider, errorMessage, errorKind, { siteScoped = false } = {}) {
161
161
  const payload = {
162
162
  error: errorMessage,
163
163
  lastUsedAt: now(),
164
164
  lastFailureAt: now(),
165
165
  }
166
- const ttl = PROVIDER_DISABLE_TTL_MS[errorKind] ?? 0
166
+ const ttl = siteScoped ? 0 : (PROVIDER_DISABLE_TTL_MS[errorKind] ?? 0)
167
167
  if (ttl > 0) {
168
168
  payload.cooldownUntil = new Date(Date.now() + ttl).toISOString()
169
169
  }
@@ -292,9 +292,33 @@ function _notifyPoolWaiter() {
292
292
  if (next) next()
293
293
  }
294
294
 
295
- async function _acquirePoolSlot() {
295
+ async function _acquirePoolSlot(signal) {
296
+ const abortError = () => signal?.reason || new Error('aborted')
297
+ // Waiting for a pool slot used to be uncancellable: an aborted tool call kept
298
+ // queueing until a slot freed, then launched a page nobody was waiting for.
296
299
  while (_poolActive >= PUPPETEER_POOL_MAX_PAGES) {
297
- await new Promise((resolve) => _poolWaiters.push(resolve))
300
+ if (signal?.aborted) throw abortError()
301
+ await new Promise((resolve, reject) => {
302
+ let onAbort = null
303
+ const waiter = () => {
304
+ if (onAbort) signal.removeEventListener('abort', onAbort)
305
+ resolve()
306
+ }
307
+ if (signal) {
308
+ onAbort = () => {
309
+ const index = _poolWaiters.indexOf(waiter)
310
+ if (index >= 0) _poolWaiters.splice(index, 1)
311
+ reject(abortError())
312
+ }
313
+ signal.addEventListener('abort', onAbort, { once: true })
314
+ }
315
+ _poolWaiters.push(waiter)
316
+ })
317
+ }
318
+ if (signal?.aborted) {
319
+ // Woken but cancelled: pass the wake-up on so a live waiter isn't stranded.
320
+ _notifyPoolWaiter()
321
+ throw abortError()
298
322
  }
299
323
  _poolActive++
300
324
  _poolLastActivity = Date.now()
@@ -421,7 +445,7 @@ async function closeBrowserBounded(browser, timeoutMs = 5000) {
421
445
  }
422
446
 
423
447
  async function withPuppeteerPage(signal, fn) {
424
- await _acquirePoolSlot()
448
+ await _acquirePoolSlot(signal)
425
449
  let browser
426
450
  let context
427
451
  let page
@@ -435,13 +459,26 @@ async function withPuppeteerPage(signal, fn) {
435
459
  }
436
460
  if (signal?.aborted) throw signal.reason || new Error('aborted')
437
461
  if (signal) {
438
- onExternalAbort = () => { closeBrowserBounded(browser) }
462
+ // Tear down THIS call's page/context only. The browser is pooled and
463
+ // shared with every concurrent scrape, so closing it here killed
464
+ // unrelated in-flight pages.
465
+ onExternalAbort = () => {
466
+ try { void page?.close()?.catch?.(() => {}) } catch {}
467
+ try { void context?.close()?.catch?.(() => {}) } catch {}
468
+ }
439
469
  signal.addEventListener('abort', onExternalAbort, { once: true })
440
470
  }
471
+ // Creation is multi-step and awaits between each step: without a checkpoint
472
+ // per step an abort landing mid-setup was lost and the page ran anyway.
473
+ // (The finally below closes whatever was created by then.)
441
474
  context = await browser.createBrowserContext()
475
+ if (signal?.aborted) throw signal.reason || new Error('aborted')
442
476
  page = await context.newPage()
477
+ if (signal?.aborted) throw signal.reason || new Error('aborted')
443
478
  cdp = await page.createCDPSession()
479
+ if (signal?.aborted) throw signal.reason || new Error('aborted')
444
480
  await installPuppeteerSsrfGate(page, cdp, signal)
481
+ if (signal?.aborted) throw signal.reason || new Error('aborted')
445
482
  return await fn(page)
446
483
  } finally {
447
484
  if (onExternalAbort && signal) signal.removeEventListener('abort', onExternalAbort)
@@ -610,7 +647,11 @@ async function scrapeUrl(url, timeoutMs, usageState, signal) {
610
647
  throw err
611
648
  }
612
649
  const errorKind = classifyProviderError(error)
613
- noteProviderFailure(usageState, extractor, message, errorKind)
650
+ // A status carried by the error came from the target page, so it is a
651
+ // property of that site — not of `readability`/`puppeteer`, which hold no
652
+ // credentials at all.
653
+ const siteScoped = Number.isFinite(Number(error?.status)) || /\bHTTP\s+\d{3}\b/.test(message)
654
+ noteProviderFailure(usageState, extractor, message, errorKind, { siteScoped })
614
655
  }
615
656
  }
616
657
 
@@ -12,7 +12,6 @@ import { SUMMARY_PREFIX } from '../runtime/agent/orchestrator/session/compact.mj
12
12
  import { hasUserConversationMessage } from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
13
13
  import {
14
14
  resolveGaugeContextTokens,
15
- resolveCompactionPressureTokens,
16
15
  resolveWorkerCompactPolicy,
17
16
  } from '../runtime/agent/orchestrator/session/loop/compact-policy.mjs';
18
17
  import {
@@ -269,15 +268,8 @@ export function createContextStatus({
269
268
  ...resolveSessionCompactPolicy(session || {}, compactBoundaryTokens),
270
269
  tokenCalibration: providerTokenCalibration(session?.provider || route.provider),
271
270
  };
272
- // Keep the user-facing gauge aligned to provider-visible context. The
273
- // compaction branch retains its stricter pressure numerator separately.
274
- const compactionPressureTokens = resolveCompactionPressureTokens(
275
- messageSummary.estimatedTokens,
276
- compactPolicy,
277
- { messages, sessionRef: session },
278
- );
279
- // Same numerator the auto-compact trigger decides on, so the gauge reaches
280
- // 100% exactly when compaction fires instead of still showing headroom.
271
+ // One canonical numerator drives the gauge, proactive compaction, and
272
+ // telemetry: provider usage plus only the growth after its aligned prefix.
281
273
  const usedTokens = resolveGaugeContextTokens(
282
274
  messageSummary.estimatedTokens,
283
275
  compactPolicy,
@@ -313,8 +305,8 @@ export function createContextStatus({
313
305
  triggerTokens: compactTriggerTokens || null,
314
306
  bufferTokens: Number.isFinite(compactBufferTokens) ? compactBufferTokens : null,
315
307
  bufferRatio: compactBufferRatio,
316
- currentEstimatedTokens: compactionPressureTokens,
317
- pressureTokens: compactionPressureTokens,
308
+ currentEstimatedTokens: usedTokens,
309
+ pressureTokens: usedTokens,
318
310
  reserveTokens: Math.max(0, Number(compactPolicy.configuredReserveTokens) || 0),
319
311
  lastApiRequestTokens: lastContextTokens || 0,
320
312
  lastApiRequestStale: lastUsageStale,
@@ -645,7 +645,26 @@ export async function createMixdogSessionRuntime({
645
645
  state: mcpState,
646
646
  });
647
647
  const hooksStartedAt = performance.now();
648
- const hooks = createStandaloneHookBus({ dataDir: cfgMod.getPluginData() });
648
+ const hooks = createStandaloneHookBus({
649
+ dataDir: cfgMod.getPluginData(),
650
+ // `mcp_tool` hooks run against the SAME connected MCP servers this session
651
+ // uses. Without this runner every configured mcp_tool hook reported
652
+ // "handler type mcp_tool not configured", so the handler's timeout +
653
+ // cancellation path never ran in production. The hook's abort signal is
654
+ // forwarded so a timed-out hook cancels its tool call instead of leaving it
655
+ // holding an admission slot.
656
+ mcpToolRunner: async ({ name, args, signal }) => {
657
+ if (typeof mcpClient?.executeMcpTool !== 'function') {
658
+ throw new Error('MCP runtime is unavailable');
659
+ }
660
+ const result = await mcpClient.executeMcpTool(name, args ?? {}, {
661
+ scopeId: rt.mcpScopeId,
662
+ signal: signal || null,
663
+ ownerKey: `hook:${name}`,
664
+ });
665
+ return typeof result === 'string' ? result : String(result?.result ?? '');
666
+ },
667
+ });
649
668
  hooks.emit('runtime:start', { cwd: rt.currentCwd, provider: rt.route.provider, model: rt.route.model, toolMode: rt.mode });
650
669
  bootProfile('hooks:ready', { ms: (performance.now() - hooksStartedAt).toFixed(1) });
651
670
 
@@ -1,4 +1,5 @@
1
1
  import { createFairCallScheduler } from './fair-call-scheduler.mjs';
2
+ import { hashStructuredValue } from '../runtime/shared/json-metrics.mjs';
2
3
 
3
4
  const MEMORY_AGENTS = Object.freeze([
4
5
  'cycle1-agent',
@@ -13,6 +14,20 @@ function abortError(signal) {
13
14
  : new Error(String(signal?.reason || 'agent dispatch canceled'));
14
15
  }
15
16
 
17
+ /** Identity of a dispatch PAYLOAD. A retry repeats it exactly; a caller that
18
+ * reuses a callId for different work is issuing a different call. */
19
+ function dispatchSignature(agent, params) {
20
+ try {
21
+ return hashStructuredValue({
22
+ agent,
23
+ prompt: String(params?.prompt ?? ''),
24
+ preset: params?.preset ?? null,
25
+ cwd: typeof params?.cwd === 'string' ? params.cwd : null,
26
+ timeout: Number.isFinite(Number(params?.timeout)) ? Number(params.timeout) : null,
27
+ });
28
+ } catch { return null; }
29
+ }
30
+
16
31
  function awaitSharedPreparation(promise, signal) {
17
32
  if (!signal) return promise;
18
33
  if (signal.aborted) return Promise.reject(abortError(signal));
@@ -113,8 +128,24 @@ export function createAgentDispatchBroker({
113
128
  if (!prompt) return Promise.reject(new Error(`agent dispatch prompt required for "${agent}"`));
114
129
 
115
130
  const id = String(callId || `broker-${process.pid}-${++nextCallId}`);
131
+ const signature = dispatchSignature(agent, params);
116
132
  const existing = inFlight.get(id);
117
- if (existing) return existing.promise;
133
+ if (existing) {
134
+ // Scope note: a broker callId dedupes CONCURRENT duplicates only. The
135
+ // memory client mints a fresh id per dispatch and cancels instead of
136
+ // replaying (runtime/memory/lib/agent-ipc.mjs callAgentDispatch), so a
137
+ // settled id carries no promise and needs no retained result.
138
+ // Within that window a callId is still an idempotency key, not an
139
+ // overwrite slot: answering a different payload from an unrelated
140
+ // in-flight run is silent data loss.
141
+ if (signature && existing.signature && existing.signature !== signature) {
142
+ return Promise.reject(Object.assign(
143
+ new Error(`agent dispatch callId '${id}' was reused with a different payload`),
144
+ { code: 'ECALLIDCONFLICT' },
145
+ ));
146
+ }
147
+ return existing.promise;
148
+ }
118
149
 
119
150
  const controller = new AbortController();
120
151
  const abortFromCaller = () => {
@@ -146,7 +177,7 @@ export function createAgentDispatchBroker({
146
177
  if (inFlight.get(id) === record) inFlight.delete(id);
147
178
  try { onActivityChanged?.(snapshot()); } catch {}
148
179
  });
149
- record = { controller, promise: tracked };
180
+ record = { controller, promise: tracked, signature };
150
181
  inFlight.set(id, record);
151
182
  try { onActivityChanged?.(snapshot()); } catch {}
152
183
  return tracked;
@@ -0,0 +1,78 @@
1
+ // Background-task reconcile for a worker turn (spawn AND send paths).
2
+ //
3
+ // A worker turn has to mark its background task terminal from three separate
4
+ // windows, and both paths must do it identically — they used to carry two
5
+ // byte-identical copies of every block:
6
+ //
7
+ // 1. TERMINAL RESULT — the worker produced its final result. Reconciling
8
+ // here (rather than after the session save) keeps a hung/slow post-result
9
+ // step from stranding the task, and the status card, in `running`. An
10
+ // empty/abnormal finish is a FAILURE, not a completion, so the Lead card
11
+ // renders `error: …` instead of a header-only empty card.
12
+ // 2. STREAM STALLED — a mid-stream stall (StreamStalledError / ESTREAMSTALL)
13
+ // throws WITHOUT a terminal result, so the finally net below (gated on
14
+ // _terminalResultValue) is skipped and only the outer task-reject path
15
+ // would notify. Reconciling to `failed` here guarantees the owner (Lead)
16
+ // gets a failure notification instead of a task stuck in `running`.
17
+ // 3. FINALLY — safety net for a post-result step (session save) that hung or
18
+ // threw after a terminal result already existed.
19
+ //
20
+ // Every call is idempotent (the first terminal state wins, so the outer reject
21
+ // path cannot double-notify) and absorbs its own errors, so reporting on a
22
+ // turn can never break the turn.
23
+ import { reconcileBackgroundTask } from '../../runtime/shared/background-tasks.mjs';
24
+
25
+ export function reconcileJobTerminalResult(job, value) {
26
+ if (!job?.taskId) return;
27
+ try {
28
+ reconcileBackgroundTask(job.taskId, value.error
29
+ ? {
30
+ status: 'failed',
31
+ result: value,
32
+ error: value.error,
33
+ terminalReason: 'agent-empty-final',
34
+ }
35
+ : {
36
+ status: 'completed',
37
+ result: value,
38
+ terminalReason: 'agent-terminal-result',
39
+ });
40
+ } catch {}
41
+ }
42
+
43
+ /** Watchdog stall that still recovered a partial handoff: a completion. */
44
+ export function reconcileJobWatchdogPartial(job, value) {
45
+ if (!job?.taskId) return;
46
+ try {
47
+ reconcileBackgroundTask(job.taskId, {
48
+ status: 'completed',
49
+ result: value,
50
+ terminalReason: 'agent-watchdog-partial',
51
+ });
52
+ } catch {}
53
+ }
54
+
55
+ export function reconcileJobStreamStalled(job, error) {
56
+ if (!job?.taskId || job._terminalResultValue !== undefined) return;
57
+ try {
58
+ reconcileBackgroundTask(job.taskId, {
59
+ status: 'failed',
60
+ error,
61
+ terminalReason: 'agent-stream-stalled',
62
+ });
63
+ } catch {}
64
+ }
65
+
66
+ export function reconcileJobFinally(job, finalStatus) {
67
+ if (!job || job._terminalResultValue === undefined) return;
68
+ try {
69
+ reconcileBackgroundTask(job.taskId, {
70
+ status: finalStatus === 'error' ? 'failed' : 'completed',
71
+ result: job._terminalResultValue,
72
+ ...(finalStatus === 'error' && job._terminalResultValue?.error
73
+ ? { error: job._terminalResultValue.error }
74
+ : {}),
75
+ terminalReason: 'agent-finally-reconcile',
76
+ });
77
+ } catch {}
78
+ }
@@ -28,7 +28,9 @@ export function createNotify(mgr, { notifySessionCompletion } = {}) {
28
28
  }
29
29
  // Mark this as a deferred completion/task notification so a later session
30
30
  // resume drops it rather than replaying it out-of-order (owner decision).
31
- return Boolean(mgr.enqueuePendingMessage(target, markCompletionEntry(visible)) > 0);
31
+ return Boolean(mgr.enqueuePendingMessage(target, markCompletionEntry(visible, {
32
+ executionId: meta?.execution_id,
33
+ })) > 0);
32
34
  } catch {
33
35
  return false;
34
36
  }
@@ -4,7 +4,13 @@
4
4
  // execution with turn-review collection and terminal accounting.
5
5
  import { agentDefinitionExists, clean, clearAgentStatuslineRoute, nonNegativeInt, normalizeAgentName, positiveInt, presetKey, readAgentFrontmatterPermission, resolvePrompt, terminalPidForContext, writeAgentStatuslineRoute } from './helpers.mjs';
6
6
  import { createNotify } from './notify.mjs';
7
- import { reconcileBackgroundTask, sanitizeTaskMeta, startBackgroundTask } from '../../runtime/shared/background-tasks.mjs';
7
+ import { sanitizeTaskMeta, startBackgroundTask } from '../../runtime/shared/background-tasks.mjs';
8
+ import {
9
+ reconcileJobFinally,
10
+ reconcileJobStreamStalled,
11
+ reconcileJobTerminalResult,
12
+ reconcileJobWatchdogPartial,
13
+ } from './job-task-reconcile.mjs';
8
14
  import { isAgentDisabled } from '../../runtime/shared/agent-route-config.mjs';
9
15
  import { abnormalEmptyFinishError, renderResult } from './render.mjs';
10
16
  import { resourceAdmission } from '../../runtime/shared/resource-admission.mjs';
@@ -14,8 +20,14 @@ import { normalizeAgentPermission } from '../../runtime/shared/markdown-frontmat
14
20
  import { resolveAgentSpawnPreset } from './spawn-preset.mjs';
15
21
  import { resolveAgentWatchdogPolicy, resolveHandoffMessageStartIndex, watchdogPartialHandoffFromError } from '../../runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs';
16
22
  import { prepareAgentSession } from '../../runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
23
+ import { saveSessionAsync } from '../../runtime/agent/orchestrator/session/store.mjs';
17
24
  import { AGENT_OWNER } from '../../runtime/agent/orchestrator/agent-owner.mjs';
18
25
  import { getProvider } from '../../runtime/agent/orchestrator/providers/registry.mjs';
26
+ // Mid-turn worker persistence cadence. The watchdog sweep runs every second;
27
+ // a transcript save is far more expensive than a bookkeeping stamp, so the
28
+ // in-progress snapshot lands on this slower clock.
29
+ const AGENT_PROGRESS_SAVE_MS = 2_000;
30
+
19
31
  export function createSpawnFlow({
20
32
  mgr,
21
33
  forgetTerminalSession,
@@ -189,6 +201,35 @@ export function createSpawnFlow({
189
201
  });
190
202
  };
191
203
 
204
+ // In-turn progress stamp. turnStartStamper only fires at a TURN boundary, so
205
+ // one long first turn (a review agent easily runs 20+ tool iterations) left
206
+ // the worker row untouched for minutes and the panel looked stalled. The
207
+ // watchdog sweep reports each observed change; the row keeps its own live
208
+ // message count and updatedAt from it.
209
+ const progressStamper = (session, tag) => {
210
+ let lastSavedAt = 0;
211
+ return (progress = {}) => {
212
+ const messages = Number(progress?.messages) || 0;
213
+ upsertWorkerSessionDeferred(session, tag, {
214
+ status: 'running',
215
+ stage: 'running',
216
+ ...(messages > 0 ? { messages } : {}),
217
+ });
218
+ // Mid-turn durability. A worker transcript otherwise reaches disk only
219
+ // when the turn ENDS, so a pane opened on a running worker shows the
220
+ // prompt and nothing after it — the work is real but invisible to every
221
+ // viewer. Persisting on the sweep makes the in-progress transcript
222
+ // readable like any other session. Throttled: a transcript save is not
223
+ // free, and the sweep runs every second.
224
+ const now = Date.now();
225
+ if (now - lastSavedAt < AGENT_PROGRESS_SAVE_MS) return;
226
+ lastSavedAt = now;
227
+ try {
228
+ Promise.resolve(saveSessionAsync(session)).catch(() => {});
229
+ } catch { /* progress persistence is best-effort */ }
230
+ };
231
+ };
232
+
192
233
  async function prepareSpawn(args, callerCwd = null, context = {}, prepState = null) {
193
234
  refreshTagsFromSessions({ context });
194
235
  return prepareSpawnInProcess(args, callerCwd, context, prepState);
@@ -325,6 +366,7 @@ export function createSpawnFlow({
325
366
  const { args, tag, session, agent, preset, presetName, workerCwd, prompt, watchdogPolicy } = prepared;
326
367
  const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy, agent, {
327
368
  onTurnStart: turnStartStamper(session, tag),
369
+ onProgress: progressStamper(session, tag),
328
370
  });
329
371
  const turnReview = createTurnReviewCollector(session, tag, agent, notifyContext || {});
330
372
  let finalStatus = 'idle';
@@ -374,29 +416,7 @@ export function createSpawnFlow({
374
416
  const value = completionValue(terminalResult);
375
417
  if (job) job._terminalResultValue = value;
376
418
  notifyOwnerAgentCompletionEarly(job, value, notifyContext || {});
377
- // Mark the task terminal the moment the worker produces its final
378
- // result, so a hung/slow post-result session save cannot strand the
379
- // task (and the status card) in `running`. Idempotent; the finally
380
- // reconcile remains a backup for the error/no-terminal-result path.
381
- if (job?.taskId) {
382
- try {
383
- // An empty/abnormal finish is a failure, not a completion:
384
- // reconcile as `failed` with the accurate error so the Lead
385
- // card renders `error: …` instead of a header-only empty card.
386
- reconcileBackgroundTask(job.taskId, value.error
387
- ? {
388
- status: 'failed',
389
- result: value,
390
- error: value.error,
391
- terminalReason: 'agent-empty-final',
392
- }
393
- : {
394
- status: 'completed',
395
- result: value,
396
- terminalReason: 'agent-terminal-result',
397
- });
398
- } catch {}
399
- }
419
+ reconcileJobTerminalResult(job, value);
400
420
  },
401
421
  } : {}),
402
422
  });
@@ -431,34 +451,11 @@ export function createSpawnFlow({
431
451
  stallAbort: true,
432
452
  };
433
453
  if (job) job._terminalResultValue = value;
434
- if (job?.taskId) {
435
- try {
436
- reconcileBackgroundTask(job.taskId, {
437
- status: 'completed',
438
- result: value,
439
- terminalReason: 'agent-watchdog-partial',
440
- });
441
- } catch {}
442
- }
454
+ reconcileJobWatchdogPartial(job, value);
443
455
  return value;
444
456
  }
445
457
  finalStatus = 'error';
446
- // Part C: a mid-stream stall (StreamStalledError / ESTREAMSTALL) throws
447
- // here WITHOUT a terminal result, so the finally reconcile below (gated on
448
- // _terminalResultValue) would be skipped and only the outer task-reject
449
- // path would notify. Belt-and-suspenders: reconcile this job to `failed`
450
- // now so the owner (Lead) always gets a failure notification instead of a
451
- // task stranded in `running`. Idempotent — completeBackgroundTask no-ops
452
- // once terminal, so the outer reject path can't double-notify.
453
- if (job?.taskId && job._terminalResultValue === undefined) {
454
- try {
455
- reconcileBackgroundTask(job.taskId, {
456
- status: 'failed',
457
- error,
458
- terminalReason: 'agent-stream-stalled',
459
- });
460
- } catch {}
461
- }
458
+ reconcileJobStreamStalled(job, error);
462
459
  throw error;
463
460
  } finally {
464
461
  turnReview.complete();
@@ -475,23 +472,7 @@ export function createSpawnFlow({
475
472
  finishedAt: new Date().toISOString(),
476
473
  });
477
474
  setImmediate(notifyStatusChange);
478
- // Safety net: if a post-result step (session save) hung or threw after the
479
- // worker already produced a terminal result, the task could otherwise be
480
- // stranded in `running`. Reconcile it to a terminal state using the
481
- // captured result so the owner gets a completion notification + the
482
- // statusline clears. Idempotent once the task is already terminal.
483
- if (job && job._terminalResultValue !== undefined) {
484
- try {
485
- reconcileBackgroundTask(job.taskId, {
486
- status: finalStatus === 'error' ? 'failed' : 'completed',
487
- result: job._terminalResultValue,
488
- ...(finalStatus === 'error' && job._terminalResultValue?.error
489
- ? { error: job._terminalResultValue.error }
490
- : {}),
491
- terminalReason: 'agent-finally-reconcile',
492
- });
493
- } catch {}
494
- }
475
+ reconcileJobFinally(job, finalStatus);
495
476
  scheduleReap(session.id);
496
477
  // SubagentStop: worker finished (terminal), regardless of outcome.
497
478
  emitSubagentEvent('stop', agent, { session_id: session.id, tag, status: finalStatus });
@@ -510,6 +491,7 @@ export function createSpawnFlow({
510
491
  progressWatchdogs,
511
492
  startProgressIdleWatchdog,
512
493
  turnStartStamper,
494
+ progressStamper,
513
495
  prepareSpawn,
514
496
  prepareSpawnInProcess,
515
497
  runSpawn,