@mjasnikovs/pi-task 0.38.29 → 0.38.31

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 (373) hide show
  1. package/dist/config/config.d.ts +70 -70
  2. package/dist/config/config.js +26 -35
  3. package/dist/config/extension-list.d.ts +6 -5
  4. package/dist/config/extension-list.js +3 -2
  5. package/dist/config/reasoning-args.d.ts +9 -7
  6. package/dist/config/reasoning-args.js +12 -10
  7. package/dist/config/reasoning.d.ts +44 -105
  8. package/dist/config/reasoning.js +27 -704
  9. package/dist/config/register.d.ts +34 -48
  10. package/dist/config/register.js +41 -51
  11. package/dist/config/tool-list.d.ts +16 -16
  12. package/dist/config/tool-list.js +1 -1
  13. package/dist/index.js +2 -0
  14. package/dist/remote/bridge.d.ts +19 -10
  15. package/dist/remote/bridge.js +3 -2
  16. package/dist/remote/broadcast.js +3 -1
  17. package/dist/remote/events.js +12 -11
  18. package/dist/remote/history.d.ts +1 -1
  19. package/dist/remote/protocol.d.ts +6 -3
  20. package/dist/remote/protocol.js +2 -1
  21. package/dist/remote/push.d.ts +16 -16
  22. package/dist/remote/push.js +27 -27
  23. package/dist/remote/register.d.ts +3 -3
  24. package/dist/remote/register.js +17 -19
  25. package/dist/remote/server.d.ts +9 -8
  26. package/dist/remote/server.js +15 -14
  27. package/dist/remote/session-state.d.ts +5 -4
  28. package/dist/remote/session-state.js +8 -5
  29. package/dist/remote/sw.d.ts +7 -6
  30. package/dist/remote/sw.js +7 -6
  31. package/dist/remote/tailscale.d.ts +4 -2
  32. package/dist/remote/tailscale.js +4 -2
  33. package/dist/remote/ui-highlight.js +6 -5
  34. package/dist/remote/ui-render.js +4 -4
  35. package/dist/remote/ui-script.js +24 -24
  36. package/dist/remote/ui-styles.d.ts +1 -1
  37. package/dist/remote/ui-styles.js +10 -13
  38. package/dist/remote/ui-tools.js +9 -6
  39. package/dist/shared/child-extensions.d.ts +29 -17
  40. package/dist/shared/child-extensions.js +29 -17
  41. package/dist/shared/child-output.d.ts +30 -24
  42. package/dist/shared/child-output.js +25 -17
  43. package/dist/shared/child-process.d.ts +47 -40
  44. package/dist/shared/child-process.js +50 -59
  45. package/dist/shared/command-watchdog.d.ts +85 -16
  46. package/dist/shared/command-watchdog.js +115 -21
  47. package/dist/shared/fs-text.d.ts +16 -10
  48. package/dist/shared/fs-text.js +16 -10
  49. package/dist/shared/git-runner.d.ts +25 -25
  50. package/dist/shared/git-runner.js +25 -25
  51. package/dist/shared/leaked-tool-call.d.ts +17 -11
  52. package/dist/shared/leaked-tool-call.js +23 -15
  53. package/dist/shared/model-endpoint.d.ts +29 -16
  54. package/dist/shared/model-endpoint.js +33 -21
  55. package/dist/shared/pi-invocation.d.ts +7 -4
  56. package/dist/shared/pi-invocation.js +12 -7
  57. package/dist/shared/pkg-version.d.ts +13 -5
  58. package/dist/shared/pkg-version.js +13 -5
  59. package/dist/shared/reasoning-capability.d.ts +35 -24
  60. package/dist/shared/reasoning-capability.js +35 -24
  61. package/dist/shared/stream-watchdog.d.ts +60 -44
  62. package/dist/shared/stream-watchdog.js +62 -45
  63. package/dist/task/accept-debt.d.ts +41 -43
  64. package/dist/task/accept-debt.js +73 -65
  65. package/dist/task/api-synthesis.d.ts +24 -21
  66. package/dist/task/api-synthesis.js +32 -26
  67. package/dist/task/apis-contract.d.ts +32 -64
  68. package/dist/task/apis-contract.js +32 -64
  69. package/dist/task/artifact-closure.d.ts +27 -13
  70. package/dist/task/artifact-closure.js +95 -67
  71. package/dist/task/auto-commit.d.ts +46 -35
  72. package/dist/task/auto-commit.js +51 -38
  73. package/dist/task/auto-io.d.ts +45 -25
  74. package/dist/task/auto-io.js +57 -29
  75. package/dist/task/auto-orchestrator.d.ts +26 -24
  76. package/dist/task/auto-orchestrator.js +192 -165
  77. package/dist/task/auto-prompts.d.ts +36 -24
  78. package/dist/task/auto-prompts.js +40 -26
  79. package/dist/task/autofix-ledger.d.ts +27 -25
  80. package/dist/task/autofix-ledger.js +29 -26
  81. package/dist/task/batch-test-task.d.ts +20 -12
  82. package/dist/task/batch-test-task.js +67 -60
  83. package/dist/task/boot-probe.d.ts +60 -44
  84. package/dist/task/boot-probe.js +91 -72
  85. package/dist/task/cancel-input.d.ts +30 -16
  86. package/dist/task/cancel-input.js +20 -11
  87. package/dist/task/cancel-points.d.ts +27 -20
  88. package/dist/task/cancel-points.js +30 -22
  89. package/dist/task/child-runner.d.ts +124 -55
  90. package/dist/task/child-runner.js +298 -90
  91. package/dist/task/child-status.d.ts +23 -16
  92. package/dist/task/child-status.js +23 -16
  93. package/dist/task/clamp-output.js +12 -5
  94. package/dist/task/command-run.d.ts +31 -28
  95. package/dist/task/command-run.js +44 -35
  96. package/dist/task/command-shrink.d.ts +25 -18
  97. package/dist/task/command-shrink.js +37 -31
  98. package/dist/task/command-watchdog.d.ts +9 -6
  99. package/dist/task/command-watchdog.js +21 -15
  100. package/dist/task/context-attribution.d.ts +34 -26
  101. package/dist/task/context-attribution.js +34 -26
  102. package/dist/task/context-silence.d.ts +39 -29
  103. package/dist/task/context-silence.js +35 -25
  104. package/dist/task/context-usage.d.ts +16 -9
  105. package/dist/task/context-usage.js +16 -9
  106. package/dist/task/contracts.d.ts +8 -4
  107. package/dist/task/contracts.js +25 -17
  108. package/dist/task/coverage-loop.d.ts +22 -18
  109. package/dist/task/coverage-loop.js +35 -30
  110. package/dist/task/critique-probes.d.ts +13 -14
  111. package/dist/task/critique-probes.js +50 -39
  112. package/dist/task/debug-log.d.ts +13 -5
  113. package/dist/task/debug-log.js +32 -20
  114. package/dist/task/decompose-fidelity.d.ts +11 -9
  115. package/dist/task/decompose-fidelity.js +38 -33
  116. package/dist/task/decompose-granularity.d.ts +41 -38
  117. package/dist/task/decompose-granularity.js +41 -38
  118. package/dist/task/deep-render-check.d.ts +22 -14
  119. package/dist/task/deep-render-check.js +40 -31
  120. package/dist/task/dropped-input.d.ts +12 -7
  121. package/dist/task/dropped-input.js +5 -2
  122. package/dist/task/enforce-attribution.d.ts +38 -47
  123. package/dist/task/enforce-attribution.js +46 -52
  124. package/dist/task/enforce-guidelines.d.ts +31 -20
  125. package/dist/task/enforce-guidelines.js +32 -21
  126. package/dist/task/enrichment.d.ts +7 -2
  127. package/dist/task/enrichment.js +26 -14
  128. package/dist/task/env-notes.d.ts +16 -7
  129. package/dist/task/env-notes.js +48 -31
  130. package/dist/task/env-template-closure.d.ts +4 -4
  131. package/dist/task/env-template-closure.js +42 -34
  132. package/dist/task/external-context.d.ts +28 -21
  133. package/dist/task/external-context.js +17 -12
  134. package/dist/task/failure-classifier.d.ts +4 -5
  135. package/dist/task/failure-classifier.js +30 -8
  136. package/dist/task/file-inventory.d.ts +15 -11
  137. package/dist/task/file-inventory.js +25 -22
  138. package/dist/task/final-gate-fix.d.ts +74 -86
  139. package/dist/task/final-gate-fix.js +97 -116
  140. package/dist/task/final-gate-progress.d.ts +29 -46
  141. package/dist/task/final-gate-progress.js +40 -51
  142. package/dist/task/final-gate.d.ts +64 -97
  143. package/dist/task/final-gate.js +192 -199
  144. package/dist/task/fix-child.d.ts +21 -27
  145. package/dist/task/fix-child.js +21 -27
  146. package/dist/task/foreign-path.d.ts +6 -5
  147. package/dist/task/foreign-path.js +0 -0
  148. package/dist/task/frozen-conflict.d.ts +9 -10
  149. package/dist/task/frozen-conflict.js +61 -64
  150. package/dist/task/frozen-path-guard.d.ts +35 -14
  151. package/dist/task/frozen-path-guard.js +56 -39
  152. package/dist/task/gate-child.d.ts +27 -28
  153. package/dist/task/gate-child.js +36 -35
  154. package/dist/task/gate-deps.d.ts +34 -27
  155. package/dist/task/gate-deps.js +169 -159
  156. package/dist/task/gate-tally.d.ts +77 -80
  157. package/dist/task/gate-tally.js +65 -68
  158. package/dist/task/git-state-guard.d.ts +15 -11
  159. package/dist/task/git-state-guard.js +76 -66
  160. package/dist/task/impl-widget.d.ts +25 -16
  161. package/dist/task/impl-widget.js +27 -17
  162. package/dist/task/implementation-guards.d.ts +26 -0
  163. package/dist/task/implementation-guards.js +177 -0
  164. package/dist/task/implementation-thinking.d.ts +33 -31
  165. package/dist/task/implementation-thinking.js +5 -6
  166. package/dist/task/implementation-turn.d.ts +39 -31
  167. package/dist/task/implementation-turn.js +41 -28
  168. package/dist/task/inline-markdown.d.ts +20 -7
  169. package/dist/task/inline-markdown.js +15 -6
  170. package/dist/task/launch-config-gap.js +25 -39
  171. package/dist/task/launch-contract.d.ts +18 -21
  172. package/dist/task/launch-contract.js +28 -30
  173. package/dist/task/launch-manifest.d.ts +6 -2
  174. package/dist/task/launch-manifest.js +35 -34
  175. package/dist/task/ledger.js +16 -14
  176. package/dist/task/lint-fix.d.ts +6 -8
  177. package/dist/task/lint-fix.js +67 -69
  178. package/dist/task/loop-detector.d.ts +27 -8
  179. package/dist/task/loop-detector.js +38 -14
  180. package/dist/task/mid-run-input.d.ts +17 -15
  181. package/dist/task/mid-run-input.js +17 -15
  182. package/dist/task/orchestrator.d.ts +24 -28
  183. package/dist/task/orchestrator.js +89 -66
  184. package/dist/task/orientation.d.ts +18 -23
  185. package/dist/task/orientation.js +24 -31
  186. package/dist/task/owned-freeze-conflict.d.ts +21 -20
  187. package/dist/task/owned-freeze-conflict.js +52 -85
  188. package/dist/task/owned-freeze-reassign.d.ts +40 -60
  189. package/dist/task/owned-freeze-reassign.js +41 -61
  190. package/dist/task/parsers.d.ts +4 -2
  191. package/dist/task/parsers.js +4 -4
  192. package/dist/task/phases.d.ts +41 -48
  193. package/dist/task/phases.js +196 -252
  194. package/dist/task/plan-io.d.ts +6 -7
  195. package/dist/task/plan-io.js +6 -7
  196. package/dist/task/plan-orchestrator.d.ts +10 -8
  197. package/dist/task/plan-orchestrator.js +14 -10
  198. package/dist/task/plan-prompts.d.ts +6 -5
  199. package/dist/task/plan-prompts.js +6 -5
  200. package/dist/task/plan-readonly.d.ts +4 -5
  201. package/dist/task/plan-readonly.js +4 -5
  202. package/dist/task/plan-rounds.d.ts +17 -29
  203. package/dist/task/plan-rounds.js +21 -34
  204. package/dist/task/plan-session.d.ts +58 -72
  205. package/dist/task/plan-session.js +61 -83
  206. package/dist/task/probe-gaming.d.ts +28 -27
  207. package/dist/task/probe-gaming.js +0 -0
  208. package/dist/task/prohibition-probe.d.ts +14 -16
  209. package/dist/task/prompts.d.ts +3 -4
  210. package/dist/task/prompts.js +17 -26
  211. package/dist/task/qa-transcript.d.ts +15 -22
  212. package/dist/task/qa-transcript.js +15 -21
  213. package/dist/task/question-box.d.ts +17 -13
  214. package/dist/task/question-box.js +19 -15
  215. package/dist/task/question-dedup.d.ts +6 -7
  216. package/dist/task/question-dedup.js +13 -14
  217. package/dist/task/question-dialog.d.ts +22 -32
  218. package/dist/task/question-dialog.js +22 -32
  219. package/dist/task/question-source.d.ts +18 -44
  220. package/dist/task/question-source.js +22 -51
  221. package/dist/task/refuted-constraint.d.ts +11 -31
  222. package/dist/task/refuted-constraint.js +27 -51
  223. package/dist/task/regenerable-artifacts.d.ts +12 -31
  224. package/dist/task/regenerable-artifacts.js +12 -31
  225. package/dist/task/render-check.d.ts +11 -22
  226. package/dist/task/render-check.js +33 -46
  227. package/dist/task/repo-health-check.d.ts +10 -14
  228. package/dist/task/repo-health-check.js +17 -23
  229. package/dist/task/requirements.d.ts +38 -71
  230. package/dist/task/requirements.js +78 -126
  231. package/dist/task/research-fanout-budget.d.ts +51 -88
  232. package/dist/task/research-fanout-budget.js +51 -88
  233. package/dist/task/research-worker.d.ts +29 -39
  234. package/dist/task/research-worker.js +37 -61
  235. package/dist/task/resume-gap.d.ts +14 -15
  236. package/dist/task/root-cause-repair.d.ts +9 -9
  237. package/dist/task/root-cause-repair.js +28 -40
  238. package/dist/task/run-bracket.d.ts +10 -13
  239. package/dist/task/run-end.d.ts +12 -22
  240. package/dist/task/run-end.js +8 -16
  241. package/dist/task/run-final-gate.d.ts +19 -21
  242. package/dist/task/run-final-gate.js +62 -80
  243. package/dist/task/runner-globs.d.ts +12 -13
  244. package/dist/task/runner-globs.js +12 -13
  245. package/dist/task/runner-resolve.d.ts +9 -9
  246. package/dist/task/runner-resolve.js +22 -23
  247. package/dist/task/script-escape.d.ts +10 -12
  248. package/dist/task/script-escape.js +13 -14
  249. package/dist/task/serve-entry.d.ts +1 -1
  250. package/dist/task/serve-entry.js +22 -25
  251. package/dist/task/service-blocks.js +4 -2
  252. package/dist/task/shipped-source.d.ts +11 -29
  253. package/dist/task/shipped-source.js +11 -29
  254. package/dist/task/skip-escape.js +10 -14
  255. package/dist/task/spec-urls.d.ts +26 -65
  256. package/dist/task/spec-urls.js +26 -65
  257. package/dist/task/spec-validation.d.ts +17 -20
  258. package/dist/task/spec-validation.js +17 -20
  259. package/dist/task/stall-detector.d.ts +23 -30
  260. package/dist/task/stall-detector.js +23 -30
  261. package/dist/task/stream-watchdog.d.ts +14 -12
  262. package/dist/task/stream-watchdog.js +14 -12
  263. package/dist/task/substitution-probe.d.ts +17 -20
  264. package/dist/task/substitution-probe.js +17 -20
  265. package/dist/task/task-gates.d.ts +36 -41
  266. package/dist/task/task-gates.js +95 -106
  267. package/dist/task/task-io.d.ts +4 -4
  268. package/dist/task/task-io.js +4 -4
  269. package/dist/task/task-parsers.js +4 -3
  270. package/dist/task/task-provenance.d.ts +2 -2
  271. package/dist/task/task-provenance.js +11 -13
  272. package/dist/task/task-types.d.ts +4 -3
  273. package/dist/task/terminal-outcome.d.ts +14 -16
  274. package/dist/task/terminal-outcome.js +12 -14
  275. package/dist/task/test-assembly.d.ts +13 -20
  276. package/dist/task/test-assembly.js +13 -20
  277. package/dist/task/timings.d.ts +5 -3
  278. package/dist/task/timings.js +5 -3
  279. package/dist/task/title-label.d.ts +9 -4
  280. package/dist/task/title-label.js +9 -4
  281. package/dist/task/type-only-answer.d.ts +44 -52
  282. package/dist/task/type-only-answer.js +44 -52
  283. package/dist/task/unfailable-command.d.ts +18 -24
  284. package/dist/task/unfailable-command.js +21 -27
  285. package/dist/task/unknown-routing.d.ts +10 -4
  286. package/dist/task/unknown-routing.js +10 -4
  287. package/dist/task/user-directives.d.ts +5 -8
  288. package/dist/task/user-directives.js +5 -8
  289. package/dist/task/verify-quality.d.ts +18 -22
  290. package/dist/task/verify-quality.js +45 -46
  291. package/dist/task/verify-reconcile.d.ts +15 -10
  292. package/dist/task/verify-reconcile.js +45 -43
  293. package/dist/task/verify-resolution.d.ts +24 -20
  294. package/dist/task/verify-resolution.js +51 -50
  295. package/dist/task/verify-work.d.ts +59 -66
  296. package/dist/task/verify-work.js +101 -138
  297. package/dist/task/widget.d.ts +15 -14
  298. package/dist/task/widget.js +22 -17
  299. package/dist/task/wiring-claims.d.ts +25 -32
  300. package/dist/task/wiring-claims.js +30 -35
  301. package/dist/task/write-guard.d.ts +39 -39
  302. package/dist/task/write-guard.js +48 -51
  303. package/dist/task/yolo.d.ts +34 -30
  304. package/dist/task/yolo.js +42 -37
  305. package/dist/workers/abstention.d.ts +21 -41
  306. package/dist/workers/abstention.js +27 -48
  307. package/dist/workers/brave-search.d.ts +4 -3
  308. package/dist/workers/brave-search.js +5 -2
  309. package/dist/workers/brave-warning.d.ts +7 -4
  310. package/dist/workers/brave-warning.js +19 -7
  311. package/dist/workers/ddg-search.d.ts +6 -6
  312. package/dist/workers/ddg-search.js +18 -12
  313. package/dist/workers/docs-cache.js +5 -2
  314. package/dist/workers/docs-chunk.d.ts +30 -37
  315. package/dist/workers/docs-chunk.js +37 -41
  316. package/dist/workers/docs-core.d.ts +28 -44
  317. package/dist/workers/docs-core.js +25 -44
  318. package/dist/workers/docs-index.js +4 -3
  319. package/dist/workers/docs-lookup.d.ts +15 -22
  320. package/dist/workers/docs-lookup.js +12 -21
  321. package/dist/workers/docs-project.d.ts +15 -9
  322. package/dist/workers/docs-project.js +17 -10
  323. package/dist/workers/docs-resolve.d.ts +19 -20
  324. package/dist/workers/docs-resolve.js +35 -32
  325. package/dist/workers/docs-retrieve.d.ts +5 -6
  326. package/dist/workers/docs-retrieve.js +18 -15
  327. package/dist/workers/exa-search.d.ts +9 -6
  328. package/dist/workers/exa-search.js +23 -12
  329. package/dist/workers/fetch-core.d.ts +13 -16
  330. package/dist/workers/fetch-core.js +23 -23
  331. package/dist/workers/focused-extractor.d.ts +13 -12
  332. package/dist/workers/focused-extractor.js +27 -19
  333. package/dist/workers/html-clean.js +24 -14
  334. package/dist/workers/http-request.d.ts +28 -20
  335. package/dist/workers/http-request.js +22 -17
  336. package/dist/workers/npm-version.d.ts +28 -11
  337. package/dist/workers/npm-version.js +24 -15
  338. package/dist/workers/phantom-imports.d.ts +15 -12
  339. package/dist/workers/phantom-imports.js +30 -24
  340. package/dist/workers/pi-worker-core.d.ts +65 -96
  341. package/dist/workers/pi-worker-core.js +93 -181
  342. package/dist/workers/pi-worker-docs.d.ts +24 -19
  343. package/dist/workers/pi-worker-docs.js +67 -76
  344. package/dist/workers/pi-worker-fetch.d.ts +7 -3
  345. package/dist/workers/pi-worker-fetch.js +27 -19
  346. package/dist/workers/pi-worker-search.js +12 -8
  347. package/dist/workers/pi-worker.d.ts +9 -4
  348. package/dist/workers/pi-worker.js +21 -14
  349. package/dist/workers/reasoning-warning.d.ts +18 -17
  350. package/dist/workers/reasoning-warning.js +22 -20
  351. package/dist/workers/research-cache.js +50 -78
  352. package/dist/workers/search-core.js +7 -5
  353. package/dist/workers/search-types.d.ts +10 -9
  354. package/dist/workers/search-types.js +9 -8
  355. package/dist/workers/session-hint.d.ts +13 -14
  356. package/dist/workers/session-hint.js +8 -9
  357. package/dist/workers/shared.d.ts +21 -25
  358. package/dist/workers/shared.js +0 -0
  359. package/dist/workers/single-read-extension.d.ts +14 -7
  360. package/dist/workers/single-read-extension.js +14 -7
  361. package/dist/workers/single-read-guard.d.ts +27 -30
  362. package/dist/workers/single-read-guard.js +36 -36
  363. package/dist/workers/typeonly-log.d.ts +12 -9
  364. package/dist/workers/typeonly-log.js +29 -33
  365. package/dist/workers/worker-channels.d.ts +15 -23
  366. package/dist/workers/worker-channels.js +15 -23
  367. package/dist/workers/worker-failure.d.ts +38 -46
  368. package/dist/workers/worker-failure.js +31 -39
  369. package/dist/workers/worker-kill.d.ts +25 -26
  370. package/dist/workers/worker-kill.js +16 -19
  371. package/dist/workers/worker-profiles.d.ts +54 -56
  372. package/dist/workers/worker-profiles.js +63 -39
  373. package/package.json +10 -8
