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/dist/policy.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { findOnPath } from "./fs-util.js";
4
- import { globStaticPrefix } from "./paths.js";
5
4
  export function commonFile(project) {
6
5
  return path.join(project.paths.shared, "common", "COMMON.md");
7
6
  }
@@ -123,8 +122,6 @@ export function writePolicy(project, agent) {
123
122
  readDirs: shared && !mayWriteShared ? [shared] : [],
124
123
  allowFiles,
125
124
  deny: [...new Set(deny)],
126
- owns: agent.owns,
127
- restrictRepoToOwns: project.dispatcher.max_parallel > 1 && agent.owns.length > 0 && !isLead,
128
125
  };
129
126
  }
130
127
  let sandboxProbe;
@@ -139,16 +136,19 @@ function osSandboxAvailable() {
139
136
  sandboxProbe = false;
140
137
  return sandboxProbe;
141
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
+ }
142
143
  export function enforcementFor(project, agent) {
143
144
  const pol = writePolicy(project, agent);
144
145
  const os = osSandboxAvailable();
145
146
  const notes = [];
146
- const parallel = project.dispatcher.max_parallel > 1;
147
147
  const isLeadAgent = agent.name === project.lead;
148
148
  let e;
149
149
  if (agent.runtime === "claude-code") {
150
150
  const lvl = os ? "os" : "tool-rules";
151
- e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : lvl, repoInstructions: lvl, owns: parallel ? "tool-rules" : "n/a" };
151
+ e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : lvl, repoInstructions: lvl };
152
152
  if (!os)
153
153
  notes.push("OS sandbox unavailable (Windows, or Linux without bubblewrap): Bash can bypass Edit rules.");
154
154
  if (!isLeadAgent && !os)
@@ -156,7 +156,7 @@ export function enforcementFor(project, agent) {
156
156
  }
157
157
  else {
158
158
  const lvl = os ? "os" : "prompt-only";
159
- e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : os ? "os" : "post-hoc", repoInstructions: "post-hoc", owns: parallel ? "prompt-only" : "n/a" };
159
+ e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : os ? "os" : "post-hoc", repoInstructions: "post-hoc" };
160
160
  notes.push("Codex cannot forbid single files inside the repo; CLAUDE.md/AGENTS.md changes are detected and reverted after each wake-up.");
161
161
  if (!isLeadAgent && !os)
162
162
  notes.push("Without the OS sandbox only the handoff index (_shared/MEMORY.md) is checked after each wake-up; other files in _shared are not protected.");
@@ -171,10 +171,6 @@ export function enforcementFor(project, agent) {
171
171
  notes,
172
172
  };
173
173
  }
174
- /** Static directory prefixes (absolute) of an agent's `owns` globs. */
175
- export function ownsDirs(project, agent) {
176
- return agent.owns.map((g) => path.join(project.dir, globStaticPrefix(g)));
177
- }
178
174
  /** Can `agent` legitimately change this protected file? */
