create-walle 0.10.2 → 0.10.3

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 (488) hide show
  1. package/README.md +3 -1
  2. package/lib/release-schema.json +2 -2
  3. package/lib/runtime-closure-policy.json +8 -7
  4. package/package.json +1 -1
  5. package/template/coding-task-manager/api-prompts.js +45 -7
  6. package/template/coding-task-manager/bin/claude-hook.js +55 -1
  7. package/template/coding-task-manager/bin/ctm-relay-client.js +35 -12
  8. package/template/coding-task-manager/bin/land.js +2069 -0
  9. package/template/coding-task-manager/bin/memory-census.js +4 -2
  10. package/template/coding-task-manager/bin/memory-sync-verify.js +151 -0
  11. package/template/coding-task-manager/bin/probe-refusal-scope.js +530 -0
  12. package/template/coding-task-manager/db.js +156 -32
  13. package/template/coding-task-manager/lib/acp/acp-client.js +383 -0
  14. package/template/coding-task-manager/lib/acp/dsh-context-truth.js +176 -0
  15. package/template/coding-task-manager/lib/acp/dsh-launch-model.js +302 -0
  16. package/template/coding-task-manager/lib/acp/dsh-mcp-servers.js +194 -0
  17. package/template/coding-task-manager/lib/acp/dsh-prompt-lane.js +616 -0
  18. package/template/coding-task-manager/lib/acp/dsh-route-patch.js +308 -0
  19. package/template/coding-task-manager/lib/acp/dsh-session-runtime.js +522 -0
  20. package/template/coding-task-manager/lib/acp/dsh-transcript.js +395 -0
  21. package/template/coding-task-manager/lib/acp/dsh-usage-log.js +162 -0
  22. package/template/coding-task-manager/lib/acp/ndjson-stdio-transport.js +225 -0
  23. package/template/coding-task-manager/lib/adopted-host-redial.js +47 -11
  24. package/template/coding-task-manager/lib/adopted-launch-environment.js +123 -0
  25. package/template/coding-task-manager/lib/agent-auto-update-runtime.js +0 -3
  26. package/template/coding-task-manager/lib/agent-auto-update.js +26 -10
  27. package/template/coding-task-manager/lib/agent-bundle/build.js +657 -0
  28. package/template/coding-task-manager/lib/agent-bundle/codex-install.js +558 -0
  29. package/template/coding-task-manager/lib/agent-bundle/drift.js +120 -0
  30. package/template/coding-task-manager/lib/agent-bundle/gc.js +522 -0
  31. package/template/coding-task-manager/lib/agent-bundle/inventory.js +346 -0
  32. package/template/coding-task-manager/lib/agent-bundle/push.js +717 -0
  33. package/template/coding-task-manager/lib/agent-bundle/skill-transfer.js +724 -0
  34. package/template/coding-task-manager/lib/agent-bundle/spawn.js +119 -0
  35. package/template/coding-task-manager/lib/agent-bundle/store.js +568 -0
  36. package/template/coding-task-manager/lib/agent-capabilities.js +142 -12
  37. package/template/coding-task-manager/lib/agent-cli-cache.js +30 -5
  38. package/template/coding-task-manager/lib/agent-definitions.js +19 -0
  39. package/template/coding-task-manager/lib/agent-hooks-codex-toml.js +28 -16
  40. package/template/coding-task-manager/lib/agent-hooks-codex-workspace.js +9 -1
  41. package/template/coding-task-manager/lib/agent-hooks-installer.js +23 -8
  42. package/template/coding-task-manager/lib/agent-hooks-prune.js +37 -11
  43. package/template/coding-task-manager/lib/agent-hooks-shim.js +105 -0
  44. package/template/coding-task-manager/lib/agent-presets.js +84 -1
  45. package/template/coding-task-manager/lib/agent-upgrade-jobs.js +12 -1
  46. package/template/coding-task-manager/lib/agent-version-service.js +74 -11
  47. package/template/coding-task-manager/lib/all-sessions-query.js +6 -2
  48. package/template/coding-task-manager/lib/api/agent-bundles.js +436 -0
  49. package/template/coding-task-manager/lib/api/backup-retention.js +83 -0
  50. package/template/coding-task-manager/lib/api/conversation-sync-routes.js +124 -0
  51. package/template/coding-task-manager/lib/api/diagnostics.js +549 -69
  52. package/template/coding-task-manager/lib/api/fleet.js +118 -0
  53. package/template/coding-task-manager/lib/api/hub-bridge.js +243 -0
  54. package/template/coding-task-manager/lib/api/ingress.js +9 -1
  55. package/template/coding-task-manager/lib/api/landing.js +403 -0
  56. package/template/coding-task-manager/lib/api/machines.js +691 -2
  57. package/template/coding-task-manager/lib/api/memory-sync-routes.js +335 -0
  58. package/template/coding-task-manager/lib/api/misc-ops.js +8 -3
  59. package/template/coding-task-manager/lib/api/remote-sessions.js +1326 -0
  60. package/template/coding-task-manager/lib/api/session-attach.js +623 -0
  61. package/template/coding-task-manager/lib/api/session-messages.js +1384 -0
  62. package/template/coding-task-manager/lib/api/sessions.js +77 -51
  63. package/template/coding-task-manager/lib/api/setup-network.js +544 -16
  64. package/template/coding-task-manager/lib/api/setup-wizard-handlers.js +884 -0
  65. package/template/coding-task-manager/lib/api/setup.js +829 -10
  66. package/template/coding-task-manager/lib/api/walle-bridge.js +9 -1
  67. package/template/coding-task-manager/lib/attachment-reconciler-wiring.js +5 -2
  68. package/template/coding-task-manager/lib/auth-rate-limit.js +23 -0
  69. package/template/coding-task-manager/lib/auth-rules.js +292 -21
  70. package/template/coding-task-manager/lib/background-stop.js +369 -0
  71. package/template/coding-task-manager/lib/best-of-attempts.js +4 -1
  72. package/template/coding-task-manager/lib/best-of-launch-plan.js +52 -0
  73. package/template/coding-task-manager/lib/box-keep-awake.js +89 -0
  74. package/template/coding-task-manager/lib/broadcast-reason-stats.js +152 -0
  75. package/template/coding-task-manager/lib/broadcast-serialize.js +5 -0
  76. package/template/coding-task-manager/lib/build-info.js +4 -2
  77. package/template/coding-task-manager/lib/claude-model-truth.js +13 -2
  78. package/template/coding-task-manager/lib/claude-transcript-terminal-renderer.js +17 -13
  79. package/template/coding-task-manager/lib/client-diag-handler.js +64 -2
  80. package/template/coding-task-manager/lib/codex-app-server-client.js +14 -16
  81. package/template/coding-task-manager/lib/codex-configuration-history.js +55 -0
  82. package/template/coding-task-manager/lib/codex-harness-qualification.js +43 -20
  83. package/template/coding-task-manager/lib/codex-rollout-history.js +743 -0
  84. package/template/coding-task-manager/lib/coding-agent-models.js +27 -12
  85. package/template/coding-task-manager/lib/coding-cli-skill-installer.js +44 -14
  86. package/template/coding-task-manager/lib/coding-session-model-adapters.js +43 -2
  87. package/template/coding-task-manager/lib/coding-session-model-owner.js +1 -1
  88. package/template/coding-task-manager/lib/coding-session-model-public.js +7 -1
  89. package/template/coding-task-manager/lib/coding-session-model-runtime.js +23 -4
  90. package/template/coding-task-manager/lib/coding-session-model-scan.js +31 -0
  91. package/template/coding-task-manager/lib/coding-session-model-state.js +7 -0
  92. package/template/coding-task-manager/lib/conversation-freshness-diagnostics.js +280 -0
  93. package/template/coding-task-manager/lib/conversation-item-clock.js +261 -0
  94. package/template/coding-task-manager/lib/conversation-source-registry.js +5 -0
  95. package/template/coding-task-manager/lib/conversation-store-v2.js +44 -0
  96. package/template/coding-task-manager/lib/conversation-sync.js +355 -0
  97. package/template/coding-task-manager/lib/create/resolve-launch-params.js +96 -12
  98. package/template/coding-task-manager/lib/create/worker-role-profile.js +4 -4
  99. package/template/coding-task-manager/lib/create-intent-lease.js +51 -0
  100. package/template/coding-task-manager/lib/ctm-session-context-api.js +97 -6
  101. package/template/coding-task-manager/lib/db-domain-tables.js +1 -0
  102. package/template/coding-task-manager/lib/db-domains.js +1 -0
  103. package/template/coding-task-manager/lib/db-owner-dispatch-probe.js +32 -0
  104. package/template/coding-task-manager/lib/db-owner-request-counts.js +178 -0
  105. package/template/coding-task-manager/lib/db-owner-worker-client.js +68 -14
  106. package/template/coding-task-manager/lib/db-schema-source-files.js +5 -0
  107. package/template/coding-task-manager/lib/deferred-restore-attempt.js +6 -2
  108. package/template/coding-task-manager/lib/feature-manifest.js +4 -0
  109. package/template/coding-task-manager/lib/fleet-conversation-route.js +97 -0
  110. package/template/coding-task-manager/lib/fleet-fanout.js +78 -0
  111. package/template/coding-task-manager/lib/fleet-hub.js +762 -0
  112. package/template/coding-task-manager/lib/fleet-origin-follower.js +252 -0
  113. package/template/coding-task-manager/lib/fleet-resolver-effects.js +147 -0
  114. package/template/coding-task-manager/lib/fleet-resolver-tick.js +148 -0
  115. package/template/coding-task-manager/lib/fleet-resolver.js +454 -0
  116. package/template/coding-task-manager/lib/fleet-session-list-routes.js +114 -0
  117. package/template/coding-task-manager/lib/fleet-session-lists.js +672 -0
  118. package/template/coding-task-manager/lib/fleet-update.js +798 -0
  119. package/template/coding-task-manager/lib/fs-watcher-root-watch.js +141 -0
  120. package/template/coding-task-manager/lib/fs-watcher.js +58 -42
  121. package/template/coding-task-manager/lib/harness-history-transition.js +50 -9
  122. package/template/coding-task-manager/lib/headless-term-service.js +84 -8
  123. package/template/coding-task-manager/lib/hermetic-agent-launch-guard.js +10 -0
  124. package/template/coding-task-manager/lib/history-transition-read-op.js +175 -8
  125. package/template/coding-task-manager/lib/host-cpu-categories.js +310 -0
  126. package/template/coding-task-manager/lib/host-fseventsd-sampler.js +399 -0
  127. package/template/coding-task-manager/lib/host-handoff.js +16 -19
  128. package/template/coding-task-manager/lib/host-stats.js +150 -3
  129. package/template/coding-task-manager/lib/hosted-sync-worker.js +43 -320
  130. package/template/coding-task-manager/lib/hub-bridge-access.js +798 -0
  131. package/template/coding-task-manager/lib/hub-bridge-box.js +409 -0
  132. package/template/coding-task-manager/lib/hub-bridge-mcp.js +380 -0
  133. package/template/coding-task-manager/lib/hub-bridge-mesh.js +313 -0
  134. package/template/coding-task-manager/lib/hub-bridge-ops.js +721 -0
  135. package/template/coding-task-manager/lib/hub-bridge.js +398 -0
  136. package/template/coding-task-manager/lib/idle-notify.js +491 -0
  137. package/template/coding-task-manager/lib/instance-id-ownership-manifest.js +8 -0
  138. package/template/coding-task-manager/lib/instance-manifest.js +5 -1
  139. package/template/coding-task-manager/lib/instance-sync-status.js +18 -8
  140. package/template/coding-task-manager/lib/landing/agent-rule.txt +1 -0
  141. package/template/coding-task-manager/lib/landing/batch-runner.js +366 -0
  142. package/template/coding-task-manager/lib/landing/config.js +536 -0
  143. package/template/coding-task-manager/lib/landing/ctm-view.js +256 -0
  144. package/template/coding-task-manager/lib/landing/gate.js +360 -0
  145. package/template/coding-task-manager/lib/landing/git.js +344 -0
  146. package/template/coding-task-manager/lib/landing/lease.js +247 -0
  147. package/template/coding-task-manager/lib/landing/merge-delta.js +380 -0
  148. package/template/coding-task-manager/lib/landing/proof.js +223 -0
  149. package/template/coding-task-manager/lib/landing/queue.js +998 -0
  150. package/template/coding-task-manager/lib/landing/radar.js +489 -0
  151. package/template/coding-task-manager/lib/landing/resolve-cli.js +424 -0
  152. package/template/coding-task-manager/lib/landing/state.js +263 -0
  153. package/template/coding-task-manager/lib/landing/stop.js +49 -0
  154. package/template/coding-task-manager/lib/landing/sync-cli.js +475 -0
  155. package/template/coding-task-manager/lib/landing/sync.js +456 -0
  156. package/template/coding-task-manager/lib/landing/train.js +885 -0
  157. package/template/coding-task-manager/lib/landing/window.js +57 -0
  158. package/template/coding-task-manager/lib/launch-environment.js +121 -0
  159. package/template/coding-task-manager/lib/launch-model-override.js +36 -10
  160. package/template/coding-task-manager/lib/launch-plan-pool.js +2 -1
  161. package/template/coding-task-manager/lib/launch-plan-warm-cache.js +6 -0
  162. package/template/coding-task-manager/lib/lead-model-observer.js +57 -1
  163. package/template/coding-task-manager/lib/lead-model-options.js +160 -3
  164. package/template/coding-task-manager/lib/lead-model-restore-reconciliation.js +106 -24
  165. package/template/coding-task-manager/lib/lead-model-switch-runtime.js +352 -68
  166. package/template/coding-task-manager/lib/lead-model-switch.js +114 -10
  167. package/template/coding-task-manager/lib/lead-model-transition-runtime.js +14 -2
  168. package/template/coding-task-manager/lib/lead-model-transition-store.js +24 -6
  169. package/template/coding-task-manager/lib/lead-route-preflight.js +179 -0
  170. package/template/coding-task-manager/lib/lead-switch-history-mode.js +110 -0
  171. package/template/coding-task-manager/lib/lead-wire-agent.js +7 -0
  172. package/template/coding-task-manager/lib/lifecycle-wake-experiment.js +9 -2
  173. package/template/coding-task-manager/lib/live-defer-announce.js +45 -0
  174. package/template/coding-task-manager/lib/machine-registry.js +750 -5
  175. package/template/coding-task-manager/lib/macos-capabilities.js +32 -15
  176. package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +433 -53
  177. package/template/coding-task-manager/lib/mainloop-observability.js +14 -9
  178. package/template/coding-task-manager/lib/media-draft-owner.js +16 -3
  179. package/template/coding-task-manager/lib/memory-join.js +337 -0
  180. package/template/coding-task-manager/lib/memory-sync-verify.js +393 -0
  181. package/template/coding-task-manager/lib/memory-sync.js +565 -0
  182. package/template/coding-task-manager/lib/migrations/v57-delegate-pref-mirror-opencode.js +68 -0
  183. package/template/coding-task-manager/lib/migrations/v58-subagent-launch-native-subagents.js +42 -0
  184. package/template/coding-task-manager/lib/migrations/v59-coding-session-model-state-dsh.js +73 -0
  185. package/template/coding-task-manager/lib/migrations/v60-delegate-pref-mirror-dsh.js +78 -0
  186. package/template/coding-task-manager/lib/migrations/v61-conversation-item-clock.js +71 -0
  187. package/template/coding-task-manager/lib/model-availability.js +727 -0
  188. package/template/coding-task-manager/lib/model-execution-recipe-metadata.js +4 -6
  189. package/template/coding-task-manager/lib/model-host-receipt.js +6 -3
  190. package/template/coding-task-manager/lib/model-id-rule.js +19 -0
  191. package/template/coding-task-manager/lib/model-proxy/anthropic-face.js +4 -0
  192. package/template/coding-task-manager/lib/model-proxy/auto-mode-review.js +110 -23
  193. package/template/coding-task-manager/lib/model-proxy/chat-response-translators.js +3 -0
  194. package/template/coding-task-manager/lib/model-proxy/generation-probe.js +60 -17
  195. package/template/coding-task-manager/lib/model-proxy/historical-tool-declarations.js +37 -0
  196. package/template/coding-task-manager/lib/model-proxy/lead-request-validation.js +1 -0
  197. package/template/coding-task-manager/lib/model-proxy/lead-token-admin.js +130 -0
  198. package/template/coding-task-manager/lib/model-proxy/refusal-scope.js +151 -0
  199. package/template/coding-task-manager/lib/model-proxy/request-budget-store.js +11 -2
  200. package/template/coding-task-manager/lib/model-proxy/tokens.js +69 -5
  201. package/template/coding-task-manager/lib/model-proxy/upstream-outcome.js +9 -0
  202. package/template/coding-task-manager/lib/native-agent-model-args.js +25 -22
  203. package/template/coding-task-manager/lib/native-history-control.js +6 -4
  204. package/template/coding-task-manager/lib/native-lead-settings-client.js +44 -4
  205. package/template/coding-task-manager/lib/oauth-proxy-supervisor.js +173 -36
  206. package/template/coding-task-manager/lib/online-backup-worker-client.js +2 -0
  207. package/template/coding-task-manager/lib/opencode-cli.js +480 -0
  208. package/template/coding-task-manager/lib/opencode-launch-model.js +471 -0
  209. package/template/coding-task-manager/lib/opencode-v1-plugin/model-carrier.mjs +35 -0
  210. package/template/coding-task-manager/lib/opencode-v2-plugin/server.js +118 -0
  211. package/template/coding-task-manager/lib/owner-action-manifest-data.json +1 -1
  212. package/template/coding-task-manager/lib/park-composer-draft.js +54 -0
  213. package/template/coding-task-manager/lib/park-fresh-relaunch.js +41 -10
  214. package/template/coding-task-manager/lib/perf-phase-trace.js +72 -0
  215. package/template/coding-task-manager/lib/perf-summary.js +7 -0
  216. package/template/coding-task-manager/lib/perf-tracker.js +63 -2
  217. package/template/coding-task-manager/lib/project-config.js +7 -0
  218. package/template/coding-task-manager/lib/prompt-purge-v2.js +52 -1
  219. package/template/coding-task-manager/lib/provider-login-broker.js +72 -15
  220. package/template/coding-task-manager/lib/provider-login-tab.js +10 -2
  221. package/template/coding-task-manager/lib/read-pool-client.js +89 -2
  222. package/template/coding-task-manager/lib/read-pool-worker-handle.js +29 -4
  223. package/template/coding-task-manager/lib/recent-catalog-query.js +15 -0
  224. package/template/coding-task-manager/lib/recent-session-catalog-response.js +67 -10
  225. package/template/coding-task-manager/lib/recovery-evidence.js +26 -11
  226. package/template/coding-task-manager/lib/relay-client.js +57 -3
  227. package/template/coding-task-manager/lib/relink-audit.js +34 -5
  228. package/template/coding-task-manager/lib/remote-machine-catalog.js +321 -26
  229. package/template/coding-task-manager/lib/remote-machine-create.js +576 -36
  230. package/template/coding-task-manager/lib/remote-machine-link.js +546 -59
  231. package/template/coding-task-manager/lib/remote-machine-proxy.js +143 -9
  232. package/template/coding-task-manager/lib/remote-session-forward.js +65 -7
  233. package/template/coding-task-manager/lib/remote-terminal-target.js +9 -2
  234. package/template/coding-task-manager/lib/restart-guard.js +18 -6
  235. package/template/coding-task-manager/lib/restore-seed-modes.js +87 -0
  236. package/template/coding-task-manager/lib/restored-scrollback-hydration-runtime.js +22 -2
  237. package/template/coding-task-manager/lib/resume-admission.js +17 -3
  238. package/template/coding-task-manager/lib/review-repo-registry.js +58 -19
  239. package/template/coding-task-manager/lib/root-delivery-runtime.js +2 -2
  240. package/template/coding-task-manager/lib/root-prompt-flow.js +1 -1
  241. package/template/coding-task-manager/lib/runtime-contract.js +3 -1
  242. package/template/coding-task-manager/lib/schema-indexes.js +1 -0
  243. package/template/coding-task-manager/lib/session-context-locator.js +742 -0
  244. package/template/coding-task-manager/lib/session-create/attachment.js +2 -2
  245. package/template/coding-task-manager/lib/session-create/create-phases.js +118 -0
  246. package/template/coding-task-manager/lib/session-create/delegate-worker.js +86 -16
  247. package/template/coding-task-manager/lib/session-create/dsh-session.js +930 -0
  248. package/template/coding-task-manager/lib/session-create/lead-route-launch.js +186 -13
  249. package/template/coding-task-manager/lib/session-create/native-lead-lifetime.js +55 -3
  250. package/template/coding-task-manager/lib/session-create/opencode-lead-provider.js +50 -0
  251. package/template/coding-task-manager/lib/session-create/prepared.js +162 -38
  252. package/template/coding-task-manager/lib/session-create/provider-process.js +160 -28
  253. package/template/coding-task-manager/lib/session-create/response.js +7 -7
  254. package/template/coding-task-manager/lib/session-create/structured-session.js +27 -0
  255. package/template/coding-task-manager/lib/session-create/subagent-pick-table.js +3 -1
  256. package/template/coding-task-manager/lib/session-create/subagent-pick.js +458 -9
  257. package/template/coding-task-manager/lib/session-create/transcript.js +1 -1
  258. package/template/coding-task-manager/lib/session-create/worker-budget-setup.js +21 -2
  259. package/template/coding-task-manager/lib/session-create/worker-continuation.js +7 -1
  260. package/template/coding-task-manager/lib/session-create/worker-launch-sandbox.js +39 -10
  261. package/template/coding-task-manager/lib/session-create/worker-observability.js +2 -2
  262. package/template/coding-task-manager/lib/session-empty.js +13 -10
  263. package/template/coding-task-manager/lib/session-host-manager.js +60 -0
  264. package/template/coding-task-manager/lib/session-jobs.js +3 -1
  265. package/template/coding-task-manager/lib/session-lifecycle.js +10 -4
  266. package/template/coding-task-manager/lib/session-model-action-preconditions.js +6 -2
  267. package/template/coding-task-manager/lib/session-model-intent-contract.js +11 -8
  268. package/template/coding-task-manager/lib/session-park.js +738 -0
  269. package/template/coding-task-manager/lib/session-path-grant.js +13 -4
  270. package/template/coding-task-manager/lib/session-status-ledger.js +106 -4
  271. package/template/coding-task-manager/lib/session-transport.js +17 -0
  272. package/template/coding-task-manager/lib/session-workspace-projection.js +54 -5
  273. package/template/coding-task-manager/lib/session-workspace-tracking-runtime.js +213 -1
  274. package/template/coding-task-manager/lib/skill-autocomplete-query.js +120 -3
  275. package/template/coding-task-manager/lib/skill-autocomplete.js +15 -1
  276. package/template/coding-task-manager/lib/sqlite-file-set.js +39 -0
  277. package/template/coding-task-manager/lib/standup-search-adapter.js +16 -2
  278. package/template/coding-task-manager/lib/state-sync/client-ledger.js +12 -0
  279. package/template/coding-task-manager/lib/state-sync/frame-emitter.js +319 -100
  280. package/template/coding-task-manager/lib/storage/coding-session-model-store.js +4 -1
  281. package/template/coding-task-manager/lib/storage/delegate-pref-mirror-store.js +32 -9
  282. package/template/coding-task-manager/lib/structured-session-boot-sweep.js +235 -0
  283. package/template/coding-task-manager/lib/submission-ack.js +90 -2
  284. package/template/coding-task-manager/lib/systemd-scope.js +115 -0
  285. package/template/coding-task-manager/lib/telemetry-receiver.js +39 -0
  286. package/template/coding-task-manager/lib/terminal-attach-geometry.js +985 -0
  287. package/template/coding-task-manager/lib/terminal-bootstrap.js +30 -0
  288. package/template/coding-task-manager/lib/terminal-draft-operation.js +357 -0
  289. package/template/coding-task-manager/lib/terminal-input-admission.js +17 -1
  290. package/template/coding-task-manager/lib/terminal-input-prepared.js +563 -0
  291. package/template/coding-task-manager/lib/transcript-cache-temps.js +171 -0
  292. package/template/coding-task-manager/lib/transcript-frame-cache.js +8 -3
  293. package/template/coding-task-manager/lib/transcript-source.js +36 -4
  294. package/template/coding-task-manager/lib/transcript-store.js +9 -0
  295. package/template/coding-task-manager/lib/typing-interaction-trace.js +106 -3
  296. package/template/coding-task-manager/lib/usage-ledger/backfill.js +40 -5
  297. package/template/coding-task-manager/lib/usage-ledger/client.js +4 -1
  298. package/template/coding-task-manager/lib/usage-ledger/dsh-parse.js +152 -0
  299. package/template/coding-task-manager/lib/usage-ledger/dsh-read.js +73 -0
  300. package/template/coding-task-manager/lib/usage-ledger/ingest.js +34 -6
  301. package/template/coding-task-manager/lib/usage-ledger/live.js +35 -8
  302. package/template/coding-task-manager/lib/usage-ledger/read.js +29 -20
  303. package/template/coding-task-manager/lib/usage-ledger/schema.js +61 -1
  304. package/template/coding-task-manager/lib/usage-ledger/task-coordinator.js +4 -2
  305. package/template/coding-task-manager/lib/wake-skip-reasons.js +1 -0
  306. package/template/coding-task-manager/lib/walle/turn.js +1 -1
  307. package/template/coding-task-manager/lib/walle-history-serve.js +838 -0
  308. package/template/coding-task-manager/lib/walle-kill-authority.js +2 -2
  309. package/template/coding-task-manager/lib/walle-log-rotation.js +164 -1
  310. package/template/coding-task-manager/lib/walle-mcp-auto-config.js +56 -2
  311. package/template/coding-task-manager/lib/walle-row-history.js +495 -0
  312. package/template/coding-task-manager/lib/walle-supervisor.js +33 -14
  313. package/template/coding-task-manager/lib/watch-worker-client.js +203 -0
  314. package/template/coding-task-manager/lib/worker-terminal-output.js +5 -2
  315. package/template/coding-task-manager/lib/ws/hub-bridge.js +35 -0
  316. package/template/coding-task-manager/lib/ws/lifecycle.js +6 -11
  317. package/template/coding-task-manager/lib/ws/misc.js +1 -1
  318. package/template/coding-task-manager/lib/ws/state-sync.js +106 -4
  319. package/template/coding-task-manager/lib/ws/terminal-attach.js +91 -0
  320. package/template/coding-task-manager/oauth-proxy.js +112 -115
  321. package/template/coding-task-manager/package-lock.json +10 -94
  322. package/template/coding-task-manager/package.json +4 -3
  323. package/template/coding-task-manager/protocol/message-table.json +10 -0
  324. package/template/coding-task-manager/protocol/messages.js +10 -0
  325. package/template/coding-task-manager/protocol/ws-schema.json +20 -0
  326. package/template/coding-task-manager/protocol/ws-schema.md +2 -0
  327. package/template/coding-task-manager/public/css/app.css +170 -0
  328. package/template/coding-task-manager/public/css/walle-session.css +4 -0
  329. package/template/coding-task-manager/public/index.html +473 -243
  330. package/template/coding-task-manager/public/js/agent-command.js +86 -0
  331. package/template/coding-task-manager/public/js/app/backups-panel.js +111 -5
  332. package/template/coding-task-manager/public/js/app/coding-agent-upgrade.js +103 -4
  333. package/template/coding-task-manager/public/js/app/machines-page.js +1328 -8
  334. package/template/coding-task-manager/public/js/app/models-tab.js +113 -38
  335. package/template/coding-task-manager/public/js/app/restart-banner.js +53 -0
  336. package/template/coding-task-manager/public/js/app/worktrees-panel.js +184 -2
  337. package/template/coding-task-manager/public/js/background-work.js +196 -27
  338. package/template/coding-task-manager/public/js/coding-session-model-presentation.js +138 -10
  339. package/template/coding-task-manager/public/js/core/active-session-groups.js +179 -0
  340. package/template/coding-task-manager/public/js/core/pending-create.js +238 -0
  341. package/template/coding-task-manager/public/js/core/recent-machine-source.js +228 -0
  342. package/template/coding-task-manager/public/js/core/session-commands.js +180 -14
  343. package/template/coding-task-manager/public/js/dashboard-client-function-order.json +8 -0
  344. package/template/coding-task-manager/public/js/document-review-links.js +9 -1
  345. package/template/coding-task-manager/public/js/file-resolution-client.js +52 -1
  346. package/template/coding-task-manager/public/js/history-app-seam.js +22 -2
  347. package/template/coding-task-manager/public/js/machine-pool-shared.js +558 -3
  348. package/template/coding-task-manager/public/js/message-renderer.js +4 -3
  349. package/template/coding-task-manager/public/js/model-picker.js +3 -1
  350. package/template/coding-task-manager/public/js/native-subagent-review.js +15 -2
  351. package/template/coding-task-manager/public/js/new-session-agent-grid.js +346 -0
  352. package/template/coding-task-manager/public/js/pasted-content.js +236 -0
  353. package/template/coding-task-manager/public/js/pe-private-content-lifetime.js +39 -7
  354. package/template/coding-task-manager/public/js/private-model-lifetime.js +6 -2
  355. package/template/coding-task-manager/public/js/prompts.js +11 -10
  356. package/template/coding-task-manager/public/js/protocol.js +10 -0
  357. package/template/coding-task-manager/public/js/restart-banner-policy.js +29 -1
  358. package/template/coding-task-manager/public/js/session-model-picker.js +447 -57
  359. package/template/coding-task-manager/public/js/session-search-utils.js +18 -2
  360. package/template/coding-task-manager/public/js/session-usage-view.js +2 -1
  361. package/template/coding-task-manager/public/js/session-workspace-tracking.js +51 -1
  362. package/template/coding-task-manager/public/js/state-sync-client.js +10 -1
  363. package/template/coding-task-manager/public/js/stream-view.js +6 -5
  364. package/template/coding-task-manager/public/js/terminal/conv-only-session.js +287 -0
  365. package/template/coding-task-manager/public/js/terminal/runtime-09.js +2 -0
  366. package/template/coding-task-manager/public/js/terminal/runtime-10.js +5 -5
  367. package/template/coding-task-manager/public/js/terminal/runtime-12.js +8 -1
  368. package/template/coding-task-manager/public/js/terminal/runtime-16.js +4 -0
  369. package/template/coding-task-manager/public/js/terminal/runtime-20.js +1 -1
  370. package/template/coding-task-manager/public/js/terminal-attachment-draft.js +67 -13
  371. package/template/coding-task-manager/public/js/terminal-image-orchestration.js +52 -6
  372. package/template/coding-task-manager/public/js/terminal-wheel.js +23 -3
  373. package/template/coding-task-manager/public/js/walle/chat-conversation.js +4 -4
  374. package/template/coding-task-manager/public/js/walle.js +6 -0
  375. package/template/coding-task-manager/public/js/ws/live-dispatch.js +10 -0
  376. package/template/coding-task-manager/public/m/app.css +140 -0
  377. package/template/coding-task-manager/public/m/app.js +507 -68
  378. package/template/coding-task-manager/public/m/index.html +17 -0
  379. package/template/coding-task-manager/public/m/machines.js +838 -4
  380. package/template/coding-task-manager/public/m/private-api.js +31 -1
  381. package/template/coding-task-manager/public/usage.html +1 -0
  382. package/template/coding-task-manager/scripts/dev-route-fixture/synthetic-route-upstream.js +104 -0
  383. package/template/coding-task-manager/scripts/dev-route-fixture.js +339 -0
  384. package/template/coding-task-manager/scripts/opencode-lead-route-probe.js +195 -0
  385. package/template/coding-task-manager/scripts/opencode-v2-contract-probe/fake-anthropic.js +168 -0
  386. package/template/coding-task-manager/scripts/opencode-v2-contract-probe/probe-plugin/server.js +95 -0
  387. package/template/coding-task-manager/scripts/opencode-v2-contract-probe.js +673 -0
  388. package/template/coding-task-manager/server.js +1950 -11679
  389. package/template/coding-task-manager/session-integrity.js +64 -4
  390. package/template/coding-task-manager/workers/db-owner-worker.js +19 -2
  391. package/template/coding-task-manager/workers/online-backup-worker.js +1 -0
  392. package/template/coding-task-manager/workers/read-pool-model-collector.js +51 -3
  393. package/template/coding-task-manager/workers/read-pool-worker.js +76 -6
  394. package/template/coding-task-manager/workers/session-host-pool-process.js +6 -6
  395. package/template/coding-task-manager/workers/session-host-process.js +5 -5
  396. package/template/coding-task-manager/workers/watch-worker.js +105 -0
  397. package/template/coding-task-manager/workers/worktree-worker.js +168 -33
  398. package/template/ctm-relay/lib/protocol.js +14 -1
  399. package/template/package-lock.json +2 -2
  400. package/template/package.json +1 -1
  401. package/template/shared/backup-retention.js +82 -0
  402. package/template/shared/conversation-admission.js +2 -0
  403. package/template/shared/conversation-provenance.js +3 -7
  404. package/template/shared/conversation-storage-v2-contract.js +5 -0
  405. package/template/shared/linked-worktree.js +51 -0
  406. package/template/shared/macos-fda-target.js +57 -0
  407. package/template/shared/orphan-parents.js +55 -0
  408. package/template/shared/release-schema.json +2 -2
  409. package/template/shared/storage/contract.js +3 -0
  410. package/template/wall-e/adapters/imessage.js +12 -0
  411. package/template/wall-e/agent.js +71 -4
  412. package/template/wall-e/api-walle.js +21 -1
  413. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +2 -2
  414. package/template/wall-e/bin/walle-mcp-stdio.js +5 -5
  415. package/template/wall-e/brain/brain-join.js +859 -0
  416. package/template/wall-e/brain/brain-sync-membership.js +20 -0
  417. package/template/wall-e/brain/brain-sync-store.js +131 -77
  418. package/template/wall-e/brain/brain-sync-v2-schema.js +203 -0
  419. package/template/wall-e/brain/brain-sync-v2.js +578 -0
  420. package/template/wall-e/brain/delegate-model-prefs-store.js +13 -4
  421. package/template/wall-e/brain/deterministic-id.js +51 -0
  422. package/template/wall-e/brain/memories.js +2 -2
  423. package/template/wall-e/brain/migrations.js +40 -6
  424. package/template/wall-e/brain/model-identity-migration.js +1 -1
  425. package/template/wall-e/brain/owner-call-whitelist.js +23 -7
  426. package/template/wall-e/brain/schema.js +45 -85
  427. package/template/wall-e/brain.js +171 -35
  428. package/template/wall-e/chat.js +3 -3
  429. package/template/wall-e/coding/agent-shell-env.js +7 -2
  430. package/template/wall-e/coding/prompt-bundle.js +66 -21
  431. package/template/wall-e/config/main-loop-exemptions.json +2 -2
  432. package/template/wall-e/http/admin-api.js +37 -0
  433. package/template/wall-e/http/gws-api.js +4 -0
  434. package/template/wall-e/http/helper-rules-api.js +7 -1
  435. package/template/wall-e/http/model-admin.js +8 -1
  436. package/template/wall-e/http/model-delegate-pref-admin.js +3 -3
  437. package/template/wall-e/http/model-proxy-providers.js +174 -1
  438. package/template/wall-e/http/model-route-health.js +157 -0
  439. package/template/wall-e/http/sync-api.js +278 -464
  440. package/template/wall-e/jobs/job-runner.js +28 -1
  441. package/template/wall-e/lib/brain-owner-op-priority.js +9 -1
  442. package/template/wall-e/lib/ctm-data-dir.js +26 -0
  443. package/template/wall-e/lib/derivation-home.js +147 -0
  444. package/template/wall-e/lib/helper-rules/evaluate.js +50 -9
  445. package/template/wall-e/lib/helper-rules/grade.js +51 -8
  446. package/template/wall-e/lib/helper-rules/lead-call-hook.js +38 -5
  447. package/template/wall-e/lib/helper-rules/store.js +4 -2
  448. package/template/wall-e/lib/landing-mcp-tools.js +170 -0
  449. package/template/wall-e/lib/mcp-client-config.js +133 -14
  450. package/template/wall-e/lib/mcp-gateway-migrate.js +28 -64
  451. package/template/wall-e/lib/mcp-integration.js +58 -13
  452. package/template/wall-e/lib/parent-death.js +7 -1
  453. package/template/wall-e/lib/scheduler-worker-jobs.js +6 -4
  454. package/template/wall-e/llm/delegate-lead-prefs.js +16 -1
  455. package/template/wall-e/llm/delegate-vendor-default.js +12 -5
  456. package/template/wall-e/llm/model-roles.js +1 -1
  457. package/template/wall-e/llm/native-delegate-selection.js +56 -8
  458. package/template/wall-e/llm/worker-delegate-args.js +72 -7
  459. package/template/wall-e/llm/worker-mcp-routing.js +66 -17
  460. package/template/wall-e/loops/brain-custom-writes.js +19 -0
  461. package/template/wall-e/loops/ingest.js +17 -1
  462. package/template/wall-e/loops/think.js +19 -4
  463. package/template/wall-e/mcp-contract.js +222 -6
  464. package/template/wall-e/mcp-protocol-contract.js +40 -5
  465. package/template/wall-e/mcp-server.js +128 -29
  466. package/template/wall-e/mcp-worker-schemas.js +5 -5
  467. package/template/wall-e/memory/ctm-context-client.js +85 -0
  468. package/template/wall-e/memory/ctm-session-context.js +9 -3
  469. package/template/wall-e/memory/hub-bridge-client.js +388 -0
  470. package/template/wall-e/memory/replica-origin.js +56 -0
  471. package/template/wall-e/memory/session-pool.js +135 -0
  472. package/template/wall-e/runtime/live-capabilities.js +8 -3
  473. package/template/wall-e/server.js +4 -2
  474. package/template/wall-e/shared/release-schema.json +2 -2
  475. package/template/wall-e/shared/sqlite-file-set.js +39 -0
  476. package/template/wall-e/shared/storage/contract.js +3 -0
  477. package/template/wall-e/skills/_templates/delegate-worker/VERSION +1 -1
  478. package/template/wall-e/skills/_templates/delegate-worker/released-sha256.json +7 -1
  479. package/template/wall-e/skills/_templates/delegate-worker/templates/claude/SKILL.md +4 -4
  480. package/template/wall-e/skills/_templates/delegate-worker/templates/codex/SKILL.md +6 -6
  481. package/template/wall-e/skills/_templates/fleet-sync/VERSION +1 -0
  482. package/template/wall-e/skills/_templates/fleet-sync/released-sha256.json +16 -0
  483. package/template/wall-e/skills/_templates/fleet-sync/templates/claude/SKILL.md +64 -0
  484. package/template/wall-e/skills/_templates/fleet-sync/templates/codex/SKILL.md +64 -0
  485. package/template/wall-e/tools/chrome-automation.js +6 -0
  486. package/template/wall-e/training/setup-cuda-train-env.sh +2 -2
  487. package/template/wall-e/workers/brain-owner-worker.js +18 -4
  488. package/template/wall-e/workers/mcp-gateway-process.js +5 -5
