catherd-cli 1.0.0 → 1.1.1

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 (83) hide show
  1. package/README.md +66 -42
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.mcp.json +2 -2
  5. package/plugin/bin/catherd-mcp +30 -0
  6. package/plugin/commands/catherd.md +1 -1
  7. package/plugin/skills/catherd/SKILL.md +112 -95
  8. package/plugin/skills/catherd-setup/SKILL.md +3 -3
  9. package/src/adapters/access.ts +118 -0
  10. package/src/adapters/backend.ts +19 -4
  11. package/src/adapters/claude-code/index.ts +95 -1
  12. package/src/adapters/codex/index.ts +78 -23
  13. package/src/adapters/opencode/index.ts +17 -0
  14. package/src/domain/errors.ts +2 -0
  15. package/src/domain/failover.ts +119 -0
  16. package/src/domain/lane.ts +23 -0
  17. package/src/domain/notice.ts +95 -0
  18. package/src/domain/profile-rules.ts +43 -9
  19. package/src/domain/profile.ts +12 -6
  20. package/src/domain/record.ts +2 -0
  21. package/src/domain/role-prompts.ts +33 -1
  22. package/src/domain/state.ts +18 -2
  23. package/src/entry/deps.ts +2 -1
  24. package/src/entry/doctor-command.ts +7 -1
  25. package/src/entry/glyphs.ts +2 -1
  26. package/src/entry/init-command.ts +41 -1
  27. package/src/entry/mcp/command.ts +5 -1
  28. package/src/entry/mcp/dispatch-tools.ts +10 -40
  29. package/src/entry/mcp/handshake.ts +19 -9
  30. package/src/entry/mcp/lane-tools.ts +7 -6
  31. package/src/entry/mcp/protocol-tools.ts +57 -0
  32. package/src/entry/mcp/run-tools.ts +4 -3
  33. package/src/entry/mcp/server.ts +17 -1
  34. package/src/entry/mcp/setup-tools.ts +1 -1
  35. package/src/entry/profile-command.ts +3 -5
  36. package/src/entry/runs-command.ts +64 -21
  37. package/src/entry/tui/commands.ts +1 -1
  38. package/src/entry/tui/effects.ts +98 -56
  39. package/src/entry/tui/fixtures.ts +352 -85
  40. package/src/entry/tui/profile-edits.ts +2 -2
  41. package/src/entry/tui/profile-tree.ts +2 -1
  42. package/src/entry/tui/providers/data.tsx +35 -8
  43. package/src/entry/tui/run.tsx +11 -1
  44. package/src/entry/tui/state.ts +26 -6
  45. package/src/entry/tui/stories.ts +36 -4
  46. package/src/entry/tui/text.ts +12 -0
  47. package/src/entry/tui/theme.ts +1 -0
  48. package/src/entry/tui/views/runs.tsx +409 -117
  49. package/src/entry/tui/views/status.tsx +5 -2
  50. package/src/entry/tui/widgets/list.tsx +4 -3
  51. package/src/infra/claude-session.ts +98 -0
  52. package/src/infra/dispatch-dir.ts +14 -6
  53. package/src/infra/env.ts +26 -2
  54. package/src/infra/peer-inbox.ts +92 -0
  55. package/src/infra/supervisor.ts +33 -3
  56. package/src/services/admission.ts +38 -12
  57. package/src/services/dispatch-service.ts +338 -197
  58. package/src/services/dispatches.ts +30 -1
  59. package/src/services/doctor-access.ts +231 -0
  60. package/src/services/doctor-backends.ts +3 -18
  61. package/src/services/doctor-checks.ts +46 -1
  62. package/src/services/doctor-push.ts +109 -0
  63. package/src/services/doctor.ts +59 -79
  64. package/src/services/finalize.ts +43 -25
  65. package/src/services/gate-service.ts +288 -0
  66. package/src/services/global-install.ts +99 -0
  67. package/src/services/lane-service.ts +150 -8
  68. package/src/services/milestones.ts +208 -0
  69. package/src/services/notifier.ts +296 -0
  70. package/src/services/peek.ts +103 -0
  71. package/src/services/ports.ts +7 -4
  72. package/src/services/preflight.ts +17 -3
  73. package/src/services/protocol.ts +212 -0
  74. package/src/services/questions.ts +102 -0
  75. package/src/services/reconcile.ts +40 -11
  76. package/src/services/reentry.ts +22 -0
  77. package/src/services/run-service.ts +42 -9
  78. package/src/services/run-store.ts +18 -0
  79. package/src/services/runs-page.ts +254 -0
  80. package/src/services/session-view.ts +152 -0
  81. package/src/services/sessions.ts +92 -0
  82. package/src/services/state.ts +10 -3
  83. package/src/services/summary.ts +14 -0