@@ -9,41 +9,37 @@ import { spawn } from 'node:child_process';
9
9
  import { getPiInvocation } from '../shared/pi-invocation.js';
10
10
  import { runChild as runChildUnified } from '../shared/child-process.js';
11
11
  import { childBaseArgs } from '../shared/child-extensions.js';
12
- import { LoopDetector } from './loop-detector.js';
12
+ import { LoopDetector, MAX_LOOP_RESTARTS } from './loop-detector.js';
13
13
  import { StallDetector, formatStallHint } from './stall-detector.js';
14
14
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
15
15
  import { readSection, setTaskSection } from './task-io.js';
16
16
  import { streamStallCause } from '../shared/stream-watchdog.js';
17
+ import { commandCeilingForAttempt, commandTimeoutHint, commandWatch } from '../shared/command-watchdog.js';
18
+ import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
19
+ // VALUE import, and it is only safe because worker-profiles.ts reads its loop
20
+ // constants from loop-detector.ts. Point those back at this file and the graph
21
+ // closes into a TDZ ReferenceError that no compile step catches.
22
+ import { workerPolicy } from '../workers/worker-profiles.js';
17
23
  import { getConfig } from '../config/config.js';
18
24
  import { groupThinkingArgs } from '../config/reasoning-args.js';
