create-walle 0.10.0 → 0.10.2

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 (381) hide show
  1. package/README.md +88 -41
  2. package/lib/owner-write-safety.js +135 -0
  3. package/lib/public-runtime.js +13 -5
  4. package/lib/release-schema.json +3 -2
  5. package/lib/runtime-closure-policy.json +7 -5
  6. package/package.json +10 -4
  7. package/template/bin/deps-doctor.js +1 -1
  8. package/template/bin/node-bin.sh +11 -0
  9. package/template/coding-task-manager/api-prompts.js +10 -22
  10. package/template/coding-task-manager/bin/capture-codex-rollout-records.js +417 -0
  11. package/template/coding-task-manager/bin/census-codex-lead-history.js +325 -0
  12. package/template/coding-task-manager/bin/claude-hook.js +7 -3
  13. package/template/coding-task-manager/bin/claude-table-parity.js +307 -0
  14. package/template/coding-task-manager/bin/conversation-storage-auto-migration-lib.js +16 -1
  15. package/template/coding-task-manager/bin/conversation-storage-generation-evidence.js +9 -2
  16. package/template/coding-task-manager/bin/conversation-storage-service-entry-lib.js +16 -1
  17. package/template/coding-task-manager/bin/conversation-storage-walle-owner.js +247 -0
  18. package/template/coding-task-manager/bin/ctm-machine-pair.js +160 -0
  19. package/template/coding-task-manager/bin/lead-wait-census.js +1348 -0
  20. package/template/coding-task-manager/bin/repair-foreign-clone.js +564 -0
  21. package/template/coding-task-manager/bin/repair-resume-echo-titles.js +248 -0
  22. package/template/coding-task-manager/bin/seam-replay-codex.js +301 -0
  23. package/template/coding-task-manager/bin/write-lock-bench.js +111 -0
  24. package/template/coding-task-manager/db.js +705 -286
  25. package/template/coding-task-manager/git-utils.js +44 -0
  26. package/template/coding-task-manager/lib/adopted-host-redial.js +4 -0
  27. package/template/coding-task-manager/lib/agent-presets.js +15 -0
  28. package/template/coding-task-manager/lib/agent-title-apply.js +88 -0
  29. package/template/coding-task-manager/lib/agent-title-backfill-read.js +92 -0
  30. package/template/coding-task-manager/lib/api/host-stats.js +98 -0
  31. package/template/coding-task-manager/lib/api/ingress.js +35 -12
  32. package/template/coding-task-manager/lib/api/machine-pairing.js +285 -0
  33. package/template/coding-task-manager/lib/api/machine-pool.js +307 -0
  34. package/template/coding-task-manager/lib/api/machines.js +461 -0
  35. package/template/coding-task-manager/lib/api/misc-ops.js +20 -5
  36. package/template/coding-task-manager/lib/api/providers.js +53 -6
  37. package/template/coding-task-manager/lib/api/resume-from-store.js +25 -0
  38. package/template/coding-task-manager/lib/api/sessions.js +160 -8
  39. package/template/coding-task-manager/lib/api/transcript-history.js +17 -4
  40. package/template/coding-task-manager/lib/api/worktrees.js +38 -37
  41. package/template/coding-task-manager/lib/approval-drift.js +16 -1
  42. package/template/coding-task-manager/lib/approval-logical-frame.js +5 -1
  43. package/template/coding-task-manager/lib/approval-scope.js +68 -4
  44. package/template/coding-task-manager/lib/auth-context.js +32 -2
  45. package/template/coding-task-manager/lib/auth-rate-limit.js +41 -1
  46. package/template/coding-task-manager/lib/auth-rules.js +186 -45
  47. package/template/coding-task-manager/lib/broadcast-serialize.js +2 -1
  48. package/template/coding-task-manager/lib/browser-session-broker.js +9 -1
  49. package/template/coding-task-manager/lib/browser-use-adapter.js +33 -2
  50. package/template/coding-task-manager/lib/claude-markdown-ansi.js +27 -85
  51. package/template/coding-task-manager/lib/claude-session-title.js +6 -30
  52. package/template/coding-task-manager/lib/claude-table-layout.js +322 -0
  53. package/template/coding-task-manager/lib/client-diag-handler.js +25 -0
  54. package/template/coding-task-manager/lib/codex-config-guard.js +33 -3
  55. package/template/coding-task-manager/lib/codex-harness-qualification.js +12 -0
  56. package/template/coding-task-manager/lib/codex-history-terminal-renderer.js +33 -33
  57. package/template/coding-task-manager/lib/codex-launch-health.js +29 -2
  58. package/template/coding-task-manager/lib/codex-link-preparation.js +9 -8
  59. package/template/coding-task-manager/lib/codex-local-links.js +267 -0
  60. package/template/coding-task-manager/lib/codex-markdown-lists.js +160 -0
  61. package/template/coding-task-manager/lib/codex-native-history-prefix.js +4 -0
  62. package/template/coding-task-manager/lib/codex-paths.js +2 -1
  63. package/template/coding-task-manager/lib/codex-source-handoff.js +186 -0
  64. package/template/coding-task-manager/lib/coding-agent-models.js +21 -30
  65. package/template/coding-task-manager/lib/coding-session-model-adapters.js +27 -2
  66. package/template/coding-task-manager/lib/coding-session-model-projection.js +5 -0
  67. package/template/coding-task-manager/lib/coding-session-model-runtime.js +1 -2
  68. package/template/coding-task-manager/lib/coding-session-model-state.js +10 -0
  69. package/template/coding-task-manager/lib/coding-session-model-sync-state.js +0 -1
  70. package/template/coding-task-manager/lib/conversation-import/cursor-guard-owner.js +10 -0
  71. package/template/coding-task-manager/lib/create/resolve-launch-params.js +8 -1
  72. package/template/coding-task-manager/lib/ctm-boot-lifecycle.js +17 -1
  73. package/template/coding-task-manager/lib/db-domain-tables.js +7 -0
  74. package/template/coding-task-manager/lib/db-domains.js +21 -2
  75. package/template/coding-task-manager/lib/db-owner-op-registry.js +7 -0
  76. package/template/coding-task-manager/lib/db-owner-worker-client.js +22 -0
  77. package/template/coding-task-manager/lib/db-schema-source-files.js +1 -0
  78. package/template/coding-task-manager/lib/feature-manifest.js +1 -3
  79. package/template/coding-task-manager/lib/fs-watcher.js +3 -2
  80. package/template/coding-task-manager/lib/harness-history-transition.js +41 -14
  81. package/template/coding-task-manager/lib/headless-inflight-ops.js +203 -0
  82. package/template/coding-task-manager/lib/headless-missing-reply.js +42 -0
  83. package/template/coding-task-manager/lib/headless-term-service.js +60 -23
  84. package/template/coding-task-manager/lib/history-transition-read-op.js +179 -23
  85. package/template/coding-task-manager/lib/host-control-dial.js +167 -0
  86. package/template/coding-task-manager/lib/host-handoff-orchestrator.js +16 -2
  87. package/template/coding-task-manager/lib/host-handoff.js +51 -13
  88. package/template/coding-task-manager/lib/host-health-ledger.js +164 -12
  89. package/template/coding-task-manager/lib/host-self-truth-watchdog.js +4 -0
  90. package/template/coding-task-manager/lib/host-stats.js +328 -0
  91. package/template/coding-task-manager/lib/host-threadpool-evacuation.js +160 -0
  92. package/template/coding-task-manager/lib/host-threadpool-monitor.js +14 -1
  93. package/template/coding-task-manager/lib/hosted-input-dispatch.js +203 -0
  94. package/template/coding-task-manager/lib/hosted-sync-worker.js +113 -5
  95. package/template/coding-task-manager/lib/instance-id-ownership-manifest.js +4 -0
  96. package/template/coding-task-manager/lib/instance-manifest-runtime.js +3 -0
  97. package/template/coding-task-manager/lib/instance-manifest.js +13 -1
  98. package/template/coding-task-manager/lib/instance-sync-status.js +15 -0
  99. package/template/coding-task-manager/lib/kernel/diag-log.js +12 -0
  100. package/template/coding-task-manager/lib/launch-model-override.js +160 -1
  101. package/template/coding-task-manager/lib/launch-plan-worker.js +59 -2
  102. package/template/coding-task-manager/lib/lead-model-observer.js +17 -2
  103. package/template/coding-task-manager/lib/lead-model-switch-runtime.js +57 -4
  104. package/template/coding-task-manager/lib/lead-model-switch.js +11 -2
  105. package/template/coding-task-manager/lib/lead-model-transition-runtime.js +42 -4
  106. package/template/coding-task-manager/lib/lead-model-transition-store.js +61 -3
  107. package/template/coding-task-manager/lib/legacy-draft-cancellation-recovery.js +111 -0
  108. package/template/coding-task-manager/lib/login-tripwire.js +18 -0
  109. package/template/coding-task-manager/lib/machine-pairing.js +238 -0
  110. package/template/coding-task-manager/lib/machine-pool.js +409 -0
  111. package/template/coding-task-manager/lib/machine-registry.js +749 -0
  112. package/template/coding-task-manager/lib/machine-self.js +66 -0
  113. package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +56 -4
  114. package/template/coding-task-manager/lib/media-journal-control-reader.js +473 -0
  115. package/template/coding-task-manager/lib/media-journal.js +84 -27
  116. package/template/coding-task-manager/lib/media-store-options.js +43 -15
  117. package/template/coding-task-manager/lib/migrations/v56-host-samples-and-machine-pool.js +76 -0
  118. package/template/coding-task-manager/lib/mirror-feed-sanitize.js +12 -3
  119. package/template/coding-task-manager/lib/model-execution-recipe-metadata.js +16 -3
  120. package/template/coding-task-manager/lib/model-proxy/anthropic-face.js +4 -0
  121. package/template/coding-task-manager/lib/model-proxy/chat-response-translators.js +3 -0
  122. package/template/coding-task-manager/lib/model-proxy/collaboration-codec.js +94 -0
  123. package/template/coding-task-manager/lib/model-proxy/handoff.js +494 -0
  124. package/template/coding-task-manager/lib/model-proxy/lead-worker-picks.js +25 -0
  125. package/template/coding-task-manager/lib/model-proxy/native-lead-listener.js +61 -7
  126. package/template/coding-task-manager/lib/model-proxy/proxy-cli.js +200 -0
  127. package/template/coding-task-manager/lib/model-proxy/proxy-instance-record.js +257 -0
  128. package/template/coding-task-manager/lib/model-proxy/responses-face.js +24 -2
  129. package/template/coding-task-manager/lib/model-proxy/stream-accounting.js +205 -0
  130. package/template/coding-task-manager/lib/model-proxy/tokens.js +78 -0
  131. package/template/coding-task-manager/lib/model-proxy/upstream-outcome.js +9 -0
  132. package/template/coding-task-manager/lib/native-history-control.js +62 -14
  133. package/template/coding-task-manager/lib/native-lead-settings-client.js +2 -1
  134. package/template/coding-task-manager/lib/native-lead-tls.js +18 -4
  135. package/template/coding-task-manager/lib/oauth-proxy-supervisor.js +628 -64
  136. package/template/coding-task-manager/lib/owner-action-manifest-data.json +14 -0
  137. package/template/coding-task-manager/lib/painted-row-recorder.js +9 -1
  138. package/template/coding-task-manager/lib/prompt-admission-trace.js +69 -0
  139. package/template/coding-task-manager/lib/prompt-submission-contract.js +21 -1
  140. package/template/coding-task-manager/lib/provider-login-broker.js +192 -9
  141. package/template/coding-task-manager/lib/provider-login-capture.js +153 -0
  142. package/template/coding-task-manager/lib/queue-draft-status-overlay.js +36 -0
  143. package/template/coding-task-manager/lib/raw-codex-history-window.js +5 -3
  144. package/template/coding-task-manager/lib/raw-history-block.js +3 -1
  145. package/template/coding-task-manager/lib/read-pool-client.js +36 -24
  146. package/template/coding-task-manager/lib/read-pool-op-registry.js +10 -1
  147. package/template/coding-task-manager/lib/read-pool-telemetry.js +14 -5
  148. package/template/coding-task-manager/lib/recent-catalog-query.js +26 -0
  149. package/template/coding-task-manager/lib/recent-session-catalog-response.js +9 -0
  150. package/template/coding-task-manager/lib/remote-command-context.js +12 -0
  151. package/template/coding-task-manager/lib/remote-command-runtime.js +18 -5
  152. package/template/coding-task-manager/lib/remote-machine-catalog.js +1062 -0
  153. package/template/coding-task-manager/lib/remote-machine-create.js +693 -0
  154. package/template/coding-task-manager/lib/remote-machine-link.js +939 -0
  155. package/template/coding-task-manager/lib/remote-machine-proxy.js +520 -0
  156. package/template/coding-task-manager/lib/remote-session-forward.js +306 -0
  157. package/template/coding-task-manager/lib/remote-submission-status.js +6 -0
  158. package/template/coding-task-manager/lib/restart-outcome-truth.js +20 -3
  159. package/template/coding-task-manager/lib/restart-preflight.js +9 -2
  160. package/template/coding-task-manager/lib/restore-backoff-gate.js +10 -2
  161. package/template/coding-task-manager/lib/restore-prepare-retry.js +12 -5
  162. package/template/coding-task-manager/lib/restore-resume-batch.js +22 -1
  163. package/template/coding-task-manager/lib/resume-blocked-wire.js +62 -1
  164. package/template/coding-task-manager/lib/resume-cwd.js +13 -0
  165. package/template/coding-task-manager/lib/resume-from-store.js +12 -1
  166. package/template/coding-task-manager/lib/resume-origin-facts.js +38 -1
  167. package/template/coding-task-manager/lib/resume-preflight.js +21 -16
  168. package/template/coding-task-manager/lib/review-repo-admission.js +39 -6
  169. package/template/coding-task-manager/lib/review-repo-registry.js +17 -0
  170. package/template/coding-task-manager/lib/root-delivery-runtime.js +72 -15
  171. package/template/coding-task-manager/lib/root-delivery-selection.js +37 -1
  172. package/template/coding-task-manager/lib/root-prompt-admission.js +43 -11
  173. package/template/coding-task-manager/lib/root-prompt-flow.js +175 -38
  174. package/template/coding-task-manager/lib/scrollback-archive-store.js +7 -4
  175. package/template/coding-task-manager/lib/server-memory-sampler-runtime.js +10 -0
  176. package/template/coding-task-manager/lib/server-memory-sampler.js +41 -2
  177. package/template/coding-task-manager/lib/session-analysis-memory.js +2 -0
  178. package/template/coding-task-manager/lib/session-create/codex-native-provider-gate.js +218 -0
  179. package/template/coding-task-manager/lib/session-create/codex-resume-exit-classify.js +52 -0
  180. package/template/coding-task-manager/lib/session-create/delegate-worker.js +95 -6
  181. package/template/coding-task-manager/lib/session-create/identity.js +20 -1
  182. package/template/coding-task-manager/lib/session-create/prepared.js +69 -1
  183. package/template/coding-task-manager/lib/session-create/provider-launch-context.js +77 -0
  184. package/template/coding-task-manager/lib/session-create/provider-process.js +38 -39
  185. package/template/coding-task-manager/lib/session-create/response.js +30 -12
  186. package/template/coding-task-manager/lib/session-create/transcript.js +13 -4
  187. package/template/coding-task-manager/lib/session-create/worker-continuation.js +43 -1
  188. package/template/coding-task-manager/lib/session-create/worker-notices.js +94 -5
  189. package/template/coding-task-manager/lib/session-create/worker-observability.js +99 -7
  190. package/template/coding-task-manager/lib/session-create/worker-stop.js +11 -3
  191. package/template/coding-task-manager/lib/session-create/worker-wait-watcher.js +315 -0
  192. package/template/coding-task-manager/lib/session-geometry-authority-handler.js +10 -1
  193. package/template/coding-task-manager/lib/session-host-manager.js +152 -206
  194. package/template/coding-task-manager/lib/session-jobs.js +17 -9
  195. package/template/coding-task-manager/lib/session-lifecycle.js +20 -50
  196. package/template/coding-task-manager/lib/session-model-action-preconditions.js +68 -3
  197. package/template/coding-task-manager/lib/session-resource-sampler.js +67 -9
  198. package/template/coding-task-manager/lib/session-standup.js +67 -4
  199. package/template/coding-task-manager/lib/session-status-ledger.js +42 -3
  200. package/template/coding-task-manager/lib/session-status-runtime.js +45 -1
  201. package/template/coding-task-manager/lib/session-status.js +2 -2
  202. package/template/coding-task-manager/lib/session-title-mirror.js +25 -57
  203. package/template/coding-task-manager/lib/session-transcript-trash.js +8 -1
  204. package/template/coding-task-manager/lib/startup-pointer-write.js +57 -0
  205. package/template/coding-task-manager/lib/state-sync/painted-frame-field.js +11 -0
  206. package/template/coding-task-manager/lib/storage/coding-session-model-store.js +21 -22
  207. package/template/coding-task-manager/lib/surviving-owner-bootstrap.js +7 -2
  208. package/template/coding-task-manager/lib/surviving-owner-recovery.js +2 -1
  209. package/template/coding-task-manager/lib/terminal-draft-http-status.js +25 -0
  210. package/template/coding-task-manager/lib/transcript-archiver.js +229 -229
  211. package/template/coding-task-manager/lib/transcript-frame-cache.js +12 -2
  212. package/template/coding-task-manager/lib/transcript-guard-job.js +55 -4
  213. package/template/coding-task-manager/lib/transcript-link.js +77 -0
  214. package/template/coding-task-manager/lib/transcript-manifest-store.js +8 -4
  215. package/template/coding-task-manager/lib/transcript-source.js +8 -3
  216. package/template/coding-task-manager/lib/transcript-store.js +40 -0
  217. package/template/coding-task-manager/lib/tunnel-lane-governor.js +6 -3
  218. package/template/coding-task-manager/lib/typing-interaction-trace.js +136 -2
  219. package/template/coding-task-manager/lib/typing-worker-stages.js +208 -1
  220. package/template/coding-task-manager/lib/usage-ledger/claude-parse.js +53 -2
  221. package/template/coding-task-manager/lib/usage-ledger/ingest.js +13 -4
  222. package/template/coding-task-manager/lib/usage-ledger/read.js +14 -0
  223. package/template/coding-task-manager/lib/usage-ledger/schema.js +49 -1
  224. package/template/coding-task-manager/lib/usage-ledger/store.js +337 -6
  225. package/template/coding-task-manager/lib/usage-ledger/task-read.js +123 -5
  226. package/template/coding-task-manager/lib/visible-revision-watchdog.js +8 -12
  227. package/template/coding-task-manager/lib/wake-skip-reasons.js +46 -0
  228. package/template/coding-task-manager/lib/walle-supervisor.js +98 -14
  229. package/template/coding-task-manager/lib/worker-failure-classify.js +13 -1
  230. package/template/coding-task-manager/lib/ws/input.js +1 -0
  231. package/template/coding-task-manager/lib/ws/state-sync.js +19 -2
  232. package/template/coding-task-manager/lib/ws/terminal-attach.js +32 -1
  233. package/template/coding-task-manager/lib/ws-router.js +68 -1
  234. package/template/coding-task-manager/lib/ws-send-admission.js +22 -4
  235. package/template/coding-task-manager/oauth-proxy.js +99 -55
  236. package/template/coding-task-manager/package-lock.json +24 -0
  237. package/template/coding-task-manager/package.json +3 -0
  238. package/template/coding-task-manager/protocol/message-table.json +0 -10
  239. package/template/coding-task-manager/protocol/messages.js +0 -10
  240. package/template/coding-task-manager/protocol/ws-schema.json +0 -20
  241. package/template/coding-task-manager/protocol/ws-schema.md +0 -2
  242. package/template/coding-task-manager/providers/codex-mcp-extract.js +121 -19
  243. package/template/coding-task-manager/providers/codex-mcp.js +14 -5
  244. package/template/coding-task-manager/public/css/app.css +279 -2
  245. package/template/coding-task-manager/public/css/reviews.css +4 -0
  246. package/template/coding-task-manager/public/index.html +297 -240
  247. package/template/coding-task-manager/public/js/app/machines-page.js +1252 -0
  248. package/template/coding-task-manager/public/js/app/models-tab.js +546 -36
  249. package/template/coding-task-manager/public/js/app/worktrees-panel.js +7 -5
  250. package/template/coding-task-manager/public/js/background-work.js +13 -7
  251. package/template/coding-task-manager/public/js/client-health-core.js +81 -0
  252. package/template/coding-task-manager/public/js/client-health-main.js +61 -0
  253. package/template/coding-task-manager/public/js/coding-session-model-presentation.js +44 -4
  254. package/template/coding-task-manager/public/js/core/session-commands.js +676 -0
  255. package/template/coding-task-manager/public/js/core/session-selectors.js +5 -1
  256. package/template/coding-task-manager/public/js/history-app-seam.js +26 -2
  257. package/template/coding-task-manager/public/js/history-painted-rows.js +17 -2
  258. package/template/coding-task-manager/public/js/history-reader-park.js +13 -1
  259. package/template/coding-task-manager/public/js/history-scroller.js +187 -9
  260. package/template/coding-task-manager/public/js/history-transcript-pages.js +5 -5
  261. package/template/coding-task-manager/public/js/machine-pool-shared.js +436 -0
  262. package/template/coding-task-manager/public/js/private-state-scope.js +31 -3
  263. package/template/coding-task-manager/public/js/prompt-submission-client.js +173 -4
  264. package/template/coding-task-manager/public/js/prompt-submission-tray.js +281 -0
  265. package/template/coding-task-manager/public/js/protocol.js +0 -10
  266. package/template/coding-task-manager/public/js/queue-panel-submission-reconcile.js +174 -1
  267. package/template/coding-task-manager/public/js/reviews.js +7 -3
  268. package/template/coding-task-manager/public/js/root-draft-lifetime.js +139 -2
  269. package/template/coding-task-manager/public/js/root-queue-builder-lifetime.js +16 -0
  270. package/template/coding-task-manager/public/js/row-join.js +18 -3
  271. package/template/coding-task-manager/public/js/session-model-picker.js +1 -1
  272. package/template/coding-task-manager/public/js/session-search-utils.js +46 -19
  273. package/template/coding-task-manager/public/js/session-usage-view.js +1 -0
  274. package/template/coding-task-manager/public/js/state-sync-client.js +4 -0
  275. package/template/coding-task-manager/public/js/terminal/runtime-02.js +16 -7
  276. package/template/coding-task-manager/public/js/terminal/runtime-03.js +41 -0
  277. package/template/coding-task-manager/public/js/terminal/runtime-04.js +41 -4
  278. package/template/coding-task-manager/public/js/terminal/runtime-12.js +52 -2
  279. package/template/coding-task-manager/public/js/terminal/runtime-16.js +12 -12
  280. package/template/coding-task-manager/public/js/terminal/runtime-18.js +14 -0
  281. package/template/coding-task-manager/public/js/terminal/runtime-19.js +8 -4
  282. package/template/coding-task-manager/public/js/terminal/runtime-20.js +210 -73
  283. package/template/coding-task-manager/public/js/terminal/runtime-21.js +19 -6
  284. package/template/coding-task-manager/public/js/terminal/runtime-22.js +86 -0
  285. package/template/coding-task-manager/public/js/terminal-history-viewport.js +1 -3
  286. package/template/coding-task-manager/public/js/terminal-wheel.js +39 -2
  287. package/template/coding-task-manager/public/js/walle/chat-conversation.js +11 -3
  288. package/template/coding-task-manager/public/js/walle-session.js +30 -7
  289. package/template/coding-task-manager/public/m/app.css +226 -8
  290. package/template/coding-task-manager/public/m/app.js +502 -9
  291. package/template/coding-task-manager/public/m/index.html +38 -1
  292. package/template/coding-task-manager/public/m/machines.js +1313 -0
  293. package/template/coding-task-manager/public/m/pool-failover.js +391 -0
  294. package/template/coding-task-manager/public/m/private-queue-actions.js +26 -7
  295. package/template/coding-task-manager/public/m/sw.js +5 -0
  296. package/template/coding-task-manager/queue-engine.js +13 -0
  297. package/template/coding-task-manager/server/approvals.js +5 -6
  298. package/template/coding-task-manager/server/boot.js +136 -17
  299. package/template/coding-task-manager/server.js +964 -425
  300. package/template/coding-task-manager/session-integrity.js +7 -9
  301. package/template/coding-task-manager/workers/db-owner-worker.js +53 -7
  302. package/template/coding-task-manager/workers/read-pool-worker.js +17 -6
  303. package/template/coding-task-manager/workers/session-host-pool-process.js +18 -4
  304. package/template/coding-task-manager/workers/session-host-process.js +14 -3
  305. package/template/coding-task-manager/workers/transcript-archive-worker.js +1 -1
  306. package/template/coding-task-manager/workers/worktree-worker.js +34 -0
  307. package/template/package-lock.json +2 -2
  308. package/template/package.json +1 -1
  309. package/template/scripts/conversation-storage-v2-migration.js +78 -11
  310. package/template/shared/conversation-storage-v2-contract.js +42 -6
  311. package/template/shared/fixtures/worker-status-payloads.json +392 -0
  312. package/template/shared/git-remote-key.js +83 -0
  313. package/template/shared/host-identity.js +98 -2
  314. package/template/shared/owner-write-safety.js +135 -0
  315. package/template/shared/public-runtime.js +13 -5
  316. package/template/shared/release-schema.json +3 -2
  317. package/template/shared/report-citations.js +14 -0
  318. package/template/shared/sqlite-write-lock.js +361 -223
  319. package/template/shared/storage/contract.js +35 -3
  320. package/template/wall-e/agent.js +21 -0
  321. package/template/wall-e/api-walle.js +5 -0
  322. package/template/wall-e/bin/brain-purge-legacy-ctm.js +3 -2
  323. package/template/wall-e/bin/walle-mcp-stdio.js +12 -6
  324. package/template/wall-e/brain/helper-rules-schema.js +129 -0
  325. package/template/wall-e/brain/migrations.js +51 -22
  326. package/template/wall-e/brain/model-deployment-facts.js +31 -1
  327. package/template/wall-e/brain/owner-call-whitelist.js +16 -0
  328. package/template/wall-e/brain/schema-ensures.js +22 -0
  329. package/template/wall-e/brain.js +1 -1
  330. package/template/wall-e/coding/host-capabilities.js +2 -2
  331. package/template/wall-e/coding/journal-dispatcher.js +51 -0
  332. package/template/wall-e/coding/session-journal.js +28 -40
  333. package/template/wall-e/coding-orchestrator.js +23 -26
  334. package/template/wall-e/fly.toml +1 -1
  335. package/template/wall-e/http/auth.js +18 -1
  336. package/template/wall-e/http/blog-views-api.js +318 -0
  337. package/template/wall-e/http/helper-rules-api.js +321 -0
  338. package/template/wall-e/http/model-proxy-providers.js +3 -1
  339. package/template/wall-e/http/telemetry-api.js +12 -0
  340. package/template/wall-e/lib/blog-view-policy.js +88 -0
  341. package/template/wall-e/lib/blog-view-store.js +169 -0
  342. package/template/wall-e/lib/client-config-lock.js +2 -1
  343. package/template/wall-e/lib/coding-journal-writer-child-client.js +67 -18
  344. package/template/wall-e/lib/error-kinds.js +10 -1
  345. package/template/wall-e/lib/feature-manifest.js +1 -1
  346. package/template/wall-e/lib/helper-rules/cell-state.js +123 -0
  347. package/template/wall-e/lib/helper-rules/evaluate.js +357 -0
  348. package/template/wall-e/lib/helper-rules/explore-rubric.md +12 -0
  349. package/template/wall-e/lib/helper-rules/grade.js +277 -0
  350. package/template/wall-e/lib/helper-rules/label.js +247 -0
  351. package/template/wall-e/lib/helper-rules/lead-call-hook.js +522 -0
  352. package/template/wall-e/lib/helper-rules/store.js +667 -0
  353. package/template/wall-e/lib/mcp-integration.js +5 -6
  354. package/template/wall-e/lib/runtime-worker-pool.js +1 -0
  355. package/template/wall-e/lib/scheduler-worker-jobs.js +6 -0
  356. package/template/wall-e/lib/telemetry-db-schema.js +37 -0
  357. package/template/wall-e/lib/telemetry-process-client.js +76 -1
  358. package/template/wall-e/llm/default-fallback.js +4 -0
  359. package/template/wall-e/llm/model-catalog.js +13 -4
  360. package/template/wall-e/llm/model-roles.js +6 -0
  361. package/template/wall-e/llm/portkey-http.js +150 -0
  362. package/template/wall-e/llm/portkey-sync.js +15 -145
  363. package/template/wall-e/llm/worker-mcp-routing.js +61 -0
  364. package/template/wall-e/llm/worker-route.js +43 -1
  365. package/template/wall-e/loops/brain-custom-writes.js +45 -0
  366. package/template/wall-e/mcp-protocol-contract.js +41 -1
  367. package/template/wall-e/mcp-server.js +13 -2
  368. package/template/wall-e/mcp-worker-schemas.js +20 -2
  369. package/template/wall-e/memory/ctm-context-client.js +25 -5
  370. package/template/wall-e/shared/release-schema.json +3 -2
  371. package/template/wall-e/shared/sqlite-write-lock.js +361 -223
  372. package/template/wall-e/shared/storage/contract.js +35 -3
  373. package/template/wall-e/skills/_templates/delegate-worker/VERSION +1 -1
  374. package/template/wall-e/skills/_templates/delegate-worker/released-sha256.json +6 -0
  375. package/template/wall-e/skills/_templates/delegate-worker/templates/claude/SKILL.md +7 -2
  376. package/template/wall-e/skills/_templates/delegate-worker/templates/codex/SKILL.md +7 -2
  377. package/template/wall-e/tools/local-tools.js +12 -5
  378. package/template/wall-e/workers/brain-owner-worker.js +9 -0
  379. package/template/wall-e/workers/coding-journal-writer-child.js +8 -1
  380. package/template/wall-e/workers/telemetry-worker.js +8 -0
  381. package/template/coding-task-manager/lib/giant-freeze-park.js +0 -31
