@tangle-network/agent-bench 0.1.0 → 0.3.5

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 (139) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/HARNESS.md +302 -0
  3. package/README.md +26 -1
  4. package/fixtures/aec-bench.json +18 -0
  5. package/fixtures/agentbench-dbbench.json +22 -0
  6. package/fixtures/bfcl.json +45 -0
  7. package/fixtures/commit0.json +72 -0
  8. package/fixtures/crag.json +10 -0
  9. package/fixtures/dabstep.json +22 -0
  10. package/fixtures/enterpriseops-gym.json +103 -0
  11. package/fixtures/finresearchbench.json +21 -0
  12. package/fixtures/finsearchcomp.json +66 -0
  13. package/fixtures/frames.json +26 -0
  14. package/fixtures/hotpotqa.json +182 -0
  15. package/fixtures/nomiracl.json +26 -0
  16. package/fixtures/open-rag-bench.json +16 -0
  17. package/fixtures/pier-agent/no-model-task/environment/Dockerfile +16 -0
  18. package/fixtures/pier-agent/no-model-task/environment/seed/src/status.txt +1 -0
  19. package/fixtures/pier-agent/no-model-task/instruction.md +6 -0
  20. package/fixtures/pier-agent/no-model-task/pre_artifacts.sh +6 -0
  21. package/fixtures/pier-agent/no-model-task/task.toml +35 -0
  22. package/fixtures/pier-agent/no-model-task/tests/Dockerfile +17 -0
  23. package/fixtures/pier-agent/no-model-task/tests/seed/src/status.txt +1 -0
  24. package/fixtures/pier-agent/no-model-task/tests/test.sh +19 -0
  25. package/fixtures/programbench.json +17 -0
  26. package/fixtures/ragbench.json +21 -0
  27. package/fixtures/simpleqa.json +121 -0
  28. package/fixtures/t2-ragbench.json +13 -0
  29. package/fixtures/tau2-bench.json +16 -0
  30. package/fixtures/tau3-banking.json +16 -0
  31. package/fixtures/toollm.json +28 -0
  32. package/fixtures/webarena-verified.json +20 -0
  33. package/package.json +39 -15
  34. package/pier_agents/__init__.py +18 -0
  35. package/pier_agents/candidate_contract.py +755 -0
  36. package/pier_agents/process_boundary.py +321 -0
  37. package/pier_agents/tangle_candidate.py +907 -0
  38. package/pier_agents/workspace_boundary.py +368 -0
  39. package/scripts/appworld_driver.py +359 -0
  40. package/scripts/cadbench_prepare.py +22 -0
  41. package/scripts/cadgenbench_hard_parts.py +48 -0
  42. package/scripts/clbench_codebase_judge.py +73 -0
  43. package/scripts/commit0_judge.py +170 -0
  44. package/scripts/dabstep_judge.py +42 -0
  45. package/scripts/enterpriseops_gym_judge.py +281 -0
  46. package/scripts/programbench_judge.py +120 -0
  47. package/scripts/render-gate-chart.mjs +176 -0
  48. package/scripts/run-package-tests.mjs +56 -0
  49. package/scripts/terminate-pier-trial.mts +66 -0
  50. package/scripts/trata-hedge/README.md +56 -0
  51. package/scripts/trata-hedge/run.sh +60 -0
  52. package/scripts/trata-hedge/solve.py +83 -0
  53. package/scripts/verify-packed-consumer.mjs +224 -0
  54. package/scripts/verify-pier-agent.mts +715 -0
  55. package/scripts/verify-pier-pair.mts +74 -0
  56. package/scripts/verify-pier-recovery.mts +139 -0
  57. package/src/adapters.ts +26 -0
  58. package/src/benchmarks/_harness.test.mts +178 -0
  59. package/src/benchmarks/_harness.ts +239 -16
  60. package/src/benchmarks/agentbench.ts +163 -0
  61. package/src/benchmarks/appworld.test.mts +15 -9
  62. package/src/benchmarks/bfcl.ts +346 -0
  63. package/src/benchmarks/crag.ts +137 -0
  64. package/src/benchmarks/dabstep.test.mts +70 -0
  65. package/src/benchmarks/dabstep.ts +212 -0
  66. package/src/benchmarks/external-adapters.test.mts +150 -0
  67. package/src/benchmarks/finresearchbench.ts +269 -0
  68. package/src/benchmarks/humaneval.ts +20 -8
  69. package/src/benchmarks/nomiracl.ts +180 -0
  70. package/src/benchmarks/open-rag-bench.ts +153 -0
  71. package/src/benchmarks/rag-benchmarks.test.mts +138 -0
  72. package/src/benchmarks/rag-shared.ts +327 -0
  73. package/src/benchmarks/ragbench.ts +171 -0
  74. package/src/benchmarks/swe-bench.test.mts +61 -0
  75. package/src/benchmarks/swe-bench.ts +201 -19
  76. package/src/benchmarks/t2-ragbench.ts +166 -0
  77. package/src/benchmarks/tau-bench-shared.ts +214 -0
  78. package/src/benchmarks/tau2-bench.ts +30 -0
  79. package/src/benchmarks/tau3-banking.ts +29 -0
  80. package/src/benchmarks/terminal-bench.test.mts +33 -0
  81. package/src/benchmarks/terminal-bench.ts +23 -8
  82. package/src/benchmarks/toollm.ts +254 -0
  83. package/src/benchmarks/types.ts +42 -0
  84. package/src/benchmarks/webarena-verified.ts +200 -0
  85. package/src/commit0-prereqs.sh +0 -0
  86. package/src/coordination-mcp-container-reach.mts +181 -0
  87. package/src/decoder-live.mts +1 -1
  88. package/src/examples/README.md +103 -39
  89. package/src/examples/benchmark-matrix.mts +101 -0
  90. package/src/examples/lean-proof-gate.README.md +77 -0
  91. package/src/examples/lean-proof-gate.mts +162 -0
  92. package/src/examples/lean-verify.ts +95 -0
  93. package/src/examples/lean.Dockerfile +12 -0
  94. package/src/examples/math-demo.mts +9 -7
  95. package/src/examples/strategy-demo.mts +10 -12
  96. package/src/gate.ts +3 -2
  97. package/src/hev-eval.mts +69 -0
  98. package/src/hev-improve.mts +169 -0
  99. package/src/hev-structural.mts +688 -0
  100. package/src/index.ts +73 -0
  101. package/src/mbpp-structural.mts +662 -0
  102. package/src/pier-agent.test-fixtures.mts +19 -0
  103. package/src/pier-agent.test.mts +363 -0
  104. package/src/pier-agent.ts +657 -0
  105. package/src/pier-result-grader.mjs +30 -0
  106. package/src/pier-result-grader.test.mts +62 -0
  107. package/src/pier-result-grader.ts +108 -0
  108. package/src/pier-task-outcome.test.mts +117 -0
  109. package/src/pier-task-outcome.ts +240 -0
  110. package/src/pier-trial-controller.test.mts +412 -0
  111. package/src/pier-trial-controller.ts +858 -0
  112. package/src/pier-trial-supervisor.mjs +352 -0
  113. package/src/resolve-client.ts +25 -2
  114. package/src/run-benchmarks-cli.mts +72 -0
  115. package/src/run-benchmarks-report.ts +66 -0
  116. package/src/run-benchmarks.test.mts +231 -0
  117. package/src/run-benchmarks.ts +589 -0
  118. package/src/smoke-structural-rollout.mts +393 -0
  119. package/src/swe-bench-env.test.ts +207 -0
  120. package/src/swe-bench-env.ts +554 -0
  121. package/src/swe-jail.ts +293 -0
  122. package/src/swe-self-improve.mts +84 -0
  123. package/src/swe-structural-judge-policy.test.ts +117 -0
  124. package/src/swe-structural-judge-policy.ts +133 -0
  125. package/src/swe-structural-policy.test.ts +124 -0
  126. package/src/swe-structural-policy.ts +132 -0
  127. package/src/swe-structural-provenance.test.ts +93 -0
  128. package/src/swe-structural-provenance.ts +138 -0
  129. package/src/swe-structural.mts +1260 -0
  130. package/src/swe-temp.ts +14 -0
  131. package/src/tb-container-executor.mts +234 -0
  132. package/src/tb-container-executor.test.mts +99 -0
  133. package/src/tb-supervisor-sidecar.mts +222 -0
  134. package/src/trata-gepa.mts +1 -1
  135. package/steerers/eops-itsm-population.json +1 -0
  136. package/tb_agents/opencode_refine_agent.py +117 -0
  137. package/tb_agents/opencode_router_agent.py +406 -0
  138. package/tb_agents/opencode_supervisor_agent.py +239 -0
  139. package/tb_agents/script_agent.py +66 -0