@@ -1,20 +1,18 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { relative } from "node:path";
3
2
  import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
4
3
  import { assertId, parseRung } from "../domain/ids.ts";
5
- import { dispatchHints } from "../domain/hints.ts";
6
4
  import type { RunRecord } from "../domain/record.ts";
7
5
  import type { Role } from "../domain/roles.ts";
8
6
  import {
9
7
  awaitsCollect,
10
8
  dispatchPaths,
9
+ endCollect,
11
10
  readExit,
12
- supervisorAlive,
13
11
  requestCancel,
12
+ supervisorAlive,
14
13
  tryCollect,
15
- endCollect,
16
- putBackCollect,
17
14
  } from "../infra/dispatch-dir.ts";
15
+ import { lockHeld, withFileLock } from "../infra/filelock.ts";
18
16
  import { log } from "../infra/log.ts";
19
17
  import { isAlive, isSurelyAlive, killGroup } from "../infra/proc.ts";
20
18
  import { writeJsonAtomic } from "../infra/store.ts";
@@ -23,14 +21,20 @@ import { standInFor } from "./backends.ts";
23
21
  import {
24
22
  type Dispatch,
25
23
  dispatchState,
24
+ type FailoverFile,
26
25
  launchPath,
27
26
  listDispatches,
28
27
  liveDispatches,
28
+ pendingDispatches,
29
+ readFailover,
29
30
  readProc,
31
+ recordHints,
30
32
  } from "./dispatches.ts";
31
- import { finalizeDispatch, lastEvent, waitForFinish } from "./finalize.ts";
33
+ import { finalizeDispatch, finalizingElsewhere, waitForFinish } from "./finalize.ts";
32
34
  import type { Deps } from "./ports.ts";
33
- import { findRun, readRecords, type Run } from "./run-store.ts";
35
+ import { route } from "./lane-service.ts";
36
+ import { findRun, readRecords, readRoutes, type Run } from "./run-store.ts";
37
+ import { claimRun, ownsRun } from "./sessions.ts";
34
38
  import { type NotesPatch, refreshState } from "./state.ts";
35
39
 