19
25
  import { reasoningGroupForChild } from '../config/reasoning.js';
20
- // ─── Loop detection constants ────────────────────────────────────────────────
21
- // Defined here (not in phases.ts) to avoid a circular dependency:
22
- // phases.ts → child-runner.ts → phases.ts
23
- export const LOOP_WINDOW = 20;
24
- export const LOOP_THRESHOLD = 5;
25
- export const MAX_LOOP_RESTARTS = 2; // 3 strikes total (initial attempt + 2 restarts)
26
- // MAX_LEAK_RETRIES lives in shared/leaked-tool-call.ts (imported above).
27
26
  // ─── Phase-child wall-clock cap ──────────────────────────────────────────────
28
27
  /**
29
28
  * Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
30
29
  *
31
- * It used to default to 600_000, sized against measured HEALTHY planning
32
- * children on one local 27B backend (decompose 89s, whole plan phase 321s) on
33
- * the reasoning that ten minutes was a 3-6x margin over honest work.
30
+ * WHY OFF, AND NOT A NUMBER. A wall clock on a model child measures the
31
+ * MODEL'S SPEED, not its health. The same planning child that answers well in
32
+ * seconds on one backend takes many minutes on another, or on the same backend
33
+ * with thinking turned on — so any cap generous enough to be safe is too loose
34
+ * to catch anything, and any cap tight enough to catch a runaway kills healthy
35
+ * work. Assume a model that emits one token per second and the number has no
36
+ * defensible value at all.
34
37
  *
35
- * That premise was measured and is false. Replaying ONE captured auto-decompose
36
- * request against the same backend with reasoning ON, n=10, everything else
37
- * byte-identical (2026-08-17): all ten answered correctly with 26-42 titles, and
38
- * every one took 610-927s. The cap would have killed 10 out of 10 GOOD runs and
39
- * then failed the phase with PhaseTimeoutError. The number was not measuring the
40
- * pathology, it was measuring one model's speed on one day.
41
- *
42
- * The runaway it was there to catch — a decompose child that ran 16m23s at
43
- * 117,370 of a 120,064-token window, forward-paging past the loop detector — is
44
- * now caught by StallDetector (stall-detector.ts), which bounds NON-PROGRESS and
45
- * CONTEXT CHURN instead of elapsed seconds. Both of those are properties of the
46
- * pathology, so neither has to be re-tuned for a slower model or a bigger repo.
38
+ * The runaway it was there to catch a child forward-paging through its whole
39
+ * context window, past the loop detector, never going to return is caught by
40
+ * StallDetector (stall-detector.ts) instead. That bounds NON-PROGRESS and
41
+ * CONTEXT CHURN, both properties of the pathology itself, so neither has to be
42
+ * re-tuned for a slower model or a bigger repo.
47
43
  *
48
44
  * The value and the plumbing stay for a caller that genuinely wants a hard stop
49
45
  * (tests inject a short one), but nothing sets it in production. Pass
@@ -108,6 +104,75 @@ export class PhaseTimeoutError extends Error {
108
104
  this.name = 'PhaseTimeoutError';
109
105
  }
110
106
  }
107
+ /**
108
+ * The terminal error for a guard kill, or null when the child was not killed.
109
+ *
110
+ * Both spawn paths must ask. A kill reports `exitCode: 0` (child-process.ts uses
111
+ * `code ?? 0`, and a signal gives null), so a path that tests the exit code
112
+ * instead returns the truncated text as the phase's answer.
113
+ */
114
+ export function guardKillError(name, r, opts = {}) {
115
+ if (r.commandKill)
116
+ return new CommandTimeoutError(name, r.commandKill);
117
+ // A dead-backend verdict is only trusted once every attempt has produced it.
118
+ // `discoverModelEndpoints` reads EVERY provider in models.json, not the one
119
+ // this child's model uses, so a stopped local server can condemn a run against
120
+ // a healthy cloud backend. The asymmetry settles it: a backend that really is
121
+ // down costs three 5s probes, a wrong verdict costs the whole run.
122
+ if (r.stalled)
123
+ return opts.finalAttempt === false ? null : new BackendDownError(name);
124
+ return null;
125
+ }
126
+ /**
127
+ * The dead-backend probe killed a phase child on its LAST attempt.
128
+ *
129
+ * Reaching this means every attempt found no endpoint answering, not one. The
130
+ * single-probe verdict is not trusted on its own: `discoverModelEndpoints` reads
131
+ * every provider in models.json rather than the one this child's model uses, so a
132
+ * stopped local server can condemn a run against a healthy cloud backend. Three
133
+ * failed probes cost ~15s; one wrong verdict costs the run.
134
+ */
135
+ export class BackendDownError extends Error {
136
+ childName;
137
+ constructor(childName) {
138
+ super(`${childName} child killed: no output for the stall window and the model `
139
+ + `endpoint did not answer a probe`);
140
+ this.childName = childName;
141
+ this.name = 'BackendDownError';
142
+ }
143
+ }
144
+ /**
145
+ * A phase child spent every attempt on a command that never returned. Its own
146
+ * class because the fix is in the SPEC, not the model's exploration: a VERIFY
147
+ * block naming an unbounded `dev` command re-hangs every attempt.
148
+ */
149
+ export class CommandTimeoutError extends Error {
150
+ childName;
151
+ kill;
152
+ constructor(childName, kill) {
153
+ super(`${childName} child ran \`${kill.toolName}\``
154
+ + `${kill.detail ? ` (${kill.detail})` : ''} past its `
155
+ + `${Math.round(kill.timeoutMs / 1000)}s ceiling on every attempt`);
156
+ this.childName = childName;
157
+ this.kill = kill;
158
+ this.name = 'CommandTimeoutError';
159
+ }
160
+ }
161
+ /**
162
+ * Causes a best-effort `catch` must NOT absorb.
163
+ *
164
+ * A phase child that merely answered badly should degrade — that is what those
165
+ * catches are for. These two are different in kind: the run is over either way,
166
+ * and swallowing them ships a half-built spec while every later phase dies
167
+ * against the same dead backend, or turns a user's ESC into silent progress.
168
+ * `failure-classifier.ts` has a verdict for both; a catch that eats them makes it
169
+ * unreachable.
170
+ */
171
+ export function isFatalChildCause(e) {
172
+ if (e instanceof BackendDownError)
173
+ return true;
174
+ return e instanceof Error && e.message === USER_CANCELLED;
175
+ }
111
176
  // ─── Connection-error retry ──────────────────────────────────────────────────
