agent-lyceum 0.18.0 → 0.19.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();
@@ -83,12 +83,14 @@ export function runInvocation(inv, opts) {
83
83
  void Promise.all([endStream(outLog), endStream(errLog)]).then(() => {
84
84
  if (spawnError) {
85
85
  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}` });
86
+ // a missing binary is a crash the chain may route around (the next candidate can be another CLI); other spawn errors stay unclassified
87
+ const kind = hint ? "crash" : "unknown";
88
+ return resolve({ ok: false, text: "", exitCode: null, timedOut: false, error: `${spawnError.message}${hint}`, kind });
87
89
  }
88
90
  if (reason === "cancel")
89
91
  return resolve({ ...cancelled, exitCode: exitCode ?? null });
90
92
  if (reason === "timeout")
91
- return resolve({ ok: false, text: "", exitCode: exitCode ?? null, timedOut: true, error: `timed out after ${opts.timeoutSec}s` });
93
+ return resolve({ ok: false, text: "", exitCode: exitCode ?? null, timedOut: true, error: `timed out after ${opts.timeoutSec}s`, kind: "timeout" });
92
94
  const parsed = parser
93
95
  ? parser.finish({ code: exitCode ?? null, stdoutTail: stdoutTail.text(), stderrTail: stderrTail.text() })
94
96
  : 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.
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { realInvoker } from "./adapters/index.js";
4
+ import { agentForCandidate, candidatesFor, Cooldowns, planAttempts, runWithFallback } from "./fallback.js";
4
5
  import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./format.js";
5
6
  import { ProtectedGuard } from "./guard.js";
6
7
  import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, resolveOptionLetters, writeAskReply } from "./ask-reply.js";
@@ -35,6 +36,21 @@ function readIndex(file) {
35
36
  function retryPrompt(prompt, error) {
36
37
  return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. Its mail was discarded. Check the current state of the repository before redoing the work, and send the mail again.`;
37
38
  }
39
+ /** `runtime/model`, or the runtime alone for its default model. */
40
+ const modelLabel = (c) => (c.model ? `${c.runtime}/${c.model}` : c.runtime);
41
+ /** Each model given up on once, in order, with its last failure (retries on the same model are one entry). */
42
+ function lastPerModel(tried) {
43
+ const out = [];
44
+ for (const t of tried) {
45
+ const last = out.at(-1);
46
+ const entry = { runtime: t.runtime, ...(t.model ? { model: t.model } : {}), ...(t.kind ? { kind: t.kind } : {}), ...(t.error ? { error: t.error } : {}) };
47
+ if (last && last.runtime === t.runtime && last.model === t.model)
48
+ out[out.length - 1] = entry;
49
+ else
50
+ out.push(entry);
51
+ }
52
+ return out;
53
+ }
38
54
  export class RunSession {
39
55
  opts;
40
56
  resume;
@@ -58,6 +74,8 @@ export class RunSession {
58
74
  doneOutcome;
59
75
  doneNote;
60
76
  failure;
77
+ /** Models cooling down after a rate limit or quota error; one table for the run, shared by parallel wake-ups. */
78
+ cooldowns;
61
79
  doneVerification;
62
80
  /** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
63
81
  openPrRequested = false;
@@ -225,6 +243,7 @@ export class RunSession {
225
243
  this.log = log;
226
244
  this.guard = guard;
227
245
  this.state = state;
246
+ this.cooldowns = Cooldowns.from(state.cooldowns);
228
247
  // Only a segment that starts here has a baseline: a fresh run or a follow-up reopen. A plain resume continues one begun by an earlier process.
229
248
  this.sharedAtStart = !resume || opts.followup ? readIndex(sharedIndex(project)) : undefined;
230
249
  }
@@ -273,7 +292,7 @@ export class RunSession {
273
292
  return pending;
274
293
  }
275
294
  async wake(agent) {
276
- const { invoke, say, cfg, runDir, project, log, state, claims, attempts, noSessionNoted, opts } = this;
295
+ const { invoke, say, cfg, runDir, project, log, state, claims, attempts, noSessionNoted, opts, cooldowns } = this;
277
296
  const queue = listUnread(project, agent.name);
278
297
  if (queue.length === 0)
279
298
  return { ok: true };
@@ -293,92 +312,153 @@ export class RunSession {
293
312
  userPrompt: buildUserPrompt(project, agent, queue, unread.length, (state.reopened?.length ?? 0) > 0),
294
313
  timeoutSec: cfg.wake_timeout_sec,
295
314
  };
296
- let result;
297
- for (let attempt = 0; attempt <= cfg.retry; attempt++) {
298
- if (state.max_rounds > 0 && state.rounds >= state.max_rounds) {
315
+ // Without a fallback chain (one candidate) the loop, its output and its records are exactly those of plain retries (spec 20.2 decision 11).
316
+ const all = candidatesFor(agent);
317
+ const chain = all.length > 1;
318
+ const candidates = chain ? planAttempts(agent, cooldowns, Date.now()) : all;
319
+ // The model a backup's calls stand in for: the one switched away from in this wake, or the cooling-down primary it started past.
320
+ let replaced = candidates[0]?.primary ? undefined : all[0];
321
+ let calls = 0;
322
+ let stoppedByCancel = false;
323
+ const outcome = await runWithFallback({
324
+ candidates,
325
+ legacy: !chain,
326
+ retry: cfg.retry,
327
+ cooldowns,
328
+ cooldownSec: cfg.fallback_cooldown_sec,
329
+ signal: opts.signal,
330
+ sleep: opts.sleep,
331
+ beforeAttempt: (_c, ctx) => {
332
+ // A cancel that came during the previous call: no new model is tried (without a chain the call still goes ahead, as before).
333
+ if (chain && ctx.attempt > 0 && opts.signal?.aborted) {
334
+ stoppedByCancel = true;
335
+ return "stop";
336
+ }
299
337
  // The round limit (when one is set) ends the run; this message stays unread for the resume.
300
- abandonClaim(runDir, claim.id);
301
- claims.delete(agent.name);
302
- return { ok: false, cancelled: true };
303
- }
304
- const round = ++state.rounds;
305
- const started = Date.now();
306
- const att = beginAttempt(runDir, claim.id);
307
- say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
308
- const sessionId = agent.resume ? state.sessions[agent.name] : undefined;
309
- const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
310
- // What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
311
- const activeHandling = unread.map((m, i) => ({ ...handling[i], digest: digestOf(m.meta.type, m.body, true) }));
312
- state.active[agent.name] = { round, since: new Date(started).toISOString(), handling: activeHandling };
313
- this.saveState();
314
- result = await invoke({ ...base, signal: opts.signal, logDir: attemptLogDir(runDir, att.id), userPrompt: attempt ? retryPrompt(base.userPrompt, result?.error) : base.userPrompt, sessionId }).catch((e) => ({
315
- ok: false,
316
- text: "",
317
- exitCode: null,
318
- timedOut: false,
319
- error: e.message,
320
- }));
321
- if (result.sessionId && agent.resume)
322
- state.sessions[agent.name] = result.sessionId;
323
- if (result.outputTokens)
324
- state.output_tokens += result.outputTokens;
325
- delete state.active[agent.name];
326
- state.wakes.push({
327
- round,
328
- agent: agent.name,
329
- at: new Date().toISOString(),
330
- duration_ms: Date.now() - started,
331
- ok: result.ok,
332
- output_tokens: result.outputTokens,
333
- handling,
334
- error: result.error,
335
- });
336
- state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error };
337
- log("wake", {
338
- agent: agent.name,
339
- runtime: agent.runtime,
340
- attempt,
341
- ok: result.ok,
342
- exit_code: result.exitCode,
343
- timed_out: result.timedOut,
344
- duration_ms: Date.now() - started,
345
- messages: unread.map((m) => m.meta.id),
346
- error: result.error,
347
- });
348
- this.saveState();
349
- if (result.ok) {
350
- attempts.set(agent.name, finishAttempt(runDir, att.id, "output_ready"));
351
- if (agent.resume && !result.sessionId && !state.sessions[agent.name] && !noSessionNoted.has(agent.name)) {
352
- noSessionNoted.add(agent.name);
353
- this.note(`${agent.name} has resume: true but its ${agent.runtime} CLI reported no session id, so every wake-up starts a fresh session.`);
338
+ return state.max_rounds > 0 && state.rounds >= state.max_rounds ? "stop" : undefined;
339
+ },
340
+ onSwitch: (from, to, kind) => {
341
+ replaced = from;
342
+ this.syncCooldowns();
343
+ if (opts.signal?.aborted)
344
+ return;
345
+ say(` ${agent.name} falling back to ${modelLabel(to)} (${kind ?? "unknown error"})`);
346
+ this.note(`${agent.name} fell back from ${modelLabel(from)} to ${modelLabel(to)} (${kind ?? "unknown error"}); the backup starts a fresh session and redoes this wake-up. ` +
347
+ `Anything the failed attempts already did outside the mailbox (file edits, commands) was not undone: side effects may have happened.`);
348
+ },
349
+ invoke: async (c, ctx) => {
350
+ calls++;
351
+ this.syncCooldowns();
352
+ const attempt = ctx.attempt;
353
+ // A backup runs as the same agent on another runtime/model, in a session of its own.
354
+ const runAs = agentForCandidate(agent, c);
355
+ const sessionKey = c.primary ? agent.name : `${agent.name}@${c.key}`;
356
+ const round = ++state.rounds;
357
+ const started = Date.now();
358
+ const att = beginAttempt(runDir, claim.id);
359
+ const retryN = chain ? ctx.candidateAttempt : attempt;
360
+ say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime})${retryN ? ` — retry ${retryN}` : ""}`);
361
+ const sessionId = agent.resume ? state.sessions[sessionKey] : undefined;
362
+ const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
363
+ // What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
364
+ const activeHandling = unread.map((m, i) => ({ ...handling[i], digest: digestOf(m.meta.type, m.body, true) }));
365
+ state.active[agent.name] = { round, since: new Date(started).toISOString(), handling: activeHandling };
366
+ this.saveState();
367
+ const result = await invoke({
368
+ ...base,
369
+ agent: runAs,
370
+ signal: opts.signal,
371
+ logDir: attemptLogDir(runDir, att.id),
372
+ userPrompt: attempt ? retryPrompt(base.userPrompt, ctx.previous?.error) : base.userPrompt,
373
+ sessionId,
374
+ }).catch((e) => ({
375
+ ok: false,
376
+ text: "",
377
+ exitCode: null,
378
+ timedOut: false,
379
+ error: e.message,
380
+ }));
381
+ if (result.sessionId && agent.resume)
382
+ state.sessions[sessionKey] = result.sessionId;
383
+ if (result.outputTokens)
384
+ state.output_tokens += result.outputTokens;
385
+ delete state.active[agent.name];
386
+ // With a chain only: which model ran, what it replaced, why it failed.
387
+ const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || !replaced ? undefined : modelLabel(replaced), kind: result.kind } : {};
388
+ state.wakes.push({
389
+ round,
390
+ agent: agent.name,
391
+ at: new Date().toISOString(),
392
+ duration_ms: Date.now() - started,
393
+ ok: result.ok,
394
+ output_tokens: result.outputTokens,
395
+ handling,
396
+ error: result.error,
397
+ ...which,
398
+ });
399
+ state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error, ...(chain ? { runtime: c.runtime, model: c.model } : {}) };
400
+ log("wake", {
401
+ agent: agent.name,
402
+ runtime: c.runtime,
403
+ attempt,
404
+ ok: result.ok,
405
+ exit_code: result.exitCode,
406
+ timed_out: result.timedOut,
407
+ duration_ms: Date.now() - started,
408
+ messages: unread.map((m) => m.meta.id),
409
+ error: result.error,
410
+ ...(chain ? { model: c.model, fallback_from: which.fallback_from, kind: result.kind } : {}),
411
+ });
412
+ this.saveState();
413
+ if (result.ok) {
414
+ attempts.set(agent.name, finishAttempt(runDir, att.id, "output_ready"));
415
+ if (agent.resume && !result.sessionId && !state.sessions[sessionKey] && !noSessionNoted.has(agent.name)) {
416
+ noSessionNoted.add(agent.name);
417
+ this.note(`${agent.name} has resume: true but its ${c.runtime} CLI reported no session id, so every wake-up starts a fresh session.`);
418
+ }
419
+ return result;
354
420
  }
355
- break;
356
- }
357
- if (result.cancelled) {
358
- // Stopped on purpose: not a failure to report. Its output is set aside and its input stays unread for the resume.
421
+ if (result.cancelled) {
422
+ // Stopped on purpose: not a failure to report. Its output is set aside (its input stays unread for the resume, below).
423
+ const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
424
+ finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
425
+ return result;
426
+ }
427
+ // A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
359
428
  const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
360
- finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
361
- abandonClaim(runDir, claim.id);
362
- claims.delete(agent.name);
363
- say(` ${agent.name} stopped (cancelled)`);
364
- return { ok: false, cancelled: true };
365
- }
366
- // A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
367
- const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
368
- finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
369
- say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
370
- this.note(`${agent.name} attempt ${att.n} failed (${result.error ?? "unknown error"})${parked.length ? `; ${parked.length} output file(s) set aside in ${path.join("mail", "attempts", att.id, "outbox")}` : ""}. ` +
371
- `Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
372
- }
373
- if (result?.ok)
429
+ finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
430
+ say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
431
+ this.note(`${agent.name} attempt ${att.n} failed (${result.error ?? "unknown error"})${parked.length ? `; ${parked.length} output file(s) set aside in ${path.join("mail", "attempts", att.id, "outbox")}` : ""}. ` +
432
+ `Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
433
+ return result;
434
+ },
435
+ });
436
+ if (this.syncCooldowns())
437
+ this.saveState();
438
+ const { result } = outcome;
439
+ if (result?.ok) {
374
440
  markOutputReady(runDir, claim.id);
375
- if (!result?.ok) {
376
- // The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
377
- abandonClaim(runDir, claim.id);
378
- claims.delete(agent.name);
379
- return { ok: false, error: result?.error, attempts: cfg.retry + 1 };
441
+ return { ok: true };
380
442
  }
381
- return { ok: true };
443
+ // The input stays unread for the resume: the round limit, or a cancel (during a call or a backoff).
444
+ abandonClaim(runDir, claim.id);
445
+ claims.delete(agent.name);
446
+ if (outcome.stopped === "beforeAttempt" && !stoppedByCancel)
447
+ return { ok: false, cancelled: true };
448
+ if (result?.cancelled || outcome.stopped) {
449
+ say(` ${agent.name} stopped (cancelled)`);
450
+ return { ok: false, cancelled: true };
451
+ }
452
+ // The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
453
+ return { ok: false, error: result?.error, attempts: calls, ...(chain ? { tried: lastPerModel(outcome.tried) } : {}) };
454
+ }
455
+ /** Copy the shared cooldown table into the state (only once something has cooled down); true when it was. */
456
+ syncCooldowns() {
457
+ const table = this.cooldowns.toJSON();
458
+ if (!Object.keys(table).length)
459
+ return false;
460
+ this.state.cooldowns = table;
461
+ return true;
382
462
  }
383
463
  /**
384
464
  * A finished member's files go from its worktree into the repo before anyone reads its mail, so the lead
@@ -543,7 +623,8 @@ export class RunSession {
543
623
  const failedIdx = results.findIndex((r) => !r.ok && !r.cancelled);
544
624
  if (failedIdx >= 0) {
545
625
  const failed = must(batch[failedIdx], "failed agent");
546
- this.failure = { agent: failed.name, error: results[failedIdx]?.error, attempts: results[failedIdx]?.attempts ?? this.cfg.retry + 1 };
626
+ const r = results[failedIdx];
627
+ this.failure = { agent: failed.name, error: r?.error, attempts: r?.attempts ?? this.cfg.retry + 1, ...(r?.tried ? { tried: r.tried } : {}) };
547
628
  state.failure = this.failure;
548
629
  this.endReason = failed.name === project.lead ? "lead_failed" : "agent_failed";
549
630
  return;