catherd-cli 0.2.0 → 1.0.0

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 (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -25
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -69
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -0,0 +1,479 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { relative } from "node:path";
3
+ import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
4
+ import { assertId, parseRung } from "../domain/ids.ts";
5
+ import { dispatchHints } from "../domain/hints.ts";
6
+ import type { RunRecord } from "../domain/record.ts";
7
+ import type { Role } from "../domain/roles.ts";
8
+ import {
9
+ awaitsCollect,
10
+ dispatchPaths,
11
+ readExit,
12
+ supervisorAlive,
13
+ requestCancel,
14
+ tryCollect,
15
+ endCollect,
16
+ putBackCollect,
17
+ } from "../infra/dispatch-dir.ts";
18
+ import { log } from "../infra/log.ts";
19
+ import { isAlive, isSurelyAlive, killGroup } from "../infra/proc.ts";
20
+ import { writeJsonAtomic } from "../infra/store.ts";
21
+ import { admit, KILL_GRACE_MS, laneFile, launch } from "./admission.ts";
22
+ import { standInFor } from "./backends.ts";
23
+ import {
24
+ type Dispatch,
25
+ dispatchState,
26
+ launchPath,
27
+ listDispatches,
28
+ liveDispatches,
29
+ readProc,
30
+ } from "./dispatches.ts";
31
+ import { finalizeDispatch, lastEvent, waitForFinish } from "./finalize.ts";
32
+ import type { Deps } from "./ports.ts";
33
+ import { findRun, readRecords, type Run } from "./run-store.ts";
34
+ import { type NotesPatch, refreshState } from "./state.ts";
35
+
36
+ export interface DispatchInput {
37
+ run: string;
38
+ role: Role;
39
+ name: string;
40
+ brief: string;
41
+ rung: string;
42
+ thread?: string;
43
+ lane?: string;
44
+ next?: string;
45
+ }
46
+
47
+ /** One finished dispatch: its record and what to do next. */
48
+ export interface DispatchResult {
49
+ record: RunRecord;
50
+ hints: string[];
51
+ }
52
+
53
+ /** A dispatch that has been admitted and launched. */
54
+ export interface Dispatched {
55
+ name: string;
56
+ role: Role;
57
+ rung: string;
58
+ dispatchId: string;
59
+ admittedAt: string;
60
+ }
61
+
62
+ export interface DispatchStarted {
63
+ dispatched: Dispatched;
64
+ hints: string[];
65
+ }
66
+
67
+ export interface WaitInput {
68
+ run: string;
69
+ /** the role names to wait on; default: every uncollected dispatch of the run */
70
+ names?: string[];
71
+ /** wait until every one of them has finished, not only the first */
72
+ all?: boolean;
73
+ }
74
+
75
+ export interface WaitResult {
76
+ /** each dispatch that finished, with its record and hints */
77
+ records: DispatchResult[];
78
+ /** failover stand-ins launched for a usage limit, not awaited */
79
+ started: Dispatched[];
80
+ /** every uncollected role of the run this call did not return, running or finished, stand-ins included */
81
+ running: string[];
82
+ hints: string[];
83
+ }
84
+
85
+ export type Progress = (message: string) => void;
86
+
87
+ const hintsFor = (run: Run, d: Dispatch, r: RunRecord) =>
88
+ dispatchHints(r, d.admit.owns, relative(run.dir, d.dir));
89
+
90
+ const dispatchedOf = (d: Dispatch): Dispatched => ({
91
+ name: d.admit.name,
92
+ role: d.admit.role,
93
+ rung: d.admit.rung,
94
+ dispatchId: d.admit.dispatchId,
95
+ admittedAt: d.admit.admittedAt,
96
+ });
97
+
98
+ /** Refreshes state.md (spec §4.5 notes kept on a git failure); a refresh that fails adds its hint once. */
99
+ async function refresh(run: Run, change: NotesPatch, hints: string[]): Promise<void> {
100
+ for (const h of (await refreshState(run, change)).hints) if (!hints.includes(h)) hints.push(h);
101
+ }
102
+
103
+ /** How a dispatch's supervisor is started; tests replace it to make a launch fail. */
104
+ export const launcher = { launch };
105
+
106
+ /**
107
+ * Starts an admitted dispatch's supervisor. Admission already left its collect mark, and a launch that
108
+ * throws keeps it: the dispatch is recorded as lost once its start grace passes, and a `wait` returns that
109
+ * record, as the error says.
110
+ */
111
+ function start(d: Dispatch, specPath: string): void {
112
+ try {
113
+ launcher.launch(d, specPath);
114
+ } catch (e) {
115
+ throw new CatherdError("E_IO_UNEXPECTED", `could not launch ${d.admit.name}: ${errorMessage(e)}`, {
116
+ fix: "wait(run) returns its record, lost, once its start grace passes; then dispatch it again",
117
+ });
118
+ }
119
+ }
120
+
121
+ /** The watchers `dispatch` started, each until its dispatch is finalized. */
122
+ const watchers = new Set<Promise<void>>();
123
+
124
+ /** Settles once every watcher has: tests await it so none outlives its test. */
125
+ export async function watchersSettled(): Promise<void> {
126
+ while (watchers.size > 0) await Promise.all(watchers);
127
+ }
128
+
129
+ /**
130
+ * Finalizes a dispatch as soon as it exits and refreshes state.md, as reconcile's watcher does, so its
131
+ * after-snapshot holds only its own writes and the live list and the spend stay true. It never collects
132
+ * the record and never fails over: `wait` does both, once. Its errors are logged; `wait` finalizes then.
133
+ */
134
+ function watch(deps: Deps, run: Run, d: Dispatch): void {
135
+ const w = (async () => {
136
+ await waitForFinish(d, { pollMs: deps.pollMs, tickMs: Number.POSITIVE_INFINITY, now: deps.now });
137
+ await finalizeDispatch(run, d);
138
+ const { hints } = await refreshState(run);
139
+ if (hints[0]) log("warn", "dispatch", { run: run.id, name: d.admit.name, hint: hints[0] });
140
+ })()
141
+ .catch((e: unknown) =>
142
+ log("warn", "dispatch", { run: run.id, name: d.admit.name, error: errorMessage(e) }),
143
+ )
144
+ .finally(() => watchers.delete(w));
145
+ watchers.add(w);
146
+ }
147
+
148
+ /**
149
+ * Spec §4.4, plan 9 ruling 1: admit and launch one role, start its watcher, then refresh state.md with
150
+ * `next` (after the launch, so nothing delays it). Returns at once; `wait` collects the record.
151
+ */
152
+ export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchStarted> {
153
+ const run = findRun(i.run);
154
+ const { d, specPath } = await admit(deps, run, {
155
+ role: i.role,
156
+ name: i.name,
157
+ brief: i.brief,
158
+ rung: i.rung,
159
+ thread: i.thread ?? null,
160
+ lane: i.lane ?? null,
161
+ failoverFrom: null,
162
+ });
163
+ start(d, specPath);
164
+ watch(deps, run, d);
165
+ const hints: string[] = [];
166
+ await refresh(run, i.next ? { next: i.next } : {}, hints);
167
+ return { dispatched: dispatchedOf(d), hints };
168
+ }
169
+
170
+ const NOTHING = "nothing to wait for: every dispatch of this run has been collected; dispatch a role first";
171
+ const nothingFor = (n: string) => `${n} has nothing to collect: result(run, "${n}") reads its last record`;
172
+ const takenElsewhere = (n: string) =>
173
+ `${n}: its record went to another call (cancel or wait); result(run, "${n}") reads it`;
174
+ const ABORTED = "wait was cancelled: nothing collected";
175
+
176
+ /** The run's dispatches a `wait` has yet to hand back, read from disk, so a restarted server finds them. */
177
+ const uncollected = (run: Run): Dispatch[] => listDispatches(run).filter((d) => awaitsCollect(d.dir));
178
+
179
+ /** Every uncollected role of the run, running or finished: what a `wait` still has to return. */
180
+ const pendingNames = (run: Run): string[] => [...new Set(uncollected(run).map((d) => d.admit.name))];
181
+
182
+ interface Collected {
183
+ result: DispatchResult;
184
+ started: Dispatched | null;
185
+ pause: string | null;
186
+ }
187
+
188
+ /** The record of a dispatch this wait has collected, with its failover; an unexpected failover error becomes a hint. */
189
+ async function collected(deps: Deps, run: Run, d: Dispatch, record: RunRecord): Promise<Collected> {
190
+ if (record.status !== "limit")
191
+ return { result: { record, hints: hintsFor(run, d, record) }, started: null, pause: null };
192
+ try {
193
+ return await failover(deps, run, d, record);
194
+ } catch (e) {
195
+ return {
196
+ result: { record, hints: [...hintsFor(run, d, record), `failover: ${errorMessage(e)}`] },
197
+ started: null,
198
+ pause: `paused: ${record.backend} usage limit; resume when the user says so`,
199
+ };
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Plan 9 ruling 1: blocks until one of the named uncollected dispatches (default: every one of the run)
205
+ * has finished, or every one with `all`; finalizes each (claim-based, so the dispatch's watcher, a cancel,
206
+ * reconcile or another server finalizes it once), hands each record to this wait only, launches a usage
207
+ * limit's stand-in without awaiting it, then refreshes state.md, with the pause a limit calls for.
208
+ * `running` names every uncollected role this call did not return. One dispatch that cannot be finalized
209
+ * becomes a hint and is dropped; an error that still escapes puts back what this call collected, and so
210
+ * does an abort, which returns without collecting.
211
+ */
212
+ export async function wait(
213
+ deps: Deps,
214
+ i: WaitInput,
215
+ onProgress?: Progress,
216
+ signal?: AbortSignal,
217
+ ): Promise<WaitResult> {
218
+ const run = findRun(i.run);
219
+ const names = i.names ? [...new Set(i.names)] : null;
220
+ for (const n of names ?? []) assertId("role name", n);
221
+ const hints: string[] = [];
222
+ const targets = uncollected(run).filter((d) => !names || names.includes(d.admit.name));
223
+ for (const n of names ?? []) if (!targets.some((d) => d.admit.name === n)) hints.push(nothingFor(n));
224
+ if (targets.length === 0) {
225
+ const running = pendingNames(run);
226
+ return { records: [], started: [], running, hints: running.length ? hints : [...hints, NOTHING] };
227
+ }
228
+
229
+ const open = new Map(targets.map((d) => [d.admit.dispatchId, d]));
230
+ const taken: Dispatch[] = [];
231
+ const records: DispatchResult[] = [];
232
+ const started: Dispatched[] = [];
233
+ let pause: string | null = null;
234
+ let ticked = Date.now();
235
+ const putBack = () => {
236
+ for (const d of taken) putBackCollect(d.dir);
237
+ };
238
+ try {
239
+ for (;;) {
240
+ if (signal?.aborted) {
241
+ putBack();
242
+ return { records: [], started: [], running: pendingNames(run), hints: [ABORTED] };
243
+ }
244
+ const recorded = new Set(readRecords(run).records.map((r) => r.dispatchId));
245
+ for (const d of open.values()) {
246
+ if (!recorded.has(d.admit.dispatchId) && dispatchState(d, deps.now()) !== "finished") continue;
247
+ open.delete(d.admit.dispatchId);
248
+ let record: RunRecord;
249
+ try {
250
+ record = await finalizeDispatch(run, d);
251
+ } catch (e) {
252
+ // dropped, not retried: one dispatch that cannot be finalized must not hold every later wait
253
+ if (await tryCollect(d.dir)) {
254
+ endCollect(d.dir);
255
+ hints.push(
256
+ `${d.admit.name}: not finalized: ${errorMessage(e)}; result(run, "${d.admit.name}") reads its record once it has one`,
257
+ );
258
+ }
259
+ continue;
260
+ }
261
+ if (signal?.aborted) break;
262
+ if (!(await tryCollect(d.dir))) {
263
+ hints.push(takenElsewhere(d.admit.name));
264
+ continue;
265
+ }
266
+ taken.push(d);
267
+ const c = await collected(deps, run, d, record);
268
+ records.push(c.result);
269
+ if (c.started) started.push(c.started);
270
+ if (c.pause) pause = c.pause;
271
+ }
272
+ if (signal?.aborted) continue;
273
+ if (open.size === 0 || (!i.all && records.length > 0)) break;
274
+ await Bun.sleep(deps.pollMs);
275
+ if (onProgress && Date.now() - ticked >= deps.tickMs) {
276
+ ticked = Date.now();
277
+ for (const d of open.values()) {
278
+ const secs = Math.max(0, Math.round((ticked - Date.parse(d.admit.admittedAt)) / 1000));
279
+ const ev = lastEvent(d);
280
+ try {
281
+ onProgress(`${d.admit.name} · ${d.admit.rung} · ${secs}s${ev ? ` · ${ev}` : ""}`);
282
+ } catch {
283
+ // a progress report must never stop the wait
284
+ }
285
+ }
286
+ }
287
+ }
288
+ await refresh(run, pause ? { next: pause } : {}, hints);
289
+ } catch (e) {
290
+ putBack();
291
+ throw e;
292
+ }
293
+ // aborted during the refresh: the result would reach no one, so put back what it collected
294
+ if (signal?.aborted) {
295
+ putBack();
296
+ return { records: [], started: [], running: pendingNames(run), hints: [ABORTED] };
297
+ }
298
+ // in the order the roles finished, however the polls happened to see them
299
+ records.sort((a, b) => Date.parse(a.record.endedAt) - Date.parse(b.record.endedAt));
300
+ // the leases end only now, with the failovers launched and state.md refreshed: a crash before this
301
+ // leaves each lease to a dead owner, and the next wait collects that record again
302
+ for (const d of taken) endCollect(d.dir);
303
+ return { records, started, running: pendingNames(run), hints };
304
+ }
305
+
306
+ /**
307
+ * Whether a dispatch has concrete launch evidence: a live supervisor holding its lock (taken first thing),
308
+ * launch.json (written by `launch` right after the spawn), proc.json (written by the supervisor once its
309
+ * worker runs) or exit.json. Not the collect mark or lease: admission writes the mark before any launch.
310
+ */
311
+ function launched(d: Dispatch): boolean {
312
+ return (
313
+ supervisorAlive(d.dir) ||
314
+ existsSync(launchPath(d.dir)) ||
315
+ readProc(d.dir) !== null ||
316
+ readExit(d.dir) !== null
317
+ );
318
+ }
319
+
320
+ const recordOf = (run: Run, d: Dispatch): boolean =>
321
+ readRecords(run).records.some((r) => r.dispatchId === d.admit.dispatchId);
322
+
323
+ /**
324
+ * The stand-in's brief (spec §4.5, audit C4). A fresh round reruns its own brief; a fix round hands
325
+ * over the lane file and the fix brief by path, since the stand-in cannot resume the old thread.
326
+ */
327
+ function standInBrief(run: Run, d: Dispatch): string {
328
+ const own = dispatchPaths(d.dir).brief;
329
+ if (d.admit.thread === null) return readFileSync(own, "utf8");
330
+ return [
331
+ `You take over ${d.admit.name} from ${d.admit.rung}, which hit a usage limit. You start on a fresh thread, so read these first:`,
332
+ ...(d.admit.lane ? [`- the lane file: ${laneFile(run, d.admit.lane)}`] : []),
333
+ `- the fix brief the previous thread was given: ${own}`,
334
+ "The work so far is in the tree. Do what the fix brief asks.",
335
+ ].join("\n");
336
+ }
337
+
338
+ /**
339
+ * Spec §4.5: on a limit, launch the rung's stand-in on a fresh thread, through admission again (budget
340
+ * included), without awaiting it. A stand-in that hits a limit too pauses the run. A stand-in already
341
+ * launched for this record (by a wait whose collection was put back) is reused, never launched twice.
342
+ */
343
+ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord): Promise<Collected> {
344
+ const hints = hintsFor(run, d, limited);
345
+ const result = (h: string[]): DispatchResult => ({ record: limited, hints: h });
346
+ if (d.admit.failoverFrom !== null)
347
+ return {
348
+ result: result(hints),
349
+ started: null,
350
+ pause: `paused: ${limited.backend} usage limit on ${limited.rung} and on ${d.admit.failoverFrom}`,
351
+ };
352
+ const paused = `paused: ${limited.backend} usage limit; resume when the user says so`;
353
+ const failedOver = (standIn: string, next: Dispatch): Collected => ({
354
+ result: result([
355
+ `limit: ${limited.rung} hit a usage limit; failed over to ${standIn}`,
356
+ // the stand-in's before-snapshot already holds the limited run's writes: surface them here
357
+ ...hints.filter((h) => /^(violation|git-unavailable):/.test(h)),
358
+ ]),
359
+ started: dispatchedOf(next),
360
+ pause: null,
361
+ });
362
+ // tied to this limited dispatch's id: a later dispatch of the same name, rung and limit is not its stand-in
363
+ const already = listDispatches(run)
364
+ .filter((x) => x.admit.failoverOf === d.admit.dispatchId)
365
+ .at(-1);
366
+ if (already && launched(already)) return failedOver(already.admit.rung, already);
367
+ if (already && !recordOf(run, already) && dispatchState(already, deps.now()) === "starting") {
368
+ // Admitted (maybe marked) by a collector that died before launching it: launch it now, never report
369
+ // it unlaunched. Only the collector holding the limited record launches its stand-in, and we hold that
370
+ // record now, so that collector is dead. If it died after spawning a supervisor that has not taken the
371
+ // dispatch's lock yet, the second supervisor started here and that one race for the lock: exactly one
372
+ // runs the worker, the other exits touching nothing (supervisor.ts), so no worker ever runs twice.
373
+ start(already, dispatchPaths(already.dir).spec);
374
+ watch(deps, run, already);
375
+ return failedOver(already.admit.rung, already);
376
+ }
377
+ // else that stand-in never ran and is over (recorded as lost, or past its start grace): admit a new one
378
+ const standIn = standInFor(deps.profiles.forRepo(run.meta.repo).failover, limited.rung, run.meta.repo);
379
+ if (!standIn) return { result: result(hints), started: null, pause: paused };
380
+ if (parseRung(standIn).backend === "claude") {
381
+ const agent = deps.profiles.agentFor(run.meta.repo, d.admit.role, standIn);
382
+ return {
383
+ result: result([
384
+ ...hints,
385
+ `failover: run ${d.admit.name} as Agent(subagent_type: "${agent}"), standing in for ${limited.rung}`,
386
+ ]),
387
+ started: null,
388
+ pause: null,
389
+ };
390
+ }
391
+ let next: Awaited<ReturnType<typeof admit>>;
392
+ try {
393
+ next = await admit(deps, run, {
394
+ role: d.admit.role,
395
+ name: d.admit.name,
396
+ brief: standInBrief(run, d),
397
+ rung: standIn,
398
+ thread: null,
399
+ lane: d.admit.lane,
400
+ failoverFrom: limited.rung,
401
+ failoverOf: d.admit.dispatchId,
402
+ });
403
+ } catch (e) {
404
+ if (!isCatherdError(e)) throw e;
405
+ return {
406
+ result: result([...hints, `failover: ${standIn} refused: ${e.code} ${e.message}`]),
407
+ started: null,
408
+ pause: paused,
409
+ };
410
+ }
411
+ start(next.d, next.specPath);
412
+ watch(deps, run, next.d);
413
+ return failedOver(standIn, next.d);
414
+ }
415
+
416
+ /** How long an orphaned worker gets between SIGTERM and SIGKILL; tests shorten it. */
417
+ export const orphanLimits = { killGraceMs: KILL_GRACE_MS };
418
+
419
+ /**
420
+ * A worker whose supervisor died has no one to read the cancel file: stop its group here (SIGTERM, then
421
+ * SIGKILL after the grace), each signal guarded by the worker's pid and start time, and write the
422
+ * exit.json the supervisor would have, with the signal that ended it. A worker catherd cannot identify
423
+ * (no start time, or a proc.json whose pgid is not the worker's own pid) is never signalled: its pid may
424
+ * belong to someone else by now, so the dispatch is only recorded as cancelled. A finalizer that sees the
425
+ * worker gone first reads the cancel file instead (finalize.ts). Nothing happens while the supervisor
426
+ * lives: it acts on the cancel file itself.
427
+ */
428
+ async function stopOrphan(deps: Deps, d: Dispatch): Promise<void> {
429
+ const proc = readProc(d.dir);
430
+ if (!proc || readExit(d.dir) || isAlive(proc.supervisorPid, proc.supervisorStartTime)) return;
431
+ const worker = () => isAlive(proc.pid, proc.startTime);
432
+ // a signal needs the start time read back exactly: a pid whose start time cannot be read now may be reused
433
+ const surely = () => isSurelyAlive(proc.pid, proc.startTime);
434
+ if (!worker()) return;
435
+ let signal: NodeJS.Signals | null = null;
436
+ if (proc.startTime !== null && (proc.pgid === undefined || proc.pgid === proc.pid)) {
437
+ if (surely()) {
438
+ signal = "SIGTERM";
439
+ killGroup(proc.pid, signal);
440
+ }
441
+ const end = Date.now() + orphanLimits.killGraceMs;
442
+ while (signal && Date.now() < end && worker()) await Bun.sleep(deps.pollMs);
443
+ if (signal && worker() && surely()) {
444
+ signal = "SIGKILL";
445
+ killGroup(proc.pid, signal);
446
+ }
447
+ }
448
+ writeJsonAtomic(dispatchPaths(d.dir).exit, {
449
+ schema: 1,
450
+ code: null,
451
+ signal,
452
+ reason: "cancelled",
453
+ endedAt: new Date().toISOString(),
454
+ });
455
+ }
456
+
457
+ /**
458
+ * Spec §4.7: stop a live dispatch (interrupt, SIGTERM, SIGKILL) and record it as cancelled. It collects the
459
+ * record it returns, so no later `wait` returns it again.
460
+ */
461
+ export async function cancel(deps: Deps, runId: string, name: string): Promise<DispatchResult> {
462
+ const run = findRun(runId);
463
+ assertId("role name", name);
464
+ const live = liveDispatches(run, deps.now()).find((d) => d.admit.name === name);
465
+ if (!live)
466
+ throw new CatherdError("E_RUN_NOT_LIVE", `${name} has no live dispatch`, {
467
+ fix: "status(run) lists the live ones",
468
+ });
469
+ requestCancel(live.dir);
470
+ await stopOrphan(deps, live);
471
+ await waitForFinish(live, { pollMs: deps.pollMs, tickMs: Number.POSITIVE_INFINITY, now: deps.now });
472
+ const record = await finalizeDispatch(run, live);
473
+ const mine = await tryCollect(live.dir);
474
+ // cancel returns at once, so its lease ends at once
475
+ if (mine) endCollect(live.dir);
476
+ const also = mine ? [] : [`${name}: a wait in flight also returned this record`];
477
+ const { hints } = await refreshState(run);
478
+ return { record, hints: [...hintsFor(run, live, record), ...also, ...hints] };
479
+ }
@@ -0,0 +1,145 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { ACCESS, type RunRecord } from "../domain/record.ts";
5
+ import { ROLES } from "../domain/roles.ts";
6
+ import { dispatchPaths, readExit, supervisorAlive } from "../infra/dispatch-dir.ts";
7
+ import { isAlive } from "../infra/proc.ts";
8
+ import { readVersioned, writeTextAtomic } from "../infra/store.ts";
9
+ import { readRecords, type Run, runPaths } from "./run-store.ts";
10
+
11
+ /** admit.json: what admission decided. Written last, so a dispatch exists once it does. */
12
+ const AdmitSchema = z.looseObject({
13
+ schema: z.literal(1),
14
+ runId: z.string(),
15
+ dispatchId: z.string(),
16
+ name: z.string(),
17
+ role: z.enum(ROLES),
18
+ lane: z.string().nullable(),
19
+ owns: z.array(z.string()),
20
+ rung: z.string(),
21
+ backend: z.string(),
22
+ thread: z.string().nullable(),
23
+ attempt: z.number().int().min(1),
24
+ failoverFrom: z.string().nullable(),
25
+ /** the limited dispatch a failover stand-in replaces (plan 9); absent on any other dispatch */
26
+ failoverOf: z.string().optional(),
27
+ access: z.enum(ACCESS),
28
+ isolated: z.boolean(),
29
+ cliVersion: z.string().nullable(),
30
+ admittedAt: z.string(),
31
+ repo: z.string(),
32
+ /** `git status` fingerprints when admitted, for changedOwned and violations */
33
+ before: z.record(z.string(), z.string()),
34
+ });
35
+ export type Admit = z.infer<typeof AdmitSchema>;
36
+
37
+ export interface Dispatch {
38
+ dir: string;
39
+ admit: Admit;
40
+ }
41
+ export type DispatchState = "starting" | "running" | "finished";
42
+
43
+ /** How long an admitted dispatch may go without a launch.json before it counts as never started. */
44
+ export const STARTING_GRACE_MS = 30_000;
45
+ /** The start grace in force; tests shorten it. */
46
+ export const startLimits = { graceMs: STARTING_GRACE_MS };
47
+
48
+ export const admitPath = (dir: string): string => join(dir, "admit.json");
49
+ export const launchPath = (dir: string): string => join(dir, "launch.json");
50
+ export const roleDir = (run: Run, name: string): string => join(runPaths(run.dir).roles, name);
51
+
52
+ /** Every admitted dispatch of the run, oldest first; a folder whose admit.json cannot be read is skipped. */
53
+ export function listDispatches(run: Run): Dispatch[] {
54
+ const roles = runPaths(run.dir).roles;
55
+ const out: Dispatch[] = [];
56
+ if (!existsSync(roles)) return out;
57
+ for (const name of readdirSync(roles, { withFileTypes: true })) {
58
+ if (!name.isDirectory()) continue;
59
+ for (const id of readdirSync(join(roles, name.name), { withFileTypes: true })) {
60
+ if (!id.isDirectory()) continue;
61
+ const dir = join(roles, name.name, id.name);
62
+ try {
63
+ out.push({ dir, admit: readVersioned(admitPath(dir), AdmitSchema, 1) });
64
+ } catch {
65
+ // admission never finished writing this folder
66
+ }
67
+ }
68
+ }
69
+ return out.sort((a, b) => a.admit.dispatchId.localeCompare(b.admit.dispatchId));
70
+ }
71
+
72
+ interface ProcFile {
73
+ pid: number;
74
+ /** the worker's process group; the detached worker leads it */
75
+ pgid?: number;
76
+ startTime: string | null;
77
+ supervisorPid: number;
78
+ supervisorStartTime: string | null;
79
+ startedAt: string;
80
+ }
81
+ interface LaunchFile {
82
+ supervisorPid: number;
83
+ supervisorStartTime: string | null;
84
+ }
85
+
86
+ function readJson<T>(file: string): T | null {
87
+ try {
88
+ return JSON.parse(readFileSync(file, "utf8")) as T;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ export const readProc = (dir: string): ProcFile | null => readJson<ProcFile>(dispatchPaths(dir).proc);
95
+
96
+ /**
97
+ * Spec §3.3: finished once exit.json exists, or once neither the supervisor nor its worker is alive,
98
+ * each identified by pid and start time, so a reused pid never keeps a dispatch alive (audit C3).
99
+ */
100
+ export function dispatchState(d: Dispatch, now = Date.now()): DispatchState {
101
+ if (readExit(d.dir)) return "finished";
102
+ // a live supervisor holds its dispatch's lock from its first moment, before launch.json or proc.json
103
+ if (!readProc(d.dir) && supervisorAlive(d.dir)) return "starting";
104
+ const proc = readProc(d.dir);
105
+ if (proc)
106
+ return isAlive(proc.supervisorPid, proc.supervisorStartTime) || isAlive(proc.pid, proc.startTime)
107
+ ? "running"
108
+ : "finished";
109
+ const launch = readJson<LaunchFile>(launchPath(d.dir));
110
+ if (launch) return isAlive(launch.supervisorPid, launch.supervisorStartTime) ? "starting" : "finished";
111
+ return now - Date.parse(d.admit.admittedAt) < startLimits.graceMs ? "starting" : "finished";
112
+ }
113
+
114
+ export type LiveDispatch = Dispatch & { state: DispatchState };
115
+
116
+ /** Dispatches that have no record yet, with their state; `live` keeps only the unfinished ones. */
117
+ export function pendingDispatches(
118
+ run: Run,
119
+ now = Date.now(),
120
+ records: RunRecord[] = readRecords(run).records,
121
+ ): LiveDispatch[] {
122
+ const recorded = new Set(records.map((r) => r.dispatchId));
123
+ return listDispatches(run)
124
+ .filter((d) => !recorded.has(d.admit.dispatchId))
125
+ .map((d) => ({ ...d, state: dispatchState(d, now) }));
126
+ }
127
+
128
+ export const liveDispatches = (run: Run, now = Date.now()): LiveDispatch[] =>
129
+ pendingDispatches(run, now).filter((d) => d.state !== "finished");
130
+
131
+ /** Spec §4.1: `roles/<name>/latest` names the newest dispatch of a role. */
132
+ export function setLatest(run: Run, name: string, dispatchId: string): void {
133
+ writeTextAtomic(join(roleDir(run, name), "latest"), dispatchId);
134
+ }
135
+
136
+ export function latestDispatch(run: Run, name: string): Dispatch | null {
137
+ const all = listDispatches(run).filter((d) => d.admit.name === name);
138
+ let id: string | null = null;
139
+ try {
140
+ id = readFileSync(join(roleDir(run, name), "latest"), "utf8").trim();
141
+ } catch {
142
+ // no pointer yet
143
+ }
144
+ return all.find((d) => d.admit.dispatchId === id) ?? all.at(-1) ?? null;
145
+ }