catherd-cli 1.3.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 (159) hide show
  1. package/README.md +144 -42
  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 +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. package/src/services/workspace-store.ts +95 -0
@@ -1,34 +1,40 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { errorMessage } from "../domain/errors.ts";
3
+ import { adapterFor } from "../adapters/registry.ts";
4
+ import "../adapters/all.ts";
5
+ import { CatherdError, errorMessage } from "../domain/errors.ts";
4
6
  import { envelope, formatNotices, type Notice, type NoticePriority, priorityOf } from "../domain/notice.ts";
7
+ import { sessionKey, type HostSessionRef } from "../domain/host.ts";
8
+ import { sendToCodexQueue, type QueueSendResult } from "../infra/codex-queue.ts";
9
+ import {
10
+ deliveryState,
11
+ readDelivery,
12
+ ROLE_THREAD_REFUSAL,
13
+ writeDeliveryAttempt,
14
+ type DeliveryAttempt,
15
+ } from "../infra/delivery.ts";
5
16
  import type { RunRecord } from "../domain/record.ts";
6
17
  import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
7
18
  import { tryLock } from "../infra/filelock.ts";
8
19
  import { log } from "../infra/log.ts";
9
20
  import { type SendResult, sendToInbox } from "../infra/peer-inbox.ts";
10
- import { writeJsonAtomic } from "../infra/store.ts";
11
- import { type Settled, settledHooks, type Stalled, stallHooks } from "./dispatch-service.ts";
12
- import { type Dispatch, listDispatches, readFailover } from "./dispatches.ts";
21
+ import { nonBlankLines, writeJsonAtomic } from "../infra/store.ts";
22
+ import {
23
+ readStallQuietMs,
24
+ type Settled,
25
+ settledHooks,
26
+ type Stalled,
27
+ stallHooks,
28
+ } from "./dispatch-service.ts";
29
+ import { type Dispatch, listDispatches, readFailover, roleThreadOf } from "./dispatches.ts";
13
30
  import type { Deps } from "./ports.ts";
14
31
  import { listRuns, readRecords, type Run } from "./run-store.ts";
15
- import { currentSession, ownsRun } from "./sessions.ts";
16
-
17
- /**
18
- * Spec §3.4: runs only inside the MCP server, the session's child and so its only sender (§3.2). For every run this
19
- * session owns, a dispatch that is settled with its record still unread is announced to the session's peer inbox;
20
- * notices that arrive within the window of each other go as one message; a message that went out is written down
21
- * (`notified.json`), so a restart never sends it twice, and claimed while it goes, so two servers of one session never
22
- * both send it. Ownership is asked again under the claim and once more after the message went: a run another session
23
- * claimed meanwhile is left unmarked, so its new owner announces it too (a copy to the session that gave the run up is
24
- * acceptable, a notice lost to the owner is not); a notice whose claim another server holds is tried again after the
25
- * window until it is sent, read, marked or no longer this session's. Disk stays the truth: a notice that cannot be
26
- * sent is dropped (its claim freed), and the record waits, unread, for `result` or `peek`.
27
- */
32
+ import { currentSession, ownsRun, readSessionRows, runOwner } from "./sessions.ts";
28
33
 
