agent-lyceum 0.25.0 → 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.
- package/README.md +2 -2
- package/README.zh-TW.md +2 -2
- package/dist/config.d.ts +15 -1
- package/dist/config.js +2 -0
- package/dist/doctor-instances.d.ts +4 -0
- package/dist/doctor-instances.js +63 -0
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +17 -1
- package/dist/instance-containment.d.ts +15 -0
- package/dist/instance-containment.js +36 -0
- package/dist/instances.d.ts +183 -0
- package/dist/instances.js +268 -0
- package/dist/mailbox.d.ts +32 -2
- package/dist/mailbox.js +76 -18
- package/dist/message-store.d.ts +47 -1
- package/dist/message-store.js +75 -7
- package/dist/paths.d.ts +5 -0
- package/dist/paths.js +23 -1
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +2 -2
- package/dist/prompt.d.ts +2 -0
- package/dist/prompt.js +38 -9
- package/dist/run-session.d.ts +33 -2
- package/dist/run-session.js +280 -12
- package/dist/run-store.d.ts +13 -2
- package/dist/run-store.js +17 -7
- package/dist/scaffold.d.ts +2 -0
- package/dist/scaffold.js +3 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +2 -0
- package/dist/status.d.ts +7 -0
- package/dist/status.js +48 -14
- package/dist/worktree.d.ts +6 -0
- package/dist/worktree.js +14 -1
- package/package.json +1 -1
package/dist/run-session.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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);
|
package/dist/run-store.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -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[];
|