@workos/quickstudy 0.2.1 → 0.4.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.
@@ -21,7 +21,7 @@
21
21
  * and never allowed to mask the attempt's result.
22
22
  */
23
23
 
24
- import { cp, mkdtemp, rm } from "node:fs/promises";
24
+ import { mkdtemp, rm } from "node:fs/promises";
25
25
  import { tmpdir } from "node:os";
26
26
  import { join } from "node:path";
27
27
  import type {
@@ -33,10 +33,12 @@ import type {
33
33
  import { ClaudeCodeAdapter } from "../adapters/claude.ts";
34
34
  import { CodexAdapter } from "../adapters/codex.ts";
35
35
  import { EchoAdapter } from "../adapters/echo.ts";
36
+ import { OracleAdapter, OracleContainerAdapter } from "../adapters/oracle.ts";
36
37
  import { validEvalResult } from "../evals/result.ts";
37
38
  import { importEvalScorer } from "../evals/discovery.ts";
38
39
  import { deadline, StageTimeoutError } from "./deadline.ts";
39
40
  import { scoreInWorker } from "./score-worker.ts";
41
+ import { prepWorkspaceSource } from "./workspace-source.ts";
40
42
  import type { EvalResult, LoadedEval } from "../evals/types.ts";
41
43
  import type { AgentSpec, Experiment, LoadedExperiment, Runtime } from "../experiments/types.ts";
42
44
  import { canonicalJson } from "../identity-diff.ts";
@@ -70,7 +72,7 @@ import type { AttemptPlan } from "../plan.ts";
70
72
  import type { ProvisionedEnvironment, RuntimeProvisionContext } from "../runtime/types.ts";
71
73
  import { collectProviderKeys, hostSecretEnv, redactSecrets, validateProviderKeys, type ProviderKeyProbe } from "../secrets.ts";
72
74
  import { ArtifactsStore } from "../store/artifacts.ts";
73
- import { activeAttempts, type NewAttempt, type ResultsStore, type RunConfig } from "../store/db.ts";
75
+ import { activeAttempts, type ErrorKind, type NewAttempt, type ResultsStore, type RunConfig } from "../store/db.ts";
74
76
  import { acquireRunLock } from "./run-lock.ts";
75
77
  import { orderedPlan, schedule } from "./scheduler.ts";
76
78
  import { compareGroups } from "../experiments/groups.ts";
@@ -136,17 +138,46 @@ interface AttemptDisposition {
136
138
  status: "completed" | "incomplete" | "error";
137
139
  result?: EvalResult;
138
140
  error?: string;
141
+ /** Which stage failed; set on every `status: "error"` disposition. */
142
+ errorKind?: ErrorKind;
143
+ }
144
+
145
+ /**
146
+ * An error tagged with the attempt stage it was thrown from. Stages wrap
147
+ * their own boundary (`inStage`) so helpers stay unaware of the taxonomy;
148
+ * the message is the cause's, so recorded errors read exactly as before.
149
+ */
150
+ export class StageFailure extends Error {
151
+ constructor(readonly kind: ErrorKind, cause: unknown) {
152
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
153
+ this.name = "StageFailure";
154
+ }
155
+ }
156
+
157
+ /** The kind an error reaching a catch-all carries: its stage, else `harness`. */
158
+ export function errorKindOf(err: unknown): ErrorKind {
159
+ return err instanceof StageFailure ? err.kind : "harness";
160
+ }
161
+
162
+ async function inStage<T>(kind: ErrorKind, work: () => Promise<T>): Promise<T> {
163
+ try {
164
+ return await work();
165
+ } catch (err) {
166
+ throw err instanceof StageFailure ? err : new StageFailure(kind, err);
167
+ }
139
168
  }
140
169
 
141
170
  function defaultHostAdapters(): Record<string, AgentAdapter> {
142
171
  const echo = new EchoAdapter();
143
- return { [echo.name]: echo };
172
+ const oracle = new OracleAdapter();
173
+ return { [echo.name]: echo, [oracle.name]: oracle };
144
174
  }
145
175
 
146
176
  function defaultContainerAdapters(): Record<string, (agent: AgentSpec) => ContainerAgentAdapter> {
147
177
  return {
148
178
  claude: (agent) => new ClaudeCodeAdapter("claude", { model: agent.model, reasoning: agent.reasoning }),
149
179
  codex: (agent) => new CodexAdapter("codex", { model: agent.model, reasoning: agent.reasoning }),
180
+ oracle: () => new OracleContainerAdapter(),
150
181
  };
151
182
  }
152
183
 
@@ -310,6 +341,12 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
310
341
  );
311
342
  }
