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.
@@ -1,18 +1,18 @@
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";
7
8
  import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
8
9
  import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
9
- import { isInside } from "./paths.js";
10
10
  import { createRunLog } from "./run-log.js";
11
11
  import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
12
- import { outboxDir, ownsDirs, sharedIndex } from "./policy.js";
12
+ import { outboxDir, sharedIndex } from "./policy.js";
13
13
  import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
14
14
  import { atomicWrite, readFileCapped } from "./fs-util.js";
15
- import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, resolveWorkspaceMode, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
15
+ import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
16
16
  import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRunState } from "./run-store.js";
17
17
  import { soloProject } from "./solo.js";
18
18
  import { taskMessageBody } from "./task.js";
@@ -36,6 +36,21 @@ function readIndex(file) {
36
36
  function retryPrompt(prompt, error) {
37
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.`;
38
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
+ }
39
54
  export class RunSession {
40
55
  opts;
41
56
  resume;
@@ -59,6 +74,8 @@ export class RunSession {
59
74
  doneOutcome;
60
75
  doneNote;
61
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;
62
79
  doneVerification;
63
80
  /** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
64
81
  openPrRequested = false;
@@ -91,12 +108,13 @@ export class RunSession {
91
108
  const layout = resume ? resume.mail_layout : "run";
92
109
  // Each run gets its own memory directory per agent and (new runs) its own mailboxes.
93
110
  const project = bindRunProject(base, runDir, layout);
94
- // Parallel agents work in their own git worktrees; refuse early (before anything is written) when that cannot be done.
95
- // Whether the lead has its own worktree is decided when the run starts and kept for its resumes, whatever the config says by then.
96
- const concurrent = resume ? resume.lead_workspace === true : cfg.max_concurrent_runs > 1;
97
- const wsMode = concurrent ? "worktree" : resolveWorkspaceMode({ ...cfg, max_concurrent_runs: 1 });
98
- if (wsMode === "worktree")
99
- assertWorktreeRunnable(project, { fresh: !resume, concurrent });
111
+ // Where agents work was decided before the lock was taken and is kept for the run's resumes. A resumed run that uses worktrees
112
+ // is refused early (before anything is written) when its repo can no longer give them.
113
+ const { plan } = opts;
114
+ const concurrent = plan.leadWorktree;
115
+ const wsMode = plan.mode;
116
+ if (resume && wsMode === "worktree")
117
+ assertWorktreeRunnable(project);
100
118
  // Concurrent runs: the lead works in its own worktree cut from HEAD, so no other run (and not you) shares its files.
101
119
  let leadSpace;
102
120
  let recut = false;
@@ -123,6 +141,9 @@ export class RunSession {
123
141
  say(` starting from HEAD ${leadSpace.base.slice(0, 8)}${uncommitted ? ` (your ${uncommitted} uncommitted file(s) are not part of this run)` : ""}; ${project.lead} works on branch ${leadSpace.branch}`);
124
142
  log("lead-workspace", { base: leadSpace.base, branch: leadSpace.branch, dir: leadSpace.dir, uncommitted });
125
143
  }
144
+ if (!resume && plan.mode === "shared" && plan.reason) {
145
+ say(` agents take turns in ${project.dir} (no separate git worktrees: ${plan.reason}); commit to a git repo to run them in parallel`);
146
+ }
126
147
  let state;
127
148
  if (resume) {
128
149
  state = { ...resume, max_rounds: roundLimit(resume, cfg.max_rounds), pid: process.pid, ended_at: undefined, end_reason: undefined, failure: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
@@ -222,6 +243,7 @@ export class RunSession {
222
243
  this.log = log;
223
244
  this.guard = guard;
224
245
  this.state = state;
246
+ this.cooldowns = Cooldowns.from(state.cooldowns);
225
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.
226
248
  this.sharedAtStart = !resume || opts.followup ? readIndex(sharedIndex(project)) : undefined;
227
249
  }
@@ -261,29 +283,16 @@ export class RunSession {
261
283
  return rows.map((r) => r.a);
262
284
  }
263
285
  pickBatch(pending) {
264
- const { cfg, project } = this;
286
+ const { project } = this;
265
287
  const lead = pending.find((a) => a.name === project.lead);
266
- if (lead || cfg.max_parallel <= 1)
267
- return [must(lead ?? pending[0], "an agent to wake")];
268
- const batch = [];
269
- const taken = [];
270
- for (const a of pending) {
271
- if (batch.length >= cfg.max_parallel)
272
- break;
273
- const mine = ownsDirs(project, a);
274
- const clash = mine.some((x) => taken.some((y) => isInside(x, y) || isInside(y, x)));
275
- if (clash || mine.length === 0) {
276
- if (batch.length === 0)
277
- batch.push(a);
278
- continue;
279
- }
280
- batch.push(a);
281
- taken.push(...mine);
282
- }
283
- return batch;
288
+ if (lead)
289
+ return [lead];
290
+ if (this.opts.plan.mode !== "worktree")
291
+ return [must(pending[0], "an agent to wake")];
292
+ return pending;
284
293
  }
285
294
  async wake(agent) {
286
- 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;
287
296
  const queue = listUnread(project, agent.name);
288
297
  if (queue.length === 0)
289
298
  return { ok: true };
@@ -303,92 +312,153 @@ export class RunSession {
303
312
  userPrompt: buildUserPrompt(project, agent, queue, unread.length, (state.reopened?.length ?? 0) > 0),
304
313
  timeoutSec: cfg.wake_timeout_sec,
305
314
  };
306
- let result;
307
- for (let attempt = 0; attempt <= cfg.retry; attempt++) {
308
- if (state.max_rounds > 0 && state.rounds >= state.max_rounds) {
315
+ // Without a fallback chain (one candidate) the loop, its output and its records are exactly those of plain retries (spec 20.2 decision 11).
316
+ const all = candidatesFor(agent);
317
+ const chain = all.length > 1;
318
+ const candidates = chain ? planAttempts(agent, cooldowns, Date.now()) : all;
319
+ // The model a backup's calls stand in for: the one switched away from in this wake, or the cooling-down primary it started past.
320
+ let replaced = candidates[0]?.primary ? undefined : all[0];
321
+ let calls = 0;
322
+ let stoppedByCancel = false;
323
+ const outcome = await runWithFallback({
324
+ candidates,
325
+ legacy: !chain,
326
+ retry: cfg.retry,
327
+ cooldowns,
328
+ cooldownSec: cfg.fallback_cooldown_sec,
329
+ signal: opts.signal,
330
+ sleep: opts.sleep,
331
+ beforeAttempt: (_c, ctx) => {
332
+ // A cancel that came during the previous call: no new model is tried (without a chain the call still goes ahead, as before).
333
+ if (chain && ctx.attempt > 0 && opts.signal?.aborted) {
334
+ stoppedByCancel = true;
335
+ return "stop";
336
+ }
309
337
  // The round limit (when one is set) ends the run; this message stays unread for the resume.
310
- abandonClaim(runDir, claim.id);
311
- claims.delete(agent.name);
312
- return { ok: false, cancelled: true };
313
- }
314
- const round = ++state.rounds;
315
- const started = Date.now();
316
- const att = beginAttempt(runDir, claim.id);
317
- say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
318
- const sessionId = agent.resume ? state.sessions[agent.name] : undefined;
319
- 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) }));
320
- // What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
321
- const activeHandling = unread.map((m, i) => ({ ...handling[i], digest: digestOf(m.meta.type, m.body, true) }));
322
- state.active[agent.name] = { round, since: new Date(started).toISOString(), handling: activeHandling };
323
- this.saveState();
324
- result = await invoke({ ...base, signal: opts.signal, logDir: attemptLogDir(runDir, att.id), userPrompt: attempt ? retryPrompt(base.userPrompt, result?.error) : base.userPrompt, sessionId }).catch((e) => ({
325
- ok: false,
326
- text: "",
327
- exitCode: null,
328
- timedOut: false,
329
- error: e.message,
330
- }));
331
- if (result.sessionId && agent.resume)
332
- state.sessions[agent.name] = result.sessionId;
333
- if (result.outputTokens)
334
- state.output_tokens += result.outputTokens;
335
- delete state.active[agent.name];
336
- state.wakes.push({
337
- round,
338
- agent: agent.name,
339
- at: new Date().toISOString(),
340
- duration_ms: Date.now() - started,
341
- ok: result.ok,
342
- output_tokens: result.outputTokens,
343
- handling,
344
- error: result.error,
345
- });
346
- state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error };
347
- log("wake", {
348
- agent: agent.name,
349
- runtime: agent.runtime,
350
- attempt,
351
- ok: result.ok,
352
- exit_code: result.exitCode,
353
- timed_out: result.timedOut,
354
- duration_ms: Date.now() - started,
355
- messages: unread.map((m) => m.meta.id),
356
- error: result.error,
357
- });
358
- this.saveState();
359
- if (result.ok) {
360
- attempts.set(agent.name, finishAttempt(runDir, att.id, "output_ready"));
361
- if (agent.resume && !result.sessionId && !state.sessions[agent.name] && !noSessionNoted.has(agent.name)) {
362
- noSessionNoted.add(agent.name);
363
- this.note(`${agent.name} has resume: true but its ${agent.runtime} CLI reported no session id, so every wake-up starts a fresh session.`);
338
+ return state.max_rounds > 0 && state.rounds >= state.max_rounds ? "stop" : undefined;
339
+ },
340
+ onSwitch: (from, to, kind) => {
341
+ replaced = from;
342
+ this.syncCooldowns();
343
+ if (opts.signal?.aborted)
344
+ return;
345
+ say(` ${agent.name} falling back to ${modelLabel(to)} (${kind ?? "unknown error"})`);
346
+ this.note(`${agent.name} fell back from ${modelLabel(from)} to ${modelLabel(to)} (${kind ?? "unknown error"}); the backup starts a fresh session and redoes this wake-up. ` +
347
+ `Anything the failed attempts already did outside the mailbox (file edits, commands) was not undone: side effects may have happened.`);
348
+ },
349
+ invoke: async (c, ctx) => {
350
+ calls++;
351
+ this.syncCooldowns();
352
+ const attempt = ctx.attempt;
353
+ // A backup runs as the same agent on another runtime/model, in a session of its own.
354
+ const runAs = agentForCandidate(agent, c);
355
+ const sessionKey = c.primary ? agent.name : `${agent.name}@${c.key}`;
356
+ const round = ++state.rounds;
357
+ const started = Date.now();
358
+ const att = beginAttempt(runDir, claim.id);
359
+ const retryN = chain ? ctx.candidateAttempt : attempt;
360
+ say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime})${retryN ? ` — retry ${retryN}` : ""}`);
361
+ const sessionId = agent.resume ? state.sessions[sessionKey] : undefined;
362
+ const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
363
+ // What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
364
+ const activeHandling = unread.map((m, i) => ({ ...handling[i], digest: digestOf(m.meta.type, m.body, true) }));
365
+ state.active[agent.name] = { round, since: new Date(started).toISOString(), handling: activeHandling };
366
+ this.saveState();
367
+ const result = await invoke({
368
+ ...base,
369
+ agent: runAs,
370
+ signal: opts.signal,
371
+ logDir: attemptLogDir(runDir, att.id),
372
+ userPrompt: attempt ? retryPrompt(base.userPrompt, ctx.previous?.error) : base.userPrompt,
373
+ sessionId,
374
+ }).catch((e) => ({
375
+ ok: false,
376
+ text: "",
377
+ exitCode: null,
378
+ timedOut: false,
379
+ error: e.message,
380
+ }));
381
+ if (result.sessionId && agent.resume)
382
+ state.sessions[sessionKey] = result.sessionId;
383
+ if (result.outputTokens)
384
+ state.output_tokens += result.outputTokens;
385
+ delete state.active[agent.name];
386
+ // With a chain only: which model ran, what it replaced, why it failed.
387
+ const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || !replaced ? undefined : modelLabel(replaced), kind: result.kind } : {};
388
+ state.wakes.push({
389
+ round,
390
+ agent: agent.name,
391
+ at: new Date().toISOString(),
392
+ duration_ms: Date.now() - started,
393
+ ok: result.ok,
394
+ output_tokens: result.outputTokens,
395
+ handling,
396
+ error: result.error,
397
+ ...which,
398
+ });
399
+ state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error, ...(chain ? { runtime: c.runtime, model: c.model } : {}) };
400
+ log("wake", {
401
+ agent: agent.name,
402
+ runtime: c.runtime,
403
+ attempt,
404
+ ok: result.ok,
405
+ exit_code: result.exitCode,
406
+ timed_out: result.timedOut,
407
+ duration_ms: Date.now() - started,
408
+ messages: unread.map((m) => m.meta.id),
409
+ error: result.error,
410
+ ...(chain ? { model: c.model, fallback_from: which.fallback_from, kind: result.kind } : {}),
411
+ });
412
+ this.saveState();
413
+ if (result.ok) {
414
+ attempts.set(agent.name, finishAttempt(runDir, att.id, "output_ready"));
415
+ if (agent.resume && !result.sessionId && !state.sessions[sessionKey] && !noSessionNoted.has(agent.name)) {
416
+ noSessionNoted.add(agent.name);
417
+ this.note(`${agent.name} has resume: true but its ${c.runtime} CLI reported no session id, so every wake-up starts a fresh session.`);
418
+ }
419
+ return result;
364
420
  }
