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
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");
|
|
@@ -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,11 +1,13 @@
|
|
|
1
1
|
import { scrubSecrets } from "./env.ts";
|
|
2
2
|
import type { Subprocess } from "bun";
|
|
3
3
|
import { appendFileSync, closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
4
|
+
import { basename } from "node:path";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { errorMessage } from "../domain/errors.ts";
|
|
6
7
|
import type { ExitInfo, ExitReason } from "../domain/record.ts";
|
|
7
8
|
import { dispatchPaths, supervisorLockTarget } from "./dispatch-dir.ts";
|
|
8
9
|
import { tryLock } from "./filelock.ts";
|
|
10
|
+
import { lastLockOutput } from "./lock-activity.ts";
|
|
9
11
|
import { log } from "./log.ts";
|
|
10
12
|
import { killGroup, processStartTime } from "./proc.ts";
|
|
11
13
|
import { PRIVATE_FILE, writeJsonAtomic } from "./store.ts";
|
|
@@ -17,6 +19,8 @@ export const SuperviseSpecSchema = z.looseObject({
|
|
|
17
19
|
cmd: z.string(),
|
|
18
20
|
args: z.array(z.string()),
|
|
19
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(),
|
|
20
24
|
cwd: z.string(),
|
|
21
25
|
stdinPath: z.string().nullable(),
|
|
22
26
|
idleMs: z.number().positive(),
|
|
@@ -24,15 +28,28 @@ export const SuperviseSpecSchema = z.looseObject({
|
|
|
24
28
|
killGraceMs: z.number().nonnegative(),
|
|
25
29
|
graceAfterFinalMs: z.number().nonnegative().nullable(),
|
|
26
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(),
|
|
27
36
|
});
|
|
28
37
|
export type SuperviseSpec = z.infer<typeof SuperviseSpecSchema>;
|
|
29
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
|
+
|
|
30
45
|
/** What one stream line tells the supervisor: the terminal event, the thread, a tool call opening or closing. */
|
|
31
46
|
interface LineInfo {
|
|
32
47
|
final?: boolean;
|
|
33
48
|
thread?: string;
|
|
34
49
|
/** a tool call the CLI started (`open`) or finished: while one is open the run is busy, however quiet */
|
|
35
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;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
export interface SuperviseHooks {
|
|
@@ -40,8 +57,23 @@ export interface SuperviseHooks {
|
|
|
40
57
|
/** `sinceMs`: when this run started, so a backend can ignore what an earlier run on the thread left */
|
|
41
58
|
isBusy?(thread: string | null, sinceMs: number): Promise<boolean>;
|
|
42
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>;
|
|
43
73
|
}
|
|
44
74
|
|
|
75
|
+
const REAL_CLOCK: SuperviseClock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) };
|
|
76
|
+
|
|
45
77
|
/** Reads whatever the child appended to `file` since `offset`, as complete lines. */
|
|
46
78
|
function readNew(file: string, state: { offset: number; rest: string; decoder: TextDecoder }): string[] {
|
|
47
79
|
if (!existsSync(file)) return [];
|
|
@@ -79,7 +111,7 @@ async function bounded<T>(call: () => Promise<T> | undefined, ms: number, fallba
|
|
|
79
111
|
}
|
|
80
112
|
}
|
|
81
113
|
|
|
82
|
-
function groupAlive(pgid: number): boolean {
|
|
114
|
+
export function groupAlive(pgid: number): boolean {
|
|
83
115
|
try {
|
|
84
116
|
process.kill(-pgid, 0);
|
|
85
117
|
return true;
|
|
@@ -89,7 +121,7 @@ function groupAlive(pgid: number): boolean {
|
|
|
89
121
|
}
|
|
90
122
|
|
|
91
123
|
/** SIGTERM the group, give it `graceMs` to leave, then SIGKILL whatever is left of it. */
|
|
92
|
-
async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
|
|
124
|
+
export async function stopGroup(pgid: number, graceMs: number, pollMs: number): Promise<void> {
|
|
93
125
|
killGroup(pgid, "SIGTERM");
|
|
94
126
|
const end = Date.now() + graceMs;
|
|
95
127
|
while (Date.now() < end && groupAlive(pgid))
|
|
@@ -168,6 +200,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
168
200
|
}
|
|
169
201
|
|
|
170
202
|
const hookMs = Math.min(10_000, spec.idleMs);
|
|
203
|
+
const clock = hooks.clock ?? REAL_CLOCK;
|
|
171
204
|
let reason: ExitReason | null = null;
|
|
172
205
|
let thread: string | null = null;
|
|
173
206
|
let failure: { error: unknown } | null = null;
|
|
@@ -187,8 +220,12 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
187
220
|
startedAt: new Date().toISOString(),
|
|
188
221
|
});
|
|
189
222
|
|
|
190
|
-
const started =
|
|
223
|
+
const started = clock.now();
|
|
224
|
+
const dispatchId = basename(spec.dispatchDir);
|
|
191
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;
|
|
192
229
|
let finalAt: number | null = null;
|
|
193
230
|
// spec §3.6: a quiet stretch of half the idle timeout, not busy, is a stall, reported once per dispatch
|
|
194
231
|
let stalled = false;
|
|
@@ -196,34 +233,88 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
196
233
|
const open = new Set<string>();
|
|
197
234
|
const stream = { offset: 0, rest: "", decoder: new TextDecoder("utf-8") };
|
|
198
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
|
+
|
|
199
244
|
while (!done && reason === null) {
|
|
200
|
-
await
|
|
245
|
+
await clock.sleep(spec.pollMs);
|
|
201
246
|
const lines = readNew(p.events, stream);
|
|
202
|
-
|
|
203
|
-
lastActivity = Date.now();
|
|
204
|
-
stallChecked = false;
|
|
205
|
-
}
|
|
247
|
+
let progress = false;
|
|
206
248
|
for (const line of lines) {
|
|
207
249
|
let d: LineInfo | undefined;
|
|
208
250
|
try {
|
|
209
251
|
d = hooks.onLine?.(line);
|
|
210
252
|
} catch {
|
|
211
|
-
|
|
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;
|
|
212
257
|
}
|
|
213
|
-
if (d?.
|
|
214
|
-
|
|
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();
|
|
215
279
|
if (d?.item) {
|
|
216
280
|
if (d.item.open) open.add(d.item.id);
|
|
217
281
|
else open.delete(d.item.id);
|
|
218
282
|
}
|
|
219
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
|
+
}
|
|
220
306
|
// a worker that ended on its own is recorded as it ended, even if a cancel or a limit arrived meanwhile
|
|
221
307
|
if (done) break;
|
|
222
|
-
const now =
|
|
308
|
+
const now = clock.now();
|
|
309
|
+
lockAt = Math.max(lockAt, lastLockOutput(dispatchId) ?? lockAt);
|
|
223
310
|
if (existsSync(p.cancel)) reason = "cancelled";
|
|
224
|
-
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";
|
|
225
314
|
else if (finalAt !== null && spec.graceAfterFinalMs !== null && now - finalAt >= spec.graceAfterFinalMs)
|
|
226
315
|
reason = "after-final";
|
|
316
|
+
else if (retries >= retry.attempts || (retrySince !== null && now - retrySince >= retry.ms))
|
|
317
|
+
reason = "provider-unavailable";
|
|
227
318
|
else if (now - lastActivity >= spec.idleMs) {
|
|
228
319
|
const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
|
|
229
320
|
// the busy check can take up to hookMs: a worker that ended meanwhile is recorded as it ended, read
|
|
@@ -231,7 +322,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
231
322
|
if (done || child.exitCode !== null || child.signalCode !== null) break;
|
|
232
323
|
if (busy) {
|
|
233
324
|
// a new quiet stretch starts: it gets its own stall check
|
|
234
|
-
lastActivity =
|
|
325
|
+
lastActivity = clock.now();
|
|
235
326
|
stallChecked = false;
|
|
236
327
|
} else reason = "idle-timeout";
|
|
237
328
|
} else if (!stalled && !stallChecked && now - lastActivity >= spec.idleMs / 2) {
|
|
@@ -240,7 +331,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
240
331
|
stallChecked = true;
|
|
241
332
|
const busy = open.size > 0 || (await bounded(() => hooks.isBusy?.(thread, started), hookMs, false));
|
|
242
333
|
if (busy) {
|
|
243
|
-
lastActivity =
|
|
334
|
+
lastActivity = clock.now();
|
|
244
335
|
stallChecked = false;
|
|
245
336
|
} else if (!done) {
|
|
246
337
|
stalled = true;
|
|
@@ -249,7 +340,7 @@ async function superviseHeld(spec: SuperviseSpec, hooks: SuperviseHooks): Promis
|
|
|
249
340
|
writeJsonAtomic(p.stall, {
|
|
250
341
|
schema: 1,
|
|
251
342
|
at: new Date().toISOString(),
|
|
252
|
-
quietMs:
|
|
343
|
+
quietMs: clock.now() - lastActivity,
|
|
253
344
|
});
|
|
254
345
|
} catch (e) {
|
|
255
346
|
log("warn", "stall", { dispatch: spec.dispatchDir, error: errorMessage(e) });
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import type { KnownHost } from "../domain/host.ts";
|
|
2
|
-
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { BackendAdapter } from "../adapters/backend.ts";
|
|
5
5
|
import { budgetStatus, formatBudget } from "../domain/budget.ts";
|
|
6
6
|
import { CatherdError, errorMessage } from "../domain/errors.ts";
|
|
7
7
|
import { assertId, formatRung, newDispatchId, parseRung } from "../domain/ids.ts";
|
|
8
8
|
import { assertLaneHeader, overlaps } from "../domain/lane.ts";
|
|
9
|
+
import { roleTimeouts } from "../domain/profile.ts";
|
|
9
10
|
import type { RunRecord } from "../domain/record.ts";
|
|
10
|
-
import {
|
|
11
|
+
import { composeBrief } from "../domain/brief.ts";
|
|
12
|
+
import { briefOwns, DOCS_OWNS } from "../domain/changes.ts";
|
|
13
|
+
import { grantsScratch, ROLE_SERVER_BACKENDS } from "../domain/role-tools.ts";
|
|
11
14
|
import type { Role } from "../domain/roles.ts";
|
|
15
|
+
import { formatRoleScope, ROLE_ENV } from "../domain/role-scope.ts";
|
|
12
16
|
import { dispatchPaths, markForCollect } from "../infra/dispatch-dir.ts";
|
|
13
17
|
import { withFileLock } from "../infra/filelock.ts";
|
|
14
18
|
import { statusSnapshot } from "../infra/git.ts";
|
|
15
19
|
import { launchSupervisor } from "../infra/launch.ts";
|
|
20
|
+
import { DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
|
|
16
21
|
import { log } from "../infra/log.ts";
|
|
17
22
|
import { processStartTime } from "../infra/proc.ts";
|
|
18
23
|
import { ensurePrivateDir, PRIVATE_FILE, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
@@ -27,12 +32,20 @@ import {
|
|
|
27
32
|
listDispatches,
|
|
28
33
|
pendingDispatches,
|
|
29
34
|
roleDir,
|
|
35
|
+
scratchDir,
|
|
30
36
|
setLatest,
|
|
31
37
|
} from "./dispatches.ts";
|
|
32
38
|
import { finalizeDispatch } from "./finalize.ts";
|
|
39
|
+
import { gateEnvParts, readGateEnv } from "./gate-env.ts";
|
|
40
|
+
import { assertNotPaused } from "./pause.ts";
|
|
41
|
+
import { currentOwns } from "./lane-edit.ts";
|
|
42
|
+
import { unfinishedAfter } from "./protocol.ts";
|
|
43
|
+
import { runProfile } from "./run-pin.ts";
|
|
33
44
|
import type { Deps } from "./ports.ts";
|
|
34
|
-
import { readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
|
|
45
|
+
import { readRecords, recordsOnThread, type Run, runPaths, supersededBy } from "./run-store.ts";
|
|
35
46
|
import { currentSession } from "./sessions.ts";
|
|
47
|
+
import { verifierBrief } from "./verifier-brief.ts";
|
|
48
|
+
import { withRunAdmission } from "./workspace-admission.ts";
|
|
36
49
|
|
|
37
50
|
export interface AdmitInput {
|
|
38
51
|
role: Role;
|
|
@@ -54,7 +67,15 @@ export const KILL_GRACE_MS = 10_000;
|
|
|
54
67
|
|
|
55
68
|
export const laneFile = (run: Run, lane: string): string => join(runPaths(run.dir).lanes, `${lane}.md`);
|
|
56
69
|
|
|
57
|
-
|
|
70
|
+
/** The role's scratch folder, created (0700) and by its real path, on a backend that grants it; else null. */
|
|
71
|
+
function roleScratch(run: Run, name: string, backend: string, isolated: boolean): string | null {
|
|
72
|
+
if (!grantsScratch(backend, isolated)) return null;
|
|
73
|
+
const dir = scratchDir(run, name);
|
|
74
|
+
ensurePrivateDir(dir);
|
|
75
|
+
return realpathSync(dir);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function laneOwns(run: Run, lane: string): string[] {
|
|
58
79
|
const file = laneFile(run, lane);
|
|
59
80
|
if (!existsSync(file))
|
|
60
81
|
throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
|
|
@@ -124,6 +145,15 @@ async function prepared(adapter: BackendAdapter, req: Parameters<NonNullable<Bac
|
|
|
124
145
|
}
|
|
125
146
|
}
|
|
126
147
|
|
|
148
|
+
/** Refuses a dispatch into a run `runs supersede` closed, naming the run that took over. */
|
|
149
|
+
function assertNotSuperseded(run: Run): void {
|
|
150
|
+
const closed = supersededBy(run);
|
|
151
|
+
if (closed)
|
|
152
|
+
throw new CatherdError("E_RUN_NOT_LIVE", `run ${run.id} is superseded by ${closed.by}`, {
|
|
153
|
+
fix: `dispatch in run ${closed.by}`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
127
157
|
/**
|
|
128
158
|
* Spec §4.4 step 1. The checks that read shared state and the write that makes the dispatch live
|
|
129
159
|
* happen under the run's admission lock, so parallel dispatches always see each other (audit C1).
|
|
@@ -136,8 +166,12 @@ export async function admit(
|
|
|
136
166
|
): Promise<{ d: Dispatch; specPath: string }> {
|
|
137
167
|
assertId("role name", i.name);
|
|
138
168
|
if (i.lane !== null) assertId("lane", i.lane);
|
|
169
|
+
// a machine or workspace pause refuses every dispatch it covers, with its reason (spec 1.5 "Group pause")
|
|
170
|
+
assertNotPaused(run);
|
|
171
|
+
assertNotSuperseded(run);
|
|
139
172
|
const rung = parseRung(i.rung);
|
|
140
|
-
|
|
173
|
+
// spec 1.5: the run's pinned profile, access and isolation, whatever the repo runs on now
|
|
174
|
+
const profile = runProfile(deps, run);
|
|
141
175
|
const rc = profile.roles[i.role];
|
|
142
176
|
if (!rc?.enabled)
|
|
143
177
|
throw new CatherdError("E_ADMIT_RUNG", `the ${i.role} role is off in profile ${profile.name}`, {
|
|
@@ -183,10 +217,23 @@ export async function admit(
|
|
|
183
217
|
{ fix: "dispatch a fresh thread (omit `thread`)" },
|
|
184
218
|
);
|
|
185
219
|
const owns = i.lane === null ? [] : laneOwns(run, i.lane);
|
|
220
|
+
// spec 1.5 plan 21: the writer's implicit docs lane, for attribution only
|
|
221
|
+
const ownsImplicit = i.role === "writer" && i.lane === null ? (briefOwns(i.brief) ?? DOCS_OWNS) : null;
|
|
222
|
+
// spec 1.5 "Lane editing": an After: line orders lanes that compile against each other
|
|
223
|
+
const before = i.lane === null ? [] : unfinishedAfter(run, i.lane);
|
|
224
|
+
if (before.length)
|
|
225
|
+
throw new CatherdError(
|
|
226
|
+
"E_ADMIT_ORDER",
|
|
227
|
+
`${i.lane} runs after ${before.join(", ")} (its After: line), which ${before.length > 1 ? "have" : "has"} not finished`,
|
|
228
|
+
{ fix: `dispatch ${i.lane} once ${before.join(", ")} ${before.length > 1 ? "end" : "ends"} ok` },
|
|
229
|
+
);
|
|
186
230
|
const id = newDispatchId();
|
|
187
231
|
const dir = join(roleDir(run, i.name), id);
|
|
188
232
|
const p = dispatchPaths(dir);
|
|
189
233
|
const isolated = started?.isolated ?? profile.isolated[rung.backend] ?? false;
|
|
234
|
+
// spec 1.5 plan 21 (#42 findings 1, 2): the role server goes in whether the harness is isolated or not, so no
|
|
235
|
+
// role is refused for isolation, and a thread an isolated run started resumes as it started
|
|
236
|
+
const roleServer = ROLE_SERVER_BACKENDS.includes(rung.backend);
|
|
190
237
|
await prepared(adapter, {
|
|
191
238
|
rung,
|
|
192
239
|
access: rc.access,
|
|
@@ -194,20 +241,27 @@ export async function admit(
|
|
|
194
241
|
repo: run.meta.repo,
|
|
195
242
|
network,
|
|
196
243
|
});
|
|
197
|
-
const plan = adapter.plan({
|
|
198
|
-
rung,
|
|
199
|
-
access: rc.access,
|
|
200
|
-
network,
|
|
201
|
-
thread: i.thread,
|
|
202
|
-
isolated,
|
|
203
|
-
repo: run.meta.repo,
|
|
204
|
-
briefPath: p.brief,
|
|
205
|
-
replyPath: p.reply,
|
|
206
|
-
dispatchDir: dir,
|
|
207
|
-
});
|
|
208
244
|
|
|
209
245
|
await finalizeFinished(run, deps.now(), onRecorded);
|
|
210
|
-
return
|
|
246
|
+
return withRunAdmission(run, deps.now, async () => {
|
|
247
|
+
// again under the lock `runs supersede` writes under: a supersede that landed since the check above wins
|
|
248
|
+
assertNotSuperseded(run);
|
|
249
|
+
// the scratch is created under the admission lock `catherd runs clean` takes too, so cleanup never removes
|
|
250
|
+
// the scratch of a dispatch it does not see live yet (PR #46)
|
|
251
|
+
const scratch = roleScratch(run, i.name, rung.backend, isolated);
|
|
252
|
+
const plan = adapter.plan({
|
|
253
|
+
rung,
|
|
254
|
+
access: rc.access,
|
|
255
|
+
network,
|
|
256
|
+
thread: i.thread,
|
|
257
|
+
isolated,
|
|
258
|
+
repo: run.meta.repo,
|
|
259
|
+
briefPath: p.brief,
|
|
260
|
+
replyPath: p.reply,
|
|
261
|
+
dispatchDir: dir,
|
|
262
|
+
...(roleServer ? { roleMcp: { run: run.id, role: i.role } } : {}),
|
|
263
|
+
...(scratch ? { scratch } : {}),
|
|
264
|
+
});
|
|
211
265
|
// A dispatch blocks until its record is written, not only while it runs: its finalizer diffs the
|
|
212
266
|
// tree after the exit, so a later dispatch's writes must not land in between. A finished one here
|
|
213
267
|
// could not be recorded just now. The records and the pending dispatches are one snapshot, taken
|
|
@@ -230,7 +284,9 @@ export async function admit(
|
|
|
230
284
|
},
|
|
231
285
|
);
|
|
232
286
|
for (const d of pending) {
|
|
233
|
-
|
|
287
|
+
// a running lane holds what owns_add granted it since its admission, too
|
|
288
|
+
const held = d.admit.lane ? [...d.admit.owns, ...currentOwns(run, d.admit.lane)] : d.admit.owns;
|
|
289
|
+
const shared = overlaps(owns, held);
|
|
234
290
|
if (shared.length)
|
|
235
291
|
throw new CatherdError(
|
|
236
292
|
"E_ADMIT_OVERLAP",
|
|
@@ -257,6 +313,7 @@ export async function admit(
|
|
|
257
313
|
role: i.role,
|
|
258
314
|
lane: i.lane,
|
|
259
315
|
owns,
|
|
316
|
+
...(ownsImplicit ? { ownsImplicit } : {}),
|
|
260
317
|
rung: formatRung(rung),
|
|
261
318
|
backend: rung.backend,
|
|
262
319
|
thread: i.thread,
|
|
@@ -273,8 +330,23 @@ export async function admit(
|
|
|
273
330
|
...(sessionId ? { sessionId, host: i.host ?? session?.host ?? "claude-code" } : {}),
|
|
274
331
|
};
|
|
275
332
|
ensurePrivateDir(dir);
|
|
276
|
-
//
|
|
277
|
-
|
|
333
|
+
// plan 23: the verifier runs with the repo's gate environment (DOCKER_HOST, a proxy, …)
|
|
334
|
+
const gate =
|
|
335
|
+
i.role === "verifier" ? gateEnvParts(await readGateEnv(run.meta.repo)) : { values: {}, refs: {} };
|
|
336
|
+
// spec 1.1 §6: every brief ends with its role's reply contract, failover stand-ins' included; spec 1.5 plan 21:
|
|
337
|
+
// before it, the lane file as it stands now, and who the role is, its scratch and its catherd tools; plan 23:
|
|
338
|
+
// a verifier's brief also carries the gate's rules and the run's recorded items
|
|
339
|
+
writeTextAtomic(
|
|
340
|
+
p.brief,
|
|
341
|
+
composeBrief(await verifierBrief(run, i.role, i.name, i.brief), {
|
|
342
|
+
run: run.id,
|
|
343
|
+
name: i.name,
|
|
344
|
+
role: i.role,
|
|
345
|
+
lane: i.lane === null ? null : { id: i.lane, text: readFileSync(laneFile(run, i.lane), "utf8") },
|
|
346
|
+
scratch,
|
|
347
|
+
roleServer,
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
278
350
|
// Spec §10.4: the adapter's overrides only; the supervisor adds its own inherited env at spawn
|
|
279
351
|
// time (src/entry/supervise-command.ts), so no credential is ever written to disk. 0600 all the same.
|
|
280
352
|
writeJsonAtomic(
|
|
@@ -285,11 +357,26 @@ export async function admit(
|
|
|
285
357
|
dispatchDir: dir,
|
|
286
358
|
cmd: plan.cmd,
|
|
287
359
|
args: plan.args,
|
|
288
|
-
|
|
360
|
+
// spec 1.5 plan 21: the supervisor gives the role its identity and, where granted, its scratch TMPDIR;
|
|
361
|
+
// plan 23: a `catherd lock` in the role reports to this dispatch, so a long gate keeps its wall alive
|
|
362
|
+
// plan 23: each dispatch its own testcontainers session, so parallel lanes never share a reaper;
|
|
363
|
+
// the gate env never overrides the role's identity, scratch, dispatch id, PWD or isolated home
|
|
364
|
+
env: {
|
|
365
|
+
...gate.values,
|
|
366
|
+
...plan.env,
|
|
367
|
+
[ROLE_ENV]: formatRoleScope({ run: run.id, name: i.name }),
|
|
368
|
+
...(scratch ? { TMPDIR: scratch } : {}),
|
|
369
|
+
[DISPATCH_ID_ENV]: id,
|
|
370
|
+
TESTCONTAINERS_SESSION_ID: id,
|
|
371
|
+
PWD: plan.cwd,
|
|
372
|
+
},
|
|
373
|
+
// a secret of the gate env by reference only: the supervisor reads it from its own env at spawn
|
|
374
|
+
...(Object.keys(gate.refs).length ? { envFrom: gate.refs } : {}),
|
|
289
375
|
cwd: plan.cwd,
|
|
290
376
|
stdinPath: plan.stdinPath,
|
|
291
|
-
|
|
292
|
-
|
|
377
|
+
// plan 23: a role's own timeouts win over the profile's (a verifier's long gate)
|
|
378
|
+
idleMs: roleTimeouts(profile, rc).idleMin * 60_000,
|
|
379
|
+
wallMs: roleTimeouts(profile, rc).wallMin * 60_000,
|
|
293
380
|
killGraceMs: KILL_GRACE_MS,
|
|
294
381
|
graceAfterFinalMs: adapter.graceAfterFinalMs,
|
|
295
382
|
pollMs: deps.pollMs,
|