agent-lyceum 0.17.3 → 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 +4 -3
- package/README.zh-TW.md +4 -3
- package/dist/adapters/claude.d.ts +12 -0
- package/dist/adapters/claude.js +39 -8
- 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 +35 -13
- package/dist/cli.js +17 -9
- package/dist/config.d.ts +36 -7
- package/dist/config.js +75 -8
- package/dist/demo.js +0 -1
- package/dist/dispatcher.d.ts +2 -1
- package/dist/dispatcher.js +3 -1
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +25 -17
- package/dist/fallback.d.ts +111 -0
- package/dist/fallback.js +157 -0
- package/dist/paths.d.ts +0 -2
- package/dist/paths.js +0 -11
- package/dist/policy.d.ts +2 -6
- package/dist/policy.js +6 -10
- package/dist/process-runner.js +4 -2
- package/dist/project-lock.d.ts +10 -2
- package/dist/project-lock.js +23 -12
- package/dist/prompt.js +0 -3
- package/dist/run-outcome.d.ts +3 -11
- package/dist/run-outcome.js +5 -0
- package/dist/run-session.d.ts +11 -7
- package/dist/run-session.js +184 -113
- package/dist/run-store.d.ts +32 -7
- package/dist/run-store.js +23 -1
- package/dist/scaffold.js +0 -5
- package/dist/schema.d.ts +117 -22
- package/dist/schema.js +22 -7
- package/dist/solo.js +1 -2
- package/dist/status.d.ts +16 -7
- package/dist/status.js +49 -1
- package/dist/validate.js +41 -38
- package/dist/worktree.d.ts +19 -12
- package/dist/worktree.js +35 -74
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@ Give a task to a **team of agents** (Claude Code and/or Codex) instead of one ov
|
|
|
10
10
|
|
|
11
11
|
- **Roles that stay in role.** Each agent has its own persona (`AGENT.md`) and long-term memory, so a reviewer keeps reviewing instead of drifting into writing code.
|
|
12
12
|
- **Agents can't rewrite the rules.** An agent can only write its own memory and outbox. It can't touch other agents' context or its own persona, and this is enforced by the OS sandbox where possible, not just by a prompt. See [Write scope](docs/write-scope.md).
|
|
13
|
-
- **Mix runtimes.** Claude Code and Codex agents work in the same team
|
|
13
|
+
- **Mix runtimes.** Claude Code and Codex agents work in the same team.
|
|
14
|
+
- **Backup models.** If a model is rate-limited, out of quota or unreachable, a run can switch to a backup model or runtime instead of stopping. See [Configuration](docs/configuration.md#fallback-models).
|
|
14
15
|
- **Nothing hidden.** Agents talk through plain-file mailboxes, so every message, decision and memory is a file you can read, grep and diff. The dispatcher never merges for you.
|
|
15
16
|
|
|
16
17
|
**Not a fit** for small, one-shot edits: a single Claude Code or Codex session is faster and cheaper. Use a team when the task is big enough to split, review or run in parallel.
|
|
@@ -39,7 +40,7 @@ Latest unreleased code: `npx github:gogogohuang/agent-lyceum <command>`.
|
|
|
39
40
|
3. Agents reply by writing mail to their own `outbox/`; the dispatcher checks `can_message` and delivers it.
|
|
40
41
|
4. The run ends when the lead sends `done`.
|
|
41
42
|
|
|
42
|
-
Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyceum-config/`, not in your repo.
|
|
43
|
+
Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyceum-config/`, not in your repo. Concurrency needs no configuration. In a git repo with a commit, every run works in its own git worktree and ends on its own branch (`agent-lyceum/<run>/…`), members with pending mail wake in parallel, and any number of runs can be active at once; the result is not written into your working tree, so use `git merge <branch>` or `open_pr`, and note that a run starts from `HEAD` and does not see your uncommitted edits. In a non-git folder, a repo with no commit, or a repo using reftable, the run works directly in the folder, one agent at a time (an info line says why), and only one such run can be active. agent-lyceum never merges. Upgrading from a version with `owns`, `max_parallel`, `max_concurrent_runs` or `workspace_mode`: see [Upgrading](docs/upgrading-run-v2.md). More: [How a run works](docs/how-it-works.md), [Configuration](docs/configuration.md).
|
|
43
44
|
|
|
44
45
|
## Commands
|
|
45
46
|
|
|
@@ -68,7 +69,7 @@ dir: ~/code/web-app
|
|
|
68
69
|
team: { lead: lead }
|
|
69
70
|
agents:
|
|
70
71
|
lead: { can_message: all, can_edit_agent_md: true }
|
|
71
|
-
fe-member: { runtime: codex, can_message: [lead]
|
|
72
|
+
fe-member: { runtime: codex, can_message: [lead] }
|
|
72
73
|
pm: { can_message: [lead], allow_web: fetch }
|
|
73
74
|
```
|
|
74
75
|
|
package/README.zh-TW.md
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
- **角色不會跑掉。** 每個 agent 有自己的人設(`AGENT.md`)與長期記憶,reviewer 會一直做 review,不會漸漸變成在寫程式。
|
|
12
12
|
- **agent 改不了規則。** agent 只能寫自己的記憶與寄件匣(outbox),不能改其他 agent 的 context,也不能改自己的人設;能做到的地方由 OS 沙箱強制,而不只靠 prompt。見 [Write scope](docs/write-scope.zh-TW.md)。
|
|
13
|
-
- **混用 runtime。** Claude Code 與 Codex agent
|
|
13
|
+
- **混用 runtime。** Claude Code 與 Codex agent 可在同一個團隊。
|
|
14
|
+
- **備用模型。** 模型被限流、額度用完或連不上時,run 可以改用備用的模型或 runtime 繼續,而不是直接停下。見[設定](docs/configuration.zh-TW.md#備援模型)。
|
|
14
15
|
- **沒有黑箱。** agent 透過純檔案信箱溝通,每則訊息、決策與記憶都是你能讀、能 grep、能 diff 的檔案;dispatcher 不會替你 merge。
|
|
15
16
|
|
|
16
17
|
**不適合**小而單次的修改:單一 Claude Code 或 Codex session 更快更省。任務大到值得拆分、review 或平行執行時,再用團隊。
|
|
@@ -39,7 +40,7 @@ npx agent-lyceum status
|
|
|
39
40
|
3. agent 把回信寫進自己的 `outbox/`,dispatcher 檢查 `can_message` 後投遞。
|
|
40
41
|
4. lead 寄出 `done` 時,run 結束。
|
|
41
42
|
|
|
42
|
-
工具自己的資料(設定、信箱、log、記憶、worktree)放在 `~/agent-lyceum-config/`,不放進你的 repo
|
|
43
|
+
工具自己的資料(設定、信箱、log、記憶、worktree)放在 `~/agent-lyceum-config/`,不放進你的 repo。並行不需要任何設定。在有 commit 的 git repo 裡,每個 run 都在自己的 git worktree 工作,並結束在自己的 branch(`agent-lyceum/<run>/…`);有待處理信件的成員會平行喚醒,同時也可有任意多個 run;結果不會寫進你的工作目錄,請用 `git merge <branch>` 或 `open_pr`,且 run 從 `HEAD` 起跑,看不到你未提交的修改。在非 git 資料夾、沒有 commit 的 repo 或使用 reftable 的 repo,run 直接在該資料夾工作、一次只跑一個 agent(會有一行說明原因),且同時只能有一個這樣的 run。agent-lyceum 不會幫你合併。從有 `owns`、`max_parallel`、`max_concurrent_runs` 或 `workspace_mode` 的版本升級:見[升級](docs/upgrading-run-v2.md)。詳見[一次執行如何運作](docs/how-it-works.zh-TW.md)、[設定](docs/configuration.zh-TW.md)。
|
|
43
44
|
|
|
44
45
|
## 指令
|
|
45
46
|
|
|
@@ -68,7 +69,7 @@ dir: ~/code/web-app
|
|
|
68
69
|
team: { lead: lead }
|
|
69
70
|
agents:
|
|
70
71
|
lead: { can_message: all, can_edit_agent_md: true }
|
|
71
|
-
fe-member: { runtime: codex, can_message: [lead]
|
|
72
|
+
fe-member: { runtime: codex, can_message: [lead] }
|
|
72
73
|
pm: { can_message: [lead], allow_web: fetch }
|
|
73
74
|
```
|
|
74
75
|
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import type { ResolvedAgent, ResolvedProject } from "../config.js";
|
|
2
|
+
import type { ErrorKind } from "../schema.js";
|
|
2
3
|
import type { Invocation, WakeInput } from "./types.js";
|
|
3
4
|
export declare function buildClaudeSettings(project: ResolvedProject, agent: ResolvedAgent): Record<string, unknown>;
|
|
4
5
|
export declare function buildClaudeInvocation(input: WakeInput): Invocation;
|
|
6
|
+
/**
|
|
7
|
+
* The text the rules read: the result message (only when it is an error message: pass `result` for an `is_error` run), stderr, and the HTTP status
|
|
8
|
+
* the CLI reports as `api_error_status`. With no `result` and no result in stdout, stdout itself, but only when it is plain text: structured
|
|
9
|
+
* output (starting with `{` or `[`) without a result is a transcript, and the agent's own words there must not read as an error.
|
|
10
|
+
*/
|
|
11
|
+
export declare function classifyClaudeError(out: {
|
|
12
|
+
code: number | null;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
result?: string;
|
|
16
|
+
}): ErrorKind;
|
|
5
17
|
interface ClaudeResult {
|
|
6
18
|
result?: string;
|
|
7
19
|
session_id?: string;
|
package/dist/adapters/claude.js
CHANGED
|
@@ -27,13 +27,7 @@ export function buildClaudeSettings(project, agent) {
|
|
|
27
27
|
allow.push("WebSearch");
|
|
28
28
|
if (agent.allowWeb === "fetch")
|
|
29
29
|
allow.push("WebFetch");
|
|
30
|
-
|
|
31
|
-
for (const g of pol.owns)
|
|
32
|
-
allow.push(rule("Edit", path.join(repo, g)));
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
allow.push(rule("Edit", `${repo}/**`));
|
|
36
|
-
}
|
|
30
|
+
allow.push(rule("Edit", `${repo}/**`));
|
|
37
31
|
for (const d of pol.allowDirs)
|
|
38
32
|
allow.push(rule("Edit", `${d}/**`));
|
|
39
33
|
for (const f of pol.allowFiles)
|
|
@@ -94,9 +88,45 @@ export function buildClaudeInvocation(input) {
|
|
|
94
88
|
stream: () => claudeStream(),
|
|
95
89
|
};
|
|
96
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Ordered, first match wins: put the more specific kind before the broader one (a usage limit is `quota`, not `rate_limit`).
|
|
93
|
+
* Samples: observed = captured from a real run, documented = from the CLI/API docs, not yet seen in a log.
|
|
94
|
+
*/
|
|
95
|
+
const CLAUDE_RULES = [
|
|
96
|
+
// observed: `Invalid API key · Fix external API key` with api_error_status 401; documented: `Not logged in · Please run /login`
|
|
97
|
+
{ kind: "auth", re: /\bHTTP 401\b|invalid api key|not logged in|please run \/login/i },
|
|
98
|
+
// documented: `5-hour limit reached ∙ resets 3pm`, API 400 `Credit balance is too low`
|
|
99
|
+
{ kind: "quota", re: /\b(5-hour|weekly|usage) limit reached\b|credit balance is too low/i },
|
|
100
|
+
// documented: API 429 `rate_limit_error`, 529 `overloaded_error`
|
|
101
|
+
{ kind: "rate_limit", re: /\bHTTP (429|529)\b|rate_limit_error|rate limit (reached|exceeded)|overloaded_error|^api error: overloaded/im },
|
|
102
|
+
// observed: `API Error: Connection refused — ... (ECONNREFUSED)`; documented: the other Node error codes
|
|
103
|
+
{ kind: "network", re: /ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|socket hang up/i },
|
|
104
|
+
// documented: a native crash prints one of these (a signal kill has no exit code, see `isSignalKill`)
|
|
105
|
+
{ kind: "crash", re: /segmentation fault|heap out of memory/i },
|
|
106
|
+
];
|
|
107
|
+
/** A process that ended without an exit code was killed by a signal that was not ours (timeouts and cancels never get here). */
|
|
108
|
+
const isSignalKill = (code) => code === null;
|
|
109
|
+
function classifyWith(rules, code, text) {
|
|
110
|
+
const hit = rules.find((r) => r.re.test(text));
|
|
111
|
+
if (hit)
|
|
112
|
+
return hit.kind;
|
|
113
|
+
return isSignalKill(code) ? "crash" : "unknown"; // never default to crash: a wrong guess would switch models for nothing
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The text the rules read: the result message (only when it is an error message: pass `result` for an `is_error` run), stderr, and the HTTP status
|
|
117
|
+
* the CLI reports as `api_error_status`. With no `result` and no result in stdout, stdout itself, but only when it is plain text: structured
|
|
118
|
+
* output (starting with `{` or `[`) without a result is a transcript, and the agent's own words there must not read as an error.
|
|
119
|
+
*/
|
|
120
|
+
export function classifyClaudeError(out) {
|
|
121
|
+
const status = /"api_error_status":\s*(\d{3})/.exec(out.stdout)?.[1];
|
|
122
|
+
const j = extractClaudeResult(out.stdout);
|
|
123
|
+
const structured = /^[[{]/.test(out.stdout.trimStart());
|
|
124
|
+
const message = out.result ?? (j ? (j.is_error ? String(j.result ?? "") : "") : structured ? "" : out.stdout);
|
|
125
|
+
return classifyWith(CLAUDE_RULES, out.code, [message, out.stderr, status ? `HTTP ${status}` : ""].join("\n"));
|
|
126
|
+
}
|
|
97
127
|
function interpretClaude(j, stdout, stderr, code) {
|
|
98
128
|
if (!j)
|
|
99
|
-
return { ok: false, text: stdout, error: (stderr || stdout || `exit code ${code}`).slice(0, 500) };
|
|
129
|
+
return { ok: false, text: stdout, error: (stderr || stdout || `exit code ${code}`).slice(0, 500), kind: classifyClaudeError({ code, stdout, stderr }) };
|
|
100
130
|
const ok = code === 0 && !j.is_error;
|
|
101
131
|
return {
|
|
102
132
|
ok,
|
|
@@ -104,6 +134,7 @@ function interpretClaude(j, stdout, stderr, code) {
|
|
|
104
134
|
sessionId: j.session_id,
|
|
105
135
|
outputTokens: j.usage?.output_tokens,
|
|
106
136
|
error: ok ? undefined : String(j.result ?? stderr).slice(0, 500),
|
|
137
|
+
kind: ok ? undefined : classifyClaudeError({ code, stdout, stderr, result: j.is_error && j.result !== undefined ? String(j.result) : undefined }),
|
|
107
138
|
};
|
|
108
139
|
}
|
|
109
140
|
/** Reads `--output-format json` output line by line: only JSON lines are kept, and only the last few, so a flood of other output costs nothing. */
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import type { ErrorKind } from "../schema.js";
|
|
1
2
|
import type { Invocation, WakeInput } from "./types.js";
|
|
2
3
|
export declare function codexWritableRoots(input: WakeInput): string[];
|
|
3
4
|
/** Sum `output_tokens` over `turn.completed` events in `codex exec --json` output. */
|
|
4
5
|
export declare function codexOutputTokens(jsonl: string): number | undefined;
|
|
5
6
|
export declare function buildCodexInvocation(input: WakeInput): Invocation;
|
|
7
|
+
export declare function classifyCodexError(out: {
|
|
8
|
+
code: number | null;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
result?: string;
|
|
12
|
+
}): ErrorKind;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -100,6 +100,46 @@ export function buildCodexInvocation(input) {
|
|
|
100
100
|
},
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Ordered, first match wins: put the more specific kind before the broader one (an exhausted quota also arrives as a 429).
|
|
105
|
+
* Samples: observed = captured from a real `codex exec`, documented = from the CLI/API docs, not yet seen in a log.
|
|
106
|
+
*/
|
|
107
|
+
const CODEX_RULES = [
|
|
108
|
+
// documented: `You exceeded your current quota` / `insufficient_quota`, ChatGPT plans: `You've hit your usage limit`
|
|
109
|
+
{ kind: "quota", re: /insufficient_quota|exceeded your current quota|hit your usage limit/i },
|
|
110
|
+
// documented: `exceeded retry limit, last status: 429 Too Many Requests`, `rate_limit_exceeded`
|
|
111
|
+
{ kind: "rate_limit", re: /\bstatus:? 429\b|\b429 too many requests|rate_limit_exceeded|rate limit reached/i },
|
|
112
|
+
// observed: `unexpected status 401 Unauthorized: Incorrect API key provided ... invalid_api_key`; documented: `Not logged in`
|
|
113
|
+
{ kind: "auth", re: /\bstatus:? 401\b|\b401 unauthorized|invalid_api_key|incorrect api key|not logged in|codex login/i },
|
|
114
|
+
// `Reconnecting... waiting for network (Connection failed: error sending request)` is observed as a retry event only (a real codex with a dead
|
|
115
|
+
// network never ends by itself: the runner times it out); a final-failure sample is documented-not-observed, as are DNS / Node error codes
|
|
116
|
+
{ kind: "network", re: /waiting for network|error sending request|dns error|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT/i },
|
|
117
|
+
{ kind: "crash", re: /segmentation fault|panicked at/i },
|
|
118
|
+
];
|
|
119
|
+
/** The message of the last `turn.failed` event: the final verdict, as opposed to the reconnect noise before it. */
|
|
120
|
+
function failureMessage(stdout) {
|
|
121
|
+
for (const line of stdout.split("\n").reverse()) {
|
|
122
|
+
if (!line.includes('"turn.failed"'))
|
|
123
|
+
continue;
|
|
124
|
+
try {
|
|
125
|
+
const m = JSON.parse(line).error?.message;
|
|
126
|
+
if (typeof m === "string")
|
|
127
|
+
return m;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* cut off by the tail window: not JSON */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
export function classifyCodexError(out) {
|
|
136
|
+
// Only error channels are read: the final turn.failed message, else stderr. stdout also carries command output and the agent's own words.
|
|
137
|
+
const text = out.result ?? failureMessage(out.stdout) ?? out.stderr;
|
|
138
|
+
const hit = CODEX_RULES.find((r) => r.re.test(text));
|
|
139
|
+
if (hit)
|
|
140
|
+
return hit.kind;
|
|
141
|
+
return out.code === null ? "crash" : "unknown"; // killed by a signal; otherwise never guess crash
|
|
142
|
+
}
|
|
103
143
|
function finishCodex(lastFile, sessionId, outputTokens, stdout, stderr, code) {
|
|
104
144
|
let text = "";
|
|
105
145
|
try {
|
|
@@ -109,5 +149,6 @@ function finishCodex(lastFile, sessionId, outputTokens, stdout, stderr, code) {
|
|
|
109
149
|
/* no last message */
|
|
110
150
|
}
|
|
111
151
|
const ok = code === 0;
|
|
112
|
-
|
|
152
|
+
// `error` is the last 500 chars of stderr||stdout; `kind` comes from the classifier's own, narrower source preference
|
|
153
|
+
return { ok, text, sessionId, outputTokens, error: ok ? undefined : (stderr || stdout || `exit code ${code}`).slice(-500), kind: ok ? undefined : classifyCodexError({ code, stdout, stderr }) };
|
|
113
154
|
}
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ErrorKind } from "../schema.js";
|
|
1
2
|
import type { ResolvedAgent, ResolvedProject } from "../config.js";
|
|
2
3
|
import type { AgentWorkspace } from "../worktree.js";
|
|
3
4
|
export interface WakeInput {
|
|
@@ -26,6 +27,8 @@ export interface WakeResult {
|
|
|
26
27
|
error?: string;
|
|
27
28
|
/** Stopped because the run was cancelled, not because it failed. */
|
|
28
29
|
cancelled?: boolean;
|
|
30
|
+
/** Why it failed, for the fallback decision. Unset on success and on cancellation. */
|
|
31
|
+
kind?: ErrorKind;
|
|
29
32
|
}
|
|
30
33
|
export type Parsed = {
|
|
31
34
|
ok: boolean;
|
|
@@ -33,6 +36,8 @@ export type Parsed = {
|
|
|
33
36
|
sessionId?: string;
|
|
34
37
|
outputTokens?: number;
|
|
35
38
|
error?: string;
|
|
39
|
+
/** Set on failure only; `unknown` when no rule recognizes the output. */
|
|
40
|
+
kind?: ErrorKind;
|
|
36
41
|
};
|
|
37
42
|
/** Reads a runtime's stdout line by line while it runs, keeping only what it needs. */
|
|
38
43
|
export interface StreamParser {
|
package/dist/call.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Invoker } from "./adapters/index.js";
|
|
2
2
|
import { type ResolvedAgent, type ResolvedProject } from "./config.js";
|
|
3
|
+
import { type Tried } from "./fallback.js";
|
|
3
4
|
export declare const CALL_PROJECT = "(call)";
|
|
4
5
|
export declare const callsDir: (home: string) => string;
|
|
5
6
|
export interface CallOptions {
|
|
@@ -11,6 +12,8 @@ export interface CallOptions {
|
|
|
11
12
|
invoker?: Invoker;
|
|
12
13
|
signal?: AbortSignal;
|
|
13
14
|
say?: (line: string) => void;
|
|
15
|
+
/** Only used with a fallback chain, to wait out a cooldown; tests pass one that does not wait. */
|
|
16
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
14
17
|
}
|
|
15
18
|
export interface CallResult {
|
|
16
19
|
callId: string;
|
|
@@ -20,6 +23,8 @@ export interface CallResult {
|
|
|
20
23
|
/** The agent's final reply (empty when the call failed). */
|
|
21
24
|
text: string;
|
|
22
25
|
error?: string;
|
|
26
|
+
/** With a fallback chain: each model that failed, in order. */
|
|
27
|
+
tried?: Tried[];
|
|
23
28
|
/** Protected files the agent changed and agent-lyceum put back. */
|
|
24
29
|
reverted: string[];
|
|
25
30
|
}
|
package/dist/call.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { realInvoker } from "./adapters/index.js";
|
|
4
4
|
import { must } from "./assert.js";
|
|
5
5
|
import { ConfigError, loadGlobal, resolveProjectFrom } from "./config.js";
|
|
6
|
+
import { agentForCandidate, candidatesFor, Cooldowns, runWithFallback } from "./fallback.js";
|
|
6
7
|
import { atomicWrite } from "./fs-util.js";
|
|
7
8
|
import { ProtectedGuard } from "./guard.js";
|
|
8
9
|
import { assertName, homePaths, isInside } from "./paths.js";
|
|
@@ -33,7 +34,6 @@ export function resolveCallAgent(home, name) {
|
|
|
33
34
|
canMessage: [],
|
|
34
35
|
canAskUser: false,
|
|
35
36
|
canEditAgentMd: false,
|
|
36
|
-
owns: [],
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
/** The made-up project a call runs in: the working directory is its repo, and every other path is inside the call's own folder. */
|
|
@@ -54,7 +54,7 @@ export function callProject(home, agent, dir, callDir) {
|
|
|
54
54
|
name: CALL_PROJECT,
|
|
55
55
|
dir,
|
|
56
56
|
lead: agent.name,
|
|
57
|
-
dispatcher:
|
|
57
|
+
dispatcher: DISPATCHER_DEFAULTS,
|
|
58
58
|
agents: { [agent.name]: agent },
|
|
59
59
|
paths,
|
|
60
60
|
solo: "call",
|
|
@@ -138,23 +138,45 @@ export async function callAgent(o) {
|
|
|
138
138
|
const guard = new ProtectedGuard(project);
|
|
139
139
|
say(`Calling ${agent.name} in ${dir} (call ${callId})`);
|
|
140
140
|
const invoke = o.invoker ?? realInvoker;
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
141
|
+
const all = candidatesFor(agent);
|
|
142
|
+
const chain = all.length > 1;
|
|
143
|
+
// The primary logs to log/ as always; each backup to a directory of its own, so its run does not overwrite the primary's logs.
|
|
144
|
+
const logDirFor = (c) => (c.primary ? path.join(callDir, "log") : path.join(callDir, "log", `fallback-${all.indexOf(c)}`));
|
|
145
|
+
// A call is a single shot: no retries on one model, and cooldowns that live only as long as it does.
|
|
146
|
+
const outcome = await runWithFallback({
|
|
147
|
+
candidates: all,
|
|
148
|
+
legacy: !chain,
|
|
149
|
+
retry: 0,
|
|
150
|
+
cooldowns: new Cooldowns(),
|
|
151
|
+
cooldownSec: project.dispatcher.fallback_cooldown_sec,
|
|
148
152
|
signal: o.signal,
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
sleep: o.sleep,
|
|
154
|
+
onSwitch: (_from, to, kind) => {
|
|
155
|
+
if (o.signal?.aborted)
|
|
156
|
+
return;
|
|
157
|
+
say(` ${agent.name} falling back to ${to.model ? `${to.runtime}/${to.model}` : to.runtime} (${kind ?? "unknown error"})`);
|
|
158
|
+
},
|
|
159
|
+
invoke: (c) => invoke({
|
|
160
|
+
project,
|
|
161
|
+
agent: agentForCandidate(agent, c),
|
|
162
|
+
workDir: callDir,
|
|
163
|
+
systemPrompt: buildSoloSystemPrompt(project, agent),
|
|
164
|
+
userPrompt: buildCallUserPrompt(agent, o.task),
|
|
165
|
+
timeoutSec: project.dispatcher.wake_timeout_sec,
|
|
166
|
+
signal: o.signal,
|
|
167
|
+
logDir: logDirFor(c),
|
|
168
|
+
}).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
|
|
169
|
+
});
|
|
170
|
+
const result = must(outcome.result, "the call's result");
|
|
171
|
+
const tried = chain ? outcome.tried : undefined;
|
|
151
172
|
const reverted = guard.check([agent]).map((v) => v.file);
|
|
152
173
|
for (const f of reverted)
|
|
153
174
|
say(`warn ${agent.name} changed ${f}; it was put back.`);
|
|
154
175
|
const cancelled = !!result.cancelled;
|
|
155
176
|
const error = result.ok ? undefined : cancelled ? "cancelled" : (result.error ?? (result.timedOut ? "timed out" : "failed"));
|
|
156
|
-
|
|
157
|
-
|
|
177
|
+
const triedList = !result.ok && !cancelled && tried?.length ? `\nTried:\n${tried.map((t) => `- ${t.model ? `${t.runtime}/${t.model}` : t.runtime} (${t.kind ?? "unknown"}): ${t.error ?? "failed"}`).join("\n")}\n` : "";
|
|
178
|
+
atomicWrite(path.join(callDir, "result.md"), result.ok ? `${result.text.trimEnd()}\n` : `# Call ${cancelled ? "cancelled" : "failed"}\n\n${error}\n${triedList}`);
|
|
179
|
+
return { callId, callDir, ok: result.ok, cancelled, text: result.ok ? result.text : "", error, ...(tried ? { tried } : {}), reverted };
|
|
158
180
|
}
|
|
159
181
|
finally {
|
|
160
182
|
lease.release();
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { RESULT_FILE, runTeam } from "./dispatcher.js";
|
|
|
11
11
|
import { executeRunCleanup, planRunCleanup } from "./run-cleanup.js";
|
|
12
12
|
import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
|
|
13
13
|
import { acquireProjectLock, forceUnlock, inspectRunLock, listRunLocks, listUnreadableRunLocks, LOCK_FILE, lockHolderAlive } from "./project-lock.js";
|
|
14
|
+
import { planWorkspace } from "./worktree.js";
|
|
14
15
|
import { loadRunState, newRunId, outcomeOf, resolveRun, roundLimit, roundsLabel, runLabel, slugify, uniqueName } from "./run-store.js";
|
|
15
16
|
import { failureGuide } from "./run-outcome.js";
|
|
16
17
|
import { exitCodeForOutcome, RUNTIMES } from "./schema.js";
|
|
@@ -132,10 +133,10 @@ function findRun(pr, query) {
|
|
|
132
133
|
const list = r.candidates.map((c) => ` ${c.state.name ?? "(no name)"} ${c.state.run_id} ${c.state.task_summary}`).join("\n");
|
|
133
134
|
return fail(`"${query}" matches several runs:\n${list}\nGive more of the name or id.`);
|
|
134
135
|
}
|
|
135
|
-
/** Take the lock of one run, or exit with the reason. `
|
|
136
|
+
/** Take the lock of one run, or exit with the reason. `checkout`: the run works in the project checkout, so only one may be active. `exclusive`: it needs the whole project (memory tidy). */
|
|
136
137
|
function takeLock(pr, runId, opts = {}) {
|
|
137
138
|
try {
|
|
138
|
-
return acquireProjectLock(pr.paths.root, runId, {
|
|
139
|
+
return acquireProjectLock(pr.paths.root, runId, { checkout: opts.checkout, exclusive: opts.exclusive, dir: pr.dir, reason: opts.reason });
|
|
139
140
|
}
|
|
140
141
|
catch (e) {
|
|
141
142
|
return fail(e.message);
|
|
@@ -197,17 +198,23 @@ function reportRun(summary, runDir) {
|
|
|
197
198
|
const firstLine = (t) => t.trim().split("\n")[0].trim();
|
|
198
199
|
/** Take the lock and continue a run; the lock is released when it stops (also when it stops because it waits for answers). */
|
|
199
200
|
async function continueRun(pr, dir, runId, followup) {
|
|
200
|
-
const
|
|
201
|
+
const planned = loadRunState(dir);
|
|
202
|
+
// Where the run works is decided (from its state) before the lock is taken, and handed to the session as is.
|
|
203
|
+
const plan = planWorkspace(pr, planned);
|
|
204
|
+
const lease = takeLock(pr, runId, { checkout: !plan.leadWorktree, reason: "it was started without its own worktree" });
|
|
201
205
|
const cancel = cancelOnSignals();
|
|
202
206
|
try {
|
|
203
|
-
|
|
207
|
+
// Read the state again under the lock: another process may have changed it while we were checking.
|
|
204
208
|
const state = loadRunState(dir);
|
|
209
|
+
if (state.lead_workspace !== planned.lead_workspace || state.workspace_mode !== planned.workspace_mode) {
|
|
210
|
+
throw new Error(`Run ${runId} changed while it was starting (where it works was ${planned.lead_workspace ? "its own worktree" : planned.workspace_mode ?? "unset"}, now ${state.lead_workspace ? "its own worktree" : state.workspace_mode ?? "unset"}); try again.`);
|
|
211
|
+
}
|
|
212
|
+
console.log(`Project ${pr.name} — repo ${pr.dir}`);
|
|
205
213
|
if (followup)
|
|
206
214
|
console.log(`Reopening run ${runLabel(state)} (follow-up ${(state.reopened?.length ?? 0) + 1}): ${firstLine(followup.text)}`);
|
|
207
215
|
else
|
|
208
216
|
console.log(`Resuming run ${runLabel(state)} (${state.end_reason ?? "interrupted"}) at round ${roundsLabel(state.rounds, roundLimit(state, pr.dispatcher.max_rounds))}: ${state.task_summary}`);
|
|
209
|
-
|
|
210
|
-
return await runTeam({ project: pr, resume: state, runDir: dir, signal: cancel.signal, followup });
|
|
217
|
+
return await runTeam({ project: pr, resume: state, plan, runDir: dir, signal: cancel.signal, followup });
|
|
211
218
|
}
|
|
212
219
|
finally {
|
|
213
220
|
cancel.dispose();
|
|
@@ -289,7 +296,8 @@ program
|
|
|
289
296
|
}
|
|
290
297
|
const runId = newRunId();
|
|
291
298
|
const runDir = path.join(pr.paths.runs, runId);
|
|
292
|
-
const
|
|
299
|
+
const plan = planWorkspace(pr);
|
|
300
|
+
const lease = takeLock(pr, runId, { checkout: !plan.leadWorktree, reason: plan.reason });
|
|
293
301
|
let summary;
|
|
294
302
|
const cancel = cancelOnSignals();
|
|
295
303
|
try {
|
|
@@ -297,7 +305,7 @@ program
|
|
|
297
305
|
console.log(`Project ${pr.name} — repo ${pr.dir}${opts.agent ? ` — solo: ${opts.agent}` : ""}`);
|
|
298
306
|
console.log(`Task: ${prepared.source === "file" ? `file ${prepared.sourcePath}` : "text"} (${prepared.bytes} bytes${prepared.inline ? "" : ", passed by reference"})`);
|
|
299
307
|
const name = opts.name ?? uniqueName(slugify(taskMessageBody(prepared).subject), taken);
|
|
300
|
-
summary = await runTeam({ project: pr, name, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
|
|
308
|
+
summary = await runTeam({ project: pr, name, plan, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
|
|
301
309
|
}
|
|
302
310
|
finally {
|
|
303
311
|
cancel.dispose();
|
|
@@ -514,7 +522,7 @@ program
|
|
|
514
522
|
refuse(plan);
|
|
515
523
|
return;
|
|
516
524
|
}
|
|
517
|
-
const lease = takeLock(pr, full, {
|
|
525
|
+
const lease = takeLock(pr, full, { checkout: false });
|
|
518
526
|
try {
|
|
519
527
|
const plan = planRunCleanup(pr, full, { keepWorktrees: opts.keepWorktrees });
|
|
520
528
|
if (plan.refusals.length)
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ProjectPaths } from "./paths.js";
|
|
2
2
|
import type { AgentWorkspace } from "./worktree.js";
|
|
3
|
-
import { type DispatcherSettings, type Effort, type Runtime, type WebLevel } from "./schema.js";
|
|
3
|
+
import { type DispatcherSettings, type Effort, type FallbackEntry, type Runtime, type WebLevel } from "./schema.js";
|
|
4
4
|
export declare class ConfigError extends Error {
|
|
5
5
|
}
|
|
6
6
|
export type Source = "global" | "project" | "default" | "model";
|
|
@@ -22,7 +22,15 @@ export interface ResolvedAgent {
|
|
|
22
22
|
canAskUser: boolean;
|
|
23
23
|
/** Web access for this agent; undefined = not configured, so the runtime keeps its own default. */
|
|
24
24
|
allowWeb?: WebLevel;
|
|
25
|
-
|
|
25
|
+
/** Ordered failover chain (runtime and model filled in); empty = no fallback. */
|
|
26
|
+
fallback: FallbackEntry[];
|
|
27
|
+
/** True when the chain came from `dispatcher.fallback` rather than the agent's own setting. */
|
|
28
|
+
fallbackShared: boolean;
|
|
29
|
+
/** Entries of the chain that could not be resolved (left out of `fallback`); `validate` reports them. */
|
|
30
|
+
fallbackInvalid: {
|
|
31
|
+
index: number;
|
|
32
|
+
reason: "empty" | "no-runtime";
|
|
33
|
+
}[];
|
|
26
34
|
/** Where each field's value came from (for `validate` output). */
|
|
27
35
|
sources: Record<string, Source>;
|
|
28
36
|
}
|
|
@@ -35,6 +43,11 @@ export interface ResolvedProject {
|
|
|
35
43
|
dispatcher: DispatcherSettings;
|
|
36
44
|
agents: Record<string, ResolvedAgent>;
|
|
37
45
|
paths: ProjectPaths;
|
|
46
|
+
/** Removed settings still present in config files (ignored); `validate` warns once per file. */
|
|
47
|
+
ignoredSettings?: {
|
|
48
|
+
file: string;
|
|
49
|
+
keys: string[];
|
|
50
|
+
}[];
|
|
38
51
|
/** Set once the project is bound to one run (see `bindRunProject`): which run, and where its mail lives. */
|
|
39
52
|
run?: {
|
|
40
53
|
id: string;
|
|
@@ -61,7 +74,12 @@ export declare function loadGlobal(home: string): {
|
|
|
61
74
|
can_edit_agent_md?: boolean | undefined;
|
|
62
75
|
can_ask_user?: boolean | undefined;
|
|
63
76
|
allow_web?: "fetch" | "none" | "search" | undefined;
|
|
64
|
-
|
|
77
|
+
fallback?: {
|
|
78
|
+
runtime?: "claude-code" | "codex" | undefined;
|
|
79
|
+
model?: string | undefined;
|
|
80
|
+
effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
|
|
81
|
+
}[] | undefined;
|
|
82
|
+
owns?: unknown;
|
|
65
83
|
}>;
|
|
66
84
|
};
|
|
67
85
|
export declare function loadProjectRaw(home: string, name: string): {
|
|
@@ -71,13 +89,19 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
71
89
|
};
|
|
72
90
|
dispatcher: {
|
|
73
91
|
max_rounds?: number | undefined;
|
|
74
|
-
max_parallel?:
|
|
75
|
-
max_concurrent_runs?:
|
|
92
|
+
max_parallel?: unknown;
|
|
93
|
+
max_concurrent_runs?: unknown;
|
|
76
94
|
wake_timeout_sec?: number | undefined;
|
|
77
95
|
retry?: number | undefined;
|
|
78
96
|
strict?: boolean | undefined;
|
|
79
|
-
workspace_mode?:
|
|
97
|
+
workspace_mode?: unknown;
|
|
80
98
|
log_max_bytes?: number | undefined;
|
|
99
|
+
fallback?: {
|
|
100
|
+
runtime?: "claude-code" | "codex" | undefined;
|
|
101
|
+
model?: string | undefined;
|
|
102
|
+
effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
|
|
103
|
+
}[] | undefined;
|
|
104
|
+
fallback_cooldown_sec?: number | undefined;
|
|
81
105
|
};
|
|
82
106
|
agents: Record<string, {
|
|
83
107
|
runtime?: "claude-code" | "codex" | undefined;
|
|
@@ -93,7 +117,12 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
93
117
|
can_edit_agent_md?: boolean | undefined;
|
|
94
118
|
can_ask_user?: boolean | undefined;
|
|
95
119
|
allow_web?: "fetch" | "none" | "search" | undefined;
|
|
96
|
-
|
|
120
|
+
fallback?: {
|
|
121
|
+
runtime?: "claude-code" | "codex" | undefined;
|
|
122
|
+
model?: string | undefined;
|
|
123
|
+
effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
|
|
124
|
+
}[] | undefined;
|
|
125
|
+
owns?: unknown;
|
|
97
126
|
}>;
|
|
98
127
|
};
|
|
99
128
|
export declare function resolveProject(home: string, name: string): ResolvedProject;
|