/** * Content-addressed cache for evidence-tool runs (ADR-061). * * `cleo verify --evidence "tool:"` historically spawned the resolved * toolchain (test, build, lint, …) on every call. With N parallel verify * processes — common in orchestrator-spawned waves — this multiplied a heavy * monorepo test or build by N, saturating CPU and memory. * * This module wraps tool execution with: * * 1. **Content-addressed cache** — keyed on `(canonical, cmd, args, * treeHash)`, where `treeHash` is the git tree of the tracked content as * it sits in the working tree (T12958, see {@link captureTreeHash}). * Cache hits return the prior `exitCode + stdoutTail` without spawning * the tool. Two worktrees holding the same content, or a commit, rebase * or amend that leaves the tree unchanged, share one result. * 2. **Cross-process semaphore** — when N processes simultaneously miss the * cache, only one runs the tool; the rest block on a `proper-lockfile` * and read the freshly-written cache entry. * 3. **Automatic invalidation** — entries become unreachable as soon as the * tracked content changes, because the key moves with it. Nothing is * deleted on access (no GC daemon required). * 4. **Failed-first reruns** — a failing `test` run remembers its failing * test files; the next run re-runs those first and stops if they still * fail (T12961, see `tool-cache-failed-first.ts`). A failing run is * re-run in full once; a pass then is recorded as `flaky`. * 5. **Resource kills are not results** — a run killed by a signal, an * exit of 128 + a kill signal, a heap OOM or worker crash its output * reports, or (on Linux) an OOM kill in its systemd scope is returned but * never cached, and the key includes the heap and worker limits the run * was given, so a retry with more memory always runs (T12989, see * {@link resourceKillReason}). Limitation (T13011): where no scope * confines the run (macOS, Windows, no user systemd), a kill that a * wrapper reports as a plain exit 1 with no marker in the output (pnpm * 12+ `-r` does this) cannot be told from a failing test and is cached * as an ordinary red. * * Cache layout (under `/.cleo/cache/evidence/`): * * - `.json` — cache entry payload * - `.json.lock` — proper-lockfile state (auto-managed) * * Each entry is a single small JSON file (≤ 4 KB) so the cache is cheap to * keep and easy to inspect / wipe (`rm -rf .cleo/cache/evidence`). * * @task T1534 * @adr ADR-061 */ import type { HeavyToolResourcePlan } from '@cleocode/contracts'; import { type LimitedCommand } from './heavy-tool-limit.js'; import { type FailedFirstReport } from './tool-cache-failed-first.js'; import { type ResolvedToolCommand } from './tool-resolver.js'; import { type ToolProcessResult } from './tool-runner-guard.js'; import { type AcquireSlotOptions } from './tool-semaphore.js'; /** * One cached tool execution. * * @task T1534 */ export interface ToolCacheEntry { /** * Schema version for forwards compatibility. * * Bumped 1 -> 2 by gh#1419, which added {@link ToolCacheEntry.executionRoot} * to the run's identity, 2 -> 3 by T12958, which replaced HEAD + dirty * fingerprint + execution root with the tree hash, and 3 -> 4 by T12989, * which added {@link ToolCacheEntry.resourceEnv}. Every entry written under * an older identity is refused wholesale by {@link readCacheEntry} on this * one comparison — the retirement mechanism gh#1380 and gh#1404 each rebuilt * by hand with a bespoke null-check on the field they had just added. * * @task T12190 (gh#1419) * @task T12958 * @task T12989 */ schemaVersion: typeof TOOL_CACHE_SCHEMA_VERSION; /** Cache key (also encoded in the filename). */ key: string; /** Canonical tool name from the resolver. */ canonical: string; /** Display name (the alias the user supplied). */ displayName: string; /** Resolved cmd. */ cmd: string; /** Resolved args. */ args: string[]; /** Resolution source from the resolver. */ source: string; /** * Git tree object of the tracked content the run measured — HEAD's tree with * every uncommitted tracked change applied (see {@link captureTreeHash}). * * This is the run's content IDENTITY and the only repo-state input to the * key (T12958). It replaced `head` + `dirtyFingerprint` + `executionRoot`, * which named WHERE and AFTER WHICH COMMIT a run happened rather than WHAT it * measured, so every worktree and every commit missed — including an empty * commit or a rebase that left the content byte-identical. * * The tree object is written to the repository's (shared) object database, * so a hit stays auditable after the worktree that produced it is deleted: * `git ls-tree ` shows exactly the content that ran. * * @task T12958 */ treeHash: string | null; /** * Fingerprint of the per-checkout environment the run depended on — the * installed-lockfile snapshot, workspace `dist/` file sizes and `.env*` * content (see `tool-cache-env.ts`). `'none'` for tools that read only * source. Part of the key. * * @task T12958 */ envFingerprint: string; /** * The resource limits the run was spawned with, as readable `NAME=value` * pairs: the heap flags in `NODE_OPTIONS` and, for heavy tools, every worker * count and memory lever `heavyToolEnv` manages (see `captureResourceEnv` in * `tool-cache-env.ts`). Part of the key, so a retry with a larger heap or * fewer workers is a different run rather than a hit on the old result, and * a cached result says which limits produced it. * * @task T12989 */ resourceEnv: string; /** Git HEAD sha at execution time. Informational since T12958: NOT keyed. */ head: string | null; /** * Absolute, symlink-resolved path of the tree the tool actually ran in. * * Recorded for audit, NOT part of the key since T12958. gh#1419 keyed it to * stop two worktrees at one HEAD sharing a result while their content * differed; the tree hash keys on the content itself, so two trees share a * result exactly when they hold the same tracked content, which is the case * sharing is correct for. Nothing the tool returns depends on the directory * name: the command and args are keyed, and script resolution is a function * of the tracked `package.json` content the tree hash covers. * * The gh#1419 "unfalsifiable after the worktree is deleted" concern is met by * {@link ToolCacheEntry.treeHash}: the content is a git object, not a path. * * @task T12190 (gh#1419) * @task T12958 */ executionRoot: string; /** Process exit code. */ exitCode: number | null; /** * POSIX signal that terminated the run, when one did (gh#1381). * * Optional because entries written before this field existed do not carry * it; absent and `null` both mean "not killed by a signal". */ signal?: NodeJS.Signals | null; /** * Why the run is not a verdict on the code: it was killed for resources * ({@link resourceKillReason}). An entry that carries it is never served or * persisted ({@link isEntryUsable}); it appears only on the `entry` of the * result that reports the kill. * * @task T12989 */ resourceKill?: string; /** Last 512 bytes of stdout. */ stdoutTail: string; /** Last 512 bytes of stderr. */ stderrTail: string; /** Total duration in milliseconds. */ durationMs: number; /** ISO 8601 wall-clock timestamp of the run. */ capturedAt: string; /** * Test files (relative to the execution root) that failed in this run, when * the tool is `test`, the run failed and the runner's output named them. * Absent when unknown. * * @task T12961 */ failedTestFiles?: string[]; /** * Present when a failed-first stage ran before this result. An entry whose * `failedFirst.outcome` is `failed` was decided by the focused rerun alone: * the previously failing files still failed, so the normal command was * never spawned. A failing subset is a failing suite, which is why that * result is cached under the normal command's key. * * @task T12961 */ failedFirst?: FailedFirstReport; /** * Test files that failed and then passed on their single retry (T12961). * Present only on a PASSING entry: the run counts as a pass, but a flaky * pass, which is visible here rather than indistinguishable from a clean * one. * * @task T12961 */ flaky?: string[]; /** * The tail of the first, failing run's output when the entry is a flaky * pass — the audit trail of what failed before the rerun passed. * * @task T12961 */ flakyFailureTail?: string; /** * `focused`: this record describes a failed-first run of only * {@link ToolCacheEntry.ranFiles}, not the recorded command. Such records * are returned to the caller but never persisted under the command's key. * * @task T12961 */ scope?: 'focused'; /** The test files a `scope: 'focused'` run executed. */ ranFiles?: string[]; } /** * Result of {@link runToolCached}. Mirrors the legacy `validateTool` * contract so callers can unconditionally inspect `exitCode + stdoutTail`. * * @task T1534 * @task T12025 */ export interface ToolRunResult { exitCode: number | null; /** * POSIX signal that terminated the run, or `null`. Non-null means the tool * STARTED and was killed — which is a different fact from `exitCode: null` * alone, and the one the caller needs to avoid reporting a killed run as a * missing binary (gh#1381). */ signal: NodeJS.Signals | null; stdoutTail: string; stderrTail: string; durationMs: number; /** `true` when the result came from cache (no spawn occurred). */ cacheHit: boolean; /** * Git tree of the source this result measured ({@link captureTreeHash}); * `null` off git. Exposed so evidence atoms can bind to it (T12958). */ treeHash: string | null; /** * `true` when the wall-clock child-process deadline was exceeded and the * tool was terminated before producing a result. The lock + semaphore * slot are released; a subsequent retry will attempt a fresh spawn. * * @task T12025 */ timedOut: boolean; /** * `true` when the per-key cache lock was held by another process and * could not be acquired within the fail-fast retry window (~0.7 s). * The semaphore slot is released; a subsequent retry will re-attempt * lock acquisition. * * @task T12025 */ lockBusy: boolean; /** * Absolute path of the tree this run was executed in and fingerprinted * against. * * Reported so the operator can SEE which checkout produced the evidence * instead of inferring it from a confusing failure — the explicit ask in * gh#1220. Lives on the result rather than on {@link ToolCacheEntry} * because the entry is content-addressed and shared between worktrees: a * hit served to a second worktree must not claim to have run in the first * one's directory. * * @task T12112 (gh#1220) */ executionRoot: string; /** * The diagnostic line from CLEO's own confinement wrapper when it failed to * START the tool, else `null`. * * gh#1397: `systemd-run --scope` is transparent on success — it exits with * the wrapped command's status — but exits `1` when it cannot create the * transient unit, and `1` is also what a suite with a failing test exits * with. "The harness never started" and "the suite ran and was red" are * therefore identical in `exitCode`, and CLEO reported both as * `E_EVIDENCE_TOOL_FAILED`: a fast red suite. Five occurrences of gh#1396 * produced no diagnosis for exactly this reason. * * Non-null means the tool did NOT run, so nothing is cached and the caller * must report an unavailable harness rather than a failing tool. * * @task T12116 (gh#1397) */ harnessFailure: string | null; /** * Why the run was killed for resources ({@link resourceKillReason}), else * `null`: a signal, an exit of 128 + a kill signal, or output reporting a * heap OOM. Non-null means the exit code is not a verdict on the code and * nothing was cached; a retry runs again, and a retry with a larger heap or * fewer workers runs under a different key. `null` too when `timedOut` or * `harnessFailure` already explains the non-result. * * @task T12989 */ resourceKill: string | null; /** * The failed-first stage of this run (T12961), when one ran: which files * were re-run first and whether they decided the result. */ failedFirst?: FailedFirstReport; /** * Test files from the first, failing run when its one full rerun passed * (T12961). Set only on a pass: it marks a FLAKY pass, distinct from a * clean one, for evidence atoms and gates to surface. */ flaky?: string[]; /** * The heap, worker count and workspace concurrency a memory-bound tool was * spawned with (or, on a hit, that this call's environment plans — the cache * key includes them, so a hit was produced under the same limits), and why * (T13122). Absent for tools that are not memory-bound. */ resources?: HeavyToolResourcePlan; /** Full cache entry — useful for audit / debugging. */ entry: ToolCacheEntry; } /** * Options for {@link runToolCached}. * * @task T1534 */ export interface RunToolOptions { /** * When `true`, bypass the cache (always spawn). The fresh result is still * written to cache for subsequent calls. * * When omitted, `CLEO_EVIDENCE_FRESH=1` in the environment turns this on. * It is the escape hatch for anything the key cannot see — a change under * an excluded or gitignored path, or environment state outside the * fingerprint. It also skips failed-first reruns (T12961). * * @defaultValue `false` (or `CLEO_EVIDENCE_FRESH === '1'`) * @task T12112 (gh#1221) */ bypassCache?: boolean; /** * Lock-acquire timeout in ms. The default (10 minutes) covers full * monorepo test suites. * * @defaultValue `600_000` */ lockStaleMs?: number; /** * Maximum tail length for stdout / stderr capture. * * @defaultValue `512` */ tailBytes?: number; /** * When `true`, skip machine-wide admission (the admission ledger that * bounds every heavy run's memory across the machine, T13133). Use only in * tests where admission would block arbitrary parallel sibling tests. * * @defaultValue `false` */ skipGlobalSemaphore?: boolean; /** * Tuning for the machine-wide admission. Forwarded to * {@link acquireGlobalSlot}. * * @internal */ semaphoreOptions?: AcquireSlotOptions; /** * Wall-clock deadline for the child process (ms). When exceeded the * process is SIGTERM'd, then SIGKILL'd after a 5 s grace period. * The lock and semaphore slot are always released so a subsequent * retry can proceed. When omitted, the deadline resolves via * {@link resolveSpawnTimeoutMs} (`CLEO_TOOL_TIMEOUT_` env * override over {@link DEFAULT_SPAWN_TIMEOUT_MS}); tests inject shorter * values for determinism. * * @defaultValue `300_000` (5 min) * @task T12025 * @task T12105 */ spawnTimeoutMs?: number; /** * Absolute path of the tree the tool should actually RUN in, and whose git * state is fingerprinted for the cache key. * * `projectRoot` is the CLEO **store** root: for a git worktree, * `getProjectRoot()` deliberately resolves to the MAIN repo so every * worktree shares one `.cleo/` database. Reusing that value as the tool's * working directory is what made evidence describe the wrong tree * (gh#1220, gh#1226, gh#1230) — a verify launched from a worktree measured * the main checkout, which on a shared box carries a peer's in-flight * branch and untracked files. That yields a false FAIL when the peer is * red, and — the dangerous direction — a silent false PASS when the peer is * green, attesting a run that never touched the code under test. * * Defaults to `projectRoot`, preserving single-checkout behaviour exactly. * * @task T12112 (gh#1220, gh#1226, gh#1230) */ executionRoot?: string; /** * How long a caller that finds this key's lock held waits for the holder's * result before giving up with `lockBusy` (T12958). The holder is running * the identical command on identical content, so waiting and reusing its * result is always cheaper than running in parallel. * * @defaultValue 3 × the spawn deadline + 60 s (focused rerun + normal run * + one flake retry) */ lockWaitMs?: number; /** * Poll interval while waiting on a held lock. * * @defaultValue `1_000` * @internal */ lockPollMs?: number; } /** * Most failing test files a failed `test` run may name and still get its one * full flake retry (T12961). More, or none named, is treated as a * deterministic failure: no retry. * * @task T12961 */ export declare const MAX_FLAKE_RETRY_FILES = 3; /** * Default wall-clock deadline for one evidence-tool child process, in ms. * * 5 minutes covers a full monorepo test suite on an idle machine. Loaded * CI runners sharing the box can push a suite past this — raise it per * tool with `CLEO_TOOL_TIMEOUT_` rather than weakening the * deadline globally in code. * * @task T12025 * @task T12105 */ export declare const DEFAULT_SPAWN_TIMEOUT_MS = 300000; /** * Wall-clock deadline for the HEAVY tool classes (`test`, `build`), in ms. * * ## Why these need their own default (gh#1221) * * A test suite is categorically not a linter. The single 300s default was * below a real monorepo suite — measured ~10 min in the gh#1221 report — so * EVERY run was killed before finishing, and the timeout path deliberately * caches nothing (T12025, correct: an unfinished run is not a result). The * cache could therefore never hit, not because the key moved but because no * entry was ever produced, on a path that always fired. Each attempt still ran * the suite at full parallelism for the full 300s before discarding it — the * worst possible shape, and the load multiplier that wedged shared hosts. * * 30 min is 3x the measured ~10 min suite, so a project whose suite triples * still completes on the default rather than discovering an env var after * burning 5 CPU-minutes to learn its name. It is a ceiling on a pathological * hang, not a budget anyone should plan to use. * * ## Why a longer rope is safe now, and was not before * * Raising a deadline means a runaway suite runs LONGER before anything stops * it, and the 300s kill was accidentally acting as a crude memory * circuit-breaker. Heavy tools are now spawned inside a memory-bounded scope * with swap denied (T12116), so duration no longer converts into unbounded * host memory: a runaway dies inside its own boundary and CLEO reports a * failed run. A failed test run is a result; a frozen workstation is not. * * `lint`, `typecheck`, `audit` and `security-scan` deliberately do NOT inherit * this — they are single-process and CPU-bound, and a lint that has run for 5 * minutes is hung, not busy. * * @task T12126 (gh#1221) */ export declare const HEAVY_TOOL_SPAWN_TIMEOUT_MS = 1800000; /** * The default wall-clock deadline for a canonical tool, before any env * override. * * @param canonical - Canonical tool name from the resolver. * @returns Deadline in milliseconds. * * @task T12126 (gh#1221) */ export declare function defaultSpawnTimeoutMs(canonical: string): number; /** * Resolve the wall-clock child-process deadline for a canonical tool. * * Precedence: * 1. `CLEO_TOOL_TIMEOUT_` env var (canonical name uppercased, * dashes → underscores). Value is * milliseconds, digits only, strictly positive. * 2. {@link DEFAULT_SPAWN_TIMEOUT_MS}. * * A set-but-invalid value (non-numeric, zero, negative) is a configuration * ERROR, not a fallback: silently ignoring it would let an operator believe * they raised the deadline while the old one still fires (gh#1193). * * @param canonical - Canonical tool name from the resolver. * @param env - Environment to read (injectable for tests). * @returns Deadline in milliseconds. * @throws CleoError(VALIDATION_ERROR) when the env var is set but invalid. * * @task T12105 (gh#1193) */ export declare function resolveSpawnTimeoutMs(canonical: string, env?: NodeJS.ProcessEnv): number; /** * The fields that establish WHICH RUN a cache entry describes. * * This list is the single source of truth for three things that must agree * and previously did not, because each was maintained by hand: * * 1. what {@link computeCacheKey} hashes, * 2. what {@link readCacheEntry} requires before serving an entry, * 3. what `runToolCached` requires before persisting one. * * ## Why this exists rather than a fourth guard clause * * Three defects in three releases, each invisible to the detector written for * the one before it: * * | | gh#1380 (2026.9.2) | gh#1404 (2026.9.3) | gh#1419 (this) | * |---|---|---|---| * | signature | `exitCode: null` | real code, `head: null` | well-formed, genuinely succeeded | * | wrong how | never ran | key cannot rotate | ran, on the wrong tree | * | detector | all-null | real code + null head | neither matches | * * Each fix added one `if` naming one field, to two separate guards — the read * path and the write path — that encode the same rule in different code with * nothing asserting they agree. A fourth defect means a fourth pair of `if`s, * and the pair for defect five is the one somebody edits only half of. * * The recurring mistake is not any of the three fields. It is that a field * could be added to {@link ToolCacheEntry} without being added to the key: * `executionRoot` was threaded all the way through execution by T12112 and * never reached `computeCacheKey`, so the tool correctly ran in worktree A * and its result was then served to worktree B under an identical key. The * execution half of that fix landed; the caching half did not. * * Deriving all three consumers from this one array makes that specific * mistake unavailable: a field added here is keyed, required on read, and * required on write, together or not at all. * * ## What is deliberately NOT here * * `exitCode` is a RESULT, not an identity — it is what the run produced, not * which run it was. It has its own non-null requirement in * {@link isEntryUsable} (gh#1380: an unknown outcome must never be cached). * Putting it here would key the cache on its own answer. * * `head` and `executionRoot` are recorded on every entry but are no longer * identity (T12958). Both name a LOCATION — which commit, which directory — * rather than the content measured, so keying on them made every worktree and * every commit a miss even when the tracked content was byte-identical. * `treeHash` is the content itself; it subsumes the old `dirtyFingerprint`. * `envFingerprint` covers the per-checkout state git does not see (installed * deps, gitignored build output, `.env*`), so sharing across worktrees * happens only when BOTH the source and the environment match. * `resourceEnv` (T12989) covers the limits the run was spawned with — heap * flags and worker counts — which neither the tree nor the checkout shows: a * 3 GB run that ran out of memory and a 6 GB retry of the same code are two * runs, and keying them as one is what replayed the OOM. * * @task T12190 (gh#1419) * @task T12958 * @task T12989 */ export declare const TOOL_RUN_IDENTITY_FIELDS: readonly ["canonical", "cmd", "args", "treeHash", "envFingerprint", "resourceEnv"]; /** * Current {@link ToolCacheEntry.schemaVersion}. Entries of any other version * are refused on read. * * @task T12958 * @task T12989 */ export declare const TOOL_CACHE_SCHEMA_VERSION = 4; /** * The identity half of a {@link ToolCacheEntry} — the inputs that determine * which run it is, independent of what that run produced. * * @task T12190 (gh#1419) */ export type ToolRunIdentity = Pick; /** * Compute the cache key for a resolved tool command + tracked tree content. * * The key covers exactly {@link TOOL_RUN_IDENTITY_FIELDS} — canonical tool * name, resolved command and args, the tree hash, the environment * fingerprint and the resource environment. Fields are projected in * the order that array declares, so the hashed payload is a function of the * array and adding a field there changes every key by construction. * * There is deliberately no directory parameter (T12958): two checkouts with * the same tracked content get the same key, which is what lets parallel * worktrees and post-rebase re-verifies reuse one run. * * Using `createHash('sha256')` makes the key collision-resistant and bounded * to 32 hex chars regardless of input size. * * @param command - Resolved tool command. * @param treeHash - {@link captureTreeHash} of the execution root. * @param envFingerprint - {@link captureEnvFingerprint} of the execution root. * @param resourceEnv - {@link captureResourceEnv} of the spawn environment. * @returns 32 hex chars. * * @task T1534 * @task T12190 (gh#1419) * @task T12958 * @task T12989 */ export declare function computeCacheKey(command: ResolvedToolCommand, treeHash: string | null, envFingerprint: string, resourceEnv: string): string; /** * Whether a cache entry may be SERVED or PERSISTED as a real result. * * One predicate, used by both {@link readCacheEntry} and the persist guard in * `runToolCached`, so the two cannot drift apart. Three rules: * * 1. every field in {@link TOOL_RUN_IDENTITY_FIELDS} is present and * non-null — an entry that cannot say which run it describes is not * evidence of anything; * 2. `exitCode` is non-null — a run that produced no exit code records that * we do not know what happened, and an unknown must never be cached * (gh#1380); * 3. `resourceKill` is absent — a run killed for resources (an OOM, a heap * limit, exit 137) has a real exit code that says nothing about the code, * and caching it replays the kill to every retry, including one given * the memory it lacked (T12989). * * Rule 1 subsumes gh#1404's `head === null` clause without naming `head`: * when the tool runs off a non-git root `treeHash` is null on EVERY run, so * the key is a function of the command alone and nothing a developer * does to the source can rotate it. That is not a cache, it is a hardcoded * answer with a filename — and unlike gh#1380 it holds a real exit code, * which can be a PASS. A poisoned red is investigated within minutes; nobody * debugs a passing gate. * * The cost is stated rather than hidden: a project whose CLEO root is not a * git checkout gets no tool-result caching at all. Such projects are not * getting valid caching today — they are getting one answer forever. * * @task T12190 (gh#1419) * @task T12989 */ export declare function isEntryUsable(entry: Partial): boolean; /** * Watch one output stream for a {@link RESOURCE_KILL_MARKERS} marker (T13012). * Each chunk is scanned with the end of the previous one prepended, so a * marker split across chunks is found; the first marker found sticks. * Decoded as latin1 (one char per byte): the markers are ASCII, and a * multi-byte character split across chunks cannot hide one. * * @returns `scan` for each chunk and `seen` for the marker found, or `null`. * * @internal Exported for tests. */ export declare function killMarkerScanner(): { scan(chunk: Buffer): void; seen(): string | null; }; /** * Why a finished run was killed for resources, or `null` when its outcome is * a real result. * * Decides from the exit and the captured output, in order: * * 1. a terminating `signal` — the process did not exit on its own; * 2. an exit code of `128 + n` for a {@link KILL_SIGNALS} signal — the same * kill, reported by a wrapper (`sh` exits 137 for a `SIGKILL`ed child); * 3. a non-zero exit in a systemd scope the kernel OOM-killed in (Linux, * T13011) — `pnpm -r` 12+ reports a SIGKILLed child as plain exit 1; * 4. a non-zero exit whose output carries a {@link RESOURCE_KILL_MARKERS} * marker — vitest catches a worker's heap OOM and exits 1. The marker * may be anywhere in the stream (`killMarker`, T13012), not only in the * captured tail. * * Exit 0 is always a result, and so is a plain non-zero exit such as a failing * assertion's 1: that red stays cacheable for failed-first reruns. A run that * never started (`exitCode` and `signal` both null) is not a kill; it is * reported, and refused by {@link isEntryUsable}, as an unknown outcome. * * @param run - The finished run's exit code, signal, captured output, the * marker its stream scan saw and the systemd scope the kernel OOM-killed in. * @returns A short reason, e.g. `exit 137 (128 + SIGKILL)`, or `null`. * * @example * ```ts * resourceKillReason({ exitCode: 1, signal: null, stdout: '', stderr: 'FAIL a.test.ts' }); // null * resourceKillReason({ exitCode: 137, signal: null, stdout: '', stderr: '' }); * // → 'exit 137 (128 + SIGKILL)' * ``` * * @task T12989 * @task T13011 * @task T13012 */ export declare function resourceKillReason(run: { exitCode: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string; killMarker?: string | null; oomKilledScope?: string | null; }): string | null; /** * {@link resourceKillReason} for a run spawned as `limited`: a non-zero exit * with no other kill evidence asks the run's systemd scope whether the kernel * OOM-killed in it (T13011), since `pnpm -r` 12+ reports that kill as exit 1. * * @param run - The finished run. * @param limited - How it was spawned ({@link withMemoryLimit}). * @param oomKilled - The scope probe (tests inject one). @defaultValue {@link scopeOomKilled} * @returns The kill reason, or `null` for a real result. * * @task T13011 */ export declare function runKillReason(run: CommandResult, limited: Pick, oomKilled?: (unitName: string) => boolean): string | null; /** Captured output of one spawned command (shared with the test-runner guard). */ type CommandResult = ToolProcessResult; /** Signals whose default action would end cleo and orphan the tool groups it started. */ declare const TERMINATION_SIGNALS: readonly ["SIGHUP", "SIGINT", "SIGTERM"]; /** A signal {@link terminateToolGroupsOnSignal} handles. */ type TerminationSignal = (typeof TERMINATION_SIGNALS)[number]; /** * SIGTERM every tool group this process started, then re-raise the signal to * this process so it ends exactly as it would have without us (T12963). * * Tools run detached, in their own process group, so a signal that ends cleo * (Ctrl-C reaches only the terminal's foreground group) never reaches them: * the tool kept running, and the slot it held looked free once cleo was gone. * * Any listener at all suppresses a signal's default action, and * `proper-lockfile` loads `signal-exit`, which re-raises only when its own * listeners are the last ones left. So ours removes itself and re-raises: * `signal-exit` then releases its locks and the process dies by the signal. * Another listener of the signal sees it twice. * * T13018: best-effort drains (brain writer, embedding and mental-model * queues) listen with `process.once` and finish within 2 s. Re-raised at once, * the signal found them consumed and `signal-exit` ended the process mid-drain. * When such a listener was registered at install time or is still pending, * the re-raise waits {@link SIGNAL_DRAIN_GRACE_MS} on a timer that keeps the * process alive; a drain that calls `process.exit` first ends it sooner. * * @param signal - The signal received. * * @internal Exported for tests. * @task T12963 * @task T13018 */ export declare function terminateToolGroupsOnSignal(signal: TerminationSignal): void; /** * The `exit` listener: SIGTERM every tool group still running. `exit` * listeners must be synchronous, and `process.kill` is. * * @internal Exported for tests. * @task T12963 */ export declare function terminateToolGroupsOnExit(): void; /** * The real tool process runner: spawns `cmd` in its own process group with a * tail-bounded capture and a wall-clock kill. A test that means to start a * child process injects it with `injectToolProcessRunner(spawnToolProcess)` * (T13203); production code reaches it through `resolveToolProcessRunner`. * * @param cmd - Executable. * @param args - Arguments. * @param cwd - Working directory. * @param spawnTimeoutMs - Kill the process tree after this many ms. * @param envOverlay - Variables layered over `process.env`. * @returns The captured result. */ export declare function spawnToolProcess(cmd: string, args: string[], cwd: string, spawnTimeoutMs?: number, envOverlay?: Readonly>): Promise; /** * Capture the repo's git HEAD sha. Returns `null` when the directory is not * a git checkout or the command fails. * * @task T1534 */ export declare function captureHead(projectRoot: string): Promise; export declare function captureDirtyFingerprint(projectRoot: string): Promise; /** Code of the refusal when `evidence.runtimePaths` covers a protected input (T13554). */ export declare const E_EVIDENCE_RUNTIME_PATH_PROTECTED = "E_EVIDENCE_RUNTIME_PATH_PROTECTED"; /** A tree hash and what declared runtime paths left out of it (T13554). */ export interface TreeHashCapture { /** {@link captureTreeHash}'s tree, or `null` (non-git root, git failure). */ readonly tree: string | null; /** Tracked files matching `evidence.runtimePaths` whose working-tree edits the tree leaves out. */ readonly runtimeExcluded: readonly string[]; /** Set when a declared runtime path covers a protected input; nothing was left out. */ readonly refusal?: { readonly codeName: string; readonly reason: string; }; } /** * Untracked files larger than this are left out of {@link captureTreeHash} * rather than hashed into the object database on every call. Such files are * rarely source; a change to one does not move the key (force a run with * `CLEO_EVIDENCE_FRESH=1`). */ export declare const MAX_UNTRACKED_HASH_BYTES: number; /** * Capture the git tree hash of the working tree's SOURCE content: HEAD's tree * with every uncommitted change applied — staged or not, to tracked files and * to untracked files that are not gitignored. Returns `null` for non-git * roots or on any git failure (the entry is then unusable and nothing is * cached). * * ## How (T12958) * * 1. Copy the checkout's own index to a private temporary file, preserving * its atime/mtime. The copy keeps the index's stat cache, so git re-hashes * only files whose stat changed. The preserved mtime matters: git's * racy-clean check compares each entry's mtime with the INDEX FILE's * mtime, and a fresh mtime on the copy would let a same-size edit made in * the same timestamp tick as the last index write read as clean. The real * index is never touched. * 2. `GIT_INDEX_FILE= git add -u -- .` stages every modified or deleted * TRACKED path — all of them, including tracked files under `.cleo/`. * 3. `git ls-files -o --exclude-standard -z` lists untracked, not-ignored * files; those that are not runtime output ({@link isUntrackedRuntimeOutput}) * and not over {@link MAX_UNTRACKED_HASH_BYTES} are added with * `git update-index --add -z --stdin`. * 4. `GIT_INDEX_FILE= git write-tree` prints the tree. * * A clean checkout yields exactly `HEAD^{tree}`. Two worktrees with the same * source yield the same hash wherever they live, and an empty commit, an * amend of only the message, or a rebase that reproduces the same content * leave it unchanged. * * ## Untracked files (review of #1774, supersedes the gh#1221 tracked-only rule) * * An untracked, not-ignored file IS part of the hash. Tracked-only was safe * while the key also carried the execution root; once the key is shared * across worktrees it is a false pass: a worker's new, uncommitted module and * failing test leave the tracked tree equal to main's, and main's cached pass * would be served without the worker's tests ever running. * * Side effect: staging writes blob objects for dirty and new files into the * repository's object database, and `write-tree` writes tree objects. Both * are ordinary unreferenced loose objects: `git gc` prunes them after * `gc.pruneExpire` (two weeks by default), so `git ls-tree ` as an * audit of an old entry is best-effort. * * @param root - Directory to fingerprint (the execution root). * @returns 40/64 hex chars of the tree object id, or `null`. * * @task T12958 */ export declare function captureTreeHash(root: string): Promise; /** * {@link captureTreeHash} with what declared runtime paths left out of it * (T13554): tracked files matching `evidence.runtimePaths` keep their HEAD * content in the tree, and untracked ones are skipped. * * @param root - Directory to fingerprint (the execution root). * @param protectedInputs - Root-relative files a typed gate captured as * inputs; a declared runtime path covering one is refused. * @returns The tree, the files left out, and any refusal. * @task T13554 */ export declare function captureTree(root: string, protectedInputs?: readonly string[]): Promise; /** * Resolve the absolute path for a cache entry by key. * * @task T1534 */ export declare function cacheEntryPath(projectRoot: string, key: string): string; /** * Read a cache entry by key. Returns `null` when the entry is missing, * unreadable, schema-incompatible, or a transient lock placeholder * (`{ pending: true }`) written by a concurrent process that has not yet * captured a real result. * * @task T1534 */ export declare function readCacheEntry(projectRoot: string, key: string): ToolCacheEntry | null; /** * Atomically write a cache entry: writes to `.tmp` then renames so concurrent * readers never observe a half-written file. * * @task T1534 */ export declare function writeCacheEntry(projectRoot: string, entry: ToolCacheEntry): void; /** * Run a resolved tool command with caching + cross-process locking. * * Flow: * * 1. Compute cache key (canonical+cmd+args+treeHash+envFingerprint+ * resourceEnv). * 2. If a fresh entry exists → return it (no spawn). * 3. Acquire a `proper-lockfile` on the cache entry path. When another * caller holds it (same command, same content — possibly another * worktree), wait for its result and reuse it (T12958). * 4. Re-check cache inside the lock (another process may have written it * while we were waiting). * 5. For `test`, when the previous run in this execution root failed on a * different tree, re-run only its failing files first; if they still * fail, return that (uncached, `scope: 'focused'`) failure without the * normal run (T12961). * 6. Spawn the tool, capture stdout/stderr tails. For a failing `test`, * re-run the FULL command once; a pass there is a pass marked `flaky` * (T12961). Write the entry, return. A run killed for resources is * returned with `resourceKill` set and is neither retried, recorded for * failed-first, nor written (T12989). * * Locks are auto-released on success or failure. Stale locks are reaped per * the `lockStaleMs` option (default 10 min — long enough to cover a full * monorepo test suite). * * @param command - Resolved tool command from the resolver. * @param projectRoot - Absolute path to the project root. * @param opts - Options. * @returns Result envelope with `exitCode`, `stdoutTail`, `cacheHit`, etc. * * @task T1534 * @task T12958 * @task T12961 * @task T12989 * @adr ADR-061 */ export declare function runToolCached(command: ResolvedToolCommand, projectRoot: string, opts?: RunToolOptions): Promise; /** * Clear all cached evidence-tool entries for a project. * * @task T1534 */ export declare function clearToolCache(projectRoot: string): { removed: number; }; export {}; //# sourceMappingURL=tool-cache.d.ts.map