@@ -25,11 +25,26 @@ const crypto = require('node:crypto');
25
25
  const { spawn } = require('node:child_process');
26
26
  const { resolveRealNode } = require('./real-node');
27
27
  const { getDataDir } = require('./data-dir');
28
+ const { readExitRecord, clearExitRecord } = require('./model-proxy/stream-accounting');
29
+ const instanceRecord = require('./model-proxy/proxy-instance-record');
30
+ const { hostRuntimeCompatibilityDecision } = require('./host-runtime-compatibility');
31
+ const { hashClosureFiles } = require('./host-closure-hash');
32
+ const { HANDOFF_READY_DEADLINE_MS } = require('./model-proxy/handoff');
28
33
  const walleClient = require('./walle-client');
29
34
 
30
35
  const PROXY_SCRIPT = path.resolve(__dirname, '..', 'oauth-proxy.js');
31
36
  const DEFAULT_PORT = parseInt(process.env.OAUTH_PROXY_PORT || '3459', 10);
32
37
  const ADMIN_HEADER = 'X-CTM-Proxy-Admin';
38
+ // §5 D-3/§6: the proxy's own identity protocol and floor. A proxy that reports a different floor is not
39
+ // adoptable at all (hostRuntimeCompatibilityDecision), which is what makes a WP-2 successor safe to run
40
+ // beside its predecessor.
41
+ const PROXY_PROTOCOL = instanceRecord.PROXY_PROTOCOL;
42
+ const PROXY_FLOOR_VERSION = instanceRecord.PROXY_FLOOR_VERSION;
43
+ // hostRuntimeCompatibilityDecision() compares protocol VERSIONS numerically (its canonical caller passes
44
+ // host-registry's integer protocol), while /_health reports the wire NAME. The name is mapped to the
45
+ // version it names here, so an identical proxy answers 'compatible' rather than the 'upgrade-pending' a
46
+ // NaN-to-NaN comparison would produce for every restart -- which would schedule a handoff each time.
47
+ const PROXY_PROTOCOL_VERSION = 1;
33
48
  const SAFE_LEAD_OWNER_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,255}$/;