365
- break;
366
- }
367
- if (result.cancelled) {
368
- // Stopped on purpose: not a failure to report. Its output is set aside and its input stays unread for the resume.
421
+ if (result.cancelled) {
422
+ // Stopped on purpose: not a failure to report. Its output is set aside (its input stays unread for the resume, below).
423
+ const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
424
+ finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
425
+ return result;
426
+ }
427
+ // A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
369
428
  const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
370
- finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
371
- abandonClaim(runDir, claim.id);
372
- claims.delete(agent.name);
373
- say(` ${agent.name} stopped (cancelled)`);
374
- return { ok: false, cancelled: true };
375
- }
376
- // A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
377
- const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
378
- finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
379
- say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
380
- 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")}` : ""}. ` +
381
- `Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
382
- }
383
- 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) {
384
440
  markOutputReady(runDir, claim.id);
385
- if (!result?.ok) {
386
- // The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
387
- abandonClaim(runDir, claim.id);
388
- claims.delete(agent.name);
389
- return { ok: false, error: result?.error, attempts: cfg.retry + 1 };
441
+ return { ok: true };
390
442
  }
391
- return { ok: true };
443
+ // The input stays unread for the resume: the round limit, or a cancel (during a call or a backoff).
444
+ abandonClaim(runDir, claim.id);
445
+ claims.delete(agent.name);
446
+ if (outcome.stopped === "beforeAttempt" && !stoppedByCancel)
447
+ return { ok: false, cancelled: true };
448
+ if (result?.cancelled || outcome.stopped) {
449
+ say(` ${agent.name} stopped (cancelled)`);
450
+ return { ok: false, cancelled: true };
451
+ }
452
+ // The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
453
+ return { ok: false, error: result?.error, attempts: calls, ...(chain ? { tried: lastPerModel(outcome.tried) } : {}) };
454
+ }
455
+ /** Copy the shared cooldown table into the state (only once something has cooled down); true when it was. */
456
+ syncCooldowns() {
457
+ const table = this.cooldowns.toJSON();
458
+ if (!Object.keys(table).length)
459
+ return false;
460
+ this.state.cooldowns = table;
461
+ return true;
392
462
  }
393
463
  /**
394
464
  * A finished member's files go from its worktree into the repo before anyone reads its mail, so the lead
@@ -399,7 +469,7 @@ export class RunSession {
399
469
  let changes;
400
470
  let r;
401
471
  try {
402
- changes = collectAgentChanges(ws, agent.owns);
472
+ changes = collectAgentChanges(ws);
403
473
  r = integrateAgentChanges(project, changes, this.leadSpace);
404
474
  }
405
475
  catch (e) {
@@ -553,7 +623,8 @@ export class RunSession {
553
623
  const failedIdx = results.findIndex((r) => !r.ok && !r.cancelled);
554
624
  if (failedIdx >= 0) {
555
625
  const failed = must(batch[failedIdx], "failed agent");
556
- this.failure = { agent: failed.name, error: results[failedIdx]?.error, attempts: results[failedIdx]?.attempts ?? this.cfg.retry + 1 };
626
+ const r = results[failedIdx];
627
+ this.failure = { agent: failed.name, error: r?.error, attempts: r?.attempts ?? this.cfg.retry + 1, ...(r?.tried ? { tried: r.tried } : {}) };
557
628
  state.failure = this.failure;
558
629
  this.endReason = failed.name === project.lead ? "lead_failed" : "agent_failed";
559
630
  return;
@@ -669,7 +740,7 @@ export class RunSession {
669
740
  if (endReason !== "done" || outcome !== "completed")
670
741
  return skip(`the run did not end completed (${endReason}, outcome ${outcome}).`);
671
742
  if (!this.leadSpace)
672
- return skip("this run has no worktree of its own, so there is no branch to push. Set dispatcher.max_concurrent_runs: 2 (or more) in project.yaml.");
743
+ return skip(`this run works directly in ${this.project.dir} (not a git repo with a commit), so there is no branch to push.`);
673
744
  if (!rb)
674
745
  return skip(`the work could not be committed on ${this.leadSpace.branch}.`);
675
746
  if (!rb.files)
@@ -1,6 +1,6 @@
1
1
  import type { ResolvedProject } from "./config.js";
2
2
  import type { DigestSection, Step } from "./format.js";
3
- import type { RunOutcome } from "./schema.js";
3
+ import { type ErrorKind, type Runtime, type RunOutcome } from "./schema.js";
4
4
  import type { ResultBranch } from "./worktree.js";
5
5
  export declare const STATE_FILE = "state.json";
6
6
  export declare const STATE_SCHEMA_VERSION = 2;
@@ -36,6 +36,27 @@ export interface WakeRecord {
36
36
  /** Mail this wake handed to others; `to` is "(done)" for the lead's final message. */
37
37
  sent?: SentTopic[];
38
38
  error?: string;
39
+ /** With a fallback chain only: the runtime and model this call ran on. */
40
+ runtime?: Runtime;
41
+ model?: string;
42
+ /** With a fallback chain only: the `runtime/model` this call replaced (set on a backup's calls). */
43
+ fallback_from?: string;
44
+ /** With a fallback chain only: why the call failed. */
45
+ kind?: ErrorKind;
46
+ }
47
+ /** A model given up on during a wake with a fallback chain, for `failure.tried`. */
48
+ export interface TriedModel {
49
+ runtime: Runtime;
50
+ model?: string;
51
+ kind?: ErrorKind;
52
+ error?: string;
53
+ }
54
+ /** The run stopped because an agent kept failing: who, the last error, how many times it was called; `tried` only with a fallback chain. */
55
+ export interface RunFailure {
56
+ agent: string;
57
+ error?: string;
58
+ attempts: number;
59
+ tried?: TriedModel[];
39
60
  }
40
61
  export interface RunState {
41
62
  /** 1 for runs written before versioning; 2 for runs with their own mailboxes. */
@@ -78,16 +99,19 @@ export interface RunState {
78
99
  snapshots?: number;
79
100
  /** Concurrent runs: where the lead's work ended up (`git merge <branch>`). Set when the run ends. */
80
101
  result_branch?: ResultBranch;
81
- /** Set when the run started with the lead in its own worktree (max_concurrent_runs > 1 then); its resumes keep that, whatever the config says later. */
102
+ /** Set when the run started with the lead in its own worktree (isolation mode); its resumes keep that, whatever the config says later. */
82
103
  lead_workspace?: true;
83
104
  /** The pull request the dispatcher opened for this run (when the lead asked for one and it worked). */
84
105
  pr_url?: string;
85
106
  sessions: Record<string, string>;
86
107
  output_tokens: number;
108
+ /** `runtime`/`model` only for agents with a fallback chain. */
87
109
  last_wake: Record<string, {
88
110
  at: string;
89
111
  ok: boolean;
90
112
  error?: string;
113
+ runtime?: Runtime;
114
+ model?: string;
91
115
  }>;
92
116
  /** Process running this dispatcher, so a monitor can tell "running" from "interrupted". */
93
117
  pid: number;
@@ -98,11 +122,12 @@ export interface RunState {
98
122
  /** Finished wakes, oldest first. */
99
123
  wakes: WakeRecord[];
100
124
  /** Set when the run stopped because an agent kept failing: who, the last error, how many times it was called. */
101
- failure?: {
102
- agent: string;
103
- error?: string;
104
- attempts: number;
105
- };
125
+ failure?: RunFailure;
126
+ /** Models cooling down after `rate_limit`/`quota` (`<runtime>/<model>` → until when); absent until one is. */
127
+ cooldowns?: Record<string, {
128
+ until: string;
129
+ kind: ErrorKind;
130
+ }>;
106
131
  /** Checklist progress: seeded from the task, then replaced by the lead's latest `## Steps` (after a reopen, added to `step_base`). */
