@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.
- package/CHANGELOG.md +17 -0
- package/HARNESS.md +302 -0
- package/README.md +26 -1
- package/fixtures/aec-bench.json +18 -0
- package/fixtures/agentbench-dbbench.json +22 -0
- package/fixtures/bfcl.json +45 -0
- package/fixtures/commit0.json +72 -0
- package/fixtures/crag.json +10 -0
- package/fixtures/dabstep.json +22 -0
- package/fixtures/enterpriseops-gym.json +103 -0
- package/fixtures/finresearchbench.json +21 -0
- package/fixtures/finsearchcomp.json +66 -0
- package/fixtures/frames.json +26 -0
- package/fixtures/hotpotqa.json +182 -0
- package/fixtures/nomiracl.json +26 -0
- package/fixtures/open-rag-bench.json +16 -0
- package/fixtures/pier-agent/no-model-task/environment/Dockerfile +16 -0
- package/fixtures/pier-agent/no-model-task/environment/seed/src/status.txt +1 -0
- package/fixtures/pier-agent/no-model-task/instruction.md +6 -0
- package/fixtures/pier-agent/no-model-task/pre_artifacts.sh +6 -0
- package/fixtures/pier-agent/no-model-task/task.toml +35 -0
- package/fixtures/pier-agent/no-model-task/tests/Dockerfile +17 -0
- package/fixtures/pier-agent/no-model-task/tests/seed/src/status.txt +1 -0
- package/fixtures/pier-agent/no-model-task/tests/test.sh +19 -0
- package/fixtures/programbench.json +17 -0
- package/fixtures/ragbench.json +21 -0
- package/fixtures/simpleqa.json +121 -0
- package/fixtures/t2-ragbench.json +13 -0
- package/fixtures/tau2-bench.json +16 -0
- package/fixtures/tau3-banking.json +16 -0
- package/fixtures/toollm.json +28 -0
- package/fixtures/webarena-verified.json +20 -0
- package/package.json +39 -15
- package/pier_agents/__init__.py +18 -0
- package/pier_agents/candidate_contract.py +755 -0
- package/pier_agents/process_boundary.py +321 -0
- package/pier_agents/tangle_candidate.py +907 -0
- package/pier_agents/workspace_boundary.py +368 -0
- package/scripts/appworld_driver.py +359 -0
- package/scripts/cadbench_prepare.py +22 -0
- package/scripts/cadgenbench_hard_parts.py +48 -0
- package/scripts/clbench_codebase_judge.py +73 -0
- package/scripts/commit0_judge.py +170 -0
- package/scripts/dabstep_judge.py +42 -0
- package/scripts/enterpriseops_gym_judge.py +281 -0
- package/scripts/programbench_judge.py +120 -0
- package/scripts/render-gate-chart.mjs +176 -0
- package/scripts/run-package-tests.mjs +56 -0
- package/scripts/terminate-pier-trial.mts +66 -0
- package/scripts/trata-hedge/README.md +56 -0
- package/scripts/trata-hedge/run.sh +60 -0
- package/scripts/trata-hedge/solve.py +83 -0
- package/scripts/verify-packed-consumer.mjs +224 -0
- package/scripts/verify-pier-agent.mts +715 -0
- package/scripts/verify-pier-pair.mts +74 -0
- package/scripts/verify-pier-recovery.mts +139 -0
- package/src/adapters.ts +26 -0
- package/src/benchmarks/_harness.test.mts +178 -0
- package/src/benchmarks/_harness.ts +239 -16
- package/src/benchmarks/agentbench.ts +163 -0
- package/src/benchmarks/appworld.test.mts +15 -9
- package/src/benchmarks/bfcl.ts +346 -0
- package/src/benchmarks/crag.ts +137 -0
- package/src/benchmarks/dabstep.test.mts +70 -0
- package/src/benchmarks/dabstep.ts +212 -0
- package/src/benchmarks/external-adapters.test.mts +150 -0
- package/src/benchmarks/finresearchbench.ts +269 -0
- package/src/benchmarks/humaneval.ts +20 -8
- package/src/benchmarks/nomiracl.ts +180 -0
- package/src/benchmarks/open-rag-bench.ts +153 -0
- package/src/benchmarks/rag-benchmarks.test.mts +138 -0
- package/src/benchmarks/rag-shared.ts +327 -0
- package/src/benchmarks/ragbench.ts +171 -0
- package/src/benchmarks/swe-bench.test.mts +61 -0
- package/src/benchmarks/swe-bench.ts +201 -19
- package/src/benchmarks/t2-ragbench.ts +166 -0
- package/src/benchmarks/tau-bench-shared.ts +214 -0
- package/src/benchmarks/tau2-bench.ts +30 -0
- package/src/benchmarks/tau3-banking.ts +29 -0
- package/src/benchmarks/terminal-bench.test.mts +33 -0
- package/src/benchmarks/terminal-bench.ts +23 -8
- package/src/benchmarks/toollm.ts +254 -0
- package/src/benchmarks/types.ts +42 -0
- package/src/benchmarks/webarena-verified.ts +200 -0
- package/src/commit0-prereqs.sh +0 -0
- package/src/coordination-mcp-container-reach.mts +181 -0
- package/src/decoder-live.mts +1 -1
- package/src/examples/README.md +103 -39
- package/src/examples/benchmark-matrix.mts +101 -0
- package/src/examples/lean-proof-gate.README.md +77 -0
- package/src/examples/lean-proof-gate.mts +162 -0
- package/src/examples/lean-verify.ts +95 -0
- package/src/examples/lean.Dockerfile +12 -0
- package/src/examples/math-demo.mts +9 -7
- package/src/examples/strategy-demo.mts +10 -12
- package/src/gate.ts +3 -2
- package/src/hev-eval.mts +69 -0
- package/src/hev-improve.mts +169 -0
- package/src/hev-structural.mts +688 -0
- package/src/index.ts +73 -0
- package/src/mbpp-structural.mts +662 -0
- package/src/pier-agent.test-fixtures.mts +19 -0
- package/src/pier-agent.test.mts +363 -0
- package/src/pier-agent.ts +657 -0
- package/src/pier-result-grader.mjs +30 -0
- package/src/pier-result-grader.test.mts +62 -0
- package/src/pier-result-grader.ts +108 -0
- package/src/pier-task-outcome.test.mts +117 -0
- package/src/pier-task-outcome.ts +240 -0
- package/src/pier-trial-controller.test.mts +412 -0
- package/src/pier-trial-controller.ts +858 -0
- package/src/pier-trial-supervisor.mjs +352 -0
- package/src/resolve-client.ts +25 -2
- package/src/run-benchmarks-cli.mts +72 -0
- package/src/run-benchmarks-report.ts +66 -0
- package/src/run-benchmarks.test.mts +231 -0
- package/src/run-benchmarks.ts +589 -0
- package/src/smoke-structural-rollout.mts +393 -0
- package/src/swe-bench-env.test.ts +207 -0
- package/src/swe-bench-env.ts +554 -0
- package/src/swe-jail.ts +293 -0
- package/src/swe-self-improve.mts +84 -0
- package/src/swe-structural-judge-policy.test.ts +117 -0
- package/src/swe-structural-judge-policy.ts +133 -0
- package/src/swe-structural-policy.test.ts +124 -0
- package/src/swe-structural-policy.ts +132 -0
- package/src/swe-structural-provenance.test.ts +93 -0
- package/src/swe-structural-provenance.ts +138 -0
- package/src/swe-structural.mts +1260 -0
- package/src/swe-temp.ts +14 -0
- package/src/tb-container-executor.mts +234 -0
- package/src/tb-container-executor.test.mts +99 -0
- package/src/tb-supervisor-sidecar.mts +222 -0
- package/src/trata-gepa.mts +1 -1
- package/steerers/eops-itsm-population.json +1 -0
- package/tb_agents/opencode_refine_agent.py +117 -0
- package/tb_agents/opencode_router_agent.py +406 -0
- package/tb_agents/opencode_supervisor_agent.py +239 -0
- 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
|
+
}
|