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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
4
|
+
import {
|
|
5
|
+
passBar,
|
|
6
|
+
SCORECARD_TASKS,
|
|
7
|
+
type ScorecardFile,
|
|
8
|
+
type ScorecardTaskId,
|
|
9
|
+
type Verdict,
|
|
10
|
+
} from "../domain/scorecard.ts";
|
|
11
|
+
import {
|
|
12
|
+
defaultPreparedRoot,
|
|
13
|
+
loadTask,
|
|
14
|
+
type Prepared,
|
|
15
|
+
prepareTask,
|
|
16
|
+
readScorecard,
|
|
17
|
+
resultsFile,
|
|
18
|
+
saveTaskScore,
|
|
19
|
+
scoreTask,
|
|
20
|
+
scorecardRoot,
|
|
21
|
+
} from "../services/scorecard.ts";
|
|
22
|
+
import { JSON_ARG, mark, printJson, refuseInRole } from "./cli-kit.ts";
|
|
23
|
+
|
|
24
|
+
const BASELINE = "1.5.0";
|
|
25
|
+
|
|
26
|
+
/** POSIX single-quote escaping; a value of plain path characters stays bare. */
|
|
27
|
+
const quote = (v: string): string => (/^[\w@%+=:,./-]+$/.test(v) ? v : `'${v.replaceAll("'", `'\\''`)}'`);
|
|
28
|
+
|
|
29
|
+
/** The prepared task as the operator needs it: where to start, what to paste, and how to record. */
|
|
30
|
+
export function formatPrepared(p: Prepared, label = "<label>"): string[] {
|
|
31
|
+
const env =
|
|
32
|
+
p.limits && p.shim ? `CATHERD_SCORECARD_LIMITS=${quote(p.limits)} PATH=${quote(p.shim)}:"$PATH" ` : "";
|
|
33
|
+
return [
|
|
34
|
+
`${mark("ok")} ${p.task} prepared in ${p.dir} at ${p.startCommit.slice(0, 8)}`,
|
|
35
|
+
` start: cd ${quote(p.repo)} && ${env}claude`,
|
|
36
|
+
` prompt: ${p.prompt}`,
|
|
37
|
+
` record: catherd scorecard record ${quote(p.dir)} --label ${label}`,
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const yes = (b: boolean): string => (b ? "yes" : "no");
|
|
42
|
+
const num = (n: number): string => String(Math.round(n * 10) / 10);
|
|
43
|
+
const k = (n: number): string => `${Math.round(n / 1000)}k`;
|
|
44
|
+
|
|
45
|
+
function row(id: ScorecardTaskId, current: ScorecardFile, baseline: ScorecardFile | null): string {
|
|
46
|
+
const s = current.tasks[id];
|
|
47
|
+
if (!s) return `${id} -`;
|
|
48
|
+
const was = baseline?.tasks[id];
|
|
49
|
+
const input = s.quota.backends.reduce((n, b) => n + b.input, 0);
|
|
50
|
+
const output = s.quota.backends.reduce((n, b) => n + b.output, 0);
|
|
51
|
+
return [
|
|
52
|
+
id,
|
|
53
|
+
s.catherdVersion,
|
|
54
|
+
`finished ${yes(s.finished)}`,
|
|
55
|
+
`interventions ${s.interventions.total}`,
|
|
56
|
+
`rounds ${s.reviewRounds}`,
|
|
57
|
+
`wall ${num(s.wallMinutes)} min (${was ? num(was.wallMinutes) : "?"} min)`,
|
|
58
|
+
`first try ${yes(s.firstTry)}`,
|
|
59
|
+
`quota ${k(input)}/${k(output)}`,
|
|
60
|
+
].join(" ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatComparison(
|
|
64
|
+
current: ScorecardFile,
|
|
65
|
+
baseline: ScorecardFile | null,
|
|
66
|
+
v: Verdict,
|
|
67
|
+
): string[] {
|
|
68
|
+
return [
|
|
69
|
+
`scorecard ${current.label} against ${baseline ? baseline.label : "no baseline"}`,
|
|
70
|
+
...SCORECARD_TASKS.map((id) => row(id, current, baseline)),
|
|
71
|
+
...(v.pass ? ["PASS"] : v.failures.map((f) => `FAIL: ${f}`)),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The contributor commands read fixtures from the checkout; an installed package ships none. */
|
|
76
|
+
function checkoutRoot(): string {
|
|
77
|
+
const root = scorecardRoot();
|
|
78
|
+
if (!root)
|
|
79
|
+
throw new CatherdError("E_INPUT_INVALID", "this catherd is not a source checkout", {
|
|
80
|
+
fix: "run it from a catherd checkout: bun src/cli.ts scorecard …",
|
|
81
|
+
});
|
|
82
|
+
return root;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const stamp = (d: Date): string => d.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
86
|
+
|
|
87
|
+
const RESULTS_ARG = {
|
|
88
|
+
results: { type: "string", description: "results folder (default: scorecard/results)" },
|
|
89
|
+
} as const;
|
|
90
|
+
const resultsDir = (root: string, given: string | undefined): string =>
|
|
91
|
+
given ? resolve(given) : join(root, "results");
|
|
92
|
+
|
|
93
|
+
const prepare = defineCommand({
|
|
94
|
+
meta: { name: "prepare", description: `Prepare a task's starting point: ${SCORECARD_TASKS.join(", ")}` },
|
|
95
|
+
args: {
|
|
96
|
+
task: { type: "positional", required: true, description: SCORECARD_TASKS.join("|") },
|
|
97
|
+
source: {
|
|
98
|
+
type: "string",
|
|
99
|
+
description: "the repository to clone (a path or URL); the Sanitell task needs one",
|
|
100
|
+
},
|
|
101
|
+
dir: { type: "string", description: "where prepared tasks go (default: catherd's data dir)" },
|
|
102
|
+
setup: {
|
|
103
|
+
type: "boolean",
|
|
104
|
+
default: true,
|
|
105
|
+
description: "run the task's setup commands (--no-setup skips them)",
|
|
106
|
+
},
|
|
107
|
+
...JSON_ARG,
|
|
108
|
+
},
|
|
109
|
+
async run({ args }) {
|
|
110
|
+
refuseInRole(process.env, "catherd scorecard prepare");
|
|
111
|
+
const root = checkoutRoot();
|
|
112
|
+
const task = loadTask(args.task, root);
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const into = join(args.dir ? resolve(args.dir) : defaultPreparedRoot(), `${task.id}-${stamp(now)}`);
|
|
115
|
+
const p = await prepareTask({ task, root, into, source: args.source, now, setup: args.setup });
|
|
116
|
+
if (args.json) return printJson(p);
|
|
117
|
+
for (const line of formatPrepared(p)) console.log(line);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const record = defineCommand({
|
|
122
|
+
meta: { name: "record", description: "Score a prepared task's run and store its row under a label" },
|
|
123
|
+
args: {
|
|
124
|
+
dir: { type: "positional", required: true, description: "the folder `prepare` printed" },
|
|
125
|
+
label: {
|
|
126
|
+
type: "string",
|
|
127
|
+
required: true,
|
|
128
|
+
description: "a version or plan name: 1.5.0, plan-29, 2.0.0-beta.1",
|
|
129
|
+
},
|
|
130
|
+
run: { type: "string", description: "the run to score, when the folder holds more than one" },
|
|
131
|
+
chat: { type: "string", description: "messages you typed into the session after the start prompt" },
|
|
132
|
+
...RESULTS_ARG,
|
|
133
|
+
...JSON_ARG,
|
|
134
|
+
},
|
|
135
|
+
async run({ args }) {
|
|
136
|
+
refuseInRole(process.env, "catherd scorecard record");
|
|
137
|
+
const chat = args.chat === undefined ? 0 : Number(args.chat);
|
|
138
|
+
if (!Number.isInteger(chat) || chat < 0)
|
|
139
|
+
throw new CatherdError("E_INPUT_INVALID", `--chat ${args.chat} is not a count`, {
|
|
140
|
+
fix: "--chat <the number of messages you typed into the session>",
|
|
141
|
+
});
|
|
142
|
+
const root = checkoutRoot();
|
|
143
|
+
const out = resultsFile(resultsDir(root, args.results), args.label);
|
|
144
|
+
const s = await scoreTask({ dir: resolve(args.dir), root, run: args.run, chat, now: Date.now() });
|
|
145
|
+
const saved = saveTaskScore(out, args.label, s);
|
|
146
|
+
if (args.json) return printJson({ file: out, score: s });
|
|
147
|
+
const baseline = readScorecard(resultsFile(resultsDir(root, args.results), BASELINE));
|
|
148
|
+
console.log(
|
|
149
|
+
formatComparison(saved, baseline, passBar(saved, baseline))[1 + SCORECARD_TASKS.indexOf(s.task)],
|
|
150
|
+
);
|
|
151
|
+
console.log(out);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const compare = defineCommand({
|
|
156
|
+
meta: {
|
|
157
|
+
name: "compare",
|
|
158
|
+
description: `Check a label against the baseline (${BASELINE}) by the spec's pass bar`,
|
|
159
|
+
},
|
|
160
|
+
args: {
|
|
161
|
+
label: { type: "positional", required: true, description: "the label to check" },
|
|
162
|
+
baseline: { type: "string", description: `the label to compare with (default: ${BASELINE})` },
|
|
163
|
+
...RESULTS_ARG,
|
|
164
|
+
...JSON_ARG,
|
|
165
|
+
},
|
|
166
|
+
run({ args }) {
|
|
167
|
+
const dir = resultsDir(checkoutRoot(), args.results);
|
|
168
|
+
const current = readScorecard(resultsFile(dir, args.label));
|
|
169
|
+
if (!current)
|
|
170
|
+
throw new CatherdError("E_INPUT_INVALID", `no scorecard ${args.label} yet`, {
|
|
171
|
+
fix: `catherd scorecard record <dir> --label ${args.label}`,
|
|
172
|
+
});
|
|
173
|
+
const baseline = readScorecard(resultsFile(dir, args.baseline ?? BASELINE));
|
|
174
|
+
const verdict = passBar(current, baseline);
|
|
175
|
+
process.exitCode = verdict.pass ? 0 : 1;
|
|
176
|
+
if (args.json) return printJson({ current, baseline, verdict });
|
|
177
|
+
for (const line of formatComparison(current, baseline, verdict)) console.log(line);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
export const scorecardCommand = defineCommand({
|
|
182
|
+
meta: {
|
|
183
|
+
name: "scorecard",
|
|
184
|
+
description: "Contributors: measure a catherd version on four fixed tasks against the 1.5.0 baseline",
|
|
185
|
+
},
|
|
186
|
+
subCommands: { prepare, record, compare },
|
|
187
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// The detached supervisor's entry: it imports only what supervising needs, never src/cli.ts
|
|
2
|
+
// The detached supervisor's entry: it imports only what supervising needs, never src/cli.ts.
|
|
3
3
|
import { errorMessage } from "../domain/errors.ts";
|
|
4
4
|
import { runSupervise } from "./supervise-command.ts";
|
|
5
5
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { adapterFor } from "../adapters/registry.ts";
|
|
3
3
|
import { workerEnv } from "../infra/env.ts";
|
|
4
|
+
import { log } from "../infra/log.ts";
|
|
5
|
+
import { loginEnv } from "../infra/login-env.ts";
|
|
4
6
|
import { readVersioned } from "../infra/store.ts";
|
|
5
7
|
import { SuperviseSpecSchema, supervise } from "../infra/supervisor.ts";
|
|
8
|
+
import { resolveRefs } from "../services/gate-env.ts";
|
|
6
9
|
import "../adapters/all.ts";
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -10,22 +13,46 @@ import "../adapters/all.ts";
|
|
|
10
13
|
* spec.json holds only the adapter's env overrides; the worker's env is built here, from this process's
|
|
11
14
|
* own inherited env (already without catherd's secrets), so no credential is ever on disk (spec §10.4).
|
|
12
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* The worker's env: this process's env, the gate env's references (plan 23: read here from this env, else the
|
|
18
|
+
* login env), then the spec's own env last, so no reference ever takes the role's identity, scratch or home.
|
|
19
|
+
*/
|
|
20
|
+
export function superviseEnv(
|
|
21
|
+
read: { env: Record<string, string>; envFrom?: Record<string, string>; cwd: string; dispatchDir?: string },
|
|
22
|
+
base: Record<string, string | undefined>,
|
|
23
|
+
login: () => Record<string, string | undefined>,
|
|
24
|
+
): Record<string, string> {
|
|
25
|
+
const refs = resolveRefs({}, read.envFrom ?? {}, base, login);
|
|
26
|
+
if (refs.missing.length) log("warn", "gate-env", { dispatch: read.dispatchDir, unset: refs.missing });
|
|
27
|
+
return workerEnv(base, { ...refs.env, ...read.env }, read.cwd);
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
export async function runSupervise(specPath: string): Promise<void> {
|
|
14
31
|
const read = readVersioned(specPath, SuperviseSpecSchema, 1);
|
|
15
|
-
const spec = { ...read, env:
|
|
32
|
+
const spec = { ...read, env: superviseEnv(read, process.env, loginEnv) };
|
|
16
33
|
const a = adapterFor(spec.backend);
|
|
17
34
|
const busy = a?.isBusy?.bind(a);
|
|
18
35
|
const stop = a?.interrupt?.bind(a);
|
|
19
|
-
|
|
36
|
+
const retrying = a?.providerRetry?.bind(a);
|
|
37
|
+
const ended = await supervise(spec, {
|
|
20
38
|
onLine: (line) => {
|
|
21
39
|
const d = a?.parse(line) ?? {};
|
|
22
|
-
return { final: d.final, thread: d.thread, item: d.item };
|
|
40
|
+
return { final: d.final, thread: d.thread, item: d.item, step: d.step };
|
|
23
41
|
},
|
|
24
42
|
isBusy: busy
|
|
25
43
|
? (thread, sinceMs) => (thread ? busy(thread, spec.cwd, sinceMs) : Promise.resolve(false))
|
|
26
44
|
: undefined,
|
|
27
45
|
interrupt: stop ? (thread) => (thread ? stop(thread, spec.cwd) : Promise.resolve()) : undefined,
|
|
46
|
+
providerRetry: retrying
|
|
47
|
+
? (thread, sinceMs) => (thread ? retrying(thread, spec.cwd, sinceMs) : Promise.resolve(null))
|
|
48
|
+
: undefined,
|
|
28
49
|
});
|
|
50
|
+
// plan 22: a Codex owner hears of the role from here too, when its thread's MCP server is gone. Loaded only
|
|
51
|
+
// once the worker ended (a supervisor that lost the lock to another pushes nothing); never throws.
|
|
52
|
+
if (ended && process.env.CATHERD_NO_END_PUSH !== "1") {
|
|
53
|
+
const { pushFromEnd } = await import("../services/end-push.ts");
|
|
54
|
+
await pushFromEnd(spec.dispatchDir);
|
|
55
|
+
}
|
|
29
56
|
}
|
|
30
57
|
|
|
31
58
|
export const superviseCommand = defineCommand({
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { inspectWorkspace, workspaceStatus } from "../services/workspace-service.ts";
|
|
4
|
+
import { JSON_ARG, printJson } from "./cli-kit.ts";
|
|
5
|
+
import { defaultDeps } from "./deps.ts";
|
|
6
|
+
|
|
7
|
+
const inspect = defineCommand({
|
|
8
|
+
meta: { name: "inspect", description: "Read the explicit repositories in a workspace manifest" },
|
|
9
|
+
args: {
|
|
10
|
+
root: { type: "positional", description: "workspace root (default: current directory)" },
|
|
11
|
+
...JSON_ARG,
|
|
12
|
+
},
|
|
13
|
+
async run({ args }) {
|
|
14
|
+
const view = await inspectWorkspace(resolve(args.root ?? process.cwd()));
|
|
15
|
+
if (args.json) return printJson(view);
|
|
16
|
+
console.log(view.root);
|
|
17
|
+
for (const [name, path] of Object.entries(view.repos)) console.log(`${name}: ${path}`);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const status = defineCommand({
|
|
22
|
+
meta: { name: "status", description: "Read a workspace run's children, dependencies and budget" },
|
|
23
|
+
args: {
|
|
24
|
+
workspace: { type: "positional", required: true, description: "workspace run id" },
|
|
25
|
+
...JSON_ARG,
|
|
26
|
+
},
|
|
27
|
+
async run({ args }) {
|
|
28
|
+
const view = await workspaceStatus(defaultDeps(), args.workspace);
|
|
29
|
+
if (args.json) return printJson(view);
|
|
30
|
+
console.log(`${view.workspace.id}: ${view.workspace.title}`);
|
|
31
|
+
for (const step of view.steps) {
|
|
32
|
+
const blocked = step.blockedBy.length ? ` (waiting for ${step.blockedBy.join(", ")})` : "";
|
|
33
|
+
console.log(`${step.id}: ${step.repo} ${step.state}${step.run ? ` ${step.run}` : ""}${blocked}`);
|
|
34
|
+
}
|
|
35
|
+
console.log(
|
|
36
|
+
`spend: ${view.spend.tokens} tokens, ${view.spend.minutes.toFixed(2)} min, $${view.spend.usd.toFixed(2)}`,
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const workspaceCommand = defineCommand({
|
|
42
|
+
meta: { name: "workspace", description: "Inspect workspace members and coordinated runs" },
|
|
43
|
+
subCommands: { inspect, status },
|
|
44
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { tryLock } from "./filelock.ts";
|
|
3
|
+
import { log } from "./log.ts";
|
|
4
|
+
import { locksDir } from "./paths.ts";
|
|
5
|
+
import { ensurePrivateDir } from "./store.ts";
|
|
6
|
+
|
|
7
|
+
/** The machine-wide lock the MCP server that runs the boot sync and reconcile holds for its life. */
|
|
8
|
+
export const bootLockTarget = (): string => join(locksDir(), "mcp-boot");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Plan 22 (the three MCP servers of one Codex session): boot sync and reconcile are single-flight across
|
|
12
|
+
* processes. The first server takes this lock (its pid and start time in it; a dead holder's is reclaimed) and
|
|
13
|
+
* keeps it until it closes; a second server finds it held and skips both. Returns the release, or null while
|
|
14
|
+
* another live server holds it. A lock that cannot be taken for another reason never costs a reconcile: the
|
|
15
|
+
* server runs the boot work, with nothing to release.
|
|
16
|
+
*/
|
|
17
|
+
export function takeBootLock(): (() => void) | null {
|
|
18
|
+
try {
|
|
19
|
+
ensurePrivateDir(locksDir());
|
|
20
|
+
return tryLock(bootLockTarget());
|
|
21
|
+
} catch (e) {
|
|
22
|
+
log("warn", "boot", { lock: bootLockTarget(), error: e instanceof Error ? e.message : String(e) });
|
|
23
|
+
return () => {};
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/infra/delivery.ts
CHANGED
|
@@ -24,6 +24,9 @@ const AttemptSchema = z
|
|
|
24
24
|
.refine((a) => a.status !== "accepted" || Boolean(a.msgId?.trim()), "accepted attempt needs a receipt");
|
|
25
25
|
const DeliverySchema = z.looseObject({ schema: z.literal(1), attempts: z.array(AttemptSchema) });
|
|
26
26
|
export type DeliveryAttempt = z.infer<typeof AttemptSchema>;
|
|
27
|
+
/** How a delivery to a role's thread is recorded (spec 1.5 plan 21); `status` warns about it. */
|
|
28
|
+
export const ROLE_THREAD_REFUSAL = "refused: the target is a role's thread";
|
|
29
|
+
|
|
27
30
|
export type DeliveryState = "pending" | "enqueue-accepted" | "ambiguous" | "collected";
|
|
28
31
|
|
|
29
32
|
export function readDelivery(dir: string): DeliveryAttempt[] {
|
|
@@ -45,6 +45,8 @@ export function dispatchPaths(dir: string) {
|
|
|
45
45
|
stall: join(dir, "stall.json"),
|
|
46
46
|
/** the message that announced the stall, once sent */
|
|
47
47
|
stallNotified: join(dir, "stall-notified.json"),
|
|
48
|
+
/** the role's own thread, written by the supervisor once the CLI names it (spec 1.5 plan 21) */
|
|
49
|
+
thread: join(dir, "thread.json"),
|
|
48
50
|
};
|
|
49
51
|
}
|
|
50
52
|
|
package/src/infra/env.ts
CHANGED
|
@@ -9,14 +9,15 @@ const SECRET_ENV = new Set([
|
|
|
9
9
|
"CODEX_SESSION_ID",
|
|
10
10
|
"CATHERD_ORCHESTRATION_HOST",
|
|
11
11
|
"TYPESAFE_API_KEY",
|
|
12
|
-
// spec 1.2 §9: the user's Artificial Analysis key reads scores for catherd alone
|
|
13
|
-
"ARTIFICIAL_ANALYSIS_API_KEY",
|
|
14
12
|
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
15
13
|
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
16
14
|
"CLAUDE_CODE_SESSION_ID",
|
|
17
15
|
"CLAUDE_CODE_HOST_SESSION_ID",
|
|
18
16
|
]);
|
|
19
17
|
|
|
18
|
+
/** Whether `name` is one of catherd's own secrets, which no process catherd starts ever gets. */
|
|
19
|
+
export const isCatherdSecret = (name: string): boolean => SECRET_ENV.has(name);
|
|
20
|
+
|
|
20
21
|
/** `base` without catherd's own secrets and without unset keys; for every process catherd starts. */
|
|
21
22
|
export function scrubSecrets(base: Record<string, string | undefined>): Record<string, string> {
|
|
22
23
|
const env: Record<string, string> = {};
|
|
@@ -62,11 +63,18 @@ const CHECK_ENV = new Set([
|
|
|
62
63
|
"CATHERD_LOCK_SLOTS",
|
|
63
64
|
]);
|
|
64
65
|
const CHECK_ENV_PATTERN = /^(LC_[A-Z_]+|XDG_[A-Z_]+_(HOME|DIR|DIRS))$/;
|
|
66
|
+
/**
|
|
67
|
+
* Plan 23: where the toolchains a check runs find their daemon, proxy and caches (DOCKER_HOST for OrbStack or
|
|
68
|
+
* rootless Docker, a proxy, Go's and pnpm's caches); without them a check that would run fails as if broken.
|
|
69
|
+
*/
|
|
70
|
+
const TOOL_ENV_PATTERN =
|
|
71
|
+
/^(DOCKER_(HOST|CONTEXT|CONFIG|CERT_PATH|TLS_VERIFY)|TESTCONTAINERS_[A-Z_]+|(HTTPS?|NO|ALL)_PROXY|(https?|no|all)_proxy|GO(PATH|CACHE|MODCACHE|FLAGS|PROXY|PRIVATE|NOSUMDB|TOOLCHAIN)|PNPM_HOME|npm_config_store_dir|BUN_INSTALL(_CACHE_DIR)?|NVM_DIR|JAVA_HOME|CARGO_HOME|RUSTUP_HOME)$/;
|
|
65
72
|
|
|
66
73
|
/** A strict allowlist of `base` plus PWD, for lane-authored shell text such as preflight checks. */
|
|
67
74
|
export function checkEnv(base: Record<string, string | undefined>, cwd: string): Record<string, string> {
|
|
68
75
|
const env: Record<string, string> = {};
|
|
69
76
|
for (const [k, v] of Object.entries(base))
|
|
70
|
-
if (v !== undefined && (CHECK_ENV.has(k) || CHECK_ENV_PATTERN.test(k)))
|
|
77
|
+
if (v !== undefined && (CHECK_ENV.has(k) || CHECK_ENV_PATTERN.test(k) || TOOL_ENV_PATTERN.test(k)))
|
|
78
|
+
env[k] = v;
|
|
71
79
|
return { ...env, PWD: cwd };
|
|
72
80
|
}
|
package/src/infra/git.ts
CHANGED
|
@@ -54,6 +54,12 @@ export async function gitToplevel(dir: string): Promise<string | null> {
|
|
|
54
54
|
return r.kind === "ok" ? r.out.trim() || null : null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The repo's `origin` remote URL, or null when it has none (or git cannot say). */
|
|
58
|
+
export async function gitOrigin(repo: string): Promise<string | null> {
|
|
59
|
+
const r = await git(repo, ["config", "--get", "remote.origin.url"]);
|
|
60
|
+
return (r.kind === "ok" && r.out.trim()) || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
/** The short HEAD, or null when git cannot say (no commit, not a repo, failure, timeout). */
|
|
58
64
|
export async function gitHead(repo: string, timeoutMs?: number): Promise<string | null> {
|
|
59
65
|
const r = await git(repo, ["rev-parse", "--short", "HEAD"], timeoutMs);
|
|
@@ -89,3 +95,67 @@ export async function statusSnapshot(repo: string, timeoutMs?: number): Promise<
|
|
|
89
95
|
for (const { xy, path } of parsePorcelainZ(r.out)) snap[path] = fingerprint(repo, xy, path);
|
|
90
96
|
return snap;
|
|
91
97
|
}
|
|
98
|
+
|
|
99
|
+
/** What a git command printed and how it ended, for the callers that show a failure (a commit hook's output). */
|
|
100
|
+
export interface GitOutput {
|
|
101
|
+
code: number | null;
|
|
102
|
+
out: string;
|
|
103
|
+
err: string;
|
|
104
|
+
timedOut: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `git -C repo <args>` with both streams, for a command that may run hooks (a commit): a long timeout, and no
|
|
109
|
+
* optional lock avoidance, since this one writes the index.
|
|
110
|
+
*/
|
|
111
|
+
export async function gitRun(repo: string, args: string[], timeoutMs = 300_000): Promise<GitOutput> {
|
|
112
|
+
let p: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
113
|
+
try {
|
|
114
|
+
p = Bun.spawn(["git", "-C", repo, ...args], {
|
|
115
|
+
stdin: "ignore",
|
|
116
|
+
stdout: "pipe",
|
|
117
|
+
stderr: "pipe",
|
|
118
|
+
env: { ...scrubSecrets(process.env), LC_ALL: "C" },
|
|
119
|
+
});
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { code: null, out: "", err: (e as Error).message, timedOut: false };
|
|
122
|
+
}
|
|
123
|
+
let timedOut = false;
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
timedOut = true;
|
|
126
|
+
p.kill("SIGKILL");
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
const out = new Response(p.stdout).text().catch(() => "");
|
|
129
|
+
const err = new Response(p.stderr).text().catch(() => "");
|
|
130
|
+
const code = await p.exited;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
return {
|
|
133
|
+
code: timedOut ? null : code,
|
|
134
|
+
out: timedOut ? "" : await out,
|
|
135
|
+
err: timedOut ? "" : await err,
|
|
136
|
+
timedOut,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `git fetch -q origin <base>` with a cap: catherd's one network git call (spec 2.0 decision 21). True when it
|
|
142
|
+
* exits 0; a failure never throws, the caller works from the last fetch and says so.
|
|
143
|
+
*/
|
|
144
|
+
export async function fetchBase(repo: string, base: string, timeoutMs = 60_000): Promise<boolean> {
|
|
145
|
+
const r = await gitRun(repo, ["fetch", "-q", "origin", base], timeoutMs);
|
|
146
|
+
return r.code === 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether `commit` is an ancestor of `ref`: true on exit 0, false on 1, null when git cannot tell. */
|
|
150
|
+
export async function isAncestor(repo: string, commit: string, ref: string): Promise<boolean | null> {
|
|
151
|
+
const r = await git(repo, ["merge-base", "--is-ancestor", commit, ref]);
|
|
152
|
+
if (r.kind === "ok") return true;
|
|
153
|
+
return r.kind === "failed" && r.exit === 1 ? false : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The checked-out branch, or null when HEAD is detached (or git cannot say). */
|
|
157
|
+
export async function currentBranch(repo: string): Promise<string | null> {
|
|
158
|
+
const r = await git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
159
|
+
const name = r.kind === "ok" ? r.out.trim() : "";
|
|
160
|
+
return name && name !== "HEAD" ? name : null;
|
|
161
|
+
}
|
package/src/infra/heavy-lock.ts
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { availableParallelism, loadavg } from "node:os";
|
|
2
4
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
5
|
+
import { roleScopeOfScratch } from "../domain/role-scope.ts";
|
|
6
|
+
import { tryLock, withFileLock } from "./filelock.ts";
|
|
4
7
|
import { locksDir } from "./paths.ts";
|
|
5
|
-
import {
|
|
8
|
+
import { isAlive, selfIdentity } from "./proc.ts";
|
|
9
|
+
import { ensurePrivateDir, writeJsonAtomic } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
/** Set in a locked command's env: a `catherd lock` inside it runs at once, holding nothing more. */
|
|
12
|
+
export const LOCK_HELD_ENV = "CATHERD_LOCK_HELD";
|
|
13
|
+
|
|
14
|
+
const envLoadWaitMs = Number(process.env.CATHERD_HEAVY_LOAD_WAIT_MS ?? 900_000);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Spec 2.0 decision 18: heavy slots wait while the load is above twice the cores, up to 15 minutes.
|
|
18
|
+
* CATHERD_HEAVY_LOAD_WAIT_MS overrides the wait (0 = none); the test preload sets 0, since a busy CI runner
|
|
19
|
+
* would otherwise stall every lock in the suite, spawned `catherd lock` processes included.
|
|
20
|
+
*/
|
|
21
|
+
export const heavyLimits = {
|
|
22
|
+
loadWaitMs: envLoadWaitMs >= 0 ? envLoadWaitMs : 900_000,
|
|
23
|
+
loadPollMs: 15_000,
|
|
24
|
+
load: (): number => loadavg()[0] ?? 0,
|
|
25
|
+
cores: (): number => availableParallelism(),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The emptied TMPDIR and GOTMPDIR of one slot: under the role's scratch when the caller's TMPDIR is one (a role
|
|
30
|
+
* is told by its TMPDIR, so the slot dir must stay under it), else under catherd's locks dir.
|
|
31
|
+
*/
|
|
32
|
+
export function slotTmpDir(slot: number, inherited?: string): string {
|
|
33
|
+
return roleScopeOfScratch(inherited)
|
|
34
|
+
? join(inherited as string, `heavy-${slot}`)
|
|
35
|
+
: join(locksDir(), "tmp", `slot-${slot}`);
|
|
36
|
+
}
|
|
6
37
|
|
|
7
38
|
/** A profile's `lock.heavy` as a slot count: a number, or half the cores. */
|
|
8
39
|
export function heavySlots(setting: number | "cpus/2" | undefined): number {
|
|
@@ -16,21 +47,117 @@ export function heavySlots(setting: number | "cpus/2" | undefined): number {
|
|
|
16
47
|
*/
|
|
17
48
|
export async function withHeavySlot<T>(
|
|
18
49
|
slots: number,
|
|
19
|
-
fn: (slot: number) => T | Promise<T>,
|
|
20
|
-
o: { pollMs?: number } = {},
|
|
50
|
+
fn: (slot: number, tmp: string) => T | Promise<T>,
|
|
51
|
+
o: { pollMs?: number; onWait?: () => void; tmpBase?: string } = {},
|
|
21
52
|
): Promise<T> {
|
|
53
|
+
const r = await withHeavySlotWithin(slots, Number.POSITIVE_INFINITY, fn, o);
|
|
54
|
+
if (r.busy) throw new Error("unreachable: an unbounded wait never gives up");
|
|
55
|
+
return r.value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* withHeavySlot with a wait budget (plan 23): `{ busy: true }` when no slot came free within `waitMs`, so a
|
|
60
|
+
* caller with a deadline of its own (preflight) reports the lock as busy instead of timing out behind it.
|
|
61
|
+
*/
|
|
62
|
+
export async function withHeavySlotWithin<T>(
|
|
63
|
+
slots: number,
|
|
64
|
+
waitMs: number,
|
|
65
|
+
fn: (slot: number, tmp: string) => T | Promise<T>,
|
|
66
|
+
o: { pollMs?: number; onWait?: () => void; tmpBase?: string } = {},
|
|
67
|
+
): Promise<{ busy: false; value: T } | { busy: true }> {
|
|
22
68
|
const dir = locksDir();
|
|
23
69
|
ensurePrivateDir(dir);
|
|
70
|
+
const deadline = Date.now() + waitMs;
|
|
71
|
+
// an overloaded host runs the command late, not never: the wait is bounded, and the slot loop follows it
|
|
72
|
+
const loadDeadline = Date.now() + Math.min(heavyLimits.loadWaitMs, waitMs);
|
|
73
|
+
while (heavyLimits.load() > 2 * heavyLimits.cores() && Date.now() < loadDeadline) {
|
|
74
|
+
o.onWait?.();
|
|
75
|
+
await Bun.sleep(Math.min(heavyLimits.loadPollMs, Math.max(1, loadDeadline - Date.now())));
|
|
76
|
+
}
|
|
24
77
|
for (;;) {
|
|
25
78
|
for (let i = 0; i < slots; i++) {
|
|
26
79
|
const release = tryLock(join(dir, `slot-${i}`));
|
|
27
80
|
if (!release) continue;
|
|
81
|
+
const tmp = slotTmpDir(i, o.tmpBase);
|
|
28
82
|
try {
|
|
29
|
-
|
|
83
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
84
|
+
ensurePrivateDir(tmp);
|
|
85
|
+
return { busy: false, value: await fn(i, tmp) };
|
|
30
86
|
} finally {
|
|
87
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
31
88
|
release();
|
|
32
89
|
}
|
|
33
90
|
}
|
|
91
|
+
const left = deadline - Date.now();
|
|
92
|
+
if (left <= 0) return { busy: true };
|
|
93
|
+
o.onWait?.();
|
|
94
|
+
await Bun.sleep(Math.min(o.pollMs ?? 500, left));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A process inside a role lock: who, so a dead one is dropped, and a token, so one process can hold twice. */
|
|
99
|
+
interface RoleHolder {
|
|
100
|
+
pid: number;
|
|
101
|
+
startTime: string | null;
|
|
102
|
+
token: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface RoleLockState {
|
|
106
|
+
/** whose the lock is: a run id (holders of one run share it), or a lone process's own key */
|
|
107
|
+
owner: string;
|
|
108
|
+
holders: RoleHolder[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readRoleState(file: string): RoleLockState | null {
|
|
112
|
+
if (!existsSync(file)) return null;
|
|
113
|
+
try {
|
|
114
|
+
const v = JSON.parse(readFileSync(file, "utf8")) as RoleLockState;
|
|
115
|
+
return typeof v?.owner === "string" && Array.isArray(v.holders) ? v : null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Spec 1.5 "Cross-run verifier contention": one owner at a time machine-wide holds the lock of `role`.
|
|
123
|
+
* Commands of the same owner (one run's verifier, side by side within its slots) share it; another owner's
|
|
124
|
+
* wait until the last of them ends, so two runs' verifiers never overlap. A dead holder is dropped.
|
|
125
|
+
*/
|
|
126
|
+
export async function withRoleLock<T>(
|
|
127
|
+
role: string,
|
|
128
|
+
owner: string,
|
|
129
|
+
fn: () => T | Promise<T>,
|
|
130
|
+
o: { pollMs?: number; onWait?: () => void } = {},
|
|
131
|
+
): Promise<T> {
|
|
132
|
+
const dir = locksDir();
|
|
133
|
+
ensurePrivateDir(dir);
|
|
134
|
+
const file = join(dir, `role-${role}.json`);
|
|
135
|
+
const me: RoleHolder = { ...selfIdentity(), token: randomUUID() };
|
|
136
|
+
const enter = () =>
|
|
137
|
+
withFileLock(file, () => {
|
|
138
|
+
const state = readRoleState(file);
|
|
139
|
+
const live = (state?.holders ?? []).filter((h) => isAlive(h.pid, h.startTime));
|
|
140
|
+
if (live.length && state?.owner !== owner) return false;
|
|
141
|
+
writeJsonAtomic(file, { owner, holders: [...live, me] } satisfies RoleLockState);
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
while (!(await enter())) {
|
|
145
|
+
o.onWait?.();
|
|
34
146
|
await Bun.sleep(o.pollMs ?? 500);
|
|
35
147
|
}
|
|
148
|
+
try {
|
|
149
|
+
return await fn();
|
|
150
|
+
} finally {
|
|
151
|
+
await withFileLock(file, () => {
|
|
152
|
+
const state = readRoleState(file);
|
|
153
|
+
if (state)
|
|
154
|
+
writeJsonAtomic(file, { ...state, holders: state.holders.filter((h) => h.token !== me.token) });
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Who holds the role lock now, or null. */
|
|
160
|
+
export function roleLockOwner(role: string): string | null {
|
|
161
|
+
const state = readRoleState(join(locksDir(), `role-${role}.json`));
|
|
162
|
+
return state && state.holders.some((h) => isAlive(h.pid, h.startTime)) ? state.owner : null;
|
|
36
163
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Plan 23: a host probe that fails once may have caught the network mid-change (a VPN just dropped, a route
|
|
2
|
+
// still settling). catherd's own host probes run twice, this far apart, before they call the host blocked.
|
|
3
|
+
|
|
4
|
+
/** The pause before the second try; tests shorten it. */
|
|
5
|
+
export const hostProbe = { retryMs: 5_000 };
|
|
6
|
+
|
|
7
|
+
/** `probe`, and once more after hostProbe.retryMs when `ok` says the first answer failed. */
|
|
8
|
+
export async function probeTwice<T>(
|
|
9
|
+
probe: () => Promise<T>,
|
|
10
|
+
ok: (r: T) => boolean,
|
|
11
|
+
): Promise<{ result: T; attempts: 1 | 2 }> {
|
|
12
|
+
const first = await probe();
|
|
13
|
+
if (ok(first)) return { result: first, attempts: 1 };
|
|
14
|
+
await Bun.sleep(hostProbe.retryMs);
|
|
15
|
+
return { result: await probe(), attempts: 2 };
|
|
16
|
+
}
|