agent-lyceum 0.11.0 → 0.12.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 +29 -4
- package/README.zh-TW.md +29 -4
- package/dist/ask-answer.js +4 -3
- package/dist/ask-reply.d.ts +2 -0
- package/dist/ask-reply.js +13 -0
- package/dist/cli.js +62 -22
- package/dist/config.d.ts +1 -0
- package/dist/doctor.js +32 -6
- package/dist/guard.d.ts +5 -0
- package/dist/guard.js +8 -1
- package/dist/open-pr.d.ts +47 -0
- package/dist/open-pr.js +71 -0
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +4 -2
- package/dist/project-lock.d.ts +24 -7
- package/dist/project-lock.js +178 -80
- package/dist/prompt.js +10 -4
- package/dist/run-cleanup.js +6 -3
- package/dist/run-session.d.ts +27 -0
- package/dist/run-session.js +163 -20
- package/dist/run-store.d.ts +7 -0
- package/dist/scaffold.js +1 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +2 -0
- package/dist/status.d.ts +1 -1
- package/dist/status.js +4 -4
- package/dist/validate.js +10 -3
- package/dist/worktree.d.ts +74 -7
- package/dist/worktree.js +372 -62
- package/package.json +1 -1
package/dist/run-session.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { realInvoker } from "./adapters/index.js";
|
|
4
4
|
import { briefOf, digestOf, doneContract, parseSteps } from "./format.js";
|
|
5
5
|
import { ProtectedGuard } from "./guard.js";
|
|
6
|
-
import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, writeAskReply } from "./ask-reply.js";
|
|
6
|
+
import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, resolveOptionLetters, writeAskReply } from "./ask-reply.js";
|
|
7
7
|
import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
|
|
8
8
|
import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
|
|
9
9
|
import { isInside } from "./paths.js";
|
|
@@ -12,11 +12,12 @@ import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
|
|
|
12
12
|
import { outboxDir, ownsDirs } from "./policy.js";
|
|
13
13
|
import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
14
14
|
import { atomicWrite } from "./fs-util.js";
|
|
15
|
-
import { assertWorktreeRunnable, collectAgentChanges, existingAgentWorkspace, integrateAgentChanges, prepareAgentWorkspace, resolveWorkspaceMode, snapshotBase } from "./worktree.js";
|
|
15
|
+
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, refusedIntegration, resolveWorkspaceMode, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
16
16
|
import { bindRunProject, newRunId, newRunState, saveRunState } from "./run-store.js";
|
|
17
17
|
import { soloProject } from "./solo.js";
|
|
18
18
|
import { taskMessageBody } from "./task.js";
|
|
19
19
|
import { must } from "./assert.js";
|
|
20
|
+
import { openPullRequest, prBody, prTitle, realRunner } from "./open-pr.js";
|
|
20
21
|
export { newRunId };
|
|
21
22
|
export const RESULT_FILE = "result.md";
|
|
22
23
|
/** How many times a non-conforming `done` goes back to the lead before it is accepted as `partial`. */
|
|
@@ -48,11 +49,18 @@ export class RunSession {
|
|
|
48
49
|
doneOutcome;
|
|
49
50
|
doneNote;
|
|
50
51
|
doneVerification;
|
|
52
|
+
/** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
|
|
53
|
+
openPrRequested = false;
|
|
54
|
+
prRunner;
|
|
51
55
|
/** Inbox mail each agent in the current batch is working on; it is marked read only after its output is routed. */
|
|
52
56
|
claims = new Map();
|
|
53
57
|
noSessionNoted = new Set();
|
|
54
58
|
/** Git worktrees of the non-lead agents in the batch being woken (empty in shared mode and for the lead). */
|
|
55
59
|
batchSpaces = {};
|
|
60
|
+
/** Members whose worktree was refused (its git files were tampered with): never woken again in this run. */
|
|
61
|
+
refused = new Set();
|
|
62
|
+
/** Concurrent runs: the lead's own git worktree for the whole run. */
|
|
63
|
+
leadSpace;
|
|
56
64
|
/** The attempt that produced each batch member's output; committed together with its claim. */
|
|
57
65
|
attempts = new Map();
|
|
58
66
|
constructor(opts) {
|
|
@@ -71,12 +79,28 @@ export class RunSession {
|
|
|
71
79
|
// Each run gets its own memory directory per agent and (new runs) its own mailboxes.
|
|
72
80
|
const project = bindRunProject(base, runDir, layout);
|
|
73
81
|
// Parallel agents work in their own git worktrees; refuse early (before anything is written) when that cannot be done.
|
|
74
|
-
|
|
82
|
+
// Whether the lead has its own worktree is decided when the run starts and kept for its resumes, whatever the config says by then.
|
|
83
|
+
const concurrent = resume ? resume.lead_workspace === true : cfg.max_concurrent_runs > 1;
|
|
84
|
+
const wsMode = concurrent ? "worktree" : resolveWorkspaceMode({ ...cfg, max_concurrent_runs: 1 });
|
|
75
85
|
if (wsMode === "worktree")
|
|
76
|
-
assertWorktreeRunnable(project, { fresh: !resume });
|
|
86
|
+
assertWorktreeRunnable(project, { fresh: !resume, concurrent });
|
|
87
|
+
// Concurrent runs: the lead works in its own worktree cut from HEAD, so no other run (and not you) shares its files.
|
|
88
|
+
let leadSpace;
|
|
89
|
+
if (concurrent && resume) {
|
|
90
|
+
leadSpace = existingAgentWorkspace(project, runId, project.lead);
|
|
91
|
+
if (!leadSpace)
|
|
92
|
+
throw new Error(`Run ${runId} has no worktree for ${project.lead} (was it cleared?), so it cannot be resumed.`);
|
|
93
|
+
}
|
|
77
94
|
ensureProjectDirs(project);
|
|
78
95
|
fs.mkdirSync(runDir, { recursive: true });
|
|
79
96
|
const log = createRunLog(path.join(runDir, "log.jsonl"), cfg.log_max_bytes);
|
|
97
|
+
if (concurrent && !resume) {
|
|
98
|
+
// Before the task is delivered or any state written, so a failure here leaves no half-started run.
|
|
99
|
+
leadSpace = prepareAgentWorkspace(project, runId, project.lead, snapshotHead(project, runId));
|
|
100
|
+
const uncommitted = dirtyPaths(project.dir).length;
|
|
101
|
+
say(` starting from HEAD ${leadSpace.base.slice(0, 8)}${uncommitted ? ` (your ${uncommitted} uncommitted file(s) are not part of this run)` : ""}; ${project.lead} works on branch ${leadSpace.branch}`);
|
|
102
|
+
log("lead-workspace", { base: leadSpace.base, branch: leadSpace.branch, dir: leadSpace.dir, uncommitted });
|
|
103
|
+
}
|
|
80
104
|
let state;
|
|
81
105
|
if (resume) {
|
|
82
106
|
state = { ...resume, max_rounds: cfg.max_rounds, pid: process.pid, ended_at: undefined, end_reason: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
@@ -108,13 +132,15 @@ export class RunSession {
|
|
|
108
132
|
mail_layout: layout,
|
|
109
133
|
workspace_mode: wsMode,
|
|
110
134
|
solo_agent: soloName,
|
|
135
|
+
lead_workspace: concurrent ? true : undefined,
|
|
111
136
|
});
|
|
112
137
|
const seed = parseSteps(task.content, false);
|
|
113
138
|
if (seed.length)
|
|
114
139
|
state.steps = seed;
|
|
115
140
|
}
|
|
116
141
|
const saveState = () => saveRunState(runDir, state);
|
|
117
|
-
|
|
142
|
+
// 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).
|
|
143
|
+
const guard = new ProtectedGuard(leadSpace ? { ...project, workspaces: { ...project.workspaces, [project.lead]: leadSpace } } : project);
|
|
118
144
|
if (resume) {
|
|
119
145
|
log("resume", { rounds: state.rounds, interrupted: Object.keys(resume.active ?? {}) });
|
|
120
146
|
}
|
|
@@ -125,7 +151,9 @@ export class RunSession {
|
|
|
125
151
|
log("start", { task_source: task.source, bytes: task.bytes, inline: task.inline });
|
|
126
152
|
}
|
|
127
153
|
saveState();
|
|
154
|
+
this.leadSpace = leadSpace;
|
|
128
155
|
this.opts = opts;
|
|
156
|
+
this.prRunner = opts.pr ?? realRunner;
|
|
129
157
|
this.resume = resume;
|
|
130
158
|
this.invoke = invoke;
|
|
131
159
|
this.say = say;
|
|
@@ -140,9 +168,12 @@ export class RunSession {
|
|
|
140
168
|
}
|
|
141
169
|
/** A waiting run only continues once every unanswered question has a valid answer. */
|
|
142
170
|
static assertAnswered(runDir, runId) {
|
|
143
|
-
const
|
|
144
|
-
if (!
|
|
171
|
+
const read = readAskReply(runDir);
|
|
172
|
+
if (!read)
|
|
145
173
|
return;
|
|
174
|
+
const reply = resolveOptionLetters(read);
|
|
175
|
+
if (JSON.stringify(reply) !== JSON.stringify(read))
|
|
176
|
+
writeAskReply(runDir, reply);
|
|
146
177
|
const check = checkAnswers(reply);
|
|
147
178
|
if (!isComplete(check)) {
|
|
148
179
|
throw new Error(`Run ${runId} is still waiting for answers in ${askReplyPath(runDir)}:\n${describeCheck(check).map((l) => ` ${l}`).join("\n")}`);
|
|
@@ -164,6 +195,7 @@ export class RunSession {
|
|
|
164
195
|
pendingAgents() {
|
|
165
196
|
const { project } = this;
|
|
166
197
|
const rows = Object.values(project.agents)
|
|
198
|
+
.filter((a) => !this.refused.has(a.name))
|
|
167
199
|
.map((a) => ({ a, files: unreadFiles(project, a.name) }))
|
|
168
200
|
.flatMap((r) => (r.files[0] ? [{ a: r.a, first: path.basename(r.files[0]) }] : []));
|
|
169
201
|
rows.sort((x, y) => x.first.localeCompare(y.first));
|
|
@@ -201,12 +233,13 @@ export class RunSession {
|
|
|
201
233
|
const claim = claimMessages(runDir, agent.name, unread);
|
|
202
234
|
claims.set(agent.name, claim);
|
|
203
235
|
const workDir = path.join(runDir, "agents", agent.name);
|
|
204
|
-
const
|
|
236
|
+
const spaces = this.leadSpace ? { ...this.batchSpaces, [project.lead]: this.leadSpace } : this.batchSpaces;
|
|
237
|
+
const aproject = Object.keys(spaces).length ? { ...project, workspaces: spaces } : project;
|
|
205
238
|
const base = {
|
|
206
239
|
project: aproject,
|
|
207
240
|
agent,
|
|
208
241
|
workDir,
|
|
209
|
-
workspace:
|
|
242
|
+
workspace: spaces[agent.name],
|
|
210
243
|
systemPrompt: buildSystemPrompt(aproject, agent),
|
|
211
244
|
userPrompt: buildUserPrompt(project, agent, queue, unread.length),
|
|
212
245
|
timeoutSec: cfg.wake_timeout_sec,
|
|
@@ -306,9 +339,17 @@ export class RunSession {
|
|
|
306
339
|
*/
|
|
307
340
|
integrateMember(agent, ws, key) {
|
|
308
341
|
const { say, project, log, state } = this;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
342
|
+
let changes;
|
|
343
|
+
let r;
|
|
344
|
+
try {
|
|
345
|
+
changes = collectAgentChanges(ws, agent.owns);
|
|
346
|
+
r = integrateAgentChanges(project, changes, this.leadSpace);
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
// e.g. a worktree whose git files were tampered with: nothing is applied, and the lead is told like for any blocked integration
|
|
350
|
+
r = refusedIntegration(project, ws, e.message);
|
|
351
|
+
}
|
|
352
|
+
log("integrate", { agent: agent.name, status: r.status, files: (changes?.files ?? []).map((f) => `${f.status} ${f.path}`), ...(r.status === "blocked" ? { reason: r.reason, report: r.report } : {}) });
|
|
312
353
|
const blocked = (state.blocked_integrations ?? []).filter((b) => b.agent !== agent.name);
|
|
313
354
|
if (r.status === "integrated") {
|
|
314
355
|
state.blocked_integrations = blocked;
|
|
@@ -330,6 +371,20 @@ export class RunSession {
|
|
|
330
371
|
});
|
|
331
372
|
this.saveState();
|
|
332
373
|
}
|
|
374
|
+
/** A member whose worktree was refused: it is not woken again in this run, and the lead is told so it can react. */
|
|
375
|
+
refuseMember(agent, reason) {
|
|
376
|
+
const { project, runId } = this;
|
|
377
|
+
this.refused.add(agent.name);
|
|
378
|
+
this.note(`${agent.name} was not woken: ${reason.split("\n")[0]}`);
|
|
379
|
+
deliverOnce(project, this.journal(), `refused:${runId}:${agent.name}`, {
|
|
380
|
+
from: "dispatcher",
|
|
381
|
+
to: project.lead,
|
|
382
|
+
type: "failure",
|
|
383
|
+
subject: `${agent.name} cannot be woken: its worktree was refused`,
|
|
384
|
+
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}.`,
|
|
385
|
+
});
|
|
386
|
+
this.saveState();
|
|
387
|
+
}
|
|
333
388
|
/** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
|
|
334
389
|
settle(batch, results) {
|
|
335
390
|
const { say, runDir, project, log, state, guard, claims, attempts } = this;
|
|
@@ -412,6 +467,7 @@ export class RunSession {
|
|
|
412
467
|
}
|
|
413
468
|
if (route.done) {
|
|
414
469
|
this.doneMessage = { subject: route.done.subject, body: route.done.body };
|
|
470
|
+
this.openPrRequested = route.done.meta.open_pr === true;
|
|
415
471
|
const resolved = resolveDoneOutcome(must(contract, "completion contract of the done report"), state.done_rejections ?? 0, state.blocked_integrations ?? []);
|
|
416
472
|
this.doneOutcome = resolved.outcome;
|
|
417
473
|
this.doneNote = resolved.note;
|
|
@@ -508,6 +564,65 @@ export class RunSession {
|
|
|
508
564
|
say(` waiting for your answers: ${askReplyPath(runDir)}`);
|
|
509
565
|
return true;
|
|
510
566
|
}
|
|
567
|
+
/**
|
|
568
|
+
* Concurrent runs, at the end of the run: the lead's work is committed on its branch for you to merge.
|
|
569
|
+
* A run that waits for your answers has not ended (it will be resumed in the same worktree), so it commits nothing.
|
|
570
|
+
*/
|
|
571
|
+
finishLead(endReason) {
|
|
572
|
+
const { say, log, state, runDir, runId, project } = this;
|
|
573
|
+
if (!this.leadSpace || endReason === "waiting")
|
|
574
|
+
return undefined;
|
|
575
|
+
try {
|
|
576
|
+
const rb = finishLeadBranch(this.leadSpace, `agent-lyceum: ${project.lead}'s work for run ${runId}\n\n${state.task_summary}`, path.join(runDir, "lead.patch"));
|
|
577
|
+
state.result_branch = rb;
|
|
578
|
+
log("lead-branch", { ...rb });
|
|
579
|
+
say(rb.files ? ` work is on branch ${rb.branch} (${rb.files} file(s)); bring it into your repo with: git merge ${rb.branch}` : ` ${rb.branch}: no file changes`);
|
|
580
|
+
return rb;
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
this.note(`Could not commit the work on ${this.leadSpace.branch}: ${e.message.split("\n")[0]}. It is still in ${this.leadSpace.root}.`);
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* The lead asked for a pull request in `done`. It is opened from the lead's branch, by the dispatcher, outside any agent sandbox.
|
|
589
|
+
* A request that cannot be honoured is reported and noted; it never changes how the run turned out.
|
|
590
|
+
*/
|
|
591
|
+
maybeOpenPr(endReason, outcome, rb) {
|
|
592
|
+
if (!this.openPrRequested)
|
|
593
|
+
return undefined;
|
|
594
|
+
const { say, log, state, runDir, project } = this;
|
|
595
|
+
const skip = (reason) => {
|
|
596
|
+
this.note(`A pull request was asked for but not opened: ${reason}`);
|
|
597
|
+
return { status: "skipped", reason };
|
|
598
|
+
};
|
|
599
|
+
if (endReason !== "done" || outcome !== "completed")
|
|
600
|
+
return skip(`the run did not end completed (${endReason}, outcome ${outcome}).`);
|
|
601
|
+
if (!this.leadSpace)
|
|
602
|
+
return skip("this run has no worktree of its own, so there is no branch to push. Set dispatcher.max_concurrent_runs: 2 (or more) in project.yaml.");
|
|
603
|
+
if (!rb)
|
|
604
|
+
return skip(`the work could not be committed on ${this.leadSpace.branch}.`);
|
|
605
|
+
if (!rb.files)
|
|
606
|
+
return skip(`${rb.branch} has no file changes.`);
|
|
607
|
+
const result = openPullRequest({
|
|
608
|
+
// Never the lead's worktree: the agent can write it (and its git pointers), and git would run what it planted there.
|
|
609
|
+
cwd: toplevel(project.dir),
|
|
610
|
+
branch: rb.branch,
|
|
611
|
+
title: prTitle(state.task_summary),
|
|
612
|
+
body: prBody(this.doneMessage?.body ?? ""),
|
|
613
|
+
bodyFile: path.join(runDir, "pr-body.md"),
|
|
614
|
+
run: this.prRunner,
|
|
615
|
+
});
|
|
616
|
+
log("pull-request", { ...result });
|
|
617
|
+
if (result.status === "opened") {
|
|
618
|
+
state.pr_url = result.url;
|
|
619
|
+
say(` pull request opened: ${result.url}`);
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
this.note(`The pull request could not be opened (${result.step}: ${result.reason}). The branch ${rb.branch} is kept.${result.manual.length ? ` To finish by hand:\n${result.manual.map((m) => ` ${m}`).join("\n")}` : ""}`);
|
|
623
|
+
}
|
|
624
|
+
return result;
|
|
625
|
+
}
|
|
511
626
|
/** On resume: sort out what the interrupted process left behind before anything is woken. */
|
|
512
627
|
recover() {
|
|
513
628
|
const { say, runId, runDir, project, wsMode, log } = this;
|
|
@@ -577,16 +692,34 @@ export class RunSession {
|
|
|
577
692
|
if (wsMode === "worktree" && members.length) {
|
|
578
693
|
state.snapshots = (state.snapshots ?? 0) + 1;
|
|
579
694
|
this.saveState();
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
695
|
+
let ref;
|
|
696
|
+
try {
|
|
697
|
+
ref = snapshotBase(project, runId, state.snapshots, this.leadSpace);
|
|
698
|
+
}
|
|
699
|
+
catch (e) {
|
|
700
|
+
// The lead's worktree was refused (or could not be read): the run cannot go on, and the work stays where it is.
|
|
701
|
+
this.note(`Could not snapshot ${this.leadSpace ? `${project.lead}'s worktree ${this.leadSpace.root}` : "the repo"} for the next batch: ${e.message.split("\n")[0]}`);
|
|
702
|
+
this.endReason = "lead_failed";
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
for (const a of members) {
|
|
706
|
+
try {
|
|
707
|
+
this.batchSpaces[a.name] = prepareAgentWorkspace(project, runId, a.name, ref);
|
|
708
|
+
}
|
|
709
|
+
catch (e) {
|
|
710
|
+
this.refuseMember(a, e.message);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
583
713
|
log("workspaces", { snapshot: ref, agents: Object.fromEntries(Object.entries(this.batchSpaces).map(([n, w]) => [n, w.dir])) });
|
|
584
714
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
715
|
+
this.guard.rebase();
|
|
716
|
+
// a member whose worktree was refused is not woken (it would have no worktree of its own)
|
|
717
|
+
const awake = wsMode === "worktree" ? batch.filter((a) => a.name === project.lead || this.batchSpaces[a.name]) : batch;
|
|
718
|
+
const results = await Promise.all(awake.map((a) => this.wake(a)));
|
|
719
|
+
leadWasLast = awake.some((a) => a.name === project.lead);
|
|
720
|
+
if (awake.some((a) => a.name !== project.lead))
|
|
588
721
|
nudged = false;
|
|
589
|
-
this.settle(
|
|
722
|
+
this.settle(awake, results);
|
|
590
723
|
if (!this.endReason && opts.signal?.aborted)
|
|
591
724
|
this.endReason = "cancelled";
|
|
592
725
|
}
|
|
@@ -597,10 +730,20 @@ export class RunSession {
|
|
|
597
730
|
state.outcome_note = outcomeNote;
|
|
598
731
|
state.verification = this.doneVerification;
|
|
599
732
|
state.ended_at = new Date().toISOString();
|
|
733
|
+
const resultBranch = this.finishLead(endReason);
|
|
734
|
+
let pr;
|
|
735
|
+
if (this.openPrRequested) {
|
|
736
|
+
// The network step can take minutes: the run is stored as ended first, so an interruption there loses nothing and a resume does not push again.
|
|
737
|
+
this.saveState();
|
|
738
|
+
if (this.pendingDoneFile)
|
|
739
|
+
finishDone(this.pendingDoneFile);
|
|
740
|
+
this.pendingDoneFile = undefined;
|
|
741
|
+
pr = this.maybeOpenPr(endReason, outcome, resultBranch);
|
|
742
|
+
}
|
|
600
743
|
log("end", { reason: endReason, outcome, rounds: state.rounds });
|
|
601
744
|
this.saveState();
|
|
602
745
|
if (this.pendingDoneFile)
|
|
603
746
|
finishDone(this.pendingDoneFile);
|
|
604
|
-
return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined };
|
|
747
|
+
return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr };
|
|
605
748
|
}
|
|
606
749
|
}
|
package/dist/run-store.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ResolvedProject } from "./config.js";
|
|
2
2
|
import type { DigestSection, Step } from "./format.js";
|
|
3
3
|
import type { RunOutcome } from "./schema.js";
|
|
4
|
+
import type { ResultBranch } from "./worktree.js";
|
|
4
5
|
export declare const STATE_FILE = "state.json";
|
|
5
6
|
export declare const STATE_SCHEMA_VERSION = 2;
|
|
6
7
|
export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "cancelled" | "waiting";
|
|
@@ -75,6 +76,12 @@ export interface RunState {
|
|
|
75
76
|
}[];
|
|
76
77
|
/** Snapshots of the repo taken so far (`refs/agent-lyceum/<run>/base-<n>`). */
|
|
77
78
|
snapshots?: number;
|
|
79
|
+
/** Concurrent runs: where the lead's work ended up (`git merge <branch>`). Set when the run ends. */
|
|
80
|
+
result_branch?: ResultBranch;
|
|
81
|
+
/** Set when the run started with the lead in its own worktree (max_concurrent_runs > 1 then); its resumes keep that, whatever the config says later. */
|
|
82
|
+
lead_workspace?: true;
|
|
83
|
+
/** The pull request the dispatcher opened for this run (when the lead asked for one and it worked). */
|
|
84
|
+
pr_url?: string;
|
|
78
85
|
sessions: Record<string, string>;
|
|
79
86
|
output_tokens: number;
|
|
80
87
|
last_wake: Record<string, {
|
package/dist/scaffold.js
CHANGED
|
@@ -87,6 +87,7 @@ team:
|
|
|
87
87
|
dispatcher:
|
|
88
88
|
max_rounds: 30 # stop after this many agent wake-ups
|
|
89
89
|
max_parallel: 1 # >1 needs non-overlapping "owns" for every non-lead agent, and a git repo
|
|
90
|
+
max_concurrent_runs: 1 # >1 lets several runs of this project be active at once, each in its own git worktree (needs a git repo); every run's work stays on its own branch
|
|
90
91
|
workspace_mode: auto # auto = a git worktree per non-lead agent when max_parallel > 1; shared | worktree to force
|
|
91
92
|
wake_timeout_sec: 600
|
|
92
93
|
retry: 1
|
package/dist/schema.d.ts
CHANGED
|
@@ -45,6 +45,7 @@ export type AgentPartialT = z.infer<typeof AgentPartial>;
|
|
|
45
45
|
export declare const DispatcherPartial: z.ZodObject<{
|
|
46
46
|
max_rounds: z.ZodOptional<z.ZodNumber>;
|
|
47
47
|
max_parallel: z.ZodOptional<z.ZodNumber>;
|
|
48
|
+
max_concurrent_runs: z.ZodOptional<z.ZodNumber>;
|
|
48
49
|
wake_timeout_sec: z.ZodOptional<z.ZodNumber>;
|
|
49
50
|
retry: z.ZodOptional<z.ZodNumber>;
|
|
50
51
|
strict: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -95,6 +96,7 @@ export declare const ProjectConfig: z.ZodObject<{
|
|
|
95
96
|
dispatcher: z.ZodDefault<z.ZodObject<{
|
|
96
97
|
max_rounds: z.ZodOptional<z.ZodNumber>;
|
|
97
98
|
max_parallel: z.ZodOptional<z.ZodNumber>;
|
|
99
|
+
max_concurrent_runs: z.ZodOptional<z.ZodNumber>;
|
|
98
100
|
wake_timeout_sec: z.ZodOptional<z.ZodNumber>;
|
|
99
101
|
retry: z.ZodOptional<z.ZodNumber>;
|
|
100
102
|
strict: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -139,6 +141,8 @@ export declare const ProjectConfig: z.ZodObject<{
|
|
|
139
141
|
export interface DispatcherSettings {
|
|
140
142
|
max_rounds: number;
|
|
141
143
|
max_parallel: number;
|
|
144
|
+
/** How many runs of one project may be active at once. Above 1, every run works in its own git worktree. */
|
|
145
|
+
max_concurrent_runs: number;
|
|
142
146
|
wake_timeout_sec: number;
|
|
143
147
|
retry: number;
|
|
144
148
|
strict: boolean;
|
package/dist/schema.js
CHANGED
|
@@ -45,6 +45,7 @@ export const DispatcherPartial = z
|
|
|
45
45
|
.object({
|
|
46
46
|
max_rounds: z.number().int().positive().optional(),
|
|
47
47
|
max_parallel: z.number().int().positive().optional(),
|
|
48
|
+
max_concurrent_runs: z.number().int().positive().optional(),
|
|
48
49
|
wake_timeout_sec: z.number().int().positive().optional(),
|
|
49
50
|
retry: z.number().int().min(0).optional(),
|
|
50
51
|
strict: z.boolean().optional(),
|
|
@@ -68,6 +69,7 @@ export const ProjectConfig = z
|
|
|
68
69
|
export const DISPATCHER_DEFAULTS = {
|
|
69
70
|
max_rounds: 30,
|
|
70
71
|
max_parallel: 1,
|
|
72
|
+
max_concurrent_runs: 1,
|
|
71
73
|
wake_timeout_sec: 600,
|
|
72
74
|
retry: 1,
|
|
73
75
|
strict: false,
|
package/dist/status.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export declare function listRuns(project: ResolvedProject, limit: number): {
|
|
|
16
16
|
state: RunState;
|
|
17
17
|
}[];
|
|
18
18
|
/**
|
|
19
|
-
* Is this run's dispatcher running now? Runs with a
|
|
19
|
+
* Is this run's dispatcher running now? Runs with a lock (schema 2) are judged by the lock holder of that run;
|
|
20
20
|
* the recorded pid is only the fallback for older runs and runs started without a lock.
|
|
21
21
|
*/
|
|
22
22
|
export declare function runIsAlive(s: RunState, project?: ResolvedProject): boolean;
|
package/dist/status.js
CHANGED
|
@@ -6,7 +6,7 @@ import { bindRunProject, loadRunState, outcomeOf } from "./run-store.js";
|
|
|
6
6
|
import { briefOf, digestOf } from "./format.js";
|
|
7
7
|
import { listUnread } from "./mailbox.js";
|
|
8
8
|
import { tidyAdvice } from "./memory-tidy.js";
|
|
9
|
-
import {
|
|
9
|
+
import { inspectRunLock, lockHolderAlive } from "./project-lock.js";
|
|
10
10
|
export function latestRun(project) {
|
|
11
11
|
const root = project.paths.runs;
|
|
12
12
|
if (!fs.existsSync(root))
|
|
@@ -57,14 +57,14 @@ export function listRuns(project, limit) {
|
|
|
57
57
|
return out;
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
|
-
* Is this run's dispatcher running now? Runs with a
|
|
60
|
+
* Is this run's dispatcher running now? Runs with a lock (schema 2) are judged by the lock holder of that run;
|
|
61
61
|
* the recorded pid is only the fallback for older runs and runs started without a lock.
|
|
62
62
|
*/
|
|
63
63
|
export function runIsAlive(s, project) {
|
|
64
64
|
if (project && s.schema_version >= 2) {
|
|
65
|
-
const l =
|
|
65
|
+
const l = inspectRunLock(project.paths.root, s.run_id);
|
|
66
66
|
if (l)
|
|
67
|
-
return
|
|
67
|
+
return lockHolderAlive(l);
|
|
68
68
|
}
|
|
69
69
|
return alive(s.pid);
|
|
70
70
|
}
|
package/dist/validate.js
CHANGED
|
@@ -76,11 +76,18 @@ export function validateProject(project) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
// Parallel agents each need their own checkout, which needs git.
|
|
80
|
-
|
|
79
|
+
// Parallel agents and concurrent runs each need their own checkout, which needs git.
|
|
80
|
+
const concurrent = project.dispatcher.max_concurrent_runs > 1;
|
|
81
|
+
if (concurrent && project.dispatcher.workspace_mode === "shared") {
|
|
82
|
+
err(`dispatcher.workspace_mode "shared" cannot be combined with max_concurrent_runs > 1: every run needs its own git worktree (use "auto" or "worktree").`);
|
|
83
|
+
}
|
|
84
|
+
else if (concurrent && fs.existsSync(project.dir) && !isGitRepo(project.dir)) {
|
|
85
|
+
err(`max_concurrent_runs > 1 needs ${project.dir} to be a git repository (each run works in its own git worktree). Set max_concurrent_runs: 1.`);
|
|
86
|
+
}
|
|
87
|
+
else if (project.dispatcher.workspace_mode === "shared" && project.dispatcher.max_parallel > 1) {
|
|
81
88
|
err(`dispatcher.workspace_mode "shared" cannot be combined with max_parallel > 1: parallel agents need separate git worktrees (use "auto" or "worktree").`);
|
|
82
89
|
}
|
|
83
|
-
else if (resolveWorkspaceMode(project.dispatcher) === "worktree" && fs.existsSync(project.dir) && !isGitRepo(project.dir)) {
|
|
90
|
+
else if (!concurrent && resolveWorkspaceMode(project.dispatcher) === "worktree" && fs.existsSync(project.dir) && !isGitRepo(project.dir)) {
|
|
84
91
|
err(`Parallel runs (worktree workspaces) need ${project.dir} to be a git repository. Run sequentially instead (max_parallel: 1, workspace_mode: auto or shared).`);
|
|
85
92
|
}
|
|
86
93
|
// Context paths should live outside the repo (the tool never writes into it).
|
package/dist/worktree.d.ts
CHANGED
|
@@ -1,34 +1,77 @@
|
|
|
1
1
|
import type { ResolvedProject } from "./config.js";
|
|
2
2
|
import type { DispatcherSettings } from "./schema.js";
|
|
3
3
|
export type WorkspaceMode = "shared" | "worktree";
|
|
4
|
-
/** Agents share the repo, or each non-lead agent gets its own git worktree. `auto` picks worktrees as soon as agents can run in parallel. */
|
|
5
|
-
export declare function resolveWorkspaceMode(d: Pick<DispatcherSettings, "workspace_mode" | "max_parallel">): WorkspaceMode;
|
|
4
|
+
/** Agents share the repo, or each non-lead agent gets its own git worktree. `auto` picks worktrees as soon as agents can run in parallel; several concurrent runs always do. */
|
|
5
|
+
export declare function resolveWorkspaceMode(d: Pick<DispatcherSettings, "workspace_mode" | "max_parallel"> & Partial<Pick<DispatcherSettings, "max_concurrent_runs">>): WorkspaceMode;
|
|
6
6
|
export interface AgentWorkspace {
|
|
7
7
|
agent: string;
|
|
8
8
|
/** Root of the git worktree. */
|
|
9
9
|
root: string;
|
|
10
10
|
/** Where the agent works: the worktree's counterpart of the project dir (differs from `root` when the project is a subfolder of the repo). */
|
|
11
11
|
dir: string;
|
|
12
|
-
/** The worktree's private git dir inside the main repo's `.git/worktrees/`. */
|
|
12
|
+
/** The worktree's private git dir inside the main repo's `.git/worktrees/`. Recorded when the worktree was made, never asked of the worktree. */
|
|
13
13
|
gitDir: string;
|
|
14
|
+
/** The main repo's git dir (absolute, real path): where config, hooks, objects and refs live. Recorded with `gitDir`. */
|
|
15
|
+
commonDir: string;
|
|
16
|
+
/** Fingerprint of `<gitDir>/config.worktree` when the worktree was made; absent when there was none. */
|
|
17
|
+
worktreeConfig?: string;
|
|
14
18
|
branch: string;
|
|
15
19
|
/** Commit of the snapshot this workspace's changes are measured against. */
|
|
16
20
|
base: string;
|
|
17
21
|
}
|
|
18
22
|
export declare function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): string;
|
|
23
|
+
type PinnedWorkspace = Pick<AgentWorkspace, "root" | "gitDir" | "commonDir" | "worktreeConfig" | "branch">;
|
|
24
|
+
/**
|
|
25
|
+
* Refuse a worktree whose git files were rewritten in a way pinning cannot neutralise:
|
|
26
|
+
* - `commondir`: git's ref store follows it even with GIT_COMMON_DIR set, so refs would be read from and written to the agent's dir;
|
|
27
|
+
* - `config.worktree` (read when the user enabled extensions.worktreeConfig): it can name filters, chosen by the worktree's own
|
|
28
|
+
* .gitattributes, and a signing program — more than any `-c` list can switch off;
|
|
29
|
+
* - files git writes by following links (COMMIT_EDITMSG, logs/HEAD): a symlink or hard link would make git write outside the sandbox;
|
|
30
|
+
* - `HEAD` not the worktree's own branch: the ref store is shared, so a HEAD naming another branch (say `main`) or a detached
|
|
31
|
+
* commit would make the dispatcher commit onto, or measure against, a ref the agent chose. git writes exactly
|
|
32
|
+
* `ref: refs/heads/<branch>\n` and keeps it so through the dispatcher's commits and `reset --hard`;
|
|
33
|
+
* - the worktree or its git dir replaced by a symlink.
|
|
34
|
+
*/
|
|
35
|
+
export declare function assertWorkspaceIntact(ws: PinnedWorkspace): void;
|
|
36
|
+
/**
|
|
37
|
+
* Run git against an agent's worktree without letting git discover anything from it. The agent may rewrite the worktree's `.git`
|
|
38
|
+
* pointer file and its per-worktree git dir; git is told exactly where the git dir, the common dir and the work tree are
|
|
39
|
+
* (`ws.gitDir` / `ws.commonDir` come from the workspace meta, recorded before any agent ran in it), and the worktree is checked
|
|
40
|
+
* for what pinning cannot neutralise first (`assertWorkspaceIntact`).
|
|
41
|
+
* The cwd is the worktree root: git does not discover from it (GIT_DIR is set), and commands that take paths relative to the cwd
|
|
42
|
+
* (`git apply`) would otherwise write wherever the cwd is.
|
|
43
|
+
*/
|
|
44
|
+
export declare function agentGit(ws: PinnedWorkspace, args: string[], env?: NodeJS.ProcessEnv, input?: string | Buffer): string;
|
|
45
|
+
/** `agentGit`, with a failing git command giving `undefined`. A worktree that fails `assertWorkspaceIntact` still throws. */
|
|
46
|
+
export declare function tryAgentGit(ws: PinnedWorkspace, args: string[], env?: NodeJS.ProcessEnv): string | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Refuse a worktree that holds a git repository of its own. `status`, `add -A` and `commit` can visit a nested repository and run
|
|
49
|
+
* git there with that repository's config — which the agent wrote. Run before any command that walks the working tree.
|
|
50
|
+
* - untracked: `ls-files -o` (it does not enter them, and runs nothing) lists a nested repository as one entry ending in `/`;
|
|
51
|
+
* - a submodule entry in the (agent-writable) index that the snapshot did not have;
|
|
52
|
+
* - a submodule path from the snapshot that now holds a repository (`git worktree add` never fills it, and an agent cannot).
|
|
53
|
+
* A repo that uses submodules keeps working: its submodule entries come from the snapshot and stay empty in a worktree.
|
|
54
|
+
*/
|
|
55
|
+
export declare function assertNoNestedRepos(ws: AgentWorkspace): void;
|
|
19
56
|
export declare function isGitRepo(dir: string): boolean;
|
|
57
|
+
/** The main repository's top-level directory for `dir`. */
|
|
58
|
+
export declare const toplevel: (dir: string) => string;
|
|
20
59
|
/** Files in the repo with changes nobody committed (modified, staged or new and not ignored). Paths are relative to the repo root. */
|
|
21
60
|
export declare function dirtyPaths(dir: string): string[];
|
|
22
61
|
/** Refuse to start a worktree run when it cannot be isolated; returns nothing when it is fine. */
|
|
23
62
|
export declare function assertWorktreeRunnable(project: ResolvedProject, opts: {
|
|
24
63
|
fresh: boolean;
|
|
64
|
+
concurrent?: boolean;
|
|
25
65
|
}): void;
|
|
66
|
+
/** `refs/agent-lyceum/<run>/base-0` pointing at the repo's HEAD: where a concurrent run starts. Nothing in the repo changes. */
|
|
67
|
+
export declare function snapshotHead(project: ResolvedProject, runId: string): string;
|
|
26
68
|
/**
|
|
27
69
|
* Commit the repo's current working tree (tracked changes and new, non-ignored files) as an unreferenced-by-branch
|
|
28
70
|
* snapshot `refs/agent-lyceum/<run>/base-<n>`. Uses a throwaway index, so HEAD, branches, the real index and the
|
|
29
71
|
* working tree are not touched. Returns the ref.
|
|
72
|
+
* `from`: the checkout to snapshot — the lead's worktree in a concurrent run; by default your repo's working tree.
|
|
30
73
|
*/
|
|
31
|
-
export declare function snapshotBase(project: ResolvedProject, runId: string, n: number): string;
|
|
74
|
+
export declare function snapshotBase(project: ResolvedProject, runId: string, n: number, from?: AgentWorkspace): string;
|
|
32
75
|
/**
|
|
33
76
|
* The agent's own checkout of the snapshot `baseRef`, at `<project home>/worktrees/<run>/<agent>`.
|
|
34
77
|
* An existing checkout is reused: untouched, when it already is on this snapshot (resume) or holds unfinished
|
|
@@ -56,6 +99,11 @@ export interface ChangeSet {
|
|
|
56
99
|
}
|
|
57
100
|
/** Glob as used in `owns`: `**` crosses folders, `*` and `?` stay within one, `{a,b}` alternates. */
|
|
58
101
|
export declare function globMatch(glob: string, file: string): boolean;
|
|
102
|
+
/** Commit whatever is in an agent's worktree (no hooks, no signing, dispatcher identity). Returns the HEAD afterwards and whether a commit was made. */
|
|
103
|
+
export declare function commitWorkspace(ws: AgentWorkspace, message: string): {
|
|
104
|
+
head: string;
|
|
105
|
+
committed: boolean;
|
|
106
|
+
};
|
|
59
107
|
/**
|
|
60
108
|
* Commit whatever the agent left in its worktree and list what changed since its snapshot, checking every
|
|
61
109
|
* changed path (modified, added, deleted, or either end of a rename) against the agent's `owns`, the project
|
|
@@ -74,14 +122,20 @@ export type IntegrationResult = {
|
|
|
74
122
|
report: string;
|
|
75
123
|
patch: string;
|
|
76
124
|
};
|
|
125
|
+
/** A member's changes that could not even be collected (e.g. its worktree was refused): reported like any blocked integration, with nothing applied. */
|
|
126
|
+
export declare function refusedIntegration(project: ResolvedProject, workspace: AgentWorkspace, reason: string): IntegrationResult;
|
|
77
127
|
/**
|
|
78
128
|
* Bring an agent's collected changes into the main working tree (not the index, not HEAD), all or nothing.
|
|
79
129
|
* Blocked, with nothing applied and the agent's work kept, when the changes break the rules, when a file
|
|
80
130
|
* changed in the repo since the agent's snapshot, or when git cannot apply the patch. Repeating a call whose
|
|
81
131
|
* changes are already in place is harmless.
|
|
132
|
+
* `target`: the checkout to bring the changes into — the lead's worktree in a concurrent run; by default your repo's working tree.
|
|
133
|
+
*/
|
|
134
|
+
export declare function integrateAgentChanges(project: ResolvedProject, changes: ChangeSet, target?: AgentWorkspace): IntegrationResult;
|
|
135
|
+
/**
|
|
136
|
+
* An agent's existing worktree for this run, if it has one (no worktree or no metadata: undefined).
|
|
137
|
+
* Throws when the metadata predates recording the git dir and the git dir cannot be told for certain.
|
|
82
138
|
*/
|
|
83
|
-
export declare function integrateAgentChanges(project: ResolvedProject, changes: ChangeSet): IntegrationResult;
|
|
84
|
-
/** An agent's existing worktree for this run, if it has one. */
|
|
85
139
|
export declare function existingAgentWorkspace(project: ResolvedProject, runId: string, agent: string): AgentWorkspace | undefined;
|
|
86
140
|
export interface WorkspaceWork {
|
|
87
141
|
agent: string;
|
|
@@ -95,9 +149,22 @@ export interface WorkspaceWork {
|
|
|
95
149
|
unknown?: string;
|
|
96
150
|
}
|
|
97
151
|
/** What is in an agent's worktree that exists nowhere else: uncommitted changes, and commits whose files are not in the main repo. */
|
|
98
|
-
export declare function inspectWorkspace(project: ResolvedProject, runId: string, agent: string): WorkspaceWork | undefined;
|
|
152
|
+
export declare function inspectWorkspace(project: ResolvedProject, runId: string, agent: string, target?: AgentWorkspace): WorkspaceWork | undefined;
|
|
153
|
+
export interface ResultBranch {
|
|
154
|
+
branch: string;
|
|
155
|
+
head: string;
|
|
156
|
+
/** The snapshot the run started from. */
|
|
157
|
+
base: string;
|
|
158
|
+
/** Files the branch changes relative to `base`. */
|
|
159
|
+
files: number;
|
|
160
|
+
/** The same changes as a patch file (absent when nothing changed). */
|
|
161
|
+
patch?: string;
|
|
162
|
+
}
|
|
163
|
+
/** Commit the lead's checkout on its branch (once, at the end of a run) and write what changed as a patch. Never touches your repo's working tree. */
|
|
164
|
+
export declare function finishLeadBranch(ws: AgentWorkspace, message: string, patchFile: string): ResultBranch;
|
|
99
165
|
/** Names of the agents that have a worktree for this run. */
|
|
100
166
|
export declare function listWorkspaceAgents(project: ResolvedProject, runId: string): string[];
|
|
101
167
|
/** The snapshot refs (`refs/agent-lyceum/<run>/...`) of a run. */
|
|
102
168
|
export declare function listSnapshotRefs(project: ResolvedProject, runId: string): string[];
|
|
103
169
|
export declare function deleteSnapshotRefs(project: ResolvedProject, runId: string): void;
|
|
170
|
+
export {};
|