agent-lyceum 0.25.1 → 0.26.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,15 +1,17 @@
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
7
  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";
8
+ import { deliver, deliverOnce, ensureAgentDirs, ensureProjectDirs, finishDone, listUnread, PENDING_DIR, readMessageNoFollow, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
9
+ import { instanceContainment } from "./instance-containment.js";
10
+ import { DEFAULT_MAX_INSTANCES, assertInstancesResumable, heldStallKey, instanceCap, ownersOf, parseSlotName, planAssignments, roleOfName } from "./instances.js";
11
+ import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, fault, finishAttempt, loadClaim, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
10
12
  import { createRunLog } from "./run-log.js";
11
13
  import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
12
- import { outboxDir, sharedIndex } from "./policy.js";
14
+ import { inboxDir, outboxDir, sharedIndex } from "./policy.js";
13
15
  import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
14
16
  import { collectDrafts, priorFailedAttempts, redoUserPrompt } from "./redo.js";
15
17
  import { atomicWrite, readFileCapped } from "./fs-util.js";
@@ -50,6 +52,30 @@ function lastPerModel(tried) {
50
52
  }
51
53
  return out;
52
54
  }
55
+ /**
56
+ * Whether same-role instances are possible in this run, decided once from configuration and paths (spec 23.11 preflight, reads only): when
57
+ * some member could run as several, the lead's prompt explains `parallel: true`. Writes nothing.
58
+ */
59
+ function withCapability(project, mode, check, table) {
60
+ if (!project.run)
61
+ return project;
62
+ const possible = Object.keys(project.agents).some((role) => {
63
+ if (project.agents[role]?.instance && project.agents[role]?.instance?.k !== 1)
64
+ return false;
65
+ return instanceCap(project, mode, role, check, table?.roles[role] ?? []).n > 1;
66
+ });
67
+ return possible ? { ...project, run: { ...project.run, instances: { max: project.dispatcher.max_instances ?? DEFAULT_MAX_INSTANCES } } } : project;
68
+ }
69
+ const WAIT_TEXT = {
70
+ blocked: "its earlier work is blocked: reply to the failure about it to redo it",
71
+ refused: "its worktree was refused",
72
+ cap: "it is above the instance limit",
73
+ busy: "every instance is busy",
74
+ cooldown: "waiting for a rate-limited model to recover",
75
+ collision: "its name is already an agent of the project",
76
+ containment: "an instance there could write the role's shared memory",
77
+ "unknown-slot": "no such instance",
78
+ };
53
79
  export class RunSession {
54
80
  opts;
55
81
  resume;
@@ -60,9 +86,21 @@ export class RunSession {
60
86
  cfg;
61
87
  runId;
62
88
  runDir;
63
- /** The project bound to this run (own memory dirs and mailboxes). */
89
+ /** The project bound to this run (own memory dirs and mailboxes). Bound again when the run gets instances (spec 23.6). */
64
90
  project;
91
+ /** The project as configured (solo-narrowed), before it is bound to this run. */
92
+ base;
93
+ layout;
94
+ /** Roles that get no new instance in this process: a check after an instance was created found a conflict. */
95
+ noMoreInstances = new Set();
96
+ heldLogged = new Set();
97
+ ignoredNoted = new Set();
98
+ /** Held mail the assigner left in `pending/` this round (read by the stall check). */
99
+ waiting = [];
65
100
  wsMode;
101
+ /** D-17: the paths an instance would get against the shared memory of its role (writes nothing). */
102
+ containment;
103
+ instanceCheck;
66
104
  log;
67
105
  guard;
68
106
  state;
@@ -92,6 +130,12 @@ export class RunSession {
92
130
  attempts = new Map();
93
131
  constructor(opts) {
94
132
  const { task, resume, runDir } = opts;
133
+ // A run whose slot table no longer fits the configuration is refused before anything is written.
134
+ if (resume?.instances) {
135
+ const soloOf = resume.solo_agent;
136
+ const cfgBase = soloOf ? soloProject(opts.project, soloOf) : opts.project;
137
+ assertInstancesResumable(cfgBase, resume.instances, path.basename(runDir), cfgBase.name, opts.instanceCheck ?? instanceContainment(cfgBase, runDir, resume.mail_layout).check);
138
+ }
95
139
  if (resume?.end_reason === "waiting")
96
140
  RunSession.assertAnswered(runDir, resume.run_id);
97
141
  if (opts.followup && resume?.end_reason !== "done")
@@ -106,7 +150,7 @@ export class RunSession {
106
150
  const runId = path.basename(runDir);
107
151
  const layout = resume ? resume.mail_layout : "run";
108
152
  // Each run gets its own memory directory per agent and (new runs) its own mailboxes.
109
- const project = bindRunProject(base, runDir, layout);
153
+ const project = bindRunProject(base, runDir, layout, resume?.instances);
110
154
  // 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
155
  // is refused early (before anything is written) when its repo can no longer give them.
112
156
  const { plan } = opts;
@@ -237,7 +281,11 @@ export class RunSession {
237
281
  this.cfg = cfg;
238
282
  this.runId = runId;
239
283
  this.runDir = runDir;
240
- this.project = project;
284
+ this.containment = instanceContainment(base, runDir, layout);
285
+ this.instanceCheck = opts.instanceCheck ?? this.containment.check;
286
+ this.project = withCapability(project, wsMode, opts.instanceCheck ?? this.containment.check, resume?.instances);
287
+ this.base = base;
288
+ this.layout = layout;
241
289
  this.wsMode = wsMode;
242
290
  this.log = log;
243
291
  this.guard = guard;
@@ -272,6 +320,201 @@ export class RunSession {
272
320
  log("note", { text });
273
321
  say(` ${text}`);
274
322
  }
323
+ /** Bind the project to this run again, after its slot table changed. */
324
+ rebind() {
325
+ this.project = withCapability(bindRunProject(this.base, this.runDir, this.layout, this.state.instances), this.wsMode, this.instanceCheck, this.state.instances);
326
+ }
327
+ /** What the router needs to hold and place mail for roles with several instances (spec 23.7.1). */
328
+ routing() {
329
+ const { state, log } = this;
330
+ return {
331
+ cap: (role) => instanceCap(this.project, this.wsMode, role, this.instanceCheck, state.instances?.roles[role] ?? []),
332
+ held: (role) => !!state.instances?.roles[role],
333
+ enable: (role) => {
334
+ // Durable before any file of the mail that opens the role's instances exists; a crash after this replays into `pending/`.
335
+ state.schema_version = 3;
336
+ state.instances = { roles: { ...(state.instances?.roles ?? {}), [role]: [role] } };
337
+ this.saveState();
338
+ fault("instances:enabled");
339
+ this.rebind();
340
+ log("instances", { role, enabled: true });
341
+ },
342
+ ignored: (reason, m) => {
343
+ log("parallel_ignored", { from: m.from, id: m.id, to: m.to, reason });
344
+ const key = `${m.from}\u0000${reason}`;
345
+ if (this.ignoredNoted.has(key))
346
+ return;
347
+ this.ignoredNoted.add(key);
348
+ this.note(reason === "not-lead"
349
+ ? `${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.`
350
+ : `${m.from}'s mail ${m.id} ("${m.subject}") has \`parallel: true\` but was delivered as an ordinary mail: ${reason}`);
351
+ },
352
+ };
353
+ }
354
+ /** 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. */
355
+ openInstance(role, slot) {
356
+ const { state, log } = this;
357
+ const table = must(state.instances?.roles[role], `slot table of ${role}`);
358
+ if (this.noMoreInstances.has(role))
359
+ return false;
360
+ table.push(slot);
361
+ this.saveState();
362
+ fault("instances:opened");
363
+ this.rebind();
364
+ ensureAgentDirs(this.project, slot);
365
+ const agent = must(this.project.agents[slot], `instance ${slot}`);
366
+ const refuse = (why) => {
367
+ this.noMoreInstances.add(role);
368
+ this.refused.add(slot); // listed in the slot table, but never given mail or woken
369
+ log("instances", { role, slot, refused: why });
370
+ this.note(`${slot} was opened but cannot be used: ${why}. ${role} gets no further instances in this run process.`);
371
+ return false;
372
+ };
373
+ for (const dir of [outboxDir(this.project, slot), agent.memory.task]) {
374
+ if (!dir)
375
+ continue;
376
+ const st = fs.lstatSync(dir, { throwIfNoEntry: false });
377
+ if (!st || st.isSymbolicLink() || !st.isDirectory())
378
+ return refuse(`${dir} is not a plain directory`);
379
+ }
380
+ const k = parseSlotName(slot)?.k ?? 2;
381
+ const conflict = this.instanceCheck?.(role, k);
382
+ if (conflict)
383
+ return refuse(conflict);
384
+ log("instances", { role, slot, opened: true });
385
+ return true;
386
+ }
387
+ /** 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`. */
388
+ assignHeld() {
389
+ this.waiting = [];
390
+ for (const role of Object.keys(this.state.instances?.roles ?? {}).sort())
391
+ this.assignRole(role);
392
+ }
393
+ assignRole(role) {
394
+ const { state, log, runDir } = this;
395
+ const opened = [...must(state.instances?.roles[role], `slot table of ${role}`)];
396
+ const pendingDir = path.join(inboxDir(this.project, role), PENDING_DIR);
397
+ if (!fs.existsSync(pendingDir))
398
+ return;
399
+ const files = fs.readdirSync(pendingDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md") && !e.name.startsWith(".")).map((e) => e.name).sort();
400
+ if (!files.length)
401
+ return;
402
+ const journal = this.journal();
403
+ const msgs = [];
404
+ const subjects = new Map();
405
+ for (const f of files) {
406
+ const entry = journal.byFile(f);
407
+ // Only the dispatcher's own held deliveries count: a file put here by anyone else is left alone.
408
+ if (!entry?.held) {
409
+ if (!this.heldLogged.has(`unknown:${f}`)) {
410
+ this.heldLogged.add(`unknown:${f}`);
411
+ log("held_unknown", { role, file: f });
412
+ }
413
+ continue;
414
+ }
415
+ let replyTo;
416
+ let thread;
417
+ try {
418
+ const m = readMessageNoFollow(path.join(pendingDir, f));
419
+ replyTo = m.meta.reply_to ? String(m.meta.reply_to) : undefined;
420
+ thread = m.meta.thread ? String(m.meta.thread) : undefined;
421
+ subjects.set(entry.msg_id, String(m.meta.subject ?? ""));
422
+ }
423
+ catch {
424
+ /* unreadable mail has no thread: it is placed as a new one */
425
+ }
426
+ msgs.push({ file: f, id: entry.msg_id, to: entry.to, replyTo, thread, parallel: entry.parallel === true });
427
+ }
428
+ if (!msgs.length)
429
+ return;
430
+ const mark = opened.map((n) => journal.firstHeld(n)).filter((x) => x !== undefined).reduce((a, b) => (a === undefined ? b : Math.min(a, b)), undefined);
431
+ const claimAgent = (id) => {
432
+ try {
433
+ return loadClaim(runDir, id).agent;
434
+ }
435
+ catch {
436
+ return undefined;
437
+ }
438
+ };
439
+ const cap = instanceCap(this.project, this.wsMode, role, this.instanceCheck, opened);
440
+ const roleAgent = must(this.project.agents[role], `agent ${role}`);
441
+ const primary = must(candidatesFor(roleAgent)[0], `model of ${role}`);
442
+ const plan = planAssignments({
443
+ role,
444
+ held: msgs,
445
+ opened,
446
+ cap: cap.n,
447
+ owners: (id) => ownersOf(id, role, { opened, rec: (x) => journal.ownership(x), mark, claimAgent }),
448
+ recorded: (id) => journal.assigned(id)?.slot,
449
+ acceptsNew: (slot) => {
450
+ if (this.refused.has(slot))
451
+ return "refused";
452
+ if (state.blocked_integrations?.some((b) => b.agent === slot))
453
+ return "blocked";
454
+ return (parseSlotName(slot)?.k ?? 1) > cap.n ? "cap" : true;
455
+ },
456
+ busy: (slot) => unreadFiles(this.project, slot).length > 0,
457
+ coolingDown: this.cooldowns.active(primary.key, Date.now()) > 0,
458
+ mayOpen: (name) => (this.noMoreInstances.has(role) ? "containment" : name in this.base.agents ? "collision" : true),
459
+ });
460
+ const byFile = new Map(msgs.map((m) => [m.file, m]));
461
+ const wait = (m, reason) => {
462
+ this.waiting.push({ id: m.id, role, to: m.to, subject: subjects.get(m.id) ?? "", reason });
463
+ const key = `${m.id}:${reason}`;
464
+ if (!this.heldLogged.has(key)) {
465
+ this.heldLogged.add(key);
466
+ log("held", { id: m.id, role, reason });
467
+ }
468
+ };
469
+ // 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.
470
+ const unusable = new Set();
471
+ for (const a of plan.assign) {
472
+ const m = must(byFile.get(a.file), `held mail ${a.file}`);
473
+ if (unusable.has(a.slot) || (a.open && !this.openInstance(role, a.open))) {
474
+ unusable.add(a.slot);
475
+ wait(m, "containment");
476
+ continue;
477
+ }
478
+ if (a.rule !== "recorded")
479
+ journal.assign({ msg_id: m.id, file: a.file, role, slot: a.slot });
480
+ fault("assign:recorded");
481
+ fs.mkdirSync(inboxDir(this.project, a.slot), { recursive: true });
482
+ fs.renameSync(path.join(pendingDir, a.file), path.join(inboxDir(this.project, a.slot), a.file));
483
+ fault("assign:moved");
484
+ log("assign", { id: m.id, role, slot: a.slot, rule: a.rule, ...(a.addressed ? { addressed: a.addressed } : {}) });
485
+ }
486
+ for (const w of plan.waiting)
487
+ wait(must(byFile.get(w.file), `held mail ${w.file}`), w.reason);
488
+ }
489
+ /**
490
+ * 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
491
+ * 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).
492
+ */
493
+ async stallHeld() {
494
+ const { log, project, opts } = this;
495
+ const held = this.waiting;
496
+ if (held.length === 0)
497
+ return "idle";
498
+ if (held.every((w) => w.reason === "cooldown")) {
499
+ const now = Date.now();
500
+ 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));
501
+ if (Number.isFinite(ms)) {
502
+ log("held_wait", { until: new Date(now + ms).toISOString() });
503
+ await (opts.sleep ?? defaultSleep)(ms, opts.signal);
504
+ return "retry";
505
+ }
506
+ return "retry";
507
+ }
508
+ const list = held.map((w) => `${w.id} "${w.subject}" for ${w.to} (${WAIT_TEXT[w.reason] ?? w.reason})`).join(", ");
509
+ const { fresh } = deliverOnce(project, this.journal(), heldStallKey(held.map((w) => w.id)), {
510
+ from: "dispatcher",
511
+ to: project.lead,
512
+ type: "failure",
513
+ subject: "Tasks are waiting and nothing is running",
514
+ 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.`,
515
+ });
516
+ return fresh ? "notified" : "idle";
517
+ }
275
518
  pendingAgents() {
276
519
  const { project } = this;
277
520
  const rows = Object.values(project.agents)
@@ -532,6 +775,8 @@ export class RunSession {
532
775
  }
533
776
  state.blocked_integrations = [...blocked, { agent: agent.name, branch: r.branch, reason: r.reason, report: r.report }];
534
777
  say(` ${agent.name}: changes NOT brought into the repo (${r.reason.split("\n")[0]})`);
778
+ // 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).
779
+ const instanced = !!state.instances?.roles[roleOfName(project.agents, agent.name)];
535
780
  deliverOnce(project, this.journal(), `integration:${key}`, {
536
781
  from: "dispatcher",
537
782
  to: project.lead,
@@ -540,8 +785,9 @@ export class RunSession {
540
785
  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
786
  `Its work is kept: branch \`${r.branch}\`, worktree \`${r.workspace}\`, report \`${r.report}\`, patch \`${r.patch}\`.\n` +
542
787
  `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. ` +
788
+ (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
789
  `Do not report the job \`completed\` while this work is missing from the repo.`,
544
- });
790
+ }, instanced ? { about: agent.name } : {});
545
791
  this.saveState();
546
792
  }
547
793
  /** 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 +801,7 @@ export class RunSession {
555
801
  type: "failure",
556
802
  subject: `${agent.name} cannot be woken: its worktree was refused`,
557
803
  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
- });
804
+ }, this.state.instances?.roles[roleOfName(project.agents, agent.name)] ? { about: agent.name } : {});
559
805
  this.saveState();
560
806
  }
561
807
  /** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
@@ -574,12 +820,14 @@ export class RunSession {
574
820
  (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
821
  });
576
822
  }
823
+ // Crash seam: every wake of the batch is finished and marked output_ready; nothing of it is integrated or routed yet.
824
+ fault("wake:ready");
577
825
  batch.forEach((a, i) => {
578
826
  const ws = this.batchSpaces[a.name];
579
827
  if (ws && results[i]?.ok)
580
828
  this.integrateMember(a, ws, claims.get(a.name)?.id ?? `${a.name}-${state.rounds}`);
581
829
  });
582
- const route = routeOutboxes(project);
830
+ const route = routeOutboxes(project, this.routing());
583
831
  // A done that breaks the completion contract goes back to the lead (a limited number of times).
584
832
  let contract;
585
833
  if (route.done) {
@@ -846,7 +1094,8 @@ export class RunSession {
846
1094
  async run() {
847
1095
  if (this.resume)
848
1096
  this.recover();
849
- const { say, runId, runDir, project, wsMode, log, state, opts } = this;
1097
+ const { say, runId, runDir, wsMode, log, state, opts } = this;
1098
+ let project = this.project;
850
1099
  // When the lead was the last one woken and left every mailbox empty without a `done`, remind it once instead of ending silently.
851
1100
  let leadWasLast = false;
852
1101
  let nudged = false;
@@ -855,6 +1104,8 @@ export class RunSession {
855
1104
  this.endReason = "cancelled";
856
1105
  break;
857
1106
  }
1107
+ this.assignHeld();
1108
+ project = this.project;
858
1109
  let pending = this.pendingAgents();
859
1110
  if (pending.length === 0 && leadWasLast && !nudged) {
860
1111
  nudged = true;
@@ -869,6 +1120,17 @@ export class RunSession {
869
1120
  });
870
1121
  pending = this.pendingAgents();
871
1122
  }
1123
+ if (pending.length === 0) {
1124
+ const stalled = await this.stallHeld();
1125
+ if (opts.signal?.aborted) {
1126
+ this.endReason = "cancelled";
1127
+ break;
1128
+ }
1129
+ if (stalled === "retry")
1130
+ continue;
1131
+ if (stalled === "notified")
1132
+ pending = this.pendingAgents();
1133
+ }
872
1134
  if (pending.length === 0) {
873
1135
  this.endReason = "idle";
874
1136
  break;
@@ -895,7 +1157,13 @@ export class RunSession {
895
1157
  }
896
1158
  for (const a of members) {
897
1159
  try {
898
- this.batchSpaces[a.name] = prepareAgentWorkspace(project, runId, a.name, ref);
1160
+ const ws = prepareAgentWorkspace(project, runId, a.name, ref);
1161
+ // An instance gets one more containment check with the worktree it really got (spec 23.11 point 3).
1162
+ const inst = a.instance;
1163
+ const conflict = inst ? (opts.instanceCheck ? opts.instanceCheck(inst.role, inst.k) : this.containment.checkWorkspace(inst.role, inst.k, ws)) : undefined;
1164
+ if (conflict)
1165
+ throw new Error(`${a.name} ${conflict}`);
1166
+ this.batchSpaces[a.name] = ws;
899
1167
  }
900
1168
  catch (e) {
901
1169
  this.refuseMember(a, e.message);
@@ -3,7 +3,12 @@ import type { DigestSection, Step } from "./format.js";
3
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
- 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 {
@@ -146,6 +151,12 @@ export interface RunState {
146
151
  }[];
147
152
  /** `rounds` at the last reopen; the round limit counts from here. */
148
153
  round_floor?: number;
154
+ /** Same-role instances opened in this run (spec 23.6); present only from the first parallel task on, together with `schema_version` 3. */
155
+ instances?: InstanceTable;
156
+ }
157
+ /** `roles[<role>]`: the role's open instances in number order, `[0]` is the role itself. Only grows. */
158
+ export interface InstanceTable {
159
+ roles: Record<string, string[]>;
149
160
  }
150
161
  /** `20261006T010203456Z-<128 random bits>`: sorts by start time and cannot collide, even for two starts in one millisecond. */
151
162
  export declare function newRunId(d?: Date): string;
@@ -160,7 +171,7 @@ export declare function saveRunState(runDir: string, state: RunState): void;
160
171
  * The project as one run sees it. Per-task memory is always private to the run. With the "run" layout the
161
172
  * mailboxes live inside the run dir; with "legacy" they stay the shared project mailboxes older versions used.
162
173
  */
163
- export declare function bindRunProject(project: ResolvedProject, runDir: string, layout: MailLayout): ResolvedProject;
174
+ export declare function bindRunProject(project: ResolvedProject, runDir: string, layout: MailLayout, instances?: InstanceTable): ResolvedProject;
164
175
  /**
165
176
  * How a finished run turned out. Runs that ended before outcomes existed have none recorded: they read as
166
177
  * 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;
@@ -1,3 +1,5 @@
1
+ /** The lead's guidance on `parallel: true` (spec 23.12 point 5). Only `init` writes it, into a new lead AGENT.md. */
2
+ export declare const LEAD_PARALLEL_HINT = "Mark a task `parallel: true` only when it is independent and touches different files from the other open tasks; list each task's files in `## Scope` first; shared components, types, routes and tokens go first, alone.";
1
3
  export interface InitResult {
2
4
  home: string;
3
5
  created: string[];
package/dist/scaffold.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { assertName, homePaths, projectPaths } from "./paths.js";
4
+ /** The lead's guidance on `parallel: true` (spec 23.12 point 5). Only `init` writes it, into a new lead AGENT.md. */
5
+ export const LEAD_PARALLEL_HINT = "Mark a task `parallel: true` only when it is independent and touches different files from the other open tasks; list each task's files in `## Scope` first; shared components, types, routes and tokens go first, alone.";
4
6
  const PERSONAS = {
5
7
  lead: `# Lead
6
8
 
@@ -11,6 +13,7 @@ You coordinate the team. You receive the user's task, break it into concrete pie
11
13
  - Write every task with the standard task headings, and cite the mail it derives from under Upstream so teammates can trace it.
12
14
  - When a teammate reports back, decide the next step: assign follow-up work, ask another teammate to verify, or accept the result.
13
15
  - Teammates cannot message each other by default; route everything through yourself.
16
+ - ${LEAD_PARALLEL_HINT}
14
17
  - When the whole job is verified and finished, send a \`done\` message summarising what was delivered.
15
18
  `,
16
19
  "fe-member": `# Frontend member
package/dist/schema.d.ts CHANGED
@@ -108,6 +108,7 @@ export declare const DispatcherPartial: z.ZodObject<{
108
108
  }>>;
109
109
  }, z.core.$strict>>>;
110
110
  fallback_cooldown_sec: z.ZodOptional<z.ZodNumber>;
111
+ max_instances: z.ZodOptional<z.ZodNumber>;
111
112
  }, z.core.$strict>;
112
113
  /** Settings that no longer exist; files that still carry them load, and `validate` warns. */
113
114
  export declare const REMOVED_DISPATCHER_KEYS: readonly ["max_parallel", "max_concurrent_runs", "workspace_mode"];
@@ -189,6 +190,7 @@ export declare const ProjectConfig: z.ZodObject<{
189
190
  }>>;
190
191
  }, z.core.$strict>>>;
191
192
  fallback_cooldown_sec: z.ZodOptional<z.ZodNumber>;
193
+ max_instances: z.ZodOptional<z.ZodNumber>;
192
194
  }, z.core.$strict>>;
193
195
  agents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
194
196
  runtime: z.ZodOptional<z.ZodEnum<{
@@ -245,6 +247,8 @@ export interface DispatcherSettings {
245
247
  log_max_bytes: number;
246
248
  /** How long a primary that hit rate_limit/quota is skipped within a run, unless the CLI says when to come back. */
247
249
  fallback_cooldown_sec: number;
250
+ /** Most instances of one role at a time. Not in `DISPATCHER_DEFAULTS`, so `doctor --resolved` lists it only when written; the default lives in `instances.ts`. */
251
+ max_instances?: number;
248
252
  }
249
253
  export declare const DISPATCHER_DEFAULTS: DispatcherSettings;
250
254
  /** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
package/dist/schema.js CHANGED
@@ -65,6 +65,8 @@ export const DispatcherPartial = z
65
65
  log_max_bytes: z.number().int().min(0).optional(),
66
66
  fallback: z.array(FallbackEntryPartial).optional(),
67
67
  fallback_cooldown_sec: z.number().int().min(0).optional(),
68
+ /** Most instances of one role at a time (spec 23); 1 turns parallel tasks off. Unset = `DEFAULT_MAX_INSTANCES`. */
69
+ max_instances: z.number().int().min(1).max(8).optional(),
68
70
  })
69
71
  .strict();
70
72
  /** Settings that no longer exist; files that still carry them load, and `validate` warns. */
package/dist/status.d.ts CHANGED
@@ -48,6 +48,8 @@ export declare function cacheText(read: number | undefined, write: number | unde
48
48
  export declare function progressBar(done: number, total: number, width?: number, c?: Paint): string;
49
49
  export interface QueueItem {
50
50
  agent: string;
51
+ /** Mail waiting for a free instance of the role in `agent` (spec 23.7): not woken yet, placed by the dispatcher. Only for roles with instances. */
52
+ held?: true;
51
53
  from: string;
52
54
  type: string;
53
55
  subject: string;
@@ -105,6 +107,8 @@ export interface RunReport {
105
107
  ok: boolean;
106
108
  error?: string;
107
109
  }>;
110
+ /** Only when the run has same-role instances: each role's open instances, the role itself first. */
111
+ instances?: Record<string, string[]>;
108
112
  /** Present while the run waits for the user: the file to fill in and how many questions are still open. */
109
113
  ask?: {
110
114
  path: string;
@@ -116,6 +120,9 @@ export interface AgentReport {
116
120
  name: string;
117
121
  runtime?: string;
118
122
  lead: boolean;
123
+ /** Only for the rows of a role that has instances (the role itself is instance 1): the role and the instance number. */
124
+ role?: string;
125
+ instance?: number;
119
126
  unread: number;
120
127
  /** Who this agent may send mail to (`all` expanded, itself left out). */
121
128
  can_message: string[];