36
40
  export interface DispatchInput {
@@ -64,28 +68,59 @@ export interface DispatchStarted {
64
68
  hints: string[];
65
69
  }
66
70
 
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;
71
+ /**
72
+ * A finalized dispatch once catherd has acted on it (spec §3.4): its record, its hints, and for a usage limit
73
+ * what failover did: the stand-in it launched, or the pause it wrote.
74
+ */
75
+ export interface Settled {
76
+ run: Run;
77
+ d: Dispatch;
78
+ record: RunRecord;
79
+ hints: string[];
80
+ started: Dispatched | null;
81
+ pause: string | null;
82
+ /** state.md's refresh failed: its hint (the notes are kept in state.json) */
83
+ stateHints: string[];
73
84
  }
74
85
 
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[];
86
+ /**
87
+ * Called with every dispatch this process settles, once each (the notifier, services/notifier.ts, is one). A
88
+ * hook that throws is logged and never stops the others.
89
+ */
90
+ export const settledHooks = new Set<(s: Settled) => void | Promise<void>>();
91
+
92
+ /** A live dispatch whose supervisor reported a stall (spec §3.6: quiet for half its idle timeout, not busy). */
93
+ export interface Stalled {
94
+ run: Run;
95
+ d: Dispatch;
96
+ /** how long it had been quiet when the supervisor noticed */
97
+ quietMs: number;
83
98
  }
84
99
 
85
- export type Progress = (message: string) => void;
100
+ /** Called once per watcher when its dispatch's stall.json appears (the notifier is one). */
101
+ export const stallHooks = new Set<(s: Stalled) => void>();
86
102
 
87
- const hintsFor = (run: Run, d: Dispatch, r: RunRecord) =>
88
- dispatchHints(r, d.admit.owns, relative(run.dir, d.dir));
103
+ /** A watcher's poll: the first time the dispatch's stall.json is there, every stall hook hears of it. */
104
+ export function stallPoll(run: Run, d: Dispatch): () => void {
105
+ let seen = false;
106
+ return () => {
107
+ if (seen || !existsSync(dispatchPaths(d.dir).stall)) return;
108
+ seen = true;
109
+ let quietMs = 0;
110
+ try {
111
+ quietMs = Number(JSON.parse(readFileSync(dispatchPaths(d.dir).stall, "utf8")).quietMs) || 0;
112
+ } catch {
113
+ // being written: the stall is reported without its length
114
+ }
115
+ for (const hook of stallHooks) {
116
+ try {
117
+ hook({ run, d, quietMs });
118
+ } catch (e) {
119
+ log("warn", "stall", { run: run.id, name: d.admit.name, error: errorMessage(e) });
120
+ }
121
+ }
122
+ };
123
+ }
89
124
 
90
125
  const dispatchedOf = (d: Dispatch): Dispatched => ({
91
126
  name: d.admit.name,
@@ -105,7 +140,7 @@ export const launcher = { launch };
105
140
 
106
141
  /**
107
142
  * 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
143
+ * throws keeps it: the dispatch is recorded as lost once its start grace passes, and `result` reads that
109
144
  * record, as the error says.
110
145
  */
111
146
  function start(d: Dispatch, specPath: string): void {
@@ -113,13 +148,15 @@ function start(d: Dispatch, specPath: string): void {
113
148
  launcher.launch(d, specPath);
114
149
  } catch (e) {
115
150
  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",
151
+ fix: `result(run, "${d.admit.name}") reads its record, lost, once its start grace passes (30 s); then dispatch it again`,
117
152
  });
118
153
  }
119
154
  }
120
155
 
121
- /** The watchers `dispatch` started, each until its dispatch is finalized. */
156
+ /** The watchers this process started, each until its dispatch is settled. */
122
157
  const watchers = new Set<Promise<void>>();
158
+ /** The dispatches some watcher of this process is on (this module's, or reconcile's): watched once each. */
159
+ export const watching = new Set<string>();
123
160
 
124
161
  /** Settles once every watcher has: tests await it so none outlives its test. */
125
162
  export async function watchersSettled(): Promise<void> {
@@ -127,186 +164,265 @@ export async function watchersSettled(): Promise<void> {
127
164
  }
128
165
 
129
166
  /**
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.
167
+ * Finalizes a dispatch as soon as it exits and settles it (failover, state.md, the settled hooks), so its
168
+ * after-snapshot holds only its own writes and the live list and the spend stay true. Its errors are logged.
133
169
  */
134
- function watch(deps: Deps, run: Run, d: Dispatch): void {
170
+ export function watch(deps: Deps, run: Run, d: Dispatch): void {
171
+ watching.add(d.admit.dispatchId);
135
172
  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] });
173
+ await waitForFinish(d, { pollMs: deps.pollMs, now: deps.now, onPoll: stallPoll(run, d) });
174
+ const s = await settle(deps, run, d, await finalizeDispatch(run, d));
175
+ if (s.stateHints[0]) log("warn", "dispatch", { run: run.id, name: d.admit.name, hint: s.stateHints[0] });
140
176
  })()