34
49
  const SAFE_LEAD_DEPLOYMENT_ID = /^[@A-Za-z0-9][A-Za-z0-9._:/@+-]{0,159}$/;
35
50
  // WP-0A (claude-native-auth-mtls-and-routed-subagents-2026-10-15 §6.2, D-2/D-7): the immutable
@@ -74,6 +89,21 @@ const _pendingLeadRevokes = new Set();
74
89
  // tokens. A tight crash loop must not become a spawn storm.
75
90
  const LEAD_RESTART_MIN_INTERVAL_MS = 10_000;
76
91
  let _lastLeadAutoRestartAt = 0;
92
+ // §5 D-6: how often CTM asks whether the ADOPTED proxy is still there. It is
93
+ // LEAD_RESTART_MIN_INTERVAL_MS by value ON PURPOSE: a probe that found a death
94
+ // faster than the restart rate limit could only be answered by the suppression
95
+ // line below, so probing faster would buy nothing but wakeups.
96
+ const PROXY_LIVENESS_MS = 10_000;
97
+ // The signal name a probe-found death carries into _onProxyGone(). It is not a
98
+ // real signal: it is what makes an adopted death "unexpected" to the ONE
99
+ // recovery branch below, exactly as a SIGKILL of a spawned child is.
100
+ const ADOPTED_PID_GONE = 'ADOPTED_PID_GONE';
101
+ // The armed probe, or null. Module memory only: it is a live timer, and it is
102
+ // dropped on stop()/_resetForTest() and whenever the mode leaves 'adopted'.
103
+ let _livenessTimer = null;
104
+ // §6 test seam: the cadence a test can shrink, so AC-3a's 10 s bound is not a
105
+ // 10 s test. Production never writes it.
106
+ let _livenessProbeMs = PROXY_LIVENESS_MS;
77
107
  // Set by stop() right before it kills the child, cleared by the child's 'exit'
