/** * Session ledger: one line per armed model session, appended best-effort to * `$GSTACK_EVAL_DIR/session-ledger.jsonl` (the same sidecar pattern as * contract-violations.jsonl). Runners record facts here; the record builders * (classifyTrialShard, junitCensus) attach them to trial records and * failureCauseOf() in eval-store.ts classifies. A failed append never fails * a trial: the record then shows its sessions as unknown. * * Each row holds a stable session key, the exact timeout the session armed, * monotonic elapsed time, the end reason, one sanitized evidence line and, * for stream-JSON runners, a compact liveness summary (never message text). */ import * as fs from 'fs'; import * as path from 'path'; import type { CodexTokenUsage } from './codex-session-runner'; export const SESSION_LEDGER_FILE = 'session-ledger.jsonl'; export const SESSION_EVIDENCE_MAX = 200; export type SessionRunner = 'claude-p' | 'agent-sdk' | 'pty' | 'codex'; export type SessionEnd = 'completed' | 'session_timeout' | 'observer_timeout' | 'api_error' | 'refusal' | 'aborted' | 'error'; export const SESSION_ENDS: readonly SessionEnd[] = ['completed', 'session_timeout', 'observer_timeout', 'api_error', 'refusal', 'aborted', 'error']; /** A provider rejected the run's credentials: Anthropic's 401 authentication_error body, the CLI's * authentication_failed code or its "Invalid API key · …" message, or OpenAI's invalid_api_key code. */ export const CREDENTIAL_REJECTION = /"type"\s*:\s*"authentication_error"|\bauthentication_failed\b|\bInvalid API key\s*·|"code"\s*:\s*"invalid_api_key"/i; export interface LivenessSummary { /** The session streamed partial messages. Without them no stall is ever inferred. */ partial: boolean; events: number; turns: number; /** Longest gap between events (or before the end) while a model request was in * flight and no tool call, hook or permission prompt was outstanding. */ max_request_silence_ms: number; /** Elapsed ms when that silence began, and the kind of event before it. */ silence_started_ms?: number; silence_after?: string; last_event?: string; open_tool_at_end?: string; } export interface SessionLedgerRow { key: string; case?: string; test_name?: string; runner: SessionRunner; started_at: string; /** The timeout this session armed; absent when the runner armed none. */ budget_ms?: number; elapsed_ms: number; end: SessionEnd; evidence?: string; liveness?: LivenessSummary; /** Whether the runner captured this session's billing (a terminal result with total_cost_usd). */ billed?: boolean; /** Codex sessions: the model, its billed cost and token classes. */ model?: string; cost_usd?: number; tokens?: CodexTokenUsage; } /** One line, control characters stripped, mentions neutralized, capped. */ export function sessionEvidence(text: string | undefined): string | undefined { const first = text?.split('\n').map(line => line.trim()).find(Boolean); if (!first) return undefined; // eslint-disable-next-line no-control-regex const clean = first.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/@(?=[A-Za-z0-9_-])/g, '@\u200b'); return clean.length > SESSION_EVIDENCE_MAX ? `${clean.slice(0, SESSION_EVIDENCE_MAX - 1)}…` : clean; } const ordinals = new Map(); /** Stable within a case: `: