catherd-cli 1.0.0 → 1.1.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 +66 -42
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +112 -95
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +63 -0
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +95 -1
- package/src/adapters/codex/index.ts +78 -23
- package/src/adapters/opencode/index.ts +17 -0
- package/src/domain/errors.ts +2 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/lane.ts +23 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +43 -9
- package/src/domain/profile.ts +12 -6
- package/src/domain/record.ts +2 -0
- package/src/domain/role-prompts.ts +33 -1
- package/src/domain/state.ts +18 -2
- package/src/entry/deps.ts +2 -1
- package/src/entry/doctor-command.ts +7 -1
- package/src/entry/glyphs.ts +2 -1
- package/src/entry/init-command.ts +41 -1
- package/src/entry/mcp/command.ts +5 -1
- package/src/entry/mcp/dispatch-tools.ts +10 -40
- package/src/entry/mcp/handshake.ts +19 -9
- package/src/entry/mcp/lane-tools.ts +7 -6
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/run-tools.ts +4 -3
- package/src/entry/mcp/server.ts +17 -1
- package/src/entry/mcp/setup-tools.ts +1 -1
- package/src/entry/profile-command.ts +3 -5
- package/src/entry/runs-command.ts +64 -21
- package/src/entry/tui/commands.ts +1 -1
- package/src/entry/tui/effects.ts +98 -56
- package/src/entry/tui/fixtures.ts +352 -85
- package/src/entry/tui/profile-edits.ts +2 -2
- package/src/entry/tui/profile-tree.ts +2 -1
- package/src/entry/tui/providers/data.tsx +35 -8
- package/src/entry/tui/state.ts +26 -6
- package/src/entry/tui/stories.ts +36 -4
- package/src/entry/tui/text.ts +12 -0
- package/src/entry/tui/theme.ts +1 -0
- package/src/entry/tui/views/runs.tsx +409 -117
- package/src/entry/tui/views/status.tsx +5 -2
- package/src/entry/tui/widgets/list.tsx +4 -3
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +14 -6
- package/src/infra/env.ts +26 -2
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/supervisor.ts +33 -3
- package/src/services/admission.ts +38 -12
- package/src/services/dispatch-service.ts +338 -197
- package/src/services/dispatches.ts +30 -1
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +3 -18
- package/src/services/doctor-checks.ts +46 -1
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +59 -79
- package/src/services/finalize.ts +43 -25
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/lane-service.ts +150 -8
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +7 -4
- package/src/services/preflight.ts +17 -3
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +40 -11
- package/src/services/reentry.ts +22 -0
- package/src/services/run-service.ts +42 -9
- package/src/services/run-store.ts +18 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/state.ts +10 -3
- package/src/services/summary.ts +14 -0
|
@@ -60,14 +60,15 @@ export function step(items: readonly { selectable: boolean }[], from: number, by
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* A view's selected row: `selected` is what this render draws, `current()` what a key handler must read
|
|
63
|
-
* (a key in the same tick may have moved it already).
|
|
63
|
+
* (a key in the same tick may have moved it already). `initial`: the row it starts on (a list snaps a key it
|
|
64
|
+
* does not hold to its first row).
|
|
64
65
|
*/
|
|
65
|
-
export function useSelected(): {
|
|
66
|
+
export function useSelected(initial: string | null = null): {
|
|
66
67
|
selected: string | null;
|
|
67
68
|
select(key: string): void;
|
|
68
69
|
current(): string | null;
|
|
69
70
|
} {
|
|
70
|
-
const [selected, setSelected] = useState<string | null>(
|
|
71
|
+
const [selected, setSelected] = useState<string | null>(initial);
|
|
71
72
|
const ref = useRef<string | null>(null);
|
|
72
73
|
ref.current = selected;
|
|
73
74
|
return {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { claudeHome } from "./paths.ts";
|
|
4
|
+
import { isAlive } from "./proc.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Claude Code session catherd's MCP server runs in (spec §3.3; docs/research/2026-09-28-cross-session-messaging.md):
|
|
8
|
+
* what its environment says, and what the session's registry file (`<config>/sessions/<pid>.json`) says now.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The four variables a Claude Code session hands its MCP servers; each may be absent. */
|
|
12
|
+
export const SESSION_ENV_KEYS = {
|
|
13
|
+
sessionId: "CLAUDE_CODE_SESSION_ID",
|
|
14
|
+
hostSessionId: "CLAUDE_CODE_HOST_SESSION_ID",
|
|
15
|
+
socketPath: "CLAUDE_CODE_MESSAGING_SOCKET",
|
|
16
|
+
token: "CLAUDE_CODE_MESSAGING_TOKEN",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export interface SessionEnv {
|
|
20
|
+
/** the id at spawn: it goes stale after /clear, so the registry's is preferred */
|
|
21
|
+
sessionId: string | null;
|
|
22
|
+
/** set only when a host (Desktop) launched the session */
|
|
23
|
+
hostSessionId: string | null;
|
|
24
|
+
socketPath: string | null;
|
|
25
|
+
token: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The session's environment, or null outside Claude Code (neither a session id nor a socket). */
|
|
29
|
+
export function readSessionEnv(env: Record<string, string | undefined> = process.env): SessionEnv | null {
|
|
30
|
+
const get = (k: string) => env[k] || null;
|
|
31
|
+
const s: SessionEnv = {
|
|
32
|
+
sessionId: get(SESSION_ENV_KEYS.sessionId),
|
|
33
|
+
hostSessionId: get(SESSION_ENV_KEYS.hostSessionId),
|
|
34
|
+
socketPath: get(SESSION_ENV_KEYS.socketPath),
|
|
35
|
+
token: get(SESSION_ENV_KEYS.token),
|
|
36
|
+
};
|
|
37
|
+
return s.sessionId || s.socketPath ? s : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One live session's registry entry, as far as catherd reads it. */
|
|
41
|
+
export interface SessionFile {
|
|
42
|
+
pid: number;
|
|
43
|
+
sessionId: string;
|
|
44
|
+
name: string | null;
|
|
45
|
+
hostSessionId: string | null;
|
|
46
|
+
messagingSocketPath: string | null;
|
|
47
|
+
/** busy | idle | waiting, when the session says */
|
|
48
|
+
status: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
|
52
|
+
|
|
53
|
+
/** Every readable registry file (`<config>/sessions/<pid>.json`); a torn or foreign one is skipped. */
|
|
54
|
+
export function readSessionFiles(dir: string = join(claudeHome(), "sessions")): SessionFile[] {
|
|
55
|
+
if (!existsSync(dir)) return [];
|
|
56
|
+
const out: SessionFile[] = [];
|
|
57
|
+
for (const f of readdirSync(dir)) {
|
|
58
|
+
if (!/^\d+\.json$/.test(f)) continue;
|
|
59
|
+
try {
|
|
60
|
+
const v = JSON.parse(readFileSync(join(dir, f), "utf8")) as Record<string, unknown>;
|
|
61
|
+
const sessionId = str(v.sessionId);
|
|
62
|
+
if (typeof v.pid !== "number" || !sessionId) continue;
|
|
63
|
+
out.push({
|
|
64
|
+
pid: v.pid,
|
|
65
|
+
sessionId,
|
|
66
|
+
name: str(v.name),
|
|
67
|
+
hostSessionId: str(v.hostSessionId),
|
|
68
|
+
messagingSocketPath: str(v.messagingSocketPath),
|
|
69
|
+
status: str(v.status),
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
// written as we read it, or not Claude Code's
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* This server's session file: the one naming its socket, else its parent's pid (the MCP server is the session's
|
|
80
|
+
* child), else the one with its env's session id. Null when none is found.
|
|
81
|
+
*/
|
|
82
|
+
export function sessionFileFor(
|
|
83
|
+
s: SessionEnv,
|
|
84
|
+
files = readSessionFiles(),
|
|
85
|
+
ppid = process.ppid,
|
|
86
|
+
): SessionFile | null {
|
|
87
|
+
return (
|
|
88
|
+
(s.socketPath ? files.find((f) => f.messagingSocketPath === s.socketPath) : undefined) ??
|
|
89
|
+
files.find((f) => f.pid === ppid) ??
|
|
90
|
+
(s.sessionId ? files.find((f) => f.sessionId === s.sessionId) : undefined) ??
|
|
91
|
+
null
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The live session that has `sessionId` now, if one does: its file names it and its process is alive. */
|
|
96
|
+
export function liveSessionFile(sessionId: string, files = readSessionFiles()): SessionFile | null {
|
|
97
|
+
return files.find((f) => f.sessionId === sessionId && isAlive(f.pid, null)) ?? null;
|
|
98
|
+
}
|
|
@@ -31,11 +31,19 @@ export function dispatchPaths(dir: string) {
|
|
|
31
31
|
cancel: join(dir, "cancel"),
|
|
32
32
|
/** held by the one supervisor of this dispatch for its lifetime (filelock, dead holders reclaimed) */
|
|
33
33
|
supervisorLock: join(dir, "supervisor.lock"),
|
|
34
|
-
/** exists from
|
|
34
|
+
/** exists from admission until the orchestrator reads the record (`result`, `cancel`): "not yet read" */
|
|
35
35
|
collect: join(dir, "collect"),
|
|
36
|
-
/** while a
|
|
36
|
+
/** while a reader takes the record: names that reader's process, so a crash leaves it reclaimable */
|
|
37
37
|
lease: join(dir, "collect.lease"),
|
|
38
38
|
supervisorLog: join(dir, "supervisor.log"),
|
|
39
|
+
/** what failover did for this limited dispatch, written once under `failover.lock` (plan 10) */
|
|
40
|
+
failover: join(dir, "failover.json"),
|
|
41
|
+
/** the message that announced this dispatch's record to its session, once sent (plan 10) */
|
|
42
|
+
notified: join(dir, "notified.json"),
|
|
43
|
+
/** written once by the supervisor when the worker went quiet for half its idle timeout, not busy (spec §3.6) */
|
|
44
|
+
stall: join(dir, "stall.json"),
|
|
45
|
+
/** the message that announced the stall, once sent */
|
|
46
|
+
stallNotified: join(dir, "stall-notified.json"),
|
|
39
47
|
};
|
|
40
48
|
}
|
|
41
49
|
|
|
@@ -81,7 +89,7 @@ export function readClaimant(dir: string): { pid: number; startTime: string | nu
|
|
|
81
89
|
}
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
/** Marks a dispatch as
|
|
92
|
+
/** Marks a dispatch's record as not yet read; written before its launch. */
|
|
85
93
|
export function markForCollect(dir: string): void {
|
|
86
94
|
writeFileSync(dispatchPaths(dir).collect, "", { mode: PRIVATE_FILE });
|
|
87
95
|
}
|
|
@@ -130,8 +138,8 @@ function leaseDead(file: string): boolean {
|
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
/**
|
|
133
|
-
* Whether
|
|
134
|
-
*
|
|
141
|
+
* Whether this dispatch's record is still unread: it has the mark, or a lease whose reader died before the
|
|
142
|
+
* record went out.
|
|
135
143
|
*/
|
|
136
144
|
export function awaitsCollect(dir: string): boolean {
|
|
137
145
|
const p = dispatchPaths(dir);
|
|
@@ -188,7 +196,7 @@ async function reviveDeadLease(dir: string): Promise<void> {
|
|
|
188
196
|
|
|
189
197
|
/**
|
|
190
198
|
* Collects the dispatch as a lease naming this process: true for the one caller that got it, so each
|
|
191
|
-
* record
|
|
199
|
+
* record is read (marked read) once. The common path takes no lock: the lease is created exclusively, and
|
|
192
200
|
* the record is this caller's only if the mark still exists then; the mark goes only once the lease
|
|
193
201
|
* exists. A dead collector's lease is first turned back into the mark (`reviveDeadLease`), then
|
|
194
202
|
* collected the same way; a live collector's is never taken. The caller ends the lease with `endCollect`
|
package/src/infra/env.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* catherd's own secrets; the user's backend credentials (OPENAI_API_KEY, …) stay, workers need them. The Claude Code
|
|
3
|
+
* session's messaging socket and token are the MCP server's alone (spec §3.2: it is the only sender), and so is the
|
|
4
|
+
* session's identity (a child that has it takes itself for that session: the doctor's MCP server would own its
|
|
5
|
+
* runs, a headless claude-code worker would inherit it): no process catherd starts gets them.
|
|
6
|
+
*/
|
|
7
|
+
const SECRET_ENV = new Set([
|
|
8
|
+
"TYPESAFE_API_KEY",
|
|
9
|
+
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
10
|
+
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
11
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
12
|
+
"CLAUDE_CODE_HOST_SESSION_ID",
|
|
13
|
+
]);
|
|
3
14
|
|
|
4
15
|
/** `base` without catherd's own secrets and without unset keys; for every process catherd starts. */
|
|
5
16
|
export function scrubSecrets(base: Record<string, string | undefined>): Record<string, string> {
|
|
@@ -8,6 +19,19 @@ export function scrubSecrets(base: Record<string, string | undefined>): Record<s
|
|
|
8
19
|
return env;
|
|
9
20
|
}
|
|
10
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Spec 1.1 §12: the plugin's launcher runs `bunx` with TMPDIR in catherd's cache, so macOS never half-cleans
|
|
24
|
+
* the package, and passes the user's own TMPDIR in CATHERD_USER_TMPDIR (empty when unset). The server and
|
|
25
|
+
* every worker it starts get the user's TMPDIR back.
|
|
26
|
+
*/
|
|
27
|
+
export function restoreTmpdir(env: Record<string, string | undefined>): void {
|
|
28
|
+
const mine = env.CATHERD_USER_TMPDIR;
|
|
29
|
+
if (mine === undefined) return;
|
|
30
|
+
if (mine) env.TMPDIR = mine;
|
|
31
|
+
else delete env.TMPDIR;
|
|
32
|
+
delete env.CATHERD_USER_TMPDIR;
|
|
33
|
+
}
|
|
34
|
+
|
|
11
35
|
export function workerEnv(
|
|
12
36
|
base: Record<string, string | undefined>,
|
|
13
37
|
overrides: Record<string, string>,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spec §3.4: one frame to a Claude Code session's peer inbox (docs/research/2026-09-28-cross-session-messaging.md).
|
|
5
|
+
* The auth line (the child token the session gave its children), then one `user` frame, in one write; the socket
|
|
6
|
+
* is closed 150 ms later, as the native client does on macOS. No `session_id` (a stale one drops the frame) and no
|
|
7
|
+
* `from-mode` (a wrong one holds it).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type SendOutcome = "sent" | "no-session" | "refused" | "error";
|
|
11
|
+
|
|
12
|
+
export interface SendResult {
|
|
13
|
+
outcome: SendOutcome;
|
|
14
|
+
/** the frame's msg_id, when one was written */
|
|
15
|
+
msgId?: string;
|
|
16
|
+
/** why it was not sent */
|
|
17
|
+
reason?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface InboxTarget {
|
|
21
|
+
socketPath: string | null;
|
|
22
|
+
token: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** How long a connection may take, and how long after the write the socket is closed; tests shorten them. */
|
|
26
|
+
export const inboxLimits = { connectMs: 2_000, closeAfterMs: 150 };
|
|
27
|
+
|
|
28
|
+
/** The two lines one send writes: the auth line (when there is a token) and the user frame. */
|
|
29
|
+
export function frameText(
|
|
30
|
+
content: string,
|
|
31
|
+
priority: "later" | "next",
|
|
32
|
+
token: string | null,
|
|
33
|
+
msgId: string,
|
|
34
|
+
): string {
|
|
35
|
+
const frame = { msgV: 1, msg_id: msgId, type: "user", message: { role: "user", content }, priority };
|
|
36
|
+
return `${token ? `${JSON.stringify({ type: "auth", token })}\n` : ""}${JSON.stringify(frame)}\n`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Sends one message; never throws: every failure is an outcome with its reason. */
|
|
40
|
+
export function sendToInbox(
|
|
41
|
+
target: InboxTarget,
|
|
42
|
+
content: string,
|
|
43
|
+
priority: "later" | "next",
|
|
44
|
+
): Promise<SendResult> {
|
|
45
|
+
if (!target.socketPath)
|
|
46
|
+
return Promise.resolve({
|
|
47
|
+
outcome: "no-session",
|
|
48
|
+
reason: "no messaging socket in this session's environment",
|
|
49
|
+
});
|
|
50
|
+
const msgId = crypto.randomUUID();
|
|
51
|
+
const socketPath = target.socketPath;
|
|
52
|
+
return new Promise<SendResult>((resolve) => {
|
|
53
|
+
let settled = false;
|
|
54
|
+
const done = (r: SendResult) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
resolve(r);
|
|
58
|
+
};
|
|
59
|
+
let s: ReturnType<typeof createConnection>;
|
|
60
|
+
try {
|
|
61
|
+
s = createConnection({ path: socketPath });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return done({ outcome: "error", reason: (e as Error).message });
|
|
64
|
+
}
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
s.destroy();
|
|
67
|
+
done({ outcome: "error", reason: `no connection within ${inboxLimits.connectMs} ms` });
|
|
68
|
+
}, inboxLimits.connectMs);
|
|
69
|
+
s.on("error", (e: NodeJS.ErrnoException) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
s.destroy();
|
|
72
|
+
if (e.code === "ENOENT")
|
|
73
|
+
done({ outcome: "no-session", reason: `the session's socket is gone (${socketPath})` });
|
|
74
|
+
else if (e.code === "ECONNREFUSED")
|
|
75
|
+
done({ outcome: "refused", reason: "the session's socket refused it" });
|
|
76
|
+
else done({ outcome: "error", reason: e.code ?? e.message });
|
|
77
|
+
});
|
|
78
|
+
s.on("connect", () => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
s.write(frameText(content, priority, target.token, msgId), (err) => {
|
|
81
|
+
if (err) {
|
|
82
|
+
s.destroy();
|
|
83
|
+
return done({ outcome: "error", reason: err.message });
|
|
84
|
+
}
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
s.end();
|
|
87
|
+
done({ outcome: "sent", msgId });
|
|
88
|
+
}, inboxLimits.closeAfterMs);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
package/src/infra/supervisor.ts
CHANGED
|
@@ -189,13 +189,19 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
189
189
|
const started = Date.now();
|
|
190
190
|
let lastActivity = started;
|
|
191
191
|
let finalAt: number | null = null;
|
|
192
|
+
// spec §3.6: a quiet stretch of half the idle timeout, not busy, is a stall, reported once per dispatch
|
|
193
|
+
let stalled = false;
|
|
194
|
+
let stallChecked = false;
|
|
192
195
|
const open = new Set<string>();
|
|
193
196
|
const stream = { offset: 0, rest: "", decoder: new TextDecoder("utf-8") };
|
|
194
197
|
|
|
195
198
|
while (!done && reason === null) {
|
|
196
199
|
await Bun.sleep(spec.pollMs);
|
|
197
200
|
const lines = readNew(p.events, stream);
|
|
198
|
-
if (lines.length)
|
|
201
|
+
if (lines.length) {
|
|
202
|
+
lastActivity = Date.now();
|
|
203
|
+
stallChecked = false;
|
|
204
|
+
}
|
|
199
205
|
for (const line of lines) {
|
|
200
206
|
let d: LineInfo | undefined;
|
|
201
207
|
try {
|
|
@@ -222,8 +228,32 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
222
228
|
// the busy check can take up to hookMs: a worker that ended meanwhile is recorded as it ended, read
|
|
223
229
|
// from the child itself too, since `done` is set by a callback that may not have run yet
|
|
224
230
|
if (done || child.exitCode !== null || child.signalCode !== null) break;
|
|
225
|
-
if (busy)
|
|
226
|
-
|
|
231
|
+
if (busy) {
|
|
232
|
+
// a new quiet stretch starts: it gets its own stall check
|
|
233
|
+
lastActivity = Date.now();
|
|
234
|
+
stallChecked = false;
|
|
235
|
+
} else reason = "idle-timeout";
|
|
236
|
+
} else if (!stalled && !stallChecked && now - lastActivity >= spec.idleMs / 2) {
|
|
237
|
+
// asked once per quiet stretch: a busy answer starts a new quiet stretch, with its own half-way check,
|
|
238
|
+
// so a worker busy here and idle later is reported stalled before the idle check ends it
|
|
239
|
+
stallChecked = true;
|
|
240
|
+
const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
|
|
241
|
+
if (busy) {
|
|
242
|
+
lastActivity = Date.now();
|
|
243
|
+
stallChecked = false;
|
|
244
|
+
} else if (!done) {
|
|
245
|
+
stalled = true;
|
|
246
|
+
// advisory: a write that fails is logged and never ends a healthy worker
|
|
247
|
+
try {
|
|
248
|
+
writeJsonAtomic(p.stall, {
|
|
249
|
+
schema: 1,
|
|
250
|
+
at: new Date().toISOString(),
|
|
251
|
+
quietMs: Date.now() - lastActivity,
|
|
252
|
+
});
|
|
253
|
+
} catch (e) {
|
|
254
|
+
log("warn", "stall", { dispatch: spec.dispatchDir, error: errorMessage(e) });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
227
257
|
}
|
|
228
258
|
}
|
|
229
259
|
} catch (e) {
|
|
@@ -2,14 +2,17 @@ import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { BackendAdapter } from "../adapters/backend.ts";
|
|
4
4
|
import { budgetStatus, formatBudget } from "../domain/budget.ts";
|
|
5
|
-
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { CatherdError, errorMessage } from "../domain/errors.ts";
|
|
6
6
|
import { assertId, formatRung, newDispatchId, parseRung } from "../domain/ids.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { assertLaneHeader, overlaps } from "../domain/lane.ts";
|
|
8
|
+
import type { RunRecord } from "../domain/record.ts";
|
|
9
|
+
import { withReplyContract } from "../domain/role-prompts.ts";
|
|
8
10
|
import type { Role } from "../domain/roles.ts";
|
|
9
11
|
import { dispatchPaths, markForCollect } from "../infra/dispatch-dir.ts";
|
|
10
12
|
import { withFileLock } from "../infra/filelock.ts";
|
|
11
13
|
import { statusSnapshot } from "../infra/git.ts";
|
|
12
14
|
import { launchSupervisor } from "../infra/launch.ts";
|
|
15
|
+
import { log } from "../infra/log.ts";
|
|
13
16
|
import { processStartTime } from "../infra/proc.ts";
|
|
14
17
|
import { ensurePrivateDir, PRIVATE_FILE, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
15
18
|
import { readyAdapter, standInFor } from "./backends.ts";
|
|
@@ -28,6 +31,7 @@ import {
|
|
|
28
31
|
import { finalizeDispatch } from "./finalize.ts";
|
|
29
32
|
import type { Deps } from "./ports.ts";
|
|
30
33
|
import { readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
|
|
34
|
+
import { currentSession } from "./sessions.ts";
|
|
31
35
|
|
|
32
36
|
export interface AdmitInput {
|
|
33
37
|
role: Role;
|
|
@@ -39,6 +43,8 @@ export interface AdmitInput {
|
|
|
39
43
|
failoverFrom: string | null;
|
|
40
44
|
/** the dispatch id of the limited dispatch this stand-in replaces */
|
|
41
45
|
failoverOf?: string;
|
|
46
|
+
/** the dispatching session (spec §3.3); absent means the session this process serves */
|
|
47
|
+
sessionId?: string | null;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
/** Spec §3.3: SIGTERM, then SIGKILL this long after. */
|
|
@@ -52,7 +58,7 @@ function laneOwns(run: Run, lane: string): string[] {
|
|
|
52
58
|
throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
|
|
53
59
|
fix: "write it with write_run_file first",
|
|
54
60
|
});
|
|
55
|
-
const owns =
|
|
61
|
+
const owns = assertLaneHeader(readFileSync(file, "utf8"), `lanes/${lane}.md`).owns;
|
|
56
62
|
if (owns.length === 0)
|
|
57
63
|
throw new CatherdError("E_LANE_INVALID", `lanes/${lane}.md has no Owns: line`, {
|
|
58
64
|
fix: "add `Owns: <paths>` below the lane's title",
|
|
@@ -60,18 +66,29 @@ function laneOwns(run: Run, lane: string): string[] {
|
|
|
60
66
|
return owns;
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
/** What a caller of `admit` does with each record `admit` itself writes (dispatch settles it). */
|
|
70
|
+
export type OnRecorded = (d: Dispatch, record: RunRecord) => Promise<unknown>;
|
|
71
|
+
|
|
63
72
|
/**
|
|
64
73
|
* Records each finished dispatch of the run that has none yet, as reconcile does, so one whose waiter died
|
|
65
|
-
* never blocks its name or lane
|
|
66
|
-
*
|
|
74
|
+
* never blocks its name or lane, and hands each record to `onRecorded` (the caller settles it, so its owner is
|
|
75
|
+
* told). Before the admission lock: a finalize may wait on another's claim. One that throws is skipped and
|
|
76
|
+
* stays pending, so the refusal still names it; an `onRecorded` that throws never fails the admit.
|
|
67
77
|
*/
|
|
68
|
-
async function finalizeFinished(run: Run, now: number): Promise<void> {
|
|
78
|
+
async function finalizeFinished(run: Run, now: number, onRecorded?: OnRecorded): Promise<void> {
|
|
69
79
|
for (const d of pendingDispatches(run, now)) {
|
|
70
80
|
if (d.state !== "finished") continue;
|
|
81
|
+
let record: RunRecord;
|
|
71
82
|
try {
|
|
72
|
-
await finalizeDispatch(run, d);
|
|
83
|
+
record = await finalizeDispatch(run, d);
|
|
73
84
|
} catch {
|
|
74
85
|
// stays pending: admission refuses its name and lane, and names it
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
await onRecorded?.(d, record);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
log("warn", "admit", { run: run.id, name: d.admit.name, error: errorMessage(e) });
|
|
75
92
|
}
|
|
76
93
|
}
|
|
77
94
|
}
|
|
@@ -109,7 +126,12 @@ async function prepared(adapter: BackendAdapter, req: Parameters<NonNullable<Bac
|
|
|
109
126
|
* Spec §4.4 step 1. The checks that read shared state and the write that makes the dispatch live
|
|
110
127
|
* happen under the run's admission lock, so parallel dispatches always see each other (audit C1).
|
|
111
128
|
*/
|
|
112
|
-
export async function admit(
|
|
129
|
+
export async function admit(
|
|
130
|
+
deps: Deps,
|
|
131
|
+
run: Run,
|
|
132
|
+
i: AdmitInput,
|
|
133
|
+
onRecorded?: OnRecorded,
|
|
134
|
+
): Promise<{ d: Dispatch; specPath: string }> {
|
|
113
135
|
assertId("role name", i.name);
|
|
114
136
|
if (i.lane !== null) assertId("lane", i.lane);
|
|
115
137
|
const rung = parseRung(i.rung);
|
|
@@ -151,6 +173,7 @@ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: D
|
|
|
151
173
|
const plan = adapter.plan({
|
|
152
174
|
rung,
|
|
153
175
|
access: rc.access,
|
|
176
|
+
network: rc.network !== false,
|
|
154
177
|
thread: i.thread,
|
|
155
178
|
isolated,
|
|
156
179
|
repo: run.meta.repo,
|
|
@@ -159,7 +182,8 @@ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: D
|
|
|
159
182
|
dispatchDir: dir,
|
|
160
183
|
});
|
|
161
184
|
|
|
162
|
-
await finalizeFinished(run, deps.now());
|
|
185
|
+
await finalizeFinished(run, deps.now(), onRecorded);
|
|
186
|
+
const sessionId = i.sessionId !== undefined ? i.sessionId : (currentSession(deps)?.sessionId ?? null);
|
|
163
187
|
return withFileLock(runPaths(run.dir).admission, async () => {
|
|
164
188
|
// A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
|
|
165
189
|
// tree after the exit, so a later dispatch's writes must not land in between. A finished one here
|
|
@@ -179,7 +203,7 @@ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: D
|
|
|
179
203
|
fix:
|
|
180
204
|
same.state === "finished"
|
|
181
205
|
? `its record could not be written; see ${same.dir}, and retry`
|
|
182
|
-
: `
|
|
206
|
+
: `its record is announced when it finishes; or cancel(run, "${i.name}")`,
|
|
183
207
|
},
|
|
184
208
|
);
|
|
185
209
|
for (const d of pending) {
|
|
@@ -219,9 +243,11 @@ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: D
|
|
|
219
243
|
admittedAt: new Date(deps.now()).toISOString(),
|
|
220
244
|
repo: run.meta.repo,
|
|
221
245
|
before: await statusSnapshot(run.meta.repo),
|
|
246
|
+
...(sessionId ? { sessionId } : {}),
|
|
222
247
|
};
|
|
223
248
|
ensurePrivateDir(dir);
|
|
224
|
-
|
|
249
|
+
// spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included
|
|
250
|
+
writeTextAtomic(p.brief, withReplyContract(i.role, i.brief));
|
|
225
251
|
// Spec §10.4: the adapter's overrides only; the supervisor adds its own inherited env at spawn
|
|
226
252
|
// time (src/entry/supervise-command.ts), so no credential is ever written to disk. 0600 all the same.
|
|
227
253
|
writeJsonAtomic(
|
|
@@ -244,7 +270,7 @@ export async function admit(deps: Deps, run: Run, i: AdmitInput): Promise<{ d: D
|
|
|
244
270
|
{ mode: 0o600 },
|
|
245
271
|
);
|
|
246
272
|
// the collect mark before admit.json: no admitted dispatch ever exists without it, so a process that
|
|
247
|
-
// dies before launching it still leaves a record (lost, once its start grace passes)
|
|
273
|
+
// dies before launching it still leaves a record (lost, once its start grace passes), unread
|
|
248
274
|
markForCollect(dir);
|
|
249
275
|
writeJsonAtomic(admitPath(dir), admitted);
|
|
250
276
|
setLatest(run, i.name, id);
|