112
177
  /**
113
178
  * A connection-class model error is transient: a single dropped fetch to a live
@@ -123,9 +188,54 @@ export class PhaseTimeoutError extends Error {
123
188
  * provider 5xx that names a real fault) still fails fast: re-spawning against
124
189
  * the same request won't fix it, so burning the budget only delays the report.
125
190
  */
126
- const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|fetch failed|network (?:error|timeout)|premature close|request timed out|terminated|unreachable)\b/i;
191
+ /**
192
+ * Transport-level failures worth another attempt.
193
+ *
194
+ * SCOPE, and it is deliberate: connection classes only. pi's own
195
+ * `isRetryableAssistantError` (@earendil-works/pi-ai, `dist/utils/retry.js`) also
196
+ * retries the provider-LOAD family — `429`, `5xx`, `rate limit`, `overloaded` —
197
+ * which `does NOT match real, non-transient faults` in child-runner.test.ts
198
+ * explicitly rejects. That disagreement is real and OPEN; it is not settled here,
199
+ * because this backoff starts at 500ms and a 429 answered that fast is a retry
200
+ * storm, not a recovery.
201
+ *
202
+ * MEASURED against pi before widening: the transport entries added here — a bare
203
+ * `timed out`, `getaddrinfo ENOTFOUND`, `upstream connect`, `reset before
204
+ * headers`, a truncated Anthropic stream and a closed websocket — were all
205
+ * MISSES. Every one is a REMOTE-provider failure, which is why a local llama.cpp
206
+ * setup never surfaced the gap. The errno spellings are pi-task's own: a child
207
+ * reports them through stderr, and pi never sees them.
208
+ *
209
+ * pi's bare `timeout` is deliberately NOT reproduced. It matched a provider 400
210
+ * that merely echoed a `timeout` field back, turning a fail-fast into a full
211
+ * retry budget, and it caught nothing the `timed out` spellings above miss.
212
+ */
213
+ const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|socket connection was closed|fetch failed|network (?:error|timeout)|premature close|terminated|unreachable|getaddrinfo|enotfound|upstream.?connect|reset before headers|timed? out|ended without|stream ended before message_stop|websocket.?(?:closed|error))\b/i;
214
+ /**
215
+ * Provider LOAD, which is transient in a different way: the server is up and
216
+ * saying "not now". pi retries all of these; 53f0488 did not, but its own message
217
+ * names only "context overflow, bad request, auth" as the fail-fast set — a
218
+ * throttle was never argued for, it just rode along in a list written for a LOCAL
219
+ * server, where none of these can occur.
220
+ *
221
+ * Words carry no trailing \b (`overloaded_error` joins on `_`, which is a word
222
+ * character); the bare status codes carry one, or `500` matches inside `15000`.
223
+ */
224
+ const PROVIDER_LOAD_RE = /(?:overloaded|rate.?limit|too many requests|service.?unavailable|server.?error|internal.?error|provider.?returned.?error)|\b(?:429|500|502|503|504|524)\b/i;
225
+ /**
226
+ * Account facts, not liveness: a budget does not refill on a retry. Checked FIRST,
227
+ * because these arrive worded as a throttle — `429 GoUsageLimitError` is a
228
+ * subscription limit, not a queue.
229
+ */
230
+ const NON_RETRYABLE_RE = /\b(?:insufficient_quota|quota exceeded|out of budget|billing|usage limit reached|available balance|GoUsageLimitError|FreeUsageLimitError)\b/i;
231
+ /**
232
+ * Retry budget is three attempts at 500ms/1s/2s — three requests over 3.5s, which
233
+ * is not a storm even against a throttle. pi's own ladder is three at 2s/4s/8s.
234
+ */
127
235
  export function isConnectionError(cause) {
128
- return CONNECTION_ERROR_RE.test(cause);
236
+ if (NON_RETRYABLE_RE.test(cause))
237
+ return false;
238
+ return CONNECTION_ERROR_RE.test(cause) || PROVIDER_LOAD_RE.test(cause);
129
239
  }