78
108
  // handler. A graceful stop must never trigger the lead-token auto-restart.
79
109
  let _stopping = false;
@@ -97,22 +127,141 @@ let _adminSecret = null;
97
127
  // child's environment, and a request for a DIFFERENT directory while a child is running is refused
98
128
  // rather than silently retargeted by an implicit restart.
99
129
  let _activeChildDataDir = null;
130
+ // §5 D-10/§9 S-12: the last-exit record read by the most recent start(), or null when the previous exit
131
+ // left none. Module memory only, and consumed by lastRestartOutcome() for the restore_complete row.
132
+ let _lastExitRecord = null;
133
+ // The in-flight read+clear of that record; a test awaits it before asserting lastRestartOutcome().
134
+ let _exitRecordLoad = Promise.resolve();
135
+
136
+ // §5 D-4: which identity this module is holding. 'child' = a proxy THIS CTM spawned (the only mode that
137
+ // owns a process handle); 'adopting'/'adopted' = a surviving proxy found through its instance record,
138
+ // which CTM did not spawn and therefore only ever signals by ps-verified pid; 'stopped' = nothing.
139
+ let _mode = 'stopped';
140
+ let _adopted = null;
141
+ // The in-flight adoption decision (record → ps → /_health → secret → compatibility). waitForHealth()
142
+ // awaits it, because every caller already follows start() with waitForHealth().
143
+ let _adoptionPromise = null;
144
+ // Adoption attempt sequence: stop() and a newer start() bump it, so a probe that lands afterwards cannot
145
+ // adopt a proxy CTM has already given up on (or spawn a second child beside a fresh one).
146
+ let _adoptionSeq = 0;
147
+
148
+ function _ownedPid() {
149
+ if (_mode === 'child') return _child ? _child.pid : null;
150
+ return _adopted ? _adopted.pid : null;
151
+ }
100
152
 
101
153
  function _ensureShutdownHook() {
102
154
  if (_shutdownRegistered) return;
103
155
  _shutdownRegistered = true;
156
+ // §5 D-5 (+ hosts' D-A6): a CTM exit or SIGTERM is a RECONNECT for the proxy, never a stop -- the
157
+ // surviving proxy is adopted by the next CTM (§5 D-4), so its in-flight streams are never cut. SIGINT
158
+ // is the one verb an operator expects to stop everything: it reaches a foreground `node server.js`,
159
+ // and the primary under launchd never receives one. stop() knows both identities (a child this CTM
160
+ // spawned, or a survivor it adopted) and also reaps the record and secret, so Ctrl-C leaves nothing
161
+ // for the next boot to adopt.
104
162
  const stopOnExit = () => {
105
- if (_child && !_child.killed) {
106
- try { _child.kill('SIGTERM'); } catch {}
107
- }
163
+ try { stop(); } catch { /* expected: the process is exiting; a failed signal changes nothing */ }
108
164
  };
109
- process.on('exit', stopOnExit);
110
165
  process.on('SIGINT', stopOnExit);
111
- process.on('SIGTERM', stopOnExit);
112
166
  }
113
167
 
114
168
  function isRunning() {
115
- return !!(_child && !_child.killed && _child.exitCode === null);
169
+ if (_mode === 'child') return !!(_child && !_child.killed && _child.exitCode === null);
170
+ return _mode === 'adopted' || _mode === 'adopting';
171
+ }
172
+
173
+ // ── §5 D-6: liveness in adopted mode ─────────────────────────────────────
174
+ // A proxy THIS CTM spawned reports its own death: the child handle has an 'exit' event. An ADOPTED proxy
175
+ // has no handle -- it is somebody else's process that outlived the CTM that started it -- so nothing but
176
+ // CTM's own probe can notice the pid going away. That is the whole of D-6: one timer, the same
177
+ // _onProxyGone() handler, and the same one rate-limited restart on the other side of it.
178
+
179
+ function _clearLivenessProbe() {
180
+ if (_livenessTimer) clearTimeout(_livenessTimer);
181
+ _livenessTimer = null;
182
+ }
183
+
184
+ /** §6 test seam: shrink the probe cadence. Pass null (or anything not a positive integer) to restore. */
185
+ function _setLivenessProbeMsForTest(ms) {
186
+ _livenessProbeMs = Number.isSafeInteger(ms) && ms > 0 ? ms : PROXY_LIVENESS_MS;
187
+ }
188
+
189
+ /**
190
+ * §5 D-6: arm the ONE probe for the proxy currently adopted. Re-arming replaces the timer, so a call
191
+ * after a WP-2 handoff watches the SUCCESSOR's pid and not the predecessor's.
192
+ */
193
+ function _armLivenessProbe({ port, dataDir }) {
194
+ _clearLivenessProbe();
195
+ if (_mode !== 'adopted' || !_adopted) return;
196
+ _livenessTimer = setTimeout(() => {
197
+ _livenessTimer = null;
198
+ // Re-checked at fire time: a stop() or a newer adoption in between owns the state now.
199
+ if (_mode !== 'adopted' || !_adopted) return;
200
+ const pid = _adopted.pid;
201
+ if (instanceRecord.isProcessAlive(pid)) {
202
+ _armLivenessProbe({ port, dataDir });
203
+ return;
204
+ }
205
+ _onProxyGone({ code: null, signal: ADOPTED_PID_GONE, port, dataDir });
206
+ }, _livenessProbeMs);
207
+ // §5 D-6: a probe is never a reason for CTM to stay alive.
208
+ _livenessTimer.unref();
209
+ }
210
+
211
+ /**
212
+ * §5 D-6: an admin call that cannot reach an ADOPTED proxy is the cheapest probe there is -- run the same
213
+ * test the timer runs, so a dead proxy is noticed now instead of on the next tick. Only the two codes that
214
+ * mean "the process is not serving" count; a timeout is not evidence of death.
215
+ */
216
+ function _probeAdoptedAfterAdminFailure({ port, err }) {
217
+ const code = (err && err.cause && err.cause.code) || (err && err.code);
218
+ if (code !== 'ECONNREFUSED' && code !== 'ECONNRESET') return;
219
+ if (_mode !== 'adopted' || !_adopted) return;
220
+ // Without the directory the record on disk belongs to, the recovery below cannot reap it and a restart
221
+ // would target the wrong root -- so the probe stays silent rather than acting on a guess.
222
+ if (!_activeChildDataDir) return;
223
+ if (instanceRecord.isProcessAlive(_adopted.pid)) return;
224
+ _onProxyGone({ code: null, signal: ADOPTED_PID_GONE, port, dataDir: _activeChildDataDir });
225
+ }
226
+
227
+ /**
228
+ * §5 D-10 / §9 S-12: the proxy's own restart outcome, as the restore_complete ledger row reports it
229
+ * (`adoption.proxy`). Exactly eight keys, so lib/restart-ledger.js's boundedRecordDetail keeps it whole.
230
+ * `action` is 'spawn' for the fresh child start() created and 'adopt' for the surviving proxy it took
231
+ * over; the previous-exit fields come from the record that spawn consumed and are null when the previous
232
+ * exit left none -- an ADOPTED proxy never exited, so a stale exit record must not be reported as this
233
+ * restart's cut (WP-1 §5 D-5).
234
+ */
235
+ function lastRestartOutcome() {
236
+ // Only a DECIDED adoption is reported as one; while the decision is in flight the fallback may still
237
+ // be a spawn, and the restore_complete row must not name an outcome that has not happened yet.
238
+ if (_adopted && _mode === 'adopted') {
239
+ return {
240
+ // §5 D-7 (WP-2): an adoption that also performed a handoff is reported as one, with the measured
241
+ // handoff time and the SUCCESSOR's pid/generation (a handoff always bumps the generation).
242
+ // `drainForcedCuts` stays null at restore time on purpose: the predecessor's drain finishes later,
243
+ // in its own process, and its count is only in the old proxy's log line.
244
+ action: _adopted.handoffMs == null ? 'adopt' : 'handoff',
245
+ pid: _adopted.pid,
246
+ generation: _adopted.generation,
247
+ openStreamsAtExit: null,
248
+ handoffMs: _adopted.handoffMs == null ? null : _adopted.handoffMs,
249
+ drainForcedCuts: null,
250
+ previousPid: null,
251
+ previousExitAt: null,
252
+ };
253
+ }
254
+ const record = _lastExitRecord;
255
+ return {
256
+ action: 'spawn',
257
+ pid: isRunning() ? _ownedPid() : null,
258
+ generation: 1,
259
+ openStreamsAtExit: record ? record.openStreams : null,
260
+ handoffMs: null,
261
+ drainForcedCuts: null,
262
+ previousPid: record ? record.pid : null,
263
+ previousExitAt: record ? record.at : null,
264
+ };
116
265
  }
