catherd-cli 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +93 -30
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/bin/catherd-mcp +1 -1
  8. package/plugin/skills/catherd/SKILL.md +110 -45
  9. package/plugin/skills/catherd-setup/SKILL.md +2 -2
  10. package/src/adapters/antigravity/index.ts +18 -3
  11. package/src/adapters/backend.ts +18 -2
  12. package/src/adapters/claude-code/index.ts +126 -14
  13. package/src/adapters/codex/events.ts +10 -0
  14. package/src/adapters/codex/index.ts +17 -5
  15. package/src/adapters/cursor/index.ts +7 -1
  16. package/src/adapters/grok/home.ts +75 -20
  17. package/src/adapters/grok/index.ts +27 -8
  18. package/src/adapters/opencode/index.ts +38 -1
  19. package/src/cli.ts +5 -0
  20. package/src/domain/agents.ts +3 -1
  21. package/src/domain/brief.ts +91 -0
  22. package/src/domain/calibration.ts +7 -0
  23. package/src/domain/catalog.ts +12 -1
  24. package/src/domain/changes.ts +23 -1
  25. package/src/domain/cost.ts +8 -3
  26. package/src/domain/errors.ts +4 -0
  27. package/src/domain/failover.ts +17 -5
  28. package/src/domain/gate-brief.ts +52 -0
  29. package/src/domain/hints.ts +13 -2
  30. package/src/domain/lane.ts +99 -3
  31. package/src/domain/notice.ts +9 -2
  32. package/src/domain/profile-rules.ts +112 -16
  33. package/src/domain/profile.ts +24 -0
  34. package/src/domain/record.ts +16 -2
  35. package/src/domain/reply.ts +29 -0
  36. package/src/domain/role-prompts.ts +56 -16
  37. package/src/domain/role-scope.ts +106 -0
  38. package/src/domain/role-tools.ts +34 -0
  39. package/src/domain/route.ts +73 -0
  40. package/src/domain/select.ts +177 -25
  41. package/src/domain/state.ts +5 -0
  42. package/src/domain/util.ts +22 -0
  43. package/src/domain/workspace.ts +130 -0
  44. package/src/entry/catalog-command.ts +14 -6
  45. package/src/entry/cli-kit.ts +17 -1
  46. package/src/entry/deps.ts +2 -0
  47. package/src/entry/doctor-command.ts +20 -2
  48. package/src/entry/host-arg.ts +28 -0
  49. package/src/entry/init-command.ts +2 -1
  50. package/src/entry/knowledge-command.ts +78 -3
  51. package/src/entry/lock-command.ts +104 -8
  52. package/src/entry/mcp/dispatch-tools.ts +3 -3
  53. package/src/entry/mcp/handshake.ts +6 -2
  54. package/src/entry/mcp/lane-tools.ts +55 -8
  55. package/src/entry/mcp/protocol-tools.ts +10 -4
  56. package/src/entry/mcp/push-tools.ts +16 -0
  57. package/src/entry/mcp/role-bin.ts +20 -0
  58. package/src/entry/mcp/role-server.ts +116 -0
  59. package/src/entry/mcp/run-tools.ts +20 -4
  60. package/src/entry/mcp/server.ts +98 -7
  61. package/src/entry/mcp/setup-tools.ts +4 -3
  62. package/src/entry/mcp/workspace-tools.ts +142 -0
  63. package/src/entry/pause-command.ts +67 -0
  64. package/src/entry/profile-command.ts +23 -4
  65. package/src/entry/role-cli-command.ts +115 -0
  66. package/src/entry/runs-command.ts +78 -8
  67. package/src/entry/supervise-command.ts +30 -3
  68. package/src/entry/tui/effects.ts +4 -0
  69. package/src/entry/tui/state.ts +37 -10
  70. package/src/entry/tui/views/profiles.tsx +1 -1
  71. package/src/entry/tui/views/runs.tsx +66 -20
  72. package/src/entry/tui/views/save-dialog.tsx +6 -2
  73. package/src/entry/tui/views/status.tsx +6 -2
  74. package/src/entry/workspace-command.ts +44 -0
  75. package/src/infra/boot-lock.ts +25 -0
  76. package/src/infra/delivery.ts +3 -0
  77. package/src/infra/dispatch-dir.ts +2 -0
  78. package/src/infra/env.ts +11 -1
  79. package/src/infra/git.ts +6 -0
  80. package/src/infra/heavy-lock.ts +93 -4
  81. package/src/infra/host-probe.ts +16 -0
  82. package/src/infra/lock-activity.ts +88 -0
  83. package/src/infra/login-env.ts +48 -0
  84. package/src/infra/paths.ts +35 -0
  85. package/src/infra/role-mcp.ts +69 -0
  86. package/src/infra/sources/arena.ts +11 -3
  87. package/src/infra/sources/artificial-analysis.ts +8 -1
  88. package/src/infra/sources/cache.ts +2 -0
  89. package/src/infra/supervisor.ts +107 -16
  90. package/src/services/admission.ts +110 -23
  91. package/src/services/budget.ts +26 -6
  92. package/src/services/catalog-service.ts +20 -12
  93. package/src/services/credentials.ts +11 -6
  94. package/src/services/dispatch-service.ts +204 -16
  95. package/src/services/dispatches.ts +30 -2
  96. package/src/services/doctor-access.ts +8 -3
  97. package/src/services/doctor-docker.ts +221 -0
  98. package/src/services/doctor-role-mcp.ts +65 -0
  99. package/src/services/doctor.ts +68 -6
  100. package/src/services/end-push.ts +157 -0
  101. package/src/services/finalize.ts +12 -2
  102. package/src/services/gate-env.ts +218 -0
  103. package/src/services/gate-service.ts +216 -45
  104. package/src/services/global-install.ts +46 -13
  105. package/src/services/lane-edit.ts +154 -0
  106. package/src/services/lane-service.ts +257 -52
  107. package/src/services/milestones.ts +137 -26
  108. package/src/services/notifier.ts +56 -3
  109. package/src/services/orchestrator-wait.ts +82 -0
  110. package/src/services/pause.ts +161 -0
  111. package/src/services/peek.ts +79 -17
  112. package/src/services/ports.ts +31 -2
  113. package/src/services/preflight.ts +153 -19
  114. package/src/services/profile-service.ts +1 -0
  115. package/src/services/profile-store.ts +10 -3
  116. package/src/services/protocol.ts +119 -25
  117. package/src/services/provenance.ts +16 -12
  118. package/src/services/questions.ts +15 -0
  119. package/src/services/reconcile.ts +22 -1
  120. package/src/services/reentry.ts +7 -6
  121. package/src/services/role-access.ts +27 -0
  122. package/src/services/routing-service.ts +144 -36
  123. package/src/services/run-pin.ts +125 -0
  124. package/src/services/run-service.ts +151 -19
  125. package/src/services/run-store.ts +109 -15
  126. package/src/services/runs-page.ts +42 -7
  127. package/src/services/scratch.ts +60 -0
  128. package/src/services/sessions.ts +3 -1
  129. package/src/services/source-derive.ts +28 -5
  130. package/src/services/source-sync.ts +9 -2
  131. package/src/services/standins.ts +40 -6
  132. package/src/services/state.ts +21 -0
  133. package/src/services/summary.ts +49 -9
  134. package/src/services/treat-likes.ts +19 -3
  135. package/src/services/verifier-brief.ts +23 -0
  136. package/src/services/verifier-step.ts +48 -0
  137. package/src/services/workspace-admission.ts +267 -0
  138. package/src/services/workspace-service.ts +254 -0
  139. package/src/services/workspace-store.ts +95 -0