130
240
  /** Exponential backoff before a connection-error retry: 500ms, 1s, 2s, …, so a
131
241
  * brief saturation window can drain before we re-issue the request. */
@@ -145,8 +255,8 @@ thinking = []) {
145
255
  // `--mode json` puts the child into the structured event stream the
146
256
  // unified runner parses in `mode: 'json-events'`. Without it the child
147
257
  // emits plain text, every line fails JSON.parse, finalText stays empty,
148
- // and every phase fails with "X child produced no output". Was silently
149
- // dropped in the 4e34f96 split-refactor; do not remove again.
258
+ // and every phase fails with "X child produced no output". A refactor has
259
+ // dropped it once already; do not remove it again.
150
260
  //
151
261
  // An empty `tools` string means "no tools at all" — emit `--no-tools`
152
262
  // instead of `--tools ''` (which pi would reject). Used by pure-judgment
@@ -154,8 +264,9 @@ thinking = []) {
154
264
  // hand them, never spend time reading the repo.
155
265
  //
156
266
  // The prompt is NOT an argv element: it goes to the child over stdin (see
157
- // runChild below / getPiInvocation), so a large inlined-design prompt can't
158
- // overflow the OS command-line limit (Windows `spawn ENAMETOOLONG`).
267
+ // runChild below / getPiInvocation), so a large inlined-design prompt cannot
268
+ // exceed the OS argv ceiling which fails the spawn outright rather than
269
+ // truncating (`E2BIG` on this platform).
159
270
  //
160
271
  // `extensions` are internal `-e` loads for in-run guards (the caller supplies
161
272
  // the path). A no-tools child cannot make a tool call, so it never carries
@@ -167,30 +278,75 @@ thinking = []) {
167
278
  // Sentinel error thrown when the user dismisses a grill-me dialog.
168
279
  // Defined here (not in failure-classifier.ts) to avoid circular dependency.
169
280
  export const USER_CANCELLED = '__user_cancelled__';
170
- export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking }) {
281
+ /**
282
+ * The `phase` row of WORKER_PROFILES, resolved with this machine's config.
283
+ *
284
+ * Read here rather than at module load so a /task-config change reaches the next
285
+ * child, the same contract childBaseArgs already keeps. Both spawn paths in this
286
+ * file go through it, so the degraded final attempt cannot drift from the ordinary
287
+ * one — the mislabel class runDegradedFinalAttempt's own comment warns about.
288
+ */
289
+ export function phasePolicy() {
290
+ return workerPolicy('phase', {
291
+ commandTimeoutMs: getConfig().requestTimeoutMs,
292
+ streamInactivityMs: getConfig().streamInactivityMs
293
+ });
294
+ }
295
+ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking, commandCeilingMs }) {
171
296
  const invocation = getPiInvocation(childArgs(tools, extensions, thinking), prompt);
172
297
  let loopHit;
173
- const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
174
- mode: 'json-events',
175
- // A hung model stream reports nothing at all, so without this the
176
- // phase child waits forever (mx5 run 14: ~2.9h of dead air). The kill
177
- // is reported below as a connection-class cause, which routes it into
178
- // the retry/backoff path this file already has for a LOUD disconnect.
179
- streamInactivityMs: getConfig().streamInactivityMs,
180
- onLine,
181
- onContextUsage,
182
- ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
183
- onToolResult: onToolResult ? r => onToolResult(r.text, r.isError) : undefined,
184
- onToolCall: call => {
185
- if (!onToolCall)
186
- return null;
187
- const hit = onToolCall(call);
188
- if (hit && !loopHit) {
189
- loopHit = hit;
298
+ const guards = phasePolicy().guards;
299
+ // Null when the user set the ceiling to `off`. Why a phase child needs this at
300
+ // all is the `phase` row's `why` in worker-profiles.ts.
301
+ const cmdWatch = commandWatch(commandCeilingMs ?? guards['command-timeout']);
302
+ const childSignal = cmdWatch ? AbortSignal.any([signal, cmdWatch.signal]) : signal;
303
+ let result;
304
+ try {
305
+ result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, childSignal, {
306
+ mode: 'json-events',
307
+ // A hung model stream reports nothing at all, so without this the
308
+ // phase child waits forever. The kill
309
+ // is reported below as a connection-class cause, which routes it into
310
+ // the retry/backoff path this file already has for a LOUD disconnect.
311
+ streamInactivityMs: guards['stream-stall'],
312
+ ...(guards.stalled === false ?
313
+ {}
314
+ : {
315
+ stall: {
316
+ afterMs: guards.stalled.afterMs,
317
+ probe: guards.stalled.probe
318
+ ?? (() => probeModelEndpoints(discoverModelEndpoints()))
319
+ }
320
+ }),
321
+ onLine,
322
+ onContextUsage,
323
+ ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
324
+ // ALWAYS wired, never conditional on the caller wanting results:
325
+ // the sink emits a tool-execution-end only when a handler exists,
326
+ // and without that end the command watchdog's timer is never
327
+ // disarmed — every healthy tool call would then look hung.
328
+ onToolResult: r => {
329
+ cmdWatch?.onEnd(r.toolCallId);
330
+ onToolResult?.(r.text, r.isError);
331
+ },
332
+ onToolCall: call => {
333
+ // Before the detectors: a call they let through still needs its
334
+ // clock started.
335
+ cmdWatch?.onStart(call);
336
+ if (!onToolCall)
337
+ return null;
338
+ const hit = onToolCall(call);
339
+ if (hit && !loopHit) {
340
+ loopHit = hit;
341
+ }
342
+ return hit; // propagate to unified runner so it can kill
190
343
  }
191
- return hit; // propagate to unified runner so it can kill
192
- }
193
- });
344
+ });
345
+ }
346
+ finally {
347
+ cmdWatch?.clear();
348
+ }
349
+ const commandKill = cmdWatch?.killed();
194
350
  // Use `||` (not `??`) so an empty string from json-events mode falls
