agent-lyceum 0.18.0 → 0.20.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.
@@ -0,0 +1,157 @@
1
+ /** `<runtime>/<model>`, the model trimmed and lowercased (no model = ""), so entries naming the same model share one key. */
2
+ const keyOf = (runtime, model) => `${runtime}/${(model ?? "").trim().toLowerCase()}`;
3
+ /**
4
+ * The primary model followed by the agent's fallback chain, in order. An entry with the same key as the primary or as an
5
+ * earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
6
+ */
7
+ export function candidatesFor(agent) {
8
+ // `validate` refuses an agent without a runtime before anything is woken.
9
+ const runtime = agent.runtime;
10
+ const primary = { runtime, model: agent.model, effort: agent.effort, primary: true, key: keyOf(runtime, agent.model) };
11
+ const out = [primary];
12
+ const seen = new Set([primary.key]);
13
+ for (const f of agent.fallback) {
14
+ const model = f.model || undefined;
15
+ const key = keyOf(f.runtime, model);
16
+ if (seen.has(key))
17
+ continue;
18
+ seen.add(key);
19
+ out.push({ runtime: f.runtime, model, effort: f.effort, primary: false, key });
20
+ }
21
+ return out;
22
+ }
23
+ /**
24
+ * The agent as it runs on candidate `c`: itself for the primary; for a backup, the same agent on the backup's runtime, model
25
+ * and effort, at the primary's effective web level (spec 20.12). A set `allow_web` carries over. Unset, claude-code has no web
26
+ * tools while codex keeps its own default, which may search: a codex backup of such an agent gets `none`.
27
+ */
28
+ export function agentForCandidate(agent, c) {
29
+ if (c.primary)
30
+ return agent;
31
+ const allowWeb = agent.allowWeb ?? (agent.runtime === "claude-code" && c.runtime === "codex" ? "none" : undefined);
32
+ return { ...agent, runtime: c.runtime, model: c.model, effort: c.effort, allowWeb };
33
+ }
34
+ /** Models that failed with `rate_limit`/`quota`, and until when (epoch ms) wakes skip them. Shared by a run, kept in `state.json`. */
35
+ export class Cooldowns {
36
+ table = new Map();
37
+ /** Cool `key` down for `sec` seconds from `now`; never shortens a longer cooldown already in place. */
38
+ mark(key, kind, now, sec) {
39
+ if (sec <= 0)
40
+ return;
41
+ const until = now + sec * 1000;
42
+ if ((this.table.get(key)?.until ?? 0) >= until)
43
+ return;
44
+ this.table.set(key, { until, kind });
45
+ }
46
+ /** Milliseconds of cooldown left for `key` at `now`; 0 = none. */
47
+ active(key, now) {
48
+ const until = this.table.get(key)?.until ?? 0;
49
+ return Math.max(0, until - now);
50
+ }
51
+ toJSON() {
52
+ return Object.fromEntries([...this.table].map(([k, v]) => [k, { until: new Date(v.until).toISOString(), kind: v.kind }]));
53
+ }
54
+ /** Rebuild from `toJSON()` output; a missing table (older `state.json`) is an empty one. */
55
+ static from(json) {
56
+ const c = new Cooldowns();
57
+ for (const [k, v] of Object.entries(json ?? {})) {
58
+ const until = Date.parse(v.until);
59
+ if (Number.isFinite(until))
60
+ c.table.set(k, { until, kind: v.kind });
61
+ }
62
+ return c;
63
+ }
64
+ }
65
+ /** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
66
+ export function planAttempts(agent, cooldowns, now) {
67
+ const all = candidatesFor(agent);
68
+ const warm = all.filter((c) => cooldowns.active(c.key, now) === 0);
69
+ return warm.length ? warm : all;
70
+ }
71
+ /**
72
+ * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
73
+ * candidate, the failed one included; each candidate gets `retry` retries. `legacy` (the agent has no fallback chain)
74
+ * keeps the old loop: every kind is retried `retry` times, then the wake fails.
75
+ */
76
+ export function nextStep(kind, attemptsOnCurrent, retry, hasNext, legacy) {
77
+ const retriesLeft = attemptsOnCurrent <= retry;
78
+ if (legacy)
79
+ return retriesLeft ? "retry-same" : "stop";
80
+ switch (kind) {
81
+ case "rate_limit":
82
+ case "quota":
83
+ return hasNext ? "next-candidate" : "stop";
84
+ case "timeout":
85
+ case "network":
86
+ case "crash":
87
+ return retriesLeft ? "retry-same" : hasNext ? "next-candidate" : "stop";
88
+ case "auth":
89
+ return "stop";
90
+ default:
91
+ // `unknown` and unclassified failures: same model only, as before fallback existed.
92
+ return retriesLeft ? "retry-same" : "stop";
93
+ }
94
+ }
95
+ /** Wait before the `n`th retry (1-based) on the same candidate: 5s, 15s, 45s, then 60s. */
96
+ export function backoffMs(n) {
97
+ return Math.min(5 * 3 ** (n - 1), 60) * 1000;
98
+ }
99
+ /** `setTimeout` as a promise that resolves early (never rejects) when `signal` aborts. */
100
+ export function defaultSleep(ms, signal) {
101
+ return new Promise((resolve) => {
102
+ if (signal?.aborted)
103
+ return resolve();
104
+ const done = () => {
105
+ clearTimeout(timer);
106
+ signal?.removeEventListener("abort", done);
107
+ resolve();
108
+ };
109
+ const timer = setTimeout(done, ms);
110
+ signal?.addEventListener("abort", done, { once: true });
111
+ });
112
+ }
113
+ /**
114
+ * The one retry/fallback loop shared by `RunSession.wake` and `call` (spec 20.2 decision 9): call each candidate,
115
+ * retry it or move on as `nextStep` says, back off between retries. Success and cancellation end the loop as they are.
116
+ */
117
+ export async function runWithFallback(o) {
118
+ const first = o.candidates[0];
119
+ if (!first)
120
+ throw new Error("runWithFallback needs at least one candidate");
121
+ const sleep = o.sleep ?? defaultSleep;
122
+ const now = o.now ?? Date.now;
123
+ const tried = [];
124
+ let index = 0;
125
+ let used = first;
126
+ let ctx = { attempt: 0, candidateAttempt: 0 };
127
+ let result;
128
+ for (;;) {
129
+ if (o.beforeAttempt?.(used, ctx) === "stop")
130
+ return { result, tried, used, stopped: "beforeAttempt" };
131
+ result = await o.invoke(used, ctx);
132
+ await o.afterAttempt?.(used, result, ctx);
133
+ if (result.ok || result.cancelled)
134
+ return { result, tried, used };
135
+ tried.push({ runtime: used.runtime, model: used.model, kind: result.kind, error: result.error });
136
+ if (!o.legacy && (result.kind === "rate_limit" || result.kind === "quota"))
137
+ o.cooldowns.mark(used.key, result.kind, now(), o.cooldownSec);
138
+ const next = o.candidates[index + 1];
139
+ const step = nextStep(result.kind, ctx.candidateAttempt + 1, o.retry, !!next, o.legacy);
140
+ if (step === "stop")
141
+ return { result, tried, used };
142
+ if (step === "retry-same") {
143
+ if (!o.legacy) {
144
+ await sleep(backoffMs(ctx.candidateAttempt + 1), o.signal);
145
+ if (o.signal?.aborted)
146
+ return { result, tried, used, stopped: "aborted" };
147
+ }
148
+ ctx = { attempt: ctx.attempt + 1, candidateAttempt: ctx.candidateAttempt + 1, previous: result };
149
+ continue;
150
+ }
151
+ const to = next;
152
+ o.onSwitch?.(used, to, result.kind, result);
153
+ ctx = { attempt: ctx.attempt + 1, candidateAttempt: 0, previous: result, switchedFrom: used };
154
+ index++;
155
+ used = to;
156
+ }
157
+ }
package/dist/policy.d.ts CHANGED
@@ -37,6 +37,8 @@ export declare function sharedIndex(project: ResolvedProject): string | undefine
37
37
  /** The repo directory this agent works in: its own worktree, or the main repo. */
