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.
@@ -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
- const report = `# ${route.done.subject}\n\n**Outcome:** ${this.doneOutcome}${this.doneNote ? ` (${this.doneNote})` : ""}\n\n${route.done.body.trimEnd()}\n`;
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
  }
@@ -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
- return {
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,2 @@
1
+ /** One local CLI call. The prompt already says what to write; this only runs it. */
2
+ export declare function summarizeProgress(runtime: "claude" | "codex", prompt: string): Promise<string>;
@@ -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
+ }
@@ -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;