catherd-cli 1.3.0 → 1.5.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 +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { KnownHost } from "../domain/host.ts";
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import type { BackendAdapter } from "../adapters/backend.ts";
|
|
4
5
|
import { budgetStatus, formatBudget } from "../domain/budget.ts";
|
|
5
6
|
import { CatherdError, errorMessage } from "../domain/errors.ts";
|
|
6
7
|
import { assertId, formatRung, newDispatchId, parseRung } from "../domain/ids.ts";
|
|
7
8
|
import { assertLaneHeader, overlaps } from "../domain/lane.ts";
|
|
9
|
+
import { roleTimeouts } from "../domain/profile.ts";
|
|
8
10
|
import type { RunRecord } from "../domain/record.ts";
|
|
9
|
-
import {
|
|
11
|
+
import { composeBrief } from "../domain/brief.ts";
|
|
12
|
+
import { briefOwns, DOCS_OWNS } from "../domain/changes.ts";
|
|
13
|
+
import { grantsScratch, ROLE_SERVER_BACKENDS } from "../domain/role-tools.ts";
|
|
10
14
|
import type { Role } from "../domain/roles.ts";
|
|
15
|
+
import { formatRoleScope, ROLE_ENV } from "../domain/role-scope.ts";
|
|
11
16
|
import { dispatchPaths, markForCollect } from "../infra/dispatch-dir.ts";
|
|
12
17
|
import { withFileLock } from "../infra/filelock.ts";
|
|
13
18
|
import { statusSnapshot } from "../infra/git.ts";
|
|
14
19
|
import { launchSupervisor } from "../infra/launch.ts";
|
|
20
|
+
import { DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
|
|
15
21
|
import { log } from "../infra/log.ts";
|
|
16
22
|
import { processStartTime } from "../infra/proc.ts";
|
|
17
23
|
import { ensurePrivateDir, PRIVATE_FILE, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
@@ -26,12 +32,20 @@ import {
|
|
|
26
32
|
listDispatches,
|
|
27
33
|
pendingDispatches,
|
|
28
34
|
roleDir,
|
|
35
|
+
scratchDir,
|
|
29
36
|
setLatest,
|
|
30
37
|
} from "./dispatches.ts";
|
|
31
38
|
import { finalizeDispatch } from "./finalize.ts";
|
|
39
|
+
import { gateEnvParts, readGateEnv } from "./gate-env.ts";
|
|
40
|
+
import { assertNotPaused } from "./pause.ts";
|
|
41
|
+
import { currentOwns } from "./lane-edit.ts";
|
|
42
|
+
import { unfinishedAfter } from "./protocol.ts";
|
|
43
|
+
import { runProfile } from "./run-pin.ts";
|
|
32
44
|
import type { Deps } from "./ports.ts";
|
|
33
|
-
import { readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
|
|
45
|
+
import { readRecords, recordsOnThread, type Run, runPaths, supersededBy } from "./run-store.ts";
|
|
34
46
|
import { currentSession } from "./sessions.ts";
|
|
47
|
+
import { verifierBrief } from "./verifier-brief.ts";
|
|
48
|
+
import { withRunAdmission } from "./workspace-admission.ts";
|
|
35
49
|
|
|
36
50
|
export interface AdmitInput {
|
|
37
51
|
role: Role;
|
|
@@ -45,6 +59,7 @@ export interface AdmitInput {
|
|
|
45
59
|
failoverOf?: string;
|
|
46
60
|
/** the dispatching session (spec §3.3); absent means the session this process serves */
|
|
47
61
|
sessionId?: string | null;
|
|
62
|
+
host?: KnownHost;
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
/** Spec §3.3: SIGTERM, then SIGKILL this long after. */
|
|
@@ -52,7 +67,15 @@ export const KILL_GRACE_MS = 10_000;
|
|
|
52
67
|
|
|
53
68
|
export const laneFile = (run: Run, lane: string): string => join(runPaths(run.dir).lanes, `${lane}.md`);
|
|
54
69
|
|
|
55
|
-
|
|
70
|
+
/** The role's scratch folder, created (0700) and by its real path, on a backend that grants it; else null. */
|
|
71
|
+
function roleScratch(run: Run, name: string, backend: string, isolated: boolean): string | null {
|
|
72
|
+
if (!grantsScratch(backend, isolated)) return null;
|
|
73
|
+
const dir = scratchDir(run, name);
|
|
74
|
+
ensurePrivateDir(dir);
|
|
75
|
+
return realpathSync(dir);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function laneOwns(run: Run, lane: string): string[] {
|
|
56
79
|
const file = laneFile(run, lane);
|
|
57
80
|
if (!existsSync(file))
|
|
58
81
|
throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
|
|
@@ -122,6 +145,15 @@ async function prepared(adapter: BackendAdapter, req: Parameters<NonNullable<Bac
|
|
|
122
145
|
}
|
|
123
146
|
}
|
|
124
147
|
|
|
148
|
+
/** Refuses a dispatch into a run `runs supersede` closed, naming the run that took over. */
|
|
149
|
+
function assertNotSuperseded(run: Run): void {
|
|
150
|
+
const closed = supersededBy(run);
|
|
151
|
+
if (closed)
|
|
152
|
+
throw new CatherdError("E_RUN_NOT_LIVE", `run ${run.id} is superseded by ${closed.by}`, {
|
|
153
|
+
fix: `dispatch in run ${closed.by}`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
125
157
|
/**
|
|
126
158
|
* Spec §4.4 step 1. The checks that read shared state and the write that makes the dispatch live
|
|
127
159
|
* happen under the run's admission lock, so parallel dispatches always see each other (audit C1).
|
|
@@ -134,8 +166,12 @@ export async function admit(
|
|
|
134
166
|
): Promise<{ d: Dispatch; specPath: string }> {
|
|
135
167
|
assertId("role name", i.name);
|
|
136
168
|
if (i.lane !== null) assertId("lane", i.lane);
|
|
169
|
+
// a machine or workspace pause refuses every dispatch it covers, with its reason (spec 1.5 "Group pause")
|
|
170
|
+
assertNotPaused(run);
|
|
171
|
+
assertNotSuperseded(run);
|
|
137
172
|
const rung = parseRung(i.rung);
|
|
138
|
-
|
|
173
|
+
// spec 1.5: the run's pinned profile, access and isolation, whatever the repo runs on now
|
|
174
|
+
const profile = runProfile(deps, run);
|
|
139
175
|
const rc = profile.roles[i.role];
|
|
140
176
|
if (!rc?.enabled)
|
|
141
177
|
throw new CatherdError("E_ADMIT_RUNG", `the ${i.role} role is off in profile ${profile.name}`, {
|
|
@@ -181,10 +217,23 @@ export async function admit(
|
|
|
181
217
|
{ fix: "dispatch a fresh thread (omit `thread`)" },
|
|
182
218
|
);
|
|
183
219
|
const owns = i.lane === null ? [] : laneOwns(run, i.lane);
|
|
220
|
+
// spec 1.5 plan 21: the writer's implicit docs lane, for attribution only
|
|
221
|
+
const ownsImplicit = i.role === "writer" && i.lane === null ? (briefOwns(i.brief) ?? DOCS_OWNS) : null;
|
|
222
|
+
// spec 1.5 "Lane editing": an After: line orders lanes that compile against each other
|
|
223
|
+
const before = i.lane === null ? [] : unfinishedAfter(run, i.lane);
|
|
224
|
+
if (before.length)
|
|
225
|
+
throw new CatherdError(
|
|
226
|
+
"E_ADMIT_ORDER",
|
|
227
|
+
`${i.lane} runs after ${before.join(", ")} (its After: line), which ${before.length > 1 ? "have" : "has"} not finished`,
|
|
228
|
+
{ fix: `dispatch ${i.lane} once ${before.join(", ")} ${before.length > 1 ? "end" : "ends"} ok` },
|
|
229
|
+
);
|
|
184
230
|
const id = newDispatchId();
|
|
185
231
|
const dir = join(roleDir(run, i.name), id);
|
|
186
232
|
const p = dispatchPaths(dir);
|
|
187
233
|
const isolated = started?.isolated ?? profile.isolated[rung.backend] ?? false;
|
|
234
|
+
// spec 1.5 plan 21 (#42 findings 1, 2): the role server goes in whether the harness is isolated or not, so no
|
|
235
|
+
// role is refused for isolation, and a thread an isolated run started resumes as it started
|
|
236
|
+
const roleServer = ROLE_SERVER_BACKENDS.includes(rung.backend);
|
|
188
237
|
await prepared(adapter, {
|
|
189
238
|
rung,
|
|
190
239
|
access: rc.access,
|
|
@@ -192,21 +241,27 @@ export async function admit(
|
|
|
192
241
|
repo: run.meta.repo,
|
|
193
242
|
network,
|
|
194
243
|
});
|
|
195
|
-
const plan = adapter.plan({
|
|
196
|
-
rung,
|
|
197
|
-
access: rc.access,
|
|
198
|
-
network,
|
|
199
|
-
thread: i.thread,
|
|
200
|
-
isolated,
|
|
201
|
-
repo: run.meta.repo,
|
|
202
|
-
briefPath: p.brief,
|
|
203
|
-
replyPath: p.reply,
|
|
204
|
-
dispatchDir: dir,
|
|
205
|
-
});
|
|
206
244
|
|
|
207
245
|
await finalizeFinished(run, deps.now(), onRecorded);
|
|
208
|
-
|
|
209
|
-
|
|
246
|
+
return withRunAdmission(run, deps.now, async () => {
|
|
247
|
+
// again under the lock `runs supersede` writes under: a supersede that landed since the check above wins
|
|
248
|
+
assertNotSuperseded(run);
|
|
249
|
+
// the scratch is created under the admission lock `catherd runs clean` takes too, so cleanup never removes
|
|
250
|
+
// the scratch of a dispatch it does not see live yet (PR #46)
|
|
251
|
+
const scratch = roleScratch(run, i.name, rung.backend, isolated);
|
|
252
|
+
const plan = adapter.plan({
|
|
253
|
+
rung,
|
|
254
|
+
access: rc.access,
|
|
255
|
+
network,
|
|
256
|
+
thread: i.thread,
|
|
257
|
+
isolated,
|
|
258
|
+
repo: run.meta.repo,
|
|
259
|
+
briefPath: p.brief,
|
|
260
|
+
replyPath: p.reply,
|
|
261
|
+
dispatchDir: dir,
|
|
262
|
+
...(roleServer ? { roleMcp: { run: run.id, role: i.role } } : {}),
|
|
263
|
+
...(scratch ? { scratch } : {}),
|
|
264
|
+
});
|
|
210
265
|
// A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
|
|
211
266
|
// tree after the exit, so a later dispatch's writes must not land in between. A finished one here
|
|
212
267
|
// could not be recorded just now. The records and the pending dispatches are one snapshot, taken
|
|
@@ -229,7 +284,9 @@ export async function admit(
|
|
|
229
284
|
},
|
|
230
285
|
);
|
|
231
286
|
for (const d of pending) {
|
|
232
|
-
|
|
287
|
+
// a running lane holds what owns_add granted it since its admission, too
|
|
288
|
+
const held = d.admit.lane ? [...d.admit.owns, ...currentOwns(run, d.admit.lane)] : d.admit.owns;
|
|
289
|
+
const shared = overlaps(owns, held);
|
|
233
290
|
if (shared.length)
|
|
234
291
|
throw new CatherdError(
|
|
235
292
|
"E_ADMIT_OVERLAP",
|
|
@@ -245,6 +302,9 @@ export async function admit(
|
|
|
245
302
|
throw new CatherdError("E_RUN_BUDGET", `the run budget is spent: ${formatBudget(budget)}`, {
|
|
246
303
|
fix: "ask the user to raise profile.budget, or finish the run with what landed",
|
|
247
304
|
});
|
|
305
|
+
const before = await statusSnapshot(run.meta.repo);
|
|
306
|
+
const session = currentSession(deps);
|
|
307
|
+
const sessionId = i.sessionId !== undefined ? i.sessionId : (session?.sessionId ?? null);
|
|
248
308
|
const admitted: Admit = {
|
|
249
309
|
schema: 1,
|
|
250
310
|
runId: run.id,
|
|
@@ -253,6 +313,7 @@ export async function admit(
|
|
|
253
313
|
role: i.role,
|
|
254
314
|
lane: i.lane,
|
|
255
315
|
owns,
|
|
316
|
+
...(ownsImplicit ? { ownsImplicit } : {}),
|
|
256
317
|
rung: formatRung(rung),
|
|
257
318
|
backend: rung.backend,
|
|
258
319
|
thread: i.thread,
|
|
@@ -265,12 +326,27 @@ export async function admit(
|
|
|
265
326
|
cliVersion: probe.version,
|
|
266
327
|
admittedAt: new Date(deps.now()).toISOString(),
|
|
267
328
|
repo: run.meta.repo,
|
|
268
|
-
before
|
|
269
|
-
...(sessionId ? { sessionId } : {}),
|
|
329
|
+
before,
|
|
330
|
+
...(sessionId ? { sessionId, host: i.host ?? session?.host ?? "claude-code" } : {}),
|
|
270
331
|
};
|
|
271
332
|
ensurePrivateDir(dir);
|
|
272
|
-
//
|
|
273
|
-
|
|
333
|
+
// plan 23: the verifier runs with the repo's gate environment (DOCKER_HOST, a proxy, …)
|
|
334
|
+
const gate =
|
|
335
|
+
i.role === "verifier" ? gateEnvParts(await readGateEnv(run.meta.repo)) : { values: {}, refs: {} };
|
|
336
|
+
// spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included; spec 1.5 plan 21:
|
|
337
|
+
// before it, the lane file as it stands now, and who the role is, its scratch and its catherd tools; plan 23:
|
|
338
|
+
// a verifier's brief also carries the gate's rules and the run's recorded items
|
|
339
|
+
writeTextAtomic(
|
|
340
|
+
p.brief,
|
|
341
|
+
composeBrief(await verifierBrief(run, i.role, i.name, i.brief), {
|
|
342
|
+
run: run.id,
|
|
343
|
+
name: i.name,
|
|
344
|
+
role: i.role,
|
|
345
|
+
lane: i.lane === null ? null : { id: i.lane, text: readFileSync(laneFile(run, i.lane), "utf8") },
|
|
346
|
+
scratch,
|
|
347
|
+
roleServer,
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
274
350
|
// Spec §10.4: the adapter's overrides only; the supervisor adds its own inherited env at spawn
|
|
275
351
|
// time (src/entry/supervise-command.ts), so no credential is ever written to disk. 0600 all the same.
|
|
276
352
|
writeJsonAtomic(
|
|
@@ -281,11 +357,26 @@ export async function admit(
|
|
|
281
357
|
dispatchDir: dir,
|
|
282
358
|
cmd: plan.cmd,
|
|
283
359
|
args: plan.args,
|
|
284
|
-
|
|
360
|
+
// spec 1.5 plan 21: the supervisor gives the role its identity and, where granted, its scratch TMPDIR;
|
|
361
|
+
// plan 23: a `catherd lock` in the role reports to this dispatch, so a long gate keeps its wall alive
|
|
362
|
+
// plan 23: each dispatch its own testcontainers session, so parallel lanes never share a reaper;
|
|
363
|
+
// the gate env never overrides the role's identity, scratch, dispatch id, PWD or isolated home
|
|
364
|
+
env: {
|
|
365
|
+
...gate.values,
|
|
366
|
+
...plan.env,
|
|
367
|
+
[ROLE_ENV]: formatRoleScope({ run: run.id, name: i.name }),
|
|
368
|
+
...(scratch ? { TMPDIR: scratch } : {}),
|
|
369
|
+
[DISPATCH_ID_ENV]: id,
|
|
370
|
+
TESTCONTAINERS_SESSION_ID: id,
|
|
371
|
+
PWD: plan.cwd,
|
|
372
|
+
},
|
|
373
|
+
// a secret of the gate env by reference only: the supervisor reads it from its own env at spawn
|
|
374
|
+
...(Object.keys(gate.refs).length ? { envFrom: gate.refs } : {}),
|
|
285
375
|
cwd: plan.cwd,
|
|
286
376
|
stdinPath: plan.stdinPath,
|
|
287
|
-
|
|
288
|
-
|
|
377
|
+
// plan 23: a role's own timeouts win over the profile's (a verifier's long gate)
|
|
378
|
+
idleMs: roleTimeouts(profile, rc).idleMin * 60_000,
|
|
379
|
+
wallMs: roleTimeouts(profile, rc).wallMin * 60_000,
|
|
289
380
|
killGraceMs: KILL_GRACE_MS,
|
|
290
381
|
graceAfterFinalMs: adapter.graceAfterFinalMs,
|
|
291
382
|
pollMs: deps.pollMs,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../domain/host.ts";
|
|
1
2
|
import {
|
|
2
3
|
existsSync,
|
|
3
4
|
lstatSync,
|
|
@@ -44,10 +45,10 @@ export interface Planned {
|
|
|
44
45
|
links: Map<string, string>;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export function plan(extra: string[] = []): Planned {
|
|
48
|
+
export function plan(host: OrchestrationHost, extra: string[] = [], selected?: string[]): Planned {
|
|
48
49
|
const files = new Map<string, AgentFile[]>();
|
|
49
|
-
for (const name of new Set([...linkedProfiles(), ...extra.filter(profileExists)]))
|
|
50
|
-
files.set(name, agentFiles(getProfile(name), VERSION));
|
|
50
|
+
for (const name of new Set(selected ?? [...linkedProfiles(), ...extra.filter(profileExists)]))
|
|
51
|
+
files.set(name, agentFiles(getProfile(name, host), VERSION));
|
|
51
52
|
const links = new Map<string, string>();
|
|
52
53
|
for (const name of linkedProfiles())
|
|
53
54
|
for (const f of files.get(name) ?? [])
|
|
@@ -71,6 +72,27 @@ export interface Synced {
|
|
|
71
72
|
newSessionNeededFor: string[];
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
export function pruneProfileAgents(name: string): Synced {
|
|
76
|
+
const dir = join(agentsRoot(), name);
|
|
77
|
+
const pruned: string[] = [];
|
|
78
|
+
const target = claudeAgentsDir();
|
|
79
|
+
if (existsSync(target))
|
|
80
|
+
for (const entry of readdirSync(target)) {
|
|
81
|
+
const link = join(target, entry);
|
|
82
|
+
if (isOurLink(link) && readlinkSync(link).startsWith(`${dir}${sep}`)) {
|
|
83
|
+
rmSync(link);
|
|
84
|
+
pruned.push(entry);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
pruned.sort();
|
|
89
|
+
return {
|
|
90
|
+
linked: [],
|
|
91
|
+
pruned,
|
|
92
|
+
newSessionNeededFor: pruned.map((entry) => basename(entry, ".md")),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
74
96
|
/**
|
|
75
97
|
* Writes the planned agent files and links, prunes catherd's stale ones, and says what changed. The
|
|
76
98
|
* ownership check runs before the first write, so no caller can replace a file catherd does not own.
|
|
@@ -122,8 +144,11 @@ export function apply(p: Planned, removed: string[] = []): Synced {
|
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
/** What `doctor` compares: each link that should exist, and whether it and its file are current. */
|
|
125
|
-
export function agentLinkState(
|
|
126
|
-
|
|
147
|
+
export function agentLinkState(
|
|
148
|
+
host: OrchestrationHost,
|
|
149
|
+
selected?: string[],
|
|
150
|
+
): { missing: string[]; stale: string[]; ok: string[] } {
|
|
151
|
+
const p = plan(host, [], selected);
|
|
127
152
|
const out = { missing: [] as string[], stale: [] as string[], ok: [] as string[] };
|
|
128
153
|
for (const [name, files] of p.files)
|
|
129
154
|
for (const f of files) {
|
package/src/services/backends.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../domain/host.ts";
|
|
2
|
+
import { nativeClaudeIssue } from "../domain/profile-rules.ts";
|
|
1
3
|
import type { BackendAdapter, Probe } from "../adapters/backend.ts";
|
|
2
4
|
import { adapterFor } from "../adapters/registry.ts";
|
|
3
5
|
import "../adapters/all.ts";
|
|
@@ -80,3 +82,8 @@ export function standInFor(failover: Record<string, string>, rung: string, repo?
|
|
|
80
82
|
const byAdapter = adapterFor(r.backend)?.failoverFor?.(r, repo) ?? null;
|
|
81
83
|
return byAdapter && formatRung(byAdapter);
|
|
82
84
|
}
|
|
85
|
+
|
|
86
|
+
export function assertNativeHost(rung: string, host: OrchestrationHost): void {
|
|
87
|
+
const issue = nativeClaudeIssue(rung, host, "rung");
|
|
88
|
+
if (issue) throw new CatherdError("E_CONFIG_INVALID", `${issue.message}; ${issue.fix}`, { fix: issue.fix });
|
|
89
|
+
}
|
package/src/services/budget.ts
CHANGED
|
@@ -2,13 +2,14 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { adapterFor } from "../adapters/registry.ts";
|
|
3
3
|
import "../adapters/all.ts";
|
|
4
4
|
import { type Budget, type BudgetStatus, budgetStatus, type Spend } from "../domain/budget.ts";
|
|
5
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
6
|
import type { RunRecord } from "../domain/record.ts";
|
|
6
7
|
import { dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
7
8
|
import { type Dispatch, liveDispatches } from "./dispatches.ts";
|
|
8
9
|
import { readAgentRuns, readRecords, type Run } from "./run-store.ts";
|
|
9
10
|
|
|
10
11
|
/** Tokens a running dispatch has used so far, summed from the events its CLI has written. */
|
|
11
|
-
export function liveTokens(d: Dispatch): number {
|
|
12
|
+
export function liveTokens(d: Dispatch, strict = false): number {
|
|
12
13
|
const a = adapterFor(d.admit.backend);
|
|
13
14
|
const file = dispatchPaths(d.dir).events;
|
|
14
15
|
if (!a || !existsSync(file)) return 0;
|
|
@@ -17,8 +18,13 @@ export function liveTokens(d: Dispatch): number {
|
|
|
17
18
|
if (!line.trim()) continue;
|
|
18
19
|
try {
|
|
19
20
|
const t = a.parse(line).tokens;
|
|
20
|
-
if (t)
|
|
21
|
-
|
|
21
|
+
if (t) {
|
|
22
|
+
if (strict && Object.values(t).some((value) => !Number.isFinite(value) || value < 0))
|
|
23
|
+
throw new CatherdError("E_RUN_CORRUPT", `dispatch ${d.admit.dispatchId} has invalid token usage`);
|
|
24
|
+
n += t.input + t.output;
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (strict && error instanceof CatherdError) throw error;
|
|
22
28
|
// a line the adapter cannot read adds nothing
|
|
23
29
|
}
|
|
24
30
|
}
|
|
@@ -29,15 +35,29 @@ export function liveTokens(d: Dispatch): number {
|
|
|
29
35
|
* Spec §4.6: minutes are wall clock since run_start; tokens and dollars sum every record (every
|
|
30
36
|
* backend), every native subagent run the orchestrator reported, and what live roles used so far.
|
|
31
37
|
*/
|
|
32
|
-
export function spendOf(
|
|
33
|
-
|
|
38
|
+
export function spendOf(
|
|
39
|
+
run: Run,
|
|
40
|
+
records: RunRecord[],
|
|
41
|
+
live: Dispatch[],
|
|
42
|
+
now: number,
|
|
43
|
+
strict = false,
|
|
44
|
+
): Spend {
|
|
45
|
+
const agents = readAgentRuns(run, strict);
|
|
46
|
+
if (strict) {
|
|
47
|
+
const invalid = (value: number | null) => value !== null && (!Number.isFinite(value) || value < 0);
|
|
48
|
+
if (
|
|
49
|
+
records.some((record) => Object.values(record.tokens).some(invalid) || invalid(record.costUsd)) ||
|
|
50
|
+
agents.some((agent) => invalid(agent.totalTokens) || invalid(agent.costUsd))
|
|
51
|
+
)
|
|
52
|
+
throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has invalid cost or token usage`);
|
|
53
|
+
}
|
|
34
54
|
const sum = <T>(xs: T[], f: (x: T) => number) => xs.reduce((n, x) => n + f(x), 0);
|
|
35
55
|
return {
|
|
36
56
|
minutes: Math.max(0, (now - Date.parse(run.meta.createdAt)) / 60_000),
|
|
37
57
|
tokens:
|
|
38
58
|
sum(records, (r) => r.tokens.input + r.tokens.output) +
|
|
39
59
|
sum(agents, (a) => a.totalTokens) +
|
|
40
|
-
sum(live, liveTokens),
|
|
60
|
+
sum(live, (dispatch) => liveTokens(dispatch, strict)),
|
|
41
61
|
usd: sum(records, (r) => r.costUsd ?? 0) + sum(agents, (a) => a.costUsd ?? 0),
|
|
42
62
|
};
|
|
43
63
|
}
|
|
@@ -99,18 +99,23 @@ export function measuredSecs(base: Catalog): Catalog["secs"] {
|
|
|
99
99
|
if (kind) add(`${canonical}|${kind}`, secs);
|
|
100
100
|
};
|
|
101
101
|
for (const run of listRuns().runs) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
102
|
+
// a run whose files cannot be read gives no timings; it never fails a route (1.2 minor)
|
|
103
|
+
try {
|
|
104
|
+
const routes = readRoutes(run);
|
|
105
|
+
for (const r of readRecords(run).records)
|
|
106
|
+
if (r.status === "ok") count(routes, r.rung, r.secs, r.lane, r.startedAt);
|
|
107
|
+
for (const a of readAgentRuns(run)) {
|
|
108
|
+
if (a.status !== "ok" || a.secs === null) continue;
|
|
109
|
+
// a row is written when the subagent ends; it started `secs` earlier
|
|
110
|
+
const lane = typeof a.lane === "string" ? a.lane : null;
|
|
111
|
+
const end = Date.parse(a.at);
|
|
112
|
+
// a duration past the Date range (record_agent_run takes any) keeps the end, not a RangeError
|
|
113
|
+
const start = new Date(end - a.secs * 1000);
|
|
114
|
+
const startedAt = Number.isNaN(start.getTime()) ? a.at : start.toISOString();
|
|
115
|
+
count(routes, a.rung, a.secs, lane, startedAt);
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
log("debug", "catalog", { run: run.id, timings: errorMessage(e) });
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
const secs: Catalog["secs"] = {};
|
|
@@ -340,6 +345,8 @@ function rungRows(
|
|
|
340
345
|
date: string;
|
|
341
346
|
/** spec 1.2 §5.3: the rung a borrowed or inferred value belongs to */
|
|
342
347
|
from?: string;
|
|
348
|
+
/** what lent it: a treat-like's mapping (`like X`) or an inferred stand-in's guess (1.2 minor) */
|
|
349
|
+
lent?: "treat-like" | "stand-in";
|
|
343
350
|
}
|
|
344
351
|
> = {};
|
|
345
352
|
for (const v of valuesUsed(c, info.canonical))
|
|
@@ -351,6 +358,7 @@ function rungRows(
|
|
|
351
358
|
source: v.source,
|
|
352
359
|
date: v.date,
|
|
353
360
|
...(v.from ? { from: v.from } : {}),
|
|
361
|
+
...(v.lent ? { lent: v.lent } : {}),
|
|
354
362
|
};
|
|
355
363
|
const like = c.treatLike[info.canonical] ?? null;
|
|
356
364
|
return {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { CatherdError, isCatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { withFileLockSync } from "../infra/filelock.ts";
|
|
5
6
|
import { log } from "../infra/log.ts";
|
|
6
7
|
import { configDir } from "../infra/paths.ts";
|
|
7
8
|
import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
|
|
@@ -45,13 +46,17 @@ export function savedCredential(field: CredentialField): {
|
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* Keeps every other credential, and the file at mode 600 (spec §10.4). A missing file starts empty; an
|
|
48
|
-
* unreadable or newer-schema one is refused (it throws) rather than overwritten.
|
|
49
|
+
* unreadable or newer-schema one is refused (it throws) rather than overwritten. The read and the write hold
|
|
50
|
+
* the file's lock (1.2 minor), so two concurrent `init`s never lose a key.
|
|
49
51
|
*/
|
|
50
52
|
export function saveCredential(field: CredentialField, key: string): void {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
:
|
|
54
|
-
|
|
53
|
+
mkdirSync(configDir(), { recursive: true });
|
|
54
|
+
withFileLockSync(credentialsPath(), () => {
|
|
55
|
+
const cur: z.infer<typeof CredentialsSchema> = existsSync(credentialsPath())
|
|
56
|
+
? readCredentials()
|
|
57
|
+
: { schema: 1 };
|
|
58
|
+
writeJsonAtomic(credentialsPath(), { ...cur, schema: 1, [field]: key.trim() }, { mode: 0o600 });
|
|
59
|
+
});
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/**
|