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
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CatherdError } from "../domain/errors.ts";
4
4
  import type { RunRecord } from "../domain/record.ts";
5
- import { git } from "../infra/git.ts";
5
+ import { commitExists, git } from "../infra/git.ts";
6
6
  import { nonBlankLines } from "../infra/store.ts";
7
7
  import { listDispatches } from "./dispatches.ts";
8
8
  import { readAgentRuns, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
@@ -50,27 +50,95 @@ export interface MilestoneReviewer {
50
50
  record: RunRecord | null;
51
51
  }
52
52
 
53
- /**
54
- * The milestone's latest passing reviewer since its lanes started: a dispatch named reviewer-<m>…, status
55
- * ok; or a record_agent_run row with role reviewer and the same name, status ok. Null when there is none.
56
- */
57
- export function milestoneReviewer(
53
+ /** Every reviewer attempt of the milestone since its lanes started that ended ok, oldest first, partial or not. */
54
+ function reviewerAttempts(
58
55
  run: Run,
59
56
  m: string,
60
- start = milestoneStart(run, m),
61
- ): MilestoneReviewer | null {
62
- const found: MilestoneReviewer[] = [
57
+ start: string | null,
58
+ ): (MilestoneReviewer & { partial: boolean })[] {
59
+ return [
63
60
  ...readRecords(run)
64
61
  .records.filter((r) => reviewsMilestone(r.name, m) && r.status === "ok" && since(r.endedAt, start))
65
- .map((r) => ({ name: r.name, at: r.endedAt, record: r })),
62
+ .map((r) => ({ name: r.name, at: r.endedAt, record: r, partial: r.replyStatus === "partial" })),
66
63
  ...readAgentRuns(run)
67
64
  .filter(
68
65
  (a) =>
69
66
  a.role === "reviewer" && reviewsMilestone(a.name, m) && a.status === "ok" && since(a.at, start),
70
67
  )
71
- .map((a) => ({ name: a.name, at: a.at, record: null })),
72
- ];
73
- return found.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).at(-1) ?? null;
68
+ .map((a) => ({ name: a.name, at: a.at, record: null, partial: a.replyStatus === "partial" })),
69
+ ].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
70
+ }
71
+
72
+ /**
73
+ * The milestone's latest passing reviewer since its lanes started: a dispatch named reviewer-<m>…, status
74
+ * ok; or a record_agent_run row with role reviewer and the same name, status ok. A reply of STATUS: partial
75
+ * read only part of the diff and is not the milestone's review (plan 23). Null when there is none.
76
+ */
77
+ export function milestoneReviewer(
78
+ run: Run,
79
+ m: string,
80
+ start = milestoneStart(run, m),
81
+ ): MilestoneReviewer | null {
82
+ const full = reviewerAttempts(run, m, start).filter((a) => !a.partial);
83
+ const last = full.at(-1);
84
+ return last ? { name: last.name, at: last.at, record: last.record } : null;
85
+ }
86
+
87
+ /** Plan 23: the milestone's latest reviewer that stopped at STATUS: partial, when no full review stands; else null. */
88
+ export function partialReviewer(
89
+ run: Run,
90
+ m: string,
91
+ start = milestoneStart(run, m),
92
+ ): MilestoneReviewer | null {
93
+ if (milestoneReviewer(run, m, start)) return null;
94
+ const last = reviewerAttempts(run, m, start)
95
+ .filter((a) => a.partial)
96
+ .at(-1);
97
+ return last ? { name: last.name, at: last.at, record: last.record } : null;
98
+ }
99
+
100
+ export const FINDING = /^\s*(?:[-*]\s*)?(BLOCKER|BUG|NIT)\b/;
101
+
102
+ /** A reviewer reply's findings by severity; null when there is no reply to read. */
103
+ export function countFindings(run: Run, r: RunRecord): { BLOCKER: number; BUG: number; NIT: number } | null {
104
+ const file = join(run.dir, r.replyPath);
105
+ if (!r.replyPath || !existsSync(file)) return null;
106
+ const n = { BLOCKER: 0, BUG: 0, NIT: 0 };
107
+ for (const line of readFileSync(file, "utf8").split("\n")) {
108
+ const k = FINDING.exec(line)?.[1] as keyof typeof n | undefined;
109
+ if (k) n[k]++;
110
+ }
111
+ return n;
112
+ }
113
+
114
+ /** The roles a fix round runs as. */
115
+ const FIX_ROLES = new Set(["worker", "writer", "artist"]);
116
+
117
+ /**
118
+ * Plan 23: the milestone reviewer's BLOCKER and BUG lines while no fix round has ended since (a worker,
119
+ * writer or artist of the milestone, ended ok after the review); null when there are none, or for a native
120
+ * reviewer, whose reply catherd does not keep.
121
+ */
122
+ export function openFindings(
123
+ run: Run,
124
+ m: string,
125
+ start = milestoneStart(run, m),
126
+ ): { name: string; blocker: number; bug: number } | null {
127
+ const reviewer = milestoneReviewer(run, m, start);
128
+ if (!reviewer?.record) return null;
129
+ const n = countFindings(run, reviewer.record);
130
+ if (!n || n.BLOCKER + n.BUG === 0) return null;
131
+ const after = (at: string) => Date.parse(at) > Date.parse(reviewer.at);
132
+ const ofM = (name: string, lane: string | null | undefined) =>
133
+ inMilestone(lane, m) || namesMilestone(name, m);
134
+ const fixed =
135
+ readRecords(run).records.some(
136
+ (r) => FIX_ROLES.has(r.role) && r.status === "ok" && ofM(r.name, r.lane) && after(r.endedAt),
137
+ ) ||
138
+ readAgentRuns(run).some(
139
+ (a) => FIX_ROLES.has(a.role) && a.status === "ok" && ofM(a.name, a.lane) && after(a.at),
140
+ );
141
+ return fixed ? null : { name: reviewer.name, blocker: n.BLOCKER, bug: n.BUG };
74
142
  }
75
143
 
76
144
  /** A reviewer record for the milestone since its lanes started (see milestoneReviewer). */
@@ -80,6 +148,15 @@ export const reviewerPassed = (run: Run, m: string, start = milestoneStart(run,
80
148
  /** The verifier's contract puts `VERDICT: PASS` or `VERDICT: FAIL` on the reply's first line (role-prompts.ts). */
81
149
  const VERDICT_PASS = /^VERDICT: PASS\b/;
82
150
 
151
+ /**
152
+ * Plan 23: `VERDICT: BLOCKED: environment — <the probe that proves it>`, a verdict on the machine, not on the
153
+ * work. Its probe, or null when `line` is no such verdict ("" when it names none).
154
+ */
155
+ export function blockedByEnvironment(line: string | null | undefined): string | null {
156
+ const m = /^VERDICT:\s*BLOCKED:\s*environment\b\s*(?:—|–|-|:)?\s*(.*)$/i.exec(line?.trim() ?? "");
157
+ return m ? (m[1] ?? "").trim() : null;
158
+ }
159
+
83
160
  /** A headless verifier's reply: its first non-blank line, trimmed; null when there is no reply to read. */
84
161
  export function replyVerdict(run: Run, r: RunRecord): string | null {
85
162
  const file = join(run.dir, r.replyPath);
@@ -106,11 +183,17 @@ export interface MilestoneVerifier {
106
183
  passed: boolean;
107
184
  /** what the attempt said: a native row's status, or a headless reply's first non-blank line ("no reply") */
108
185
  verdict: string;
186
+ /** plan 23: the probe of a `VERDICT: BLOCKED: environment` attempt ("" when it named none); null otherwise */
187
+ blocked: string | null;
109
188
  }
110
189
 
190
+ /** The one name a milestone's verifier is counted under (spec 1.5 "The ledger"): verifier-<m>, exactly. */
191
+ export const verifierName = (m: string): string => `verifier-${m}`;
192
+
111
193
  /**
112
194
  * The milestone's latest verifier attempt since its lanes started, whatever it said: a record_agent_run row
113
- * with role verifier whose name names the milestone (the skill records a FAIL as failed), passing when its
195
+ * with role verifier named exactly verifier-<m> (the skill records a FAIL as failed; verifier-M2-pre or
196
+ * verifier-M2-gate1 is not M2's verifier), passing when its
114
197
  * status is ok; or a headless verifier's dispatch record of the same shape, passing when it exited ok and its
115
198
  * reply opens VERDICT: PASS (status ok means only the CLI exited). A later FAIL undoes an earlier PASS. Null
116
199
  * when there is none.
@@ -122,16 +205,21 @@ export function milestoneVerifier(
122
205
  ): MilestoneVerifier | null {
123
206
  const found: MilestoneVerifier[] = [
124
207
  ...readAgentRuns(run)
125
- .filter((a) => a.role === "verifier" && namesMilestone(a.name, m) && since(a.at, start))
126
- .map((a) => ({
127
- name: a.name,
128
- at: a.at,
129
- headless: false,
130
- passed: a.status === "ok",
131
- verdict: a.status,
132
- })),
208
+ .filter((a) => a.role === "verifier" && a.name === verifierName(m) && since(a.at, start))
209
+ .map((a) => {
210
+ // plan 23: a native verifier's verdict line, when record_agent_run passed it
211
+ const blocked = a.status === "ok" ? null : blockedByEnvironment(a.verdict);
212
+ return {
213
+ name: a.name,
214
+ at: a.at,
215
+ headless: false,
216
+ passed: a.status === "ok",
217
+ verdict: blocked === null ? a.status : (a.verdict ?? a.status),
218
+ blocked,
219
+ };
220
+ }),
133
221
  ...readRecords(run)
134
- .records.filter((r) => r.role === "verifier" && namesMilestone(r.name, m) && since(r.endedAt, start))
222
+ .records.filter((r) => r.role === "verifier" && r.name === verifierName(m) && since(r.endedAt, start))
135
223
  .map((r) => {
136
224
  const first = replyVerdict(run, r);
137
225
  const passed = r.status === "ok" && first !== null && VERDICT_PASS.test(first);
@@ -142,6 +230,7 @@ export function milestoneVerifier(
142
230
  headless: true,
143
231
  passed,
144
232
  verdict: r.status === "ok" ? said : `${r.status}, ${said}`,
233
+ blocked: r.status === "ok" ? blockedByEnvironment(first) : null,
145
234
  };
146
235
  }),
147
236
  ];
@@ -170,12 +259,16 @@ export function landedMilestones(run: Run): string[] {
170
259
  }
171
260
 
172
261
  /**
173
- * The files the milestone's commit range changed: from the previous landed commit (else the commit's own
174
- * parent) to `commit`. Throws E_IO_UNEXPECTED when git cannot say.
262
+ * The files the milestone's commit range changed: from the previous landed commit, else the HEAD the run
263
+ * started on (meta.json `startHead`, when that commit is still there), else the commit's own parent, to
264
+ * `commit`. Throws E_IO_UNEXPECTED when git cannot say.
175
265
  */
176
266
  export async function milestoneFiles(run: Run, commit: string): Promise<string[]> {
177
- const base = landedCommits(run).at(-1);
178
267
  const repo = run.meta.repo;
268
+ const start = run.meta.startHead;
269
+ const base =
270
+ landedCommits(run).at(-1) ??
271
+ (start && (await commitExists(repo, start).catch(() => false)) ? start : undefined);
179
272
  const r = base
180
273
  ? await git(repo, ["diff", "--name-only", base, commit])
181
274
  : await git(repo, ["show", "--name-only", "--format=", "--first-parent", commit]);
@@ -186,6 +279,24 @@ export async function milestoneFiles(run: Run, commit: string): Promise<string[]
186
279
  return r.out.split("\n").filter(Boolean);
187
280
  }
188
281
 
282
+ /** The most commits a digest lists; the rest are counted. */
283
+ const DIGEST_COMMITS = 20;
284
+
285
+ /**
286
+ * The commits the milestone lands, `<short hash> <subject>`, oldest first: from the previous landed commit
287
+ * (else the commit alone) to `commit`. Just the hash when git cannot say: a digest never fails a landing.
288
+ */
289
+ export async function milestoneCommits(run: Run, commit: string): Promise<string[]> {
290
+ const base = landedCommits(run).at(-1);
291
+ const range = base ? [`${base}..${commit}`] : ["-1", commit];
292
+ const r = await git(run.meta.repo, ["log", "--reverse", "--format=%h %s", ...range]);
293
+ if (r.kind !== "ok") return [commit.slice(0, 7)];
294
+ const all = r.out.split("\n").filter(Boolean);
295
+ return all.length > DIGEST_COMMITS
296
+ ? [...all.slice(-DIGEST_COMMITS), `and ${all.length - DIGEST_COMMITS} earlier`]
297
+ : all;
298
+ }
299
+
189
300
  /** `rev`'s full commit hash in the run's repo. Throws E_IO_UNEXPECTED when git cannot say. */
190
301
  export async function fullCommit(run: Run, rev: string): Promise<string> {
191
302
  const r = await git(run.meta.repo, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { adapterFor } from "../adapters/registry.ts";
4
+ import "../adapters/all.ts";
3
5
  import { CatherdError, errorMessage } from "../domain/errors.ts";
4
6
  import { envelope, formatNotices, type Notice, type NoticePriority, priorityOf } from "../domain/notice.ts";
5
7
  import { sessionKey, type HostSessionRef } from "../domain/host.ts";
@@ -7,6 +9,7 @@ import { sendToCodexQueue, type QueueSendResult } from "../infra/codex-queue.ts"
7
9
  import {
8
10
  deliveryState,
9
11
  readDelivery,
12
+ ROLE_THREAD_REFUSAL,
10
13
  writeDeliveryAttempt,
11
14
  type DeliveryAttempt,
12
15
  } from "../infra/delivery.ts";
@@ -15,7 +18,7 @@ import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
15
18
  import { tryLock } from "../infra/filelock.ts";
16
19
  import { log } from "../infra/log.ts";
17
20
  import { type SendResult, sendToInbox } from "../infra/peer-inbox.ts";
18
- import { writeJsonAtomic } from "../infra/store.ts";
21
+ import { nonBlankLines, writeJsonAtomic } from "../infra/store.ts";
19
22
  import {
20
23
  readStallQuietMs,
21
24
  type Settled,
@@ -23,7 +26,7 @@ import {
23
26
  type Stalled,
24
27
  stallHooks,
25
28
  } from "./dispatch-service.ts";
26
- import { type Dispatch, listDispatches, readFailover } from "./dispatches.ts";
29
+ import { type Dispatch, listDispatches, readFailover, roleThreadOf } from "./dispatches.ts";
27
30
  import type { Deps } from "./ports.ts";
28
31
  import { listRuns, readRecords, type Run } from "./run-store.ts";
29
32
  import { currentSession, ownsRun, readSessionRows, runOwner } from "./sessions.ts";
@@ -76,7 +79,8 @@ function legacyNotified(q: Queued, target: HostSessionRef): boolean {
76
79
  }
77
80
  }
78
81
 
79
- function recoverSubmission(dir: string, eventId: string): void {
82
+ /** A "submitting" claim left by a sender that died is ambiguous: retrying it may duplicate input. Under the notify lock. */
83
+ export function recoverSubmission(dir: string, eventId: string): void {
80
84
  for (const a of readDelivery(dir)) {
81
85
  if (a.status === "submitting" && a.eventIds.includes(eventId))
82
86
  writeDeliveryAttempt(dir, {
@@ -119,6 +123,7 @@ export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
119
123
  name: r.name,
120
124
  role: r.role,
121
125
  lane: r.lane,
126
+ thread: r.thread,
122
127
  rung: r.rung,
123
128
  status,
124
129
  replyStatus: r.replyStatus,
@@ -129,6 +134,21 @@ export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
129
134
  };
130
135
  }
131
136
 
137
+ /** A live fresh role's thread, as its CLI named it early in its stream; null before it did. */
138
+ function streamThread(d: Dispatch): string | null {
139
+ const a = adapterFor(d.admit.backend);
140
+ if (!a) return null;
141
+ for (const line of nonBlankLines(dispatchPaths(d.dir).events)) {
142
+ try {
143
+ const t = a.parse(line).thread;
144
+ if (t) return t;
145
+ } catch {
146
+ // a line the adapter cannot read names no thread
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
132
152
  /** Spec §3.6: a live role that went quiet, once, at `next`. */
133
153
  export function stalledNotice(run: Run, d: Dispatch, quietMs: number, now: number): Notice {
134
154
  return {
@@ -140,6 +160,7 @@ export function stalledNotice(run: Run, d: Dispatch, quietMs: number, now: numbe
140
160
  name: d.admit.name,
141
161
  role: d.admit.role,
142
162
  lane: d.admit.lane,
163
+ thread: d.admit.thread ?? streamThread(d),
143
164
  rung: d.admit.rung,
144
165
  status: `stalled: no output for ${Math.max(1, Math.round(quietMs / 60_000))} min`,
145
166
  replyStatus: null,
@@ -242,6 +263,38 @@ function notifierFor(deps: Deps, o: NotifierOptions, retryEvent?: string): Notif
242
263
  log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
243
264
  }
244
265
  }
266
+ // spec 1.5 plan 21: a notice never goes to a role's own thread (a `codex exec` thread exits after its turn,
267
+ // so what is queued there is lost). It fails loudly, and `status` says so, instead of enqueue-accepted.
268
+ const roles = new Map(due.map((q) => [q, roleThreadOf(q.run, target.sessionId)]));
269
+ for (const [q, role] of roles) {
270
+ if (role === null) continue;
271
+ due.splice(due.indexOf(q), 1);
272
+ const reason = `${ROLE_THREAD_REFUSAL} (${role}, ${target.host} ${target.sessionId}), not the orchestrator's; peek(run) from the orchestrator's session takes the run back`;
273
+ log("error", "notify", {
274
+ run: q.run.id,
275
+ dispatch: q.notice.dispatchId,
276
+ target: target.sessionId,
277
+ reason,
278
+ });
279
+ try {
280
+ writeDeliveryAttempt(q.dir, {
281
+ attemptId: crypto.randomUUID(),
282
+ target: {
283
+ host: target.host,
284
+ sessionId: target.sessionId,
285
+ hostSessionId: target.hostSessionId,
286
+ name: target.name,
287
+ },
288
+ eventIds: [q.notice.eventId],
289
+ at: new Date(deps.now()).toISOString(),
290
+ status: "failed",
291
+ msgId: null,
292
+ reason,
293
+ });
294
+ } catch (e) {
295
+ log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
296
+ }
297
+ }
245
298
  // nothing is sent unless every notice's claim is on disk. A claim that cannot be written rolls the batch's
246
299
  // written claims back to failed (provably not submitted, so still deliverable), keeps that notice for a
247
300
  // later pass, and claims the rest again at once: one bad file never holds back the others
@@ -0,0 +1,82 @@
1
+ import type { RunRecord } from "../domain/record.ts";
2
+ import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
3
+ import { awaitsCollect } from "../infra/dispatch-dir.ts";
4
+ import { listDispatches, liveDispatches } from "./dispatches.ts";
5
+ import { readRecords, type Run } from "./run-store.ts";
6
+ import { runOwner } from "./sessions.ts";
7
+
8
+ export const ORCHESTRATOR_STALL_MS = 5 * 60_000;
9
+
10
+ /**
11
+ * Plan 22 (#42 finding 3): the line shows only for a current owner (its Claude Code session live, or seen in the
12
+ * last day) and an unread record that ended in the last day, so an abandoned run never reads "stalled" forever.
13
+ */
14
+ export const WAIT_WINDOW_MS = 24 * 3_600_000;
15
+
16
+ export interface OrchestratorWait {
17
+ since: string;
18
+ seconds: number;
19
+ stalled: boolean;
20
+ unread: number;
21
+ /** when the line stops showing, unless the owner is seen again or another record ends */
22
+ until: string;
23
+ }
24
+
25
+ /**
26
+ * When the owner was last seen acting on the run: it took the run (`since`), or one of the run's dispatches was
27
+ * admitted from its session.
28
+ */
29
+ function ownerSeen(run: Run, owner: { sessionId: string; since: string }): number {
30
+ let seen = Date.parse(owner.since) || 0;
31
+ for (const d of listDispatches(run))
32
+ if (d.admit.sessionId?.toLowerCase() === owner.sessionId.toLowerCase())
33
+ seen = Math.max(seen, Date.parse(d.admit.admittedAt) || 0);
34
+ return seen;
35
+ }
36
+
37
+ /**
38
+ * Pure diagnostic: queue acceptance cannot show that the orchestrator processed a completion. Null unless the
39
+ * run has a current owner, no live role, and an unread record that ended within WAIT_WINDOW_MS.
40
+ */
41
+ export function orchestratorWait(
42
+ run: Run,
43
+ now: number,
44
+ records: RunRecord[] = readRecords(run).records,
45
+ hasLive = liveDispatches(run, now).length > 0,
46
+ files: () => SessionFile[] = readSessionFiles,
47
+ ): OrchestratorWait | null {
48
+ const owner = runOwner(run);
49
+ if (!owner || hasLive) return null;
50
+ const unread = new Set(
51
+ listDispatches(run)
52
+ .filter((d) => awaitsCollect(d.dir))
53
+ .map((d) => d.admit.dispatchId),
54
+ );
55
+ const recent = records.filter(
56
+ (r) => unread.has(r.dispatchId) && now - (Date.parse(r.endedAt) || 0) < WAIT_WINDOW_MS,
57
+ );
58
+ if (!recent.length) return null;
59
+ const ended = Math.max(...recent.map((r) => Date.parse(r.endedAt) || 0));
60
+ const live = owner.host === "claude-code" && liveSessionFile(owner.sessionId, files()) !== null;
61
+ const seen = live ? now : ownerSeen(run, owner);
62
+ if (now - seen >= WAIT_WINDOW_MS) return null;
63
+ const since = Math.max(ended, Date.parse(owner.since) || 0);
64
+ const elapsed = Math.max(0, now - since);
65
+ return {
66
+ since: new Date(since).toISOString(),
67
+ seconds: Math.floor(elapsed / 1000),
68
+ stalled: elapsed >= ORCHESTRATOR_STALL_MS,
69
+ unread: records.filter((r) => unread.has(r.dispatchId)).length,
70
+ until: new Date(Math.min(ended, seen) + WAIT_WINDOW_MS).toISOString(),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The one "waiting for orchestrator" line every view shows (#42 finding 7): seconds and `stalled` from `since` at
76
+ * `now`, so a memoised wait stays true between recomputes; null once it is past `until`.
77
+ */
78
+ export function waitingLine(w: OrchestratorWait | null | undefined, now: number): string | null {
79
+ if (!w || now >= Date.parse(w.until)) return null;
80
+ const elapsed = Math.max(0, now - Date.parse(w.since));
81
+ return `${elapsed >= ORCHESTRATOR_STALL_MS ? "stalled · " : ""}waiting for orchestrator ${Math.floor(elapsed / 1000)}s`;
82
+ }
@@ -0,0 +1,161 @@
1
+ import { join } from "node:path";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import type { Span } from "../domain/util.ts";
4
+ import { dataDir } from "../infra/paths.ts";
5
+ import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
6
+ /** What a pause is checked against: a run, or a workspace step about to start one. */
7
+ type Covered = { meta: { workspace?: { id: string; step: string } } };
8
+ import { findWorkspace, workspaceDirectory } from "./workspace-store.ts";
9
+
10
+ // Spec 1.5 plan 25, "Group pause": one environment blocker (a VPN, Docker down) pauses every run on the
11
+ // machine, or every child of a workspace, at once: admission refuses with the reason until it is resumed.
12
+
13
+ /** One row per pause or resume, oldest first; `reason` null is a resume. */
14
+ interface PauseRow {
15
+ at: string;
16
+ reason: string | null;
17
+ }
18
+
19
+ /** A pause in force: whose (the machine, or one workspace), why and since when. */
20
+ export interface Pause {
21
+ scope: "machine" | "workspace";
22
+ /** the workspace id, for a workspace pause */
23
+ workspace?: string;
24
+ reason: string;
25
+ since: string;
26
+ }
27
+
28
+ /** A stretch of time something was paused; `to` null while it still is. */
29
+ export interface PauseInterval {
30
+ from: string;
31
+ to: string | null;
32
+ }
33
+
34
+ export const machinePauseFile = (): string => join(dataDir(), "pauses.jsonl");
35
+ export const workspacePauseFile = (id: string): string => join(workspaceDirectory(id), "pauses.jsonl");
36
+
37
+ const rows = (file: string): PauseRow[] =>
38
+ readJsonl<PauseRow>(file).rows.filter(
39
+ (r) => typeof r?.at === "string" && (r.reason === null || typeof r.reason === "string"),
40
+ );
41
+
42
+ /** The pause in force in `file`, or null: its last row, when that row pauses. */
43
+ function current(file: string): { reason: string; since: string } | null {
44
+ const last = rows(file).at(-1);
45
+ return last && last.reason !== null ? { reason: last.reason, since: last.at } : null;
46
+ }
47
+
48
+ /** Every paused stretch `file` records, oldest first. */
49
+ export function pauseIntervals(file: string): PauseInterval[] {
50
+ const out: PauseInterval[] = [];
51
+ for (const r of rows(file)) {
52
+ const open = out.at(-1);
53
+ if (r.reason !== null && (!open || open.to !== null)) out.push({ from: r.at, to: null });
54
+ else if (r.reason === null && open && open.to === null) open.to = r.at;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function append(file: string, row: PauseRow): void {
60
+ ensureJsonlHeader(file, "pauses");
61
+ appendJsonl(file, row);
62
+ }
63
+
64
+ const PAUSE_HINT = "push the reason to the owner once (PushNotification); every run it covers waits on it";
65
+
66
+ /** `catherd pause --machine <reason>`: every dispatch on this machine is refused until resumed. */
67
+ export function pauseMachine(now: number, reason: string): { pause: Pause; hints: string[] } {
68
+ if (!reason.trim())
69
+ throw new CatherdError("E_INPUT_INVALID", "a pause needs a reason", {
70
+ fix: 'catherd pause --machine "the VPN takes the default route"',
71
+ });
72
+ const at = new Date(now).toISOString();
73
+ append(machinePauseFile(), { at, reason });
74
+ return { pause: { scope: "machine", reason, since: at }, hints: [PAUSE_HINT] };
75
+ }
76
+
77
+ /** `catherd resume --machine`: lifts the machine pause; the previous one, or null when none was in force. */
78
+ export function resumeMachine(now: number): { resumed: Pause | null } {
79
+ const was = current(machinePauseFile());
80
+ if (was) append(machinePauseFile(), { at: new Date(now).toISOString(), reason: null });
81
+ return { resumed: was ? { scope: "machine", ...was } : null };
82
+ }
83
+
84
+ /** `workspace_pause(workspace, reason)`: every child of the workspace is refused admission until resumed. */
85
+ export function pauseWorkspace(
86
+ now: number,
87
+ i: { workspace: string; reason: string },
88
+ ): { pause: Pause; hints: string[] } {
89
+ const workspace = findWorkspace(i.workspace);
90
+ if (!i.reason.trim())
91
+ throw new CatherdError("E_INPUT_INVALID", "a pause needs a reason", {
92
+ fix: 'workspace_pause(workspace, "the VPN takes the default route")',
93
+ });
94
+ const at = new Date(now).toISOString();
95
+ append(workspacePauseFile(workspace.id), { at, reason: i.reason });
96
+ return {
97
+ pause: { scope: "workspace", workspace: workspace.id, reason: i.reason, since: at },
98
+ hints: [PAUSE_HINT],
99
+ };
100
+ }
101
+
102
+ /** `workspace_resume(workspace)`: lifts the workspace's pause; the previous one, or null. */
103
+ export function resumeWorkspace(now: number, i: { workspace: string }): { resumed: Pause | null } {
104
+ const workspace = findWorkspace(i.workspace);
105
+ const file = workspacePauseFile(workspace.id);
106
+ const was = current(file);
107
+ if (was) append(file, { at: new Date(now).toISOString(), reason: null });
108
+ return { resumed: was ? { scope: "workspace", workspace: workspace.id, ...was } : null };
109
+ }
110
+
111
+ /** The pauses in force over `run`: the machine's, then its workspace's. */
112
+ export function pausesFor(run: Covered | null): Pause[] {
113
+ const out: Pause[] = [];
114
+ const machine = current(machinePauseFile());
115
+ if (machine) out.push({ scope: "machine", ...machine });
116
+ const id = run?.meta.workspace?.id;
117
+ const ws = id ? current(workspacePauseFile(id)) : null;
118
+ if (id && ws) out.push({ scope: "workspace", workspace: id, ...ws });
119
+ return out;
120
+ }
121
+
122
+ /** Every stretch the machine, or the run's workspace, was paused, in epoch ms: `land` leaves them out. */
123
+ export function pauseSpans(run: Covered): Span[] {
124
+ const id = run.meta.workspace?.id;
125
+ const files = [machinePauseFile(), ...(id ? [workspacePauseFile(id)] : [])];
126
+ return files.flatMap((f) =>
127
+ pauseIntervals(f).map((p) => ({ from: Date.parse(p.from), to: p.to === null ? null : Date.parse(p.to) })),
128
+ );
129
+ }
130
+
131
+ /** The pauses in force over any of `runs`, each once, the machine's first. */
132
+ export function pausesOver(runs: Covered[]): Pause[] {
133
+ const seen = new Set<string>();
134
+ const out: Pause[] = [];
135
+ for (const p of [pausesFor(null), ...runs.map(pausesFor)].flat()) {
136
+ const key = `${p.scope} ${p.workspace ?? ""}`;
137
+ if (!seen.has(key)) {
138
+ seen.add(key);
139
+ out.push(p);
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ const describe = (p: Pause): string =>
146
+ `${p.scope === "machine" ? "the machine" : `workspace ${p.workspace}`} is paused since ${p.since}: ${p.reason}`;
147
+
148
+ /** One line per pause, for status and the admission refusal. */
149
+ export const pauseLine = describe;
150
+
151
+ /** Admission refuses while a pause covers the run, naming its reason and how to lift it. */
152
+ export function assertNotPaused(run: Covered): void {
153
+ const [p] = pausesFor(run);
154
+ if (!p) return;
155
+ throw new CatherdError("E_ADMIT_PAUSED", describe(p), {
156
+ fix:
157
+ p.scope === "machine"
158
+ ? "when the blocker is gone, the owner runs: catherd resume --machine"
159
+ : `when the blocker is gone, call workspace_resume(${p.workspace})`,
160
+ });
161
+ }