agent-lyceum 0.31.1 → 0.32.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 +2 -0
- package/README.zh-TW.md +2 -0
- package/dist/cli.js +1 -25
- package/dist/config.d.ts +11 -1
- package/dist/config.js +8 -1
- package/dist/doctor.js +8 -0
- package/dist/fs-util.d.ts +1 -1
- package/dist/process-runner.d.ts +5 -0
- package/dist/process-runner.js +4 -3
- package/dist/repeat-detect.d.ts +40 -0
- package/dist/repeat-detect.js +121 -0
- package/dist/run-outcome.d.ts +5 -1
- package/dist/run-outcome.js +9 -1
- package/dist/run-print.d.ts +3 -0
- package/dist/run-print.js +31 -0
- package/dist/run-session.d.ts +18 -0
- package/dist/run-session.js +109 -8
- package/dist/run-store.d.ts +29 -0
- package/dist/run-store.js +3 -0
- package/dist/schema.d.ts +34 -0
- package/dist/schema.js +24 -0
- package/dist/status.js +10 -1
- package/dist/verify.d.ts +67 -0
- package/dist/verify.js +184 -0
- package/dist/worktree.d.ts +9 -0
- package/dist/worktree.js +80 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,6 +79,8 @@ Folder layout, every agent field and the rules `doctor` checks: [Configuration](
|
|
|
79
79
|
|
|
80
80
|
Older versions refuse a `team.yaml` or `project.yaml` that contains `model_tiers` (a load error, "Unrecognized key"). Before using an older binary, remove `model_tiers` from every `project.yaml` and from `team.yaml`. Other mail and run state are unaffected: older versions ignore a `tier` line in mail. See [Model tiers](docs/configuration.md#model-tiers). (Spec 24.3.2, 24.13)
|
|
81
81
|
|
|
82
|
+
Likewise, older versions refuse a `project.yaml` that contains `verify` ("Unrecognized key"). Remove the `verify` block before using an older binary. Run state is unaffected. See [Verify](docs/configuration.md#verify). (Spec 27.4)
|
|
83
|
+
|
|
82
84
|
## Topics
|
|
83
85
|
|
|
84
86
|
| Topic | Read |
|
package/README.zh-TW.md
CHANGED
|
@@ -79,6 +79,8 @@ agents:
|
|
|
79
79
|
|
|
80
80
|
舊版程式會拒絕載入含 `model_tiers` 的 `team.yaml` 或 `project.yaml`(載入錯誤,「Unrecognized key」)。改用舊版前,請先從每個 `project.yaml` 與 `team.yaml` 拿掉 `model_tiers`。其他信件與 run 狀態不受影響:舊版會忽略信件中的 `tier` 行。見[模型分級](docs/configuration.zh-TW.md#模型分級)。(spec 24.3.2、24.13)
|
|
81
81
|
|
|
82
|
+
同樣地,舊版程式會拒絕載入含 `verify` 的 `project.yaml`(「Unrecognized key」)。改用舊版前,請先拿掉 `verify` 區塊。run 狀態不受影響。見[驗證](docs/configuration.zh-TW.md#驗證verify)。(spec 27.4)
|
|
83
|
+
|
|
82
84
|
## 主題
|
|
83
85
|
|
|
84
86
|
| 主題 | 閱讀 |
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
|
|
|
14
14
|
import { acquireProjectLock, forceUnlock, inspectRunLock, listRunLocks, listUnreadableRunLocks, LOCK_FILE, lockHolderAlive } from "./project-lock.js";
|
|
15
15
|
import { planWorkspace } from "./worktree.js";
|
|
16
16
|
import { loadRunState, newRunId, outcomeOf, resolveRun, roundLimit, roundsLabel, runLabel, slugify, uniqueName } from "./run-store.js";
|
|
17
|
-
import {
|
|
17
|
+
import { printRun } from "./run-print.js";
|
|
18
18
|
import { exitCodeForOutcome, RUNTIMES } from "./schema.js";
|
|
19
19
|
import { absPath, assertName, NAME_RE, projectPaths, resolveHome } from "./paths.js";
|
|
20
20
|
import { addProject, initHome, removeProject } from "./scaffold.js";
|
|
@@ -192,30 +192,6 @@ function cancelOnSignals() {
|
|
|
192
192
|
},
|
|
193
193
|
};
|
|
194
194
|
}
|
|
195
|
-
function printRun(summary, runDir) {
|
|
196
|
-
console.log(`\nRun ${runLabel({ name: summary.name, run_id: summary.runId })} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
|
|
197
|
-
if (summary.outcomeNote)
|
|
198
|
-
console.log(`Note: ${summary.outcomeNote}`);
|
|
199
|
-
if (summary.failure)
|
|
200
|
-
console.log(failureGuide(summary.project, summary.name ?? summary.runId, summary.failure, runDir));
|
|
201
|
-
if (summary.verification)
|
|
202
|
-
console.log(`Verification (as reported by the lead, not checked by agent-lyceum): ${summary.verification}`);
|
|
203
|
-
if (summary.resultBranch?.files) {
|
|
204
|
-
console.log(`\nThe work is on branch ${summary.resultBranch.branch} (${summary.resultBranch.files} file(s)); your working tree was not touched.\nBring it in with: git merge ${summary.resultBranch.branch}${summary.resultBranch.patch ? `\n(or apply the patch: git apply ${summary.resultBranch.patch})` : ""}`);
|
|
205
|
-
}
|
|
206
|
-
if (summary.pr) {
|
|
207
|
-
if (summary.pr.status === "opened")
|
|
208
|
-
console.log(`\nPull request ${summary.pr.updated ? "updated" : "opened"}: ${summary.pr.url}`);
|
|
209
|
-
else if (summary.pr.status === "skipped")
|
|
210
|
-
console.log(`\nA pull request was asked for but not opened: ${summary.pr.reason}`);
|
|
211
|
-
else
|
|
212
|
-
console.log(`\nThe pull request could not be opened (${summary.pr.step}: ${summary.pr.reason}).${summary.pr.manual.length ? ` To finish by hand:\n${summary.pr.manual.map((m) => ` ${m}`).join("\n")}` : ""}`);
|
|
213
|
-
}
|
|
214
|
-
if (summary.doneMessage) {
|
|
215
|
-
console.log(`\nLead's final message — ${summary.doneMessage.subject}\n\n${summary.doneMessage.body}`);
|
|
216
|
-
console.log(`\nResult saved to: ${path.join(runDir, RESULT_FILE)}`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
195
|
function reportRun(summary, runDir) {
|
|
220
196
|
printRun(summary, runDir);
|
|
221
197
|
process.exit(exitCodeForOutcome(summary.outcome));
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ProjectPaths } from "./paths.js";
|
|
2
2
|
import type { AgentWorkspace } from "./worktree.js";
|
|
3
|
-
import { type DispatcherSettings, type Effort, type FallbackEntry, type SandboxPartialT, type Runtime, type WebLevel } from "./schema.js";
|
|
3
|
+
import { type VerifySettings, type DispatcherSettings, type Effort, type FallbackEntry, type SandboxPartialT, type Runtime, type WebLevel } from "./schema.js";
|
|
4
4
|
export declare class ConfigError extends Error {
|
|
5
5
|
}
|
|
6
6
|
export type Source = "global" | "project" | "default" | "model";
|
|
@@ -64,6 +64,8 @@ export interface ResolvedProject {
|
|
|
64
64
|
agents: Record<string, ResolvedAgent>;
|
|
65
65
|
/** Spec 24: non-empty model ladders per runtime, lowest first (project.yaml replaces team.yaml's per runtime). */
|
|
66
66
|
modelTiers: Partial<Record<Runtime, string[]>>;
|
|
67
|
+
/** Spec 27: dispatcher-run verification commands from project.yaml; absent = off. Read once when the project is loaded. */
|
|
68
|
+
verify?: VerifySettings;
|
|
67
69
|
/** Which file each ladder in `modelTiers` came from. */
|
|
68
70
|
modelTiersFrom: Partial<Record<Runtime, "global" | "project">>;
|
|
69
71
|
paths: ProjectPaths;
|
|
@@ -128,6 +130,14 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
128
130
|
"claude-code"?: string[] | undefined;
|
|
129
131
|
codex?: string[] | undefined;
|
|
130
132
|
} | undefined;
|
|
133
|
+
verify?: {
|
|
134
|
+
max_rejections?: number | undefined;
|
|
135
|
+
commands: {
|
|
136
|
+
name: string;
|
|
137
|
+
run: string;
|
|
138
|
+
timeout_sec?: number | undefined;
|
|
139
|
+
}[];
|
|
140
|
+
} | undefined;
|
|
131
141
|
dispatcher: {
|
|
132
142
|
max_rounds?: number | undefined;
|
|
133
143
|
max_parallel?: unknown;
|
package/dist/config.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { absPath, canonicalPath, escapesParent, expandHome, homePaths, projectPaths, } from "./paths.js";
|
|
5
5
|
import { availableTiers } from "./tiers.js";
|
|
6
|
-
import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, RUNTIMES, } from "./schema.js";
|
|
6
|
+
import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, VERIFY_DEFAULTS, inferRuntime, RUNTIMES, } from "./schema.js";
|
|
7
7
|
export class ConfigError extends Error {
|
|
8
8
|
}
|
|
9
9
|
function readYaml(file, schema, label) {
|
|
@@ -71,6 +71,12 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
71
71
|
modelTiersFrom[rt] = fromProject ? "project" : "global";
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
const verify = p.verify?.commands.length
|
|
75
|
+
? {
|
|
76
|
+
max_rejections: p.verify.max_rejections ?? VERIFY_DEFAULTS.max_rejections,
|
|
77
|
+
commands: p.verify.commands.map((c) => ({ name: c.name, run: c.run, timeout_sec: c.timeout_sec ?? VERIFY_DEFAULTS.timeout_sec })),
|
|
78
|
+
}
|
|
79
|
+
: undefined;
|
|
74
80
|
const agents = {};
|
|
75
81
|
for (const agentName of Object.keys(p.agents)) {
|
|
76
82
|
const gl = resolveLayer(g.agents[agentName], home);
|
|
@@ -201,6 +207,7 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
201
207
|
agents,
|
|
202
208
|
modelTiers,
|
|
203
209
|
modelTiersFrom,
|
|
210
|
+
...(verify ? { verify } : {}),
|
|
204
211
|
paths,
|
|
205
212
|
ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
|
|
206
213
|
};
|
package/dist/doctor.js
CHANGED
|
@@ -158,6 +158,14 @@ export async function diagnoseProject(project, opts = {}) {
|
|
|
158
158
|
? { level: "info", subject: "pr", message: `A run cannot open a pull request (a lead asks for one with \`open_pr: true\`): ${missing.join("; ")}.` }
|
|
159
159
|
: { level: "ok", subject: "pr", message: "`gh` and `origin` found: a run can open a pull request when the lead asks for one." });
|
|
160
160
|
}
|
|
161
|
+
if (project.verify) {
|
|
162
|
+
const cmds = project.verify.commands.map((c) => `${c.name}: ${c.run} (timeout ${c.timeout_sec}s)`).join("; ");
|
|
163
|
+
checks.push({
|
|
164
|
+
level: "warn",
|
|
165
|
+
subject: "verify",
|
|
166
|
+
message: `Before it accepts a lead's \`completed\` done, the dispatcher runs ${project.verify.commands.length} verify command(s), up to ${project.verify.max_rejections} rejection(s): ${cmds}. They run OUTSIDE the agents' sandbox with your permissions and execute code the agents wrote, in the lead's worktree, or directly in ${project.dir} when runs do not use worktrees. Only you can change them (project.yaml).`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
161
169
|
for (const lock of listRunLocks(project.paths.root)) {
|
|
162
170
|
const alive = lockHolderAlive(lock);
|
|
163
171
|
checks.push({
|
package/dist/fs-util.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Atomic write: temp file in the same dir, then rename. */
|
|
2
|
-
export declare function atomicWrite(file: string, content: string): void;
|
|
2
|
+
export declare function atomicWrite(file: string, content: string | Buffer): void;
|
|
3
3
|
/** Is `cmd` an executable file in one of the PATH directories? (No `which`: it may be missing in slim containers.) */
|
|
4
4
|
export declare function findOnPath(cmd: string, pathVar?: string): boolean;
|
|
5
5
|
/**
|
package/dist/process-runner.d.ts
CHANGED
|
@@ -15,10 +15,15 @@ export interface RunnerOptions {
|
|
|
15
15
|
}
|
|
16
16
|
/** Pids below `root` (children, grandchildren, ...) by parent links; catches descendants that left the process group (setsid, daemons). */
|
|
17
17
|
export declare function descendantsOf(root: number): number[];
|
|
18
|
+
/** Make a child visible to `killAllRunning` (also used by verify commands, spec 27). */
|
|
19
|
+
export declare function register(pid: number): void;
|
|
20
|
+
export declare function unregister(pid: number): void;
|
|
18
21
|
/** Sampling interval of the descendant timer (default 2000 ms); for tests. Takes effect for the next timer. */
|
|
19
22
|
export declare function setSamplerIntervalMs(ms: number): void;
|
|
20
23
|
/** Whether the descendant timer is running (for tests). */
|
|
21
24
|
export declare function samplerActive(): boolean;
|
|
25
|
+
/** Signal the runtime's group, the descendants recorded earlier that are still in the process table, and its current descendants. */
|
|
26
|
+
export declare function killTree(pid: number, sig: NodeJS.Signals): void;
|
|
22
27
|
/** How many runtimes are registered as running (for tests). */
|
|
23
28
|
export declare function runningCount(): number;
|
|
24
29
|
/** Synchronously SIGKILL every runtime still running, with the descendants seen when it was started or last checked. Used when agent-lyceum itself is going away. */
|
package/dist/process-runner.js
CHANGED
|
@@ -88,7 +88,8 @@ function sample() {
|
|
|
88
88
|
for (const d of below(kids, pid))
|
|
89
89
|
seen.add(d);
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
/** Make a child visible to `killAllRunning` (also used by verify commands, spec 27). */
|
|
92
|
+
export function register(pid) {
|
|
92
93
|
live.set(pid, new Set());
|
|
93
94
|
if (!exitHook) {
|
|
94
95
|
exitHook = true;
|
|
@@ -99,7 +100,7 @@ function register(pid) {
|
|
|
99
100
|
sampler.unref();
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
|
-
function unregister(pid) {
|
|
103
|
+
export function unregister(pid) {
|
|
103
104
|
live.delete(pid);
|
|
104
105
|
if (!live.size && sampler) {
|
|
105
106
|
clearInterval(sampler);
|
|
@@ -123,7 +124,7 @@ function signalPid(pid, sig, group) {
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
/** Signal the runtime's group, the descendants recorded earlier that are still in the process table, and its current descendants. */
|
|
126
|
-
function killTree(pid, sig) {
|
|
127
|
+
export function killTree(pid, sig) {
|
|
127
128
|
const kids = processTable();
|
|
128
129
|
const seen = live.get(pid) ?? new Set();
|
|
129
130
|
const now = below(kids, pid);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { VerifyCommandRecord, VerifyRecord } from "./run-store.js";
|
|
2
|
+
/** Spec 28: has the same verify failure come back on an unchanged workspace? Pure functions except `fingerprintPaths`. */
|
|
3
|
+
/** How much of a command's raw output is looked at before it is cut into lines. */
|
|
4
|
+
export declare const FINGERPRINT_WINDOW_BYTES: number;
|
|
5
|
+
export declare const FINGERPRINT_LINES = 100;
|
|
6
|
+
/** A literal path and what it is replaced with. */
|
|
7
|
+
export type PathRule = [literal: string, replacement: string];
|
|
8
|
+
/** The paths of this run that should not make two outputs differ: the working directory, the run directory and the temp directory. */
|
|
9
|
+
export declare function fingerprintPaths(cwd: string, runDir: string): PathRule[];
|
|
10
|
+
/** The last FINGERPRINT_LINES lines of `text` with ANSI codes, overwritten progress, clock times, durations and this run's paths removed. */
|
|
11
|
+
export declare function normalizeOutput(text: string, paths: PathRule[]): string;
|
|
12
|
+
/** 16 hex characters over the command name, how it failed, and the normalised tail of its output. */
|
|
13
|
+
export declare function failureFingerprint(c: Pick<VerifyCommandRecord, "name" | "exit" | "timed_out" | "error">, tail: string, paths: PathRule[]): string;
|
|
14
|
+
export type HashRelation = "same" | "changed" | "unknown" | "no-worktree";
|
|
15
|
+
export type RepeatAction = "none" | "label" | "stop" | "limit";
|
|
16
|
+
export interface RepeatComparison {
|
|
17
|
+
previous?: string;
|
|
18
|
+
sameFingerprint: boolean;
|
|
19
|
+
hash: HashRelation;
|
|
20
|
+
}
|
|
21
|
+
/** Compared with the newest record of the last rejected `done` of this segment, never with the current one (a replay adds a second record for the same source). */
|
|
22
|
+
export declare function compareWithLastRejection(a: {
|
|
23
|
+
source: string;
|
|
24
|
+
fingerprint: string;
|
|
25
|
+
hash: string | undefined;
|
|
26
|
+
worktree: boolean;
|
|
27
|
+
records: VerifyRecord[] | undefined;
|
|
28
|
+
rejected: string[] | undefined;
|
|
29
|
+
}): RepeatComparison;
|
|
30
|
+
/** True when this `done` was already stopped as a repeat in this segment (`segment` is the number of reopens so far). */
|
|
31
|
+
export declare function stoppedInSegment(records: VerifyRecord[] | undefined, source: string, segment: number): boolean;
|
|
32
|
+
export declare function repeatAction(cmp: RepeatComparison, s: {
|
|
33
|
+
counted: boolean;
|
|
34
|
+
stoppedBefore: boolean;
|
|
35
|
+
left: number;
|
|
36
|
+
}): RepeatAction;
|
|
37
|
+
/** The label at the top of a rejection mail; the message itself is unchanged. */
|
|
38
|
+
export declare function withRepeatLabel(action: RepeatAction, hash: HashRelation, message: string): string;
|
|
39
|
+
/** The outcome note when a repeated failure on an unchanged workspace is accepted as partial; `failure` is `describeFailure`'s text. */
|
|
40
|
+
export declare function repeatStopNote(name: string, run: string, failure: string, log: string, used: number, max: number): string;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { stripVTControlCharacters } from "node:util";
|
|
5
|
+
/** Spec 28: has the same verify failure come back on an unchanged workspace? Pure functions except `fingerprintPaths`. */
|
|
6
|
+
/** How much of a command's raw output is looked at before it is cut into lines. */
|
|
7
|
+
export const FINGERPRINT_WINDOW_BYTES = 256 * 1024;
|
|
8
|
+
export const FINGERPRINT_LINES = 100;
|
|
9
|
+
const TMP = /(?<![\w./-])(?:\/private)?(?:\/tmp|\/var\/tmp|\/var\/folders\/[^\s/]+\/[^\s/]+\/T)(?:\/[^\s:'"`()[\]<>,;]*)?/g;
|
|
10
|
+
const STAMP = /(?<![\w.:-])\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}(?::?\d{2})?)?(?![\w:])/g;
|
|
11
|
+
const CLOCK = /(?<![\w.:-])\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?(?![\w:])/g;
|
|
12
|
+
const DUR = /(?<![\w./-])(?:\d+h\s?)?(?:\d+m\s?)?\d+(?:\.\d+)?(?:ms|µs|ns|s|secs?|seconds?|mins?|minutes?)(?![\w/-]|\.\w)/g;
|
|
13
|
+
/** The paths of this run that should not make two outputs differ: the working directory, the run directory and the temp directory. */
|
|
14
|
+
export function fingerprintPaths(cwd, runDir) {
|
|
15
|
+
const rules = [];
|
|
16
|
+
const add = (p, to) => {
|
|
17
|
+
if (p && !rules.some(([l]) => l === p))
|
|
18
|
+
rules.push([p, to]);
|
|
19
|
+
};
|
|
20
|
+
const real = (p) => {
|
|
21
|
+
try {
|
|
22
|
+
return fs.realpathSync.native(p);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
for (const [p, to] of [[cwd, "<cwd>"], [runDir, "<run-dir>"], [os.tmpdir(), "/tmp"]]) {
|
|
29
|
+
add(p, to);
|
|
30
|
+
const r = real(p);
|
|
31
|
+
if (r)
|
|
32
|
+
add(r, to);
|
|
33
|
+
}
|
|
34
|
+
return rules;
|
|
35
|
+
}
|
|
36
|
+
const PATH_NEXT = /[\w.-]/;
|
|
37
|
+
/** Replace each literal that is not directly followed by a name character (`/w/x` does not eat `/w/x2` or `/w/x.bak`). */
|
|
38
|
+
function replacePaths(line, paths) {
|
|
39
|
+
let s = line;
|
|
40
|
+
for (const [literal, to] of [...paths].sort((a, b) => b[0].length - a[0].length)) {
|
|
41
|
+
if (!literal || literal === to)
|
|
42
|
+
continue;
|
|
43
|
+
let out = "";
|
|
44
|
+
let from = 0;
|
|
45
|
+
for (;;) {
|
|
46
|
+
const i = s.indexOf(literal, from);
|
|
47
|
+
if (i < 0)
|
|
48
|
+
break;
|
|
49
|
+
const next = s[i + literal.length];
|
|
50
|
+
out += s.slice(from, i);
|
|
51
|
+
if (next !== undefined && PATH_NEXT.test(next))
|
|
52
|
+
out += literal;
|
|
53
|
+
else
|
|
54
|
+
out += to;
|
|
55
|
+
from = i + literal.length;
|
|
56
|
+
}
|
|
57
|
+
s = out + s.slice(from);
|
|
58
|
+
}
|
|
59
|
+
return s;
|
|
60
|
+
}
|
|
61
|
+
/** The last FINGERPRINT_LINES lines of `text` with ANSI codes, overwritten progress, clock times, durations and this run's paths removed. */
|
|
62
|
+
export function normalizeOutput(text, paths) {
|
|
63
|
+
const lines = stripVTControlCharacters(text)
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((l) => {
|
|
66
|
+
const noCr = l.replace(/\r+$/, "");
|
|
67
|
+
return noCr.slice(noCr.lastIndexOf("\r") + 1).trimEnd();
|
|
68
|
+
});
|
|
69
|
+
while (lines.length && lines[lines.length - 1] === "")
|
|
70
|
+
lines.pop();
|
|
71
|
+
return lines
|
|
72
|
+
.slice(-FINGERPRINT_LINES)
|
|
73
|
+
.map((l) => replacePaths(l, paths).replace(TMP, "<tmp>").replace(STAMP, "<time>").replace(CLOCK, "<time>").replace(DUR, "<duration>"))
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
/** 16 hex characters over the command name, how it failed, and the normalised tail of its output. */
|
|
77
|
+
export function failureFingerprint(c, tail, paths) {
|
|
78
|
+
const status = c.error ? `error:${normalizeOutput(c.error, paths)}` : c.timed_out ? "timeout" : c.exit === null ? "signal" : `exit:${c.exit}`;
|
|
79
|
+
return createHash("sha256").update(`${c.name}\0${status}\0${normalizeOutput(tail, paths)}`).digest("hex").slice(0, 16);
|
|
80
|
+
}
|
|
81
|
+
/** Compared with the newest record of the last rejected `done` of this segment, never with the current one (a replay adds a second record for the same source). */
|
|
82
|
+
export function compareWithLastRejection(a) {
|
|
83
|
+
const records = a.records ?? [];
|
|
84
|
+
let last;
|
|
85
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
86
|
+
const r = records[i];
|
|
87
|
+
if (r.source !== a.source && (a.rejected ?? []).includes(r.source)) {
|
|
88
|
+
last = r;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const prev = last?.commands.find((c) => typeof c.fingerprint === "string")?.fingerprint;
|
|
93
|
+
const hash = !a.worktree ? "no-worktree" : a.hash === undefined || last?.diff_hash === undefined ? "unknown" : a.hash === last.diff_hash ? "same" : "changed";
|
|
94
|
+
return { ...(last ? { previous: last.source } : {}), sameFingerprint: prev !== undefined && prev === a.fingerprint, hash };
|
|
95
|
+
}
|
|
96
|
+
/** True when this `done` was already stopped as a repeat in this segment (`segment` is the number of reopens so far). */
|
|
97
|
+
export function stoppedInSegment(records, source, segment) {
|
|
98
|
+
return (records ?? []).some((r) => r.source === source && r.repeat_stop_segment === segment);
|
|
99
|
+
}
|
|
100
|
+
export function repeatAction(cmp, s) {
|
|
101
|
+
if (s.counted)
|
|
102
|
+
return cmp.sameFingerprint ? "label" : "none";
|
|
103
|
+
if (s.stoppedBefore)
|
|
104
|
+
return "stop";
|
|
105
|
+
if (s.left <= 0)
|
|
106
|
+
return "limit";
|
|
107
|
+
if (!cmp.sameFingerprint)
|
|
108
|
+
return "none";
|
|
109
|
+
return cmp.hash === "same" ? "stop" : "label";
|
|
110
|
+
}
|
|
111
|
+
/** The label at the top of a rejection mail; the message itself is unchanged. */
|
|
112
|
+
export function withRepeatLabel(action, hash, message) {
|
|
113
|
+
if (action !== "label")
|
|
114
|
+
return message;
|
|
115
|
+
const changed = hash === "changed" ? " The workspace changed since then, but this result did not." : "";
|
|
116
|
+
return `**Same failure as your last rejected \`done\`** (same command, same exit status, same last lines of output; times and paths ignored).${changed}\n\n${message}`;
|
|
117
|
+
}
|
|
118
|
+
/** The outcome note when a repeated failure on an unchanged workspace is accepted as partial; `failure` is `describeFailure`'s text. */
|
|
119
|
+
export function repeatStopNote(name, run, failure, log, used, max) {
|
|
120
|
+
return `Verification stopped early: \`${name}\` (\`${run}\`) ${failure}; this is the same failure as the last rejected attempt and the workspace has not changed since, so no progress is likely and the \`done\` was not sent back again (${used} of ${max} rejection(s) used); see ${log}. Reported as partial; the lead's report is kept as written.`;
|
|
121
|
+
}
|
package/dist/run-outcome.d.ts
CHANGED
|
@@ -7,7 +7,11 @@ export interface UnintegratedWork {
|
|
|
7
7
|
report: string;
|
|
8
8
|
}
|
|
9
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[]
|
|
10
|
+
export declare function resolveDoneOutcome(contract: DoneContract, rejections: number, unintegrated: UnintegratedWork[],
|
|
11
|
+
/** Spec 27/28: a verify command failed and no rejections were left, or the same failure repeated on an unchanged workspace; `note` says which and how. */
|
|
12
|
+
verifyFailure?: {
|
|
13
|
+
note: string;
|
|
14
|
+
}): {
|
|
11
15
|
outcome: RunOutcome;
|
|
12
16
|
note?: string;
|
|
13
17
|
verification?: string;
|
package/dist/run-outcome.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
/** What a lead's `done` amounts to: its declared outcome, unless the contract is broken or member work never reached the repo. */
|
|
3
|
-
export function resolveDoneOutcome(contract, rejections, unintegrated
|
|
3
|
+
export function resolveDoneOutcome(contract, rejections, unintegrated,
|
|
4
|
+
/** Spec 27/28: a verify command failed and no rejections were left, or the same failure repeated on an unchanged workspace; `note` says which and how. */
|
|
5
|
+
verifyFailure) {
|
|
4
6
|
const ok = contract.missing.length === 0;
|
|
5
7
|
let outcome = ok ? (contract.outcome ?? "partial") : "partial";
|
|
6
8
|
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.`;
|
|
@@ -8,6 +10,12 @@ export function resolveDoneOutcome(contract, rejections, unintegrated) {
|
|
|
8
10
|
outcome = "blocked";
|
|
9
11
|
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(", ")}.`;
|
|
10
12
|
}
|
|
13
|
+
if (verifyFailure) {
|
|
14
|
+
// blocked (work never reached the repo) is the worse verdict and stays; the note names both
|
|
15
|
+
if (outcome === "completed")
|
|
16
|
+
outcome = "partial";
|
|
17
|
+
note = note ? `${note} ${verifyFailure.note}` : verifyFailure.note;
|
|
18
|
+
}
|
|
11
19
|
return { outcome, note, verification: contract.verification };
|
|
12
20
|
}
|
|
13
21
|
/** How a run turned out, from why it stopped (`done` carries the lead's own verdict). */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { RESULT_FILE } from "./dispatcher.js";
|
|
3
|
+
import { failureGuide } from "./run-outcome.js";
|
|
4
|
+
import { runLabel } from "./run-store.js";
|
|
5
|
+
/** What a finished run prints on the terminal. */
|
|
6
|
+
export function printRun(summary, runDir) {
|
|
7
|
+
console.log(`\nRun ${runLabel({ name: summary.name, run_id: summary.runId })} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
|
|
8
|
+
if (summary.outcomeNote)
|
|
9
|
+
console.log(`Note: ${summary.outcomeNote}`);
|
|
10
|
+
if (summary.failure)
|
|
11
|
+
console.log(failureGuide(summary.project, summary.name ?? summary.runId, summary.failure, runDir));
|
|
12
|
+
if (summary.verification)
|
|
13
|
+
console.log(summary.dispatcherVerification ? `Verification (the lead's own report): ${summary.verification}` : `Verification (as reported by the lead, not checked by agent-lyceum): ${summary.verification}`);
|
|
14
|
+
if (summary.dispatcherVerification)
|
|
15
|
+
console.log(`Dispatcher verification (run by agent-lyceum): ${summary.dispatcherVerification.text}`);
|
|
16
|
+
if (summary.resultBranch?.files) {
|
|
17
|
+
console.log(`\nThe work is on branch ${summary.resultBranch.branch} (${summary.resultBranch.files} file(s)); your working tree was not touched.\nBring it in with: git merge ${summary.resultBranch.branch}${summary.resultBranch.patch ? `\n(or apply the patch: git apply ${summary.resultBranch.patch})` : ""}`);
|
|
18
|
+
}
|
|
19
|
+
if (summary.pr) {
|
|
20
|
+
if (summary.pr.status === "opened")
|
|
21
|
+
console.log(`\nPull request ${summary.pr.updated ? "updated" : "opened"}: ${summary.pr.url}`);
|
|
22
|
+
else if (summary.pr.status === "skipped")
|
|
23
|
+
console.log(`\nA pull request was asked for but not opened: ${summary.pr.reason}`);
|
|
24
|
+
else
|
|
25
|
+
console.log(`\nThe pull request could not be opened (${summary.pr.step}: ${summary.pr.reason}).${summary.pr.manual.length ? ` To finish by hand:\n${summary.pr.manual.map((m) => ` ${m}`).join("\n")}` : ""}`);
|
|
26
|
+
}
|
|
27
|
+
if (summary.doneMessage) {
|
|
28
|
+
console.log(`\nLead's final message — ${summary.doneMessage.subject}\n\n${summary.doneMessage.body}`);
|
|
29
|
+
console.log(`\nResult saved to: ${path.join(runDir, RESULT_FILE)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
package/dist/run-session.d.ts
CHANGED
|
@@ -23,6 +23,11 @@ export interface RunSummary {
|
|
|
23
23
|
outcome: RunOutcome;
|
|
24
24
|
outcomeNote?: string;
|
|
25
25
|
verification?: string;
|
|
26
|
+
/** Spec 27: the dispatcher's own verification of this done, when `verify` is configured and ran. */
|
|
27
|
+
dispatcherVerification?: {
|
|
28
|
+
passed: boolean;
|
|
29
|
+
text: string;
|
|
30
|
+
};
|
|
26
31
|
doneMessage?: {
|
|
27
32
|
subject: string;
|
|
28
33
|
body: string;
|
|
@@ -68,6 +73,11 @@ export interface RunOptions {
|
|
|
68
73
|
* shared memory), or undefined. Asked before activation, after an instance is created and when a run is resumed. Tests inject one.
|
|
69
74
|
*/
|
|
70
75
|
instanceCheck?: (role: string, k: number) => string | undefined;
|
|
76
|
+
/** Spec 27: kill grace and pipe wait of verify commands (milliseconds); tests shorten them. */
|
|
77
|
+
verifyTiming?: {
|
|
78
|
+
killGraceMs?: number;
|
|
79
|
+
settleWaitMs?: number;
|
|
80
|
+
};
|
|
71
81
|
}
|
|
72
82
|
export declare class RunSession {
|
|
73
83
|
private readonly opts;
|
|
@@ -107,6 +117,8 @@ export declare class RunSession {
|
|
|
107
117
|
/** Models cooling down after a rate limit or quota error; one table for the run, shared by parallel wake-ups. */
|
|
108
118
|
private readonly cooldowns;
|
|
109
119
|
private doneVerification;
|
|
120
|
+
/** What the dispatcher's own verification of the accepted done came to (spec 27); set only when it ran. */
|
|
121
|
+
private dispatcherVerification;
|
|
110
122
|
/** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
|
|
111
123
|
private openPrRequested;
|
|
112
124
|
private readonly prRunner;
|
|
@@ -162,6 +174,12 @@ export declare class RunSession {
|
|
|
162
174
|
private refuseMember;
|
|
163
175
|
/** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
|
|
164
176
|
private settle;
|
|
177
|
+
/**
|
|
178
|
+
* Run the project's verify commands on a completed `done` (spec 27). `passed`: accept; `exhausted`: accept as partial;
|
|
179
|
+
* `rejected`: sent back to the lead (the done file is set aside); `cancelled`: no verdict, the done file stays in the outbox.
|
|
180
|
+
* The commands are `project.verify` as loaded when this process started (project.yaml is not read again during a run).
|
|
181
|
+
*/
|
|
182
|
+
private verifyDone;
|
|
165
183
|
/** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
|
|
166
184
|
private deliverAnswers;
|
|
167
185
|
/** 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. */
|
package/dist/run-session.js
CHANGED
|
@@ -3,6 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import { invokeWithSandboxChecks } from "./sandbox-invocation.js";
|
|
4
4
|
import { realInvoker } from "./adapters/index.js";
|
|
5
5
|
import { agentForCandidate, candidatesFor, Cooldowns, defaultSleep, deferredAttempts, planAttempts, runWithFallback } from "./fallback.js";
|
|
6
|
+
import { compareWithLastRejection, repeatAction, repeatStopNote, stoppedInSegment, withRepeatLabel } from "./repeat-detect.js";
|
|
7
|
+
import { commandPassed, describeFailure, rejectionMessage, runVerify, verificationSection } from "./verify.js";
|
|
6
8
|
import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./format.js";
|
|
7
9
|
import { ProtectedGuard } from "./guard.js";
|
|
8
10
|
import { listRunLocks, lockHolderAlive } from "./project-lock.js";
|
|
@@ -18,6 +20,7 @@ import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
|
18
20
|
import { collectDrafts, priorFailedAttempts, redoUserPrompt } from "./redo.js";
|
|
19
21
|
import { atomicWrite, readFileCapped } from "./fs-util.js";
|
|
20
22
|
import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
|
|
23
|
+
import { workingTreeHash } from "./worktree.js";
|
|
21
24
|
import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRunState } from "./run-store.js";
|
|
22
25
|
import { soloProject } from "./solo.js";
|
|
23
26
|
import { taskMessageBody } from "./task.js";
|
|
@@ -119,6 +122,8 @@ export class RunSession {
|
|
|
119
122
|
/** Models cooling down after a rate limit or quota error; one table for the run, shared by parallel wake-ups. */
|
|
120
123
|
cooldowns;
|
|
121
124
|
doneVerification;
|
|
125
|
+
/** What the dispatcher's own verification of the accepted done came to (spec 27); set only when it ran. */
|
|
126
|
+
dispatcherVerification;
|
|
122
127
|
/** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
|
|
123
128
|
openPrRequested = false;
|
|
124
129
|
prRunner;
|
|
@@ -213,6 +218,8 @@ export class RunSession {
|
|
|
213
218
|
state.max_rounds = roundLimit(state, cfg.max_rounds);
|
|
214
219
|
state.result_branch = undefined;
|
|
215
220
|
state.done_rejections = 0;
|
|
221
|
+
state.verify_rejections = undefined;
|
|
222
|
+
state.verify_rejected = undefined;
|
|
216
223
|
// The progress so far stays shown: the follow-up's own steps are added after it.
|
|
217
224
|
state.step_base = resume.steps;
|
|
218
225
|
if (recut && resume.pr_url) {
|
|
@@ -862,7 +869,7 @@ export class RunSession {
|
|
|
862
869
|
this.saveState();
|
|
863
870
|
}
|
|
864
871
|
/** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
|
|
865
|
-
settle(batch, results) {
|
|
872
|
+
async settle(batch, results) {
|
|
866
873
|
const { say, runDir, project, log, state, guard, claims, attempts } = this;
|
|
867
874
|
const violations = guard.check(batch);
|
|
868
875
|
for (const v of violations) {
|
|
@@ -913,6 +920,23 @@ export class RunSession {
|
|
|
913
920
|
contract = undefined;
|
|
914
921
|
}
|
|
915
922
|
}
|
|
923
|
+
// Spec 27: a completed done that met the contract is judged by the project's verify commands (the contract came first).
|
|
924
|
+
let verified;
|
|
925
|
+
if (route.done && contract && contract.missing.length === 0 && contract.outcome === "completed" && project.verify) {
|
|
926
|
+
const v = await this.verifyDone(route.done, project.verify);
|
|
927
|
+
if (v.kind === "cancelled") {
|
|
928
|
+
// No verdict: the done stays in the outbox (routed again on resume); the rest of this settle still runs.
|
|
929
|
+
route.done = undefined;
|
|
930
|
+
contract = undefined;
|
|
931
|
+
this.endReason = "cancelled";
|
|
932
|
+
}
|
|
933
|
+
else if (v.kind === "rejected") {
|
|
934
|
+
route.done = undefined;
|
|
935
|
+
contract = undefined;
|
|
936
|
+
}
|
|
937
|
+
else
|
|
938
|
+
verified = v;
|
|
939
|
+
}
|
|
916
940
|
this.noteTierIgnored(route);
|
|
917
941
|
const askAccepted = this.handleAsks(route);
|
|
918
942
|
for (const d of route.delivered)
|
|
@@ -961,11 +985,16 @@ export class RunSession {
|
|
|
961
985
|
}
|
|
962
986
|
this.doneMessage = { subject: route.done.subject, body: route.done.body };
|
|
963
987
|
this.openPrRequested = route.done.meta.open_pr === true;
|
|
964
|
-
const resolved = resolveDoneOutcome(must(contract, "completion contract of the done report"), state.done_rejections ?? 0, state.blocked_integrations ?? []);
|
|
988
|
+
const resolved = resolveDoneOutcome(must(contract, "completion contract of the done report"), state.done_rejections ?? 0, state.blocked_integrations ?? [], verified?.failure);
|
|
965
989
|
this.doneOutcome = resolved.outcome;
|
|
966
990
|
this.doneNote = resolved.note;
|
|
967
991
|
this.doneVerification = resolved.verification;
|
|
968
|
-
|
|
992
|
+
if (verified) {
|
|
993
|
+
const bad = verified.record.commands.find((c) => !commandPassed(c));
|
|
994
|
+
const spec = bad ? project.verify?.commands.find((c) => c.name === bad.name) : undefined;
|
|
995
|
+
this.dispatcherVerification = bad ? { passed: false, text: `FAILED: ${bad.name} (${spec?.run ?? "?"}) ${describeFailure(bad, spec?.timeout_sec)}` } : { passed: true, text: `passed (${verified.record.commands.map((c) => c.name).join(", ")})` };
|
|
996
|
+
}
|
|
997
|
+
const report = `# ${route.done.subject}\n\n**Outcome:** ${this.doneOutcome}${this.doneNote ? ` (${this.doneNote})` : ""}\n\n${route.done.body.trimEnd()}\n${verified ? `\n${verificationSection(verified.record, must(project.verify, "verify settings"), verified.cwd)}` : ""}`;
|
|
969
998
|
atomicWrite(path.join(runDir, RESULT_FILE), report);
|
|
970
999
|
atomicWrite(path.join(runDir, `done-${(state.reopened?.length ?? 0) + 1}.md`), report);
|
|
971
1000
|
this.pendingDoneFile = route.done.file;
|
|
@@ -973,6 +1002,8 @@ export class RunSession {
|
|
|
973
1002
|
this.endReason = "done";
|
|
974
1003
|
return;
|
|
975
1004
|
}
|
|
1005
|
+
if (this.endReason)
|
|
1006
|
+
return; // cancelled while verifying: nothing later may change why the run ended
|
|
976
1007
|
if (askAccepted) {
|
|
977
1008
|
this.endReason = "waiting";
|
|
978
1009
|
return;
|
|
@@ -987,6 +1018,75 @@ export class RunSession {
|
|
|
987
1018
|
return;
|
|
988
1019
|
}
|
|
989
1020
|
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Run the project's verify commands on a completed `done` (spec 27). `passed`: accept; `exhausted`: accept as partial;
|
|
1023
|
+
* `rejected`: sent back to the lead (the done file is set aside); `cancelled`: no verdict, the done file stays in the outbox.
|
|
1024
|
+
* The commands are `project.verify` as loaded when this process started (project.yaml is not read again during a run).
|
|
1025
|
+
*/
|
|
1026
|
+
async verifyDone(done, verify) {
|
|
1027
|
+
const { say, runDir, project, log, state } = this;
|
|
1028
|
+
const cwd = this.leadSpace?.dir ?? project.dir;
|
|
1029
|
+
const source = sourceIdOf(done.from, done.file);
|
|
1030
|
+
// The tree is hashed before any command runs; with the lead's worktree this uses the pinned git, never a bare one.
|
|
1031
|
+
const diffHash = await workingTreeHash(project, this.leadSpace, { signal: this.opts.signal });
|
|
1032
|
+
say(` verifying ${project.lead}'s done: ${verify.commands.map((c) => c.name).join(", ")}`);
|
|
1033
|
+
const out = await runVerify(verify, runDir, state.verify, { cwd, signal: this.opts.signal, ...this.opts.verifyTiming }, {
|
|
1034
|
+
start: (name) => log("verify-start", { name }),
|
|
1035
|
+
end: (c) => log("verify-end", { name: c.name, exit: c.exit, ms: c.ms, timed_out: c.timed_out, ...(c.error ? { error: c.error } : {}) }),
|
|
1036
|
+
});
|
|
1037
|
+
if (out.cancelled) {
|
|
1038
|
+
say(" verification cancelled: no verdict; the done report stays for the next resume");
|
|
1039
|
+
log("verify-cancelled", { source });
|
|
1040
|
+
return { kind: "cancelled" };
|
|
1041
|
+
}
|
|
1042
|
+
const record = { at: new Date().toISOString(), passed: out.passed, source, ...(diffHash ? { diff_hash: diffHash } : {}), commands: out.commands };
|
|
1043
|
+
state.verify = [...(state.verify ?? []), record];
|
|
1044
|
+
if (out.passed)
|
|
1045
|
+
return { kind: "passed", record, cwd };
|
|
1046
|
+
const failed = must(out.failed, "failed verify command");
|
|
1047
|
+
const spec = must(verify.commands.find((c) => c.name === failed.name), "verify command");
|
|
1048
|
+
const counted = state.verify_rejected?.includes(source) ?? false;
|
|
1049
|
+
const used = state.verify_rejections ?? 0;
|
|
1050
|
+
const cmp = compareWithLastRejection({ source, fingerprint: must(failed.fingerprint, "failure fingerprint"), hash: diffHash, worktree: this.leadSpace !== undefined, records: state.verify, rejected: state.verify_rejected });
|
|
1051
|
+
const segment = state.reopened?.length ?? 0;
|
|
1052
|
+
const left = verify.max_rejections - used;
|
|
1053
|
+
const action = repeatAction(cmp, { counted, stoppedBefore: stoppedInSegment(state.verify, source, segment), left });
|
|
1054
|
+
log("verify-repeat", { source, name: failed.name, fingerprint: failed.fingerprint, previous: cmp.previous ?? null, same_fingerprint: cmp.sameFingerprint, hash: cmp.hash, action, rejections_left: left });
|
|
1055
|
+
if (action === "limit") {
|
|
1056
|
+
const note = `Verification failed after ${used} rejection(s): \`${failed.name}\` (\`${spec.run}\`) ${describeFailure(failed, spec.timeout_sec)}; see ${failed.log}. Reported as partial; the lead's report is kept as written.`;
|
|
1057
|
+
log("verify-failed", { name: failed.name, exit: failed.exit });
|
|
1058
|
+
return { kind: "exhausted", record, cwd, failure: { note } };
|
|
1059
|
+
}
|
|
1060
|
+
if (action === "stop") {
|
|
1061
|
+
// The verdict is stored before anything else, so a replay after a crash keeps it even if the command's output differs this time.
|
|
1062
|
+
record.repeat_stop_segment = segment;
|
|
1063
|
+
const note = repeatStopNote(failed.name, spec.run, describeFailure(failed, spec.timeout_sec), failed.log, used, verify.max_rejections);
|
|
1064
|
+
this.saveState();
|
|
1065
|
+
fault("verify:stopped");
|
|
1066
|
+
log("verify-failed", { name: failed.name, exit: failed.exit, repeat: true });
|
|
1067
|
+
say(` done report not sent back: ${failed.name} failed the same way as last time and the workspace has not changed; accepted as partial`);
|
|
1068
|
+
return { kind: "exhausted", record, cwd, failure: { note } };
|
|
1069
|
+
}
|
|
1070
|
+
// Counted once per done file: the count and its source id are saved before the mail goes out, so a replay after a crash neither counts nor sends twice.
|
|
1071
|
+
if (!counted) {
|
|
1072
|
+
state.verify_rejections = used + 1;
|
|
1073
|
+
state.verify_rejected = [...(state.verify_rejected ?? []), source];
|
|
1074
|
+
}
|
|
1075
|
+
this.saveState();
|
|
1076
|
+
fault("verify:counted");
|
|
1077
|
+
deliverOnce(project, this.journal(), `verify-reject:${source}`, {
|
|
1078
|
+
from: "dispatcher",
|
|
1079
|
+
to: project.lead,
|
|
1080
|
+
type: "failure",
|
|
1081
|
+
subject: "Your done report failed verification",
|
|
1082
|
+
body: withRepeatLabel(action, cmp.hash, rejectionMessage(failed, spec.run, out.tails[failed.name] ?? "", spec.timeout_sec, state.verify_rejections ?? used, verify.max_rejections)),
|
|
1083
|
+
});
|
|
1084
|
+
fault("verify:sent");
|
|
1085
|
+
rejectDone(done.file);
|
|
1086
|
+
say(` done report sent back to ${project.lead}: ${failed.name} ${describeFailure(failed, spec.timeout_sec)}`);
|
|
1087
|
+
log("verify-rejected", { name: failed.name, exit: failed.exit, count: state.verify_rejections });
|
|
1088
|
+
return { kind: "rejected" };
|
|
1089
|
+
}
|
|
990
1090
|
/** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
|
|
991
1091
|
deliverAnswers() {
|
|
992
1092
|
const { runDir, project, log } = this;
|
|
@@ -1126,7 +1226,7 @@ export class RunSession {
|
|
|
1126
1226
|
return result;
|
|
1127
1227
|
}
|
|
1128
1228
|
/** On resume: sort out what the interrupted process left behind before anything is woken. */
|
|
1129
|
-
recover() {
|
|
1229
|
+
async recover() {
|
|
1130
1230
|
const { say, runId, runDir, project, wsMode, log } = this;
|
|
1131
1231
|
if (this.resume?.end_reason === "waiting")
|
|
1132
1232
|
this.deliverAnswers();
|
|
@@ -1149,14 +1249,14 @@ export class RunSession {
|
|
|
1149
1249
|
}
|
|
1150
1250
|
for (const c of rec.abandoned)
|
|
1151
1251
|
say(` ${c.agent}'s unfinished wake-up is redone: its ${c.message_ids.length} message(s) stay unread`);
|
|
1152
|
-
this.settle([], []);
|
|
1252
|
+
await this.settle([], []);
|
|
1153
1253
|
// Wake-ups that had finished before the crash: their output is routed now, so their input is consumed.
|
|
1154
1254
|
for (const c of rec.ready)
|
|
1155
1255
|
commitClaim(runDir, c.id);
|
|
1156
1256
|
}
|
|
1157
1257
|
async run() {
|
|
1158
1258
|
if (this.resume)
|
|
1159
|
-
this.recover();
|
|
1259
|
+
await this.recover();
|
|
1160
1260
|
const { say, runId, runDir, wsMode, log, state, opts } = this;
|
|
1161
1261
|
let project = this.project;
|
|
1162
1262
|
// When the lead was the last one woken and left every mailbox empty without a `done`, remind it once instead of ending silently.
|
|
@@ -1241,7 +1341,7 @@ export class RunSession {
|
|
|
1241
1341
|
leadWasLast = awake.some((a) => a.name === project.lead);
|
|
1242
1342
|
if (awake.some((a) => a.name !== project.lead))
|
|
1243
1343
|
nudged = false;
|
|
1244
|
-
this.settle(awake, results);
|
|
1344
|
+
await this.settle(awake, results);
|
|
1245
1345
|
if (!this.endReason && opts.signal?.aborted)
|
|
1246
1346
|
this.endReason = "cancelled";
|
|
1247
1347
|
}
|
|
@@ -1264,8 +1364,9 @@ export class RunSession {
|
|
|
1264
1364
|
}
|
|
1265
1365
|
log("end", { reason: endReason, outcome, rounds: state.rounds });
|
|
1266
1366
|
this.saveState();
|
|
1367
|
+
fault("run:ended");
|
|
1267
1368
|
if (this.pendingDoneFile)
|
|
1268
1369
|
finishDone(this.pendingDoneFile);
|
|
1269
|
-
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, project: project.name, failure: this.failure };
|
|
1370
|
+
return { runId, name: state.name, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, ...(this.dispatcherVerification ? { dispatcherVerification: this.dispatcherVerification } : {}), doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined, resultBranch, pr, project: project.name, failure: this.failure };
|
|
1270
1371
|
}
|
|
1271
1372
|
}
|
package/dist/run-store.d.ts
CHANGED
|
@@ -68,6 +68,29 @@ export interface RunFailure {
|
|
|
68
68
|
attempts: number;
|
|
69
69
|
tried?: TriedModel[];
|
|
70
70
|
}
|
|
71
|
+
/** What one verify command did (spec 27.7). `log` is relative to the run dir. */
|
|
72
|
+
export interface VerifyCommandRecord {
|
|
73
|
+
name: string;
|
|
74
|
+
exit: number | null;
|
|
75
|
+
ms: number;
|
|
76
|
+
timed_out: boolean;
|
|
77
|
+
log: string;
|
|
78
|
+
/** Set when the command could not be started (for example no shell). */
|
|
79
|
+
error?: string;
|
|
80
|
+
/** spec 28: hash of the failure (name, status, normalised last lines); only on a failed command. */
|
|
81
|
+
fingerprint?: string;
|
|
82
|
+
}
|
|
83
|
+
/** One verification of a lead's `done` (spec 27.7). `diff_hash` is the git tree of the lead's workspace when it started; absent outside a git repo. */
|
|
84
|
+
export interface VerifyRecord {
|
|
85
|
+
at: string;
|
|
86
|
+
passed: boolean;
|
|
87
|
+
/** The `done` mail's source id. */
|
|
88
|
+
source: string;
|
|
89
|
+
diff_hash?: string;
|
|
90
|
+
/** spec 28: this done was stopped as a repeat, in this segment (state.reopened length). */
|
|
91
|
+
repeat_stop_segment?: number;
|
|
92
|
+
commands: VerifyCommandRecord[];
|
|
93
|
+
}
|
|
71
94
|
export interface RunState {
|
|
72
95
|
/** 1 for runs written before versioning; 2 for runs with their own mailboxes. */
|
|
73
96
|
schema_version: number;
|
|
@@ -90,6 +113,12 @@ export interface RunState {
|
|
|
90
113
|
verification?: string;
|
|
91
114
|
/** How many `done` mails were sent back because they broke the completion contract. */
|
|
92
115
|
done_rejections?: number;
|
|
116
|
+
/** Spec 27: one record per verification the dispatcher ran, oldest first (kept across reopens). */
|
|
117
|
+
verify?: VerifyRecord[];
|
|
118
|
+
/** Spec 27: `done` reports sent back because a verify command failed, in the current segment (reset at reopen). */
|
|
119
|
+
verify_rejections?: number;
|
|
120
|
+
/** Spec 27: source ids of the `done` files already counted in `verify_rejections`, so a replay does not count twice. */
|
|
121
|
+
verify_rejected?: string[];
|
|
93
122
|
/** How many malformed `ask` mails each agent had sent back (the limit is MAX_ASK_REJECTIONS). */
|
|
94
123
|
ask_rejections?: Record<string, number>;
|
|
95
124
|
/** Recovery remarks (interrupted attempts whose side effects may have happened). */
|
package/dist/run-store.js
CHANGED
|
@@ -52,6 +52,9 @@ const WireState = z
|
|
|
52
52
|
outcome_note: z.string().optional(),
|
|
53
53
|
verification: z.string().optional(),
|
|
54
54
|
done_rejections: nonNeg.optional(),
|
|
55
|
+
verify: z.array(z.any()).optional(),
|
|
56
|
+
verify_rejections: nonNeg.optional(),
|
|
57
|
+
verify_rejected: z.array(z.string()).optional(),
|
|
55
58
|
notes: z.array(z.string()).optional(),
|
|
56
59
|
ask_rejections: z.record(z.string(), nonNeg).optional(),
|
|
57
60
|
sessions: z.record(z.string(), z.string()).optional(),
|
package/dist/schema.d.ts
CHANGED
|
@@ -191,6 +191,32 @@ export declare const GlobalConfig: z.ZodObject<{
|
|
|
191
191
|
owns: z.ZodOptional<z.ZodUnknown>;
|
|
192
192
|
}, z.core.$strict>>>;
|
|
193
193
|
}, z.core.$strict>;
|
|
194
|
+
/** Spec 27: commands the dispatcher runs before it accepts a lead's `completed` done. Only the user writes them (project.yaml). */
|
|
195
|
+
export declare const VerifyCommandPartial: z.ZodObject<{
|
|
196
|
+
name: z.ZodString;
|
|
197
|
+
run: z.ZodString;
|
|
198
|
+
timeout_sec: z.ZodOptional<z.ZodNumber>;
|
|
199
|
+
}, z.core.$strict>;
|
|
200
|
+
export declare const VerifyPartial: z.ZodObject<{
|
|
201
|
+
max_rejections: z.ZodOptional<z.ZodNumber>;
|
|
202
|
+
commands: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
203
|
+
name: z.ZodString;
|
|
204
|
+
run: z.ZodString;
|
|
205
|
+
timeout_sec: z.ZodOptional<z.ZodNumber>;
|
|
206
|
+
}, z.core.$strict>>>;
|
|
207
|
+
}, z.core.$strict>;
|
|
208
|
+
export interface VerifySettings {
|
|
209
|
+
max_rejections: number;
|
|
210
|
+
commands: {
|
|
211
|
+
name: string;
|
|
212
|
+
run: string;
|
|
213
|
+
timeout_sec: number;
|
|
214
|
+
}[];
|
|
215
|
+
}
|
|
216
|
+
export declare const VERIFY_DEFAULTS: {
|
|
217
|
+
readonly max_rejections: 2;
|
|
218
|
+
readonly timeout_sec: 600;
|
|
219
|
+
};
|
|
194
220
|
export declare const ProjectConfig: z.ZodObject<{
|
|
195
221
|
dir: z.ZodString;
|
|
196
222
|
team: z.ZodObject<{
|
|
@@ -200,6 +226,14 @@ export declare const ProjectConfig: z.ZodObject<{
|
|
|
200
226
|
"claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
201
227
|
codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
202
228
|
}, z.core.$strict>>;
|
|
229
|
+
verify: z.ZodOptional<z.ZodObject<{
|
|
230
|
+
max_rejections: z.ZodOptional<z.ZodNumber>;
|
|
231
|
+
commands: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
232
|
+
name: z.ZodString;
|
|
233
|
+
run: z.ZodString;
|
|
234
|
+
timeout_sec: z.ZodOptional<z.ZodNumber>;
|
|
235
|
+
}, z.core.$strict>>>;
|
|
236
|
+
}, z.core.$strict>>;
|
|
203
237
|
dispatcher: z.ZodDefault<z.ZodObject<{
|
|
204
238
|
max_rounds: z.ZodOptional<z.ZodNumber>;
|
|
205
239
|
max_parallel: z.ZodOptional<z.ZodUnknown>;
|
package/dist/schema.js
CHANGED
|
@@ -99,11 +99,35 @@ export const GlobalConfig = z
|
|
|
99
99
|
agents: z.record(z.string(), AgentPartial).default({}),
|
|
100
100
|
})
|
|
101
101
|
.strict();
|
|
102
|
+
/** Spec 27: commands the dispatcher runs before it accepts a lead's `completed` done. Only the user writes them (project.yaml). */
|
|
103
|
+
export const VerifyCommandPartial = z
|
|
104
|
+
.object({
|
|
105
|
+
name: z.string().regex(/^[a-z0-9-]+$/, "must be lowercase letters, digits and '-'"),
|
|
106
|
+
run: z.string().min(1),
|
|
107
|
+
timeout_sec: z.number().int().min(1).max(7200).optional(),
|
|
108
|
+
})
|
|
109
|
+
.strict();
|
|
110
|
+
export const VerifyPartial = z
|
|
111
|
+
.object({
|
|
112
|
+
max_rejections: z.number().int().min(0).max(5).optional(),
|
|
113
|
+
commands: z.array(VerifyCommandPartial).default([]),
|
|
114
|
+
})
|
|
115
|
+
.strict()
|
|
116
|
+
.superRefine((v, ctx) => {
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
v.commands.forEach((c, i) => {
|
|
119
|
+
if (seen.has(c.name))
|
|
120
|
+
ctx.addIssue({ code: "custom", path: ["commands", i, "name"], message: `"${c.name}" is used twice; names must be unique` });
|
|
121
|
+
seen.add(c.name);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
export const VERIFY_DEFAULTS = { max_rejections: 2, timeout_sec: 600 };
|
|
102
125
|
export const ProjectConfig = z
|
|
103
126
|
.object({
|
|
104
127
|
dir: z.string(),
|
|
105
128
|
team: z.object({ lead: z.string() }).strict(),
|
|
106
129
|
model_tiers: ModelTiersPartial.optional(),
|
|
130
|
+
verify: VerifyPartial.optional(),
|
|
107
131
|
dispatcher: DispatcherPartial.default({}),
|
|
108
132
|
agents: z.record(z.string(), AgentPartial).default({}),
|
|
109
133
|
})
|
package/dist/status.js
CHANGED
|
@@ -227,7 +227,7 @@ export function buildRunReport(run, project, opts = {}) {
|
|
|
227
227
|
ask = { path: askReplyPath(run.dir), total: 0, unanswered: 0 };
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
-
|
|
230
|
+
const report = {
|
|
231
231
|
run_id: s.run_id,
|
|
232
232
|
name: s.name,
|
|
233
233
|
dir: run.dir,
|
|
@@ -265,7 +265,13 @@ export function buildRunReport(run, project, opts = {}) {
|
|
|
265
265
|
...(s.instances ? { instances: s.instances.roles } : {}),
|
|
266
266
|
...(ask ? { ask } : {}),
|
|
267
267
|
};
|
|
268
|
+
const lastVerify = s.verify?.at(-1);
|
|
269
|
+
if (lastVerify)
|
|
270
|
+
lastVerifyOf.set(report, lastVerify); // human output only: `status --json` stays as it was
|
|
271
|
+
return report;
|
|
268
272
|
}
|
|
273
|
+
/** The latest dispatcher verification (spec 27), kept off the report so `status --json` is unchanged. */
|
|
274
|
+
const lastVerifyOf = new WeakMap();
|
|
269
275
|
/** A run that ended with a `completed` or `partial` outcome: the only kinds the monitor stops listing (blocked, failed and cancelled stay). */
|
|
270
276
|
export const isFinishedRun = (r) => r.state === "ended" && (r.outcome === "completed" || r.outcome === "partial");
|
|
271
277
|
const projectInfo = (p) => ({ name: p.name, dir: p.dir, lead: p.lead });
|
|
@@ -400,6 +406,9 @@ function summaryBlock(run, now, c) {
|
|
|
400
406
|
if (s.end_reason === "done" && s.result_path && s.result_head.length) {
|
|
401
407
|
lines.push(`${L("結果:")}${s.result_path}`, ...s.result_head.map((l) => c.dim(" " + clip(l, 80))));
|
|
402
408
|
}
|
|
409
|
+
const dv = lastVerifyOf.get(s);
|
|
410
|
+
if (dv)
|
|
411
|
+
lines.push(`${L("dispatcher 驗證:")}${dv.passed ? c.green("通過") : c.red("未通過")}(${dv.commands.map((x) => `${x.name} ${x.timed_out ? "逾時" : x.error ? "無法執行" : `exit ${x.exit}`}`).join("、")};${dv.at})`);
|
|
403
412
|
const lastWake = (s.wakes ?? []).at(-1);
|
|
404
413
|
if (lastWake)
|
|
405
414
|
lines.push(`${L("上一次:")}#${lastWake.round} ${c.cyan(lastWake.agent)} ${okText(c, lastWake.ok)} → ${sentText(lastWake.sent)}`);
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { VerifyCommandRecord, VerifyRecord } from "./run-store.js";
|
|
2
|
+
import type { VerifySettings } from "./schema.js";
|
|
3
|
+
/** Most output kept in a verify log; a longer output keeps its end and says so at the top. */
|
|
4
|
+
export declare const VERIFY_LOG_BYTES: number;
|
|
5
|
+
/** Most output of one command put into the mail to the lead and into the report. */
|
|
6
|
+
export declare const VERIFY_TAIL_BYTES = 4096;
|
|
7
|
+
export declare const VERIFY_DIR = "verify";
|
|
8
|
+
/** The last `max` bytes of `buf` as text, never starting in the middle of a UTF-8 character. */
|
|
9
|
+
export declare function tailText(buf: Buffer, max: number): string;
|
|
10
|
+
export interface RunCommandOptions {
|
|
11
|
+
cwd: string;
|
|
12
|
+
timeoutSec: number;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
/** Called with each chunk of output as it arrives (a readiness barrier for tests). */
|
|
15
|
+
onOutput?: (chunk: Buffer) => void;
|
|
16
|
+
/** Between SIGTERM and SIGKILL. Default 5000. */
|
|
17
|
+
killGraceMs?: number;
|
|
18
|
+
/** How long to wait for the output pipes after the shell is gone. Default 1000. */
|
|
19
|
+
settleWaitMs?: number;
|
|
20
|
+
}
|
|
21
|
+
export type CommandResult = {
|
|
22
|
+
cancelled: true;
|
|
23
|
+
} | {
|
|
24
|
+
cancelled: false;
|
|
25
|
+
exit: number | null;
|
|
26
|
+
ms: number;
|
|
27
|
+
timedOut: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
output: Buffer;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Run one command with `sh -c` in its own process group: stdin closed, output (both streams, in arrival order) kept in memory up to
|
|
33
|
+
* 2 x VERIFY_LOG_BYTES. Timeout and cancel stop the whole tree (SIGTERM, SIGKILL after `killGraceMs`); this settles once, whatever
|
|
34
|
+
* still holds the pipes. The child is registered with the process runner, so `killAllRunning` takes it down when agent-lyceum exits.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runCommand(run: string, opts: RunCommandOptions): Promise<CommandResult>;
|
|
37
|
+
/** The output as it goes into the log file: at most VERIFY_LOG_BYTES, with a first line saying so when the start was cut. */
|
|
38
|
+
export declare function logBody(output: Buffer): Buffer;
|
|
39
|
+
/** The next free log number: above every record and every file already in `verify/`, so no attempt (also not an unrecorded one from a crash) is overwritten. */
|
|
40
|
+
export declare function nextLogNumber(runDir: string, records: VerifyRecord[] | undefined): number;
|
|
41
|
+
export type VerifyOutcome = {
|
|
42
|
+
cancelled: true;
|
|
43
|
+
} | {
|
|
44
|
+
cancelled: false;
|
|
45
|
+
passed: boolean;
|
|
46
|
+
commands: VerifyCommandRecord[];
|
|
47
|
+
tails: Record<string, string>;
|
|
48
|
+
failed?: VerifyCommandRecord;
|
|
49
|
+
};
|
|
50
|
+
export interface VerifyHooks {
|
|
51
|
+
start?: (name: string) => void;
|
|
52
|
+
end?: (c: VerifyCommandRecord) => void;
|
|
53
|
+
}
|
|
54
|
+
/** Run the commands in order, stopping at the first failure (fail-fast). Cancelling gives no verdict. */
|
|
55
|
+
export declare function runVerify(verify: VerifySettings, runDir: string, records: VerifyRecord[] | undefined, opts: {
|
|
56
|
+
cwd: string;
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
killGraceMs?: number;
|
|
59
|
+
settleWaitMs?: number;
|
|
60
|
+
}, hooks?: VerifyHooks): Promise<VerifyOutcome>;
|
|
61
|
+
export declare const commandPassed: (c: VerifyCommandRecord) => boolean;
|
|
62
|
+
/** How a failed command reads: "exit 1", "timed out after 600s", ... */
|
|
63
|
+
export declare function describeFailure(c: VerifyCommandRecord, timeoutSec?: number): string;
|
|
64
|
+
/** `## Dispatcher verification`: appended by the dispatcher after the lead's own report. */
|
|
65
|
+
export declare function verificationSection(rec: VerifyRecord, verify: VerifySettings, cwd: string): string;
|
|
66
|
+
/** The mail that sends a failed `done` back to the lead. */
|
|
67
|
+
export declare function rejectionMessage(failed: VerifyCommandRecord, run: string, tail: string, timeoutSec: number, count: number, max: number): string;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { atomicWrite } from "./fs-util.js";
|
|
5
|
+
import { killTree, register, unregister } from "./process-runner.js";
|
|
6
|
+
import { FINGERPRINT_WINDOW_BYTES, failureFingerprint, fingerprintPaths } from "./repeat-detect.js";
|
|
7
|
+
/** Most output kept in a verify log; a longer output keeps its end and says so at the top. */
|
|
8
|
+
export const VERIFY_LOG_BYTES = 1024 * 1024;
|
|
9
|
+
/** Most output of one command put into the mail to the lead and into the report. */
|
|
10
|
+
export const VERIFY_TAIL_BYTES = 4096;
|
|
11
|
+
export const VERIFY_DIR = "verify";
|
|
12
|
+
/** The last `max` bytes of `buf` as text, never starting in the middle of a UTF-8 character. */
|
|
13
|
+
export function tailText(buf, max) {
|
|
14
|
+
if (buf.length <= max)
|
|
15
|
+
return buf.toString("utf8");
|
|
16
|
+
let start = buf.length - max;
|
|
17
|
+
while (start < buf.length && (buf[start] & 0xc0) === 0x80)
|
|
18
|
+
start++;
|
|
19
|
+
return buf.subarray(start).toString("utf8");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Run one command with `sh -c` in its own process group: stdin closed, output (both streams, in arrival order) kept in memory up to
|
|
23
|
+
* 2 x VERIFY_LOG_BYTES. Timeout and cancel stop the whole tree (SIGTERM, SIGKILL after `killGraceMs`); this settles once, whatever
|
|
24
|
+
* still holds the pipes. The child is registered with the process runner, so `killAllRunning` takes it down when agent-lyceum exits.
|
|
25
|
+
*/
|
|
26
|
+
export function runCommand(run, opts) {
|
|
27
|
+
const grace = opts.killGraceMs ?? 5000;
|
|
28
|
+
const settleWait = opts.settleWaitMs ?? 1000;
|
|
29
|
+
if (opts.signal?.aborted)
|
|
30
|
+
return Promise.resolve({ cancelled: true });
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const started = Date.now();
|
|
33
|
+
let child;
|
|
34
|
+
try {
|
|
35
|
+
child = spawn("sh", ["-c", run], { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
return resolve({ cancelled: false, exit: null, ms: 0, timedOut: false, error: e.message, output: Buffer.alloc(0) });
|
|
39
|
+
}
|
|
40
|
+
if (child.pid !== undefined)
|
|
41
|
+
register(child.pid);
|
|
42
|
+
let buf = Buffer.alloc(0);
|
|
43
|
+
let dropped = 0;
|
|
44
|
+
let exit = null;
|
|
45
|
+
let reason;
|
|
46
|
+
let spawnError;
|
|
47
|
+
let settled = false;
|
|
48
|
+
const timers = [];
|
|
49
|
+
const kill = (sig) => {
|
|
50
|
+
if (child.pid !== undefined)
|
|
51
|
+
killTree(child.pid, sig);
|
|
52
|
+
};
|
|
53
|
+
const finish = () => {
|
|
54
|
+
if (settled)
|
|
55
|
+
return;
|
|
56
|
+
settled = true;
|
|
57
|
+
for (const t of timers)
|
|
58
|
+
clearTimeout(t);
|
|
59
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
60
|
+
kill("SIGKILL"); // whatever of the tree is still around
|
|
61
|
+
if (child.pid !== undefined)
|
|
62
|
+
unregister(child.pid);
|
|
63
|
+
child.stdout?.destroy();
|
|
64
|
+
child.stderr?.destroy();
|
|
65
|
+
// An abort that came after the process was gone but before the result was judged also withholds the verdict.
|
|
66
|
+
if (reason === "cancel" || opts.signal?.aborted)
|
|
67
|
+
return resolve({ cancelled: true });
|
|
68
|
+
const out = dropped ? Buffer.concat([Buffer.from(`[agent-lyceum: ${dropped} byte(s) of earlier output dropped]\n`), buf]) : buf;
|
|
69
|
+
resolve({ cancelled: false, exit, ms: Date.now() - started, timedOut: reason === "timeout", ...(spawnError ? { error: spawnError } : {}), output: out });
|
|
70
|
+
};
|
|
71
|
+
const stop = (why) => {
|
|
72
|
+
if (settled)
|
|
73
|
+
return;
|
|
74
|
+
// Cancel outranks a timeout that is still cleaning up: the verdict is withheld either way, and the tree is already being stopped.
|
|
75
|
+
if (reason) {
|
|
76
|
+
if (why === "cancel")
|
|
77
|
+
reason = "cancel";
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
reason = why;
|
|
81
|
+
kill("SIGTERM");
|
|
82
|
+
timers.push(setTimeout(() => kill("SIGKILL"), grace));
|
|
83
|
+
timers.push(setTimeout(finish, grace + settleWait));
|
|
84
|
+
};
|
|
85
|
+
function onAbort() {
|
|
86
|
+
stop("cancel");
|
|
87
|
+
}
|
|
88
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
89
|
+
timers.push(setTimeout(() => stop("timeout"), opts.timeoutSec * 1000));
|
|
90
|
+
const onData = (chunk) => {
|
|
91
|
+
opts.onOutput?.(chunk);
|
|
92
|
+
buf = Buffer.concat([buf, chunk]);
|
|
93
|
+
if (buf.length > VERIFY_LOG_BYTES * 2) {
|
|
94
|
+
let start = buf.length - VERIFY_LOG_BYTES;
|
|
95
|
+
while (start < buf.length && (buf[start] & 0xc0) === 0x80)
|
|
96
|
+
start++;
|
|
97
|
+
dropped += start;
|
|
98
|
+
buf = buf.subarray(start);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
child.stdout?.on("data", onData);
|
|
102
|
+
child.stderr?.on("data", onData);
|
|
103
|
+
child.on("error", (e) => {
|
|
104
|
+
spawnError = e.code === "ENOENT" ? `${e.message} (sh or the working directory ${opts.cwd} not found)` : e.message;
|
|
105
|
+
finish();
|
|
106
|
+
});
|
|
107
|
+
child.on("exit", (code) => {
|
|
108
|
+
exit = code;
|
|
109
|
+
timers.push(setTimeout(finish, settleWait)); // a descendant may still hold the pipes
|
|
110
|
+
});
|
|
111
|
+
child.on("close", finish);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** The output as it goes into the log file: at most VERIFY_LOG_BYTES, with a first line saying so when the start was cut. */
|
|
115
|
+
export function logBody(output) {
|
|
116
|
+
if (output.length <= VERIFY_LOG_BYTES)
|
|
117
|
+
return output;
|
|
118
|
+
let start = output.length - VERIFY_LOG_BYTES;
|
|
119
|
+
while (start < output.length && (output[start] & 0xc0) === 0x80)
|
|
120
|
+
start++;
|
|
121
|
+
return Buffer.concat([Buffer.from(`[agent-lyceum: output truncated; the first ${start} byte(s) are not kept, the last ${output.length - start} follow]\n`), output.subarray(start)]);
|
|
122
|
+
}
|
|
123
|
+
/** The next free log number: above every record and every file already in `verify/`, so no attempt (also not an unrecorded one from a crash) is overwritten. */
|
|
124
|
+
export function nextLogNumber(runDir, records) {
|
|
125
|
+
let max = 0;
|
|
126
|
+
for (const r of records ?? [])
|
|
127
|
+
for (const c of r.commands)
|
|
128
|
+
max = Math.max(max, Number(/^verify\/(\d+)-/.exec(c.log)?.[1] ?? 0));
|
|
129
|
+
try {
|
|
130
|
+
for (const f of fs.readdirSync(path.join(runDir, VERIFY_DIR)))
|
|
131
|
+
max = Math.max(max, Number(/^(\d+)-/.exec(f)?.[1] ?? 0));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* no verify dir yet */
|
|
135
|
+
}
|
|
136
|
+
return max + 1;
|
|
137
|
+
}
|
|
138
|
+
/** Run the commands in order, stopping at the first failure (fail-fast). Cancelling gives no verdict. */
|
|
139
|
+
export async function runVerify(verify, runDir, records, opts, hooks = {}) {
|
|
140
|
+
const commands = [];
|
|
141
|
+
const tails = {};
|
|
142
|
+
let n = nextLogNumber(runDir, records);
|
|
143
|
+
for (const c of verify.commands) {
|
|
144
|
+
const rel = `${VERIFY_DIR}/${n++}-${c.name}.log`;
|
|
145
|
+
hooks.start?.(c.name);
|
|
146
|
+
const r = await runCommand(c.run, { cwd: opts.cwd, timeoutSec: c.timeout_sec, signal: opts.signal, killGraceMs: opts.killGraceMs, settleWaitMs: opts.settleWaitMs });
|
|
147
|
+
if (r.cancelled)
|
|
148
|
+
return { cancelled: true };
|
|
149
|
+
const body = logBody(r.output);
|
|
150
|
+
fs.mkdirSync(path.join(runDir, VERIFY_DIR), { recursive: true });
|
|
151
|
+
atomicWrite(path.join(runDir, rel), body);
|
|
152
|
+
const rec = { name: c.name, exit: r.exit, ms: r.ms, timed_out: r.timedOut, log: rel, ...(r.error ? { error: r.error } : {}) };
|
|
153
|
+
if (!commandPassed(rec))
|
|
154
|
+
rec.fingerprint = failureFingerprint(rec, tailText(r.output, FINGERPRINT_WINDOW_BYTES), fingerprintPaths(opts.cwd, runDir));
|
|
155
|
+
commands.push(rec);
|
|
156
|
+
tails[c.name] = tailText(r.output, VERIFY_TAIL_BYTES).replace(/\r\n/g, "\n");
|
|
157
|
+
hooks.end?.(rec);
|
|
158
|
+
if (!commandPassed(rec))
|
|
159
|
+
return { cancelled: false, passed: false, commands, tails, failed: rec };
|
|
160
|
+
}
|
|
161
|
+
if (opts.signal?.aborted)
|
|
162
|
+
return { cancelled: true };
|
|
163
|
+
return { cancelled: false, passed: true, commands, tails };
|
|
164
|
+
}
|
|
165
|
+
export const commandPassed = (c) => c.exit === 0 && !c.timed_out && !c.error;
|
|
166
|
+
/** How a failed command reads: "exit 1", "timed out after 600s", ... */
|
|
167
|
+
export function describeFailure(c, timeoutSec) {
|
|
168
|
+
if (c.error)
|
|
169
|
+
return `could not be run (${c.error})`;
|
|
170
|
+
if (c.timed_out)
|
|
171
|
+
return `timed out${timeoutSec ? ` after ${timeoutSec}s` : ""} and was killed`;
|
|
172
|
+
return c.exit === null ? "was killed by a signal" : `exited with code ${c.exit}`;
|
|
173
|
+
}
|
|
174
|
+
const secs = (ms) => `${(ms / 1000).toFixed(1)}s`;
|
|
175
|
+
/** `## Dispatcher verification`: appended by the dispatcher after the lead's own report. */
|
|
176
|
+
export function verificationSection(rec, verify, cwd) {
|
|
177
|
+
const lines = rec.commands.map((c) => `- \`${c.name}\`: ${commandPassed(c) ? "passed" : `FAILED, ${describeFailure(c, verify.commands.find((x) => x.name === c.name)?.timeout_sec)}`} (${secs(c.ms)}, log ${c.log})`);
|
|
178
|
+
const skipped = verify.commands.slice(rec.commands.length).map((c) => `- \`${c.name}\`: not run (an earlier command failed)`);
|
|
179
|
+
return `## Dispatcher verification\n\nRun by the dispatcher in ${cwd}, not by an agent: ${rec.passed ? "all commands passed" : "NOT all commands passed"}.\n\n${[...lines, ...skipped].join("\n")}\n`;
|
|
180
|
+
}
|
|
181
|
+
/** The mail that sends a failed `done` back to the lead. */
|
|
182
|
+
export function rejectionMessage(failed, run, tail, timeoutSec, count, max) {
|
|
183
|
+
return `The run was not ended: before accepting your \`done\` (outcome completed) the dispatcher ran the project's verify commands, and one failed.\n\nCommand \`${failed.name}\` (\`${run}\`) ${describeFailure(failed, timeoutSec)}. Later commands were not run.\n\nLast output (at most ${VERIFY_TAIL_BYTES} bytes; the full log is ${failed.log} in the run directory):\n\n\`\`\`\n${tail.trimEnd()}\n\`\`\`\n\nFix the cause, then send \`done\` again. If the work cannot pass, report \`partial\` or \`blocked\` honestly (those are not verified). (Verification rejection ${count} of ${max}.)`;
|
|
184
|
+
}
|
package/dist/worktree.d.ts
CHANGED
|
@@ -81,6 +81,15 @@ export declare function snapshotHead(project: ResolvedProject, runId: string): s
|
|
|
81
81
|
* `from`: the checkout to snapshot — the lead's worktree in a concurrent run; by default your repo's working tree.
|
|
82
82
|
*/
|
|
83
83
|
export declare function snapshotBase(project: ResolvedProject, runId: string, n: number, from?: AgentWorkspace): string;
|
|
84
|
+
/**
|
|
85
|
+
* The git tree of the working tree as it is now (tracked changes and new, non-ignored files), through a throwaway index:
|
|
86
|
+
* nothing of the repo is touched. A worktree is read with the pinned git (no hooks, fsmonitor, filters from the worktree).
|
|
87
|
+
* Undefined when `dir` is not a git repository or the tree cannot be read (a nested repository in a worktree, ...).
|
|
88
|
+
*/
|
|
89
|
+
export declare function workingTreeHash(project: ResolvedProject, from?: AgentWorkspace, opts?: {
|
|
90
|
+
signal?: AbortSignal;
|
|
91
|
+
timeoutMs?: number;
|
|
92
|
+
}): Promise<string | undefined>;
|
|
84
93
|
export declare const workspaceRoot: (project: ResolvedProject, runId: string, agent: string) => string;
|
|
85
94
|
/**
|
|
86
95
|
* The agent's own checkout of the snapshot `baseRef`, at `<project home>/worktrees/<run>/<agent>`.
|
package/dist/worktree.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
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
7
|
import { escapesParent } from "./paths.js";
|
|
8
|
+
import { killTree, register, unregister } from "./process-runner.js";
|
|
8
9
|
const IDENTITY = {
|
|
9
10
|
GIT_AUTHOR_NAME: "agent-lyceum",
|
|
10
11
|
GIT_AUTHOR_EMAIL: "agent-lyceum@localhost",
|
|
@@ -291,6 +292,84 @@ export function snapshotBase(project, runId, n, from) {
|
|
|
291
292
|
fs.rmSync(index, { force: true });
|
|
292
293
|
}
|
|
293
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* The git tree of the working tree as it is now (tracked changes and new, non-ignored files), through a throwaway index:
|
|
297
|
+
* nothing of the repo is touched. A worktree is read with the pinned git (no hooks, fsmonitor, filters from the worktree).
|
|
298
|
+
* Undefined when `dir` is not a git repository or the tree cannot be read (a nested repository in a worktree, ...).
|
|
299
|
+
*/
|
|
300
|
+
export async function workingTreeHash(project, from, opts = {}) {
|
|
301
|
+
const index = path.join(os.tmpdir(), `agent-lyceum-index-${crypto.randomBytes(6).toString("hex")}`);
|
|
302
|
+
const deadline = Date.now() + (opts.timeoutMs ?? HASH_BUDGET_MS);
|
|
303
|
+
// No hooks (git runs post-index-change after writing an index, also a temporary one) and no fsmonitor, in both modes.
|
|
304
|
+
// Clean filters named in the repo's own config still run during `add -A` (as in snapshotBase); only the deadline bounds them.
|
|
305
|
+
const run = (cwd, args, env = {}) => boundedGit(cwd, from ? pinnedArgs(args) : ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", ...args], from ? pinnedEnv(from, env) : env, deadline, opts.signal);
|
|
306
|
+
try {
|
|
307
|
+
let cwd;
|
|
308
|
+
if (from) {
|
|
309
|
+
assertWorkspaceIntact(from);
|
|
310
|
+
assertNoNestedRepos(from); // sync, no hooks, quick
|
|
311
|
+
cwd = from.root;
|
|
312
|
+
}
|
|
313
|
+
else
|
|
314
|
+
cwd = await run(project.dir, ["rev-parse", "--show-toplevel"]);
|
|
315
|
+
const env = { GIT_INDEX_FILE: index };
|
|
316
|
+
const head = await run(cwd, ["rev-parse", "--verify", "HEAD"]).catch(() => undefined);
|
|
317
|
+
if (head)
|
|
318
|
+
await run(cwd, ["read-tree", head], env);
|
|
319
|
+
await run(cwd, ["add", "-A"], env);
|
|
320
|
+
return await run(cwd, ["write-tree"], env);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
fs.rmSync(index, { force: true });
|
|
327
|
+
fs.rmSync(`${index}.lock`, { force: true }); // a git killed during `add` leaves its lock behind
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** The most the tree hash may take in total (spec 27.7 record, spec 28 comparison); then it is absent and verification goes on. */
|
|
331
|
+
const HASH_BUDGET_MS = 30_000;
|
|
332
|
+
/** git in its own process group, killed with SIGKILL (group and descendants) at `deadline` or on `signal`; the event loop stays free meanwhile. */
|
|
333
|
+
function boundedGit(cwd, args, env, deadline, signal) {
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
if (signal?.aborted)
|
|
336
|
+
return reject(new Error("aborted"));
|
|
337
|
+
const child = spawn("git", args, { cwd, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
338
|
+
if (child.pid !== undefined)
|
|
339
|
+
register(child.pid);
|
|
340
|
+
let out = "";
|
|
341
|
+
let err = "";
|
|
342
|
+
let done = false;
|
|
343
|
+
const end = (e) => {
|
|
344
|
+
if (done)
|
|
345
|
+
return;
|
|
346
|
+
done = true;
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
signal?.removeEventListener("abort", onAbort);
|
|
349
|
+
if (child.pid !== undefined) {
|
|
350
|
+
killTree(child.pid, "SIGKILL"); // also what a filter left behind
|
|
351
|
+
unregister(child.pid);
|
|
352
|
+
}
|
|
353
|
+
child.stdout?.destroy();
|
|
354
|
+
child.stderr?.destroy();
|
|
355
|
+
if (e)
|
|
356
|
+
reject(e);
|
|
357
|
+
else
|
|
358
|
+
resolve(out.replace(/\n$/, ""));
|
|
359
|
+
};
|
|
360
|
+
const onAbort = () => end(new Error("aborted"));
|
|
361
|
+
const timer = setTimeout(() => end(new Error("timed out")), Math.max(0, deadline - Date.now()));
|
|
362
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
363
|
+
child.stdout?.on("data", (c) => {
|
|
364
|
+
out += c.toString("utf8");
|
|
365
|
+
});
|
|
366
|
+
child.stderr?.on("data", (c) => {
|
|
367
|
+
err += c.toString("utf8").slice(0, 4096);
|
|
368
|
+
});
|
|
369
|
+
child.on("error", (e) => end(e));
|
|
370
|
+
child.on("close", (code) => end(code === 0 ? undefined : new Error(`git ${args.join(" ")} failed: ${err.trim()}`)));
|
|
371
|
+
});
|
|
372
|
+
}
|
|
294
373
|
export const workspaceRoot = (project, runId, agent) => path.join(project.paths.root, "worktrees", runId, agent);
|
|
295
374
|
/** The user's repo's git common dir, as a real absolute path. Asked of the user's repo, never of a worktree. */
|
|
296
375
|
const commonDirOf = (top) => fs.realpathSync(git(top, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
|