107
132
  steps?: Step[];
108
133
  /** The checklist as it stood at the last reopen: the follow-up's steps are shown after it, and its open steps do not hold up the follow-up's done. */
package/dist/run-store.js CHANGED
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { atomicWrite } from "./fs-util.js";
6
+ import { ERROR_KINDS, RUNTIMES } from "./schema.js";
6
7
  export const STATE_FILE = "state.json";
7
8
  export const STATE_SCHEMA_VERSION = 2;
8
9
  /** `20261006T010203456Z-<128 random bits>`: sorts by start time and cannot collide, even for two starts in one millisecond. */
@@ -11,6 +12,19 @@ export function newRunId(d = new Date()) {
11
12
  return `${t}-${crypto.randomBytes(16).toString("hex")}`;
12
13
  }
13
14
  const nonNeg = z.number().finite();
15
+ const CooldownEntry = z.object({ until: z.string(), kind: z.enum(ERROR_KINDS) });
16
+ /** `state.cooldowns` is advisory: keep the entries that parse and drop the rest, so one bad entry never makes the run unreadable. */
17
+ function lenientCooldowns(raw) {
18
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
19
+ return undefined;
20
+ const out = {};
21
+ for (const [k, v] of Object.entries(raw)) {
22
+ const e = CooldownEntry.safeParse(v);
23
+ if (e.success)
24
+ out[k] = e.data;
25
+ }
26
+ return out;
27
+ }
14
28
  const WireState = z