141
177
  .catch((e: unknown) =>
142
178
  log("warn", "dispatch", { run: run.id, name: d.admit.name, error: errorMessage(e) }),
143
179
  )
144
- .finally(() => watchers.delete(w));
180
+ .finally(() => {
181
+ watchers.delete(w);
182
+ watching.delete(d.admit.dispatchId);
183
+ });
145
184
  watchers.add(w);
146
185
  }
147
186
 
187
+ /**
188
+ * Spec §3.3: a run this session has just taken over may have roles another session's server launched. This
189
+ * process watches each live one it is not already watching, so it settles them and its notifier announces them
190
+ * to their new owner (a second settle of a dispatch is harmless: finalize and failover run once).
191
+ */
192
+ export function adopt(deps: Deps, run: Run): void {
193
+ for (const d of liveDispatches(run, deps.now())) if (!watching.has(d.admit.dispatchId)) watch(deps, run, d);
194
+ }
195
+
196
+ /**
197
+ * Spec §3.3 with the plan 10 fix-round ruling: `dispatch` and `peek` on a run make this session its owner. The
198
+ * owner, new or not, then takes care of what is left on the run: it watches the live roles no watcher here is
199
+ * on (`adopt`), records and settles each role that finished with no record (the earlier owner's server died, or
200
+ * a watcher here failed to finalize it), settles each unread usage limit that was never failed over, and
201
+ * settles again each unread record no message announced (the earlier owner's server settled it but never told
202
+ * its session). Each settle runs the hooks, so the notifier tells the owner. Nothing left, nothing done. A
203
+ * settle that fails is logged and never fails the call.
204
+ *
205
+ * Codex r4 ruling: the recovery never waits on another live process. A role whose finalizer claim, or a limit
206
+ * whose failover lock, another live process holds is left to that process (it writes the record, fails over
207
+ * and runs its own hooks). With `background` (peek, spec §3.7 "never waits") the recovery runs after the call
208
+ * returns, its errors logged; `dispatch` awaits it, bounded by those skips. One recovery per run at a time.
209
+ */
210
+ export async function claim(deps: Deps, run: Run, o: { background?: boolean } = {}): Promise<void> {
211
+ if (!(await claimRun(deps, run)) && !ownsRun(deps, run)) return;
212
+ adopt(deps, run);
213
+ let r = recovering.get(run.id);
214
+ if (!r) {
215
+ const p: Promise<void> = recover(deps, run)
216
+ .catch((e: unknown) => log("warn", "claim", { run: run.id, error: errorMessage(e) }))
217
+ .finally(() => {
218
+ recovering.delete(run.id);
219
+ watchers.delete(p);
220
+ });
221
+ recovering.set(run.id, p);
222
+ watchers.add(p);
223
+ r = p;
224
+ }
225
+ if (!o.background) await r;
226
+ }
227
+
228
+ /** The recovery pass `claim` has in flight, by run id. */
229
+ const recovering = new Map<string, Promise<void>>();
230
+
231
+ /** A limit's failover lock another live process holds: its settle fails it over and announces it. */
232
+ const failingOverElsewhere = (d: Dispatch): boolean => lockHeld(dispatchPaths(d.dir).failover);
233
+
234
+ async function recover(deps: Deps, run: Run): Promise<void> {
235
+ const warn = (d: Dispatch, e: unknown) =>
236
+ log("warn", "claim", { run: run.id, name: d.admit.name, error: errorMessage(e) });
237
+ for (const d of pendingDispatches(run, deps.now())) {
238
+ if (d.state !== "finished" || watching.has(d.admit.dispatchId)) continue;
239
+ if (finalizingElsewhere(d.dir)) continue;
240
+ try {
241
+ await settle(deps, run, d, await finalizeDispatch(run, d));
242
+ } catch (e) {
243
+ warn(d, e);
244
+ }
245
+ }
246
+ const limits = unsettledLimits(run);
247
+ for (const { d, record } of limits) {
248
+ // a watcher here settles it (a peek never waits on its failover lock)
249
+ if (watching.has(d.admit.dispatchId) || failingOverElsewhere(d)) continue;
250
+ try {
251
+ await settle(deps, run, d, record);
252
+ } catch (e) {
253
+ warn(d, e);
254
+ }
255
+ }
256
+ const done = new Set(limits.map((l) => l.d.admit.dispatchId));
257
+ for (const { d, record } of unannounced(run)) {
258
+ if (done.has(d.admit.dispatchId) || watching.has(d.admit.dispatchId)) continue;
259
+ if (record.status === "limit" && failingOverElsewhere(d)) continue;
260
+ try {
261
+ await settle(deps, run, d, record);
262
+ } catch (e) {
263
+ warn(d, e);
264
+ }
265
+ }
266
+ }
267
+
268
+ /** The recorded dispatches of a run still unread that no message announced (no notified.json). */
269
+ function unannounced(run: Run): { d: Dispatch; record: RunRecord }[] {
270
+ const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
271
+ return listDispatches(run).flatMap((d) => {
272
+ const record = records.get(d.admit.dispatchId);
273
+ return record && awaitsCollect(d.dir) && !existsSync(dispatchPaths(d.dir).notified)
274
+ ? [{ d, record }]
275
+ : [];
276
+ });
277
+ }
278
+
148
279
  /**
149
280
  * 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.
281
+ * `next` (after the launch, so nothing delays it). Returns at once; catherd announces the record (spec §3).
151
282
  */