@@ -0,0 +1,412 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFile } from 'node:child_process'
3
+ import { chmod, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import test from 'node:test'
7
+ import { promisify } from 'node:util'
8
+
9
+ import type { AgentCandidateExecutorRequest } from '@tangle-network/agent-runtime'
10
+ import { InMemoryTraceStore } from '@tangle-network/agent-eval'
11
+
12
+ import { createStagedPierCandidateExecutionFixture } from './pier-agent.test-fixtures.mts'
13
+ import { FilePierCandidateTrialController } from './pier-trial-controller'
14
+
15
+ const execFileAsync = promisify(execFile)
16
+
17
+ function testRequest(executionId: string, executionPlanDigest: `sha256:${string}`) {
18
+ return {
19
+ staged: createStagedPierCandidateExecutionFixture(executionId),
20
+ context: {
21
+ request: {
22
+ executionId,
23
+ executionPlan: { value: { digest: executionPlanDigest } },
24
+ } as AgentCandidateExecutorRequest,
25
+ traceStore: new InMemoryTraceStore(),
26
+ signal: new AbortController().signal,
27
+ deadlineAtMs: Date.now() + 30_000,
28
+ },
29
+ }
30
+ }
31
+
32
+ test('an existing Pier job is rejected without deleting or starting it', async () => {
33
+ const root = await mkdtemp(path.join(tmpdir(), 'pier-controller-scope-'))
34
+ const controlRoot = path.join(root, 'control')
35
+ const jobsDirectory = path.join(root, 'jobs')
36
+ const jobName = 'already-owned'
37
+ const marker = path.join(jobsDirectory, jobName, 'keep')
38
+ const fakeDocker = path.join(root, 'docker')
39
+ try {
40
+ await mkdir(path.dirname(marker), { recursive: true })
41
+ await writeFile(marker, 'do not delete\n')
42
+ await writeFile(fakeDocker, '#!/bin/sh\nexit 0\n', { mode: 0o700 })
43
+ await chmod(fakeDocker, 0o700)
44
+ const controller = new FilePierCandidateTrialController({
45
+ directory: controlRoot,
46
+ launch: () => ({
47
+ command: process.execPath,
48
+ args: ['-e', 'process.exit(0)'],
49
+ cwd: root,
50
+ env: { ...process.env },
51
+ jobsDirectory,
52
+ jobName,
53
+ dockerCommand: fakeDocker,
54
+ readResult: async () => {
55
+ throw new Error('existing job must never launch')
56
+ },
57
+ }),
58
+ })
59
+ const { staged, context } = testRequest(
60
+ 'pier-job-scope',
61
+ `sha256:${'c'.repeat(64)}`,
62
+ )
63
+ assert.throws(
64
+ () => controller.start(staged, context),
65
+ /job name must be unique/,
66
+ )
67
+ assert.equal(await readFile(marker, 'utf8'), 'do not delete\n')
68
+ assert.deepEqual(await readdir(controlRoot), [])
69
+ } finally {
70
+ await rm(root, { recursive: true, force: true })
71
+ }
72
+ })
73
+
74
+ test('the Pier result wait does not add time beyond an expired deadline', async () => {
75
+ const root = await mkdtemp(path.join(tmpdir(), 'pier-controller-deadline-'))
76
+ const controlRoot = path.join(root, 'control')
77
+ const jobsDirectory = path.join(root, 'jobs')
78
+ const fakeDocker = path.join(root, 'docker')
79
+ const readyPath = path.join(root, 'supervisor-ready')
80
+ const supervisorPath = path.join(root, 'supervisor.mjs')
81
+ let handle: ReturnType<FilePierCandidateTrialController['start']> | undefined
82
+ let resultSettled: Promise<unknown> | undefined
83
+ try {
84
+ await writeFile(fakeDocker, '#!/bin/sh\nexit 0\n', { mode: 0o700 })
85
+ await chmod(fakeDocker, 0o700)
86
+ await writeFile(
87
+ supervisorPath,
88
+ `import { createReadStream } from 'node:fs'
89
+ import { writeFileSync } from 'node:fs'
90
+ import path from 'node:path'
91
+ const directory = process.argv[2]
92
+ process.on('SIGTERM', () => {
93
+ writeFileSync(path.join(directory, 'terminal.json'), JSON.stringify({
94
+ schemaVersion: 1,
95
+ kind: 'pier-trial-terminal',
96
+ status: 'stopped',
97
+ processExited: true,
98
+ containersRemoved: true,
99
+ }))
100
+ process.exit(0)
101
+ })
102
+ for await (const _chunk of createReadStream('', { fd: 3 })) {}
103
+ writeFileSync(${JSON.stringify(readyPath)}, 'ready')
104
+ setInterval(() => undefined, 1_000)
105
+ `,
106
+ )
107
+ const controller = new FilePierCandidateTrialController({
108
+ directory: controlRoot,
109
+ supervisorPath,
110
+ pollIntervalMs: 5,
111
+ launch: () => ({
112
+ command: process.execPath,
113
+ args: ['-e', 'process.exit(0)'],
114
+ cwd: root,
115
+ env: {},
116
+ jobsDirectory,
117
+ jobName: 'expired-deadline',
118
+ dockerCommand: fakeDocker,
119
+ readResult: async () => {
120
+ throw new Error('an expired trial must not read a result')
121
+ },
122
+ }),
123
+ })
124
+ const { staged, context } = testRequest(
125
+ 'pier-expired-deadline',
126
+ `sha256:${'e'.repeat(64)}`,
127
+ )
128
+ context.deadlineAtMs = Date.now()
129
+ handle = controller.start(staged, context)
130
+ resultSettled = handle.result.catch((error) => error)
131
+
132
+ const outcome = await Promise.race([
133
+ resultSettled,
134
+ new Promise<'pending'>((resolvePending) => setTimeout(() => resolvePending('pending'), 500)),
135
+ ])
136
+ assert.notEqual(outcome, 'pending')
137
+ assert.match(String(outcome), /no terminal acknowledgement/)
138
+ } finally {
139
+ if (handle) await handle.terminateAndWait().catch(() => undefined)
140
+ if (resultSettled) await resultSettled
141
+ await rm(root, { recursive: true, force: true })
142
+ }
143
+ })
144
+
145
+ test('the supervisor receives only launch data and no inherited evaluator environment', async () => {
146
+ const root = await mkdtemp(path.join(tmpdir(), 'pier-controller-supervisor-env-'))
147
+ const controlRoot = path.join(root, 'control')
148
+ const jobsDirectory = path.join(root, 'jobs')
149
+ const fakeDocker = path.join(root, 'docker')
150
+ const supervisorPath = path.join(root, 'supervisor.mjs')
151
+ const previousSecret = process.env.PIER_PARENT_SECRET
152
+ let handle: ReturnType<FilePierCandidateTrialController['start']> | undefined
153
+ try {
154
+ process.env.PIER_PARENT_SECRET = 'must-not-be-inherited'
155
+ await writeFile(fakeDocker, '#!/bin/sh\nexit 0\n', { mode: 0o700 })
156
+ await chmod(fakeDocker, 0o700)
157
+ await writeFile(
158
+ supervisorPath,
159
+ `import { createReadStream, writeFileSync } from 'node:fs'
160
+ import path from 'node:path'
161
+ const chunks = []
162
+ for await (const chunk of createReadStream('', { fd: 3 })) chunks.push(Buffer.from(chunk))
163
+ const launch = JSON.parse(Buffer.concat(chunks).toString('utf8'))
164
+ const isolated = process.env.PIER_SUPERVISOR_MODE === 'explicit' &&
165
+ process.env.PIER_PARENT_SECRET === undefined &&
166
+ launch.env.PIER_PARENT_SECRET === undefined &&
167
+ launch.env.PIER_CHILD_SECRET === 'child-only'
168
+ writeFileSync(path.join(process.argv[2], 'terminal.json'), JSON.stringify({
169
+ schemaVersion: 1,
170
+ kind: 'pier-trial-terminal',
171
+ status: isolated ? 'completed' : 'failed',
172
+ processExited: true,
173
+ containersRemoved: true,
174
+ ...(isolated ? {} : { error: 'supervisor inherited evaluator environment' }),
175
+ }))
176
+ `,
177
+ )
178
+ const controller = new FilePierCandidateTrialController({
179
+ directory: controlRoot,
180
+ supervisorPath,
181
+ pollIntervalMs: 5,
182
+ dockerConnection: {
183
+ id: 'explicit-supervisor-test',
184
+ env: { PIER_SUPERVISOR_MODE: 'explicit' },
185
+ },
186
+ launch: () => ({
187
+ command: process.execPath,
188
+ args: ['-e', 'process.exit(0)'],
189
+ cwd: root,
190
+ env: { PIER_CHILD_SECRET: 'child-only' },
191
+ jobsDirectory,
192
+ jobName: 'isolated-supervisor',
193
+ dockerCommand: fakeDocker,
194
+ readResult: async () => ({
195
+ value: { output: 'isolated' },
196
+ resultBytes: Uint8Array.from(Buffer.from('{}')),
197
+ taskPatch: new Uint8Array(),
198
+ }),
199
+ }),
200
+ })
201
+ const { staged, context } = testRequest(
202
+ 'pier-isolated-supervisor',
203
+ `sha256:${'f'.repeat(64)}`,
204
+ )
205
+ handle = controller.start(staged, context)
206
+ assert.deepEqual((await handle.result).value, { output: 'isolated' })
207
+ } finally {
208
+ if (previousSecret === undefined) delete process.env.PIER_PARENT_SECRET
209
+ else process.env.PIER_PARENT_SECRET = previousSecret
210
+ if (handle) await handle.terminateAndWait().catch(() => undefined)
211
+ await rm(root, { recursive: true, force: true })
212
+ }
213
+ })
214
+
215
+ test('terminal acknowledgements reject unknown fields', async () => {
216
+ const root = await mkdtemp(path.join(tmpdir(), 'pier-controller-terminal-schema-'))
217
+ const controlRoot = path.join(root, 'control')
218
+ const jobsDirectory = path.join(root, 'jobs')
219
+ const fakeDocker = path.join(root, 'docker')
220
+ const supervisorPath = path.join(root, 'supervisor.mjs')
221
+ let handle: ReturnType<FilePierCandidateTrialController['start']> | undefined
222
+ try {
223
+ await writeFile(fakeDocker, '#!/bin/sh\nexit 0\n', { mode: 0o700 })
224
+ await chmod(fakeDocker, 0o700)
225
+ await writeFile(
226
+ supervisorPath,
227
+ `import { createReadStream, writeFileSync } from 'node:fs'
228
+ import path from 'node:path'
229
+ for await (const _chunk of createReadStream('', { fd: 3 })) {}
230
+ writeFileSync(path.join(process.argv[2], 'terminal.json'), JSON.stringify({
231
+ schemaVersion: 1,
232
+ kind: 'pier-trial-terminal',
233
+ status: 'completed',
234
+ processExited: true,
235
+ containersRemoved: true,
236
+ containerRemoved: true,
237
+ }))
238
+ `,
239
+ )
240
+ const controller = new FilePierCandidateTrialController({
241
+ directory: controlRoot,
242
+ supervisorPath,
243
+ pollIntervalMs: 5,
244
+ launch: () => ({
245
+ command: process.execPath,
246
+ args: ['-e', 'process.exit(0)'],
247
+ cwd: root,
248
+ env: {},
249
+ jobsDirectory,
250
+ jobName: 'strict-terminal',
251
+ dockerCommand: fakeDocker,
252
+ readResult: async () => {
253
+ throw new Error('a malformed terminal must not read a result')
254
+ },
255
+ }),
256
+ })
257
+ const { staged, context } = testRequest(
258
+ 'pier-strict-terminal',
259
+ `sha256:${'0'.repeat(64)}`,
260
+ )
261
+ handle = controller.start(staged, context)
262
+ await assert.rejects(handle.result, /terminal is malformed/)
263
+ } finally {
264
+ if (handle) await handle.terminateAndWait().catch(() => undefined)
265
+ await rm(root, { recursive: true, force: true })
266
+ }
267
+ })
268
+
269
+ test('a fresh evaluator process terminates the persisted process and container identity', async () => {
270
+ const root = await mkdtemp(path.join(tmpdir(), 'pier-controller-recovery-'))
271
+ const controlRoot = path.join(root, 'control')
272
+ const jobsDirectory = path.join(root, 'jobs')
273
+ const jobName = 'recovery-job'
274
+ const trialName = 'trial-recovery'
275
+ const primaryContainerState = path.join(root, 'primary-container-live')
276
+ const otherContainerState = path.join(root, 'other-container-live')
277
+ const fakeDocker = path.join(root, 'docker')
278
+ const childPidPath = path.join(root, 'child.pid')
279
+ const executionId = 'pier-fresh-process-recovery'
280
+ const executionPlanDigest = `sha256:${'d'.repeat(64)}` as const
281
+ let handle: ReturnType<FilePierCandidateTrialController['start']> | undefined
282
+ let resultSettled: Promise<unknown> | undefined
283
+
284
+ try {
285
+ await writeFile(primaryContainerState, 'live\n')
286
+ await writeFile(otherContainerState, 'live\n')
287
+ await writeFile(
288
+ fakeDocker,
289
+ `#!/bin/sh
290
+ set -eu
291
+ state="\${FAKE_DOCKER_STATE:?missing FAKE_DOCKER_STATE}"
292
+ if test "\${1:-}" = ps; then
293
+ test -f "$state" && printf 'fake-container\\t${trialName}\\n'
294
+ exit 0
295
+ fi
296
+ if test "\${1:-}" = rm; then
297
+ rm -f -- "$state"
298
+ exit 0
299
+ fi
300
+ printf 'unexpected fake docker invocation: %s\\n' "$*" >&2
301
+ exit 2
302
+ `,
303
+ { mode: 0o700 },
304
+ )
305
+ await chmod(fakeDocker, 0o700)
306
+
307
+ const controller = new FilePierCandidateTrialController({
308
+ directory: controlRoot,
309
+ dockerConnection: {
310
+ id: 'fake-primary',
311
+ env: { FAKE_DOCKER_STATE: primaryContainerState },
312
+ },
313
+ launch: () => ({
314
+ command: process.execPath,
315
+ args: [
316
+ '-e',
317
+ `const { spawn } = require('node:child_process'); const { mkdirSync, writeFileSync } = require('node:fs'); mkdirSync(${JSON.stringify(path.join(jobsDirectory, jobName, trialName))}, { recursive: true }); const child = spawn(process.execPath, ['-e', 'setInterval(() => undefined, 1_000)'], { stdio: 'ignore' }); writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid)); setInterval(() => undefined, 1_000)`,
318
+ ],
319
+ cwd: root,
320
+ env: {},
321
+ jobsDirectory,
322
+ jobName,
323
+ dockerCommand: fakeDocker,
324
+ readResult: async () => {
325
+ throw new Error('recovery trial must not complete normally')
326
+ },
327
+ }),
328
+ })
329
+ const { staged, context } = testRequest(executionId, executionPlanDigest)
330
+ handle = controller.start(staged, context)
331
+ resultSettled = handle.result.catch(() => undefined)
332
+
333
+ const [slot] = await readdir(controlRoot)
334
+ assert.ok(slot)
335
+ const identityPath = path.join(controlRoot, slot, 'identity.json')
336
+ let persisted: { state?: string; pier?: { pid: number } } = {}
337
+ for (let attempt = 0; attempt < 200; attempt++) {
338
+ persisted = JSON.parse(await readFile(identityPath, 'utf8'))
339
+ if (persisted.state === 'running' && persisted.pier) break
340
+ await new Promise((resolveWait) => setTimeout(resolveWait, 10))
341
+ }
342
+ assert.equal(persisted.state, 'running')
343
+ assert.ok(persisted.pier)
344
+ let childPid: number | undefined
345
+ for (let attempt = 0; attempt < 200; attempt++) {
346
+ try {
347
+ childPid = Number(await readFile(childPidPath, 'utf8'))
348
+ if (Number.isSafeInteger(childPid)) break
349
+ } catch {}
350
+ await new Promise((resolveWait) => setTimeout(resolveWait, 10))
351
+ }
352
+ assert.ok(childPid)
353
+
354
+ const recoveryScript = path.resolve('scripts/terminate-pier-trial.mts')
355
+ const recoveryArgs = [
356
+ '--import',
357
+ 'tsx',
358
+ recoveryScript,
359
+ controlRoot,
360
+ executionId,
361
+ executionPlanDigest,
362
+ ]
363
+ const recoveryEnvironment = {
364
+ ...process.env,
365
+ PIER_DOCKER_CONNECTION_ID: 'fake-primary',
366
+ PIER_DOCKER_ENV_NAMES: 'FAKE_DOCKER_STATE',
367
+ FAKE_DOCKER_STATE: primaryContainerState,
368
+ }
369
+ await assert.rejects(
370
+ execFileAsync(process.execPath, recoveryArgs, {
371
+ cwd: path.resolve('.'),
372
+ env: {
373
+ ...recoveryEnvironment,
374
+ PIER_DOCKER_CONNECTION_ID: 'fake-other',
375
+ FAKE_DOCKER_STATE: otherContainerState,
376
+ },
377
+ timeout: 20_000,
378
+ }),
379
+ (error: unknown) =>
380
+ String((error as { stderr?: string }).stderr).includes(
381
+ 'requires Docker connection fake-primary',
382
+ ),
383
+ )
384
+ const recovered = await Promise.all([
385
+ execFileAsync(process.execPath, recoveryArgs, {
386
+ cwd: path.resolve('.'),
387
+ env: recoveryEnvironment,
388
+ timeout: 20_000,
389
+ }),
390
+ execFileAsync(process.execPath, recoveryArgs, {
391
+ cwd: path.resolve('.'),
392
+ env: recoveryEnvironment,
393
+ timeout: 20_000,
394
+ }),
395
+ ])
396
+ for (const worker of recovered) {
397
+ assert.deepEqual(JSON.parse(worker.stdout), {
398
+ processExited: true,
399
+ containersRemoved: true,
400
+ })
401
+ }
402
+ await assert.rejects(readFile(primaryContainerState), /ENOENT/)
403
+ assert.equal(await readFile(otherContainerState, 'utf8'), 'live\n')
404
+ assert.throws(() => process.kill(persisted.pier!.pid, 0), /ESRCH/)
405
+ assert.throws(() => process.kill(childPid!, 0), /ESRCH/)
406
+ await resultSettled
407
+ } finally {
408
+ if (handle) await handle.terminateAndWait().catch(() => undefined)
409
+ if (resultSettled) await resultSettled
410
+ await rm(root, { recursive: true, force: true })
411
+ }
412
+ })