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.
- package/README.md +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
package/src/services/notifier.ts
CHANGED
|
@@ -1,34 +1,40 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
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 {
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
key:
|
|
458
|
+
const notice = finishedNotice(run, d, record);
|
|
459
|
+
queueNotice({
|
|
460
|
+
key: notice.eventId,
|
|
222
461
|
run,
|
|
223
|
-
|
|
462
|
+
dir: d.dir,
|
|
463
|
+
notice,
|
|
224
464
|
mark: dispatchPaths(d.dir).notified,
|
|
225
|
-
due: () => awaitsCollect(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
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
476
|
+
dir: s.d.dir,
|
|
477
|
+
notice,
|
|
239
478
|
mark: p.stallNotified,
|
|
240
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
+
}
|