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.
- package/README.md +1 -0
- package/README.zh-TW.md +1 -0
- package/dist/adapters/claude.d.ts +12 -0
- package/dist/adapters/claude.js +38 -1
- package/dist/adapters/codex.d.ts +7 -0
- package/dist/adapters/codex.js +42 -1
- package/dist/adapters/types.d.ts +5 -0
- package/dist/call.d.ts +5 -0
- package/dist/call.js +34 -11
- package/dist/config.d.ts +26 -1
- package/dist/config.js +53 -3
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +14 -5
- package/dist/fallback.d.ts +111 -0
- package/dist/fallback.js +157 -0
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js +4 -0
- package/dist/process-runner.js +4 -2
- package/dist/run-outcome.d.ts +3 -11
- package/dist/run-outcome.js +5 -0
- package/dist/run-session.d.ts +8 -6
- package/dist/run-session.js +164 -83
- package/dist/run-store.d.ts +31 -6
- package/dist/run-store.js +23 -1
- package/dist/schema.d.ts +105 -0
- package/dist/schema.js +14 -0
- package/dist/status.d.ts +16 -7
- package/dist/status.js +49 -1
- package/dist/validate.js +36 -0
- package/package.json +1 -1
package/dist/fallback.js
ADDED
|
@@ -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();
|
package/dist/process-runner.js
CHANGED
|
@@ -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
|
-
|
|
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 });
|
package/dist/run-outcome.d.ts
CHANGED
|
@@ -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;
|
package/dist/run-outcome.js
CHANGED
|
@@ -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}`,
|
package/dist/run-session.d.ts
CHANGED
|
@@ -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.
|
package/dist/run-session.js
CHANGED
|
@@ -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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
state.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
round,
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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:
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
return
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|