195
351
  // back to raw stdout. Without this, a child that exits 0 but emits no
196
352
  // assistant text (e.g. model API error swallowed in json mode) always
@@ -204,14 +360,16 @@ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUs
204
360
  ?? (result.streamStalled ? streamStallCause(result.streamStalled.idleMs) : undefined);
205
361
  return {
206
362
  text,
207
- // WE killed this child, so its exit status describes our own SIGTERM, not
208
- // the child's verdict. Report 0 and let `modelError` carry the cause
209
- // otherwise the wrappers' `exitCode !== 0` guard throws a bare "child
210
- // failed" before the connection-error retry ever gets to look.
211
- exitCode: result.streamStalled ? 0 : result.exitCode,
363
+ // WE killed this child, so its exit status is our own SIGTERM. Report 0
364
+ // and let the named cause carry it. EVERY guard kill must be listed: one
365
+ // omitted here arrives as exit 0 with partial text, and triageChildResult
366
+ // returns it as a successful answer.
367
+ exitCode: result.streamStalled || result.stalled || commandKill ? 0 : result.exitCode,
212
368
  stderr: result.stderr.trim(),
213
369
  loopHit,
214
370
  modelError,
371
+ ...(commandKill ? { commandKill } : {}),
372
+ ...(result.stalled ? { stalled: true } : {}),
215
373
  // A tool call the model wrote as text (wrong dialect) never executed and
216
374
  // sailed past the structured-event guards above; flag it so the wrappers
217
375
  // can re-prompt instead of accepting the unexecuted call. Only meaningful
@@ -226,9 +384,8 @@ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUs
226
384
  * verdict, so a fix to any rung lands in every caller at once.
227
385
  *
228
386
  * `attempt` is the caller's 0-based counter, `budget` the matching restart
229
- * allowance (MAX_LEAK_RETRIES, which is also MAX_LOOP_RESTARTS the loop and
230
- * leak budgets were separate constants at the same value and are shared by one
231
- * loop now) — so a phase runs `budget + 1` attempts before a rung gives up.
387
+ * allowance MAX_LEAK_RETRIES and MAX_LOOP_RESTARTS are both 2, and one loop
388
+ * spends the pair so a phase runs `budget + 1` attempts before a rung gives up.
232
389
  *
233
390
  * `verb` names the restart in the debug log ("retry" by default, "restart" for
234
391
  * refine and grill-gen). It is the only externally visible thing that differed
@@ -240,7 +397,11 @@ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUs
240
397
  */
241
398
  async function triageChildResult(deps, name, r, attempt, budget, verb) {
242
399
  if (r.exitCode !== 0) {
243
- throw new Error(`${name} child failed: ${r.stderr || '(no stderr)'}`);
400
+ // The exit code is the only signal when stderr is empty, and pi exits
401
+ // silently on several paths (143 = SIGTERM/loop-kill, 137 = SIGKILL/OOM).
402
+ // Dropping it left "(no stderr)" as the whole diagnosis.
403
+ const why = r.stderr || `no stderr, exit ${r.exitCode}`;
404
+ throw new Error(`${name} child failed: ${why}`);
244
405
  }
245
406
  if (r.modelError) {
246
407
  // The model/provider failed (pi exited 0 with a stopReason "error"
@@ -286,16 +447,16 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
286
447
  *
287
448
  * THREE RUNAWAY GUARDS ride the same budget, because this is the runner every
288
449
  * /task-auto planning child goes through (clarify, decompose, coverage,
289
- * contract-extract) and until mx5-n 2026-08-14 it had none:
450
+ * contract-extract), and an unguarded planning child can burn a whole run:
290
451
  * • a LoopDetector, so an identical repeated tool call is killed and
291
452
  * re-prompted instead of being allowed to fill the context window;
292
453
  * • a StallDetector, the backstop for the varied-args thrash the loop
293
- * detector's short window cannot see — the shape that actually cost us a
294
- * 16-minute decompose child that was never going to return. It bounds
454
+ * detector's short window cannot see — a child that keeps calling tools with
455
+ * different arguments, learns nothing, and is never going to return. It bounds
295
456
  * consecutive no-new-ground calls and total context churn, NOT elapsed time;
296
- * • PHASE_CHILD_TIMEOUT_MS, a hard wall clock, OFF by default because the
297
- * measured healthy range (610-927s for a reasoning-on decompose) overlaps
298
- * any value that would catch the pathology. See its comment.
457
+ * • PHASE_CHILD_TIMEOUT_MS, a hard wall clock, OFF by default: a healthy
458
+ * reasoning-on planning child and a runaway one occupy the same range of
459
+ * elapsed times, so no threshold separates them. See its comment.
299
460
  * All three are checked BEFORE the triage ladder: we killed the child, so its
300
461
  * exit status describes our SIGTERM and says nothing about its verdict.
301
462
  */
@@ -345,17 +506,29 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
345
506
  let hint = null;
346
507
  const loopHistory = [];
347
508
  const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
509
+ // From the `phase` row, not from literals here, so the table is the one place
510
+ // that answers "how may this child die". The row resolves to the same
511
+ // DEFAULT_LOOP_DETECTOR / DEFAULT_LOOP_PROGRESS this file used to hard-code.
512
+ const loopGuard = phasePolicy().guards.loop;
513
+ // Watchdog kills, NOT total strikes: a loop restart never saw the
514
+ // bound-your-command hint. Without the halving, three attempts at the default
515
+ // ceiling cost 45 minutes and nothing else bounds this path.
516
+ let hangKills = 0;
348
517
  for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
349
518
  // A cancel between attempts must not buy another spawn.
350
519
  if (deps.signal.aborted)
351
520
  throw new Error(USER_CANCELLED);
352
- const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
353
- const stall = new StallDetector();
354
- // Arm the churn rule BEFORE the first tool call. The window used to reach
355
- // the detector only through a context snapshot, and pi's stream never
356
- // carries one, so it was always 0 and rule 2 never fired (issue #16). The
357
- // parent knows the value at spawn time — say it then, not later.
358
- stall.noteContext(deps.contextWindow ?? 0);
521
+ const detector = loopGuard.detector === false ?
522
+ null
523
+ : new LoopDetector(loopGuard.detector.window, loopGuard.detector.threshold, loopGuard.detector.pathThreshold);
524
+ const stall = loopGuard.progress === false ?
525
+ null
526
+ : new StallDetector(loopGuard.progress.limit, loopGuard.progress.churnFactor);
527
+ // Arm the churn rule BEFORE the first tool call. pi's stream carries no
528
+ // context WINDOW, so a detector that waited to be told one would sit at 0,
529
+ // and the churn rule returns false on a non-positive window. The parent
530
+ // knows the value at spawn time — say it then, not later.
531
+ stall?.noteContext(deps.contextWindow ?? 0);
359
532
  const clock = phaseTimeout(deps.signal, budgetMs);
360
533
  let r;
361
534
  try {
@@ -365,14 +538,16 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
365
538
  signal: clock.signal,
366
539
  thinking,
367
540
  onContextUsage: snapshot => {
368
- // Real window or nothing: noteContext ignores 0, and until
369
- // deps.contextWindow existed 0 was all it ever saw, which
370
- // left the churn rule permanently disarmed (issue #16).
371
- stall.noteContext(snapshot.contextWindow);
541
+ // Real window or nothing: `noteContext` ignores 0, which is
542
+ // why the parent's value must be supplied at spawn — a
543
+ // stream that only ever reports 0 leaves the churn rule
544
+ // permanently disarmed.
545
+ stall?.noteContext(snapshot.contextWindow);
372
546
  deps.onContextUsage?.(snapshot);
373
547
  },
374
- onToolCall: call => detector.record(call) ?? stall.record(call),
375
- onToolResult: (text, isError) => stall.noteResult(text, isError)
548
+ commandCeilingMs: commandCeilingForAttempt(phasePolicy().guards['command-timeout'], hangKills),
549
+ onToolCall: call => detector?.record(call) ?? stall?.record(call) ?? null,
550
+ onToolResult: (text, isError) => stall?.noteResult(text, isError)
376
551
  }));