@@ -0,0 +1,998 @@
1
+ 'use strict';
2
+ // WP-1 (docs/local-landing-queue-multi-session-integration-2026-10-26.html §5 D-1/D-2, §6.3,
3
+ // §8 WP-1): job and queue state operations for the landing queue.
4
+ //
5
+ // All state lives under `<git-common-dir>/landing/` (WP-0 `state.js`), shared by every worktree of
6
+ // the repository. `queue.json` is the single ordering authority — its `order` array holds the id of
7
+ // every NON-TERMINAL job, sorted: the `in_progress` ids first in their existing relative order, then
8
+ // the `queued` ids by priority rank descending (`normal` < `high` < `urgent`, §5 D-1) and, within a
9
+ // level, by arrival (the id ascending, §5 D-3) — and each job's own `<id>.json` carries its state.
10
+ // Every mutation of that pair happens inside `state.withMutex`, so two `land` processes can never
11
+ // interleave a read-modify-write of the queue.
12
+ //
13
+ // There is no daemon and no scheduler (D-2): the runner is whichever `land` process holds the lease
14
+ // in `lease.js`, and this module is the state machine it drives. No git, no spawn, no config here.
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+
20
+ const state = require('./state');
21
+ const lease = require('./lease');
22
+ const { landingError } = require('./git');
23
+
24
+ const JOB_STATES = Object.freeze(['queued', 'in_progress', 'landed', 'failed', 'blocked', 'canceled']);
25
+ const TERMINAL_STATES = Object.freeze(['landed', 'failed', 'blocked', 'canceled']);
26
+ const ACTIVE_STATES = Object.freeze(['queued', 'in_progress']);
27
+
28
+ // D-1: three named levels, ascending rank. There is no numeric form and no `low`.
29
+ const PRIORITIES = Object.freeze(['normal', 'high', 'urgent']);
30
+ // D-7: a non-empty reason of at most this many characters is required for high and urgent.
31
+ const PRIORITY_REASON_MAX = 200;
32
+
33
+ // Sortable id (no deps): 10 Crockford-base32 characters of the submit timestamp (50 bits ≈ 35k
34
+ // years of milliseconds) followed by 6 random characters. Lexicographic order of the id IS arrival
35
+ // order, and two processes submitting in the same millisecond still cannot collide.
36
+ const BASE32 = '0123456789abcdefghjkmnpqrstvwxyz';
37
+ const ID_TIME_CHARS = 10;
38
+ const ID_RANDOM_CHARS = 6;
39
+ // OQ-3: the ETA stays `null` until this many gate durations have been recorded.
40
+ const ETA_SAMPLES = 20;
41
+ // OQ-3: the ETA is position × the MEDIAN of the last ETA_SAMPLES gate seconds.
42
+ const ETA_WINDOW = 20;
43
+
44
+ function nowIso(ms) {
45
+ return new Date(ms === undefined ? Date.now() : ms).toISOString();
46
+ }
47
+
48
+ function newJobId(nowMs) {
49
+ let value = Math.floor(nowMs === undefined ? Date.now() : nowMs);
50
+ let stamp = '';
51
+ for (let index = 0; index < ID_TIME_CHARS; index += 1) {
52
+ stamp = BASE32[value % 32] + stamp;
53
+ value = Math.floor(value / 32);
54
+ }
55
+ let suffix = '';
56
+ for (const byte of crypto.randomBytes(ID_RANDOM_CHARS)) suffix += BASE32[byte % 32];
57
+ return `${stamp}${suffix}`;
58
+ }
59
+
60
+ // WP-1 D-1 (§6): the batch id, the train id of a real batch (a singleton keeps its job id). A ULID:
61
+ // 48 bits of timestamp then 80 bits of randomness, Crockford base32, so two batches built in the
62
+ // same millisecond still cannot collide and the id sorts by creation time.
63
+ function newBatchId(nowMs) {
64
+ let value = Math.floor(nowMs === undefined ? Date.now() : nowMs);
65
+ let stamp = '';
66
+ for (let index = 0; index < 10; index += 1) {
67
+ stamp = BASE32[value % 32] + stamp;
68
+ value = Math.floor(value / 32);
69
+ }
70
+ let suffix = '';
71
+ for (const byte of crypto.randomBytes(16)) suffix += BASE32[byte % 32];
72
+ return `b-${stamp}${suffix}`;
73
+ }
74
+
75
+ // --- paths (§6.3 layout, on top of state.js's SUBDIRS) ---------------------------------------
76
+
77
+ function jobsDir(root) { return path.join(root, 'jobs'); }
78
+ function jobFile(root, id) { return path.join(jobsDir(root), `${id}.json`); }
79
+ function queueFile(root) { return path.join(root, 'queue.json'); }
80
+ function metricsDir(root) { return path.join(root, 'metrics'); }
81
+ function verdictsDir(root) { return path.join(root, 'verdicts'); }
82
+ function logsDir(root) { return path.join(root, 'logs'); }
83
+ function trainsDir(root) { return path.join(root, 'trains'); }
84
+
85
+ // `landing/logs/` is not in state.js's SUBDIRS (WP-0 owns that list) and is created lazily here
86
+ // because a job only needs a gate log once a gate actually runs.
87
+ function ensureLogsDir(root) {
88
+ fs.mkdirSync(logsDir(root), { recursive: true });
89
+ return logsDir(root);
90
+ }
91
+
92
+ // --- queue.json ------------------------------------------------------------------------------
93
+
94
+ function readQueue(root) {
95
+ const raw = state.readJsonSafe(queueFile(root));
96
+ if (!raw) return { contract: state.CONTRACT, order: [] };
97
+ state.assertContract(raw, { file: queueFile(root) });
98
+ const order = Array.isArray(raw.order) ? raw.order.filter((id) => typeof id === 'string') : [];
99
+ return { contract: state.CONTRACT, order };
100
+ }
101
+
102
+ function writeQueue(root, order) {
103
+ return state.writeJsonAtomic(queueFile(root), { contract: state.CONTRACT, order });
104
+ }
105
+
106
+ // --- jobs ------------------------------------------------------------------------------------
107
+
108
+ function readJob(root, id) {
109
+ const raw = state.readJsonSafe(jobFile(root, id));
110
+ if (!raw) return null;
111
+ state.assertContract(raw, { file: jobFile(root, id) });
112
+ return raw;
113
+ }
114
+
115
+ function writeJob(root, job) {
116
+ return state.writeJsonAtomic(jobFile(root, job.id), job);
117
+ }
118
+
119
+ function listJobs(root) {
120
+ let names;
121
+ try {
122
+ names = fs.readdirSync(jobsDir(root));
123
+ } catch (error) {
124
+ /* expected: no job has ever been submitted in this repository, so landing/jobs/ does not
125
+ exist yet — an empty queue is the correct answer, never an error */
126
+ return [];
127
+ }
128
+ const jobs = [];
129
+ for (const name of names.filter((n) => n.endsWith('.json')).sort()) {
130
+ const job = readJob(root, name.slice(0, -'.json'.length));
131
+ if (job) jobs.push(job);
132
+ }
133
+ return jobs;
134
+ }
135
+
136
+ // One line per transition (§6.3). `gateSeconds` is what OQ-3's ETA reads; the doc's five fields
137
+ // are all present.
138
+ //
139
+ // The `contract` default is the state contract, but an EVENT row (`refused`, `batch`) overrides it
140
+ // with the metrics-schema version of §6/D-8 — a transition row and an event row are different
141
+ // schemas in the same append-only file, and a reader can tell them apart by `contract`/`event`
142
+ // without knowing which release wrote them.
143
+ function appendMetric(root, entry) {
144
+ fs.mkdirSync(metricsDir(root), { recursive: true });
145
+ const file = path.join(metricsDir(root), `${nowIso().slice(0, 10)}.jsonl`);
146
+ fs.appendFileSync(file, `${JSON.stringify({ contract: state.CONTRACT, ...entry })}\n`);
147
+ return file;
148
+ }
149
+
150
+ // §6/D-8: the metrics-schema version an `event` row carries. A transition row stays at
151
+ // `state.CONTRACT`; an event row is contract 2, which is what §7's metrics-shape invariant reads.
152
+ const EVENT_CONTRACT = 2;
153
+
154
+ function isTerminal(job) {
155
+ return Boolean(job) && TERMINAL_STATES.includes(job.state);
156
+ }
157
+
158
+ function isActive(job) {
159
+ return Boolean(job) && ACTIVE_STATES.includes(job.state);
160
+ }
161
+
162
+ // --- priority (§5 D-1, D-2, D-3, D-7) -----------------------------------------------------------
163
+
164
+ // D-1: a job with no `priority`, or with a value outside the enum, is `normal` — every job written
165
+ // before this change, and any job written by an older binary, keeps working unchanged.
166
+ function priorityOf(job) {
167
+ const value = job && job.priority;
168
+ return PRIORITIES.includes(value) ? value : 'normal';
169
+ }
170
+
171
+ function priorityRank(job) {
172
+ return PRIORITIES.indexOf(priorityOf(job));
173
+ }
174
+
175
+ function jobFrom(jobsById, id) {
176
+ if (!jobsById) return null;
177
+ if (typeof jobsById.get === 'function') return jobsById.get(id) || null;
178
+ return jobsById[id] || null;
179
+ }
180
+
181
+ // D-2: pure. `jobsById` maps id -> job; an id with no job file and an id whose job is terminal are
182
+ // both dropped (a terminal job is not in the queue). The result is the canonical order: the
183
+ // `in_progress` ids first in their existing relative order, then the rest by rank descending, then
184
+ // by id ascending (D-3 — the id is arrival-sortable, queue.js:28-30).
185
+ function sortOrder(ids, jobsById) {
186
+ const running = [];
187
+ const queued = [];
188
+ for (const id of ids) {
189
+ const job = jobFrom(jobsById, id);
190
+ if (!job) continue;
191
+ if (TERMINAL_STATES.includes(job.state)) continue;
192
+ if (job.state === 'in_progress') running.push(id);
193
+ else queued.push(id);
194
+ }
195
+ queued.sort((a, b) => {
196
+ const byRank = priorityRank(jobFrom(jobsById, b)) - priorityRank(jobFrom(jobsById, a));
197
+ if (byRank !== 0) return byRank;
198
+ if (a === b) return 0;
199
+ return a < b ? -1 : 1;
200
+ });
201
+ return [...running, ...queued];
202
+ }
203
+
204
+ // The canonical order of the ids currently in `queue.json`, read from the job files themselves.
205
+ function sortedQueueOrder(root, ids, overrides) {
206
+ const jobsById = {};
207
+ for (const id of ids) {
208
+ jobsById[id] = overrides && overrides[id] ? overrides[id] : readJob(root, id);
209
+ }
210
+ return sortOrder(ids, jobsById);
211
+ }
212
+
213
+ // Idempotency (§6.2): the same branch at the same tip that is already queued or in_progress is not
214
+ // a second job.
215
+ function activeJobFor(root, branch, tip) {
216
+ return listJobs(root).find((job) => job.branch === branch && job.tip === tip && isActive(job)) || null;
217
+ }
218
+
219
+ // D-11 / I-5: a second `land` process for the same CTM session is a refusal, but only while the
220
+ // first one is actually alive — a session that died leaves its job reapable, not a lockout.
221
+ //
222
+ // A submit no longer drains in the foreground, so the submit process is usually already gone by the
223
+ // time anyone asks. The process that actually represents the job is the RUNNER working it
224
+ // (`runnerPid`, recorded by `land submit`); either pid being alive is what makes the session busy.
225
+ //
226
+ // D-7: the refusal is per (session, BRANCH), not per session — the I-5 race it guards (two resumed
227
+ // sessions sharing one index) is per worktree+branch, so one session landing two DIFFERENT branches
228
+ // from two worktrees is legitimate; only a second active job for the SAME branch collides.
229
+ function duplicateSubmitter(root, session, pid, branch) {
230
+ if (!session) return null;
231
+ return listJobs(root).find((job) => {
232
+ if (!isActive(job)) return false;
233
+ if (!job.submittedBy || job.submittedBy.session !== session) return false;
234
+ if (job.branch !== branch) return false;
235
+ const owners = [job.submittedBy.pid, job.runnerPid]
236
+ .filter((value) => typeof value === 'number' && value !== pid);
237
+ return owners.some((value) => state.pidIsAlive(value));
238
+ }) || null;
239
+ }
240
+
241
+ function enqueue(root, job) {
242
+ return state.withMutex(root, () => {
243
+ state.ensureDirs(root);
244
+ writeJob(root, job);
245
+ const queued = readQueue(root).order;
246
+ // D-2: the sorted place of a new job is the canonical one — an older binary's plain append and
247
+ // this sort agree, because the end of the `queued` run IS a normal job's correct place.
248
+ const order = sortedQueueOrder(root, [...queued, job.id], { [job.id]: job });
249
+ writeQueue(root, order);
250
+ appendMetric(root, {
251
+ jobId: job.id,
252
+ from: null,
253
+ to: job.state,
254
+ at: job.timestamps && job.timestamps.queued ? job.timestamps.queued : nowIso(),
255
+ seconds: null,
256
+ gateSeconds: null,
257
+ });
258
+ return { position: order.indexOf(job.id) + 1, queued: order.length };
259
+ });
260
+ }
261
+
262
+ function positionOf(root, id) {
263
+ const index = readQueue(root).order.indexOf(id);
264
+ return index === -1 ? null : index + 1;
265
+ }
266
+
267
+ function updateJob(root, id, patch) {
268
+ return state.withMutex(root, () => {
269
+ const job = readJob(root, id);
270
+ if (!job) return null;
271
+ const next = { ...job, ...patch };
272
+ writeJob(root, next);
273
+ return next;
274
+ });
275
+ }
276
+
277
+ // Terminal transition: the job leaves the queue and one metrics line is appended. `metric` carries
278
+ // the per-transition extras (`gateSeconds`) that the ETA needs.
279
+ //
280
+ // The body is mutex-FREE (`finishJobLocked`) so a batch can write N of them under ONE hold —
281
+ // `state.withMutex` is not re-entrant, so `finishBatch` below must not call the public wrapper.
282
+ function finishJob(root, id, patch, metric = {}) {
283
+ return state.withMutex(root, () => finishJobLocked(root, id, patch, metric));
284
+ }
285
+
286
+ function finishJobLocked(root, id, patch, metric = {}) {
287
+ const job = readJob(root, id);
288
+ const from = job ? job.state : null;
289
+ const next = { ...(job || { id }), ...patch, id };
290
+ writeJob(root, next);
291
+ writeQueue(root, readQueue(root).order.filter((queuedId) => queuedId !== id));
292
+ const startedAt = next.timestamps && next.timestamps.started ? Date.parse(next.timestamps.started) : NaN;
293
+ appendMetric(root, {
294
+ jobId: id,
295
+ from,
296
+ to: next.state,
297
+ at: nowIso(),
298
+ seconds: Number.isFinite(startedAt) ? Number(((Date.now() - startedAt) / 1000).toFixed(3)) : null,
299
+ gateSeconds: typeof metric.gateSeconds === 'number' ? metric.gateSeconds : null,
300
+ // WP-0 (§6.6/D-8): the terminal row carries the same `gate` object the job file does, so a
301
+ // later WP can measure every terminal transition from the metrics stream alone.
302
+ gate: metric.gate || null,
303
+ });
304
+ return next;
305
+ }
306
+
307
+ // WP-1 D-1/§6 (AC-1a): ONE terminal write for a whole batch. Every member lands with the same
308
+ // `train`/`tree`/`base`/`batch`, and exactly ONE `{ event: 'batch' }` metrics row records the
309
+ // verdict — a batch is one gate, so it must also be one row for the metrics readers to count.
310
+ // `patch` is an object (spread onto every member) or `(member) => object`. `metric` is the batch's
311
+ // ONE telemetry row; it is written under this same hold, so "the members landed" and "the row that
312
+ // records the verdict is on disk" are one transition, exactly as `finishJobLocked` writes a job's
313
+ // state and its row together. (A reader that does not take the mutex can still, in principle, catch
314
+ // the instant between the last member's write and the row's; a reader that needs both waits for both.)
315
+ function finishBatch(root, batchId, members, patch = {}, { gateSeconds = null, gate = null, metric = null } = {}) {
316
+ return state.withMutex(root, () => {
317
+ const results = [];
318
+ for (const member of members) {
319
+ const next = typeof patch === 'function' ? patch(member) : patch;
320
+ results.push(finishJobLocked(root, member.id, next, { gateSeconds, gate }));
321
+ }
322
+ if (metric) recordBatchMetric(root, { batchId, ...metric });
323
+ return results;
324
+ });
325
+ }
326
+
327
+ // WP-1 D-1 (§6): the batch telemetry row, written under the caller's hold (`finishBatch`) or on its
328
+ // own (the FAIL path, which changes no job state). It carries `event`, so every state-transition
329
+ // reader skips it (`metricSequence` filters on `to`; the ETA reader skips `event` rows explicitly).
330
+ function recordBatchMetric(root, entry) {
331
+ return appendMetric(root, { contract: EVENT_CONTRACT, event: 'batch', ...entry, at: nowIso() });
332
+ }
333
+
334
+ // WP-1 D-2 (§6, AC-1b): the second half of a bisected batch. Every member goes back to `queued` —
335
+ // NOT failed: the gate said the batch is bad, not which member is — carrying `batch.attempt + 1` so
336
+ // the next bisection round is visible on the record. One metrics row per member, copying the
337
+ // `requeuePreempted` shape with `reason: 'bisected'`. The ids sort by arrival (D-3), so the half is
338
+ // ahead of everything submitted after it without any new ordering state.
339
+ // `reason` names the metric row: 'bisected' (a FAIL halving) or 'withdrawn' (a member's cancel voided the run, D-4).
340
+ function requeueHalf(root, members, { batchId = null, attempt = 1, batchMembers = null, reason = 'bisected' } = {}) {
341
+ return state.withMutex(root, () => {
342
+ const ids = members.map((member) => member.id);
343
+ const results = [];
344
+ for (const member of members) {
345
+ const job = readJob(root, member.id);
346
+ if (!job) continue;
347
+ const timestamps = { ...(job.timestamps || {}) };
348
+ delete timestamps.started;
349
+ const next = {
350
+ ...job,
351
+ state: 'queued',
352
+ timestamps,
353
+ batch: {
354
+ id: batchId || (job.batch && job.batch.id) || job.id,
355
+ members: (batchMembers || ids).slice(),
356
+ attempt,
357
+ index: ids.indexOf(member.id),
358
+ },
359
+ };
360
+ writeJob(root, next);
361
+ appendMetric(root, {
362
+ jobId: member.id,
363
+ from: 'in_progress',
364
+ to: 'queued',
365
+ at: nowIso(),
366
+ seconds: null,
367
+ gateSeconds: null,
368
+ reason,
369
+ });
370
+ results.push(next);
371
+ }
372
+ const order = readQueue(root).order;
373
+ const head = order.filter((queuedId) => !ids.includes(queuedId));
374
+ writeQueue(root, [...head, ...ids.filter((id) => order.includes(id))]);
375
+ return results;
376
+ });
377
+ }
378
+
379
+ // WP-0 D-8 (§6, §9 S-13, AC-0a): a submit REFUSAL is an event. `duplicate_submitter` and
380
+ // `conflict_at_submit` are thrown before any job record exists, so until now the queue could not see
381
+ // its own back-pressure at all (census §1). One row per refusal, in the day's metrics file, through
382
+ // the same writer as every other row.
383
+ function recordRefusal(root, { reason, session = null, branch = null } = {}) {
384
+ return appendMetric(root, {
385
+ contract: EVENT_CONTRACT,
386
+ event: 'refused',
387
+ reason: reason === undefined ? null : reason,
388
+ session: session === undefined ? null : session,
389
+ branch: branch === undefined ? null : branch,
390
+ // §6/D-8: the row is stamped like every other one, so a reader can order refusals against the
391
+ // transitions around them.
392
+ at: nowIso(),
393
+ });
394
+ }
395
+
396
+ // AC-0a/§6: how many refusals were recorded today, for `land status`'s `refused today: N` line. Pure
397
+ // over the day's metrics file — a missing file is 0, never an error.
398
+ function refusalsToday(root, day = nowIso().slice(0, 10)) {
399
+ const name = `${day}.jsonl`;
400
+ let raw;
401
+ try {
402
+ raw = fs.readFileSync(path.join(metricsDir(root), name), 'utf8');
403
+ } catch (error) {
404
+ /* expected: nothing has been refused (or nothing has happened at all) today */
405
+ return 0;
406
+ }
407
+ let count = 0;
408
+ for (const line of raw.split('\n')) {
409
+ if (!line) continue;
410
+ let entry;
411
+ try {
412
+ entry = JSON.parse(line);
413
+ } catch (error) {
414
+ /* expected: a torn final line from a process killed mid-append — one unparseable row never
415
+ invalidates the rows around it */
416
+ continue;
417
+ }
418
+ if (entry && entry.event === 'refused') count += 1;
419
+ }
420
+ return count;
421
+ }
422
+
423
+ // --- priority mutation (§5 D-1, D-7; §6.1) -----------------------------------------------------
424
+
425
+ // D-7/D-4: one effective change = one `priorityHistory` entry + exactly one metrics line. The
426
+ // metrics line deliberately carries `priorityFrom`/`priorityTo` and NEVER `from`/`to`, so the
427
+ // state-transition readers (metricSequence, the ETA) skip it.
428
+ //
429
+ // Reason VALIDATION is the caller's job (a usage error, exit 2, whose message names the flag), so
430
+ // nothing here re-formats it. There is no ownership check, matching `land cancel`.
431
+ //
432
+ // §6.1/D-7: the preempt field is the D-4 sentence's outcome, and asking for `urgent` really does
433
+ // write the stop request of the running lower-ranked job (WP-3) — inside this same mutex hold, so no
434
+ // runner checkpoint can read the queue between the rank change and the request.
435
+ function setPriority(root, id, { priority, reason = null, by = null, via = null } = {}) {
436
+ return state.withMutex(root, () => {
437
+ const job = readJob(root, id);
438
+ if (!job) throw landingError('job_not_found', `no landing job ${JSON.stringify(id)} in ${root}`, { jobId: id, root });
439
+ if (TERMINAL_STATES.includes(job.state)) {
440
+ throw landingError('job_terminal', `job ${id} is already ${job.state}`, { jobId: id, state: job.state });
441
+ }
442
+ if (!PRIORITIES.includes(priority)) {
443
+ throw landingError('priority_invalid', `priority ${JSON.stringify(priority)} is not one of ${PRIORITIES.join(', ')}`, { jobId: id, priority });
444
+ }
445
+ const from = priorityOf(job);
446
+ const order = readQueue(root).order;
447
+ const at = nowIso();
448
+ // D-7: a no-op writes no history and no metric — asking for the level a job already has is not a
449
+ // change, and a log full of no-ops is not an audit trail.
450
+ if (from === priority) {
451
+ const index = order.indexOf(id);
452
+ return { job, changed: false, position: index === -1 ? null : index + 1, preempt: null };
453
+ }
454
+ const history = Array.isArray(job.priorityHistory) ? job.priorityHistory : [];
455
+ const next = { ...job, priority, priorityHistory: [...history, { from, to: priority, reason, at, by, via }] };
456
+ writeJob(root, next);
457
+ const jobsById = {};
458
+ for (const queuedId of order) jobsById[queuedId] = queuedId === id ? next : readJob(root, queuedId);
459
+ const sorted = sortOrder(order, jobsById);
460
+ writeQueue(root, sorted);
461
+ appendMetric(root, {
462
+ event: 'priority',
463
+ jobId: id,
464
+ priorityFrom: from,
465
+ priorityTo: priority,
466
+ reason,
467
+ by,
468
+ at,
469
+ });
470
+ const index = sorted.indexOf(id);
471
+ return {
472
+ job: next,
473
+ changed: true,
474
+ position: index === -1 ? null : index + 1,
475
+ preempt: priority === 'urgent' ? requestPreemptLocked(root, id, { by, reason }) : null,
476
+ };
477
+ });
478
+ }
479
+
480
+ // §6.1/D-4: `null` unless the requested level is `urgent`; then the D-4 sentence, decided and — when
481
+ // it allows it — WRITTEN, all inside the caller's mutex hold. The full outcome set, in the order the
482
+ // checks run:
483
+ // 'not_running' no `in_progress` job other than the bumped one — nothing to stop
484
+ // 'not_lower' the running job is itself `urgent`: urgent never stops urgent
485
+ // 'already_preempted' the running job already carries `preemptions >= 1` (D-4: at most once)
486
+ // 'no_runner' no lease record, or a dead one — nothing is working that job any more
487
+ // 'runner_cannot_stop' a LIVE lease without the `stop` capability (D-10): a runner spawned from an
488
+ // older `land.js` would land the job anyway, so nothing is written
489
+ // 'already_requested' the job already carries a stop request (cancel or preempt): a preempt never
490
+ // replaces one, and never downgrades a cancel
491
+ // 'requested' the request was written
492
+ //
493
+ // Mutex-free on purpose: both callers run inside one `state.withMutex` hold (`setPriority`'s urgent
494
+ // bump and a fresh urgent `cmdSubmit`), and `state.withMutex` is not re-entrant. Use
495
+ // `requestPreemptFor` from anywhere else — one implementation, two callers, no duplicated decision.
496
+ function requestPreemptLocked(root, urgentJobId, { by = null, reason = null } = {}) {
497
+ const order = readQueue(root).order;
498
+ let running = null;
499
+ for (const queuedId of order) {
500
+ if (queuedId === urgentJobId) continue;
501
+ const job = readJob(root, queuedId);
502
+ if (job && job.state === 'in_progress') {
503
+ running = job;
504
+ break;
505
+ }
506
+ }
507
+ if (!running) return 'not_running';
508
+ if (priorityOf(running) === 'urgent') return 'not_lower';
509
+ if ((Number.isFinite(running.preemptions) ? running.preemptions : 0) >= 1) return 'already_preempted';
510
+ const record = lease.read(root);
511
+ if (!record || lease.isDead(record)) return 'no_runner';
512
+ if (!lease.hasCap(record, 'stop')) return 'runner_cannot_stop';
513
+ if (running.stopRequest) return 'already_requested';
514
+ writeJob(root, {
515
+ ...running,
516
+ stopRequest: { kind: 'preempt', by, reason: reason || null, forJob: urgentJobId, at: nowIso() },
517
+ });
518
+ return 'requested';
519
+ }
520
+
521
+ // §6.1: the mutex-taking entry point (a fresh `--priority urgent` submit).
522
+ function requestPreemptFor(root, urgentJobId, { by = null, reason = null } = {}) {
523
+ return state.withMutex(root, () => requestPreemptLocked(root, urgentJobId, { by, reason }));
524
+ }
525
+
526
+ // D-4: the runner's preempt transition, inside the mutex. The job keeps its id, so `sortOrder` puts
527
+ // it back at the front of its own level (D-3 — an id is arrival-sortable), and the ONE metrics line
528
+ // copies the `runner_reaped` shape with `reason: 'preempted'`.
529
+ function requeuePreempted(root, id) {
530
+ return state.withMutex(root, () => {
531
+ const job = readJob(root, id);
532
+ if (!job) return null;
533
+ const request = job.stopRequest || null;
534
+ const timestamps = { ...(job.timestamps || {}) };
535
+ delete timestamps.started;
536
+ const next = {
537
+ ...job,
538
+ state: 'queued',
539
+ preemptions: (Number.isFinite(job.preemptions) ? job.preemptions : 0) + 1,
540
+ preemptedBy: { jobId: request ? request.forJob : null, at: nowIso() },
541
+ timestamps,
542
+ };
543
+ delete next.stopRequest;
544
+ writeJob(root, next);
545
+ const order = readQueue(root).order;
546
+ const ids = order.includes(id) ? order : [...order, id];
547
+ const jobsById = {};
548
+ for (const queuedId of ids) jobsById[queuedId] = queuedId === id ? next : readJob(root, queuedId);
549
+ writeQueue(root, sortOrder(ids, jobsById));
550
+ appendMetric(root, {
551
+ jobId: id,
552
+ from: 'in_progress',
553
+ to: 'queued',
554
+ at: nowIso(),
555
+ seconds: null,
556
+ gateSeconds: null,
557
+ reason: 'preempted',
558
+ });
559
+ return next;
560
+ });
561
+ }
562
+
563
+ // D-6 (§9 S-11, WP-2): the runner's base_broken transition, inside the mutex. The job is NOT
564
+ // terminal — every file the gate named fails on the base commit too, so this is main's failure, not
565
+ // the branch owner's, and re-gating the same tip would just burn another gate on the same broken
566
+ // main. It goes back to `queued` in its own place (the id is arrival-sortable, so `sortOrder` puts
567
+ // it where it was, and its priority is untouched) carrying `retryAfterTargetMoves`, which is what
568
+ // `takeNextJob` holds it against until the target tip actually moves. The ONE metrics line copies
569
+ // the `requeuePreempted` shape with `reason: 'base_broken'`.
570
+ function requeueBaseBroken(root, id, patch = {}, metric = {}) {
571
+ return state.withMutex(root, () => {
572
+ const job = readJob(root, id);
573
+ if (!job) return null;
574
+ const next = { ...job, ...patch, id, state: 'queued', retryAfterTargetMoves: true };
575
+ writeJob(root, next);
576
+ const order = readQueue(root).order;
577
+ const ids = order.includes(id) ? order : [...order, id];
578
+ const jobsById = {};
579
+ for (const queuedId of ids) jobsById[queuedId] = queuedId === id ? next : readJob(root, queuedId);
580
+ writeQueue(root, sortOrder(ids, jobsById));
581
+ const startedAt = next.timestamps && next.timestamps.started ? Date.parse(next.timestamps.started) : NaN;
582
+ appendMetric(root, {
583
+ jobId: id,
584
+ from: 'in_progress',
585
+ to: 'queued',
586
+ at: nowIso(),
587
+ // `seconds` is the run that just ended, exactly as `finishJob` reports it, so the ETA reader
588
+ // sees one shape whether a job left the queue by failing or by being parked on a broken base.
589
+ seconds: typeof metric.seconds === 'number'
590
+ ? metric.seconds
591
+ : (Number.isFinite(startedAt) ? Number(((Date.now() - startedAt) / 1000).toFixed(3)) : null),
592
+ gateSeconds: typeof metric.gateSeconds === 'number' ? metric.gateSeconds : null,
593
+ reason: 'base_broken',
594
+ });
595
+ return next;
596
+ });
597
+ }
598
+
599
+ // D-6/D-7: a job whose base rerun found the failure on base as well is held until the target moves.
600
+ // `triage.base` is the tip the rerun was measured against, so while the target still IS that tip the
601
+ // job has nothing new to learn — it is not `takeNextJob`'s turn to run, it is main's turn to be
602
+ // fixed. An unknown tip (`targetTip` undefined/null, e.g. a config this process cannot read) holds
603
+ // nothing: the safe direction is always to run the job rather than to park it forever.
604
+ function heldByTargetMove(job, targetTip) {
605
+ if (!job || job.retryAfterTargetMoves !== true || !targetTip) return false;
606
+ return Boolean(job.triage) && job.triage.base === targetTip;
607
+ }
608
+
609
+ // WP-1 D-1/§9 S-8: READ-ONLY peek at the runnable order — the ids `takeNextBatch` would consider,
610
+ // in the order it would consider them. `drain` uses it to decide the window BEFORE anything is
611
+ // marked `in_progress`: whether the head job must land alone (a proven/cached tip), and whether a
612
+ // lone job should wait for company (`landing.batch.waitSeconds`). Writes nothing, takes no mutex.
613
+ function peekRunnable(root, { targetTip, exclude = null } = {}) {
614
+ const excluded = exclude instanceof Set ? exclude : new Set(Array.isArray(exclude) ? exclude : []);
615
+ const order = readQueue(root).order;
616
+ const jobsById = new Map();
617
+ for (const id of order) jobsById.set(id, readJob(root, id));
618
+ const candidates = order.filter((id) => {
619
+ const job = jobsById.get(id);
620
+ if (!job || TERMINAL_STATES.includes(job.state)) return false;
621
+ // An `in_progress` job belongs to a runner; if that runner is dead, `takeNextBatch` reaps it back
622
+ // to `queued` and it is eligible again — the peek must not silently drop it, but it must also not
623
+ // claim the runner would gate it in this same tick. Treat it as eligible: the reap happens under
624
+ // the mutex immediately before the take.
625
+ if (excluded.has(id)) return false;
626
+ return !heldByTargetMove(job, targetTip);
627
+ });
628
+ return sortOrder(candidates, jobsById);
629
+ }
630
+
631
+ // --- the runner's queue operations (D-2) ------------------------------------------------------
632
+
633
+ // D-5: the ONLY way another process asks the runner to stop a job — never by writing a terminal
634
+ // state itself (the runner is the single writer of in-progress transitions). Inside the mutex, so a
635
+ // request and the runner's own checkpoint read cannot interleave. Refuses `job_terminal`: a job that
636
+ // already has an outcome has nothing left to stop.
637
+ //
638
+ // A pending `kind:'cancel'` is never DOWNGRADED to `'preempt'`: cancel is the stronger request, and
639
+ // a preempt arriving behind it must not turn a "stop and never land" into a "stop and requeue".
640
+ function requestStop(root, id, { kind, by = null, reason = null, forJob = null } = {}) {
641
+ return state.withMutex(root, () => {
642
+ if (kind !== 'cancel' && kind !== 'preempt') {
643
+ throw landingError('stop_kind_invalid', `stop kind ${JSON.stringify(kind)} is not one of cancel, preempt`, { jobId: id, kind });
644
+ }
645
+ const job = readJob(root, id);
646
+ if (!job) throw landingError('job_not_found', `no landing job ${JSON.stringify(id)} in ${root}`, { jobId: id, root });
647
+ if (TERMINAL_STATES.includes(job.state)) {
648
+ throw landingError('job_terminal', `job ${id} is already ${job.state}`, { jobId: id, state: job.state });
649
+ }
650
+ const existing = job.stopRequest;
651
+ const request = {
652
+ kind,
653
+ by,
654
+ reason: reason || null,
655
+ forJob: forJob || null,
656
+ at: nowIso(),
657
+ };
658
+ const stopRequest = existing && existing.kind === 'cancel' && kind === 'preempt' ? existing : request;
659
+ const next = { ...job, stopRequest };
660
+ writeJob(root, next);
661
+ return next;
662
+ });
663
+ }
664
+
665
+ // Take the head job. Two D-2 duties ride along, both safe because the caller already holds the
666
+ // lease (so no other runner exists for this repository):
667
+ // * ids of terminal jobs are dropped from `queue.json`;
668
+ // * a job left in `in_progress` by a previous, dead runner goes back to the HEAD of the queue.
669
+ //
670
+ // D-2/§7: the remaining ids are re-sorted with `sortOrder` before `order[0]` is taken and the sorted
671
+ // order is what gets written, so the invariant is SELF-HEALING — a hand edit, an older binary that
672
+ // appended a normal job, or a crash cannot make this runner take a lower-ranked job first.
673
+ //
674
+ // `onEmpty` runs INSIDE the mutex when the queue is empty: the runner releases its lease there, so
675
+ // a submitter's enqueue (also under the mutex) either lands before the check and is taken, or lands
676
+ // after the release and finds no live runner and starts one. Releasing outside the mutex would leave
677
+ // a window where a job is enqueued behind a runner that has already decided to exit.
678
+ // WP-1 D-1 (§6, §9 S-1): a job is not RUNNABLE while the merge it will be gated on is already known
679
+ // to conflict — the submit precheck wrote that preview, and re-gating a conflict the queue cannot
680
+ // resolve burns a gate to reach a verdict `buildTrain` computes in milliseconds. A job with a CLEAR
681
+ // preview (`null`) and a job that has never conflicted are both runnable.
682
+ function hasConflictPaths(job) {
683
+ const preview = job && job.conflictPreview;
684
+ return Boolean(preview) && Array.isArray(preview.paths) && preview.paths.length > 0;
685
+ }
686
+
687
+ // WP-1 D-1 (§6, §9 S-1, AC-1a/AC-1b): take the batch. The HEAD of the runnable order is always
688
+ // taken — that is the job `takeNextJob` has always taken, and a job the submitter queued with
689
+ // `--queue-anyway` must still reach the runner's `blocked (conflict)` verdict rather than sit in the
690
+ // queue forever. The remaining slots are filled, in the same order, with the runnable jobs that have
691
+ // no conflict preview, up to `window`.
692
+ //
693
+ // The two D-2 duties of `takeNextJob` ride along unchanged (they are safe because the caller already
694
+ // holds the lease): terminal ids are dropped from `queue.json`, and a job left `in_progress` by a
695
+ // previous, dead runner goes back to the HEAD of the queue.
696
+ //
697
+ // `exclude` is the set of ids the caller has already decided not to include (D-7: a member whose stop
698
+ // request arrived is dropped from the NEXT train build).
699
+ function takeNextBatch(root, { onEmpty, targetTip, window: windowSize = 1, exclude = null, batchId = null, attempt = 1 } = {}) {
700
+ const limit = Number.isInteger(windowSize) && windowSize >= 1 ? windowSize : 1;
701
+ const excluded = exclude instanceof Set ? exclude : new Set(Array.isArray(exclude) ? exclude : []);
702
+ return state.withMutex(root, () => {
703
+ const raw = readQueue(root);
704
+ const jobsById = {};
705
+ const requeued = [];
706
+ const rest = [];
707
+ for (const id of raw.order) {
708
+ const job = readJob(root, id);
709
+ jobsById[id] = job;
710
+ if (!job) continue;
711
+ if (TERMINAL_STATES.includes(job.state)) continue;
712
+ if (job.state === 'in_progress') requeued.push(id);
713
+ else rest.push(id);
714
+ }
715
+ for (const id of requeued) {
716
+ const job = jobsById[id];
717
+ const timestamps = { ...(job.timestamps || {}) };
718
+ delete timestamps.started;
719
+ jobsById[id] = { ...job, state: 'queued', timestamps };
720
+ writeJob(root, jobsById[id]);
721
+ appendMetric(root, { jobId: id, from: 'in_progress', to: 'queued', at: nowIso(), seconds: null, gateSeconds: null, reason: 'runner_reaped' });
722
+ }
723
+ const order = [...requeued, ...sortOrder(rest, jobsById)];
724
+ // D-6/D-7 (WP-2): a job the runner parked as `base_broken` is not runnable while the target tip
725
+ // is still the tip its rerun measured — re-gating it would report main's own failure again as
726
+ // this branch's. It stays in `queue.json` (the whole non-terminal order is written either way),
727
+ // so it is still visible to `land status` and still holds its place; `drain` passes the tip it
728
+ // resolved this iteration. EVERY remaining job held is the same as an empty queue for the runner:
729
+ // there is nothing to do, so `onEmpty` releases the lease (a later `land submit` or the next
730
+ // target move starts it again).
731
+ const runnable = order.filter((queuedId) => !heldByTargetMove(jobsById[queuedId], targetTip) && !excluded.has(queuedId));
732
+ if (!runnable.length) {
733
+ writeQueue(root, order);
734
+ if (onEmpty) onEmpty();
735
+ return [];
736
+ }
737
+ const head = runnable[0];
738
+ const members = [head];
739
+ for (const id of runnable.slice(1)) {
740
+ if (members.length >= limit) break;
741
+ if (hasConflictPaths(jobsById[id])) continue;
742
+ members.push(id);
743
+ }
744
+ const id = batchId || (members.length > 1 ? newBatchId() : members[0]);
745
+ const taken = [];
746
+ for (let index = 0; index < members.length; index += 1) {
747
+ const memberId = members[index];
748
+ const job = readJob(root, memberId);
749
+ const from = job.state;
750
+ const next = { ...job, state: 'in_progress', timestamps: { ...(job.timestamps || {}), started: nowIso() } };
751
+ // Taking it is the whole point of the flag: the hold is spent, and the record must not claim a
752
+ // wait that is over (D-7). Written explicitly as `false`, never deleted, so a reader sees the
753
+ // transition rather than a missing key.
754
+ if (job.retryAfterTargetMoves === true) next.retryAfterTargetMoves = false;
755
+ // D-1 (§6 job record): the batch rides on every member — but ONLY on a real batch. A window of
756
+ // one is today's serial runner, and its job record (and therefore every line `land status`
757
+ // prints) must stay byte-identical to what it was.
758
+ if (members.length > 1) next.batch = { id, members: members.slice(), attempt, index };
759
+ writeJob(root, next);
760
+ appendMetric(root, { jobId: memberId, from, to: 'in_progress', at: nowIso(), seconds: null, gateSeconds: null });
761
+ taken.push(next);
762
+ }
763
+ writeQueue(root, order);
764
+ return taken;
765
+ });
766
+ }
767
+
768
+ // The serial entry point: one job, exactly as before this change. Every existing caller (and every
769
+ // existing test) keeps its contract because a window of one never writes a `batch` field.
770
+ function takeNextJob(root, opts = {}) {
771
+ return takeNextBatch(root, { ...opts, window: 1 })[0] || null;
772
+ }
773
+
774
+ // D-2: drop the lease of a runner that is provably gone. The in_progress job it left behind is
775
+ // returned to the head by `takeNextJob`, which only ever runs while we hold the lease.
776
+ function reapDeadRunner(root) {
777
+ const record = lease.read(root);
778
+ if (!record) return null;
779
+ if (!lease.isDead(record)) return null;
780
+ lease.reapIfDead(root);
781
+ return record;
782
+ }
783
+
784
+ // --- ETA (OQ-3) -------------------------------------------------------------------------------
785
+
786
+ function median(values) {
787
+ if (!values.length) return null;
788
+ const sorted = [...values].sort((a, b) => a - b);
789
+ const middle = Math.floor(sorted.length / 2);
790
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
791
+ }
792
+
793
+ // The last `limit` gate durations recorded for this repository, newest file first.
794
+ function recentGateSeconds(root, limit = ETA_WINDOW) {
795
+ let names;
796
+ try {
797
+ names = fs.readdirSync(metricsDir(root));
798
+ } catch (error) {
799
+ /* expected: no job has completed yet, so landing/metrics/ does not exist — "no samples",
800
+ which the ETA caller renders as `null` (never as a guess) */
801
+ return [];
802
+ }
803
+ const values = [];
804
+ for (const name of names.filter((n) => n.endsWith('.jsonl')).sort().reverse()) {
805
+ let raw;
806
+ try {
807
+ raw = fs.readFileSync(path.join(metricsDir(root), name), 'utf8');
808
+ } catch (error) {
809
+ /* expected: a metrics file rotated away between readdir and read — skip it; older files
810
+ still provide the sample */
811
+ continue;
812
+ }
813
+ for (const line of raw.split('\n').filter(Boolean).reverse()) {
814
+ let entry;
815
+ try {
816
+ entry = JSON.parse(line);
817
+ } catch (error) {
818
+ /* expected: a torn final line from a process killed mid-append — a metrics file is
819
+ append-only evidence, and one unparseable line never invalidates the samples around it */
820
+ continue;
821
+ }
822
+ // WP-0/WP-1: an `event` row (`refused`, `priority`, `batch`) is NOT a state transition. The
823
+ // ETA reads transitions only, so the guard is explicit rather than a side effect of the row
824
+ // happening to lack `gateSeconds`.
825
+ if (entry && entry.event) continue;
826
+ if (typeof entry.gateSeconds === 'number' && Number.isFinite(entry.gateSeconds)) {
827
+ values.push(entry.gateSeconds);
828
+ if (values.length >= limit) return values;
829
+ }
830
+ }
831
+ }
832
+ return values;
833
+ }
834
+
835
+ // `eta_s` = position × median of the last 20 gate seconds, or null with fewer than 20 samples.
836
+ function etaSeconds(root, position) {
837
+ if (!Number.isFinite(position) || position <= 0) return null;
838
+ const samples = recentGateSeconds(root, ETA_SAMPLES);
839
+ if (samples.length < ETA_SAMPLES) return null;
840
+ return Math.round(position * median(samples));
841
+ }
842
+
843
+ // --- read models ------------------------------------------------------------------------------
844
+
845
+ function jobSummary(job) {
846
+ return {
847
+ id: job.id,
848
+ branch: job.branch,
849
+ tip: job.tip,
850
+ title: job.title,
851
+ state: job.state,
852
+ reason: job.reason === undefined ? null : job.reason,
853
+ priority: priorityOf(job),
854
+ priorityReason: priorityReasonOf(job),
855
+ preemptions: Number.isFinite(job.preemptions) ? job.preemptions : 0,
856
+ stopRequest: job.stopRequest === undefined ? null : job.stopRequest,
857
+ base: job.base === undefined ? null : job.base,
858
+ train: job.train === undefined ? null : job.train,
859
+ tree: job.tree === undefined ? null : job.tree,
860
+ verdict: job.verdict === undefined ? null : job.verdict,
861
+ // D-5 (design landing-throughput-and-big-file-decomposition-2026-10-29.html §6 job record): the
862
+ // conflict the queue measured for this job against the target tip, written by `land submit`
863
+ // (`--queue-anyway`) and re-written by the runner's post-landing sweep. `null` for a job that has
864
+ // never conflicted, and an explicit `null` (never `undefined`) so a JSON reader sees the key.
865
+ conflictPreview: job.conflictPreview === undefined ? null : job.conflictPreview,
866
+ // D-6/D-7 (WP-2): the base-rerun receipt (`{ base, files, failedOnBase, seconds }`, or null when
867
+ // the pass did not run) and the hold it implies. `land status` reads both: the first is the
868
+ // evidence for why a queued job is not this branch's fault, the second is why it is not running.
869
+ triage: job.triage === undefined ? null : job.triage,
870
+ retryAfterTargetMoves: job.retryAfterTargetMoves === true,
871
+ submittedBy: job.submittedBy || null,
872
+ timestamps: job.timestamps || {},
873
+ gateLog: job.gateLog === undefined ? null : job.gateLog,
874
+ // WP-1 D-1 (§6 job record): the batch this job is being gated in — `{ id, members, attempt,
875
+ // index }`, or an explicit `null` for the serial runner (a window of one writes no batch, so
876
+ // every pre-change job and every singleton row reads exactly as it did).
877
+ batch: job.batch === undefined ? null : job.batch,
878
+ // WP-0 (§6.6/D-8): the gate's self-description rides on every listing/`--json` row, so
879
+ // `land status` can print `gate <mode> <executed>/<selected>` without a second read.
880
+ gate: job.gate === undefined ? null : job.gate,
881
+ };
882
+ }
883
+
884
+ // The latest reason a human is meant to read: the last `priorityHistory` entry's reason, or null for
885
+ // a job that has never been bumped (a normal job's audit trail is empty by D-7).
886
+ function priorityReasonOf(job) {
887
+ const history = job && Array.isArray(job.priorityHistory) ? job.priorityHistory : [];
888
+ if (!history.length) return null;
889
+ const last = history[history.length - 1];
890
+ return last && last.reason !== undefined ? last.reason : null;
891
+ }
892
+
893
+ // D-8 revisit trigger: how many `normal` jobs (submitted within the last `days`) waited longer than
894
+ // `thresholdMinutes` in the queue while a higher-ranked job started inside that same window. Pure over
895
+ // the job files — no new metrics format, no clock beyond the timestamps already recorded.
896
+ function priorityStarvationCount(root, { days = 14, thresholdMinutes = 180 } = {}) {
897
+ const since = Date.now() - days * 24 * 60 * 60 * 1000;
898
+ const thresholdMs = thresholdMinutes * 60 * 1000;
899
+ const all = listJobs(root);
900
+ let count = 0;
901
+ for (const job of all) {
902
+ if (priorityOf(job) !== 'normal') continue;
903
+ const queuedAt = job.timestamps && job.timestamps.queued ? Date.parse(job.timestamps.queued) : NaN;
904
+ const startedAt = job.timestamps && job.timestamps.started ? Date.parse(job.timestamps.started) : NaN;
905
+ if (!Number.isFinite(queuedAt) || !Number.isFinite(startedAt)) continue;
906
+ if (queuedAt < since) continue;
907
+ if (startedAt - queuedAt <= thresholdMs) continue;
908
+ const overtaken = all.some((other) => {
909
+ if (other.id === job.id) return false;
910
+ if (priorityRank(other) <= priorityRank(job)) return false;
911
+ const otherStarted = other.timestamps && other.timestamps.started ? Date.parse(other.timestamps.started) : NaN;
912
+ return Number.isFinite(otherStarted) && otherStarted >= queuedAt && otherStarted <= startedAt;
913
+ });
914
+ if (overtaken) count += 1;
915
+ }
916
+ return count;
917
+ }
918
+
919
+ function runnerSummary(root) {
920
+ const record = lease.read(root);
921
+ if (!record) return null;
922
+ return {
923
+ pid: record.pid,
924
+ jobId: record.jobId === undefined ? null : record.jobId,
925
+ host: record.host === undefined ? null : record.host,
926
+ startedAt: record.startedAt,
927
+ heartbeatAt: record.heartbeatAt,
928
+ alive: !lease.isDead(record),
929
+ };
930
+ }
931
+
932
+ function jobOrThrow(root, id) {
933
+ const job = readJob(root, id);
934
+ if (!job) throw landingError('job_not_found', `no landing job ${JSON.stringify(id)} in ${root}`, { jobId: id, root });
935
+ return job;
936
+ }
937
+
938
+ module.exports = {
939
+ JOB_STATES,
940
+ TERMINAL_STATES,
941
+ ACTIVE_STATES,
942
+ PRIORITIES,
943
+ PRIORITY_REASON_MAX,
944
+ BASE32,
945
+ ETA_SAMPLES,
946
+ ETA_WINDOW,
947
+ nowIso,
948
+ newJobId,
949
+ newBatchId,
950
+ jobsDir,
951
+ jobFile,
952
+ queueFile,
953
+ metricsDir,
954
+ verdictsDir,
955
+ logsDir,
956
+ trainsDir,
957
+ ensureLogsDir,
958
+ readQueue,
959
+ writeQueue,
960
+ readJob,
961
+ writeJob,
962
+ listJobs,
963
+ appendMetric,
964
+ isTerminal,
965
+ isActive,
966
+ priorityOf,
967
+ priorityRank,
968
+ sortOrder,
969
+ activeJobFor,
970
+ duplicateSubmitter,
971
+ enqueue,
972
+ setPriority,
973
+ requestStop,
974
+ requestPreemptFor,
975
+ requeuePreempted,
976
+ requeueBaseBroken,
977
+ heldByTargetMove,
978
+ priorityStarvationCount,
979
+ positionOf,
980
+ updateJob,
981
+ finishJob,
982
+ finishBatch,
983
+ recordBatchMetric,
984
+ recordRefusal,
985
+ refusalsToday,
986
+ requeueHalf,
987
+ hasConflictPaths,
988
+ peekRunnable,
989
+ takeNextJob,
990
+ takeNextBatch,
991
+ reapDeadRunner,
992
+ median,
993
+ recentGateSeconds,
994
+ etaSeconds,
995
+ jobSummary,
996
+ runnerSummary,
997
+ jobOrThrow,
998
+ };