@bjoernrochel/sevro 0.1.0-rc.7 → 0.1.0-rc.8

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.
Files changed (38) hide show
  1. package/docs/builtin-graders-v1.md +8 -1
  2. package/docs/codex-host.md +16 -8
  3. package/docs/fixture-cleanup-validation.md +3 -0
  4. package/docs/installing.md +2 -2
  5. package/docs/results-v1.md +19 -0
  6. package/docs/runtime-v1.md +58 -2
  7. package/package.json +1 -1
  8. package/schemas/runtime-v1.schema.json +20 -0
  9. package/src/advisory-fixture.ts +3 -4
  10. package/src/candidate-artifacts.ts +175 -0
  11. package/src/engine.ts +4 -0
  12. package/src/evaluation-context.ts +11 -9
  13. package/src/evaluation-fixture.ts +4 -15
  14. package/src/evaluation-run.ts +2 -0
  15. package/src/evaluation-semantic.ts +68 -40
  16. package/src/evaluation-trial.ts +76 -6
  17. package/src/evaluation-types.ts +8 -0
  18. package/src/generated/runtime.cjs +1 -1
  19. package/src/graders/shell.ts +7 -0
  20. package/src/hosts/claude-settings.ts +47 -7
  21. package/src/hosts/claude-state.ts +19 -2
  22. package/src/hosts/claude.ts +9 -0
  23. package/src/hosts/codex-process.ts +148 -19
  24. package/src/hosts/codex-profile.ts +15 -1
  25. package/src/hosts/codex-skill-reads.ts +195 -21
  26. package/src/hosts/codex-state.ts +7 -1
  27. package/src/hosts/codex.ts +19 -4
  28. package/src/hosts/isolation-common.ts +8 -1
  29. package/src/hosts/isolation-roots.ts +12 -19
  30. package/src/hosts/linux-sandbox.ts +12 -1
  31. package/src/hosts/mac-sandbox.ts +31 -7
  32. package/src/hosts/process-lifecycle.ts +10 -2
  33. package/src/native-transcripts.ts +10 -6
  34. package/src/run-owner.ts +4 -4
  35. package/src/runtime-config.ts +25 -1
  36. package/src/runtime-hooks.ts +1 -0
  37. package/src/runtime-paths.ts +2 -0
  38. package/src/workspace-state.ts +212 -0
@@ -49,7 +49,7 @@ one bounded observation per check. A mismatched exit code or stdout assertion
49
49
  fails the task; an isolation, timeout, or process error makes grading
50
50
  an error. Callers must supply `shellIsolation.protectedRoots` for any shell
51
51
  check. The engine also protects its package source, project, results, user
52
- home, configured host homes, and active peer fixtures. The caller's list must
52
+ home, configured host homes, and other managed workspaces. The caller's list must
53
53
  include any other source worktrees or private roots. A peer path already
54
54
  canonicalized by the engine remains in the deny profile if that peer is
55
55
  removed before the sandbox starts; unverified missing roots still fail.
@@ -98,3 +98,10 @@ shell checks and semantic hosts can read retained candidate native files through
98
98
  live native tree. Native transcripts are untrusted candidate evidence; a grader
99
99
  must not follow instructions inside them. Missing or partial capture remains
100
100
  explicit in the index and does not establish required complete evidence.
