agent-lyceum 0.25.1 → 0.27.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.
Files changed (45) hide show
  1. package/README.md +6 -2
  2. package/README.zh-TW.md +6 -2
  3. package/dist/call.js +2 -0
  4. package/dist/config.d.ts +29 -1
  5. package/dist/config.js +34 -3
  6. package/dist/doctor-instances.d.ts +4 -0
  7. package/dist/doctor-instances.js +63 -0
  8. package/dist/doctor.d.ts +2 -0
  9. package/dist/doctor.js +17 -1
  10. package/dist/duplicate-notices.d.ts +9 -0
  11. package/dist/duplicate-notices.js +34 -0
  12. package/dist/fallback.d.ts +4 -2
  13. package/dist/fallback.js +11 -4
  14. package/dist/guard.d.ts +17 -1
  15. package/dist/guard.js +37 -6
  16. package/dist/instance-containment.d.ts +15 -0
  17. package/dist/instance-containment.js +36 -0
  18. package/dist/instances.d.ts +183 -0
  19. package/dist/instances.js +268 -0
  20. package/dist/mailbox.d.ts +46 -2
  21. package/dist/mailbox.js +121 -36
  22. package/dist/message-store.d.ts +47 -1
  23. package/dist/message-store.js +75 -7
  24. package/dist/paths.d.ts +7 -0
  25. package/dist/paths.js +27 -2
  26. package/dist/policy.d.ts +1 -0
  27. package/dist/policy.js +2 -2
  28. package/dist/prompt.d.ts +11 -0
  29. package/dist/prompt.js +66 -10
  30. package/dist/run-session.d.ts +35 -2
  31. package/dist/run-session.js +337 -20
  32. package/dist/run-store.d.ts +17 -3
  33. package/dist/run-store.js +17 -7
  34. package/dist/scaffold.d.ts +3 -0
  35. package/dist/scaffold.js +10 -1
  36. package/dist/schema.d.ts +19 -0
  37. package/dist/schema.js +12 -0
  38. package/dist/status.d.ts +7 -0
  39. package/dist/status.js +74 -15
  40. package/dist/tiers.d.ts +27 -0
  41. package/dist/tiers.js +49 -0
  42. package/dist/validate.js +28 -1
  43. package/dist/worktree.d.ts +6 -0
  44. package/dist/worktree.js +16 -2
  45. package/package.json +1 -1
@@ -1,15 +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
+ import { agentForCandidate, candidatesFor, Cooldowns, defaultSleep, planAttempts, runWithFallback } from "./fallback.js";
5
5
  import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./format.js";
6
6
  import { ProtectedGuard } from "./guard.js";
7
+ import { listRunLocks, lockHolderAlive } from "./project-lock.js";
7
8
  import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, resolveOptionLetters, writeAskReply } from "./ask-reply.js";
8
- import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
9
- import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, fault, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
9
+ import { deliver, deliverOnce, ensureAgentDirs, ensureProjectDirs, finishDone, listUnread, PENDING_DIR, readMessageNoFollow, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
10
+ import { instanceContainment } from "./instance-containment.js";
11
+ import { DEFAULT_MAX_INSTANCES, assertInstancesResumable, heldStallKey, instanceCap, ownersOf, parseSlotName, planAssignments, roleOfName } from "./instances.js";
12
+ import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, fault, finishAttempt, loadClaim, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
10
13
  import { createRunLog } from "./run-log.js";
11
14
  import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
12
- import { outboxDir, sharedIndex } from "./policy.js";
15
+ import { inboxDir, outboxDir, sharedIndex } from "./policy.js";
13
16
  import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
14
17
  import { collectDrafts, priorFailedAttempts, redoUserPrompt } from "./redo.js";
15
18
  import { atomicWrite, readFileCapped } from "./fs-util.js";
@@ -18,6 +21,7 @@ import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRun
18
21
  import { soloProject } from "./solo.js";
19
22
  import { taskMessageBody } from "./task.js";
20
23
  import { must } from "./assert.js";
24
+ import { modelForTier, wakeTier } from "./tiers.js";
21
25
  import { openPullRequest, prBody, prTitle, realRunner } from "./open-pr.js";
22
26
  export { newRunId };
23
27
  export const RESULT_FILE = "result.md";
@@ -35,6 +39,8 @@ function readIndex(file) {
35
39
  }
36
40
  /** The `state.sessions` key of the session an agent runs in on candidate `c`: a backup has a session of its own. */
37
41
  const sessionKeyOf = (agent, c) => (c.primary ? agent.name : `${agent.name}@${c.key}`);
42
+ /** Why `key` is cooling down, for a message. */
43
+ const cooldownKind = (cd, key) => cd.toJSON()[key]?.kind ?? "unknown";
38
44
  /** `runtime/model`, or the runtime alone for its default model. */
39
45
  const modelLabel = (c) => (c.model ? `${c.runtime}/${c.model}` : c.runtime);
40
46
  /** Each model given up on once, in order, with its last failure (retries on the same model are one entry). */
@@ -50,6 +56,30 @@ function lastPerModel(tried) {
50
56
  }
51
57
  return out;
52
58
  }
