| 1 |
1 |
import { spawn } from "node:child_process"; |
| 2 |
2 |
import { createHash } from "node:crypto"; |
|
3 |
+import { statSync } from "node:fs"; |
| 3 |
4 |
import { |
| 4 |
5 |
appendFile, |
| 5 |
6 |
chmod, |
| 17 |
18 |
import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; |
| 18 |
19 |
import type { ArtifactKind, RunEventInput, RunManifest, StageKey } from "@autoscholar/schemas"; |
| 19 |
20 |
import { MetricsFile } from "@autoscholar/schemas"; |
| 20 |
|
−import { syncArtifacts, writeChecksums } from "./artifacts.js"; |
|
21 |
+import { MAX_ARTIFACT_BYTES, syncArtifacts, writeChecksums } from "./artifacts.js"; |
| 21 |
22 |
import { buildLatex, listFiles, renderPages } from "./latex.js"; |
| 22 |
23 |
import { |
| 23 |
24 |
createPolicyHook, |
| 568 |
569 |
}; |
| 569 |
570 |
child.stdout.on("data", cap); |
| 570 |
571 |
child.stderr.on("data", cap); |
| 571 |
|
− const timer = setTimeout(() => child.kill("SIGKILL"), 40 * 60_000); |
|
572 |
+ const timer = setTimeout(() => child.kill("SIGKILL"), 90 * 60_000); |
| 572 |
573 |
child.on("close", (code) => { |
| 573 |
574 |
clearTimeout(timer); |
| 574 |
575 |
resolve({ code, log }); |
| 627 |
628 |
}; |
| 628 |
629 |
}; |
| 629 |
630 |
const total = this.job.manifest.maxBudgetUsd; |
|
631 |
+ if (startIndex >= STAGE_SPECS.length) { |
|
632 |
+ const r = await this.qaRevision(); |
|
633 |
+ if (r) return finish(r.outcome, r.error); |
|
634 |
+ } |
| 630 |
635 |
for (let i = startIndex; i < STAGE_SPECS.length; i++) { |
| 631 |
636 |
const spec = STAGE_SPECS[i]!; |
| 632 |
637 |
if (this.host.isCancelled()) return finish("cancelled", "cancellation requested"); |
| 812 |
817 |
return finish("completed"); |
| 813 |
818 |
} |
| 814 |
819 |
|
| 815 |
|
− private async finalize(outcome: RunOutcome["outcome"], error?: string) { |
|
820 |
+ /** |
|
821 |
+ * Retry after `quality_failed`: fetch the server's QA report, run one revision session that addresses the hard |
|
822 |
+ * failures (same rules as the reproduction stage), rebuild, and let finalize/QA re-run. Returns an outcome only on failure. |
|
823 |
+ */ |
|
824 |
+ private async qaRevision(): Promise<{ outcome: RunOutcome["outcome"]; error?: string } | null> { |
|
825 |
+ const spec = STAGE_SPECS.find((s) => s.key === "reproduction")!; |
|
826 |
+ let report: { hardFailures?: string[]; warnings?: string[] } | null = null; |
| 816 |
827 |
try { |
| 817 |
|
− const summary = { |
| 818 |
|
− runId: this.job.runId, |
| 819 |
|
− attemptNo: this.job.attemptNo, |
| 820 |
|
− researchMode: this.job.manifest.researchMode, |
| 821 |
|
− stagesCompleted: this.completed, |
| 822 |
|
− costUsd: Number(this.spent.toFixed(4)), |
| 823 |
|
− turns: this.turns, |
| 824 |
|
− model: this.job.manifest.model, |
| 825 |
|
− reviewerModel: this.job.manifest.reviewerModel, |
| 826 |
|
− workerName: this.host.workerName, |
| 827 |
|
− startedAt: new Date(this.started).toISOString(), |
| 828 |
|
− endedAt: new Date().toISOString(), |
| 829 |
|
− sdkVersion: this.host.sdkVersion, |
| 830 |
|
− toolchain: this.host.toolchain, |
| 831 |
|
− synthetic: false, |
| 832 |
|
− outcome, |
| 833 |
|
− error: error ?? null, |
| 834 |
|
− }; |
| 835 |
|
− await mkdir(join(this.ws, "provenance"), { recursive: true }); |
| 836 |
|
− await writeFile(join(this.ws, "provenance/run-summary.json"), `${JSON.stringify(summary, null, 2)}\n`); |
| 837 |
|
− await rm(join(this.ws, "_repro"), { recursive: true, force: true }); |
| 838 |
|
− await writeChecksums(this.ws); |
| 839 |
|
− await syncArtifacts(this.ws, "reproduction", this.host.upload, (k, m, d) => this.emit(k, m, d)); |
|
828 |
+ const list = (await this.host.fetchArtifacts?.()) ?? []; |
|
829 |
+ const qa = list.find((a) => a.path === "review/qa-report.json"); |
|
830 |
+ if (qa) { |
|
831 |
+ const buf = await qa.read(); |
|
832 |
+ await mkdir(join(this.ws, "review"), { recursive: true }); |
|
833 |
+ await writeFile(join(this.ws, "review/qa-report.json"), buf); |
|
834 |
+ report = JSON.parse(buf.toString("utf8")); |
|
835 |
+ } |
|
836 |
+ } catch (e) { |
| 840 |
837 |
this.emit( |
| 841 |
|
− "run.finalized", |
| 842 |
|
− `run ${outcome}${error ? `: ${error}` : ""} — $${this.spent.toFixed(3)}, ${this.turns} turns, ${this.completed.length} stages`, |
| 843 |
|
− { outcome, costUsd: this.spent, turns: this.turns }, |
| 844 |
|
− outcome === "completed" ? "info" : "warn", |
|
838 |
+ "qa.revision", |
|
839 |
+ `could not fetch the QA report: ${(e as Error).message}`, |
|
840 |
+ undefined, |
|
841 |
+ "warn", |
|
842 |
+ "reproduction", |
| 845 |
843 |
); |
| 846 |
|
− } catch (e) { |
| 847 |
|
− this.emit("run.finalize_error", (e as Error).message, undefined, "error"); |
| 848 |
844 |
} |
|
845 |
+ const hard = report?.hardFailures ?? []; |
|
846 |
+ if (hard.length === 0) { |
|
847 |
+ this.emit( |
|
848 |
+ "qa.revision", |
|
849 |
+ "no QA report with hard failures; re-finalizing only", |
|
850 |
+ undefined, |
|
851 |
+ "info", |
|
852 |
+ "reproduction", |
|
853 |
+ ); |
|
854 |
+ return null; |
|
855 |
+ } |
|
856 |
+ if (this.remainingBudget() < 2) |
|
857 |
+ return { outcome: "budget_exceeded", error: "no budget left for the QA revision" }; |
|
858 |
+ await this.host.stage({ stage: "reproduction", status: "running", costUsd: 0, turns: 0 }); |
|
859 |
+ this.emit( |
|
860 |
+ "qa.revision", |
|
861 |
+ `revision pass for ${hard.length} failed hard gate(s)`, |
|
862 |
+ { hardFailures: hard }, |
|
863 |
+ "info", |
|
864 |
+ "reproduction", |
|
865 |
+ ); |
|
866 |
+ const prompt = `${spec.prompt(this.job.manifest, await this.stageContext())} |
|
867 |
+ |
|
868 |
+# QUALITY GATES FAILED — revision pass |
|
869 |
+The platform's deterministic publication gates rejected the previous attempt. Fix the causes in the artifacts (never by weakening claims you cannot support; downgrade or remove unsupported claims instead), then rebuild with latex_build. Failed hard gates: |
|
870 |
+- ${hard.join("\n- ")} |
|
871 |
+Soft warnings: ${(report?.warnings ?? []).join(" | ") || "none"}. |
|
872 |
+Contract reminders: results/metrics.json must be {"metrics": {...}, "reportedValues": [...]} (register every reported number with record_reported_value); results/experiment-log.jsonl entries need "name", "command", "startedAt", "endedAt", "exitCode"; code/reproduce.sh must finish within 60 minutes (put long certificate re-checks behind an optional flag and say so in the paper); review/review-1.json must keep every finding with a resolution.`; |
|
873 |
+ const res = await this.session(spec, prompt, { |
|
874 |
+ maxBudgetUsd: Math.min(this.remainingBudget() * 0.6, 20), |
|
875 |
+ maxTurns: 120, |
|
876 |
+ timeoutMs: 60 * 60_000, |
|
877 |
+ }); |
|
878 |
+ if (res.subtype.startsWith("aborted:cancelled")) |
|
879 |
+ return { outcome: "cancelled", error: "cancelled during QA revision" }; |
|
880 |
+ const b = await buildLatex(join(this.ws, "manuscript"), { |
|
881 |
+ tectonicBin: this.host.tectonicBin, |
|
882 |
+ cacheDir: this.host.tectonicCache, |
|
883 |
+ }); |
|
884 |
+ this.emit( |
|
885 |
+ "latex.build", |
|
886 |
+ b.ok ? `revision build ok: ${b.pages} pages` : "revision build failed", |
|
887 |
+ { ok: b.ok, pages: b.pages }, |
|
888 |
+ b.ok ? "info" : "error", |
|
889 |
+ "reproduction", |
|
890 |
+ ); |
|
891 |
+ const problems = await spec.check(this.ws, this.job.manifest); |
|
892 |
+ await this.syncAndCheckpoint( |
|
893 |
+ "reproduction", |
|
894 |
+ problems.length || !b.ok ? "failed" : "completed", |
|
895 |
+ res.costUsd, |
|
896 |
+ res.turns, |
|
897 |
+ res.usage, |
|
898 |
+ problems.join("; ") || (b.ok ? undefined : "build failed"), |
|
899 |
+ ); |
|
900 |
+ if (!b.ok) return { outcome: "failed", error: "LaTeX build failed after QA revision" }; |
|
901 |
+ if (problems.length) return { outcome: "failed", error: `QA revision: ${problems.join("; ")}` }; |
|
902 |
+ return null; |
|
903 |
+ } |
|
904 |
+ |
|
905 |
+ private async finalize(outcome: RunOutcome["outcome"], error?: string) { |
|
906 |
+ const summary = { |
|
907 |
+ runId: this.job.runId, |
|
908 |
+ attemptNo: this.job.attemptNo, |
|
909 |
+ researchMode: this.job.manifest.researchMode, |
|
910 |
+ stagesCompleted: this.completed, |
|
911 |
+ costUsd: Number(this.spent.toFixed(4)), |
|
912 |
+ turns: this.turns, |
|
913 |
+ model: this.job.manifest.model, |
|
914 |
+ reviewerModel: this.job.manifest.reviewerModel, |
|
915 |
+ workerName: this.host.workerName, |
|
916 |
+ startedAt: new Date(this.started).toISOString(), |
|
917 |
+ endedAt: new Date().toISOString(), |
|
918 |
+ sdkVersion: this.host.sdkVersion, |
|
919 |
+ toolchain: this.host.toolchain, |
|
920 |
+ synthetic: false, |
|
921 |
+ outcome, |
|
922 |
+ error: error ?? null, |
|
923 |
+ }; |
|
924 |
+ const step = async (name: string, fn: () => Promise<unknown>) => { |
|
925 |
+ try { |
|
926 |
+ await fn(); |
|
927 |
+ } catch (e) { |
|
928 |
+ this.emit( |
|
929 |
+ "run.finalize_error", |
|
930 |
+ `${name}: ${(e as Error).message}`.slice(0, 1000), |
|
931 |
+ undefined, |
|
932 |
+ "error", |
|
933 |
+ ); |
|
934 |
+ } |
|
935 |
+ }; |
|
936 |
+ await step("summary", async () => { |
|
937 |
+ await mkdir(join(this.ws, "provenance"), { recursive: true }); |
|
938 |
+ await writeFile(join(this.ws, "provenance/run-summary.json"), `${JSON.stringify(summary, null, 2)}\n`); |
|
939 |
+ }); |
|
940 |
+ await step("cleanup", () => rm(join(this.ws, "_repro"), { recursive: true, force: true })); |
|
941 |
+ await step("checksums", () => writeChecksums(this.ws)); |
|
942 |
+ await step("sync", () => |
|
943 |
+ syncArtifacts(this.ws, "reproduction", this.host.upload, (k, m, d) => this.emit(k, m, d)), |
|
944 |
+ ); |
|
945 |
+ this.emit( |
|
946 |
+ "run.finalized", |
|
947 |
+ `run ${outcome}${error ? `: ${error}` : ""} — $${this.spent.toFixed(3)}, ${this.turns} turns, ${this.completed.length} stages`, |
|
948 |
+ { outcome, costUsd: this.spent, turns: this.turns }, |
|
949 |
+ outcome === "completed" ? "info" : "warn", |
|
950 |
+ ); |
| 849 |
951 |
} |
| 850 |
952 |
} |
| 851 |
953 |
|