179
175
  export function mayEditProtected(project, agent, file) {
180
176
  return writePolicy(project, agent).allowFiles.includes(file);
@@ -83,12 +83,14 @@ export function runInvocation(inv, opts) {
83
83
  void Promise.all([endStream(outLog), endStream(errLog)]).then(() => {
84
84
  if (spawnError) {
85
85
  const hint = spawnError.code === "ENOENT" ? ` ("${inv.cmd}" not found on PATH)` : "";
86
- return resolve({ ok: false, text: "", exitCode: null, timedOut: false, error: `${spawnError.message}${hint}` });
86
+ // a missing binary is a crash the chain may route around (the next candidate can be another CLI); other spawn errors stay unclassified
87
+ const kind = hint ? "crash" : "unknown";
88
+ return resolve({ ok: false, text: "", exitCode: null, timedOut: false, error: `${spawnError.message}${hint}`, kind });
87
89
  }
88
90
  if (reason === "cancel")
89
91
  return resolve({ ...cancelled, exitCode: exitCode ?? null });
90
92
  if (reason === "timeout")
91
- return resolve({ ok: false, text: "", exitCode: exitCode ?? null, timedOut: true, error: `timed out after ${opts.timeoutSec}s` });
93
+ return resolve({ ok: false, text: "", exitCode: exitCode ?? null, timedOut: true, error: `timed out after ${opts.timeoutSec}s`, kind: "timeout" });
92
94
  const parsed = parser
93
95
  ? parser.finish({ code: exitCode ?? null, stdoutTail: stdoutTail.text(), stderrTail: stderrTail.text() })
94
96
  : inv.parse({ stdout: fallback.length ? fallback.toString("utf8") : stdoutTail.text(), stderr: stderrTail.text(), code: exitCode ?? null });
@@ -4,10 +4,13 @@ export declare const LOCK_FILE = "lock.json";
4
4
  export declare const LOCKS_DIR = "locks";
5
5
  export interface LockOptions {
6
6
  heartbeatMs?: number;
7
- /** Most runs that may hold a lock of this project at once, this one included. Default 1. */
8
- maxConcurrent?: number;
7
+ /** The run works directly in the project checkout (not in its own worktree): only one such run may be active. It is written with `exclusive` too, so older binaries keep out. */
8
+ checkout?: boolean;
9
9
  /** The holder needs the whole project (e.g. a memory tidy): refused while any other lock is alive, and it keeps every other lock out while held. */
10
10
  exclusive?: boolean;
11
+ /** For a checkout run's refusal message: the checkout directory, and why this run has no worktree. */
12
+ dir?: string;
13
+ reason?: string;
11
14
  }
12
15
  export interface LockInfo {
13
16
  token: string;
@@ -20,7 +23,12 @@ export interface LockInfo {
20
23
  heartbeat_at: string;
21
24
  /** Held by an operation that needs the project to itself (a memory tidy). */
22
25
  exclusive?: boolean;
26
+ /** `true`: works in the project checkout; `false`: in its own worktree (or holds no files, like `clear`). Absent: an older version's run, treated as a checkout run. */
27
+ checkout?: boolean;
23
28
  }
29
+ export type LockKind = "checkout" | "whole" | "worktree";
30
+ /** What a lock protects: the checkout, the whole project (tidy/restore), or nothing shared (a worktree run). Locks of older versions count as checkout runs. */
31
+ export declare function lockKind(l: Pick<LockInfo, "exclusive" | "checkout">): LockKind;
24
32
  export interface ProjectLease {
25
33
  readonly info: LockInfo;
26
34
  /** True once another process replaced our lock file; the run should stop writing. */
@@ -10,6 +10,16 @@ export const LOCK_FILE = "lock.json";
10
10
  export const LOCKS_DIR = "locks";
11
11
  const ADMIT_FILE = ".admit";
12
12
  const HEARTBEAT_MS = 5000;
13
+ /** What a lock protects: the checkout, the whole project (tidy/restore), or nothing shared (a worktree run). Locks of older versions count as checkout runs. */
14
+ export function lockKind(l) {
15
+ if (l.exclusive && l.checkout === true)
16
+ return "checkout";
17
+ if (l.exclusive)
18
+ return "whole";
19
+ if (l.checkout === false)
20
+ return "worktree";
21
+ return "checkout";
22
+ }
13
23
  /** Field 22 (starttime, in clock ticks) of a /proc/<pid>/stat line. The command name (field 2) may contain spaces and ")". */
14
24
  export function parseProcStat(text) {
15
25
  const end = text.lastIndexOf(")");
@@ -137,10 +147,9 @@ function heldError(info) {
137
147
  return new Error(`Run ${info.run_id} is still running (pid ${info.pid}, heartbeat ${Number.isFinite(late) ? late : "?"}s ago). ` +
138
148
  `A run cannot be started or resumed twice; wait for it to end, or stop it first.`);
139
149
  }
140
- function limitError(active, max) {
141
- const list = active.map((l) => `${l.run_id} (pid ${l.pid})`).join(", ");
142
- return new Error(`This project already has ${active.length} run(s) active: ${list}. At most ${max} may run at once (dispatcher.max_concurrent_runs: ${max}); ` +
143
- `wait for one to end, stop one, or raise max_concurrent_runs in project.yaml.`);
150
+ function checkoutBusyError(holder, dir, reason) {
151
+ return new Error(`Run ${holder.run_id} (pid ${holder.pid}) is working directly in ${dir}, and this run would too (${reason}), so only one can be active. ` +
152
+ `Wait for it to end or stop it; in a git repo with a commit, runs work in their own worktrees and can run side by side.`);
144
153
  }
145
154
  function exclusiveError(holder) {
146
155
  return new Error(`${holder.run_id} (pid ${holder.pid}) needs the project to itself (a memory tidy or restore); wait for it to finish, then try again.`);
@@ -214,7 +223,6 @@ export function acquireProjectLock(root, runId, opts = {}) {
214
223
  assertRunId(runId);
215
224
  fs.mkdirSync(path.join(root, LOCKS_DIR), { recursive: true });
216
225
  const file = runLockFile(root, runId);
217
- const max = opts.maxConcurrent ?? 1;
218
226
  const now = new Date().toISOString();
219
227
  const info = {
220
228
  token: crypto.randomBytes(16).toString("hex"),
@@ -224,8 +232,10 @@ export function acquireProjectLock(root, runId, opts = {}) {
224
232
  run_id: runId,
225
233
  acquired_at: now,
226
234
  heartbeat_at: now,
227
- ...(opts.exclusive ? { exclusive: true } : {}),
235
+ ...(opts.exclusive || opts.checkout ? { exclusive: true } : {}),
236
+ checkout: opts.checkout === true,
228
237
  };
238
+ const kind = lockKind(info);
229
239
  withAdmission(root, () => {
230
240
  // 1. This run's own lock: a live holder means it is already running; a dead one is replaced.
231
241
  const own = readLockFile(file);
@@ -246,13 +256,14 @@ export function acquireProjectLock(root, runId, opts = {}) {
246
256
  fs.rmSync(legacyFile(root), { force: true });
247
257
  // 3. The other runs that are active now.
248
258
  const active = listRunLocks(root).filter((l) => l.run_id !== runId && lockHolderAlive(l));
249
- const exclusiveHolder = active.find((l) => l.exclusive);
250
- if (exclusiveHolder)
251
- throw exclusiveError(exclusiveHolder);
252
- if (opts.exclusive && active.length)
259
+ const whole = active.find((l) => lockKind(l) === "whole");
260
+ if (whole)
261
+ throw exclusiveError(whole);
262
+ if (kind === "whole" && active.length)
253
263
  throw needsIdleError(active);
254
- if (active.length >= max)
255
- throw limitError(active, max);
264
+ const busy = kind === "checkout" ? active.find((l) => lockKind(l) === "checkout") : undefined;
265
+ if (busy)
266
+ throw checkoutBusyError(busy, opts.dir ?? root, opts.reason ?? "no worktree is available for it");
256
267
  if (!tryCreate(file, info))
257
268
  throw new Error(`Could not take the run lock ${file}; another process just took it. Try again.`);
258
269
  });
package/dist/prompt.js CHANGED
@@ -131,9 +131,6 @@ export function buildSystemPrompt(project, agent) {
131
131
  lines.push("", "## Rules");
132
132
  lines.push("- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`" +
133
133
  (isLead || agent.canEditAgentMd ? " unless you need to as lead (changes are logged)." : "."), "- Never touch another agent's memory, inbox or outbox, or the team's config files.", `- The shared team context is in \`${commonFile(project)}\`${isLead ? " (you maintain it; keep it short)" : " (read-only)"}.`, PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
134
- if (project.dispatcher.max_parallel > 1 && agent.owns.length > 0 && !isLead) {
135
- lines.push(`- You own these repo paths and must only change files there: ${agent.owns.join(", ")}.`);
136
- }
137
134
  return lines.join("\n");
138
135
  }
139
136
  /** Messages one wake-up handles: the oldest one, or for the lead a run of `reply`/`failure` mail (up to LEAD_BATCH_LIMIT) so it decides once on all of it. */
@@ -1,5 +1,5 @@
1
1
  import type { DoneContract } from "./format.js";
2
- import type { EndReason } from "./run-store.js";
2
+ import type { EndReason, RunFailure } from "./run-store.js";
3
3
  import type { RunOutcome } from "./schema.js";
4
4
  export interface UnintegratedWork {
5
5
  agent: string;
@@ -16,17 +16,9 @@ export declare function resolveDoneOutcome(contract: DoneContract, rejections: n
16
16
  export declare function endOutcome(endReason: EndReason, done?: {
17
17
  outcome: RunOutcome;
18
18
  note?: string;
19
- }, failure?: {
20
- agent: string;
21
- error?: string;
22
- attempts: number;
23
- }): {
19
+ }, failure?: RunFailure): {
24
20
  outcome: RunOutcome;
25
21
  note?: string;
26
22
  };
27
23
  /** What to tell the user when an agent kept failing and the run stopped: where to look, what to fix, how to go on. */
28
- export declare function failureGuide(project: string, run: string, f: {
29
- agent: string;
30
- error?: string;
31
- attempts: number;
32
- }, runDir: string): string;
24
+ export declare function failureGuide(project: string, run: string, f: RunFailure, runDir: string): string;
@@ -35,9 +35,14 @@ export function endOutcome(endReason, done, failure) {
35
35
  }
36
36
  /** What to tell the user when an agent kept failing and the run stopped: where to look, what to fix, how to go on. */
37
37
  export function failureGuide(project, run, f, runDir) {
38
+ // With a fallback chain: every model given up on, in order.
39
+ const tried = f.tried?.length
40
+ ? ["Models tried, in order:", ...f.tried.map((t, i) => ` ${i + 1}. ${t.model ? `${t.runtime}/${t.model}` : t.runtime}: ${t.kind ?? "unknown error"}${t.error ? ` (${t.error})` : ""}`)]
41
+ : [];
38
42
  return [
39
43
  "",
40
44
  `${f.agent} was called ${f.attempts} time(s) and failed every time (${f.error ?? "unknown error"}). The run stopped; nothing was lost: the message ${f.agent} was handling is still unread and the work done so far is kept.`,
45
+ ...tried,
41
46
  "To fix it:",
42
47
  ` 1. Read why it failed: ${path.join(runDir, "log.jsonl")} (event "wake"), and the full output of each attempt in ${path.join(runDir, "mail", "attempts")}`,
43
48
  ` 2. Fix the cause: the ${f.agent} CLI (installed, logged in, model name), a timeout (dispatcher.wake_timeout_sec), or its AGENT.md / the message it keeps failing on. Check with: agent-lyceum doctor -p ${project}`,
@@ -1,8 +1,8 @@
1
1
  import { type Invoker } from "./adapters/index.js";
2
2
  import type { ResolvedProject } from "./config.js";
3
3
  import type { RunOutcome } from "./schema.js";
4
- import { type ResultBranch } from "./worktree.js";
5
- import { newRunId, type EndReason, type RunState } from "./run-store.js";
4
+ import { type ResultBranch, type WorkspacePlan } from "./worktree.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;
@@ -52,6 +48,8 @@ export interface RunOptions {
52
48
  task?: PreparedTask;
53
49
  /** Continue this earlier run (same run dir, sessions and counters) instead of starting a new one. */
54
50
  resume?: RunState;
51
+ /** Where the run's agents work, decided by `planWorkspace` before the run's lock was taken; the session never recomputes it. */
52
+ plan: WorkspacePlan;
55
53
  /** Reopen a done run: this text becomes a new task for the lead. Only with `resume` of a run that ended `done`. */
56
54
  followup?: {
57
55
  text: string;
@@ -63,6 +61,8 @@ export interface RunOptions {
63
61
  /** Stop the run when this fires: running agents are killed, unread mail is kept, the run ends `cancelled` and can be resumed. */
64
62
  signal?: AbortSignal;
65
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>;
66
66
  }
67
67
  export declare class RunSession {
68
68
  private readonly opts;
@@ -87,6 +87,8 @@ export declare class RunSession {
87
87
  private doneOutcome;
88
88
  private doneNote;
89
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;
90
92
  private doneVerification;
91
93
  /** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
92
94
  private openPrRequested;
@@ -111,6 +113,8 @@ export declare class RunSession {
111
113
  private pendingAgents;
112
114
  private pickBatch;
113
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;
114
118
  /**
115
119
  * A finished member's files go from its worktree into the repo before anyone reads its mail, so the lead
116
120
  * sees them. When that cannot be done, nothing is applied, the member's work stays on its branch, and the lead is told.