catherd-cli 1.4.0 → 2.0.0-beta.20261022113309

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 (320) hide show
  1. package/README.md +188 -169
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -239
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -356
  11. package/plugin/skills/catherd-setup/SKILL.md +21 -20
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +19 -4
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/codex/events.ts +10 -0
  16. package/src/adapters/codex/index.ts +17 -5
  17. package/src/adapters/cursor/home.ts +1 -1
  18. package/src/adapters/cursor/index.ts +10 -4
  19. package/src/adapters/opencode/index.ts +38 -1
  20. package/src/cli.ts +27 -27
  21. package/src/domain/agents.ts +3 -1
  22. package/src/domain/bars.ts +33 -107
  23. package/src/domain/blocked.ts +72 -0
  24. package/src/domain/branch.ts +51 -0
  25. package/src/domain/brief.ts +106 -0
  26. package/src/domain/catalog.ts +69 -313
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +19 -29
  29. package/src/domain/errors.ts +6 -2
  30. package/src/domain/fail-lines.ts +26 -0
  31. package/src/domain/failover.ts +37 -75
  32. package/src/domain/findings.ts +47 -0
  33. package/src/domain/gate-brief.ts +53 -0
  34. package/src/domain/hints.ts +13 -2
  35. package/src/domain/ids.ts +13 -2
  36. package/src/domain/jev.ts +47 -2
  37. package/src/domain/judge.ts +124 -0
  38. package/src/domain/lane.ts +118 -3
  39. package/src/domain/milestone-memory.ts +53 -0
  40. package/src/domain/notice.ts +32 -8
  41. package/src/domain/notify.ts +32 -0
  42. package/src/domain/plan-doc.ts +198 -0
  43. package/src/domain/plan-lint.ts +141 -0
  44. package/src/domain/profile-rules.ts +175 -138
  45. package/src/domain/profile.ts +140 -63
  46. package/src/domain/record.ts +68 -3
  47. package/src/domain/reply.ts +29 -0
  48. package/src/domain/role-prompts.ts +182 -56
  49. package/src/domain/role-scope.ts +76 -0
  50. package/src/domain/role-tools.ts +34 -0
  51. package/src/domain/roles.ts +2 -0
  52. package/src/domain/route.ts +90 -0
  53. package/src/domain/rules.ts +33 -0
  54. package/src/domain/run-events.ts +298 -0
  55. package/src/domain/run-machine.ts +668 -0
  56. package/src/domain/run-state.ts +1310 -0
  57. package/src/domain/scorecard.ts +197 -0
  58. package/src/domain/select.ts +229 -72
  59. package/src/domain/slopalytics.ts +72 -0
  60. package/src/domain/state.ts +5 -0
  61. package/src/domain/util.ts +22 -0
  62. package/src/domain/workspace.ts +130 -0
  63. package/src/entry/answer-command.ts +39 -0
  64. package/src/entry/catalog-command.ts +24 -165
  65. package/src/entry/check-bin.ts +17 -0
  66. package/src/entry/cli-kit.ts +18 -2
  67. package/src/entry/daemon-bin.ts +22 -0
  68. package/src/entry/deps.ts +2 -0
  69. package/src/entry/doctor-command.ts +20 -2
  70. package/src/entry/glyphs.ts +15 -2
  71. package/src/entry/host-arg.ts +28 -0
  72. package/src/entry/init-command.ts +20 -69
  73. package/src/entry/knowledge-command.ts +121 -5
  74. package/src/entry/lock-command.ts +103 -8
  75. package/src/entry/mcp/handshake.ts +6 -2
  76. package/src/entry/mcp/role-bin.ts +20 -0
  77. package/src/entry/mcp/role-server.ts +134 -0
  78. package/src/entry/mcp/server.ts +85 -21
  79. package/src/entry/mcp/session-tools.ts +149 -0
  80. package/src/entry/panel/openapi.ts +189 -0
  81. package/src/entry/panel/server.ts +410 -0
  82. package/src/entry/panel-command.ts +68 -0
  83. package/src/entry/pause-command.ts +67 -0
  84. package/src/entry/profile-command.ts +33 -18
  85. package/src/entry/role-cli-command.ts +159 -0
  86. package/src/entry/runs-command.ts +81 -27
  87. package/src/entry/scorecard-command.ts +187 -0
  88. package/src/entry/supervise-bin.ts +1 -1
  89. package/src/entry/supervise-command.ts +30 -3
  90. package/src/entry/workspace-command.ts +44 -0
  91. package/src/infra/boot-lock.ts +25 -0
  92. package/src/infra/delivery.ts +3 -0
  93. package/src/infra/dispatch-dir.ts +2 -0
  94. package/src/infra/env.ts +11 -3
  95. package/src/infra/git.ts +70 -0
  96. package/src/infra/heavy-lock.ts +133 -6
  97. package/src/infra/host-probe.ts +16 -0
  98. package/src/infra/launch.ts +63 -7
  99. package/src/infra/lock-activity.ts +88 -0
  100. package/src/infra/login-env.ts +48 -0
  101. package/src/infra/memory.ts +45 -0
  102. package/src/infra/ntfy.ts +40 -0
  103. package/src/infra/open-url.ts +42 -0
  104. package/src/infra/paths.ts +37 -2
  105. package/src/infra/role-mcp.ts +69 -0
  106. package/src/infra/slopalytics.ts +30 -0
  107. package/src/infra/store.ts +6 -1
  108. package/src/infra/supervisor.ts +107 -16
  109. package/src/services/admission.ts +133 -31
  110. package/src/services/backends.ts +4 -6
  111. package/src/services/budget.ts +26 -6
  112. package/src/services/capture.ts +1 -22
  113. package/src/services/catalog-service.ts +155 -254
  114. package/src/services/credentials.ts +15 -26
  115. package/src/services/daemon.ts +181 -0
  116. package/src/services/dispatch-service.ts +209 -17
  117. package/src/services/dispatches.ts +30 -2
  118. package/src/services/doctor-access.ts +8 -3
  119. package/src/services/doctor-backends.ts +1 -1
  120. package/src/services/doctor-checks.ts +47 -0
  121. package/src/services/doctor-docker.ts +221 -0
  122. package/src/services/doctor-role-mcp.ts +65 -0
  123. package/src/services/doctor.ts +72 -15
  124. package/src/services/end-push.ts +157 -0
  125. package/src/services/failover-rungs.ts +109 -0
  126. package/src/services/finalize.ts +24 -2
  127. package/src/services/gate-env.ts +217 -0
  128. package/src/services/gate-service.ts +231 -45
  129. package/src/services/global-install.ts +46 -13
  130. package/src/services/jev-service.ts +2 -4
  131. package/src/services/lane-edit.ts +154 -0
  132. package/src/services/lane-service.ts +291 -55
  133. package/src/services/milestone-check.ts +433 -0
  134. package/src/services/milestone-tree.ts +170 -0
  135. package/src/services/milestones.ts +167 -29
  136. package/src/services/notifier.ts +124 -4
  137. package/src/services/notify-service.ts +26 -0
  138. package/src/services/orchestrator-wait.ts +82 -0
  139. package/src/services/panel-setup.ts +116 -0
  140. package/src/services/pause.ts +161 -0
  141. package/src/services/peek.ts +79 -17
  142. package/src/services/plan-input.ts +151 -0
  143. package/src/services/ports.ts +60 -5
  144. package/src/services/preflight.ts +232 -24
  145. package/src/services/profile-service.ts +31 -3
  146. package/src/services/profile-store.ts +18 -6
  147. package/src/services/protocol.ts +123 -25
  148. package/src/services/provenance.ts +34 -89
  149. package/src/services/questions.ts +24 -0
  150. package/src/services/reconcile.ts +33 -1
  151. package/src/services/reentry.ts +7 -6
  152. package/src/services/role-access.ts +27 -0
  153. package/src/services/routing-service.ts +161 -42
  154. package/src/services/run-briefs.ts +477 -0
  155. package/src/services/run-client.ts +217 -0
  156. package/src/services/run-effects.ts +837 -0
  157. package/src/services/run-events-store.ts +174 -0
  158. package/src/services/run-evidence.ts +1 -1
  159. package/src/services/run-pin.ts +125 -0
  160. package/src/services/run-read.ts +221 -0
  161. package/src/services/run-report.ts +176 -0
  162. package/src/services/run-service.ts +204 -23
  163. package/src/services/run-store.ts +159 -18
  164. package/src/services/scorecard-metrics.ts +86 -0
  165. package/src/services/scorecard.ts +382 -0
  166. package/src/services/scratch.ts +60 -0
  167. package/src/services/sessions.ts +3 -1
  168. package/src/services/state.ts +21 -0
  169. package/src/services/summary.ts +76 -12
  170. package/src/services/tree-path.ts +27 -0
  171. package/src/services/verifier-brief.ts +23 -0
  172. package/src/services/verifier-step.ts +48 -0
  173. package/src/services/workspace-admission.ts +259 -0
  174. package/src/services/workspace-service.ts +259 -0
  175. package/src/services/workspace-store.ts +95 -0
  176. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  177. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  178. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  179. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  180. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  181. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  182. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  183. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  184. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  185. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  186. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  187. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  188. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  189. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  190. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  191. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  192. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  193. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  194. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  195. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  196. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  197. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  198. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  199. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  200. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  201. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  202. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  203. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  204. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  205. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  206. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  207. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  208. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  209. package/web/dist/assets/index-C5IbylvM.css +2 -0
  210. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  211. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  212. package/web/dist/assets/label-DFSYBc62.js +1 -0
  213. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  214. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  215. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  216. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  217. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  218. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  219. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  220. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  221. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  222. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  223. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  224. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  225. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  226. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  227. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  228. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  229. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  230. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  231. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  232. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  233. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  234. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  235. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  236. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  237. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  238. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  239. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  240. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  241. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  242. package/web/dist/assets/time--9kSQbbp.js +1 -0
  243. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  244. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  245. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  246. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  247. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  248. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  249. package/web/dist/index.html +15 -0
  250. package/catalog/ATTRIBUTION.md +0 -36
  251. package/catalog/scores.json +0 -3202
  252. package/catalog/sources.json +0 -97
  253. package/src/adapters/antigravity/events.ts +0 -131
  254. package/src/adapters/antigravity/home.ts +0 -46
  255. package/src/adapters/antigravity/index.ts +0 -320
  256. package/src/adapters/antigravity/models.ts +0 -37
  257. package/src/adapters/grok/events.ts +0 -112
  258. package/src/adapters/grok/home.ts +0 -132
  259. package/src/adapters/grok/index.ts +0 -339
  260. package/src/adapters/grok/models.ts +0 -48
  261. package/src/domain/calibration.ts +0 -125
  262. package/src/domain/sources.ts +0 -144
  263. package/src/entry/mcp/dispatch-tools.ts +0 -52
  264. package/src/entry/mcp/lane-tools.ts +0 -84
  265. package/src/entry/mcp/protocol-tools.ts +0 -57
  266. package/src/entry/mcp/run-tools.ts +0 -148
  267. package/src/entry/mcp/setup-tools.ts +0 -108
  268. package/src/entry/tui/commands.ts +0 -563
  269. package/src/entry/tui/effects.ts +0 -381
  270. package/src/entry/tui/fixtures.ts +0 -637
  271. package/src/entry/tui/profile-edits.ts +0 -146
  272. package/src/entry/tui/profile-tree.ts +0 -429
  273. package/src/entry/tui/providers/app.tsx +0 -162
  274. package/src/entry/tui/providers/boundary.tsx +0 -57
  275. package/src/entry/tui/providers/data.tsx +0 -160
  276. package/src/entry/tui/providers/keymap.tsx +0 -166
  277. package/src/entry/tui/providers/root.tsx +0 -45
  278. package/src/entry/tui/providers/theme.tsx +0 -17
  279. package/src/entry/tui/providers/toast.tsx +0 -77
  280. package/src/entry/tui/run.tsx +0 -114
  281. package/src/entry/tui/state.ts +0 -373
  282. package/src/entry/tui/stories.ts +0 -114
  283. package/src/entry/tui/text.ts +0 -130
  284. package/src/entry/tui/theme.ts +0 -140
  285. package/src/entry/tui/views/app.tsx +0 -208
  286. package/src/entry/tui/views/dialogs.tsx +0 -41
  287. package/src/entry/tui/views/profile-actions.ts +0 -331
  288. package/src/entry/tui/views/profiles.tsx +0 -520
  289. package/src/entry/tui/views/runs.tsx +0 -573
  290. package/src/entry/tui/views/save-dialog.tsx +0 -340
  291. package/src/entry/tui/views/status.tsx +0 -209
  292. package/src/entry/tui/widgets/chrome.tsx +0 -114
  293. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  294. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  295. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  296. package/src/entry/tui/widgets/dialog.tsx +0 -68
  297. package/src/entry/tui/widgets/line.tsx +0 -92
  298. package/src/entry/tui/widgets/list.tsx +0 -197
  299. package/src/entry/tui/widgets/spinner.tsx +0 -49
  300. package/src/entry/tui/widgets/toast.tsx +0 -62
  301. package/src/infra/sources/arena.ts +0 -87
  302. package/src/infra/sources/artificial-analysis.ts +0 -112
  303. package/src/infra/sources/cache.ts +0 -124
  304. package/src/infra/sources/csv.ts +0 -38
  305. package/src/infra/sources/epoch.ts +0 -82
  306. package/src/infra/sources/http.ts +0 -75
  307. package/src/infra/sources/litellm.ts +0 -58
  308. package/src/infra/sources/models-dev.ts +0 -59
  309. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  310. package/src/infra/sources/openrouter-models.ts +0 -60
  311. package/src/infra/sources/rows.ts +0 -38
  312. package/src/infra/sources/vectara.ts +0 -69
  313. package/src/infra/sources/zip.ts +0 -48
  314. package/src/services/catalog-refresh.ts +0 -201
  315. package/src/services/doctor-sources.ts +0 -76
  316. package/src/services/runs-page.ts +0 -255
  317. package/src/services/source-derive.ts +0 -374
  318. package/src/services/source-sync.ts +0 -305
  319. package/src/services/standins.ts +0 -204
  320. package/src/services/treat-likes.ts +0 -97
