catherd-cli 1.4.0 → 2.0.0-beta.20261022113309
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 +188 -169
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -239
- package/package.json +6 -8
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -356
- package/plugin/skills/catherd-setup/SKILL.md +21 -20
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +10 -4
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +27 -27
- package/src/domain/agents.ts +3 -1
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +106 -0
- package/src/domain/catalog.ts +69 -313
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +19 -29
- package/src/domain/errors.ts +6 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -75
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +53 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +118 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +32 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +175 -138
- package/src/domain/profile.ts +140 -63
- package/src/domain/record.ts +68 -3
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +182 -56
- package/src/domain/role-scope.ts +76 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +90 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +229 -72
- package/src/domain/slopalytics.ts +72 -0
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +24 -165
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +18 -2
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +20 -69
- package/src/entry/knowledge-command.ts +121 -5
- package/src/entry/lock-command.ts +103 -8
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +134 -0
- package/src/entry/mcp/server.ts +85 -21
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +33 -18
- package/src/entry/role-cli-command.ts +159 -0
- package/src/entry/runs-command.ts +81 -27
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -3
- package/src/infra/git.ts +70 -0
- package/src/infra/heavy-lock.ts +133 -6
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/launch.ts +63 -7
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +37 -2
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +133 -31
- package/src/services/backends.ts +4 -6
- package/src/services/budget.ts +26 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -254
- package/src/services/credentials.ts +15 -26
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +209 -17
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +72 -15
- package/src/services/end-push.ts +157 -0
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +24 -2
- package/src/services/gate-env.ts +217 -0
- package/src/services/gate-service.ts +231 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +291 -55
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +167 -29
- package/src/services/notifier.ts +124 -4
- package/src/services/notify-service.ts +26 -0
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +60 -5
- package/src/services/preflight.ts +232 -24
- package/src/services/profile-service.ts +31 -3
- package/src/services/profile-store.ts +18 -6
- package/src/services/protocol.ts +123 -25
- package/src/services/provenance.ts +34 -89
- package/src/services/questions.ts +24 -0
- package/src/services/reconcile.ts +33 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +161 -42
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +204 -23
- package/src/services/run-store.ts +159 -18
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +76 -12
- package/src/services/tree-path.ts +27 -0
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +259 -0
- package/src/services/workspace-service.ts +259 -0
- package/src/services/workspace-store.ts +95 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -36
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -320
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -132
- package/src/adapters/grok/index.ts +0 -339
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -125
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -84
- package/src/entry/mcp/protocol-tools.ts +0 -57
- package/src/entry/mcp/run-tools.ts +0 -148
- package/src/entry/mcp/setup-tools.ts +0 -108
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -381
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -373
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -573
- package/src/entry/tui/views/save-dialog.tsx +0 -340
- package/src/entry/tui/views/status.tsx +0 -209
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -87
- package/src/infra/sources/artificial-analysis.ts +0 -112
- package/src/infra/sources/cache.ts +0 -124
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -255
- package/src/services/source-derive.ts +0 -374
- package/src/services/source-sync.ts +0 -305
- package/src/services/standins.ts +0 -204
- package/src/services/treat-likes.ts +0 -97
package/src/infra/supervisor.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { scrubSecrets } from "./env.ts";
|
|
2
2
|
import type { Subprocess } from "bun";
|
|
3
3
|
import { appendFileSync, closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
4
|
+
import { basename } from "node:path";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { errorMessage } from "../domain/errors.ts";
|
|
6
7
|
import type { ExitInfo, ExitReason } from "../domain/record.ts";
|
|
7
8
|
import { dispatchPaths, supervisorLockTarget } from "./dispatch-dir.ts";
|
|
8
9
|
import { tryLock } from "./filelock.ts";
|
|
10
|
+
import { lastLockOutput } from "./lock-activity.ts";
|
|
9
11
|
import { log } from "./log.ts";
|
|
10
12
|
import { killGroup, processStartTime } from "./proc.ts";
|
|
11
13
|
import { PRIVATE_FILE, writeJsonAtomic } from "./store.ts";
|
|
@@ -17,6 +19,8 @@ export const SuperviseSpecSchema = z.looseObject({
|
|
|
17
19
|
cmd: z.string(),
|
|
18
20
|
args: z.array(z.string()),
|
|
19
21
|
env: z.record(z.string(), z.string()),
|
|
22
|
+
/** plan 23: env vars set from another one at spawn (a gate env secret by reference): name → source name */
|
|
23
|
+
envFrom: z.record(z.string(), z.string()).optional(),
|
|
20
24
|
cwd: z.string(),
|
|
21
25
|
stdinPath: z.string().nullable(),
|
|
22
26
|
idleMs: z.number().positive(),
|
|
@@ -24,15 +28,28 @@ export const SuperviseSpecSchema = z.looseObject({
|
|
|
24
28
|
killGraceMs: z.number().nonnegative(),
|
|
25
29
|
graceAfterFinalMs: z.number().nonnegative().nullable(),
|
|
26
30
|
pollMs: z.number().positive(),
|
|
31
|
+
/** plan 23: when provider retries end an attempt; PROVIDER_RETRY when absent (tests shorten it) */
|
|
32
|
+
providerRetry: z
|
|
33
|
+
.object({ attempts: z.number().positive(), ms: z.number().positive(), pollMs: z.number().positive() })
|
|
34
|
+
.partial()
|
|
35
|
+
.optional(),
|
|
27
36
|
});
|
|
28
37
|
export type SuperviseSpec = z.infer<typeof SuperviseSpecSchema>;
|
|
29
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Plan 23: an attempt that has only retried its provider (3 retries, or 3 min of nothing else) ends as
|
|
41
|
+
* provider-unavailable; a quiet session's own retry field is read every pollMs.
|
|
42
|
+
*/
|
|
43
|
+
export const PROVIDER_RETRY = { attempts: 3, ms: 180_000, pollMs: 30_000 };
|
|
44
|
+
|
|
30
45
|
/** What one stream line tells the supervisor: the terminal event, the thread, a tool call opening or closing. */
|
|
31
46
|
interface LineInfo {
|
|
32
47
|
final?: boolean;
|
|
33
48
|
thread?: string;
|
|
34
49
|
/** a tool call the CLI started (`open`) or finished: while one is open the run is busy, however quiet */
|
|
35
50
|
item?: { id: string; open: boolean };
|
|
51
|
+
/** plan 23: a model step started; one right after another, with nothing between, is a provider retry */
|
|
52
|
+
step?: boolean;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
export interface SuperviseHooks {
|
|
@@ -40,8 +57,23 @@ export interface SuperviseHooks {
|
|
|
40
57
|
/** `sinceMs`: when this run started, so a backend can ignore what an earlier run on the thread left */
|
|
41
58
|
isBusy?(thread: string | null, sinceMs: number): Promise<boolean>;
|
|
42
59
|
interrupt?(thread: string | null): Promise<void>;
|
|
60
|
+
/** plan 23: the provider retry attempt the session waits out (its own retry field), or null */
|
|
61
|
+
providerRetry?(thread: string | null, sinceMs: number): Promise<number | null>;
|
|
62
|
+
/**
|
|
63
|
+
* The poll loop's time: what it reads as now and how it waits a poll. The real clock when absent; a test
|
|
64
|
+
* passes its own so the wall, idle and retry limits are decided by the time it drives, never by how fast a
|
|
65
|
+
* loaded runner happens to schedule timers.
|
|
66
|
+
*/
|
|
67
|
+
clock?: SuperviseClock;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SuperviseClock {
|
|
71
|
+
now(): number;
|
|
72
|
+
sleep(ms: number): Promise<void>;
|
|
43
73
|
}
|
|
44
74
|
|
|
75
|
+
const REAL_CLOCK: SuperviseClock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) };
|
|
76
|
+
|
|
45
77
|
/** Reads whatever the child appended to `file` since `offset`, as complete lines. */
|
|
46
78
|
function readNew(file: string, state: { offset: number; rest: string; decoder: TextDecoder }): string[] {
|
|
47
79
|
if (!existsSync(file)) return [];
|
|
@@ -79,7 +111,7 @@ async function bounded<T>(call: () => Promise<T> | undefined, ms: number, fallba
|
|
|
79
111
|
}
|
|
80
112
|
}
|
|
81
113
|
|
|
82
|
-
function groupAlive(pgid: number): boolean {
|
|
114
|
+
export function groupAlive(pgid: number): boolean {
|
|
83
115
|
try {
|
|
84
116
|
process.kill(-pgid, 0);
|
|
85
117
|
return true;
|
|
@@ -89,7 +121,7 @@ function groupAlive(pgid: number): boolean {
|
|
|
89
121
|
}
|
|
90
122
|
|
|
91
123
|
/** SIGTERM the group, give it `graceMs` to leave, then SIGKILL whatever is left of it. */
|
|
92
|
-
async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
|
|
124
|
+
export async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
|
|
93
125
|
killGroup(pgid, "SIGTERM");
|
|
94
126
|
const end = Date.now() + graceMs;
|
|
95
127
|
while (Date.now() < end && groupAlive(pgid))
|
|
@@ -168,6 +200,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
168
200
|
}
|
|
169
201
|
|
|
170
202
|
const hookMs = Math.min(10_000, spec.idleMs);
|
|
203
|
+
const clock = hooks.clock ?? REAL_CLOCK;
|
|
171
204
|
let reason: ExitReason | null = null;
|
|
172
205
|
let thread: string | null = null;
|
|
173
206
|
let failure: { error: unknown } | null = null;
|
|
@@ -187,8 +220,12 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
187
220
|
startedAt: new Date().toISOString(),
|
|
188
221
|
});
|
|
189
222
|
|
|
190
|
-
const started =
|
|
223
|
+
const started = clock.now();
|
|
224
|
+
const dispatchId = basename(spec.dispatchDir);
|
|
191
225
|
let lastActivity = started;
|
|
226
|
+
// plan 23: the last output seen from a `catherd lock` of the role, kept after the lock exits (its file goes),
|
|
227
|
+
// so the wall counts from that output, never from the role's start again
|
|
228
|
+
let lockAt = started;
|
|
192
229
|
let finalAt: number | null = null;
|
|
193
230
|
// spec §3.6: a quiet stretch of half the idle timeout, not busy, is a stall, reported once per dispatch
|
|
194
231
|
let stalled = false;
|
|
@@ -196,34 +233,88 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
196
233
|
const open = new Set<string>();
|
|
197
234
|
const stream = { offset: 0, rest: "", decoder: new TextDecoder("utf-8") };
|
|
198
235
|
|
|
236
|
+
// plan 23: a step that starts right after another with nothing between is a provider retry; retries are
|
|
237
|
+
// not progress, and enough of them (or a long enough stretch) end the attempt as provider-unavailable
|
|
238
|
+
let lastWasStep = false;
|
|
239
|
+
let retries = 0;
|
|
240
|
+
let retrySince: number | null = null;
|
|
241
|
+
let lastRetryPoll = started;
|
|
242
|
+
const retry = { ...PROVIDER_RETRY, ...spec.providerRetry };
|
|
243
|
+
|
|
199
244
|
while (!done && reason === null) {
|
|
200
|
-
await
|
|
245
|
+
await clock.sleep(spec.pollMs);
|
|
201
246
|
const lines = readNew(p.events, stream);
|
|
202
|
-
|
|
203
|
-
lastActivity = Date.now();
|
|
204
|
-
stallChecked = false;
|
|
205
|
-
}
|
|
247
|
+
let progress = false;
|
|
206
248
|
for (const line of lines) {
|
|
207
249
|
let d: LineInfo | undefined;
|
|
208
250
|
try {
|
|
209
251
|
d = hooks.onLine?.(line);
|
|
210
252
|
} catch {
|
|
211
|
-
|
|
253
|
+
// one line the hook cannot read must not end supervision; it is output all the same
|
|
254
|
+
progress = true;
|
|
255
|
+
lastWasStep = false;
|
|
256
|
+
continue;
|
|
212
257
|
}
|
|
213
|
-
if (d?.
|
|
214
|
-
|
|
258
|
+
if (d?.step && lastWasStep) {
|
|
259
|
+
retries++;
|
|
260
|
+
retrySince ??= clock.now();
|
|
261
|
+
} else {
|
|
262
|
+
progress = true;
|
|
263
|
+
if (!d?.step) {
|
|
264
|
+
retries = 0;
|
|
265
|
+
retrySince = null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
lastWasStep = d?.step === true;
|
|
269
|
+
if (d?.thread && d.thread !== thread) {
|
|
270
|
+
thread = d.thread;
|
|
271
|
+
// spec 1.5 plan 21: a live role's thread is on disk, so no delivery ever targets it
|
|
272
|
+
try {
|
|
273
|
+
writeJsonAtomic(p.thread, { schema: 1, thread, at: new Date().toISOString() });
|
|
274
|
+
} catch (e) {
|
|
275
|
+
log("warn", "supervise", { dispatch: spec.dispatchDir, error: errorMessage(e) });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (d?.final) finalAt ??= clock.now();
|
|
215
279
|
if (d?.item) {
|
|
216
280
|
if (d.item.open) open.add(d.item.id);
|
|
217
281
|
else open.delete(d.item.id);
|
|
218
282
|
}
|
|
219
283
|
}
|
|
284
|
+
if (progress) {
|
|
285
|
+
lastActivity = clock.now();
|
|
286
|
+
stallChecked = false;
|
|
287
|
+
}
|
|
288
|
+
// plan 23: a session quiet for a while may be waiting out a provider retry the stream does not show
|
|
289
|
+
if (
|
|
290
|
+
hooks.providerRetry &&
|
|
291
|
+
clock.now() - lastActivity >= retry.pollMs &&
|
|
292
|
+
clock.now() - lastRetryPoll >= retry.pollMs
|
|
293
|
+
) {
|
|
294
|
+
lastRetryPoll = clock.now();
|
|
295
|
+
const attempt = await bounded(() => hooks.providerRetry?.(thread, started), hookMs, null);
|
|
296
|
+
if (attempt !== null) {
|
|
297
|
+
retrySince ??= clock.now();
|
|
298
|
+
retries = Math.max(retries, attempt);
|
|
299
|
+
} else {
|
|
300
|
+
// the session says it is not retrying: the steps seen were a retry that worked, and the quiet since
|
|
301
|
+
// is work the stream does not show yet (a long tool call), never a provider outage
|
|
302
|
+
retries = 0;
|
|
303
|
+
retrySince = null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
220
306
|
// a worker that ended on its own is recorded as it ended, even if a cancel or a limit arrived meanwhile
|
|
221
307
|
if (done) break;
|
|
222
|
-
const now =
|
|
308
|
+
const now = clock.now();
|
|
309
|
+
lockAt = Math.max(lockAt, lastLockOutput(dispatchId) ?? lockAt);
|
|
223
310
|
if (existsSync(p.cancel)) reason = "cancelled";
|
|
224
|
-
else if (now -
|
|
311
|
+
else if (now - lockAt >= spec.wallMs)
|
|
312
|
+
// plan 23: once a `catherd lock` of the role has written, the wall counts from its last output
|
|
313
|
+
reason = "wall-timeout";
|
|
225
314
|
else if (finalAt !== null && spec.graceAfterFinalMs !== null && now - finalAt >= spec.graceAfterFinalMs)
|
|
226
315
|
reason = "after-final";
|
|
316
|
+
else if (retries >= retry.attempts || (retrySince !== null && now - retrySince >= retry.ms))
|
|
317
|
+
reason = "provider-unavailable";
|
|
227
318
|
else if (now - lastActivity >= spec.idleMs) {
|
|
228
319
|
const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
|
|
229
320
|
// the busy check can take up to hookMs: a worker that ended meanwhile is recorded as it ended, read
|
|
@@ -231,7 +322,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
231
322
|
if (done || child.exitCode !== null || child.signalCode !== null) break;
|
|
232
323
|
if (busy) {
|
|
233
324
|
// a new quiet stretch starts: it gets its own stall check
|
|
234
|
-
lastActivity =
|
|
325
|
+
lastActivity = clock.now();
|
|
235
326
|
stallChecked = false;
|
|
236
327
|
} else reason = "idle-timeout";
|
|
237
328
|
} else if (!stalled && !stallChecked && now - lastActivity >= spec.idleMs / 2) {
|
|
@@ -240,7 +331,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
240
331
|
stallChecked = true;
|
|
241
332
|
const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
|
|
242
333
|
if (busy) {
|
|
243
|
-
lastActivity =
|
|
334
|
+
lastActivity = clock.now();
|
|
244
335
|
stallChecked = false;
|
|
245
336
|
} else if (!done) {
|
|
246
337
|
stalled = true;
|
|
@@ -249,7 +340,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
249
340
|
writeJsonAtomic(p.stall, {
|
|
250
341
|
schema: 1,
|
|
251
342
|
at: new Date().toISOString(),
|
|
252
|
-
quietMs:
|
|
343
|
+
quietMs: clock.now() - lastActivity,
|
|
253
344
|
});
|
|
254
345
|
} catch (e) {
|
|
255
346
|
log("warn", "stall", { dispatch: spec.dispatchDir, error: errorMessage(e) });
|
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
import type { KnownHost } from "../domain/host.ts";
|
|
2
|
-
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { BackendAdapter } from "../adapters/backend.ts";
|
|
5
5
|
import { budgetStatus, formatBudget } from "../domain/budget.ts";
|
|
6
6
|
import { CatherdError, errorMessage } from "../domain/errors.ts";
|
|
7
|
-
import { assertId, formatRung, newDispatchId, parseRung } from "../domain/ids.ts";
|
|
7
|
+
import { assertId, formatRung, headlessRung, newDispatchId, parseRung } from "../domain/ids.ts";
|
|
8
8
|
import { assertLaneHeader, overlaps } from "../domain/lane.ts";
|
|
9
|
+
import { roleTimeouts } from "../domain/profile.ts";
|
|
9
10
|
import type { RunRecord } from "../domain/record.ts";
|
|
10
|
-
import {
|
|
11
|
+
import { composeBrief } from "../domain/brief.ts";
|
|
12
|
+
import { rulesBlock } from "../domain/rules.ts";
|
|
13
|
+
import { briefOwns, DOCS_OWNS } from "../domain/changes.ts";
|
|
14
|
+
import { grantsScratch, ROLE_SERVER_BACKENDS } from "../domain/role-tools.ts";
|
|
11
15
|
import type { Role } from "../domain/roles.ts";
|
|
16
|
+
import { formatRoleScope, ROLE_ENV } from "../domain/role-scope.ts";
|
|
12
17
|
import { dispatchPaths, markForCollect } from "../infra/dispatch-dir.ts";
|
|
13
18
|
import { withFileLock } from "../infra/filelock.ts";
|
|
14
19
|
import { statusSnapshot } from "../infra/git.ts";
|
|
15
20
|
import { launchSupervisor } from "../infra/launch.ts";
|
|
21
|
+
import { DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
|
|
16
22
|
import { log } from "../infra/log.ts";
|
|
17
23
|
import { processStartTime } from "../infra/proc.ts";
|
|
18
24
|
import { ensurePrivateDir, PRIVATE_FILE, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
19
25
|
import { readyAdapter, standInFor } from "./backends.ts";
|
|
26
|
+
import { failoverBackends } from "./failover-rungs.ts";
|
|
20
27
|
import { spendOf } from "./budget.ts";
|
|
21
28
|
import {
|
|
22
29
|
type Admit,
|
|
@@ -27,12 +34,22 @@ import {
|
|
|
27
34
|
listDispatches,
|
|
28
35
|
pendingDispatches,
|
|
29
36
|
roleDir,
|
|
37
|
+
scratchDir,
|
|
30
38
|
setLatest,
|
|
31
39
|
} from "./dispatches.ts";
|
|
32
40
|
import { finalizeDispatch } from "./finalize.ts";
|
|
41
|
+
import { gateEnvParts, readGateEnv } from "./gate-env.ts";
|
|
42
|
+
import { assertNotPaused } from "./pause.ts";
|
|
43
|
+
import { currentOwns } from "./lane-edit.ts";
|
|
44
|
+
import { planContext } from "./plan-input.ts";
|
|
45
|
+
import { standingRules } from "./run-service.ts";
|
|
46
|
+
import { unfinishedAfter } from "./protocol.ts";
|
|
47
|
+
import { runProfile } from "./run-pin.ts";
|
|
33
48
|
import type { Deps } from "./ports.ts";
|
|
34
|
-
import { readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
|
|
49
|
+
import { readRecords, recordsOnThread, type Run, runPaths, supersededBy } from "./run-store.ts";
|
|
35
50
|
import { currentSession } from "./sessions.ts";
|
|
51
|
+
import { verifierBrief } from "./verifier-brief.ts";
|
|
52
|
+
import { withRunAdmission } from "./workspace-admission.ts";
|
|
36
53
|
|
|
37
54
|
export interface AdmitInput {
|
|
38
55
|
role: Role;
|
|
@@ -47,6 +64,8 @@ export interface AdmitInput {
|
|
|
47
64
|
/** the dispatching session (spec §3.3); absent means the session this process serves */
|
|
48
65
|
sessionId?: string | null;
|
|
49
66
|
host?: KnownHost;
|
|
67
|
+
/** plan 35: where the role works (its milestone's worktree in a base run); absent means the run's repo */
|
|
68
|
+
cwd?: string;
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
/** Spec §3.3: SIGTERM, then SIGKILL this long after. */
|
|
@@ -54,7 +73,15 @@ export const KILL_GRACE_MS = 10_000;
|
|
|
54
73
|
|
|
55
74
|
export const laneFile = (run: Run, lane: string): string => join(runPaths(run.dir).lanes, `${lane}.md`);
|
|
56
75
|
|
|
57
|
-
|
|
76
|
+
/** The role's scratch folder, created (0700) and by its real path, on a backend that grants it; else null. */
|
|
77
|
+
function roleScratch(run: Run, name: string, backend: string, isolated: boolean): string | null {
|
|
78
|
+
if (!grantsScratch(backend, isolated)) return null;
|
|
79
|
+
const dir = scratchDir(run, name);
|
|
80
|
+
ensurePrivateDir(dir);
|
|
81
|
+
return realpathSync(dir);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function laneOwns(run: Run, lane: string): string[] {
|
|
58
85
|
const file = laneFile(run, lane);
|
|
59
86
|
if (!existsSync(file))
|
|
60
87
|
throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
|
|
@@ -124,6 +151,15 @@ async function prepared(adapter: BackendAdapter, req: Parameters<NonNullable<Bac
|
|
|
124
151
|
}
|
|
125
152
|
}
|
|
126
153
|
|
|
154
|
+
/** Refuses a dispatch into a run `runs supersede` closed, naming the run that took over. */
|
|
155
|
+
function assertNotSuperseded(run: Run): void {
|
|
156
|
+
const closed = supersededBy(run);
|
|
157
|
+
if (closed)
|
|
158
|
+
throw new CatherdError("E_RUN_NOT_LIVE", `run ${run.id} is superseded by ${closed.by}`, {
|
|
159
|
+
fix: `dispatch in run ${closed.by}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
127
163
|
/**
|
|
128
164
|
* Spec §4.4 step 1. The checks that read shared state and the write that makes the dispatch live
|
|
129
165
|
* happen under the run's admission lock, so parallel dispatches always see each other (audit C1).
|
|
@@ -136,8 +172,12 @@ export async function admit(
|
|
|
136
172
|
): Promise<{ d: Dispatch; specPath: string }> {
|
|
137
173
|
assertId("role name", i.name);
|
|
138
174
|
if (i.lane !== null) assertId("lane", i.lane);
|
|
175
|
+
// a machine or workspace pause refuses every dispatch it covers, with its reason (spec 1.5 "Group pause")
|
|
176
|
+
assertNotPaused(run);
|
|
177
|
+
assertNotSuperseded(run);
|
|
139
178
|
const rung = parseRung(i.rung);
|
|
140
|
-
|
|
179
|
+
// spec 1.5: the run's pinned profile, access and isolation, whatever the repo runs on now
|
|
180
|
+
const profile = runProfile(deps, run);
|
|
141
181
|
const rc = profile.roles[i.role];
|
|
142
182
|
if (!rc?.enabled)
|
|
143
183
|
throw new CatherdError("E_ADMIT_RUNG", `the ${i.role} role is off in profile ${profile.name}`, {
|
|
@@ -147,11 +187,15 @@ export async function admit(
|
|
|
147
187
|
throw new CatherdError("E_ADMIT_RUNG", `${i.rung} is a native Claude rung`, {
|
|
148
188
|
fix: `run it as Agent(subagent_type: "${deps.profiles.agentFor(run.meta.repo, i.role, i.rung)}"), then record_agent_run`,
|
|
149
189
|
});
|
|
150
|
-
const
|
|
190
|
+
const ladder = [
|
|
151
191
|
...rc.rungs,
|
|
152
192
|
...rc.rungs.flatMap((r) => standInFor(profile.failover, r, run.meta.repo) ?? []),
|
|
153
|
-
]
|
|
154
|
-
|
|
193
|
+
];
|
|
194
|
+
// the daemon runs every role headless (spec 2.0 decision 12): a native rung's headless twin is on its ladder too
|
|
195
|
+
const allowed = new Set([...ladder, ...ladder.map(headlessRung)]);
|
|
196
|
+
// a rung the daemon failed a limit over to may be on any configured backend (spec 2.0 decision 6)
|
|
197
|
+
const failedOver = i.failoverFrom !== null && failoverBackends(profile).has(rung.backend);
|
|
198
|
+
if (!allowed.has(formatRung(rung)) && !failedOver)
|
|
155
199
|
throw new CatherdError(
|
|
156
200
|
"E_ADMIT_RUNG",
|
|
157
201
|
`${i.rung} is not on the ${i.role} ladder of profile ${profile.name}`,
|
|
@@ -174,7 +218,7 @@ export async function admit(
|
|
|
174
218
|
`${adapter.id} keeps the access a thread started with: ${i.thread} ran ${started.access}, and the ${i.role} role runs ${rc.access}`,
|
|
175
219
|
{ fix: "dispatch a fresh thread (omit `thread`)" },
|
|
176
220
|
);
|
|
177
|
-
// and the network grant:
|
|
221
|
+
// and the network grant: a CLI that keeps a session's sandbox profile keeps it.
|
|
178
222
|
// A record from before 1.3 carries no grant to compare.
|
|
179
223
|
if (started?.network !== undefined && adapter.resume.sameAccessOnly && started.network !== network)
|
|
180
224
|
throw new CatherdError(
|
|
@@ -183,31 +227,52 @@ export async function admit(
|
|
|
183
227
|
{ fix: "dispatch a fresh thread (omit `thread`)" },
|
|
184
228
|
);
|
|
185
229
|
const owns = i.lane === null ? [] : laneOwns(run, i.lane);
|
|
230
|
+
// spec 1.5 plan 21: the writer's implicit docs lane, for attribution only
|
|
231
|
+
const ownsImplicit = i.role === "writer" && i.lane === null ? (briefOwns(i.brief) ?? DOCS_OWNS) : null;
|
|
232
|
+
// spec 1.5 "Lane editing": an After: line orders lanes that compile against each other
|
|
233
|
+
const before = i.lane === null ? [] : unfinishedAfter(run, i.lane);
|
|
234
|
+
if (before.length)
|
|
235
|
+
throw new CatherdError(
|
|
236
|
+
"E_ADMIT_ORDER",
|
|
237
|
+
`${i.lane} runs after ${before.join(", ")} (its After: line), which ${before.length > 1 ? "have" : "has"} not finished`,
|
|
238
|
+
{ fix: `dispatch ${i.lane} once ${before.join(", ")} ${before.length > 1 ? "end" : "ends"} ok` },
|
|
239
|
+
);
|
|
186
240
|
const id = newDispatchId();
|
|
187
241
|
const dir = join(roleDir(run, i.name), id);
|
|
188
242
|
const p = dispatchPaths(dir);
|
|
189
243
|
const isolated = started?.isolated ?? profile.isolated[rung.backend] ?? false;
|
|
244
|
+
// spec 1.5 plan 21 (#42 findings 1, 2): the role server goes in whether the harness is isolated or not, so no
|
|
245
|
+
// role is refused for isolation, and a thread an isolated run started resumes as it started
|
|
246
|
+
const roleServer = ROLE_SERVER_BACKENDS.includes(rung.backend);
|
|
247
|
+
const cwd = i.cwd ?? run.meta.repo;
|
|
190
248
|
await prepared(adapter, {
|
|
191
249
|
rung,
|
|
192
250
|
access: rc.access,
|
|
193
251
|
isolated,
|
|
194
|
-
repo:
|
|
252
|
+
repo: cwd,
|
|
195
253
|
network,
|
|
196
254
|
});
|
|
197
|
-
const plan = adapter.plan({
|
|
198
|
-
rung,
|
|
199
|
-
access: rc.access,
|
|
200
|
-
network,
|
|
201
|
-
thread: i.thread,
|
|
202
|
-
isolated,
|
|
203
|
-
repo: run.meta.repo,
|
|
204
|
-
briefPath: p.brief,
|
|
205
|
-
replyPath: p.reply,
|
|
206
|
-
dispatchDir: dir,
|
|
207
|
-
});
|
|
208
255
|
|
|
209
256
|
await finalizeFinished(run, deps.now(), onRecorded);
|
|
210
|
-
return
|
|
257
|
+
return withRunAdmission(run, deps.now, async () => {
|
|
258
|
+
// again under the lock `runs supersede` writes under: a supersede that landed since the check above wins
|
|
259
|
+
assertNotSuperseded(run);
|
|
260
|
+
// the scratch is created under the admission lock `catherd runs clean` takes too, so cleanup never removes
|
|
261
|
+
// the scratch of a dispatch it does not see live yet (PR #46)
|
|
262
|
+
const scratch = roleScratch(run, i.name, rung.backend, isolated);
|
|
263
|
+
const plan = adapter.plan({
|
|
264
|
+
rung,
|
|
265
|
+
access: rc.access,
|
|
266
|
+
network,
|
|
267
|
+
thread: i.thread,
|
|
268
|
+
isolated,
|
|
269
|
+
repo: cwd,
|
|
270
|
+
briefPath: p.brief,
|
|
271
|
+
replyPath: p.reply,
|
|
272
|
+
dispatchDir: dir,
|
|
273
|
+
...(roleServer ? { roleMcp: { run: run.id, role: i.role } } : {}),
|
|
274
|
+
...(scratch ? { scratch } : {}),
|
|
275
|
+
});
|
|
211
276
|
// A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
|
|
212
277
|
// tree after the exit, so a later dispatch's writes must not land in between. A finished one here
|
|
213
278
|
// could not be recorded just now. The records and the pending dispatches are one snapshot, taken
|
|
@@ -230,7 +295,9 @@ export async function admit(
|
|
|
230
295
|
},
|
|
231
296
|
);
|
|
232
297
|
for (const d of pending) {
|
|
233
|
-
|
|
298
|
+
// a running lane holds what owns_add granted it since its admission, too
|
|
299
|
+
const held = d.admit.lane ? [...d.admit.owns, ...currentOwns(run, d.admit.lane)] : d.admit.owns;
|
|
300
|
+
const shared = overlaps(owns, held);
|
|
234
301
|
if (shared.length)
|
|
235
302
|
throw new CatherdError(
|
|
236
303
|
"E_ADMIT_OVERLAP",
|
|
@@ -246,7 +313,7 @@ export async function admit(
|
|
|
246
313
|
throw new CatherdError("E_RUN_BUDGET", `the run budget is spent: ${formatBudget(budget)}`, {
|
|
247
314
|
fix: "ask the user to raise profile.budget, or finish the run with what landed",
|
|
248
315
|
});
|
|
249
|
-
const before = await statusSnapshot(
|
|
316
|
+
const before = await statusSnapshot(cwd);
|
|
250
317
|
const session = currentSession(deps);
|
|
251
318
|
const sessionId = i.sessionId !== undefined ? i.sessionId : (session?.sessionId ?? null);
|
|
252
319
|
const admitted: Admit = {
|
|
@@ -257,6 +324,7 @@ export async function admit(
|
|
|
257
324
|
role: i.role,
|
|
258
325
|
lane: i.lane,
|
|
259
326
|
owns,
|
|
327
|
+
...(ownsImplicit ? { ownsImplicit } : {}),
|
|
260
328
|
rung: formatRung(rung),
|
|
261
329
|
backend: rung.backend,
|
|
262
330
|
thread: i.thread,
|
|
@@ -268,13 +336,32 @@ export async function admit(
|
|
|
268
336
|
network,
|
|
269
337
|
cliVersion: probe.version,
|
|
270
338
|
admittedAt: new Date(deps.now()).toISOString(),
|
|
271
|
-
repo:
|
|
339
|
+
repo: cwd,
|
|
272
340
|
before,
|
|
273
341
|
...(sessionId ? { sessionId, host: i.host ?? session?.host ?? "claude-code" } : {}),
|
|
274
342
|
};
|
|
275
343
|
ensurePrivateDir(dir);
|
|
276
|
-
//
|
|
277
|
-
|
|
344
|
+
// plan 34: the repo's standing rules for this role; a knowledge file that cannot be read adds nothing
|
|
345
|
+
const rules = await standingRules(run.meta.repo, i.role).catch(() => "");
|
|
346
|
+
// plan 23: the verifier runs with the repo's gate environment (DOCKER_HOST, a proxy, …)
|
|
347
|
+
const gate =
|
|
348
|
+
i.role === "verifier" ? gateEnvParts(await readGateEnv(run.meta.repo)) : { values: {}, refs: {} };
|
|
349
|
+
// spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included; spec 1.5 plan 21:
|
|
350
|
+
// before it, the lane file as it stands now, and who the role is, its scratch and its catherd tools; plan 23:
|
|
351
|
+
// a verifier's brief also carries the gate's rules and the run's recorded items
|
|
352
|
+
writeTextAtomic(
|
|
353
|
+
p.brief,
|
|
354
|
+
composeBrief(await verifierBrief(run, i.role, i.name, i.brief), {
|
|
355
|
+
run: run.id,
|
|
356
|
+
name: i.name,
|
|
357
|
+
role: i.role,
|
|
358
|
+
lane: i.lane === null ? null : { id: i.lane, text: readFileSync(laneFile(run, i.lane), "utf8") },
|
|
359
|
+
scratch,
|
|
360
|
+
roleServer,
|
|
361
|
+
version: deps.version,
|
|
362
|
+
context: [planContext(run, i.role), rulesBlock(rules, i.role)].filter(Boolean).join("\n\n") || null,
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
278
365
|
// Spec §10.4: the adapter's overrides only; the supervisor adds its own inherited env at spawn
|
|
279
366
|
// time (src/entry/supervise-command.ts), so no credential is ever written to disk. 0600 all the same.
|
|
280
367
|
writeJsonAtomic(
|
|
@@ -285,11 +372,26 @@ export async function admit(
|
|
|
285
372
|
dispatchDir: dir,
|
|
286
373
|
cmd: plan.cmd,
|
|
287
374
|
args: plan.args,
|
|
288
|
-
|
|
375
|
+
// spec 1.5 plan 21: the supervisor gives the role its identity and, where granted, its scratch TMPDIR;
|
|
376
|
+
// plan 23: a `catherd lock` in the role reports to this dispatch, so a long gate keeps its wall alive
|
|
377
|
+
// plan 23: each dispatch its own testcontainers session, so parallel lanes never share a reaper;
|
|
378
|
+
// the gate env never overrides the role's identity, scratch, dispatch id, PWD or isolated home
|
|
379
|
+
env: {
|
|
380
|
+
...gate.values,
|
|
381
|
+
...plan.env,
|
|
382
|
+
[ROLE_ENV]: formatRoleScope({ run: run.id, name: i.name }),
|
|
383
|
+
...(scratch ? { TMPDIR: scratch } : {}),
|
|
384
|
+
[DISPATCH_ID_ENV]: id,
|
|
385
|
+
TESTCONTAINERS_SESSION_ID: id,
|
|
386
|
+
PWD: plan.cwd,
|
|
387
|
+
},
|
|
388
|
+
// a secret of the gate env by reference only: the supervisor reads it from its own env at spawn
|
|
389
|
+
...(Object.keys(gate.refs).length ? { envFrom: gate.refs } : {}),
|
|
289
390
|
cwd: plan.cwd,
|
|
290
391
|
stdinPath: plan.stdinPath,
|
|
291
|
-
|
|
292
|
-
|
|
392
|
+
// plan 23: a role's own timeouts win over the profile's (a verifier's long gate)
|
|
393
|
+
idleMs: roleTimeouts(profile, rc).idleMin * 60_000,
|
|
394
|
+
wallMs: roleTimeouts(profile, rc).wallMin * 60_000,
|
|
293
395
|
killGraceMs: KILL_GRACE_MS,
|
|
294
396
|
graceAfterFinalMs: adapter.graceAfterFinalMs,
|
|
295
397
|
pollMs: deps.pollMs,
|
package/src/services/backends.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { adapterFor } from "../adapters/registry.ts";
|
|
|
5
5
|
import "../adapters/all.ts";
|
|
6
6
|
import { CatherdError } from "../domain/errors.ts";
|
|
7
7
|
import { formatRung, tryParseRung } from "../domain/ids.ts";
|
|
8
|
-
import { PAIRED_FAILOVER } from "../domain/profile.ts";
|
|
9
8
|
|
|
10
9
|
const READY_TTL_MS = 10 * 60_000;
|
|
11
10
|
const ready = new Map<string, { at: number; probe: Probe }>();
|
|
@@ -17,7 +16,7 @@ export const resetReadiness = (): void => ready.clear();
|
|
|
17
16
|
function execFormatError(e: unknown): string | null {
|
|
18
17
|
if (!(e instanceof Error)) return null;
|
|
19
18
|
if ((e as { code?: unknown }).code !== "ENOEXEC" && !/exec format error/i.test(e.message)) return null;
|
|
20
|
-
// macOS names the path (posix_spawn '/…/
|
|
19
|
+
// macOS names the path (posix_spawn '/…/bin'), Linux only the command (uv_spawn 'bin')
|
|
21
20
|
const bin = /spawn '([^']+)'/.exec(e.message)?.[1];
|
|
22
21
|
return bin ? (Bun.which(bin, { PATH: process.env.PATH ?? "" }) ?? bin) : "exec format error";
|
|
23
22
|
}
|
|
@@ -64,18 +63,17 @@ export async function readyAdapter(backend: string): Promise<{ adapter: BackendA
|
|
|
64
63
|
const probe = await probeBackend(adapter);
|
|
65
64
|
const problem = probe.problems[0];
|
|
66
65
|
// spec 1.3 §3.3: a probe that finds the CLI logged out lists E_BACKEND_NOT_LOGGED_IN, so a dispatch never
|
|
67
|
-
// reaches it (
|
|
66
|
+
// reaches it (it may open a browser and wait); logged out alone is no problem where a CLI runs without a login
|
|
68
67
|
if (problem) throw new CatherdError(problem.code, problem.message, { fix: problem.fix });
|
|
69
68
|
ready.set(backend, { at: now, probe });
|
|
70
69
|
return { adapter, probe };
|
|
71
70
|
}
|
|
72
71
|
|
|
73
72
|
/**
|
|
74
|
-
* Spec §4.5: a rung's stand-in on a usage limit: the profile's, else
|
|
75
|
-
* (spec 1.3 §7.3), else its backend's default (for `repo`, when given), else none.
|
|
73
|
+
* Spec §4.5: a rung's stand-in on a usage limit: the profile's, else its backend's default (for `repo`, when given), else none.
|
|
76
74
|
*/
|
|
77
75
|
export function standInFor(failover: Record<string, string>, rung: string, repo?: string): string | null {
|
|
78
|
-
const own = failover[rung]
|
|
76
|
+
const own = failover[rung];
|
|
79
77
|
if (own) return own;
|
|
80
78
|
const r = tryParseRung(rung);
|
|
81
79
|
if (!r) return null;
|
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
|
}
|