377
552
  }
378
553
  finally {
@@ -402,6 +577,36 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
402
577
  hint = r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
403
578
  continue;
404
579
  }
580
+ // Ordered after the loop rule and before the clock, matching RESTART_ORDER
581
+ // (worker-kill.ts): the loop hint names the offending call and is the more
582
+ // specific thing to tell a re-spawn, and a watchdog kill leaves the phase
583
+ // clock's own flag false, so the two cannot be confused.
584
+ if (r.commandKill) {
585
+ hangKills++;
586
+ if (attempt === MAX_LEAK_RETRIES) {
587
+ throw new CommandTimeoutError(name, r.commandKill);
588
+ }
589
+ deps.logDebug?.(`${name}: ${r.commandKill.toolName} outran `
590
+ + `${Math.round(r.commandKill.timeoutMs / 1000)}s — `
591
+ + `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
592
+ // Tracks the TOOLS, not the phase: verify-tooling holds `read,bash`,
593
+ // and a half-written node_modules survives the kill.
594
+ hint = commandTimeoutHint(r.commandKill.toolName, r.commandKill.timeoutMs, {
595
+ ...(r.commandKill.detail ? { commandDetail: r.commandKill.detail } : {}),
596
+ editsMayPersist: /\b(?:bash|edit|write)\b/.test(tools)
597
+ });
598
+ continue;
599
+ }
600
+ // A dead-backend kill spends a strike like the others; guardKillError says
601
+ // when the verdict has been earned.
602
+ const killed = guardKillError(name, r, { finalAttempt: attempt === MAX_LEAK_RETRIES });
603
+ if (killed)
604
+ throw killed;
605
+ if (r.stalled) {
606
+ deps.logDebug?.(`${name}: no output for the stall window and no endpoint answered — `
607
+ + `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
608
+ continue;
609
+ }
405
610
  if (clock.timedOut()) {
406
611
  if (attempt === MAX_LEAK_RETRIES) {
407
612
  throw new PhaseTimeoutError(name, budgetMs, MAX_LEAK_RETRIES + 1);
@@ -450,8 +655,8 @@ export function prependHint(hint, prompt) {
450
655
  * Append one line to the task file's `loop events` section.
451
656
  *
452
657
  * Best-effort by contract: it runs for EVERY phase child now that there is one
453
- * loop, and the six sites that used to reach the un-trailed wrapper do not all
454
- * own a task file on disk (a scripted harness, a bare unit deps bag). A trail
658
+ * loop, and not every caller owns a task file on disk (a scripted harness, a
659
+ * bare unit deps bag). A trail
455
660
  * that cannot be written must cost the phase nothing — the loop kill itself is
456
661
  * already reported through the debug log and the thrown LoopExhaustedError.
457
662
  */
@@ -480,11 +685,9 @@ async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
480
685
  */
481
686
  async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
482
687
  deps.logDebug?.(`${name}: loop budget exhausted — degrading to a no-tools final attempt`);
483
- // BEHAVIOUR DELTA. This attempt now runs under the same wall clock as the
484
- // strikes that led here. It used to pass `deps.signal` raw one of the
485
- // three drifts that came of reaching past bare `undefined`s to the later
486
- // positionals — so the one attempt made after a loop budget was spent was
487
- // also the one attempt that could hang forever.
688
+ // This attempt runs under the same wall clock as the strikes that led here.
689
+ // Passing `deps.signal` raw instead would make the one attempt taken after a
690
+ // loop budget is spent the one attempt that can hang forever.
488
691
  const clock = phaseTimeout(deps.signal, deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS);
489
692
  let r;
490
693
  try {
@@ -501,12 +704,17 @@ async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
501
704
  finally {
502
705
  clock.cleanup();
503
706
  }
707
+ // BEFORE the exit-code test, not inside it: a guard kill reports exit 0, so
708
+ // asking afterwards would already have returned the truncated text as this
709
+ // phase's deliverable.
710
+ const killed = guardKillError(name, r);
711
+ if (killed)
712
+ throw killed;
504
713
  if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
505
- // A wall-clock kill is NOT a loop. The clock above is new here, and
506
- // without this check a degrade that outran its budget was reported as
507
- // "loop budget exhausted", carrying a loop history that did not cause it
508
- // the same mislabel class the worker-kill roster exists to prevent, on
509
- // the very path that clock was added to guard.
714
+ // A wall-clock kill is NOT a loop. Without this check a child that outran
715
+ // its budget is reported as "loop budget exhausted", carrying a loop
716
+ // history that did not cause it the same mislabel class the worker-kill
717
+ // roster exists to prevent, on the very path the clock guards.
510
718
  if (clock.timedOut()) {
511
719
  throw new PhaseTimeoutError(name, deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS, 1);
512
720
  }
@@ -2,21 +2,22 @@
2
2
  * ChildStatus — the live status of the child pi currently running under a
3
3
  * status loader: its latest output line and its context usage.
4
4
  *
5
- * Four sites used to keep this state by hand — `let lastLine; let contextUsage;`
5
+ * Without it each spawn site keeps this state by hand — `let lastLine; let contextUsage;`
6
6
  * plus two callbacks (`onChildOutput` writes the line, `onContextUsage` folds a
7
7
  * snapshot through `resolveContextUsage` with the parent window), a reset before
8
- * every child, and a loader whose every tick read both — in `/task-auto`'s
9
- * planning `runChild`, `/task-plan`'s `child`, `buildGateDeps` (an accessor box
10
- * handed to `makeGateChild`), and the single-task `TaskRunner`. The first three
11
- * are one ritual and are now this class; the fourth stays where it is (see
12
- * `orchestrator.ts`: its state is the whole-run `WidgetState`, shared by
13
- * reference with `PhaseContext` and written by the phases themselves).
8
+ * every child, and a loader whose every tick reads both.
9
+ *
10
+ * Three sites share that ritual and are now this class: `/task-auto`'s planning
11
+ * `runChild`, `buildGateDeps`, and `/task-plan` one `new ChildStatus` each, and
12
+ * no others. The single-task `TaskRunner` deliberately stays where it is: its
13
+ * state is the whole-run `WidgetState`, shared by reference with `PhaseContext`
14
+ * and written by the phases themselves (see `orchestrator.ts`).
14
15
  *
15
16
  * `track` is the loader ritual: reset, raise the loader reading this status on
16
17
  * every tick, run, always stop. The status OUTLIVES a track — `buildGateDeps`
17
18
  * shares one across every gate child, and the verify gate raises its own
18
- * gate-wide loader over a child that renders none (`frame: null`), so both must
19
- * see the same object.
19
+ * gate-wide loader over a child that renders none (`frame: null`, reached when
20
+ * `deps.loader === false` in gate-child), so both must see the same object.
20
21
  */
21
22
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
22
23
  import type { ContextSnapshot } from '../shared/child-process.js';
@@ -55,12 +56,17 @@ export declare class ChildStatus {
55
56
  };
56
57
  /**
57
58
  * Run `run` under the loader: reset, raise a loader whose every tick is
58
- * `frame()` plus the live line and gauge, and stop it in a `finally` — a
59
- * throwing child must not leave the widget up. `frame` wins on a clash, which
60
- * is how the verify gate shows its deterministic-stage label until the child
61
- * has a line of its own. `frame: null` renders NO loader (the caller already
62
- * has one reading this status) but still resets, so the previous child's
63
- * trailer is cleared either way.
59
+ * `frame()` spread OVER the live line and gauge, and stop it in a `finally`.
60
+ * All four behaviours were run against a fake loader:
61
+ * - the reset lands first, so the previous child's line never reaches the
62
+ * new loader's first tick;
63
+ * - `frame` wins on a clash, because it is spread last which is how the
64
+ * verify gate shows its deterministic-stage label until the child has a
65
+ * line of its own;
66
+ * - `frame: null` raises NO loader at all (the caller already has one
67
+ * reading this status) but still resets;
68
+ * - a child that THROWS still stops the loader, so a failure never leaves
69
+ * the widget up.
64
70
  */
65
71
  track<T>(ctx: ExtensionCommandContext, frame: (() => AutoLoaderState) | null, run: () => Promise<T>): Promise<T>;
66
72
  }
@@ -98,6 +104,7 @@ export declare function runPlanningChild(opts: {
98
104
  }): Promise<string>;
99
105
  /**
100
106
  * Wire a `ChildStatus` as a phase child's stream callbacks — plus the window the
101
- * child must be TOLD, since pi's event stream never reports one (issue #16).
107
+ * child must be TOLD, since pi's `--mode json` stream reports token counts but no
108
+ * context window.
102
109
  */
103
110
  export declare function statusCallbacks(status: ChildStatus): Pick<PhaseDeps, 'onChildOutput' | 'onContextUsage' | 'contextWindow'>;