38
38
  export declare function repoDirFor(project: ResolvedProject, agent: string): string;
39
39
  export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
40
+ /** Test seam: pretend the OS sandbox is (un)available; `undefined` re-probes the host. */
41
+ export declare function setOsSandboxForTest(available: boolean | undefined): void;
40
42
  export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
41
43
  /** Can `agent` legitimately change this protected file? */
42
44
  export declare function mayEditProtected(project: ResolvedProject, agent: ResolvedAgent, file: string): boolean;
package/dist/policy.js CHANGED
@@ -136,6 +136,10 @@ function osSandboxAvailable() {
136
136
  sandboxProbe = false;
137
137
  return sandboxProbe;
138
138
  }
139
+ /** Test seam: pretend the OS sandbox is (un)available; `undefined` re-probes the host. */
140
+ export function setOsSandboxForTest(available) {
141
+ sandboxProbe = available;
142
+ }
139
143
  export function enforcementFor(project, agent) {
140
144
  const pol = writePolicy(project, agent);
141
145
  const os = osSandboxAvailable();
@@ -13,6 +13,16 @@ export interface RunnerOptions {
13
13
  /** How long to wait for output pipes to close after the process is gone or killed, before giving up on them. Default 1000. */
14
14
  settleWaitMs?: number;
15
15
  }
16
+ /** Pids below `root` (children, grandchildren, ...) by parent links; catches descendants that left the process group (setsid, daemons). */
17
+ export declare function descendantsOf(root: number): number[];
18
+ /** Sampling interval of the descendant timer (default 2000 ms); for tests. Takes effect for the next timer. */
19
+ export declare function setSamplerIntervalMs(ms: number): void;
20
+ /** Whether the descendant timer is running (for tests). */
21
+ export declare function samplerActive(): boolean;
22
+ /** How many runtimes are registered as running (for tests). */
23
+ export declare function runningCount(): number;
24
+ /** Synchronously SIGKILL every runtime still running, with the descendants seen when it was started or last checked. Used when agent-lyceum itself is going away. */
25
+ export declare function killAllRunning(): void;
16
26
  /**
17
27
  * Run an invocation as a child process in its own process group, with a timeout, cancellation, bounded memory
18
28
  * and a bounded finish: whatever the runtime and its descendants do with the output pipes, this settles once,
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  /** Most output kept in memory per stream; the rest only goes to the log files. */
@@ -31,6 +31,121 @@ function endStream(s) {
31
31
  });
