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/infra/paths.ts
CHANGED
|
@@ -34,6 +34,41 @@ export function repoKey(toplevel: string): string {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export const repoDir = (toplevel: string): string => join(dataDir(), "repos", repoKey(toplevel));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One repository however it is cloned or checked out (spec 1.5 "Knowledge keyed by git origin"):
|
|
40
|
+
* `git@github.com:a/b.git`, `https://user@github.com/a/b` and `ssh://git@github.com/a/b.git/` are all
|
|
41
|
+
* `github.com/a/b`. The host is lower-cased; the path keeps its case.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeOrigin(url: string): string {
|
|
44
|
+
let s = url
|
|
45
|
+
.trim()
|
|
46
|
+
.replace(/\/+$/, "")
|
|
47
|
+
.replace(/\.git$/, "");
|
|
48
|
+
const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(s)?.[1]?.toLowerCase() ?? null;
|
|
49
|
+
const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/.exec(s);
|
|
50
|
+
if (scp && !scheme) s = `${scp[1]}/${scp[2]}`;
|
|
51
|
+
else s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^[^@/]+@/, "");
|
|
52
|
+
const [host = "", ...path] = s.split("/");
|
|
53
|
+
// only the scheme's own default port goes: two servers on one host on other ports are two repos (PR #50)
|
|
54
|
+
const port = /:(\d+)$/.exec(host)?.[1];
|
|
55
|
+
const bare = port !== undefined && scheme !== null && DEFAULT_PORTS[scheme] === port;
|
|
56
|
+
return [(bare ? host.slice(0, -port.length - 1) : host).toLowerCase(), ...path].join("/");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Each git URL scheme's default port, which a URL may name or leave out for the same server. */
|
|
60
|
+
const DEFAULT_PORTS: Record<string, string> = {
|
|
61
|
+
ssh: "22",
|
|
62
|
+
"git+ssh": "22",
|
|
63
|
+
"ssh+git": "22",
|
|
64
|
+
https: "443",
|
|
65
|
+
http: "80",
|
|
66
|
+
git: "9418",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Where what runs of one repository learned lives, keyed by its origin, whatever worktree it runs in. */
|
|
70
|
+
export const originDir = (url: string): string =>
|
|
71
|
+
join(dataDir(), "repos", `origin-${repoKey(normalizeOrigin(url))}`);
|
|
37
72
|
export const runsDir = (toplevel: string): string => join(repoDir(toplevel), "runs");
|
|
38
73
|
export const logsDir = (): string => join(dataDir(), "logs");
|
|
39
74
|
export const discoveryDir = (): string => join(dataDir(), "discovery");
|
package/src/infra/peer-inbox.ts
CHANGED
|
@@ -51,10 +51,12 @@ export function sendToInbox(
|
|
|
51
51
|
const socketPath = target.socketPath;
|
|
52
52
|
return new Promise<SendResult>((resolve) => {
|
|
53
53
|
let settled = false;
|
|
54
|
+
// once the frame is being written a failure may still have delivered it: such a result carries its msg_id
|
|
55
|
+
let writing = false;
|
|
54
56
|
const done = (r: SendResult) => {
|
|
55
57
|
if (settled) return;
|
|
56
58
|
settled = true;
|
|
57
|
-
resolve(r);
|
|
59
|
+
resolve(writing && r.outcome === "error" ? { ...r, msgId } : r);
|
|
58
60
|
};
|
|
59
61
|
let s: ReturnType<typeof createConnection>;
|
|
60
62
|
try {
|
|
@@ -77,6 +79,7 @@ export function sendToInbox(
|
|
|
77
79
|
});
|
|
78
80
|
s.on("connect", () => {
|
|
79
81
|
clearTimeout(timer);
|
|
82
|
+
writing = true;
|
|
80
83
|
s.write(frameText(content, priority, target.token, msgId), (err) => {
|
|
81
84
|
if (err) {
|
|
82
85
|
s.destroy();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { type RoleMcpContext, ROLE_MCP_SERVER, roleMcpTools } from "../domain/role-tools.ts";
|
|
3
|
+
import { scrubSecrets } from "./env.ts";
|
|
4
|
+
import { configDir, dataDir } from "./paths.ts";
|
|
5
|
+
|
|
6
|
+
export const ROLE_MCP_ENTRY = fileURLToPath(new URL("../entry/mcp/role-bin.ts", import.meta.url));
|
|
7
|
+
|
|
8
|
+
/** How long Codex waits for the role server to start (spec 1.5 plan 21, #42 finding 8). */
|
|
9
|
+
export const ROLE_MCP_STARTUP_SEC = 30;
|
|
10
|
+
|
|
11
|
+
export type RoleServerStart = { ok: true; ms: number } | { ok: false; error: string };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Doctor's probe: a cold start of the role server's entry, which loads the server's whole module graph and
|
|
15
|
+
* exits (`--probe`), timed against the startup timeout. Never a model turn.
|
|
16
|
+
*/
|
|
17
|
+
export async function probeRoleServer(timeoutMs = ROLE_MCP_STARTUP_SEC * 1000): Promise<RoleServerStart> {
|
|
18
|
+
const started = performance.now();
|
|
19
|
+
const p = Bun.spawn([process.execPath, ROLE_MCP_ENTRY, "--probe"], {
|
|
20
|
+
stdin: "ignore",
|
|
21
|
+
stdout: "pipe",
|
|
22
|
+
stderr: "pipe",
|
|
23
|
+
env: { ...scrubSecrets(process.env), CATHERD_NO_SYNC: "1" },
|
|
24
|
+
});
|
|
25
|
+
const timer = setTimeout(() => p.kill("SIGKILL"), timeoutMs);
|
|
26
|
+
try {
|
|
27
|
+
const code = await p.exited;
|
|
28
|
+
const ms = performance.now() - started;
|
|
29
|
+
const out = await new Response(p.stdout).text();
|
|
30
|
+
if (code === 0 && out.trim() === "ready") return { ok: true, ms };
|
|
31
|
+
const err = (await new Response(p.stderr).text()).trim().split("\n").at(-1);
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: p.signalCode === "SIGKILL" ? `no answer in ${timeoutMs} ms` : err || `exit ${code}`,
|
|
35
|
+
};
|
|
36
|
+
} finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Launch the same installed package as the dispatching process, without resolving another release. */
|
|
42
|
+
export function roleMcpConfig(context: RoleMcpContext): {
|
|
43
|
+
command: string;
|
|
44
|
+
args: string[];
|
|
45
|
+
env: Record<string, string>;
|
|
46
|
+
} {
|
|
47
|
+
return {
|
|
48
|
+
command: process.execPath,
|
|
49
|
+
args: [ROLE_MCP_ENTRY, context.role, context.run],
|
|
50
|
+
env: { CATHERD_CONFIG_DIR: configDir(), CATHERD_DATA_DIR: dataDir() },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function codexRoleMcpArgs(context: RoleMcpContext): string[] {
|
|
55
|
+
const config = roleMcpConfig(context);
|
|
56
|
+
const prefix = `mcp_servers.${ROLE_MCP_SERVER}`;
|
|
57
|
+
const settings = [
|
|
58
|
+
`${prefix}.command=${JSON.stringify(config.command)}`,
|
|
59
|
+
`${prefix}.args=${JSON.stringify(config.args)}`,
|
|
60
|
+
...Object.entries(config.env).map(([key, value]) => `${prefix}.env.${key}=${JSON.stringify(value)}`),
|
|
61
|
+
`${prefix}.enabled=true`,
|
|
62
|
+
// required: a role without its run tools cannot work; doctor's probe shows it starts well inside the timeout
|
|
63
|
+
`${prefix}.required=true`,
|
|
64
|
+
`${prefix}.startup_timeout_sec=${ROLE_MCP_STARTUP_SEC}`,
|
|
65
|
+
`${prefix}.enabled_tools=${JSON.stringify(roleMcpTools(context.role))}`,
|
|
66
|
+
...roleMcpTools(context.role).map((tool) => `${prefix}.tools.${tool}.approval_mode="approve"`),
|
|
67
|
+
];
|
|
68
|
+
return settings.flatMap((setting) => ["-c", setting]);
|
|
69
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { effortWord } from "../../domain/sources.ts";
|
|
2
|
-
import { type SourceTransport, sourceGet } from "./http.ts";
|
|
2
|
+
import { SourceError, type SourceTransport, sourceGet } from "./http.ts";
|
|
3
3
|
import { isoDay, num, type SourceRow } from "./rows.ts";
|
|
4
4
|
|
|
5
5
|
/** Spec 1.2 §3.1: the Agent Arena boards and WebDev (CC-BY-4.0). */
|
|
@@ -18,10 +18,18 @@ export const ARENA_PAGE = "https://huggingface.co/datasets/lmarena-ai/leaderboar
|
|
|
18
18
|
export const arenaUrl = (config: ArenaConfig): string =>
|
|
19
19
|
`https://datasets-server.huggingface.co/rows?dataset=lmarena-ai/leaderboard-dataset&config=${config}&split=latest&length=100`;
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Every config, in parallel; one failure fails the source, which keeps its last answer. A config that answers
|
|
23
|
+
* no rows is a failure too (1.2 minor): an answer of the wrong shape never replaces a good one.
|
|
24
|
+
*/
|
|
22
25
|
export async function fetchArena(t: SourceTransport = {}): Promise<Record<ArenaConfig, unknown>> {
|
|
23
26
|
const got = await Promise.all(
|
|
24
|
-
ARENA_CONFIGS.map(async (c) =>
|
|
27
|
+
ARENA_CONFIGS.map(async (c) => {
|
|
28
|
+
const body = (await sourceGet(arenaUrl(c), t)).json() as { rows?: unknown } | null;
|
|
29
|
+
if (!Array.isArray(body?.rows) || body.rows.length === 0)
|
|
30
|
+
throw new SourceError(`Arena ${c} answered no rows`);
|
|
31
|
+
return [c, body];
|
|
32
|
+
}),
|
|
25
33
|
);
|
|
26
34
|
return Object.fromEntries(got) as Record<ArenaConfig, unknown>;
|
|
27
35
|
}
|
|
@@ -26,7 +26,11 @@ export async function fetchArtificialAnalysis(key: string, t: SourceTransport =
|
|
|
26
26
|
const o = { ...t, headers: { "x-api-key": key } };
|
|
27
27
|
try {
|
|
28
28
|
const r = await sourceGet(AA_MODELS_URL, o);
|
|
29
|
-
|
|
29
|
+
const body = r.json() as { data?: unknown } | null;
|
|
30
|
+
// (1.2 minor) an answer of the wrong shape is a failure: the last good one stays
|
|
31
|
+
if (!Array.isArray(body?.data) || body.data.length === 0)
|
|
32
|
+
throw new SourceError("Artificial Analysis answered no models", r.status, r.headers);
|
|
33
|
+
return { path: "models", pages: [body], rateLimitRemaining: rateLimitRemaining(r.headers) };
|
|
30
34
|
} catch (e) {
|
|
31
35
|
if (!(e instanceof SourceError) || (e.status !== 403 && e.status !== 404)) throw e;
|
|
32
36
|
}
|
|
@@ -41,6 +45,7 @@ export async function fetchArtificialAnalysis(key: string, t: SourceTransport =
|
|
|
41
45
|
const total = num(body.pagination?.total_pages);
|
|
42
46
|
if (total !== null && page >= total) break;
|
|
43
47
|
}
|
|
48
|
+
if (pages.length === 0) throw new SourceError("Artificial Analysis answered an empty first page");
|
|
44
49
|
return { path: "free", pages, rateLimitRemaining: remaining };
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -53,10 +58,12 @@ export async function testAaKey(
|
|
|
53
58
|
t: SourceTransport = {},
|
|
54
59
|
): Promise<{ result: "ok" | "refused" | "unchecked"; error?: string; rateLimitRemaining: number | null }> {
|
|
55
60
|
try {
|
|
61
|
+
// spec 1.2 §9: one request, never retried (a 429 or 5xx leaves the key unchecked)
|
|
56
62
|
const r = await sourceGet(aaFreeUrl(1), {
|
|
57
63
|
attemptMs: 10_000,
|
|
58
64
|
deadlineMs: 20_000,
|
|
59
65
|
...t,
|
|
66
|
+
retries: 0,
|
|
60
67
|
headers: { "x-api-key": key },
|
|
61
68
|
});
|
|
62
69
|
return { result: "ok", rateLimitRemaining: rateLimitRemaining(r.headers) };
|
|
@@ -59,6 +59,8 @@ const StateSchema = z.looseObject({
|
|
|
59
59
|
error: z.string().nullable(),
|
|
60
60
|
/** Artificial Analysis: `x-ratelimit-remaining` of its last answer */
|
|
61
61
|
rateLimitRemaining: z.number().nullable().default(null),
|
|
62
|
+
/** when that count was read (1.2 minor); absent in a state written before 1.5 */
|
|
63
|
+
rateLimitAt: z.iso.datetime().nullable().optional(),
|
|
62
64
|
}),
|
|
63
65
|
)
|
|
64
66
|
.default({}),
|
package/src/infra/supervisor.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { scrubSecrets } from "./env.ts";
|
|
1
2
|
import type { Subprocess } from "bun";
|
|
2
3
|
import { appendFileSync, closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
4
|
+
import { basename } from "node:path";
|
|
3
5
|
import { z } from "zod";
|
|
4
6
|
import { errorMessage } from "../domain/errors.ts";
|
|
5
7
|
import type { ExitInfo, ExitReason } from "../domain/record.ts";
|
|
6
8
|
import { dispatchPaths, supervisorLockTarget } from "./dispatch-dir.ts";
|
|
7
9
|
import { tryLock } from "./filelock.ts";
|
|
10
|
+
import { lastLockOutput } from "./lock-activity.ts";
|
|
8
11
|
import { log } from "./log.ts";
|
|
9
12
|
import { killGroup, processStartTime } from "./proc.ts";
|
|
10
13
|
import { PRIVATE_FILE, writeJsonAtomic } from "./store.ts";
|
|
@@ -16,6 +19,8 @@ export const SuperviseSpecSchema = z.looseObject({
|
|
|
16
19
|
cmd: z.string(),
|
|
17
20
|
args: z.array(z.string()),
|
|
18
21
|
env: z.record(z.string(), z.string()),
|
|
22
|
+
/** plan 23: env vars set from another one at spawn (a gate env secret by reference): name → source name */
|
|
23
|
+
envFrom: z.record(z.string(), z.string()).optional(),
|
|
19
24
|
cwd: z.string(),
|
|
20
25
|
stdinPath: z.string().nullable(),
|
|
21
26
|
idleMs: z.number().positive(),
|
|
@@ -23,15 +28,28 @@ export const SuperviseSpecSchema = z.looseObject({
|
|
|
23
28
|
killGraceMs: z.number().nonnegative(),
|
|
24
29
|
graceAfterFinalMs: z.number().nonnegative().nullable(),
|
|
25
30
|
pollMs: z.number().positive(),
|
|
31
|
+
/** plan 23: when provider retries end an attempt; PROVIDER_RETRY when absent (tests shorten it) */
|
|
32
|
+
providerRetry: z
|
|
33
|
+
.object({ attempts: z.number().positive(), ms: z.number().positive(), pollMs: z.number().positive() })
|
|
34
|
+
.partial()
|
|
35
|
+
.optional(),
|
|
26
36
|
});
|
|
27
37
|
export type SuperviseSpec = z.infer<typeof SuperviseSpecSchema>;
|
|
28
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Plan 23: an attempt that has only retried its provider (3 retries, or 3 min of nothing else) ends as
|
|
41
|
+
* provider-unavailable; a quiet session's own retry field is read every pollMs.
|
|
42
|
+
*/
|
|
43
|
+
export const PROVIDER_RETRY = { attempts: 3, ms: 180_000, pollMs: 30_000 };
|
|
44
|
+
|
|
29
45
|
/** What one stream line tells the supervisor: the terminal event, the thread, a tool call opening or closing. */
|
|
30
46
|
interface LineInfo {
|
|
31
47
|
final?: boolean;
|
|
32
48
|
thread?: string;
|
|
33
49
|
/** a tool call the CLI started (`open`) or finished: while one is open the run is busy, however quiet */
|
|
34
50
|
item?: { id: string; open: boolean };
|
|
51
|
+
/** plan 23: a model step started; one right after another, with nothing between, is a provider retry */
|
|
52
|
+
step?: boolean;
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
export interface SuperviseHooks {
|
|
@@ -39,8 +57,23 @@ export interface SuperviseHooks {
|
|
|
39
57
|
/** `sinceMs`: when this run started, so a backend can ignore what an earlier run on the thread left */
|
|
40
58
|
isBusy?(thread: string | null, sinceMs: number): Promise<boolean>;
|
|
41
59
|
interrupt?(thread: string | null): Promise<void>;
|
|
60
|
+
/** plan 23: the provider retry attempt the session waits out (its own retry field), or null */
|
|
61
|
+
providerRetry?(thread: string | null, sinceMs: number): Promise<number | null>;
|
|
62
|
+
/**
|
|
63
|
+
* The poll loop's time: what it reads as now and how it waits a poll. The real clock when absent; a test
|
|
64
|
+
* passes its own so the wall, idle and retry limits are decided by the time it drives, never by how fast a
|
|
65
|
+
* loaded runner happens to schedule timers.
|
|
66
|
+
*/
|
|
67
|
+
clock?: SuperviseClock;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SuperviseClock {
|
|
71
|
+
now(): number;
|
|
72
|
+
sleep(ms: number): Promise<void>;
|
|
42
73
|
}
|
|
43
74
|
|
|
75
|
+
const REAL_CLOCK: SuperviseClock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) };
|
|
76
|
+
|
|
44
77
|
/** Reads whatever the child appended to `file` since `offset`, as complete lines. */
|
|
45
78
|
function readNew(file: string, state: { offset: number; rest: string; decoder: TextDecoder }): string[] {
|
|
46
79
|
if (!existsSync(file)) return [];
|
|
@@ -78,7 +111,7 @@ async function bounded<T>(call: () => Promise<T> | undefined, ms: number, fallba
|
|
|
78
111
|
}
|
|
79
112
|
}
|
|
80
113
|
|
|
81
|
-
function groupAlive(pgid: number): boolean {
|
|
114
|
+
export function groupAlive(pgid: number): boolean {
|
|
82
115
|
try {
|
|
83
116
|
process.kill(-pgid, 0);
|
|
84
117
|
return true;
|
|
@@ -88,7 +121,7 @@ function groupAlive(pgid: number): boolean {
|
|
|
88
121
|
}
|
|
89
122
|
|
|
90
123
|
/** SIGTERM the group, give it `graceMs` to leave, then SIGKILL whatever is left of it. */
|
|
91
|
-
async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
|
|
124
|
+
export async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
|
|
92
125
|
killGroup(pgid, "SIGTERM");
|
|
93
126
|
const end = Date.now() + graceMs;
|
|
94
127
|
while (Date.now() < end && groupAlive(pgid))
|
|
@@ -139,7 +172,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
139
172
|
fds.push(stderr);
|
|
140
173
|
child = Bun.spawn([spec.cmd, ...spec.args], {
|
|
141
174
|
cwd: spec.cwd,
|
|
142
|
-
env: spec.env,
|
|
175
|
+
env: scrubSecrets(spec.env),
|
|
143
176
|
stdin,
|
|
144
177
|
stdout,
|
|
145
178
|
stderr,
|
|
@@ -149,7 +182,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
149
182
|
backend: spec.backend,
|
|
150
183
|
dispatch: spec.dispatchDir,
|
|
151
184
|
argv: [spec.cmd, ...spec.args],
|
|
152
|
-
env: spec.env,
|
|
185
|
+
env: scrubSecrets(spec.env),
|
|
153
186
|
cwd: spec.cwd,
|
|
154
187
|
pid: child.pid,
|
|
155
188
|
});
|
|
@@ -167,6 +200,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
167
200
|
}
|
|
168
201
|
|
|
169
202
|
const hookMs = Math.min(10_000, spec.idleMs);
|
|
203
|
+
const clock = hooks.clock ?? REAL_CLOCK;
|
|
170
204
|
let reason: ExitReason | null = null;
|
|
171
205
|
let thread: string | null = null;
|
|
172
206
|
let failure: { error: unknown } | null = null;
|
|
@@ -186,8 +220,12 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
186
220
|
startedAt: new Date().toISOString(),
|
|
187
221
|
});
|
|
188
222
|
|
|
189
|
-
const started =
|
|
223
|
+
const started = clock.now();
|
|
224
|
+
const dispatchId = basename(spec.dispatchDir);
|
|
190
225
|
let lastActivity = started;
|
|
226
|
+
// plan 23: the last output seen from a `catherd lock` of the role, kept after the lock exits (its file goes),
|
|
227
|
+
// so the wall counts from that output, never from the role's start again
|
|
228
|
+
let lockAt = started;
|
|
191
229
|
let finalAt: number | null = null;
|
|
192
230
|
// spec §3.6: a quiet stretch of half the idle timeout, not busy, is a stall, reported once per dispatch
|
|
193
231
|
let stalled = false;
|
|
@@ -195,34 +233,88 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
195
233
|
const open = new Set<string>();
|
|
196
234
|
const stream = { offset: 0, rest: "", decoder: new TextDecoder("utf-8") };
|
|
197
235
|
|
|
236
|
+
// plan 23: a step that starts right after another with nothing between is a provider retry; retries are
|
|
237
|
+
// not progress, and enough of them (or a long enough stretch) end the attempt as provider-unavailable
|
|
238
|
+
let lastWasStep = false;
|
|
239
|
+
let retries = 0;
|
|
240
|
+
let retrySince: number | null = null;
|
|
241
|
+
let lastRetryPoll = started;
|
|
242
|
+
const retry = { ...PROVIDER_RETRY, ...spec.providerRetry };
|
|
243
|
+
|
|
198
244
|
while (!done && reason === null) {
|
|
199
|
-
await
|
|
245
|
+
await clock.sleep(spec.pollMs);
|
|
200
246
|
const lines = readNew(p.events, stream);
|
|
201
|
-
|
|
202
|
-
lastActivity = Date.now();
|
|
203
|
-
stallChecked = false;
|
|
204
|
-
}
|
|
247
|
+
let progress = false;
|
|
205
248
|
for (const line of lines) {
|
|
206
249
|
let d: LineInfo | undefined;
|
|
207
250
|
try {
|
|
208
251
|
d = hooks.onLine?.(line);
|
|
209
252
|
} catch {
|
|
210
|
-
|
|
253
|
+
// one line the hook cannot read must not end supervision; it is output all the same
|
|
254
|
+
progress = true;
|
|
255
|
+
lastWasStep = false;
|
|
256
|
+
continue;
|
|
211
257
|
}
|
|
212
|
-
if (d?.
|
|
213
|
-
|
|
258
|
+
if (d?.step && lastWasStep) {
|
|
259
|
+
retries++;
|
|
260
|
+
retrySince ??= clock.now();
|
|
261
|
+
} else {
|
|
262
|
+
progress = true;
|
|
263
|
+
if (!d?.step) {
|
|
264
|
+
retries = 0;
|
|
265
|
+
retrySince = null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
lastWasStep = d?.step === true;
|
|
269
|
+
if (d?.thread && d.thread !== thread) {
|
|
270
|
+
thread = d.thread;
|
|
271
|
+
// spec 1.5 plan 21: a live role's thread is on disk, so no delivery ever targets it
|
|
272
|
+
try {
|
|
273
|
+
writeJsonAtomic(p.thread, { schema: 1, thread, at: new Date().toISOString() });
|
|
274
|
+
} catch (e) {
|
|
275
|
+
log("warn", "supervise", { dispatch: spec.dispatchDir, error: errorMessage(e) });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (d?.final) finalAt ??= clock.now();
|
|
214
279
|
if (d?.item) {
|
|
215
280
|
if (d.item.open) open.add(d.item.id);
|
|
216
281
|
else open.delete(d.item.id);
|
|
217
282
|
}
|
|
218
283
|
}
|
|
284
|
+
if (progress) {
|
|
285
|
+
lastActivity = clock.now();
|
|
286
|
+
stallChecked = false;
|
|
287
|
+
}
|
|
288
|
+
// plan 23: a session quiet for a while may be waiting out a provider retry the stream does not show
|
|
289
|
+
if (
|
|
290
|
+
hooks.providerRetry &&
|
|
291
|
+
clock.now() - lastActivity >= retry.pollMs &&
|
|
292
|
+
clock.now() - lastRetryPoll >= retry.pollMs
|
|
293
|
+
) {
|
|
294
|
+
lastRetryPoll = clock.now();
|
|
295
|
+
const attempt = await bounded(() => hooks.providerRetry?.(thread, started), hookMs, null);
|
|
296
|
+
if (attempt !== null) {
|
|
297
|
+
retrySince ??= clock.now();
|
|
298
|
+
retries = Math.max(retries, attempt);
|
|
299
|
+
} else {
|
|
300
|
+
// the session says it is not retrying: the steps seen were a retry that worked, and the quiet since
|
|
301
|
+
// is work the stream does not show yet (a long tool call), never a provider outage
|
|
302
|
+
retries = 0;
|
|
303
|
+
retrySince = null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
219
306
|
// a worker that ended on its own is recorded as it ended, even if a cancel or a limit arrived meanwhile
|
|
220
307
|
if (done) break;
|
|
221
|
-
const now =
|
|
308
|
+
const now = clock.now();
|
|
309
|
+
lockAt = Math.max(lockAt, lastLockOutput(dispatchId) ?? lockAt);
|
|
222
310
|
if (existsSync(p.cancel)) reason = "cancelled";
|
|
223
|
-
else if (now -
|
|
311
|
+
else if (now - lockAt >= spec.wallMs)
|
|
312
|
+
// plan 23: once a `catherd lock` of the role has written, the wall counts from its last output
|
|
313
|
+
reason = "wall-timeout";
|
|
224
314
|
else if (finalAt !== null && spec.graceAfterFinalMs !== null && now - finalAt >= spec.graceAfterFinalMs)
|
|
225
315
|
reason = "after-final";
|
|
316
|
+
else if (retries >= retry.attempts || (retrySince !== null && now - retrySince >= retry.ms))
|
|
317
|
+
reason = "provider-unavailable";
|
|
226
318
|
else if (now - lastActivity >= spec.idleMs) {
|
|
227
319
|
const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
|
|
228
320
|
// the busy check can take up to hookMs: a worker that ended meanwhile is recorded as it ended, read
|
|
@@ -230,7 +322,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
230
322
|
if (done || child.exitCode !== null || child.signalCode !== null) break;
|
|
231
323
|
if (busy) {
|
|
232
324
|
// a new quiet stretch starts: it gets its own stall check
|
|
233
|
-
lastActivity =
|
|
325
|
+
lastActivity = clock.now();
|
|
234
326
|
stallChecked = false;
|
|
235
327
|
} else reason = "idle-timeout";
|
|
236
328
|
} else if (!stalled && !stallChecked && now - lastActivity >= spec.idleMs / 2) {
|
|
@@ -239,7 +331,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
239
331
|
stallChecked = true;
|
|
240
332
|
const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
|
|
241
333
|
if (busy) {
|
|
242
|
-
lastActivity =
|
|
334
|
+
lastActivity = clock.now();
|
|
243
335
|
stallChecked = false;
|
|
244
336
|
} else if (!done) {
|
|
245
337
|
stalled = true;
|
|
@@ -248,7 +340,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
248
340
|
writeJsonAtomic(p.stall, {
|
|
249
341
|
schema: 1,
|
|
250
342
|
at: new Date().toISOString(),
|
|
251
|
-
quietMs:
|
|
343
|
+
quietMs: clock.now() - lastActivity,
|
|
252
344
|
});
|
|
253
345
|
} catch (e) {
|
|
254
346
|
log("warn", "stall", { dispatch: spec.dispatchDir, error: errorMessage(e) });
|