agent-lyceum 0.11.2 → 0.13.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.
@@ -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
- const wsMode = resolveWorkspaceMode(cfg);
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
- const guard = new ProtectedGuard(project);
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 reply = readAskReply(runDir);
144
- if (!reply)
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 aproject = Object.keys(this.batchSpaces).length ? { ...project, workspaces: this.batchSpaces } : project;
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: this.batchSpaces[agent.name],
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
- const changes = collectAgentChanges(ws, agent.owns);
310
- const r = integrateAgentChanges(project, changes);
311
- 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 } : {}) });
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
- const ref = snapshotBase(project, runId, state.snapshots);
581
- for (const a of members)
582
- this.batchSpaces[a.name] = prepareAgentWorkspace(project, runId, a.name, ref);
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
- const results = await Promise.all(batch.map((a) => this.wake(a)));
586
- leadWasLast = batch.some((a) => a.name === project.lead);
587
- if (batch.some((a) => a.name !== project.lead))
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(batch, results);
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
  }
@@ -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 project lock (schema 2) are judged by the lock holder;
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 { inspectProjectLock, lockHolderAlive } from "./project-lock.js";
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 project lock (schema 2) are judged by the lock holder;
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 = inspectProjectLock(project.paths.root);
65
+ const l = inspectRunLock(project.paths.root, s.run_id);
66
66
  if (l)
67
- return l.run_id === s.run_id && lockHolderAlive(l);
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
- if (project.dispatcher.workspace_mode === "shared" && project.dispatcher.max_parallel > 1) {
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).
@@ -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 {};