152
283
  export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchStarted> {
153
284
  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);
285
+ await claim(deps, run);
165
286
  const hints: string[] = [];
287
+ let rung = i.rung;
288
+ // spec 1.1 §6: a lane is routed before its first dispatch; a rung off the routed ladder starts at the routed one
289
+ if (i.lane !== undefined && !readRoutes(run).some((r) => r.lane === i.lane)) {
290
+ assertId("lane", i.lane);
291
+ const routed = await route(deps, { run: i.run, laneFile: `lanes/${i.lane}.md`, role: i.role });
292
+ if (!routed.ladder.includes(i.rung)) {
293
+ rung = routed.rung;
294
+ hints.push(`${i.rung} is not on ${i.lane}'s routed ladder: dispatched at ${routed.rung}`);
295
+ }
296
+ }
297
+ const { d, specPath } = await admit(
298
+ deps,
299
+ run,
300
+ {
301
+ role: i.role,
302
+ name: i.name,
303
+ brief: i.brief,
304
+ rung,
305
+ thread: i.thread ?? null,
306
+ lane: i.lane ?? null,
307
+ failoverFrom: null,
308
+ },
309
+ // a finished role no watcher here covers, recorded by admit: settled as a watcher would, so its owner hears
310
+ async (f, record) => {
311
+ if (!watching.has(f.admit.dispatchId)) await settle(deps, run, f, record);
312
+ },
313
+ );
314
+ try {
315
+ start(d, specPath);
316
+ } finally {
317
+ // a launch that failed is still watched: its record (lost, after the start grace) is announced
318
+ watch(deps, run, d);
319
+ }
166
320
  await refresh(run, i.next ? { next: i.next } : {}, hints);
167
321
  return { dispatched: dispatchedOf(d), hints };
168
322
  }
169
323
 
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));
324
+ /** How long a settle waits for another process's failover of the same dispatch (a test seam). */
325
+ export const failoverLock = { timeoutMs: 120_000 };
178
326
 
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;
327
+ /** A failover's outcome before it is written down. */
328
+ interface Outcome {
329
+ hints: string[];
184
330
  started: Dispatched | null;
185
331
  pause: string | null;
186
332
  }
