catherd-cli 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -30
- 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 +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +110 -45
- package/plugin/skills/catherd-setup/SKILL.md +2 -2
- 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/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 +5 -0
- 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/lane.ts +99 -3
- package/src/domain/notice.ts +9 -2
- package/src/domain/profile-rules.ts +112 -16
- package/src/domain/profile.ts +24 -0
- package/src/domain/record.ts +16 -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 +14 -6
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +2 -1
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +104 -8
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +6 -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 +20 -4
- package/src/entry/mcp/server.ts +98 -7
- package/src/entry/mcp/setup-tools.ts +4 -3
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +23 -4
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +78 -8
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +4 -0
- package/src/entry/tui/state.ts +37 -10
- package/src/entry/tui/views/profiles.tsx +1 -1
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +6 -2
- 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/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -1
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- 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/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 +107 -16
- package/src/services/admission.ts +110 -23
- 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 +204 -16
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +68 -6
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +12 -2
- 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 +257 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +56 -3
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/ports.ts +31 -2
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +1 -0
- package/src/services/profile-store.ts +10 -3
- 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 +144 -36
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +151 -19
- package/src/services/run-store.ts +109 -15
- package/src/services/runs-page.ts +42 -7
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- 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 +49 -9
- package/src/services/treat-likes.ts +19 -3
- 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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accessSync,
|
|
3
|
+
constants,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statfsSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { realTmpdir, toolchainCaches } from "../adapters/access.ts";
|
|
14
|
+
import { runCli } from "../adapters/cli.ts";
|
|
15
|
+
import { probeTwice } from "../infra/host-probe.ts";
|
|
16
|
+
import { probeTargets } from "./doctor-access.ts";
|
|
17
|
+
import type { Check } from "./doctor-checks.ts";
|
|
18
|
+
|
|
19
|
+
// Plan 23: the rows that catch what cost the identity and payment runs their verifier rounds: a Docker client
|
|
20
|
+
// that injects proxies into every container, a compose network whose services cannot reach each other by
|
|
21
|
+
// name, a Docker disk about to fill, and the toolchain caches a sandboxed worker writes.
|
|
22
|
+
|
|
23
|
+
/** Under this much free space in Docker's data root, doctor warns: image builds start failing near it (tests raise it). */
|
|
24
|
+
export const dockerDisk = { minFreeBytes: 10 * 1024 ** 3 };
|
|
25
|
+
|
|
26
|
+
/** How long the compose probe may take, an image pull included. */
|
|
27
|
+
export const composeProbe = { timeoutMs: 120_000 };
|
|
28
|
+
|
|
29
|
+
/** The Docker client's config file: $DOCKER_CONFIG/config.json, else ~/.docker/config.json. */
|
|
30
|
+
export const dockerConfigFile = (env = process.env): string =>
|
|
31
|
+
join(env.DOCKER_CONFIG || join(env.HOME || homedir(), ".docker"), "config.json");
|
|
32
|
+
|
|
33
|
+
/** The keys of a proxies entry the Docker client turns into container environment variables. */
|
|
34
|
+
const PROXY_KEYS = ["httpProxy", "httpsProxy", "noProxy", "ftpProxy", "allProxy"];
|
|
35
|
+
|
|
36
|
+
/** The Docker client's default daemon host, which its proxies block may key an entry by. */
|
|
37
|
+
const DEFAULT_DOCKER_HOST = "unix:///var/run/docker.sock";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A `proxies` block in the Docker client's config that sets a value: every container then gets HTTP_PROXY and its
|
|
41
|
+
* siblings. The Docker client applies the current daemon host's entry, else `default`; an entry with no value
|
|
42
|
+
* (or an empty block) injects nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function proxiesCheck(
|
|
45
|
+
file = dockerConfigFile(),
|
|
46
|
+
env: Record<string, string | undefined> = process.env,
|
|
47
|
+
): Check | null {
|
|
48
|
+
let config: { proxies?: unknown };
|
|
49
|
+
try {
|
|
50
|
+
config = JSON.parse(readFileSync(file, "utf8")) as { proxies?: unknown };
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const proxies = config.proxies;
|
|
55
|
+
if (!proxies || typeof proxies !== "object") return null;
|
|
56
|
+
const entries = proxies as Record<string, unknown>;
|
|
57
|
+
const host = env.DOCKER_HOST || DEFAULT_DOCKER_HOST;
|
|
58
|
+
const entry = Object.hasOwn(entries, host) ? entries[host] : entries.default;
|
|
59
|
+
const sets =
|
|
60
|
+
!!entry &&
|
|
61
|
+
typeof entry === "object" &&
|
|
62
|
+
PROXY_KEYS.some((k) => {
|
|
63
|
+
const v = (entry as Record<string, unknown>)[k];
|
|
64
|
+
return typeof v === "string" && v.trim() !== "";
|
|
65
|
+
});
|
|
66
|
+
if (!sets) return null;
|
|
67
|
+
return {
|
|
68
|
+
id: "docker-proxies",
|
|
69
|
+
label: "Docker client proxies",
|
|
70
|
+
state: "warn",
|
|
71
|
+
word: "injects proxies",
|
|
72
|
+
detail: `${file} has a proxies block: every container gets HTTP_PROXY, so compose services cannot reach each other by name and a loopback health check goes to the proxy`,
|
|
73
|
+
fix: `remove the proxies block from ${file}, or add the compose service names and 127.0.0.1 to its noProxy`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Two busybox services: `web` serves on 8080 with a loopback health check, `probe` fetches it by name. */
|
|
78
|
+
const COMPOSE = `services:
|
|
79
|
+
web:
|
|
80
|
+
image: busybox:1.36
|
|
81
|
+
command: ["httpd", "-f", "-p", "8080", "-h", "/etc"]
|
|
82
|
+
healthcheck:
|
|
83
|
+
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/hostname"]
|
|
84
|
+
interval: 1s
|
|
85
|
+
retries: 30
|
|
86
|
+
probe:
|
|
87
|
+
image: busybox:1.36
|
|
88
|
+
depends_on:
|
|
89
|
+
web:
|
|
90
|
+
condition: service_healthy
|
|
91
|
+
command: ["wget", "-q", "-O", "/dev/null", "http://web:8080/hostname"]
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
/** `docker compose up` on the two-service file, then `down`; the result of the up. */
|
|
95
|
+
async function composeUp(docker: string): Promise<{ ok: boolean; why: string }> {
|
|
96
|
+
const dir = mkdtempSync(join(realTmpdir(), "catherd-compose-"));
|
|
97
|
+
const file = join(dir, "compose.yaml");
|
|
98
|
+
writeFileSync(file, COMPOSE);
|
|
99
|
+
const project = ["compose", "-p", `catherd-doctor-${process.pid}`, "-f", file];
|
|
100
|
+
try {
|
|
101
|
+
const up = await runCli(
|
|
102
|
+
docker,
|
|
103
|
+
[...project, "up", "--abort-on-container-exit", "--exit-code-from", "probe"],
|
|
104
|
+
{
|
|
105
|
+
timeoutMs: composeProbe.timeoutMs,
|
|
106
|
+
cwd: dir,
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
const lines = `${up?.err ?? ""}\n${up?.out ?? ""}`
|
|
110
|
+
.split("\n")
|
|
111
|
+
.map((l) => l.trim())
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
return { ok: up?.ok === true, why: lines.at(-1) ?? "no output" };
|
|
114
|
+
} finally {
|
|
115
|
+
await runCli(docker, [...project, "down", "-v", "--remove-orphans"], { timeoutMs: 30_000, cwd: dir });
|
|
116
|
+
rmSync(dir, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Free bytes in Docker's data root, when it is on this machine's disk; null when Docker runs in a VM. */
|
|
121
|
+
async function dockerFree(docker: string): Promise<{ root: string; free: number } | null> {
|
|
122
|
+
const r = await runCli(docker, ["info", "--format", "{{.DockerRootDir}}"], { timeoutMs: 15_000 });
|
|
123
|
+
const root = r?.ok ? r.out.trim() : "";
|
|
124
|
+
if (!root || !existsSync(root)) return null;
|
|
125
|
+
try {
|
|
126
|
+
const s = statfsSync(root);
|
|
127
|
+
return { root, free: Number(s.bavail) * Number(s.bsize) };
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const gb = (bytes: number): string => `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The Docker rows: the client's proxies, read from its config file; with `probe` (`catherd doctor --docker`), when
|
|
137
|
+
* a daemon answers, the compose network probe (retried once after 5 s before it calls the host blocked) and the
|
|
138
|
+
* free space in Docker's data root. The probes pull busybox and start containers, so a plain doctor skips them.
|
|
139
|
+
*/
|
|
140
|
+
export async function dockerChecks(o: { probe: boolean }): Promise<Check[]> {
|
|
141
|
+
const docker = probeTargets.docker;
|
|
142
|
+
const checks: Check[] = [];
|
|
143
|
+
const proxies = proxiesCheck();
|
|
144
|
+
if (proxies) checks.push(proxies);
|
|
145
|
+
if (!o.probe) return checks;
|
|
146
|
+
const version = await runCli(docker, ["version"], { timeoutMs: 15_000 });
|
|
147
|
+
if (!version?.ok) return checks;
|
|
148
|
+
const compose = await runCli(docker, ["compose", "version"], { timeoutMs: 15_000 });
|
|
149
|
+
if (compose?.ok) {
|
|
150
|
+
const { result, attempts } = await probeTwice(
|
|
151
|
+
() => composeUp(docker),
|
|
152
|
+
(r) => r.ok,
|
|
153
|
+
);
|
|
154
|
+
checks.push(
|
|
155
|
+
result.ok
|
|
156
|
+
? {
|
|
157
|
+
id: "docker-network",
|
|
158
|
+
label: "compose network",
|
|
159
|
+
state: "ok",
|
|
160
|
+
word: "ready",
|
|
161
|
+
detail: "two services reach each other by name, and a loopback health check passes",
|
|
162
|
+
}
|
|
163
|
+
: {
|
|
164
|
+
id: "docker-network",
|
|
165
|
+
label: "compose network",
|
|
166
|
+
state: "warn",
|
|
167
|
+
word: "blocked",
|
|
168
|
+
detail: `a compose service could not reach another by name, or its loopback health check failed (${attempts} tries, 5 s apart): ${result.why}`,
|
|
169
|
+
fix: proxies
|
|
170
|
+
? (proxies.fix as string)
|
|
171
|
+
: "check the Docker client's proxies, a VPN or socket filter on the Docker bridge, and that busybox can be pulled",
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const free = await dockerFree(docker);
|
|
176
|
+
if (free && free.free < dockerDisk.minFreeBytes)
|
|
177
|
+
checks.push({
|
|
178
|
+
id: "docker-disk",
|
|
179
|
+
label: "Docker disk",
|
|
180
|
+
state: "warn",
|
|
181
|
+
word: "low",
|
|
182
|
+
detail: `${gb(free.free)} free in Docker's data root ${free.root}; builds fail with no space left on device`,
|
|
183
|
+
fix: "docker image prune -f, then docker builder prune -f",
|
|
184
|
+
});
|
|
185
|
+
return checks;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether this user may write `dir`. */
|
|
189
|
+
const writable = (dir: string): boolean => {
|
|
190
|
+
try {
|
|
191
|
+
accessSync(dir, constants.W_OK);
|
|
192
|
+
return true;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The toolchain caches present on the machine (Go's build and module caches, the pnpm store, Bun's and npm's
|
|
200
|
+
* caches), which join every workspace-write worker's writable roots; a warning for one this user cannot write.
|
|
201
|
+
*/
|
|
202
|
+
export function cachesCheck(caches = toolchainCaches()): Check {
|
|
203
|
+
const base = { id: "caches", label: "toolchain caches" };
|
|
204
|
+
if (caches.length === 0)
|
|
205
|
+
return { ...base, state: "skip", word: "none", detail: "no Go, pnpm, Bun or npm cache on this machine" };
|
|
206
|
+
const blocked = caches.filter((c) => !writable(c));
|
|
207
|
+
return blocked.length
|
|
208
|
+
? {
|
|
209
|
+
...base,
|
|
210
|
+
state: "warn",
|
|
211
|
+
word: "not writable",
|
|
212
|
+
detail: `${blocked.join(", ")}: a worker's checks cannot write it, even with it in their writable roots`,
|
|
213
|
+
fix: `chown -R "$(id -un)" ${blocked.join(" ")}`,
|
|
214
|
+
}
|
|
215
|
+
: {
|
|
216
|
+
...base,
|
|
217
|
+
state: "ok",
|
|
218
|
+
word: "ready",
|
|
219
|
+
detail: `in every workspace-write worker's writable roots: ${caches.join(", ")}`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { tryParseRung } from "../domain/ids.ts";
|
|
2
|
+
import type { Profile } from "../domain/profile.ts";
|
|
3
|
+
import { roleMcpTools } from "../domain/role-tools.ts";
|
|
4
|
+
import { ROLES } from "../domain/roles.ts";
|
|
5
|
+
import { ROLE_MCP_STARTUP_SEC, type RoleServerStart } from "../infra/role-mcp.ts";
|
|
6
|
+
import { standInFor } from "./backends.ts";
|
|
7
|
+
import type { Check } from "./doctor-checks.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The active profile's role server, per backend that runs one (codex, claude-code), isolated or not (spec 1.5
|
|
11
|
+
* plan 21, #42 finding 6): the roles that get it and their tools, and, when doctor probed it, how long a cold
|
|
12
|
+
* start took against Codex's startup timeout. A start past half that timeout warns: on a loaded machine Codex,
|
|
13
|
+
* which requires the server, would abort the role.
|
|
14
|
+
*/
|
|
15
|
+
export function roleMcpChecks(profile: Profile | null, start?: RoleServerStart): Check[] {
|
|
16
|
+
if (!profile) return [];
|
|
17
|
+
const checks: Check[] = [];
|
|
18
|
+
for (const backend of ["codex", "claude-code"] as const) {
|
|
19
|
+
const uses = (rung: string) => tryParseRung(rung)?.backend === backend;
|
|
20
|
+
const roles = ROLES.filter((role) => {
|
|
21
|
+
const configured = profile.roles[role];
|
|
22
|
+
return (
|
|
23
|
+
configured.enabled &&
|
|
24
|
+
configured.rungs.some((rung) => {
|
|
25
|
+
const failover = standInFor(profile.failover, rung);
|
|
26
|
+
return uses(rung) || (failover !== null && uses(failover));
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
if (!roles.length) continue;
|
|
31
|
+
const base = { id: `role-mcp:${profile.name}:${backend}`, label: `${profile.name} ${backend} role MCP` };
|
|
32
|
+
const tools = [...new Set(roles.flatMap(roleMcpTools))];
|
|
33
|
+
const configured = `${roles.join(", ")}: scoped catherd_role exposes ${tools.join(", ")}; ${backend === "codex" ? "per-tool approval_mode=approve" : "exact MCP tool allowlist"}. Model invocation not tested; managed policy may still deny tools.`;
|
|
34
|
+
const limit = ROLE_MCP_STARTUP_SEC * 1000;
|
|
35
|
+
if (start && !start.ok) {
|
|
36
|
+
checks.push({
|
|
37
|
+
...base,
|
|
38
|
+
state: "fail",
|
|
39
|
+
word: "no start",
|
|
40
|
+
detail: `the role server did not start: ${start.error}`,
|
|
41
|
+
fix: "reinstall catherd at this version, then run catherd doctor again",
|
|
42
|
+
});
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (start?.ok && start.ms > limit / 2) {
|
|
46
|
+
checks.push({
|
|
47
|
+
...base,
|
|
48
|
+
state: "warn",
|
|
49
|
+
word: "slow start",
|
|
50
|
+
detail: `the role server took ${Math.round(start.ms)} ms to start; Codex gives it ${ROLE_MCP_STARTUP_SEC} s and stops the role when it misses`,
|
|
51
|
+
fix: "install catherd globally (catherd init) so roles start it without resolving a package, and check the machine's load",
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
checks.push({
|
|
56
|
+
...base,
|
|
57
|
+
state: "info",
|
|
58
|
+
word: "configured",
|
|
59
|
+
detail: start?.ok
|
|
60
|
+
? `${configured} Starts in ${Math.round(start.ms)} ms (limit ${ROLE_MCP_STARTUP_SEC} s).`
|
|
61
|
+
: configured,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return checks;
|
|
65
|
+
}
|
package/src/services/doctor.ts
CHANGED
|
@@ -7,11 +7,14 @@ import { BUILTIN_ROLES, type Profile } from "../domain/profile.ts";
|
|
|
7
7
|
import { DEFAULT_ACCESS, ROLES } from "../domain/roles.ts";
|
|
8
8
|
import { bunTooOld, MIN_BUN } from "../domain/runtime.ts";
|
|
9
9
|
import type { JevTransport } from "../infra/jev-client.ts";
|
|
10
|
+
import type { RoleServerStart } from "../infra/role-mcp.ts";
|
|
10
11
|
import { linkedProfiles } from "./agent-links.ts";
|
|
11
12
|
import { accessChecks } from "./doctor-access.ts";
|
|
12
13
|
import { backendChecks, usedBackends } from "./doctor-backends.ts";
|
|
13
14
|
import { type PushProbe, pushCheck, probePush } from "./doctor-push.ts";
|
|
14
15
|
import { sourcesCheck, standInsToConfirmIn } from "./doctor-sources.ts";
|
|
16
|
+
import { cachesCheck, dockerChecks } from "./doctor-docker.ts";
|
|
17
|
+
import { roleMcpChecks } from "./doctor-role-mcp.ts";
|
|
15
18
|
import {
|
|
16
19
|
agentsCheck,
|
|
17
20
|
hostFlag,
|
|
@@ -66,14 +69,33 @@ export interface DoctorDeps {
|
|
|
66
69
|
/** Explicit smoke test injection; default doctor never invokes it. */
|
|
67
70
|
push?: () => Promise<PushProbe>;
|
|
68
71
|
testPush?: boolean;
|
|
72
|
+
/** plan 23: `--docker`: run the Docker host probes (a compose network by service name, the free disk) */
|
|
73
|
+
docker?: boolean;
|
|
69
74
|
env?: Record<string, string | undefined>;
|
|
70
75
|
jev?: JevTransport;
|
|
76
|
+
/** spec 1.5 plan 21: a cold start of the role server, timed; absent, doctor does not probe it */
|
|
77
|
+
roleServerStart?: () => Promise<RoleServerStart>;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
/**
|
|
74
81
|
* Spec §10.3: the readiness report. Reads and probes; it writes discovery and a lock probe itself, and the
|
|
75
82
|
* handshake starts `catherd mcp`, which reconciles runs as it starts. A check that throws becomes a `fail` row.
|
|
76
83
|
*/
|
|
84
|
+
/**
|
|
85
|
+
* Where the access probes look for a per-repo sandbox: the current repo and each repo bound to a checked
|
|
86
|
+
* profile. A repo bound to another profile never runs these roles, so its settings must not decide the probes.
|
|
87
|
+
*/
|
|
88
|
+
export function accessRepos(
|
|
89
|
+
repo: string | null,
|
|
90
|
+
bindings: Record<string, string>,
|
|
91
|
+
checked: string[],
|
|
92
|
+
): string[] {
|
|
93
|
+
const bound = Object.entries(bindings)
|
|
94
|
+
.filter(([, name]) => checked.includes(name))
|
|
95
|
+
.map(([r]) => r);
|
|
96
|
+
return [...new Set([...(repo ? [repo] : []), ...bound])];
|
|
97
|
+
}
|
|
98
|
+
|
|
77
99
|
export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
78
100
|
const checks: Check[] = [];
|
|
79
101
|
checks.push(
|
|
@@ -86,7 +108,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
86
108
|
detail: `${d.bunVersion}, needs ${MIN_BUN}`,
|
|
87
109
|
fix: "bun upgrade",
|
|
88
110
|
}
|
|
89
|
-
: {
|
|
111
|
+
: {
|
|
112
|
+
id: "bun",
|
|
113
|
+
label: "Bun",
|
|
114
|
+
state: "ok",
|
|
115
|
+
word: "ready",
|
|
116
|
+
detail: d.bunVersion,
|
|
117
|
+
},
|
|
90
118
|
);
|
|
91
119
|
|
|
92
120
|
// every fix names the host doctor judged with: run elsewhere (a plain terminal), it must judge the same way
|
|
@@ -170,6 +198,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
170
198
|
const used = usedBackends(profiles);
|
|
171
199
|
const installed = new Set<string>();
|
|
172
200
|
checks.push(...(await backendChecks(used, profiles, installed, d.host.host)));
|
|
201
|
+
checks.push(...roleMcpChecks(active, await d.roleServerStart?.()));
|
|
173
202
|
|
|
174
203
|
if (active && [active, ...profiles].every((p) => p.jev.use === "off"))
|
|
175
204
|
checks.push({
|
|
@@ -194,7 +223,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
194
223
|
const ok = await testJevKey(key, d.jev).catch(() => false);
|
|
195
224
|
checks.push(
|
|
196
225
|
ok
|
|
197
|
-
? {
|
|
226
|
+
? {
|
|
227
|
+
id: "jev",
|
|
228
|
+
label: "Jev",
|
|
229
|
+
state: "ok",
|
|
230
|
+
word: "ready",
|
|
231
|
+
detail: "the key answers",
|
|
232
|
+
}
|
|
198
233
|
: {
|
|
199
234
|
id: "jev",
|
|
200
235
|
label: "Jev",
|
|
@@ -275,7 +310,9 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
275
310
|
word: !queue.cli ? "unavailable" : queue.server,
|
|
276
311
|
detail: queue.reason ?? "Native server advertises queue support",
|
|
277
312
|
...(!queue.cli || queue.server === "unsupported"
|
|
278
|
-
? {
|
|
313
|
+
? {
|
|
314
|
+
fix: "Update native Codex/server; use peek/result while push is unavailable.",
|
|
315
|
+
}
|
|
279
316
|
: {}),
|
|
280
317
|
});
|
|
281
318
|
let push: PushProbe | null = null;
|
|
@@ -291,8 +328,21 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
291
328
|
}
|
|
292
329
|
|
|
293
330
|
checks.push(locksCheck());
|
|
331
|
+
// plan 23: the toolchain caches workers write, and Docker as a gate sees it (before the access probes)
|
|
332
|
+
checks.push(cachesCheck());
|
|
333
|
+
checks.push(...(await dockerChecks({ probe: d.docker === true })));
|
|
294
334
|
// spec §5 and §12: which codex sandbox form runs, and the five access probes per workspace-write backend
|
|
295
|
-
|
|
335
|
+
let repos: string[] = d.repo ? [d.repo] : [];
|
|
336
|
+
try {
|
|
337
|
+
repos = accessRepos(
|
|
338
|
+
d.repo,
|
|
339
|
+
readProjects().bindings,
|
|
340
|
+
profiles.map((p) => p.name),
|
|
341
|
+
);
|
|
342
|
+
} catch {
|
|
343
|
+
// the config row above already reports an unreadable projects.json
|
|
344
|
+
}
|
|
345
|
+
checks.push(...(await accessChecks(profiles, installed, repos)));
|
|
296
346
|
|
|
297
347
|
// spec 1.1 §13: what the shipped defaults do is info; a warning only for what a profile changed
|
|
298
348
|
const full = { changed: [] as string[], shipped: [] as string[] };
|
|
@@ -329,7 +379,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
329
379
|
word: "warning",
|
|
330
380
|
detail: `${what}: ${rows.changed.join(", ")}${shipped ? `; as shipped: ${shipped}` : ""}`,
|
|
331
381
|
}
|
|
332
|
-
: {
|
|
382
|
+
: {
|
|
383
|
+
id,
|
|
384
|
+
label,
|
|
385
|
+
state: "info",
|
|
386
|
+
word: "default",
|
|
387
|
+
detail: `${what}: ${shipped}`,
|
|
388
|
+
};
|
|
333
389
|
};
|
|
334
390
|
for (const row of [
|
|
335
391
|
accessRow("access:full", "full access", full, "no sandbox for"),
|
|
@@ -372,7 +428,13 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
372
428
|
detail: unreadable.message,
|
|
373
429
|
fix: unreadable.fix ?? `fix or delete ${creds}, then catherd init`,
|
|
374
430
|
}
|
|
375
|
-
: {
|
|
431
|
+
: {
|
|
432
|
+
id: "credentials",
|
|
433
|
+
label: "credentials.json",
|
|
434
|
+
state: "ok",
|
|
435
|
+
word: "ready",
|
|
436
|
+
detail: "mode 600",
|
|
437
|
+
},
|
|
376
438
|
);
|
|
377
439
|
}
|
|
378
440
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { errorMessage } from "../domain/errors.ts";
|
|
3
|
+
import type { HostSessionRef } from "../domain/host.ts";
|
|
4
|
+
import { envelope, formatNotices } from "../domain/notice.ts";
|
|
5
|
+
import { type QueueSendResult, sendToCodexQueue } from "../infra/codex-queue.ts";
|
|
6
|
+
import {
|
|
7
|
+
type DeliveryAttempt,
|
|
8
|
+
deliveryState,
|
|
9
|
+
ROLE_THREAD_REFUSAL,
|
|
10
|
+
writeDeliveryAttempt,
|
|
11
|
+
} from "../infra/delivery.ts";
|
|
12
|
+
import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
13
|
+
import { tryLock } from "../infra/filelock.ts";
|
|
14
|
+
import { log } from "../infra/log.ts";
|
|
15
|
+
import { admitPath, listDispatches, roleThreadOf } from "./dispatches.ts";
|
|
16
|
+
import { finalizeDispatch } from "./finalize.ts";
|
|
17
|
+
import { finishedNotice, recoverSubmission } from "./notifier.ts";
|
|
18
|
+
import { findRun } from "./run-store.ts";
|
|
19
|
+
import { runOwner } from "./sessions.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How long the supervisor leaves the owner's MCP server to push first (the fast path: its watcher finalizes,
|
|
23
|
+
* settles and sends within the notifier's coalescing window). Either path sends under the same per-event receipt,
|
|
24
|
+
* so a late server or a late supervisor finds the event accepted and sends nothing.
|
|
25
|
+
*/
|
|
26
|
+
export const END_PUSH_GRACE_MS = 20_000;
|
|
27
|
+
|
|
28
|
+
/** `CATHERD_END_PUSH_GRACE_MS` (a test of the spawned supervisor shortens it), else END_PUSH_GRACE_MS. */
|
|
29
|
+
function graceFromEnv(): number {
|
|
30
|
+
const ms = Number(process.env.CATHERD_END_PUSH_GRACE_MS);
|
|
31
|
+
return process.env.CATHERD_END_PUSH_GRACE_MS !== undefined && Number.isFinite(ms) && ms >= 0
|
|
32
|
+
? ms
|
|
33
|
+
: END_PUSH_GRACE_MS;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface EndPushOptions {
|
|
37
|
+
graceMs?: number;
|
|
38
|
+
/** the sender; tests replace it */
|
|
39
|
+
sendCodex?: (target: HostSessionRef, content: string) => Promise<QueueSendResult>;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Why the supervisor pushed nothing, or the receipt's status when it did. */
|
|
44
|
+
export type EndPushOutcome =
|
|
45
|
+
| "off"
|
|
46
|
+
| "not-a-run"
|
|
47
|
+
| "not-codex"
|
|
48
|
+
| "limit"
|
|
49
|
+
| "read"
|
|
50
|
+
| "busy"
|
|
51
|
+
| "delivered"
|
|
52
|
+
| DeliveryAttempt["status"];
|
|
53
|
+
|
|
54
|
+
/** The run's owner, when it is a Codex thread: the supervisor's push targets it. */
|
|
55
|
+
function codexOwner(runId: string): HostSessionRef | null {
|
|
56
|
+
const owner = runOwner(findRun(runId));
|
|
57
|
+
return owner?.host === "codex"
|
|
58
|
+
? { host: "codex", sessionId: owner.sessionId, hostSessionId: null, name: null }
|
|
59
|
+
: null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Plan 22, push from where a role ends: the detached supervisor of a dispatch whose run a Codex thread owns, once
|
|
64
|
+
* its worker exited, queues the role's notice to that thread with `codex queue --remote unix:// --thread <owner>`.
|
|
65
|
+
* Codex's app-server keeps queued input for an unloaded thread, so a result reaches a coordinator whose MCP
|
|
66
|
+
* server the daemon stopped (no client attached) at its next attach. The notice is the server's own text, and it
|
|
67
|
+
* goes under the same per-event receipt (`delivery.json`, the dispatch's notify lock), so the two paths never
|
|
68
|
+
* double-deliver. A usage limit is left to the owner's server, which fails it over before it announces it.
|
|
69
|
+
* `CATHERD_NO_END_PUSH=1` turns it off (the test preload sets it). Never throws.
|
|
70
|
+
*/
|
|
71
|
+
export async function pushFromEnd(dispatchDir: string, o: EndPushOptions = {}): Promise<EndPushOutcome> {
|
|
72
|
+
if (process.env.CATHERD_NO_END_PUSH === "1") return "off";
|
|
73
|
+
try {
|
|
74
|
+
const runId = (JSON.parse(readFileSync(admitPath(dispatchDir), "utf8")) as { runId?: unknown }).runId;
|
|
75
|
+
if (typeof runId !== "string") return "not-a-run";
|
|
76
|
+
// read before the grace: a run no Codex thread owns costs the supervisor nothing
|
|
77
|
+
if (!codexOwner(runId)) return "not-codex";
|
|
78
|
+
await Bun.sleep(o.graceMs ?? graceFromEnv());
|
|
79
|
+
const run = findRun(runId);
|
|
80
|
+
const d = listDispatches(run).find((x) => x.dir === dispatchDir);
|
|
81
|
+
if (!d) return "not-a-run";
|
|
82
|
+
const record = await finalizeDispatch(run, d);
|
|
83
|
+
if (record.status === "limit") return "limit";
|
|
84
|
+
// the owner of record now, which a claim may have changed during the grace
|
|
85
|
+
const target = codexOwner(runId);
|
|
86
|
+
if (!target) return "not-codex";
|
|
87
|
+
if (!awaitsCollect(d.dir)) return "read";
|
|
88
|
+
const notice = finishedNotice(run, d, record);
|
|
89
|
+
const release = tryLock(dispatchPaths(d.dir).notified);
|
|
90
|
+
if (!release) return "busy";
|
|
91
|
+
try {
|
|
92
|
+
recoverSubmission(d.dir, notice.eventId);
|
|
93
|
+
if (deliveryState(d.dir, target, notice.eventId) !== "pending") return "delivered";
|
|
94
|
+
// spec 1.5 plan 21: never to a role's own thread (a `codex exec` thread exits after its turn, so what is
|
|
95
|
+
// queued there is lost); it fails loudly, with the notifier's reason, which `status` shows
|
|
96
|
+
const role = roleThreadOf(run, target.sessionId);
|
|
97
|
+
if (role !== null) {
|
|
98
|
+
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`;
|
|
99
|
+
log("error", "notify", { from: "supervisor", run: run.id, dispatch: notice.dispatchId, reason });
|
|
100
|
+
writeDeliveryAttempt(d.dir, {
|
|
101
|
+
attemptId: crypto.randomUUID(),
|
|
102
|
+
target,
|
|
103
|
+
eventIds: [notice.eventId],
|
|
104
|
+
at: new Date((o.now ?? Date.now)()).toISOString(),
|
|
105
|
+
status: "failed",
|
|
106
|
+
msgId: null,
|
|
107
|
+
reason,
|
|
108
|
+
});
|
|
109
|
+
return "failed";
|
|
110
|
+
}
|
|
111
|
+
const attempt: DeliveryAttempt = {
|
|
112
|
+
attemptId: crypto.randomUUID(),
|
|
113
|
+
target,
|
|
114
|
+
eventIds: [notice.eventId],
|
|
115
|
+
at: new Date((o.now ?? Date.now)()).toISOString(),
|
|
116
|
+
status: "submitting",
|
|
117
|
+
msgId: null,
|
|
118
|
+
reason: null,
|
|
119
|
+
};
|
|
120
|
+
writeDeliveryAttempt(d.dir, attempt);
|
|
121
|
+
let sent: QueueSendResult;
|
|
122
|
+
try {
|
|
123
|
+
sent = await (o.sendCodex ?? ((t, c) => sendToCodexQueue(t, c, process.env)))(
|
|
124
|
+
target,
|
|
125
|
+
envelope(formatNotices([notice])),
|
|
126
|
+
);
|
|
127
|
+
} catch {
|
|
128
|
+
sent = { outcome: "ambiguous", reason: "Sender ended without a verified receipt; use peek/result." };
|
|
129
|
+
}
|
|
130
|
+
const receipt: DeliveryAttempt = {
|
|
131
|
+
...attempt,
|
|
132
|
+
status:
|
|
133
|
+
sent.outcome === "accepted"
|
|
134
|
+
? "accepted"
|
|
135
|
+
: sent.outcome === "not-submitted"
|
|
136
|
+
? "failed"
|
|
137
|
+
: "ambiguous",
|
|
138
|
+
msgId: sent.outcome === "accepted" ? sent.msgId : null,
|
|
139
|
+
reason: sent.outcome === "accepted" ? null : sent.reason,
|
|
140
|
+
};
|
|
141
|
+
writeDeliveryAttempt(d.dir, receipt);
|
|
142
|
+
log(receipt.status === "accepted" ? "info" : "warn", "notify", {
|
|
143
|
+
from: "supervisor",
|
|
144
|
+
outcome: receipt.status,
|
|
145
|
+
msgId: receipt.msgId,
|
|
146
|
+
reason: receipt.reason,
|
|
147
|
+
dispatches: [notice.dispatchId],
|
|
148
|
+
});
|
|
149
|
+
return receipt.status;
|
|
150
|
+
} finally {
|
|
151
|
+
release();
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
log("warn", "notify", { from: "supervisor", dispatch: dispatchDir, error: errorMessage(e) });
|
|
155
|
+
return "not-a-run";
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/services/finalize.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { isCatherdError } from "../domain/errors.ts";
|
|
|
8
8
|
import { parseRung } from "../domain/ids.ts";
|
|
9
9
|
import {
|
|
10
10
|
type ExitInfo,
|
|
11
|
+
parseReplyEnvironment,
|
|
11
12
|
parseReplyStatus,
|
|
12
13
|
type RunRecord,
|
|
13
14
|
THREAD_HEAVY_INPUT,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
writeTextAtomic,
|
|
26
27
|
} from "../infra/store.ts";
|
|
27
28
|
import { type Dispatch, dispatchState, listDispatches, readProc } from "./dispatches.ts";
|
|
29
|
+
import { currentOwns } from "./lane-edit.ts";
|
|
28
30
|
import { tail } from "./run-debug.ts";
|
|
29
31
|
import { appendRecord, readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
|
|
30
32
|
|
|
@@ -75,7 +77,7 @@ function othersOwns(run: Run, self: Dispatch, start: number, end: number): strin
|
|
|
75
77
|
const to = ended.get(d.admit.dispatchId) ?? (Date.parse(readExit(d.dir)?.endedAt ?? "") || Date.now());
|
|
76
78
|
return from <= end && to >= start;
|
|
77
79
|
})
|
|
78
|
-
.flatMap((d) => d.admit.owns);
|
|
80
|
+
.flatMap((d) => [...d.admit.owns, ...(d.admit.ownsImplicit ?? [])]);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
/** The tree after the run, or null when git cannot say: the record is still written, its changes unknown. */
|
|
@@ -186,13 +188,20 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
186
188
|
const { changedOwned, violations } = after
|
|
187
189
|
? splitChanges(
|
|
188
190
|
changedPaths(a.before, after),
|
|
189
|
-
|
|
191
|
+
// spec 1.5 owns_add: paths the lane gained while it ran are its own, not violations; a laneless
|
|
192
|
+
// writer is held to its implicit docs lane (plan 21)
|
|
193
|
+
a.lane !== null
|
|
194
|
+
? [...new Set([...a.owns, ...currentOwns(run, a.lane)])]
|
|
195
|
+
: a.owns.length
|
|
196
|
+
? a.owns
|
|
197
|
+
: (a.ownsImplicit ?? []),
|
|
190
198
|
// from admission, when the before-snapshot was taken, not from the worker's start
|
|
191
199
|
othersOwns(run, d, Date.parse(a.admittedAt), end),
|
|
192
200
|
a.lane !== null || a.access === "read-only",
|
|
193
201
|
)
|
|
194
202
|
: { changedOwned: [], violations: [] };
|
|
195
203
|
const reported = parseReplyStatus(replyText);
|
|
204
|
+
const environment = parseReplyEnvironment(replyText);
|
|
196
205
|
return {
|
|
197
206
|
schema: 1,
|
|
198
207
|
runId: run.id,
|
|
@@ -219,6 +228,7 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
219
228
|
...(after ? {} : { gitUnavailable: true }),
|
|
220
229
|
replyStatus: reported.status,
|
|
221
230
|
replyWhy: reported.why,
|
|
231
|
+
...(environment ? { environment } : {}),
|
|
222
232
|
threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
|
|
223
233
|
access: a.access,
|
|
224
234
|
isolated: a.isolated,
|