agent-lyceum 0.4.14 → 0.6.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 +21 -21
- package/README.zh-TW.md +21 -21
- package/dist/adapters/claude.js +2 -0
- package/dist/ask-answer.d.ts +10 -0
- package/dist/ask-answer.js +21 -0
- package/dist/ask-reply.d.ts +65 -0
- package/dist/ask-reply.js +165 -0
- package/dist/assert.d.ts +2 -0
- package/dist/assert.js +6 -0
- package/dist/cli.js +202 -16
- package/dist/config.d.ts +21 -16
- package/dist/config.js +19 -7
- package/dist/dispatcher.d.ts +3 -34
- package/dist/dispatcher.js +4 -464
- package/dist/doctor.js +3 -0
- package/dist/format.js +7 -5
- package/dist/fs-util.d.ts +2 -0
- package/dist/fs-util.js +15 -0
- package/dist/guard.d.ts +8 -0
- package/dist/guard.js +24 -3
- package/dist/mailbox.d.ts +11 -1
- package/dist/mailbox.js +21 -8
- package/dist/memory-tidy.d.ts +115 -0
- package/dist/memory-tidy.js +351 -0
- package/dist/policy.js +3 -3
- package/dist/process-runner.js +1 -2
- package/dist/project-lock.d.ts +10 -1
- package/dist/project-lock.js +39 -4
- package/dist/prompt.d.ts +20 -0
- package/dist/prompt.js +76 -3
- package/dist/run-cleanup.js +4 -3
- package/dist/run-log.d.ts +5 -0
- package/dist/run-log.js +18 -0
- package/dist/run-outcome.d.ts +22 -0
- package/dist/run-outcome.js +28 -0
- package/dist/run-session.d.ts +92 -0
- package/dist/run-session.js +602 -0
- package/dist/run-store.d.ts +3 -1
- package/dist/run-store.js +7 -6
- package/dist/scaffold.js +1 -0
- package/dist/schema.d.ts +72 -239
- package/dist/schema.js +8 -3
- package/dist/status.d.ts +9 -1
- package/dist/status.js +51 -15
- package/dist/validate.js +12 -8
- package/dist/worktree.js +7 -6
- package/package.json +14 -8
package/dist/project-lock.js
CHANGED
|
@@ -7,11 +7,46 @@ import { atomicWrite } from "./fs-util.js";
|
|
|
7
7
|
export const LOCK_FILE = "lock.json";
|
|
8
8
|
const RECLAIM_FILE = "lock.json.reclaim";
|
|
9
9
|
const HEARTBEAT_MS = 5000;
|
|
10
|
+
/** Field 22 (starttime, in clock ticks) of a /proc/<pid>/stat line. The command name (field 2) may contain spaces and ")". */
|
|
11
|
+
export function parseProcStat(text) {
|
|
12
|
+
const end = text.lastIndexOf(")");
|
|
13
|
+
if (end < 0)
|
|
14
|
+
return undefined;
|
|
15
|
+
const fields = text.slice(end + 2).split(" "); // fields[0] is field 3 (state), so field 22 is fields[19]
|
|
16
|
+
const v = fields[19];
|
|
17
|
+
return v && /^\d+$/.test(v) ? v : undefined;
|
|
18
|
+
}
|
|
19
|
+
/** A start time that identifies one process: `proc:<ticks>` on Linux, the `ps` text elsewhere or when /proc cannot be read. */
|
|
20
|
+
export function pidStartFrom(pid, io) {
|
|
21
|
+
if (io.platform === "linux") {
|
|
22
|
+
const text = io.readFile(`/proc/${pid}/stat`);
|
|
23
|
+
const ticks = text ? parseProcStat(text) : undefined;
|
|
24
|
+
if (ticks)
|
|
25
|
+
return `proc:${ticks}`;
|
|
26
|
+
}
|
|
27
|
+
return io.ps(pid);
|
|
28
|
+
}
|
|
29
|
+
const realIo = {
|
|
30
|
+
platform: process.platform,
|
|
31
|
+
readFile: (p) => {
|
|
32
|
+
try {
|
|
33
|
+
return fs.readFileSync(p, "utf8");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
ps: (pid) => {
|
|
40
|
+
const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
|
|
41
|
+
const out = r.status === 0 ? r.stdout.trim() : "";
|
|
42
|
+
return out || undefined;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
10
45
|
function pidStart(pid) {
|
|
11
|
-
|
|
12
|
-
const out = r.status === 0 ? r.stdout.trim() : "";
|
|
13
|
-
return out || undefined;
|
|
46
|
+
return pidStartFrom(pid, realIo);
|
|
14
47
|
}
|
|
48
|
+
/** Two start times can only be compared when they were taken the same way. */
|
|
49
|
+
const sameKind = (a, b) => a.startsWith("proc:") === b.startsWith("proc:");
|
|
15
50
|
function readLock(root) {
|
|
16
51
|
let text;
|
|
17
52
|
try {
|
|
@@ -49,7 +84,7 @@ export function lockHolderAlive(info) {
|
|
|
49
84
|
}
|
|
50
85
|
if (info.pid_started) {
|
|
51
86
|
const now = pidStart(info.pid);
|
|
52
|
-
if (now && now !== info.pid_started)
|
|
87
|
+
if (now && sameKind(now, info.pid_started) && now !== info.pid_started)
|
|
53
88
|
return false;
|
|
54
89
|
}
|
|
55
90
|
return true;
|
package/dist/prompt.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ResolvedAgent, ResolvedProject } from "./config.js";
|
|
2
2
|
import type { Message } from "./mailbox.js";
|
|
3
|
+
import type { Layer, MemoryFile, MissingPath, ProjectChanges } from "./memory-tidy.js";
|
|
3
4
|
export declare const COMMON_LIMIT: number;
|
|
4
5
|
export declare const MEMORY_INDEX_LIMIT: number;
|
|
5
6
|
export declare const LEAD_BATCH_LIMIT = 5;
|
|
@@ -9,3 +10,22 @@ export declare function buildSystemPrompt(project: ResolvedProject, agent: Resol
|
|
|
9
10
|
/** 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. */
|
|
10
11
|
export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
|
|
11
12
|
export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number): string;
|
|
13
|
+
export interface TidyContext {
|
|
14
|
+
layer: Layer;
|
|
15
|
+
/** The memory directory being tidied. */
|
|
16
|
+
dir: string;
|
|
17
|
+
/** Absolute path of this tidy's archive folder (the agent creates it). */
|
|
18
|
+
archiveDir: string;
|
|
19
|
+
/** Live files only (the archive is not listed). */
|
|
20
|
+
files: MemoryFile[];
|
|
21
|
+
/** MEMORY.md text ("" when there is none). */
|
|
22
|
+
index: string;
|
|
23
|
+
/** What changed in the project since the last tidy (project layer only). */
|
|
24
|
+
changes?: ProjectChanges;
|
|
25
|
+
missing: MissingPath[];
|
|
26
|
+
}
|
|
27
|
+
/** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
|
|
28
|
+
export declare function buildTidyPrompts(agent: ResolvedAgent, ctx: TidyContext): {
|
|
29
|
+
systemPrompt: string;
|
|
30
|
+
userPrompt: string;
|
|
31
|
+
};
|
package/dist/prompt.js
CHANGED
|
@@ -32,15 +32,33 @@ export function buildSystemPrompt(project, agent) {
|
|
|
32
32
|
lines.push(readPersona(agent), "", "---", "", "# Team protocol (injected by agent-lyceum)", "");
|
|
33
33
|
lines.push(`You are **${agent.name}**, a member of the team "${project.name}". You work in the repository \`${repoDirFor(project, agent.name)}\`.`, ...(project.workspaces?.[agent.name]
|
|
34
34
|
? [
|
|
35
|
-
`That directory is your own isolated git worktree (branch \`${project.workspaces[agent.name]
|
|
35
|
+
`That directory is your own isolated git worktree (branch \`${project.workspaces?.[agent.name]?.branch}\`): teammates cannot see your changes while you work, and you cannot see theirs. Do not commit, push, switch branches or touch any other checkout; the dispatcher collects your changes from this directory after you finish.`,
|
|
36
36
|
]
|
|
37
|
-
: []), `The team lead is **${project.lead}**${isLead ? " (that is you)" : ""}. Teammates: ${mates.join(", ") || "(none)"}.`, "", "## Sending mail", "You cannot talk to teammates directly. Mail you receive is shown in your prompt. To send mail, create a Markdown file", `(any name ending in \`.md\`) directly inside \`${outbox}\` with this exact shape:`, "", "```", "---", "to: <agent-name>",
|
|
37
|
+
: []), `The team lead is **${project.lead}**${isLead ? " (that is you)" : ""}. Teammates: ${mates.join(", ") || "(none)"}.`, "", "## Sending mail", "You cannot talk to teammates directly. Mail you receive is shown in your prompt. To send mail, create a Markdown file", `(any name ending in \`.md\`) directly inside \`${outbox}\` with this exact shape:`, "", "```", "---", "to: <agent-name>", `type: reply # task | reply${agent.canAskUser ? " | ask" : ""}${isLead ? " | done" : ""}`, "subject: <short subject>", "reply_to: <message id> # optional", "---", "", "<body>", "```", "", `You may send to: ${recipients.join(", ") || "(nobody)"}.`, isLead
|
|
38
38
|
? `When the whole job is finished, send a message with \`type: done\` (no \`to\` needed). That ends the run. A summary in your reply text is never delivered and does not end the run: after the last teammate reply is verified you MUST send \`done\` (or a \`task\` for the remaining work), or the run stops as "idle" with no final report.`
|
|
39
39
|
: `Report results to the lead by mail; the lead decides what happens next.`, isLead
|
|
40
40
|
? "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."
|
|
41
41
|
: "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
|
|
42
42
|
? "- `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."
|
|
43
|
-
: "- `done` has no required headings.", ...(
|
|
43
|
+
: "- `done` has no required headings.", ...(agent.canAskUser
|
|
44
|
+
? [
|
|
45
|
+
"",
|
|
46
|
+
"## Asking the user",
|
|
47
|
+
"You may ask the human user with `type: ask` (no `to` needed). The run then pauses until they answer, so ask only when the answer changes what you do next and you cannot work it out from the code, the memory or the task. Ask everything you need in ONE batch (at most 10 questions); prefer questions with `<options>` and a `<suggested>` value (with a `reason`) so the user can answer quickly. The body is exactly this:",
|
|
48
|
+
"",
|
|
49
|
+
"```",
|
|
50
|
+
"<ask>",
|
|
51
|
+
' <question id="q1">',
|
|
52
|
+
" <text>Session or JWT for login?</text>",
|
|
53
|
+
" <options><option>session</option><option>jwt</option></options>",
|
|
54
|
+
' <suggested reason="existing code already uses cookies">session</suggested>',
|
|
55
|
+
" </question>",
|
|
56
|
+
"</ask>",
|
|
57
|
+
"```",
|
|
58
|
+
"",
|
|
59
|
+
"The answers come back to you as a `reply` mail containing an `<answers>` block. A user answer that starts with `other:` is free text. Do not ask what you could decide yourself, and do not ask again for something already answered.",
|
|
60
|
+
]
|
|
61
|
+
: []), ...(isLead
|
|
44
62
|
? [
|
|
45
63
|
"",
|
|
46
64
|
"## Progress tracking (optional, shown in `agent-lyceum status`)",
|
|
@@ -123,3 +141,58 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1) {
|
|
|
123
141
|
}
|
|
124
142
|
return parts.join("\n");
|
|
125
143
|
}
|
|
144
|
+
/** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
|
|
145
|
+
export function buildTidyPrompts(agent, ctx) {
|
|
146
|
+
const systemPrompt = [
|
|
147
|
+
readPersona(agent),
|
|
148
|
+
"",
|
|
149
|
+
"---",
|
|
150
|
+
"",
|
|
151
|
+
"# Memory tidy (injected by agent-lyceum)",
|
|
152
|
+
"",
|
|
153
|
+
`You are **${agent.name}**. This wake-up is a maintenance job, not a task: there is no mail to send or read, and nothing to do in the repository. Your only job is to tidy your own ${ctx.layer} memory so it stays small, accurate and useful.`,
|
|
154
|
+
"",
|
|
155
|
+
"## What you may touch",
|
|
156
|
+
`Only files inside \`${ctx.dir}\`. Everything else is read-only. Do not touch your other memory directories, \`${ctx.dir}/.tidy-state.json\` or the archive folders of earlier tidies.`,
|
|
157
|
+
"",
|
|
158
|
+
"## What to do",
|
|
159
|
+
"- Merge entries that say the same thing; keep one file with the combined, current content.",
|
|
160
|
+
"- Rewrite entries that are stale (see the project changes and the missing paths below); correct them if you can tell what is true now, otherwise keep them and say so in the report.",
|
|
161
|
+
"- Keep `MEMORY.md` as the index: one line per entry, each pointing at an existing file, and nothing for files that were archived.",
|
|
162
|
+
"- **When unsure, keep it.** Prefer leaving an entry alone over losing information.",
|
|
163
|
+
"",
|
|
164
|
+
"## Never delete",
|
|
165
|
+
`To retire a file, move it into the archive folder, keeping its relative path (\`${ctx.dir}/x/y.md\` goes to \`${ctx.archiveDir}/x/y.md\`). Never delete a file. \`MEMORY.md\` itself is never archived. A file whose content you merged into another one is archived too.`,
|
|
166
|
+
"",
|
|
167
|
+
"## Before you finish",
|
|
168
|
+
`Write \`${ctx.archiveDir}/tidy-report.md\` — even when you changed nothing — with these headings: \`## Merged\` (what was combined into what), \`## Archived\` (each archived file and why), \`## Unsure\` (things you kept but doubt). Write \`None\` under a heading with nothing to say. The tidy is rolled back if the report is missing or any file went missing.`,
|
|
169
|
+
].join("\n");
|
|
170
|
+
const parts = [
|
|
171
|
+
`Archive directory: ${ctx.archiveDir}`,
|
|
172
|
+
`Layer: ${ctx.layer}${ctx.layer === "global" ? " (shared by every project; do not judge entries by one project's code)" : ""}`,
|
|
173
|
+
`Memory directory: ${ctx.dir}`,
|
|
174
|
+
"",
|
|
175
|
+
"# Files",
|
|
176
|
+
...(ctx.files.length ? ctx.files.map((f) => `${f.rel} ${f.bytes} B modified ${f.mtime.slice(0, 10)}`) : ["(none)"]),
|
|
177
|
+
"",
|
|
178
|
+
`# MEMORY.md (${ctx.dir}/MEMORY.md)`,
|
|
179
|
+
ctx.index.trim() ? ctx.index.trim() : "(no MEMORY.md)",
|
|
180
|
+
];
|
|
181
|
+
if (ctx.layer === "project") {
|
|
182
|
+
const c = ctx.changes;
|
|
183
|
+
parts.push("", "# Changes in the project since the last tidy");
|
|
184
|
+
if (!c)
|
|
185
|
+
parts.push("(not available)");
|
|
186
|
+
else {
|
|
187
|
+
if (c.note)
|
|
188
|
+
parts.push(c.note);
|
|
189
|
+
if (c.log.length)
|
|
190
|
+
parts.push("", "Commits:", ...c.log);
|
|
191
|
+
if (c.stat.length)
|
|
192
|
+
parts.push("", "Files changed:", ...c.stat);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
parts.push("", "# Paths named in the memory that no longer exist");
|
|
196
|
+
parts.push(...(ctx.missing.length ? ctx.missing.map((m) => `- \`${m.path}\` (mentioned in ${m.file})`) : ["(none found)"]));
|
|
197
|
+
return { systemPrompt, userPrompt: parts.join("\n") };
|
|
198
|
+
}
|
package/dist/run-cleanup.js
CHANGED
|
@@ -5,6 +5,7 @@ import { assertName } from "./paths.js";
|
|
|
5
5
|
import { loadRunState } from "./run-store.js";
|
|
6
6
|
import { runIsAlive } from "./status.js";
|
|
7
7
|
import { deleteSnapshotRefs, inspectWorkspace, listSnapshotRefs, listWorkspaceAgents, removeAgentWorkspace } from "./worktree.js";
|
|
8
|
+
import { must } from "./assert.js";
|
|
8
9
|
const journalDir = (p) => path.join(p.paths.root, "cleanup");
|
|
9
10
|
const journalFile = (p, runId) => path.join(journalDir(p), `${runId}.json`);
|
|
10
11
|
const lexists = (p) => {
|
|
@@ -58,7 +59,7 @@ export function planRunCleanup(project, runId, opts = {}) {
|
|
|
58
59
|
if (lexists(mem))
|
|
59
60
|
plan.items.push({ kind: "task-memory", path: mem, label: `task memory ${mem}` });
|
|
60
61
|
if (hasRun || lexists(runDir))
|
|
61
|
-
plan.items.push({ kind: "run", path: runDir, label: `run directory ${runDir} (state, log, result, snapshots, mailboxes)` });
|
|
62
|
+
plan.items.push({ kind: "run", path: runDir, label: `run directory ${runDir} (state, log, result, snapshots, kept violations, mailboxes)` });
|
|
62
63
|
return plan;
|
|
63
64
|
}
|
|
64
65
|
/** Remove a path without ever following a symlink, and only inside the project home. */
|
|
@@ -91,7 +92,7 @@ export function executeRunCleanup(plan, hooks = {}) {
|
|
|
91
92
|
const journal = { started_at: new Date().toISOString(), items: plan.items.map((i) => ({ ...i, done: false })) };
|
|
92
93
|
record(journal);
|
|
93
94
|
for (let n = 0; n < plan.items.length; n++) {
|
|
94
|
-
const item = plan.items[n];
|
|
95
|
+
const item = must(plan.items[n], "cleanup item");
|
|
95
96
|
try {
|
|
96
97
|
if (item.kind === "workspace") {
|
|
97
98
|
removeAgentWorkspace(project, runId, item.agent);
|
|
@@ -109,7 +110,7 @@ export function executeRunCleanup(plan, hooks = {}) {
|
|
|
109
110
|
return report; // keep the journal and the run directory: the next `clear` picks up here
|
|
110
111
|
}
|
|
111
112
|
report.done.push(item);
|
|
112
|
-
journal.items[n].done = true;
|
|
113
|
+
must(journal.items[n], "journal item").done = true;
|
|
113
114
|
record(journal);
|
|
114
115
|
hooks.afterItem?.(item);
|
|
115
116
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only run log, one JSON object per line. When the next line would push the file past `maxBytes`
|
|
3
|
+
* (0 = no limit) the file becomes `<name>.1.jsonl`, replacing an older one, and a new file starts.
|
|
4
|
+
*/
|
|
5
|
+
export declare function createRunLog(file: string, maxBytes: number): (event: string, data?: Record<string, unknown>) => void;
|
package/dist/run-log.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Append-only run log, one JSON object per line. When the next line would push the file past `maxBytes`
|
|
4
|
+
* (0 = no limit) the file becomes `<name>.1.jsonl`, replacing an older one, and a new file starts.
|
|
5
|
+
*/
|
|
6
|
+
export function createRunLog(file, maxBytes) {
|
|
7
|
+
let size = fs.existsSync(file) ? fs.statSync(file).size : 0;
|
|
8
|
+
return (event, data = {}) => {
|
|
9
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + "\n";
|
|
10
|
+
const bytes = Buffer.byteLength(line);
|
|
11
|
+
if (maxBytes > 0 && size > 0 && size + bytes > maxBytes) {
|
|
12
|
+
fs.renameSync(file, file.replace(/\.jsonl$/, ".1.jsonl"));
|
|
13
|
+
size = 0;
|
|
14
|
+
}
|
|
15
|
+
fs.appendFileSync(file, line);
|
|
16
|
+
size += bytes;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DoneContract } from "./format.js";
|
|
2
|
+
import type { EndReason } from "./run-store.js";
|
|
3
|
+
import type { RunOutcome } from "./schema.js";
|
|
4
|
+
export interface UnintegratedWork {
|
|
5
|
+
agent: string;
|
|
6
|
+
branch: string;
|
|
7
|
+
report: string;
|
|
8
|
+
}
|
|
9
|
+
/** What a lead's `done` amounts to: its declared outcome, unless the contract is broken or member work never reached the repo. */
|
|
10
|
+
export declare function resolveDoneOutcome(contract: DoneContract, rejections: number, unintegrated: UnintegratedWork[]): {
|
|
11
|
+
outcome: RunOutcome;
|
|
12
|
+
note?: string;
|
|
13
|
+
verification?: string;
|
|
14
|
+
};
|
|
15
|
+
/** How a run turned out, from why it stopped (`done` carries the lead's own verdict). */
|
|
16
|
+
export declare function endOutcome(endReason: EndReason, done?: {
|
|
17
|
+
outcome: RunOutcome;
|
|
18
|
+
note?: string;
|
|
19
|
+
}): {
|
|
20
|
+
outcome: RunOutcome;
|
|
21
|
+
note?: string;
|
|
22
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** What a lead's `done` amounts to: its declared outcome, unless the contract is broken or member work never reached the repo. */
|
|
2
|
+
export function resolveDoneOutcome(contract, rejections, unintegrated) {
|
|
3
|
+
const ok = contract.missing.length === 0;
|
|
4
|
+
let outcome = ok ? (contract.outcome ?? "partial") : "partial";
|
|
5
|
+
let note = ok ? undefined : `Completion contract not met after ${rejections} reminder(s): missing ${contract.missing.join("; ")}. Reported as partial; the lead's report is kept as written.`;
|
|
6
|
+
if (outcome === "completed" && unintegrated.length) {
|
|
7
|
+
outcome = "blocked";
|
|
8
|
+
note = `Not completed: work of ${unintegrated.map((b) => `${b.agent} (branch ${b.branch})`).join(", ")} was never brought into the repo; see ${unintegrated.map((b) => b.report).join(", ")}.`;
|
|
9
|
+
}
|
|
10
|
+
return { outcome, note, verification: contract.verification };
|
|
11
|
+
}
|
|
12
|
+
/** How a run turned out, from why it stopped (`done` carries the lead's own verdict). */
|
|
13
|
+
export function endOutcome(endReason, done) {
|
|
14
|
+
switch (endReason) {
|
|
15
|
+
case "done":
|
|
16
|
+
return { outcome: done?.outcome ?? "partial", note: done?.note };
|
|
17
|
+
case "lead_failed":
|
|
18
|
+
return { outcome: "failed", note: "The lead's wake-up failed." };
|
|
19
|
+
case "waiting":
|
|
20
|
+
return { outcome: "waiting", note: "Waiting for your answers. Fill in ask-reply.md or run `agent-lyceum answer <run-id>`, then continue with `agent-lyceum resume`." };
|
|
21
|
+
case "cancelled":
|
|
22
|
+
return { outcome: "cancelled", note: "Cancelled before the lead finished. Unread mail was kept; continue with `agent-lyceum resume`." };
|
|
23
|
+
case "idle":
|
|
24
|
+
return { outcome: "partial", note: "All mailboxes were empty but the lead never sent a done message." };
|
|
25
|
+
case "max_rounds":
|
|
26
|
+
return { outcome: "partial", note: "Stopped at the round limit before the lead sent done." };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type Invoker } from "./adapters/index.js";
|
|
2
|
+
import type { ResolvedProject } from "./config.js";
|
|
3
|
+
import type { RunOutcome } from "./schema.js";
|
|
4
|
+
import { newRunId, type EndReason, type RunState } from "./run-store.js";
|
|
5
|
+
import { type PreparedTask } from "./task.js";
|
|
6
|
+
export { newRunId };
|
|
7
|
+
export type { ActiveWake, EndReason, RunState, SentTopic, WakeRecord, WakeTopic } from "./run-store.js";
|
|
8
|
+
export declare const RESULT_FILE = "result.md";
|
|
9
|
+
/** How many times a non-conforming `done` goes back to the lead before it is accepted as `partial`. */
|
|
10
|
+
export declare const MAX_DONE_REJECTIONS = 2;
|
|
11
|
+
/** How many times a malformed `ask` goes back to its sender before the lead is told instead. */
|
|
12
|
+
export declare const MAX_ASK_REJECTIONS = 2;
|
|
13
|
+
export interface RunSummary {
|
|
14
|
+
runId: string;
|
|
15
|
+
runDir: string;
|
|
16
|
+
rounds: number;
|
|
17
|
+
endReason: EndReason;
|
|
18
|
+
/** How the work turned out; only "completed" means success. */
|
|
19
|
+
outcome: RunOutcome;
|
|
20
|
+
outcomeNote?: string;
|
|
21
|
+
verification?: string;
|
|
22
|
+
doneMessage?: {
|
|
23
|
+
subject: string;
|
|
24
|
+
body: string;
|
|
25
|
+
};
|
|
26
|
+
/** Set when the run waits for the user: the file with the questions. */
|
|
27
|
+
askReplyPath?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface RunOptions {
|
|
30
|
+
project: ResolvedProject;
|
|
31
|
+
/** The task for a new run; omit when resuming. */
|
|
32
|
+
task?: PreparedTask;
|
|
33
|
+
/** Continue this earlier run (same run dir, sessions and counters) instead of starting a new one. */
|
|
34
|
+
resume?: RunState;
|
|
35
|
+
runDir: string;
|
|
36
|
+
invoker?: Invoker;
|
|
37
|
+
/** Stop the run when this fires: running agents are killed, unread mail is kept, the run ends `cancelled` and can be resumed. */
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
log?: (line: string) => void;
|
|
40
|
+
}
|
|
41
|
+
export declare class RunSession {
|
|
42
|
+
private readonly opts;
|
|
43
|
+
private readonly resume;
|
|
44
|
+
private readonly invoke;
|
|
45
|
+
private readonly say;
|
|
46
|
+
private readonly cfg;
|
|
47
|
+
private readonly runId;
|
|
48
|
+
private readonly runDir;
|
|
49
|
+
/** The project bound to this run (own memory dirs and mailboxes). */
|
|
50
|
+
private readonly project;
|
|
51
|
+
private readonly wsMode;
|
|
52
|
+
private readonly log;
|
|
53
|
+
private readonly guard;
|
|
54
|
+
private readonly state;
|
|
55
|
+
private endReason;
|
|
56
|
+
private doneMessage;
|
|
57
|
+
/** The lead's done file; it leaves the outbox only after the result and end state are stored. */
|
|
58
|
+
private pendingDoneFile;
|
|
59
|
+
private doneOutcome;
|
|
60
|
+
private doneNote;
|
|
61
|
+
private doneVerification;
|
|
62
|
+
/** Inbox mail each agent in the current batch is working on; it is marked read only after its output is routed. */
|
|
63
|
+
private readonly claims;
|
|
64
|
+
private readonly noSessionNoted;
|
|
65
|
+
/** Git worktrees of the non-lead agents in the batch being woken (empty in shared mode and for the lead). */
|
|
66
|
+
private batchSpaces;
|
|
67
|
+
/** The attempt that produced each batch member's output; committed together with its claim. */
|
|
68
|
+
private readonly attempts;
|
|
69
|
+
constructor(opts: RunOptions);
|
|
70
|
+
/** A waiting run only continues once every unanswered question has a valid answer. */
|
|
71
|
+
private static assertAnswered;
|
|
72
|
+
private saveState;
|
|
73
|
+
private journal;
|
|
74
|
+
private note;
|
|
75
|
+
private pendingAgents;
|
|
76
|
+
private pickBatch;
|
|
77
|
+
private wake;
|
|
78
|
+
/**
|
|
79
|
+
* A finished member's files go from its worktree into the repo before anyone reads its mail, so the lead
|
|
80
|
+
* sees them. When that cannot be done, nothing is applied, the member's work stays on its branch, and the lead is told.
|
|
81
|
+
*/
|
|
82
|
+
private integrateMember;
|
|
83
|
+
/** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
|
|
84
|
+
private settle;
|
|
85
|
+
/** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
|
|
86
|
+
private deliverAnswers;
|
|
87
|
+
/** 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. */
|
|
88
|
+
private handleAsks;
|
|
89
|
+
/** On resume: sort out what the interrupted process left behind before anything is woken. */
|
|
90
|
+
private recover;
|
|
91
|
+
run(): Promise<RunSummary>;
|
|
92
|
+
}
|