@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,858 @@
1
+ /** Restart-safe evaluator ownership for one Pier process and its Docker projects. */
2
+ import { execFileSync, spawn } from 'node:child_process'
3
+ import { createHash } from 'node:crypto'
4
+ import {
5
+ accessSync,
6
+ closeSync,
7
+ constants,
8
+ existsSync,
9
+ fsyncSync,
10
+ lstatSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ readdirSync,
15
+ realpathSync,
16
+ renameSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from 'node:fs'
20
+ import { isAbsolute, join, resolve } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+ import type { Writable } from 'node:stream'
23
+
24
+ import type { AgentCandidateExecutorRequest } from '@tangle-network/agent-runtime'
25
+ import type { TraceStore } from '@tangle-network/agent-eval'
26
+
27
+ import type {
28
+ PierCandidateTerminationAcknowledgement,
29
+ PierCandidateTrialController,
30
+ PierCandidateTrialHandle,
31
+ PierCandidateTrialIdentity,
32
+ PierCandidateTrialResult,
33
+ StagedPierCandidateExecution,
34
+ } from './pier-agent'
35
+
36
+ const identityFile = 'identity.json'
37
+ const terminalFile = 'terminal.json'
38
+ const stopFile = 'stop-requested'
39
+ const sha256Pattern = /^sha256:[a-f0-9]{64}$/
40
+ const defaultDockerConnectionId = 'local-default'
41
+
42
+ interface ProcessIdentity {
43
+ readonly pid: number
44
+ readonly processGroupId: number
45
+ readonly sessionId: number
46
+ readonly startTicks: string
47
+ }
48
+
49
+ interface PersistedTrialIdentity {
50
+ readonly schemaVersion: 1
51
+ readonly kind: 'pier-trial-identity'
52
+ readonly state: 'allocating' | 'starting' | 'running'
53
+ readonly executionId: string
54
+ readonly executionPlanDigest: `sha256:${string}`
55
+ readonly supervisor?: ProcessIdentity
56
+ readonly pier?: ProcessIdentity
57
+ readonly jobsDirectory: string
58
+ readonly jobName: string
59
+ readonly dockerCommand: string
60
+ readonly dockerConnectionId: string
61
+ }
62
+
63
+ interface PersistedTrialTerminal {
64
+ readonly schemaVersion: 1
65
+ readonly kind: 'pier-trial-terminal'
66
+ readonly status: 'completed' | 'stopped' | 'failed'
67
+ readonly processExited: boolean
68
+ readonly containersRemoved: boolean
69
+ readonly removedContainers?: number
70
+ readonly exitCode?: number | null
71
+ readonly signal?: NodeJS.Signals | null
72
+ readonly error?: string
73
+ readonly recoveredByPid?: number
74
+ }
75
+
76
+ export interface PierCandidateProcessSpec {
77
+ /** Returns launch data only. The controller, not this callback, starts the process. */
78
+ readonly command: string
79
+ readonly args: readonly string[]
80
+ readonly cwd: string
81
+ /** Exact Pier child environment. The controller adds its Docker connection variables. */
82
+ readonly env: Readonly<Record<string, string | undefined>>
83
+ readonly jobsDirectory: string
84
+ /** Must be unique: the controller atomically reserves this Pier job directory. */
85
+ readonly jobName: string
86
+ readonly dockerCommand?: string
87
+ /** Called only by the originating process after the supervisor reports clean exit. */
88
+ readonly readResult: () => Promise<PierCandidateTrialResult>
89
+ }
90
+
91
+ export interface PierDockerConnection {
92
+ /** Stable, non-secret name that every recovery worker maps to the same Docker endpoint. */
93
+ readonly id: string
94
+ /** Exact variables needed by both Pier and Docker cleanup; values are never persisted. */
95
+ readonly env: Readonly<Record<string, string | undefined>>
96
+ }
97
+
98
+ export interface FilePierCandidateTrialControllerOptions {
99
+ /** Shared evaluator-owned control root, available to crash-recovery workers. */
100
+ readonly directory: string
101
+ readonly launch?: (
102
+ staged: StagedPierCandidateExecution,
103
+ context: {
104
+ readonly request: AgentCandidateExecutorRequest
105
+ readonly traceStore: TraceStore
106
+ readonly signal: AbortSignal
107
+ readonly deadlineAtMs: number
108
+ },
109
+ ) => PierCandidateProcessSpec
110
+ /** Restart-safe reader for immutable official bytes under the persisted Pier job identity. */
111
+ readonly readResult?: (input: {
112
+ readonly jobsDirectory: string
113
+ readonly jobName: string
114
+ }) => Promise<PierCandidateTrialResult | undefined>
115
+ /** Omit only for the default local Docker socket with no environment variables. */
116
+ readonly dockerConnection?: PierDockerConnection
117
+ readonly supervisorPath?: string
118
+ readonly pollIntervalMs?: number
119
+ }
120
+
121
+ function nonEmpty(value: string, label: string): string {
122
+ if (!value || value.includes('\0')) throw new Error(`${label} must be non-empty without NUL`)
123
+ return value
124
+ }
125
+
126
+ function stableDockerConnectionId(value: string): string {
127
+ if (!/^[A-Za-z0-9._:-]{1,200}$/.test(value)) {
128
+ throw new Error('Docker connection id must be a non-secret stable name')
129
+ }
130
+ return value
131
+ }
132
+
133
+ function absolutePath(value: string, label: string): string {
134
+ if (!isAbsolute(value)) throw new Error(`${label} must be an absolute path`)
135
+ return resolve(value)
136
+ }
137
+
138
+ function validateEnvironment(
139
+ environment: Readonly<Record<string, string | undefined>>,
140
+ label: string,
141
+ ): void {
142
+ for (const [name, value] of Object.entries(environment)) {
143
+ if (!name || name.includes('\0') || value?.includes('\0')) {
144
+ throw new Error(`${label} contains an invalid name or value`)
145
+ }
146
+ }
147
+ }
148
+
149
+ function resolveExecutable(command: string, searchPath: string | undefined, label: string): string {
150
+ nonEmpty(command, label)
151
+ const candidates = command.includes('/')
152
+ ? [absolutePath(command, label)]
153
+ : (searchPath ?? '')
154
+ .split(':')
155
+ .filter(Boolean)
156
+ .map((directory) => join(directory, command))
157
+ for (const candidate of candidates) {
158
+ try {
159
+ accessSync(candidate, constants.X_OK)
160
+ return realpathSync(candidate)
161
+ } catch (error) {
162
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && (error as NodeJS.ErrnoException).code !== 'EACCES') {
163
+ throw error
164
+ }
165
+ }
166
+ }
167
+ throw new Error(`${label} is not executable on the evaluator PATH`)
168
+ }
169
+
170
+ function syncDirectory(directory: string): void {
171
+ const fd = openSync(directory, 'r')
172
+ try {
173
+ fsyncSync(fd)
174
+ } finally {
175
+ closeSync(fd)
176
+ }
177
+ }
178
+
179
+ function writeJsonAtomic(directory: string, name: string, value: unknown): void {
180
+ const target = join(directory, name)
181
+ const temporary = join(directory, `.${name}.${process.pid}.${Date.now()}.tmp`)
182
+ const fd = openSync(temporary, 'wx', 0o600)
183
+ try {
184
+ writeFileSync(fd, `${JSON.stringify(value)}\n`, 'utf8')
185
+ fsyncSync(fd)
186
+ } finally {
187
+ closeSync(fd)
188
+ }
189
+ renameSync(temporary, target)
190
+ syncDirectory(directory)
191
+ }
192
+
193
+ function touchDurable(directory: string, name: string): void {
194
+ try {
195
+ const fd = openSync(join(directory, name), 'wx', 0o600)
196
+ try {
197
+ fsyncSync(fd)
198
+ } finally {
199
+ closeSync(fd)
200
+ }
201
+ syncDirectory(directory)
202
+ } catch (error) {
203
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
204
+ }
205
+ }
206
+
207
+ function readJson(path: string, label: string): Record<string, unknown> {
208
+ let value: unknown
209
+ try {
210
+ value = JSON.parse(readFileSync(path, 'utf8'))
211
+ } catch (error) {
212
+ throw new Error(`${label} is not valid JSON`, { cause: error })
213
+ }
214
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
215
+ throw new Error(`${label} must be an object`)
216
+ }
217
+ return value as Record<string, unknown>
218
+ }
219
+
220
+ function parseProcessIdentity(value: unknown, label: string): ProcessIdentity {
221
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
222
+ throw new Error(`${label} must be an object`)
223
+ }
224
+ const record = value as Record<string, unknown>
225
+ if (
226
+ !Number.isSafeInteger(record.pid) ||
227
+ !Number.isSafeInteger(record.processGroupId) ||
228
+ !Number.isSafeInteger(record.sessionId) ||
229
+ typeof record.startTicks !== 'string' ||
230
+ !/^\d+$/.test(record.startTicks)
231
+ ) {
232
+ throw new Error(`${label} is malformed`)
233
+ }
234
+ return {
235
+ pid: record.pid as number,
236
+ processGroupId: record.processGroupId as number,
237
+ sessionId: record.sessionId as number,
238
+ startTicks: record.startTicks,
239
+ }
240
+ }
241
+
242
+ function parsePersistedIdentity(path: string): PersistedTrialIdentity {
243
+ const record = readJson(path, 'Pier trial identity')
244
+ if (
245
+ record.schemaVersion !== 1 ||
246
+ record.kind !== 'pier-trial-identity' ||
247
+ !['allocating', 'starting', 'running'].includes(record.state as string) ||
248
+ typeof record.executionId !== 'string' ||
249
+ typeof record.executionPlanDigest !== 'string' ||
250
+ !sha256Pattern.test(record.executionPlanDigest) ||
251
+ typeof record.jobsDirectory !== 'string' ||
252
+ typeof record.jobName !== 'string' ||
253
+ typeof record.dockerCommand !== 'string' ||
254
+ typeof record.dockerConnectionId !== 'string'
255
+ ) {
256
+ throw new Error('Pier trial identity is malformed')
257
+ }
258
+ return {
259
+ schemaVersion: 1,
260
+ kind: 'pier-trial-identity',
261
+ state: record.state as PersistedTrialIdentity['state'],
262
+ executionId: record.executionId,
263
+ executionPlanDigest: record.executionPlanDigest as `sha256:${string}`,
264
+ ...(record.supervisor
265
+ ? { supervisor: parseProcessIdentity(record.supervisor, 'Pier supervisor identity') }
266
+ : {}),
267
+ ...(record.pier ? { pier: parseProcessIdentity(record.pier, 'Pier process identity') } : {}),
268
+ jobsDirectory: absolutePath(record.jobsDirectory, 'persisted jobs directory'),
269
+ jobName: nonEmpty(record.jobName, 'persisted job name'),
270
+ dockerCommand: nonEmpty(record.dockerCommand, 'persisted Docker command'),
271
+ dockerConnectionId: stableDockerConnectionId(record.dockerConnectionId),
272
+ }
273
+ }
274
+
275
+ function parseTerminal(path: string): PersistedTrialTerminal {
276
+ const record = readJson(path, 'Pier trial terminal')
277
+ const allowedKeys = new Set([
278
+ 'schemaVersion',
279
+ 'kind',
280
+ 'status',
281
+ 'processExited',
282
+ 'containersRemoved',
283
+ 'removedContainers',
284
+ 'exitCode',
285
+ 'signal',
286
+ 'error',
287
+ 'recoveredByPid',
288
+ ])
289
+ const hasMalformedOptionalField =
290
+ (record.removedContainers !== undefined &&
291
+ (!Number.isSafeInteger(record.removedContainers) || (record.removedContainers as number) < 0)) ||
292
+ (record.exitCode !== undefined &&
293
+ record.exitCode !== null &&
294
+ !Number.isSafeInteger(record.exitCode)) ||
295
+ (record.signal !== undefined &&
296
+ record.signal !== null &&
297
+ (typeof record.signal !== 'string' || !/^SIG[A-Z0-9]+$/.test(record.signal))) ||
298
+ (record.error !== undefined && typeof record.error !== 'string') ||
299
+ (record.recoveredByPid !== undefined &&
300
+ (!Number.isSafeInteger(record.recoveredByPid) || (record.recoveredByPid as number) < 1))
301
+ if (
302
+ Object.keys(record).some((key) => !allowedKeys.has(key)) ||
303
+ record.schemaVersion !== 1 ||
304
+ record.kind !== 'pier-trial-terminal' ||
305
+ !['completed', 'stopped', 'failed'].includes(record.status as string) ||
306
+ typeof record.processExited !== 'boolean' ||
307
+ typeof record.containersRemoved !== 'boolean' ||
308
+ hasMalformedOptionalField
309
+ ) {
310
+ throw new Error('Pier trial terminal is malformed')
311
+ }
312
+ return {
313
+ schemaVersion: 1,
314
+ kind: 'pier-trial-terminal',
315
+ status: record.status as PersistedTrialTerminal['status'],
316
+ processExited: record.processExited,
317
+ containersRemoved: record.containersRemoved,
318
+ ...(record.removedContainers !== undefined
319
+ ? { removedContainers: record.removedContainers as number }
320
+ : {}),
321
+ ...(record.exitCode !== undefined ? { exitCode: record.exitCode as number | null } : {}),
322
+ ...(record.signal !== undefined ? { signal: record.signal as NodeJS.Signals | null } : {}),
323
+ ...(record.error !== undefined ? { error: record.error as string } : {}),
324
+ ...(record.recoveredByPid !== undefined
325
+ ? { recoveredByPid: record.recoveredByPid as number }
326
+ : {}),
327
+ }
328
+ }
329
+
330
+ function processIdentity(pid: number): ProcessIdentity {
331
+ const text = readFileSync(`/proc/${pid}/stat`, 'utf8')
332
+ const closing = text.lastIndexOf(')')
333
+ if (closing < 0) throw new Error(`cannot parse process identity for ${pid}`)
334
+ const fields = text.slice(closing + 2).trim().split(/\s+/)
335
+ const processGroupId = Number(fields[2])
336
+ const sessionId = Number(fields[3])
337
+ const startTicks = fields[19]
338
+ if (
339
+ !Number.isSafeInteger(processGroupId) ||
340
+ !Number.isSafeInteger(sessionId) ||
341
+ !/^\d+$/.test(startTicks ?? '')
342
+ ) {
343
+ throw new Error(`cannot parse process identity for ${pid}`)
344
+ }
345
+ return { pid, processGroupId, sessionId, startTicks }
346
+ }
347
+
348
+ function processMatches(identity: ProcessIdentity): boolean {
349
+ try {
350
+ const current = processIdentity(identity.pid)
351
+ return (
352
+ current.startTicks === identity.startTicks &&
353
+ current.processGroupId === identity.processGroupId &&
354
+ current.sessionId === identity.sessionId
355
+ )
356
+ } catch (error) {
357
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
358
+ throw error
359
+ }
360
+ }
361
+
362
+ function signalProcess(identity: ProcessIdentity, signal: NodeJS.Signals, group: boolean): void {
363
+ if (!processMatches(identity)) return
364
+ try {
365
+ process.kill(group ? -identity.processGroupId : identity.pid, signal)
366
+ } catch (error) {
367
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
368
+ }
369
+ }
370
+
371
+ function sessionMembers(sessionId: number): ProcessIdentity[] {
372
+ const members: ProcessIdentity[] = []
373
+ for (const entry of readdirSync('/proc', { withFileTypes: true })) {
374
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue
375
+ try {
376
+ const identity = processIdentity(Number(entry.name))
377
+ if (identity.sessionId === sessionId) members.push(identity)
378
+ } catch (error) {
379
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
380
+ }
381
+ }
382
+ return members
383
+ }
384
+
385
+ async function signalSessionUntilEmpty(
386
+ identity: ProcessIdentity,
387
+ signal: NodeJS.Signals,
388
+ timeoutMs: number,
389
+ intervalMs: number,
390
+ ): Promise<boolean> {
391
+ const deadline = Date.now() + timeoutMs
392
+ while (true) {
393
+ const members = sessionMembers(identity.sessionId)
394
+ if (members.length === 0) return true
395
+ for (const member of members) signalProcess(member, signal, false)
396
+ if (Date.now() >= deadline) return false
397
+ await sleep(intervalMs)
398
+ }
399
+ }
400
+
401
+ function sanitizeProject(value: string): string {
402
+ let project = value.toLowerCase()
403
+ if (!/^[a-z0-9]/.test(project)) project = `0${project}`
404
+ return project.replace(/[^a-z0-9_-]/g, '-')
405
+ }
406
+
407
+ function trialProjects(identity: PersistedTrialIdentity): string[] {
408
+ const jobRoot = join(identity.jobsDirectory, identity.jobName)
409
+ if (!existsSync(jobRoot)) return []
410
+ return readdirSync(jobRoot, { withFileTypes: true })
411
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
412
+ .map((entry) => sanitizeProject(entry.name))
413
+ }
414
+
415
+ function matchingContainers(
416
+ identity: PersistedTrialIdentity,
417
+ dockerEnvironment: Readonly<Record<string, string | undefined>>,
418
+ ): string[] {
419
+ const projects = trialProjects(identity)
420
+ if (projects.length === 0) return []
421
+ const output = execFileSync(
422
+ identity.dockerCommand,
423
+ ['ps', '-a', '--format', '{{.ID}}\t{{.Label "com.docker.compose.project"}}'],
424
+ {
425
+ encoding: 'utf8',
426
+ env: dockerEnvironment,
427
+ timeout: 30_000,
428
+ maxBuffer: 4 * 1024 * 1024,
429
+ },
430
+ )
431
+ return output
432
+ .split('\n')
433
+ .filter(Boolean)
434
+ .flatMap((line) => {
435
+ const tab = line.indexOf('\t')
436
+ if (tab < 1) return []
437
+ const id = line.slice(0, tab)
438
+ const project = line.slice(tab + 1)
439
+ return projects.some(
440
+ (expected) =>
441
+ project === expected || project.startsWith(`${expected}__verifier__`),
442
+ )
443
+ ? [id]
444
+ : []
445
+ })
446
+ }
447
+
448
+ function removeTrialContainers(
449
+ identity: PersistedTrialIdentity,
450
+ dockerEnvironment: Readonly<Record<string, string | undefined>>,
451
+ ): number {
452
+ const containers = matchingContainers(identity, dockerEnvironment)
453
+ if (containers.length > 0) {
454
+ try {
455
+ execFileSync(identity.dockerCommand, ['rm', '-f', ...containers], {
456
+ encoding: 'utf8',
457
+ env: dockerEnvironment,
458
+ timeout: 30_000,
459
+ maxBuffer: 4 * 1024 * 1024,
460
+ })
461
+ } catch (error) {
462
+ if (matchingContainers(identity, dockerEnvironment).length > 0) throw error
463
+ }
464
+ }
465
+ const remaining = matchingContainers(identity, dockerEnvironment)
466
+ if (remaining.length > 0) {
467
+ throw new Error(`Pier task containers survived removal: ${remaining.join(', ')}`)
468
+ }
469
+ return containers.length
470
+ }
471
+
472
+ function sleep(milliseconds: number): Promise<void> {
473
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds))
474
+ }
475
+
476
+ async function waitUntil(predicate: () => boolean, timeoutMs: number, intervalMs: number) {
477
+ const deadline = Date.now() + timeoutMs
478
+ while (!predicate()) {
479
+ if (Date.now() >= deadline) return false
480
+ await sleep(intervalMs)
481
+ }
482
+ return true
483
+ }
484
+
485
+ function trialKey(identity: PierCandidateTrialIdentity): string {
486
+ if (!sha256Pattern.test(identity.executionPlanDigest)) {
487
+ throw new Error('executionPlanDigest must be a SHA-256 digest')
488
+ }
489
+ return createHash('sha256')
490
+ .update(JSON.stringify([nonEmpty(identity.executionId, 'executionId'), identity.executionPlanDigest]))
491
+ .digest('hex')
492
+ }
493
+
494
+ function assertRealDirectory(path: string, label: string): void {
495
+ const stats = lstatSync(path)
496
+ if (!stats.isDirectory() || stats.isSymbolicLink() || realpathSync(path) !== path) {
497
+ throw new Error(`${label} must be a real canonical directory`)
498
+ }
499
+ }
500
+
501
+ export class FilePierCandidateTrialController implements PierCandidateTrialController {
502
+ private readonly directory: string
503
+ private readonly launch?: NonNullable<FilePierCandidateTrialControllerOptions['launch']>
504
+ private readonly readResult?: NonNullable<FilePierCandidateTrialControllerOptions['readResult']>
505
+ private readonly dockerConnection: PierDockerConnection
506
+ private readonly supervisorPath: string
507
+ private readonly pollIntervalMs: number
508
+
509
+ constructor(options: FilePierCandidateTrialControllerOptions) {
510
+ this.directory = absolutePath(options.directory, 'Pier controller directory')
511
+ this.launch = options.launch
512
+ this.readResult = options.readResult
513
+ const configuredDocker = options.dockerConnection
514
+ if (configuredDocker?.id === defaultDockerConnectionId) {
515
+ throw new Error(`${defaultDockerConnectionId} is reserved for the implicit local connection`)
516
+ }
517
+ const dockerConnection = configuredDocker ?? {
518
+ id: defaultDockerConnectionId,
519
+ env: {},
520
+ }
521
+ const connectionId = stableDockerConnectionId(dockerConnection.id)
522
+ validateEnvironment(dockerConnection.env, 'Docker connection environment')
523
+ this.dockerConnection = Object.freeze({
524
+ id: connectionId,
525
+ env: Object.freeze({ ...dockerConnection.env }),
526
+ })
527
+ this.supervisorPath = absolutePath(
528
+ options.supervisorPath ??
529
+ fileURLToPath(new URL('./pier-trial-supervisor.mjs', import.meta.url)),
530
+ 'Pier supervisor path',
531
+ )
532
+ this.pollIntervalMs = options.pollIntervalMs ?? 25
533
+ if (!Number.isSafeInteger(this.pollIntervalMs) || this.pollIntervalMs < 1) {
534
+ throw new Error('pollIntervalMs must be a positive integer')
535
+ }
536
+ mkdirSync(this.directory, { recursive: true, mode: 0o700 })
537
+ assertRealDirectory(this.directory, 'Pier controller directory')
538
+ }
539
+
540
+ start(
541
+ staged: StagedPierCandidateExecution,
542
+ context: {
543
+ readonly request: AgentCandidateExecutorRequest
544
+ readonly traceStore: TraceStore
545
+ readonly signal: AbortSignal
546
+ readonly deadlineAtMs: number
547
+ },
548
+ ): PierCandidateTrialHandle {
549
+ if (!this.launch) throw new Error('this Pier controller is recovery-only')
550
+ const identity: PierCandidateTrialIdentity = {
551
+ executionId: context.request.executionId,
552
+ executionPlanDigest: context.request.executionPlan.value.digest,
553
+ }
554
+ if (staged.executionId !== identity.executionId) {
555
+ throw new Error('staged Pier execution does not match the runtime request')
556
+ }
557
+
558
+ // The launch callback is a pure spec builder. Resolve every fallible input
559
+ // before reserving durable identities or starting a process.
560
+ const spec = this.launch(staged, context)
561
+ this.validateSpec(spec)
562
+ for (const [name, value] of Object.entries(this.dockerConnection.env)) {
563
+ if (
564
+ Object.prototype.hasOwnProperty.call(spec.env, name) &&
565
+ spec.env[name] !== value
566
+ ) {
567
+ throw new Error(`Pier child environment conflicts with Docker connection variable ${name}`)
568
+ }
569
+ }
570
+ const pierEnvironment = Object.freeze({
571
+ ...spec.env,
572
+ ...this.dockerConnection.env,
573
+ })
574
+ mkdirSync(spec.jobsDirectory, { recursive: true, mode: 0o700 })
575
+ assertRealDirectory(spec.jobsDirectory, 'Pier jobs directory')
576
+ const dockerCommand = resolveExecutable(
577
+ spec.dockerCommand ?? 'docker',
578
+ process.env.PATH,
579
+ 'Docker command',
580
+ )
581
+ const pierCommand = resolveExecutable(spec.command, pierEnvironment.PATH, 'Pier command')
582
+
583
+ const controlDirectory = this.controlDirectory(identity)
584
+ try {
585
+ mkdirSync(controlDirectory, { mode: 0o700 })
586
+ } catch (error) {
587
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
588
+ throw new Error('Pier trial identity already has durable control state')
589
+ }
590
+ throw error
591
+ }
592
+ assertRealDirectory(controlDirectory, 'Pier trial control directory')
593
+
594
+ // Pier creates each trial directory before starting its environment. An
595
+ // atomically reserved, one-execution job directory therefore makes every
596
+ // Compose project discovered below exclusive to this execution.
597
+ const jobRoot = join(spec.jobsDirectory, spec.jobName)
598
+ let jobRootCreated = false
599
+ try {
600
+ mkdirSync(jobRoot, { mode: 0o700 })
601
+ jobRootCreated = true
602
+ assertRealDirectory(jobRoot, 'Pier job directory')
603
+ } catch (error) {
604
+ // No identity or process exists yet; leave the execution reusable.
605
+ rmSync(controlDirectory, { recursive: true, force: true })
606
+ if (jobRootCreated) rmSync(jobRoot, { recursive: true, force: true })
607
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
608
+ throw new Error('Pier job name must be unique; its job directory already exists')
609
+ }
610
+ throw error
611
+ }
612
+ const allocating: PersistedTrialIdentity = {
613
+ schemaVersion: 1,
614
+ kind: 'pier-trial-identity',
615
+ state: 'allocating',
616
+ ...identity,
617
+ jobsDirectory: absolutePath(spec.jobsDirectory, 'Pier jobs directory'),
618
+ jobName: nonEmpty(spec.jobName, 'Pier job name'),
619
+ dockerCommand: nonEmpty(dockerCommand, 'Docker command'),
620
+ dockerConnectionId: this.dockerConnection.id,
621
+ }
622
+ try {
623
+ writeJsonAtomic(controlDirectory, identityFile, allocating)
624
+ } catch (error) {
625
+ // The supervisor can only start after this durable write succeeds.
626
+ rmSync(controlDirectory, { recursive: true, force: true })
627
+ rmSync(jobRoot, { recursive: true, force: true })
628
+ throw error
629
+ }
630
+
631
+ const supervisor = spawn(process.execPath, [this.supervisorPath, controlDirectory], {
632
+ detached: true,
633
+ // Launch data arrives over fd 3. The trusted supervisor receives only
634
+ // explicitly declared process variables, never the evaluator environment.
635
+ env: { ...this.dockerConnection.env },
636
+ stdio: ['ignore', 'ignore', 'ignore', 'pipe'],
637
+ })
638
+ if (supervisor.pid === undefined) throw new Error('Pier supervisor started without a pid')
639
+ const controlPipe = supervisor.stdio[3] as (Writable & { unref?: () => void }) | null
640
+ let supervisorIdentity: ProcessIdentity | undefined
641
+ try {
642
+ supervisorIdentity = processIdentity(supervisor.pid)
643
+ const persisted: PersistedTrialIdentity = {
644
+ ...allocating,
645
+ state: 'starting',
646
+ supervisor: supervisorIdentity,
647
+ }
648
+ writeJsonAtomic(controlDirectory, identityFile, persisted)
649
+ if (!controlPipe) throw new Error('Pier supervisor has no protected control pipe')
650
+ controlPipe.on('error', () => undefined)
651
+ controlPipe.end(
652
+ JSON.stringify({
653
+ command: pierCommand,
654
+ args: spec.args,
655
+ cwd: spec.cwd,
656
+ env: pierEnvironment,
657
+ jobsDirectory: spec.jobsDirectory,
658
+ jobName: spec.jobName,
659
+ dockerCommand,
660
+ }),
661
+ )
662
+ } catch (error) {
663
+ controlPipe?.destroy()
664
+ if (supervisorIdentity) signalProcess(supervisorIdentity, 'SIGKILL', false)
665
+ else {
666
+ try {
667
+ process.kill(supervisor.pid, 'SIGKILL')
668
+ } catch (killError) {
669
+ if ((killError as NodeJS.ErrnoException).code !== 'ESRCH') throw killError
670
+ }
671
+ }
672
+ throw error
673
+ }
674
+ supervisor.unref()
675
+ controlPipe.unref?.()
676
+
677
+ const result = this.waitForResult(controlDirectory, spec, context.deadlineAtMs)
678
+ return {
679
+ identity,
680
+ result,
681
+ terminateAndWait: async () => await this.terminateAndWait(identity),
682
+ }
683
+ }
684
+
685
+ async terminateAndWait(
686
+ requested: PierCandidateTrialIdentity,
687
+ ): Promise<PierCandidateTerminationAcknowledgement> {
688
+ const controlDirectory = this.controlDirectory(requested)
689
+ assertRealDirectory(controlDirectory, 'Pier trial control directory')
690
+ const persisted = parsePersistedIdentity(join(controlDirectory, identityFile))
691
+ if (
692
+ persisted.executionId !== requested.executionId ||
693
+ persisted.executionPlanDigest !== requested.executionPlanDigest
694
+ ) {
695
+ throw new Error('persisted Pier trial identity differs from the stop request')
696
+ }
697
+ this.assertDockerConnection(persisted)
698
+
699
+ touchDurable(controlDirectory, stopFile)
700
+ if (!existsSync(join(controlDirectory, terminalFile))) {
701
+ if (persisted.supervisor) signalProcess(persisted.supervisor, 'SIGTERM', false)
702
+ }
703
+ const terminalAppeared = await waitUntil(
704
+ () => existsSync(join(controlDirectory, terminalFile)),
705
+ persisted.supervisor ? 10_000 : 1,
706
+ this.pollIntervalMs,
707
+ )
708
+ if (!terminalAppeared) await this.forceRecovery(controlDirectory)
709
+
710
+ let terminal = parseTerminal(join(controlDirectory, terminalFile))
711
+ const latest = parsePersistedIdentity(join(controlDirectory, identityFile))
712
+ const liveProcesses = latest.pier ? sessionMembers(latest.pier.sessionId).length : 0
713
+ this.assertDockerConnection(latest)
714
+ const liveContainers = matchingContainers(latest, this.dockerConnection.env).length
715
+ if (liveProcesses > 0 || liveContainers > 0) {
716
+ await this.forceRecovery(controlDirectory)
717
+ terminal = parseTerminal(join(controlDirectory, terminalFile))
718
+ }
719
+ if (!terminal.processExited || !terminal.containersRemoved) {
720
+ throw new Error(
721
+ `Pier recovery did not prove process/container death${terminal.error ? `: ${terminal.error}` : ''}`,
722
+ )
723
+ }
724
+ return { processExited: true, containersRemoved: true }
725
+ }
726
+
727
+ async captureResult(
728
+ requested: PierCandidateTrialIdentity,
729
+ ): Promise<PierCandidateTrialResult | undefined> {
730
+ const controlDirectory = this.controlDirectory(requested)
731
+ assertRealDirectory(controlDirectory, 'Pier trial control directory')
732
+ const persisted = parsePersistedIdentity(join(controlDirectory, identityFile))
733
+ if (
734
+ persisted.executionId !== requested.executionId ||
735
+ persisted.executionPlanDigest !== requested.executionPlanDigest
736
+ ) {
737
+ throw new Error('persisted Pier trial identity differs from the capture request')
738
+ }
739
+ this.assertDockerConnection(persisted)
740
+ const terminal = parseTerminal(join(controlDirectory, terminalFile))
741
+ if (!terminal.processExited || !terminal.containersRemoved) {
742
+ throw new Error('Pier result capture requires proven process and container death')
743
+ }
744
+ if (!this.readResult) {
745
+ throw new Error('Pier result capture has no restart-safe result reader')
746
+ }
747
+ return await this.readResult({
748
+ jobsDirectory: persisted.jobsDirectory,
749
+ jobName: persisted.jobName,
750
+ })
751
+ }
752
+
753
+ private controlDirectory(identity: PierCandidateTrialIdentity): string {
754
+ return join(this.directory, trialKey(identity))
755
+ }
756
+
757
+ private assertDockerConnection(identity: PersistedTrialIdentity): void {
758
+ if (identity.dockerConnectionId !== this.dockerConnection.id) {
759
+ throw new Error(
760
+ `Pier trial requires Docker connection ${identity.dockerConnectionId}; configured ${this.dockerConnection.id}`,
761
+ )
762
+ }
763
+ }
764
+
765
+ private validateSpec(spec: PierCandidateProcessSpec): void {
766
+ nonEmpty(spec.command, 'Pier command')
767
+ if (!Array.isArray(spec.args) || spec.args.some((arg) => typeof arg !== 'string' || arg.includes('\0'))) {
768
+ throw new Error('Pier arguments must be strings without NUL')
769
+ }
770
+ absolutePath(spec.cwd, 'Pier cwd')
771
+ absolutePath(spec.jobsDirectory, 'Pier jobs directory')
772
+ if (!/^[A-Za-z0-9._-]{1,200}$/.test(spec.jobName)) {
773
+ throw new Error('Pier job name must be filesystem-neutral')
774
+ }
775
+ validateEnvironment(spec.env, 'Pier child environment')
776
+ }
777
+
778
+ private async waitForResult(
779
+ controlDirectory: string,
780
+ spec: PierCandidateProcessSpec,
781
+ deadlineAtMs: number,
782
+ ): Promise<PierCandidateTrialResult> {
783
+ const waitMs = Math.max(0, deadlineAtMs - Date.now())
784
+ const appeared = await waitUntil(
785
+ () => existsSync(join(controlDirectory, terminalFile)),
786
+ waitMs,
787
+ this.pollIntervalMs,
788
+ )
789
+ if (!appeared) throw new Error('Pier supervisor emitted no terminal acknowledgement')
790
+ const terminal = parseTerminal(join(controlDirectory, terminalFile))
791
+ if (!terminal.processExited || !terminal.containersRemoved) {
792
+ throw new Error('Pier supervisor did not prove process and container death')
793
+ }
794
+ if (terminal.status !== 'completed') {
795
+ throw new Error(terminal.error ? `Pier process failed: ${terminal.error}` : 'Pier process stopped')
796
+ }
797
+ return await spec.readResult()
798
+ }
799
+
800
+ private async forceRecovery(
801
+ controlDirectory: string,
802
+ ): Promise<void> {
803
+ const latest = parsePersistedIdentity(join(controlDirectory, identityFile))
804
+ this.assertDockerConnection(latest)
805
+ const errors: string[] = []
806
+ if (latest.pier) {
807
+ try {
808
+ await signalSessionUntilEmpty(latest.pier, 'SIGTERM', 2_000, this.pollIntervalMs)
809
+ } catch (error) {
810
+ errors.push(`could not signal Pier session: ${error instanceof Error ? error.message : String(error)}`)
811
+ }
812
+ }
813
+ if (latest.supervisor) {
814
+ try {
815
+ signalProcess(latest.supervisor, 'SIGKILL', false)
816
+ if (!(await waitUntil(() => !processMatches(latest.supervisor!), 5_000, this.pollIntervalMs))) {
817
+ errors.push(`Pier supervisor ${latest.supervisor.pid} survived SIGKILL`)
818
+ }
819
+ } catch (error) {
820
+ errors.push(`could not kill Pier supervisor: ${error instanceof Error ? error.message : String(error)}`)
821
+ }
822
+ }
823
+ if (latest.pier) {
824
+ try {
825
+ if (
826
+ !(await signalSessionUntilEmpty(
827
+ latest.pier,
828
+ 'SIGKILL',
829
+ 5_000,
830
+ this.pollIntervalMs,
831
+ ))
832
+ ) {
833
+ errors.push(`Pier process session ${latest.pier.sessionId} survived SIGKILL`)
834
+ }
835
+ } catch (error) {
836
+ errors.push(`could not kill Pier session: ${error instanceof Error ? error.message : String(error)}`)
837
+ }
838
+ }
839
+ let removedContainers = 0
840
+ try {
841
+ removedContainers = removeTrialContainers(latest, this.dockerConnection.env)
842
+ } catch (error) {
843
+ errors.push(`could not remove Pier containers: ${error instanceof Error ? error.message : String(error)}`)
844
+ }
845
+ if (errors.length > 0) {
846
+ throw new Error(`Pier recovery incomplete: ${errors.join('; ')}`)
847
+ }
848
+ writeJsonAtomic(controlDirectory, terminalFile, {
849
+ schemaVersion: 1,
850
+ kind: 'pier-trial-terminal',
851
+ status: 'stopped',
852
+ processExited: true,
853
+ containersRemoved: true,
854
+ removedContainers,
855
+ recoveredByPid: process.pid,
856
+ })
857
+ }
858
+ }