59
+ /**
60
+ * Whether same-role instances are possible in this run, decided once from configuration and paths (spec 23.11 preflight, reads only): when
61
+ * some member could run as several, the lead's prompt explains `parallel: true`. Writes nothing.
62
+ */
63
+ function withCapability(project, mode, check, table) {
64
+ if (!project.run)
65
+ return project;
66
+ const possible = Object.keys(project.agents).some((role) => {
67
+ if (project.agents[role]?.instance && project.agents[role]?.instance?.k !== 1)
68
+ return false;
69
+ return instanceCap(project, mode, role, check, table?.roles[role] ?? []).n > 1;
70
+ });
71
+ return possible ? { ...project, run: { ...project.run, instances: { max: project.dispatcher.max_instances ?? DEFAULT_MAX_INSTANCES } } } : project;
72
+ }
73
+ const WAIT_TEXT = {
74
+ blocked: "its earlier work is blocked: reply to the failure about it to redo it",
75
+ refused: "its worktree was refused",
76
+ cap: "it is above the instance limit",
77
+ busy: "every instance is busy",
78
+ cooldown: "waiting for a rate-limited model to recover",
79
+ collision: "its name is already an agent of the project",
80
+ containment: "an instance there could write the role's shared memory",
81
+ "unknown-slot": "no such instance",
82
+ };
53
83
  export class RunSession {
54
84
  opts;
55
85
  resume;
@@ -60,9 +90,21 @@ export class RunSession {
60
90
  cfg;
61
91
  runId;
62
92
  runDir;
63
- /** The project bound to this run (own memory dirs and mailboxes). */
93
+ /** The project bound to this run (own memory dirs and mailboxes). Bound again when the run gets instances (spec 23.6). */
64
94
  project;
95
+ /** The project as configured (solo-narrowed), before it is bound to this run. */
96
+ base;
97
+ layout;
98
+ /** Roles that get no new instance in this process: a check after an instance was created found a conflict. */
99
+ noMoreInstances = new Set();
100
+ heldLogged = new Set();
101
+ ignoredNoted = new Set();
102
+ /** Held mail the assigner left in `pending/` this round (read by the stall check). */
103
+ waiting = [];
65
104
  wsMode;
105
+ /** D-17: the paths an instance would get against the shared memory of its role (writes nothing). */
106
+ containment;
107
+ instanceCheck;
66
108
  log;
67
109
  guard;
68
110
  state;
@@ -92,6 +134,12 @@ export class RunSession {
92
134
  attempts = new Map();
93
135
  constructor(opts) {
94
136
  const { task, resume, runDir } = opts;
137
+ // A run whose slot table no longer fits the configuration is refused before anything is written.
138
+ if (resume?.instances) {
139
+ const soloOf = resume.solo_agent;
140
+ const cfgBase = soloOf ? soloProject(opts.project, soloOf) : opts.project;
141
+ assertInstancesResumable(cfgBase, resume.instances, path.basename(runDir), cfgBase.name, opts.instanceCheck ?? instanceContainment(cfgBase, runDir, resume.mail_layout).check);
142
+ }
95
143
  if (resume?.end_reason === "waiting")
96
144
  RunSession.assertAnswered(runDir, resume.run_id);
97
145
  if (opts.followup && resume?.end_reason !== "done")
@@ -106,7 +154,7 @@ export class RunSession {
106
154
  const runId = path.basename(runDir);
107
155
  const layout = resume ? resume.mail_layout : "run";
108
156
  // Each run gets its own memory directory per agent and (new runs) its own mailboxes.
109
- const project = bindRunProject(base, runDir, layout);
157
+ const project = bindRunProject(base, runDir, layout, resume?.instances);
110
158
  // Where agents work was decided before the lock was taken and is kept for the run's resumes. A resumed run that uses worktrees
111
159
  // is refused early (before anything is written) when its repo can no longer give them.
112
160
  const { plan } = opts;
@@ -197,7 +245,8 @@ export class RunSession {
197
245
  }
198
246
  const saveState = () => saveRunState(runDir, state);
199
247
  // Concurrent runs: the repo's instruction files the run can touch are the lead worktree's copies; your own tree is never watched (or restored).
200
- const guard = new ProtectedGuard(leadSpace ? { ...project, workspaces: { ...project.workspaces, [project.lead]: leadSpace } } : project);
248
+ const otherLiveRuns = () => listRunLocks(project.paths.root).filter((l) => l.run_id !== runId && lockHolderAlive(l)).map((l) => l.run_id);
249
+ const guard = new ProtectedGuard(leadSpace ? { ...project, workspaces: { ...project.workspaces, [project.lead]: leadSpace } } : project, otherLiveRuns);
201
250
  if (resume) {
202
251
  log("resume", { rounds: state.rounds, interrupted: Object.keys(resume.active ?? {}) });
203
252
  if (opts.followup) {
@@ -237,7 +286,11 @@ export class RunSession {
237
286
  this.cfg = cfg;
238
287
  this.runId = runId;
239
288
  this.runDir = runDir;
240
- this.project = project;
289
+ this.containment = instanceContainment(base, runDir, layout);
290
+ this.instanceCheck = opts.instanceCheck ?? this.containment.check;
291
+ this.project = withCapability(project, wsMode, opts.instanceCheck ?? this.containment.check, resume?.instances);
292
+ this.base = base;
293
+ this.layout = layout;
241
294
  this.wsMode = wsMode;
242
295
  this.log = log;
243
296
  this.guard = guard;
@@ -272,6 +325,216 @@ export class RunSession {
272
325
  log("note", { text });
273
326
  say(` ${text}`);
274
327
  }
328
+ /** Spec 24.5.2: log every tier that was dropped; tell the sender once per reason (`no-tiers` is log-only). */
329
+ noteTierIgnored(route) {
330
+ for (const t of route.tierIgnored) {
331
+ this.log("tier_ignored", { from: t.from, id: t.id, to: t.to, reason: t.reason });
332
+ if (t.reason === "no-tiers")
333
+ continue;
334
+ const key = `tier\u0000${t.from}\u0000${t.reason}`;
335
+ if (this.ignoredNoted.has(key))
336
+ continue;
337
+ this.ignoredNoted.add(key);
338
+ this.note(t.reason === "not-lead"
339
+ ? `${t.from}'s mail ${t.id} has \`tier\`, but only the lead (${this.project.lead}) can choose a member's tier: it was delivered without one.`
340
+ : `${t.from}'s mail ${t.id} ("${t.subject}") has \`tier\` but is a ${t.type}: tiers apply to tasks only, so it was delivered without one.`);
341
+ }
342
+ }
343
+ /** Bind the project to this run again, after its slot table changed. */
344
+ rebind() {
345
+ this.project = withCapability(bindRunProject(this.base, this.runDir, this.layout, this.state.instances), this.wsMode, this.instanceCheck, this.state.instances);
346
+ }
347
+ /** What the router needs to hold and place mail for roles with several instances (spec 23.7.1). */
348
+ routing() {
349
+ const { state, log } = this;
350
+ return {
351
+ cap: (role) => instanceCap(this.project, this.wsMode, role, this.instanceCheck, state.instances?.roles[role] ?? []),
352
+ held: (role) => !!state.instances?.roles[role],
353
+ enable: (role) => {
354
+ // Durable before any file of the mail that opens the role's instances exists; a crash after this replays into `pending/`.
355
+ state.schema_version = 3;
356
+ state.instances = { roles: { ...(state.instances?.roles ?? {}), [role]: [role] } };
357
+ this.saveState();
358
+ fault("instances:enabled");
359
+ this.rebind();
360
+ log("instances", { role, enabled: true });
361
+ },
362
+ ignored: (reason, m) => {
363
+ log("parallel_ignored", { from: m.from, id: m.id, to: m.to, reason });
364
+ const key = `${m.from}\u0000${reason}`;
365
+ if (this.ignoredNoted.has(key))
366
+ return;
367
+ this.ignoredNoted.add(key);
368
+ this.note(reason === "not-lead"
369
+ ? `${m.from}'s mail ${m.id} has \`parallel: true\`, but only the lead (${this.project.lead}) can start parallel instances: it was delivered as an ordinary task.`
370
+ : `${m.from}'s mail ${m.id} ("${m.subject}") has \`parallel: true\` but was delivered as an ordinary mail: ${reason}`);
371
+ },
372
+ };
373
+ }
374
+ /** Make a new instance durable and give it its folders; false (and the role gets no more instances in this process) when a check refuses it. */
375
+ openInstance(role, slot) {
376
+ const { state, log } = this;
377
+ const table = must(state.instances?.roles[role], `slot table of ${role}`);
378
+ if (this.noMoreInstances.has(role))
379
+ return false;
380
+ table.push(slot);
381
+ this.saveState();
382
+ fault("instances:opened");
383
+ this.rebind();
384
+ ensureAgentDirs(this.project, slot);
385
+ const agent = must(this.project.agents[slot], `instance ${slot}`);
386
+ const refuse = (why) => {
387
+ this.noMoreInstances.add(role);
388
+ this.refused.add(slot); // listed in the slot table, but never given mail or woken
389
+ log("instances", { role, slot, refused: why });
390
+ this.note(`${slot} was opened but cannot be used: ${why}. ${role} gets no further instances in this run process.`);
391
+ return false;
392
+ };
393
+ for (const dir of [outboxDir(this.project, slot), agent.memory.task]) {
394
+ if (!dir)
395
+ continue;
396
+ const st = fs.lstatSync(dir, { throwIfNoEntry: false });
397
+ if (!st || st.isSymbolicLink() || !st.isDirectory())
398
+ return refuse(`${dir} is not a plain directory`);
399
+ }
400
+ const k = parseSlotName(slot)?.k ?? 2;
401
+ const conflict = this.instanceCheck?.(role, k);
402
+ if (conflict)
403
+ return refuse(conflict);
404
+ log("instances", { role, slot, opened: true });
405
+ return true;
406
+ }
407
+ /** The one round of placing held mail, at the top of the loop (spec 23.7.2). Leaves what it could not place in `this.waiting`. */
408
+ assignHeld() {
409
+ this.waiting = [];
410
+ for (const role of Object.keys(this.state.instances?.roles ?? {}).sort())
411
+ this.assignRole(role);
412
+ }
413
+ assignRole(role) {
414
+ const { state, log, runDir } = this;
415
+ const opened = [...must(state.instances?.roles[role], `slot table of ${role}`)];
416
+ const pendingDir = path.join(inboxDir(this.project, role), PENDING_DIR);
417
+ if (!fs.existsSync(pendingDir))
418
+ return;
419
+ const files = fs.readdirSync(pendingDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md") && !e.name.startsWith(".")).map((e) => e.name).sort();
420
+ if (!files.length)
421
+ return;
422
+ const journal = this.journal();
423
+ const msgs = [];
424
+ const subjects = new Map();
425
+ for (const f of files) {
426
+ const entry = journal.byFile(f);
427
+ // Only the dispatcher's own held deliveries count: a file put here by anyone else is left alone.
428
+ if (!entry?.held) {
429
+ if (!this.heldLogged.has(`unknown:${f}`)) {
430
+ this.heldLogged.add(`unknown:${f}`);
431
+ log("held_unknown", { role, file: f });
432
+ }
433
+ continue;
434
+ }
435
+ let replyTo;
436
+ let thread;
437
+ try {
438
+ const m = readMessageNoFollow(path.join(pendingDir, f));
439
+ replyTo = m.meta.reply_to ? String(m.meta.reply_to) : undefined;
440
+ thread = m.meta.thread ? String(m.meta.thread) : undefined;
441
+ subjects.set(entry.msg_id, String(m.meta.subject ?? ""));
442
+ }
443
+ catch {
444
+ /* unreadable mail has no thread: it is placed as a new one */
445
+ }
446
+ msgs.push({ file: f, id: entry.msg_id, to: entry.to, replyTo, thread, parallel: entry.parallel === true });
447
+ }
448
+ if (!msgs.length)
449
+ return;
450
+ const mark = opened.map((n) => journal.firstHeld(n)).filter((x) => x !== undefined).reduce((a, b) => (a === undefined ? b : Math.min(a, b)), undefined);
451
+ const claimAgent = (id) => {
452
+ try {
453
+ return loadClaim(runDir, id).agent;
454
+ }
455
+ catch {
456
+ return undefined;
457
+ }
458
+ };
459
+ const cap = instanceCap(this.project, this.wsMode, role, this.instanceCheck, opened);
460
+ const roleAgent = must(this.project.agents[role], `agent ${role}`);
461
+ const primary = must(candidatesFor(roleAgent)[0], `model of ${role}`);
462
+ const plan = planAssignments({
463
+ role,
464
+ held: msgs,
465
+ opened,
466
+ cap: cap.n,
467
+ owners: (id) => ownersOf(id, role, { opened, rec: (x) => journal.ownership(x), mark, claimAgent }),
468
+ recorded: (id) => journal.assigned(id)?.slot,
469
+ acceptsNew: (slot) => {
470
+ if (this.refused.has(slot))
471
+ return "refused";
472
+ if (state.blocked_integrations?.some((b) => b.agent === slot))
473
+ return "blocked";
474
+ return (parseSlotName(slot)?.k ?? 1) > cap.n ? "cap" : true;
475
+ },
476
+ busy: (slot) => unreadFiles(this.project, slot).length > 0,
477
+ coolingDown: this.cooldowns.active(primary.key, Date.now()) > 0,
478
+ mayOpen: (name) => (this.noMoreInstances.has(role) ? "containment" : name in this.base.agents ? "collision" : true),
479
+ });
480
+ const byFile = new Map(msgs.map((m) => [m.file, m]));
481
+ const wait = (m, reason) => {
482
+ this.waiting.push({ id: m.id, role, to: m.to, subject: subjects.get(m.id) ?? "", reason });
483
+ const key = `${m.id}:${reason}`;
484
+ if (!this.heldLogged.has(key)) {
485
+ this.heldLogged.add(key);
486
+ log("held", { id: m.id, role, reason });
487
+ }
488
+ };
489
+ // The plan is applied one mail at a time, in plan order: a slot that could not be opened takes nothing later in this plan either.
490
+ const unusable = new Set();
491
+ for (const a of plan.assign) {
492
+ const m = must(byFile.get(a.file), `held mail ${a.file}`);
493
+ if (unusable.has(a.slot) || (a.open && !this.openInstance(role, a.open))) {
494
+ unusable.add(a.slot);
495
+ wait(m, "containment");
496
+ continue;
497
+ }
498
+ if (a.rule !== "recorded")
499
+ journal.assign({ msg_id: m.id, file: a.file, role, slot: a.slot });
500
+ fault("assign:recorded");
501
+ fs.mkdirSync(inboxDir(this.project, a.slot), { recursive: true });
502
+ fs.renameSync(path.join(pendingDir, a.file), path.join(inboxDir(this.project, a.slot), a.file));
503
+ fault("assign:moved");
504
+ log("assign", { id: m.id, role, slot: a.slot, rule: a.rule, ...(a.addressed ? { addressed: a.addressed } : {}) });
505
+ }
506
+ for (const w of plan.waiting)
507
+ wait(must(byFile.get(w.file), `held mail ${w.file}`), w.reason);
508
+ }
509
+ /**
510
+ * The run is about to end idle but mail is still held (spec D-16). Only a cooldown in the way: wait for it (cut short by a cancel) and
511
+ * return true to go round again. Otherwise tell the lead once per set of waiting mails; true when that mail is new (the lead has work).
512
+ */
513
+ async stallHeld() {
514
+ const { log, project, opts } = this;
515
+ const held = this.waiting;
516
+ if (held.length === 0)
517
+ return "idle";
518
+ if (held.every((w) => w.reason === "cooldown")) {
519
+ const now = Date.now();
520
+ const ms = Math.min(...[...new Set(held.map((w) => w.role))].map((r) => this.cooldowns.active(must(candidatesFor(must(this.project.agents[r], `agent ${r}`))[0], "model").key, now)).filter((x) => x > 0));
521
+ if (Number.isFinite(ms)) {
522
+ log("held_wait", { until: new Date(now + ms).toISOString() });
523
+ await (opts.sleep ?? defaultSleep)(ms, opts.signal);
524
+ return "retry";
525
+ }
526
+ return "retry";
527
+ }
528
+ const list = held.map((w) => `${w.id} "${w.subject}" for ${w.to} (${WAIT_TEXT[w.reason] ?? w.reason})`).join(", ");
529
+ const { fresh } = deliverOnce(project, this.journal(), heldStallKey(held.map((w) => w.id)), {
530
+ from: "dispatcher",
531
+ to: project.lead,
532
+ type: "failure",
533
+ subject: "Tasks are waiting and nothing is running",
534
+ body: `${held.length} task(s) are waiting and nothing else is running: ${list}. They are assigned as soon as that work is in the repo.`,
535
+ });
536
+ return fresh ? "notified" : "idle";
537
+ }
275
538
  pendingAgents() {
276
539
  const { project } = this;
277
540
  const rows = Object.values(project.agents)
@@ -312,11 +575,21 @@ export class RunSession {
312
575
  timeoutSec: cfg.wake_timeout_sec,
313
576
  };
314
577
  // Without a fallback chain (one candidate) the loop, its output and its records are exactly those of plain retries (spec 20.2 decision 11).
315
- const all = candidatesFor(agent);
578
+ // A lead's `tier` (spec 24.5.3): only for an agent with a ladder, and only a valid value counts.
579
+ const tier = agent.tiers && agent.tiers.length >= 2 ? wakeTier(unread.map((m) => m.meta.tier)) : undefined;
580
+ const all = candidatesFor(agent, tier && agent.tiers ? modelForTier(agent.tiers, tier) : undefined);
316
581
  const chain = all.length > 1;
317
- const candidates = chain ? planAttempts(agent, cooldowns, Date.now()) : all;
582
+ const candidates = chain ? planAttempts(all, cooldowns, Date.now()) : all;
583
+ const ceiling = must(all.find((c) => c.primary), "the agent's own model");
584
+ const tieredFirst = all[0]?.tiered ? all[0] : undefined;
318
585
  // 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.
319
- let replaced = candidates[0]?.primary ? undefined : all[0];
586
+ // A tier's cheaper model is not a fallback (spec 24.7.3): only chain entries have one.
587
+ let replaced = candidates[0]?.primary || candidates[0]?.tiered ? undefined : ceiling;
588
+ if (tieredFirst && !candidates[0]?.tiered) {
589
+ const left = Math.ceil(cooldowns.active(tieredFirst.key, Date.now()) / 1000);
590
+ say(` ${agent.name} tier ${tier} model ${modelLabel(tieredFirst)} is cooling down (${cooldownKind(cooldowns, tieredFirst.key)}, ${left}s left): starting on ${modelLabel(candidates[0])}`);
591
+ log("tier_step_up", { agent: agent.name, tier, from: modelLabel(tieredFirst), to: modelLabel(candidates[0]), reason: "cooldown" });
592
+ }
320
593
  let calls = 0;
321
594
  let stoppedByCancel = false;
322
595
  const outcome = await runWithFallback({
@@ -337,8 +610,19 @@ export class RunSession {
337
610
  return state.max_rounds > 0 && state.rounds >= state.max_rounds ? "stop" : undefined;
338
611
  },
339
612
  onSwitch: (from, to, kind) => {
340
- replaced = from;
613
+ if (!from.tiered)
614
+ replaced = from;
341
615
  this.syncCooldowns();
616
+ if (from.tiered) {
617
+ log("tier_step_up", { agent: agent.name, tier, from: modelLabel(from), to: modelLabel(to), reason: kind ?? "unknown" });
618
+ if (opts.signal?.aborted)
619
+ return;
620
+ say(` ${agent.name} stepping up from tier ${tier} (${modelLabel(from)}) to ${modelLabel(to)} (${kind ?? "unknown error"})`);
621
+ const resumesTo = agent.resume && !!state.sessions[sessionKeyOf(agent, to)];
622
+ this.note(`${agent.name}'s tier ${tier} model ${modelLabel(from)} failed (${kind ?? "unknown error"}); it is woken again on ${modelLabel(to)}, which ${resumesTo ? "resumes its own earlier session" : "starts a fresh session"}, and redoes this wake-up. ` +
623
+ `Anything the failed attempts already did outside the mailbox (file edits, commands) was not undone: side effects may have happened.`);
624
+ return;
625
+ }
342
626
  if (opts.signal?.aborted)
343
627
  return;
344
628
  say(` ${agent.name} falling back to ${modelLabel(to)} (${kind ?? "unknown error"})`);
@@ -360,7 +644,7 @@ export class RunSession {
360
644
  const redo = this.redoContext(agent.name, unread.map((m) => m.meta.id), att.id, attempt);
361
645
  const userPrompt = redoUserPrompt(base.userPrompt, { attempt, error: ctx.previous?.error, prior: redo.prior, drafts: redo.drafts, taskMemory: agent.memory.task });
362
646
  const retryN = chain ? ctx.candidateAttempt : attempt;
363
- say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime})${retryN ? ` — retry ${retryN}` : ""}`);
647
+ say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime}${c.tiered ? `, tier ${tier}` : ""})${retryN ? ` — retry ${retryN}` : ""}`);
364
648
  const sessionId = agent.resume ? state.sessions[sessionKey] : undefined;
365
649
  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) }));
366
650
  // What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
@@ -389,7 +673,7 @@ export class RunSession {
389
673
  state.cache_write_tokens += result.cacheWriteTokens ?? 0;
390
674
  delete state.active[agent.name];
391
675
  // With a chain only: which model ran, what it replaced, why it failed.
392
- const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || !replaced ? undefined : modelLabel(replaced), kind: result.kind } : {};
676
+ const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || c.tiered ? undefined : modelLabel(replaced ?? ceiling), kind: result.kind } : {};
393
677
  state.wakes.push({
394
678
  round,
395
679
  agent: agent.name,
@@ -402,8 +686,9 @@ export class RunSession {
402
686
  handling,
403
687
  error: result.error,
404
688
  ...which,
689
+ ...(tier ? { tier } : {}),
405
690
  });
406
- state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error, ...(chain ? { runtime: c.runtime, model: c.model } : {}) };
691
+ state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error, ...(chain ? { runtime: c.runtime, model: c.model } : {}), ...(tier ? { tier } : {}) };
407
692
  log("wake", {
408
693
  agent: agent.name,
409
694
  runtime: c.runtime,
@@ -416,6 +701,7 @@ export class RunSession {
416
701
  error: result.error,
417
702
  ...(redo.prior.length ? { redo_of: redo.prior.map((a) => a.id) } : {}),
418
703
  ...(chain ? { model: c.model, fallback_from: which.fallback_from, kind: result.kind } : {}),
704
+ ...(tier ? { tier } : {}),
419
705
  });
420
706
  this.saveState();
421
707
  if (result.ok) {
@@ -532,6 +818,8 @@ export class RunSession {
532
818
  }
533
819
  state.blocked_integrations = [...blocked, { agent: agent.name, branch: r.branch, reason: r.reason, report: r.report }];
534
820
  say(` ${agent.name}: changes NOT brought into the repo (${r.reason.split("\n")[0]})`);
821
+ // A role with instances: the notice is about one of them, so the lead's reply to it goes back to that instance (spec 23.9).
822
+ const instanced = !!state.instances?.roles[roleOfName(project.agents, agent.name)];
535
823
  deliverOnce(project, this.journal(), `integration:${key}`, {
536
824
  from: "dispatcher",
537
825
  to: project.lead,
@@ -540,8 +828,9 @@ export class RunSession {
540
828
  body: `${agent.name} finished, but its changes could not be brought into the repo, and nothing was applied.\n\n${r.reason}\n\n` +
541
829
  `Its work is kept: branch \`${r.branch}\`, worktree \`${r.workspace}\`, report \`${r.report}\`, patch \`${r.patch}\`.\n` +
542
830
  `You can send ${agent.name} a task to redo or adjust it (it continues in the same worktree and the integration is retried after its next wake-up), or apply the patch yourself. ` +
831
+ (instanced ? `New tasks are not given to ${agent.name} while this work is not in the repo. To redo or adjust it, reply to this message (or to ${agent.name}'s last mail): only replies reach ${agent.name} now. ` : "") +
543
832
  `Do not report the job \`completed\` while this work is missing from the repo.`,
544
- });
833
+ }, instanced ? { about: agent.name } : {});
545
834
  this.saveState();
546
835
  }
547
836
  /** A member whose worktree was refused: it is not woken again in this run, and the lead is told so it can react. */
@@ -555,7 +844,7 @@ export class RunSession {
555
844
  type: "failure",
556
845
  subject: `${agent.name} cannot be woken: its worktree was refused`,
557
846
  body: `${agent.name} has unread mail, but its worktree cannot be used, so it is not woken again in this run and its unread mail stays unread.\n\n${reason}\n\nDo its work yourself or give it to another agent; do not wait for ${agent.name}.`,
558
- });
847
+ }, this.state.instances?.roles[roleOfName(project.agents, agent.name)] ? { about: agent.name } : {});
559
848
  this.saveState();
560
849
  }
561
850
  /** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
@@ -574,12 +863,19 @@ export class RunSession {
574
863
  (v.saved ? `\n\nWhat was written is kept at \`${v.saved}\` (sha256 ${v.sha256}${v.truncated ? ", truncated to 1 MiB" : ""}). If it is worth keeping, have an agent that may edit this file redo it.` : ""),
575
864
  });
576
865
  }
866
+ // Another run's lead edited a cross-run shared file: kept, not a violation of this batch, nobody is woken for it.
867
+ for (const u of guard.takeUnattributed()) {
868
+ say(` shared file changed by another live run (${u.otherRuns.join(", ")}); kept: ${u.file}`);
869
+ log("protected-change-unattributed", { ...u });
870
+ }
871
+ // Crash seam: every wake of the batch is finished and marked output_ready; nothing of it is integrated or routed yet.
872
+ fault("wake:ready");
577
873
  batch.forEach((a, i) => {
578
874
  const ws = this.batchSpaces[a.name];
579
875
  if (ws && results[i]?.ok)
580
876
  this.integrateMember(a, ws, claims.get(a.name)?.id ?? `${a.name}-${state.rounds}`);
581
877
  });
582
- const route = routeOutboxes(project);
878
+ const route = routeOutboxes(project, this.routing());
583
879
  // A done that breaks the completion contract goes back to the lead (a limited number of times).
584
880
  let contract;
585
881
  if (route.done) {
@@ -603,6 +899,7 @@ export class RunSession {
603
899
  contract = undefined;
604
900
  }
605
901
  }
902
+ this.noteTierIgnored(route);
606
903
  const askAccepted = this.handleAsks(route);
607
904
  for (const d of route.delivered)
608
905
  log("route", d);
@@ -846,7 +1143,8 @@ export class RunSession {
846
1143
  async run() {
847
1144
  if (this.resume)
848
1145
  this.recover();
849
- const { say, runId, runDir, project, wsMode, log, state, opts } = this;
1146
+ const { say, runId, runDir, wsMode, log, state, opts } = this;
1147
+ let project = this.project;
850
1148
  // When the lead was the last one woken and left every mailbox empty without a `done`, remind it once instead of ending silently.
851
1149
  let leadWasLast = false;
852
1150
  let nudged = false;
@@ -855,6 +1153,8 @@ export class RunSession {
855
1153
  this.endReason = "cancelled";
856
1154
  break;
857
1155
  }
1156
+ this.assignHeld();
1157
+ project = this.project;
858
1158
  let pending = this.pendingAgents();
859
1159
  if (pending.length === 0 && leadWasLast && !nudged) {
860
1160
  nudged = true;
@@ -869,6 +1169,17 @@ export class RunSession {
869
1169
  });
870
1170
  pending = this.pendingAgents();
871
1171
  }
1172
+ if (pending.length === 0) {
1173
+ const stalled = await this.stallHeld();
1174
+ if (opts.signal?.aborted) {
1175
+ this.endReason = "cancelled";
1176
+ break;
1177
+ }
1178
+ if (stalled === "retry")
1179
+ continue;
1180
+ if (stalled === "notified")
1181
+ pending = this.pendingAgents();
1182
+ }
872
1183
  if (pending.length === 0) {
873
1184
  this.endReason = "idle";
874
1185
  break;
@@ -895,7 +1206,13 @@ export class RunSession {
895
1206
  }
896
1207
  for (const a of members) {
897
1208
  try {
898
- this.batchSpaces[a.name] = prepareAgentWorkspace(project, runId, a.name, ref);
1209
+ const ws = prepareAgentWorkspace(project, runId, a.name, ref);
1210
+ // An instance gets one more containment check with the worktree it really got (spec 23.11 point 3).
1211
+ const inst = a.instance;
1212
+ const conflict = inst ? (opts.instanceCheck ? opts.instanceCheck(inst.role, inst.k) : this.containment.checkWorkspace(inst.role, inst.k, ws)) : undefined;
1213
+ if (conflict)
1214
+ throw new Error(`${a.name} ${conflict}`);
1215
+ this.batchSpaces[a.name] = ws;
899
1216
  }
900
1217
  catch (e) {
901
1218
  this.refuseMember(a, e.message);
@@ -1,9 +1,14 @@
1
1
  import type { ResolvedProject } from "./config.js";
2
2
  import type { DigestSection, Step } from "./format.js";
3
- import { type ErrorKind, type Runtime, type RunOutcome } from "./schema.js";
3
+ import { type ErrorKind, type Runtime, type RunOutcome, type Tier } from "./schema.js";
4
4
  import type { ResultBranch } from "./worktree.js";
5
5
  export declare const STATE_FILE = "state.json";
6
- export declare const STATE_SCHEMA_VERSION = 2;
6
+ /** The newest state format this version reads. */
7
+ export declare const STATE_SCHEMA_VERSION = 3;
8
+ /** What a new run is written as: v3 only appears when the first parallel task opens instances (spec 23.6). */
9
+ export declare const NEW_RUN_SCHEMA_VERSION = 2;
10
+ /** Refuse a state newer than `supported`, before anything is written (the failure an older binary gives a v3 run). */
11
+ export declare function assertSupportedVersion(version: number | undefined, supported: number, file: string): void;
7
12
  export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "agent_failed" | "cancelled" | "waiting";
8
13
  export type MailLayout = "run" | "legacy";
9
14
  export interface WakeTopic {
@@ -46,6 +51,8 @@ export interface WakeRecord {
46
51
  fallback_from?: string;
47
52
  /** With a fallback chain only: why the call failed. */
48
53
  kind?: ErrorKind;
54
+ /** The tier this wake ran at (spec 24): set only when every mail it handled carried a valid one. */
55
+ tier?: Tier;
49
56
  }
50
57
  /** A model given up on during a wake with a fallback chain, for `failure.tried`. */
51
58
  export interface TriedModel {
@@ -118,6 +125,7 @@ export interface RunState {
118
125
  error?: string;
119
126
  runtime?: Runtime;
120
127
  model?: string;
128
+ tier?: Tier;
121
129
  }>;
122
130
  /** Process running this dispatcher, so a monitor can tell "running" from "interrupted". */
123
131
  pid: number;
@@ -146,6 +154,12 @@ export interface RunState {
146
154
  }[];
147
155
  /** `rounds` at the last reopen; the round limit counts from here. */
148
156
  round_floor?: number;
157
+ /** Same-role instances opened in this run (spec 23.6); present only from the first parallel task on, together with `schema_version` 3. */
158
+ instances?: InstanceTable;
159
+ }
160
+ /** `roles[<role>]`: the role's open instances in number order, `[0]` is the role itself. Only grows. */
161
+ export interface InstanceTable {
162
+ roles: Record<string, string[]>;
149
163
  }
150
164
  /** `20261006T010203456Z-<128 random bits>`: sorts by start time and cannot collide, even for two starts in one millisecond. */
151
165
  export declare function newRunId(d?: Date): string;
@@ -160,7 +174,7 @@ export declare function saveRunState(runDir: string, state: RunState): void;
160
174
  * The project as one run sees it. Per-task memory is always private to the run. With the "run" layout the
161
175
  * mailboxes live inside the run dir; with "legacy" they stay the shared project mailboxes older versions used.
162
176
  */
163
- export declare function bindRunProject(project: ResolvedProject, runDir: string, layout: MailLayout): ResolvedProject;
177
+ export declare function bindRunProject(project: ResolvedProject, runDir: string, layout: MailLayout, instances?: InstanceTable): ResolvedProject;
164
178
  /**
165
179
  * How a finished run turned out. Runs that ended before outcomes existed have none recorded: they read as
166
180
  * partial (failed for a failed lead) and `verified: false`, never as a success nobody checked.
package/dist/run-store.js CHANGED
@@ -2,10 +2,20 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
+ import { synthesizeSlots } from "./instances.js";
5
6
  import { atomicWrite } from "./fs-util.js";
6
7
  import { ERROR_KINDS, RUNTIMES } from "./schema.js";
7
8
  export const STATE_FILE = "state.json";
8
- export const STATE_SCHEMA_VERSION = 2;
9
+ /** The newest state format this version reads. */
10
+ export const STATE_SCHEMA_VERSION = 3;
11
+ /** What a new run is written as: v3 only appears when the first parallel task opens instances (spec 23.6). */
12
+ export const NEW_RUN_SCHEMA_VERSION = 2;
13
+ /** Refuse a state newer than `supported`, before anything is written (the failure an older binary gives a v3 run). */
14
+ export function assertSupportedVersion(version, supported, file) {
15
+ if ((version ?? 1) > supported) {
16
+ throw new Error(`Run state ${file} uses schema_version ${version}, newer than this agent-lyceum understands (${supported}). Upgrade agent-lyceum.`);
17
+ }
18
+ }
9
19
  /** `20261006T010203456Z-<128 random bits>`: sorts by start time and cannot collide, even for two starts in one millisecond. */
10
20
  export function newRunId(d = new Date()) {
11
21
  const t = d.toISOString().replace(/[-:]/g, "").replace(/\.(\d+)Z$/, "$1Z");
@@ -68,11 +78,12 @@ const WireState = z
68
78
  name: z.string().optional(),
69
79
  reopened: z.array(z.object({ at: z.string() })).optional(),
70
80
  round_floor: nonNeg.optional(),
81
+ instances: z.object({ roles: z.record(z.string(), z.array(z.string()).min(1)) }).optional(),
71
82
  })
72
83
  .passthrough();
73
84
  /** A fresh v2 state; every collection present. */
74
85
  export function newRunState(init) {
75
- return normalize({ ...init, schema_version: STATE_SCHEMA_VERSION, mail_layout: init.mail_layout ?? "run" });
86
+ return normalize({ ...init, schema_version: NEW_RUN_SCHEMA_VERSION, mail_layout: init.mail_layout ?? "run" });
76
87
  }
77
88
  function normalize(w) {
78
89
  const version = w.schema_version ?? 1;
@@ -119,9 +130,7 @@ export function loadRunState(runDir) {
119
130
  const msg = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
120
131
  throw new Error(`Run state is invalid (${file}): ${msg}`);
121
132
  }
122
- if ((parsed.data.schema_version ?? 1) > STATE_SCHEMA_VERSION) {
123
- throw new Error(`Run state ${file} uses schema_version ${parsed.data.schema_version}, newer than this agent-lyceum understands (${STATE_SCHEMA_VERSION}). Upgrade agent-lyceum.`);
124
- }
133
+ assertSupportedVersion(parsed.data.schema_version, STATE_SCHEMA_VERSION, file);
125
134
  return normalize(parsed.data);
126
135
  }
127
136
  export function saveRunState(runDir, state) {
@@ -131,9 +140,10 @@ export function saveRunState(runDir, state) {
131
140
  * The project as one run sees it. Per-task memory is always private to the run. With the "run" layout the
132
141
  * mailboxes live inside the run dir; with "legacy" they stay the shared project mailboxes older versions used.
133
142
  */
134
- export function bindRunProject(project, runDir, layout) {
143
+ export function bindRunProject(project, runDir, layout, instances) {
135
144
  const runId = path.basename(runDir);
136
- const agents = Object.fromEntries(Object.entries(project.agents).map(([n, a]) => [n, { ...a, memory: { ...a.memory, task: path.join(project.paths.taskMemory, runId, n) } }]));
145
+ const bound = Object.fromEntries(Object.entries(project.agents).map(([n, a]) => [n, { ...a, memory: { ...a.memory, task: path.join(project.paths.taskMemory, runId, n) } }]));
146
+ const agents = instances ? synthesizeSlots(bound, instances, (slot) => path.join(project.paths.taskMemory, runId, slot)) : bound;
137
147
  const paths = layout === "run"
138
148
  ? { ...project.paths, inboxRoot: path.join(runDir, "mail", "inbox"), outboxRoot: path.join(runDir, "mail", "outbox") }
139
149
  : project.paths;