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.
- package/README.md +29 -4
- package/README.zh-TW.md +29 -4
- package/dist/ask-answer.js +4 -3
- package/dist/ask-reply.d.ts +2 -0
- package/dist/ask-reply.js +13 -0
- package/dist/cli.js +62 -22
- package/dist/config.d.ts +1 -0
- package/dist/doctor.js +32 -6
- package/dist/guard.d.ts +5 -0
- package/dist/guard.js +8 -1
- package/dist/open-pr.d.ts +47 -0
- package/dist/open-pr.js +71 -0
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +4 -2
- package/dist/project-lock.d.ts +24 -7
- package/dist/project-lock.js +178 -80
- package/dist/prompt.js +4 -2
- package/dist/run-cleanup.js +6 -3
- package/dist/run-session.d.ts +27 -0
- package/dist/run-session.js +163 -20
- package/dist/run-store.d.ts +7 -0
- package/dist/scaffold.js +1 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +2 -0
- package/dist/status.d.ts +1 -1
- package/dist/status.js +4 -4
- package/dist/validate.js +10 -3
- package/dist/worktree.d.ts +74 -7
- package/dist/worktree.js +372 -62
- package/package.json +1 -1
package/dist/project-lock.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
+
/** The project-wide lock of older versions. Still read (it counts as one active run) and cleared; never written. */
|
|
1
2
|
export declare const LOCK_FILE = "lock.json";
|
|
3
|
+
/** One lock file per active run: `<project home>/locks/<run-id>.json`. */
|
|
4
|
+
export declare const LOCKS_DIR = "locks";
|
|
5
|
+
export interface LockOptions {
|
|
6
|
+
heartbeatMs?: number;
|
|
7
|
+
/** Most runs that may hold a lock of this project at once, this one included. Default 1. */
|
|
8
|
+
maxConcurrent?: number;
|
|
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
|
+
exclusive?: boolean;
|
|
11
|
+
}
|
|
2
12
|
export interface LockInfo {
|
|
3
13
|
token: string;
|
|
4
14
|
hostname: string;
|
|
@@ -8,6 +18,8 @@ export interface LockInfo {
|
|
|
8
18
|
run_id: string;
|
|
9
19
|
acquired_at: string;
|
|
10
20
|
heartbeat_at: string;
|
|
21
|
+
/** Held by an operation that needs the project to itself (a memory tidy). */
|
|
22
|
+
exclusive?: boolean;
|
|
11
23
|
}
|
|
12
24
|
export interface ProjectLease {
|
|
13
25
|
readonly info: LockInfo;
|
|
@@ -15,9 +27,6 @@ export interface ProjectLease {
|
|
|
15
27
|
readonly lost: boolean;
|
|
16
28
|
release(): void;
|
|
17
29
|
}
|
|
18
|
-
export interface LockOptions {
|
|
19
|
-
heartbeatMs?: number;
|
|
20
|
-
}
|
|
21
30
|
/** Field 22 (starttime, in clock ticks) of a /proc/<pid>/stat line. The command name (field 2) may contain spaces and ")". */
|
|
22
31
|
export declare function parseProcStat(text: string): string | undefined;
|
|
23
32
|
export interface PidStartIo {
|
|
@@ -27,13 +36,21 @@ export interface PidStartIo {
|
|
|
27
36
|
}
|
|
28
37
|
/** A start time that identifies one process: `proc:<ticks>` on Linux, the `ps` text elsewhere or when /proc cannot be read. */
|
|
29
38
|
export declare function pidStartFrom(pid: number, io: PidStartIo): string | undefined;
|
|
30
|
-
/** The lock of a project (`root` = the project's home directory), if one is held and readable. */
|
|
31
|
-
export declare function inspectProjectLock(root: string): LockInfo | undefined;
|
|
32
39
|
/**
|
|
33
40
|
* Is the process that wrote this lock still the same running process? Judged by pid and its start time,
|
|
34
41
|
* never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
|
|
35
42
|
*/
|
|
36
43
|
export declare function lockHolderAlive(info: LockInfo): boolean;
|
|
44
|
+
/** Every lock of a project that can be read: one per run, plus an older version's `lock.json`. Oldest first. */
|
|
45
|
+
export declare function listRunLocks(root: string): LockInfo[];
|
|
46
|
+
/** Run ids whose per-run lock file exists but cannot be read (it can only be removed with `forceUnlock(root, runId)`). */
|
|
47
|
+
export declare function listUnreadableRunLocks(root: string): string[];
|
|
48
|
+
/** The lock a run holds (per-run file, or an older version's `lock.json` written for that run), if readable. */
|
|
49
|
+
export declare function inspectRunLock(root: string, runId: string): LockInfo | undefined;
|
|
37
50
|
export declare function acquireProjectLock(root: string, runId: string, opts?: LockOptions): ProjectLease;
|
|
38
|
-
/**
|
|
39
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Remove a lock regardless of owner (the `clear --lock --force` escape hatch). With a run id: that run's lock (and an
|
|
53
|
+
* older version's `lock.json` when it was written for that run). Without: only the older project-wide `lock.json`.
|
|
54
|
+
* Returns what was there, if readable.
|
|
55
|
+
*/
|
|
56
|
+
export declare function forceUnlock(root: string, runId?: string): LockInfo | undefined;
|
package/dist/project-lock.js
CHANGED
|
@@ -4,8 +4,11 @@ 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
|
+
/** The project-wide lock of older versions. Still read (it counts as one active run) and cleared; never written. */
|
|
7
8
|
export const LOCK_FILE = "lock.json";
|
|
8
|
-
|
|
9
|
+
/** One lock file per active run: `<project home>/locks/<run-id>.json`. */
|
|
10
|
+
export const LOCKS_DIR = "locks";
|
|
11
|
+
const ADMIT_FILE = ".admit";
|
|
9
12
|
const HEARTBEAT_MS = 5000;
|
|
10
13
|
/** Field 22 (starttime, in clock ticks) of a /proc/<pid>/stat line. The command name (field 2) may contain spaces and ")". */
|
|
11
14
|
export function parseProcStat(text) {
|
|
@@ -47,10 +50,29 @@ function pidStart(pid) {
|
|
|
47
50
|
}
|
|
48
51
|
/** Two start times can only be compared when they were taken the same way. */
|
|
49
52
|
const sameKind = (a, b) => a.startsWith("proc:") === b.startsWith("proc:");
|
|
50
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Is the process that wrote this lock still the same running process? Judged by pid and its start time,
|
|
55
|
+
* never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
|
|
56
|
+
*/
|
|
57
|
+
export function lockHolderAlive(info) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(info.pid, 0);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
if (e.code !== "EPERM")
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (info.pid_started) {
|
|
66
|
+
const now = pidStart(info.pid);
|
|
67
|
+
if (now && sameKind(now, info.pid_started) && now !== info.pid_started)
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
function readLockFile(file) {
|
|
51
73
|
let text;
|
|
52
74
|
try {
|
|
53
|
-
text = fs.readFileSync(
|
|
75
|
+
text = fs.readFileSync(file, "utf8");
|
|
54
76
|
}
|
|
55
77
|
catch {
|
|
56
78
|
return { kind: "none" };
|
|
@@ -65,44 +87,118 @@ function readLock(root) {
|
|
|
65
87
|
}
|
|
66
88
|
return { kind: "unreadable" };
|
|
67
89
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
const runLockFile = (root, runId) => path.join(root, LOCKS_DIR, `${runId}.json`);
|
|
91
|
+
const legacyFile = (root) => path.join(root, LOCK_FILE);
|
|
92
|
+
function assertRunId(runId) {
|
|
93
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(runId))
|
|
94
|
+
throw new Error(`Invalid run id "${runId}" for a lock: use letters, digits, ".", "-" or "_".`);
|
|
72
95
|
}
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
export function lockHolderAlive(info) {
|
|
96
|
+
/** Per-run lock files (not temp or mutex files), then the old project-wide one if it exists. */
|
|
97
|
+
function lockFiles(root) {
|
|
98
|
+
const dir = path.join(root, LOCKS_DIR);
|
|
99
|
+
let names = [];
|
|
78
100
|
try {
|
|
79
|
-
|
|
101
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith(".json") && !n.startsWith("."));
|
|
80
102
|
}
|
|
81
|
-
catch
|
|
82
|
-
|
|
83
|
-
return false;
|
|
103
|
+
catch {
|
|
104
|
+
/* no locks yet */
|
|
84
105
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
106
|
+
const files = names.sort().map((n) => path.join(dir, n));
|
|
107
|
+
if (fs.existsSync(legacyFile(root)))
|
|
108
|
+
files.push(legacyFile(root));
|
|
109
|
+
return files;
|
|
110
|
+
}
|
|
111
|
+
/** Every lock of a project that can be read: one per run, plus an older version's `lock.json`. Oldest first. */
|
|
112
|
+
export function listRunLocks(root) {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const f of lockFiles(root)) {
|
|
115
|
+
const r = readLockFile(f);
|
|
116
|
+
if (r.kind === "ok")
|
|
117
|
+
out.push(r.info);
|
|
89
118
|
}
|
|
90
|
-
return
|
|
119
|
+
return out.sort((a, b) => a.acquired_at.localeCompare(b.acquired_at) || a.run_id.localeCompare(b.run_id));
|
|
120
|
+
}
|
|
121
|
+
/** Run ids whose per-run lock file exists but cannot be read (it can only be removed with `forceUnlock(root, runId)`). */
|
|
122
|
+
export function listUnreadableRunLocks(root) {
|
|
123
|
+
return lockFiles(root)
|
|
124
|
+
.filter((f) => path.dirname(f) !== root && readLockFile(f).kind === "unreadable")
|
|
125
|
+
.map((f) => path.basename(f, ".json"));
|
|
126
|
+
}
|
|
127
|
+
/** The lock a run holds (per-run file, or an older version's `lock.json` written for that run), if readable. */
|
|
128
|
+
export function inspectRunLock(root, runId) {
|
|
129
|
+
const own = readLockFile(runLockFile(root, runId));
|
|
130
|
+
if (own.kind === "ok")
|
|
131
|
+
return own.info;
|
|
132
|
+
const old = readLockFile(legacyFile(root));
|
|
133
|
+
return old.kind === "ok" && old.info.run_id === runId ? old.info : undefined;
|
|
91
134
|
}
|
|
92
135
|
function heldError(info) {
|
|
93
136
|
const late = Math.round((Date.now() - Date.parse(info.heartbeat_at)) / 1000);
|
|
94
|
-
return new Error(`
|
|
95
|
-
`
|
|
137
|
+
return new Error(`Run ${info.run_id} is still running (pid ${info.pid}, heartbeat ${Number.isFinite(late) ? late : "?"}s ago). ` +
|
|
138
|
+
`A run cannot be started or resumed twice; wait for it to end, or stop it first.`);
|
|
96
139
|
}
|
|
97
|
-
function
|
|
98
|
-
|
|
140
|
+
function limitError(active, max) {
|
|
141
|
+
const list = active.map((l) => `${l.run_id} (pid ${l.pid})`).join(", ");
|
|
142
|
+
return new Error(`This project already has ${active.length} run(s) active: ${list}. At most ${max} may run at once (dispatcher.max_concurrent_runs: ${max}); ` +
|
|
143
|
+
`wait for one to end, stop one, or raise max_concurrent_runs in project.yaml.`);
|
|
144
|
+
}
|
|
145
|
+
function exclusiveError(holder) {
|
|
146
|
+
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.`);
|
|
147
|
+
}
|
|
148
|
+
function needsIdleError(active) {
|
|
149
|
+
return new Error(`This needs the project to itself, but ${active.map((l) => `${l.run_id} (pid ${l.pid})`).join(", ")} ${active.length === 1 ? "is" : "are"} active. Wait for ${active.length === 1 ? "it" : "them"} to end, or stop ${active.length === 1 ? "it" : "them"}.`);
|
|
150
|
+
}
|
|
151
|
+
function unreadableError(file) {
|
|
152
|
+
const perRun = path.basename(path.dirname(file)) === LOCKS_DIR;
|
|
153
|
+
const cmd = perRun ? `agent-lyceum clear --lock ${path.basename(file, ".json")} --force` : "agent-lyceum clear --lock --force";
|
|
154
|
+
return new Error(`The run lock ${file} cannot be read or has no known owner. Check that no run is active, then run \`${cmd}\`.`);
|
|
155
|
+
}
|
|
156
|
+
function sleepSync(ms) {
|
|
157
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Counting the active runs and creating our lock must happen as one step, or two starts could both pass the count.
|
|
161
|
+
* A short-lived mutex file serialises them; a holder that died leaves it behind for at most 10 s.
|
|
162
|
+
*/
|
|
163
|
+
function withAdmission(root, fn) {
|
|
164
|
+
const mutex = path.join(root, LOCKS_DIR, ADMIT_FILE);
|
|
165
|
+
const deadline = Date.now() + 5000;
|
|
166
|
+
for (;;) {
|
|
167
|
+
let fd;
|
|
168
|
+
try {
|
|
169
|
+
fd = fs.openSync(mutex, "wx");
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
if (e.code !== "EEXIST")
|
|
173
|
+
throw e;
|
|
174
|
+
}
|
|
175
|
+
if (fd !== undefined) {
|
|
176
|
+
try {
|
|
177
|
+
return fn();
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
fs.closeSync(fd);
|
|
181
|
+
fs.rmSync(mutex, { force: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
if (Date.now() - fs.statSync(mutex).mtimeMs > 10_000)
|
|
186
|
+
fs.rmSync(mutex, { force: true });
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
/* released meanwhile */
|
|
190
|
+
}
|
|
191
|
+
if (Date.now() > deadline)
|
|
192
|
+
throw new Error(`Could not take the project's admission lock ${mutex}; another process holds it. Try again.`);
|
|
193
|
+
sleepSync(20);
|
|
194
|
+
}
|
|
99
195
|
}
|
|
100
196
|
/** Create the lock file only if absent: write a private temp file, then hard-link it into place (atomic, never half-written). */
|
|
101
|
-
function tryCreate(
|
|
102
|
-
const tmp = path.join(
|
|
197
|
+
function tryCreate(file, info) {
|
|
198
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${info.token}.tmp`);
|
|
103
199
|
fs.writeFileSync(tmp, JSON.stringify(info, null, 2));
|
|
104
200
|
try {
|
|
105
|
-
fs.linkSync(tmp,
|
|
201
|
+
fs.linkSync(tmp, file);
|
|
106
202
|
return true;
|
|
107
203
|
}
|
|
108
204
|
catch (e) {
|
|
@@ -114,37 +210,11 @@ function tryCreate(root, info) {
|
|
|
114
210
|
fs.rmSync(tmp, { force: true });
|
|
115
211
|
}
|
|
116
212
|
}
|
|
117
|
-
/** Replace a lock whose holder is gone. Reclaimers take a short mutex so two of them cannot delete each other's fresh lock. */
|
|
118
|
-
function reclaim(root, info, stale) {
|
|
119
|
-
const mutex = path.join(root, RECLAIM_FILE);
|
|
120
|
-
try {
|
|
121
|
-
if (Date.now() - fs.statSync(mutex).mtimeMs > 10_000)
|
|
122
|
-
fs.rmSync(mutex, { force: true });
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
/* no mutex yet */
|
|
126
|
-
}
|
|
127
|
-
let fd;
|
|
128
|
-
try {
|
|
129
|
-
fd = fs.openSync(mutex, "wx");
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
try {
|
|
135
|
-
const again = readLock(root);
|
|
136
|
-
if (again.kind !== "ok" || again.info.token !== stale.token)
|
|
137
|
-
return false;
|
|
138
|
-
fs.rmSync(path.join(root, LOCK_FILE), { force: true });
|
|
139
|
-
return tryCreate(root, info);
|
|
140
|
-
}
|
|
141
|
-
finally {
|
|
142
|
-
fs.closeSync(fd);
|
|
143
|
-
fs.rmSync(mutex, { force: true });
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
213
|
export function acquireProjectLock(root, runId, opts = {}) {
|
|
147
|
-
|
|
214
|
+
assertRunId(runId);
|
|
215
|
+
fs.mkdirSync(path.join(root, LOCKS_DIR), { recursive: true });
|
|
216
|
+
const file = runLockFile(root, runId);
|
|
217
|
+
const max = opts.maxConcurrent ?? 1;
|
|
148
218
|
const now = new Date().toISOString();
|
|
149
219
|
const info = {
|
|
150
220
|
token: crypto.randomBytes(16).toString("hex"),
|
|
@@ -154,27 +224,42 @@ export function acquireProjectLock(root, runId, opts = {}) {
|
|
|
154
224
|
run_id: runId,
|
|
155
225
|
acquired_at: now,
|
|
156
226
|
heartbeat_at: now,
|
|
227
|
+
...(opts.exclusive ? { exclusive: true } : {}),
|
|
157
228
|
};
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
throw
|
|
172
|
-
|
|
229
|
+
withAdmission(root, () => {
|
|
230
|
+
// 1. This run's own lock: a live holder means it is already running; a dead one is replaced.
|
|
231
|
+
const own = readLockFile(file);
|
|
232
|
+
if (own.kind === "unreadable")
|
|
233
|
+
throw unreadableError(file);
|
|
234
|
+
if (own.kind === "ok") {
|
|
235
|
+
if (lockHolderAlive(own.info))
|
|
236
|
+
throw heldError(own.info);
|
|
237
|
+
fs.rmSync(file, { force: true });
|
|
238
|
+
}
|
|
239
|
+
// 2. An older version's project-wide lock counts as one active run; a dead one is cleared.
|
|
240
|
+
const old = readLockFile(legacyFile(root));
|
|
241
|
+
if (old.kind === "unreadable")
|
|
242
|
+
throw unreadableError(legacyFile(root));
|
|
243
|
+
if (old.kind === "ok" && old.info.run_id === runId && lockHolderAlive(old.info))
|
|
244
|
+
throw heldError(old.info);
|
|
245
|
+
if (old.kind === "ok" && !lockHolderAlive(old.info))
|
|
246
|
+
fs.rmSync(legacyFile(root), { force: true });
|
|
247
|
+
// 3. The other runs that are active now.
|
|
248
|
+
const active = listRunLocks(root).filter((l) => l.run_id !== runId && lockHolderAlive(l));
|
|
249
|
+
const exclusiveHolder = active.find((l) => l.exclusive);
|
|
250
|
+
if (exclusiveHolder)
|
|
251
|
+
throw exclusiveError(exclusiveHolder);
|
|
252
|
+
if (opts.exclusive && active.length)
|
|
253
|
+
throw needsIdleError(active);
|
|
254
|
+
if (active.length >= max)
|
|
255
|
+
throw limitError(active, max);
|
|
256
|
+
if (!tryCreate(file, info))
|
|
257
|
+
throw new Error(`Could not take the run lock ${file}; another process just took it. Try again.`);
|
|
258
|
+
});
|
|
173
259
|
let released = false;
|
|
174
260
|
let lost = false;
|
|
175
|
-
const file = path.join(root, LOCK_FILE);
|
|
176
261
|
const mine = () => {
|
|
177
|
-
const r =
|
|
262
|
+
const r = readLockFile(file);
|
|
178
263
|
return r.kind === "ok" && r.info.token === info.token;
|
|
179
264
|
};
|
|
180
265
|
const timer = setInterval(() => {
|
|
@@ -212,10 +297,23 @@ export function acquireProjectLock(root, runId, opts = {}) {
|
|
|
212
297
|
release,
|
|
213
298
|
};
|
|
214
299
|
}
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Remove a lock regardless of owner (the `clear --lock --force` escape hatch). With a run id: that run's lock (and an
|
|
302
|
+
* older version's `lock.json` when it was written for that run). Without: only the older project-wide `lock.json`.
|
|
303
|
+
* Returns what was there, if readable.
|
|
304
|
+
*/
|
|
305
|
+
export function forceUnlock(root, runId) {
|
|
306
|
+
if (runId === undefined) {
|
|
307
|
+
const old = readLockFile(legacyFile(root));
|
|
308
|
+
fs.rmSync(legacyFile(root), { force: true });
|
|
309
|
+
fs.rmSync(`${legacyFile(root)}.reclaim`, { force: true });
|
|
310
|
+
return old.kind === "ok" ? old.info : undefined;
|
|
311
|
+
}
|
|
312
|
+
assertRunId(runId);
|
|
313
|
+
const info = inspectRunLock(root, runId);
|
|
314
|
+
fs.rmSync(runLockFile(root, runId), { force: true });
|
|
315
|
+
const old = readLockFile(legacyFile(root));
|
|
316
|
+
if (old.kind === "ok" && old.info.run_id === runId)
|
|
317
|
+
fs.rmSync(legacyFile(root), { force: true });
|
|
220
318
|
return info;
|
|
221
319
|
}
|
package/dist/prompt.js
CHANGED
|
@@ -23,6 +23,8 @@ function readCapped(file, limit) {
|
|
|
23
23
|
const PR_ATTRIBUTION_RULE = "- If you open a pull request, end its description with a line saying it was opened by agent-lyceum (e.g. `🤖 Opened by agent-lyceum`). Do not name any agent or role in it.";
|
|
24
24
|
/** A tool call that never returns (a server or daemon started in the foreground) burns the whole wake-up timeout and the finished work is thrown away with it. */
|
|
25
25
|
const NO_DAEMONS_RULE = "- Never start a server, daemon or watcher (pm2, `serve`, `dev`, `--watch`) in the foreground of a command, and never leave one running. Run it in the background, bound every such command with `timeout 60`, stop what you started before you finish, and if it cannot start (e.g. a sandbox denies binding a port) report that as unverified instead of retrying.";
|
|
26
|
+
/** Only the lead (or a solo agent, the lead of a team of one) can send `done`, so only it can ask for a pull request. */
|
|
27
|
+
const OPEN_PR_RULE = "Add the frontmatter line `open_pr: true` to `done` only when the user's task explicitly asks for a pull request; otherwise leave it out. It only has an effect when the run ends `completed` with changes on its own branch, and agent-lyceum (not you) then pushes the branch and opens the pull request. Keep agent and role names out of the report, because it becomes the pull request description.";
|
|
26
28
|
export function readPersona(agent) {
|
|
27
29
|
return fs.readFileSync(agent.agentMd, "utf8").trim();
|
|
28
30
|
}
|
|
@@ -34,7 +36,7 @@ export function buildSoloSystemPrompt(project, agent) {
|
|
|
34
36
|
? "Do the task you are given. Your final reply text is printed to the user as your answer, so end with the answer or a short report of what you did; nothing else is delivered."
|
|
35
37
|
: "");
|
|
36
38
|
if (!isCall) {
|
|
37
|
-
lines.push("## Finishing", `When the job is finished, create a Markdown file (any name ending in \`.md\`) directly inside \`${outboxDir(project, agent.name)}\` with this exact shape:`, "", "```", "---", "type: done", "subject: <short subject>", "outcome: completed|partial|blocked|failed", "---", "", "## Result", "## Files", "## Verification", "## Not done", "```", "", "That ends the run. `## Result` is the actual deliverable or conclusion (not just \"done\"), `## Files` the paths you created or changed, `## Verification` what you ran or checked and what it showed, `## Not done` anything skipped or unverified (or `None`). Only `completed` means success and it needs all four headings; if work remains or is blocked say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A summary in your reply text is never delivered: without a `done` mail the run ends as idle with no report.");
|
|
39
|
+
lines.push("## Finishing", `When the job is finished, create a Markdown file (any name ending in \`.md\`) directly inside \`${outboxDir(project, agent.name)}\` with this exact shape:`, "", "```", "---", "type: done", "subject: <short subject>", "outcome: completed|partial|blocked|failed", "---", "", "## Result", "## Files", "## Verification", "## Not done", "```", "", "That ends the run. `## Result` is the actual deliverable or conclusion (not just \"done\"), `## Files` the paths you created or changed, `## Verification` what you ran or checked and what it showed, `## Not done` anything skipped or unverified (or `None`). Only `completed` means success and it needs all four headings; if work remains or is blocked say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A summary in your reply text is never delivered: without a `done` mail the run ends as idle with no report. " + OPEN_PR_RULE);
|
|
38
40
|
}
|
|
39
41
|
lines.push("", "## Memory");
|
|
40
42
|
const mems = memoryDirs(agent);
|
|
@@ -81,7 +83,7 @@ export function buildSystemPrompt(project, agent) {
|
|
|
81
83
|
: `Report results to the lead by mail; the lead decides what happens next.`, isLead
|
|
82
84
|
? "Each wake-up handles one message, except that several `reply`/`failure` messages waiting together are handed to you in one wake-up: weigh them together, then decide the next step once."
|
|
83
85
|
: "Each wake-up handles one message. Do the work it asks, then send the replies that are needed, then stop.", "", "## Message format", "The body of every `task` and `reply` must contain these `##` headings, exactly as written (write `None` under a heading if there is nothing to say).", "Mail missing a heading is still delivered, but is flagged to the recipient.", "", `- \`task\`: ${REQUIRED_SECTIONS.task.map((h) => `\`## ${h}\``).join(", ")} (Upstream = id of the mail this task derives from, or \`None\`).`, `- \`reply\`: ${REQUIRED_SECTIONS.reply.map((h) => `\`## ${h}\``).join(", ")}.`, isLead
|
|
84
|
-
? "- `done`: your final report to the user (shown when the run ends, saved as `result.md`). Its frontmatter must contain `outcome: completed|partial|blocked|failed`, and the body the headings `## Result` (the actual deliverable or conclusion, not just \"done\"), `## Files` (paths created or changed), `## Verification` (what you ran or checked, and what it showed) and `## Not done` (anything skipped or unverified, or `None`). Only `completed` means success, and it needs all four headings and every checklist step ticked; if work remains or is blocked, say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A `done` that breaks this is sent back to you and does not end the run."
|
|
86
|
+
? "- `done`: your final report to the user (shown when the run ends, saved as `result.md`). Its frontmatter must contain `outcome: completed|partial|blocked|failed`, and the body the headings `## Result` (the actual deliverable or conclusion, not just \"done\"), `## Files` (paths created or changed), `## Verification` (what you ran or checked, and what it showed) and `## Not done` (anything skipped or unverified, or `None`). Only `completed` means success, and it needs all four headings and every checklist step ticked; if work remains or is blocked, say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A `done` that breaks this is sent back to you and does not end the run. " + OPEN_PR_RULE
|
|
85
87
|
: "- `done` has no required headings.", ...(agent.canAskUser
|
|
86
88
|
? [
|
|
87
89
|
"",
|
package/dist/run-cleanup.js
CHANGED
|
@@ -4,7 +4,7 @@ import { atomicWrite } from "./fs-util.js";
|
|
|
4
4
|
import { assertName } from "./paths.js";
|
|
5
5
|
import { loadRunState } from "./run-store.js";
|
|
6
6
|
import { runIsAlive } from "./status.js";
|
|
7
|
-
import { deleteSnapshotRefs, inspectWorkspace, listSnapshotRefs, listWorkspaceAgents, removeAgentWorkspace } from "./worktree.js";
|
|
7
|
+
import { deleteSnapshotRefs, existingAgentWorkspace, inspectWorkspace, listSnapshotRefs, listWorkspaceAgents, removeAgentWorkspace } from "./worktree.js";
|
|
8
8
|
import { must } from "./assert.js";
|
|
9
9
|
const journalDir = (p) => path.join(p.paths.root, "cleanup");
|
|
10
10
|
const journalFile = (p, runId) => path.join(journalDir(p), `${runId}.json`);
|
|
@@ -36,8 +36,10 @@ export function planRunCleanup(project, runId, opts = {}) {
|
|
|
36
36
|
if (!state.end_reason && runIsAlive(state, project))
|
|
37
37
|
plan.refusals.push(`Run ${runId} is still running (pid ${state.pid}); stop it first.`);
|
|
38
38
|
}
|
|
39
|
+
// A concurrent run has a worktree for the lead: members' work lands there, and the lead's branch is what you merge.
|
|
40
|
+
const leadWs = existingAgentWorkspace(project, runId, project.lead);
|
|
39
41
|
for (const agent of listWorkspaceAgents(project, runId)) {
|
|
40
|
-
const w = inspectWorkspace(project, runId, agent);
|
|
42
|
+
const w = inspectWorkspace(project, runId, agent, agent === project.lead ? undefined : leadWs);
|
|
41
43
|
if (!w)
|
|
42
44
|
continue;
|
|
43
45
|
const files = [...new Set([...w.dirty, ...w.unintegrated])];
|
|
@@ -45,7 +47,8 @@ export function planRunCleanup(project, runId, opts = {}) {
|
|
|
45
47
|
if (keepWorktrees || holdsWork) {
|
|
46
48
|
plan.keep.push({ agent, branch: w.branch, path: w.root, files: files.length ? files : w.unknown ? [`(${w.unknown})`] : [] });
|
|
47
49
|
if (holdsWork && !keepWorktrees) {
|
|
48
|
-
|
|
50
|
+
const merge = agent === project.lead && leadWs ? ` Merge it first with \`git merge ${w.branch}\`.` : "";
|
|
51
|
+
plan.refusals.push(`${agent} has work that is not in the repo: ${files.length ? files.join(", ") : w.unknown} (branch ${w.branch}, worktree ${w.root}).${merge} ` +
|
|
49
52
|
`Bring it in or discard it by hand, or use --keep-worktrees to keep the worktree and branch and clear the rest.`);
|
|
50
53
|
}
|
|
51
54
|
continue;
|
package/dist/run-session.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
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
5
|
import { newRunId, type EndReason, type RunState } from "./run-store.js";
|
|
5
6
|
import { type PreparedTask } from "./task.js";
|
|
7
|
+
import { type CommandRunner, type PrOutcome } from "./open-pr.js";
|
|
6
8
|
export { newRunId };
|
|
7
9
|
export type { ActiveWake, EndReason, RunState, SentTopic, WakeRecord, WakeTopic } from "./run-store.js";
|
|
8
10
|
export declare const RESULT_FILE = "result.md";
|
|
@@ -25,6 +27,10 @@ export interface RunSummary {
|
|
|
25
27
|
};
|
|
26
28
|
/** Set when the run waits for the user: the file with the questions. */
|
|
27
29
|
askReplyPath?: string;
|
|
30
|
+
/** Concurrent runs: the branch holding the lead's work. */
|
|
31
|
+
resultBranch?: ResultBranch;
|
|
32
|
+
/** The lead asked for a pull request: what became of it. */
|
|
33
|
+
pr?: PrOutcome;
|
|
28
34
|
}
|
|
29
35
|
export interface RunOptions {
|
|
30
36
|
project: ResolvedProject;
|
|
@@ -36,6 +42,8 @@ export interface RunOptions {
|
|
|
36
42
|
resume?: RunState;
|
|
37
43
|
runDir: string;
|
|
38
44
|
invoker?: Invoker;
|
|
45
|
+
/** Runs git and gh for the pull request. Tests pass a fake; the default is the real thing. */
|
|
46
|
+
pr?: CommandRunner;
|
|
39
47
|
/** Stop the run when this fires: running agents are killed, unread mail is kept, the run ends `cancelled` and can be resumed. */
|
|
40
48
|
signal?: AbortSignal;
|
|
41
49
|
log?: (line: string) => void;
|
|
@@ -61,11 +69,18 @@ export declare class RunSession {
|
|
|
61
69
|
private doneOutcome;
|
|
62
70
|
private doneNote;
|
|
63
71
|
private doneVerification;
|
|
72
|
+
/** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
|
|
73
|
+
private openPrRequested;
|
|
74
|
+
private readonly prRunner;
|
|
64
75
|
/** Inbox mail each agent in the current batch is working on; it is marked read only after its output is routed. */
|
|
65
76
|
private readonly claims;
|
|
66
77
|
private readonly noSessionNoted;
|
|
67
78
|
/** Git worktrees of the non-lead agents in the batch being woken (empty in shared mode and for the lead). */
|
|
68
79
|
private batchSpaces;
|
|
80
|
+
/** Members whose worktree was refused (its git files were tampered with): never woken again in this run. */
|
|
81
|
+
private readonly refused;
|
|
82
|
+
/** Concurrent runs: the lead's own git worktree for the whole run. */
|
|
83
|
+
private readonly leadSpace;
|
|
69
84
|
/** The attempt that produced each batch member's output; committed together with its claim. */
|
|
70
85
|
private readonly attempts;
|
|
71
86
|
constructor(opts: RunOptions);
|
|
@@ -82,12 +97,24 @@ export declare class RunSession {
|
|
|
82
97
|
* sees them. When that cannot be done, nothing is applied, the member's work stays on its branch, and the lead is told.
|
|
83
98
|
*/
|
|
84
99
|
private integrateMember;
|
|
100
|
+
/** A member whose worktree was refused: it is not woken again in this run, and the lead is told so it can react. */
|
|
101
|
+
private refuseMember;
|
|
85
102
|
/** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
|
|
86
103
|
private settle;
|
|
87
104
|
/** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
|
|
88
105
|
private deliverAnswers;
|
|
89
106
|
/** Store the questions of this pass's valid `ask` mails; malformed ones go back to their sender. Returns true when the run should now wait. */
|
|
90
107
|
private handleAsks;
|
|
108
|
+
/**
|
|
109
|
+
* Concurrent runs, at the end of the run: the lead's work is committed on its branch for you to merge.
|
|
110
|
+
* A run that waits for your answers has not ended (it will be resumed in the same worktree), so it commits nothing.
|
|
111
|
+
*/
|
|
112
|
+
private finishLead;
|
|
113
|
+
/**
|
|
114
|
+
* The lead asked for a pull request in `done`. It is opened from the lead's branch, by the dispatcher, outside any agent sandbox.
|
|
115
|
+
* A request that cannot be honoured is reported and noted; it never changes how the run turned out.
|
|
116
|
+
*/
|
|
117
|
+
private maybeOpenPr;
|
|
91
118
|
/** On resume: sort out what the interrupted process left behind before anything is woken. */
|
|
92
119
|
private recover;
|
|
93
120
|
run(): Promise<RunSummary>;
|