catherd-cli 1.5.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 +164 -208
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -247
- 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 -421
- package/plugin/skills/catherd-setup/SKILL.md +20 -19
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +1 -2
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +4 -4
- package/src/cli.ts +22 -27
- 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 +21 -6
- package/src/domain/catalog.ts +69 -324
- package/src/domain/cost.ts +15 -30
- package/src/domain/errors.ts +2 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -87
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +1 -0
- 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 +22 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +25 -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 +101 -160
- package/src/domain/profile.ts +116 -63
- package/src/domain/record.ts +53 -2
- package/src/domain/role-prompts.ts +131 -45
- package/src/domain/role-scope.ts +4 -34
- package/src/domain/role-tools.ts +2 -2
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +17 -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 +151 -146
- package/src/domain/slopalytics.ts +72 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +25 -174
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +1 -1
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/init-command.ts +18 -68
- package/src/entry/knowledge-command.ts +45 -4
- package/src/entry/lock-command.ts +6 -7
- package/src/entry/mcp/role-server.ts +18 -0
- package/src/entry/mcp/server.ts +13 -40
- 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/profile-command.ts +10 -14
- package/src/entry/role-cli-command.ts +47 -3
- package/src/entry/runs-command.ts +3 -19
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/infra/env.ts +0 -2
- package/src/infra/git.ts +64 -0
- package/src/infra/heavy-lock.ts +45 -7
- package/src/infra/launch.ts +63 -7
- 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 +2 -2
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/services/admission.ts +24 -9
- package/src/services/backends.ts +4 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -262
- package/src/services/credentials.ts +4 -20
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +5 -1
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor.ts +4 -9
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +12 -0
- package/src/services/gate-env.ts +1 -2
- package/src/services/gate-service.ts +21 -6
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-service.ts +34 -3
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +35 -8
- package/src/services/notifier.ts +69 -2
- package/src/services/notify-service.ts +26 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +30 -4
- package/src/services/preflight.ts +82 -8
- package/src/services/profile-service.ts +30 -3
- package/src/services/profile-store.ts +8 -3
- package/src/services/protocol.ts +4 -0
- package/src/services/provenance.ts +31 -90
- package/src/services/questions.ts +9 -0
- package/src/services/reconcile.ts +11 -0
- package/src/services/routing-service.ts +26 -15
- 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-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +55 -6
- package/src/services/run-store.ts +54 -7
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/summary.ts +27 -3
- package/src/services/tree-path.ts +27 -0
- package/src/services/workspace-admission.ts +5 -13
- package/src/services/workspace-service.ts +5 -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 -44
- 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 -335
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -187
- package/src/adapters/grok/index.ts +0 -358
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -132
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -131
- package/src/entry/mcp/protocol-tools.ts +0 -63
- package/src/entry/mcp/push-tools.ts +0 -16
- package/src/entry/mcp/run-tools.ts +0 -164
- package/src/entry/mcp/setup-tools.ts +0 -109
- package/src/entry/mcp/workspace-tools.ts +0 -142
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -385
- 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 -400
- 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 -619
- package/src/entry/tui/views/save-dialog.tsx +0 -344
- package/src/entry/tui/views/status.tsx +0 -213
- 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 -95
- package/src/infra/sources/artificial-analysis.ts +0 -119
- package/src/infra/sources/cache.ts +0 -126
- 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 -290
- package/src/services/source-derive.ts +0 -397
- package/src/services/source-sync.ts +0 -312
- package/src/services/standins.ts +0 -238
- package/src/services/treat-likes.ts +0 -113
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mrCommand, unitMilestone } from "../domain/branch.ts";
|
|
4
|
+
import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { type BlockedReason, foreignFailure, parseBlockedReason } from "../domain/blocked.ts";
|
|
6
|
+
import { quotaOf } from "../domain/failover.ts";
|
|
7
|
+
import { parseFailLines, parseFindings } from "../domain/findings.ts";
|
|
8
|
+
import { headlessRung, tryParseRung } from "../domain/ids.ts";
|
|
9
|
+
import { type JudgeKind, judgeName, parseJudgeReply } from "../domain/judge.ts";
|
|
10
|
+
import { milestoneAfter, onlyAllowedHits, parseLaneHeader } from "../domain/lane.ts";
|
|
11
|
+
import { milestoneProblems } from "../domain/plan-lint.ts";
|
|
12
|
+
import { mayStartMilestone, memoryLimitsOf, memoryLoad, neededMb } from "../domain/milestone-memory.ts";
|
|
13
|
+
import { changedDecisions, reapprovalQuestion } from "../domain/plan-doc.ts";
|
|
14
|
+
import type { RunRecord } from "../domain/record.ts";
|
|
15
|
+
import { currentRoute } from "../domain/route.ts";
|
|
16
|
+
import type { Role } from "../domain/roles.ts";
|
|
17
|
+
import { type MilestonePlan, RUN_UNIT, type RunEventInput } from "../domain/run-events.ts";
|
|
18
|
+
import { type Action, unitOf } from "../domain/run-machine.ts";
|
|
19
|
+
import { lanesOf, milestoneOf, ownerOfFile, type RunState } from "../domain/run-state.ts";
|
|
20
|
+
import { endCollect, requestCancel, tryCollect } from "../infra/dispatch-dir.ts";
|
|
21
|
+
import { currentBranch, fetchBase, gitOrigin, gitRun, isAncestor } from "../infra/git.ts";
|
|
22
|
+
import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
|
|
23
|
+
import { log } from "../infra/log.ts";
|
|
24
|
+
import { admit } from "./admission.ts";
|
|
25
|
+
import { cancel, launcher } from "./dispatch-service.ts";
|
|
26
|
+
import { type Dispatch, listDispatches } from "./dispatches.ts";
|
|
27
|
+
import { finalizeDispatch, waitForFinish } from "./finalize.ts";
|
|
28
|
+
import { failoverRung } from "./failover-rungs.ts";
|
|
29
|
+
import { climb, land, routeLanes } from "./lane-service.ts";
|
|
30
|
+
import { blockedByEnvironment, landedCommitOf, replyVerdict } from "./milestones.ts";
|
|
31
|
+
import { machineGate } from "./milestone-check.ts";
|
|
32
|
+
import { branchPusher, claimTree, memoryReader, removeTree } from "./milestone-tree.ts";
|
|
33
|
+
import { sendRunNotice } from "./notify-service.ts";
|
|
34
|
+
import { planProblems } from "./plan-input.ts";
|
|
35
|
+
import type { Deps } from "./ports.ts";
|
|
36
|
+
import { checkEnvFor, preflight, runCheck } from "./preflight.ts";
|
|
37
|
+
import { park } from "./questions.ts";
|
|
38
|
+
import { briefFor, judgeBrief } from "./run-briefs.ts";
|
|
39
|
+
import { appendEvent, appendEvents, readEvents, replayRun } from "./run-events-store.ts";
|
|
40
|
+
import { writeRunReport } from "./run-report.ts";
|
|
41
|
+
import { runProfile } from "./run-pin.ts";
|
|
42
|
+
import { appendRoute, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
|
|
43
|
+
import { treeOf } from "./tree-path.ts";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What carries the machine's actions out. `run` appends the events the action produces (a long one when it is
|
|
47
|
+
* done), never throws: a failure becomes an `effect.failed` event.
|
|
48
|
+
*/
|
|
49
|
+
export interface Effects {
|
|
50
|
+
run(a: Action, state: RunState): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Limits a test shortens. */
|
|
54
|
+
export const effectLimits = { checkMs: 600_000, commitMs: 300_000 };
|
|
55
|
+
|
|
56
|
+
/** The lane a finding's file belongs to, by the longest `Owns:` entry. */
|
|
57
|
+
export const ownerOf = ownerOfFile;
|
|
58
|
+
|
|
59
|
+
const read = (file: string): string => {
|
|
60
|
+
try {
|
|
61
|
+
return readFileSync(file, "utf8");
|
|
62
|
+
} catch {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** A lane's `Owns:` entries, and those of the other lanes of its milestone; empty when a lane file is gone. */
|
|
68
|
+
function laneOwnsOf(run: Run, lane: string): { own: string[]; siblings: string[] } {
|
|
69
|
+
const dir = runPaths(run.dir).lanes;
|
|
70
|
+
const prefix = lane.slice(0, lane.indexOf(".") + 1);
|
|
71
|
+
const owns = (id: string): string[] => parseLaneHeader(read(join(dir, `${id}.md`))).owns;
|
|
72
|
+
const ids = existsSync(dir)
|
|
73
|
+
? readdirSync(dir)
|
|
74
|
+
.filter((f) => f.endsWith(".md") && f.startsWith(prefix))
|
|
75
|
+
.map((f) => f.slice(0, -3))
|
|
76
|
+
: [];
|
|
77
|
+
return { own: owns(lane), siblings: ids.filter((id) => id !== lane).flatMap(owns) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The sibling entries a failed lane check blames when it blames only them: the lane counts as done. */
|
|
81
|
+
function foreignOf(run: Run, lane: string, log: string): string[] {
|
|
82
|
+
const { own, siblings } = laneOwnsOf(run, lane);
|
|
83
|
+
return foreignFailure(log, own, siblings);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const FIRST_LINE = /^VERDICT:\s*FAIL\b/i;
|
|
87
|
+
const PASS_LINE = /^VERDICT: PASS\b/;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The `dispatch.ended` event of a finished dispatch: the record's status, the reply's findings (a reviewer's
|
|
91
|
+
* BLOCKER, BUG and NIT lines, a failing verifier's lines) and the verifier's verdict, the reason a blocked reply
|
|
92
|
+
* names, the HEAD a verifier checked, and the sibling files a `check_broken` lane blames.
|
|
93
|
+
*/
|
|
94
|
+
export function endedEvent(run: Run, record: RunRecord, unit: string): RunEventInput {
|
|
95
|
+
const reply = record.replyPath ? read(join(run.dir, record.replyPath)) : "";
|
|
96
|
+
let verdict: "pass" | "fail" | "blocked" | null = null;
|
|
97
|
+
let probe: string | null = null;
|
|
98
|
+
let findings = record.role === "reviewer" || record.role === "ui-reviewer" ? parseFindings(reply) : [];
|
|
99
|
+
if (record.role === "verifier") {
|
|
100
|
+
const first = replyVerdict(run, record) ?? "";
|
|
101
|
+
probe = blockedByEnvironment(first);
|
|
102
|
+
if (probe !== null) verdict = "blocked";
|
|
103
|
+
else if (PASS_LINE.test(first)) {
|
|
104
|
+
verdict = "pass";
|
|
105
|
+
// plan 34: a PASS lands with the NITs it lists as known issues
|
|
106
|
+
findings = parseFindings(reply).filter((f) => f.label === "NIT");
|
|
107
|
+
}
|
|
108
|
+
// a reply with no readable verdict is not a pass: the next fix round starts from what it said
|
|
109
|
+
else if (record.status === "ok") verdict = "fail";
|
|
110
|
+
if (verdict === "fail") {
|
|
111
|
+
findings = parseFailLines(reply);
|
|
112
|
+
if (!FIRST_LINE.test(first) && findings.length === 0)
|
|
113
|
+
findings = [{ label: "BLOCKER", text: `no VERDICT line: ${first || "empty reply"}`, file: null }];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const stopped = record.replyStatus === "blocked" || record.replyStatus === "refused";
|
|
117
|
+
const reason = stopped ? (parseBlockedReason(record.replyWhy)?.reason ?? null) : null;
|
|
118
|
+
return {
|
|
119
|
+
type: "dispatch.ended",
|
|
120
|
+
dispatchId: record.dispatchId,
|
|
121
|
+
name: record.name,
|
|
122
|
+
unit,
|
|
123
|
+
status: record.status,
|
|
124
|
+
replyStatus: record.replyStatus,
|
|
125
|
+
reason,
|
|
126
|
+
why: record.replyWhy ?? record.error?.message ?? "",
|
|
127
|
+
thread: record.thread,
|
|
128
|
+
head: record.head ?? null,
|
|
129
|
+
environment: record.environment ?? (probe ? probe : null),
|
|
130
|
+
verdict,
|
|
131
|
+
findings,
|
|
132
|
+
foreign: reason === "check_broken" && record.lane ? foreignOf(run, record.lane, reply) : [],
|
|
133
|
+
replyPath: record.replyPath,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ID = /^M[\w-]*\.\S+$/;
|
|
138
|
+
const HEADING = /^##\s+(M\d+)\b\s*(?:[—–:.-]\s*)?(.*)$/;
|
|
139
|
+
const AFTER = /^\s*[*_]*After[*_]*\s*:[*_]*\s*(.*)$/i;
|
|
140
|
+
|
|
141
|
+
const ROLE_OF_KIND: Record<string, Role> = { prose: "writer", research: "researcher" };
|
|
142
|
+
|
|
143
|
+
/** The plan the architect wrote: milestones from plan.md's `## M<n> …` headings, their lanes from lanes/*.md. */
|
|
144
|
+
export function planOf(run: Run): MilestonePlan[] {
|
|
145
|
+
const dir = runPaths(run.dir).lanes;
|
|
146
|
+
const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".md")) : [];
|
|
147
|
+
const natural = (a: string, b: string): number => a.localeCompare(b, "en", { numeric: true });
|
|
148
|
+
const lanes = files
|
|
149
|
+
.map((f) => f.slice(0, -3))
|
|
150
|
+
.filter((id) => ID.test(id))
|
|
151
|
+
.sort(natural)
|
|
152
|
+
.map((id) => {
|
|
153
|
+
const h = parseLaneHeader(read(join(dir, `${id}.md`)));
|
|
154
|
+
return {
|
|
155
|
+
id,
|
|
156
|
+
milestone: id.slice(0, id.indexOf(".")),
|
|
157
|
+
role: (h.kind ? ROLE_OF_KIND[h.kind] : undefined) ?? ("worker" as Role),
|
|
158
|
+
kind: h.kind,
|
|
159
|
+
after: h.after,
|
|
160
|
+
owns: h.owns,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
if (lanes.length === 0) throw new CatherdError("E_LANE_INVALID", "the architect wrote no lane files");
|
|
164
|
+
const heads: { id: string; title: string; after: string[] }[] = [];
|
|
165
|
+
for (const line of read(runPaths(run.dir).plan).split("\n")) {
|
|
166
|
+
const h = HEADING.exec(line);
|
|
167
|
+
if (h) heads.push({ id: h[1] as string, title: (h[2] ?? "").trim() || (h[1] as string), after: [] });
|
|
168
|
+
else if (line.startsWith("## ")) heads.push({ id: "", title: "", after: [] });
|
|
169
|
+
else if (heads.length) {
|
|
170
|
+
const a = AFTER.exec(line)?.[1];
|
|
171
|
+
if (a) (heads.at(-1) as { after: string[] }).after.push(...(a.match(/\bM\d+\b/g) ?? []));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const known = heads.filter((h) => h.id);
|
|
175
|
+
const ids = [...new Set([...known.map((h) => h.id), ...lanes.map((l) => l.milestone).sort(natural)])];
|
|
176
|
+
return ids.flatMap((id) => {
|
|
177
|
+
const mine = lanes.filter((l) => l.milestone === id);
|
|
178
|
+
if (mine.length === 0) return [];
|
|
179
|
+
const head = known.find((h) => h.id === id);
|
|
180
|
+
return [
|
|
181
|
+
{
|
|
182
|
+
id,
|
|
183
|
+
title: head?.title ?? id,
|
|
184
|
+
// a plan.md heading's After: line, and the milestones the milestone's lanes name (plan 35 D7)
|
|
185
|
+
after: [
|
|
186
|
+
...new Set([
|
|
187
|
+
...(head?.after ?? []).filter((a) => a !== id),
|
|
188
|
+
...milestoneAfter(
|
|
189
|
+
lanes.map((l) => ({ id: l.id, after: l.after })),
|
|
190
|
+
id,
|
|
191
|
+
),
|
|
192
|
+
]),
|
|
193
|
+
].sort(natural),
|
|
194
|
+
lanes: mine.map(({ milestone: _m, ...l }) => l),
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const lower = (s: string): string => s.charAt(0).toLowerCase() + s.slice(1);
|
|
201
|
+
|
|
202
|
+
/** The last `n` lines of what a command printed. */
|
|
203
|
+
const tailOf = (text: string, n = 40): string =>
|
|
204
|
+
text
|
|
205
|
+
.split("\n")
|
|
206
|
+
.filter((l) => l.trim())
|
|
207
|
+
.slice(-n)
|
|
208
|
+
.join("\n");
|
|
209
|
+
|
|
210
|
+
/** The effects that carry a run's actions out through the existing services. */
|
|
211
|
+
export function realEffects(deps: Deps, run: Run): Effects {
|
|
212
|
+
const at = (): Date => new Date(deps.now());
|
|
213
|
+
const append = (e: RunEventInput) => appendEvent(run, e, at());
|
|
214
|
+
const watching = new Set<string>();
|
|
215
|
+
|
|
216
|
+
const heldQuotas = (s: RunState): string[] =>
|
|
217
|
+
Object.entries(s.holds)
|
|
218
|
+
.filter(([, until]) => until > s.at)
|
|
219
|
+
.map(([q]) => q);
|
|
220
|
+
|
|
221
|
+
/** Plan 33: every failover, chosen or none, is a row in routes.jsonl; a failed append never fails the dispatch. */
|
|
222
|
+
function recordFailover(
|
|
223
|
+
a: { name: string; role: Role; lane: string | null },
|
|
224
|
+
from: string,
|
|
225
|
+
rung: string | null,
|
|
226
|
+
s: RunState,
|
|
227
|
+
): void {
|
|
228
|
+
try {
|
|
229
|
+
appendRoute(run, {
|
|
230
|
+
at: at().toISOString(),
|
|
231
|
+
lane: a.lane,
|
|
232
|
+
role: a.role,
|
|
233
|
+
name: a.name,
|
|
234
|
+
source: "failover",
|
|
235
|
+
from,
|
|
236
|
+
rung,
|
|
237
|
+
why: rung
|
|
238
|
+
? `failed over from ${from} to ${rung}`
|
|
239
|
+
: `no stand-in for ${from}: every candidate is held or below it`,
|
|
240
|
+
held: heldQuotas(s).sort(),
|
|
241
|
+
});
|
|
242
|
+
} catch (e) {
|
|
243
|
+
log("warn", "routes", { failover: errorMessage(e) });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The rung a dispatch runs on, or a refusal's code and message. */
|
|
248
|
+
async function rungFor(
|
|
249
|
+
a: { name: string; role: Role; lane: string | null; rung: string | null; failoverFrom: string | null },
|
|
250
|
+
s: RunState,
|
|
251
|
+
): Promise<{ rung: string } | { code: string; message: string }> {
|
|
252
|
+
const profile = runProfile(deps, run);
|
|
253
|
+
let rung = a.failoverFrom ?? a.rung;
|
|
254
|
+
if (!rung && a.lane) rung = currentRoute(readRoutes(run), a.lane)?.rung ?? null;
|
|
255
|
+
if (!rung && a.role === "judge") rung = profile.roles.judge?.rungs[0] ?? null;
|
|
256
|
+
if (!rung) {
|
|
257
|
+
const [routed] = await routeLanes(deps, { run: run.id, laneFiles: [undefined], role: a.role });
|
|
258
|
+
rung = routed?.rung ?? null;
|
|
259
|
+
}
|
|
260
|
+
if (!rung) throw new CatherdError("E_ADMIT_RUNG", `no rung for the ${a.role}`);
|
|
261
|
+
const parsed = tryParseRung(rung);
|
|
262
|
+
const held = heldQuotas(s);
|
|
263
|
+
if (a.failoverFrom || (parsed !== null && held.includes(quotaOf(parsed)))) {
|
|
264
|
+
const stand = failoverRung(deps, run, a.role, rung, held);
|
|
265
|
+
recordFailover(a, a.failoverFrom ?? rung, stand, s);
|
|
266
|
+
if (!stand)
|
|
267
|
+
return {
|
|
268
|
+
code: "E_ALL_HELD",
|
|
269
|
+
message: `every rung of the ${a.role} is on a held quota (${held.join(", ")})`,
|
|
270
|
+
};
|
|
271
|
+
rung = stand;
|
|
272
|
+
}
|
|
273
|
+
return { rung: headlessRung(rung) };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Waits for a dispatch to end, then appends what it said: a judge's decision first, then `dispatch.ended`. */
|
|
277
|
+
async function settle(
|
|
278
|
+
d: Dispatch,
|
|
279
|
+
unit: string,
|
|
280
|
+
judge: { id: string; kind: JudgeKind } | null,
|
|
281
|
+
): Promise<void> {
|
|
282
|
+
const id = d.admit.dispatchId;
|
|
283
|
+
if (watching.has(id)) return;
|
|
284
|
+
watching.add(id);
|
|
285
|
+
try {
|
|
286
|
+
await waitForFinish(d, { pollMs: deps.pollMs, now: deps.now });
|
|
287
|
+
const record = await finalizeDispatch(run, d);
|
|
288
|
+
if (replayRun(run).dispatches[id]?.ended) return;
|
|
289
|
+
// a daemon that died between judge.decided and dispatch.ended decided it already
|
|
290
|
+
const decidedBefore = readEvents(run).some((e) => e.type === "judge.decided" && e.dispatchId === id);
|
|
291
|
+
if (judge && record.status !== "limit" && !decidedBefore) {
|
|
292
|
+
const reply = record.replyPath ? read(join(run.dir, record.replyPath)) : "";
|
|
293
|
+
const decision = record.status === "ok" ? parseJudgeReply(reply, judge.kind) : null;
|
|
294
|
+
await append(
|
|
295
|
+
decision
|
|
296
|
+
? { type: "judge.decided", id: judge.id, unit, kind: judge.kind, decision, dispatchId: id }
|
|
297
|
+
: {
|
|
298
|
+
type: "judge.failed",
|
|
299
|
+
id: judge.id,
|
|
300
|
+
unit,
|
|
301
|
+
why:
|
|
302
|
+
record.status === "ok"
|
|
303
|
+
? "the reply holds no decision its kind allows"
|
|
304
|
+
: `the judge ${record.status}`,
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
await append(endedEvent(run, record, unit));
|
|
309
|
+
// catherd announces nothing about a role of a daemon run: the run's reports do
|
|
310
|
+
if (await tryCollect(d.dir)) endCollect(d.dir);
|
|
311
|
+
} finally {
|
|
312
|
+
watching.delete(id);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const judgeOf = (name: string, s: RunState): { id: string; kind: JudgeKind } | null => {
|
|
317
|
+
const id = name.replace(/^judge-/, "");
|
|
318
|
+
return name.startsWith("judge-") ? { id, kind: s.judges[id]?.kind ?? "unexpected" } : null;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/** An admitted dispatch of `name` that the log never heard of. */
|
|
322
|
+
const orphanOf = (name: string, s: RunState): Dispatch | undefined =>
|
|
323
|
+
listDispatches(run).find((d) => d.admit.name === name && !s.dispatches[d.admit.dispatchId]);
|
|
324
|
+
|
|
325
|
+
async function start(
|
|
326
|
+
a: {
|
|
327
|
+
unit: string;
|
|
328
|
+
name: string;
|
|
329
|
+
role: Role;
|
|
330
|
+
lane: string | null;
|
|
331
|
+
rung: string | null;
|
|
332
|
+
thread: string | null;
|
|
333
|
+
failoverFrom: string | null;
|
|
334
|
+
},
|
|
335
|
+
brief: string,
|
|
336
|
+
s: RunState,
|
|
337
|
+
judge: { id: string; kind: JudgeKind } | null,
|
|
338
|
+
): Promise<void> {
|
|
339
|
+
const refuse = (code: string, message: string) =>
|
|
340
|
+
append({ type: "dispatch.refused", name: a.name, unit: a.unit, code, message });
|
|
341
|
+
// the daemon died between its admission and its event: the dispatch is recorded and watched, never launched twice
|
|
342
|
+
const adopt = async (found: Dispatch): Promise<void> => {
|
|
343
|
+
await append({
|
|
344
|
+
type: "dispatch.started",
|
|
345
|
+
dispatchId: found.admit.dispatchId,
|
|
346
|
+
name: a.name,
|
|
347
|
+
role: a.role,
|
|
348
|
+
unit: a.unit,
|
|
349
|
+
lane: a.lane,
|
|
350
|
+
rung: found.admit.rung,
|
|
351
|
+
thread: found.admit.thread,
|
|
352
|
+
...(judge ? { judgeKind: judge.kind } : {}),
|
|
353
|
+
});
|
|
354
|
+
await settle(found, a.unit, judge);
|
|
355
|
+
};
|
|
356
|
+
const orphan = orphanOf(a.name, s);
|
|
357
|
+
if (orphan) return adopt(orphan);
|
|
358
|
+
const picked = await rungFor(a, s);
|
|
359
|
+
if ("code" in picked) return void (await refuse(picked.code, picked.message));
|
|
360
|
+
let admitted: Awaited<ReturnType<typeof admit>>;
|
|
361
|
+
try {
|
|
362
|
+
admitted = await admit(deps, run, {
|
|
363
|
+
role: a.role,
|
|
364
|
+
name: a.name,
|
|
365
|
+
brief,
|
|
366
|
+
rung: picked.rung,
|
|
367
|
+
thread: a.thread,
|
|
368
|
+
lane: a.lane,
|
|
369
|
+
failoverFrom: a.failoverFrom,
|
|
370
|
+
// plan 35: a base run's milestone works in its own worktree
|
|
371
|
+
cwd: treeOf(run, unitMilestone(a.unit)),
|
|
372
|
+
});
|
|
373
|
+
} catch (e) {
|
|
374
|
+
if (!isCatherdError(e)) throw e;
|
|
375
|
+
if (e.code === "E_ADMIT_DUPLICATE") {
|
|
376
|
+
const orphan = orphanOf(a.name, s);
|
|
377
|
+
if (orphan) return await adopt(orphan);
|
|
378
|
+
}
|
|
379
|
+
return void (await refuse(e.code, e.message));
|
|
380
|
+
}
|
|
381
|
+
const { d, specPath } = admitted;
|
|
382
|
+
// a cancel that landed while this start was in flight: the role must not start after the run is cancelled
|
|
383
|
+
const now = replayRun(run);
|
|
384
|
+
if (now.cancelling || now.phase === "ended") {
|
|
385
|
+
requestCancel(d.dir);
|
|
386
|
+
await finalizeDispatch(run, d);
|
|
387
|
+
if (await tryCollect(d.dir)) endCollect(d.dir);
|
|
388
|
+
return void (await refuse("E_RUN_CANCELLED", "the run was cancelled before the role started"));
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
launcher.launch(d, specPath);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
// watched all the same: a dispatch that never started is recorded as lost once its start grace passes
|
|
394
|
+
log("warn", "daemon", { run: run.id, name: a.name, launch: errorMessage(e) });
|
|
395
|
+
}
|
|
396
|
+
await append({
|
|
397
|
+
type: "dispatch.started",
|
|
398
|
+
dispatchId: d.admit.dispatchId,
|
|
399
|
+
name: a.name,
|
|
400
|
+
role: a.role,
|
|
401
|
+
unit: a.unit,
|
|
402
|
+
lane: a.lane,
|
|
403
|
+
rung: d.admit.rung,
|
|
404
|
+
thread: a.thread,
|
|
405
|
+
...(judge ? { judgeKind: judge.kind } : {}),
|
|
406
|
+
});
|
|
407
|
+
await settle(d, a.unit, judge);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The architect's plan into `plan.ready`, once the plan backs every lane. A gap is `effect.failed`: its architect
|
|
412
|
+
* is sent back with it. An edit to a decision the owner approved asks them again before the plan is read.
|
|
413
|
+
*/
|
|
414
|
+
async function readPlan(): Promise<void> {
|
|
415
|
+
const milestones = planOf(run);
|
|
416
|
+
const problems = [...planProblems(run), ...milestoneProblems(milestones)];
|
|
417
|
+
if (problems.length > 0) {
|
|
418
|
+
await append({
|
|
419
|
+
type: "effect.failed",
|
|
420
|
+
action: "readPlan",
|
|
421
|
+
unit: RUN_UNIT,
|
|
422
|
+
message: `the plan needs: ${problems.join("; ")}`,
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const approved = read(join(run.dir, "plan-approved.md"));
|
|
427
|
+
const changed = approved ? changedDecisions(approved, read(runPaths(run.dir).plan)) : [];
|
|
428
|
+
// one write: a crash between the two would lose the owner's change request
|
|
429
|
+
await appendEvents(
|
|
430
|
+
run,
|
|
431
|
+
[
|
|
432
|
+
...(changed.length > 0
|
|
433
|
+
? [
|
|
434
|
+
{
|
|
435
|
+
type: "question.parked" as const,
|
|
436
|
+
unit: RUN_UNIT,
|
|
437
|
+
question: reapprovalQuestion(run.id, changed),
|
|
438
|
+
reason: "approval" as const,
|
|
439
|
+
},
|
|
440
|
+
]
|
|
441
|
+
: []),
|
|
442
|
+
{ type: "plan.ready", milestones },
|
|
443
|
+
],
|
|
444
|
+
at(),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function route(a: Extract<Action, { type: "route" }>, s: RunState): Promise<void> {
|
|
449
|
+
const lanes = lanesOf(s, a.milestone);
|
|
450
|
+
const roles = [...new Set(lanes.map((l) => l.role))];
|
|
451
|
+
const routed: { lane: string; rung: string; ladder: string[] }[] = [];
|
|
452
|
+
for (const role of roles) {
|
|
453
|
+
const ids = lanes.filter((l) => l.role === role).map((l) => l.id);
|
|
454
|
+
const out = await routeLanes(deps, { run: run.id, laneFiles: ids.map((id) => `lanes/${id}.md`), role });
|
|
455
|
+
for (const r of out) if (r.lane) routed.push({ lane: r.lane, rung: r.rung, ladder: r.ladder });
|
|
456
|
+
}
|
|
457
|
+
await append({ type: "lanes.routed", milestone: a.milestone, lanes: routed });
|
|
458
|
+
const rep = await preflight(deps, { run: run.id, milestone: a.milestone, confirmed: a.confirmed });
|
|
459
|
+
await append(
|
|
460
|
+
rep.needsConfirmation
|
|
461
|
+
? {
|
|
462
|
+
type: "preflight.done",
|
|
463
|
+
milestone: a.milestone,
|
|
464
|
+
blocking: [],
|
|
465
|
+
confirm: rep.commands.map((c) => `${c.lane}: ${c.check ?? "(no fast check)"}`),
|
|
466
|
+
lint: rep.lint,
|
|
467
|
+
}
|
|
468
|
+
: {
|
|
469
|
+
type: "preflight.done",
|
|
470
|
+
milestone: a.milestone,
|
|
471
|
+
blocking: rep.results
|
|
472
|
+
.filter((r) => r.outcome === "cannot-start")
|
|
473
|
+
.map((r) => ({
|
|
474
|
+
lane: r.lane,
|
|
475
|
+
note: r.note ?? (tailOf(r.tail.join("\n"), 3) || "cannot start"),
|
|
476
|
+
})),
|
|
477
|
+
confirm: null,
|
|
478
|
+
lint: rep.lint,
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function fastCheck(a: Extract<Action, { type: "fastCheck" }>): Promise<void> {
|
|
484
|
+
const header = parseLaneHeader(read(join(runPaths(run.dir).lanes, `${a.lane}.md`)));
|
|
485
|
+
if (!header.fastCheck) {
|
|
486
|
+
await append({
|
|
487
|
+
type: "check.ran",
|
|
488
|
+
lane: a.lane,
|
|
489
|
+
exit: 0,
|
|
490
|
+
tail: "the lane has no fast check",
|
|
491
|
+
foreign: [],
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const check = header.fastCheck;
|
|
496
|
+
const cwd = treeOf(run, unitMilestone(a.lane));
|
|
497
|
+
const env = await checkEnvFor(run.meta.repo);
|
|
498
|
+
const slots = heavySlots(runProfile(deps, run).heavy);
|
|
499
|
+
const r = await withHeavySlot(
|
|
500
|
+
slots,
|
|
501
|
+
(_slot, tmp) => runCheck(cwd, check, effectLimits.checkMs, { ...env, TMPDIR: tmp, GOTMPDIR: tmp }),
|
|
502
|
+
{ tmpBase: process.env.TMPDIR },
|
|
503
|
+
);
|
|
504
|
+
// an absence check whose only hits are the lane's Allow: exceptions has passed
|
|
505
|
+
const allowed = r.code !== 0 && !r.timedOut && onlyAllowedHits(r.lines, header.allow);
|
|
506
|
+
const failed = !allowed && r.code !== 0;
|
|
507
|
+
await append({
|
|
508
|
+
type: "check.ran",
|
|
509
|
+
lane: a.lane,
|
|
510
|
+
exit: allowed ? 0 : r.code,
|
|
511
|
+
tail: r.tail.join("\n"),
|
|
512
|
+
// a check that fails only in a sibling's files still counts: the sibling is not done writing them
|
|
513
|
+
foreign: failed ? foreignOf(run, a.lane, (r.lines.length ? r.lines : r.tail).join("\n")) : [],
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Why a stop gave no reason: Jev reads the end of the reply; null when it is off, unsure or says other. */
|
|
518
|
+
async function classify(a: Extract<Action, { type: "classify" }>): Promise<void> {
|
|
519
|
+
const record = readRecords(run).records.find((r) => r.dispatchId === a.dispatchId);
|
|
520
|
+
let reason: BlockedReason | null = null;
|
|
521
|
+
if (record) {
|
|
522
|
+
const reply = record.replyPath ? read(join(run.dir, record.replyPath)) : "";
|
|
523
|
+
try {
|
|
524
|
+
const v = await deps.routing.stopCause(
|
|
525
|
+
run.dir,
|
|
526
|
+
`${record.status}${record.replyStatus ? `, STATUS ${record.replyStatus}` : ""}`,
|
|
527
|
+
reply,
|
|
528
|
+
runProfile(deps, run).jev.use,
|
|
529
|
+
);
|
|
530
|
+
if (v.source === "jev" && v.value !== "other") reason = v.value;
|
|
531
|
+
} catch (e) {
|
|
532
|
+
log("warn", "daemon", { run: run.id, action: "classify", error: errorMessage(e) });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
await append({ type: "stop.classified", dispatchId: a.dispatchId, reason });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function commit(a: Extract<Action, { type: "commit" }>, s: RunState): Promise<void> {
|
|
539
|
+
const m = milestoneOf(s, a.milestone);
|
|
540
|
+
const lanes = lanesOf(s, a.milestone);
|
|
541
|
+
const ids = new Set(lanes.map((l) => l.id));
|
|
542
|
+
const paths = new Set(lanes.flatMap((l) => l.owns));
|
|
543
|
+
for (const r of readRecords(run).records)
|
|
544
|
+
if (r.lane && ids.has(r.lane)) for (const p of [...r.changedOwned, ...r.violations]) paths.add(p);
|
|
545
|
+
// plan 35: a base run's milestone is committed in its own worktree, on its own branch
|
|
546
|
+
const repo = treeOf(run, a.milestone);
|
|
547
|
+
const failed = (r: { out: string; err: string; timedOut: boolean }) =>
|
|
548
|
+
append({
|
|
549
|
+
type: "commit.failed",
|
|
550
|
+
milestone: a.milestone,
|
|
551
|
+
round: a.round,
|
|
552
|
+
tail: r.timedOut ? "git timed out" : tailOf(`${r.out}\n${r.err}`),
|
|
553
|
+
});
|
|
554
|
+
// a merge of the base is open: the files it left in conflict are resolved now, and the merge itself is committed
|
|
555
|
+
const merging = (m?.integrateFiles.length ?? 0) > 0;
|
|
556
|
+
for (const f of merging ? (m?.integrateFiles ?? []) : []) paths.add(f);
|
|
557
|
+
for (const p of paths) {
|
|
558
|
+
const r = await gitRun(repo, ["--literal-pathspecs", "add", "-A", "--", p], effectLimits.commitMs);
|
|
559
|
+
// an Owns: entry the lane never created is no error
|
|
560
|
+
if (r.code !== 0 && !/did not match any files/.test(r.err)) return void (await failed(r));
|
|
561
|
+
}
|
|
562
|
+
if (merging) {
|
|
563
|
+
// a file the worker left unresolved must not become the milestone's commit
|
|
564
|
+
for (const f of m?.integrateFiles ?? []) {
|
|
565
|
+
const text = existsSync(join(repo, f)) ? readFileSync(join(repo, f), "utf8") : "";
|
|
566
|
+
if (/^<{7} /m.test(text) && /^>{7} /m.test(text))
|
|
567
|
+
return void (await failed({ out: "", err: `${f} still holds conflict markers`, timedOut: false }));
|
|
568
|
+
}
|
|
569
|
+
// an open merge is committed whole: a partial commit is refused while one is in progress
|
|
570
|
+
const c = await gitRun(repo, ["commit", "--no-edit"], effectLimits.commitMs);
|
|
571
|
+
if (c.code !== 0) return void (await failed(c));
|
|
572
|
+
} else {
|
|
573
|
+
// only what the milestone owns: work staged elsewhere stays staged and out of the commit
|
|
574
|
+
const staged = paths.size
|
|
575
|
+
? await gitRun(
|
|
576
|
+
repo,
|
|
577
|
+
["--literal-pathspecs", "diff", "--cached", "--name-only", "-z", "--", ...paths],
|
|
578
|
+
effectLimits.commitMs,
|
|
579
|
+
)
|
|
580
|
+
: { code: 0, out: "", err: "", timedOut: false };
|
|
581
|
+
if (staged.code !== 0) return void (await failed(staged));
|
|
582
|
+
const files = staged.out.split("\0").filter(Boolean);
|
|
583
|
+
if (files.length) {
|
|
584
|
+
const title = lower(m?.title ?? a.milestone);
|
|
585
|
+
const suffix = a.round === 0 ? "" : `, review round ${a.round}`;
|
|
586
|
+
const subject = `${`${a.round === 0 ? "feat" : "fix"}: ${title}`.slice(0, 100 - suffix.length)}${suffix}`;
|
|
587
|
+
const c = await gitRun(
|
|
588
|
+
repo,
|
|
589
|
+
["--literal-pathspecs", "commit", "--only", "-m", subject, "--", ...files],
|
|
590
|
+
effectLimits.commitMs,
|
|
591
|
+
);
|
|
592
|
+
if (c.code !== 0) return void (await failed(c));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const head = await gitRun(repo, ["rev-parse", "HEAD"]);
|
|
596
|
+
if (head.code !== 0) return void (await failed(head));
|
|
597
|
+
await append({ type: "commit.made", milestone: a.milestone, round: a.round, commit: head.out.trim() });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function landIt(a: Extract<Action, { type: "land" }>, s: RunState): Promise<void> {
|
|
601
|
+
const m = milestoneOf(s, a.milestone);
|
|
602
|
+
if (!m?.commit) throw new CatherdError("E_RUN_COMMIT", `${a.milestone} has no commit to land`);
|
|
603
|
+
const issues = a.knownIssues.length ? `; ${a.knownIssues.length} known issue(s)` : "";
|
|
604
|
+
// a base run lands only what origin's base is behind; a base that moved is merged in first (spec 2.0, decision 21)
|
|
605
|
+
let stale = "";
|
|
606
|
+
if (s.base !== null) {
|
|
607
|
+
const repo = run.meta.repo;
|
|
608
|
+
const fetched = await fetchBase(repo, s.base);
|
|
609
|
+
if ((await isAncestor(repo, `origin/${s.base}`, m.commit)) === false)
|
|
610
|
+
return void (await append({ type: "base.behind", milestone: a.milestone }));
|
|
611
|
+
if (!fetched) stale = `; fetch of origin/${s.base} failed: using the copy from the last fetch`;
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
const r = await land(deps, {
|
|
615
|
+
run: run.id,
|
|
616
|
+
milestone: a.milestone,
|
|
617
|
+
what: m.title,
|
|
618
|
+
commit: m.commit,
|
|
619
|
+
evidence: `verifier-${a.milestone} PASS on ${m.commit.slice(0, 12)}${issues}${stale}`,
|
|
620
|
+
next: `the daemon continues after ${a.milestone}`,
|
|
621
|
+
});
|
|
622
|
+
await append({
|
|
623
|
+
type: "milestone.landed",
|
|
624
|
+
milestone: a.milestone,
|
|
625
|
+
commit: m.commit,
|
|
626
|
+
knownIssues: a.knownIssues,
|
|
627
|
+
digestPath: r.digestPath,
|
|
628
|
+
...(s.base !== null ? { branch: m.branch ?? (await currentBranch(treeOf(run, a.milestone))) } : {}),
|
|
629
|
+
});
|
|
630
|
+
} catch (e) {
|
|
631
|
+
if (!isCatherdError(e)) throw e;
|
|
632
|
+
await append({
|
|
633
|
+
type: "land.refused",
|
|
634
|
+
milestone: a.milestone,
|
|
635
|
+
code: e.code,
|
|
636
|
+
message: e.message,
|
|
637
|
+
fix: e.fix ?? "",
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Merges origin's base into the run's branch: done with the merge commit, or the files left in conflict. */
|
|
643
|
+
async function integrate(a: Extract<Action, { type: "integrate" }>, s: RunState): Promise<void> {
|
|
644
|
+
const repo = treeOf(run, a.milestone);
|
|
645
|
+
const base = s.base;
|
|
646
|
+
if (base === null) throw new CatherdError("E_RUN_COMMIT", `${a.milestone} has no base branch to merge`);
|
|
647
|
+
await fetchBase(repo, base);
|
|
648
|
+
const branch = milestoneOf(s, a.milestone)?.branch ?? (await currentBranch(repo)) ?? "HEAD";
|
|
649
|
+
const r = await gitRun(
|
|
650
|
+
repo,
|
|
651
|
+
["merge", "--no-ff", "-m", `Merge origin/${base} into ${branch}`, `origin/${base}`],
|
|
652
|
+
effectLimits.commitMs,
|
|
653
|
+
);
|
|
654
|
+
if (r.code === 0) {
|
|
655
|
+
const head = await gitRun(repo, ["rev-parse", "HEAD"]);
|
|
656
|
+
if (head.code !== 0) throw new Error(`git rev-parse HEAD failed: ${tailOf(head.err)}`);
|
|
657
|
+
return void (await append({ type: "integrate.done", milestone: a.milestone, commit: head.out.trim() }));
|
|
658
|
+
}
|
|
659
|
+
const unmerged = await gitRun(repo, ["diff", "--name-only", "--diff-filter=U"]);
|
|
660
|
+
const files = unmerged.out.split("\n").filter(Boolean);
|
|
661
|
+
if (unmerged.code !== 0 || files.length === 0)
|
|
662
|
+
throw new Error(`merging origin/${base} failed: ${tailOf(`${r.out}\n${r.err}`, 5)}`);
|
|
663
|
+
await append({ type: "integrate.conflict", milestone: a.milestone, files });
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Whether the milestone is merged: origin's base holds its landed commit, or the owner's answer counted it so. A
|
|
668
|
+
* merged milestone's clean worktree goes (a dirty one stays, logged), and then `milestone.merged` follows; silent
|
|
669
|
+
* while it is not merged, or cannot be told.
|
|
670
|
+
*/
|
|
671
|
+
async function checkMerged(a: Extract<Action, { type: "checkMerged" }>, s: RunState): Promise<void> {
|
|
672
|
+
if (s.base === null) return;
|
|
673
|
+
const commit = landedCommitOf(run, a.milestone);
|
|
674
|
+
let merged = milestoneOf(s, a.milestone)?.status === "merged";
|
|
675
|
+
if (!merged && commit) {
|
|
676
|
+
await fetchBase(run.meta.repo, s.base);
|
|
677
|
+
merged = (await isAncestor(run.meta.repo, commit, `origin/${s.base}`)) === true;
|
|
678
|
+
}
|
|
679
|
+
if (!merged) return;
|
|
680
|
+
const gone = await removeTree(run, a.milestone);
|
|
681
|
+
if (gone.hint) log("warn", "trees", { run: run.id, milestone: a.milestone, hint: gone.hint });
|
|
682
|
+
await append({ type: "milestone.merged", milestone: a.milestone });
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** A memory check, then the milestone's worktree: `tree.deferred` while the machine has no room for another. */
|
|
686
|
+
async function claim(a: Extract<Action, { type: "claimTree" }>, s: RunState): Promise<void> {
|
|
687
|
+
const limits = memoryLimitsOf(runProfile(deps, run));
|
|
688
|
+
const load = memoryLoad(s.milestones, deps.now(), limits);
|
|
689
|
+
const availableMb = memoryReader.read();
|
|
690
|
+
if (!mayStartMilestone({ availableMb, ...load }, limits))
|
|
691
|
+
return void (await append({
|
|
692
|
+
type: "tree.deferred",
|
|
693
|
+
milestone: a.milestone,
|
|
694
|
+
availableMb,
|
|
695
|
+
needMb: neededMb(load.ramping, limits),
|
|
696
|
+
}));
|
|
697
|
+
const c = await claimTree(deps, run, a.milestone, {
|
|
698
|
+
known: milestoneOf(s, a.milestone)?.branch != null,
|
|
699
|
+
});
|
|
700
|
+
for (const hint of c.hints) log("warn", "trees", { run: run.id, milestone: a.milestone, hint });
|
|
701
|
+
await append({
|
|
702
|
+
type: "tree.ready",
|
|
703
|
+
milestone: a.milestone,
|
|
704
|
+
tree: c.tree,
|
|
705
|
+
branch: c.branch,
|
|
706
|
+
base: c.base,
|
|
707
|
+
hints: c.hints,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** The owner's `push` answer: the one push catherd makes, of this milestone's own branch. It opens no MR. */
|
|
712
|
+
async function pushBranch(a: Extract<Action, { type: "pushBranch" }>, s: RunState): Promise<void> {
|
|
713
|
+
const m = milestoneOf(s, a.milestone);
|
|
714
|
+
if (!m?.tree || !m.branch || s.base === null)
|
|
715
|
+
throw new CatherdError("E_TREE", `${a.milestone} has no branch to push`);
|
|
716
|
+
await branchPusher.push(m.tree, m.branch);
|
|
717
|
+
const mr = mrCommand({
|
|
718
|
+
originUrl: (await gitOrigin(run.meta.repo)) ?? "",
|
|
719
|
+
base: s.base,
|
|
720
|
+
branch: m.branch,
|
|
721
|
+
body: m.digestPath ?? "",
|
|
722
|
+
});
|
|
723
|
+
await append({ type: "branch.pushed", milestone: a.milestone, branch: m.branch, remote: "origin", mr });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function perform(a: Action, s: RunState): Promise<void> {
|
|
727
|
+
switch (a.type) {
|
|
728
|
+
case "dispatch":
|
|
729
|
+
return start(a, briefFor(run, s, a), s, null);
|
|
730
|
+
case "judge":
|
|
731
|
+
return start(
|
|
732
|
+
{
|
|
733
|
+
unit: a.input.unit,
|
|
734
|
+
name: judgeName(a.input.id),
|
|
735
|
+
role: "judge",
|
|
736
|
+
lane: null,
|
|
737
|
+
rung: null,
|
|
738
|
+
thread: null,
|
|
739
|
+
failoverFrom: null,
|
|
740
|
+
},
|
|
741
|
+
judgeBrief(run, a.input),
|
|
742
|
+
s,
|
|
743
|
+
{ id: a.input.id, kind: a.input.kind },
|
|
744
|
+
);
|
|
745
|
+
case "watch": {
|
|
746
|
+
const v = s.dispatches[a.dispatchId];
|
|
747
|
+
if (!v || v.ended) return;
|
|
748
|
+
const d = listDispatches(run).find((x) => x.admit.dispatchId === a.dispatchId);
|
|
749
|
+
if (!d) throw new Error(`dispatch ${a.dispatchId} has no folder`);
|
|
750
|
+
return settle(d, v.unit, v.role === "judge" ? judgeOf(v.name, s) : null);
|
|
751
|
+
}
|
|
752
|
+
case "readPlan":
|
|
753
|
+
return readPlan();
|
|
754
|
+
case "route":
|
|
755
|
+
return route(a, s);
|
|
756
|
+
case "fastCheck":
|
|
757
|
+
return fastCheck(a);
|
|
758
|
+
case "commit":
|
|
759
|
+
return commit(a, s);
|
|
760
|
+
case "climb": {
|
|
761
|
+
const r = await climb(deps, { run: run.id, lane: a.lane, reason: a.reason });
|
|
762
|
+
return void (await append({ type: "lane.climbed", lane: a.lane, rung: r.rung, top: r.top }));
|
|
763
|
+
}
|
|
764
|
+
case "park":
|
|
765
|
+
// the push question is the daemon's own: a 1.5 question would hold the run's pause and ask twice
|
|
766
|
+
if (milestoneOf(s, a.unit) && a.reason !== "push")
|
|
767
|
+
await park(deps, { run: run.id, milestone: a.unit, question: a.question });
|
|
768
|
+
return void (await append({
|
|
769
|
+
type: "question.parked",
|
|
770
|
+
unit: a.unit,
|
|
771
|
+
question: a.question,
|
|
772
|
+
reason: a.reason,
|
|
773
|
+
}));
|
|
774
|
+
case "land":
|
|
775
|
+
return landIt(a, s);
|
|
776
|
+
case "cancel": {
|
|
777
|
+
for (const id of a.dispatchIds) {
|
|
778
|
+
const d = listDispatches(run).find((x) => x.admit.dispatchId === id);
|
|
779
|
+
if (!d) continue;
|
|
780
|
+
try {
|
|
781
|
+
await cancel(deps, run.id, d.admit.name);
|
|
782
|
+
} catch (e) {
|
|
783
|
+
if (!(isCatherdError(e) && e.code === "E_RUN_NOT_LIVE")) throw e;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
case "report":
|
|
789
|
+
return void (await append({ type: "report.posted", kind: a.kind, unit: a.unit, text: a.text }));
|
|
790
|
+
case "notify":
|
|
791
|
+
// the phone; the session hears the run's reports through its notifier
|
|
792
|
+
await sendRunNotice(deps, run, { unit: a.unit, moment: a.moment });
|
|
793
|
+
return;
|
|
794
|
+
case "finish": {
|
|
795
|
+
// what each landed milestone's check passed on only through the base or a rerun
|
|
796
|
+
const audits: Record<string, string[]> = {};
|
|
797
|
+
for (const m of s.milestones) {
|
|
798
|
+
if (!m.commit || (m.status !== "landed" && m.status !== "merged")) continue;
|
|
799
|
+
const g = await machineGate(run, m.id, m.commit).catch(() => null);
|
|
800
|
+
if (g?.audit.length) audits[m.id] = g.audit;
|
|
801
|
+
}
|
|
802
|
+
const reportPath = writeRunReport(run, s, readEvents(run), audits, a.proposals);
|
|
803
|
+
return void (await append({ type: "run.ended", outcome: a.outcome, reportPath }));
|
|
804
|
+
}
|
|
805
|
+
case "classify":
|
|
806
|
+
return classify(a);
|
|
807
|
+
case "checkMerged":
|
|
808
|
+
return checkMerged(a, s);
|
|
809
|
+
case "integrate":
|
|
810
|
+
return integrate(a, s);
|
|
811
|
+
case "claimTree":
|
|
812
|
+
return claim(a, s);
|
|
813
|
+
case "pushBranch":
|
|
814
|
+
return pushBranch(a, s);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
async run(a, s) {
|
|
820
|
+
try {
|
|
821
|
+
await perform(a, s);
|
|
822
|
+
} catch (e) {
|
|
823
|
+
log("warn", "daemon", { run: run.id, action: a.type, error: errorMessage(e) });
|
|
824
|
+
try {
|
|
825
|
+
await append({
|
|
826
|
+
type: "effect.failed",
|
|
827
|
+
action: a.type,
|
|
828
|
+
unit: unitOf(a, s),
|
|
829
|
+
message: errorMessage(e),
|
|
830
|
+
});
|
|
831
|
+
} catch (inner) {
|
|
832
|
+
log("error", "daemon", { run: run.id, action: a.type, lost: errorMessage(inner) });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
}
|