117
266
 
118
267
  function start({ port = DEFAULT_PORT, dataDir } = {}) {
@@ -122,12 +271,155 @@ function start({ port = DEFAULT_PORT, dataDir } = {}) {
122
271
  if (_activeChildDataDir && _activeChildDataDir !== resolvedDataDir) {
123
272
  // No implicit restart: the running child owns its directory (and any mounted authority, tokens and
124
273
  // worker budgets that came with it). The caller must stop it explicitly.
125
- return { ok: false, running: true, port, pid: _child.pid, reason: 'data_dir_mismatch' };
274
+ return { ok: false, running: true, port, pid: _ownedPid(), reason: 'data_dir_mismatch' };
126
275
  }
127
- return { ok: true, running: true, port, pid: _child.pid, alreadyRunning: true };
276
+ return { ok: true, running: true, port, pid: _ownedPid(), alreadyRunning: true };
128
277
  }
129
278
  _ensureShutdownHook();
130
279
  _lastError = null;
280
+ // §5 D-4 (+ D-11, D-12): ADOPT BEFORE SPAWN, on the boot path and on the crash-recovery path alike. A
281
+ // record that names a live, ps-verified proxy on THIS port and in THIS data directory is the previous
282
+ // process answering; it is taken over instead of being replaced (and its in-flight streams are never
283
+ // cut). Anything else -- a dead pid, another port, a foreign process that reused the pid -- is stale
284
+ // truth and is removed before the fresh spawn below. The decision itself is synchronous because six
285
+ // callers and the boot log read start()'s return value; only the HTTP half is asynchronous.
286
+ const record = instanceRecord.readInstanceRecord({ dataDir: resolvedDataDir });
287
+ if (record && record.port === port && instanceRecord.isProxyProcess(record.pid)) {
288
+ _mode = 'adopting';
289
+ _adopted = { pid: record.pid, generation: record.generation, closureHash: null, decision: null };
290
+ _adminSecret = null;
291
+ _activeChildDataDir = resolvedDataDir;
292
+ _stopping = false;
293
+ const seq = (_adoptionSeq += 1);
294
+ _adoptionPromise = _finishAdoption({ port, dataDir: resolvedDataDir, record, seq });
295
+ return { ok: true, running: true, port, pid: record.pid, mode: 'adopting' };
296
+ }
297
+ if (record) instanceRecord.unlinkInstanceRecord({ dataDir: resolvedDataDir });
298
+ return _spawnFresh({ port, resolvedDataDir });
299
+ }
300
+
301
+ /**
302
+ * §5 D-4: the asynchronous half of an adoption -- the secret read, the two loopback probes and the
303
+ * compatibility verdict. On success the proxy keeps running under this CTM; on any failure the record is
304
+ * reaped and the caller's start() falls through to a fresh spawn, so the next isRunning()/waitForHealth()
305
+ * sees a child. WP-1 adopts an `upgrade-pending` proxy and leaves it running: the handoff that would
306
+ * replace it is WP-2.
307
+ */
308
+ async function _finishAdoption({ port, dataDir, record, seq }) {
309
+ try {
310
+ const secret = instanceRecord.readAdminSecret({ dataDir });
311
+ const health = secret ? await _probeHealth(port) : null;
312
+ const authed = secret ? await _probeAdminSecret({ port, secret }) : false;
313
+ const decision = health && authed && health.pid === record.pid
314
+ ? hostRuntimeCompatibilityDecision({
315
+ expectedProtocol: PROXY_PROTOCOL_VERSION,
316
+ actualProtocol: health.protocol === PROXY_PROTOCOL ? PROXY_PROTOCOL_VERSION : 0,
317
+ expectedFloor: PROXY_FLOOR_VERSION,
318
+ actualFloor: health.floor_version,
319
+ diskClosureHash: _hashClosureFiles(health.closure_files),
320
+ hostClosureHash: health.closure_hash,
321
+ })
322
+ : { compatible: false, reason: secret ? 'proxy_not_answering' : 'admin_secret_missing' };
323
+ if (seq !== _adoptionSeq) return; // a stop() or a newer start() superseded this attempt
324
+ if (decision.compatible) {
325
+ _mode = 'adopted';
326
+ _adopted = { pid: record.pid, generation: record.generation, closureHash: health.closure_hash, decision: decision.reason };
327
+ _adminSecret = secret;
328
+ _activeChildDataDir = dataDir;
329
+ // §5 D-5: the previous proxy did not exit, so there is no previous exit to report -- and a
330
+ // last-exit.json left behind by some earlier stop must not be attributed to this restart.
331
+ _lastExitRecord = null;
332
+ _exitRecordLoad = Promise.resolve();
333
+ // D-4 step 7: replay into the adopted proxy exactly as the fresh-spawn path does; the token
334
+ // registry and the pick table live in CTM, the adopted proxy's own table survived with it.
335
+ await _replayLeadTokens({ port });
336
+ process.stderr.write(`[oauth-proxy-supervisor] adopted proxy pid=${record.pid} generation=${record.generation} decision=${decision.reason}\n`);
337
+ // §5 D-7 (WP-2): `upgrade-pending` means the proxy answering on the wire is not the code on disk,
338
+ // so it is asked ONCE to hand its listening sockets and its memory-only state to a successor it
339
+ // spawns from that disk. Adopting first (above) is what makes a refusal harmless: the adopted
340
+ // proxy keeps serving, and the next restart asks again.
341
+ if (decision.reason === 'upgrade-pending') await _performUpgradeHandoff({ port });
342
+ // §5 D-6, LAST: after the handoff above, `_adopted.pid` is the SUCCESSOR's, which is the process
343
+ // that will be answering from here on. Nothing before this point may probe -- the adoption is not
344
+ // decided yet (and _pollHealth, never waitForHealth, is what the steps above may await).
345
+ _armLivenessProbe({ port, dataDir });
346
+ return;
347
+ }
348
+ // Rejected: reap the record. The pid is signalled ONLY when ps proved it is this proxy -- a language
349
+ // runtime is free to hand a dead proxy's pid to an unrelated process, and that process is not ours.
350
+ process.stderr.write(`[oauth-proxy-supervisor] proxy record rejected: ${decision.reason}\n`);
351
+ instanceRecord.unlinkInstanceRecord({ dataDir });
352
+ try { process.kill(record.pid, 'SIGTERM'); } catch { /* expected: the pid may be gone; the record is already reaped */ }
353
+ } catch (err) {
354
+ // REPORT: an adoption that throws is refused like any other; the spawn below is the fallback.
355
+ process.stderr.write(`[oauth-proxy-supervisor] adoption failed: ${String(err && err.message || err).slice(0, 200)}\n`);
356
+ instanceRecord.unlinkInstanceRecord({ dataDir });
357
+ }
358
+ if (seq !== _adoptionSeq) return;
359
+ _mode = 'stopped';
360
+ _adopted = null;
361
+ _adminSecret = null;
362
+ _activeChildDataDir = null;
363
+ _adoptionPromise = null;
364
+ _spawnFresh({ port, resolvedDataDir: dataDir });
365
+ }
366
+
367
+ /**
368
+ * §5 D-7 (WP-2, AC-2f): the ONE handoff a `upgrade-pending` adoption schedules. On 202 the module
369
+ * switches to the successor's identity and replays CTM's lead registry and pick table into it exactly as
370
+ * the fresh-spawn path does (idempotent: the rows that survived with the predecessor are re-registered
371
+ * verbatim). A refusal or a throw is REPORTED and the adopted proxy is kept -- it still serves, and there
372
+ * is nothing here worth failing a boot over.
373
+ */
374
+ async function _performUpgradeHandoff({ port }) {
375
+ let handed = null;
376
+ try {
377
+ handed = await requestHandoff({ port, reason: 'upgrade-pending' });
378
+ } catch (err) {
379
+ // REPORT: the old proxy is still serving; the next restart asks again.
380
+ process.stderr.write(`[oauth-proxy-supervisor] proxy handoff refused: ${String((err && err.reason) || (err && err.message) || err).slice(0, 200)}\n`);
381
+ return;
382
+ }
383
+ // The successor loaded the code on disk, so the predecessor's closure hash is no longer this adoption's
384
+ // identity: status() and the restore_complete row must describe the process now answering. (WP-4 live
385
+ // proof, 2026-10-23: status reported the predecessor's hash after a handoff while /_health had the new one.)
386
+ const successorHealth = await _probeHealth(port);
387
+ _adopted = {
388
+ ..._adopted,
389
+ pid: handed.successorPid,
390
+ generation: handed.generation,
391
+ handoffMs: handed.handoffMs,
392
+ decision: 'upgrade-pending',
393
+ closureHash: successorHealth && successorHealth.pid === handed.successorPid ? (successorHealth.closure_hash || null) : null,
394
+ };
395
+ // The successor starts with the token table the predecessor handed over PLUS whatever CTM replays; the
396
+ // replay is the same idempotent call the fresh-spawn path makes, so a token cannot be double-registered.
397
+ // Its ROUTE and PICK tables, though, start EMPTY (§1.5 rows 4-5: both are CTM's, and the handoff
398
+ // carries only the state that has no other authority), so `successor:true` pushes them even when CTM
399
+ // holds no lead token at all -- the carried-over tokens would otherwise 404 on `unknown route alias`
400
+ // until the next provider save happened to re-push.
401
+ await _replayLeadTokens({ port, successor: true });
402
+ process.stderr.write(`[oauth-proxy-supervisor] proxy handoff generation=${handed.generation} successorPid=${handed.successorPid} handoffMs=${handed.handoffMs}\n`);
403
+ }
404
+
405
+ function _spawnFresh({ port, resolvedDataDir }) {
406
+ _mode = 'child';
407
+ _adopted = null;
408
+ _adoptionPromise = null;
409
+ // §5 D-6: the mode is leaving 'adopted', and a spawned child reports its own death.
410
+ _clearLivenessProbe();
411
+ // §5 D-10 / §9 S-12: what the PREVIOUS proxy was still carrying when it stopped. Read once, here,
412
+ // immediately before a fresh spawn, and cleared so one exit is reported once. The outcome this module
413
+ // reports afterwards always describes THIS spawn. The read is async (start() stays synchronous);
414
+ // lastRestartOutcome() answers null for the previous-exit fields until it lands, which is well before
415
+ // the restore_complete row that reads them.
416
+ _lastExitRecord = null;
417
+ _exitRecordLoad = readExitRecord({ dataDir: resolvedDataDir })
418
+ .then((record) => { _lastExitRecord = record; return clearExitRecord({ dataDir: resolvedDataDir }); })
419
+ .catch((err) => {
420
+ // REPORT: an unreadable record costs the ledger one field, never the boot.
421
+ process.stderr.write(`[oauth-proxy-supervisor] exit record unreadable: ${String(err && err.code || err).slice(0, 80)}\n`);
422
+ });
131
423
  // A superseded child may exit late with _stopping still set; this spawn is not a stop, so the
132
424
  // stale flag can never opt a future crash out of the lead auto-restart.
133
425
  _stopping = false;
@@ -147,73 +439,54 @@ function start({ port = DEFAULT_PORT, dataDir } = {}) {
147
439
  // stdin (never env/argv — oauth-proxy.js:_readAdminSecretFromStdin() is the
148
440
  // only reader). Kept in module memory for the admin-channel calls below.
149
441
  _adminSecret = crypto.randomBytes(32).toString('hex');
442
+ // §5 D-2: the secret is on disk BEFORE the child exists. A CTM that dies between this spawn and its
443
+ // own exit leaves no adopter behind, but the NEXT boot finds a readable secret and can take the
444
+ // survivor over; the child is handed the very same value on stdin.
445
+ instanceRecord.writeAdminSecret({ dataDir: resolvedDataDir, secret: _adminSecret });
446
+ // §5 D-1: the child's stdout/stderr ARE the log file (rotated once per generation inside
447
+ // openProxyLogFd). No pipe to relay and nothing for an orphaned child to write into -- a write to a
448
+ // dead parent's pipe is what killed the previous shape with EPIPE.
449
+ const logFd = instanceRecord.openProxyLogFd(resolvedDataDir);
150
450
  try {
151
451
  _child = spawn(resolveRealNode(), [PROXY_SCRIPT], {
152
- stdio: ['pipe', 'pipe', 'pipe'],
452
+ detached: true,
453
+ stdio: ['pipe', logFd, logFd],
153
454
  env: childEnv,
154
- detached: false,
155
455
  });
156
456
  } catch (err) {
457
+ instanceRecord.closeProxyLogFd(logFd);
157
458
  _lastError = err.message;
158
459
  _adminSecret = null;
460
+ _mode = 'stopped';
159
461
  return { ok: false, running: false, error: err.message };
160
462
  }
463
+ instanceRecord.closeProxyLogFd(logFd); // the child holds its own copy of the descriptor
464
+ // §5 D-1: CTM is never held open by the proxy it spawned -- the proxy outlives this process and the
465
+ // next CTM adopts it (D-4), so nothing here may wait on its exit.
466
+ _child.unref();
161
467
  // D-13: capture THIS spawn so a superseded child's late 'exit' (stop() -> start(), or a rapid
162
468
  // restart) cannot null the current child's handle / admin secret out from under a fresh boot.
163
469
  const child = _child;
470
+ // S-5: the secret travels on stdin, never env/argv -- oauth-proxy.js:_readAdminSecretFromStdin() is
471
+ // the only reader, and the child starts as soon as that line ends.
164
472
  try {
165
473
  _child.stdin.write(_adminSecret + '\n');
166
474
  _child.stdin.end();
167
475
  } catch (err) {
168
- // expected: the child may exit before stdin flushes on a fast startup
169
- // failure — the health probe below is the authoritative signal, not this
170
- // write.
476
+ // expected: the child may exit before stdin flushes on a fast startup failure -- the health probe
477
+ // below is the authoritative signal, not this write.
171
478
  _lastError = `admin secret handoff failed: ${err.message}`;
172
479
  }
173
480
  _startedAt = Date.now();
174
- _child.stdout.on('data', (d) => {
175
- // Forward to CTM stderr so it lands in the same log files. Prefix to
176
- // disambiguate.
177
- process.stderr.write('[oauth-proxy] ' + d.toString());
178
- });
179
- _child.stderr.on('data', (d) => {
180
- process.stderr.write('[oauth-proxy] ' + d.toString());
181
- });
182
- _child.on('error', (err) => {
481
+ child.on('error', (err) => {
183
482
  _lastError = err.message;
184
483
  });
185
- _child.on('exit', (code, signal) => {
484
+ child.on('exit', (code, signal) => {
186
485
  // D-13: a superseded child (stop() -> start(), or a rapid restart) can exit LATE, after a newer
187
486
  // child is live. Its exit owns none of the module state below -- nulling the current child's
188
487
  // handle or admin secret would tear the live proxy's admin channel down.
189
488
  if (child !== _child) return;
190
- if (code !== 0 && !signal) {
191
- _lastError = `exited code=${code}`;
192
- }
193
- _child = null;
194
- _startedAt = null;
195
- _adminSecret = null;
196
- _activeChildDataDir = null;
197
- // The dead child's in-memory token table is gone; any revoke that was still
198
- // pending against it no longer has anything to reap.
199
- _pendingLeadRevokes.clear();
200
- // WP-0 (§6.2): an unexpected exit (signal, or a non-zero code) with live
201
- // lead tokens must bring the proxy back so those tokens keep working. A
202
- // graceful stop() opts out via _stopping; a crash-loop is rate-limited.
203
- const unexpected = !!signal || code !== 0;
204
- const wasStopping = _stopping;
205
- _stopping = false;
206
- if (unexpected && !wasStopping && _leadTokens.size > 0) {
207
- if (Date.now() - _lastLeadAutoRestartAt >= LEAD_RESTART_MIN_INTERVAL_MS) {
208
- _lastLeadAutoRestartAt = Date.now();
209
- // WP-1 §5 D-3: the restart is bound to the directory the DEAD child was actually running with,
210
- // captured here rather than re-resolved from a possibly-changed environment. A restart therefore
211
- // cannot silently move the authority root out from under a surviving native owner.
212
- start({ port, dataDir: resolvedDataDir });
213
- } else {
214
- process.stderr.write('[oauth-proxy-supervisor] model-proxy-restart-suppressed\n');
215
- }
216
- }
489
+ _onProxyGone({ code, signal, port, dataDir: resolvedDataDir });
217
490
  });
218
491
  // WP-0 (§6.2): a fresh child has an empty token registry. Replay ours without
219
492
  // blocking the caller (start() is synchronous).
@@ -222,26 +495,108 @@ function start({ port = DEFAULT_PORT, dataDir } = {}) {
222
495
  process.stderr.write(`[oauth-proxy-supervisor] lead replay failed: ${String(err && err.message || err).slice(0, 200)}\n`);
223
496
  });
224
497
  }
225
- return { ok: true, running: true, port, pid: _child.pid };
498
+ return { ok: true, running: true, port, pid: _child.pid, mode: 'child' };
499
+ }
500
+
501
+ /**
502
+ * Everything the child's 'exit' event owns (WP-3 calls this from the liveness probe too). `dataDir` and
503
+ * `port` are the ones THIS spawn was started with, so a recovery restart can never be retargeted by a
504
+ * changed environment.
505
+ */
506
+ function _onProxyGone({ code, signal, port, dataDir }) {
507
+ // §5 D-6: an ADOPTED proxy has no child handle, so this handler is reached from the probe (or from an
508
+ // admin call that could not connect) rather than from an 'exit' event. Everything below is the same
509
+ // recovery either way; only the reaping differs, because the record a dead adopted pid left behind is
510
+ // stale truth that start() would otherwise have to discover by ps.
511
+ const adoptedPid = _mode === 'adopted' || _mode === 'adopting' ? (_adopted ? _adopted.pid : null) : null;
512
+ _clearLivenessProbe();
513
+ if (adoptedPid !== null) {
514
+ process.stderr.write(`[oauth-proxy-supervisor] adopted proxy pid=${adoptedPid} gone\n`);
515
+ instanceRecord.unlinkInstanceRecord({ dataDir });
516
+ }
517
+ if (code !== 0 && !signal) {
518
+ _lastError = `exited code=${code}`;
519
+ }
520
+ _child = null;
521
+ _startedAt = null;
522
+ _adminSecret = null;
523
+ _activeChildDataDir = null;
524
+ _adopted = null;
525
+ _mode = 'stopped';
526
+ // The dead child's in-memory token table is gone; any revoke that was still
527
+ // pending against it no longer has anything to reap.
528
+ _pendingLeadRevokes.clear();
529
+ // WP-0 (§6.2): an unexpected exit (signal, or a non-zero code) with live
530
+ // lead tokens must bring the proxy back so those tokens keep working. A
531
+ // graceful stop() opts out via _stopping; a crash-loop is rate-limited.
532
+ const unexpected = !!signal || code !== 0;
533
+ const wasStopping = _stopping;
534
+ _stopping = false;
535
+ // §9 S-5: the child records every SIGTERM exit, including the one a deliberate stop() sent. A stop
536
+ // is not a restart, so its record must not become the next start()'s "previous exit".
537
+ if (wasStopping) clearExitRecord({ dataDir });
538
+ if (unexpected && !wasStopping && _leadTokens.size > 0) {
539
+ if (Date.now() - _lastLeadAutoRestartAt >= LEAD_RESTART_MIN_INTERVAL_MS) {
540
+ _lastLeadAutoRestartAt = Date.now();
541
+ // WP-1 §5 D-3: the restart is bound to the directory the DEAD child was actually running with,
542
+ // captured here rather than re-resolved from a possibly-changed environment. A restart therefore
543
+ // cannot silently move the authority root out from under a surviving native owner.
544
+ start({ port, dataDir });
545
+ } else {
546
+ process.stderr.write('[oauth-proxy-supervisor] model-proxy-restart-suppressed\n');
547
+ }
548
+ }
226
549
  }
227
550
 
228
551
  function stop() {
552
+ const mode = _mode;
553
+ // §5 D-6: whatever happens below, this CTM is no longer watching that pid.
554
+ _clearLivenessProbe();
229
555
  if (!isRunning()) {
230
556
  _adminSecret = null;
231
557
  _activeChildDataDir = null;
232
- return { ok: true, running: false, alreadyStopped: true };
558
+ _adopted = null;
559
+ return { ok: true, running: false, alreadyStopped: true, mode };
233
560
  }
234
- // WP-0 (§6.2): mark this exit as deliberate so the lead-token auto-restart in
235
- // the 'exit' handler stands down.
236
- _stopping = true;
237
- try {
238
- _child.kill('SIGTERM');
239
- } catch (err) {
240
- return { ok: false, error: err.message };
561
+ const dataDir = _activeChildDataDir;
562
+ if (mode === 'child') {
563
+ // WP-0 (§6.2): mark this exit as deliberate so the lead-token auto-restart in
564
+ // the 'exit' handler stands down.
565
+ _stopping = true;
566
+ try {
567
+ _child.kill('SIGTERM');
568
+ } catch (err) {
569
+ return { ok: false, error: err.message };
570
+ }
571
+ } else if (_adopted && instanceRecord.isProxyProcess(_adopted.pid)) {
572
+ // §5 D-5: an explicit stop -- the /api/setup/oauth-proxy/stop path -- is the one caller allowed to
573
+ // end a proxy this CTM did not spawn, and only after ps proved the pid is still that proxy.
574
+ try {
575
+ process.kill(_adopted.pid, 'SIGTERM');
576
+ } catch (err) {
577
+ // REPORT: a pid that vanished between the probe and the signal is already stopped.
578
+ process.stderr.write(`[oauth-proxy-supervisor] stop signal failed: ${String(err && err.code || err).slice(0, 40)}\n`);
579
+ }
580
+ }
581
+ // An adoption in flight is superseded here: its continuation must not adopt (or spawn) after a stop.
582
+ _adoptionSeq += 1;
583
+ _adoptionPromise = null;
584
+ // The record and the secret are THIS CTM's files: an explicit stop leaves neither behind, so the next
585
+ // boot cannot resurrect a proxy that was deliberately stopped.
586
+ if (dataDir) {
587
+ instanceRecord.unlinkInstanceRecord({ dataDir });
588
+ instanceRecord.unlinkAdminSecret({ dataDir });
241
589
  }
242
590
  _adminSecret = null;
243
591
  _activeChildDataDir = null;
244
- return { ok: true, running: false };
592
+ // §5 D-5: an adopted proxy is CTM's memory of a process it never owned, so the stop above ends that
593
+ // memory here. A child's own 'exit' handler owns the same reset, and only after the process is really
594
+ // gone -- the handle stays live until then so a start() in between is still refused.
595
+ if (mode !== 'child') {
596
+ _adopted = null;
597
+ _mode = 'stopped';
598
+ }
599
+ return { ok: true, running: false, mode };
245
600
  }
246
601
 
247
602
  async function status({ port = DEFAULT_PORT } = {}) {
@@ -250,6 +605,9 @@ async function status({ port = DEFAULT_PORT } = {}) {
250
605
  running: false,
251
606
  port,
252
607
  pid: null,
608
+ mode: _mode,
609
+ generation: null,
610
+ closureHash: null,
253
611
  uptime_ms: null,
254
612
  health: null,
255
613
  last_error: _lastError,
@@ -269,13 +627,101 @@ async function status({ port = DEFAULT_PORT } = {}) {
269
627
  return {
270
628
  running: true,
271
629
  port,
272
- pid: _child.pid,
630
+ // §5 D-4: which identity is answering -- a child this CTM spawned, or a surviving proxy it adopted.
631
+ mode: _mode,
632
+ pid: _ownedPid(),
633
+ generation: _adopted ? _adopted.generation : 1,
634
+ closureHash: _adopted ? _adopted.closureHash : null,
273
635
  uptime_ms: _startedAt ? Date.now() - _startedAt : null,
636
+ // §5 D-6: the two things the PROXY knows and CTM does not -- lifted from /_health when it answered.
637
+ openStreams: health && Number.isSafeInteger(health.open_streams) ? health.open_streams : null,
638
+ draining: health ? !!health.draining : null,
274
639
  health,
275
640
  last_error: _lastError,
276
641
  };
277
642
  }
278
643
 
644
+ /**
645
+ * §5 D-10 / §6 (WP-3): what the NEXT boot will do with the model proxy. The preflight's `proxy` field is
646
+ * this answer, passed through by lib/restart-preflight.js -- never recomputed there.
647
+ *
648
+ * This reads LIVE state only -- the published record and the proxy's own `/_health` -- never this
649
+ * module's `_mode`: a preflight answers for the next boot, which adopts whatever the record names
650
+ * whether or not this process is the one that spawned it. The verdict is the boot's own rule (D-4): no
651
+ * record on this port means nothing to do (`none`); a record the port does not answer for is the stale
652
+ * truth the boot reaps and replaces (`spawn`); a record whose pid IS the process answering is adopted,
653
+ * or handed off when the code on disk is not the code that process loaded. `closureDiffers:null` means
654
+ * the answering proxy published no closure at all (an older build), which is adoptable as it stands.
655
+ *
656
+ * @returns {Promise<{action:'adopt'|'handoff'|'spawn'|'none', pid:number|null,
657
+ * openStreams:number|null, closureDiffers:boolean|null}>}
658
+ */
659
+ async function proxyPreflight({ port = DEFAULT_PORT, dataDir } = {}) {
660
+ const resolvedDataDir = typeof dataDir === 'string' && dataDir.trim() ? path.resolve(dataDir) : getDataDir();
661
+ const record = instanceRecord.readInstanceRecord({ dataDir: resolvedDataDir });
662
+ if (!record || record.port !== port) {
663
+ return { action: 'none', pid: null, openStreams: null, closureDiffers: null };
664
+ }
665
+ const health = await _probeHealth(port);
666
+ // Same test _finishAdoption makes: the record names the process that must be answering on the port.
667
+ if (!health || health.pid !== record.pid) {
668
+ return { action: 'spawn', pid: null, openStreams: null, closureDiffers: null };
669
+ }
670
+ // The same seam the adoption decision reads: the disk side of the closure comparison.
671
+ const closureDiffers = Array.isArray(health.closure_files) && typeof health.closure_hash === 'string'
672
+ ? _hashClosureFiles(health.closure_files) !== health.closure_hash
673
+ : null;
674
+ return {
675
+ action: closureDiffers ? 'handoff' : 'adopt',
676
+ pid: health.pid,
677
+ openStreams: Number.isSafeInteger(health.open_streams) ? health.open_streams : null,
678
+ closureDiffers,
679
+ };
680
+ }
681
+
682
+ /**
683
+ * §5 D-4 at boot (WP-4 AC-4e): is there a proxy on this port that this data dir already owns -- a published
684
+ * record whose pid is a live proxy process? server.js's boot-start gate asks this beside WALLE_AUTH_METHOD,
685
+ * because the live proof found the primary does NOT set that method (its proxy is started later by the
686
+ * setup surface): without this question a proxy that survived the restart was never adopted, its lead
687
+ * tokens never re-tracked, and the restore_complete row said `spawn`. Sync on purpose -- the gate is in
688
+ * the sync boot path -- and it reuses the exact test start() makes before adopting (`isProxyProcess`).
689
+ * A stale record (pid gone) is NOT a survivor: start() is not called for it, so an operator who never
690
+ * configured the proxy never gets one spawned by a leftover file.
691
+ */
692
+ function hasLiveSurvivor({ port = DEFAULT_PORT, dataDir } = {}) {
693
+ const resolvedDataDir = typeof dataDir === 'string' && dataDir.trim() ? path.resolve(dataDir) : getDataDir();
694
+ const record = instanceRecord.readInstanceRecord({ dataDir: resolvedDataDir });
695
+ return !!record && record.port === port && instanceRecord.isProxyProcess(record.pid);
696
+ }
697
+
698
+ /** §5 D-4 step 4: the health probe the adoption decision reads, with its own 2 s budget. */
699
+ async function _probeHealth(port, timeoutMs = 2000) {
700
+ try {
701
+ const resp = await fetch(`http://127.0.0.1:${port}/_health`, { signal: AbortSignal.timeout(timeoutMs) });
702
+ return resp.ok ? await resp.json() : null;
703
+ } catch (err) {
704
+ // expected: an unreachable proxy is a refused adoption, never a thrown boot
705
+ void err;
706
+ return null;
707
+ }
708
+ }
709
+
710
+ /** §5 D-4 step 5: the secret check -- an authenticated admin read, 200 means this secret is its own. */
711
+ async function _probeAdminSecret({ port, secret, timeoutMs = 2000 }) {
712
+ try {
713
+ const resp = await fetch(`http://127.0.0.1:${port}/_admin/lead-worker-picks`, {
714
+ headers: { [ADMIN_HEADER]: secret },
715
+ signal: AbortSignal.timeout(timeoutMs),
716
+ });
717
+ return resp.ok;
718
+ } catch (err) {
719
+ // expected: an unreachable proxy cannot confirm the secret; the caller falls back to a spawn
720
+ void err;
721
+ return false;
722
+ }
723
+ }
724
+
279
725
  // ── D-8 readiness ────────────────────────────────────────────────────────
280
726
  // Polls /_health every ~100ms until it answers ok or the deadline passes.
281
727
  // A single fetch is not enough here: during the child's cold start (just
@@ -287,6 +733,22 @@ async function status({ port = DEFAULT_PORT } = {}) {
287
733
  const HEALTH_POLL_INTERVAL_MS = 100;
288
734
 
289
735
  async function waitForHealth({ port = DEFAULT_PORT, timeoutMs = 2000, signal } = {}) {
736
+ // §5 D-4: an adoption in flight decides whether /_health will be answered by the proxy already
737
+ // listening or by the child start() falls back to. Every caller already follows start() with this
738
+ // wait, so this is the one place that must outlast the decision.
739
+ if (_adoptionPromise) {
740
+ await _adoptionPromise.catch(() => {
741
+ // expected: a failed adoption already reaped the record and spawned a child; the loop below is
742
+ // the verdict on whichever proxy is now listening.
743
+ });
744
+ }
745
+ return _pollHealth({ port, timeoutMs, signal });
746
+ }
747
+
748
+ // The bare /_health poll, with no adoption wait in front of it. `_replayLeadTokens` runs INSIDE
749
+ // `_finishAdoption` (the adoption promise itself), so it must use this one: awaiting the adoption from
750
+ // there is a wait on oneself, and the replay -- hence the adoption -- would never finish.
751
+ async function _pollHealth({ port = DEFAULT_PORT, timeoutMs = 2000, signal } = {}) {
290
752
  const deadline = Date.now() + timeoutMs;
291
753
  for (;;) {
292
754
  if (signal?.aborted) throw signal.reason || new Error('proxy health wait aborted');
@@ -332,6 +794,8 @@ async function _adminRequest({ port = DEFAULT_PORT, method, path: adminPath, bod
332
794
  signal: signal ? AbortSignal.any([AbortSignal.timeout(timeoutMs), signal]) : AbortSignal.timeout(timeoutMs),
333
795
  });
334
796
  } catch (err) {
797
+ // §5 D-6: a refused/reset connection to an ADOPTED proxy is a liveness answer for free.
798
+ _probeAdoptedAfterAdminFailure({ port, err });
335
799
  throw new ProxyAdminError('proxy_unreachable', `admin request failed: ${err.message}`);
336
800
  }
337
801
  if (!resp.ok) {
@@ -361,6 +825,50 @@ async function _adminRequest({ port = DEFAULT_PORT, method, path: adminPath, bod
361
825
  }
362
826
  }
363
827
 
828
+ /**
829
+ * §5 D-7 (WP-2): ask the running proxy to hand its listening sockets and its memory-only state to a
830
+ * successor it spawns from disk. Resolves once the proxy answered 202 -- which it does only after the
831
+ * successor is genuinely serving AND the predecessor stopped accepting -- or throws a ProxyAdminError
832
+ * carrying the 409 reason (`already_draining`, `successor_not_ready`, `spawn_failed`).
833
+ *
834
+ * The two deadlines are TEST OVERRIDES and travel in the admin body only, never in the environment; the
835
+ * client's own wait outlives the proxy's readiness deadline, so a handoff the PROXY decides to abandon is
836
+ * reported by its own 409 rather than by a socket timeout here.
837
+ *
838
+ * @returns {Promise<{ok:true, generation:number, successorPid:number, handoffMs:number}>}
839
+ */
840
+ async function requestHandoff({ port = DEFAULT_PORT, reason = 'closure-changed', readyDeadlineMs, drainDeadlineMs, signal } = {}) {
841
+ const body = { reason };
842
+ if (Number.isSafeInteger(readyDeadlineMs) && readyDeadlineMs > 0) body.readyDeadlineMs = readyDeadlineMs;
843
+ if (Number.isSafeInteger(drainDeadlineMs) && drainDeadlineMs > 0) body.drainDeadlineMs = drainDeadlineMs;
844
+ const readyMs = Number.isSafeInteger(readyDeadlineMs) && readyDeadlineMs > 0 ? readyDeadlineMs : HANDOFF_READY_DEADLINE_MS;
845
+ const result = await _adminRequest({
846
+ port,
847
+ method: 'POST',
848
+ path: '/_admin/handoff',
849
+ body,
850
+ timeoutMs: readyMs + 5000,
851
+ returnHttpErrors: true,
852
+ signal,
853
+ });
854
+ if (result && result.ok === true && Number.isSafeInteger(result.successorPid)) {
855
+ return { ok: true, generation: result.generation, successorPid: result.successorPid, handoffMs: result.handoffMs };
856
+ }
857
+ const refused = String((result && (result.reason || result.error)) || 'handoff_refused');
858
+ const err = new ProxyAdminError('handoff_refused', refused);
859
+ err.reason = refused;
860
+ throw err;
861
+ }
862
+
863
+ // Test seam (§6, AC-2f): the disk-side closure hash the compatibility decision compares, injectable so a
864
+ // test can make an otherwise-compatible adoption read `upgrade-pending` without editing source.
865
+ // Production never calls the setter.
866
+ let _hashClosureFiles = hashClosureFiles;
867
+
868
+ function _setHashClosureFilesForTest(fn) {
869
+ _hashClosureFiles = typeof fn === 'function' ? fn : hashClosureFiles;
870
+ }
871
+
364
872
  // Forwards one worker-route contract row from Wall-E's internal feed verbatim.
365
873
  // Field names match lib/model-proxy/routes.js's buildRoute() input contract
366
874
  // ("model-proxy route protocol and gateway auth", 2026-09-26 §5 D-3..D-7,
@@ -745,9 +1253,13 @@ async function adoptLeadToken(registration = {}) {
745
1253
 
746
1254
  // Replays the CTM-side lead-token registry into a freshly spawned child: the new
747
1255
  // child starts empty, so the SAME tokens must be re-registered to keep working.
748
- async function _replayLeadTokens({ port = DEFAULT_PORT } = {}) {
749
- if (_leadTokens.size === 0) return;
750
- await waitForHealth({ port, timeoutMs: 5000 });
1256
+ // `successor:true` (§5 D-7, WP-2) names a handoff successor: it already holds the tokens its
1257
+ // predecessor passed over, but its route and pick tables are empty, so CTM's copies are pushed
1258
+ // even when this process has no lead token of its own to replay.
1259
+ async function _replayLeadTokens({ port = DEFAULT_PORT, successor = false } = {}) {
1260
+ if (_leadTokens.size === 0 && !successor) return;
1261
+ // Never waitForHealth() here: two of the three callers are inside the adoption promise it awaits.
1262
+ await _pollHealth({ port, timeoutMs: 5000 });
751
1263
  try {
752
1264
  await pushRoutesNow({ port });
753
1265
  } catch (err) {
@@ -833,13 +1345,40 @@ async function probeRouteGeneration(alias, { model, timeoutMs = 30_000, port = D
833
1345
  });
834
1346
  }
835
1347
 
1348
+ /** Test seam (§6): drop every module global this module owns, so the next case starts from a cold boot. */
1349
+ function _resetForTest() {
1350
+ _child = null;
1351
+ _startedAt = null;
1352
+ _lastError = null;
1353
+ _mode = 'stopped';
1354
+ _adopted = null;
1355
+ _adoptionPromise = null;
1356
+ _adoptionSeq += 1;
1357
+ _adminSecret = null;
1358
+ _activeChildDataDir = null;
1359
+ _lastExitRecord = null;
1360
+ _exitRecordLoad = Promise.resolve();
1361
+ _stopping = false;
1362
+ _lastLeadAutoRestartAt = 0;
1363
+ _leadTokens.clear();
1364
+ _pendingLeadRevokes.clear();
1365
+ _leadWorkerPicks = [];
1366
+ _hashClosureFiles = hashClosureFiles;
1367
+ _clearLivenessProbe();
1368
+ _livenessProbeMs = PROXY_LIVENESS_MS;
1369
+ }
1370
+
836
1371
  module.exports = {
837
1372
  DEFAULT_PORT,
838
1373
  start,
839
1374
  stop,
840
1375
  status,
841
1376
  isRunning,
1377
+ // §5 D-10 / §9 S-12: the proxy's own restart outcome for the restore_complete ledger row.
1378
+ lastRestartOutcome,
842
1379
  waitForHealth,
1380
+ // §5 D-7 (WP-2): the upgrade handoff CTM schedules on an `upgrade-pending` adoption.
1381
+ requestHandoff,
843
1382
  pushRoutesNow,
844
1383
  pushLeadWorkerPicks,
845
1384
  listWorkerRoutes,
@@ -856,20 +1395,45 @@ module.exports = {
856
1395
  adoptLeadToken,
857
1396
  leadTokenCount,
858
1397
  LEAD_RESTART_MIN_INTERVAL_MS,
1398
+ // §5 D-6: the adopted-mode probe cadence, exported beside the rate limit it mirrors.
1399
+ PROXY_LIVENESS_MS,
1400
+ // §5 D-10 (WP-3): what the next boot will do with the proxy, for computeRestartPreflight({ proxy }).
1401
+ proxyPreflight,
1402
+ // §5 D-4 at boot (WP-4 AC-4e): the boot-start gate's second question -- a live recorded survivor is adopted
1403
+ // whether or not WALLE_AUTH_METHOD names the proxy.
1404
+ hasLiveSurvivor,
859
1405
  ProxyAdminError,
860
1406
  _internals: {
861
1407
  _rowToAdminRoute,
862
1408
  workerTokenAdminBody,
863
1409
  ADMIN_HEADER,
864
1410
  _setRouteFetcherForTest,
1411
+ // §5 D-6/§6: shrink the adopted-mode probe cadence so a liveness case does not take 10 s (and so
1412
+ // AC-3a's own bound can be checked against production's constant, which is exported beside it).
1413
+ _setLivenessProbeMsForTest,
1414
+ // §5 D-7/§6 (AC-2f): make the disk-side closure hash disagree, so the adoption decision reads
1415
+ // `upgrade-pending` and the handoff path runs, without editing source. Pass null to restore.
1416
+ _setHashClosureFilesForTest,
865
1417
  _leadTokens,
866
1418
  _pendingLeadRevokes,
867
1419
  // WP-1 §5 D-3/§6.5: the directory the LIVE child was actually started with, resolved once at spawn.
868
1420
  // Exposed read-only for tests that assert pinning; nothing here is a secret.
869
1421
  get _activeChildDataDir() { return _activeChildDataDir; },
1422
+ // §9 S-12: the async read+clear of the previous exit record that start() kicked off; a test awaits it
1423
+ // before asserting lastRestartOutcome()'s previous-exit fields.
1424
+ get _exitRecordLoad() { return _exitRecordLoad; },
870
1425
  // Test read/reset seam: a getter so tests see the CURRENT table after a
871
1426
  // replay reassigns it, and a setter so a test can clear it in after().
872
1427
  get _leadWorkerPicks() { return _leadWorkerPicks; },
873
1428
  set _leadWorkerPicks(value) { _leadWorkerPicks = Array.isArray(value) ? value : []; },
1429
+ // §5 D-4: which identity this module holds ('stopped' | 'child' | 'adopting' | 'adopted') and the
1430
+ // adopted proxy's facts, plus the in-flight adoption decision a test can await.
1431
+ get _mode() { return _mode; },
1432
+ get _adopted() { return _adopted; },
1433
+ get _adoptionPromise() { return _adoptionPromise; },
1434
+ // Test seam: forgets every module-global this module owns, so the next case in the same process
1435
+ // starts from a genuinely cold boot. It does NOT signal anything -- a case that started a proxy
1436
+ // stops it in its own t.after().
1437
+ _resetForTest,
874
1438
  },
875
1439
  };