29
34
  export interface NotifierOptions {
30
35
  /** notices this close to each other go as one message (spec: 3 s); tests shorten it */
31
36
  coalesceMs?: number;
37
+ sendCodex?: (target: HostSessionRef, content: string) => Promise<QueueSendResult>;
32
38
  /** the sender; tests may replace it */
33
39
  send?: (
34
40
  target: { socketPath: string | null; token: string | null },
@@ -50,7 +56,40 @@ export interface Notifier {
50
56
  stop(): void;
51
57
  }
52
58
 
53
- const notified = (dir: string): boolean => existsSync(dispatchPaths(dir).notified);
59
+ function legacyNotified(q: Queued, target: HostSessionRef): boolean {
60
+ if (target.host !== "claude-code") return false;
61
+ try {
62
+ const marker = JSON.parse(readFileSync(q.mark, "utf8")) as {
63
+ schema?: unknown;
64
+ at?: unknown;
65
+ msgId?: unknown;
66
+ };
67
+ if (marker.schema !== 1 || typeof marker.at !== "string" || typeof marker.msgId !== "string")
68
+ return false;
69
+ const at = Date.parse(marker.at);
70
+ if (!Number.isFinite(at)) return false;
71
+ const historical = readSessionRows(q.run)
72
+ .filter((row) => Date.parse(row.at) <= at)
73
+ .at(-1);
74
+ const owner = runOwner(q.run);
75
+ const attributed = historical ?? (owner && Date.parse(owner.since) <= at ? owner : null);
76
+ return attributed !== null && sessionKey(attributed) === sessionKey(target);
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
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 {
84
+ for (const a of readDelivery(dir)) {
85
+ if (a.status === "submitting" && a.eventIds.includes(eventId))
86
+ writeDeliveryAttempt(dir, {
87
+ ...a,
88
+ status: "ambiguous",
89
+ reason: "Sender ended without a verified receipt; retry may duplicate input. Use peek/result.",
90
+ });
91
+ }
92
+ }
54
93
 
55
94
  function replyOf(run: Run, r: RunRecord): string {
56
95
  try {
@@ -77,12 +116,14 @@ export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
77
116
  const urgent = r.status === "limit" || r.replyStatus === "blocked" || r.replyStatus === "refused";
78
117
  return {
79
118
  kind: "finished",
119
+ eventId: JSON.stringify([run.id, r.dispatchId, "finished"]),
80
120
  runId: run.id,
81
121
  runTitle: run.meta.title,
82
122
  dispatchId: r.dispatchId,
83
123
  name: r.name,
84
124
  role: r.role,
85
125
  lane: r.lane,
126
+ thread: r.thread,
86
127
  rung: r.rung,
87
128
  status,
88
129
  replyStatus: r.replyStatus,
@@ -93,16 +134,33 @@ export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
93
134
  };
94
135
  }
95
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
+
96
152
  /** Spec §3.6: a live role that went quiet, once, at `next`. */
97
153
  export function stalledNotice(run: Run, d: Dispatch, quietMs: number, now: number): Notice {
98
154
  return {
99
155
  kind: "stalled",
156
+ eventId: JSON.stringify([run.id, d.admit.dispatchId, "stalled"]),
100
157
  runId: run.id,
101
158
  runTitle: run.meta.title,
102
159
  dispatchId: d.admit.dispatchId,
103
160
  name: d.admit.name,
104
161
  role: d.admit.role,
105
162
  lane: d.admit.lane,
163
+ thread: d.admit.thread ?? streamThread(d),
106
164
  rung: d.admit.rung,
107
165
  status: `stalled: no output for ${Math.max(1, Math.round(quietMs / 60_000))} min`,
108
166
  replyStatus: null,
@@ -119,16 +177,32 @@ interface Queued {
119
177
  /** the run it is about: its owner may change before the message goes */
120
178
  run: Run;
121
179
  notice: Notice;
180
+ dir: string;
122
181
  /** the mark a message that went out leaves */
123
182
  mark: string;
124
183
  /** whether it is still news when the message goes */
125
184
  due: () => boolean;
185
+ /** how many passes could not write its delivery claim */
186
+ claimFailures?: number;
187
+ /** how many sends the transport provably did not submit */
188
+ sendFailures?: number;
126
189
  }
127
190
 
191
+ /** Passes a notice whose delivery claim cannot be written is kept for before it is given up (and logged). */
192
+ const MAX_CLAIM_FAILURES = 5;
193
+
194
+ /** Sends a notice the transport provably did not submit is tried, a coalescing window apart, before peek/result. */
195
+ const MAX_SEND_FAILURES = 5;
196
+
128
197
  /** Starts the notifier for this process's session and hooks it to every settled dispatch. */
129
198
  export function startNotifier(deps: Deps, o: NotifierOptions = {}): Notifier {
199
+ return notifierFor(deps, o);
200
+ }
201
+
202
+ function notifierFor(deps: Deps, o: NotifierOptions, retryEvent?: string): Notifier {
130
203
  const window = o.coalesceMs ?? 3_000;
131
204
  const send = o.send ?? sendToInbox;
205
+ const sendCodex = o.sendCodex ?? ((target, content) => sendToCodexQueue(target, content, process.env));
132
206
  const queue = new Map<string, Queued>();
133
207
  let timer: ReturnType<typeof setTimeout> | null = null;
134
208
  let firstAt = 0;
@@ -158,89 +232,252 @@ export function startNotifier(deps: Deps, o: NotifierOptions = {}): Notifier {
158
232
  timer = setTimeout(flush, Math.max(0, Math.min(window, firstAt + 5 * window - now)));
159
233
  };
160
234
 
235
+ const pending = (q: Queued, target: HostSessionRef): boolean => {
236
+ if (!q.due() || legacyNotified(q, target)) return false;
237
+ const state = deliveryState(q.dir, target, q.notice.eventId);
238
+ return state === "pending" || (q.notice.eventId === retryEvent && state === "ambiguous");
239
+ };
240
+
161
241
  async function deliver(batch: Queued[]): Promise<void> {
162
- if (!deps.session) return;
163
- // Each notice is claimed before it goes (an exclusive lock on its mark, freed if its holder dies): two servers
164
- // of one session, a replacement starting while the old one still watches, never both send it. Whether it is
165
- // still news is asked under the claim: the other may have announced it just before letting go.
242
+ const target = currentSession(deps);
243
+ if (stopped || !target || (target.host === "claude-code" && !deps.session)) return;
166
244
  const claims: (() => void)[] = [];
167
245
  const due: Queued[] = [];
168
246
  const busy: Queued[] = [];
169
247
  try {
170
248
  for (const q of batch) {
171
- // read (or announced) meanwhile, or the run moved to another session (whose server tells it)
172
- if (!q.due() || !owned(q.run)) continue;
173
- const release = tryLock(q.mark);
174
- // another server is announcing it: asked again after the window, since that server may leave it unmarked
175
- // (the run moved to this session while its message was on its way)
176
- if (!release) {
177
- busy.push(q);
178
- continue;
249
+ // one notice's damaged evidence drops that notice alone, never the rest of the batch
250
+ try {
251
+ if (!q.due() || !owned(q.run)) continue;
252
+ const release = tryLock(q.mark);
253
+ if (!release) {
254
+ busy.push(q);
255
+ continue;
256
+ }
257
+ claims.push(release);
258
+ const live = currentSession(deps);
259
+ if (stopped || !live || sessionKey(live) !== sessionKey(target) || !owned(q.run)) continue;
260
+ recoverSubmission(q.dir, q.notice.eventId);
261
+ if (pending(q, target)) due.push(q);
262
+ } catch (e) {
263
+ log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
179
264
  }
180
- claims.push(release);
181
- // asked again under the claim: the run may have moved, or the notice gone out, while it was taken
182
- if (q.due() && owned(q.run)) due.push(q);
183
265
  }
184
- if (due.length === 0) return;
185
- const notices = due.map((q) => q.notice);
186
- const r = await send(
187
- { socketPath: deps.session.socketPath, token: deps.session.token },
188
- envelope(formatNotices(notices)),
189
- priorityOf(notices),
190
- ).catch((e: unknown): SendResult => ({ outcome: "error", reason: errorMessage(e) }));
191
- if (r.outcome !== "sent") {
192
- log("warn", "notify", {
193
- outcome: r.outcome,
194
- reason: r.reason,
195
- dispatches: notices.map((n) => n.dispatchId),
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,
196
278
  });
197
- return;
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
+ }
198
297
  }
199
- const at = new Date(deps.now()).toISOString();
200
- // a run that moved while the message was on its way is left unmarked: its new owner announces it too
201
- const marked = due.filter((q) => owned(q.run));
202
- for (const q of marked) writeJsonAtomic(q.mark, { schema: 1, msgId: r.msgId, at });
203
- const unmarked = due.filter((q) => !marked.includes(q)).map((q) => q.notice.dispatchId);
204
- log("info", "notify", { msgId: r.msgId, dispatches: notices.map((n) => n.dispatchId), unmarked });
298
+ // nothing is sent unless every notice's claim is on disk. A claim that cannot be written rolls the batch's
299
+ // written claims back to failed (provably not submitted, so still deliverable), keeps that notice for a
300
+ // later pass, and claims the rest again at once: one bad file never holds back the others
301
+ let attempt: DeliveryAttempt;
302
+ for (;;) {
303
+ if (!due.length) return;
304
+ attempt = {
305
+ attemptId: crypto.randomUUID(),
306
+ target: {
307
+ host: target.host,
308
+ sessionId: target.sessionId,
309
+ hostSessionId: target.hostSessionId,
310
+ name: target.name,
311
+ },
312
+ eventIds: due.map((q) => q.notice.eventId),
313
+ at: new Date(deps.now()).toISOString(),
314
+ status: "submitting",
315
+ msgId: null,
316
+ reason: null,
317
+ };
318
+ const claimed: Queued[] = [];
319
+ const broken = due.find((q) => {
320
+ try {
321
+ writeDeliveryAttempt(q.dir, attempt);
322
+ claimed.push(q);
323
+ return false;
324
+ } catch (e) {
325
+ log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
326
+ return true;
327
+ }
328
+ });
329
+ if (!broken) break;
330
+ const undone: DeliveryAttempt = {
331
+ ...attempt,
332
+ status: "failed",
333
+ reason: "A claim in this batch was not persisted; nothing was submitted.",
334
+ };
335
+ for (const c of claimed)
336
+ try {
337
+ writeDeliveryAttempt(c.dir, undone);
338
+ } catch (u) {
339
+ log("warn", "notify", { error: errorMessage(u), dispatch: c.notice.dispatchId });
340
+ }
341
+ // a claim that fails again and again is a damaged file, not a passing fault: it is given up then
342
+ broken.claimFailures = (broken.claimFailures ?? 0) + 1;
343
+ if (broken.claimFailures < MAX_CLAIM_FAILURES) busy.push(broken);
344
+ due.splice(due.indexOf(broken), 1);
345
+ }
346
+ const notices = due.map((q) => q.notice);
347
+ let outcome: QueueSendResult;
348
+ const content = envelope(formatNotices(notices));
349
+ try {
350
+ if (target.host === "codex") outcome = await sendCodex(target, content);
351
+ else {
352
+ const r = await send(
353
+ { socketPath: deps.session!.socketPath, token: deps.session!.token },
354
+ content,
355
+ priorityOf(notices),
356
+ );
357
+ outcome =
358
+ r.outcome === "sent" && r.msgId
359
+ ? { outcome: "accepted", msgId: r.msgId }
360
+ : {
361
+ // an error before any frame was written (no msg_id) provably submitted nothing: retried later
362
+ outcome:
363
+ r.outcome === "no-session" ||
364
+ r.outcome === "refused" ||
365
+ (r.outcome === "error" && !r.msgId)
366
+ ? "not-submitted"
367
+ : "ambiguous",
368
+ reason: "Claude inbox has no verified receipt; use peek/result.",
369
+ };
370
+ }
371
+ } catch {
372
+ outcome = {
373
+ outcome: "ambiguous",
374
+ reason: "Sender ended without a verified receipt; retry may duplicate input. Use peek/result.",
375
+ };
376
+ }
377
+ const receipt: DeliveryAttempt = {
378
+ ...attempt,
379
+ status:
380
+ outcome.outcome === "accepted"
381
+ ? "accepted"
382
+ : outcome.outcome === "not-submitted"
383
+ ? "failed"
384
+ : "ambiguous",
385
+ msgId: outcome.outcome === "accepted" ? outcome.msgId : null,
386
+ reason:
387
+ outcome.outcome === "accepted"
388
+ ? null
389
+ : outcome.outcome === "not-submitted"
390
+ ? "Transport did not submit input; check native transport and use peek/result."
391
+ : "No verified receipt; retry may duplicate input. Use peek/result.",
392
+ };
393
+ // the outcome is known for every notice: one damaged delivery file never discards it for the others
394
+ for (const q of due)
395
+ try {
396
+ writeDeliveryAttempt(q.dir, receipt);
397
+ } catch (e) {
398
+ log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
399
+ }
400
+ const live = currentSession(deps);
401
+ const stillHere = !stopped && live !== null && sessionKey(live) === sessionKey(target);
402
+ const marked = due.filter((q) => stillHere && owned(q.run));
403
+ // provably not submitted, so a retry cannot duplicate input: tried again a window later, a few times
404
+ if (receipt.status === "failed")
405
+ for (const q of marked) {
406
+ q.sendFailures = (q.sendFailures ?? 0) + 1;
407
+ if (q.sendFailures < MAX_SEND_FAILURES) busy.push(q);
408
+ }
409
+ if (receipt.status === "accepted" && target.host === "claude-code")
410
+ for (const q of marked)
411
+ try {
412
+ if (!existsSync(q.mark))
413
+ writeJsonAtomic(q.mark, { schema: 1, msgId: receipt.msgId, at: receipt.at });
414
+ } catch (e) {
415
+ log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
416
+ }
417
+ log(receipt.status === "accepted" ? "info" : "warn", "notify", {
418
+ outcome: receipt.status,
419
+ msgId: receipt.msgId,
420
+ reason: receipt.reason,
421
+ dispatches: notices.map((n) => n.dispatchId),
422
+ unmarked: due.filter((q) => !marked.includes(q)).map((q) => q.notice.dispatchId),
423
+ });
205
424
  } finally {
206
425
  for (const release of claims) release();
207
426
  requeue(busy);
208
427
  }
209
428
  }
210
429
 
211
- /** Queues again the notices another server held the claim on; the next pass drops those no longer due or owned. */
430
+ /**
431
+ * Queues again the notices another server held the claim on, whose batch could not claim them all, or that
432
+ * the transport provably did not submit; the next pass drops those no longer due or owned.
433
+ */
212
434
  function requeue(busy: Queued[]): void {
213
435
  if (stopped || busy.length === 0) return;
214
436
  for (const q of busy) if (!queue.has(q.key)) queue.set(q.key, q);
215
437
  schedule();
216
438
  }
217
439
 
440
+ function queueNotice(q: Queued): void {
441
+ const target = currentSession(deps);
442
+ if (stopped || !target || queue.has(q.key) || !q.due() || !owned(q.run)) return;
443
+ let recovering = false;
444
+ try {
445
+ recovering = readDelivery(q.dir).some(
446
+ (a) => a.status === "submitting" && a.eventIds.includes(q.notice.eventId),
447
+ );
448
+ } catch (e) {
449
+ log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
450
+ return;
451
+ }
452
+ if (!recovering && !pending(q, target)) return;
453
+ queue.set(q.key, q);
454
+ schedule();
455
+ }
456
+
218
457
  const enqueue = (run: Run, d: Dispatch, record: RunRecord): void => {
219
- if (queue.has(record.dispatchId) || notified(d.dir) || !awaitsCollect(d.dir) || !owned(run)) return;
220
- queue.set(record.dispatchId, {
221
- key: record.dispatchId,
458
+ const notice = finishedNotice(run, d, record);
459
+ queueNotice({
460
+ key: notice.eventId,
222
461
  run,
223
- notice: finishedNotice(run, d, record),
462
+ dir: d.dir,
463
+ notice,
224
464
  mark: dispatchPaths(d.dir).notified,
225
- due: () => awaitsCollect(d.dir) && !notified(d.dir),
465
+ due: () => awaitsCollect(d.dir),
226
466
  });
227
- schedule();
228
467
  };
229
468
 
230
469
  const onStall = (s: Stalled): void => {
231
470
  try {
232
471
  const p = dispatchPaths(s.d.dir);
233
- const key = `${s.d.admit.dispatchId} stall`;
234
- if (queue.has(key) || existsSync(p.stallNotified) || existsSync(p.exit) || !owned(s.run)) return;
235
- queue.set(key, {
236
- key,
472
+ const notice = stalledNotice(s.run, s.d, s.quietMs, deps.now());
473
+ queueNotice({
474
+ key: notice.eventId,
237
475
  run: s.run,
238
- notice: stalledNotice(s.run, s.d, s.quietMs, deps.now()),
476
+ dir: s.d.dir,
477
+ notice,
239
478
  mark: p.stallNotified,
240
- // it finished meanwhile: its record is the news now
241
- due: () => !existsSync(p.stallNotified) && !existsSync(p.exit),
479
+ due: () => !existsSync(p.exit),
242
480
  });
243
- schedule();
244
481
  } catch (e) {
245
482
  log("warn", "notify", { run: s.run.id, name: s.d.admit.name, error: errorMessage(e) });
246
483
  }
@@ -264,9 +501,30 @@ export function startNotifier(deps: Deps, o: NotifierOptions = {}): Notifier {
264
501
  if (!owned(run)) continue;
265
502
  const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
266
503
  for (const d of listDispatches(run)) {
267
- const r = records.get(d.admit.dispatchId);
268
- // a limit not yet failed over is reconcile's to settle first: its hook announces it then
269
- if (r && !(r.status === "limit" && !readFailover(d.dir))) enqueue(run, d, r);
504
+ // one dispatch's damaged evidence stays ambiguous for it alone: the rest of the run is still scanned
505
+ try {
506
+ const p = dispatchPaths(d.dir);
507
+ for (const [kind, mark] of [
508
+ ["finished", p.notified],
509
+ ["stalled", p.stallNotified],
510
+ ] as const) {
511
+ const release = tryLock(mark);
512
+ if (!release) continue;
513
+ try {
514
+ recoverSubmission(d.dir, JSON.stringify([run.id, d.admit.dispatchId, kind]));
515
+ } finally {
516
+ release();
517
+ }
518
+ }
519
+ const r = records.get(d.admit.dispatchId);
520
+ // a limit not yet failed over is reconcile's to settle first: its hook announces it then
521
+ if (r && !(r.status === "limit" && !readFailover(d.dir))) enqueue(run, d, r);
522
+ if (existsSync(p.stall) && !existsSync(p.exit)) {
523
+ onStall({ run, d, quietMs: readStallQuietMs(d.dir) });
524
+ }
525
+ } catch (e) {
526
+ log("warn", "notify", { run: run.id, name: d.admit.name, error: errorMessage(e) });
527
+ }
270
528
  }
271
529
  } catch (e) {
272
530
  log("warn", "notify", { run: run.id, error: errorMessage(e) });
@@ -290,7 +548,54 @@ export function startNotifier(deps: Deps, o: NotifierOptions = {}): Notifier {
290
548
  queue.clear();
291
549
  },
292
550
  };
293
- settledHooks.add(onSettled);
294
- stallHooks.add(onStall);
551
+ if (!retryEvent) {
552
+ settledHooks.add(onSettled);
553
+ stallHooks.add(onStall);
554
+ }
295
555
  return n;
296
556
  }
557
+
558
+ /** Retry only a stored event for the current owner, after an explicit duplicate-risk decision. */
559
+ export async function retryDelivery(
560
+ deps: Deps,
561
+ dir: string,
562
+ eventId: string,
563
+ decision: { allowPossibleDuplicate: true },
564
+ ): Promise<void> {
565
+ if (decision?.allowPossibleDuplicate !== true)
566
+ throw new CatherdError("E_INPUT_INVALID", "Retry requires acknowledging possible duplicate input");
567
+ const target = currentSession(deps);
568
+ const located = listRuns().runs.flatMap((run) =>
569
+ listDispatches(run)
570
+ .filter((d) => d.dir === dir)
571
+ .map((d) => ({ run, d })),
572
+ )[0];
573
+ if (!target || !located || !ownsRun(deps, located.run))
574
+ throw new CatherdError(
575
+ "E_INPUT_INVALID",
576
+ "Retry requires the validated current owner and stored dispatch",
577
+ );
578
+ const { run, d } = located;
579
+ const record = readRecords(run).records.find((r) => r.dispatchId === d.admit.dispatchId);
580
+ const finished = JSON.stringify([run.id, d.admit.dispatchId, "finished"]);
581
+ const stalled = JSON.stringify([run.id, d.admit.dispatchId, "stalled"]);
582
+ if (eventId !== finished && eventId !== stalled)
583
+ throw new CatherdError("E_INPUT_INVALID", "Event does not belong to this dispatch");
584
+ if (eventId === finished && (!record || !awaitsCollect(dir)))
585
+ throw new CatherdError("E_INPUT_INVALID", "Finished result is absent or already collected");
586
+ if (eventId === stalled && (!existsSync(dispatchPaths(dir).stall) || existsSync(dispatchPaths(dir).exit)))
587
+ throw new CatherdError("E_INPUT_INVALID", "Stalled event is absent or already finished");
588
+ // Corrupt evidence cannot be safely replaced by an explicit retry.
589
+ readDelivery(dir);
590
+ const n = notifierFor(deps, { coalesceMs: 0 }, eventId);
591
+ try {
592
+ if (eventId === finished)
593
+ n.onSettled({ run, d, record: record!, hints: [], started: null, pause: null, stateHints: [] });
594
+ else {
595
+ n.onStall({ run, d, quietMs: readStallQuietMs(dir) });
596
+ }
597
+ await n.idle();
598
+ } finally {
599
+ n.stop();
600
+ }
601
+ }
@@ -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
+ }