187
333
 
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
334
  /**
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.
335
+ * Spec §3.4 (plan 10): what catherd does once a dispatch is finalized, whoever finalized it (its watcher,
336
+ * reconcile after a restart): a usage limit fails over (once per limited dispatch, under a lock, its outcome
337
+ * written to failover.json so a second settle or a restarted server reuses it), state.md is refreshed with the
338
+ * pause a limit calls for (a refresh that fails comes back in `stateHints`), and every settled hook runs. Never
339
+ * throws for a hook.
340
+ *
341
+ * Codex r4 ruling: a settle that times out waiting for the failover lock (another live process holds it) is
342
+ * not a failed failover. It writes nothing and runs no hook: the holder's settle writes failover.json, refreshes
343
+ * state.md and announces the outcome. It returns the hints and pause failover.json holds by then, else none.
211
344
  */
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] };
345
+ export async function settle(deps: Deps, run: Run, d: Dispatch, record: RunRecord): Promise<Settled> {
346
+ let o: Outcome = { hints: recordHints(run, d, record), started: null, pause: null };
347
+ if (record.status === "limit") {
348
+ let entered = false;
349
+ try {
350
+ o = await withFileLock(
351
+ dispatchPaths(d.dir).failover,
352
+ () => {
353
+ entered = true;
354
+ return failoverOnce(deps, run, d, record);
355
+ },
356
+ { timeoutMs: failoverLock.timeoutMs },
357
+ );
358
+ } catch (e) {
359
+ if (!entered && isCatherdError(e) && e.code === "E_IO_LOCK") {
360
+ log("info", "settle", { run: run.id, name: d.admit.name, failover: "left to the lock's holder" });
361
+ const done = readFailover(d.dir);
362
+ return {
363
+ run,
364
+ d,
365
+ record,
366
+ hints: done?.hints ?? [...o.hints, "failover: another catherd process is failing it over"],
367
+ started: null,
368
+ pause: done?.pause ?? null,
369
+ stateHints: [],
370
+ };
371
+ }
372
+ o = {
373
+ hints: [...o.hints, `failover: ${errorMessage(e)}`],
374
+ started: null,
375
+ pause: `paused: ${record.backend} usage limit; resume when the user says so`,
376
+ };
377
+ }
227
378
  }
379
+ const stateHints: string[] = [];
380
+ await refresh(run, o.pause ? { next: o.pause } : {}, stateHints);
381
+ const s: Settled = { run, d, record, hints: o.hints, started: o.started, pause: o.pause, stateHints };
382
+ for (const hook of settledHooks) {
383
+ try {
384
+ await hook(s);
385
+ } catch (e) {
386
+ log("warn", "settle", { run: run.id, name: d.admit.name, error: errorMessage(e) });
387
+ }
388
+ }
389
+ return s;
390
+ }
228
391
 
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
- };
392
+ /** Under the failover lock: the outcome already written, else a failover run now and written down. */
393
+ async function failoverOnce(deps: Deps, run: Run, d: Dispatch, record: RunRecord): Promise<Outcome> {
394
+ const done = readFailover(d.dir);
395
+ if (done) {
396
+ const next = done.standIn
397
+ ? listDispatches(run).find((x) => x.admit.dispatchId === done.standIn?.dispatchId)
398
+ : undefined;
399
+ return { hints: done.hints, started: next ? dispatchedOf(next) : null, pause: done.pause };
400
+ }
401
+ let o: Outcome;
238
402
  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);
403
+ o = await failover(deps, run, d, record);
289
404
  } 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] };