32
32
  });
33
33
  }
34
+ /**
35
+ * Runtimes that are running right now, with the descendants seen so far, so an exiting agent-lyceum can take them with it.
36
+ * A descendant that left the process group (setsid, daemon) is only found by parent links while its parent is alive, so a
37
+ * shared timer samples the process table while any runtime is registered. Remaining gap: a child that escapes and
38
+ * outlives its parent within one sampling interval is never seen.
39
+ */
40
+ const live = new Map();
41
+ let exitHook = false;
42
+ let sampler;
43
+ let samplerMs = 2000;
44
+ /** Parent -> children from one `ps` run, or undefined when it fails or hangs (SIGKILL on timeout: a ps that ignores SIGTERM must not block, even in the exit hook). */
45
+ function processTable() {
46
+ if (process.platform === "win32")
47
+ return undefined;
48
+ const ps = spawnSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8", timeout: 2000, killSignal: "SIGKILL" });
49
+ if (ps.status !== 0 || !ps.stdout)
50
+ return undefined;
51
+ const kids = new Map();
52
+ for (const line of ps.stdout.split("\n")) {
53
+ const [pid, ppid] = line.trim().split(/\s+/).map(Number);
54
+ if (!pid || !ppid)
55
+ continue;
56
+ kids.set(ppid, [...(kids.get(ppid) ?? []), pid]);
57
+ }
58
+ return kids;
59
+ }
60
+ function below(kids, root) {
61
+ const out = [];
62
+ if (!kids)
63
+ return out;
64
+ for (let todo = [root]; todo.length;) {
65
+ const next = [];
66
+ for (const p of todo) {
67
+ for (const k of kids.get(p) ?? []) {
68
+ if (out.includes(k))
69
+ continue;
70
+ out.push(k);
71
+ next.push(k);
72
+ }
73
+ }
74
+ todo = next;
75
+ }
76
+ return out;
77
+ }
78
+ /** Pids below `root` (children, grandchildren, ...) by parent links; catches descendants that left the process group (setsid, daemons). */
79
+ export function descendantsOf(root) {
80
+ return below(processTable(), root);
81
+ }
82
+ /** Record the current descendants of every live runtime (one `ps` for all). */
83
+ function sample() {
84
+ if (!live.size)
85
+ return;
86
+ const kids = processTable();
87
+ for (const [pid, seen] of live)
88
+ for (const d of below(kids, pid))
89
+ seen.add(d);
90
+ }
91
+ function register(pid) {
92
+ live.set(pid, new Set());
93
+ if (!exitHook) {
94
+ exitHook = true;
95
+ process.on("exit", killAllRunning); // process.exit() and crashes must not leave detached runtimes behind
96
+ }
97
+ if (!sampler) {
98
+ sampler = setInterval(sample, samplerMs);
99
+ sampler.unref();
100
+ }
101
+ }
102
+ function unregister(pid) {
103
+ live.delete(pid);
104
+ if (!live.size && sampler) {
105
+ clearInterval(sampler);
106
+ sampler = undefined;
107
+ }
108
+ }
109
+ /** Sampling interval of the descendant timer (default 2000 ms); for tests. Takes effect for the next timer. */
110
+ export function setSamplerIntervalMs(ms) {
111
+ samplerMs = ms;
112
+ }
113
+ /** Whether the descendant timer is running (for tests). */
114
+ export function samplerActive() {
115
+ return sampler !== undefined;
116
+ }
117
+ function signalPid(pid, sig, group) {
118
+ try {
119
+ process.kill(group && process.platform !== "win32" ? -pid : pid, sig);
120
+ }
121
+ catch {
122
+ /* already gone */
123
+ }
124
+ }
125
+ /** Signal the runtime's group, the descendants recorded earlier that are still in the process table, and its current descendants. */
126
+ function killTree(pid, sig) {
127
+ const kids = processTable();
128
+ const seen = live.get(pid) ?? new Set();
129
+ const now = below(kids, pid);
130
+ const present = kids ? new Set([...kids.values()].flat()) : undefined; // without a table, trust the record
131
+ for (const d of now)
132
+ seen.add(d);
133
+ signalPid(pid, sig, true);
134
+ for (const d of seen)
135
+ if (!present || present.has(d))
136
+ signalPid(d, sig, false);
137
+ }
138
+ /** How many runtimes are registered as running (for tests). */
139
+ export function runningCount() {
140
+ return live.size;
141
+ }
142
+ /** Synchronously SIGKILL every runtime still running, with the descendants seen when it was started or last checked. Used when agent-lyceum itself is going away. */
143
+ export function killAllRunning() {
144
+ for (const pid of [...live.keys()]) {
145
+ killTree(pid, "SIGKILL");
146
+ unregister(pid);
147
+ }
148
+ }
34
149
  /**
35
150
  * Run an invocation as a child process in its own process group, with a timeout, cancellation, bounded memory
36
151
  * and a bounded finish: whatever the runtime and its descendants do with the output pipes, this settles once,
@@ -49,6 +164,8 @@ export function runInvocation(inv, opts) {
49
164
  outLog.on("error", () => { });
50
165
  errLog.on("error", () => { });
51
166
  const child = spawn(inv.cmd, inv.args, { cwd: inv.cwd, env: inv.env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
167
+ if (child.pid !== undefined)
168
+ register(child.pid);
52
169
  const stdoutTail = new Tail();
53
170
  const stderrTail = new Tail();
54
171
  const parser = inv.stream?.();
@@ -58,17 +175,8 @@ export function runInvocation(inv, opts) {
58
175
  let settled = false;
59
176
  const timers = [];
60
177
  const killGroup = (sig) => {
61
- if (child.pid === undefined)
62
- return;
63
- try {
64
- if (process.platform === "win32")
65
- child.kill(sig);
66
- else
67
- process.kill(-child.pid, sig);
68
- }
69
- catch {
70
- /* nothing left to signal */
71
- }
178
+ if (child.pid !== undefined)
179
+ killTree(child.pid, sig);
72
180
  };
