catherd-cli 0.2.1 → 1.1.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.
- package/README.md +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- 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 +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -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/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
3
|
+
import { assertId, parseRung } from "../domain/ids.ts";
|
|
4
|
+
import type { RunRecord } from "../domain/record.ts";
|
|
5
|
+
import type { Role } from "../domain/roles.ts";
|
|
6
|
+
import {
|
|
7
|
+
awaitsCollect,
|
|
8
|
+
dispatchPaths,
|
|
9
|
+
endCollect,
|
|
10
|
+
readExit,
|
|
11
|
+
requestCancel,
|
|
12
|
+
supervisorAlive,
|
|
13
|
+
tryCollect,
|
|
14
|
+
} from "../infra/dispatch-dir.ts";
|
|
15
|
+
import { lockHeld, withFileLock } from "../infra/filelock.ts";
|
|
16
|
+
import { log } from "../infra/log.ts";
|
|
17
|
+
import { isAlive, isSurelyAlive, killGroup } from "../infra/proc.ts";
|
|
18
|
+
import { writeJsonAtomic } from "../infra/store.ts";
|
|
19
|
+
import { admit, KILL_GRACE_MS, laneFile, launch } from "./admission.ts";
|
|
20
|
+
import { standInFor } from "./backends.ts";
|
|
21
|
+
import {
|
|
22
|
+
type Dispatch,
|
|
23
|
+
dispatchState,
|
|
24
|
+
type FailoverFile,
|
|
25
|
+
launchPath,
|
|
26
|
+
listDispatches,
|
|
27
|
+
liveDispatches,
|
|
28
|
+
pendingDispatches,
|
|
29
|
+
readFailover,
|
|
30
|
+
readProc,
|
|
31
|
+
recordHints,
|
|
32
|
+
} from "./dispatches.ts";
|
|
33
|
+
import { finalizeDispatch, finalizingElsewhere, waitForFinish } from "./finalize.ts";
|
|
34
|
+
import type { Deps } from "./ports.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";
|
|
38
|
+
import { type NotesPatch, refreshState } from "./state.ts";
|
|
39
|
+
|
|
40
|
+
export interface DispatchInput {
|
|
41
|
+
run: string;
|
|
42
|
+
role: Role;
|
|
43
|
+
name: string;
|
|
44
|
+
brief: string;
|
|
45
|
+
rung: string;
|
|
46
|
+
thread?: string;
|
|
47
|
+
lane?: string;
|
|
48
|
+
next?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One finished dispatch: its record and what to do next. */
|
|
52
|
+
export interface DispatchResult {
|
|
53
|
+
record: RunRecord;
|
|
54
|
+
hints: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A dispatch that has been admitted and launched. */
|
|
58
|
+
export interface Dispatched {
|
|
59
|
+
name: string;
|
|
60
|
+
role: Role;
|
|
61
|
+
rung: string;
|
|
62
|
+
dispatchId: string;
|
|
63
|
+
admittedAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DispatchStarted {
|
|
67
|
+
dispatched: Dispatched;
|
|
68
|
+
hints: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
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[];
|
|
84
|
+
}
|
|
85
|
+
|
|
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;
|
|
98
|
+
}
|
|
99
|
+
|
|
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>();
|
|
102
|
+
|
|
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
|
+
}
|
|
124
|
+
|
|
125
|
+
const dispatchedOf = (d: Dispatch): Dispatched => ({
|
|
126
|
+
name: d.admit.name,
|
|
127
|
+
role: d.admit.role,
|
|
128
|
+
rung: d.admit.rung,
|
|
129
|
+
dispatchId: d.admit.dispatchId,
|
|
130
|
+
admittedAt: d.admit.admittedAt,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** Refreshes state.md (spec §4.5 notes kept on a git failure); a refresh that fails adds its hint once. */
|
|
134
|
+
async function refresh(run: Run, change: NotesPatch, hints: string[]): Promise<void> {
|
|
135
|
+
for (const h of (await refreshState(run, change)).hints) if (!hints.includes(h)) hints.push(h);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** How a dispatch's supervisor is started; tests replace it to make a launch fail. */
|
|
139
|
+
export const launcher = { launch };
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Starts an admitted dispatch's supervisor. Admission already left its collect mark, and a launch that
|
|
143
|
+
* throws keeps it: the dispatch is recorded as lost once its start grace passes, and `result` reads that
|
|
144
|
+
* record, as the error says.
|
|
145
|
+
*/
|
|
146
|
+
function start(d: Dispatch, specPath: string): void {
|
|
147
|
+
try {
|
|
148
|
+
launcher.launch(d, specPath);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
throw new CatherdError("E_IO_UNEXPECTED", `could not launch ${d.admit.name}: ${errorMessage(e)}`, {
|
|
151
|
+
fix: `result(run, "${d.admit.name}") reads its record, lost, once its start grace passes (30 s); then dispatch it again`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The watchers this process started, each until its dispatch is settled. */
|
|
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>();
|
|
160
|
+
|
|
161
|
+
/** Settles once every watcher has: tests await it so none outlives its test. */
|
|
162
|
+
export async function watchersSettled(): Promise<void> {
|
|
163
|
+
while (watchers.size > 0) await Promise.all(watchers);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
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.
|
|
169
|
+
*/
|
|
170
|
+
export function watch(deps: Deps, run: Run, d: Dispatch): void {
|
|
171
|
+
watching.add(d.admit.dispatchId);
|
|
172
|
+
const w = (async () => {
|
|
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] });
|
|
176
|
+
})()
|
|
177
|
+
.catch((e: unknown) =>
|
|
178
|
+
log("warn", "dispatch", { run: run.id, name: d.admit.name, error: errorMessage(e) }),
|
|
179
|
+
)
|
|
180
|
+
.finally(() => {
|
|
181
|
+
watchers.delete(w);
|
|
182
|
+
watching.delete(d.admit.dispatchId);
|
|
183
|
+
});
|
|
184
|
+
watchers.add(w);
|
|
185
|
+
}
|
|
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
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Spec §4.4, plan 9 ruling 1: admit and launch one role, start its watcher, then refresh state.md with
|
|
281
|
+
* `next` (after the launch, so nothing delays it). Returns at once; catherd announces the record (spec §3).
|
|
282
|
+
*/
|
|
283
|
+
export async function dispatch(deps: Deps, i: DispatchInput): Promise<DispatchStarted> {
|
|
284
|
+
const run = findRun(i.run);
|
|
285
|
+
await claim(deps, run);
|
|
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
|
+
}
|
|
320
|
+
await refresh(run, i.next ? { next: i.next } : {}, hints);
|
|
321
|
+
return { dispatched: dispatchedOf(d), hints };
|
|
322
|
+
}
|
|
323
|
+
|
|
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 };
|
|
326
|
+
|
|
327
|
+
/** A failover's outcome before it is written down. */
|
|
328
|
+
interface Outcome {
|
|
329
|
+
hints: string[];
|
|
330
|
+
started: Dispatched | null;
|
|
331
|
+
pause: string | null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
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.
|
|
344
|
+
*/
|
|
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
|
+
}
|
|
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
|
+
}
|
|
391
|
+
|
|
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;
|
|
402
|
+
try {
|
|
403
|
+
o = await failover(deps, run, d, record);
|
|
404
|
+
} catch (e) {
|
|
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
|
+
};
|
|
410
|
+
}
|
|
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;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Whether a dispatch has concrete launch evidence: a live supervisor holding its lock (taken first thing),
|
|
424
|
+
* launch.json (written by `launch` right after the spawn), proc.json (written by the supervisor once its
|
|
425
|
+
* worker runs) or exit.json. Not the collect mark: admission writes the mark before any launch.
|
|
426
|
+
*/
|
|
427
|
+
function launched(d: Dispatch): boolean {
|
|
428
|
+
return (
|
|
429
|
+
supervisorAlive(d.dir) ||
|
|
430
|
+
existsSync(launchPath(d.dir)) ||
|
|
431
|
+
readProc(d.dir) !== null ||
|
|
432
|
+
readExit(d.dir) !== null
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const recordOf = (run: Run, d: Dispatch): boolean =>
|
|
437
|
+
readRecords(run).records.some((r) => r.dispatchId === d.admit.dispatchId);
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The stand-in's brief (spec §4.5, audit C4). A fresh round reruns its own brief; a fix round hands
|
|
441
|
+
* over the lane file and the fix brief by path, since the stand-in cannot resume the old thread.
|
|
442
|
+
*/
|
|
443
|
+
function standInBrief(run: Run, d: Dispatch): string {
|
|
444
|
+
const own = dispatchPaths(d.dir).brief;
|
|
445
|
+
if (d.admit.thread === null) return readFileSync(own, "utf8");
|
|
446
|
+
return [
|
|
447
|
+
`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:`,
|
|
448
|
+
...(d.admit.lane ? [`- the lane file: ${laneFile(run, d.admit.lane)}`] : []),
|
|
449
|
+
`- the fix brief the previous thread was given: ${own}`,
|
|
450
|
+
"The work so far is in the tree. Do what the fix brief asks.",
|
|
451
|
+
].join("\n");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Spec §4.5: on a limit, launch the rung's stand-in on a fresh thread, through admission again (budget
|
|
456
|
+
* included), without awaiting it. A stand-in that hits a limit too pauses the run. A stand-in already
|
|
457
|
+
* admitted for this record (by a settle that died before writing failover.json) is reused, never launched
|
|
458
|
+
* twice.
|
|
459
|
+
*/
|
|
460
|
+
async function failover(deps: Deps, run: Run, d: Dispatch, limited: RunRecord): Promise<Outcome> {
|
|
461
|
+
const hints = recordHints(run, d, limited);
|
|
462
|
+
if (d.admit.failoverFrom !== null)
|
|
463
|
+
return {
|
|
464
|
+
hints,
|
|
465
|
+
started: null,
|
|
466
|
+
pause: `paused: ${limited.backend} usage limit on ${limited.rung} and on ${d.admit.failoverFrom}`,
|
|
467
|
+
};
|
|
468
|
+
const paused = `paused: ${limited.backend} usage limit; resume when the user says so`;
|
|
469
|
+
const failedOver = (standIn: string, next: Dispatch): Outcome => ({
|
|
470
|
+
hints: [
|
|
471
|
+
`limit: ${limited.rung} hit a usage limit; failed over to ${standIn}`,
|
|
472
|
+
// the stand-in's before-snapshot already holds the limited run's writes: surface them here
|
|
473
|
+
...hints.filter((h) => /^(violation|git-unavailable):/.test(h)),
|
|
474
|
+
],
|
|
475
|
+
started: dispatchedOf(next),
|
|
476
|
+
pause: null,
|
|
477
|
+
});
|
|
478
|
+
// tied to this limited dispatch's id: a later dispatch of the same name, rung and limit is not its stand-in
|
|
479
|
+
const already = listDispatches(run)
|
|
480
|
+
.filter((x) => x.admit.failoverOf === d.admit.dispatchId)
|
|
481
|
+
.at(-1);
|
|
482
|
+
if (already && launched(already)) return failedOver(already.admit.rung, already);
|
|
483
|
+
if (already && !recordOf(run, already) && dispatchState(already, deps.now()) === "starting") {
|
|
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.
|
|
489
|
+
start(already, dispatchPaths(already.dir).spec);
|
|
490
|
+
watch(deps, run, already);
|
|
491
|
+
return failedOver(already.admit.rung, already);
|
|
492
|
+
}
|
|
493
|
+
// else that stand-in never ran and is over (recorded as lost, or past its start grace): admit a new one
|
|
494
|
+
const standIn = standInFor(deps.profiles.forRepo(run.meta.repo).failover, limited.rung, run.meta.repo);
|
|
495
|
+
if (!standIn) return { hints, started: null, pause: paused };
|
|
496
|
+
if (parseRung(standIn).backend === "claude") {
|
|
497
|
+
const agent = deps.profiles.agentFor(run.meta.repo, d.admit.role, standIn);
|
|
498
|
+
return {
|
|
499
|
+
hints: [
|
|
500
|
+
...hints,
|
|
501
|
+
`failover: run ${d.admit.name} as Agent(subagent_type: "${agent}"), standing in for ${limited.rung}`,
|
|
502
|
+
],
|
|
503
|
+
started: null,
|
|
504
|
+
pause: null,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
let next: Awaited<ReturnType<typeof admit>>;
|
|
508
|
+
try {
|
|
509
|
+
next = await admit(deps, run, {
|
|
510
|
+
role: d.admit.role,
|
|
511
|
+
name: d.admit.name,
|
|
512
|
+
brief: standInBrief(run, d),
|
|
513
|
+
rung: standIn,
|
|
514
|
+
thread: null,
|
|
515
|
+
lane: d.admit.lane,
|
|
516
|
+
failoverFrom: limited.rung,
|
|
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,
|
|
520
|
+
});
|
|
521
|
+
} catch (e) {
|
|
522
|
+
if (!isCatherdError(e)) throw e;
|
|
523
|
+
return {
|
|
524
|
+
hints: [...hints, `failover: ${standIn} refused: ${e.code} ${e.message}`],
|
|
525
|
+
started: null,
|
|
526
|
+
pause: paused,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
start(next.d, next.specPath);
|
|
530
|
+
watch(deps, run, next.d);
|
|
531
|
+
return failedOver(standIn, next.d);
|
|
532
|
+
}
|
|
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
|
+
|
|
547
|
+
/** How long an orphaned worker gets between SIGTERM and SIGKILL; tests shorten it. */
|
|
548
|
+
export const orphanLimits = { killGraceMs: KILL_GRACE_MS };
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* A worker whose supervisor died has no one to read the cancel file: stop its group here (SIGTERM, then
|
|
552
|
+
* SIGKILL after the grace), each signal guarded by the worker's pid and start time, and write the
|
|
553
|
+
* exit.json the supervisor would have, with the signal that ended it. A worker catherd cannot identify
|
|
554
|
+
* (no start time, or a proc.json whose pgid is not the worker's own pid) is never signalled: its pid may
|
|
555
|
+
* belong to someone else by now, so the dispatch is only recorded as cancelled. A finalizer that sees the
|
|
556
|
+
* worker gone first reads the cancel file instead (finalize.ts). Nothing happens while the supervisor
|
|
557
|
+
* lives: it acts on the cancel file itself.
|
|
558
|
+
*/
|
|
559
|
+
async function stopOrphan(deps: Deps, d: Dispatch): Promise<void> {
|
|
560
|
+
const proc = readProc(d.dir);
|
|
561
|
+
if (!proc || readExit(d.dir) || isAlive(proc.supervisorPid, proc.supervisorStartTime)) return;
|
|
562
|
+
const worker = () => isAlive(proc.pid, proc.startTime);
|
|
563
|
+
// a signal needs the start time read back exactly: a pid whose start time cannot be read now may be reused
|
|
564
|
+
const surely = () => isSurelyAlive(proc.pid, proc.startTime);
|
|
565
|
+
if (!worker()) return;
|
|
566
|
+
let signal: NodeJS.Signals | null = null;
|
|
567
|
+
if (proc.startTime !== null && (proc.pgid === undefined || proc.pgid === proc.pid)) {
|
|
568
|
+
if (surely()) {
|
|
569
|
+
signal = "SIGTERM";
|
|
570
|
+
killGroup(proc.pid, signal);
|
|
571
|
+
}
|
|
572
|
+
const end = Date.now() + orphanLimits.killGraceMs;
|
|
573
|
+
while (signal && Date.now() < end && worker()) await Bun.sleep(deps.pollMs);
|
|
574
|
+
if (signal && worker() && surely()) {
|
|
575
|
+
signal = "SIGKILL";
|
|
576
|
+
killGroup(proc.pid, signal);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
writeJsonAtomic(dispatchPaths(d.dir).exit, {
|
|
580
|
+
schema: 1,
|
|
581
|
+
code: null,
|
|
582
|
+
signal,
|
|
583
|
+
reason: "cancelled",
|
|
584
|
+
endedAt: new Date().toISOString(),
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
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).
|
|
593
|
+
*/
|
|
594
|
+
export async function cancel(
|
|
595
|
+
deps: Deps,
|
|
596
|
+
runId: string,
|
|
597
|
+
name: string,
|
|
598
|
+
o: { read?: boolean } = {},
|
|
599
|
+
): Promise<DispatchResult> {
|
|
600
|
+
const run = findRun(runId);
|
|
601
|
+
assertId("role name", name);
|
|
602
|
+
const live = liveDispatches(run, deps.now()).find((d) => d.admit.name === name);
|
|
603
|
+
if (!live)
|
|
604
|
+
throw new CatherdError("E_RUN_NOT_LIVE", `${name} has no live dispatch`, {
|
|
605
|
+
fix: "status(run) lists the live ones",
|
|
606
|
+
});
|
|
607
|
+
requestCancel(live.dir);
|
|
608
|
+
await stopOrphan(deps, live);
|
|
609
|
+
await waitForFinish(live, { pollMs: deps.pollMs, now: deps.now });
|
|
610
|
+
const record = await finalizeDispatch(run, live);
|
|
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
|
+
}
|
|
618
|
+
const { hints } = await refreshState(run);
|
|
619
|
+
return { record, hints: [...recordHints(run, live, record), ...also, ...hints] };
|
|
620
|
+
}
|