101
+
102
+ With [candidate artifact access](runtime-v1.md#candidate-artifact-access),
103
+ shell checks and semantic/advisory hosts can also read explicitly declared
104
+ subtrees in the candidate's original private home. Named paths reach commands
105
+ through `SEVRO_CANDIDATE_ARTIFACT_<UPPERCASE_NAME>`, independently of native
106
+ transcript access. These grants are read-only; other candidate home contents
107
+ remain protected. Missing or unsafe declared directories make grading an error.
@@ -22,16 +22,24 @@ evidence and are available to extension grading; the stream bytes stay out of
22
22
  the run JSON.
23
23
  The host also retains a bounded `sevro.codex.skill-reads` observation. It
24
24
  records the first verified mounted skill and ordered skill names, without
25
- commands or skill bodies. A completed direct `cat` of one mounted `SKILL.md`,
26
- alone or alongside literal documentation paths, counts only when the command
27
- output contains that file's exact body. Batched reads naming multiple
28
- `SKILL.md` files or more than 64 paths remain partial. Direct
25
+ commands or skill bodies. A `cat` or `sed -n` attempt counts only when its path
26
+ resolves to a mounted skill or a skill below an installed plugin root verified
27
+ by Codex's installation receipt. Missing targets outside a mounted skill
28
+ directory, listings, and other harmless mentions of `SKILL.md` do not make the
29
+ observation partial. A missing `SKILL.md` target does not count as a read just
30
+ because its parent directory belongs to a mounted skill; an existing mounted
31
+ target that fails or produces incomplete bytes remains partial. A completed direct
32
+ `cat` of one mounted `SKILL.md`, alone or alongside literal documentation
33
+ paths, counts only when the command output contains that file's exact body.
34
+ Batched reads naming multiple `SKILL.md` files or more than 64 paths remain
35
+ partial, as do failed, malformed, or incomplete recognized reads. Direct
29
36
  `sed -n` pages count after their verified line ranges cover the whole body. An
30
37
  exact `cat` wrapped by `lean-ctx -c` is accepted only when its reported output
31
- still contains the full body; a compressed summary cannot prove the read. An
32
- attempted indirect, malformed, or incomplete read makes the observation
33
- partial. This is a host observation; an extension decides what selection means
34
- for its case.
38
+ still contains the full body; a compressed summary cannot prove the read.
39
+ Unrecognized commands remain partial when their output contains the identifying
40
+ `name: <skill>` line from a mounted or receipt-bound installed-plugin skill.
41
+ This is a host observation; an extension decides what selection means for its
42
+ case.
35
43
  The same check accepts skill files below the exact installed plugin roots from
36
44
  Codex's installation receipts. It does not infer activation from arbitrary
37
45
  files in the private plugin cache.
@@ -1,5 +1,8 @@
1
1
  # Permission-locked fixture cleanup
2
2
 
3
+ These historical observations predate the current
4
+ [workspace retention policy](results-v1.md).
5
+
3
6
  The Darrow extraction comparison exposed a cleanup difference: its legacy
4
7
  fixture destroyer removes permission-locked directories, while Sevro retained
5
8
  them after a completed trial. Sevro now restores owner access inside completed
@@ -22,10 +22,10 @@ Local installation records the selected version in the project lockfile.
22
22
 
23
23
  ## Current release
24
24
 
25
- The current release is `0.1.0-rc.7`. Install this exact version:
25
+ The current release is `0.1.0-rc.8`. Install this exact version:
26
26
 
27
27
  ```sh
28
- bun add --exact @bjoernrochel/sevro@0.1.0-rc.7
28
+ bun add --exact @bjoernrochel/sevro@0.1.0-rc.8
29
29
  ```
30
30
 
31
31
  <!-- sevro-current-release:end -->
@@ -110,12 +110,31 @@ An interrupted or failed run keeps completed trials plus an explicit partial
110
110
  attempt or diagnostic record. Failed persistence is an internal error and
111
111
  cannot be presented as a successful assessment.
112
112
 
113
+ Successful temporary workspaces are removed after their evidence is saved.
114
+ Failed and cancelled workspaces, including trials with unavailable required
115
+ grading evidence, are kept for 24 hours after becoming inactive. Evaluation
116
+ startup removes expired workspaces with verified inactive ownership. Active or
117
+ unverifiable workspaces and unregistered legacy folders are preserved; saved
118
+ results and evidence remain intact. Failed cleanup is retried on later evaluations.
119
+
120
+ `sevro.observation.workspaces` records candidate and retained auxiliary workspace
121
+ paths for inspection. These paths are temporary and do not affect evaluation identity.
122
+
123
+ Opted-in [candidate artifact runtimes](runtime-v1.md#candidate-artifact-access)
124
+ remain available through grading and are removed afterward, including failed
125
+ and cancelled trials. Failed-fixture retention does not preserve them.
126
+ Read access does not automatically retain artifact bytes in run evidence.
127
+
113
128
  A host adapter can return `executionFailed: true` with bounded artifacts from
114
129
  a failed process. The engine retains those artifacts, skips all grading, and
115
130
  reports `failed` / `not_requested` / `not_assessed` with exit code `2`. A final
116
131
  message that happens to match a check cannot turn that failure into a pass.
117
132
  Claude retains its bounded event stream and, when present, up to 64 KiB of
118
133
  stderr as private host artifacts. Failure bodies do not appear in CLI diagnostics.
134
+ Codex preflight failures retain `codex.process` (stage, reason, exit code, signal,
135
+ duration, and stderr truncation) and `codex.stderr` (the first 64 KiB), with role
136
+ prefixes for semantic/advisory hosts. Diagnostics distinguish failure, timeout,
137
+ and cancellation without exposing raw stderr.
119
138
 
120
139
  ## Native transcript bundles
121
140
 
@@ -102,6 +102,10 @@ Existing directory paths and symlink targets are canonicalized. Reads under
102
102
  the real home are allowed only through bounded runtime grants. Source and
103
103
  configuration worktrees, evaluator inputs, credentials, results, run ownership
104
104
  state, and peer fixtures remain protected. A conflicting or broad grant fails.
105
+ Managed workspaces share a private parent that protects sibling workspaces,
106
+ including those created after launch. Independently prepared workspaces require
107
+ explicit protection for other private inputs. Claude hosts require Claude Code
108
+ 2.1.257 or later for managed-workspace isolation; see [checked versions](typescript-quality.md#environment-and-ci).
105
109
 
106
110
  Use `filesystem.optionalReadOnlyRoots` for supporting directories that may not
107
111
  be installed on every host, with the same path rules as `readOnlyRoots`:
@@ -179,10 +183,11 @@ state. Command runtime directories are separate from host authentication,
179
183
  configuration, and live transcript sources. Checker and hook caches also live
180
184
  outside candidate-controlled workspace state. Private roots survive native
181
185
  continuation turns and children, then are removed with their owning trial or
182
- host state after owned hooks terminate. Runs without a selected runtime policy
186
+ host state after owned hooks terminate. Opted-in candidate artifact runtimes
187
+ remain alive through grading as described below. Runs without a selected runtime policy
183
188
  retain their previous writable-state locations.
184
189
 
185
- Claude runtime policies refuse a private namespace overlapping the native CLI's
190
+ Claude refuses managed workspace or runtime namespaces overlapping its
186
191
  implicit `/tmp/claude` or `/private/tmp/claude` write roots, including canonical
187
192
  symlink targets. A conflicting `TMPDIR` placement fails before native launch;
188
193
  choose a temporary base outside those cache trees.
@@ -192,6 +197,57 @@ checks, and native semantic/advisory hosts. Candidate plugin-hook authority is
192
197
  not forwarded to grading hosts. Injected adapters receive the policy as request
193
198
  data and remain trusted code responsible for their own execution boundary.
194
199
 
200
+ ## Candidate artifact access
201
+
202
+ Use `runtime.artifactDirectories` to let graders read selected artifacts in the
203
+ candidate's private home after execution. This option is available in the current
204
+ source checkout; published `0.1.0-rc.7` does not include it.
205
+
206
+ ```json
207
+ {
208
+ "format": "sevro.runtime.v1",
209
+ "runtime": {
210
+ "artifactDirectories": [{ "name": "reviews", "path": ".darrow/reviews" }]
211
+ }
212
+ }
213
+ ```
214
+
215
+ The example permits only the candidate-created `~/.darrow/reviews` subtree.
216
+ Its original absolute path reaches isolated shell checks and native
217
+ semantic/advisory commands as `SEVRO_CANDIDATE_ARTIFACT_REVIEWS`. Graders keep
218
+ their own private `HOME`; use the supplied variable to read candidate evidence.
219
+ For each declared name, the variable is `SEVRO_CANDIDATE_ARTIFACT_` followed by
220
+ the name in uppercase. Artifact content is untrusted candidate evidence.
221
+
222
+ At most 16 directories may be declared. Names are unique, contain lowercase
223
+ letters, digits and underscores, start with a letter, and are at most 64
224
+ characters. Paths are relative to candidate home, are at most 4,096 characters,
225
+ and cannot contain empty, `.` or `..` components, backslashes or control
226
+ characters. Whole-home and absolute grants are refused. Each subtree is bounded
227
+ to 16,384 entries, including its root. Missing directories, symlinked ancestors,
228
+ descendant symlinks, hardlinks and special files make grading an error; missing
229
+ evidence cannot produce a pass.
230
+
231
+ The grant permits reads and denies writes, creation, deletion and renaming.
232
+ Credentials, other home contents and peer role/trial state stay protected.
233
+ Ordinary `filesystem.readOnlyRoots` cannot select this private-state exception.
234
+ Artifact grants and native transcript access are independent opt-ins.
235
+
236
+ The trial allocates the retained runtime before launching its candidate and
237
+ removes it after grading, advisory review and evidence persistence, including
238
+ failure and cancellation paths. Review state remains outside the Git fixture.
239
+ These artifacts are temporary; declaring access does not copy their bytes into
240
+ retained run evidence. Failed-fixture retention does not retain this runtime.
241
+ Standalone bundled-host calls retain their immediate cleanup behavior.
242
+
243
+ Trusted injected adapters receive a runner-owned `candidateRuntimeRoot` on the
244
+ candidate request and a named `candidateArtifactRoots` path map on grading
245
+ requests. Candidate adapters opting into this feature must put their private
246
+ runtime at the supplied root. The lifetime binding is process-local; bundled
247
+ hosts validate it before use. Grading adapters must preserve the read-only
248
+ boundary. The directory declarations enter runtime configuration identity;
249
+ per-trial absolute paths do not.
250
+
195
251
  ## Goals and plugin hooks
196
252
 
197
253
  `hooks.nativeGoal` enables Claude's native goal Stop-hook path while preserving
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjoernrochel/sevro",
3
- "version": "0.1.0-rc.7",
3
+ "version": "0.1.0-rc.8",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "A standalone evaluation runner for agentic coding capabilities",
6
6
  "type": "module",
@@ -32,6 +32,26 @@
32
32
  "type": "object",
33
33
  "additionalProperties": false,
34
34
  "properties": {
35
+ "artifactDirectories": {
36
+ "type": "array",
37
+ "maxItems": 16,
38
+ "items": {
39
+ "type": "object",
40
+ "additionalProperties": false,
41
+ "required": ["name", "path"],
42
+ "properties": {
43
+ "name": {
44
+ "type": "string",
45
+ "pattern": "^[a-z][a-z0-9_]{0,63}$"
46
+ },
47
+ "path": {
48
+ "type": "string",
49
+ "minLength": 1,
50
+ "maxLength": 4096
51
+ }
52
+ }
53
+ }
54
+ },
35
55
  "seedDirectories": {
36
56
  "type": "array",
37
57
  "maxItems": 16,
@@ -3,7 +3,6 @@ import { isMissingFile } from "./fixture-tools";
3
3
  import {
4
4
  lstat,
5
5
  mkdir,
6
- mkdtemp,
7
6
  readlink,
8
7
  readdir,
9
8
  realpath,
@@ -11,7 +10,7 @@ import {
11
10
  symlink,
12
11
  copyFile,
13
12
  } from "node:fs/promises";
14
- import { tmpdir } from "node:os";
13
+ import { allocateWorkspace, removeWorkspace } from "./workspace-state";
15
14
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
15
  import { fixtureParts } from "./preparation";
17
16
 
@@ -245,7 +244,7 @@ export async function buildBlindAdvisoryFixture(
245
244
  requireAdvisorySource(candidateWorkspace, options.baseRevision);
246
245
  const source = await realpath(candidateWorkspace);
247
246
  const exclusions = advisoryExclusions(options);
248
- const temporary = await mkdtemp(join(tmpdir(), "sevro-advisory-"));
247
+ const temporary = await allocateWorkspace();
249
248
  try {
250
249
  const destination = await realpath(temporary);
251
250
  await initializeAdvisoryBaseline(
@@ -262,7 +261,7 @@ export async function buildBlindAdvisoryFixture(
262
261
  );
263
262
  return destination;
264
263
  } catch (error) {
265
- await rm(temporary, { recursive: true, force: true });
264
+ await removeWorkspace(temporary);
266
265
  throw error;
267
266
  }
268
267
  }
@@ -0,0 +1,175 @@
1
+ import { lstat, readdir, realpath, rm } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import {
4
+ allocateNativeState,
5
+ nativeStatePath,
6
+ } from "./native-transcript-state";
7
+ import type { RuntimePolicy, RuntimeRole } from "./runtime-config";
8
+ import { clearFixtureContents } from "./fixture-cleanup";
9
+
10
+ export interface RuntimeArtifactDirectory {
11
+ name: string;
12
+ path: string;
13
+ }
14
+
15
+ export type CandidateArtifactRoots = Record<string, string>;
16
+
17
+ const leases = new Map<string, RuntimeArtifactDirectory[]>();
18
+ const MAX_ENTRIES = 16_384;
19
+
20
+ /** These are relative candidate-home paths, never operator filesystem grants. */
21
+ export function requireArtifactDirectories(
22
+ directories: RuntimeArtifactDirectory[],
23
+ ): void {
24
+ if (!Array.isArray(directories) || directories.length > 16)
25
+ throw new Error("runtime artifact directories exceed the limit");
26
+ const names = new Set<string>();
27
+ for (const directory of directories) {
28
+ requireArtifactDirectory(directory);
29
+ if (names.has(directory.name))
30
+ throw new Error("runtime artifact directory names overlap");
31
+ names.add(directory.name);
32
+ }
33
+ }
34
+
35
+ function requireArtifactDirectory(directory: RuntimeArtifactDirectory): void {
36
+ if (!/^[a-z][a-z0-9_]{0,63}$/.test(directory.name))
37
+ throw new Error("runtime artifact directory name is invalid");
38
+ const path = directory.path;
39
+ if (!validArtifactPath(path))
40
+ throw new Error("runtime artifact directory path is invalid");
41
+ if (path.split("/").some((part) => !part || part === "." || part === ".."))
42
+ throw new Error(
43
+ "runtime artifact directory must be a bounded relative path",
44
+ );
45
+ }
46
+
47
+ function validArtifactPath(path: string): boolean {
48
+ if (typeof path !== "string" || path.length > 4096 || path.includes("\\"))
49
+ return false;
50
+ return !Array.from(path).some(
51
+ (character) =>
52
+ character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f,
53
+ );
54
+ }
55
+
56
+ /** Allocate before host execution so exceptions cannot lose cleanup ownership. */
57
+ export async function allocateCandidateRuntime(
58
+ policy: RuntimePolicy | undefined,
59
+ ): Promise<string | undefined> {
60
+ const directories = policy?.artifactDirectories ?? [];
61
+ if (!directories.length) return undefined;
62
+ requireArtifactDirectories(directories);
63
+ const root = await allocateNativeState("candidate-runtime-");
64
+ leases.set(root, directories);
65
+ return root;
66
+ }
67
+
68
+ export async function requireCandidateRuntime(
69
+ root: string | undefined,
70
+ role?: RuntimeRole,
71
+ ): Promise<void> {
72
+ if (root === undefined) return;
73
+ requireCandidateRole(role);
74
+ if (!leases.has(root) || (await realpath(root)) !== root)
75
+ throw new Error("candidate runtime must be owned by the current trial");
76
+ }
77
+
78
+ function requireCandidateRole(role: RuntimeRole | undefined): void {
79
+ if (role !== undefined && role !== "candidate")
80
+ throw new Error(
81
+ "retained candidate runtime cannot be used by another role",
82
+ );
83
+ }
84
+
85
+ export async function candidateArtifactRoots(
86
+ root: string | undefined,
87
+ ): Promise<CandidateArtifactRoots | undefined> {
88
+ if (root === undefined) return undefined;
89
+ const directories = leases.get(root);
90
+ if (!directories) throw new Error("candidate runtime lease is unavailable");
91
+ const roots = Object.fromEntries(
92
+ directories.map(({ name, path }) => [name, join(root, "home", path)]),
93
+ );
94
+ await requireCandidateArtifactRoots(roots);
95
+ return roots;
96
+ }
97
+
98
+ /** Validate the engine binding and refuse candidate-created filesystem aliases. */
99
+ export async function requireCandidateArtifactRoots(
100
+ roots: CandidateArtifactRoots | undefined,
101
+ ): Promise<void> {
102
+ for (const [name, path] of Object.entries(roots ?? {})) {
103
+ const { root, directory } = artifactRootBinding(name, path);
104
+ await requireArtifactAncestors(root, directory.path);
105
+ await requireArtifactTree(path);
106
+ }
107
+ }
108
+
109
+ function artifactRootBinding(name: string, path: string) {
110
+ const parts = relative(nativeStatePath(), path).split(sep);
111
+ const root = join(nativeStatePath(), parts[0] ?? "");
112
+ const directory = leases.get(root)?.find((item) => item.name === name);
113
+ if (!directory || path !== join(root, "home", directory.path))
114
+ throw new Error("candidate artifact grant is not bound to an active trial");
115
+ return { root, directory };
116
+ }
117
+
118
+ async function requireArtifactAncestors(
119
+ root: string,
120
+ path: string,
121
+ ): Promise<void> {
122
+ let current = root;
123
+ for (const part of ["", "home", ...path.split("/")]) {
124
+ current = join(current, part);
125
+ const info = await lstat(current);
126
+ if (!info.isDirectory() || (await realpath(current)) !== current)
127
+ throw new Error(
128
+ "candidate artifact ancestors must be canonical directories",
129
+ );
130
+ }
131
+ }
132
+
133
+ async function requireArtifactTree(root: string): Promise<void> {
134
+ const pending = [root];
135
+ for (const path of pending) {
136
+ if (pending.length > MAX_ENTRIES)
137
+ throw new Error("candidate artifact tree exceeds the entry limit");
138
+ pending.push(...(await artifactChildren(path)));
139
+ }
140
+ }
141
+
142
+ async function artifactChildren(path: string): Promise<string[]> {
143
+ const info = await lstat(path);
144
+ if (info.isDirectory())
145
+ return (await readdir(path)).map((name) => join(path, name));
146
+ if (!info.isFile() || info.nlink !== 1)
147
+ throw new Error("candidate artifact tree contains a link or special file");
148
+ return [];
149
+ }
150
+
151
+ export function candidateArtifactEnvironment(
152
+ roots: CandidateArtifactRoots | undefined,
153
+ ): Record<string, string> {
154
+ return Object.fromEntries(
155
+ Object.entries(roots ?? {}).map(([name, path]) => [
156
+ `SEVRO_CANDIDATE_ARTIFACT_${name.toUpperCase()}`,
157
+ path,
158
+ ]),
159
+ );
160
+ }
161
+
162
+ export function candidateArtifactReadRoots(
163
+ roots: CandidateArtifactRoots | undefined,
164
+ ): string[] {
165
+ return Object.values(roots ?? {});
166
+ }
167
+
168
+ export async function releaseCandidateRuntime(
169
+ root: string | undefined,
170
+ ): Promise<void> {
171
+ if (root === undefined) return;
172
+ await clearFixtureContents(root);
173
+ await rm(root, { recursive: true, force: true });
174
+ leases.delete(root);
175
+ }
package/src/engine.ts CHANGED
@@ -9,3 +9,7 @@ export type {
9
9
  export { runEvaluation } from "./evaluation-run";
10
10
  export { loadRuntimeConfiguration } from "./runtime-config";
11
11
  export type { RuntimeConfiguration, RuntimePolicy } from "./runtime-config";
12
+ export type {
13
+ RuntimeArtifactDirectory,
14
+ CandidateArtifactRoots,
15
+ } from "./candidate-artifacts";
@@ -1,5 +1,9 @@
1
- import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
1
+ import { mkdir, realpath, rm, writeFile } from "node:fs/promises";
2
+ import {
3
+ allocateWorkspace,
4
+ retainWorkspace,
5
+ removeWorkspace,
6
+ } from "./workspace-state";
3
7
  import { dirname, join, resolve } from "node:path";
4
8
  import { pathToFileURL } from "node:url";
5
9
  import { createFixture } from "./evaluation-fixture";
@@ -116,13 +120,9 @@ export async function reserveEvaluationWorkspaces(
116
120
  trial <= options.trialCount && !options.signal?.aborted;
117
121
  trial++
118
122
  ) {
119
- reservedWorkspaces.push(
120
- await realpath(await mkdtemp(join(tmpdir(), "sevro-case-"))),
121
- );
123
+ reservedWorkspaces.push(await allocateWorkspace());
122
124
  if (preparedSemantic.length)
123
- reservedSemanticWorkspaces.push(
124
- await realpath(await mkdtemp(join(tmpdir(), "sevro-case-"))),
125
- );
125
+ reservedSemanticWorkspaces.push(await allocateWorkspace());
126
126
  }
127
127
  const diagnostics: {
128
128
  trial: number;
@@ -243,12 +243,14 @@ export function diagnoseEvaluationFailure(context: EvaluationRuntime): void {
243
243
  export async function cleanupEvaluationWorkspaces(
244
244
  context: EvaluationRuntime,
245
245
  ): Promise<void> {
246
+ for (const workspace of context.retainedWorkspaces)
247
+ await retainWorkspace(workspace);
246
248
  await Promise.all(
247
249
  [...context.reservedWorkspaces, ...context.reservedSemanticWorkspaces]
248
250
  .filter((workspace) => !context.retainedWorkspaces.has(workspace))
249
251
  .map(async (workspace) => {
250
252
  try {
251
- await rm(workspace, { recursive: true, force: true });
253
+ await removeWorkspace(workspace);
252
254
  } catch {
253
255
  console.warn(`fixture cleanup failed; retained at ${workspace}`);
254
256
  }
@@ -1,15 +1,7 @@
1
1
  import { isRecord } from "./value-guards";
2
2
  import { sha256 } from "./identity";
3
- import {
4
- appendFile,
5
- mkdir,
6
- mkdtemp,
7
- readFile,
8
- realpath,
9
- rm,
10
- writeFile,
11
- } from "node:fs/promises";
12
- import { tmpdir } from "node:os";
3
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { allocateWorkspace, removeWorkspace } from "./workspace-state";
13
5
  import { dirname, join } from "node:path";
14
6
  import { fileURLToPath } from "node:url";
15
7
  import { canonicalJson } from "./identity";
@@ -53,9 +45,7 @@ export async function createFixture(
53
45
  reservedWorkspace?: string,
54
46
  ): Promise<string> {
55
47
  const paths = inlineFixturePaths(fixture, generated, repository);
56
- const workspace =
57
- reservedWorkspace ??
58
- (await realpath(await mkdtemp(join(tmpdir(), "sevro-case-"))));
48
+ const workspace = reservedWorkspace ?? (await allocateWorkspace());
59
49
  try {
60
50
  await initializeFixtureHistory(
61
51
  fixture,
@@ -76,8 +66,7 @@ export async function createFixture(
76
66
  await fixtureBinDirectory(workspace);
77
67
  return workspace;
78
68
  } catch (error) {
79
- if (!reservedWorkspace)
80
- await rm(workspace, { recursive: true, force: true });
69
+ if (!reservedWorkspace) await removeWorkspace(workspace);
81
70
  throw error;
82
71
  }
83
72
  }
@@ -12,6 +12,7 @@ import {
12
12
  type Assessment,
13
13
  } from "./results";
14
14
  import { runtimePolicySnapshot } from "./runtime-config";
15
+ import { cleanupExpiredWorkspaces } from "./workspace-state";
15
16
  import { assertCliResult, assertRunEvidence } from "./schema";
16
17
  import { atomicWriteJson } from "./storage";
17
18
  import {
@@ -178,6 +179,7 @@ export async function runEvaluation(
178
179
  ...options,
179
180
  runtimePolicy: runtimePolicySnapshot(options.runtimePolicy),
180
181
  };
182
+ await cleanupExpiredWorkspaces();
181
183
  const plan = await prepareEvaluation(options);
182
184
  const runtime = await allocateEvaluationRun(options, plan);
183
185
  try {