@@ -8,11 +8,15 @@ import type { Tokens } from "../domain/record.ts";
8
8
  import { median } from "../domain/util.ts";
9
9
  import { nonBlankLines, readJsonl } from "../infra/store.ts";
10
10
  import { spendOf } from "./budget.ts";
11
- import { latestVerifierStep, type VerifierStep } from "./gate-service.ts";
11
+ import { verifierStepView, type VerifierStepView } from "./verifier-step.ts";
12
+ import { type Pause, pausesOver } from "./pause.ts";
13
+ import { pinChanges } from "./run-pin.ts";
12
14
  import { type OpenQuestion, openQuestions } from "./questions.ts";
13
15
  import { type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
16
+ import { readDelivery, ROLE_THREAD_REFUSAL } from "../infra/delivery.ts";
14
17
  import { type RunSession, sessionFacts } from "./session-view.ts";
15
18
  import type { Deps } from "./ports.ts";
19
+ import { orchestratorWait, type OrchestratorWait } from "./orchestrator-wait.ts";
16
20
  import {
17
21
  findRun,
18
22
  listRuns,
@@ -21,11 +25,18 @@ import {
21
25
  readRoutes,
22
26
  type Run,
23
27
  runPaths,
28
+ supersededBy,
24
29
  } from "./run-store.ts";
25
30
 
26
31
  export interface RunSummary {
32
+ /** No live roles and at least one durable completion the orchestrator has not collected. */
33
+ waiting?: OrchestratorWait | null;
27
34
  delivery: (DeliveryInspection & { name: string; dispatchId: string })[];
28
35
  id: string;
36
+ /** spec 1.5 "runs supersede": the run that took this one over; status() without a run hides it */
37
+ supersededBy?: string | null;
38
+ /** spec 1.5: what changed since the run's pin; dispatch keeps the pinned values */
39
+ pinChanges?: string[];
29
40
  /** spec 1.1 §8: the owner questions not answered yet, listed first */
30
41
  questions: OpenQuestion[];
31
42
  title: string;
@@ -45,8 +56,8 @@ export interface RunSummary {
45
56
  harness: { backend: string; native: number; isolated: number }[];
46
57
  budget: BudgetStatus | null;
47
58
  milestones: string[];
48
- /** spec 1.1 §7: the verifier's latest gate_check, so the user sees where it is */
49
- verifier: VerifierStep | null;
59
+ /** spec 1.1 §7: the verifier's latest gate_check, so the user sees where it is; plan 22: its age, and what closed it */
60
+ verifier: VerifierStepView | null;
50
61
  warnings: string[];
51
62
  }
52
63
 
@@ -61,6 +72,9 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
61
72
  const sum = (f: (t: Tokens) => number) => records.reduce((n, r) => n + f(r.tokens), 0);
62
73
  let budget: BudgetStatus | null = null;
63
74
  const warnings = corrupt ? [`runs.jsonl: skipped ${corrupt} unreadable row(s)`] : [];
75
+ // spec 1.5 "Pinned per run": a change since the pin, which dispatch does not follow
76
+ const pinned = pinChanges(deps, run);
77
+ for (const c of pinned) warnings.push(`pinned: ${c}; dispatch keeps the pinned value (run_pin re-pins)`);
64
78
  try {
65
79
  budget = budgetStatus(
66
80
  spendOf(run, records, live, now),
@@ -69,7 +83,18 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
69
83
  } catch (e) {
70
84
  warnings.push(`budget: ${isCatherdError(e) ? e.message : String(e)}`);
71
85
  }
86
+ // spec 1.5 plan 21: a notice refused because its target was a role's thread, while that is its latest attempt
87
+ for (const d of listDispatches(run)) {
88
+ try {
89
+ const last = readDelivery(d.dir).at(-1);
90
+ if (last?.status === "failed" && last.reason?.startsWith(ROLE_THREAD_REFUSAL))
91
+ warnings.push(`${d.admit.name}: its notice was not sent: ${last.reason}`);
92
+ } catch {
93
+ // unreadable delivery evidence is the delivery row's to show
94
+ }
95
+ }
72
96
  return {
97
+ waiting: orchestratorWait(run, now, records, live.length > 0),
73
98
  delivery: listDispatches(run).flatMap((d) =>
74
99
  inspectDeliveries(run, d, recorded.has(d.admit.dispatchId)).map((inspection) => ({
75
100
  name: d.admit.name,
@@ -78,6 +103,8 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
78
103
  })),
79
104
  ),
80
105
  id: run.id,
106
+ supersededBy: supersededBy(run)?.by ?? null,
107
+ pinChanges: pinned,
81
108
  questions: openQuestions(run),
82
109
  title: run.meta.title,
83
110
  repo: run.meta.repo,
@@ -116,40 +143,53 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
116
143
  }),
117
144
  budget,
118
145
  milestones: nonBlankLines(runPaths(run.dir).ledger).slice(1),
119
- verifier: latestVerifierStep(run),
146
+ verifier: verifierStepView(run, now),
120
147
  warnings,
121
148
  };
122
149
  }
123
150
 
124
- /** `status(run?)`: that run; without one, every run with a live role, else the newest run. */
151
+ /**
152
+ * `status(run?)`: that run; without one, the live runs, else the runs waiting for their orchestrator (a current
153
+ * owner, a recent unread record: plan 22), else the newest run.
154
+ */
125
155
  export function status(
126
156
  deps: Deps,
127
157
  runId?: string,
128
158
  queue: QueueCapability | null = null,
129
159
  ): {
160
+ /** spec 1.5 "Group pause": the machine's pause and the listed runs' workspace pauses, shown first */
161
+ paused: Pause[];
130
162
  version: string;
131
163
  runs: RunSummary[];
132
164
  warnings: string[];
133
165
  host: HostContext;
134
166
  queue: QueueCapability | null;
135
167
  } {
136
- if (runId)
168
+ if (runId) {
169
+ const run = findRun(runId);
137
170
  return {
171
+ paused: pausesOver([run]),
138
172
  host: inspectionHost(deps.host),
139
173
  queue,
140
174
  version: deps.version,
141
- runs: [summarizeRun(deps, findRun(runId))],
175
+ runs: [summarizeRun(deps, run)],
142
176
  warnings: [],
143
177
  };
144
- const { runs, corrupt } = listRuns();
178
+ }
179
+ const { runs: listed, corrupt } = listRuns();
145
180
  const warnings = corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`);
181
+ // a superseded run is closed: only status(run) shows it
182
+ const runs = listed.filter((r) => !supersededBy(r));
146
183
  const all = runs.map((r) => summarizeRun(deps, r));
147
184
  const live = all.filter((s) => s.live.length > 0);
185
+ const waiting = all.filter((s) => s.waiting);
186
+ const shown = live.length ? live : waiting.length ? waiting : all.slice(0, 1);
148
187
  return {
188
+ paused: pausesOver(runs.filter((r) => shown.some((s) => s.id === r.id))),
149
189
  host: inspectionHost(deps.host),
150
190
  queue,
151
191
  version: deps.version,
152
- runs: live.length ? live : all.slice(0, 1),
192
+ runs: shown,
153
193
  warnings,
154
194
  };
155
195
  }
@@ -5,7 +5,7 @@ import { tryParseRung } from "../domain/ids.ts";
5
5
  import { ROLES } from "../domain/roles.ts";
6
6
  import { canonicalRung, loadCatalog, readOverride, removeTreatLikes } from "./catalog-service.ts";
7
7
  import { getProfile, listProfiles } from "./profile-store.ts";
8
- import { type Suggestion, suggestStandIns } from "./standins.ts";
8
+ import { barDimsIn, type Suggestion, suggestStandIns } from "./standins.ts";
9
9
 
10
10
  /** A profile's rung that would lean on an inferred stand-in, or be left with no value at all (spec 1.2 §6.4). */
11
11
  export interface LeftOnStandIn {
@@ -15,6 +15,11 @@ export interface LeftOnStandIn {
15
15
  dims: Dim[];
16
16
  /** no value of its own and no stand-in near enough: routing skips it */
17
17
  unscored: boolean;
18
+ /**
19
+ * (1.2 minor) the bar dimensions it keeps no value on at all, though it keeps others: present only when some
20
+ * are; no bar that needs one is cleared
21
+ */
22
+ missing?: Dim[];
18
23
  }
19
24
 
20
25
  /**
@@ -50,8 +55,19 @@ export function leftOnStandIns(
50
55
  const canonical = rungInfo(c, rung).canonical;
51
56
  if (!gone.includes(canonical)) continue;
52
57
  const s = scoresOf(c, canonical);
53
- if (!s) out.push({ profile, rung, dims: [], unscored: true });
54
- else if (s.inferred.length) out.push({ profile, rung, dims: s.inferred, unscored: false });
58
+ if (!s) {
59
+ out.push({ profile, rung, dims: [], unscored: true });
60
+ continue;
61
+ }
62
+ const missing = barDimsIn(c).filter((d) => s.values[d] === undefined);
63
+ if (s.inferred.length || missing.length)
64
+ out.push({
65
+ profile,
66
+ rung,
67
+ dims: s.inferred,
68
+ unscored: false,
69
+ ...(missing.length ? { missing } : {}),
70
+ });
55
71
  }
56
72
  }
57
73
  return out;
@@ -0,0 +1,23 @@
1
+ import { withGateNotes } from "../domain/gate-brief.ts";
2
+ import { gateEnvLines, readGateEnv } from "./gate-env.ts";
3
+ import { recordedItems } from "./gate-service.ts";
4
+ import type { Run } from "./run-store.ts";
5
+
6
+ // Plan 23: a dispatched verifier's brief ends with the gate's rules and the run's facts: the milestone's
7
+ // recorded items (reuse their names), the failed ones (a re-check runs them first, each command capped), and
8
+ // the repo's gate environment.
9
+
10
+ /** The milestone a verifier dispatch checks: the first `M<n>` its name names as a word (verifier-M1 → M1). */
11
+ export const milestoneOfName = (name: string): string | null =>
12
+ /(?:^|[^A-Za-z0-9])(M\d+)(?=$|[-._\s])/.exec(name)?.[1] ?? null;
13
+
14
+ /** A verifier's brief with the gate notes, once; any other role's brief as it is. */
15
+ export async function verifierBrief(run: Run, role: string, name: string, brief: string): Promise<string> {
16
+ if (role !== "verifier") return brief;
17
+ const milestone = milestoneOfName(name);
18
+ return withGateNotes(brief, {
19
+ milestone,
20
+ recorded: milestone ? await recordedItems(run, milestone) : [],
21
+ env: gateEnvLines(await readGateEnv(run.meta.repo)),
22
+ });
23
+ }
@@ -0,0 +1,48 @@
1
+ import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
2
+ import { readJsonl } from "../infra/store.ts";
3
+ import { gatesFile, latestVerifierStep, type VerifierStep } from "./gate-service.ts";
4
+ import { readAgentRuns, readRecords, type Run } from "./run-store.ts";
5
+ import { runOwner } from "./sessions.ts";
6
+
7
+ /** What ended a verifier step (plan 22: a step nothing ends shows as live forever). */
8
+ export type StepClosedBy = "gate_pass" | "agent-run" | "record" | "owner-gone";
9
+
10
+ export interface VerifierStepView extends VerifierStep {
11
+ /** seconds since the step began */
12
+ secs: number;
13
+ open: boolean;
14
+ closedBy: StepClosedBy | null;
15
+ }
16
+
17
+ /**
18
+ * Plan 22: the verifier's latest step with its age, closed by the first evidence after it: a `gate_pass` of its
19
+ * item in this run, a native verifier's `record_agent_run`, a verifier dispatch's record, or its owner session
20
+ * gone (another session took the run since, or the owner's Claude Code session no longer runs). A Codex owner
21
+ * that still holds the run has no liveness to read: its step stays open until other evidence ends it.
22
+ */
23
+ export function verifierStepView(
24
+ run: Run,
25
+ now: number,
26
+ files: () => SessionFile[] = readSessionFiles,
27
+ ): VerifierStepView | null {
28
+ const step = latestVerifierStep(run);
29
+ if (!step) return null;
30
+ const at = Date.parse(step.at) || 0;
31
+ const after = (t: string | undefined) => (Date.parse(t ?? "") || 0) >= at;
32
+ const passed = readJsonl<{ run?: unknown; item?: unknown; at?: string }>(
33
+ gatesFile(run.meta.repo),
34
+ ).rows.some((p) => p?.run === run.id && p.item === step.item && after(p.at));
35
+ const owner = runOwner(run);
36
+ const closedBy: StepClosedBy | null = passed
37
+ ? "gate_pass"
38
+ : readAgentRuns(run).some((a) => a.role === "verifier" && after(a.at))
39
+ ? "agent-run"
40
+ : readRecords(run).records.some((r) => r.role === "verifier" && after(r.endedAt))
41
+ ? "record"
42
+ : owner &&
43
+ ((Date.parse(owner.since) || 0) > at ||
44
+ (owner.host === "claude-code" && liveSessionFile(owner.sessionId, files()) === null))
45
+ ? "owner-gone"
46
+ : null;
47
+ return { ...step, secs: Math.max(0, Math.floor((now - at) / 1000)), open: closedBy === null, closedBy };
48
+ }
@@ -0,0 +1,267 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { budgetStatus, formatBudget, type BudgetStatus, type Spend } from "../domain/budget.ts";
3
+ import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
4
+ import type { Workspace, WorkspaceStep } from "../domain/workspace.ts";
5
+ import { withFileLock } from "../infra/filelock.ts";
6
+ import { git, gitToplevel } from "../infra/git.ts";
7
+ import { writeTextAtomic } from "../infra/store.ts";
8
+ import { spendOf } from "./budget.ts";
9
+ import { pendingDispatches } from "./dispatches.ts";
10
+ import { landedCommits, landedMilestones } from "./milestones.ts";
11
+ import { readRecords, type Run, runPaths } from "./run-store.ts";
12
+ import {
13
+ findWorkspace,
14
+ saveWorkspace,
15
+ WORKSPACE_LOCK_WAIT_MS,
16
+ workspaceChildren,
17
+ workspaceDirectory,
18
+ workspacePaths,
19
+ } from "./workspace-store.ts";
20
+
21
+ /**
22
+ * Count each recorded or unrecorded dispatch once; elapsed minutes belong to the parent and start at its first
23
+ * child (#43 finding 4), none before one. With `warnings`, a child whose cost evidence cannot be read is
24
+ * skipped and named there (status); without, it throws E_RUN_CORRUPT (admission against a cap, which cannot
25
+ * prove the cap holds without it).
26
+ */
27
+ export async function workspaceSpend(
28
+ workspace: Workspace,
29
+ now: number,
30
+ children = workspaceChildren(workspace),
31
+ warnings?: string[],
32
+ ): Promise<Spend> {
33
+ const first = Math.min(...children.map((c) => Date.parse(c.meta.createdAt)).filter(Number.isFinite));
34
+ const total: Spend = {
35
+ minutes: Number.isFinite(first) ? Math.max(0, (now - first) / 60_000) : 0,
36
+ tokens: 0,
37
+ usd: 0,
38
+ };
39
+ for (const child of children) {
40
+ try {
41
+ const spent = await withFileLock(runPaths(child.dir).runs, () => {
42
+ const { records, corrupt } = readRecords(child);
43
+ if (corrupt) throw new CatherdError("E_RUN_CORRUPT", `run ${child.id} has unreadable cost records`);
44
+ return spendOf(child, records, pendingDispatches(child, now, records, true), now, true);
45
+ });
46
+ total.tokens += spent.tokens;
47
+ total.usd += spent.usd;
48
+ } catch (e) {
49
+ if (!warnings || !isCatherdError(e) || e.code !== "E_RUN_CORRUPT") throw e;
50
+ warnings.push(`spend leaves out ${child.id}: ${errorMessage(e)}`);
51
+ }
52
+ }
53
+ return total;
54
+ }
55
+
56
+ /** The workspace budget a child run's routing sees; a child whose evidence is unreadable counts nothing. */
57
+ export async function workspaceBudget(run: Run, now: number): Promise<BudgetStatus | null> {
58
+ if (!run.meta.workspace) return null;
59
+ const workspace = findWorkspace(run.meta.workspace.id);
60
+ return budgetStatus(await workspaceSpend(workspace, now, undefined, []), workspace.budget);
61
+ }
62
+
63
+ /** Why a dependency keeps a step waiting: the dependency's step id and the reason, in words. */
64
+ export interface Blocker {
65
+ step: string;
66
+ why: string;
67
+ }
68
+
69
+ /** The dependency's uncollected dispatches, read under its records lock: a reason, or null when none. */
70
+ async function uncollected(child: Run, now: number): Promise<string | null> {
71
+ const step = child.meta.workspace?.step ?? child.id;
72
+ try {
73
+ const pending = await withFileLock(runPaths(child.dir).runs, () => {
74
+ const { records, corrupt } = readRecords(child);
75
+ if (corrupt) throw new CatherdError("E_RUN_CORRUPT", `run ${child.id} has unreadable dispatch records`);
76
+ return pendingDispatches(child, now, records, true);
77
+ });
78
+ return pending.length
79
+ ? `${step} has ${pending.length} dispatch(es) not collected (${pending.map((d) => d.admit.name).join(", ")})`
80
+ : null;
81
+ } catch (e) {
82
+ if (isCatherdError(e) && e.code === "E_RUN_CORRUPT") return `${step} cannot be read: ${e.message}`;
83
+ throw e;
84
+ }
85
+ }
86
+
87
+ /** A landed milestone that differs from the step's only by case: `m1` for `M1` (#43 finding 7). */
88
+ const caseTwin = (landed: string[], milestone: string): string | undefined =>
89
+ landed.find((m) => m !== milestone && m.toLowerCase() === milestone.toLowerCase());
90
+
91
+ /**
92
+ * #43 finding 8: the one dependency rule admission, child start and status share. A dependency releases its
93
+ * dependents once its child has landed the dependency's milestone and every dispatch it admitted is
94
+ * collected. Reads only the dependencies' own evidence (#43 finding 2); an unreadable one blocks, named.
95
+ */
96
+ export async function dependencyBlockers(
97
+ workspace: Workspace,
98
+ step: WorkspaceStep,
99
+ children: Run[],
100
+ now: number,
101
+ o: { merge?: boolean } = {},
102
+ ): Promise<Blocker[]> {
103
+ const out: Blocker[] = [];
104
+ for (const id of step.dependsOn) {
105
+ const before = workspace.steps.find((s) => s.id === id);
106
+ const child = children.find((c) => c.meta.workspace?.step === id);
107
+ if (!before || !child) {
108
+ out.push({ step: id, why: `${id} has not started` });
109
+ continue;
110
+ }
111
+ const landed = landedMilestones(child);
112
+ if (!landed.includes(before.milestone)) {
113
+ const twin = caseTwin(landed, before.milestone);
114
+ out.push({
115
+ step: id,
116
+ why: `${id} has not landed ${before.milestone}${twin ? ` (it landed ${twin}: the step completes on ${before.milestone})` : ""}`,
117
+ });
118
+ continue;
119
+ }
120
+ const why = (await uncollected(child, now)) ?? (o.merge ? await unmerged(before, child) : null);
121
+ if (why) out.push({ step: id, why });
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /** The commit the ledger holds for `milestone`, its latest landing; null when it never landed. */
127
+ function landedCommitOf(run: Run, milestone: string): string | null {
128
+ const commits = landedCommits(run);
129
+ const milestones = landedMilestones(run);
130
+ const at = milestones.lastIndexOf(milestone);
131
+ return at < 0 ? null : (commits[at] ?? null);
132
+ }
133
+
134
+ /**
135
+ * A `release: "merge"` step releases its dependents once its landed commit is an ancestor of its base ref
136
+ * (`git merge-base --is-ancestor`), in the repository as it stands: catherd never fetches or polls.
137
+ */
138
+ async function unmerged(step: WorkspaceStep, child: Run): Promise<string | null> {
139
+ if (step.release !== "merge" || !step.base) return null;
140
+ const commit = landedCommitOf(child, step.milestone);
141
+ if (!commit) return `${step.id} has no landed commit for ${step.milestone} in its ledger`;
142
+ const r = await git(child.meta.repo, ["merge-base", "--is-ancestor", commit, step.base]);
143
+ if (r.kind === "ok") return null;
144
+ if (r.kind === "failed" && r.exit === 1)
145
+ return `${step.id}'s ${step.milestone} (${commit.slice(0, 7)}) is not merged into ${step.base} yet: merge it, git fetch, then ask again`;
146
+ return `${step.id}: git cannot tell whether ${commit.slice(0, 7)} is in ${step.base} (does the ref exist in ${child.meta.repo}?)`;
147
+ }
148
+
149
+ export function withRunAdmission<T>(run: Run, now: () => number, admit: () => Promise<T>): Promise<T> {
150
+ return withWorkspaceAdmission(run, now, () => withFileLock(runPaths(run.dir).admission, admit));
151
+ }
152
+
153
+ /** Parent → child admission → child records; siblings share the actual admission boundary. */
154
+ export async function withWorkspaceAdmission<T>(
155
+ run: Run,
156
+ clock: number | (() => number),
157
+ admit: () => Promise<T>,
158
+ ): Promise<T> {
159
+ const link = run.meta.workspace;
160
+ if (!link) return admit();
161
+ const workspace = findWorkspace(link.id);
162
+ // git outside the workspace lock (#43 finding 5): the checkout check needs no sibling to wait
163
+ if ((await gitToplevel(run.meta.repo)) !== run.meta.repo)
164
+ throw new CatherdError(
165
+ "E_IO_PATH",
166
+ `workspace repository of ${link.step} is no longer the captured git root`,
167
+ );
168
+ return withFileLock(
169
+ workspacePaths(workspaceDirectory(workspace.id)).admission,
170
+ async () => {
171
+ const now = typeof clock === "function" ? clock() : clock;
172
+ const children = workspaceChildren(workspace);
173
+ const step = workspace.steps.find((s) => s.id === link.step);
174
+ if (!step || !children.some((c) => c.id === run.id))
175
+ throw new CatherdError("E_INPUT_INVALID", "the run is not a member of this workspace execution", {
176
+ fix: "start the child with workspace_child_start",
177
+ });
178
+ // #43 finding 7: a landed step's child admits again (a post-land fix); its dependents wait for it
179
+ const blockers = await dependencyBlockers(workspace, step, children, now);
180
+ if (blockers.length)
181
+ throw new CatherdError(
182
+ "E_INPUT_INVALID",
183
+ `${step.id} is waiting: ${blockers.map((b) => b.why).join("; ")}`,
184
+ {
185
+ fix: "land the dependency's completion milestone and collect its finished dispatches first",
186
+ },
187
+ );
188
+ await assertWorkspaceBudget(workspace, now, children);
189
+ return admit();
190
+ },
191
+ { timeoutMs: WORKSPACE_LOCK_WAIT_MS },
192
+ );
193
+ }
194
+
195
+ /** Whether the budget caps anything: without a cap, no sibling's evidence is read (#43 finding 2). */
196
+ const capped = (workspace: Workspace): boolean =>
197
+ Object.values(workspace.budget).some((v) => typeof v === "number");
198
+
199
+ /** Refuses E_RUN_BUDGET once a capped workspace budget is spent; reads every child only when capped. */
200
+ export async function assertWorkspaceBudget(
201
+ workspace: Workspace,
202
+ now: number,
203
+ children: Run[],
204
+ ): Promise<void> {
205
+ if (!capped(workspace)) return;
206
+ const budget = budgetStatus(await workspaceSpend(workspace, now, children), workspace.budget);
207
+ if (budget && budget.fraction >= 1)
208
+ throw new CatherdError("E_RUN_BUDGET", `the workspace budget is spent: ${formatBudget(budget)}`, {
209
+ fix: `finish with the work already admitted, or raise it: workspace_budget(${workspace.id}, …)`,
210
+ });
211
+ }
212
+
213
+ /**
214
+ * `workspace_budget`: sets the caps given (a number) or removes them (null), keeping the others; the owner's
215
+ * way to raise a spent budget without a new workspace (#43 finding 4). Returns the budget and its status.
216
+ */
217
+ export async function setWorkspaceBudget(
218
+ deps: { now: () => number },
219
+ i: { workspace: string; minutes?: number | null; tokens?: number | null; usd?: number | null },
220
+ ): Promise<{ budget: Workspace["budget"]; spend: Spend; status: BudgetStatus | null }> {
221
+ const workspace = findWorkspace(i.workspace);
222
+ return withFileLock(
223
+ workspacePaths(workspaceDirectory(workspace.id)).admission,
224
+ async () => {
225
+ const budget: Workspace["budget"] = { ...findWorkspace(workspace.id).budget };
226
+ for (const key of ["minutes", "tokens", "usd"] as const) {
227
+ const v = i[key];
228
+ if (v === null) delete budget[key];
229
+ else if (v !== undefined) budget[key] = v;
230
+ }
231
+ const saved = saveWorkspace({ ...findWorkspace(workspace.id), budget });
232
+ const spend = await workspaceSpend(saved, deps.now(), undefined, []);
233
+ return { budget: saved.budget, spend, status: budgetStatus(spend, saved.budget) };
234
+ },
235
+ { timeoutMs: WORKSPACE_LOCK_WAIT_MS },
236
+ );
237
+ }
238
+
239
+ /** The shared contract is frozen when the first child starts, then copied into child dossiers. */
240
+ export async function workspaceContract(i: {
241
+ workspace: string;
242
+ content?: string;
243
+ }): Promise<{ path: string; content: string }> {
244
+ const workspace = findWorkspace(i.workspace);
245
+ const paths = workspacePaths(workspaceDirectory(workspace.id));
246
+ if (i.content === undefined)
247
+ return {
248
+ path: paths.contract,
249
+ content: existsSync(paths.contract) ? readFileSync(paths.contract, "utf8") : "",
250
+ };
251
+ return withFileLock(
252
+ paths.admission,
253
+ () => {
254
+ if (workspaceChildren(workspace).length)
255
+ throw new CatherdError(
256
+ "E_INPUT_INVALID",
257
+ "the workspace contract is frozen because a child has started",
258
+ {
259
+ fix: "start a new workspace execution to change the shared contract",
260
+ },
261
+ );
262
+ writeTextAtomic(paths.contract, i.content as string);
263
+ return { path: paths.contract, content: i.content as string };
264
+ },
265
+ { timeoutMs: WORKSPACE_LOCK_WAIT_MS },
266
+ );
267
+ }