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 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, and each can be limited to the paths it owns.
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. Agents edit your repo directly, or in git worktrees when running in parallel. agent-lyceum never merges. More: [How a run works](docs/how-it-works.md), [Configuration](docs/configuration.md).
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], owns: ["src/web/**"] }
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。agent 直接修改你的 repo;平行執行時則在各自的 git worktree 修改。agent-lyceum 不會幫你合併。詳見[一次執行如何運作](docs/how-it-works.zh-TW.md)、[設定](docs/configuration.zh-TW.md)。
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], owns: ["src/web/**"] }
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;
@@ -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
- if (pol.restrictRepoToOwns) {
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. */
@@ -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;
@@ -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
- return { ok, text, sessionId, outputTokens, error: ok ? undefined : (stderr || stdout || `exit code ${code}`).slice(-500) };
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
  }
@@ -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: { ...DISPATCHER_DEFAULTS, max_parallel: 1, workspace_mode: "shared" },
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 result = await invoke({
142
- project,
143
- agent,
144
- workDir: callDir,
145
- systemPrompt: buildSoloSystemPrompt(project, agent),
146
- userPrompt: buildCallUserPrompt(agent, o.task),
147
- timeoutSec: project.dispatcher.wake_timeout_sec,
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
- logDir: path.join(callDir, "log"),
150
- }).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message }));
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
- atomicWrite(path.join(callDir, "result.md"), result.ok ? `${result.text.trimEnd()}\n` : `# Call ${cancelled ? "cancelled" : "failed"}\n\n${error}\n`);
157
- return { callId, callDir, ok: result.ok, cancelled, text: result.ok ? result.text : "", error, reverted };
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. `ignoreLimit`: the caller does not start a run (e.g. `clear`). `exclusive`: it needs the whole project (memory tidy). */
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, { maxConcurrent: opts.ignoreLimit ? Number.MAX_SAFE_INTEGER : pr.dispatcher.max_concurrent_runs, exclusive: opts.exclusive });
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 lease = takeLock(pr, runId);
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
- console.log(`Project ${pr.name} — repo ${pr.dir}`);
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
- // Read the state again under the lock: another process may have changed it while we were checking.
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 lease = takeLock(pr, runId);
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, { ignoreLimit: true });
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
- owns: string[];
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
- owns?: string[] | undefined;
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?: number | undefined;
75
- max_concurrent_runs?: number | undefined;
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?: "auto" | "shared" | "worktree" | undefined;
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
- owns?: string[] | undefined;
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;