agent-lyceum 0.31.0 → 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 +3 -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/summarize.d.ts +2 -0
- package/dist/summarize.js +85 -0
- 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/web/dist/public/assets/index-CAW06fSt.css +2 -0
- package/web/dist/public/assets/index-CP0uF27G.js +9 -0
- package/web/dist/public/index.html +2 -2
- package/web/dist/server/brief.js +84 -0
- package/web/dist/server/hub.js +3 -0
- package/web/dist/server/summary.js +92 -0
- package/web/dist/start.js +10 -0
- package/web/dist/public/assets/index-B1Ld6Pl5.css +0 -2
- package/web/dist/public/assets/index-D2JeeQpl.js +0 -9
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)}`);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const TIMEOUT_MS = 90_000;
|
|
6
|
+
const CAP = 200_000;
|
|
7
|
+
/** One local CLI call. The prompt already says what to write; this only runs it. */
|
|
8
|
+
export async function summarizeProgress(runtime, prompt) {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lyceum-summary-"));
|
|
10
|
+
try {
|
|
11
|
+
const text = runtime === "claude" ? await claude(dir, prompt) : await codex(dir, prompt);
|
|
12
|
+
const trimmed = text.trim();
|
|
13
|
+
if (!trimmed)
|
|
14
|
+
throw new Error("沒有寫出摘要。");
|
|
15
|
+
return trimmed;
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function claude(dir, prompt) {
|
|
22
|
+
const settings = path.join(dir, "settings.json");
|
|
23
|
+
fs.writeFileSync(settings, JSON.stringify({
|
|
24
|
+
disableAllHooks: true,
|
|
25
|
+
permissions: { deny: ["Bash", "Edit", "Write", "NotebookEdit", "WebSearch", "WebFetch", "Agent", "Read", "Glob", "Grep"] },
|
|
26
|
+
}));
|
|
27
|
+
const result = await run("claude", ["-p", "--no-session-persistence", "--output-format", "text", "--permission-mode", "dontAsk", "--settings", settings], prompt, dir);
|
|
28
|
+
if (!result.stdout.trim())
|
|
29
|
+
throw new Error(first(result.stderr) || "claude 沒有寫出摘要。");
|
|
30
|
+
return result.stdout;
|
|
31
|
+
}
|
|
32
|
+
async function codex(dir, prompt) {
|
|
33
|
+
const last = path.join(dir, "last.txt");
|
|
34
|
+
const result = await run("codex", ["exec", "-C", dir, "-s", "read-only", "--skip-git-repo-check", "-c", 'approval_policy="never"', "-o", last, "-"], prompt, dir);
|
|
35
|
+
let text = "";
|
|
36
|
+
try {
|
|
37
|
+
text = fs.readFileSync(last, "utf8");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
text = "";
|
|
41
|
+
}
|
|
42
|
+
if (!text.trim())
|
|
43
|
+
text = result.stdout;
|
|
44
|
+
if (!text.trim())
|
|
45
|
+
throw new Error(first(result.stderr) || "codex 沒有寫出摘要。");
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
function first(text) {
|
|
49
|
+
return text.split("\n").map((line) => line.trim()).find(Boolean) ?? "";
|
|
50
|
+
}
|
|
51
|
+
function run(cmd, args, stdin, cwd) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
54
|
+
let stdout = "";
|
|
55
|
+
let stderr = "";
|
|
56
|
+
let settled = false;
|
|
57
|
+
const finish = (error) => {
|
|
58
|
+
if (settled)
|
|
59
|
+
return;
|
|
60
|
+
settled = true;
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
if (error)
|
|
63
|
+
reject(error);
|
|
64
|
+
else
|
|
65
|
+
resolve({ stdout, stderr });
|
|
66
|
+
};
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
child.kill();
|
|
69
|
+
finish(new Error(`${cmd} 超過 ${Math.round(TIMEOUT_MS / 1000)} 秒沒有回應。`));
|
|
70
|
+
}, TIMEOUT_MS);
|
|
71
|
+
child.stdout.on("data", (chunk) => {
|
|
72
|
+
stdout = (stdout + String(chunk)).slice(0, CAP);
|
|
73
|
+
});
|
|
74
|
+
child.stderr.on("data", (chunk) => {
|
|
75
|
+
stderr = (stderr + String(chunk)).slice(0, CAP);
|
|
76
|
+
});
|
|
77
|
+
child.on("error", (error) => {
|
|
78
|
+
const code = error.code;
|
|
79
|
+
finish(code === "ENOENT" ? new Error(`找不到 ${cmd}。`) : error);
|
|
80
|
+
});
|
|
81
|
+
child.on("close", () => finish());
|
|
82
|
+
child.stdin.write(stdin);
|
|
83
|
+
child.stdin.end();
|
|
84
|
+
});
|
|
85
|
+
}
|
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;
|