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.
- package/README.md +6 -2
- package/README.zh-TW.md +6 -2
- package/dist/call.js +2 -0
- package/dist/config.d.ts +29 -1
- package/dist/config.js +34 -3
- 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/duplicate-notices.d.ts +9 -0
- package/dist/duplicate-notices.js +34 -0
- package/dist/fallback.d.ts +4 -2
- package/dist/fallback.js +11 -4
- package/dist/guard.d.ts +17 -1
- package/dist/guard.js +37 -6
- 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 +46 -2
- package/dist/mailbox.js +121 -36
- package/dist/message-store.d.ts +47 -1
- package/dist/message-store.js +75 -7
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +27 -2
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +2 -2
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +66 -10
- package/dist/run-session.d.ts +35 -2
- package/dist/run-session.js +337 -20
- package/dist/run-store.d.ts +17 -3
- package/dist/run-store.js +17 -7
- package/dist/scaffold.d.ts +3 -0
- package/dist/scaffold.js +10 -1
- package/dist/schema.d.ts +19 -0
- package/dist/schema.js +12 -0
- package/dist/status.d.ts +7 -0
- package/dist/status.js +74 -15
- package/dist/tiers.d.ts +27 -0
- package/dist/tiers.js +49 -0
- package/dist/validate.js +28 -1
- package/dist/worktree.d.ts +6 -0
- package/dist/worktree.js +16 -2
- package/package.json +1 -1
package/dist/run-session.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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,
|
|
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
|
-
|
|
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);
|
package/dist/run-store.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|