@@ -1,11 +1,13 @@
1
1
  import { scrubSecrets } from "./env.ts";
2
2
  import type { Subprocess } from "bun";
3
3
  import { appendFileSync, closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
4
+ import { basename } from "node:path";
4
5
  import { z } from "zod";
5
6
  import { errorMessage } from "../domain/errors.ts";
6
7
  import type { ExitInfo, ExitReason } from "../domain/record.ts";
7
8
  import { dispatchPaths, supervisorLockTarget } from "./dispatch-dir.ts";
8
9
  import { tryLock } from "./filelock.ts";
10
+ import { lastLockOutput } from "./lock-activity.ts";
9
11
  import { log } from "./log.ts";
10
12
  import { killGroup, processStartTime } from "./proc.ts";
11
13
  import { PRIVATE_FILE, writeJsonAtomic } from "./store.ts";
@@ -17,6 +19,8 @@ export const SuperviseSpecSchema = z.looseObject({
17
19
  cmd: z.string(),
18
20
  args: z.array(z.string()),
19
21
  env: z.record(z.string(), z.string()),
22
+ /** plan 23: env vars set from another one at spawn (a gate env secret by reference): name → source name */
23
+ envFrom: z.record(z.string(), z.string()).optional(),
20
24
  cwd: z.string(),
21
25
  stdinPath: z.string().nullable(),
22
26
  idleMs: z.number().positive(),
@@ -24,15 +28,28 @@ export const SuperviseSpecSchema = z.looseObject({
24
28
  killGraceMs: z.number().nonnegative(),
25
29
  graceAfterFinalMs: z.number().nonnegative().nullable(),
26
30
  pollMs: z.number().positive(),
31
+ /** plan 23: when provider retries end an attempt; PROVIDER_RETRY when absent (tests shorten it) */
32
+ providerRetry: z
33
+ .object({ attempts: z.number().positive(), ms: z.number().positive(), pollMs: z.number().positive() })
34
+ .partial()
35
+ .optional(),
27
36
  });
28
37
  export type SuperviseSpec = z.infer<typeof SuperviseSpecSchema>;
29
38
 
39
+ /**
40
+ * Plan 23: an attempt that has only retried its provider (3 retries, or 3 min of nothing else) ends as
41
+ * provider-unavailable; a quiet session's own retry field is read every pollMs.
42
+ */
43
+ export const PROVIDER_RETRY = { attempts: 3, ms: 180_000, pollMs: 30_000 };
44
+
30
45
  /** What one stream line tells the supervisor: the terminal event, the thread, a tool call opening or closing. */
31
46
  interface LineInfo {
32
47
  final?: boolean;
33
48
  thread?: string;
34
49
  /** a tool call the CLI started (`open`) or finished: while one is open the run is busy, however quiet */
35
50
  item?: { id: string; open: boolean };
51
+ /** plan 23: a model step started; one right after another, with nothing between, is a provider retry */
52
+ step?: boolean;
36
53
  }
37
54
 
38
55
  export interface SuperviseHooks {
@@ -40,8 +57,23 @@ export interface SuperviseHooks {
40
57
  /** `sinceMs`: when this run started, so a backend can ignore what an earlier run on the thread left */
41
58
  isBusy?(thread: string | null, sinceMs: number): Promise<boolean>;
42
59
  interrupt?(thread: string | null): Promise<void>;
60
+ /** plan 23: the provider retry attempt the session waits out (its own retry field), or null */
61
+ providerRetry?(thread: string | null, sinceMs: number): Promise<number | null>;
62
+ /**
63
+ * The poll loop's time: what it reads as now and how it waits a poll. The real clock when absent; a test
64
+ * passes its own so the wall, idle and retry limits are decided by the time it drives, never by how fast a
65
+ * loaded runner happens to schedule timers.
66
+ */
67
+ clock?: SuperviseClock;
68
+ }
69
+
70
+ export interface SuperviseClock {
71
+ now(): number;
72
+ sleep(ms: number): Promise<void>;
43
73
  }
44
74
 
75
+ const REAL_CLOCK: SuperviseClock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) };
76
+
45
77
  /** Reads whatever the child appended to `file` since `offset`, as complete lines. */
46
78
  function readNew(file: string, state: { offset: number; rest: string; decoder: TextDecoder }): string[] {
47
79
  if (!existsSync(file)) return [];
@@ -79,7 +111,7 @@ async function bounded<T>(call: () => Promise<T> | undefined, ms: number, fallba
79
111
  }
80
112
  }
81
113
 
82
- function groupAlive(pgid: number): boolean {
114
+ export function groupAlive(pgid: number): boolean {
83
115
  try {
84
116
  process.kill(-pgid, 0);
85
117
  return true;
@@ -89,7 +121,7 @@ function groupAlive(pgid: number): boolean {
89
121
  }
90
122
 
91
123
  /** SIGTERM the group, give it `graceMs` to leave, then SIGKILL whatever is left of it. */
92
- async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
124
+ export async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
93
125
  killGroup(pgid, "SIGTERM");
94
126
  const end = Date.now() + graceMs;
95
127
  while (Date.now() < end && groupAlive(pgid))
@@ -168,6 +200,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
168
200
  }
169
201
 
170
202
  const hookMs = Math.min(10_000, spec.idleMs);
203
+ const clock = hooks.clock ?? REAL_CLOCK;
171
204
  let reason: ExitReason | null = null;
172
205
  let thread: string | null = null;
173
206
  let failure: { error: unknown } | null = null;
@@ -187,8 +220,12 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
187
220
  startedAt: new Date().toISOString(),
188
221
  });
189
222
 
190
- const started = Date.now();
223
+ const started = clock.now();
224
+ const dispatchId = basename(spec.dispatchDir);
191
225
  let lastActivity = started;
226
+ // plan 23: the last output seen from a `catherd lock` of the role, kept after the lock exits (its file goes),
227
+ // so the wall counts from that output, never from the role's start again
228
+ let lockAt = started;
192
229
  let finalAt: number | null = null;
193
230
  // spec §3.6: a quiet stretch of half the idle timeout, not busy, is a stall, reported once per dispatch
194
231
  let stalled = false;
@@ -196,34 +233,88 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
196
233
  const open = new Set<string>();
197
234
  const stream = { offset: 0, rest: "", decoder: new TextDecoder("utf-8") };
198
235
 
236
+ // plan 23: a step that starts right after another with nothing between is a provider retry; retries are
237
+ // not progress, and enough of them (or a long enough stretch) end the attempt as provider-unavailable
238
+ let lastWasStep = false;
239
+ let retries = 0;
240
+ let retrySince: number | null = null;
241
+ let lastRetryPoll = started;
242
+ const retry = { ...PROVIDER_RETRY, ...spec.providerRetry };
243
+
199
244
  while (!done && reason === null) {
200
- await Bun.sleep(spec.pollMs);
245
+ await clock.sleep(spec.pollMs);
201
246
  const lines = readNew(p.events, stream);
202
- if (lines.length) {
203
- lastActivity = Date.now();
204
- stallChecked = false;
205
- }
247
+ let progress = false;
206
248
  for (const line of lines) {
207
249
  let d: LineInfo | undefined;
208
250
  try {
209
251
  d = hooks.onLine?.(line);
210
252
  } catch {
211
- continue; // one line the hook cannot read must not end supervision
253
+ // one line the hook cannot read must not end supervision; it is output all the same
254
+ progress = true;
255
+ lastWasStep = false;
256
+ continue;
212
257
  }
213
- if (d?.thread) thread = d.thread;
214
- if (d?.final) finalAt ??= Date.now();
258
+ if (d?.step && lastWasStep) {
259
+ retries++;
260
+ retrySince ??= clock.now();
261
+ } else {
262
+ progress = true;
263
+ if (!d?.step) {
264
+ retries = 0;
265
+ retrySince = null;
266
+ }
267
+ }
268
+ lastWasStep = d?.step === true;
269
+ if (d?.thread && d.thread !== thread) {
270
+ thread = d.thread;
271
+ // spec 1.5 plan 21: a live role's thread is on disk, so no delivery ever targets it
272
+ try {
273
+ writeJsonAtomic(p.thread, { schema: 1, thread, at: new Date().toISOString() });
274
+ } catch (e) {
275
+ log("warn", "supervise", { dispatch: spec.dispatchDir, error: errorMessage(e) });
276
+ }
277
+ }
278
+ if (d?.final) finalAt ??= clock.now();
215
279
  if (d?.item) {
216
280
  if (d.item.open) open.add(d.item.id);
217
281
  else open.delete(d.item.id);
218
282
  }
219
283
  }
284
+ if (progress) {
285
+ lastActivity = clock.now();
286
+ stallChecked = false;
287
+ }
288
+ // plan 23: a session quiet for a while may be waiting out a provider retry the stream does not show
289
+ if (
290
+ hooks.providerRetry &&
291
+ clock.now() - lastActivity >= retry.pollMs &&
292
+ clock.now() - lastRetryPoll >= retry.pollMs
293
+ ) {
294
+ lastRetryPoll = clock.now();
295
+ const attempt = await bounded(() => hooks.providerRetry?.(thread, started), hookMs, null);
296
+ if (attempt !== null) {
297
+ retrySince ??= clock.now();
298
+ retries = Math.max(retries, attempt);
299
+ } else {
300
+ // the session says it is not retrying: the steps seen were a retry that worked, and the quiet since
301
+ // is work the stream does not show yet (a long tool call), never a provider outage
302
+ retries = 0;
303
+ retrySince = null;
304
+ }
305
+ }
220
306
  // a worker that ended on its own is recorded as it ended, even if a cancel or a limit arrived meanwhile
221
307
  if (done) break;
222
- const now = Date.now();
308
+ const now = clock.now();
309
+ lockAt = Math.max(lockAt, lastLockOutput(dispatchId) ?? lockAt);
223
310
  if (existsSync(p.cancel)) reason = "cancelled";
224
- else if (now - started >= spec.wallMs) reason = "wall-timeout";
311
+ else if (now - lockAt >= spec.wallMs)
312
+ // plan 23: once a `catherd lock` of the role has written, the wall counts from its last output
313
+ reason = "wall-timeout";
225
314
  else if (finalAt !== null && spec.graceAfterFinalMs !== null && now - finalAt >= spec.graceAfterFinalMs)
226
315
  reason = "after-final";
316
+ else if (retries >= retry.attempts || (retrySince !== null && now - retrySince >= retry.ms))
317
+ reason = "provider-unavailable";
227
318
  else if (now - lastActivity >= spec.idleMs) {
228
319
  const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
229
320
  // the busy check can take up to hookMs: a worker that ended meanwhile is recorded as it ended, read
@@ -231,7 +322,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
231
322
  if (done || child.exitCode !== null || child.signalCode !== null) break;
232
323
  if (busy) {
233
324
  // a new quiet stretch starts: it gets its own stall check
234
- lastActivity = Date.now();
325
+ lastActivity = clock.now();
235
326
  stallChecked = false;
236
327
  } else reason = "idle-timeout";
237
328
  } else if (!stalled && !stallChecked && now - lastActivity >= spec.idleMs / 2) {
@@ -240,7 +331,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
240
331
  stallChecked = true;
241
332
  const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
242
333
  if (busy) {
243
- lastActivity = Date.now();
334
+ lastActivity = clock.now();
244
335
  stallChecked = false;
245
336
  } else if (!done) {
246
337
  stalled = true;
@@ -249,7 +340,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
249
340
  writeJsonAtomic(p.stall, {
250
341
  schema: 1,
251
342
  at: new Date().toISOString(),
252
- quietMs: Date.now() - lastActivity,
343
+ quietMs: clock.now() - lastActivity,
253
344
  });
254
345
  } catch (e) {
255
346
  log("warn", "stall", { dispatch: spec.dispatchDir, error: errorMessage(e) });
@@ -1,22 +1,29 @@
1
1
  import type { KnownHost } from "../domain/host.ts";
2
- import { appendFileSync, existsSync, readFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { BackendAdapter } from "../adapters/backend.ts";
5
5
  import { budgetStatus, formatBudget } from "../domain/budget.ts";
6
6
  import { CatherdError, errorMessage } from "../domain/errors.ts";
7
- import { assertId, formatRung, newDispatchId, parseRung } from "../domain/ids.ts";
7
+ import { assertId, formatRung, headlessRung, newDispatchId, parseRung } from "../domain/ids.ts";
8
8
  import { assertLaneHeader, overlaps } from "../domain/lane.ts";
9
+ import { roleTimeouts } from "../domain/profile.ts";
9
10
  import type { RunRecord } from "../domain/record.ts";
10
- import { withReplyContract } from "../domain/role-prompts.ts";
11
+ import { composeBrief } from "../domain/brief.ts";
12
+ import { rulesBlock } from "../domain/rules.ts";
13
+ import { briefOwns, DOCS_OWNS } from "../domain/changes.ts";
14
+ import { grantsScratch, ROLE_SERVER_BACKENDS } from "../domain/role-tools.ts";
11
15
  import type { Role } from "../domain/roles.ts";
16
+ import { formatRoleScope, ROLE_ENV } from "../domain/role-scope.ts";
12
17
  import { dispatchPaths, markForCollect } from "../infra/dispatch-dir.ts";
13
18
  import { withFileLock } from "../infra/filelock.ts";
14
19
  import { statusSnapshot } from "../infra/git.ts";
15
20
  import { launchSupervisor } from "../infra/launch.ts";
21
+ import { DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
16
22
  import { log } from "../infra/log.ts";
17
23
  import { processStartTime } from "../infra/proc.ts";
18
24
  import { ensurePrivateDir, PRIVATE_FILE, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
19
25
  import { readyAdapter, standInFor } from "./backends.ts";
26
+ import { failoverBackends } from "./failover-rungs.ts";
20
27
  import { spendOf } from "./budget.ts";
21
28
  import {
22
29
  type Admit,
@@ -27,12 +34,22 @@ import {
27
34
  listDispatches,
28
35
  pendingDispatches,
29
36
  roleDir,
37
+ scratchDir,
30
38
  setLatest,
31
39
  } from "./dispatches.ts";
32
40
  import { finalizeDispatch } from "./finalize.ts";
41
+ import { gateEnvParts, readGateEnv } from "./gate-env.ts";
42
+ import { assertNotPaused } from "./pause.ts";
43
+ import { currentOwns } from "./lane-edit.ts";
44
+ import { planContext } from "./plan-input.ts";
45
+ import { standingRules } from "./run-service.ts";
46
+ import { unfinishedAfter } from "./protocol.ts";
47
+ import { runProfile } from "./run-pin.ts";
33
48
  import type { Deps } from "./ports.ts";
34
- import { readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
49
+ import { readRecords, recordsOnThread, type Run, runPaths, supersededBy } from "./run-store.ts";
35
50
  import { currentSession } from "./sessions.ts";
51
+ import { verifierBrief } from "./verifier-brief.ts";
52
+ import { withRunAdmission } from "./workspace-admission.ts";
36
53
 
37
54
  export interface AdmitInput {
38
55
  role: Role;
@@ -47,6 +64,8 @@ export interface AdmitInput {
47
64
  /** the dispatching session (spec §3.3); absent means the session this process serves */
48
65
  sessionId?: string | null;
49
66
  host?: KnownHost;
67
+ /** plan 35: where the role works (its milestone's worktree in a base run); absent means the run's repo */
68
+ cwd?: string;
50
69
  }
51
70
 
52
71
  /** Spec §3.3: SIGTERM, then SIGKILL this long after. */
@@ -54,7 +73,15 @@ export const KILL_GRACE_MS = 10_000;
54
73
 
55
74
  export const laneFile = (run: Run, lane: string): string => join(runPaths(run.dir).lanes, `${lane}.md`);
56
75
 
57
- function laneOwns(run: Run, lane: string): string[] {
76
+ /** The role's scratch folder, created (0700) and by its real path, on a backend that grants it; else null. */
77
+ function roleScratch(run: Run, name: string, backend: string, isolated: boolean): string | null {
78
+ if (!grantsScratch(backend, isolated)) return null;
79
+ const dir = scratchDir(run, name);
80
+ ensurePrivateDir(dir);
81
+ return realpathSync(dir);
82
+ }
83
+
84
+ export function laneOwns(run: Run, lane: string): string[] {
58
85
  const file = laneFile(run, lane);
59
86
  if (!existsSync(file))
60
87
  throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
@@ -124,6 +151,15 @@ async function prepared(adapter: BackendAdapter, req: Parameters<NonNullable<Bac
124
151
  }
125
152
  }
126
153
 
154
+ /** Refuses a dispatch into a run `runs supersede` closed, naming the run that took over. */
155
+ function assertNotSuperseded(run: Run): void {
156
+ const closed = supersededBy(run);
157
+ if (closed)
158
+ throw new CatherdError("E_RUN_NOT_LIVE", `run ${run.id} is superseded by ${closed.by}`, {
159
+ fix: `dispatch in run ${closed.by}`,
160
+ });
161
+ }
162
+
127
163
  /**
128
164
  * Spec §4.4 step 1. The checks that read shared state and the write that makes the dispatch live
129
165
  * happen under the run's admission lock, so parallel dispatches always see each other (audit C1).
@@ -136,8 +172,12 @@ export async function admit(
136
172
  ): Promise<{ d: Dispatch; specPath: string }> {
137
173
  assertId("role name", i.name);
138
174
  if (i.lane !== null) assertId("lane", i.lane);
175
+ // a machine or workspace pause refuses every dispatch it covers, with its reason (spec 1.5 "Group pause")
176
+ assertNotPaused(run);
177
+ assertNotSuperseded(run);
139
178
  const rung = parseRung(i.rung);
140
- const profile = deps.profiles.forRepo(run.meta.repo);
179
+ // spec 1.5: the run's pinned profile, access and isolation, whatever the repo runs on now
180
+ const profile = runProfile(deps, run);
141
181
  const rc = profile.roles[i.role];
142
182
  if (!rc?.enabled)
143
183
  throw new CatherdError("E_ADMIT_RUNG", `the ${i.role} role is off in profile ${profile.name}`, {
@@ -147,11 +187,15 @@ export async function admit(
147
187
  throw new CatherdError("E_ADMIT_RUNG", `${i.rung} is a native Claude rung`, {
148
188
  fix: `run it as Agent(subagent_type: "${deps.profiles.agentFor(run.meta.repo, i.role, i.rung)}"), then record_agent_run`,
149
189
  });
150
- const allowed = new Set([
190
+ const ladder = [
151
191
  ...rc.rungs,
152
192
  ...rc.rungs.flatMap((r) => standInFor(profile.failover, r, run.meta.repo) ?? []),
153
- ]);
154
- if (!allowed.has(formatRung(rung)))
193
+ ];
194
+ // the daemon runs every role headless (spec 2.0 decision 12): a native rung's headless twin is on its ladder too
195
+ const allowed = new Set([...ladder, ...ladder.map(headlessRung)]);
196
+ // a rung the daemon failed a limit over to may be on any configured backend (spec 2.0 decision 6)
197
+ const failedOver = i.failoverFrom !== null && failoverBackends(profile).has(rung.backend);
198
+ if (!allowed.has(formatRung(rung)) && !failedOver)
155
199
  throw new CatherdError(
156
200
  "E_ADMIT_RUNG",
157
201
  `${i.rung} is not on the ${i.role} ladder of profile ${profile.name}`,
@@ -174,7 +218,7 @@ export async function admit(
174
218
  `${adapter.id} keeps the access a thread started with: ${i.thread} ran ${started.access}, and the ${i.role} role runs ${rc.access}`,
175
219
  { fix: "dispatch a fresh thread (omit `thread`)" },
176
220
  );
177
- // and the network grant: grok keeps a session's sandbox profile, agy's isolated homes differ by it.
221
+ // and the network grant: a CLI that keeps a session's sandbox profile keeps it.
178
222
  // A record from before 1.3 carries no grant to compare.
179
223
  if (started?.network !== undefined && adapter.resume.sameAccessOnly && started.network !== network)
180
224
  throw new CatherdError(
@@ -183,31 +227,52 @@ export async function admit(
183
227
  { fix: "dispatch a fresh thread (omit `thread`)" },
184
228
  );
185
229
  const owns = i.lane === null ? [] : laneOwns(run, i.lane);
230
+ // spec 1.5 plan 21: the writer's implicit docs lane, for attribution only
231
+ const ownsImplicit = i.role === "writer" && i.lane === null ? (briefOwns(i.brief) ?? DOCS_OWNS) : null;
232
+ // spec 1.5 "Lane editing": an After: line orders lanes that compile against each other
233
+ const before = i.lane === null ? [] : unfinishedAfter(run, i.lane);
234
+ if (before.length)
235
+ throw new CatherdError(
236
+ "E_ADMIT_ORDER",
237
+ `${i.lane} runs after ${before.join(", ")} (its After: line), which ${before.length > 1 ? "have" : "has"} not finished`,
238
+ { fix: `dispatch ${i.lane} once ${before.join(", ")} ${before.length > 1 ? "end" : "ends"} ok` },
239
+ );
186
240
  const id = newDispatchId();
187
241
  const dir = join(roleDir(run, i.name), id);
188
242
  const p = dispatchPaths(dir);
189
243
  const isolated = started?.isolated ?? profile.isolated[rung.backend] ?? false;
244
+ // spec 1.5 plan 21 (#42 findings 1, 2): the role server goes in whether the harness is isolated or not, so no
245
+ // role is refused for isolation, and a thread an isolated run started resumes as it started
246
+ const roleServer = ROLE_SERVER_BACKENDS.includes(rung.backend);
247
+ const cwd = i.cwd ?? run.meta.repo;
190
248
  await prepared(adapter, {
191
249
  rung,
192
250
  access: rc.access,
193
251
  isolated,
194
- repo: run.meta.repo,
252
+ repo: cwd,
195
253
  network,
196
254
  });
197
- const plan = adapter.plan({
198
- rung,
199
- access: rc.access,
200
- network,
201
- thread: i.thread,
202
- isolated,
203
- repo: run.meta.repo,
204
- briefPath: p.brief,
205
- replyPath: p.reply,
206
- dispatchDir: dir,
207
- });
208
255
 
209
256
  await finalizeFinished(run, deps.now(), onRecorded);
210
- return withFileLock(runPaths(run.dir).admission, async () => {
257
+ return withRunAdmission(run, deps.now, async () => {
258
+ // again under the lock `runs supersede` writes under: a supersede that landed since the check above wins
259
+ assertNotSuperseded(run);
260
+ // the scratch is created under the admission lock `catherd runs clean` takes too, so cleanup never removes
261
+ // the scratch of a dispatch it does not see live yet (PR #46)
262
+ const scratch = roleScratch(run, i.name, rung.backend, isolated);
263
+ const plan = adapter.plan({
264
+ rung,
265
+ access: rc.access,
266
+ network,
267
+ thread: i.thread,
268
+ isolated,
269
+ repo: cwd,
270
+ briefPath: p.brief,
271
+ replyPath: p.reply,
272
+ dispatchDir: dir,
273
+ ...(roleServer ? { roleMcp: { run: run.id, role: i.role } } : {}),
274
+ ...(scratch ? { scratch } : {}),
275
+ });
211
276
  // A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
212
277
  // tree after the exit, so a later dispatch's writes must not land in between. A finished one here
213
278
  // could not be recorded just now. The records and the pending dispatches are one snapshot, taken
@@ -230,7 +295,9 @@ export async function admit(
230
295
  },
231
296
  );
232
297
  for (const d of pending) {
233
- const shared = overlaps(owns, d.admit.owns);
298
+ // a running lane holds what owns_add granted it since its admission, too
299
+ const held = d.admit.lane ? [...d.admit.owns, ...currentOwns(run, d.admit.lane)] : d.admit.owns;
300
+ const shared = overlaps(owns, held);
234
301
  if (shared.length)
235
302
  throw new CatherdError(
236
303
  "E_ADMIT_OVERLAP",
@@ -246,7 +313,7 @@ export async function admit(
246
313
  throw new CatherdError("E_RUN_BUDGET", `the run budget is spent: ${formatBudget(budget)}`, {
247
314
  fix: "ask the user to raise profile.budget, or finish the run with what landed",
248
315
  });
249
- const before = await statusSnapshot(run.meta.repo);
316
+ const before = await statusSnapshot(cwd);
250
317
  const session = currentSession(deps);
251
318
  const sessionId = i.sessionId !== undefined ? i.sessionId : (session?.sessionId ?? null);
252
319
  const admitted: Admit = {
@@ -257,6 +324,7 @@ export async function admit(
257
324
  role: i.role,
258
325
  lane: i.lane,
259
326
  owns,
327
+ ...(ownsImplicit ? { ownsImplicit } : {}),
260
328
  rung: formatRung(rung),
261
329
  backend: rung.backend,
262
330
  thread: i.thread,
@@ -268,13 +336,32 @@ export async function admit(
268
336
  network,
269
337
  cliVersion: probe.version,
270
338
  admittedAt: new Date(deps.now()).toISOString(),
271
- repo: run.meta.repo,
339
+ repo: cwd,
272
340
  before,
273
341
  ...(sessionId ? { sessionId, host: i.host ?? session?.host ?? "claude-code" } : {}),
274
342
  };
275
343
  ensurePrivateDir(dir);
276
- // spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included
277
- writeTextAtomic(p.brief, withReplyContract(i.role, i.brief));
344
+ // plan 34: the repo's standing rules for this role; a knowledge file that cannot be read adds nothing
345
+ const rules = await standingRules(run.meta.repo, i.role).catch(() => "");
346
+ // plan 23: the verifier runs with the repo's gate environment (DOCKER_HOST, a proxy, …)
347
+ const gate =
348
+ i.role === "verifier" ? gateEnvParts(await readGateEnv(run.meta.repo)) : { values: {}, refs: {} };
349
+ // spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included; spec 1.5 plan 21:
350
+ // before it, the lane file as it stands now, and who the role is, its scratch and its catherd tools; plan 23:
351
+ // a verifier's brief also carries the gate's rules and the run's recorded items
352
+ writeTextAtomic(
353
+ p.brief,
354
+ composeBrief(await verifierBrief(run, i.role, i.name, i.brief), {
355
+ run: run.id,
356
+ name: i.name,
357
+ role: i.role,
358
+ lane: i.lane === null ? null : { id: i.lane, text: readFileSync(laneFile(run, i.lane), "utf8") },
359
+ scratch,
360
+ roleServer,
361
+ version: deps.version,
362
+ context: [planContext(run, i.role), rulesBlock(rules, i.role)].filter(Boolean).join("\n\n") || null,
363
+ }),
364
+ );
278
365
  // Spec §10.4: the adapter's overrides only; the supervisor adds its own inherited env at spawn
279
366
  // time (src/entry/supervise-command.ts), so no credential is ever written to disk. 0600 all the same.
280
367
  writeJsonAtomic(
@@ -285,11 +372,26 @@ export async function admit(
285
372
  dispatchDir: dir,
286
373
  cmd: plan.cmd,
287
374
  args: plan.args,
288
- env: { ...plan.env, PWD: plan.cwd },
375
+ // spec 1.5 plan 21: the supervisor gives the role its identity and, where granted, its scratch TMPDIR;
376
+ // plan 23: a `catherd lock` in the role reports to this dispatch, so a long gate keeps its wall alive
377
+ // plan 23: each dispatch its own testcontainers session, so parallel lanes never share a reaper;
378
+ // the gate env never overrides the role's identity, scratch, dispatch id, PWD or isolated home
379
+ env: {
380
+ ...gate.values,
381
+ ...plan.env,
382
+ [ROLE_ENV]: formatRoleScope({ run: run.id, name: i.name }),
383
+ ...(scratch ? { TMPDIR: scratch } : {}),
384
+ [DISPATCH_ID_ENV]: id,
385
+ TESTCONTAINERS_SESSION_ID: id,
386
+ PWD: plan.cwd,
387
+ },
388
+ // a secret of the gate env by reference only: the supervisor reads it from its own env at spawn
389
+ ...(Object.keys(gate.refs).length ? { envFrom: gate.refs } : {}),
289
390
  cwd: plan.cwd,
290
391
  stdinPath: plan.stdinPath,
291
- idleMs: profile.timeouts.idleMin * 60_000,
292
- wallMs: profile.timeouts.wallMin * 60_000,
392
+ // plan 23: a role's own timeouts win over the profile's (a verifier's long gate)
393
+ idleMs: roleTimeouts(profile, rc).idleMin * 60_000,
394
+ wallMs: roleTimeouts(profile, rc).wallMin * 60_000,
293
395
  killGraceMs: KILL_GRACE_MS,
294
396
  graceAfterFinalMs: adapter.graceAfterFinalMs,
295
397
  pollMs: deps.pollMs,
@@ -5,7 +5,6 @@ import { adapterFor } from "../adapters/registry.ts";
5
5
  import "../adapters/all.ts";
6
6
  import { CatherdError } from "../domain/errors.ts";
7
7
  import { formatRung, tryParseRung } from "../domain/ids.ts";
8
- import { PAIRED_FAILOVER } from "../domain/profile.ts";
9
8
 
10
9
  const READY_TTL_MS = 10 * 60_000;
11
10
  const ready = new Map<string, { at: number; probe: Probe }>();
@@ -17,7 +16,7 @@ export const resetReadiness = (): void => ready.clear();
17
16
  function execFormatError(e: unknown): string | null {
18
17
  if (!(e instanceof Error)) return null;
19
18
  if ((e as { code?: unknown }).code !== "ENOEXEC" && !/exec format error/i.test(e.message)) return null;
20
- // macOS names the path (posix_spawn '/…/grok'), Linux only the command (uv_spawn 'grok')
19
+ // macOS names the path (posix_spawn '/…/bin'), Linux only the command (uv_spawn 'bin')
21
20
  const bin = /spawn '([^']+)'/.exec(e.message)?.[1];
22
21
  return bin ? (Bun.which(bin, { PATH: process.env.PATH ?? "" }) ?? bin) : "exec format error";
23
22
  }
@@ -64,18 +63,17 @@ export async function readyAdapter(backend: string): Promise<{ adapter: BackendA
64
63
  const probe = await probeBackend(adapter);
65
64
  const problem = probe.problems[0];
66
65
  // spec 1.3 §3.3: a probe that finds the CLI logged out lists E_BACKEND_NOT_LOGGED_IN, so a dispatch never
67
- // reaches it (agy opens a browser and waits); logged out alone is no problem where a CLI runs without a login
66
+ // reaches it (it may open a browser and wait); logged out alone is no problem where a CLI runs without a login
68
67
  if (problem) throw new CatherdError(problem.code, problem.message, { fix: problem.fix });
69
68
  ready.set(backend, { at: now, probe });
70
69
  return { adapter, probe };
71
70
  }
72
71
 
73
72
  /**
74
- * Spec §4.5: a rung's stand-in on a usage limit: the profile's, else the same model on its paired backend
75
- * (spec 1.3 §7.3), else its backend's default (for `repo`, when given), else none.
73
+ * Spec §4.5: a rung's stand-in on a usage limit: the profile's, else its backend's default (for `repo`, when given), else none.
76
74
  */
77
75
  export function standInFor(failover: Record<string, string>, rung: string, repo?: string): string | null {
78
- const own = failover[rung] ?? PAIRED_FAILOVER[rung];
76
+ const own = failover[rung];
79
77
  if (own) return own;
80
78
  const r = tryParseRung(rung);
81
79
  if (!r) return null;
@@ -2,13 +2,14 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { adapterFor } from "../adapters/registry.ts";
3
3
  import "../adapters/all.ts";
4
4
  import { type Budget, type BudgetStatus, budgetStatus, type Spend } from "../domain/budget.ts";
5
+ import { CatherdError } from "../domain/errors.ts";
5
6
  import type { RunRecord } from "../domain/record.ts";
6
7
  import { dispatchPaths } from "../infra/dispatch-dir.ts";
7
8
  import { type Dispatch, liveDispatches } from "./dispatches.ts";
8
9
  import { readAgentRuns, readRecords, type Run } from "./run-store.ts";
9
10
 
10
11
  /** Tokens a running dispatch has used so far, summed from the events its CLI has written. */
11
- export function liveTokens(d: Dispatch): number {
12
+ export function liveTokens(d: Dispatch, strict = false): number {
12
13
  const a = adapterFor(d.admit.backend);
13
14
  const file = dispatchPaths(d.dir).events;
14
15
  if (!a || !existsSync(file)) return 0;
@@ -17,8 +18,13 @@ export function liveTokens(d: Dispatch): number {
17
18
  if (!line.trim()) continue;
18
19
  try {
19
20
  const t = a.parse(line).tokens;
20
- if (t) n += t.input + t.output;
21
- } catch {
21
+ if (t) {
22
+ if (strict && Object.values(t).some((value) => !Number.isFinite(value) || value < 0))
23
+ throw new CatherdError("E_RUN_CORRUPT", `dispatch ${d.admit.dispatchId} has invalid token usage`);
24
+ n += t.input + t.output;
25
+ }
26
+ } catch (error) {
27
+ if (strict && error instanceof CatherdError) throw error;
22
28
  // a line the adapter cannot read adds nothing
23
29
  }
24
30
  }
@@ -29,15 +35,29 @@ export function liveTokens(d: Dispatch): number {
29
35
  * Spec §4.6: minutes are wall clock since run_start; tokens and dollars sum every record (every
30
36
  * backend), every native subagent run the orchestrator reported, and what live roles used so far.
31
37
  */
32
- export function spendOf(run: Run, records: RunRecord[], live: Dispatch[], now: number): Spend {
33
- const agents = readAgentRuns(run);
38
+ export function spendOf(
39
+ run: Run,
40
+ records: RunRecord[],
41
+ live: Dispatch[],
42
+ now: number,
43
+ strict = false,
44
+ ): Spend {
45
+ const agents = readAgentRuns(run, strict);
46
+ if (strict) {
47
+ const invalid = (value: number | null) => value !== null && (!Number.isFinite(value) || value < 0);
48
+ if (
49
+ records.some((record) => Object.values(record.tokens).some(invalid) || invalid(record.costUsd)) ||
50
+ agents.some((agent) => invalid(agent.totalTokens) || invalid(agent.costUsd))
51
+ )
52
+ throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has invalid cost or token usage`);
53
+ }
34
54
  const sum = <T>(xs: T[], f: (x: T) => number) => xs.reduce((n, x) => n + f(x), 0);
35
55
  return {
36
56
  minutes: Math.max(0, (now - Date.parse(run.meta.createdAt)) / 60_000),
37
57
  tokens:
38
58
  sum(records, (r) => r.tokens.input + r.tokens.output) +
39
59
  sum(agents, (a) => a.totalTokens) +
40
- sum(live, liveTokens),
60
+ sum(live, (dispatch) => liveTokens(dispatch, strict)),
41
61
  usd: sum(records, (r) => r.costUsd ?? 0) + sum(agents, (a) => a.costUsd ?? 0),
42
62
  };
43
63
  }