312
343
  if (experiment.runtime.kind === "container") {
344
+ if ((experiment.runtime.command ?? []).length > 0 && experiment.agent.adapter === "oracle") {
345
+ throw new Error(
346
+ `experiment "${experiment.id}" selects the oracle adapter but sets runtime.command, which bypasses it — ` +
347
+ `drop runtime.command so the reference solution is what gets scored`,
348
+ );
349
+ }
313
350
  if ((experiment.runtime.command ?? []).length === 0 && !containerAgents.has(experiment.id)) {
314
351
  const adapter = resolveContainerAdapter(experiment, containerAdapterFactories);
315
352
  // Provider keys are validated here — before any run row, sidecar, or
@@ -334,6 +371,26 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
334
371
  }
335
372
  }
336
373
 
374
+ // Reference-solution preflight: an adapter that starts from the solution
375
+ // overlay (the oracle) cannot run an eval without solution/. Every
376
+ // offending pair is named at once, before any run row exists.
377
+ const missingSolutions: string[] = [];
378
+ for (const entry of plan) {
379
+ const experiment = experimentsById.get(entry.experimentId) as Experiment;
380
+ const startingState =
381
+ experiment.runtime.kind === "host"
382
+ ? hostAdapters[experiment.agent.adapter]?.startingState
383
+ : containerAgents.get(experiment.id)?.startingState;
384
+ if (startingState === "solution" && (evalsById.get(entry.evalId) as LoadedEval).solutionDir === null) {
385
+ missingSolutions.push(`${entry.experimentId} × ${entry.evalId}`);
386
+ }
387
+ }
388
+ if (missingSolutions.length > 0) {
389
+ throw new Error(
390
+ `the oracle adapter needs solution/ — add it or drop the eval from the run: ${[...new Set(missingSolutions)].join(", ")}`,
391
+ );
392
+ }
393
+
337
394
  // Live key preflight: a key that is present but rejected by the provider
338
395
  // would burn a container per attempt just to 401 inside it. Probe each
339
396
  // unique agent's endpoint once — before any docker, sidecar, or run-row
@@ -507,10 +564,14 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
507
564
  // An attempt failure never aborts the run: persist the error and move on.
508
565
  const message = err instanceof Error ? err.message : String(err);
509
566
  await artifacts.writeError(runId, attemptId, message);
510
- disposition = { status: "error", error: message };
567
+ disposition = { status: "error", error: message, errorKind: errorKindOf(err) };
511
568
  }
512
569
 
513
570
  persisted.status = disposition.status;
571
+ persisted.errorKind = disposition.status === "error" ? (disposition.errorKind ?? "harness") : null;
572
+ // A limit hit means the workspace it left was graded; an unscored
573
+ // attempt (incomplete, or a later stage errored) carries none.
574
+ if (disposition.status !== "completed") persisted.limitHit = null;
514
575
  if (disposition.judgeRecords) persisted.judgeRecords = disposition.judgeRecords;
515
576
  if (disposition.result !== undefined) persisted.result = disposition.result;
516
577
  if (disposition.error !== undefined) persisted.error = disposition.error;
@@ -556,15 +617,6 @@ async function mustExec(
556
617
  return result;
557
618
  }
558
619
 
