agent-lyceum 0.17.3 → 0.18.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 +3 -3
- package/README.zh-TW.md +3 -3
- package/dist/adapters/claude.js +1 -7
- package/dist/call.js +1 -2
- package/dist/cli.js +17 -9
- package/dist/config.d.ts +10 -6
- package/dist/config.js +24 -7
- package/dist/demo.js +0 -1
- package/dist/dispatcher.d.ts +2 -1
- package/dist/dispatcher.js +3 -1
- package/dist/doctor.js +11 -12
- package/dist/paths.d.ts +0 -2
- package/dist/paths.js +0 -11
- package/dist/policy.d.ts +0 -6
- package/dist/policy.js +2 -10
- package/dist/project-lock.d.ts +10 -2
- package/dist/project-lock.js +23 -12
- package/dist/prompt.js +0 -3
- package/dist/run-session.d.ts +3 -1
- package/dist/run-session.js +20 -30
- package/dist/run-store.d.ts +1 -1
- package/dist/scaffold.js +0 -5
- package/dist/schema.d.ts +12 -22
- package/dist/schema.js +8 -7
- package/dist/solo.js +1 -2
- package/dist/validate.js +5 -38
- package/dist/worktree.d.ts +19 -12
- package/dist/worktree.js +35 -74
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Give a task to a **team of agents** (Claude Code and/or Codex) instead of one ov
|
|
|
10
10
|
|
|
11
11
|
- **Roles that stay in role.** Each agent has its own persona (`AGENT.md`) and long-term memory, so a reviewer keeps reviewing instead of drifting into writing code.
|
|
12
12
|
- **Agents can't rewrite the rules.** An agent can only write its own memory and outbox. It can't touch other agents' context or its own persona, and this is enforced by the OS sandbox where possible, not just by a prompt. See [Write scope](docs/write-scope.md).
|
|
13
|
-
- **Mix runtimes.** Claude Code and Codex agents work in the same team
|
|
13
|
+
- **Mix runtimes.** Claude Code and Codex agents work in the same team.
|
|
14
14
|
- **Nothing hidden.** Agents talk through plain-file mailboxes, so every message, decision and memory is a file you can read, grep and diff. The dispatcher never merges for you.
|
|
15
15
|
|
|
16
16
|
**Not a fit** for small, one-shot edits: a single Claude Code or Codex session is faster and cheaper. Use a team when the task is big enough to split, review or run in parallel.
|
|
@@ -39,7 +39,7 @@ Latest unreleased code: `npx github:gogogohuang/agent-lyceum <command>`.
|
|
|
39
39
|
3. Agents reply by writing mail to their own `outbox/`; the dispatcher checks `can_message` and delivers it.
|
|
40
40
|
4. The run ends when the lead sends `done`.
|
|
41
41
|
|
|
42
|
-
Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyceum-config/`, not in your repo.
|
|
42
|
+
Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyceum-config/`, not in your repo. Concurrency needs no configuration. In a git repo with a commit, every run works in its own git worktree and ends on its own branch (`agent-lyceum/<run>/…`), members with pending mail wake in parallel, and any number of runs can be active at once; the result is not written into your working tree, so use `git merge <branch>` or `open_pr`, and note that a run starts from `HEAD` and does not see your uncommitted edits. In a non-git folder, a repo with no commit, or a repo using reftable, the run works directly in the folder, one agent at a time (an info line says why), and only one such run can be active. agent-lyceum never merges. Upgrading from a version with `owns`, `max_parallel`, `max_concurrent_runs` or `workspace_mode`: see [Upgrading](docs/upgrading-run-v2.md). More: [How a run works](docs/how-it-works.md), [Configuration](docs/configuration.md).
|
|
43
43
|
|
|
44
44
|
## Commands
|
|
45
45
|
|
|
@@ -68,7 +68,7 @@ dir: ~/code/web-app
|
|
|
68
68
|
team: { lead: lead }
|
|
69
69
|
agents:
|
|
70
70
|
lead: { can_message: all, can_edit_agent_md: true }
|
|
71
|
-
fe-member: { runtime: codex, can_message: [lead]
|
|
71
|
+
fe-member: { runtime: codex, can_message: [lead] }
|
|
72
72
|
pm: { can_message: [lead], allow_web: fetch }
|
|
73
73
|
```
|
|
74
74
|
|
package/README.zh-TW.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
- **角色不會跑掉。** 每個 agent 有自己的人設(`AGENT.md`)與長期記憶,reviewer 會一直做 review,不會漸漸變成在寫程式。
|
|
12
12
|
- **agent 改不了規則。** agent 只能寫自己的記憶與寄件匣(outbox),不能改其他 agent 的 context,也不能改自己的人設;能做到的地方由 OS 沙箱強制,而不只靠 prompt。見 [Write scope](docs/write-scope.zh-TW.md)。
|
|
13
|
-
- **混用 runtime。** Claude Code 與 Codex agent
|
|
13
|
+
- **混用 runtime。** Claude Code 與 Codex agent 可在同一個團隊。
|
|
14
14
|
- **沒有黑箱。** agent 透過純檔案信箱溝通,每則訊息、決策與記憶都是你能讀、能 grep、能 diff 的檔案;dispatcher 不會替你 merge。
|
|
15
15
|
|
|
16
16
|
**不適合**小而單次的修改:單一 Claude Code 或 Codex session 更快更省。任務大到值得拆分、review 或平行執行時,再用團隊。
|
|
@@ -39,7 +39,7 @@ npx agent-lyceum status
|
|
|
39
39
|
3. agent 把回信寫進自己的 `outbox/`,dispatcher 檢查 `can_message` 後投遞。
|
|
40
40
|
4. lead 寄出 `done` 時,run 結束。
|
|
41
41
|
|
|
42
|
-
工具自己的資料(設定、信箱、log、記憶、worktree)放在 `~/agent-lyceum-config/`,不放進你的 repo
|
|
42
|
+
工具自己的資料(設定、信箱、log、記憶、worktree)放在 `~/agent-lyceum-config/`,不放進你的 repo。並行不需要任何設定。在有 commit 的 git repo 裡,每個 run 都在自己的 git worktree 工作,並結束在自己的 branch(`agent-lyceum/<run>/…`);有待處理信件的成員會平行喚醒,同時也可有任意多個 run;結果不會寫進你的工作目錄,請用 `git merge <branch>` 或 `open_pr`,且 run 從 `HEAD` 起跑,看不到你未提交的修改。在非 git 資料夾、沒有 commit 的 repo 或使用 reftable 的 repo,run 直接在該資料夾工作、一次只跑一個 agent(會有一行說明原因),且同時只能有一個這樣的 run。agent-lyceum 不會幫你合併。從有 `owns`、`max_parallel`、`max_concurrent_runs` 或 `workspace_mode` 的版本升級:見[升級](docs/upgrading-run-v2.md)。詳見[一次執行如何運作](docs/how-it-works.zh-TW.md)、[設定](docs/configuration.zh-TW.md)。
|
|
43
43
|
|
|
44
44
|
## 指令
|
|
45
45
|
|
|
@@ -68,7 +68,7 @@ dir: ~/code/web-app
|
|
|
68
68
|
team: { lead: lead }
|
|
69
69
|
agents:
|
|
70
70
|
lead: { can_message: all, can_edit_agent_md: true }
|
|
71
|
-
fe-member: { runtime: codex, can_message: [lead]
|
|
71
|
+
fe-member: { runtime: codex, can_message: [lead] }
|
|
72
72
|
pm: { can_message: [lead], allow_web: fetch }
|
|
73
73
|
```
|
|
74
74
|
|
package/dist/adapters/claude.js
CHANGED
|
@@ -27,13 +27,7 @@ export function buildClaudeSettings(project, agent) {
|
|
|
27
27
|
allow.push("WebSearch");
|
|
28
28
|
if (agent.allowWeb === "fetch")
|
|
29
29
|
allow.push("WebFetch");
|
|
30
|
-
|
|
31
|
-
for (const g of pol.owns)
|
|
32
|
-
allow.push(rule("Edit", path.join(repo, g)));
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
allow.push(rule("Edit", `${repo}/**`));
|
|
36
|
-
}
|
|
30
|
+
allow.push(rule("Edit", `${repo}/**`));
|
|
37
31
|
for (const d of pol.allowDirs)
|
|
38
32
|
allow.push(rule("Edit", `${d}/**`));
|
|
39
33
|
for (const f of pol.allowFiles)
|
package/dist/call.js
CHANGED
|
@@ -33,7 +33,6 @@ export function resolveCallAgent(home, name) {
|
|
|
33
33
|
canMessage: [],
|
|
34
34
|
canAskUser: false,
|
|
35
35
|
canEditAgentMd: false,
|
|
36
|
-
owns: [],
|
|
37
36
|
};
|
|
38
37
|
}
|
|
39
38
|
/** The made-up project a call runs in: the working directory is its repo, and every other path is inside the call's own folder. */
|
|
@@ -54,7 +53,7 @@ export function callProject(home, agent, dir, callDir) {
|
|
|
54
53
|
name: CALL_PROJECT,
|
|
55
54
|
dir,
|
|
56
55
|
lead: agent.name,
|
|
57
|
-
dispatcher:
|
|
56
|
+
dispatcher: DISPATCHER_DEFAULTS,
|
|
58
57
|
agents: { [agent.name]: agent },
|
|
59
58
|
paths,
|
|
60
59
|
solo: "call",
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { RESULT_FILE, runTeam } from "./dispatcher.js";
|
|
|
11
11
|
import { executeRunCleanup, planRunCleanup } from "./run-cleanup.js";
|
|
12
12
|
import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
|
|
13
13
|
import { acquireProjectLock, forceUnlock, inspectRunLock, listRunLocks, listUnreadableRunLocks, LOCK_FILE, lockHolderAlive } from "./project-lock.js";
|
|
14
|
+
import { planWorkspace } from "./worktree.js";
|
|
14
15
|
import { loadRunState, newRunId, outcomeOf, resolveRun, roundLimit, roundsLabel, runLabel, slugify, uniqueName } from "./run-store.js";
|
|
15
16
|
import { failureGuide } from "./run-outcome.js";
|
|
16
17
|
import { exitCodeForOutcome, RUNTIMES } from "./schema.js";
|
|
@@ -132,10 +133,10 @@ function findRun(pr, query) {
|
|
|
132
133
|
const list = r.candidates.map((c) => ` ${c.state.name ?? "(no name)"} ${c.state.run_id} ${c.state.task_summary}`).join("\n");
|
|
133
134
|
return fail(`"${query}" matches several runs:\n${list}\nGive more of the name or id.`);
|
|
134
135
|
}
|
|
135
|
-
/** Take the lock of one run, or exit with the reason. `
|
|
136
|
+
/** Take the lock of one run, or exit with the reason. `checkout`: the run works in the project checkout, so only one may be active. `exclusive`: it needs the whole project (memory tidy). */
|
|
136
137
|
function takeLock(pr, runId, opts = {}) {
|
|
137
138
|
try {
|
|
138
|
-
return acquireProjectLock(pr.paths.root, runId, {
|
|
139
|
+
return acquireProjectLock(pr.paths.root, runId, { checkout: opts.checkout, exclusive: opts.exclusive, dir: pr.dir, reason: opts.reason });
|
|
139
140
|
}
|
|
140
141
|
catch (e) {
|
|
141
142
|
return fail(e.message);
|
|
@@ -197,17 +198,23 @@ function reportRun(summary, runDir) {
|
|
|
197
198
|
const firstLine = (t) => t.trim().split("\n")[0].trim();
|
|
198
199
|
/** Take the lock and continue a run; the lock is released when it stops (also when it stops because it waits for answers). */
|
|
199
200
|
async function continueRun(pr, dir, runId, followup) {
|
|
200
|
-
const
|
|
201
|
+
const planned = loadRunState(dir);
|
|
202
|
+
// Where the run works is decided (from its state) before the lock is taken, and handed to the session as is.
|
|
203
|
+
const plan = planWorkspace(pr, planned);
|
|
204
|
+
const lease = takeLock(pr, runId, { checkout: !plan.leadWorktree, reason: "it was started without its own worktree" });
|
|
201
205
|
const cancel = cancelOnSignals();
|
|
202
206
|
try {
|
|
203
|
-
|
|
207
|
+
// Read the state again under the lock: another process may have changed it while we were checking.
|
|
204
208
|
const state = loadRunState(dir);
|
|
209
|
+
if (state.lead_workspace !== planned.lead_workspace || state.workspace_mode !== planned.workspace_mode) {
|
|
210
|
+
throw new Error(`Run ${runId} changed while it was starting (where it works was ${planned.lead_workspace ? "its own worktree" : planned.workspace_mode ?? "unset"}, now ${state.lead_workspace ? "its own worktree" : state.workspace_mode ?? "unset"}); try again.`);
|
|
211
|
+
}
|
|
212
|
+
console.log(`Project ${pr.name} — repo ${pr.dir}`);
|
|
205
213
|
if (followup)
|
|
206
214
|
console.log(`Reopening run ${runLabel(state)} (follow-up ${(state.reopened?.length ?? 0) + 1}): ${firstLine(followup.text)}`);
|
|
207
215
|
else
|
|
208
216
|
console.log(`Resuming run ${runLabel(state)} (${state.end_reason ?? "interrupted"}) at round ${roundsLabel(state.rounds, roundLimit(state, pr.dispatcher.max_rounds))}: ${state.task_summary}`);
|
|
209
|
-
|
|
210
|
-
return await runTeam({ project: pr, resume: state, runDir: dir, signal: cancel.signal, followup });
|
|
217
|
+
return await runTeam({ project: pr, resume: state, plan, runDir: dir, signal: cancel.signal, followup });
|
|
211
218
|
}
|
|
212
219
|
finally {
|
|
213
220
|
cancel.dispose();
|
|
@@ -289,7 +296,8 @@ program
|
|
|
289
296
|
}
|
|
290
297
|
const runId = newRunId();
|
|
291
298
|
const runDir = path.join(pr.paths.runs, runId);
|
|
292
|
-
const
|
|
299
|
+
const plan = planWorkspace(pr);
|
|
300
|
+
const lease = takeLock(pr, runId, { checkout: !plan.leadWorktree, reason: plan.reason });
|
|
293
301
|
let summary;
|
|
294
302
|
const cancel = cancelOnSignals();
|
|
295
303
|
try {
|
|
@@ -297,7 +305,7 @@ program
|
|
|
297
305
|
console.log(`Project ${pr.name} — repo ${pr.dir}${opts.agent ? ` — solo: ${opts.agent}` : ""}`);
|
|
298
306
|
console.log(`Task: ${prepared.source === "file" ? `file ${prepared.sourcePath}` : "text"} (${prepared.bytes} bytes${prepared.inline ? "" : ", passed by reference"})`);
|
|
299
307
|
const name = opts.name ?? uniqueName(slugify(taskMessageBody(prepared).subject), taken);
|
|
300
|
-
summary = await runTeam({ project: pr, name, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
|
|
308
|
+
summary = await runTeam({ project: pr, name, plan, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
|
|
301
309
|
}
|
|
302
310
|
finally {
|
|
303
311
|
cancel.dispose();
|
|
@@ -514,7 +522,7 @@ program
|
|
|
514
522
|
refuse(plan);
|
|
515
523
|
return;
|
|
516
524
|
}
|
|
517
|
-
const lease = takeLock(pr, full, {
|
|
525
|
+
const lease = takeLock(pr, full, { checkout: false });
|
|
518
526
|
try {
|
|
519
527
|
const plan = planRunCleanup(pr, full, { keepWorktrees: opts.keepWorktrees });
|
|
520
528
|
if (plan.refusals.length)
|
package/dist/config.d.ts
CHANGED
|
@@ -22,7 +22,6 @@ export interface ResolvedAgent {
|
|
|
22
22
|
canAskUser: boolean;
|
|
23
23
|
/** Web access for this agent; undefined = not configured, so the runtime keeps its own default. */
|
|
24
24
|
allowWeb?: WebLevel;
|
|
25
|
-
owns: string[];
|
|
26
25
|
/** Where each field's value came from (for `validate` output). */
|
|
27
26
|
sources: Record<string, Source>;
|
|
28
27
|
}
|
|
@@ -35,6 +34,11 @@ export interface ResolvedProject {
|
|
|
35
34
|
dispatcher: DispatcherSettings;
|
|
36
35
|
agents: Record<string, ResolvedAgent>;
|
|
37
36
|
paths: ProjectPaths;
|
|
37
|
+
/** Removed settings still present in config files (ignored); `validate` warns once per file. */
|
|
38
|
+
ignoredSettings?: {
|
|
39
|
+
file: string;
|
|
40
|
+
keys: string[];
|
|
41
|
+
}[];
|
|
38
42
|
/** Set once the project is bound to one run (see `bindRunProject`): which run, and where its mail lives. */
|
|
39
43
|
run?: {
|
|
40
44
|
id: string;
|
|
@@ -61,7 +65,7 @@ export declare function loadGlobal(home: string): {
|
|
|
61
65
|
can_edit_agent_md?: boolean | undefined;
|
|
62
66
|
can_ask_user?: boolean | undefined;
|
|
63
67
|
allow_web?: "fetch" | "none" | "search" | undefined;
|
|
64
|
-
owns?:
|
|
68
|
+
owns?: unknown;
|
|
65
69
|
}>;
|
|
66
70
|
};
|
|
67
71
|
export declare function loadProjectRaw(home: string, name: string): {
|
|
@@ -71,12 +75,12 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
71
75
|
};
|
|
72
76
|
dispatcher: {
|
|
73
77
|
max_rounds?: number | undefined;
|
|
74
|
-
max_parallel?:
|
|
75
|
-
max_concurrent_runs?:
|
|
78
|
+
max_parallel?: unknown;
|
|
79
|
+
max_concurrent_runs?: unknown;
|
|
76
80
|
wake_timeout_sec?: number | undefined;
|
|
77
81
|
retry?: number | undefined;
|
|
78
82
|
strict?: boolean | undefined;
|
|
79
|
-
workspace_mode?:
|
|
83
|
+
workspace_mode?: unknown;
|
|
80
84
|
log_max_bytes?: number | undefined;
|
|
81
85
|
};
|
|
82
86
|
agents: Record<string, {
|
|
@@ -93,7 +97,7 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
93
97
|
can_edit_agent_md?: boolean | undefined;
|
|
94
98
|
can_ask_user?: boolean | undefined;
|
|
95
99
|
allow_web?: "fetch" | "none" | "search" | undefined;
|
|
96
|
-
owns?:
|
|
100
|
+
owns?: unknown;
|
|
97
101
|
}>;
|
|
98
102
|
};
|
|
99
103
|
export declare function resolveProject(home: string, name: string): ResolvedProject;
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { absPath, homePaths, projectPaths, } from "./paths.js";
|
|
5
|
-
import { DISPATCHER_DEFAULTS, GlobalConfig, ProjectConfig, inferRuntime, } from "./schema.js";
|
|
5
|
+
import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, } from "./schema.js";
|
|
6
6
|
export class ConfigError extends Error {
|
|
7
7
|
}
|
|
8
8
|
function readYaml(file, schema, label) {
|
|
@@ -58,7 +58,8 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
58
58
|
const paths = projectPaths(home, name);
|
|
59
59
|
const hp = homePaths(home);
|
|
60
60
|
const lead = p.team.lead;
|
|
61
|
-
const
|
|
61
|
+
const { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, ...liveDispatcher } = p.dispatcher;
|
|
62
|
+
const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(liveDispatcher) };
|
|
62
63
|
const agents = {};
|
|
63
64
|
for (const agentName of Object.keys(p.agents)) {
|
|
64
65
|
const gl = resolveLayer(g.agents[agentName], home);
|
|
@@ -134,13 +135,11 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
134
135
|
const canEdit = pick("can_edit_agent_md");
|
|
135
136
|
const canAsk = pick("can_ask_user");
|
|
136
137
|
const allowWeb = pick("allow_web");
|
|
137
|
-
const owns = pick("owns");
|
|
138
138
|
for (const [k, v] of [
|
|
139
139
|
["resume", resume],
|
|
140
140
|
["can_message", canMessage],
|
|
141
141
|
["can_edit_agent_md", canEdit],
|
|
142
142
|
["can_ask_user", canAsk],
|
|
143
|
-
["owns", owns],
|
|
144
143
|
]) {
|
|
145
144
|
if (v === undefined)
|
|
146
145
|
sources[k] = "default";
|
|
@@ -157,7 +156,6 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
157
156
|
canEditAgentMd: canEdit ?? isLead,
|
|
158
157
|
canAskUser: canAsk ?? isLead,
|
|
159
158
|
allowWeb,
|
|
160
|
-
owns: owns ?? [],
|
|
161
159
|
sources,
|
|
162
160
|
};
|
|
163
161
|
}
|
|
@@ -169,8 +167,28 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
169
167
|
dispatcher,
|
|
170
168
|
agents,
|
|
171
169
|
paths,
|
|
170
|
+
ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
|
|
172
171
|
};
|
|
173
172
|
}
|
|
173
|
+
/** The removed keys present in the global and project files, by full path, per file (empty files omitted). */
|
|
174
|
+
function collectIgnored(globalFile, g, projectFile, p) {
|
|
175
|
+
const out = [];
|
|
176
|
+
const scan = (file, dispatcher, agents) => {
|
|
177
|
+
const keys = [];
|
|
178
|
+
for (const k of REMOVED_DISPATCHER_KEYS)
|
|
179
|
+
if (dispatcher?.[k] !== undefined)
|
|
180
|
+
keys.push(`dispatcher.${k}`);
|
|
181
|
+
for (const [name, a] of Object.entries(agents))
|
|
182
|
+
for (const k of REMOVED_AGENT_KEYS)
|
|
183
|
+
if (a[k] !== undefined)
|
|
184
|
+
keys.push(`agents.${name}.${k}`);
|
|
185
|
+
if (keys.length)
|
|
186
|
+
out.push({ file, keys });
|
|
187
|
+
};
|
|
188
|
+
scan(globalFile, undefined, g.agents);
|
|
189
|
+
scan(projectFile, p.dispatcher, p.agents);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
174
192
|
function stripUndef(o) {
|
|
175
193
|
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
176
194
|
}
|
|
@@ -205,7 +223,7 @@ export function findProjectForCwd(home, cwd) {
|
|
|
205
223
|
}
|
|
206
224
|
return best?.name;
|
|
207
225
|
}
|
|
208
|
-
const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web"
|
|
226
|
+
const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web"];
|
|
209
227
|
/** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
|
|
210
228
|
export function resolveProjectWithSources(home, name) {
|
|
211
229
|
const project = resolveProject(home, name);
|
|
@@ -264,7 +282,6 @@ export function flattenResolved(project) {
|
|
|
264
282
|
v[`${p}.can_edit_agent_md`] = a.canEditAgentMd;
|
|
265
283
|
v[`${p}.can_ask_user`] = a.canAskUser;
|
|
266
284
|
v[`${p}.allow_web`] = a.allowWeb;
|
|
267
|
-
v[`${p}.owns`] = a.owns;
|
|
268
285
|
}
|
|
269
286
|
return v;
|
|
270
287
|
}
|
package/dist/demo.js
CHANGED
|
@@ -82,7 +82,6 @@ export function prepareDemo(dir, runtime) {
|
|
|
82
82
|
// One runtime for the whole team, and the roles scoped to the sample repo.
|
|
83
83
|
for (const agent of ["lead", "fe-member", "qa-member"])
|
|
84
84
|
yaml = yaml.replace(` ${agent}:\n`, ` ${agent}:\n runtime: ${runtime}\n`);
|
|
85
|
-
yaml = yaml.replace('# owns: ["src/web/**"]', 'owns: ["src/**"]').replace('# owns: ["tests/**"]', 'owns: ["test/**"]');
|
|
86
85
|
fs.writeFileSync(cfg, yaml);
|
|
87
86
|
return { dir: root, home, repo };
|
|
88
87
|
}
|
package/dist/dispatcher.d.ts
CHANGED
|
@@ -3,4 +3,5 @@ import { newRunId } from "./run-store.js";
|
|
|
3
3
|
export { newRunId };
|
|
4
4
|
export type { ActiveWake, EndReason, RunState, SentTopic, WakeRecord, WakeTopic } from "./run-store.js";
|
|
5
5
|
export { MAX_DONE_REJECTIONS, RESULT_FILE, type RunOptions, type RunSummary } from "./run-session.js";
|
|
6
|
-
|
|
6
|
+
/** `plan` is what the CLI decided before taking the run's lock. The CLI always passes it; the fallback exists only for direct callers and tests. */
|
|
7
|
+
export declare function runTeam(opts: Omit<RunOptions, "plan"> & Partial<Pick<RunOptions, "plan">>): Promise<RunSummary>;
|
package/dist/dispatcher.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { RunSession } from "./run-session.js";
|
|
2
2
|
import { newRunId } from "./run-store.js";
|
|
3
|
+
import { planWorkspace } from "./worktree.js";
|
|
3
4
|
export { newRunId };
|
|
4
5
|
export { MAX_DONE_REJECTIONS, RESULT_FILE } from "./run-session.js";
|
|
6
|
+
/** `plan` is what the CLI decided before taking the run's lock. The CLI always passes it; the fallback exists only for direct callers and tests. */
|
|
5
7
|
export async function runTeam(opts) {
|
|
6
|
-
return new RunSession(opts).run();
|
|
8
|
+
return new RunSession({ ...opts, plan: opts.plan ?? planWorkspace(opts.project, opts.resume) }).run();
|
|
7
9
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -6,7 +6,7 @@ import { tidyAdvice } from "./memory-tidy.js";
|
|
|
6
6
|
import { listRunLocks, listUnreadableRunLocks, lockHolderAlive } from "./project-lock.js";
|
|
7
7
|
import { runInvocation } from "./process-runner.js";
|
|
8
8
|
import { validateProject } from "./validate.js";
|
|
9
|
-
import { dirtyPaths, git,
|
|
9
|
+
import { dirtyPaths, git, isolationBlocker } from "./worktree.js";
|
|
10
10
|
const BINARY = { "claude-code": "claude", codex: "codex" };
|
|
11
11
|
/** Run a read-only CLI query (`--version`, `--help`): bounded in time and output, never an agent task. */
|
|
12
12
|
async function query(cmd, args, opts) {
|
|
@@ -116,20 +116,19 @@ export async function diagnoseProject(project, opts = {}) {
|
|
|
116
116
|
checks.push({ level: i.level === "error" ? "error" : "warn", subject: "config", message: i.message });
|
|
117
117
|
if (!v.issues.length)
|
|
118
118
|
checks.push({ level: "ok", subject: "config", message: "Configuration is valid." });
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
const blocker = fs.existsSync(project.dir) ? isolationBlocker(project.dir) : undefined;
|
|
120
|
+
if (fs.existsSync(project.dir)) {
|
|
121
|
+
if (blocker) {
|
|
122
|
+
checks.push({ level: "info", subject: "git", message: `Runs work directly in ${project.dir}, one agent at a time (${blocker}).` });
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
121
125
|
const dirty = dirtyPaths(project.dir);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
else if (dirty.length) {
|
|
126
|
-
checks.push({ level: "warn", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: a parallel run would refuse to start until they are committed or stashed.` });
|
|
127
|
-
}
|
|
128
|
-
else
|
|
129
|
-
checks.push({ level: "ok", subject: "git", message: "Repo is clean; parallel runs can start." });
|
|
126
|
+
checks.push(dirty.length
|
|
127
|
+
? { level: "info", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: runs start from HEAD and do not see them.` }
|
|
128
|
+
: { level: "ok", subject: "git", message: "git repo with a commit: runs and agents work in their own worktrees." });
|
|
130
129
|
}
|
|
131
130
|
}
|
|
132
|
-
if (
|
|
131
|
+
if (fs.existsSync(project.dir) && !blocker) {
|
|
133
132
|
const missing = [];
|
|
134
133
|
if (!findOnPath("gh", opts.env?.PATH ?? process.env.PATH))
|
|
135
134
|
missing.push("`gh` is not on PATH");
|
package/dist/paths.d.ts
CHANGED
|
@@ -26,5 +26,3 @@ export interface ProjectPaths {
|
|
|
26
26
|
export declare function projectPaths(home: string, project: string): ProjectPaths;
|
|
27
27
|
/** True when `child` is `parent` or inside it. */
|
|
28
28
|
export declare function isInside(parent: string, child: string): boolean;
|
|
29
|
-
/** Directory part of a glob that precedes the first wildcard segment. */
|
|
30
|
-
export declare function globStaticPrefix(glob: string): string;
|
package/dist/paths.js
CHANGED
|
@@ -49,14 +49,3 @@ export function isInside(parent, child) {
|
|
|
49
49
|
const rel = path.relative(parent, child);
|
|
50
50
|
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
51
51
|
}
|
|
52
|
-
/** Directory part of a glob that precedes the first wildcard segment. */
|
|
53
|
-
export function globStaticPrefix(glob) {
|
|
54
|
-
const segs = glob.split("/");
|
|
55
|
-
const out = [];
|
|
56
|
-
for (const s of segs) {
|
|
57
|
-
if (/[*?[\]{}]/.test(s))
|
|
58
|
-
break;
|
|
59
|
-
out.push(s);
|
|
60
|
-
}
|
|
61
|
-
return out.join("/");
|
|
62
|
-
}
|
package/dist/policy.d.ts
CHANGED
|
@@ -8,7 +8,6 @@ export interface AgentEnforcement {
|
|
|
8
8
|
otherContext: Level;
|
|
9
9
|
shared: Level;
|
|
10
10
|
repoInstructions: Level;
|
|
11
|
-
owns: Level;
|
|
12
11
|
/** Absolute paths outside the repo this agent may write. */
|
|
13
12
|
writablePaths: string[];
|
|
14
13
|
notes: string[];
|
|
@@ -22,9 +21,6 @@ export interface WritePolicy {
|
|
|
22
21
|
allowFiles: string[];
|
|
23
22
|
/** Files/dirs that must stay unwritable (checked even inside the repo). */
|
|
24
23
|
deny: string[];
|
|
25
|
-
/** Repo-relative globs the agent owns (only enforced when max_parallel > 1). */
|
|
26
|
-
owns: string[];
|
|
27
|
-
restrictRepoToOwns: boolean;
|
|
28
24
|
}
|
|
29
25
|
export declare function commonFile(project: ResolvedProject): string;
|
|
30
26
|
export declare function outboxDir(project: ResolvedProject, agent: string): string;
|
|
@@ -42,7 +38,5 @@ export declare function sharedIndex(project: ResolvedProject): string | undefine
|
|
|
42
38
|
export declare function repoDirFor(project: ResolvedProject, agent: string): string;
|
|
43
39
|
export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
|
|
44
40
|
export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
|
|
45
|
-
/** Static directory prefixes (absolute) of an agent's `owns` globs. */
|
|
46
|
-
export declare function ownsDirs(project: ResolvedProject, agent: ResolvedAgent): string[];
|
|
47
41
|
/** Can `agent` legitimately change this protected file? */
|
|
48
42
|
export declare function mayEditProtected(project: ResolvedProject, agent: ResolvedAgent, file: string): boolean;
|
package/dist/policy.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { findOnPath } from "./fs-util.js";
|
|
4
|
-
import { globStaticPrefix } from "./paths.js";
|
|
5
4
|
export function commonFile(project) {
|
|
6
5
|
return path.join(project.paths.shared, "common", "COMMON.md");
|
|
7
6
|
}
|
|
@@ -123,8 +122,6 @@ export function writePolicy(project, agent) {
|
|
|
123
122
|
readDirs: shared && !mayWriteShared ? [shared] : [],
|
|
124
123
|
allowFiles,
|
|
125
124
|
deny: [...new Set(deny)],
|
|
126
|
-
owns: agent.owns,
|
|
127
|
-
restrictRepoToOwns: project.dispatcher.max_parallel > 1 && agent.owns.length > 0 && !isLead,
|
|
128
125
|
};
|
|
129
126
|
}
|
|
130
127
|
let sandboxProbe;
|
|
@@ -143,12 +140,11 @@ export function enforcementFor(project, agent) {
|
|
|
143
140
|
const pol = writePolicy(project, agent);
|
|
144
141
|
const os = osSandboxAvailable();
|
|
145
142
|
const notes = [];
|
|
146
|
-
const parallel = project.dispatcher.max_parallel > 1;
|
|
147
143
|
const isLeadAgent = agent.name === project.lead;
|
|
148
144
|
let e;
|
|
149
145
|
if (agent.runtime === "claude-code") {
|
|
150
146
|
const lvl = os ? "os" : "tool-rules";
|
|
151
|
-
e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : lvl, repoInstructions: lvl
|
|
147
|
+
e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : lvl, repoInstructions: lvl };
|
|
152
148
|
if (!os)
|
|
153
149
|
notes.push("OS sandbox unavailable (Windows, or Linux without bubblewrap): Bash can bypass Edit rules.");
|
|
154
150
|
if (!isLeadAgent && !os)
|
|
@@ -156,7 +152,7 @@ export function enforcementFor(project, agent) {
|
|
|
156
152
|
}
|
|
157
153
|
else {
|
|
158
154
|
const lvl = os ? "os" : "prompt-only";
|
|
159
|
-
e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : os ? "os" : "post-hoc", repoInstructions: "post-hoc"
|
|
155
|
+
e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : os ? "os" : "post-hoc", repoInstructions: "post-hoc" };
|
|
160
156
|
notes.push("Codex cannot forbid single files inside the repo; CLAUDE.md/AGENTS.md changes are detected and reverted after each wake-up.");
|
|
161
157
|
if (!isLeadAgent && !os)
|
|
162
158
|
notes.push("Without the OS sandbox only the handoff index (_shared/MEMORY.md) is checked after each wake-up; other files in _shared are not protected.");
|
|
@@ -171,10 +167,6 @@ export function enforcementFor(project, agent) {
|
|
|
171
167
|
notes,
|
|
172
168
|
};
|
|
173
169
|
}
|
|
174
|
-
/** Static directory prefixes (absolute) of an agent's `owns` globs. */
|
|
175
|
-
export function ownsDirs(project, agent) {
|
|
176
|
-
return agent.owns.map((g) => path.join(project.dir, globStaticPrefix(g)));
|
|
177
|
-
}
|
|
178
170
|
/** Can `agent` legitimately change this protected file? */
|
|
179
171
|
export function mayEditProtected(project, agent, file) {
|
|
180
172
|
return writePolicy(project, agent).allowFiles.includes(file);
|
package/dist/project-lock.d.ts
CHANGED
|
@@ -4,10 +4,13 @@ export declare const LOCK_FILE = "lock.json";
|
|
|
4
4
|
export declare const LOCKS_DIR = "locks";
|
|
5
5
|
export interface LockOptions {
|
|
6
6
|
heartbeatMs?: number;
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/** The run works directly in the project checkout (not in its own worktree): only one such run may be active. It is written with `exclusive` too, so older binaries keep out. */
|
|
8
|
+
checkout?: boolean;
|
|
9
9
|
/** The holder needs the whole project (e.g. a memory tidy): refused while any other lock is alive, and it keeps every other lock out while held. */
|
|
10
10
|
exclusive?: boolean;
|
|
11
|
+
/** For a checkout run's refusal message: the checkout directory, and why this run has no worktree. */
|
|
12
|
+
dir?: string;
|
|
13
|
+
reason?: string;
|
|
11
14
|
}
|
|
12
15
|
export interface LockInfo {
|
|
13
16
|
token: string;
|
|
@@ -20,7 +23,12 @@ export interface LockInfo {
|
|
|
20
23
|
heartbeat_at: string;
|
|
21
24
|
/** Held by an operation that needs the project to itself (a memory tidy). */
|
|
22
25
|
exclusive?: boolean;
|
|
26
|
+
/** `true`: works in the project checkout; `false`: in its own worktree (or holds no files, like `clear`). Absent: an older version's run, treated as a checkout run. */
|
|
27
|
+
checkout?: boolean;
|
|
23
28
|
}
|
|
29
|
+
export type LockKind = "checkout" | "whole" | "worktree";
|
|
30
|
+
/** What a lock protects: the checkout, the whole project (tidy/restore), or nothing shared (a worktree run). Locks of older versions count as checkout runs. */
|
|
31
|
+
export declare function lockKind(l: Pick<LockInfo, "exclusive" | "checkout">): LockKind;
|
|
24
32
|
export interface ProjectLease {
|
|
25
33
|
readonly info: LockInfo;
|
|
26
34
|
/** True once another process replaced our lock file; the run should stop writing. */
|
package/dist/project-lock.js
CHANGED
|
@@ -10,6 +10,16 @@ export const LOCK_FILE = "lock.json";
|
|
|
10
10
|
export const LOCKS_DIR = "locks";
|
|
11
11
|
const ADMIT_FILE = ".admit";
|
|
12
12
|
const HEARTBEAT_MS = 5000;
|
|
13
|
+
/** What a lock protects: the checkout, the whole project (tidy/restore), or nothing shared (a worktree run). Locks of older versions count as checkout runs. */
|
|
14
|
+
export function lockKind(l) {
|
|
15
|
+
if (l.exclusive && l.checkout === true)
|
|
16
|
+
return "checkout";
|
|
17
|
+
if (l.exclusive)
|
|
18
|
+
return "whole";
|
|
19
|
+
if (l.checkout === false)
|
|
20
|
+
return "worktree";
|
|
21
|
+
return "checkout";
|
|
22
|
+
}
|
|
13
23
|
/** Field 22 (starttime, in clock ticks) of a /proc/<pid>/stat line. The command name (field 2) may contain spaces and ")". */
|
|
14
24
|
export function parseProcStat(text) {
|
|
15
25
|
const end = text.lastIndexOf(")");
|
|
@@ -137,10 +147,9 @@ function heldError(info) {
|
|
|
137
147
|
return new Error(`Run ${info.run_id} is still running (pid ${info.pid}, heartbeat ${Number.isFinite(late) ? late : "?"}s ago). ` +
|
|
138
148
|
`A run cannot be started or resumed twice; wait for it to end, or stop it first.`);
|
|
139
149
|
}
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
`wait for one to end, stop one, or raise max_concurrent_runs in project.yaml.`);
|
|
150
|
+
function checkoutBusyError(holder, dir, reason) {
|
|
151
|
+
return new Error(`Run ${holder.run_id} (pid ${holder.pid}) is working directly in ${dir}, and this run would too (${reason}), so only one can be active. ` +
|
|
152
|
+
`Wait for it to end or stop it; in a git repo with a commit, runs work in their own worktrees and can run side by side.`);
|
|
144
153
|
}
|
|
145
154
|
function exclusiveError(holder) {
|
|
146
155
|
return new Error(`${holder.run_id} (pid ${holder.pid}) needs the project to itself (a memory tidy or restore); wait for it to finish, then try again.`);
|
|
@@ -214,7 +223,6 @@ export function acquireProjectLock(root, runId, opts = {}) {
|
|
|
214
223
|
assertRunId(runId);
|
|
215
224
|
fs.mkdirSync(path.join(root, LOCKS_DIR), { recursive: true });
|
|
216
225
|
const file = runLockFile(root, runId);
|
|
217
|
-
const max = opts.maxConcurrent ?? 1;
|
|
218
226
|
const now = new Date().toISOString();
|
|
219
227
|
const info = {
|
|
220
228
|
token: crypto.randomBytes(16).toString("hex"),
|
|
@@ -224,8 +232,10 @@ export function acquireProjectLock(root, runId, opts = {}) {
|
|
|
224
232
|
run_id: runId,
|
|
225
233
|
acquired_at: now,
|
|
226
234
|
heartbeat_at: now,
|
|
227
|
-
...(opts.exclusive ? { exclusive: true } : {}),
|
|
235
|
+
...(opts.exclusive || opts.checkout ? { exclusive: true } : {}),
|
|
236
|
+
checkout: opts.checkout === true,
|
|
228
237
|
};
|
|
238
|
+
const kind = lockKind(info);
|
|
229
239
|
withAdmission(root, () => {
|
|
230
240
|
// 1. This run's own lock: a live holder means it is already running; a dead one is replaced.
|
|
231
241
|
const own = readLockFile(file);
|
|
@@ -246,13 +256,14 @@ export function acquireProjectLock(root, runId, opts = {}) {
|
|
|
246
256
|
fs.rmSync(legacyFile(root), { force: true });
|
|
247
257
|
// 3. The other runs that are active now.
|
|
248
258
|
const active = listRunLocks(root).filter((l) => l.run_id !== runId && lockHolderAlive(l));
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
251
|
-
throw exclusiveError(
|
|
252
|
-
if (
|
|
259
|
+
const whole = active.find((l) => lockKind(l) === "whole");
|
|
260
|
+
if (whole)
|
|
261
|
+
throw exclusiveError(whole);
|
|
262
|
+
if (kind === "whole" && active.length)
|
|
253
263
|
throw needsIdleError(active);
|
|
254
|
-
|
|
255
|
-
|
|
264
|
+
const busy = kind === "checkout" ? active.find((l) => lockKind(l) === "checkout") : undefined;
|
|
265
|
+
if (busy)
|
|
266
|
+
throw checkoutBusyError(busy, opts.dir ?? root, opts.reason ?? "no worktree is available for it");
|
|
256
267
|
if (!tryCreate(file, info))
|
|
257
268
|
throw new Error(`Could not take the run lock ${file}; another process just took it. Try again.`);
|
|
258
269
|
});
|
package/dist/prompt.js
CHANGED
|
@@ -131,9 +131,6 @@ export function buildSystemPrompt(project, agent) {
|
|
|
131
131
|
lines.push("", "## Rules");
|
|
132
132
|
lines.push("- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`" +
|
|
133
133
|
(isLead || agent.canEditAgentMd ? " unless you need to as lead (changes are logged)." : "."), "- Never touch another agent's memory, inbox or outbox, or the team's config files.", `- The shared team context is in \`${commonFile(project)}\`${isLead ? " (you maintain it; keep it short)" : " (read-only)"}.`, PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
|
|
134
|
-
if (project.dispatcher.max_parallel > 1 && agent.owns.length > 0 && !isLead) {
|
|
135
|
-
lines.push(`- You own these repo paths and must only change files there: ${agent.owns.join(", ")}.`);
|
|
136
|
-
}
|
|
137
134
|
return lines.join("\n");
|
|
138
135
|
}
|
|
139
136
|
/** Messages one wake-up handles: the oldest one, or for the lead a run of `reply`/`failure` mail (up to LEAD_BATCH_LIMIT) so it decides once on all of it. */
|
package/dist/run-session.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Invoker } from "./adapters/index.js";
|
|
2
2
|
import type { ResolvedProject } from "./config.js";
|
|
3
3
|
import type { RunOutcome } from "./schema.js";
|
|
4
|
-
import { type ResultBranch } from "./worktree.js";
|
|
4
|
+
import { type ResultBranch, type WorkspacePlan } from "./worktree.js";
|
|
5
5
|
import { newRunId, type EndReason, type RunState } from "./run-store.js";
|
|
6
6
|
import { type PreparedTask } from "./task.js";
|
|
7
7
|
import { type CommandRunner, type PrOutcome } from "./open-pr.js";
|
|
@@ -52,6 +52,8 @@ export interface RunOptions {
|
|
|
52
52
|
task?: PreparedTask;
|
|
53
53
|
/** Continue this earlier run (same run dir, sessions and counters) instead of starting a new one. */
|
|
54
54
|
resume?: RunState;
|
|
55
|
+
/** Where the run's agents work, decided by `planWorkspace` before the run's lock was taken; the session never recomputes it. */
|
|
56
|
+
plan: WorkspacePlan;
|
|
55
57
|
/** Reopen a done run: this text becomes a new task for the lead. Only with `resume` of a run that ended `done`. */
|
|
56
58
|
followup?: {
|
|
57
59
|
text: string;
|
package/dist/run-session.js
CHANGED
|
@@ -6,13 +6,12 @@ import { ProtectedGuard } from "./guard.js";
|
|
|
6
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
|
-
import { isInside } from "./paths.js";
|
|
10
9
|
import { createRunLog } from "./run-log.js";
|
|
11
10
|
import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
|
|
12
|
-
import { outboxDir,
|
|
11
|
+
import { outboxDir, sharedIndex } from "./policy.js";
|
|
13
12
|
import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
14
13
|
import { atomicWrite, readFileCapped } from "./fs-util.js";
|
|
15
|
-
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration,
|
|
14
|
+
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
16
15
|
import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRunState } from "./run-store.js";
|
|
17
16
|
import { soloProject } from "./solo.js";
|
|
18
17
|
import { taskMessageBody } from "./task.js";
|
|
@@ -91,12 +90,13 @@ export class RunSession {
|
|
|
91
90
|
const layout = resume ? resume.mail_layout : "run";
|
|
92
91
|
// Each run gets its own memory directory per agent and (new runs) its own mailboxes.
|
|
93
92
|
const project = bindRunProject(base, runDir, layout);
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
// Where agents work was decided before the lock was taken and is kept for the run's resumes. A resumed run that uses worktrees
|
|
94
|
+
// is refused early (before anything is written) when its repo can no longer give them.
|
|
95
|
+
const { plan } = opts;
|
|
96
|
+
const concurrent = plan.leadWorktree;
|
|
97
|
+
const wsMode = plan.mode;
|
|
98
|
+
if (resume && wsMode === "worktree")
|
|
99
|
+
assertWorktreeRunnable(project);
|
|
100
100
|
// Concurrent runs: the lead works in its own worktree cut from HEAD, so no other run (and not you) shares its files.
|
|
101
101
|
let leadSpace;
|
|
102
102
|
let recut = false;
|
|
@@ -123,6 +123,9 @@ export class RunSession {
|
|
|
123
123
|
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}`);
|
|
124
124
|
log("lead-workspace", { base: leadSpace.base, branch: leadSpace.branch, dir: leadSpace.dir, uncommitted });
|
|
125
125
|
}
|
|
126
|
+
if (!resume && plan.mode === "shared" && plan.reason) {
|
|
127
|
+
say(` agents take turns in ${project.dir} (no separate git worktrees: ${plan.reason}); commit to a git repo to run them in parallel`);
|
|
128
|
+
}
|
|
126
129
|
let state;
|
|
127
130
|
if (resume) {
|
|
128
131
|
state = { ...resume, max_rounds: roundLimit(resume, cfg.max_rounds), pid: process.pid, ended_at: undefined, end_reason: undefined, failure: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
@@ -261,26 +264,13 @@ export class RunSession {
|
|
|
261
264
|
return rows.map((r) => r.a);
|
|
262
265
|
}
|
|
263
266
|
pickBatch(pending) {
|
|
264
|
-
const {
|
|
267
|
+
const { project } = this;
|
|
265
268
|
const lead = pending.find((a) => a.name === project.lead);
|
|
266
|
-
if (lead
|
|
267
|
-
return [
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (batch.length >= cfg.max_parallel)
|
|
272
|
-
break;
|
|
273
|
-
const mine = ownsDirs(project, a);
|
|
274
|
-
const clash = mine.some((x) => taken.some((y) => isInside(x, y) || isInside(y, x)));
|
|
275
|
-
if (clash || mine.length === 0) {
|
|
276
|
-
if (batch.length === 0)
|
|
277
|
-
batch.push(a);
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
batch.push(a);
|
|
281
|
-
taken.push(...mine);
|
|
282
|
-
}
|
|
283
|
-
return batch;
|
|
269
|
+
if (lead)
|
|
270
|
+
return [lead];
|
|
271
|
+
if (this.opts.plan.mode !== "worktree")
|
|
272
|
+
return [must(pending[0], "an agent to wake")];
|
|
273
|
+
return pending;
|
|
284
274
|
}
|
|
285
275
|
async wake(agent) {
|
|
286
276
|
const { invoke, say, cfg, runDir, project, log, state, claims, attempts, noSessionNoted, opts } = this;
|
|
@@ -399,7 +389,7 @@ export class RunSession {
|
|
|
399
389
|
let changes;
|
|
400
390
|
let r;
|
|
401
391
|
try {
|
|
402
|
-
changes = collectAgentChanges(ws
|
|
392
|
+
changes = collectAgentChanges(ws);
|
|
403
393
|
r = integrateAgentChanges(project, changes, this.leadSpace);
|
|
404
394
|
}
|
|
405
395
|
catch (e) {
|
|
@@ -669,7 +659,7 @@ export class RunSession {
|
|
|
669
659
|
if (endReason !== "done" || outcome !== "completed")
|
|
670
660
|
return skip(`the run did not end completed (${endReason}, outcome ${outcome}).`);
|
|
671
661
|
if (!this.leadSpace)
|
|
672
|
-
return skip(
|
|
662
|
+
return skip(`this run works directly in ${this.project.dir} (not a git repo with a commit), so there is no branch to push.`);
|
|
673
663
|
if (!rb)
|
|
674
664
|
return skip(`the work could not be committed on ${this.leadSpace.branch}.`);
|
|
675
665
|
if (!rb.files)
|
package/dist/run-store.d.ts
CHANGED
|
@@ -78,7 +78,7 @@ export interface RunState {
|
|
|
78
78
|
snapshots?: number;
|
|
79
79
|
/** Concurrent runs: where the lead's work ended up (`git merge <branch>`). Set when the run ends. */
|
|
80
80
|
result_branch?: ResultBranch;
|
|
81
|
-
/** Set when the run started with the lead in its own worktree (
|
|
81
|
+
/** Set when the run started with the lead in its own worktree (isolation mode); its resumes keep that, whatever the config says later. */
|
|
82
82
|
lead_workspace?: true;
|
|
83
83
|
/** The pull request the dispatcher opened for this run (when the lead asked for one and it worked). */
|
|
84
84
|
pr_url?: string;
|
package/dist/scaffold.js
CHANGED
|
@@ -86,9 +86,6 @@ team:
|
|
|
86
86
|
lead: lead
|
|
87
87
|
dispatcher:
|
|
88
88
|
max_rounds: 0 # 0 = no limit on wake-ups; set a number to stop a run after that many
|
|
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
|
|
91
|
-
workspace_mode: auto # auto = a git worktree per non-lead agent when max_parallel > 1; shared | worktree to force
|
|
92
89
|
wake_timeout_sec: 600
|
|
93
90
|
retry: 2 # an agent that fails is called again up to this many times; if it still fails the run stops (resume it after fixing the cause)
|
|
94
91
|
log_max_bytes: 8388608 # log.jsonl is renamed to log.1.jsonl past this size; 0 = never
|
|
@@ -101,11 +98,9 @@ agents:
|
|
|
101
98
|
can_edit_agent_md: true
|
|
102
99
|
fe-member:
|
|
103
100
|
can_message: [lead]
|
|
104
|
-
# owns: ["src/web/**"]
|
|
105
101
|
# memory: { project: agents/fe-member/memory }
|
|
106
102
|
qa-member:
|
|
107
103
|
can_message: [lead]
|
|
108
|
-
# owns: ["tests/**"]
|
|
109
104
|
`;
|
|
110
105
|
fs.writeFileSync(pp.config, yaml);
|
|
111
106
|
const common = path.join(pp.shared, "common", "COMMON.md");
|
package/dist/schema.d.ts
CHANGED
|
@@ -39,23 +39,22 @@ export declare const AgentPartial: z.ZodObject<{
|
|
|
39
39
|
none: "none";
|
|
40
40
|
search: "search";
|
|
41
41
|
}>>;
|
|
42
|
-
owns: z.ZodOptional<z.
|
|
42
|
+
owns: z.ZodOptional<z.ZodUnknown>;
|
|
43
43
|
}, z.core.$strict>;
|
|
44
44
|
export type AgentPartialT = z.infer<typeof AgentPartial>;
|
|
45
45
|
export declare const DispatcherPartial: z.ZodObject<{
|
|
46
46
|
max_rounds: z.ZodOptional<z.ZodNumber>;
|
|
47
|
-
max_parallel: z.ZodOptional<z.
|
|
48
|
-
max_concurrent_runs: z.ZodOptional<z.
|
|
47
|
+
max_parallel: z.ZodOptional<z.ZodUnknown>;
|
|
48
|
+
max_concurrent_runs: z.ZodOptional<z.ZodUnknown>;
|
|
49
49
|
wake_timeout_sec: z.ZodOptional<z.ZodNumber>;
|
|
50
50
|
retry: z.ZodOptional<z.ZodNumber>;
|
|
51
51
|
strict: z.ZodOptional<z.ZodBoolean>;
|
|
52
|
-
workspace_mode: z.ZodOptional<z.
|
|
53
|
-
auto: "auto";
|
|
54
|
-
shared: "shared";
|
|
55
|
-
worktree: "worktree";
|
|
56
|
-
}>>;
|
|
52
|
+
workspace_mode: z.ZodOptional<z.ZodUnknown>;
|
|
57
53
|
log_max_bytes: z.ZodOptional<z.ZodNumber>;
|
|
58
54
|
}, z.core.$strict>;
|
|
55
|
+
/** Settings that no longer exist; files that still carry them load, and `validate` warns. */
|
|
56
|
+
export declare const REMOVED_DISPATCHER_KEYS: readonly ["max_parallel", "max_concurrent_runs", "workspace_mode"];
|
|
57
|
+
export declare const REMOVED_AGENT_KEYS: readonly ["owns"];
|
|
59
58
|
export declare const GlobalConfig: z.ZodObject<{
|
|
60
59
|
agents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
61
60
|
runtime: z.ZodOptional<z.ZodEnum<{
|
|
@@ -85,7 +84,7 @@ export declare const GlobalConfig: z.ZodObject<{
|
|
|
85
84
|
none: "none";
|
|
86
85
|
search: "search";
|
|
87
86
|
}>>;
|
|
88
|
-
owns: z.ZodOptional<z.
|
|
87
|
+
owns: z.ZodOptional<z.ZodUnknown>;
|
|
89
88
|
}, z.core.$strict>>>;
|
|
90
89
|
}, z.core.$strict>;
|
|
91
90
|
export declare const ProjectConfig: z.ZodObject<{
|
|
@@ -95,16 +94,12 @@ export declare const ProjectConfig: z.ZodObject<{
|
|
|
95
94
|
}, z.core.$strict>;
|
|
96
95
|
dispatcher: z.ZodDefault<z.ZodObject<{
|
|
97
96
|
max_rounds: z.ZodOptional<z.ZodNumber>;
|
|
98
|
-
max_parallel: z.ZodOptional<z.
|
|
99
|
-
max_concurrent_runs: z.ZodOptional<z.
|
|
97
|
+
max_parallel: z.ZodOptional<z.ZodUnknown>;
|
|
98
|
+
max_concurrent_runs: z.ZodOptional<z.ZodUnknown>;
|
|
100
99
|
wake_timeout_sec: z.ZodOptional<z.ZodNumber>;
|
|
101
100
|
retry: z.ZodOptional<z.ZodNumber>;
|
|
102
101
|
strict: z.ZodOptional<z.ZodBoolean>;
|
|
103
|
-
workspace_mode: z.ZodOptional<z.
|
|
104
|
-
auto: "auto";
|
|
105
|
-
shared: "shared";
|
|
106
|
-
worktree: "worktree";
|
|
107
|
-
}>>;
|
|
102
|
+
workspace_mode: z.ZodOptional<z.ZodUnknown>;
|
|
108
103
|
log_max_bytes: z.ZodOptional<z.ZodNumber>;
|
|
109
104
|
}, z.core.$strict>>;
|
|
110
105
|
agents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
@@ -135,19 +130,14 @@ export declare const ProjectConfig: z.ZodObject<{
|
|
|
135
130
|
none: "none";
|
|
136
131
|
search: "search";
|
|
137
132
|
}>>;
|
|
138
|
-
owns: z.ZodOptional<z.
|
|
133
|
+
owns: z.ZodOptional<z.ZodUnknown>;
|
|
139
134
|
}, z.core.$strict>>>;
|
|
140
135
|
}, z.core.$strict>;
|
|
141
136
|
export interface DispatcherSettings {
|
|
142
137
|
max_rounds: number;
|
|
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;
|
|
146
138
|
wake_timeout_sec: number;
|
|
147
139
|
retry: number;
|
|
148
140
|
strict: boolean;
|
|
149
|
-
/** "auto": a git worktree per non-lead agent when agents can run in parallel, otherwise one shared checkout. */
|
|
150
|
-
workspace_mode: "auto" | "shared" | "worktree";
|
|
151
141
|
/** log.jsonl is rotated to log.1.jsonl past this size; 0 = never. */
|
|
152
142
|
log_max_bytes: number;
|
|
153
143
|
}
|
package/dist/schema.js
CHANGED
|
@@ -38,21 +38,25 @@ export const AgentPartial = z
|
|
|
38
38
|
can_edit_agent_md: z.boolean().optional(),
|
|
39
39
|
can_ask_user: z.boolean().optional(),
|
|
40
40
|
allow_web: z.enum(WEB_LEVELS).optional(),
|
|
41
|
-
|
|
41
|
+
/** Removed setting: still accepted (any value) so old files load; ignored, with a warning from `validate`. */
|
|
42
|
+
owns: z.unknown().optional(),
|
|
42
43
|
})
|
|
43
44
|
.strict();
|
|
44
45
|
export const DispatcherPartial = z
|
|
45
46
|
.object({
|
|
46
47
|
max_rounds: z.number().int().min(0).optional(),
|
|
47
|
-
max_parallel: z.
|
|
48
|
-
max_concurrent_runs: z.
|
|
48
|
+
max_parallel: z.unknown().optional(),
|
|
49
|
+
max_concurrent_runs: z.unknown().optional(),
|
|
49
50
|
wake_timeout_sec: z.number().int().positive().optional(),
|
|
50
51
|
retry: z.number().int().min(0).optional(),
|
|
51
52
|
strict: z.boolean().optional(),
|
|
52
|
-
workspace_mode: z.
|
|
53
|
+
workspace_mode: z.unknown().optional(),
|
|
53
54
|
log_max_bytes: z.number().int().min(0).optional(),
|
|
54
55
|
})
|
|
55
56
|
.strict();
|
|
57
|
+
/** Settings that no longer exist; files that still carry them load, and `validate` warns. */
|
|
58
|
+
export const REMOVED_DISPATCHER_KEYS = ["max_parallel", "max_concurrent_runs", "workspace_mode"];
|
|
59
|
+
export const REMOVED_AGENT_KEYS = ["owns"];
|
|
56
60
|
export const GlobalConfig = z
|
|
57
61
|
.object({
|
|
58
62
|
agents: z.record(z.string(), AgentPartial).default({}),
|
|
@@ -68,12 +72,9 @@ export const ProjectConfig = z
|
|
|
68
72
|
.strict();
|
|
69
73
|
export const DISPATCHER_DEFAULTS = {
|
|
70
74
|
max_rounds: 0,
|
|
71
|
-
max_parallel: 1,
|
|
72
|
-
max_concurrent_runs: 1,
|
|
73
75
|
wake_timeout_sec: 600,
|
|
74
76
|
retry: 2,
|
|
75
77
|
strict: false,
|
|
76
|
-
workspace_mode: "auto",
|
|
77
78
|
log_max_bytes: 8 * 1024 * 1024,
|
|
78
79
|
};
|
|
79
80
|
/** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
|
package/dist/solo.js
CHANGED
|
@@ -15,8 +15,7 @@ export function soloProject(project, name, hints = {}) {
|
|
|
15
15
|
...project,
|
|
16
16
|
solo: "run",
|
|
17
17
|
lead: name,
|
|
18
|
-
|
|
19
|
-
agents: { ...project.agents, [name]: { ...a, canMessage: [], canAskUser: false, canEditAgentMd: false, owns: [] } },
|
|
18
|
+
agents: { ...project.agents, [name]: { ...a, canMessage: [], canAskUser: false, canEditAgentMd: false } },
|
|
20
19
|
};
|
|
21
20
|
}
|
|
22
21
|
/** `project` with only `name` listed: what a preflight of a solo run needs to look at. */
|
package/dist/validate.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { enforcementFor, memoryDirs
|
|
2
|
+
import { enforcementFor, memoryDirs } from "./policy.js";
|
|
3
3
|
import { isInside } from "./paths.js";
|
|
4
4
|
import { inferRuntime, RUNTIME_EFFORTS } from "./schema.js";
|
|
5
|
-
import { isGitRepo, resolveWorkspaceMode } from "./worktree.js";
|
|
6
5
|
import { must } from "./assert.js";
|
|
7
6
|
export function validateProject(project) {
|
|
8
7
|
const issues = [];
|
|
@@ -55,40 +54,8 @@ export function validateProject(project) {
|
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
56
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const withOwns = Object.values(project.agents).filter((a) => a.name !== project.lead);
|
|
61
|
-
for (const a of withOwns) {
|
|
62
|
-
if (a.owns.length === 0)
|
|
63
|
-
err(`max_parallel > 1 requires "owns" for agent "${a.name}".`);
|
|
64
|
-
}
|
|
65
|
-
for (let i = 0; i < withOwns.length; i++) {
|
|
66
|
-
for (let j = i + 1; j < withOwns.length; j++) {
|
|
67
|
-
const ai = must(withOwns[i], "agent");
|
|
68
|
-
const aj = must(withOwns[j], "agent");
|
|
69
|
-
for (const x of ownsDirs(project, ai)) {
|
|
70
|
-
for (const y of ownsDirs(project, aj)) {
|
|
71
|
-
if (isInside(x, y) || isInside(y, x)) {
|
|
72
|
-
err(`"owns" overlap: ${ai.name} (${x}) vs ${aj.name} (${y})`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
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) {
|
|
88
|
-
err(`dispatcher.workspace_mode "shared" cannot be combined with max_parallel > 1: parallel agents need separate git worktrees (use "auto" or "worktree").`);
|
|
89
|
-
}
|
|
90
|
-
else if (!concurrent && resolveWorkspaceMode(project.dispatcher) === "worktree" && fs.existsSync(project.dir) && !isGitRepo(project.dir)) {
|
|
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).`);
|
|
57
|
+
for (const ig of project.ignoredSettings ?? []) {
|
|
58
|
+
warn(`${ig.file}: ignoring removed setting(s) ${ig.keys.join(", ")}. Concurrency needs no configuration now: in a git repo with a commit every run and every agent gets its own worktree. Delete these lines.`);
|
|
92
59
|
}
|
|
93
60
|
// Context paths should live outside the repo (the tool never writes into it).
|
|
94
61
|
for (const a of Object.values(project.agents)) {
|
|
@@ -109,10 +76,10 @@ export function validateProject(project) {
|
|
|
109
76
|
return { issues, enforcement, ok: !issues.some((i) => i.level === "error") };
|
|
110
77
|
}
|
|
111
78
|
export function formatEnforcement(list) {
|
|
112
|
-
const header = ["agent", "runtime", "memory", "AGENT.md", "others' ctx", "handoff", "repo CLAUDE/AGENTS"
|
|
79
|
+
const header = ["agent", "runtime", "memory", "AGENT.md", "others' ctx", "handoff", "repo CLAUDE/AGENTS"];
|
|
113
80
|
const rows = [header];
|
|
114
81
|
for (const e of list)
|
|
115
|
-
rows.push([e.agent, e.runtime, e.memory, e.agentMd, e.otherContext, e.shared, e.repoInstructions
|
|
82
|
+
rows.push([e.agent, e.runtime, e.memory, e.agentMd, e.otherContext, e.shared, e.repoInstructions]);
|
|
116
83
|
const w = header.map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length)));
|
|
117
84
|
const lines = rows.map((r) => r.map((c, i) => c.padEnd(w[i] ?? 0)).join(" ").trimEnd());
|
|
118
85
|
lines.splice(1, 0, w.map((n) => "-".repeat(n)).join(" "));
|
package/dist/worktree.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { ResolvedProject } from "./config.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { RunState } from "./run-store.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; 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
4
|
export interface AgentWorkspace {
|
|
7
5
|
agent: string;
|
|
8
6
|
/** Root of the git worktree. */
|
|
@@ -58,11 +56,22 @@ export declare function isGitRepo(dir: string): boolean;
|
|
|
58
56
|
export declare const toplevel: (dir: string) => string;
|
|
59
57
|
/** Files in the repo with changes nobody committed (modified, staged or new and not ignored). Paths are relative to the repo root. */
|
|
60
58
|
export declare function dirtyPaths(dir: string): string[];
|
|
61
|
-
/**
|
|
62
|
-
export declare function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
/** Why `dir` cannot give every run and agent its own git worktree, or undefined when it can. */
|
|
60
|
+
export declare function isolationBlocker(dir: string): string | undefined;
|
|
61
|
+
export interface WorkspacePlan {
|
|
62
|
+
mode: WorkspaceMode;
|
|
63
|
+
/** The lead works in its own worktree (isolation mode); otherwise it works in the checkout. */
|
|
64
|
+
leadWorktree: boolean;
|
|
65
|
+
/** Why the run works in the checkout, when it was probed. */
|
|
66
|
+
reason?: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Where a run's agents work. A new run is isolated when the project dir can be (see `isolationBlocker`), otherwise it shares the
|
|
70
|
+
* checkout. A resumed run keeps what its state recorded, whatever the repo looks like now. Decided once, before the run's lock is taken.
|
|
71
|
+
*/
|
|
72
|
+
export declare function planWorkspace(project: ResolvedProject, resume?: RunState): WorkspacePlan;
|
|
73
|
+
/** Refuse to resume a worktree run when its repo can no longer be isolated; returns nothing when it is fine. */
|
|
74
|
+
export declare function assertWorktreeRunnable(project: ResolvedProject): void;
|
|
66
75
|
/** `refs/agent-lyceum/<run>/base-0` pointing at the repo's HEAD: where a concurrent run starts. Nothing in the repo changes. */
|
|
67
76
|
export declare function snapshotHead(project: ResolvedProject, runId: string): string;
|
|
68
77
|
/**
|
|
@@ -97,8 +106,6 @@ export interface ChangeSet {
|
|
|
97
106
|
/** Why these changes may not be integrated (empty when they may). */
|
|
98
107
|
violations: string[];
|
|
99
108
|
}
|
|
100
|
-
/** Glob as used in `owns`: `**` crosses folders, `*` and `?` stay within one, `{a,b}` alternates. */
|
|
101
|
-
export declare function globMatch(glob: string, file: string): boolean;
|
|
102
109
|
/** 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
110
|
export declare function commitWorkspace(ws: AgentWorkspace, message: string): {
|
|
104
111
|
head: string;
|
|
@@ -106,10 +113,10 @@ export declare function commitWorkspace(ws: AgentWorkspace, message: string): {
|
|
|
106
113
|
};
|
|
107
114
|
/**
|
|
108
115
|
* Commit whatever the agent left in its worktree and list what changed since its snapshot, checking every
|
|
109
|
-
* changed path (modified, added, deleted, or either end of a rename) against the
|
|
116
|
+
* changed path (modified, added, deleted, or either end of a rename) against the project
|
|
110
117
|
* folder, the protected instruction files, and symlink/submodule rules. Nothing is applied here.
|
|
111
118
|
*/
|
|
112
|
-
export declare function collectAgentChanges(workspace: AgentWorkspace
|
|
119
|
+
export declare function collectAgentChanges(workspace: AgentWorkspace): ChangeSet;
|
|
113
120
|
export type IntegrationResult = {
|
|
114
121
|
status: "integrated";
|
|
115
122
|
files: ChangedFile[];
|
package/dist/worktree.js
CHANGED
|
@@ -4,16 +4,6 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { atomicWrite } from "./fs-util.js";
|
|
7
|
-
/** 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. */
|
|
8
|
-
export function resolveWorkspaceMode(d) {
|
|
9
|
-
if ((d.max_concurrent_runs ?? 1) > 1)
|
|
10
|
-
return "worktree";
|
|
11
|
-
if (d.workspace_mode === "worktree")
|
|
12
|
-
return "worktree";
|
|
13
|
-
if (d.workspace_mode === "shared")
|
|
14
|
-
return "shared";
|
|
15
|
-
return d.max_parallel > 1 ? "worktree" : "shared";
|
|
16
|
-
}
|
|
17
7
|
const IDENTITY = {
|
|
18
8
|
GIT_AUTHOR_NAME: "agent-lyceum",
|
|
19
9
|
GIT_AUTHOR_EMAIL: "agent-lyceum@localhost",
|
|
@@ -227,33 +217,42 @@ export const toplevel = (dir) => git(dir, ["rev-parse", "--show-toplevel"]);
|
|
|
227
217
|
export function dirtyPaths(dir) {
|
|
228
218
|
return parseStatus(execFileSync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: dir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }));
|
|
229
219
|
}
|
|
230
|
-
/**
|
|
231
|
-
export function
|
|
232
|
-
if (!isGitRepo(
|
|
233
|
-
|
|
234
|
-
|
|
220
|
+
/** Why `dir` cannot give every run and agent its own git worktree, or undefined when it can. */
|
|
221
|
+
export function isolationBlocker(dir) {
|
|
222
|
+
if (!isGitRepo(dir))
|
|
223
|
+
return "not a git repository";
|
|
224
|
+
if (tryGit(dir, ["rev-parse", "--verify", "-q", "HEAD"]) === undefined)
|
|
225
|
+
return "no commit yet";
|
|
235
226
|
// With git's reftable ref storage a linked worktree's HEAD lives in `<gitDir>/reftable/`, a place the agent can write, and the
|
|
236
227
|
// file `<gitDir>/HEAD` only says `ref: refs/heads/.invalid`: the check that a worktree's HEAD is its own branch cannot hold
|
|
237
|
-
// there, so every worktree would be refused in the middle of the run.
|
|
238
|
-
//
|
|
239
|
-
if (tryGit(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
228
|
+
// there, so every worktree would be refused in the middle of the run. (`rev-parse --show-ref-format` does not exist in older
|
|
229
|
+
// git, which only knows `files`.)
|
|
230
|
+
if (tryGit(dir, ["rev-parse", "--show-ref-format"]) === "reftable")
|
|
231
|
+
return "reftable ref storage";
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Where a run's agents work. A new run is isolated when the project dir can be (see `isolationBlocker`), otherwise it shares the
|
|
236
|
+
* checkout. A resumed run keeps what its state recorded, whatever the repo looks like now. Decided once, before the run's lock is taken.
|
|
237
|
+
*/
|
|
238
|
+
export function planWorkspace(project, resume) {
|
|
239
|
+
if (resume) {
|
|
240
|
+
if (resume.lead_workspace === true)
|
|
241
|
+
return { mode: "worktree", leadWorktree: true };
|
|
242
|
+
if (resume.workspace_mode === "worktree")
|
|
243
|
+
return { mode: "worktree", leadWorktree: false };
|
|
244
|
+
return { mode: "shared", leadWorktree: false };
|
|
245
|
+
}
|
|
246
|
+
const reason = isolationBlocker(project.dir);
|
|
247
|
+
return reason === undefined ? { mode: "worktree", leadWorktree: true } : { mode: "shared", leadWorktree: false, reason };
|
|
248
|
+
}
|
|
249
|
+
/** Refuse to resume a worktree run when its repo can no longer be isolated; returns nothing when it is fine. */
|
|
250
|
+
export function assertWorktreeRunnable(project) {
|
|
251
|
+
if (!isGitRepo(project.dir)) {
|
|
252
|
+
throw new Error(`This run works in git worktrees, but ${project.dir} is no longer a git repository.`);
|
|
251
253
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const shown = dirty.slice(0, 5).join(", ") + (dirty.length > 5 ? `, ... (${dirty.length} in all)` : "");
|
|
255
|
-
throw new Error(`Parallel runs start from a clean repo, but ${project.dir} has uncommitted changes: ${shown}. ` +
|
|
256
|
-
`Commit or stash them first (agent-lyceum never touches your own edits), or run sequentially with dispatcher.max_parallel: 1.`);
|
|
254
|
+
if (tryGit(project.dir, ["rev-parse", "--show-ref-format"]) === "reftable") {
|
|
255
|
+
throw new Error(`This run works in git worktrees, but ${project.dir} now uses git's reftable ref storage, whose worktrees agent-lyceum cannot yet verify as isolated.`);
|
|
257
256
|
}
|
|
258
257
|
}
|
|
259
258
|
/** `refs/agent-lyceum/<run>/base-0` pointing at the repo's HEAD: where a concurrent run starts. Nothing in the repo changes. */
|
|
@@ -427,39 +426,6 @@ export function removeAgentWorkspace(project, runId, agent) {
|
|
|
427
426
|
tryGit(top, ["branch", "-D", branch]);
|
|
428
427
|
fs.rmSync(`${root}.json`, { force: true });
|
|
429
428
|
}
|
|
430
|
-
/** Glob as used in `owns`: `**` crosses folders, `*` and `?` stay within one, `{a,b}` alternates. */
|
|
431
|
-
export function globMatch(glob, file) {
|
|
432
|
-
let re = "";
|
|
433
|
-
for (let i = 0; i < glob.length; i++) {
|
|
434
|
-
const c = glob.charAt(i);
|
|
435
|
-
if (c === "*" && glob[i + 1] === "*") {
|
|
436
|
-
if (glob[i + 2] === "/") {
|
|
437
|
-
re += "(?:.*/)?";
|
|
438
|
-
i += 2;
|
|
439
|
-
}
|
|
440
|
-
else {
|
|
441
|
-
re += ".*";
|
|
442
|
-
i += 1;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
else if (c === "*")
|
|
446
|
-
re += "[^/]*";
|
|
447
|
-
else if (c === "?")
|
|
448
|
-
re += "[^/]";
|
|
449
|
-
else if (c === "{") {
|
|
450
|
-
const end = glob.indexOf("}", i);
|
|
451
|
-
if (end < 0)
|
|
452
|
-
re += "\\{";
|
|
453
|
-
else {
|
|
454
|
-
re += `(?:${glob.slice(i + 1, end).split(",").map((s) => s.replace(/[.+^$()|[\]\\]/g, "\\$&")).join("|")})`;
|
|
455
|
-
i = end;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
else
|
|
459
|
-
re += c.replace(/[.+^$()|[\]\\}]/g, "\\$&");
|
|
460
|
-
}
|
|
461
|
-
return new RegExp(`^${re}$`).test(file);
|
|
462
|
-
}
|
|
463
429
|
const PROTECTED_NAMES = ["CLAUDE.md", "AGENTS.md"];
|
|
464
430
|
/** Commit whatever is in an agent's worktree (no hooks, no signing, dispatcher identity). Returns the HEAD afterwards and whether a commit was made. */
|
|
465
431
|
export function commitWorkspace(ws, message) {
|
|
@@ -475,10 +441,10 @@ export function commitWorkspace(ws, message) {
|
|
|
475
441
|
}
|
|
476
442
|
/**
|
|
477
443
|
* Commit whatever the agent left in its worktree and list what changed since its snapshot, checking every
|
|
478
|
-
* changed path (modified, added, deleted, or either end of a rename) against the
|
|
444
|
+
* changed path (modified, added, deleted, or either end of a rename) against the project
|
|
479
445
|
* folder, the protected instruction files, and symlink/submodule rules. Nothing is applied here.
|
|
480
446
|
*/
|
|
481
|
-
export function collectAgentChanges(workspace
|
|
447
|
+
export function collectAgentChanges(workspace) {
|
|
482
448
|
const { head } = commitWorkspace(workspace, `agent-lyceum: changes by ${workspace.agent}`);
|
|
483
449
|
const base = workspace.base;
|
|
484
450
|
const raw = base === head ? "" : agentGit(workspace, ["diff", "--raw", "-z", "--no-renames", "--no-abbrev", base, head]);
|
|
@@ -501,9 +467,6 @@ export function collectAgentChanges(workspace, owns) {
|
|
|
501
467
|
const p = rel === "" ? f.path : path.posix.relative(rel, f.path);
|
|
502
468
|
if (PROTECTED_NAMES.includes(p))
|
|
503
469
|
violations.push(`${f.path}: protected instruction file; only the lead may change it, by hand`);
|
|
504
|
-
const allowed = (candidate) => owns.length === 0 || owns.some((g) => globMatch(g, candidate));
|
|
505
|
-
if (!allowed(p))
|
|
506
|
-
violations.push(`${f.path}: outside this agent's owns (${owns.join(", ")})`);
|
|
507
470
|
if (f.mode === "160000")
|
|
508
471
|
violations.push(`${f.path}: submodule changes are not integrated`);
|
|
509
472
|
if (f.mode === "120000" && f.status !== "D") {
|
|
@@ -513,8 +476,6 @@ export function collectAgentChanges(workspace, owns) {
|
|
|
513
476
|
violations.push(`${f.path}: symlink to an absolute path (${target})`);
|
|
514
477
|
else if (resolved.startsWith(".."))
|
|
515
478
|
violations.push(`${f.path}: symlink leaves the project directory (${target})`);
|
|
516
|
-
else if (!allowed(resolved))
|
|
517
|
-
violations.push(`${f.path}: symlink points outside this agent's owns (${target})`);
|
|
518
479
|
}
|
|
519
480
|
}
|
|
520
481
|
return { workspace, base, head, files, violations };
|