agent-lyceum 0.13.1 → 0.15.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 +20 -6
- package/README.zh-TW.md +20 -7
- package/dist/adapters/claude.js +1 -1
- package/dist/ask-reply.d.ts +1 -1
- package/dist/ask-reply.js +10 -10
- package/dist/cli.js +112 -43
- package/dist/fs-util.d.ts +8 -0
- package/dist/fs-util.js +36 -0
- package/dist/guard.js +4 -1
- package/dist/mailbox.js +4 -1
- package/dist/open-pr.d.ts +6 -1
- package/dist/open-pr.js +10 -0
- package/dist/policy.d.ts +9 -0
- package/dist/policy.js +34 -3
- package/dist/prompt.d.ts +2 -1
- package/dist/prompt.js +26 -12
- package/dist/run-session.d.ts +10 -0
- package/dist/run-session.js +82 -13
- package/dist/run-store.d.ts +31 -0
- package/dist/run-store.js +38 -0
- package/dist/status.d.ts +7 -0
- package/dist/status.js +12 -5
- package/dist/validate.js +4 -4
- package/dist/worktree.d.ts +10 -0
- package/dist/worktree.js +25 -0
- package/package.json +1 -1
package/dist/open-pr.js
CHANGED
|
@@ -48,6 +48,16 @@ const firstLine = (s) => s.split(/\r?\n/).find((l) => l.trim())?.trim() ?? "no o
|
|
|
48
48
|
const sh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
49
49
|
/** Push the branch and open a pull request against the default branch. Never throws; says how to finish by hand when a step fails. */
|
|
50
50
|
export function openPullRequest(i) {
|
|
51
|
+
if (i.existingUrl) {
|
|
52
|
+
// A squash merge leaves the branch looking unmerged, so ask GitHub. When the lookup itself fails, assume the PR is still open.
|
|
53
|
+
const view = i.run("gh", ["pr", "view", i.existingUrl, "--json", "state", "-q", ".state"], i.cwd);
|
|
54
|
+
if (!view.ok || view.out.trim() === "OPEN") {
|
|
55
|
+
const push = i.run("git", ["push", "-u", "origin", i.branch], i.cwd);
|
|
56
|
+
if (push.ok)
|
|
57
|
+
return { status: "opened", url: i.existingUrl, updated: true };
|
|
58
|
+
return { status: "failed", step: "push", reason: firstLine(push.out), manual: [`git -C ${sh(i.cwd)} push -u origin ${sh(i.branch)}`] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
51
61
|
const base = defaultBase(i.cwd, i.run);
|
|
52
62
|
try {
|
|
53
63
|
atomicWrite(i.bodyFile, i.body);
|
package/dist/policy.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface AgentEnforcement {
|
|
|
6
6
|
memory: Level;
|
|
7
7
|
agentMd: Level;
|
|
8
8
|
otherContext: Level;
|
|
9
|
+
shared: Level;
|
|
9
10
|
repoInstructions: Level;
|
|
10
11
|
owns: Level;
|
|
11
12
|
/** Absolute paths outside the repo this agent may write. */
|
|
@@ -15,6 +16,8 @@ export interface AgentEnforcement {
|
|
|
15
16
|
export interface WritePolicy {
|
|
16
17
|
/** Directories the agent may write to (besides the repo). */
|
|
17
18
|
allowDirs: string[];
|
|
19
|
+
/** Directories the agent may read besides the repo and its writable ones, but must not write (the run's handoff notes, for everyone but the lead). */
|
|
20
|
+
readDirs: string[];
|
|
18
21
|
/** Individual files the agent may write (outside allowDirs). */
|
|
19
22
|
allowFiles: string[];
|
|
20
23
|
/** Files/dirs that must stay unwritable (checked even inside the repo). */
|
|
@@ -29,6 +32,12 @@ export declare function inboxDir(project: ResolvedProject, agent: string): strin
|
|
|
29
32
|
/** The repo's own CLAUDE.md/AGENTS.md: in the lead's worktree when it has one (concurrent runs), else in the repo. */
|
|
30
33
|
export declare function repoInstructionFiles(project: ResolvedProject): string[];
|
|
31
34
|
export declare function memoryDirs(a: ResolvedAgent): string[];
|
|
35
|
+
/** Folder name of a run's handoff notes inside `task-memory/<run-id>/`. Agent names start with a letter or digit, so it cannot be an agent's own folder. */
|
|
36
|
+
export declare const SHARED_DIR = "_shared";
|
|
37
|
+
/** The run's handoff folder: read by every agent, written only by the lead. Undefined outside a run and for `call`. */
|
|
38
|
+
export declare function sharedDir(project: ResolvedProject): string | undefined;
|
|
39
|
+
/** The index of the handoff notes: the one file shown in prompts, and the one the guard watches. */
|
|
40
|
+
export declare function sharedIndex(project: ResolvedProject): string | undefined;
|
|
32
41
|
/** The repo directory this agent works in: its own worktree, or the main repo. */
|
|
33
42
|
export declare function repoDirFor(project: ResolvedProject, agent: string): string;
|
|
34
43
|
export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
|
package/dist/policy.js
CHANGED
|
@@ -19,6 +19,19 @@ export function repoInstructionFiles(project) {
|
|
|
19
19
|
export function memoryDirs(a) {
|
|
20
20
|
return [a.memory.global, a.memory.project, a.memory.task].filter((x) => !!x);
|
|
21
21
|
}
|
|
22
|
+
/** Folder name of a run's handoff notes inside `task-memory/<run-id>/`. Agent names start with a letter or digit, so it cannot be an agent's own folder. */
|
|
23
|
+
export const SHARED_DIR = "_shared";
|
|
24
|
+
/** The run's handoff folder: read by every agent, written only by the lead. Undefined outside a run and for `call`. */
|
|
25
|
+
export function sharedDir(project) {
|
|
26
|
+
if (!project.run || project.solo === "call")
|
|
27
|
+
return undefined;
|
|
28
|
+
return path.join(project.paths.taskMemory, project.run.id, SHARED_DIR);
|
|
29
|
+
}
|
|
30
|
+
/** The index of the handoff notes: the one file shown in prompts, and the one the guard watches. */
|
|
31
|
+
export function sharedIndex(project) {
|
|
32
|
+
const dir = sharedDir(project);
|
|
33
|
+
return dir ? path.join(dir, "MEMORY.md") : undefined;
|
|
34
|
+
}
|
|
22
35
|
const RUN_ENTRIES = ["state.json", "log.jsonl", "log.1.jsonl", "result.md", "task.md", "snapshots", "violations", "agents", "mail"];
|
|
23
36
|
const MAIL_ENTRIES = ["inbox", "claims", "attempts", "journal.jsonl"];
|
|
24
37
|
function entries(dir) {
|
|
@@ -54,10 +67,20 @@ export function writePolicy(project, agent) {
|
|
|
54
67
|
const isLead = agent.name === project.lead && !project.solo;
|
|
55
68
|
const ws = project.workspaces?.[agent.name];
|
|
56
69
|
// A `call` has no mailboxes at all; a solo member of a run keeps its outbox to send `done`.
|
|
57
|
-
const
|
|
70
|
+
const shared = sharedDir(project);
|
|
71
|
+
const sharedIdx = sharedIndex(project);
|
|
72
|
+
const mayWriteShared = !!shared && agent.name === project.lead;
|
|
73
|
+
const allowDirs = [
|
|
74
|
+
...memoryDirs(agent),
|
|
75
|
+
...(project.solo === "call" ? [] : [outboxDir(project, agent.name)]),
|
|
76
|
+
...(ws ? [ws.gitDir] : []),
|
|
77
|
+
...(shared && mayWriteShared ? [shared] : []),
|
|
78
|
+
];
|
|
58
79
|
const allowFiles = [];
|
|
59
80
|
if (isLead)
|
|
60
81
|
allowFiles.push(commonFile(project));
|
|
82
|
+
if (mayWriteShared && sharedIdx)
|
|
83
|
+
allowFiles.push(sharedIdx);
|
|
61
84
|
if (agent.canEditAgentMd) {
|
|
62
85
|
for (const a of Object.values(project.agents))
|
|
63
86
|
allowFiles.push(a.agentMd);
|
|
@@ -81,6 +104,8 @@ export function writePolicy(project, agent) {
|
|
|
81
104
|
addDeny(commonFile(project));
|
|
82
105
|
for (const f of repoInstructionFiles(project))
|
|
83
106
|
addDeny(f);
|
|
107
|
+
if (shared && !mayWriteShared)
|
|
108
|
+
deny.push(shared);
|
|
84
109
|
deny.push(...runDenyPaths(project), project.paths.config, path.join(project.home, "team.yaml"));
|
|
85
110
|
// In a worktree: other agents' checkouts are off limits, and so are this checkout's own copies of the protected files.
|
|
86
111
|
for (const [name, other] of Object.entries(project.workspaces ?? {}))
|
|
@@ -95,6 +120,7 @@ export function writePolicy(project, agent) {
|
|
|
95
120
|
deny.push(path.join(ws.root, ".git"), ...["commondir", "config.worktree", "gitdir"].map((f) => path.join(ws.gitDir, f)));
|
|
96
121
|
return {
|
|
97
122
|
allowDirs,
|
|
123
|
+
readDirs: shared && !mayWriteShared ? [shared] : [],
|
|
98
124
|
allowFiles,
|
|
99
125
|
deny: [...new Set(deny)],
|
|
100
126
|
owns: agent.owns,
|
|
@@ -118,17 +144,22 @@ export function enforcementFor(project, agent) {
|
|
|
118
144
|
const os = osSandboxAvailable();
|
|
119
145
|
const notes = [];
|
|
120
146
|
const parallel = project.dispatcher.max_parallel > 1;
|
|
147
|
+
const isLeadAgent = agent.name === project.lead;
|
|
121
148
|
let e;
|
|
122
149
|
if (agent.runtime === "claude-code") {
|
|
123
150
|
const lvl = os ? "os" : "tool-rules";
|
|
124
|
-
e = { memory: lvl, agentMd: lvl, otherContext: lvl, repoInstructions: lvl, owns: parallel ? "tool-rules" : "n/a" };
|
|
151
|
+
e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : lvl, repoInstructions: lvl, owns: parallel ? "tool-rules" : "n/a" };
|
|
125
152
|
if (!os)
|
|
126
153
|
notes.push("OS sandbox unavailable (Windows, or Linux without bubblewrap): Bash can bypass Edit rules.");
|
|
154
|
+
if (!isLeadAgent && !os)
|
|
155
|
+
notes.push("Without the OS sandbox Bash can write _shared; only the handoff index (_shared/MEMORY.md) is checked after each wake-up.");
|
|
127
156
|
}
|
|
128
157
|
else {
|
|
129
158
|
const lvl = os ? "os" : "prompt-only";
|
|
130
|
-
e = { memory: lvl, agentMd: lvl, otherContext: lvl, repoInstructions: "post-hoc", owns: parallel ? "prompt-only" : "n/a" };
|
|
159
|
+
e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : os ? "os" : "post-hoc", repoInstructions: "post-hoc", owns: parallel ? "prompt-only" : "n/a" };
|
|
131
160
|
notes.push("Codex cannot forbid single files inside the repo; CLAUDE.md/AGENTS.md changes are detected and reverted after each wake-up.");
|
|
161
|
+
if (!isLeadAgent && !os)
|
|
162
|
+
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.");
|
|
132
163
|
if (agent.canEditAgentMd)
|
|
133
164
|
notes.push("can_edit_agent_md widens Codex writable roots to whole agent directories.");
|
|
134
165
|
}
|
package/dist/prompt.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Message } from "./mailbox.js";
|
|
|
3
3
|
import type { Layer, MemoryFile, MissingPath, ProjectChanges } from "./memory-tidy.js";
|
|
4
4
|
export declare const COMMON_LIMIT: number;
|
|
5
5
|
export declare const MEMORY_INDEX_LIMIT: number;
|
|
6
|
+
export declare const HANDOFF_LIMIT: number;
|
|
6
7
|
export declare const LEAD_BATCH_LIMIT = 5;
|
|
7
8
|
export declare function readPersona(agent: ResolvedAgent): string;
|
|
8
9
|
/** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
|
|
@@ -13,7 +14,7 @@ export declare function buildCallUserPrompt(agent: ResolvedAgent, task: string):
|
|
|
13
14
|
export declare function buildSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
|
|
14
15
|
/** 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. */
|
|
15
16
|
export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
|
|
16
|
-
export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number): string;
|
|
17
|
+
export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number, handoff?: boolean): string;
|
|
17
18
|
export interface TidyContext {
|
|
18
19
|
layer: Layer;
|
|
19
20
|
/** The memory directory being tidied. */
|
package/dist/prompt.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { readFileCapped } from "./fs-util.js";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { REQUIRED_SECTIONS } from "./format.js";
|
|
4
|
-
import { commonFile, memoryDirs, outboxDir, repoDirFor } from "./policy.js";
|
|
5
|
+
import { commonFile, memoryDirs, outboxDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
|
|
5
6
|
function memoryLabel(agent, dir) {
|
|
6
7
|
if (dir === agent.memory.task)
|
|
7
8
|
return "task (this task only, isolated)";
|
|
@@ -9,16 +10,10 @@ function memoryLabel(agent, dir) {
|
|
|
9
10
|
}
|
|
10
11
|
export const COMMON_LIMIT = 8 * 1024;
|
|
11
12
|
export const MEMORY_INDEX_LIMIT = 4 * 1024;
|
|
13
|
+
export const HANDOFF_LIMIT = COMMON_LIMIT;
|
|
12
14
|
export const LEAD_BATCH_LIMIT = 5;
|
|
13
15
|
const BATCHABLE = new Set(["reply", "failure"]);
|
|
14
|
-
|
|
15
|
-
if (!fs.existsSync(file))
|
|
16
|
-
return undefined;
|
|
17
|
-
const buf = fs.readFileSync(file);
|
|
18
|
-
if (buf.length <= limit)
|
|
19
|
-
return { text: buf.toString("utf8"), truncated: false };
|
|
20
|
-
return { text: buf.subarray(0, limit).toString("utf8"), truncated: true };
|
|
21
|
-
}
|
|
16
|
+
const readCapped = readFileCapped;
|
|
22
17
|
/** A PR only needs to say agent-lyceum opened it; no agent or role names. */
|
|
23
18
|
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
19
|
/** 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. */
|
|
@@ -28,6 +23,14 @@ const OPEN_PR_RULE = "Add the frontmatter line `open_pr: true` to `done` only wh
|
|
|
28
23
|
export function readPersona(agent) {
|
|
29
24
|
return fs.readFileSync(agent.agentMd, "utf8").trim();
|
|
30
25
|
}
|
|
26
|
+
/** What the lead (or a solo agent) is told about the handoff notes: they are what a reopened task starts from. */
|
|
27
|
+
function handoffRule(dir) {
|
|
28
|
+
return [
|
|
29
|
+
"",
|
|
30
|
+
"## Handoff for follow-ups",
|
|
31
|
+
`This task may be reopened later with a follow-up request, and the agents woken then start with fresh sessions. Before you send \`done\`, write what they need to carry on into \`${dir}\`: keep a \`MEMORY.md\` index there (one line per entry, pointing at a file) and record the decisions made and why, the interfaces agreed, the current state, what is left and what to do next, and the pitfalls met. Your teammates can read this folder but cannot write it; if a report from a teammate holds something worth keeping, add it yourself. Only the index is shown in prompts, and only the first ${HANDOFF_LIMIT / 1024} KB of it, so keep it short. These notes are about this task only; lessons that outlive it belong in your project or global memory.`,
|
|
32
|
+
];
|
|
33
|
+
}
|
|
31
34
|
/** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
|
|
32
35
|
export function buildSoloSystemPrompt(project, agent) {
|
|
33
36
|
const isCall = project.solo === "call";
|
|
@@ -38,6 +41,9 @@ export function buildSoloSystemPrompt(project, agent) {
|
|
|
38
41
|
if (!isCall) {
|
|
39
42
|
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);
|
|
40
43
|
}
|
|
44
|
+
const shared = sharedDir(project);
|
|
45
|
+
if (!isCall && shared)
|
|
46
|
+
lines.push(...handoffRule(shared));
|
|
41
47
|
lines.push("", "## Memory");
|
|
42
48
|
const mems = memoryDirs(agent);
|
|
43
49
|
if (mems.length === 0)
|
|
@@ -100,7 +106,7 @@ export function buildSystemPrompt(project, agent) {
|
|
|
100
106
|
"</ask>",
|
|
101
107
|
"```",
|
|
102
108
|
"",
|
|
103
|
-
"The answers come back to you as a `reply` mail containing an `<answers>` block. A user answer
|
|
109
|
+
"The answers come back to you as a `reply` mail containing an `<answers>` block. A user answer may be one of your options or any free text of their own (it may start with `other:`). Do not ask what you could decide yourself, and do not ask again for something already answered.",
|
|
104
110
|
]
|
|
105
111
|
: []), ...(isLead
|
|
106
112
|
? [
|
|
@@ -111,7 +117,7 @@ export function buildSystemPrompt(project, agent) {
|
|
|
111
117
|
"Your final `done` mail must also include the full `## Steps` checklist. Tick only steps that were actually done and verified; leave undone ones",
|
|
112
118
|
"(e.g. build, app start, commit) unticked and say so in the mail instead of reporting them as finished.",
|
|
113
119
|
]
|
|
114
|
-
: []), "", "## Memory");
|
|
120
|
+
: []), ...(isLead && sharedDir(project) ? handoffRule(sharedDir(project)) : []), "", "## Memory");
|
|
115
121
|
const mems = memoryDirs(agent);
|
|
116
122
|
if (mems.length === 0)
|
|
117
123
|
lines.push("You have no long-term memory directory.");
|
|
@@ -143,7 +149,7 @@ export function pickMessages(project, agent, queue) {
|
|
|
143
149
|
}
|
|
144
150
|
return run;
|
|
145
151
|
}
|
|
146
|
-
export function buildUserPrompt(project, agent, unread, handleCount = 1) {
|
|
152
|
+
export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff = false) {
|
|
147
153
|
const parts = [];
|
|
148
154
|
const common = readCapped(commonFile(project), COMMON_LIMIT);
|
|
149
155
|
parts.push("# Shared team context (COMMON.md)");
|
|
@@ -155,6 +161,14 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1) {
|
|
|
155
161
|
parts.push(`\n[COMMON.md was truncated at ${COMMON_LIMIT} bytes — the lead should condense it.]`);
|
|
156
162
|
}
|
|
157
163
|
}
|
|
164
|
+
const handoffFile = handoff ? sharedIndex(project) : undefined;
|
|
165
|
+
const notes = handoffFile ? readCapped(handoffFile, HANDOFF_LIMIT) : undefined;
|
|
166
|
+
if (handoffFile && notes?.text.trim()) {
|
|
167
|
+
parts.push("", `# Handoff notes from earlier segments of this task (${handoffFile})`, notes.text.trim());
|
|
168
|
+
if (notes.truncated)
|
|
169
|
+
parts.push(`\n[handoff index truncated at ${HANDOFF_LIMIT} bytes — the lead should condense it.]`);
|
|
170
|
+
parts.push("The other files in that folder are there to read on demand.");
|
|
171
|
+
}
|
|
158
172
|
for (const dir of memoryDirs(agent)) {
|
|
159
173
|
const idx = readCapped(path.join(dir, "MEMORY.md"), MEMORY_INDEX_LIMIT);
|
|
160
174
|
const label = memoryLabel(agent, dir).split(" ")[0];
|
package/dist/run-session.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export declare const MAX_DONE_REJECTIONS = 2;
|
|
|
14
14
|
export declare const MAX_ASK_REJECTIONS = 2;
|
|
15
15
|
export interface RunSummary {
|
|
16
16
|
runId: string;
|
|
17
|
+
/** The run's name; absent on runs from older versions. */
|
|
18
|
+
name?: string;
|
|
17
19
|
runDir: string;
|
|
18
20
|
rounds: number;
|
|
19
21
|
endReason: EndReason;
|
|
@@ -34,12 +36,18 @@ export interface RunSummary {
|
|
|
34
36
|
}
|
|
35
37
|
export interface RunOptions {
|
|
36
38
|
project: ResolvedProject;
|
|
39
|
+
/** Name for a new run: already decided and not used by another run of the project. */
|
|
40
|
+
name?: string;
|
|
37
41
|
/** Run only this member of the project, alone (`run --agent`). A resumed run uses the one recorded in its state. */
|
|
38
42
|
soloAgent?: string;
|
|
39
43
|
/** The task for a new run; omit when resuming. */
|
|
40
44
|
task?: PreparedTask;
|
|
41
45
|
/** Continue this earlier run (same run dir, sessions and counters) instead of starting a new one. */
|
|
42
46
|
resume?: RunState;
|
|
47
|
+
/** Reopen a done run: this text becomes a new task for the lead. Only with `resume` of a run that ended `done`. */
|
|
48
|
+
followup?: {
|
|
49
|
+
text: string;
|
|
50
|
+
};
|
|
43
51
|
runDir: string;
|
|
44
52
|
invoker?: Invoker;
|
|
45
53
|
/** Runs git and gh for the pull request. Tests pass a fake; the default is the real thing. */
|
|
@@ -51,6 +59,8 @@ export interface RunOptions {
|
|
|
51
59
|
export declare class RunSession {
|
|
52
60
|
private readonly opts;
|
|
53
61
|
private readonly resume;
|
|
62
|
+
/** The handoff index as this segment started; the lead is noted when it is the same at `done`. */
|
|
63
|
+
private readonly sharedAtStart;
|
|
54
64
|
private readonly invoke;
|
|
55
65
|
private readonly say;
|
|
56
66
|
private readonly cfg;
|
package/dist/run-session.js
CHANGED
|
@@ -9,11 +9,11 @@ import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, c
|
|
|
9
9
|
import { isInside } from "./paths.js";
|
|
10
10
|
import { createRunLog } from "./run-log.js";
|
|
11
11
|
import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
|
|
12
|
-
import { outboxDir, ownsDirs } from "./policy.js";
|
|
12
|
+
import { outboxDir, ownsDirs, sharedIndex } from "./policy.js";
|
|
13
13
|
import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
14
|
-
import { atomicWrite } from "./fs-util.js";
|
|
15
|
-
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, refusedIntegration, resolveWorkspaceMode, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
16
|
-
import { bindRunProject, newRunId, newRunState, saveRunState } from "./run-store.js";
|
|
14
|
+
import { atomicWrite, readFileCapped } from "./fs-util.js";
|
|
15
|
+
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, resolveWorkspaceMode, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
16
|
+
import { bindRunProject, newRunId, newRunState, roundLimit, saveRunState } from "./run-store.js";
|
|
17
17
|
import { soloProject } from "./solo.js";
|
|
18
18
|
import { taskMessageBody } from "./task.js";
|
|
19
19
|
import { must } from "./assert.js";
|
|
@@ -24,6 +24,14 @@ export const RESULT_FILE = "result.md";
|
|
|
24
24
|
export const MAX_DONE_REJECTIONS = 2;
|
|
25
25
|
/** How many times a malformed `ask` goes back to its sender before the lead is told instead. */
|
|
26
26
|
export const MAX_ASK_REJECTIONS = 2;
|
|
27
|
+
/** How much of the handoff index is compared; the prompts show far less. */
|
|
28
|
+
const INDEX_COMPARE_LIMIT = 1024 * 1024;
|
|
29
|
+
/** The text of a file, "" when it is missing, undefined when there is no such file for this run (no handoff folder). */
|
|
30
|
+
function readIndex(file) {
|
|
31
|
+
if (!file)
|
|
32
|
+
return undefined;
|
|
33
|
+
return readFileCapped(file, INDEX_COMPARE_LIMIT)?.text ?? "";
|
|
34
|
+
}
|
|
27
35
|
/** Prompt for a repeat attempt: the earlier one failed and may have changed things already. */
|
|
28
36
|
function retryPrompt(prompt, error) {
|
|
29
37
|
return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. Its mail was discarded. Check the current state of the repository before redoing the work, and send the mail again.`;
|
|
@@ -31,6 +39,8 @@ function retryPrompt(prompt, error) {
|
|
|
31
39
|
export class RunSession {
|
|
32
40
|
opts;
|
|
33
41
|
resume;
|
|
42
|
+
/** The handoff index as this segment started; the lead is noted when it is the same at `done`. */
|
|
43
|
+
sharedAtStart;
|
|
34
44
|
invoke;
|
|
35
45
|
say;
|
|
36
46
|
cfg;
|
|
@@ -67,6 +77,8 @@ export class RunSession {
|
|
|
67
77
|
const { task, resume, runDir } = opts;
|
|
68
78
|
if (resume?.end_reason === "waiting")
|
|
69
79
|
RunSession.assertAnswered(runDir, resume.run_id);
|
|
80
|
+
if (opts.followup && resume?.end_reason !== "done")
|
|
81
|
+
throw new Error("followup needs a finished (done) run");
|
|
70
82
|
if (!task && !resume)
|
|
71
83
|
throw new Error("runTeam needs a task or a run to resume");
|
|
72
84
|
const invoke = opts.invoker ?? realInvoker;
|
|
@@ -86,10 +98,19 @@ export class RunSession {
|
|
|
86
98
|
assertWorktreeRunnable(project, { fresh: !resume, concurrent });
|
|
87
99
|
// Concurrent runs: the lead works in its own worktree cut from HEAD, so no other run (and not you) shares its files.
|
|
88
100
|
let leadSpace;
|
|
101
|
+
let recut = false;
|
|
89
102
|
if (concurrent && resume) {
|
|
90
103
|
leadSpace = existingAgentWorkspace(project, runId, project.lead);
|
|
91
104
|
if (!leadSpace)
|
|
92
105
|
throw new Error(`Run ${runId} has no worktree for ${project.lead} (was it cleared?), so it cannot be resumed.`);
|
|
106
|
+
if (opts.followup) {
|
|
107
|
+
// A reopened run whose branch you already merged starts on a fresh branch from HEAD (never when work would be lost).
|
|
108
|
+
const r = recutLeadWorkspace(project, runId, project.lead, leadSpace, resume.result_branch?.head);
|
|
109
|
+
leadSpace = r.ws;
|
|
110
|
+
recut = r.recut;
|
|
111
|
+
if (r.recut)
|
|
112
|
+
say(` ${project.lead}'s branch was already merged: a fresh branch ${leadSpace.branch} was cut from HEAD ${leadSpace.base.slice(0, 8)}`);
|
|
113
|
+
}
|
|
93
114
|
}
|
|
94
115
|
ensureProjectDirs(project);
|
|
95
116
|
fs.mkdirSync(runDir, { recursive: true });
|
|
@@ -103,7 +124,7 @@ export class RunSession {
|
|
|
103
124
|
}
|
|
104
125
|
let state;
|
|
105
126
|
if (resume) {
|
|
106
|
-
state = { ...resume, max_rounds: cfg.max_rounds, pid: process.pid, ended_at: undefined, end_reason: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
127
|
+
state = { ...resume, max_rounds: roundLimit(resume, cfg.max_rounds), pid: process.pid, ended_at: undefined, end_reason: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
107
128
|
// A wake that was running when the process died never finished; keep it in the history as interrupted.
|
|
108
129
|
for (const [agent, a] of Object.entries(resume.active ?? {})) {
|
|
109
130
|
state.wakes.push({
|
|
@@ -116,11 +137,24 @@ export class RunSession {
|
|
|
116
137
|
error: "interrupted",
|
|
117
138
|
});
|
|
118
139
|
}
|
|
140
|
+
if (opts.followup) {
|
|
141
|
+
state.reopened = [...(resume.reopened ?? []), { at: new Date().toISOString() }];
|
|
142
|
+
state.round_floor = resume.rounds;
|
|
143
|
+
state.max_rounds = roundLimit(state, cfg.max_rounds);
|
|
144
|
+
state.result_branch = undefined;
|
|
145
|
+
state.done_rejections = 0;
|
|
146
|
+
if (recut && resume.pr_url) {
|
|
147
|
+
// The recorded pull request is the merged branch's: it must not be mistaken for the new segment's.
|
|
148
|
+
state.notes = [...(resume.notes ?? []), `The earlier pull request ${resume.pr_url} belongs to the merged branch; a new one is opened for the follow-up work.`];
|
|
149
|
+
state.pr_url = undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
119
152
|
}
|
|
120
153
|
else {
|
|
121
154
|
const t = taskMessageBody(task);
|
|
122
155
|
state = newRunState({
|
|
123
156
|
run_id: runId,
|
|
157
|
+
name: opts.name,
|
|
124
158
|
project: project.name,
|
|
125
159
|
task_source: task.source,
|
|
126
160
|
task_path: task.sourcePath,
|
|
@@ -143,6 +177,26 @@ export class RunSession {
|
|
|
143
177
|
const guard = new ProtectedGuard(leadSpace ? { ...project, workspaces: { ...project.workspaces, [project.lead]: leadSpace } } : project);
|
|
144
178
|
if (resume) {
|
|
145
179
|
log("resume", { rounds: state.rounds, interrupted: Object.keys(resume.active ?? {}) });
|
|
180
|
+
if (opts.followup) {
|
|
181
|
+
const n = state.reopened?.length ?? 1;
|
|
182
|
+
fs.mkdirSync(path.join(runDir, "followups"), { recursive: true });
|
|
183
|
+
fs.writeFileSync(path.join(runDir, "followups", `${n}.md`), `${opts.followup.text.trimEnd()}\n`);
|
|
184
|
+
// A run finished before done-<n>.md existed has only result.md: keep that report before the next done overwrites it.
|
|
185
|
+
const prev = path.join(runDir, `done-${n}.md`);
|
|
186
|
+
const legacy = path.join(runDir, RESULT_FILE);
|
|
187
|
+
if (!fs.existsSync(prev) && fs.existsSync(legacy))
|
|
188
|
+
fs.copyFileSync(legacy, prev);
|
|
189
|
+
const handoffIdx = sharedIndex(project);
|
|
190
|
+
const handoffLine = handoffIdx && fs.existsSync(handoffIdx) ? ` The handoff notes written for follow-ups are in \`${handoffIdx}\`.` : "";
|
|
191
|
+
deliver(project, {
|
|
192
|
+
from: "user",
|
|
193
|
+
to: project.lead,
|
|
194
|
+
type: "task",
|
|
195
|
+
subject: opts.followup.text.split("\n").find((l) => l.trim())?.slice(0, 80) ?? "Follow-up",
|
|
196
|
+
body: `## Goal\n${opts.followup.text.trimEnd()}\n\n## Acceptance criteria\nAs stated in the goal; the earlier result still holds unless the goal changes it.\n\n## Scope\nThis run's earlier work and files.\n\n## Upstream\nThe previous final report is in \`${path.join(runDir, `done-${n}.md`)}\`; the original task is in \`${path.join(runDir, "task.md")}\`.${handoffLine}\n`,
|
|
197
|
+
});
|
|
198
|
+
log("reopen", { n });
|
|
199
|
+
}
|
|
146
200
|
}
|
|
147
201
|
else {
|
|
148
202
|
const t = taskMessageBody(task);
|
|
@@ -165,6 +219,8 @@ export class RunSession {
|
|
|
165
219
|
this.log = log;
|
|
166
220
|
this.guard = guard;
|
|
167
221
|
this.state = state;
|
|
222
|
+
// Only a segment that starts here has a baseline: a fresh run or a follow-up reopen. A plain resume continues one begun by an earlier process.
|
|
223
|
+
this.sharedAtStart = !resume || opts.followup ? readIndex(sharedIndex(project)) : undefined;
|
|
168
224
|
}
|
|
169
225
|
/** A waiting run only continues once every unanswered question has a valid answer. */
|
|
170
226
|
static assertAnswered(runDir, runId) {
|
|
@@ -241,19 +297,19 @@ export class RunSession {
|
|
|
241
297
|
workDir,
|
|
242
298
|
workspace: spaces[agent.name],
|
|
243
299
|
systemPrompt: buildSystemPrompt(aproject, agent),
|
|
244
|
-
userPrompt: buildUserPrompt(project, agent, queue, unread.length),
|
|
300
|
+
userPrompt: buildUserPrompt(project, agent, queue, unread.length, (state.reopened?.length ?? 0) > 0),
|
|
245
301
|
timeoutSec: cfg.wake_timeout_sec,
|
|
246
302
|
};
|
|
247
303
|
let result;
|
|
248
304
|
for (let attempt = 0; attempt <= cfg.retry; attempt++) {
|
|
249
|
-
if (state.rounds >=
|
|
305
|
+
if (state.rounds >= state.max_rounds) {
|
|
250
306
|
result = { ok: false, text: "", exitCode: null, timedOut: false, error: "round limit reached" };
|
|
251
307
|
break;
|
|
252
308
|
}
|
|
253
309
|
const round = ++state.rounds;
|
|
254
310
|
const started = Date.now();
|
|
255
311
|
const att = beginAttempt(runDir, claim.id);
|
|
256
|
-
say(`[round ${state.rounds}/${
|
|
312
|
+
say(`[round ${state.rounds}/${state.max_rounds}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
|
|
257
313
|
const sessionId = agent.resume ? state.sessions[agent.name] : undefined;
|
|
258
314
|
const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
|
|
259
315
|
state.active[agent.name] = { round, since: new Date(started).toISOString(), handling };
|
|
@@ -466,13 +522,22 @@ export class RunSession {
|
|
|
466
522
|
log("rejected", r);
|
|
467
523
|
}
|
|
468
524
|
if (route.done) {
|
|
525
|
+
const handoffIdx = sharedIndex(project);
|
|
526
|
+
const nowShared = handoffIdx && this.sharedAtStart !== undefined ? readIndex(handoffIdx) : undefined;
|
|
527
|
+
if (nowShared !== undefined && nowShared === this.sharedAtStart) {
|
|
528
|
+
this.note(nowShared.trim()
|
|
529
|
+
? `shared-not-updated: the lead did not change the handoff notes (${handoffIdx}) in this segment; a follow-up will see them as they are.`
|
|
530
|
+
: `shared-not-updated: the lead did not write handoff notes (${handoffIdx}) in this segment, so a follow-up will start without handoff notes.`);
|
|
531
|
+
}
|
|
469
532
|
this.doneMessage = { subject: route.done.subject, body: route.done.body };
|
|
470
533
|
this.openPrRequested = route.done.meta.open_pr === true;
|
|
471
534
|
const resolved = resolveDoneOutcome(must(contract, "completion contract of the done report"), state.done_rejections ?? 0, state.blocked_integrations ?? []);
|
|
472
535
|
this.doneOutcome = resolved.outcome;
|
|
473
536
|
this.doneNote = resolved.note;
|
|
474
537
|
this.doneVerification = resolved.verification;
|
|
475
|
-
|
|
538
|
+
const report = `# ${route.done.subject}\n\n**Outcome:** ${this.doneOutcome}${this.doneNote ? ` (${this.doneNote})` : ""}\n\n${route.done.body.trimEnd()}\n`;
|
|
539
|
+
atomicWrite(path.join(runDir, RESULT_FILE), report);
|
|
540
|
+
atomicWrite(path.join(runDir, `done-${(state.reopened?.length ?? 0) + 1}.md`), report);
|
|
476
541
|
this.pendingDoneFile = route.done.file;
|
|
477
542
|
log("done", { subject: route.done.subject });
|
|
478
543
|
this.endReason = "done";
|
|
@@ -604,6 +669,7 @@ export class RunSession {
|
|
|
604
669
|
return skip(`the work could not be committed on ${this.leadSpace.branch}.`);
|
|
605
670
|
if (!rb.files)
|
|
606
671
|
return skip(`${rb.branch} has no file changes.`);
|
|
672
|
+
const existingUrl = state.pr_url;
|
|
607
673
|
const result = openPullRequest({
|
|
608
674
|
// Never the lead's worktree: the agent can write it (and its git pointers), and git would run what it planted there.
|
|
609
675
|
cwd: toplevel(project.dir),
|
|
@@ -612,11 +678,14 @@ export class RunSession {
|
|
|
612
678
|
body: prBody(this.doneMessage?.body ?? ""),
|
|
613
679
|
bodyFile: path.join(runDir, "pr-body.md"),
|
|
614
680
|
run: this.prRunner,
|
|
681
|
+
existingUrl,
|
|
615
682
|
});
|
|
616
683
|
log("pull-request", { ...result });
|
|
617
684
|
if (result.status === "opened") {
|
|
685
|
+
say(` pull request ${result.updated ? "updated" : "opened"}: ${result.url}`);
|
|
686
|
+
if (existingUrl && !result.updated && !state.notes?.some((n) => n.includes(existingUrl)))
|
|
687
|
+
this.note(`The earlier pull request ${existingUrl} was no longer open; a new one was opened for the follow-up work.`);
|
|
618
688
|
state.pr_url = result.url;
|
|
619
|
-
say(` pull request opened: ${result.url}`);
|
|
620
689
|
}
|
|
621
690
|
else {
|
|
622
691
|
this.note(`The pull request could not be opened (${result.step}: ${result.reason}). The branch ${rb.branch} is kept.${result.manual.length ? ` To finish by hand:\n${result.manual.map((m) => ` ${m}`).join("\n")}` : ""}`);
|
|
@@ -655,7 +724,7 @@ export class RunSession {
|
|
|
655
724
|
async run() {
|
|
656
725
|
if (this.resume)
|
|
657
726
|
this.recover();
|
|
658
|
-
const { say,
|
|
727
|
+
const { say, runId, runDir, project, wsMode, log, state, opts } = this;
|
|
659
728
|
// When the lead was the last one woken and left every mailbox empty without a `done`, remind it once instead of ending silently.
|
|
660
729
|
let leadWasLast = false;
|
|
661
730
|
let nudged = false;
|
|
@@ -682,7 +751,7 @@ export class RunSession {
|
|
|
682
751
|
this.endReason = "idle";
|
|
683
752
|
break;
|
|
684
753
|
}
|
|
685
|
-
if (state.rounds >=
|
|
754
|
+
if (state.rounds >= state.max_rounds) {
|
|
686
755
|
this.endReason = "max_rounds";
|
|
687
756
|
break;
|
|
688
757
|
}
|
|
@@ -744,6 +813,6 @@ export class RunSession {
|
|
|
744
813
|
this.saveState();
|
|
745
814
|
if (this.pendingDoneFile)
|
|
746
815
|
finishDone(this.pendingDoneFile);
|
|
747
|
-
return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr };
|
|
816
|
+
return { runId, name: state.name, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr };
|
|
748
817
|
}
|
|
749
818
|
}
|
package/dist/run-store.d.ts
CHANGED
|
@@ -99,6 +99,14 @@ export interface RunState {
|
|
|
99
99
|
wakes: WakeRecord[];
|
|
100
100
|
/** Checklist progress: seeded from the task, then replaced by the lead's latest `## Steps`. */
|
|
101
101
|
steps?: Step[];
|
|
102
|
+
/** Readable name; lets `resume`, `clear` and `status` find the run without its id. Absent on runs from older versions. */
|
|
103
|
+
name?: string;
|
|
104
|
+
/** One entry per time a finished run was reopened with a follow-up. */
|
|
105
|
+
reopened?: {
|
|
106
|
+
at: string;
|
|
107
|
+
}[];
|
|
108
|
+
/** `rounds` at the last reopen; the round limit counts from here. */
|
|
109
|
+
round_floor?: number;
|
|
102
110
|
}
|
|
103
111
|
/** `20261006T010203456Z-<128 random bits>`: sorts by start time and cannot collide, even for two starts in one millisecond. */
|
|
104
112
|
export declare function newRunId(d?: Date): string;
|
|
@@ -122,3 +130,26 @@ export declare function outcomeOf(s: Pick<RunState, "outcome" | "end_reason">):
|
|
|
122
130
|
outcome: RunOutcome;
|
|
123
131
|
verified: boolean;
|
|
124
132
|
} | undefined;
|
|
133
|
+
export declare function slugify(text: string): string;
|
|
134
|
+
/** `base`, or `base-2`, `base-3` … the first that is not in `taken`. */
|
|
135
|
+
export declare function uniqueName(base: string, taken: string[]): string;
|
|
136
|
+
/** How a run is named in output: `name (run_id)`, or just the id for a run without a name. */
|
|
137
|
+
export declare const runLabel: (r: {
|
|
138
|
+
name?: string;
|
|
139
|
+
run_id: string;
|
|
140
|
+
}) => string;
|
|
141
|
+
export type RunRef = {
|
|
142
|
+
dir: string;
|
|
143
|
+
state: RunState;
|
|
144
|
+
};
|
|
145
|
+
export type Resolved = {
|
|
146
|
+
ok: true;
|
|
147
|
+
run: RunRef;
|
|
148
|
+
} | {
|
|
149
|
+
ok: false;
|
|
150
|
+
reason: "none" | "ambiguous";
|
|
151
|
+
candidates: RunRef[];
|
|
152
|
+
};
|
|
153
|
+
/** Find a run by full id, exact name, or a prefix of either that matches exactly one run. An exact id or name always wins. */
|
|
154
|
+
export declare function resolveRun(runs: RunRef[], query: string): Resolved;
|
|
155
|
+
export declare const roundLimit: (state: Pick<RunState, "round_floor">, maxRounds: number) => number;
|
package/dist/run-store.js
CHANGED
|
@@ -39,6 +39,9 @@ const WireState = z
|
|
|
39
39
|
wakes: z.array(z.any()).optional(),
|
|
40
40
|
solo_agent: z.string().optional(),
|
|
41
41
|
steps: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
|
|
42
|
+
name: z.string().optional(),
|
|
43
|
+
reopened: z.array(z.object({ at: z.string() })).optional(),
|
|
44
|
+
round_floor: nonNeg.optional(),
|
|
42
45
|
})
|
|
43
46
|
.passthrough();
|
|
44
47
|
/** A fresh v2 state; every collection present. */
|
|
@@ -119,3 +122,38 @@ export function outcomeOf(s) {
|
|
|
119
122
|
return undefined;
|
|
120
123
|
return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : s.end_reason === "waiting" ? "waiting" : "partial", verified: false };
|
|
121
124
|
}
|
|
125
|
+
const NAME_MAX = 30;
|
|
126
|
+
export function slugify(text) {
|
|
127
|
+
const s = text
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
130
|
+
.replace(/^-+|-+$/g, "")
|
|
131
|
+
.slice(0, NAME_MAX)
|
|
132
|
+
.replace(/-+$/g, "");
|
|
133
|
+
return s || "task";
|
|
134
|
+
}
|
|
135
|
+
/** `base`, or `base-2`, `base-3` … the first that is not in `taken`. */
|
|
136
|
+
export function uniqueName(base, taken) {
|
|
137
|
+
if (!taken.includes(base))
|
|
138
|
+
return base;
|
|
139
|
+
for (let n = 2;; n++) {
|
|
140
|
+
const suffix = `-${n}`;
|
|
141
|
+
// The result stays within the 30 characters of a slug: trim the base, then any "-" the trim leaves at its end.
|
|
142
|
+
const candidate = `${base.slice(0, Math.max(1, NAME_MAX - suffix.length)).replace(/-+$/g, "")}${suffix}`;
|
|
143
|
+
if (!taken.includes(candidate))
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** How a run is named in output: `name (run_id)`, or just the id for a run without a name. */
|
|
148
|
+
export const runLabel = (r) => (r.name ? `${r.name} (${r.run_id})` : r.run_id);
|
|
149
|
+
/** Find a run by full id, exact name, or a prefix of either that matches exactly one run. An exact id or name always wins. */
|
|
150
|
+
export function resolveRun(runs, query) {
|
|
151
|
+
const exact = runs.filter((r) => r.state.run_id === query || r.state.name === query);
|
|
152
|
+
if (exact.length === 1)
|
|
153
|
+
return { ok: true, run: exact[0] };
|
|
154
|
+
const pool = exact.length ? exact : runs.filter((r) => r.state.run_id.startsWith(query) || (r.state.name ?? "").startsWith(query));
|
|
155
|
+
if (pool.length === 1)
|
|
156
|
+
return { ok: true, run: pool[0] };
|
|
157
|
+
return { ok: false, reason: pool.length ? "ambiguous" : "none", candidates: pool };
|
|
158
|
+
}
|
|
159
|
+
export const roundLimit = (state, maxRounds) => (state.round_floor ?? 0) + maxRounds;
|
package/dist/status.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export declare function latestUnfinishedRun(project: ResolvedProject): {
|
|
|
11
11
|
dir: string;
|
|
12
12
|
state: RunState;
|
|
13
13
|
} | undefined;
|
|
14
|
+
/** Runs a follow-up text can reopen: the finished ones (end_reason "done"). Newest first. */
|
|
15
|
+
export declare function reopenCandidates(project: ResolvedProject): {
|
|
16
|
+
dir: string;
|
|
17
|
+
state: RunState;
|
|
18
|
+
}[];
|
|
14
19
|
export declare function listRuns(project: ResolvedProject, limit: number): {
|
|
15
20
|
dir: string;
|
|
16
21
|
state: RunState;
|
|
@@ -44,6 +49,8 @@ export interface QueueItem {
|
|
|
44
49
|
}
|
|
45
50
|
export interface RunReport {
|
|
46
51
|
run_id: string;
|
|
52
|
+
/** Absent on runs from older versions. */
|
|
53
|
+
name?: string;
|
|
47
54
|
dir: string;
|
|
48
55
|
/** running: its dispatcher is alive; interrupted: it died mid-run; ended: it stopped (see end_reason). */
|
|
49
56
|
state: "running" | "interrupted" | "ended" | "waiting";
|