405
+ o = {
406
+ hints: [...recordHints(run, d, record), `failover: ${errorMessage(e)}`],
407
+ started: null,
408
+ pause: `paused: ${record.backend} usage limit; resume when the user says so`,
409
+ };
297
410
  }
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 };
411
+ const file: FailoverFile = {
412
+ schema: 1,
413
+ at: new Date(deps.now()).toISOString(),
414
+ standIn: o.started ? { dispatchId: o.started.dispatchId, rung: o.started.rung } : null,
415
+ hints: o.hints,
416
+ pause: o.pause,
417
+ };
418
+ writeJsonAtomic(dispatchPaths(d.dir).failover, file);
419
+ return o;
304
420
  }
305
421
 
306
422
  /**
307
423
  * Whether a dispatch has concrete launch evidence: a live supervisor holding its lock (taken first thing),
308
424
  * 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.
425
+ * worker runs) or exit.json. Not the collect mark: admission writes the mark before any launch.
310
426
  */
311
427
  function launched(d: Dispatch): boolean {
312
428
  return (
@@ -338,24 +454,24 @@ function standInBrief(run: Run, d: Dispatch): string {
338
454
  /**
339
455
  * Spec §4.5: on a limit, launch the rung's stand-in on a fresh thread, through admission again (budget
340
456
  * 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.
457
+ * admitted for this record (by a settle that died before writing failover.json) is reused, never launched
458
+ * twice.
342
459
  */
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 });
460
+ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord): Promise<Outcome> {
461
+ const hints = recordHints(run, d, limited);
346
462
  if (d.admit.failoverFrom !== null)
347
463
  return {
348
- result: result(hints),
464
+ hints,
349
465
  started: null,
350
466
  pause: `paused: ${limited.backend} usage limit on ${limited.rung} and on ${d.admit.failoverFrom}`,
351
467
  };
352
468
  const paused = `paused: ${limited.backend} usage limit; resume when the user says so`;
353
- const failedOver = (standIn: string, next: Dispatch): Collected => ({
354
- result: result([
469
+ const failedOver = (standIn: string, next: Dispatch): Outcome => ({
470
+ hints: [
355
471
  `limit: ${limited.rung} hit a usage limit; failed over to ${standIn}`,
356
472
  // the stand-in's before-snapshot already holds the limited run's writes: surface them here
357
473
  ...hints.filter((h) => /^(violation|git-unavailable):/.test(h)),
358
- ]),
474
+ ],
359
475
  started: dispatchedOf(next),
360
476
  pause: null,
361
477
  });
@@ -365,25 +481,25 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
365
481
  .at(-1);
366
482
  if (already && launched(already)) return failedOver(already.admit.rung, already);
367
483
  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.
484
+ // Admitted by a settle that died before launching it: launch it now, never report it unlaunched. Only
485
+ // the holder of the failover lock launches a stand-in, and we hold it now, so that settle is dead. If
486
+ // it died after spawning a supervisor that has not taken the dispatch's lock yet, the second supervisor
487
+ // started here and that one race for the lock: exactly one runs the worker, the other exits touching
488
+ // nothing (supervisor.ts), so no worker ever runs twice.
373
489
  start(already, dispatchPaths(already.dir).spec);
374
490
  watch(deps, run, already);
375
491
  return failedOver(already.admit.rung, already);
376
492
  }
377
493
  // else that stand-in never ran and is over (recorded as lost, or past its start grace): admit a new one
378
494
  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 };
495
+ if (!standIn) return { hints, started: null, pause: paused };
380
496
  if (parseRung(standIn).backend === "claude") {
381
497
  const agent = deps.profiles.agentFor(run.meta.repo, d.admit.role, standIn);
382
498
  return {
383
- result: result([
499
+ hints: [
384
500
  ...hints,
385
501
  `failover: run ${d.admit.name} as Agent(subagent_type: "${agent}"), standing in for ${limited.rung}`,
386
- ]),
502
+ ],
387
503
  started: null,
388
504
  pause: null,
389
505
  };
@@ -399,11 +515,13 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
399
515
  lane: d.admit.lane,
400
516
  failoverFrom: limited.rung,
401
517
  failoverOf: d.admit.dispatchId,
518
+ // the stand-in answers to the session that dispatched the limited role, whoever fails it over
519
+ sessionId: d.admit.sessionId ?? null,
402
520
  });
403
521
  } catch (e) {
404
522
  if (!isCatherdError(e)) throw e;
405
523
  return {
406
- result: result([...hints, `failover: ${standIn} refused: ${e.code} ${e.message}`]),
524
+ hints: [...hints, `failover: ${standIn} refused: ${e.code} ${e.message}`],
407
525
  started: null,
408
526
  pause: paused,
409
527
  };
@@ -413,6 +531,19 @@ async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord):
413
531
  return failedOver(standIn, next.d);
414
532
  }
415
533
 
534
+ /**
535
+ * The finished dispatches of a run that still await catherd's action after a restart: a usage limit recorded,
536
+ * not yet failed over (no failover.json) and not yet read. Reconcile settles them for a run this session owns;
537
+ * a claim of the run (`claim`) settles them for the session that takes it over.
538
+ */
539
+ export const unsettledLimits = (run: Run): { d: Dispatch; record: RunRecord }[] => {
540
+ const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
541
+ return listDispatches(run).flatMap((d) => {
542
+ const record = records.get(d.admit.dispatchId);
543
+ return record?.status === "limit" && !readFailover(d.dir) && awaitsCollect(d.dir) ? [{ d, record }] : [];
544
+ });
545
+ };
546
+
416
547
  /** How long an orphaned worker gets between SIGTERM and SIGKILL; tests shorten it. */
417
548
  export const orphanLimits = { killGraceMs: KILL_GRACE_MS };
418
549
 
@@ -455,10 +586,17 @@ async function stopOrphan(deps: Deps, d: Dispatch): Promise<void> {
455
586
  }
456
587
 
457
588
  /**
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.
589
+ * Spec §4.7: stop a live dispatch (interrupt, SIGTERM, SIGKILL) and record it as cancelled. With `read` (the MCP
590
+ * `cancel` tool, whose caller holds the record it returns) the record is marked read, so `peek` no longer lists
591
+ * it. Without it (the dashboard, `catherd runs cancel`) the record stays unread and catherd announces it to the
592
+ * run's owner at `later` (spec §3.6).
460
593
  */
461
- export async function cancel(deps: Deps, runId: string, name: string): Promise<DispatchResult> {
594
+ export async function cancel(
595
+ deps: Deps,
596
+ runId: string,
597
+ name: string,
598
+ o: { read?: boolean } = {},
599
+ ): Promise<DispatchResult> {
462
600
  const run = findRun(runId);
463
601
  assertId("role name", name);
464
602
  const live = liveDispatches(run, deps.now()).find((d) => d.admit.name === name);
@@ -468,12 +606,15 @@ export async function cancel(deps: Deps, runId: string, name: string): Promise<D
468
606
  });
469
607
  requestCancel(live.dir);
470
608
  await stopOrphan(deps, live);
471
- await waitForFinish(live, { pollMs: deps.pollMs, tickMs: Number.POSITIVE_INFINITY, now: deps.now });
609
+ await waitForFinish(live, { pollMs: deps.pollMs, now: deps.now });
472
610
  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`];
611
+ let also: string[] = [];
612
+ if (o.read) {
613
+ const mine = await tryCollect(live.dir);
614
+ // cancel returns at once, so its lease ends at once
615
+ if (mine) endCollect(live.dir);
616
+ else also = [`${name}: result had already read this record`];
617
+ }
477
618
  const { hints } = await refreshState(run);
478
- return { record, hints: [...hintsFor(run, live, record), ...also, ...hints] };
619
+ return { record, hints: [...recordHints(run, live, record), ...also, ...hints] };
479
620
  }