blob: 58d971346fb290e748c09372af5f281d1db5775f [file]
import { spawn, type ChildProcess } from 'node:child_process';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { basename, delimiter, join } from 'node:path';
import { PROVIDER_DEFAULTS, type ProviderType } from '@maka/core/llm-connections';
import type { ThinkingLevel } from '@maka/core/model-thinking';
import {
FixedPromptBudgetExhaustedError,
type HarborVerifierOutcome,
type TaskRunInput,
type TaskRunOutput,
type TaskRunner,
} from './fixed-prompt-controller.js';
import {
assertNoExperimentIdentityOverrides,
assertNoProviderSecretsInAgentEnv,
findTrialDir,
harborTraceMode,
hostTraceEventsPath,
isBudgetExhaustedError,
mergeAgentEnv,
modelIdForProvider,
providerProxyApiProtocol,
providerRequiresSecret,
providerTelemetryArtifactRefs,
providerTokenSummary,
readCellOutput,
readTimedOutTrialArtifacts,
classifyTrialTermination,
formatTrialException,
readTrialException,
resolveNativeTrialTimeoutMs,
trialExceptionSuffix,
withProviderTelemetryArtifact,
incompleteTerminalProviderRequest,
trialGradeSurvivingProviderOutage,
modelForOpenCode,
type HarborTaskPricing,
} from './harbor-task-runner.js';
import { agentPhaseTimeoutSec, settlementGraceSec } from './maka-settlement.js';
import {
harnessAgentImportPath,
providerProxyClientAuthMode,
providerProxyUpstreamAuthMode,
providerProxyUpstreamBaseUrl,
providerProxyUsageProtocol,
type HarnessAgentId,
} from './harness-agent-registry.js';
import { lenientPositiveIntEnv } from './headless-run-env.js';
import {
CODEX_TOOLCHAIN_CONTAINER_PATH,
CODEX_TOOLCHAIN_FINGERPRINT,
CODEX_TOOLCHAIN_SPEC,
} from './codex-toolchain.js';
import {
KIMI_CODE_TOOLCHAIN_CONTAINER_PATH,
KIMI_CODE_TOOLCHAIN_FINGERPRINT,
} from './kimi-code-toolchain.js';
import {
OPENCODE_TOOLCHAIN_CONTAINER_PATH,
OPENCODE_TOOLCHAIN_FINGERPRINT,
OPENCODE_TOOLCHAIN_SPEC,
} from './opencode-toolchain.js';
import {
MAKA_NODE_TOOLCHAIN_CONTAINER_PATH,
MAKA_NODE_TOOLCHAIN_FINGERPRINT,
} from './maka-node-toolchain.js';
import { buildAgentRepoMounts, CONTAINER_MAKA_REPO } from './agent-repo-mount.js';
import {
summarizeProviderTelemetry,
startProviderAuthProxy,
startProviderAuthProxyHub,
type ProviderAuthProxyHub,
type ProviderAuthProxyRouteInput,
type ProviderRequestTelemetry,
type ProviderTokenUsage,
type ProviderUpstreamCredentialResolver,
} from './provider-auth-proxy.js';
const TRIAL_CELL_OUTPUT = 'agent/maka-cell-output.json';
const TRIAL_RUNTIME_EVENTS = 'agent/runtime-events.jsonl';
const TRIAL_REWARD_JSON = 'verifier/reward.json';
const TRIAL_RESULT = 'result.json';
const PROVIDER_REQUEST_TELEMETRY = 'provider-request-telemetry.json';
/** The default port an in-container agent binds the host provider
* proxy to. Pier's Squid egress for offline (`allow_internet=false`) tasks only
* permits destination ports 80/443 (`acl Safe_ports port 80 443`), so a
* container reaching the host proxy through Squid must present one of those.
* 443 keeps the model endpoint on the conventional TLS port. */
export const PIER_PROVIDER_PROXY_DEFAULT_PORT = 443;
/** Compatibility fallback for callers that do not provide a run-scoped proxy
* hub. Such callers still bind one fixed port per attempt, so concurrent binds
* on the same port must serialize. Benchmark runs that need concurrency share
* one listener through `providerProxyHub` and do not enter this queue. */
const proxyPortQueues = new Map<number, Promise<unknown>>();
function withProxyPortLock<T>(port: number, fn: () => Promise<T>): Promise<T> {
const queue = proxyPortQueues.get(port) ?? Promise.resolve();
const run = queue.then(fn);
proxyPortQueues.set(
port,
run.catch(() => {}),
);
return run;
}
/** A Pier-side failure (build/docker/timeout/missing artifact) — NOT a benchmark
* result. The fixed-prompt controller turns a thrown error into an infra_failed
* event, excluding it from scoring instead of recording reward 0.
*
* Deliberately separate from HarborInfraError: the controller classifies infra
* by behavior (any thrown non-budget error), never by error identity, so
* sharing a class buys no invariant — while a Pier failure surfacing as
* "HarborInfraError" in diagnostics would misattribute the failing harness.
* The telemetry-attachment helpers below stay local for the same reason: they
* construct this runner-local error type. */
export class PierInfraError extends Error {
constructor(
message: string,
readonly detail?: string,
readonly kind: 'infra_failed' | 'timed_out' = 'infra_failed',
readonly artifactRefs?: { providerTelemetryPath?: string },
) {
super(message);
this.name = 'PierInfraError';
}
}
/** Same per-1M pricing contract as the Harbor runner (shared cost math). */
export type PierTaskPricing = HarborTaskPricing;
export type PierProviderProxyHub = ProviderAuthProxyHub;
export interface PierProviderProxyHubOptions {
providerProxyPort?: number;
providerProxyAdvertisedHost?: string;
}
export interface PierTaskRunnerOptions {
/** Host path to the maka repo, bind-mounted read-only at /opt/maka-agent. */
makaRepoPath: string;
/** Pinned Linux/x64 Node runtime mounted read-only for in-container Maka task runs. */
makaNodeToolchainPath?: string;
/** Pier adapter under test (default: Maka). */
agent?: PierAgent;
/** In-container/host cell backend. Only the Maka arm reads it. `fake` runs the
* inert cell for zero-cost structural checks; `ai-sdk` is the real run. */
backend?: 'ai-sdk' | 'fake';
/** Prepared Kimi Code toolchain bind-mounted read-only into task containers. */
kimiCodeToolchainPath?: string;
/** Prepared Codex toolchain bind-mounted read-only into task containers. */
codexToolchainPath?: string;
/** Prepared OpenCode toolchain bind-mounted read-only into task containers. */
opencodeToolchainPath?: string;
/** Competitor CLI version, forwarded as the adapter's `version` kwarg. The
* Codex arm requires it to match the pinned toolchain spec so the fixed
* build under test is exactly the one fingerprinted into the manifest. */
agentVersion?: string;
/** Base directory under which each task gets an isolated per-task job dir. */
jobsDir: string;
/** MAKA_MODEL / pier `-m`, e.g. "k3" or "deepseek/deepseek-v4-flash". */
model: string;
/** MAKA_PROVIDER, e.g. "kimi-coding-plan". */
provider?: string;
reasoningEffort?: ThinkingLevel;
/** Upstream model base URL. Falls back to the provider's registry default. */
baseUrl?: string;
/** Host path to an API key file. The key stays in the host control process
* (read by the host cell, or minted into a scoped token by the proxy); the task
* container never receives a provider key env, key-file path, or secret mount. */
apiKeyFile?: string;
/** Resolves the upstream authority inside the host proxy for every request. */
resolveProviderCredential?: ProviderUpstreamCredentialResolver;
/** Route a Maka host cell through the auth proxy instead of reading the key
* file directly. In-container agents, including Maka task-run, always use it. */
useProviderProxy?: boolean;
/** Explicit host proxy listen port for an in-container agent (default 443). */
providerProxyPort?: number;
/** Host an in-container agent should dial to reach the host provider proxy.
* This covers Kimi, Codex, and Maka task-run mode; a Maka host cell still uses
* loopback (127.0.0.1). Unset keeps the default host.docker.internal, which
* Docker Desktop injects but native Linux Docker does NOT provide (pier
* 0.3.0's compose wires no
* extra_hosts/host-gateway), so on native Linux pass the host's
* docker-bridge-reachable address (e.g. 172.17.0.1) or the container's Squid
* cannot resolve the proxy. */
providerProxyAdvertisedHost?: string;
/** Run-scoped fixed-port listener shared by concurrent in-container attempts.
* Each attempt receives an independent token-routed lease, including its own
* upstream credential resolver, usage, telemetry, and abort lifecycle. */
providerProxyHub?: PierProviderProxyHub;
/** Per-1M USD pricing forwarded as MAKA_TRIAL_* so the cell emits real costUsd. */
pricing?: PierTaskPricing;
/** Extra agent env merged last (e.g. MAKA_HARBOR_MODE). Never provider secrets. */
agentEnv?: Record<string, string>;
/** Pier launcher (default "pier"). */
pierBin?: string;
/** Pier environment type (default "docker"). Unlike Harbor, Pier's
* EnvironmentConfig has no extra_docker_compose or platform field, so an
* explicit Docker target platform cannot be wired through `pier run`. */
environment?: string;
timeoutMultiplier?: number;
/** Wall-clock ceiling for a single `pier run`; a hung Docker/Pier would
* otherwise stall the unattended loop forever. Defaults to pier's maximum
* legitimate trial lifecycle (2 x build + agent + 2 x verifier task-native
* seconds, covering pier's one-retry policy on build and verification) x
* timeoutMultiplier plus setup/teardown grace, floored at 45 minutes —
* shared floor+grace contract with the Harbor runner. */
pierTimeoutMs?: number;
/** Injectable Pier process runner (default: execFile the pier binary). */
runPier?: PierProcessRunner;
}
/** Start the one fixed-port provider listener owned by a Pier benchmark run.
* Runners receive the returned hub through `providerProxyHub` and issue one
* isolated lease per attempt. */
export async function createPierProviderProxyHub(
input: PierProviderProxyHubOptions = {},
): Promise<PierProviderProxyHub> {
return await startProviderAuthProxyHub({
port: input.providerProxyPort ?? PIER_PROVIDER_PROXY_DEFAULT_PORT,
...(input.providerProxyAdvertisedHost
? { advertisedHost: input.providerProxyAdvertisedHost }
: {}),
});
}
export interface PierRunRequest {
pierBin: string;
jobName: string;
jobsDir: string;
args: readonly string[];
cwd: string;
/** Wall-clock ceiling in ms; the default runner kills pier past this. */
timeoutMs?: number;
/** Env overlaid onto the pier process: PYTHONPATH, MAKA_BACKEND (the adapter's
* CliFlag env_fallback reads only os.environ), and MAKA_SYSTEM_PROMPT (byte-
* exact; pier's --ae parser would strip its whitespace). */
env?: Record<string, string>;
/** SIGTERM-to-SIGKILL grace once the watchdog fires (default 120s: pier's
* SIGTERM-triggered finally chain must finish docker compose teardown). */
terminationGraceMs?: number;
}
export interface PierRunResult {
exitCode: number;
stdout: string;
stderr: string;
timedOut?: boolean;
signal?: string;
}
export type PierProcessRunner = (request: PierRunRequest) => Promise<PierRunResult>;
export type PierAgent = Exclude<HarnessAgentId, 'claude-code' | 'reasonix'>;
interface PierProviderRuntime {
/** Proxy-minted secret env delivered via `--env-file` (kept off argv). */
envFile: Record<string, string>;
/** Non-secret host env delivered via `--ae` (paths, base URLs). */
agentEnv: Record<string, string>;
usage?: () => ProviderTokenUsage | null;
telemetry?: () => ProviderRequestTelemetry[];
close?: () => Promise<void>;
}
export function createPierTaskRunner(options: PierTaskRunnerOptions): TaskRunner {
if (options.agent === 'codex' && options.agentVersion !== CODEX_TOOLCHAIN_SPEC.codex.version) {
throw new Error(
`Codex adapter version must match toolchain version ${CODEX_TOOLCHAIN_SPEC.codex.version}`,
);
}
if (
options.agent === 'opencode' &&
options.agentVersion !== OPENCODE_TOOLCHAIN_SPEC.opencode.version
) {
throw new Error(
`OpenCode adapter version must match toolchain version ${OPENCODE_TOOLCHAIN_SPEC.opencode.version}`,
);
}
const runPier = options.runPier ?? defaultPierProcessRunner;
const pierBin = options.pierBin ?? 'pier';
// The bare adapter import path (`maka_agent:MakaAgent`) resolves only when the
// adapter directory is on pier's PYTHONPATH; pier is a uv-installed tool, so its
// cwd is not enough. Prepend it, keeping any inherited PYTHONPATH.
const harborAdapterDir = join(options.makaRepoPath, 'packages', 'headless', 'harbor');
const pythonPath = [harborAdapterDir, process.env.PYTHONPATH].filter(Boolean).join(delimiter);
const runner: TaskRunner = async (input: TaskRunInput): Promise<TaskRunOutput> => {
const agent = options.agent ?? 'maka';
const timeoutMultiplier = options.timeoutMultiplier ?? 1;
const taskAgentTimeoutSec = input.task.metadata?.agentTimeoutSec;
const attemptAgentEnv = mergeAgentEnv(options.agentEnv, input.agentEnv);
const traceMode = harborTraceMode(attemptAgentEnv);
const makaDeadline =
agent === 'maka' &&
traceMode === 'task-run' &&
timeoutMultiplier === 1 &&
taskAgentTimeoutSec !== undefined
? {
modelBudgetSec: taskAgentTimeoutSec,
settlementGraceSec: settlementGraceSec(agent, attemptAgentEnv),
phaseTimeoutSec: agentPhaseTimeoutSec(agent, attemptAgentEnv, taskAgentTimeoutSec),
}
: undefined;
const jobsDir = join(
options.jobsDir,
sanitize(input.runId),
sanitize(input.roundId),
sanitize(input.task.id),
);
const jobName = 'trial';
const jobDir = join(jobsDir, jobName);
// Start each attempt from a clean dir so a crashed prior attempt cannot be
// mistaken for this attempt's trial output.
await rm(jobsDir, { recursive: true, force: true });
await mkdir(jobsDir, { recursive: true });
assertNoProviderSecretsInAgentEnv(attemptAgentEnv);
// Same benchmark invariant as the Harbor runner (shared implementation):
// agentEnv must not override experiment identity or MAKA_TRIAL_* pricing.
assertNoExperimentIdentityOverrides(attemptAgentEnv);
const providerTelemetryPath = join(jobsDir, PROVIDER_REQUEST_TELEMETRY);
let providerUsage: ProviderTokenUsage | null = null;
let providerTelemetry: ProviderRequestTelemetry[] = [];
const envFilePath = join(jobsDir, 'pier-agent.env');
// Config errors fail fast before the proxy exists (and outside the port lock).
const mounts = buildPierMounts(options, agent, traceMode);
const launchAttempt = async (): Promise<PierRunResult> => {
// Proxy bind errors surface raw, before the launch try, with their own
// message — they are configuration faults, not infra flakes.
const providerRuntime = await pierProviderRuntime(
{ ...options, agentEnv: attemptAgentEnv },
agent,
traceMode,
);
const envFileEntries = providerRuntime?.envFile ?? {};
const usesEnvFile = Object.keys(envFileEntries).length > 0;
try {
// Everything from here until runPier returns lives under one finally that
// closes the proxy: a failure in this window (env-file write, arg
// assembly) must not leak the listening socket.
try {
const aeEnv = buildPierAgentEnv(
input,
options,
agent,
providerRuntime?.agentEnv ?? {},
makaDeadline,
);
const processEnv: Record<string, string> = {
PYTHONPATH: pythonPath,
// MAKA_BACKEND is a CliFlag whose env_fallback reads os.environ only, so
// `--ae MAKA_BACKEND=` is silently ignored — it must ride the pier process
// env. The Kimi adapter ignores it.
MAKA_BACKEND: options.backend ?? 'ai-sdk',
// Byte-safe channel for the prompt: pier's --ae parser strips leading and
// trailing whitespace from values (pier/cli/utils.py key.strip() /
// value.strip()), which would drop the prompt's trailing newline and break
// the execution-identity hash round-trip on every task. Both adapters fall
// back to os.environ (CliFlag env_fallback for Maka, _get_env for Kimi) and
// forward the exact bytes into the cell, so the value rides the pier
// process env verbatim — and must never also appear in --ae, where the
// stripped extra_env copy would take precedence in _get_env.
MAKA_SYSTEM_PROMPT: input.systemPrompt,
};
if (usesEnvFile) await writeEnvFile(envFilePath, envFileEntries);
const args = buildPierRunArgs({
agent,
// Provider-local bare id for the Maka/Kimi/Codex arms (same
// normalization contract as the Harbor runner); OpenCode instead
// requires the provider/model form its adapter splits on.
model:
agent === 'opencode'
? modelForOpenCode(options.model, options.provider ?? 'deepseek')
: modelIdForProvider(options.model, options.provider ?? 'deepseek'),
taskPath: input.task.path,
jobsDir,
jobName,
environment: options.environment ?? 'docker',
timeoutMultiplier,
...(makaDeadline
? {
agentTimeoutMultiplier:
makaDeadline.phaseTimeoutSec / makaDeadline.modelBudgetSec,
}
: {}),
mounts,
agentEnv: aeEnv,
...(usesEnvFile ? { envFile: envFilePath } : {}),
// Constructor kwargs ride `--ak`; env cannot carry them. The Codex
// adapter pins its CLI build via `version` and forwards the effort
// as the `-c model_reasoning_effort` CLI flag descriptor.
...(agent === 'codex'
? {
agentKwargs: {
version: options.agentVersion!,
...(options.reasoningEffort
? { reasoning_effort: options.reasoningEffort }
: {}),
},
}
: agent === 'opencode'
? {
// OpenCode pins its CLI build the same way so trial
// provenance records the fingerprinted version instead of
// 'unknown'; its reasoning variant rides --ae instead.
agentKwargs: { version: options.agentVersion! },
}
: {}),
});
return await runPier({
pierBin,
jobName,
jobsDir,
args,
cwd: harborAdapterDir,
// Task-aware watchdog (shared Harbor floor+grace contract, fed with
// pier's complete lifecycle model): a fixed 45-minute default would
// systematically undercut DeepSWE's native budgets.
timeoutMs:
options.pierTimeoutMs ??
resolveNativeTrialTimeoutMs({
nativePhasesSec:
pierMaxTrialPhasesSec(input.task.metadata) +
(makaDeadline?.settlementGraceSec ?? 0),
timeoutMultiplier,
}),
env: processEnv,
});
} finally {
await providerRuntime?.close?.();
if (usesEnvFile) await rm(envFilePath, { force: true });
providerUsage = providerRuntime?.usage?.() ?? null;
providerTelemetry = providerRuntime?.telemetry?.() ?? [];
if (providerTelemetry.length > 0) {
await writeFile(
providerTelemetryPath,
`${JSON.stringify(
{
schemaVersion: 1,
summary: summarizeProviderTelemetry(providerTelemetry),
requests: providerTelemetry,
},
null,
2,
)}\n`,
'utf8',
);
}
}
} catch (error) {
if (isBudgetExhaustedError(error)) throw error;
throw new PierInfraError(
`pier run failed to launch for task ${input.task.id}`,
errorText(error),
'infra_failed',
providerTelemetryArtifactRefs(providerTelemetry, providerTelemetryPath),
);
}
};
// Only an in-container agent on a FIXED port competes for the bind; a Maka
// host cell and an explicit port 0 (OS-assigned, used by tests) cannot
// collide and need no serialization.
const containerProxyPort = options.providerProxyPort ?? PIER_PROVIDER_PROXY_DEFAULT_PORT;
const usesContainerProxy = agent !== 'maka' || traceMode === 'task-run';
const result =
usesContainerProxy && !options.providerProxyHub && containerProxyPort !== 0
? await withProxyPortLock(containerProxyPort, launchAttempt)
: await launchAttempt();
try {
if (result.timedOut) {
throw new PierInfraError(
`pier run timed out for task ${input.task.id}`,
tail(result.stderr || result.stdout),
'timed_out',
);
}
let trialDir: string;
try {
trialDir = await findTrialDir(jobDir, basename(input.task.path), 'pier', PierInfraError);
} catch (error) {
if (result.exitCode === 0) throw error;
throw new PierInfraError(
`pier run exited ${result.exitCode} for task ${input.task.id}`,
tail(result.stderr || result.stdout),
);
}
// A populated `exception_info` records how the agent phase ended, NOT
// whether the trial was graded: pier's trial.py records the exception and
// then unconditionally runs verification, so an exceptional trial can
// still carry an authoritative reward. Mirror Harbor's authority order
// exactly: an ungraded budget exhaustion is a budget_exhausted outcome;
// an agent-owned exit the verifier graded scores on its actual reward
// (e.g. a non-zero Kimi CLI exit the verifier still passed), whatever
// pier's own exit code was; and an externally ended run is infra however
// complete its artifacts look, because the agent never got the run it was
// given. The trial exception rides along for diagnosis.
const trialException = await readTrialException(
join(trialDir, TRIAL_RESULT),
'PierTrialError',
);
const termination = classifyTrialTermination(trialException);
let completeTimedOutTrial = false;
let verifierSettledTrial = false;
if (termination === 'agent_budget' || termination === 'agent_exit') {
const [grade, cellArtifact] = await Promise.all([
readPierGrade(trialDir, input.task.id),
readOptionalText(join(trialDir, TRIAL_CELL_OUTPUT)),
]);
const settledByEvidence = grade.state === 'graded' && cellArtifact !== null;
if (termination === 'agent_exit') {
verifierSettledTrial = settledByEvidence;
} else {
// Budget-gate context, distinct from the graded read path: the agent has
// already exhausted its budget, which is the authoritative fact. A
// verifier that crashed or wrote a corrupt reward here does NOT overturn
// it — an `invalid` grade is treated exactly like `ungraded` / a missing
// reward file, yielding budget_exhausted (no retry, Pass@1 evidence
// preserved) rather than an infra failure the controller would retry. In
// the graded read path a corrupt scoring authority IS infra; only when
// the budget is already spent does the agent fact take precedence.
if (!settledByEvidence) {
// Recover attested evidence (identity/usage/cell output) via the shared
// Harbor implementation so a budget-exhausted sample keeps its Pass@1
// eligibility instead of being excluded as missing_execution_identity.
const artifactRefs = await readTimedOutTrialArtifacts(
trialDir,
input.task.id,
agent,
harborTraceMode(attemptAgentEnv),
);
// Same cross-runner contract as Harbor: a graded trial that never
// filed its cell output still carries the verifier's own verdict,
// because the self-report is not what scores a trial.
const harbor = trialGradeSurvivingProviderOutage(
grade.state === 'graded'
? {
reward: grade.reward,
verifier: pierVerifierOutcome(
grade.reward,
await readVerifierDurationMs(join(trialDir, TRIAL_RESULT)),
),
}
: undefined,
providerTelemetry,
);
throw new FixedPromptBudgetExhaustedError(
`agent budget exhausted for task ${input.task.id}`,
// Carry the invalid-grade detail alongside the exhaustion cause so a
// corrupt/crashed verifier is still diagnosable, without letting it
// count toward the score.
grade.state === 'invalid'
? `${formatTrialException(trialException)}; ${grade.detail}`
: formatTrialException(trialException),
{
...(artifactRefs ?? {}),
...(harbor ? { harbor } : {}),
...(providerTelemetry.length > 0 ? { providerTelemetryPath } : {}),
},
);
}
completeTimedOutTrial = true;
}
}
if (result.exitCode !== 0 && !completeTimedOutTrial && !verifierSettledTrial) {
throw new PierInfraError(
// The WAL keeps only the message, so the trial exception has to travel
// in it or an infra bucket full of timeouts stays invisible to grep.
`pier run exited ${result.exitCode} for task ${input.task.id}${trialExceptionSuffix(trialException)}`,
tail(result.stderr || result.stdout),
);
}
// Same terminal-stream contract as the Harbor runner: a non-completing
// last provider request is infra, never a graded model failure.
const terminalProviderRequest = incompleteTerminalProviderRequest(
providerTelemetry,
termination === null || completeTimedOutTrial || verifierSettledTrial,
);
if (terminalProviderRequest) {
throw new PierInfraError(
`terminal provider request did not complete for task ${input.task.id}${trialExceptionSuffix(trialException)}`,
[
`outcome=${terminalProviderRequest.outcome}`,
terminalProviderRequest.status !== undefined
? `status=${terminalProviderRequest.status}`
: undefined,
]
.filter(Boolean)
.join(', '),
'infra_failed',
{ providerTelemetryPath },
);
}
const reward = await readPierReward(
trialDir,
input.task.id,
formatTrialException(trialException),
);
const rawCell = await readCellOutput(
join(trialDir, TRIAL_CELL_OUTPUT),
input.task.id,
PierInfraError,
);
const cell =
rawCell.tokenSummary || !providerUsage || !options.pricing
? rawCell
: { ...rawCell, tokenSummary: providerTokenSummary(providerUsage, options.pricing) };
const hostEventsPath = join(trialDir, TRIAL_RUNTIME_EVENTS);
// Pier's verifier grading is the scoring authority. Surface it as the
// structured verifier outcome the controller requires: without it a
// graded failed cell (max_tokens / tool_step_cap_reached / policy_denied)
// is never verifierGraded and drops out of the benchmark denominator as
// scored=false. The outcome is derived from the reward itself, so unlike
// Harbor's independently-written oracle artifact it cannot disagree.
const verifier = pierVerifierOutcome(
reward,
await readVerifierDurationMs(join(trialDir, TRIAL_RESULT)),
);
return {
harbor: { reward, verifier },
cell: {
...cell,
...(providerTelemetry.length > 0 ? { providerTelemetryPath } : {}),
runtimeEventsPath: hostEventsPath,
// Shared Harbor resolution: cell mode prefers the maka-storage
// session events (the rich trace with tool_failed /
// provider_request_captured), task-run mode the combined trace, and
// only then the raw runtime events — same layouts, same adapters.
traceEventsPath: hostTraceEventsPath(
agent,
harborTraceMode(attemptAgentEnv),
trialDir,
cell,
hostEventsPath,
),
},
};
} catch (error) {
throw withProviderTelemetryArtifact(
error,
providerTelemetry,
providerTelemetryPath,
PierInfraError,
);
}
};
return runner;
}
export interface BuildPierRunArgsInput {
agent: PierAgent;
model: string;
taskPath: string;
jobsDir: string;
jobName: string;
environment: string;
timeoutMultiplier: number;
agentTimeoutMultiplier?: number;
mounts: ReadonlyArray<Record<string, unknown>>;
agentEnv: Record<string, string>;
envFile?: string;
/** Adapter constructor kwargs forwarded as `--ak key=value`. */
agentKwargs?: Record<string, string>;
}
/** Assemble the `pier run` argv. Exported for deterministic unit tests. */
export function buildPierRunArgs(input: BuildPierRunArgsInput): string[] {
const importPath = harnessAgentImportPath(input.agent);
const args = [
'run',
'--agent-import-path',
importPath,
'-m',
input.model,
'-p',
input.taskPath,
'-o',
input.jobsDir,
'--job-name',
input.jobName,
// -k attempts / -n concurrent: one attempt, one trial — Pass@1 semantics.
'-k',
'1',
'-n',
'1',
'--timeout-multiplier',
String(input.timeoutMultiplier),
'-e',
input.environment,
'--mounts-json',
JSON.stringify(input.mounts),
'--yes',
'--quiet',
];
if (input.agentTimeoutMultiplier !== undefined) {
args.push('--agent-timeout-multiplier', String(input.agentTimeoutMultiplier));
}
if (input.envFile) args.push('--env-file', input.envFile);
for (const [key, value] of Object.entries(input.agentKwargs ?? {})) {
args.push('--ak', `${key}=${value}`);
}
for (const [key, value] of Object.entries(input.agentEnv)) {
args.push('--ae', `${key}=${value}`);
}
return args;
}
function buildPierMounts(
options: PierTaskRunnerOptions,
agent: PierAgent,
mode: 'cell' | 'task-run',
): Array<Record<string, unknown>> {
const mounts: Array<Record<string, unknown>> = [
...buildAgentRepoMounts(agent, options.makaRepoPath),
];
if (agent === 'maka' && mode === 'task-run') {
if (!options.makaNodeToolchainPath) {
throw new Error('makaNodeToolchainPath is required for Maka container task-run mode');
}
mounts.push({
type: 'bind',
source: options.makaNodeToolchainPath,
target: MAKA_NODE_TOOLCHAIN_CONTAINER_PATH,
read_only: true,
});
}
if (agent === 'kimi-code') {
if (!options.kimiCodeToolchainPath) {
throw new Error('kimiCodeToolchainPath is required for the Kimi Code adapter');
}
mounts.push({
type: 'bind',
source: options.kimiCodeToolchainPath,
target: KIMI_CODE_TOOLCHAIN_CONTAINER_PATH,
read_only: true,
});
}
if (agent === 'codex') {
if (!options.codexToolchainPath) {
throw new Error('codexToolchainPath is required for the Codex adapter');
}
mounts.push({
type: 'bind',
source: options.codexToolchainPath,
target: CODEX_TOOLCHAIN_CONTAINER_PATH,
read_only: true,
});
}
if (agent === 'opencode') {
if (!options.opencodeToolchainPath) {
throw new Error('opencodeToolchainPath is required for the OpenCode adapter');
}
mounts.push({
type: 'bind',
source: options.opencodeToolchainPath,
target: OPENCODE_TOOLCHAIN_CONTAINER_PATH,
read_only: true,
});
}
return mounts;
}
function buildPierAgentEnv(
input: TaskRunInput,
options: PierTaskRunnerOptions,
agent: PierAgent,
providerAgentEnv: Record<string, string>,
makaDeadline?: {
modelBudgetSec: number;
settlementGraceSec: number;
phaseTimeoutSec: number;
},
): Record<string, string> {
const provider = options.provider ?? 'deepseek';
const makaModel = modelIdForProvider(options.model, provider);
const env: Record<string, string> = {
MAKA_MODEL: makaModel,
MAKA_PROVIDER: provider,
MAKA_LLM_CONNECTION_SLUG: provider,
MAKA_AGENT_TOOLS: input.config.agentTools === true ? 'true' : 'false',
MAKA_REPO_ROOT: CONTAINER_MAKA_REPO,
// MAKA_SYSTEM_PROMPT deliberately does NOT ride --ae: pier's CLI strips
// whitespace from --ae values, and a stripped copy in the adapter's
// extra_env would shadow the byte-exact os.environ value. See processEnv.
};
if (options.reasoningEffort) env.MAKA_REASONING_EFFORT = options.reasoningEffort;
if (agent === 'maka' && options.makaNodeToolchainPath) {
env.MAKA_NODE_TOOLCHAIN_FINGERPRINT = MAKA_NODE_TOOLCHAIN_FINGERPRINT;
}
if (agent === 'kimi-code') {
env.MAKA_KIMI_CODE_TOOLCHAIN_FINGERPRINT = KIMI_CODE_TOOLCHAIN_FINGERPRINT;
}
if (agent === 'codex') {
env.MAKA_CODEX_TOOLCHAIN_FINGERPRINT = CODEX_TOOLCHAIN_FINGERPRINT;
}
if (agent === 'opencode') {
env.MAKA_OPENCODE_TOOLCHAIN_FINGERPRINT = OPENCODE_TOOLCHAIN_FINGERPRINT;
if (options.reasoningEffort) env.MAKA_OPENCODE_VARIANT = options.reasoningEffort;
}
if (options.pricing) {
env.MAKA_TRIAL_INPUT_USD_PER_1M = String(options.pricing.inputUsdPer1M);
env.MAKA_TRIAL_OUTPUT_USD_PER_1M = String(options.pricing.outputUsdPer1M);
if (options.pricing.cacheReadUsdPer1M !== undefined) {
env.MAKA_TRIAL_CACHE_READ_USD_PER_1M = String(options.pricing.cacheReadUsdPer1M);
}
if (options.pricing.cacheWriteUsdPer1M !== undefined) {
env.MAKA_TRIAL_CACHE_WRITE_USD_PER_1M = String(options.pricing.cacheWriteUsdPer1M);
}
if (options.pricing.source) env.MAKA_TRIAL_PRICING_SOURCE = options.pricing.source;
}
Object.assign(env, providerAgentEnv);
Object.assign(env, mergeAgentEnv(options.agentEnv, input.agentEnv) ?? {});
if (makaDeadline) {
// The budget is the model budget and the settlement window is added around
// it, so the agent phase Pier is given is one window longer.
env.MAKA_CELL_TIMEOUT_SEC = String(makaDeadline.modelBudgetSec);
env.MAKA_CELL_SETTLEMENT_GRACE_SEC = String(makaDeadline.settlementGraceSec);
env.MAKA_AGENT_PHASE_TIMEOUT_SEC = String(makaDeadline.phaseTimeoutSec);
}
// Lenient by shared contract with the Python adapter: a malformed value must
// fall back to the task metadata rather than fail the run.
const cellTimeoutSec =
lenientPositiveIntEnv(env.MAKA_CELL_TIMEOUT_SEC) ?? input.task.metadata?.agentTimeoutSec;
if (cellTimeoutSec !== undefined) {
env.MAKA_CELL_TIMEOUT_SEC = String(cellTimeoutSec);
const streamTimeoutMs = cellTimeoutSec * 1_000;
if (agent === 'maka' && Number.isSafeInteger(streamTimeoutMs)) {
// Pier already owns the task-native hard deadline. Keep the runtime's
// first-event and between-event watchdogs from imposing a shorter cutoff.
env.MAKA_STREAM_CONNECT_TIMEOUT_MS = String(streamTimeoutMs);
env.MAKA_STREAM_IDLE_TIMEOUT_MS = String(streamTimeoutMs);
}
}
return env;
}
async function pierProviderRuntime(
options: PierTaskRunnerOptions,
agent: PierAgent,
mode: 'cell' | 'task-run',
): Promise<PierProviderRuntime | null> {
const provider = options.provider ?? 'deepseek';
const baseUrl = options.baseUrl ?? providerDefaultBaseUrl(provider);
const makaContainerTaskRun = agent === 'maka' && mode === 'task-run';
// A resolver-backed credential (e.g. Codex OAuth) is only usable through the
// proxy — same contract as the Harbor runner — so it forces the proxy path
// even when the caller forgot useProviderProxy.
const usesProxy =
agent !== 'maka' ||
(makaContainerTaskRun && options.backend !== 'fake') ||
options.useProviderProxy === true ||
options.resolveProviderCredential !== undefined;
if (!usesProxy) {
if (agent !== 'maka') return null;
// The fake backend runs the inert in-container cell with no host-side
// provider runtime at all — the zero-cost structural path the live e2e
// evidence depends on. Never wire MAKA_HOST_* for it.
if (options.backend === 'fake') return null;
if (!options.apiKeyFile) {
// Mirror the Harbor runner's predicate: a keyless-ready provider
// (registry authKind 'none' — ollama, lm-studio, localai) runs the host
// cell with MAKA_HOST_NO_AUTH; a secret-requiring provider without a key
// stays unconfigured so the adapter fails loud at environment creation
// instead of the cell dialing the API with an empty credential.
if (providerRequiresSecret(provider)) return null;
return {
envFile: {},
agentEnv: {
MAKA_HOST_REPO_ROOT: options.makaRepoPath,
MAKA_HOST_NO_AUTH: 'true',
...(baseUrl ? { MAKA_HOST_BASE_URL: baseUrl } : {}),
},
};
}
// Direct host-side key file: the host cell reads the real key from the
// file path. The path (not the key) rides `--ae`; the container stays
// offline and never sees a key. No proxy, so no token metering.
return {
envFile: {},
agentEnv: {
MAKA_HOST_REPO_ROOT: options.makaRepoPath,
MAKA_HOST_API_KEY_FILE: options.apiKeyFile,
...(baseUrl ? { MAKA_HOST_BASE_URL: baseUrl } : {}),
},
};
}
if (!options.apiKeyFile && !options.resolveProviderCredential) {
throw new Error(
`${agent} Pier runs require apiKeyFile or resolveProviderCredential to mint the proxy credential`,
);
}
if (!baseUrl) throw new Error(`Pier ${agent} provider ${provider} requires a base URL`);
const proxyPort =
agent !== 'maka' || makaContainerTaskRun
? (options.providerProxyPort ?? PIER_PROVIDER_PROXY_DEFAULT_PORT)
: undefined;
// A Maka host cell reaches the proxy on loopback. In-container agents reach
// it through Docker's host gateway on a Squid-legal port, defaulting to
// host.docker.internal unless native Linux supplies an explicit bridge host.
const advertisedHost =
agent === 'maka' && !makaContainerTaskRun ? '127.0.0.1' : options.providerProxyAdvertisedHost;
const apiProtocol = providerProxyApiProtocol(agent, options.agentEnv);
const routeInput: ProviderAuthProxyRouteInput = {
upstreamBaseUrl: providerProxyUpstreamBaseUrl(baseUrl, provider, apiProtocol),
...(options.resolveProviderCredential
? { resolveUpstreamCredential: options.resolveProviderCredential }
: { apiKeyFile: options.apiKeyFile! }),
clientAuthMode: providerProxyClientAuthMode(agent, provider, apiProtocol),
upstreamAuthMode: providerProxyUpstreamAuthMode(agent, provider, apiProtocol),
usageProtocol: providerProxyUsageProtocol(agent, provider, apiProtocol),
};
const proxy =
options.providerProxyHub && proxyPort !== undefined
? options.providerProxyHub.issue(routeInput)
: await startProviderAuthProxy({
...routeInput,
...(advertisedHost !== undefined ? { advertisedHost } : {}),
...(proxyPort !== undefined ? { port: proxyPort } : {}),
});
return {
// Proxy-minted, scoped, ephemeral token — never the real provider key. Routed
// through `--env-file` (0600, removed after the run) so it stays off argv.
envFile:
agent === 'maka'
? makaContainerTaskRun
? {
MAKA_PROVIDER_PROXY_URL: proxy.baseUrl,
MAKA_PROVIDER_PROXY_TOKEN: proxy.token,
}
: {
MAKA_HOST_REPO_ROOT: options.makaRepoPath,
MAKA_HOST_BASE_URL: proxy.baseUrl,
MAKA_HOST_API_KEY: proxy.token,
}
: {
MAKA_PROVIDER_PROXY_URL: proxy.baseUrl,
MAKA_PROVIDER_PROXY_TOKEN: proxy.token,
},
agentEnv: {},
usage: proxy.usage,
telemetry: proxy.telemetry,
close: proxy.close,
};
}
function providerDefaultBaseUrl(provider: string): string | undefined {
const definition = (
PROVIDER_DEFAULTS as Partial<Record<string, (typeof PROVIDER_DEFAULTS)[ProviderType]>>
)[provider];
return definition?.baseUrl;
}
async function writeEnvFile(path: string, env: Record<string, string>): Promise<void> {
// dotenv KEY=VALUE lines. Values here are minted/scoped proxy tokens and URLs,
// never the real provider key; the file is created 0600 and removed after run.
const body = Object.entries(env)
.map(([key, value]) => `${key}=${value}`)
.join('\n');
await writeFile(path, `${body}\n`, { encoding: 'utf8', mode: 0o600 });
await chmod(path, 0o600);
}
/** Pier's agent_setup phase budget (pier/trial/trial.py:176
* `_AGENT_SETUP_TIMEOUT_SEC = 360.0`); like the other phases it is scaled by
* the timeout multiplier (pier/trial/execution.py:129-143). */
const PIER_AGENT_SETUP_TIMEOUT_SEC = 360;
/** Pier's maximum legitimate trial lifecycle in task-native seconds. Owns the
* COMPLETE pier phase-and-retry model in one place — never patch phases in
* piecemeal. Pier runs environment build, then agent setup, then the agent
* once, then the verifier, and retries two of those phases once on their
* timeout errors (tenacity `stop_after_attempt(2)`: `start_environment` in
* pier/trial/execution.py:208 on EnvironmentStartTimeoutError, and
* `_verify_with_retry` in pier/trial/trial.py:333 on VerifierTimeoutError), so
* the legitimate ceiling is 2 x build + setup + agent + 2 x verifier. For
* DeepSWE (build 1800s, setup 360s, agent 5400s, verifier 1800s) that is
* 12960s — a derivation missing any phase or retry would let the watchdog
* kill legitimate trials as infra. */
function pierMaxTrialPhasesSec(metadata: TaskRunInput['task']['metadata']): number {
return (
2 * (metadata?.buildTimeoutSec ?? 0) +
PIER_AGENT_SETUP_TIMEOUT_SEC +
(metadata?.agentTimeoutSec ?? 0) +
2 * (metadata?.verifierTimeoutSec ?? 0)
);
}
/** Single-attempt structured outcome from Pier's scoring authority, aligned
* with the HarborVerifierOutcome contract the fixed-prompt controller consumes.
* Harbor reads this from the Maka oracle verifier's own JSON artifact; Pier has
* no such artifact, so the outcome is constructed from the graded reward. */
function pierVerifierOutcome(reward: number, durationMs: number): HarborVerifierOutcome {
const passed = reward > 0;
return {
outcome: passed ? 'passed' : 'failed',
attempts: [
{
attempt: 1,
classification: passed ? 'passed' : 'failed',
durationMs,
reward,
},
],
};
}
async function readVerifierDurationMs(resultPath: string): Promise<number> {
const result = await readOptionalJson(resultPath);
const phase = result && isRecord(result.verifier) ? result.verifier : null;
const started = typeof phase?.started_at === 'string' ? Date.parse(phase.started_at) : Number.NaN;
const finished =
typeof phase?.finished_at === 'string' ? Date.parse(phase.finished_at) : Number.NaN;
return Number.isFinite(started) && Number.isFinite(finished) && finished >= started
? finished - started
: 0;
}
/** The trial's grading state, read as ONE discriminated value from both
* persisted mirrors of Pier's scoring authority. Callers interpret it per
* context (a corrupt authority is infra when reading a grade, but does not
* overturn an already-exhausted agent budget at the budget gate), so the read
* itself never throws — there is exactly one place that decides what each
* state means for each context.
*
* - `graded`: an authoritative binary 0/1 reward. Either reward.json carried
* it and the trial result mirror (verifier_result.rewards.reward), if
* present, agreed; or reward.json was absent/empty and the result mirror
* carried it.
* - `ungraded`: neither mirror carried a numeric reward (the trial was never
* graded).
* - `invalid`: the grading authority existed but cannot be trusted as a grade
* — reward.json is not valid JSON, or is valid JSON with no finite numeric
* reward field, or a value is the crash sentinel / violates the binary 0/1
* contract, or the two mirrors both carried a value and disagree. */
type PierGrade =
| { readonly state: 'graded'; readonly reward: number }
| { readonly state: 'ungraded' }
| { readonly state: 'invalid'; readonly detail: string };
/** Read both persisted mirrors of Pier's scoring authority and reduce them to a
* single discriminated grade. Prefers the DeepSWE task verifier's reward.json,
* cross-checking it against the trial result's verifier_result.rewards.reward
* (same value on a completed trial); when reward.json is absent or empty the
* result mirror stands alone. Unlike the Maka oracle verifier, Pier tasks write
* no structured maka-verifier-outcome.json. */
async function readPierGrade(trialDir: string, taskId: string): Promise<PierGrade> {
const rewardJsonText = await readOptionalText(join(trialDir, TRIAL_REWARD_JSON));
let rewardJsonValue: number | null = null;
// An empty file is treated as absent (never written); a non-empty reward.json
// is the grading authority and, if it cannot be read as a numeric reward, is
// infra — the authority existed and cannot be trusted.
if (rewardJsonText && rewardJsonText.trim().length > 0) {
let parsed: unknown;
try {
parsed = JSON.parse(rewardJsonText);
} catch (error) {
return {
state: 'invalid',
detail: `verifier reward.json is not valid JSON for task ${taskId}: ${errorText(error)}`,
};
}
if (isRecord(parsed) && typeof parsed.reward === 'number' && Number.isFinite(parsed.reward)) {
rewardJsonValue = parsed.reward;
} else {
return {
state: 'invalid',
detail: `verifier reward.json for task ${taskId} has no valid numeric reward field; a corrupt reward.json is infra — the grading authority existed and cannot be read`,
};
}
}
const result = await readOptionalJson(join(trialDir, TRIAL_RESULT));
const verifierResult = isRecord(result?.verifier_result) ? result.verifier_result : undefined;
const rewards =
verifierResult && isRecord(verifierResult.rewards) ? verifierResult.rewards : undefined;
const resultValue =
rewards && typeof rewards.reward === 'number' && Number.isFinite(rewards.reward)
? rewards.reward
: null;
if (rewardJsonValue !== null && resultValue !== null && rewardJsonValue !== resultValue) {
return {
state: 'invalid',
detail: `verifier reward mirrors disagree for task ${taskId}: ${TRIAL_REWARD_JSON} reward ${rewardJsonValue} != ${TRIAL_RESULT} verifier_result.rewards.reward ${resultValue}; the two persisted mirrors of the grading authority must be identical`,
};
}
const value = rewardJsonValue ?? resultValue;
if (value === null) return { state: 'ungraded' };
return classifyPierReward(value, taskId);
}
/** DeepSWE's grading contract is BINARY: grader.py documents the main reward
* as exactly 0 or 1 ("reward binary 0/1"). -1 is the verifier CRASH sentinel —
* every task's tests/test.sh traps EXIT with `echo -1 > reward.txt` when the
* verifier died before writing any reward file, grader.py documents that path
* as "an infrastructure error", and pier's verifier parses reward.txt verbatim
* into verifier_result.rewards.reward. Any other value (0.5, 2, ...) can only
* come from corrupt or non-contract verifier output. Recording either as a
* grade would poison the benchmark: a sentinel as a scored failure, a
* fractional value as a pass (`reward > 0`). */
function classifyPierReward(reward: number, taskId: string): PierGrade {
if (reward === 0 || reward === 1) return { state: 'graded', reward };
if (reward < 0) {
return {
state: 'invalid',
detail: `verifier crashed for task ${taskId}: reward ${reward} is the DeepSWE test.sh crash sentinel, not a grade`,
};
}
return {
state: 'invalid',
detail: `verifier reward ${reward} for task ${taskId} violates the DeepSWE binary 0/1 contract (grader.py); treating as infra, not a grade`,
};
}
/** Graded read path (normal completion and non-budget exception fall-through):
* the scoring authority is authoritative here, so an invalid grade IS infra. */
async function readPierReward(
trialDir: string,
taskId: string,
trialException: string | undefined,
): Promise<number> {
const grade = await readPierGrade(trialDir, taskId);
if (grade.state === 'graded') return grade.reward;
if (grade.state === 'invalid') throw new PierInfraError(grade.detail);
// ungraded — mirror Harbor's readReward diagnostics: when the trial recorded
// an exception and grading never produced a reward, name the exception, which
// is the root cause, not the missing file.
if (trialException) {
throw new PierInfraError(
`pier trial failed before verifier reward for task ${taskId}: ${trialException}`,
);
}
throw new PierInfraError(`missing verifier reward for task ${taskId}`);
}
/** Grace between SIGTERM and SIGKILL when the watchdog fires. Pier's SIGTERM
* handler (pier/cli/jobs.py:771 -> :148 raises KeyboardInterrupt) unwinds its
* Python finally chain, which owns docker compose teardown — containers need
* real time to stop and delete, so the grace must cover a compose down. */
const DEFAULT_TERMINATION_GRACE_MS = 120_000;
const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024 * 1024;
/** Two-phase trial termination. A direct SIGKILL (execFile's timeout path)
* would skip every Python finally in pier — leaking docker compose containers
* — and, on the Maka host-cell arm, orphan the run-host-cell.mjs child, which
* would keep burning real tokens until its own cell deadline. Instead the
* child runs detached in its own process group; on timeout the whole group
* gets SIGTERM (triggering pier's own teardown — the teardown authority is
* pier's finally chain, never docker scanning here), then SIGKILL after the
* grace. Exported for the termination-contract tests. The Harbor runner shares
* this SIGKILL gap on main; its CLI's signal semantics are unverified, so that
* fix is deliberately out of this module's scope. */
export const defaultPierProcessRunner: PierProcessRunner = async (request) => {
return await new Promise<PierRunResult>((resolvePromise) => {
const child = spawn(request.pierBin, [...request.args], {
cwd: request.cwd,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
...(request.env ? { env: { ...process.env, ...request.env } } : {}),
});
let stdout = '';
let stderr = '';
let timedOut = false;
let spawnError: Error | null = null;
let graceTimer: NodeJS.Timeout | undefined;
const watchdog =
request.timeoutMs !== undefined
? setTimeout(() => {
timedOut = true;
killProcessGroup(child, 'SIGTERM');
graceTimer = setTimeout(
() => killProcessGroup(child, 'SIGKILL'),
request.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS,
);
}, request.timeoutMs)
: undefined;
child.stdout?.on('data', (chunk: Buffer) => {
if (stdout.length < MAX_CAPTURED_OUTPUT_BYTES) stdout += chunk.toString('utf8');
});
child.stderr?.on('data', (chunk: Buffer) => {
if (stderr.length < MAX_CAPTURED_OUTPUT_BYTES) stderr += chunk.toString('utf8');
});
child.once('error', (error) => {
spawnError = error;
});
child.once('close', (code, signal) => {
clearTimeout(watchdog);
clearTimeout(graceTimer);
resolvePromise({
exitCode: code ?? 1,
stdout,
stderr: stderr || (spawnError ? errorText(spawnError) : ''),
...(timedOut ? { timedOut } : {}),
...(signal ? { signal } : {}),
});
});
});
};
function killProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void {
if (child.pid === undefined) return;
try {
// Negative pid: signal the whole detached process group, so pier's own
// children (docker compose, the host cell) are reached too.
process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
// Already gone.
}
}
}
// The generic helpers below (readOptionalText .. isRecord) intentionally stay
// local copies of their harbor-task-runner counterparts: they carry no
// benchmark semantics, and exporting 3-line utilities would widen the Harbor
// module's surface for no invariant. Everything with benchmark meaning is
// imported from harbor-task-runner above.
async function readOptionalText(path: string): Promise<string | null> {
try {
return await readFile(path, 'utf8');
} catch {
return null;
}
}
async function readOptionalJson(path: string): Promise<Record<string, unknown> | null> {
const raw = await readOptionalText(path);
if (raw === null) return null;
try {
const parsed: unknown = JSON.parse(raw);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function sanitize(value: string): string {
return value.replace(/[^A-Za-z0-9._-]/g, '_');
}
function tail(text: string, lines = 20): string {
return text.split('\n').slice(-lines).join('\n');
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}