73
181
  const finish = (spawnError) => {
74
182
  if (settled)
@@ -78,17 +186,21 @@ export function runInvocation(inv, opts) {
78
186
  clearTimeout(t);
79
187
  opts.signal?.removeEventListener("abort", onAbort);
80
188
  killGroup("SIGKILL"); // whatever of the tree is still around
189
+ if (child.pid !== undefined)
190
+ unregister(child.pid);
81
191
  child.stdout?.destroy();
82
192
  child.stderr?.destroy();
83
193
  void Promise.all([endStream(outLog), endStream(errLog)]).then(() => {
84
194
  if (spawnError) {
85
195
  const hint = spawnError.code === "ENOENT" ? ` ("${inv.cmd}" not found on PATH)` : "";
86
- return resolve({ ok: false, text: "", exitCode: null, timedOut: false, error: `${spawnError.message}${hint}` });
196
+ // a missing binary is a crash the chain may route around (the next candidate can be another CLI); other spawn errors stay unclassified
197
+ const kind = hint ? "crash" : "unknown";
198
+ return resolve({ ok: false, text: "", exitCode: null, timedOut: false, error: `${spawnError.message}${hint}`, kind });
87
199
  }
88
200
  if (reason === "cancel")
89
201
  return resolve({ ...cancelled, exitCode: exitCode ?? null });
90
202
  if (reason === "timeout")
91
- return resolve({ ok: false, text: "", exitCode: exitCode ?? null, timedOut: true, error: `timed out after ${opts.timeoutSec}s` });
203
+ return resolve({ ok: false, text: "", exitCode: exitCode ?? null, timedOut: true, error: `timed out after ${opts.timeoutSec}s`, kind: "timeout" });
92
204
  const parsed = parser
93
205
  ? parser.finish({ code: exitCode ?? null, stdoutTail: stdoutTail.text(), stderrTail: stderrTail.text() })
94
206
  : inv.parse({ stdout: fallback.length ? fallback.toString("utf8") : stdoutTail.text(), stderr: stderrTail.text(), code: exitCode ?? null });
@@ -1,5 +1,5 @@
1
1
  import type { DoneContract } from "./format.js";
2
- import type { EndReason } from "./run-store.js";
2
+ import type { EndReason, RunFailure } from "./run-store.js";
3
3
  import type { RunOutcome } from "./schema.js";
4
4
  export interface UnintegratedWork {
5
5
  agent: string;
@@ -16,17 +16,9 @@ export declare function resolveDoneOutcome(contract: DoneContract, rejections: n
16
16
  export declare function endOutcome(endReason: EndReason, done?: {
17
17
  outcome: RunOutcome;
18
18
  note?: string;
19
- }, failure?: {
20
- agent: string;
21
- error?: string;
22
- attempts: number;
23
- }): {
19
+ }, failure?: RunFailure): {
24
20
  outcome: RunOutcome;
25
21
  note?: string;
26
22
  };
27
23
  /** What to tell the user when an agent kept failing and the run stopped: where to look, what to fix, how to go on. */
28
- export declare function failureGuide(project: string, run: string, f: {
29
- agent: string;
30
- error?: string;
31
- attempts: number;
32
- }, runDir: string): string;
24
+ export declare function failureGuide(project: string, run: string, f: RunFailure, runDir: string): string;
@@ -35,9 +35,14 @@ export function endOutcome(endReason, done, failure) {
35
35
  }
36
36
  /** What to tell the user when an agent kept failing and the run stopped: where to look, what to fix, how to go on. */
37
37
  export function failureGuide(project, run, f, runDir) {
38
+ // With a fallback chain: every model given up on, in order.
39
+ const tried = f.tried?.length
40
+ ? ["Models tried, in order:", ...f.tried.map((t, i) => ` ${i + 1}. ${t.model ? `${t.runtime}/${t.model}` : t.runtime}: ${t.kind ?? "unknown error"}${t.error ? ` (${t.error})` : ""}`)]
41
+ : [];
38
42
  return [
39
43
  "",
40
44
  `${f.agent} was called ${f.attempts} time(s) and failed every time (${f.error ?? "unknown error"}). The run stopped; nothing was lost: the message ${f.agent} was handling is still unread and the work done so far is kept.`,
45
+ ...tried,
41
46
  "To fix it:",
42
47
  ` 1. Read why it failed: ${path.join(runDir, "log.jsonl")} (event "wake"), and the full output of each attempt in ${path.join(runDir, "mail", "attempts")}`,
43
48
  ` 2. Fix the cause: the ${f.agent} CLI (installed, logged in, model name), a timeout (dispatcher.wake_timeout_sec), or its AGENT.md / the message it keeps failing on. Check with: agent-lyceum doctor -p ${project}`,
@@ -2,7 +2,7 @@ import { type Invoker } from "./adapters/index.js";
2
2
  import type { ResolvedProject } from "./config.js";
3
3
  import type { RunOutcome } from "./schema.js";
4
4
  import { type ResultBranch, type WorkspacePlan } from "./worktree.js";
5
- import { newRunId, type EndReason, type RunState } from "./run-store.js";
5
+ import { newRunId, type EndReason, type RunFailure, type RunState } from "./run-store.js";
6
6
  import { type PreparedTask } from "./task.js";
7
7
  import { type CommandRunner, type PrOutcome } from "./open-pr.js";
8
8
  export { newRunId };
@@ -36,11 +36,7 @@ export interface RunSummary {
36
36
  /** The project's name, for the commands printed after the run. */
37
37
  project: string;
38
38
  /** The run stopped because this agent kept failing. */
39
- failure?: {
40
- agent: string;
41
- error?: string;
42
- attempts: number;
43
- };
39
+ failure?: RunFailure;
44
40
  }
45
41
  export interface RunOptions {
46
42
  project: ResolvedProject;
@@ -65,6 +61,8 @@ export interface RunOptions {
65
61
  /** Stop the run when this fires: running agents are killed, unread mail is kept, the run ends `cancelled` and can be resumed. */
66
62
  signal?: AbortSignal;
67
63
  log?: (line: string) => void;
64
+ /** The wait between retries of a model with a fallback chain; tests pass one that does not wait. Default: a timer cut short by `signal`. */
65
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
68
66
  }
69
67
  export declare class RunSession {
70
68
  private readonly opts;
@@ -89,6 +87,8 @@ export declare class RunSession {
89
87
  private doneOutcome;
90
88
  private doneNote;
91
89
  private failure;
90
+ /** Models cooling down after a rate limit or quota error; one table for the run, shared by parallel wake-ups. */
91
+ private readonly cooldowns;
92
92
  private doneVerification;
93
93
  /** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
94
94
  private openPrRequested;
@@ -113,6 +113,8 @@ export declare class RunSession {
113
113
  private pendingAgents;
114
114
  private pickBatch;
115
115
  private wake;
116
+ /** Copy the shared cooldown table into the state (only once something has cooled down); true when it was. */
117
+ private syncCooldowns;
116
118
  /**
117
119
  * A finished member's files go from its worktree into the repo before anyone reads its mail, so the lead
118
120
  * sees them. When that cannot be done, nothing is applied, the member's work stays on its branch, and the lead is told.