15
29
  .object({
16
30
  schema_version: z.number().int().optional(),
@@ -39,7 +53,15 @@ const WireState = z
39
53
  wakes: z.array(z.any()).optional(),
40
54
  solo_agent: z.string().optional(),
41
55
  steps: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
42
- failure: z.object({ agent: z.string(), error: z.string().optional(), attempts: z.number() }).optional(),
56
+ failure: z
57
+ .object({
58
+ agent: z.string(),
59
+ error: z.string().optional(),
60
+ attempts: z.number(),
61
+ tried: z.array(z.object({ runtime: z.enum(RUNTIMES), model: z.string().optional(), kind: z.enum(ERROR_KINDS).optional(), error: z.string().optional() })).optional(),
62
+ })
63
+ .optional(),
64
+ cooldowns: z.unknown().transform(lenientCooldowns).optional(),
43
65
  step_base: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
44
66
  name: z.string().optional(),
45
67
  reopened: z.array(z.object({ at: z.string() })).optional(),
package/dist/scaffold.js CHANGED
@@ -86,9 +86,6 @@ team:
86
86
  lead: lead
87
87
  dispatcher:
88
88
  max_rounds: 0 # 0 = no limit on wake-ups; set a number to stop a run after that many
89
- max_parallel: 1 # >1 needs non-overlapping "owns" for every non-lead agent, and a git repo
90
- max_concurrent_runs: 1 # >1 lets several runs of this project be active at once, each in its own git worktree (needs a git repo); every run's work stays on its own branch
91
- workspace_mode: auto # auto = a git worktree per non-lead agent when max_parallel > 1; shared | worktree to force
92
89
  wake_timeout_sec: 600
93
90
  retry: 2 # an agent that fails is called again up to this many times; if it still fails the run stops (resume it after fixing the cause)
94
91
  log_max_bytes: 8388608 # log.jsonl is renamed to log.1.jsonl past this size; 0 = never
@@ -101,11 +98,9 @@ agents:
101
98
  can_edit_agent_md: true
102
99
  fe-member:
103
100
  can_message: [lead]
104
- # owns: ["src/web/**"]
105
101
  # memory: { project: agents/fe-member/memory }
106
102
  qa-member:
107
103
  can_message: [lead]
108
- # owns: ["tests/**"]
109
104
  `;
110
105
  fs.writeFileSync(pp.config, yaml);
111
106
  const common = path.join(pp.shared, "common", "COMMON.md");