559
- /** Prepare the attempt's starting state: a copy of `local/`, or empty (tools-only evals). */
560
- async function prepWorkspaceSource(loadedEval: LoadedEval): Promise<string> {
561
- const sourceDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-source-"));
562
- if (loadedEval.localDir !== null) {
563
- await cp(loadedEval.localDir, sourceDir, { recursive: true });
564
- }
565
- return sourceDir;
566
- }
567
-
568
620
  interface AttemptArgs {
569
621
  runId: string;
570
622
  attemptId: string;
@@ -585,10 +637,11 @@ async function runHostAttemptV3(
585
637
  const { runId, attemptId, persisted } = args;
586
638
  const adapter = hostAdapters[experiment.agent.adapter] as AgentAdapter;
587
639
 
588
- const sourceDir = await prepWorkspaceSource(loadedEval);
589
- const scratchDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-attempt-"));
640
+ const sourceDir = await inStage("environment", () => prepWorkspaceSource(loadedEval, adapter.startingState ?? "fixture"));
641
+ const scratchDir = await inStage("environment", () => mkdtemp(join(tmpdir(), "quickstudy-v3-attempt-")));
590
642
  try {
591
- let outcome: AttemptOutcome;
643
+ let outcome: AttemptOutcome | undefined;
644
+ persisted.agentStartedAt = Date.now();
592
645
  try {
593
646
  outcome = await deadline("agent", args.attemptTimeoutMs, (signal) => adapter.runAttempt({
594
647
  signal,
@@ -600,9 +653,19 @@ async function runHostAttemptV3(
600
653
  trialIndex: entry.trialIndex,
601
654
  }));
602
655
  } catch (err) {
603
- if (!(err instanceof StageTimeoutError)) throw err;
604
- await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms`);
605
- return { status: "incomplete", error: `attempt timed out after ${args.attemptTimeoutMs}ms` };
656
+ if (!(err instanceof StageTimeoutError)) throw new StageFailure("agent", err);
657
+ // Graded timeout: the adapter never returned (no transcript, diff, or
658
+ // metrics), but the scratch workspace it was writing into exists — the
659
+ // tree it left behind is exported and scored like any other.
660
+ await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms; workspace graded as left`);
661
+ persisted.limitHit = "timeout";
662
+ } finally {
663
+ persisted.agentFinishedAt = Date.now();
664
+ }
665
+
666
+ if (outcome === undefined) {
667
+ const workspaceDir = await inStage("environment", () => artifacts.exportWorkspace(runId, attemptId, scratchDir));
668
+ return await scoreAttempt(options, loadedEval, experiment, entry, { runId, attemptId, workspaceDir });
606
669
  }
607
670
 
608
671
  persisted.transcriptRef = await artifacts.writeTranscript(runId, attemptId, outcome.transcript);
@@ -614,7 +677,7 @@ async function runHostAttemptV3(
614
677
 
615
678
  // Export FIRST, then score against the export (no live sandbox exists on
616
679
  // the host path — exec runs in the exported workspace directory).
617
- const workspaceDir = await artifacts.exportWorkspace(runId, attemptId, scratchDir);
680
+ const workspaceDir = await inStage("environment", () => artifacts.exportWorkspace(runId, attemptId, scratchDir));
618
681
  return await scoreAttempt(options, loadedEval, experiment, entry, { runId, attemptId, workspaceDir, agentOutput: { finalReport: outcome.transcript.filter((e) => e.role === "assistant").at(-1)?.content ?? null, transcript: JSON.stringify(outcome.transcript).slice(0, 131072), truncated: JSON.stringify(outcome.transcript).length > 131072 } });
619
682
  } finally {
620
683
  await rm(scratchDir, { recursive: true, force: true });
@@ -682,7 +745,7 @@ async function runContainerAttemptV3(
682
745
  const teardownFailure = await attemptRuntimeTeardown(runtime, undefined, provisionCtx, environment?.budgets.cleanupMs ?? options.budgets?.cleanupMs);
683
746
  const error = teardownFailure !== undefined ? `${message}; ${teardownFailure}` : message;
684
747
  await artifacts.writeError(runId, attemptId, error);
685
- return { status: "error", error };
748
+ return { status: "error", error, errorKind: "environment" };
686
749
  }
687
750
  }
688
751
 
@@ -691,19 +754,22 @@ async function runContainerAttemptV3(
691
754
  let disposition: AttemptDisposition;
692
755
  try {
693
756
  disposition = await (async (): Promise<AttemptDisposition> => {
694
- sourceDir = await prepWorkspaceSource(loadedEval);
757
+ sourceDir = await inStage("environment", () => prepWorkspaceSource(loadedEval, agentAdapter?.startingState ?? "fixture"));
695
758
  const resolvedImage = environment?.image;
696
759
  const image = resolvedImage && resolvedImage.id !== "unresolved" ? resolvedImage.id : resolvedImage?.tag ?? runtime.image ?? (runtime.containerImage?.(loadedEval.metadata) as string);
697
760
  if (environment?.egress.proxy) {
698
- const networkName = await createRunNetwork(`${runId}-${attemptId}`);
699
- try {
700
- const sidecar = await startEgressSidecar({ runId, networkName, allowlist: environment.egress.allowlist, image: EGRESS_PROXY_IMAGE });
701
- egress = { networkName, sidecar };
702
- } catch (err) { await removeRunNetwork(networkName); throw err; }
703
- if (treatments.mcp?.sidecar) {
704
- mcpNetwork = networkName;
705
- mcpIp = await connectContainerNetwork(treatments.mcp.sidecar.id, networkName);
706
- }
761
+ const egressPolicy = environment.egress;
762
+ await inStage("environment", async () => {
763
+ const networkName = await createRunNetwork(`${runId}-${attemptId}`);
764
+ try {
765
+ const sidecar = await startEgressSidecar({ runId, networkName, allowlist: egressPolicy.allowlist, image: EGRESS_PROXY_IMAGE });
766
+ egress = { networkName, sidecar };
767
+ } catch (err) { await removeRunNetwork(networkName); throw err; }
768
+ if (treatments.mcp?.sidecar) {
769
+ mcpNetwork = networkName;
770
+ mcpIp = await connectContainerNetwork(treatments.mcp.sidecar.id, networkName);
771
+ }
772
+ });
707
773
  }
708
774
 
709
775
  // --- treatment env: everything rides the 0600 env-file, never argv ---
@@ -748,17 +814,18 @@ async function runContainerAttemptV3(
748
814
  Object.assign(env, proxyEnv);
749
815
  }
750
816
 
751
- const paths = await artifacts.prepareAttempt(runId, attemptId);
752
- const container = await createAttemptContainer({
817
+ const paths = await inStage("environment", () => artifacts.prepareAttempt(runId, attemptId));
818
+ const fixtureDir = sourceDir;
819
+ const container = await inStage("environment", () => createAttemptContainer({
753
820
  image,
754
821
  env,
755
822
  resultsDir: paths.attemptDir,
756
823
  attemptId,
757
- fixtureDir: sourceDir,
824
+ fixtureDir,
758
825
  // Under the egress treatment the internal network replaces the
759
826
  // default bridge: the proxy sidecar is the only path to the world.
760
827
  ...(egress ? { network: egress.networkName } : {}),
761
- });
828
+ }));
762
829
  try {
763
830
  let command: string[];
764
831
  let baselineSha: string | undefined;
@@ -768,15 +835,17 @@ async function runContainerAttemptV3(
768
835
  // final workspace against this exact commit. The SHA is recorded
769
836
  // (not just HEAD) so an agent that makes its own commits cannot
770
837
  // move the baseline out from under the diff.
771
- const gitAvailable = (await container.execInWorkspace(["git", "--version"])).exitCode === 0;
838
+ const gitAvailable = await inStage("environment", async () => (await container.execInWorkspace(["git", "--version"])).exitCode === 0);
772
839
  if (gitAvailable) {
773
- await mustExec(container, ["git", "init", "-q"]);
774
- await mustExec(container, ["git", "add", "-A"]);
775
- await mustExec(container, [
776
- "git", "-c", "user.name=quickstudy", "-c", "user.email=quickstudy@localhost",
777
- "commit", "-q", "--allow-empty", "-m", "quickstudy fixture baseline",
778
- ]);
779
- baselineSha = (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
840
+ baselineSha = await inStage("environment", async () => {
841
+ await mustExec(container, ["git", "init", "-q"]);
842
+ await mustExec(container, ["git", "add", "-A"]);
843
+ await mustExec(container, [
844
+ "git", "-c", "user.name=quickstudy", "-c", "user.email=quickstudy@localhost",
845
+ "commit", "-q", "--allow-empty", "-m", "quickstudy fixture baseline",
846
+ ]);
847
+ return (await mustExec(container, ["git", "rev-parse", "HEAD"])).stdout.trim();
848
+ });
780
849
  } else {
781
850
  (options.log ?? console.log)(
782
851
  `attempt ${attemptId}: git is unavailable in image "${image}" — diff.patch will be empty ` +
@@ -794,15 +863,23 @@ async function runContainerAttemptV3(
794
863
  };
795
864
  // Treatment config files (e.g. the MCP treatment's vendor config).
796
865
  // The prompt itself is byte-identical across experiments.
797
- for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
798
- await container.writeFile(path, content);
799
- }
866
+ await inStage("environment", async () => {
867
+ for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
868
+ await container.writeFile(path, content);
869
+ }
870
+ });
800
871
  command = agentAdapter.command(promptCtx);
801
872
  } else {
802
873
  command = runtime.command as string[];
803
874
  }
804
875
 
805
- const agentResult = await container.execInWorkspace(command, { timeoutMs: args.attemptTimeoutMs });
876
+ persisted.agentStartedAt = Date.now();
877
+ let agentResult: Awaited<ReturnType<typeof container.execInWorkspace>>;
878
+ try {
879
+ agentResult = await inStage("agent", () => container.execInWorkspace(command, { timeoutMs: args.attemptTimeoutMs }));
880
+ } finally {
881
+ persisted.agentFinishedAt = Date.now();
882
+ }
806
883
  // The raw stream is the transcript artifact (redacted) — persisted
807
884
  // even when the agent timed out or failed, so there is always evidence.
808
885
  const redactedStream = redactSecrets(agentResult.stdout, secretEnv);
@@ -845,24 +922,34 @@ async function runContainerAttemptV3(
845
922
  }
846
923
  }
847
924
  if (agentResult.timedOut) {
848
- await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms — container killed`);
849
- return { status: "incomplete", error: `attempt timed out after ${args.attemptTimeoutMs}ms` };
850
- }
851
- if (agentResult.exitCode !== 0) {
925
+ // Graded timeout: runDocker restarted the killed container, so the
926
+ // tree the agent left behind is exported and scored below.
927
+ // captureDiff already ran above.
928
+ await artifacts.writeError(runId, attemptId, `attempt timed out after ${args.attemptTimeoutMs}ms — container killed; workspace graded as left`);
929
+ persisted.limitHit = "timeout";
930
+ } else if (agentResult.exitCode !== 0) {
852
931
  const message = `agent command exited ${agentResult.exitCode}\n--- stderr ---\n${agentResult.stderr}`;
853
932
  await artifacts.writeError(runId, attemptId, redactSecrets(message, secretEnv));
854
- return { status: "error", error: `agent command exited ${agentResult.exitCode}` };
933
+ return { status: "error", error: `agent command exited ${agentResult.exitCode}`, errorKind: "agent" };
934
+ } else {
935
+ await inStage("environment", captureDiff);
855
936
  }
856
937
 
857
- await captureDiff();
858
-
859
938
  // Export the workspace BEFORE scoring, with the container still alive:
860
939
  // file checks read the export, ctx.exec reaches into the live sandbox.
861
- await deadline("export", environment?.budgets.exportMs ?? DEFAULT_BUDGETS.exportMs, async (signal) => {
862
- const kill = () => { void container.teardown(); };
863
- signal.addEventListener("abort", kill, { once: true });
864
- try { await container.exportWorkspace(paths.workspace); } finally { signal.removeEventListener("abort", kill); }
865
- });
940
+ try {
941
+ await inStage("environment", () => deadline("export", environment?.budgets.exportMs ?? DEFAULT_BUDGETS.exportMs, async (signal) => {
942
+ const kill = () => { void container.teardown(); };
943
+ signal.addEventListener("abort", kill, { once: true });
944
+ try { await container.exportWorkspace(paths.workspace); } finally { signal.removeEventListener("abort", kill); }
945
+ }));
946
+ } catch (err) {
947
+ if (persisted.limitHit !== "timeout") throw err;
948
+ // Nothing to score: `incomplete` keeps its meaning "no workspace".
949
+ const error = `attempt timed out after ${args.attemptTimeoutMs}ms; workspace could not be exported`;
950
+ await artifacts.writeError(runId, attemptId, `${error}: ${err instanceof Error ? err.message : String(err)}`);
951
+ return { status: "incomplete", error };
952
+ }
866
953
  return await scoreAttempt(options, loadedEval, experiment, entry, {
867
954
  runId,
868
955
  attemptId,
@@ -899,7 +986,7 @@ async function runContainerAttemptV3(
899
986
  } catch (err) {
900
987
  const message = err instanceof Error ? err.message : String(err);
901
988
  await artifacts.writeError(runId, attemptId, message);
902
- disposition = { status: "error", error: message };
989
+ disposition = { status: "error", error: message, errorKind: errorKindOf(err) };
903
990
  } finally {
904
991
  if (mcpNetwork && treatments.mcp?.sidecar) {
905
992
  try { await disconnectContainerNetwork(treatments.mcp.sidecar.id, mcpNetwork); } catch { containerTeardownFailure = "MCP network disconnect failed"; }
@@ -983,7 +1070,7 @@ async function scoreAttempt(
983
1070
  await options.artifacts.writeError(args.runId, args.attemptId, message);
984
1071
  const judgments = (err as { judgments?: import("../evals/types.ts").JudgeRecord[] })?.judgments;
985
1072
  const judgeRecords = judgments && validEvalResult({passed:false,checks:[],judgments}) ? judgments : undefined;
986
- return { status: "error", error: message, ...(judgeRecords ? {judgeRecords} : {}) };
1073
+ return { status: "error", error: message, errorKind: "scorer", ...(judgeRecords ? {judgeRecords} : {}) };
987
1074
  }
988
1075
  }
989
1076
 
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `validate --oracle`: score each eval's reference solution (`solution/`
3
+ * overlaid on `local/`) on the host, with no agent and no runtime.
4
+ *
5
+ * `exec`, `query` and `getClient` are runtime-only here: each records that it
6
+ * was called and throws {@link RuntimeCapabilityError}. Tracking is by
7
+ * invocation on the parent side of the scorer worker (the worker RPCs every
8
+ * capability call back to this context), so a scorer that swallows the error
9
+ * is still reported as deferred. Evals that defer are proven by running an
10
+ * oracle experiment under the real runtime, which costs no model spend.
11
+ */
12
+
13
+ import { rm } from "node:fs/promises";
14
+ import { DEFAULT_BUDGETS } from "../execution-policy.ts";
15
+ import { validEvalResult } from "../evals/result.ts";
16
+ import type { EvalExecResult, LoadedEval } from "../evals/types.ts";
17
+ import { buildEvalContext, RuntimeCapabilityError } from "./context.ts";
18
+ import { deadline, StageTimeoutError } from "./deadline.ts";
19
+ import { scoreInWorker } from "./score-worker.ts";
20
+ import { prepWorkspaceSource } from "./workspace-source.ts";
21
+
22
+ export type OracleOutcome =
23
+ | { kind: "pass"; ungradedFailures: string[] }
24
+ | { kind: "deferred"; capabilities: string[] }
25
+ | { kind: "failed"; failingChecks: string[]; error?: string }
26
+ | { kind: "missing" };
27
+
28
+ export interface ValidateOracleOptions {
29
+ /** Scoring deadline; defaults to the runner's scoring budget. */
30
+ timeoutMs?: number;
31
+ }
32
+
33
+ const EXPERIMENT_ID = "validate-oracle";
34
+
35
+ /** Score one eval's reference solution; see the module doc for the outcomes. */
36
+ export async function validateOracle(loaded: LoadedEval, opts: ValidateOracleOptions = {}): Promise<OracleOutcome> {
37
+ if (loaded.solutionDir === null) return { kind: "missing" };
38
+
39
+ const used = new Set<"exec" | "query" | "getClient">();
40
+ const runtimeOnly = (capability: "exec" | "query" | "getClient"): never => {
41
+ used.add(capability);
42
+ throw new RuntimeCapabilityError(
43
+ capability,
44
+ `${capability}() is runtime-only under validate --oracle — run the oracle experiment under the real runtime`,
45
+ );
46
+ };
47
+
48
+ const workspaceDir = await prepWorkspaceSource(loaded, "solution");
49
+ try {
50
+ let result;
51
+ try {
52
+ result = await deadline("scoring", opts.timeoutMs ?? DEFAULT_BUDGETS.scoreMs, (signal) =>
53
+ scoreInWorker(
54
+ loaded.scorerPath,
55
+ buildEvalContext({
56
+ signal,
57
+ evalId: loaded.metadata.id,
58
+ experimentId: EXPERIMENT_ID,
59
+ trialIndex: 0,
60
+ ...(loaded.metadata.framework !== undefined ? { framework: loaded.metadata.framework } : {}),
61
+ workspaceDir,
62
+ exec: async (): Promise<EvalExecResult> => runtimeOnly("exec"),
63
+ query: async () => runtimeOnly("query"),
64
+ getClient: () => runtimeOnly("getClient"),
65
+ }),
66
+ signal,
67
+ ),
68
+ );
69
+ if (!validEvalResult(result)) throw new Error("scorer returned an invalid EvalResult");
70
+ } catch (err) {
71
+ // A scorer that runs past its deadline failed, whatever it touched first.
72
+ if (used.size > 0 && !(err instanceof StageTimeoutError)) return { kind: "deferred", capabilities: [...used].sort() };
73
+ return { kind: "failed", failingChecks: [], error: err instanceof Error ? err.message : String(err) };
74
+ }
75
+
76
+ // A scorer that reached for a runtime-only capability has not been proven
77
+ // here, even when it swallowed the error and returned passing checks.
78
+ if (used.size > 0) return { kind: "deferred", capabilities: [...used].sort() };
79
+ const graded = result.checks.filter((check) => check.graded !== false);
80
+ const failingGraded = graded.filter((check) => !check.passed).map((check) => check.name);
81
+ if (result.passed && failingGraded.length === 0) {
82
+ const ungradedFailures = result.checks.filter((check) => check.graded === false && !check.passed).map((check) => check.name);
83
+ return { kind: "pass", ungradedFailures };
84
+ }
85
+ return failingGraded.length > 0
86
+ ? { kind: "failed", failingChecks: failingGraded }
87
+ : { kind: "failed", failingChecks: [], error: "scorer returned passed: false" };
88
+ } finally {
89
+ await rm(workspaceDir, { recursive: true, force: true });
90
+ }
91
+ }
92
+
93
+ /** One `validate --oracle` output line for an eval (without the leading bullet). */
94
+ export function formatOracleOutcome(evalId: string, outcome: OracleOutcome): string {
95
+ switch (outcome.kind) {
96
+ case "pass":
97
+ return outcome.ungradedFailures.length > 0
98
+ ? `${evalId}: 1.0 (ungraded checks failing: ${outcome.ungradedFailures.join(", ")})`
99
+ : `${evalId}: 1.0`;
100
+ case "deferred":
101
+ return `${evalId}: deferred (scorer needs ${outcome.capabilities.join(", ")} — run the oracle experiment under the real runtime)`;
102
+ case "failed": {
103
+ const detail = outcome.failingChecks.length > 0 ? outcome.failingChecks.join(", ") : outcome.error ?? "scorer failed";
104
+ return `${evalId}: FAILED (${detail})`;
105
+ }
106
+ case "missing":
107
+ return `${evalId}: MISSING (no solution/)`;
108
+ }
109
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Workspace-source preparation shared by both runner paths (host and
3
+ * container) and by `validate --oracle`: build the attempt's starting tree in
4
+ * a fresh temp directory. The host path copies it into the attempt scratch
5
+ * dir; the container path `docker cp`s it whole into /workspace.
6
+ */
7
+
8
+ import { cp, mkdtemp } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import type { StartingState } from "../adapters/types.ts";
12
+ import type { LoadedEval } from "../evals/types.ts";
13
+
14
+ export type { StartingState } from "../adapters/types.ts";
15
+
16
+ /**
17
+ * Prepare the attempt's starting state: a copy of `local/`, or empty
18
+ * (tools-only evals). With `startingState: "solution"` the eval's
19
+ * `solution/` is then copied over it — solution files win; solutions are
20
+ * additive overlays and cannot delete fixture files.
21
+ */
22
+ export async function prepWorkspaceSource(loadedEval: LoadedEval, startingState: StartingState = "fixture"): Promise<string> {
23
+ const sourceDir = await mkdtemp(join(tmpdir(), "quickstudy-v3-source-"));
24
+ if (loadedEval.localDir !== null) {
25
+ await cp(loadedEval.localDir, sourceDir, { recursive: true });
26
+ }
27
+ if (startingState === "solution") {
28
+ if (loadedEval.solutionDir === null) {
29
+ throw new Error(`eval "${loadedEval.metadata.id}" has no solution/ — the oracle adapter needs one`);
30
+ }
31
+ await cp(loadedEval.solutionDir, sourceDir, { recursive: true, force: true });
32
+ }
33
+ return sourceDir;
34
+ }
package/src/store/db.ts CHANGED
@@ -18,6 +18,20 @@ import { SCHEMA_SQL } from "./schema.ts";
18
18
 
19
19
  export type AttemptStatus = "completed" | "incomplete" | "error";
20
20
 
21
+ /** Which stage failed when an attempt's status is "error". */
22
+ export const ERROR_KINDS = ["environment", "agent", "scorer", "harness"] as const;
23
+ export type ErrorKind = (typeof ERROR_KINDS)[number];
24
+ /** Which limit an attempt hit; the tree it left behind was still graded. */
25
+ export type LimitKind = "timeout";
26
+
27
+ function toErrorKind(value: string | null): ErrorKind | null {
28
+ return value !== null && (ERROR_KINDS as readonly string[]).includes(value) ? (value as ErrorKind) : null;
29
+ }
30
+
31
+ function toLimitKind(value: string | null): LimitKind | null {
32
+ return value === "timeout" ? value : null;
33
+ }
34
+
21
35
  /** The run-level configuration recorded in `runs.config_json`. */
22
36
  export interface RunConfig {
23
37
  scheduler?: { concurrency: number; seed: string; policy: "seeded-hash-v1"; order: import("../plan.ts").AttemptPlan[] };
@@ -82,6 +96,13 @@ export interface NewAttempt {
82
96
  startedAt?: number | null;
83
97
  finishedAt?: number | null;
84
98
  error?: string | null;
99
+ /** "timeout" = the agent hit its wall-clock limit and the tree it left was graded (column limit_hit). */
100
+ limitHit?: LimitKind | null;
101
+ /** Which stage failed; set only when status = "error". */
102
+ errorKind?: ErrorKind | null;
103
+ /** Agent phase only, epoch ms; null = not recorded. */
104
+ agentStartedAt?: number | null;
105
+ agentFinishedAt?: number | null;
85
106
  }
86
107
 
87
108
  export interface AttemptRecord {
@@ -106,6 +127,13 @@ export interface AttemptRecord {
106
127
  startedAt: number | null;
107
128
  finishedAt: number | null;
108
129
  error: string | null;
130
+ /** "timeout" = the agent hit its wall-clock limit and the tree it left was graded (column limit_hit). */
131
+ limitHit?: LimitKind | null;
132
+ /** Which stage failed; set only when status = "error". */
133
+ errorKind?: ErrorKind | null;
134
+ /** Agent phase only, epoch ms; null = not recorded. */
135
+ agentStartedAt?: number | null;
136
+ agentFinishedAt?: number | null;
109
137
  }
110
138
 
111
139
  interface RunRow {
@@ -139,6 +167,10 @@ interface AttemptRow {
139
167
  started_at: number | null;
140
168
  finished_at: number | null;
141
169
  error: string | null;
170
+ limit_hit: string | null;
171
+ error_kind: string | null;
172
+ agent_started_at: number | null;
173
+ agent_finished_at: number | null;
142
174
  }
143
175
 
144
176
  function toRunRecord(row: RunRow): RunRecord {
@@ -175,6 +207,10 @@ function toAttemptRecord(row: AttemptRow): AttemptRecord {
175
207
  startedAt: row.started_at,
176
208
  finishedAt: row.finished_at,
177
209
  error: row.error,
210
+ limitHit: toLimitKind(row.limit_hit ?? null),
211
+ errorKind: toErrorKind(row.error_kind ?? null),
212
+ agentStartedAt: row.agent_started_at ?? null,
213
+ agentFinishedAt: row.agent_finished_at ?? null,
178
214
  };
179
215
  }
180
216
 
@@ -226,6 +262,21 @@ export class ResultsStore {
226
262
  }
227
263
  if (!attemptColumns.includes("judge_usage_json")) this.db.exec("ALTER TABLE attempts ADD COLUMN judge_usage_json TEXT;");
228
264
  if (!attemptColumns.includes("retry_of")) this.db.exec("ALTER TABLE attempts ADD COLUMN retry_of TEXT;");
265
+ // Outcome taxonomy + agent-phase timing (nullable; NULL = unavailable).
266
+ for (const [name, type] of [
267
+ ["limit_hit", "TEXT"],
268
+ ["error_kind", "TEXT"],
269
+ ["agent_started_at", "INTEGER"],
270
+ ["agent_finished_at", "INTEGER"],
271
+ ] as const) {
272
+ if (attemptColumns.includes(name)) continue;
273
+ try {
274
+ this.db.exec(`ALTER TABLE attempts ADD COLUMN ${name} ${type};`);
275
+ } catch (err) {
276
+ // A concurrent opener may have added it between the read and the ALTER.
277
+ if (!/duplicate column/i.test(err instanceof Error ? err.message : String(err))) throw err;
278
+ }
279
+ }
229
280
  this.db.exec("PRAGMA user_version = 3;");
230
281
  }
231
282
 
@@ -264,8 +315,9 @@ export class ResultsStore {
264
315
  `INSERT INTO attempts (
265
316
  id, run_id, eval_id, experiment_id, suite, trial_index, attempt_identity_hash, status,
266
317
  result_json, surface_usage_json, transcript_ref, diff_ref, cost_usd, tokens_in, tokens_out,
267
- turns, started_at, finished_at, error, retry_of, judge_usage_json
268
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
318
+ turns, started_at, finished_at, error, retry_of, judge_usage_json,
319
+ limit_hit, error_kind, agent_started_at, agent_finished_at
320
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
269
321
  )
270
322
  .run(
271
323
  attempt.id,
@@ -289,6 +341,10 @@ export class ResultsStore {
289
341
  attempt.error ?? null,
290
342
  attempt.retryOf ?? null,
291
343
  attempt.judgeRecords ? JSON.stringify(attempt.judgeRecords) : null,
344
+ attempt.limitHit ?? null,
345
+ attempt.errorKind ?? null,
346
+ attempt.agentStartedAt ?? null,
347
+ attempt.agentFinishedAt ?? null,
292
348
  );
293
349
  }
294
350
 
@@ -32,7 +32,13 @@ CREATE TABLE IF NOT EXISTS attempts (
32
32
  tokens_in INTEGER, tokens_out INTEGER,
33
33
  turns INTEGER, -- agent steps; NULL = no adapter stream (scripted commands)
34
34
  started_at INTEGER, finished_at INTEGER,
35
- error TEXT -- only when status = 'error'
35
+ error TEXT, -- only when status = 'error'
36
+ -- Outcome taxonomy + agent-phase timing. NULL = unavailable / not applicable
37
+ -- (rows written before these columns existed read NULL; never backfilled).
38
+ limit_hit TEXT, -- 'timeout' when the agent hit its wall-clock limit and the tree it left was graded
39
+ error_kind TEXT, -- 'environment' | 'agent' | 'scorer' | 'harness' (status = 'error' only)
40
+ agent_started_at INTEGER, -- epoch ms; agent phase only
41
+ agent_finished_at INTEGER -- epoch ms; agent phase only
36
42
  );
37
43
 
38
44
  CREATE INDEX IF NOT EXISTS idx_attempts_run_pair ON attempts(run_id, eval_id, experiment_id);