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.
- package/README.md +66 -42
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +112 -95
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +118 -0
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +95 -1
- package/src/adapters/codex/index.ts +78 -23
- package/src/adapters/opencode/index.ts +17 -0
- package/src/domain/errors.ts +2 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/lane.ts +23 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +43 -9
- package/src/domain/profile.ts +12 -6
- package/src/domain/record.ts +2 -0
- package/src/domain/role-prompts.ts +33 -1
- package/src/domain/state.ts +18 -2
- package/src/entry/deps.ts +2 -1
- package/src/entry/doctor-command.ts +7 -1
- package/src/entry/glyphs.ts +2 -1
- package/src/entry/init-command.ts +41 -1
- package/src/entry/mcp/command.ts +5 -1
- package/src/entry/mcp/dispatch-tools.ts +10 -40
- package/src/entry/mcp/handshake.ts +19 -9
- package/src/entry/mcp/lane-tools.ts +7 -6
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/run-tools.ts +4 -3
- package/src/entry/mcp/server.ts +17 -1
- package/src/entry/mcp/setup-tools.ts +1 -1
- package/src/entry/profile-command.ts +3 -5
- package/src/entry/runs-command.ts +64 -21
- package/src/entry/tui/commands.ts +1 -1
- package/src/entry/tui/effects.ts +98 -56
- package/src/entry/tui/fixtures.ts +352 -85
- package/src/entry/tui/profile-edits.ts +2 -2
- package/src/entry/tui/profile-tree.ts +2 -1
- package/src/entry/tui/providers/data.tsx +35 -8
- package/src/entry/tui/run.tsx +11 -1
- package/src/entry/tui/state.ts +26 -6
- package/src/entry/tui/stories.ts +36 -4
- package/src/entry/tui/text.ts +12 -0
- package/src/entry/tui/theme.ts +1 -0
- package/src/entry/tui/views/runs.tsx +409 -117
- package/src/entry/tui/views/status.tsx +5 -2
- package/src/entry/tui/widgets/list.tsx +4 -3
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +14 -6
- package/src/infra/env.ts +26 -2
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/supervisor.ts +33 -3
- package/src/services/admission.ts +38 -12
- package/src/services/dispatch-service.ts +338 -197
- package/src/services/dispatches.ts +30 -1
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +3 -18
- package/src/services/doctor-checks.ts +46 -1
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +59 -79
- package/src/services/finalize.ts +43 -25
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/lane-service.ts +150 -8
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +7 -4
- package/src/services/preflight.ts +17 -3
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +40 -11
- package/src/services/reentry.ts +22 -0
- package/src/services/run-service.ts +42 -9
- package/src/services/run-store.ts +18 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/state.ts +10 -3
- 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,
|
|
33
|
+
import { finalizeDispatch, finalizingElsewhere, waitForFinish } from "./finalize.ts";
|
|
32
34
|
import type { Deps } from "./ports.ts";
|
|
33
|
-
import {
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
131
|
-
* after-snapshot holds only its own writes and the live list and the spend stay true.
|
|
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,
|
|
137
|
-
await finalizeDispatch(run, d);
|
|
138
|
-
|
|
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(() =>
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
const
|
|
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
|
-
/**
|
|
180
|
-
|
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* limit
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
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
|
-
*
|
|
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<
|
|
344
|
-
const hints =
|
|
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
|
-
|
|
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):
|
|
354
|
-
|
|
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
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
459
|
-
* record it returns, so
|
|
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(
|
|
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,
|
|
609
|
+
await waitForFinish(live, { pollMs: deps.pollMs, now: deps.now });
|
|
472
610
|
const record = await finalizeDispatch(run, live);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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: [...
|
|
619
|
+
return { record, hints: [...recordHints(run, live, record), ...also, ...hints] };
|
|
479
620
|
}
|