From 7792049e4a1c6522668ad0c26fb11f6461bdefc3 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 20 Sep 2026 21:25:16 +0200 Subject: [PATCH 01/16] context: Add doctor mutation-scope repair plan Define the staged implementation plan for safely repairing blocked mutation-scope states through adapter-owned recovery protocols and positive owner evidence. The plan sequences formalization, shared process-owner extraction, adapter repairs, doctor orchestration, rendering, and cross-adapter validation while preserving fail-closed behavior. Plan: doctor-mutation-scope-fix Tasks: T01-T07 Co-authored-by: SCE --- context/plans/doctor-mutation-scope-fix.md | 566 +++++++++++++++++++++ 1 file changed, 566 insertions(+) create mode 100644 context/plans/doctor-mutation-scope-fix.md diff --git a/context/plans/doctor-mutation-scope-fix.md b/context/plans/doctor-mutation-scope-fix.md new file mode 100644 index 00000000..57d5f692 --- /dev/null +++ b/context/plans/doctor-mutation-scope-fix.md @@ -0,0 +1,566 @@ +# Plan: doctor-mutation-scope-fix + +## Change summary + +The completed `doctor-mutation-scope-health` plan gave `sce doctor` read-only +visibility into each adapter's persisted mutation-scope runtime state, +reporting `healthy | recovering | blocked | invalid` under the human-facing +"Agent tracing" row (internal contract: `mutation_scope_health`). It +deliberately added no repair path: a `blocked`/`invalid` state is +`manual_only`, and `sce doctor --fix` cannot touch it. The original Claude +incident that motivated that plan — a repository-wide `PreToolUse` lockout +from `recovery_pending = true` with a leftover `attempts` entry after a +failed abandon — is now *visible* in `sce doctor`, but still requires manual +state-file surgery to clear, and doctor gives the operator nowhere useful to +look. + +This plan extends `sce doctor --fix` so that a `Blocked` Agent tracing state +can be repaired automatically, but only when the owning adapter can prove +the repair is safe from positive evidence — never a timestamp, file age, or +generic "clear the state" fallback. Health (`healthy/recovering/blocked/invalid`) +and repairability (`auto_fixable`/`manual_only` once `Blocked`) are modeled +as separate facts, exactly as the existing four-way status vocabulary +already separates "is this blocked" from "can doctor fix it." + +Code inspection (not the two prior plans' documentation) establishes the +actual scope: + +- **Codex** (`codex_mutation_scope::health::classify_health`) can never + reach `Blocked` today — every reachable non-`Healthy` shape is + `Recovering` (same-lane sweep) or `Invalid` (unreachable-flush, malformed). + No adapter change is needed or proposed for Codex. +- **Pi** (`pi_mutation_scope::health::classify_health`) can never reach + `Blocked` either — its D10 dead-owner sweep (`reconcile_stale_owners`) + already retires a dead-owner `PendingStart`/`Executed` attempt on the next + tracked `Start` from any session, so the only non-`Healthy` shapes are + `Recovering`/`Invalid`. No adapter change is needed for Pi's own recovery + behavior. Pi's `process_owner.rs` (PID + `/proc` start-time liveness, + never TTL-based) is the one proven positive-evidence primitive this + repository already has, and this plan extracts it into shared adapter + infrastructure so OpenCode can reuse it rather than re-inventing it. +- **OpenCode** (`opencode_mutation_scope::health::classify_health`) reaches + `Blocked` whenever any attempt is `PendingStart` — no boundary in the + current adapter ever sweeps a stale `PendingStart` on behalf of an + unrelated call (unlike Pi's D10 sweep or Codex's same-lane retry), and + `AdapterAttempt` records no owner evidence today, so nothing can currently + distinguish a crashed owner from a live one. This plan adds + backward-compatible owner evidence and a repair path that only fires when + the owner is positively, re-provably dead. +- **Claude** (`claude_mutation_scope::health::classify_health`) reaches + `Blocked` whenever `recovery_pending == true` with non-empty `attempts` — + proven by `claude_mutation_scope::health::tests::stale_non_empty_attempts_after_a_failed_abandon_stays_blocked_across_repeated_pre_tool_use_ac3`, + which drives a real failed `abandon_attempt` seam call. `abandon_attempt` + already runs only when Claude's own runtime has decided an attempt should + be abandoned (a failed close, `PermissionDenied`, or a broad cleanup + signal); the failure mode is that the seam's `abandon` call itself did not + confirm, not that the decision was unsound. Claude's own persisted state + has no phase-level distinction between "abandonment already decided, + awaiting a retry" and "may still be running" — this plan adds one + (`PendingAbandon`) so a doctor repair can safely retry exactly the + already-established terminal cleanup, without inventing a new decision. + +Both real repair paths reuse each adapter's own existing recovery protocol +operations (OpenCode's `flush`/`abandon`/`flush` sequence; Claude's +`abandon_attempt` seam call), but their synchronization boundaries are +adapter-specific. OpenCode's existing `AdapterBoundaryLock` serializes the +complete adapter lifecycle boundary across concurrent OpenCode processes; +its `AdapterStateLock` protects individual durable state transactions and is +never held across a mutation-scope seam call. The intended OpenCode repair +shape is: acquire the boundary lock, normalize orphaned recovery state, +perform state transactions that re-read and re-prove the dead owner, +persist `PendingStart -> PendingAbandon`, and establish/claim the recovery +generation, +release the state lock, run the normal `flush`/`abandon`/`flush` seam protocol, +record progress/completion in further individual state transactions, then +release the boundary lock. + +Claude has no corresponding boundary lock in this plan. Its durable +`PendingAbandon` state is the safety evidence: a state transaction re-reads +and proves that the attempt is already `PendingAbandon`, releases the state +lock, retries the `abandon` seam, and uses another state transaction to +record completion. In both adapters, a stale lock-free diagnosis never +authorizes mutation; the repair function performs a fresh proof inside the +adapter's existing lifecycle-serialization boundary where one exists, while +every durable transition is protected by the state lock. Neither adapter +holds its state lock while calling a mutation-scope seam operation, and doctor +never performs `remove_attempt()`/direct JSON rewriting itself. + +## Acceptance criteria + +- [ ] AC1: For a `Blocked` Agent tracing row an adapter proves `auto_fixable`, + plain `sce doctor` (no `--fix`) states the literal remediation + `Run 'sce doctor --fix' to recover ...` in both human text and the + JSON `problems[]` remediation text, and the `DoctorProblem` carries + `fixability: auto_fixable` / `next_action: doctor_fix`. + - Validate: a doctor test seeds an `AutoFixable`-repairable Blocked + OpenCode or Claude state and asserts the rendered text and + `--format json` payload both contain the literal string + `sce doctor --fix` and `"fixability":"auto_fixable"` / + `"next_action":"doctor_fix"`. +- [ ] AC2: For a `Blocked`/`Invalid` row that remains `manual_only`, both + human text and JSON name the exact persisted adapter state-file path + (e.g. `/sce/claude-mutation-scope-state.json`) and never + suggest deleting it. + - Validate: a doctor test seeds a `ManualOnly` Blocked/Invalid state and + asserts the rendered text and JSON both contain the real + `state::state_path(...)` value and that no rendered remediation string + contains `delete`. +- [ ] AC3: `sce doctor --fix` never abandons or repairs an attempt without + positive evidence freshly re-read and re-proven inside the adapter's + existing lifecycle-serialization boundary where one exists, and every + durable state transition is performed under the adapter state lock. + OpenCode's boundary is explicitly its `AdapterBoundaryLock`; Claude's + primary repairable proof is the durable `PendingAbandon` state and its + state transition, not a state lock held across the seam. Adapter state + locks are never held across mutation-scope seam calls. No repair path + infers staleness from a timestamp, file modification time, or elapsed + duration. + - Validate: `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope cli/src/services/hooks/opencode_mutation_scope cli/src/services/hooks/mutation_scope_owner.rs` finds no staleness use outside unrelated lock-timeout constants; the concurrent-race regressions in T03/T04 pass. +- [ ] AC4: A legacy persisted state file written before this change (no + owner evidence, no `PendingAbandon` phase) remains readable and stays + `manual_only` when `Blocked` — upgrading SCE never makes an + old, ambiguous attempt auto-fixable on its own. + - Validate: T03/T04 legacy-fixture regression tests pass. +- [ ] AC5: An interrupted repair (a crash or process kill between any two + durable writes the repair introduces) leaves state that a later + `sce doctor --fix` completes safely, without ever requiring state + deletion and without resurrecting or duplicating a terminal/removed + attempt. + - Validate: T03/T04 crash-mid-repair regression tests pass. +- [ ] AC6: `sce doctor --fix` never reports a mutation-scope fix result + `fixed` while the freshly recomputed final `classify_health` result + for that target remains `Blocked`/`Invalid`; a final result of + `Recovering` is accepted as a successful repair (`Blocked` -> + `Recovering` counts as removing the durable wedge). + - Validate: T05 postcondition regression tests, including one whose + repair only reaches `Recovering` (a normal residual recovery step + remains) and one where the repair leaves the target still `Blocked` + (must not report `fixed`). +- [ ] AC7: The `mutation_scope_health` JSON array's shape + (`target`/`status`/`reason`/`detail`) and the `healthy`/`recovering`/ + `blocked`/`invalid` status strings are unchanged; no `MutationScope*` + Rust type or the `mutation_scope_health` field name is renamed. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor`; inspect the JSON payload in that test output for the unchanged array shape and status strings. +- [ ] AC8: The stated safety invariants (doctor cannot abandon a + potentially live attempt; an unprovable/unknown owner is never + treated as proof of death; doctor cannot clear recovery while + unresolved lifecycle evidence exists; concurrent hook-process and + doctor execution cannot abandon a live attempt; a terminal/removed + attempt is never resurrected; an interrupted repair stays fail-closed + and retryable; a reported successful repair cannot leave + `health == Blocked`) are stated formally and connected to the Rust + implementation. + - Validate: `nix run .#quint -- typecheck spec/doctor_recovery.qnt && nix run .#quint -- test spec/doctor_recovery.qnt`; T07's connection tests pass. + +### Full validation + +- `nix flake check` + +### Context sync + +- `context/sce/mutation-scope-health-status.md` (must describe the new + repairability facet — `auto_fixable` becoming reachable for `Blocked`, + the adapter-owned `assess_repairability`/`repair_blocked` boundary, and + that a `Blocked` problem record may now dynamically change fixability) +- `context/sce/agent-trace-hook-doctor.md` (the `--fix` execution contract + must describe the new adapter-owned mutation-scope repair step and where + it sits in the initial-diagnosis -> existing-repairs -> final-diagnosis + flow) +- `context/sce/doctor-human-text-contract.md` (the new `Remediation:` line + under the "Agent tracing" row) +- `context/cli/claude-mutation-scope-integration.md` (the new + `PendingAbandon` phase and its safety semantics) +- `context/cli/opencode-mutation-scope-adapter-lifecycle.md` and/or + `context/cli/opencode-mutation-scope-integration.md` (the new + backward-compatible owner-evidence field and the dead-owner repair path) +- `context/cli/pi-mutation-scope-integration.md` (the process-owner + primitive is now shared, not Pi-only, if its description changes as a + result) +- `context/context-map.md`, only if navigation actually needs it +- A new shared doc for the extracted process-owner module and/or the + doctor-repair invariants, only if implementation reveals content + substantial enough that folding it into the docs above would be unclear + (mirroring the exception the prior plan took for + `mutation-scope-health-status.md` itself) — do not create one + speculatively. + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `cli/src/services/hooks/claude_mutation_scope/{state,mod,health}.rs`; + `cli/src/services/hooks/opencode_mutation_scope/{state,mod,health}.rs`; + `cli/src/services/hooks/mutation_scope_health.rs` (new `Repairability` + enum); a new shared process-owner module extracted from + `cli/src/services/hooks/pi_mutation_scope/process_owner.rs`; + `cli/src/services/hooks/pi_mutation_scope/{mod,health}.rs` (import-only + change to consume the extracted module); `cli/src/services/doctor/{mod,inspect,render,fixes,types}.rs`; + a new focused Quint model under `spec/`; the context docs listed above. +- **Out of scope:** Codex and Pi adapter *behavior* (their recovery + protocols, state machines, and reachable health shapes are unchanged; + Pi's only change is the pure extraction with identical behavior); + `MutationScopeHealthStatus`'s four-way vocabulary (unchanged, per the + existing `doctor-mutation-scope-health` plan); `Invalid`-state auto-repair + or migration; generic doctor integration-asset repair; DB schema repair; + fixing the existing Claude/OpenCode/Codex asset `ManualOnly` + inconsistencies unrelated to mutation-scope; redesigning `ServiceLifecycle` + providers; general `doctor --fix` cleanup unrelated to mutation-scope + recovery; extending `spec/mutation_cursor.qnt` itself (the new formal + model is a separate, focused file — the doctor-repair concern is not part + of the verified core mutation-cursor protocol that file owns). +- **Constraints:** never delete an adapter state file, clear + `recovery_pending`/`RecoveryState` generically, reset attempts, or + fabricate an abandon outside an adapter's own real protocol operations; + never use a TTL/file-age/elapsed-duration heuristic as proof of staleness; + every repair mutates state only through each adapter's existing recovery + protocol seam (`flush`/`abandon` payloads through the mutation-scope + ingress), never by doctor calling `remove_attempt()` or rewriting JSON + directly. OpenCode repair must acquire its `AdapterBoundaryLock` before + lifecycle recovery, and its `AdapterStateLock` may protect only an + individual read/re-proof/durable-transition transaction; it must be + released before every seam call and reacquired for later durable progress. + Claude must use its existing state lock for each fresh read/proof and + durable transition, but must release it before every mutation-scope seam + call; this plan does not add a Claude boundary lock. Neither adapter may + trust an earlier lock-free classification as authorization, and the + repair function itself must perform the fresh proof. A new persisted field + must deserialize from a state file written before this change + (backward-compatible `#[serde(default)]`, matching the existing precedent + in `opencode_mutation_scope::state::AdapterState`); the JSON + `mutation_scope_health` array's field set and status strings, and every + existing `MutationScope*` Rust type name, are preserved exactly. +- **Non-goal:** this plan does not make `Invalid` states auto-fixable; it + does not unify the four adapters onto one shared repair trait (per the + request's guidance, prefer adapter-owned functions dispatched by doctor's + existing `IntegrationTargetId` match, matching how + `inspect_mutation_scope_health` already dispatches, unless a real + multi-adapter shared abstraction turns out to be unavoidable); it does + not broaden doctor's mutation-scope repair into a general "reset + integration state" feature. + +## Assumptions + +- Codex and Pi need no adapter-behavior change because their current + classifiers (`codex_mutation_scope::health::classify_health`, + `pi_mutation_scope::health::classify_health`) never produce `Blocked`, + confirmed by direct inspection of both functions and their existing + regression tests. If a future adapter change ever makes either reach + `Blocked`, this plan's investigation should be re-run for that adapter + rather than assumed to still hold. +- Claude's dead-owner liveness path (for a `PendingStart`/`Active` attempt + with no established abandon intent) is included only if T04's own + investigation finds a currently-reachable ambiguous shape that needs it; + Claude's primary, currently-proven `Blocked` shape (a failed abandon + seam call) does not need owner evidence at all, since the abandon + decision was already made by Claude's own runtime before the seam call + failed — only the seam confirmation is missing, which + `PendingAbandon`-retry addresses directly. +- The new adapter-owned repairability distinction is modeled as + `assess_repairability`/`repair_blocked` functions on each adapter's + existing module rather than a new Rust trait, per the request's explicit + instruction not to introduce a trait merely for architectural appearance; + doctor dispatches to them the same way `inspect_mutation_scope_health` + already dispatches `classify_health` by `IntegrationTargetId`. + +## Task stack + +- [ ] T01: `Formalize the safe doctor-repair protocol and its invariants` (status:todo) + - Task ID: T01 + - Scope: In — a new, focused Quint model (e.g. `spec/doctor_recovery.qnt`, + exact name chosen at implementation time) modeling the shared abstract + pattern this plan introduces: attempt phases (a start-pending phase, an + executing/active phase, and a phase meaning "abandonment already + decided"), an abstract three-valued owner-liveness oracle (`Alive` / + `Dead` / `Unknown`, deliberately never time-based), a per-adapter lock, + and two actors — the adapter's own hook process and doctor — racing + over the same durable state. The model must state and check, as + invariants or temporal properties: doctor cannot abandon a potentially + live attempt; `Unknown` owner state is never treated as proof of death; + doctor cannot clear recovery while unresolved lifecycle evidence + exists; a doctor repair action cannot produce a state outside the + adapter's own reachable transition set; concurrent hook-process and + doctor execution cannot cause a live attempt to be abandoned; a + terminal/removed attempt is never resurrected; an interrupted repair + remains fail-closed and retryable (every state it can stop in is + itself a legitimate, already-reachable state); a reported successful + repair cannot coincide with final `health == Blocked`. Out — any Rust + behavior change; any change to `spec/mutation_cursor.qnt` itself (this + is a separate, focused model, not an extension of the verified core + mutation-cursor protocol, since doctor-repair concerns are adapter + bookkeeping the core protocol does not model). + - Dependencies: none + - Done when: the new `.qnt` file typechecks; it has its own `quint test` + example-based sanity tests; each invariant above is expressed as a + Quint `invariant` (or temporal property) and a documented + `quint run --invariant=... [--max-samples=...]` / `quint verify` + invocation (recorded in the file's own header comment) finds no + violation within a stated, documented bound; the file's header states + explicitly which real Claude/OpenCode concepts each abstract phase and + the owner oracle correspond to, so T03/T04 can be checked against it. + - Verify: `nix run .#quint -- typecheck spec/doctor_recovery.qnt`; `nix run .#quint -- test spec/doctor_recovery.qnt`; the invariant-check command recorded in the file's own header. + - Context synchronization: pending + +- [ ] T02: `Extract shared positive process-owner evidence from Pi` (status:todo) + - Task ID: T02 + - Scope: In — move `ProcessOwner`, `current_process_owner`, + `process_owner_for`, and `is_definitely_dead` out of + `cli/src/services/hooks/pi_mutation_scope/process_owner.rs` into a new + shared module reachable by other adapters (e.g. + `cli/src/services/hooks/mutation_scope_owner.rs`, sibling to the + existing shared `mutation_scope_health.rs`), with identical logic + (`kill(pid, 0)` liveness plus Linux `/proc/{pid}/stat` start-time + PID-reuse proofing, conservative `false` on non-Linux/non-unix, and the + same "never assume dead" test suite including the static + no-TTL/elapsed-time-token source scan); update + `pi_mutation_scope::{mod,health}.rs` to import the shared module with + zero behavior change. Out — any new consumer of the extracted module + (OpenCode/Claude wiring happens in T03/T04); any change to Pi's own D10 + dead-owner sweep behavior, reachable health statuses, or persisted + `owner` field shape. + - Dependencies: T01 (the extracted module's `Alive`/`Dead`/`Unknown` + liveness contract must match T01's abstract owner oracle) + - Done when: `pi_mutation_scope` no longer defines its own + `ProcessOwner`/`is_definitely_dead`; every existing Pi mutation-scope + regression (dispatch, recovery, and health-classifier tests) passes + with identical outcomes; the extracted module's own test suite, + including the static "no TTL/elapsed-time primitive" source scan + (now scoped to the new file), passes. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml pi_mutation_scope`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_owner` + - Context synchronization: pending + +- [ ] T03: `OpenCode: persisted owner evidence and safe PendingStart repair` (status:todo) + - Task ID: T03 + - Scope: In — `cli/src/services/hooks/opencode_mutation_scope/{boundary_lock,events,lifecycle,mod,os_lock,payload,state,health,tests}.rs`. + Preserve the existing lock hierarchy: `AdapterBoundaryLock` + (`opencode-mutation-scope-boundary.lock`) serializes complete OpenCode + lifecycle boundaries, while `AdapterStateLock` + (`opencode-mutation-scope-state.lock`) protects only individual durable + state operations and is never held across a seam call. Add an optional, + backward-compatible `owner: Option` + (`#[serde(default)]`, mirroring the existing `next_recovery_generation`/ + `recovery` precedent in `AdapterState`) to `AdapterAttempt`, stamped + with the shared `current_process_owner()` when a `PendingStart` attempt + is allocated; a state file with no `owner` for an attempt (any file + written before this change) must deserialize to `owner: None`, never + inferred. Add `assess_repairability(git_dir) -> Repairability`, called + only when `classify_health` reports `Blocked`: `AutoFixable` only when + every `PendingStart` attempt currently contributing to the `Blocked` + classification has `owner: Some(owner)` with `is_definitely_dead(owner)` + true; `ManualOnly` otherwise (no recorded owner, a live owner, or + unprovable liveness). Add `repair_blocked(git_dir, repository_root, seam) -> Result`: + acquire the OpenCode `AdapterBoundaryLock`, normalize orphaned recovery + state, then use an `AdapterStateLock` transaction to re-read state fresh + and re-prove the exact dead-owner condition `assess_repairability` found + (never trusting the caller's earlier lock-free read). Release the state + lock before driving the attempt through the adapter's real recovery + protocol: the existing `PendingStart` -> `PendingAbandon` persisted + transition, then the same `flush`/`abandon`/`flush` seam sequence + `ToolError` cleanup already uses. Each progress/completion write is its + own state transaction after the seam call; the state lock is never held + across `flush`, `abandon`, or any other seam operation. Remove the + attempt only when that protocol's own success proves it valid. A losing + re-proof (owner now alive/unknown, or the attempt already resolved) is a + safe no-op, not an error. Out — Claude, Codex, Pi; any change to + OpenCode's + existing `ToolError`/generation-tracked recovery machinery for + non-`PendingStart` shapes; any change to `classify_health`'s existing + four status boundaries (`Blocked` stays `Blocked` either way — + repairability is an additional fact, not a fifth status). + - Dependencies: T01, T02 + - Done when: a state file predating this change (no `owner` field) still + parses, and its `PendingStart` `Blocked` shape classifies `ManualOnly`; + a `PendingStart` attempt with a positively dead owner classifies + `AutoFixable` and `repair_blocked` clears it end to end (final + `classify_health` reports `Healthy` or `Recovering`, never `Blocked`); + a `PendingStart` attempt with a live or unprovable owner classifies + `ManualOnly` and `repair_blocked` is a safe no-op; a test simulates a + concurrent live hook process rewriting state between + `assess_repairability`'s read and `repair_blocked`'s lock acquisition, + proving the fresh state-transaction re-proof refuses to abandon the + now-different state; a test interrupts `repair_blocked` after the + `PendingAbandon` persist but before the seam call resolves, and proves + the next `repair_blocked` (or the ordinary recovery path) safely + continues from `PendingAbandon` without state deletion or attempt + duplication. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` + - Context synchronization: pending + +- [ ] T04: `Claude: persisted terminal-cleanup evidence and safe PendingAbandon repair` (status:todo) + - Task ID: T04 + - Scope: In — `cli/src/services/hooks/claude_mutation_scope/{state,mod,health}.rs`. + Add a `PendingAbandon` `AttemptPhase` variant alongside the existing + `PendingStart`/`Active`; change `abandon_attempt` to persist the + attempt's phase as `PendingAbandon` before calling the seam's `abandon` + operation (replacing today's whole-file `mark_recovery_pending()` with + per-attempt durable evidence, or keeping both if the barrier still + needs the file-level flag — decide from the actual barrier logic), so a + failed seam call leaves explicit per-attempt retryable evidence instead + of today's ambiguous state. Audit `cleanup_attempts_matching`'s current + early-return-on-first-failure loop (`abandon_attempt(...)?` inside the + loop stops the whole batch on the first failure, leaving any + later-matched attempt untouched in its pre-cleanup phase rather than + uniformly `PendingAbandon`) and correct it, if the investigation + confirms this is reachable, so every attempt a broad cleanup signal + (`Stop`/`SessionEnd`/`UserPromptSubmit`/`SubagentStop`) decides to + abandon is durably marked `PendingAbandon` before any seam call is + attempted. Add `assess_repairability`/`repair_blocked` mirroring T03's + shape: `AutoFixable` only when every attempt contributing to the + `Blocked` classification is `PendingAbandon` (no `PendingStart`/`Active` + attempt present — those have no established abandon intent and stay + `ManualOnly`). Claude's `repair_blocked` re-reads state and re-proves + every present attempt is `PendingAbandon` in an individual state-lock + transaction, releases that lock before each already-established seam + `abandon` call, and uses later state transactions to remove only + successfully abandoned attempts and clear the recovery barrier only once + no attempts remain. It must never hold the state lock across mutation- + scope ingress or seam calls, and this cleanup pass must not add a Claude + boundary lock. If the investigation finds a currently-reachable shape where a + `PendingStart`/`Active` attempt coexists with no established abandon + intent and genuinely needs dead-owner evidence to become fixable, reuse + T02's shared primitive for it and record the finding; otherwise record + that no such shape is reachable and do not add unused machinery. Out — + OpenCode, Codex, Pi; Claude's `PreToolUse`/`PostToolUse`/`establish_start` + happy-path logic. + - Dependencies: T01 + - Done when: the existing + `stale_non_empty_attempts_after_a_failed_abandon_stays_blocked_across_repeated_pre_tool_use_ac3` + regression is preserved (still `Blocked` before repair) and extended: + after `repair_blocked`, the same scenario resolves to + `Healthy`/`Recovering`; a crash simulated between the `PendingAbandon` + persist and the seam call resolving leaves state that a second + `repair_blocked` safely completes without resurrecting or duplicating + the attempt; a `PendingStart`/`Active` attempt with no established + abandon intent is proven `ManualOnly` and untouched by + `repair_blocked`; a legacy state file persisted before this change + (no `PendingAbandon` variant ever written) remains readable and, if + `Blocked`, stays `ManualOnly` until a new abandonment establishes real + `PendingAbandon` evidence. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` + - Context synchronization: pending + +- [ ] T05: `Shared repairability contract and doctor --fix orchestration` (status:todo) + - Task ID: T05 + - Scope: In — `cli/src/services/hooks/mutation_scope_health.rs` (add the + shared `Repairability` enum, kept separate from `MutationScopeHealthStatus`); + `cli/src/services/doctor/{mod,inspect}.rs`. Add a new step to + `execute_doctor_with_lifecycle_providers`, positioned after + `fix_lifecycle_providers`/`repair_merge_target_configs` and before the + final `diagnose_lifecycle_providers`/`build_report_with_lifecycle_problems` + call, preserving the existing initial-diagnosis -> existing-repairs -> + final-diagnosis -> manual-results flow: for each `Blocked` row the + *initial* report found, call the matching adapter's + `assess_repairability`; when `AutoFixable`, call that adapter's + `repair_blocked` and record a `DoctorFixResultRecord`. Doctor only + dispatches these adapter-owned operations; it does not acquire or hold + either adapter's state lock, and it does not hold any state lock across a + seam call. OpenCode owns its larger lifecycle serialization through + `AdapterBoundaryLock`; Claude's adapter owns its per-transaction state + locking and `PendingAbandon` proof without a new boundary lock. The final + report's already-existing `inspect_mutation_scope_health` call (which + recomputes `classify_health` from scratch) is the sole postcondition: a + repair function returning `Ok(())` is never itself treated as proof of + success — only the freshly recomputed final status decides whether the + fix result becomes `Fixed` (final `Healthy`/`Recovering`) or falls + through to the existing generic manual/unresolved handling (final still + `Blocked`/`Invalid`, which must never be reported `Fixed`). Codex/Pi + targets take no path through this new step, since they never classify + `Blocked`. Out — Claude/OpenCode adapter internals (owned by T03/T04); + human text/JSON rendering (T06). + - Dependencies: T01, T03, T04 + - Done when: a repository seeded with an `AutoFixable` `Blocked` + OpenCode or Claude state, run through `sce doctor --fix`, ends with a + `Fixed` fix result and a final report that is never `Blocked` while + reporting `Fixed`; a repository seeded with a `ManualOnly` `Blocked`/ + `Invalid` state is untouched by the new step and still receives the + existing manual-result handling; a repository with no mutation-scope + problems runs the new step as a no-op with no behavior change from + today. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` + - Context synchronization: pending + +- [ ] T06: `Human and JSON remediation contract for repaired and manual Agent tracing states` (status:todo) + - Task ID: T06 + - Scope: In — `cli/src/services/doctor/{render,fixes,types}.rs`. Fix + `build_manual_fix_results` (currently drops `DoctorProblem.remediation` + entirely, rendering only `"{summary} Manual remediation is still + required."`) so a `mutation_scope_health` manual result instead + retains the adapter's own remediation text and the exact state-file + path — e.g. `"Agent tracing remains blocked. Inspect ''. + {remediation}"` — while deciding whether to fix this generically for + every `ManualOnly` category or only for `mutation_scope_health` (state + the choice and why). Extend `DoctorDisplayDetail::MutationScopeHealth` + (today `{reason, detail}` only — the "Agent tracing" tree row renders + no `Remediation:` line at all, unlike the generic + `DoctorDisplayDetail::Problem{summary, remediation}` variant used + elsewhere) to also carry and render a `Remediation:` line sourced from + the matching `DoctorProblem`, so a plain `sce doctor` run states, for + every `Blocked` row, either `Run 'sce doctor --fix' to recover ...` + (when `AutoFixable` — wire the new remediation text + `push_mutation_scope_health_problem` must gain for that fixability) or + the existing `Automatic recovery is not safe for this state. Inspect + ''.` wording (`ManualOnly`, now actually rendered). Confirm the + `[fixed]`/`[manual]` fix-result lines T05 produces render correctly + through the existing generic `[{outcome}] {detail}` formatter (no new + formatter needed). Verify the JSON `problems[]` array already carries + `fixability`/`remediation.{next_action,text}` correctly for the new + `auto_fixable`/`doctor_fix` case, while the `mutation_scope_health[]` + array's shape is untouched. Out — any change to the + `mutation_scope_health` JSON array's field set, the status strings, or + any `MutationScope*`/`mutation_scope_health` naming. + - Dependencies: T05 + - Done when: `sce doctor --format json` and human text for a seeded + `AutoFixable` `Blocked` state both name `sce doctor --fix` explicitly; + a `ManualOnly` `Blocked`/`Invalid` state's human text and JSON both + name the exact adapter state-file path with no deletion suggestion; + `sce doctor --fix` human output shows `[fixed] Recovered + Agent tracing ...` for a resolved repair and `[manual] Agent tracing + remains blocked. Inspect ''.` for an unresolved one. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` + - Context synchronization: pending + +- [ ] T07: `Cross-adapter end-to-end regression and formal-model connection` (status:todo) + - Task ID: T07 + - Scope: In — one or more command-level integration tests (driving + `run_doctor_with_context`/the `sce doctor`/`sce doctor --fix` command + surface directly, not just the `inspect.rs` helper functions) seeding a + single repository with both a Claude `Blocked` state and an OpenCode + `Blocked` state at once, and asserting the full rendered text and JSON + output for both `sce doctor` (AC1/AC2 wording, verbatim) and + `sce doctor --fix` (`[fixed]`/`[manual]` lines, verbatim, and a final + report matching AC6) — proving T03/T04/T05/T06 integrate correctly + across adapters, which no earlier task's adapter-local tests exercise + together. Connect T01's `spec/doctor_recovery.qnt` invariants to this + implementation: either a Quint-Connect harness mirroring the existing + `cli/src/services/mutation_trace/mbt/` convention, or, if that is + disproportionate for this plan's scope, direct doc comments on these + regression tests naming which T01 invariant each one proves — decide + and record which approach was taken and why. Out — any new production + behavior; this task authors regression coverage and the formal-model + connection only, not a "run the check suite" pass. + - Dependencies: T05, T06 + - Done when: the multi-adapter end-to-end test(s) pass and assert the + literal remediation/fix-result wording from AC1, AC2, and AC6; every + T01 invariant is traceably connected to at least one Rust test (via + Quint-Connect or documented mapping); `nix flake check` passes. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor`; `nix flake check` + - Context synchronization: pending + +## Open questions + +None. The change request explicitly required deriving the state-machine and +architectural decisions from the actual code rather than from the request's +own hypothesized shapes, and that inspection is recorded above (Codex/Pi +need no adapter change; Claude's primary repairable shape needs no owner +evidence; OpenCode's does). Where the request itself flagged a genuine +implementation choice ("decide from the actual barrier logic", "if the +investigation finds..."), the corresponding task scope says so explicitly +rather than presenting a false certainty here. From 79ca8249b3adf8f5a8dc9447cd5f13cd9751ae50 Mon Sep 17 00:00:00 2001 From: David Abram Date: Sun, 20 Sep 2026 21:37:48 +0200 Subject: [PATCH 02/16] tests: Extract hook and setup tests into dedicated modules Move the large inline hook and setup test suites into focused sibling modules so production modules remain easier to navigate. Split Pi mutation-scope coverage by lifecycle, guard reconciliation, and runtime seams while preserving the existing behavioral coverage. Co-authored-by: SCE --- .../hooks/claude_mutation_scope/mod.rs | 3564 +----------- .../hooks/claude_mutation_scope/tests.rs | 3506 ++++++++++++ .../hooks/codex_mutation_scope/mod.rs | 4799 +---------------- .../hooks/codex_mutation_scope/tests.rs | 4754 ++++++++++++++++ cli/src/services/hooks/mod.rs | 4090 +------------- .../guard_reconciliation_tests.rs | 606 +++ .../pi_mutation_scope/lifecycle_tests.rs | 1155 ++++ .../services/hooks/pi_mutation_scope/mod.rs | 2366 +------- .../pi_mutation_scope/runtime_seam_tests.rs | 240 + .../services/hooks/pi_mutation_scope/tests.rs | 332 ++ cli/src/services/hooks/tests.rs | 4035 ++++++++++++++ cli/src/services/setup/mod.rs | 1165 +--- cli/src/services/setup/tests.rs | 1123 ++++ 13 files changed, 15764 insertions(+), 15971 deletions(-) create mode 100644 cli/src/services/hooks/claude_mutation_scope/tests.rs create mode 100644 cli/src/services/hooks/codex_mutation_scope/tests.rs create mode 100644 cli/src/services/hooks/pi_mutation_scope/guard_reconciliation_tests.rs create mode 100644 cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs create mode 100644 cli/src/services/hooks/pi_mutation_scope/runtime_seam_tests.rs create mode 100644 cli/src/services/hooks/pi_mutation_scope/tests.rs create mode 100644 cli/src/services/hooks/tests.rs create mode 100644 cli/src/services/setup/tests.rs diff --git a/cli/src/services/hooks/claude_mutation_scope/mod.rs b/cli/src/services/hooks/claude_mutation_scope/mod.rs index aa4c72c0..94eaf5e6 100644 --- a/cli/src/services/hooks/claude_mutation_scope/mod.rs +++ b/cli/src/services/hooks/claude_mutation_scope/mod.rs @@ -820,3566 +820,4 @@ fn pre_tool_use_deny_json(reason: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn pre_tool_use_json(overrides: &[(&str, Value)]) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_PRE_TOOL_USE.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert( - CWD_FIELD.to_string(), - Value::String("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/repo/checkout".to_string()), - ); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String("Write".to_string()), - ); - object.insert( - TOOL_USE_ID_FIELD.to_string(), - Value::String("toolu_1".to_string()), - ); - for (field, value) in overrides { - object.insert((*field).to_string(), value.clone()); - } - Value::Object(object).to_string() - } - - fn identity(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> AttemptKey { - AttemptKey { - session_id: session_id.to_string(), - agent_id: agent_id.map(str::to_string), - tool_use_id: tool_use_id.to_string(), - } - } - - #[test] - fn pre_tool_use_parses_required_and_optional_fields() { - let payload = pre_tool_use_json(&[ - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - (PROMPT_ID_FIELD, Value::String("prompt-1".to_string())), - ( - AGENT_TYPE_FIELD, - Value::String("general-purpose".to_string()), - ), - ]); - - let event = parse_claude_hook_event(&payload).expect("valid PreToolUse parses"); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - - assert_eq!(execution.identity.session_id, "session-1"); - assert_eq!(execution.identity.cwd, "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/repo/checkout"); - assert_eq!(execution.identity.tool_name, "Write"); - assert_eq!(execution.identity.tool_use_id, "toolu_1"); - assert_eq!(execution.identity.agent_id.as_deref(), Some("agent-1")); - assert_eq!(execution.prompt_id.as_deref(), Some("prompt-1")); - assert_eq!(execution.agent_type.as_deref(), Some("general-purpose")); - assert!(!execution.run_in_background); - } - - #[test] - fn pre_tool_use_agent_id_absent_means_main_thread() { - let payload = pre_tool_use_json(&[]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - - assert_eq!(execution.identity.agent_id, None); - assert!(!execution.identity.is_subagent()); - } - - #[test] - fn pre_tool_use_agent_id_present_means_subagent() { - let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - - assert!(execution.identity.is_subagent()); - } - - #[test] - fn pre_tool_use_prompt_id_and_agent_type_are_optional() { - let payload = pre_tool_use_json(&[]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - - assert_eq!(execution.prompt_id, None); - assert_eq!(execution.agent_type, None); - } - - #[test] - fn missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [ - SESSION_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - TOOL_USE_ID_FIELD, - ] { - let mut object: serde_json::Map = - serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!( - error.to_string().contains(&format!("'{field}'")), - "expected missing-field error to name '{field}', got: {error}" - ); - } - } - - #[test] - fn wrong_type_required_field_is_rejected() { - let payload = pre_tool_use_json(&[(SESSION_ID_FIELD, Value::from(42))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("session_id")); - } - - #[test] - fn empty_string_required_field_is_rejected() { - let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String(String::new()))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("non-blank")); - } - - #[test] - fn wrong_type_optional_field_is_rejected() { - let payload = pre_tool_use_json(&[(PROMPT_ID_FIELD, Value::from(1))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("prompt_id")); - } - - #[test] - fn empty_optional_field_is_rejected() { - let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String(String::new()))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("agent_id")); - } - - #[test] - fn null_optional_field_is_none() { - let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Null)]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - assert_eq!(execution.identity.agent_id, None); - } - - #[test] - fn empty_payload_is_rejected() { - let error = parse_claude_hook_event("").unwrap_err(); - assert!(error.to_string().contains("empty payload")); - - let error = parse_claude_hook_event(" ").unwrap_err(); - assert!(error.to_string().contains("empty payload")); - } - - #[test] - fn malformed_json_is_rejected() { - let error = parse_claude_hook_event("{not json").unwrap_err(); - assert!(error.to_string().contains("valid JSON")); - } - - #[test] - fn non_object_json_is_rejected() { - let error = parse_claude_hook_event("[1, 2, 3]").unwrap_err(); - assert!(error.to_string().contains("JSON object")); - } - - #[test] - fn unsupported_hook_event_name_is_rejected() { - let payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String("PostToolBatch".to_string()), - )]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("unsupported hook_event_name")); - } - - #[test] - fn post_tool_use_and_failure_and_permission_denied_share_tool_identity_shape() { - for event_name in [ - HOOK_EVENT_POST_TOOL_USE, - HOOK_EVENT_POST_TOOL_USE_FAILURE, - HOOK_EVENT_PERMISSION_DENIED, - ] { - let payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(event_name.to_string()), - )]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let identity = match event { - ClaudeHookEvent::PostToolUse(identity) - | ClaudeHookEvent::PostToolUseFailure(identity) - | ClaudeHookEvent::PermissionDenied(identity) => identity, - other => panic!("expected a tool-identity event, got {other:?}"), - }; - assert_eq!(identity.session_id, "session-1"); - assert_eq!(identity.tool_use_id, "toolu_1"); - } - } - - #[test] - fn session_scoped_lifecycle_events_parse_session_identity() { - for event_name in [ - HOOK_EVENT_STOP, - HOOK_EVENT_STOP_FAILURE, - HOOK_EVENT_USER_PROMPT_SUBMIT, - HOOK_EVENT_SESSION_END, - ] { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - let payload = Value::Object(object).to_string(); - - let event = parse_claude_hook_event(&payload).unwrap(); - let identity = match event { - ClaudeHookEvent::Stop(identity) - | ClaudeHookEvent::StopFailure(identity) - | ClaudeHookEvent::UserPromptSubmit(identity) - | ClaudeHookEvent::SessionEnd(identity) => identity, - other => panic!("expected a session-identity event, got {other:?}"), - }; - assert_eq!(identity.session_id, "session-1"); - assert_eq!(identity.cwd, "/repo"); - } - } - - #[test] - fn subagent_stop_requires_agent_id() { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - let payload = Value::Object(object).to_string(); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("agent_id")); - } - - #[test] - fn subagent_stop_parses_agent_identity() { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - object.insert( - AGENT_ID_FIELD.to_string(), - Value::String("agent-1".to_string()), - ); - let payload = Value::Object(object).to_string(); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::SubagentStop(identity) = event else { - panic!("expected SubagentStop"); - }; - assert_eq!(identity.agent_id, "agent-1"); - } - - #[test] - fn worktree_remove_requires_worktree_path_not_cwd() { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert( - WORKTREE_PATH_FIELD.to_string(), - Value::String("/repo/.claude/worktrees/agent-1".to_string()), - ); - let payload = Value::Object(object).to_string(); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::WorktreeRemove(worktree_remove) = event else { - panic!("expected WorktreeRemove"); - }; - assert_eq!(worktree_remove.session_id, "session-1"); - assert_eq!( - worktree_remove.worktree_path, - "/repo/.claude/worktrees/agent-1" - ); - } - - #[test] - fn session_start_and_subagent_start_establish_no_scope_payload() { - for event_name in [HOOK_EVENT_SESSION_START, HOOK_EVENT_SUBAGENT_START] { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - let payload = Value::Object(object).to_string(); - - let event = parse_claude_hook_event(&payload).unwrap(); - assert!(matches!( - event, - ClaudeHookEvent::SessionStart | ClaudeHookEvent::SubagentStart - )); - } - } - - #[test] - fn run_in_background_true_is_parsed() { - let payload = pre_tool_use_json(&[( - TOOL_INPUT_FIELD, - serde_json::json!({ "run_in_background": true }), - )]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - assert!(execution.run_in_background); - } - - #[test] - fn run_in_background_false_is_parsed() { - let payload = pre_tool_use_json(&[( - TOOL_INPUT_FIELD, - serde_json::json!({ "run_in_background": false }), - )]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - assert!(!execution.run_in_background); - } - - #[test] - fn run_in_background_absent_defaults_to_false() { - let payload = pre_tool_use_json(&[( - TOOL_INPUT_FIELD, - serde_json::json!({ "command": "echo hi" }), - )]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - assert!(!execution.run_in_background); - } - - #[test] - fn tool_input_absent_defaults_run_in_background_to_false() { - let mut object: serde_json::Map = - serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); - object.remove(TOOL_INPUT_FIELD); - let payload = Value::Object(object).to_string(); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - assert!(!execution.run_in_background); - } - - #[test] - fn run_in_background_wrong_type_is_rejected() { - let payload = pre_tool_use_json(&[( - TOOL_INPUT_FIELD, - serde_json::json!({ "run_in_background": "yes" }), - )]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("run_in_background")); - } - - #[test] - fn tool_input_wrong_type_is_rejected() { - let payload = pre_tool_use_json(&[(TOOL_INPUT_FIELD, Value::String("nope".to_string()))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("tool_input")); - } - - #[test] - fn known_mutation_capable_tools_are_classified_mutation_capable() { - for tool_name in [ - "Bash", - "PowerShell", - "Write", - "Edit", - "NotebookEdit", - "MultiEdit", - ] { - assert_eq!( - classify_tool(tool_name), - ToolClassification::MutationCapable, - "expected {tool_name} to be MutationCapable" - ); - } - } - - #[test] - fn known_read_only_tools_are_classified_read_only() { - for tool_name in [ - "Read", - "Glob", - "Grep", - "WebFetch", - "WebSearch", - "AskUserQuestion", - ] { - assert_eq!( - classify_tool(tool_name), - ToolClassification::ReadOnly, - "expected {tool_name} to be ReadOnly" - ); - } - } - - #[test] - fn agent_is_classified_delegation() { - assert_eq!(classify_tool("Agent"), ToolClassification::Delegation); - } - - #[test] - fn mcp_tools_are_classified_mutation_capable() { - assert_eq!( - classify_tool("mcp__claude-in-chrome__navigate"), - ToolClassification::MutationCapable - ); - } - - #[test] - fn unknown_tool_names_are_conservatively_mutation_capable() { - assert_eq!( - classify_tool("SomeBrandNewTool"), - ToolClassification::MutationCapable - ); - } - - const PROBE14_BASH_RUN_IN_BACKGROUND_TRUE: &str = - include_str!("fixtures/probe14-run-in-background-true.pre_tool_use.json"); - const PROBE15_BASH_RUN_IN_BACKGROUND_FALSE: &str = - include_str!("fixtures/probe15-run-in-background-false-hard-gate.pre_tool_use.json"); - - fn parsed_pre_tool_use(payload: &str) -> ClaudeToolExecution { - let event = parse_claude_hook_event(payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - execution - } - - #[test] - fn real_bash_run_in_background_true_fixture_is_explicit_background_shell() { - let execution = parsed_pre_tool_use(PROBE14_BASH_RUN_IN_BACKGROUND_TRUE); - - assert_eq!(execution.identity.tool_name, "Bash"); - assert!(execution.run_in_background); - assert!(is_explicit_background_shell( - &execution.identity.tool_name, - execution.run_in_background - )); - } - - #[test] - fn real_bash_run_in_background_false_fixture_is_not_explicit_background_shell() { - let execution = parsed_pre_tool_use(PROBE15_BASH_RUN_IN_BACKGROUND_FALSE); - - assert_eq!(execution.identity.tool_name, "Bash"); - assert!(!execution.run_in_background); - assert!(!is_explicit_background_shell( - &execution.identity.tool_name, - execution.run_in_background - )); - } - - #[test] - fn powershell_with_run_in_background_true_is_explicit_background_shell() { - assert!(is_explicit_background_shell("PowerShell", true)); - } - - #[test] - fn powershell_with_run_in_background_false_is_not_explicit_background_shell() { - assert!(!is_explicit_background_shell("PowerShell", false)); - } - - #[test] - fn write_with_run_in_background_true_is_not_explicit_background_shell() { - assert!(!is_explicit_background_shell("Write", true)); - } - - #[test] - fn same_attempt_seq_and_key_is_deterministic() { - let key = identity("session-1", Some("agent-1"), "toolu_1"); - - let first = format_claude_scope_id(3, &key); - let second = format_claude_scope_id(3, &key); - - assert_eq!( - first, second, - "AC4: duplicate delivery must reuse the same ScopeId" - ); - assert_eq!( - claude_scope_start_event_id(&first), - claude_scope_start_event_id(&second) - ); - } - - #[test] - fn fresh_attempt_seq_yields_a_new_scope_id() { - let key = identity("session-1", None, "toolu_1"); - - let first = format_claude_scope_id(1, &key); - let second = format_claude_scope_id(2, &key); - - assert_ne!( - first, second, - "AC5: a fresh attempt_seq for the same tool_use_id must get a new ScopeId" - ); - } - - #[test] - fn main_and_distinct_agents_produce_distinct_scope_ids() { - let main = identity("session-1", None, "toolu_1"); - let agent_a = identity("session-1", Some("A"), "toolu_1"); - let agent_b = identity("session-1", Some("B"), "toolu_1"); - - let main_scope = format_claude_scope_id(1, &main); - let scope_for_a = format_claude_scope_id(1, &agent_a); - let scope_for_b = format_claude_scope_id(1, &agent_b); - - assert_ne!( - main_scope, scope_for_a, - "AC6: main vs agent_id=A must differ" - ); - assert_ne!( - main_scope, scope_for_b, - "AC6: main vs agent_id=B must differ" - ); - assert_ne!( - scope_for_a, scope_for_b, - "AC6: agent_id=A vs agent_id=B must differ" - ); - } - - #[test] - fn event_id_derivation_is_a_pure_function_of_scope_id() { - let scope_id = format_claude_scope_id(7, &identity("session-1", None, "toolu_1")); - - assert_eq!( - claude_scope_start_event_id(&scope_id), - format!("{scope_id}|start") - ); - assert_eq!( - claude_scope_close_event_id(&scope_id), - format!("{scope_id}|close") - ); - assert_ne!( - claude_scope_start_event_id(&scope_id), - claude_scope_close_event_id(&scope_id) - ); - } - - #[test] - fn length_prefixing_disambiguates_delimiter_characters_inside_fields() { - let tricky = identity( - "sess|a=0:x|t=1:y", - Some("agent|with|pipes"), - "tool:with:colons", - ); - - let scope_id = format_claude_scope_id(1, &tricky); - - let agent_id = tricky.agent_id.as_deref().unwrap(); - let expected = format!( - "cc-tool-v1|n=1|s={}:{}|a={}:{}|t={}:{}", - tricky.session_id.len(), - tricky.session_id, - agent_id.len(), - agent_id, - tricky.tool_use_id.len(), - tricky.tool_use_id, - ); - - assert_eq!(scope_id, expected); - } - - #[test] - fn attempt_key_projects_only_the_execution_key_fields() { - let identity_a = ClaudeToolIdentity { - session_id: "session-1".to_string(), - cwd: "/repo".to_string(), - agent_id: Some("agent-1".to_string()), - tool_name: "Write".to_string(), - tool_use_id: "toolu_1".to_string(), - }; - let identity_b = ClaudeToolIdentity { - tool_name: "Bash".to_string(), - cwd: "/other".to_string(), - ..identity_a.clone() - }; - - assert_eq!( - identity_a.attempt_key(), - identity_b.attempt_key(), - "attempt_key must depend only on (session_id, agent_id, tool_use_id)" - ); - } - - mod driver { - use std::cell::{Cell, RefCell}; - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::{Arc, Mutex}; - - use anyhow::anyhow; - - use super::*; - - static NEXT_TEST_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); - - fn unique_test_git_dir(label: &str) -> PathBuf { - let id = NEXT_TEST_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "sce-claude-mutation-scope-driver-{label}-{}-{id}", - std::process::id() - )) - } - - fn remove_test_git_dir(git_dir: &Path) { - let _ = std::fs::remove_dir_all(git_dir); - } - - #[allow(clippy::unnecessary_wraps)] - fn ok_seam(_root: &Path, _payload: &str, _logger: Option<&dyn Logger>) -> Result { - Ok(String::new()) - } - - fn unreachable_seam( - _root: &Path, - payload: &str, - _logger: Option<&dyn Logger>, - ) -> Result { - panic!("the ingress seam must not be called for this payload: {payload}"); - } - - fn seam_failing_on( - operation: &'static str, - ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { - seam_failing_on_any(vec![operation]) - } - - fn seam_failing_on_any( - operations: Vec<&'static str>, - ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { - move |_root, payload, _logger| { - if operations - .iter() - .any(|operation| payload.contains(&format!(r#""operation":"{operation}""#))) - { - Err(anyhow!( - "seam failure injected by test for one of {operations:?}" - )) - } else { - Ok(String::new()) - } - } - } - - fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result { - move |_cwd| Ok(git_dir.clone()) - } - - fn start_with_model_resolver( - payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - model_state_resolver: ClaudeModelStateResolver, - seam: IngressSeam, - ) -> Result { - run_claude_mutation_scope_from_payload_with_resolver( - payload, - logger, - resolve_git_dir, - model_state_resolver, - seam, - ) - } - - #[derive(Clone, Default)] - struct RecordingLogger { - warnings: Arc>>, - } - - impl RecordingLogger { - fn warnings(&self) -> Vec<(String, String)> { - self.warnings - .lock() - .expect("recording logger mutex must not be poisoned") - .clone() - } - } - - impl Logger for RecordingLogger { - fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - - fn warn(&self, event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { - self.warnings - .lock() - .expect("recording logger mutex must not be poisoned") - .push((event_id.to_string(), message.to_string())); - } - - fn error(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - - fn log_cli_error(&self, _: &crate::services::error::CliError, _: Option<&str>) {} - } - - fn session_scoped_payload(event_name: &str, session_id: &str, cwd: &str) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(session_id.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); - Value::Object(object).to_string() - } - - #[test] - fn pre_tool_use_resolves_main_and_subagent_model_state_exactly_at_admission() { - let git_dir = unique_test_git_dir("model-state-admission"); - let git_dir_resolver = fixed_resolver(git_dir.clone()); - let resolver_calls: RefCell> = RefCell::new(Vec::new()); - let model_state_resolver = |_: &Path, session_id: &str, agent_id: &str| { - resolver_calls - .borrow_mut() - .push((session_id.to_string(), agent_id.to_string())); - Ok(Some(if agent_id.is_empty() { - "claude/sonnet".to_string() - } else { - "claude/opus".to_string() - })) - }; - let starts: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - let value: Value = serde_json::from_str(payload).expect("payload is JSON"); - if value.get("operation") == Some(&Value::String("start".to_string())) { - starts.borrow_mut().push(value); - } - Ok(String::new()) - }; - - let main_payload = pre_tool_use_json(&[]); - let subagent_payload = pre_tool_use_json(&[ - ( - TOOL_USE_ID_FIELD, - Value::String("toolu_subagent".to_string()), - ), - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - ]); - start_with_model_resolver( - &main_payload, - None, - &git_dir_resolver, - &model_state_resolver, - &seam, - ) - .expect("main-agent Start should succeed"); - start_with_model_resolver( - &subagent_payload, - None, - &git_dir_resolver, - &model_state_resolver, - &seam, - ) - .expect("subagent Start should succeed"); - - assert_eq!( - resolver_calls.into_inner(), - vec![ - ("cc_session-1".to_string(), String::new()), - ("cc_session-1".to_string(), "agent-1".to_string()), - ] - ); - let starts = starts.into_inner(); - assert_eq!(starts.len(), 2); - assert_eq!( - starts[0]["provenance"], - json!({"session_id": "cc_session-1", "model_id": "claude/sonnet"}) - ); - assert_eq!( - starts[1]["provenance"], - json!({"session_id": "cc_session-1", "model_id": "claude/opus"}) - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn subagent_without_exact_model_state_does_not_inherit_main_model() { - let git_dir = unique_test_git_dir("subagent-model-state-missing"); - let git_dir_resolver = fixed_resolver(git_dir.clone()); - let starts: RefCell> = RefCell::new(Vec::new()); - let model_state_resolver = |_: &Path, _: &str, agent_id: &str| { - Ok(if agent_id.is_empty() { - Some("claude/sonnet".to_string()) - } else { - None - }) - }; - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - starts - .borrow_mut() - .push(serde_json::from_str(payload).expect("payload is JSON")); - Ok(String::new()) - }; - - for (tool_use_id, agent_id) in - [("toolu_main", None), ("toolu_subagent", Some("agent-1"))] - { - let overrides = agent_id - .map(|agent_id| vec![(AGENT_ID_FIELD, Value::String(agent_id.to_string()))]) - .unwrap_or_default(); - let mut overrides = overrides; - overrides.push((TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string()))); - let payload = pre_tool_use_json(&overrides); - start_with_model_resolver( - &payload, - None, - &git_dir_resolver, - &model_state_resolver, - &seam, - ) - .expect("both Starts should succeed"); - } - - let starts = starts.into_inner(); - assert_eq!(starts.len(), 2); - assert_eq!(starts[0]["provenance"]["model_id"], "claude/sonnet"); - assert_eq!(starts[1]["provenance"]["session_id"], "cc_session-1"); - assert_eq!(starts[1]["provenance"]["model_id"], Value::Null); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn missing_or_failed_model_resolution_keeps_session_provenance_and_allows_start() { - let git_dir = unique_test_git_dir("model-state-unavailable"); - let git_dir_resolver = fixed_resolver(git_dir.clone()); - let starts: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - starts - .borrow_mut() - .push(serde_json::from_str(payload).expect("payload is JSON")); - Ok(String::new()) - }; - let response_index = Cell::new(0); - let model_state_resolver = |_: &Path, _: &str, _: &str| -> Result> { - let index = response_index.get(); - response_index.set(index + 1); - if index == 0 { - Ok(None) - } else { - Err(anyhow!("local model-state DB is unavailable")) - } - }; - - for tool_use_id in ["toolu_missing", "toolu_failed"] { - let payload = pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String(tool_use_id.to_string()), - )]); - let output = start_with_model_resolver( - &payload, - None, - &git_dir_resolver, - &model_state_resolver, - &seam, - ) - .expect("model unavailability must not fail Start"); - assert_eq!(output, ""); - } - - let starts = starts.into_inner(); - assert_eq!(starts.len(), 2); - for start in starts { - assert_eq!( - start["provenance"], - json!({"session_id": "cc_session-1", "model_id": null}) - ); - } - - remove_test_git_dir(&git_dir); - } - - #[test] - fn read_only_tool_creates_no_scope_and_never_touches_the_seam_or_git_dir() { - let resolver = |_: &str| -> Result { - panic!("a read-only tool must never resolve a git dir") - }; - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Read".to_string()))]); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("read-only PreToolUse should succeed"); - - assert_eq!(output, ""); - } - - #[test] - fn delegation_tool_creates_no_scope_ac3() { - let resolver = |_: &str| -> Result { - panic!("Agent delegation must never resolve a git dir") - }; - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Agent".to_string()))]); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("Agent delegation PreToolUse should succeed"); - - assert_eq!(output, ""); - } - - #[test] - fn session_start_and_subagent_start_establish_no_scope_ac3() { - let resolver = |_: &str| -> Result { - panic!("a lifecycle-only event must never resolve a git dir") - }; - - for event_name in [HOOK_EVENT_SESSION_START, HOOK_EVENT_SUBAGENT_START] { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - let payload = Value::Object(object).to_string(); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("a lifecycle-only event should succeed with no scope"); - assert_eq!(output, ""); - } - } - - #[test] - fn explicit_background_bash_is_denied_with_the_exact_reason_d20() { - let git_dir = unique_test_git_dir("explicit-background-bash"); - let resolver = fixed_resolver(git_dir.clone()); - let payload = pre_tool_use_json(&[ - (TOOL_NAME_FIELD, Value::String("Bash".to_string())), - ( - TOOL_INPUT_FIELD, - serde_json::json!({ "run_in_background": true }), - ), - ]); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("an explicit background shell should still return Ok with a deny payload"); - - assert_eq!( - output, - pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) - ); - assert!( - !git_dir.exists(), - "D20: denial must precede any adapter-state I/O" - ); - } - - #[test] - fn explicit_background_powershell_is_denied_ac21() { - let git_dir = unique_test_git_dir("explicit-background-powershell"); - let resolver = fixed_resolver(git_dir.clone()); - let payload = pre_tool_use_json(&[ - (TOOL_NAME_FIELD, Value::String("PowerShell".to_string())), - ( - TOOL_INPUT_FIELD, - serde_json::json!({ "run_in_background": true }), - ), - ]); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("explicit background PowerShell should be denied"); - - assert_eq!( - output, - pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) - ); - } - - #[test] - fn write_ahead_pending_start_persists_before_the_seam_start_call_ac7() { - let git_dir = unique_test_git_dir("write-ahead"); - let resolver = fixed_resolver(git_dir.clone()); - let git_dir_for_seam = git_dir.clone(); - let phase_seen_before_start: RefCell> = RefCell::new(None); - let observed_roots: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - if payload.contains(r#""operation":"start""#) { - observed_roots.borrow_mut().push(root.to_path_buf()); - let observed = state::read_state(&git_dir_for_seam) - .expect("state should be readable under git_dir inside the seam call"); - *phase_seen_before_start.borrow_mut() = - observed.attempts.first().map(|attempt| attempt.phase); - } - Ok(String::new()) - }; - - let payload = pre_tool_use_json(&[]); - let output = - run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) - .expect("mutation-capable PreToolUse should succeed"); - - assert_eq!(output, ""); - assert_eq!( - phase_seen_before_start.into_inner(), - Some(state::AttemptPhase::PendingStart), - "AC7: the attempt must be durably pending_start (under git_dir) before the seam Start call" - ); - assert_eq!( - observed_roots.into_inner(), - vec![PathBuf::from("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/repo/checkout")], - "the seam must receive the raw Claude cwd as repository_root, never the resolved git_dir" - ); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!( - final_state.attempts[0].phase, - state::AttemptPhase::Active, - "phase must become active after a successful Start" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn linked_worktree_cwd_and_git_dir_are_never_conflated_for_pre_tool_use_start() { - let git_dir = unique_test_git_dir("cwd-vs-git-dir-start"); - let raw_cwd = "/repo/.claude/worktrees/agent-123"; - let resolver = fixed_resolver(git_dir.clone()); - - let observed_roots: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, _payload: &str, _logger: Option<&dyn Logger>| -> Result { - observed_roots.borrow_mut().push(root.to_path_buf()); - Ok(String::new()) - }; - - let payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); - run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) - .expect("PreToolUse Start should succeed"); - - assert_ne!( - PathBuf::from(raw_cwd), - git_dir, - "test sanity: the raw checkout path and the resolved git_dir must be deliberately distinct" - ); - assert_eq!( - observed_roots.into_inner(), - vec![PathBuf::from(raw_cwd)], - "the ingress seam must receive the raw Claude cwd, never git_dir" - ); - - let state = - state::read_state(&git_dir).expect("state should be readable under git_dir"); - assert_eq!( - state.attempts.len(), - 1, - "adapter bookkeeping must be written under the resolved git_dir" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn post_tool_use_close_uses_git_dir_for_state_and_raw_cwd_for_the_seam() { - let git_dir = unique_test_git_dir("cwd-vs-git-dir-close"); - let raw_cwd = "/repo/.claude/worktrees/agent-123"; - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let observed_roots: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, _payload: &str, _logger: Option<&dyn Logger>| -> Result { - observed_roots.borrow_mut().push(root.to_path_buf()); - Ok(String::new()) - }; - let post_payload = pre_tool_use_json(&[ - (CWD_FIELD, Value::String(raw_cwd.to_string())), - ( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), - ), - ]); - run_claude_mutation_scope_from_payload_with(&post_payload, None, &resolver, &seam) - .expect("PostToolUse Close should succeed"); - - assert_eq!( - observed_roots.into_inner(), - vec![PathBuf::from(raw_cwd)], - "Close must invoke the seam with the raw Claude cwd, never git_dir" - ); - - let state = - state::read_state(&git_dir).expect("state should be readable under git_dir"); - assert!( - state.attempts.is_empty(), - "the closed attempt must be removed from git_dir bookkeeping" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn abandon_via_permission_denied_uses_git_dir_for_state_and_raw_cwd_for_the_seam() { - let git_dir = unique_test_git_dir("cwd-vs-git-dir-abandon"); - let raw_cwd = "/repo/.claude/worktrees/agent-123"; - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let observed_roots: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - if payload.contains(r#""operation":"abandon""#) { - observed_roots.borrow_mut().push(root.to_path_buf()); - } - Ok(String::new()) - }; - let denied_payload = pre_tool_use_json(&[ - (CWD_FIELD, Value::String(raw_cwd.to_string())), - ( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_PERMISSION_DENIED.to_string()), - ), - ]); - run_claude_mutation_scope_from_payload_with(&denied_payload, None, &resolver, &seam) - .expect("PermissionDenied should succeed"); - - assert_eq!( - observed_roots.into_inner(), - vec![PathBuf::from(raw_cwd)], - "Abandon must invoke the seam with the raw Claude cwd, never git_dir" - ); - - let state = - state::read_state(&git_dir).expect("state should be readable under git_dir"); - assert!(state.attempts.is_empty()); - assert!(state.recovery_pending); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_flush_uses_raw_cwd_for_the_seam_and_git_dir_for_state() { - let git_dir = unique_test_git_dir("cwd-vs-git-dir-flush"); - let raw_cwd = "/repo/.claude/worktrees/agent-123"; - let resolver = fixed_resolver(git_dir.clone()); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - - let seeded = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_seed".to_string(), - }, - "Write", - ) - .expect("seed allocation should succeed"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - state::remove_attempt(&git_dir, &seeded.attempt.scope_id) - .expect("removing the seed attempt should succeed"); - - let observed_roots: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - if payload.contains(r#""operation":"flush""#) { - observed_roots.borrow_mut().push(root.to_path_buf()); - } - Ok(String::new()) - }; - - let new_pre = pre_tool_use_json(&[ - (CWD_FIELD, Value::String(raw_cwd.to_string())), - (TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string())), - ]); - run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) - .expect("quiescent recovery should flush against the raw checkout path"); - - assert_eq!( - observed_roots.into_inner(), - vec![PathBuf::from(raw_cwd)], - "flush must run against the raw Claude cwd, not git_dir" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn duplicate_live_pre_tool_use_reuses_the_same_scope_and_start_event_id_ac4() { - let git_dir = unique_test_git_dir("duplicate-delivery"); - let resolver = fixed_resolver(git_dir.clone()); - let start_event_ids: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - if payload.contains(r#""operation":"start""#) { - let value: Value = serde_json::from_str(payload).unwrap(); - start_event_ids - .borrow_mut() - .push(value["event_id"].as_str().unwrap().to_string()); - } - Ok(String::new()) - }; - - let payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) - .expect("first delivery should succeed"); - run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) - .expect("duplicate delivery should succeed"); - - let ids = start_event_ids.into_inner(); - assert_eq!(ids.len(), 2); - assert_eq!( - ids[0], ids[1], - "AC4: duplicate delivery must reuse the same Start EventId" - ); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!( - final_state.attempts.len(), - 1, - "duplicate delivery must not create a second bookkeeping entry" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn seam_start_failure_denies_and_leaves_the_attempt_pending_start_d8_d11() { - let git_dir = unique_test_git_dir("start-failure"); - let resolver = fixed_resolver(git_dir.clone()); - let seam = seam_failing_on("start"); - - let payload = pre_tool_use_json(&[]); - let output = - run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) - .expect( - "a Start failure must still return Ok with a deny payload, not propagate", - ); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!( - final_state.attempts[0].phase, - state::AttemptPhase::PendingStart, - "D11: a failed Start must not be marked active nor removed" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn pending_start_attempt_is_abandoned_not_late_started_on_a_terminal_signal_d11() { - let git_dir = unique_test_git_dir("pending-start-then-terminal"); - let resolver = fixed_resolver(git_dir.clone()); - - let start_failing_seam = seam_failing_on("start"); - let pre_payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with( - &pre_payload, - None, - &resolver, - &start_failing_seam, - ) - .expect("the failed Start must still return Ok with a deny payload"); - - let seen_operations: RefCell> = RefCell::new(Vec::new()); - let recording = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen_operations.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - let post_payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), - )]); - let output = run_claude_mutation_scope_from_payload_with( - &post_payload, - None, - &resolver, - &recording, - ) - .expect("PostToolUse for a pending_start attempt should succeed"); - - assert_eq!(output, ""); - let operations = seen_operations.into_inner(); - assert_eq!(operations.len(), 1); - assert!( - operations[0].contains(r#""operation":"abandon""#), - "D11: a pending_start attempt must be abandoned, not late-started, got: {operations:?}" - ); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!(final_state.attempts.is_empty()); - assert!(final_state.recovery_pending); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn close_seam_failure_is_retired_through_abandonment_not_a_replayed_close_d12() { - let git_dir = unique_test_git_dir("close-failure"); - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let close_failing_seam = seam_failing_on("close"); - let post_payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), - )]); - let output = run_claude_mutation_scope_from_payload_with( - &post_payload, - None, - &resolver, - &close_failing_seam, - ) - .expect("a Close failure must still succeed via abandonment"); - - assert_eq!(output, ""); - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!( - final_state.attempts.is_empty(), - "D12: after abandonment the attempt must be retired" - ); - assert!(final_state.recovery_pending); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn post_tool_use_failure_also_closes_the_scope_d10() { - let git_dir = unique_test_git_dir("post-tool-use-failure"); - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let seen_operations: RefCell> = RefCell::new(Vec::new()); - let recording = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen_operations.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - let failure_payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE_FAILURE.to_string()), - )]); - let output = run_claude_mutation_scope_from_payload_with( - &failure_payload, - None, - &resolver, - &recording, - ) - .expect("PostToolUseFailure should close the scope"); - - assert_eq!(output, ""); - let operations = seen_operations.into_inner(); - assert_eq!(operations.len(), 1); - assert!(operations[0].contains(r#""operation":"close""#)); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!(final_state.attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn post_tool_use_with_no_live_attempt_is_a_safe_no_op_d9() { - let payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), - )]); - let resolver = - fixed_resolver(std::env::temp_dir().join("sce-unused-nonexistent-git-dir")); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("a PostToolUse with no live attempt must be a safe no-op"); - - assert_eq!(output, ""); - } - - #[test] - fn permission_denied_abandons_a_live_attempt_d13() { - let git_dir = unique_test_git_dir("permission-denied"); - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let seen_operations: RefCell> = RefCell::new(Vec::new()); - let recording = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen_operations.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - let denied_payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_PERMISSION_DENIED.to_string()), - )]); - let output = run_claude_mutation_scope_from_payload_with( - &denied_payload, - None, - &resolver, - &recording, - ) - .expect("PermissionDenied should succeed"); - - assert_eq!(output, ""); - let operations = seen_operations.into_inner(); - assert_eq!(operations.len(), 1); - assert!(operations[0].contains(r#""operation":"abandon""#)); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!(final_state.attempts.is_empty()); - assert!(final_state.recovery_pending); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn stop_abandons_only_stale_main_thread_attempts_d14() { - let git_dir = unique_test_git_dir("stop-cleanup"); - let resolver = fixed_resolver(git_dir.clone()); - - let main_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); - let subagent_pre = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - ]); - run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) - .expect("main-thread PreToolUse should succeed"); - run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) - .expect("subagent PreToolUse should succeed"); - - let stop_payload = - session_scoped_payload(HOOK_EVENT_STOP, "session-1", "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/repo/checkout"); - let output = run_claude_mutation_scope_from_payload_with( - &stop_payload, - None, - &resolver, - &ok_seam, - ) - .expect("Stop cleanup should succeed"); - assert_eq!(output, ""); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!( - final_state.attempts.len(), - 1, - "only the subagent attempt should remain" - ); - assert_eq!(final_state.attempts[0].tool_use_id, "toolu_agent"); - assert!( - final_state.recovery_pending, - "abandoning the stale main attempt must arm the barrier" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn stop_failure_abandons_stale_main_thread_attempts_the_same_way_d15() { - let git_dir = unique_test_git_dir("stop-failure-cleanup"); - let resolver = fixed_resolver(git_dir.clone()); - - let main_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); - run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) - .expect("main-thread PreToolUse should succeed"); - - let stop_failure_payload = - session_scoped_payload(HOOK_EVENT_STOP_FAILURE, "session-1", "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/repo/checkout"); - run_claude_mutation_scope_from_payload_with( - &stop_failure_payload, - None, - &resolver, - &ok_seam, - ) - .expect("StopFailure cleanup should succeed"); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!(final_state.attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn user_prompt_submit_abandons_only_stale_main_thread_attempts_d16() { - let git_dir = unique_test_git_dir("user-prompt-submit-cleanup"); - let resolver = fixed_resolver(git_dir.clone()); - - let main_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); - let subagent_pre = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - ]); - run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) - .expect("main-thread PreToolUse should succeed"); - run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) - .expect("subagent PreToolUse should succeed"); - - let prompt_payload = session_scoped_payload( - HOOK_EVENT_USER_PROMPT_SUBMIT, - "session-1", - "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/repo/checkout", - ); - run_claude_mutation_scope_from_payload_with(&prompt_payload, None, &resolver, &ok_seam) - .expect("UserPromptSubmit cleanup should succeed"); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!( - final_state.attempts.len(), - 1, - "the subagent attempt must survive" - ); - assert_eq!(final_state.attempts[0].tool_use_id, "toolu_agent"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn subagent_stop_abandons_only_the_matching_agent_id_attempts_d17() { - let git_dir = unique_test_git_dir("subagent-stop-cleanup"); - let resolver = fixed_resolver(git_dir.clone()); - - let first_agent_payload = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("toolu_a".to_string())), - (AGENT_ID_FIELD, Value::String("agent-a".to_string())), - ]); - let second_agent_payload = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("toolu_b".to_string())), - (AGENT_ID_FIELD, Value::String("agent-b".to_string())), - ]); - run_claude_mutation_scope_from_payload_with( - &first_agent_payload, - None, - &resolver, - &ok_seam, - ) - .expect("agent-a PreToolUse should succeed"); - run_claude_mutation_scope_from_payload_with( - &second_agent_payload, - None, - &resolver, - &ok_seam, - ) - .expect("agent-b PreToolUse should succeed"); - - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert( - CWD_FIELD.to_string(), - Value::String("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/repo/checkout".to_string()), - ); - object.insert( - AGENT_ID_FIELD.to_string(), - Value::String("agent-a".to_string()), - ); - let subagent_stop_payload = Value::Object(object).to_string(); - - run_claude_mutation_scope_from_payload_with( - &subagent_stop_payload, - None, - &resolver, - &ok_seam, - ) - .expect("SubagentStop cleanup should succeed"); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "toolu_b"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn session_end_abandons_every_attempt_regardless_of_agent_id_d18() { - let git_dir = unique_test_git_dir("session-end-cleanup"); - let resolver = fixed_resolver(git_dir.clone()); - - let main_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); - let subagent_pre = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - ]); - run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) - .expect("main-thread PreToolUse should succeed"); - run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) - .expect("subagent PreToolUse should succeed"); - - let session_end_payload = - session_scoped_payload(HOOK_EVENT_SESSION_END, "session-1", "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/repo/checkout"); - run_claude_mutation_scope_from_payload_with( - &session_end_payload, - None, - &resolver, - &ok_seam, - ) - .expect("SessionEnd cleanup should succeed"); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!(final_state.attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn worktree_remove_resolves_git_dir_from_worktree_path_not_cwd_d22() { - let main_git_dir = unique_test_git_dir("worktree-remove-main"); - let worktree_git_dir = unique_test_git_dir("worktree-remove-isolated"); - let main_git_dir_for_resolver = main_git_dir.clone(); - let worktree_git_dir_for_resolver = worktree_git_dir.clone(); - - let resolver = move |cwd: &str| -> Result { - if cwd == "/repo/.claude/worktrees/agent-1" { - Ok(worktree_git_dir_for_resolver.clone()) - } else { - Ok(main_git_dir_for_resolver.clone()) - } - }; - - let subagent_pre = pre_tool_use_json(&[ - ( - CWD_FIELD, - Value::String("/repo/.claude/worktrees/agent-1".to_string()), - ), - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - ]); - run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) - .expect("isolated-worktree PreToolUse should succeed"); - - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert( - WORKTREE_PATH_FIELD.to_string(), - Value::String("/repo/.claude/worktrees/agent-1".to_string()), - ); - let worktree_remove_payload = Value::Object(object).to_string(); - - run_claude_mutation_scope_from_payload_with( - &worktree_remove_payload, - None, - &resolver, - &ok_seam, - ) - .expect("WorktreeRemove cleanup should succeed"); - - let worktree_state = - state::read_state(&worktree_git_dir).expect("worktree state should be readable"); - assert!( - worktree_state.attempts.is_empty(), - "D22: WorktreeRemove must retire attempts under the worktree_path's git dir" - ); - - remove_test_git_dir(&main_git_dir); - remove_test_git_dir(&worktree_git_dir); - } - - #[test] - fn recovery_barrier_denies_new_mutation_capable_pre_tool_use_while_attempts_remain_d19() { - let git_dir = unique_test_git_dir("barrier-attempts-remain"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - - let surviving = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_surviving".to_string(), - }, - "Write", - ) - .expect("seeding a surviving attempt should succeed"); - let retiring = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_retiring".to_string(), - }, - "Write", - ) - .expect("seeding a retiring attempt should succeed"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - state::remove_attempt(&git_dir, &retiring.attempt.scope_id) - .expect("removing the retiring attempt should succeed"); - let _ = surviving; - - let new_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); - let output = run_claude_mutation_scope_from_payload_with( - &new_pre, - None, - &resolver, - &unreachable_seam, - ) - .expect("D19: barrier denial must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_flushes_once_quiescent_and_clears_before_starting_d19() { - let git_dir = unique_test_git_dir("barrier-flush-success"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - - let seeded = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_seed".to_string(), - }, - "Write", - ) - .expect("seeding the retired attempt should succeed"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - state::remove_attempt(&git_dir, &seeded.attempt.scope_id) - .expect("removing the seeded attempt should succeed"); - - let seen_operations: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen_operations.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - - let new_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); - let output = - run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) - .expect("D19: a quiescent recovery should flush then proceed"); - - assert_eq!(output, ""); - let operations = seen_operations.into_inner(); - assert_eq!( - operations.len(), - 2, - "expected flush then start, got: {operations:?}" - ); - assert!(operations[0].contains(r#""operation":"flush""#)); - assert!(operations[1].contains(r#""operation":"start""#)); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!( - !final_state.recovery_pending, - "a successful flush must clear the barrier" - ); - assert_eq!(final_state.attempts.len(), 1); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_stays_fail_closed_when_flush_fails_d19() { - let git_dir = unique_test_git_dir("barrier-flush-failure"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - - let seeded = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_seed".to_string(), - }, - "Write", - ) - .expect("seeding the retired attempt should succeed"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - state::remove_attempt(&git_dir, &seeded.attempt.scope_id) - .expect("removing the seeded attempt should succeed"); - - let seam = seam_failing_on("flush"); - let new_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); - let output = - run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) - .expect("D19: a failed flush must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!( - final_state.recovery_pending, - "a failed flush must keep the barrier armed" - ); - assert!( - final_state.attempts.is_empty(), - "a denied PreToolUse must not allocate a new attempt" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn failed_close_and_failed_abandon_keep_recovery_armed_and_the_attempt_tracked_d12_d19() { - let git_dir = unique_test_git_dir("close-and-abandon-failure"); - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let failing_seam = seam_failing_on_any(vec!["close", "abandon"]); - let post_payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), - )]); - let error = run_claude_mutation_scope_from_payload_with( - &post_payload, - None, - &resolver, - &failing_seam, - ) - .expect_err( - "a failed Close followed by a failed Abandon must propagate, not silently succeed", - ); - assert!(error.to_string().contains("abandon")); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!( - final_state.attempts.len(), - 1, - "D12: an attempt whose abandonment failed must remain tracked" - ); - assert!( - final_state.recovery_pending, - "D19: recovery must be armed even though abandonment itself failed" - ); - - let new_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); - let output = run_claude_mutation_scope_from_payload_with( - &new_pre, - None, - &resolver, - &unreachable_seam, - ) - .expect("the barrier denial must still return Ok with a deny payload"); - assert_eq!( - output, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "the next mutation-capable PreToolUse must be denied, and no new Start may occur" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn lifecycle_cleanup_with_a_failed_abandon_keeps_recovery_armed_and_the_attempt_tracked() { - let git_dir = unique_test_git_dir("lifecycle-cleanup-abandon-failure"); - let resolver = fixed_resolver(git_dir.clone()); - - let main_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); - run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) - .expect("main-thread PreToolUse should succeed"); - - let abandon_failing_seam = seam_failing_on("abandon"); - let stop_payload = - session_scoped_payload(HOOK_EVENT_STOP, "session-1", "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/repo/checkout"); - let error = run_claude_mutation_scope_from_payload_with( - &stop_payload, - None, - &resolver, - &abandon_failing_seam, - ) - .expect_err("a failed abandonment during Stop cleanup must propagate"); - assert!(error.to_string().contains("abandon")); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!( - final_state.attempts.len(), - 1, - "the attempt whose abandonment failed must remain tracked" - ); - assert!( - final_state.recovery_pending, - "the barrier must remain armed even though cleanup abandonment failed" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn resolver_failure_logs_the_detailed_error_and_denies_with_the_stable_reason_ac8_d8() { - let logger = RecordingLogger::default(); - let resolver = |_: &str| -> Result { - Err(anyhow!("boom: git rev-parse --git-dir failed")) - }; - - let payload = pre_tool_use_json(&[]); - let output = run_claude_mutation_scope_from_payload_with( - &payload, - Some(&logger), - &resolver, - &unreachable_seam, - ) - .expect("a resolver failure must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - assert!( - !output.contains("boom"), - "the detailed internal error must never leak into Claude's deny reason" - ); - assert!( - !output.contains("allow"), - "a fail-closed PreToolUse must never emit an allow decision" - ); - - let warnings = logger.warnings(); - assert_eq!(warnings.len(), 1); - assert!( - warnings[0].1.contains("boom"), - "the detailed error must be logged for operators, got: {warnings:?}" - ); - } - - #[test] - fn start_seam_failure_logs_the_detailed_error_and_denies_with_the_stable_reason() { - let git_dir = unique_test_git_dir("start-failure-logged"); - let resolver = fixed_resolver(git_dir.clone()); - let logger = RecordingLogger::default(); - let seam = seam_failing_on("start"); - - let payload = pre_tool_use_json(&[]); - let output = run_claude_mutation_scope_from_payload_with( - &payload, - Some(&logger), - &resolver, - &seam, - ) - .expect("a Start failure must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let warnings = logger.warnings(); - assert!(!warnings.is_empty(), "the Start failure must be logged"); - assert!(warnings - .iter() - .any(|(_, message)| message.to_lowercase().contains("start"))); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_flush_failure_logs_the_detailed_error() { - let git_dir = unique_test_git_dir("barrier-flush-failure-logged"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - let logger = RecordingLogger::default(); - - let seeded = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_seed".to_string(), - }, - "Write", - ) - .expect("seed allocation should succeed"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - state::remove_attempt(&git_dir, &seeded.attempt.scope_id) - .expect("removing the seeded attempt should succeed"); - - let seam = seam_failing_on("flush"); - let new_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); - let output = run_claude_mutation_scope_from_payload_with( - &new_pre, - Some(&logger), - &resolver, - &seam, - ) - .expect("a failed flush must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let warnings = logger.warnings(); - assert!(!warnings.is_empty(), "the flush failure must be logged"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn malformed_payload_propagates_as_a_real_error_not_fail_open() { - let error = run_claude_mutation_scope_from_payload("not json", None).unwrap_err(); - assert!(error.to_string().contains("valid JSON")); - } - } - - mod production_regressions { - use std::fs; - use std::path::{Path, PathBuf}; - use std::process::Command; - - use super::*; - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - use crate::services::mutation_trace::runtime::{resolve_git_dir, resolve_worktree_id}; - use crate::services::mutation_trace::store::decode_revision; - - fn git(dir: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).expect("git output should be UTF-8") - } - - struct ClaudeRepo { - temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - } - - impl ClaudeRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-claude-mutation-scope-regression-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - temp, - root, - state_root, - } - } - - fn drive(&self, payload: &str) -> Result { - run_claude_mutation_scope_from_payload_at_state_root( - &self.state_root, - payload, - None, - ) - } - - fn drive_generic(&self, payload: &str) -> Result { - crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( - &self.root, - &self.state_root, - payload, - None, - ) - } - - fn drive_flush(&self) -> Result { - self.drive_generic(&flush_payload()) - } - - fn db(&self) -> RepositoryAgentTraceDb { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &self.root, - &self.state_root, - "claude mutation-scope regression test assertions", - ) - .expect("assertion DB should open") - } - - fn cwd(&self) -> String { - self.root.to_string_lossy().into_owned() - } - - fn cwd_at(root: &Path) -> String { - root.to_string_lossy().into_owned() - } - - fn working_tree_at(root: &Path) -> String { - git(root, &["add", "-A"]); - git(root, &["write-tree"]).trim().to_owned() - } - - fn working_tree(&self) -> String { - Self::working_tree_at(&self.root) - } - - fn git_dir_at(root: &Path) -> PathBuf { - resolve_git_dir(root).expect("git dir should resolve") - } - - fn git_dir(&self) -> PathBuf { - Self::git_dir_at(&self.root) - } - - fn adapter_state_at(root: &Path) -> state::AdapterState { - state::read_state(&Self::git_dir_at(root)) - .expect("adapter state should be readable") - } - - fn adapter_state(&self) -> state::AdapterState { - Self::adapter_state_at(&self.root) - } - - fn worktree_id_at(root: &Path) -> String { - resolve_worktree_id(root) - .expect("worktree id should resolve") - .0 - } - - fn worktree_id(&self) -> String { - Self::worktree_id_at(&self.root) - } - - fn add_worktree(&self, name: &str) -> PathBuf { - let worktree_path = self.temp.path().join(name); - git( - &self.root, - &[ - "worktree", - "add", - "-q", - worktree_path.to_str().expect("utf-8 worktree path"), - ], - ); - worktree_path - } - } - - fn count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { - db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("count query should succeed") - .into_iter() - .next() - .expect("a count row should exist") - } - - fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { - assert_eq!(count(db, "diff_traces"), 0); - assert_eq!(count(db, "post_commit_patch_intersections"), 0); - assert_eq!(count(db, "agent_traces"), 0); - } - - fn worktree_row( - db: &RepositoryAgentTraceDb, - worktree_id: &str, - ) -> Option<(u64, String, bool)> { - db.query_map( - "SELECT revision, cursor_tree, needs_rebaseline FROM mutation_trace_worktrees \ - WHERE worktree_id = ?1", - (worktree_id,), - |row| { - let blob: Vec = row.get(0).map_err(anyhow::Error::from)?; - let revision = decode_revision(&blob)?; - let cursor_tree = row.get::(1).map_err(anyhow::Error::from)?; - let needs_rebaseline = row.get::(2).map_err(anyhow::Error::from)? != 0; - Ok((revision, cursor_tree, needs_rebaseline)) - }, - ) - .expect("worktree-row query should succeed") - .into_iter() - .next() - } - - fn processed_events(db: &RepositoryAgentTraceDb) -> Vec<(String, String)> { - db.query_map( - "SELECT scope_id, event_id FROM mutation_trace_processed_events \ - ORDER BY scope_id, event_id", - (), - |row| { - let scope_id = row.get::(0).map_err(anyhow::Error::from)?; - let event_id = row.get::(1).map_err(anyhow::Error::from)?; - Ok((scope_id, event_id)) - }, - ) - .expect("processed-events query should succeed") - } - - fn scope_status(db: &RepositoryAgentTraceDb, scope_id: &str) -> Option<(String, String)> { - db.query_map( - "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", - (scope_id,), - |row| { - let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; - let status = row.get::(1).map_err(anyhow::Error::from)?; - Ok((actor_kind, status)) - }, - ) - .expect("scope query should succeed") - .into_iter() - .next() - } - - fn scope_provenance( - db: &RepositoryAgentTraceDb, - scope_id: &str, - ) -> Option<(String, Option)> { - db.query_map( - "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ - WHERE scope_id = ?1", - (scope_id,), - |row| { - let session_id = row.get::(0).map_err(anyhow::Error::from)?; - let model_id = row.get::>(1).map_err(anyhow::Error::from)?; - Ok((session_id, model_id)) - }, - ) - .expect("scope-provenance query should succeed") - .into_iter() - .next() - } - - fn mutation_events_for( - db: &RepositoryAgentTraceDb, - worktree_id: &str, - ) -> Vec<(String, Option, String)> { - db.query_map( - "SELECT attribution_kind, attribution_scope_id, boundary_kind \ - FROM mutation_trace_events WHERE worktree_id = ?1 ORDER BY revision", - (worktree_id,), - |row| { - let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - let boundary_kind = row.get::(2).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id, boundary_kind)) - }, - ) - .expect("mutation-events query should succeed") - } - - fn tool_identity_json( - event_name: &str, - cwd: &str, - session_id: &str, - tool_name: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(session_id.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String(tool_name.to_string()), - ); - object.insert( - TOOL_USE_ID_FIELD.to_string(), - Value::String(tool_use_id.to_string()), - ); - if let Some(agent_id) = agent_id { - object.insert( - AGENT_ID_FIELD.to_string(), - Value::String(agent_id.to_string()), - ); - } - Value::Object(object).to_string() - } - - fn pre_tool_use_for( - cwd: &str, - session_id: &str, - tool_name: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - tool_identity_json( - HOOK_EVENT_PRE_TOOL_USE, - cwd, - session_id, - tool_name, - tool_use_id, - agent_id, - ) - } - - fn background_pre_tool_use_for( - cwd: &str, - session_id: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - let mut object: serde_json::Map = serde_json::from_str( - &pre_tool_use_for(cwd, session_id, "Bash", tool_use_id, agent_id), - ) - .expect("base PreToolUse payload should parse"); - object.insert( - TOOL_INPUT_FIELD.to_string(), - json!({ "run_in_background": true }), - ); - Value::Object(object).to_string() - } - - fn post_tool_use_for( - cwd: &str, - session_id: &str, - tool_name: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - tool_identity_json( - HOOK_EVENT_POST_TOOL_USE, - cwd, - session_id, - tool_name, - tool_use_id, - agent_id, - ) - } - - fn post_tool_use_failure_for( - cwd: &str, - session_id: &str, - tool_name: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - tool_identity_json( - HOOK_EVENT_POST_TOOL_USE_FAILURE, - cwd, - session_id, - tool_name, - tool_use_id, - agent_id, - ) - } - - fn permission_denied_for( - cwd: &str, - session_id: &str, - tool_name: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - tool_identity_json( - HOOK_EVENT_PERMISSION_DENIED, - cwd, - session_id, - tool_name, - tool_use_id, - agent_id, - ) - } - - fn session_json(event_name: &str, cwd: &str, session_id: &str) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(session_id.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); - Value::Object(object).to_string() - } - - fn agent_json(event_name: &str, cwd: &str, session_id: &str, agent_id: &str) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(session_id.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); - object.insert( - AGENT_ID_FIELD.to_string(), - Value::String(agent_id.to_string()), - ); - Value::Object(object).to_string() - } - - fn worktree_remove_json(session_id: &str, worktree_path: &str) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(session_id.to_string()), - ); - object.insert( - WORKTREE_PATH_FIELD.to_string(), - Value::String(worktree_path.to_string()), - ); - Value::Object(object).to_string() - } - - #[test] - fn test1_foreground_write_closes_ai_exclusive() { - let repo = ClaudeRepo::new("test1-foreground-write"); - let cwd = repo.cwd(); - - assert_eq!( - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None - )) - .expect("PreToolUse should succeed"), - "" - ); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\ntwo\n") - .expect("the tool's own edit should write"); - - assert_eq!( - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None - )) - .expect("PostToolUse should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "the closed attempt must be removed from adapter bookkeeping" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "closed".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id.clone()), - "close".to_string(), - )] - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(repo.working_tree()) - ); - assert_eq!( - processed_events(&db), - vec![ - (scope_id.clone(), claude_scope_close_event_id(&scope_id)), - (scope_id.clone(), claude_scope_start_event_id(&scope_id)), - ], - "rows are ordered by (scope_id, event_id), and 'close' sorts before 'start'" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test2_failed_bash_partial_write_still_closes_ai_exclusive() { - let repo = ClaudeRepo::new("test2-failed-bash"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Bash", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\npartial\n") - .expect("the failed tool's partial edit should write"); - - assert_eq!( - repo.drive(&post_tool_use_failure_for( - &cwd, - "session-1", - "Bash", - "toolu_1", - None - )) - .expect("PostToolUseFailure should succeed"), - "" - ); - - assert!(repo.adapter_state().attempts.is_empty()); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "closed".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id.clone()), - "close".to_string(), - )] - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(repo.working_tree()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test4_duplicate_pre_and_post_replay_has_no_duplicate_transition() { - let repo = ClaudeRepo::new("test4-duplicate-replay"); - let cwd = repo.cwd(); - let pre = pre_tool_use_for(&cwd, "session-1", "Write", "toolu_1", None); - - repo.drive(&pre).expect("first PreToolUse should succeed"); - assert_eq!( - repo.drive(&pre) - .expect("duplicate PreToolUse should be idempotent"), - "" - ); - assert_eq!( - repo.adapter_state().attempts.len(), - 1, - "AC4: duplicate PreToolUse delivery must reuse the same attempt" - ); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); - - let post = post_tool_use_for(&cwd, "session-1", "Write", "toolu_1", None); - repo.drive(&post).expect("first PostToolUse should succeed"); - - let db = repo.db(); - let (revision_before, events_before, processed_before) = ( - worktree_row(&db, &repo.worktree_id()) - .map(|(revision, _, _)| revision) - .expect("a worktree row should exist"), - count(&db, "mutation_trace_events"), - count(&db, "mutation_trace_processed_events"), - ); - - assert_eq!( - repo.drive(&post) - .expect("duplicate PostToolUse delivery must be a safe no-op"), - "" - ); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), - Some(revision_before) - ); - assert_eq!(count(&db, "mutation_trace_events"), events_before); - assert_eq!( - count(&db, "mutation_trace_processed_events"), - processed_before - ); - assert_eq!( - processed_events(&db) - .into_iter() - .filter(|(scope, event)| scope == &scope_id - && event == &claude_scope_close_event_id(&scope_id)) - .count(), - 1 - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test5_auto_permission_denied_abandons_and_requires_rebaseline() { - let repo = ClaudeRepo::new("test5-permission-denied"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - assert_eq!( - repo.drive(&permission_denied_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None - )) - .expect("PermissionDenied should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "the denied attempt must be retired from adapter bookkeeping" - ); - assert!( - repo.adapter_state().recovery_pending, - "D19: abandonment must arm the recovery barrier" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "abandoned".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert!( - worktree_row(&db, &worktree_id) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline), - "AC12: a denied execution must leave the worktree needing rebaseline" - ); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test3_two_parallel_subagent_tools_produce_ai_contended() { - let repo = ClaudeRepo::new("test3-parallel-subagents"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_a", - Some("agent-a"), - )) - .expect("agent-a PreToolUse should succeed"); - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_b", - Some("agent-b"), - )) - .expect("agent-b PreToolUse should succeed"); - assert_eq!(repo.adapter_state().attempts.len(), 2); - - fs::write(repo.root.join("file.txt"), "one\ncontended\n") - .expect("the racing edit should write"); - - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_a", - Some("agent-a"), - )) - .expect("agent-a PostToolUse (closing while agent-b is still active) should succeed"); - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_b", - Some("agent-b"), - )) - .expect("agent-b PostToolUse should succeed"); - - assert!(repo.adapter_state().attempts.is_empty()); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![("ai_contended".to_string(), None, "close".to_string())], - "AC11: a tree transition observed while two scopes are live must be AiContended" - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(repo.working_tree()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test6_other_hook_denial_is_retired_by_stop_cleanup() { - let repo = ClaudeRepo::new("test6-stop-cleanup"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Bash", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - assert_eq!( - repo.drive(&session_json(HOOK_EVENT_STOP, &cwd, "session-1")) - .expect("Stop should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "AC13: Stop must retire the stale main-thread attempt" - ); - assert!(repo.adapter_state().recovery_pending); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "abandoned".to_string())) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test7_interrupted_main_turn_is_retired_by_next_user_prompt_submit() { - let repo = ClaudeRepo::new("test7-user-prompt-submit-cleanup"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\ninterrupted\n") - .expect("the interrupted edit should write"); - - assert_eq!( - repo.drive(&session_json( - HOOK_EVENT_USER_PROMPT_SUBMIT, - &cwd, - "session-1" - )) - .expect("UserPromptSubmit should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "AC14: UserPromptSubmit must retire the stale main-thread attempt \ - before another mutation-capable tool can start" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "abandoned".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert!(worktree_row(&db, &worktree_id) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test8_resumed_subagent_tool_use_id_gets_a_fresh_scope_id() { - let repo = ClaudeRepo::new("test8-resumed-subagent"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_resumed", - Some("agent-a"), - )) - .expect("first PreToolUse should succeed"); - let first_scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\nfirst\n").expect("first edit should write"); - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_resumed", - Some("agent-a"), - )) - .expect("first PostToolUse should succeed"); - assert!(repo.adapter_state().attempts.is_empty()); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_resumed", - Some("agent-a"), - )) - .expect("resumed PreToolUse should succeed"); - let second_scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - assert_ne!( - first_scope_id, second_scope_id, - "AC15: a resumed subagent's new tool attempt must receive a fresh ScopeId" - ); - - fs::write(repo.root.join("file.txt"), "one\nfirst\nsecond\n") - .expect("second edit should write"); - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_resumed", - Some("agent-a"), - )) - .expect("second PostToolUse should succeed"); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &first_scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - assert_eq!( - scope_status(&db, &second_scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test9_main_and_subagent_concurrent_mutation_is_ai_contended() { - let repo = ClaudeRepo::new("test9-main-plus-subagent"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_main", - None, - )) - .expect("main-thread PreToolUse should succeed"); - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_sub", - Some("agent-a"), - )) - .expect("subagent PreToolUse should succeed"); - assert_eq!(repo.adapter_state().attempts.len(), 2); - - fs::write(repo.root.join("file.txt"), "one\nboth-writing\n") - .expect("the racing edit should write"); - - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_main", - None, - )) - .expect("main-thread PostToolUse should succeed"); - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_sub", - Some("agent-a"), - )) - .expect("subagent PostToolUse should succeed"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![("ai_contended".to_string(), None, "close".to_string())], - "AC11: main + subagent concurrent mutation must be AiContended" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test10_isolated_subagent_worktree_advances_only_its_own_cursor() { - let repo = ClaudeRepo::new("test10-isolated-worktree"); - let worktree_path = repo.add_worktree("subagent-worktree"); - let worktree_cwd = ClaudeRepo::cwd_at(&worktree_path); - - let main_worktree_id = repo.worktree_id(); - let sub_worktree_id = ClaudeRepo::worktree_id_at(&worktree_path); - assert_ne!( - main_worktree_id, sub_worktree_id, - "a linked worktree must resolve to a distinct WorktreeId" - ); - - repo.drive_flush() - .expect("main-checkout baseline flush should succeed"); - let main_cursor_before = worktree_row(&repo.db(), &main_worktree_id) - .map(|(_, cursor_tree, _)| cursor_tree) - .expect("main checkout should have a baseline worktree row"); - - repo.drive(&pre_tool_use_for( - &worktree_cwd, - "session-1", - "Write", - "toolu_sub", - Some("agent-a"), - )) - .expect("subagent PreToolUse in the isolated worktree should succeed"); - fs::write(worktree_path.join("file.txt"), "one\nisolated\n") - .expect("the isolated worktree's own edit should write"); - repo.drive(&post_tool_use_for( - &worktree_cwd, - "session-1", - "Write", - "toolu_sub", - Some("agent-a"), - )) - .expect("subagent PostToolUse in the isolated worktree should succeed"); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &main_worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(main_cursor_before), - "AC17: the main checkout's mutation cursor must be unchanged" - ); - let sub_row = worktree_row(&db, &sub_worktree_id).expect("subagent worktree row"); - assert_eq!( - sub_row.1, - ClaudeRepo::working_tree_at(&worktree_path), - "AC16/AC17: the isolated worktree's own cursor must advance" - ); - assert_eq!( - mutation_events_for(&db, &sub_worktree_id) - .into_iter() - .map(|(attribution, _, boundary)| (attribution, boundary)) - .collect::>(), - vec![("ai_exclusive".to_string(), "close".to_string())] - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test11_worktree_remove_cleans_only_that_worktrees_outstanding_attempt() { - let repo = ClaudeRepo::new("test11-worktree-remove"); - let worktree_path = repo.add_worktree("removed-worktree"); - let worktree_cwd = ClaudeRepo::cwd_at(&worktree_path); - let main_cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &main_cwd, - "session-1", - "Write", - "toolu_main", - None, - )) - .expect("main-thread PreToolUse should succeed"); - repo.drive(&pre_tool_use_for( - &worktree_cwd, - "session-1", - "Write", - "toolu_sub", - Some("agent-a"), - )) - .expect("subagent PreToolUse in the isolated worktree should succeed"); - - assert_eq!(ClaudeRepo::adapter_state_at(&repo.root).attempts.len(), 1); - assert_eq!( - ClaudeRepo::adapter_state_at(&worktree_path).attempts.len(), - 1 - ); - - assert_eq!( - repo.drive(&worktree_remove_json("session-1", &worktree_cwd)) - .expect("WorktreeRemove should succeed"), - "" - ); - - assert!( - ClaudeRepo::adapter_state_at(&worktree_path) - .attempts - .is_empty(), - "AC13/D22: WorktreeRemove must retire the outstanding attempt for that worktree" - ); - assert_eq!( - ClaudeRepo::adapter_state_at(&repo.root).attempts.len(), - 1, - "WorktreeRemove for one worktree must not touch the main checkout's attempts" - ); - - assert_raw_agent_trace_tables_untouched(&repo.db()); - } - - #[test] - fn test12_pending_start_crash_before_start_is_recovered_conservatively() { - let repo = ClaudeRepo::new("test12-pending-start-crash"); - let cwd = repo.cwd(); - let git_dir = repo.git_dir(); - - let key = AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_crashed".to_string(), - }; - let allocated = state::allocate_attempt(&git_dir, &key, "Write") - .expect("allocation should succeed"); - assert_eq!(allocated.attempt.phase, state::AttemptPhase::PendingStart); - - assert_eq!( - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_crashed", - None - )) - .expect("D11: PostToolUse on a pending_start attempt must abandon, not late-start"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "the never-started attempt must be retired" - ); - assert!(repo.adapter_state().recovery_pending); - let db = repo.db(); - assert_eq!( - scope_status(&db, &allocated.attempt.scope_id), - None, - "a Start that never committed must never appear as a real scope" - ); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_fresh", - None, - )) - .expect("the next PreToolUse should proceed after the quiescent flush"); - assert!(!repo.adapter_state().recovery_pending); - assert_eq!(repo.adapter_state().attempts.len(), 1); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test13_start_committed_before_state_settlement_is_recovered_by_abandonment() { - let repo = ClaudeRepo::new("test13-start-committed-crash"); - let cwd = repo.cwd(); - let git_dir = repo.git_dir(); - - let key = AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_crashed".to_string(), - }; - let allocated = state::allocate_attempt(&git_dir, &key, "Write") - .expect("allocation should succeed"); - let scope_id = allocated.attempt.scope_id.clone(); - - repo.drive_generic(&scope_boundary_payload( - "start", - &scope_id, - &claude_scope_start_event_id(&scope_id), - )) - .expect("the runtime Start should commit durably"); - assert_eq!( - state::read_state(&git_dir).unwrap().attempts[0].phase, - state::AttemptPhase::PendingStart - ); - - assert_eq!( - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_crashed", - None - )) - .expect("D11: a pending_start attempt with a committed Start must be abandoned"), - "" - ); - - assert!(repo.adapter_state().attempts.is_empty()); - assert!(repo.adapter_state().recovery_pending); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "abandoned".to_string())), - "the runtime's own committed Start must settle as a real abandonment" - ); - let worktree_id = repo.worktree_id(); - assert!(worktree_row(&db, &worktree_id) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test14_terminal_runtime_success_before_state_cleanup_is_replay_safe() { - let repo = ClaudeRepo::new("test14-close-committed-crash"); - let cwd = repo.cwd(); - let git_dir = repo.git_dir(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); - - repo.drive_generic(&scope_boundary_payload( - "close", - &scope_id, - &claude_scope_close_event_id(&scope_id), - )) - .expect("the runtime Close should commit durably"); - assert_eq!(state::read_state(&git_dir).unwrap().attempts.len(), 1); - - let db = repo.db(); - let (revision_before, events_before) = ( - worktree_row(&db, &repo.worktree_id()) - .map(|(revision, _, _)| revision) - .expect("a worktree row should exist"), - count(&db, "mutation_trace_events"), - ); - - assert_eq!( - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None - )) - .expect("a replayed Close against an already-durable commit must be safe"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "the stale bookkeeping must finally be cleared" - ); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), - Some(revision_before), - "a durably completed Close must never be re-applied as a second transition" - ); - assert_eq!(count(&db, "mutation_trace_events"), events_before); - assert_eq!( - scope_status(&db, &scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test15_explicit_background_bash_is_denied_with_no_scope() { - let repo = ClaudeRepo::new("test15-explicit-background-bash"); - let cwd = repo.cwd(); - - let output = repo - .drive(&background_pre_tool_use_for( - &cwd, - "session-1", - "toolu_1", - None, - )) - .expect("an explicit background shell must still return Ok with a deny payload"); - - assert_eq!( - output, - pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) - ); - assert!( - repo.adapter_state().attempts.is_empty(), - "AC21: an explicit background shell must create no scope" - ); - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test18_model_switch_does_not_rewrite_scope_provenance() { - let repo = ClaudeRepo::new("model-switch-provenance"); - let session_id = "session-model-switch"; - let db = repo.db(); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: "cc_session-model-switch".to_string(), - agent_id: String::new(), - model_id: "claude/sonnet".to_string(), - observation_kind: ObservationKind::SessionStart, - source: "test".to_string(), - observed_at_ms: 1, - }) - .expect("initial Claude model state should persist"); - - let pre_payload = - pre_tool_use_for(&repo.cwd(), session_id, "Bash", "toolu_model_switch", None); - repo.drive(&pre_payload) - .expect("initial PreToolUse should establish a scope"); - let scope_id = repo - .adapter_state() - .attempts - .first() - .expect("the scope should remain live") - .scope_id - .clone(); - let before = scope_provenance(&repo.db(), &scope_id); - assert_eq!( - before, - Some(( - "cc_session-model-switch".to_string(), - Some("claude/sonnet".to_string()), - )) - ); - - repo.db() - .upsert_claude_model_state(ClaudeModelStateObservation { - session_id: "cc_session-model-switch".to_string(), - agent_id: String::new(), - model_id: "claude/opus".to_string(), - observation_kind: ObservationKind::PostModelSwitch, - source: "test".to_string(), - observed_at_ms: 2, - }) - .expect("model switch should persist"); - repo.drive(&pre_payload) - .expect("replayed PreToolUse should remain idempotent"); - - assert_eq!(scope_provenance(&repo.db(), &scope_id), before); - } - - #[test] - fn test16_regression_matrix_leaves_raw_agent_trace_tables_untouched() { - let repo = ClaudeRepo::new("test16-raw-tables-untouched"); - let cwd = repo.cwd(); - - let before = { - let db = repo.db(); - ( - count(&db, "diff_traces"), - count(&db, "post_commit_patch_intersections"), - count(&db, "agent_traces"), - ) - }; - assert_eq!(before, (0, 0, 0)); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None, - )) - .expect("PostToolUse should succeed"); - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_2", - None, - )) - .expect("second PreToolUse should succeed"); - repo.drive(&permission_denied_for( - &cwd, - "session-1", - "Write", - "toolu_2", - None, - )) - .expect("PermissionDenied should succeed"); - - let db = repo.db(); - let after = ( - count(&db, "diff_traces"), - count(&db, "post_commit_patch_intersections"), - count(&db, "agent_traces"), - ); - assert_eq!( - after, - (0, 0, 0), - "AC20: Claude mutation-scope-only regressions must leave the raw \ - Agent Trace tables unchanged" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test17_detached_descendant_write_after_post_tool_use_is_not_folded_into_the_closed_scope( - ) { - let repo = ClaudeRepo::new("test17-detached-descendant"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Bash", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\nforeground-output\n") - .expect("the tool's own foreground write should write"); - let tree_at_close = repo.working_tree(); - - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Bash", - "toolu_1", - None, - )) - .expect("PostToolUse should succeed"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - scope_status(&db, &scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(tree_at_close.clone()), - "the scope must close at the tool's own observed tree" - ); - - fs::write( - repo.root.join("file.txt"), - "one\nforeground-output\ndetached-descendant\n", - ) - .expect("the detached descendant's later write should write"); - let tree_after_descendant = repo.working_tree(); - assert_ne!(tree_after_descendant, tree_at_close); - - let events_before_flush = mutation_events_for(&db, &worktree_id); - - repo.drive_flush() - .expect("a later recovery/diagnostic flush should succeed"); - - let db = repo.db(); - let events_after_flush = mutation_events_for(&db, &worktree_id); - assert_eq!( - events_after_flush.len(), - events_before_flush.len() + 1, - "the detached descendant's mutation must surface as its own event" - ); - let (attribution_kind, attribution_scope_id, _) = events_after_flush - .last() - .expect("a flush event should exist"); - assert_ne!( - attribution_scope_id.as_deref(), - Some(scope_id.as_str()), - "the detached descendant's mutation must never be attributed to the \ - already-closed tool scope" - ); - assert_eq!(attribution_kind, "ineligible_unscoped"); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(tree_after_descendant) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - } -} +mod tests; diff --git a/cli/src/services/hooks/claude_mutation_scope/tests.rs b/cli/src/services/hooks/claude_mutation_scope/tests.rs new file mode 100644 index 00000000..c2b407c9 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/tests.rs @@ -0,0 +1,3506 @@ +use super::*; + +fn pre_tool_use_json(overrides: &[(&str, Value)]) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_PRE_TOOL_USE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("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/repo/checkout".to_string()), + ); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("Write".to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String("toolu_1".to_string()), + ); + for (field, value) in overrides { + object.insert((*field).to_string(), value.clone()); + } + Value::Object(object).to_string() +} + +fn identity(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> AttemptKey { + AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + } +} + +#[test] +fn pre_tool_use_parses_required_and_optional_fields() { + let payload = pre_tool_use_json(&[ + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + (PROMPT_ID_FIELD, Value::String("prompt-1".to_string())), + ( + AGENT_TYPE_FIELD, + Value::String("general-purpose".to_string()), + ), + ]); + + let event = parse_claude_hook_event(&payload).expect("valid PreToolUse parses"); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert_eq!(execution.identity.session_id, "session-1"); + assert_eq!(execution.identity.cwd, "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/repo/checkout"); + assert_eq!(execution.identity.tool_name, "Write"); + assert_eq!(execution.identity.tool_use_id, "toolu_1"); + assert_eq!(execution.identity.agent_id.as_deref(), Some("agent-1")); + assert_eq!(execution.prompt_id.as_deref(), Some("prompt-1")); + assert_eq!(execution.agent_type.as_deref(), Some("general-purpose")); + assert!(!execution.run_in_background); +} + +#[test] +fn pre_tool_use_agent_id_absent_means_main_thread() { + let payload = pre_tool_use_json(&[]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert_eq!(execution.identity.agent_id, None); + assert!(!execution.identity.is_subagent()); +} + +#[test] +fn pre_tool_use_agent_id_present_means_subagent() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert!(execution.identity.is_subagent()); +} + +#[test] +fn pre_tool_use_prompt_id_and_agent_type_are_optional() { + let payload = pre_tool_use_json(&[]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert_eq!(execution.prompt_id, None); + assert_eq!(execution.agent_type, None); +} + +#[test] +fn missing_required_fields_are_rejected_without_fabricating_identity() { + for field in [ + SESSION_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + TOOL_USE_ID_FIELD, + ] { + let mut object: serde_json::Map = + serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); + object.remove(field); + let payload = Value::Object(object).to_string(); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!( + error.to_string().contains(&format!("'{field}'")), + "expected missing-field error to name '{field}', got: {error}" + ); + } +} + +#[test] +fn wrong_type_required_field_is_rejected() { + let payload = pre_tool_use_json(&[(SESSION_ID_FIELD, Value::from(42))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("session_id")); +} + +#[test] +fn empty_string_required_field_is_rejected() { + let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String(String::new()))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("non-blank")); +} + +#[test] +fn wrong_type_optional_field_is_rejected() { + let payload = pre_tool_use_json(&[(PROMPT_ID_FIELD, Value::from(1))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("prompt_id")); +} + +#[test] +fn empty_optional_field_is_rejected() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String(String::new()))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("agent_id")); +} + +#[test] +fn null_optional_field_is_none() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Null)]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(execution.identity.agent_id, None); +} + +#[test] +fn empty_payload_is_rejected() { + let error = parse_claude_hook_event("").unwrap_err(); + assert!(error.to_string().contains("empty payload")); + + let error = parse_claude_hook_event(" ").unwrap_err(); + assert!(error.to_string().contains("empty payload")); +} + +#[test] +fn malformed_json_is_rejected() { + let error = parse_claude_hook_event("{not json").unwrap_err(); + assert!(error.to_string().contains("valid JSON")); +} + +#[test] +fn non_object_json_is_rejected() { + let error = parse_claude_hook_event("[1, 2, 3]").unwrap_err(); + assert!(error.to_string().contains("JSON object")); +} + +#[test] +fn unsupported_hook_event_name_is_rejected() { + let payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String("PostToolBatch".to_string()), + )]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("unsupported hook_event_name")); +} + +#[test] +fn post_tool_use_and_failure_and_permission_denied_share_tool_identity_shape() { + for event_name in [ + HOOK_EVENT_POST_TOOL_USE, + HOOK_EVENT_POST_TOOL_USE_FAILURE, + HOOK_EVENT_PERMISSION_DENIED, + ] { + let payload = + pre_tool_use_json(&[(HOOK_EVENT_NAME_FIELD, Value::String(event_name.to_string()))]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let identity = match event { + ClaudeHookEvent::PostToolUse(identity) + | ClaudeHookEvent::PostToolUseFailure(identity) + | ClaudeHookEvent::PermissionDenied(identity) => identity, + other => panic!("expected a tool-identity event, got {other:?}"), + }; + assert_eq!(identity.session_id, "session-1"); + assert_eq!(identity.tool_use_id, "toolu_1"); + } +} + +#[test] +fn session_scoped_lifecycle_events_parse_session_identity() { + for event_name in [ + HOOK_EVENT_STOP, + HOOK_EVENT_STOP_FAILURE, + HOOK_EVENT_USER_PROMPT_SUBMIT, + HOOK_EVENT_SESSION_END, + ] { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let identity = match event { + ClaudeHookEvent::Stop(identity) + | ClaudeHookEvent::StopFailure(identity) + | ClaudeHookEvent::UserPromptSubmit(identity) + | ClaudeHookEvent::SessionEnd(identity) => identity, + other => panic!("expected a session-identity event, got {other:?}"), + }; + assert_eq!(identity.session_id, "session-1"); + assert_eq!(identity.cwd, "/repo"); + } +} + +#[test] +fn subagent_stop_requires_agent_id() { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + let payload = Value::Object(object).to_string(); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("agent_id")); +} + +#[test] +fn subagent_stop_parses_agent_identity() { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String("agent-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::SubagentStop(identity) = event else { + panic!("expected SubagentStop"); + }; + assert_eq!(identity.agent_id, "agent-1"); +} + +#[test] +fn worktree_remove_requires_worktree_path_not_cwd() { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + WORKTREE_PATH_FIELD.to_string(), + Value::String("/repo/.claude/worktrees/agent-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::WorktreeRemove(worktree_remove) = event else { + panic!("expected WorktreeRemove"); + }; + assert_eq!(worktree_remove.session_id, "session-1"); + assert_eq!( + worktree_remove.worktree_path, + "/repo/.claude/worktrees/agent-1" + ); +} + +#[test] +fn session_start_and_subagent_start_establish_no_scope_payload() { + for event_name in [HOOK_EVENT_SESSION_START, HOOK_EVENT_SUBAGENT_START] { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + assert!(matches!( + event, + ClaudeHookEvent::SessionStart | ClaudeHookEvent::SubagentStart + )); + } +} + +#[test] +fn run_in_background_true_is_parsed() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": true }), + )]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(execution.run_in_background); +} + +#[test] +fn run_in_background_false_is_parsed() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": false }), + )]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(!execution.run_in_background); +} + +#[test] +fn run_in_background_absent_defaults_to_false() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "command": "echo hi" }), + )]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(!execution.run_in_background); +} + +#[test] +fn tool_input_absent_defaults_run_in_background_to_false() { + let mut object: serde_json::Map = + serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); + object.remove(TOOL_INPUT_FIELD); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(!execution.run_in_background); +} + +#[test] +fn run_in_background_wrong_type_is_rejected() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": "yes" }), + )]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("run_in_background")); +} + +#[test] +fn tool_input_wrong_type_is_rejected() { + let payload = pre_tool_use_json(&[(TOOL_INPUT_FIELD, Value::String("nope".to_string()))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("tool_input")); +} + +#[test] +fn known_mutation_capable_tools_are_classified_mutation_capable() { + for tool_name in [ + "Bash", + "PowerShell", + "Write", + "Edit", + "NotebookEdit", + "MultiEdit", + ] { + assert_eq!( + classify_tool(tool_name), + ToolClassification::MutationCapable, + "expected {tool_name} to be MutationCapable" + ); + } +} + +#[test] +fn known_read_only_tools_are_classified_read_only() { + for tool_name in [ + "Read", + "Glob", + "Grep", + "WebFetch", + "WebSearch", + "AskUserQuestion", + ] { + assert_eq!( + classify_tool(tool_name), + ToolClassification::ReadOnly, + "expected {tool_name} to be ReadOnly" + ); + } +} + +#[test] +fn agent_is_classified_delegation() { + assert_eq!(classify_tool("Agent"), ToolClassification::Delegation); +} + +#[test] +fn mcp_tools_are_classified_mutation_capable() { + assert_eq!( + classify_tool("mcp__claude-in-chrome__navigate"), + ToolClassification::MutationCapable + ); +} + +#[test] +fn unknown_tool_names_are_conservatively_mutation_capable() { + assert_eq!( + classify_tool("SomeBrandNewTool"), + ToolClassification::MutationCapable + ); +} + +const PROBE14_BASH_RUN_IN_BACKGROUND_TRUE: &str = + include_str!("fixtures/probe14-run-in-background-true.pre_tool_use.json"); +const PROBE15_BASH_RUN_IN_BACKGROUND_FALSE: &str = + include_str!("fixtures/probe15-run-in-background-false-hard-gate.pre_tool_use.json"); + +fn parsed_pre_tool_use(payload: &str) -> ClaudeToolExecution { + let event = parse_claude_hook_event(payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + execution +} + +#[test] +fn real_bash_run_in_background_true_fixture_is_explicit_background_shell() { + let execution = parsed_pre_tool_use(PROBE14_BASH_RUN_IN_BACKGROUND_TRUE); + + assert_eq!(execution.identity.tool_name, "Bash"); + assert!(execution.run_in_background); + assert!(is_explicit_background_shell( + &execution.identity.tool_name, + execution.run_in_background + )); +} + +#[test] +fn real_bash_run_in_background_false_fixture_is_not_explicit_background_shell() { + let execution = parsed_pre_tool_use(PROBE15_BASH_RUN_IN_BACKGROUND_FALSE); + + assert_eq!(execution.identity.tool_name, "Bash"); + assert!(!execution.run_in_background); + assert!(!is_explicit_background_shell( + &execution.identity.tool_name, + execution.run_in_background + )); +} + +#[test] +fn powershell_with_run_in_background_true_is_explicit_background_shell() { + assert!(is_explicit_background_shell("PowerShell", true)); +} + +#[test] +fn powershell_with_run_in_background_false_is_not_explicit_background_shell() { + assert!(!is_explicit_background_shell("PowerShell", false)); +} + +#[test] +fn write_with_run_in_background_true_is_not_explicit_background_shell() { + assert!(!is_explicit_background_shell("Write", true)); +} + +#[test] +fn same_attempt_seq_and_key_is_deterministic() { + let key = identity("session-1", Some("agent-1"), "toolu_1"); + + let first = format_claude_scope_id(3, &key); + let second = format_claude_scope_id(3, &key); + + assert_eq!( + first, second, + "AC4: duplicate delivery must reuse the same ScopeId" + ); + assert_eq!( + claude_scope_start_event_id(&first), + claude_scope_start_event_id(&second) + ); +} + +#[test] +fn fresh_attempt_seq_yields_a_new_scope_id() { + let key = identity("session-1", None, "toolu_1"); + + let first = format_claude_scope_id(1, &key); + let second = format_claude_scope_id(2, &key); + + assert_ne!( + first, second, + "AC5: a fresh attempt_seq for the same tool_use_id must get a new ScopeId" + ); +} + +#[test] +fn main_and_distinct_agents_produce_distinct_scope_ids() { + let main = identity("session-1", None, "toolu_1"); + let agent_a = identity("session-1", Some("A"), "toolu_1"); + let agent_b = identity("session-1", Some("B"), "toolu_1"); + + let main_scope = format_claude_scope_id(1, &main); + let scope_for_a = format_claude_scope_id(1, &agent_a); + let scope_for_b = format_claude_scope_id(1, &agent_b); + + assert_ne!( + main_scope, scope_for_a, + "AC6: main vs agent_id=A must differ" + ); + assert_ne!( + main_scope, scope_for_b, + "AC6: main vs agent_id=B must differ" + ); + assert_ne!( + scope_for_a, scope_for_b, + "AC6: agent_id=A vs agent_id=B must differ" + ); +} + +#[test] +fn event_id_derivation_is_a_pure_function_of_scope_id() { + let scope_id = format_claude_scope_id(7, &identity("session-1", None, "toolu_1")); + + assert_eq!( + claude_scope_start_event_id(&scope_id), + format!("{scope_id}|start") + ); + assert_eq!( + claude_scope_close_event_id(&scope_id), + format!("{scope_id}|close") + ); + assert_ne!( + claude_scope_start_event_id(&scope_id), + claude_scope_close_event_id(&scope_id) + ); +} + +#[test] +fn length_prefixing_disambiguates_delimiter_characters_inside_fields() { + let tricky = identity( + "sess|a=0:x|t=1:y", + Some("agent|with|pipes"), + "tool:with:colons", + ); + + let scope_id = format_claude_scope_id(1, &tricky); + + let agent_id = tricky.agent_id.as_deref().unwrap(); + let expected = format!( + "cc-tool-v1|n=1|s={}:{}|a={}:{}|t={}:{}", + tricky.session_id.len(), + tricky.session_id, + agent_id.len(), + agent_id, + tricky.tool_use_id.len(), + tricky.tool_use_id, + ); + + assert_eq!(scope_id, expected); +} + +#[test] +fn attempt_key_projects_only_the_execution_key_fields() { + let identity_a = ClaudeToolIdentity { + session_id: "session-1".to_string(), + cwd: "/repo".to_string(), + agent_id: Some("agent-1".to_string()), + tool_name: "Write".to_string(), + tool_use_id: "toolu_1".to_string(), + }; + let identity_b = ClaudeToolIdentity { + tool_name: "Bash".to_string(), + cwd: "/other".to_string(), + ..identity_a.clone() + }; + + assert_eq!( + identity_a.attempt_key(), + identity_b.attempt_key(), + "attempt_key must depend only on (session_id, agent_id, tool_use_id)" + ); +} + +mod driver { + use std::cell::{Cell, RefCell}; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Mutex}; + + use anyhow::anyhow; + + use super::*; + + static NEXT_TEST_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_test_git_dir(label: &str) -> PathBuf { + let id = NEXT_TEST_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-claude-mutation-scope-driver-{label}-{}-{id}", + std::process::id() + )) + } + + fn remove_test_git_dir(git_dir: &Path) { + let _ = std::fs::remove_dir_all(git_dir); + } + + #[allow(clippy::unnecessary_wraps)] + fn ok_seam(_root: &Path, _payload: &str, _logger: Option<&dyn Logger>) -> Result { + Ok(String::new()) + } + + fn unreachable_seam( + _root: &Path, + payload: &str, + _logger: Option<&dyn Logger>, + ) -> Result { + panic!("the ingress seam must not be called for this payload: {payload}"); + } + + fn seam_failing_on( + operation: &'static str, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + seam_failing_on_any(vec![operation]) + } + + fn seam_failing_on_any( + operations: Vec<&'static str>, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + move |_root, payload, _logger| { + if operations + .iter() + .any(|operation| payload.contains(&format!(r#""operation":"{operation}""#))) + { + Err(anyhow!( + "seam failure injected by test for one of {operations:?}" + )) + } else { + Ok(String::new()) + } + } + } + + fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result { + move |_cwd| Ok(git_dir.clone()) + } + + fn start_with_model_resolver( + payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, + ) -> Result { + run_claude_mutation_scope_from_payload_with_resolver( + payload, + logger, + resolve_git_dir, + model_state_resolver, + seam, + ) + } + + #[derive(Clone, Default)] + struct RecordingLogger { + warnings: Arc>>, + } + + impl RecordingLogger { + fn warnings(&self) -> Vec<(String, String)> { + self.warnings + .lock() + .expect("recording logger mutex must not be poisoned") + .clone() + } + } + + impl Logger for RecordingLogger { + fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn warn(&self, event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { + self.warnings + .lock() + .expect("recording logger mutex must not be poisoned") + .push((event_id.to_string(), message.to_string())); + } + + fn error(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn log_cli_error(&self, _: &crate::services::error::CliError, _: Option<&str>) {} + } + + fn session_scoped_payload(event_name: &str, session_id: &str, cwd: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + Value::Object(object).to_string() + } + + #[test] + fn pre_tool_use_resolves_main_and_subagent_model_state_exactly_at_admission() { + let git_dir = unique_test_git_dir("model-state-admission"); + let git_dir_resolver = fixed_resolver(git_dir.clone()); + let resolver_calls: RefCell> = RefCell::new(Vec::new()); + let model_state_resolver = |_: &Path, session_id: &str, agent_id: &str| { + resolver_calls + .borrow_mut() + .push((session_id.to_string(), agent_id.to_string())); + Ok(Some(if agent_id.is_empty() { + "claude/sonnet".to_string() + } else { + "claude/opus".to_string() + })) + }; + let starts: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + let value: Value = serde_json::from_str(payload).expect("payload is JSON"); + if value.get("operation") == Some(&Value::String("start".to_string())) { + starts.borrow_mut().push(value); + } + Ok(String::new()) + }; + + let main_payload = pre_tool_use_json(&[]); + let subagent_payload = pre_tool_use_json(&[ + ( + TOOL_USE_ID_FIELD, + Value::String("toolu_subagent".to_string()), + ), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + start_with_model_resolver( + &main_payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("main-agent Start should succeed"); + start_with_model_resolver( + &subagent_payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("subagent Start should succeed"); + + assert_eq!( + resolver_calls.into_inner(), + vec![ + ("cc_session-1".to_string(), String::new()), + ("cc_session-1".to_string(), "agent-1".to_string()), + ] + ); + let starts = starts.into_inner(); + assert_eq!(starts.len(), 2); + assert_eq!( + starts[0]["provenance"], + json!({"session_id": "cc_session-1", "model_id": "claude/sonnet"}) + ); + assert_eq!( + starts[1]["provenance"], + json!({"session_id": "cc_session-1", "model_id": "claude/opus"}) + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn subagent_without_exact_model_state_does_not_inherit_main_model() { + let git_dir = unique_test_git_dir("subagent-model-state-missing"); + let git_dir_resolver = fixed_resolver(git_dir.clone()); + let starts: RefCell> = RefCell::new(Vec::new()); + let model_state_resolver = |_: &Path, _: &str, agent_id: &str| { + Ok(if agent_id.is_empty() { + Some("claude/sonnet".to_string()) + } else { + None + }) + }; + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + starts + .borrow_mut() + .push(serde_json::from_str(payload).expect("payload is JSON")); + Ok(String::new()) + }; + + for (tool_use_id, agent_id) in [("toolu_main", None), ("toolu_subagent", Some("agent-1"))] { + let overrides = agent_id + .map(|agent_id| vec![(AGENT_ID_FIELD, Value::String(agent_id.to_string()))]) + .unwrap_or_default(); + let mut overrides = overrides; + overrides.push((TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string()))); + let payload = pre_tool_use_json(&overrides); + start_with_model_resolver( + &payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("both Starts should succeed"); + } + + let starts = starts.into_inner(); + assert_eq!(starts.len(), 2); + assert_eq!(starts[0]["provenance"]["model_id"], "claude/sonnet"); + assert_eq!(starts[1]["provenance"]["session_id"], "cc_session-1"); + assert_eq!(starts[1]["provenance"]["model_id"], Value::Null); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn missing_or_failed_model_resolution_keeps_session_provenance_and_allows_start() { + let git_dir = unique_test_git_dir("model-state-unavailable"); + let git_dir_resolver = fixed_resolver(git_dir.clone()); + let starts: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + starts + .borrow_mut() + .push(serde_json::from_str(payload).expect("payload is JSON")); + Ok(String::new()) + }; + let response_index = Cell::new(0); + let model_state_resolver = |_: &Path, _: &str, _: &str| -> Result> { + let index = response_index.get(); + response_index.set(index + 1); + if index == 0 { + Ok(None) + } else { + Err(anyhow!("local model-state DB is unavailable")) + } + }; + + for tool_use_id in ["toolu_missing", "toolu_failed"] { + let payload = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string()))]); + let output = start_with_model_resolver( + &payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("model unavailability must not fail Start"); + assert_eq!(output, ""); + } + + let starts = starts.into_inner(); + assert_eq!(starts.len(), 2); + for start in starts { + assert_eq!( + start["provenance"], + json!({"session_id": "cc_session-1", "model_id": null}) + ); + } + + remove_test_git_dir(&git_dir); + } + + #[test] + fn read_only_tool_creates_no_scope_and_never_touches_the_seam_or_git_dir() { + let resolver = |_: &str| -> Result { + panic!("a read-only tool must never resolve a git dir") + }; + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Read".to_string()))]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("read-only PreToolUse should succeed"); + + assert_eq!(output, ""); + } + + #[test] + fn delegation_tool_creates_no_scope_ac3() { + let resolver = |_: &str| -> Result { + panic!("Agent delegation must never resolve a git dir") + }; + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Agent".to_string()))]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("Agent delegation PreToolUse should succeed"); + + assert_eq!(output, ""); + } + + #[test] + fn session_start_and_subagent_start_establish_no_scope_ac3() { + let resolver = |_: &str| -> Result { + panic!("a lifecycle-only event must never resolve a git dir") + }; + + for event_name in [HOOK_EVENT_SESSION_START, HOOK_EVENT_SUBAGENT_START] { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("a lifecycle-only event should succeed with no scope"); + assert_eq!(output, ""); + } + } + + #[test] + fn explicit_background_bash_is_denied_with_the_exact_reason_d20() { + let git_dir = unique_test_git_dir("explicit-background-bash"); + let resolver = fixed_resolver(git_dir.clone()); + let payload = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("Bash".to_string())), + ( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": true }), + ), + ]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("an explicit background shell should still return Ok with a deny payload"); + + assert_eq!( + output, + pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) + ); + assert!( + !git_dir.exists(), + "D20: denial must precede any adapter-state I/O" + ); + } + + #[test] + fn explicit_background_powershell_is_denied_ac21() { + let git_dir = unique_test_git_dir("explicit-background-powershell"); + let resolver = fixed_resolver(git_dir.clone()); + let payload = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("PowerShell".to_string())), + ( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": true }), + ), + ]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("explicit background PowerShell should be denied"); + + assert_eq!( + output, + pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) + ); + } + + #[test] + fn write_ahead_pending_start_persists_before_the_seam_start_call_ac7() { + let git_dir = unique_test_git_dir("write-ahead"); + let resolver = fixed_resolver(git_dir.clone()); + let git_dir_for_seam = git_dir.clone(); + let phase_seen_before_start: RefCell> = RefCell::new(None); + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"start""#) { + observed_roots.borrow_mut().push(root.to_path_buf()); + let observed = state::read_state(&git_dir_for_seam) + .expect("state should be readable under git_dir inside the seam call"); + *phase_seen_before_start.borrow_mut() = + observed.attempts.first().map(|attempt| attempt.phase); + } + Ok(String::new()) + }; + + let payload = pre_tool_use_json(&[]); + let output = run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("mutation-capable PreToolUse should succeed"); + + assert_eq!(output, ""); + assert_eq!( + phase_seen_before_start.into_inner(), + Some(state::AttemptPhase::PendingStart), + "AC7: the attempt must be durably pending_start (under git_dir) before the seam Start call" + ); + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from("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/repo/checkout")], + "the seam must receive the raw Claude cwd as repository_root, never the resolved git_dir" + ); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!( + final_state.attempts[0].phase, + state::AttemptPhase::Active, + "phase must become active after a successful Start" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn linked_worktree_cwd_and_git_dir_are_never_conflated_for_pre_tool_use_start() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-start"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, _payload: &str, _logger: Option<&dyn Logger>| -> Result { + observed_roots.borrow_mut().push(root.to_path_buf()); + Ok(String::new()) + }; + + let payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("PreToolUse Start should succeed"); + + assert_ne!( + PathBuf::from(raw_cwd), + git_dir, + "test sanity: the raw checkout path and the resolved git_dir must be deliberately distinct" + ); + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "the ingress seam must receive the raw Claude cwd, never git_dir" + ); + + let state = state::read_state(&git_dir).expect("state should be readable under git_dir"); + assert_eq!( + state.attempts.len(), + 1, + "adapter bookkeeping must be written under the resolved git_dir" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_close_uses_git_dir_for_state_and_raw_cwd_for_the_seam() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-close"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, _payload: &str, _logger: Option<&dyn Logger>| -> Result { + observed_roots.borrow_mut().push(root.to_path_buf()); + Ok(String::new()) + }; + let post_payload = pre_tool_use_json(&[ + (CWD_FIELD, Value::String(raw_cwd.to_string())), + ( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + ), + ]); + run_claude_mutation_scope_from_payload_with(&post_payload, None, &resolver, &seam) + .expect("PostToolUse Close should succeed"); + + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "Close must invoke the seam with the raw Claude cwd, never git_dir" + ); + + let state = state::read_state(&git_dir).expect("state should be readable under git_dir"); + assert!( + state.attempts.is_empty(), + "the closed attempt must be removed from git_dir bookkeeping" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn abandon_via_permission_denied_uses_git_dir_for_state_and_raw_cwd_for_the_seam() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-abandon"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"abandon""#) { + observed_roots.borrow_mut().push(root.to_path_buf()); + } + Ok(String::new()) + }; + let denied_payload = pre_tool_use_json(&[ + (CWD_FIELD, Value::String(raw_cwd.to_string())), + ( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_PERMISSION_DENIED.to_string()), + ), + ]); + run_claude_mutation_scope_from_payload_with(&denied_payload, None, &resolver, &seam) + .expect("PermissionDenied should succeed"); + + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "Abandon must invoke the seam with the raw Claude cwd, never git_dir" + ); + + let state = state::read_state(&git_dir).expect("state should be readable under git_dir"); + assert!(state.attempts.is_empty()); + assert!(state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flush_uses_raw_cwd_for_the_seam_and_git_dir_for_state() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-flush"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seed allocation should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seed attempt should succeed"); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"flush""#) { + observed_roots.borrow_mut().push(root.to_path_buf()); + } + Ok(String::new()) + }; + + let new_pre = pre_tool_use_json(&[ + (CWD_FIELD, Value::String(raw_cwd.to_string())), + (TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) + .expect("quiescent recovery should flush against the raw checkout path"); + + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "flush must run against the raw Claude cwd, not git_dir" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn duplicate_live_pre_tool_use_reuses_the_same_scope_and_start_event_id_ac4() { + let git_dir = unique_test_git_dir("duplicate-delivery"); + let resolver = fixed_resolver(git_dir.clone()); + let start_event_ids: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"start""#) { + let value: Value = serde_json::from_str(payload).unwrap(); + start_event_ids + .borrow_mut() + .push(value["event_id"].as_str().unwrap().to_string()); + } + Ok(String::new()) + }; + + let payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("first delivery should succeed"); + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("duplicate delivery should succeed"); + + let ids = start_event_ids.into_inner(); + assert_eq!(ids.len(), 2); + assert_eq!( + ids[0], ids[1], + "AC4: duplicate delivery must reuse the same Start EventId" + ); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "duplicate delivery must not create a second bookkeeping entry" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn seam_start_failure_denies_and_leaves_the_attempt_pending_start_d8_d11() { + let git_dir = unique_test_git_dir("start-failure"); + let resolver = fixed_resolver(git_dir.clone()); + let seam = seam_failing_on("start"); + + let payload = pre_tool_use_json(&[]); + let output = run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("a Start failure must still return Ok with a deny payload, not propagate"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!( + final_state.attempts[0].phase, + state::AttemptPhase::PendingStart, + "D11: a failed Start must not be marked active nor removed" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn pending_start_attempt_is_abandoned_not_late_started_on_a_terminal_signal_d11() { + let git_dir = unique_test_git_dir("pending-start-then-terminal"); + let resolver = fixed_resolver(git_dir.clone()); + + let start_failing_seam = seam_failing_on("start"); + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with( + &pre_payload, + None, + &resolver, + &start_failing_seam, + ) + .expect("the failed Start must still return Ok with a deny payload"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let recording = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let post_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let output = + run_claude_mutation_scope_from_payload_with(&post_payload, None, &resolver, &recording) + .expect("PostToolUse for a pending_start attempt should succeed"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!(operations.len(), 1); + assert!( + operations[0].contains(r#""operation":"abandon""#), + "D11: a pending_start attempt must be abandoned, not late-started, got: {operations:?}" + ); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn close_seam_failure_is_retired_through_abandonment_not_a_replayed_close_d12() { + let git_dir = unique_test_git_dir("close-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let close_failing_seam = seam_failing_on("close"); + let post_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let output = run_claude_mutation_scope_from_payload_with( + &post_payload, + None, + &resolver, + &close_failing_seam, + ) + .expect("a Close failure must still succeed via abandonment"); + + assert_eq!(output, ""); + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!( + final_state.attempts.is_empty(), + "D12: after abandonment the attempt must be retired" + ); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_failure_also_closes_the_scope_d10() { + let git_dir = unique_test_git_dir("post-tool-use-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let recording = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let failure_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE_FAILURE.to_string()), + )]); + let output = run_claude_mutation_scope_from_payload_with( + &failure_payload, + None, + &resolver, + &recording, + ) + .expect("PostToolUseFailure should close the scope"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!(operations.len(), 1); + assert!(operations[0].contains(r#""operation":"close""#)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_with_no_live_attempt_is_a_safe_no_op_d9() { + let payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let resolver = fixed_resolver(std::env::temp_dir().join("sce-unused-nonexistent-git-dir")); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("a PostToolUse with no live attempt must be a safe no-op"); + + assert_eq!(output, ""); + } + + #[test] + fn permission_denied_abandons_a_live_attempt_d13() { + let git_dir = unique_test_git_dir("permission-denied"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let recording = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let denied_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_PERMISSION_DENIED.to_string()), + )]); + let output = run_claude_mutation_scope_from_payload_with( + &denied_payload, + None, + &resolver, + &recording, + ) + .expect("PermissionDenied should succeed"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!(operations.len(), 1); + assert!(operations[0].contains(r#""operation":"abandon""#)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn stop_abandons_only_stale_main_thread_attempts_d14() { + let git_dir = unique_test_git_dir("stop-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + let subagent_pre = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("subagent PreToolUse should succeed"); + + let stop_payload = session_scoped_payload(HOOK_EVENT_STOP, "session-1", "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/repo/checkout"); + let output = + run_claude_mutation_scope_from_payload_with(&stop_payload, None, &resolver, &ok_seam) + .expect("Stop cleanup should succeed"); + assert_eq!(output, ""); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "only the subagent attempt should remain" + ); + assert_eq!(final_state.attempts[0].tool_use_id, "toolu_agent"); + assert!( + final_state.recovery_pending, + "abandoning the stale main attempt must arm the barrier" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn stop_failure_abandons_stale_main_thread_attempts_the_same_way_d15() { + let git_dir = unique_test_git_dir("stop-failure-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + + let stop_failure_payload = + session_scoped_payload(HOOK_EVENT_STOP_FAILURE, "session-1", "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/repo/checkout"); + run_claude_mutation_scope_from_payload_with( + &stop_failure_payload, + None, + &resolver, + &ok_seam, + ) + .expect("StopFailure cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn user_prompt_submit_abandons_only_stale_main_thread_attempts_d16() { + let git_dir = unique_test_git_dir("user-prompt-submit-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + let subagent_pre = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("subagent PreToolUse should succeed"); + + let prompt_payload = + session_scoped_payload(HOOK_EVENT_USER_PROMPT_SUBMIT, "session-1", "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/repo/checkout"); + run_claude_mutation_scope_from_payload_with(&prompt_payload, None, &resolver, &ok_seam) + .expect("UserPromptSubmit cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "the subagent attempt must survive" + ); + assert_eq!(final_state.attempts[0].tool_use_id, "toolu_agent"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn subagent_stop_abandons_only_the_matching_agent_id_attempts_d17() { + let git_dir = unique_test_git_dir("subagent-stop-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let first_agent_payload = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_a".to_string())), + (AGENT_ID_FIELD, Value::String("agent-a".to_string())), + ]); + let second_agent_payload = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_b".to_string())), + (AGENT_ID_FIELD, Value::String("agent-b".to_string())), + ]); + run_claude_mutation_scope_from_payload_with( + &first_agent_payload, + None, + &resolver, + &ok_seam, + ) + .expect("agent-a PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with( + &second_agent_payload, + None, + &resolver, + &ok_seam, + ) + .expect("agent-b PreToolUse should succeed"); + + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("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/repo/checkout".to_string()), + ); + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String("agent-a".to_string()), + ); + let subagent_stop_payload = Value::Object(object).to_string(); + + run_claude_mutation_scope_from_payload_with( + &subagent_stop_payload, + None, + &resolver, + &ok_seam, + ) + .expect("SubagentStop cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "toolu_b"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn session_end_abandons_every_attempt_regardless_of_agent_id_d18() { + let git_dir = unique_test_git_dir("session-end-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + let subagent_pre = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("subagent PreToolUse should succeed"); + + let session_end_payload = + session_scoped_payload(HOOK_EVENT_SESSION_END, "session-1", "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/repo/checkout"); + run_claude_mutation_scope_from_payload_with( + &session_end_payload, + None, + &resolver, + &ok_seam, + ) + .expect("SessionEnd cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn worktree_remove_resolves_git_dir_from_worktree_path_not_cwd_d22() { + let main_git_dir = unique_test_git_dir("worktree-remove-main"); + let worktree_git_dir = unique_test_git_dir("worktree-remove-isolated"); + let main_git_dir_for_resolver = main_git_dir.clone(); + let worktree_git_dir_for_resolver = worktree_git_dir.clone(); + + let resolver = move |cwd: &str| -> Result { + if cwd == "/repo/.claude/worktrees/agent-1" { + Ok(worktree_git_dir_for_resolver.clone()) + } else { + Ok(main_git_dir_for_resolver.clone()) + } + }; + + let subagent_pre = pre_tool_use_json(&[ + ( + CWD_FIELD, + Value::String("/repo/.claude/worktrees/agent-1".to_string()), + ), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("isolated-worktree PreToolUse should succeed"); + + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + WORKTREE_PATH_FIELD.to_string(), + Value::String("/repo/.claude/worktrees/agent-1".to_string()), + ); + let worktree_remove_payload = Value::Object(object).to_string(); + + run_claude_mutation_scope_from_payload_with( + &worktree_remove_payload, + None, + &resolver, + &ok_seam, + ) + .expect("WorktreeRemove cleanup should succeed"); + + let worktree_state = + state::read_state(&worktree_git_dir).expect("worktree state should be readable"); + assert!( + worktree_state.attempts.is_empty(), + "D22: WorktreeRemove must retire attempts under the worktree_path's git dir" + ); + + remove_test_git_dir(&main_git_dir); + remove_test_git_dir(&worktree_git_dir); + } + + #[test] + fn recovery_barrier_denies_new_mutation_capable_pre_tool_use_while_attempts_remain_d19() { + let git_dir = unique_test_git_dir("barrier-attempts-remain"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + let surviving = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_surviving".to_string(), + }, + "Write", + ) + .expect("seeding a surviving attempt should succeed"); + let retiring = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_retiring".to_string(), + }, + "Write", + ) + .expect("seeding a retiring attempt should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &retiring.attempt.scope_id) + .expect("removing the retiring attempt should succeed"); + let _ = surviving; + + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with( + &new_pre, + None, + &resolver, + &unreachable_seam, + ) + .expect("D19: barrier denial must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flushes_once_quiescent_and_clears_before_starting_d19() { + let git_dir = unique_test_git_dir("barrier-flush-success"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seeding the retired attempt should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seeded attempt should succeed"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) + .expect("D19: a quiescent recovery should flush then proceed"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!( + operations.len(), + 2, + "expected flush then start, got: {operations:?}" + ); + assert!(operations[0].contains(r#""operation":"flush""#)); + assert!(operations[1].contains(r#""operation":"start""#)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!( + !final_state.recovery_pending, + "a successful flush must clear the barrier" + ); + assert_eq!(final_state.attempts.len(), 1); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_stays_fail_closed_when_flush_fails_d19() { + let git_dir = unique_test_git_dir("barrier-flush-failure"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seeding the retired attempt should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seeded attempt should succeed"); + + let seam = seam_failing_on("flush"); + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) + .expect("D19: a failed flush must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!( + final_state.recovery_pending, + "a failed flush must keep the barrier armed" + ); + assert!( + final_state.attempts.is_empty(), + "a denied PreToolUse must not allocate a new attempt" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_close_and_failed_abandon_keep_recovery_armed_and_the_attempt_tracked_d12_d19() { + let git_dir = unique_test_git_dir("close-and-abandon-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let failing_seam = seam_failing_on_any(vec!["close", "abandon"]); + let post_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let error = run_claude_mutation_scope_from_payload_with( + &post_payload, + None, + &resolver, + &failing_seam, + ) + .expect_err( + "a failed Close followed by a failed Abandon must propagate, not silently succeed", + ); + assert!(error.to_string().contains("abandon")); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "D12: an attempt whose abandonment failed must remain tracked" + ); + assert!( + final_state.recovery_pending, + "D19: recovery must be armed even though abandonment itself failed" + ); + + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with( + &new_pre, + None, + &resolver, + &unreachable_seam, + ) + .expect("the barrier denial must still return Ok with a deny payload"); + assert_eq!( + output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "the next mutation-capable PreToolUse must be denied, and no new Start may occur" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn lifecycle_cleanup_with_a_failed_abandon_keeps_recovery_armed_and_the_attempt_tracked() { + let git_dir = unique_test_git_dir("lifecycle-cleanup-abandon-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + + let abandon_failing_seam = seam_failing_on("abandon"); + let stop_payload = session_scoped_payload(HOOK_EVENT_STOP, "session-1", "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/repo/checkout"); + let error = run_claude_mutation_scope_from_payload_with( + &stop_payload, + None, + &resolver, + &abandon_failing_seam, + ) + .expect_err("a failed abandonment during Stop cleanup must propagate"); + assert!(error.to_string().contains("abandon")); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "the attempt whose abandonment failed must remain tracked" + ); + assert!( + final_state.recovery_pending, + "the barrier must remain armed even though cleanup abandonment failed" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn resolver_failure_logs_the_detailed_error_and_denies_with_the_stable_reason_ac8_d8() { + let logger = RecordingLogger::default(); + let resolver = + |_: &str| -> Result { Err(anyhow!("boom: git rev-parse --git-dir failed")) }; + + let payload = pre_tool_use_json(&[]); + let output = run_claude_mutation_scope_from_payload_with( + &payload, + Some(&logger), + &resolver, + &unreachable_seam, + ) + .expect("a resolver failure must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + assert!( + !output.contains("boom"), + "the detailed internal error must never leak into Claude's deny reason" + ); + assert!( + !output.contains("allow"), + "a fail-closed PreToolUse must never emit an allow decision" + ); + + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert!( + warnings[0].1.contains("boom"), + "the detailed error must be logged for operators, got: {warnings:?}" + ); + } + + #[test] + fn start_seam_failure_logs_the_detailed_error_and_denies_with_the_stable_reason() { + let git_dir = unique_test_git_dir("start-failure-logged"); + let resolver = fixed_resolver(git_dir.clone()); + let logger = RecordingLogger::default(); + let seam = seam_failing_on("start"); + + let payload = pre_tool_use_json(&[]); + let output = + run_claude_mutation_scope_from_payload_with(&payload, Some(&logger), &resolver, &seam) + .expect("a Start failure must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let warnings = logger.warnings(); + assert!(!warnings.is_empty(), "the Start failure must be logged"); + assert!(warnings + .iter() + .any(|(_, message)| message.to_lowercase().contains("start"))); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flush_failure_logs_the_detailed_error() { + let git_dir = unique_test_git_dir("barrier-flush-failure-logged"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + let logger = RecordingLogger::default(); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seed allocation should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seeded attempt should succeed"); + + let seam = seam_failing_on("flush"); + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = + run_claude_mutation_scope_from_payload_with(&new_pre, Some(&logger), &resolver, &seam) + .expect("a failed flush must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let warnings = logger.warnings(); + assert!(!warnings.is_empty(), "the flush failure must be logged"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn malformed_payload_propagates_as_a_real_error_not_fail_open() { + let error = run_claude_mutation_scope_from_payload("not json", None).unwrap_err(); + assert!(error.to_string().contains("valid JSON")); + } +} + +mod production_regressions { + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + use super::*; + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::mutation_trace::runtime::{resolve_git_dir, resolve_worktree_id}; + use crate::services::mutation_trace::store::decode_revision; + + fn git(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + struct ClaudeRepo { + temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + } + + impl ClaudeRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-claude-mutation-scope-regression-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + temp, + root, + state_root, + } + } + + fn drive(&self, payload: &str) -> Result { + run_claude_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn drive_generic(&self, payload: &str) -> Result { + crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + &self.root, + &self.state_root, + payload, + None, + ) + } + + fn drive_flush(&self) -> Result { + self.drive_generic(&flush_payload()) + } + + fn db(&self) -> RepositoryAgentTraceDb { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "claude mutation-scope regression test assertions", + ) + .expect("assertion DB should open") + } + + fn cwd(&self) -> String { + self.root.to_string_lossy().into_owned() + } + + fn cwd_at(root: &Path) -> String { + root.to_string_lossy().into_owned() + } + + fn working_tree_at(root: &Path) -> String { + git(root, &["add", "-A"]); + git(root, &["write-tree"]).trim().to_owned() + } + + fn working_tree(&self) -> String { + Self::working_tree_at(&self.root) + } + + fn git_dir_at(root: &Path) -> PathBuf { + resolve_git_dir(root).expect("git dir should resolve") + } + + fn git_dir(&self) -> PathBuf { + Self::git_dir_at(&self.root) + } + + fn adapter_state_at(root: &Path) -> state::AdapterState { + state::read_state(&Self::git_dir_at(root)).expect("adapter state should be readable") + } + + fn adapter_state(&self) -> state::AdapterState { + Self::adapter_state_at(&self.root) + } + + fn worktree_id_at(root: &Path) -> String { + resolve_worktree_id(root) + .expect("worktree id should resolve") + .0 + } + + fn worktree_id(&self) -> String { + Self::worktree_id_at(&self.root) + } + + fn add_worktree(&self, name: &str) -> PathBuf { + let worktree_path = self.temp.path().join(name); + git( + &self.root, + &[ + "worktree", + "add", + "-q", + worktree_path.to_str().expect("utf-8 worktree path"), + ], + ); + worktree_path + } + } + + fn count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("a count row should exist") + } + + fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { + assert_eq!(count(db, "diff_traces"), 0); + assert_eq!(count(db, "post_commit_patch_intersections"), 0); + assert_eq!(count(db, "agent_traces"), 0); + } + + fn worktree_row(db: &RepositoryAgentTraceDb, worktree_id: &str) -> Option<(u64, String, bool)> { + db.query_map( + "SELECT revision, cursor_tree, needs_rebaseline FROM mutation_trace_worktrees \ + WHERE worktree_id = ?1", + (worktree_id,), + |row| { + let blob: Vec = row.get(0).map_err(anyhow::Error::from)?; + let revision = decode_revision(&blob)?; + let cursor_tree = row.get::(1).map_err(anyhow::Error::from)?; + let needs_rebaseline = row.get::(2).map_err(anyhow::Error::from)? != 0; + Ok((revision, cursor_tree, needs_rebaseline)) + }, + ) + .expect("worktree-row query should succeed") + .into_iter() + .next() + } + + fn processed_events(db: &RepositoryAgentTraceDb) -> Vec<(String, String)> { + db.query_map( + "SELECT scope_id, event_id FROM mutation_trace_processed_events \ + ORDER BY scope_id, event_id", + (), + |row| { + let scope_id = row.get::(0).map_err(anyhow::Error::from)?; + let event_id = row.get::(1).map_err(anyhow::Error::from)?; + Ok((scope_id, event_id)) + }, + ) + .expect("processed-events query should succeed") + } + + fn scope_status(db: &RepositoryAgentTraceDb, scope_id: &str) -> Option<(String, String)> { + db.query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn scope_provenance( + db: &RepositoryAgentTraceDb, + scope_id: &str, + ) -> Option<(String, Option)> { + db.query_map( + "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ + WHERE scope_id = ?1", + (scope_id,), + |row| { + let session_id = row.get::(0).map_err(anyhow::Error::from)?; + let model_id = row.get::>(1).map_err(anyhow::Error::from)?; + Ok((session_id, model_id)) + }, + ) + .expect("scope-provenance query should succeed") + .into_iter() + .next() + } + + fn mutation_events_for( + db: &RepositoryAgentTraceDb, + worktree_id: &str, + ) -> Vec<(String, Option, String)> { + db.query_map( + "SELECT attribution_kind, attribution_scope_id, boundary_kind \ + FROM mutation_trace_events WHERE worktree_id = ?1 ORDER BY revision", + (worktree_id,), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + let boundary_kind = row.get::(2).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id, boundary_kind)) + }, + ) + .expect("mutation-events query should succeed") + } + + fn tool_identity_json( + event_name: &str, + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String(tool_name.to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String(tool_use_id.to_string()), + ); + if let Some(agent_id) = agent_id { + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String(agent_id.to_string()), + ); + } + Value::Object(object).to_string() + } + + fn pre_tool_use_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_PRE_TOOL_USE, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn background_pre_tool_use_for( + cwd: &str, + session_id: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + let mut object: serde_json::Map = serde_json::from_str(&pre_tool_use_for( + cwd, + session_id, + "Bash", + tool_use_id, + agent_id, + )) + .expect("base PreToolUse payload should parse"); + object.insert( + TOOL_INPUT_FIELD.to_string(), + json!({ "run_in_background": true }), + ); + Value::Object(object).to_string() + } + + fn post_tool_use_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_POST_TOOL_USE, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn post_tool_use_failure_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_POST_TOOL_USE_FAILURE, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn permission_denied_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_PERMISSION_DENIED, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn session_json(event_name: &str, cwd: &str, session_id: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + Value::Object(object).to_string() + } + + fn agent_json(event_name: &str, cwd: &str, session_id: &str, agent_id: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String(agent_id.to_string()), + ); + Value::Object(object).to_string() + } + + fn worktree_remove_json(session_id: &str, worktree_path: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert( + WORKTREE_PATH_FIELD.to_string(), + Value::String(worktree_path.to_string()), + ); + Value::Object(object).to_string() + } + + #[test] + fn test1_foreground_write_closes_ai_exclusive() { + let repo = ClaudeRepo::new("test1-foreground-write"); + let cwd = repo.cwd(); + + assert_eq!( + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("PreToolUse should succeed"), + "" + ); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\ntwo\n") + .expect("the tool's own edit should write"); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("PostToolUse should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the closed attempt must be removed from adapter bookkeeping" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id.clone()), + "close".to_string(), + )] + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + assert_eq!( + processed_events(&db), + vec![ + (scope_id.clone(), claude_scope_close_event_id(&scope_id)), + (scope_id.clone(), claude_scope_start_event_id(&scope_id)), + ], + "rows are ordered by (scope_id, event_id), and 'close' sorts before 'start'" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test2_failed_bash_partial_write_still_closes_ai_exclusive() { + let repo = ClaudeRepo::new("test2-failed-bash"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\npartial\n") + .expect("the failed tool's partial edit should write"); + + assert_eq!( + repo.drive(&post_tool_use_failure_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None + )) + .expect("PostToolUseFailure should succeed"), + "" + ); + + assert!(repo.adapter_state().attempts.is_empty()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id.clone()), + "close".to_string(), + )] + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test4_duplicate_pre_and_post_replay_has_no_duplicate_transition() { + let repo = ClaudeRepo::new("test4-duplicate-replay"); + let cwd = repo.cwd(); + let pre = pre_tool_use_for(&cwd, "session-1", "Write", "toolu_1", None); + + repo.drive(&pre).expect("first PreToolUse should succeed"); + assert_eq!( + repo.drive(&pre) + .expect("duplicate PreToolUse should be idempotent"), + "" + ); + assert_eq!( + repo.adapter_state().attempts.len(), + 1, + "AC4: duplicate PreToolUse delivery must reuse the same attempt" + ); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); + + let post = post_tool_use_for(&cwd, "session-1", "Write", "toolu_1", None); + repo.drive(&post).expect("first PostToolUse should succeed"); + + let db = repo.db(); + let (revision_before, events_before, processed_before) = ( + worktree_row(&db, &repo.worktree_id()) + .map(|(revision, _, _)| revision) + .expect("a worktree row should exist"), + count(&db, "mutation_trace_events"), + count(&db, "mutation_trace_processed_events"), + ); + + assert_eq!( + repo.drive(&post) + .expect("duplicate PostToolUse delivery must be a safe no-op"), + "" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before) + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + assert_eq!( + count(&db, "mutation_trace_processed_events"), + processed_before + ); + assert_eq!( + processed_events(&db) + .into_iter() + .filter(|(scope, event)| scope == &scope_id + && event == &claude_scope_close_event_id(&scope_id)) + .count(), + 1 + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test5_auto_permission_denied_abandons_and_requires_rebaseline() { + let repo = ClaudeRepo::new("test5-permission-denied"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + assert_eq!( + repo.drive(&permission_denied_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("PermissionDenied should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the denied attempt must be retired from adapter bookkeeping" + ); + assert!( + repo.adapter_state().recovery_pending, + "D19: abandonment must arm the recovery barrier" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert!( + worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline), + "AC12: a denied execution must leave the worktree needing rebaseline" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test3_two_parallel_subagent_tools_produce_ai_contended() { + let repo = ClaudeRepo::new("test3-parallel-subagents"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_a", + Some("agent-a"), + )) + .expect("agent-a PreToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_b", + Some("agent-b"), + )) + .expect("agent-b PreToolUse should succeed"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + fs::write(repo.root.join("file.txt"), "one\ncontended\n") + .expect("the racing edit should write"); + + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_a", + Some("agent-a"), + )) + .expect("agent-a PostToolUse (closing while agent-b is still active) should succeed"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_b", + Some("agent-b"), + )) + .expect("agent-b PostToolUse should succeed"); + + assert!(repo.adapter_state().attempts.is_empty()); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ai_contended".to_string(), None, "close".to_string())], + "AC11: a tree transition observed while two scopes are live must be AiContended" + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test6_other_hook_denial_is_retired_by_stop_cleanup() { + let repo = ClaudeRepo::new("test6-stop-cleanup"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + assert_eq!( + repo.drive(&session_json(HOOK_EVENT_STOP, &cwd, "session-1")) + .expect("Stop should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "AC13: Stop must retire the stale main-thread attempt" + ); + assert!(repo.adapter_state().recovery_pending); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test7_interrupted_main_turn_is_retired_by_next_user_prompt_submit() { + let repo = ClaudeRepo::new("test7-user-prompt-submit-cleanup"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\ninterrupted\n") + .expect("the interrupted edit should write"); + + assert_eq!( + repo.drive(&session_json( + HOOK_EVENT_USER_PROMPT_SUBMIT, + &cwd, + "session-1" + )) + .expect("UserPromptSubmit should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "AC14: UserPromptSubmit must retire the stale main-thread attempt \ + before another mutation-capable tool can start" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test8_resumed_subagent_tool_use_id_gets_a_fresh_scope_id() { + let repo = ClaudeRepo::new("test8-resumed-subagent"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("first PreToolUse should succeed"); + let first_scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\nfirst\n").expect("first edit should write"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("first PostToolUse should succeed"); + assert!(repo.adapter_state().attempts.is_empty()); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("resumed PreToolUse should succeed"); + let second_scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + assert_ne!( + first_scope_id, second_scope_id, + "AC15: a resumed subagent's new tool attempt must receive a fresh ScopeId" + ); + + fs::write(repo.root.join("file.txt"), "one\nfirst\nsecond\n") + .expect("second edit should write"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("second PostToolUse should succeed"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &first_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + assert_eq!( + scope_status(&db, &second_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test9_main_and_subagent_concurrent_mutation_is_ai_contended() { + let repo = ClaudeRepo::new("test9-main-plus-subagent"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_main", + None, + )) + .expect("main-thread PreToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PreToolUse should succeed"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + fs::write(repo.root.join("file.txt"), "one\nboth-writing\n") + .expect("the racing edit should write"); + + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_main", + None, + )) + .expect("main-thread PostToolUse should succeed"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PostToolUse should succeed"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ai_contended".to_string(), None, "close".to_string())], + "AC11: main + subagent concurrent mutation must be AiContended" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test10_isolated_subagent_worktree_advances_only_its_own_cursor() { + let repo = ClaudeRepo::new("test10-isolated-worktree"); + let worktree_path = repo.add_worktree("subagent-worktree"); + let worktree_cwd = ClaudeRepo::cwd_at(&worktree_path); + + let main_worktree_id = repo.worktree_id(); + let sub_worktree_id = ClaudeRepo::worktree_id_at(&worktree_path); + assert_ne!( + main_worktree_id, sub_worktree_id, + "a linked worktree must resolve to a distinct WorktreeId" + ); + + repo.drive_flush() + .expect("main-checkout baseline flush should succeed"); + let main_cursor_before = worktree_row(&repo.db(), &main_worktree_id) + .map(|(_, cursor_tree, _)| cursor_tree) + .expect("main checkout should have a baseline worktree row"); + + repo.drive(&pre_tool_use_for( + &worktree_cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PreToolUse in the isolated worktree should succeed"); + fs::write(worktree_path.join("file.txt"), "one\nisolated\n") + .expect("the isolated worktree's own edit should write"); + repo.drive(&post_tool_use_for( + &worktree_cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PostToolUse in the isolated worktree should succeed"); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &main_worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(main_cursor_before), + "AC17: the main checkout's mutation cursor must be unchanged" + ); + let sub_row = worktree_row(&db, &sub_worktree_id).expect("subagent worktree row"); + assert_eq!( + sub_row.1, + ClaudeRepo::working_tree_at(&worktree_path), + "AC16/AC17: the isolated worktree's own cursor must advance" + ); + assert_eq!( + mutation_events_for(&db, &sub_worktree_id) + .into_iter() + .map(|(attribution, _, boundary)| (attribution, boundary)) + .collect::>(), + vec![("ai_exclusive".to_string(), "close".to_string())] + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test11_worktree_remove_cleans_only_that_worktrees_outstanding_attempt() { + let repo = ClaudeRepo::new("test11-worktree-remove"); + let worktree_path = repo.add_worktree("removed-worktree"); + let worktree_cwd = ClaudeRepo::cwd_at(&worktree_path); + let main_cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &main_cwd, + "session-1", + "Write", + "toolu_main", + None, + )) + .expect("main-thread PreToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &worktree_cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PreToolUse in the isolated worktree should succeed"); + + assert_eq!(ClaudeRepo::adapter_state_at(&repo.root).attempts.len(), 1); + assert_eq!( + ClaudeRepo::adapter_state_at(&worktree_path).attempts.len(), + 1 + ); + + assert_eq!( + repo.drive(&worktree_remove_json("session-1", &worktree_cwd)) + .expect("WorktreeRemove should succeed"), + "" + ); + + assert!( + ClaudeRepo::adapter_state_at(&worktree_path) + .attempts + .is_empty(), + "AC13/D22: WorktreeRemove must retire the outstanding attempt for that worktree" + ); + assert_eq!( + ClaudeRepo::adapter_state_at(&repo.root).attempts.len(), + 1, + "WorktreeRemove for one worktree must not touch the main checkout's attempts" + ); + + assert_raw_agent_trace_tables_untouched(&repo.db()); + } + + #[test] + fn test12_pending_start_crash_before_start_is_recovered_conservatively() { + let repo = ClaudeRepo::new("test12-pending-start-crash"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + + let key = AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_crashed".to_string(), + }; + let allocated = + state::allocate_attempt(&git_dir, &key, "Write").expect("allocation should succeed"); + assert_eq!(allocated.attempt.phase, state::AttemptPhase::PendingStart); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_crashed", + None + )) + .expect("D11: PostToolUse on a pending_start attempt must abandon, not late-start"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the never-started attempt must be retired" + ); + assert!(repo.adapter_state().recovery_pending); + let db = repo.db(); + assert_eq!( + scope_status(&db, &allocated.attempt.scope_id), + None, + "a Start that never committed must never appear as a real scope" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_fresh", + None, + )) + .expect("the next PreToolUse should proceed after the quiescent flush"); + assert!(!repo.adapter_state().recovery_pending); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test13_start_committed_before_state_settlement_is_recovered_by_abandonment() { + let repo = ClaudeRepo::new("test13-start-committed-crash"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + + let key = AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_crashed".to_string(), + }; + let allocated = + state::allocate_attempt(&git_dir, &key, "Write").expect("allocation should succeed"); + let scope_id = allocated.attempt.scope_id.clone(); + + repo.drive_generic(&scope_boundary_payload( + "start", + &scope_id, + &claude_scope_start_event_id(&scope_id), + )) + .expect("the runtime Start should commit durably"); + assert_eq!( + state::read_state(&git_dir).unwrap().attempts[0].phase, + state::AttemptPhase::PendingStart + ); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_crashed", + None + )) + .expect("D11: a pending_start attempt with a committed Start must be abandoned"), + "" + ); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(repo.adapter_state().recovery_pending); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())), + "the runtime's own committed Start must settle as a real abandonment" + ); + let worktree_id = repo.worktree_id(); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test14_terminal_runtime_success_before_state_cleanup_is_replay_safe() { + let repo = ClaudeRepo::new("test14-close-committed-crash"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); + + repo.drive_generic(&scope_boundary_payload( + "close", + &scope_id, + &claude_scope_close_event_id(&scope_id), + )) + .expect("the runtime Close should commit durably"); + assert_eq!(state::read_state(&git_dir).unwrap().attempts.len(), 1); + + let db = repo.db(); + let (revision_before, events_before) = ( + worktree_row(&db, &repo.worktree_id()) + .map(|(revision, _, _)| revision) + .expect("a worktree row should exist"), + count(&db, "mutation_trace_events"), + ); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("a replayed Close against an already-durable commit must be safe"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the stale bookkeeping must finally be cleared" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before), + "a durably completed Close must never be re-applied as a second transition" + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test15_explicit_background_bash_is_denied_with_no_scope() { + let repo = ClaudeRepo::new("test15-explicit-background-bash"); + let cwd = repo.cwd(); + + let output = repo + .drive(&background_pre_tool_use_for( + &cwd, + "session-1", + "toolu_1", + None, + )) + .expect("an explicit background shell must still return Ok with a deny payload"); + + assert_eq!( + output, + pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) + ); + assert!( + repo.adapter_state().attempts.is_empty(), + "AC21: an explicit background shell must create no scope" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test18_model_switch_does_not_rewrite_scope_provenance() { + let repo = ClaudeRepo::new("model-switch-provenance"); + let session_id = "session-model-switch"; + let db = repo.db(); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: "cc_session-model-switch".to_string(), + agent_id: String::new(), + model_id: "claude/sonnet".to_string(), + observation_kind: ObservationKind::SessionStart, + source: "test".to_string(), + observed_at_ms: 1, + }) + .expect("initial Claude model state should persist"); + + let pre_payload = + pre_tool_use_for(&repo.cwd(), session_id, "Bash", "toolu_model_switch", None); + repo.drive(&pre_payload) + .expect("initial PreToolUse should establish a scope"); + let scope_id = repo + .adapter_state() + .attempts + .first() + .expect("the scope should remain live") + .scope_id + .clone(); + let before = scope_provenance(&repo.db(), &scope_id); + assert_eq!( + before, + Some(( + "cc_session-model-switch".to_string(), + Some("claude/sonnet".to_string()), + )) + ); + + repo.db() + .upsert_claude_model_state(ClaudeModelStateObservation { + session_id: "cc_session-model-switch".to_string(), + agent_id: String::new(), + model_id: "claude/opus".to_string(), + observation_kind: ObservationKind::PostModelSwitch, + source: "test".to_string(), + observed_at_ms: 2, + }) + .expect("model switch should persist"); + repo.drive(&pre_payload) + .expect("replayed PreToolUse should remain idempotent"); + + assert_eq!(scope_provenance(&repo.db(), &scope_id), before); + } + + #[test] + fn test16_regression_matrix_leaves_raw_agent_trace_tables_untouched() { + let repo = ClaudeRepo::new("test16-raw-tables-untouched"); + let cwd = repo.cwd(); + + let before = { + let db = repo.db(); + ( + count(&db, "diff_traces"), + count(&db, "post_commit_patch_intersections"), + count(&db, "agent_traces"), + ) + }; + assert_eq!(before, (0, 0, 0)); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PostToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_2", + None, + )) + .expect("second PreToolUse should succeed"); + repo.drive(&permission_denied_for( + &cwd, + "session-1", + "Write", + "toolu_2", + None, + )) + .expect("PermissionDenied should succeed"); + + let db = repo.db(); + let after = ( + count(&db, "diff_traces"), + count(&db, "post_commit_patch_intersections"), + count(&db, "agent_traces"), + ); + assert_eq!( + after, + (0, 0, 0), + "AC20: Claude mutation-scope-only regressions must leave the raw \ + Agent Trace tables unchanged" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test17_detached_descendant_write_after_post_tool_use_is_not_folded_into_the_closed_scope() { + let repo = ClaudeRepo::new("test17-detached-descendant"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\nforeground-output\n") + .expect("the tool's own foreground write should write"); + let tree_at_close = repo.working_tree(); + + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PostToolUse should succeed"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(tree_at_close.clone()), + "the scope must close at the tool's own observed tree" + ); + + fs::write( + repo.root.join("file.txt"), + "one\nforeground-output\ndetached-descendant\n", + ) + .expect("the detached descendant's later write should write"); + let tree_after_descendant = repo.working_tree(); + assert_ne!(tree_after_descendant, tree_at_close); + + let events_before_flush = mutation_events_for(&db, &worktree_id); + + repo.drive_flush() + .expect("a later recovery/diagnostic flush should succeed"); + + let db = repo.db(); + let events_after_flush = mutation_events_for(&db, &worktree_id); + assert_eq!( + events_after_flush.len(), + events_before_flush.len() + 1, + "the detached descendant's mutation must surface as its own event" + ); + let (attribution_kind, attribution_scope_id, _) = events_after_flush + .last() + .expect("a flush event should exist"); + assert_ne!( + attribution_scope_id.as_deref(), + Some(scope_id.as_str()), + "the detached descendant's mutation must never be attributed to the \ + already-closed tool scope" + ); + assert_eq!(attribution_kind, "ineligible_unscoped"); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(tree_after_descendant) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } +} diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index beaba255..6041cc5e 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -856,4801 +856,4 @@ fn pre_tool_use_deny_json(reason: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - const PROBE01_SHELL_PRE: &str = - include_str!("fixtures/probe01-apply-patch-and-shell-success.shell.pre_tool_use.json"); - const PROBE01_SHELL_POST: &str = - include_str!("fixtures/probe01-apply-patch-and-shell-success.shell.post_tool_use.json"); - const PROBE01_APPLY_PATCH_PRE: &str = include_str!( - "fixtures/probe01-apply-patch-and-shell-success.apply_patch.pre_tool_use.json" - ); - const PROBE01_STOP: &str = - include_str!("fixtures/probe01-apply-patch-and-shell-success.stop.json"); - const PROBE01_SESSION_END: &str = - include_str!("fixtures/probe01-apply-patch-and-shell-success.session_end.json"); - const PROBE02_FAILED_SHELL_POST: &str = - include_str!("fixtures/probe02-shell-partial-write-then-nonzero-exit.post_tool_use.json"); - const PROBE04_BLOCKED_PRE: &str = include_str!( - "fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.pre_tool_use.json" - ); - const PROBE05_SHELL_PRE: &str = - include_str!("fixtures/probe05-tool-vocabulary.shell-read-list-search.pre_tool_use.json"); - const PROBE08_SPAWN_AGENT_PRE: &str = - include_str!("fixtures/probe08-subagent-delegation.spawn_agent.pre_tool_use.json"); - const PROBE08_WAIT_AGENT_PRE: &str = - include_str!("fixtures/probe08-subagent-delegation.wait_agent.pre_tool_use.json"); - const PROBE08_AGENT_APPLY_PATCH_PRE: &str = - include_str!("fixtures/probe08-subagent-delegation.agent-apply-patch.pre_tool_use.json"); - const PROBE08_AGENT_APPLY_PATCH_POST: &str = - include_str!("fixtures/probe08-subagent-delegation.agent-apply-patch.post_tool_use.json"); - const PROBE08_SUBAGENT_STOP: &str = - include_str!("fixtures/probe08-subagent-delegation.subagent_stop.json"); - const PROBE10_WORKTREE_PRE: &str = - include_str!("fixtures/probe10-linked-worktree-cwd.pre_tool_use.json"); - const PROBE11_INTERRUPT: &str = - include_str!("fixtures/probe11-interrupt-event-on-sigint.interrupt.json"); - const PROBE12_MCP_PRE: &str = - include_str!("fixtures/probe12-mcp-mutate-success.pre_tool_use.json"); - const PROBE12_MCP_POST: &str = - include_str!("fixtures/probe12-mcp-mutate-success.post_tool_use.json"); - const PROBE13_MCP_MUTATE_THEN_ERROR_PRE: &str = - include_str!("fixtures/probe13-mcp-mutate-then-error.pre_tool_use.json"); - const PROBE13_MCP_SESSION_END: &str = - include_str!("fixtures/probe13-mcp-mutate-then-error.session_end.json"); - - fn pre_tool_use_json(overrides: &[(&str, Value)]) -> String { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_PRE_TOOL_USE.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert( - TURN_ID_FIELD.to_string(), - Value::String("turn-1".to_string()), - ); - object.insert( - CWD_FIELD.to_string(), - Value::String("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/repo/checkout".to_string()), - ); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String("Bash".to_string()), - ); - object.insert( - TOOL_USE_ID_FIELD.to_string(), - Value::String("exec-1".to_string()), - ); - object.insert(TOOL_INPUT_FIELD.to_string(), json!({"command": "true"})); - for (field, value) in overrides { - object.insert((*field).to_string(), value.clone()); - } - Value::Object(object).to_string() - } - - fn key(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> AttemptKey { - AttemptKey { - session_id: session_id.to_string(), - agent_id: agent_id.map(str::to_string), - tool_use_id: tool_use_id.to_string(), - } - } - - fn pre_tool_use(payload: &str) -> CodexToolExecution { - match parse_codex_hook_event(payload).expect("valid PreToolUse parses") { - CodexHookEvent::PreToolUse(execution) => execution, - other => panic!("expected PreToolUse, got {other:?}"), - } - } - - #[test] - fn ac2_empty_payload_is_rejected() { - let error = parse_codex_hook_event(" ").unwrap_err().to_string(); - assert_eq!( - error, - "Invalid Codex hook event payload from STDIN: expected a JSON object, got an empty payload." - ); - } - - #[test] - fn ac2_non_object_json_is_rejected() { - for payload in ["[]", "\"PreToolUse\"", "42", "null"] { - let error = parse_codex_hook_event(payload).unwrap_err().to_string(); - assert!( - error.contains("expected a JSON object"), - "payload {payload:?} produced {error:?}" - ); - } - } - - #[test] - fn ac2_invalid_json_is_rejected() { - let error = parse_codex_hook_event("{not json").unwrap_err().to_string(); - assert!( - error.contains("Invalid Codex hook event payload from STDIN: expected valid JSON"), - "{error:?}" - ); - } - - #[test] - fn ac2_unsupported_hook_event_name_is_rejected() { - for name in [ - "SessionStart", - "SubagentStart", - "UserPromptSubmit", - "PreCompact", - ] { - let payload = - pre_tool_use_json(&[(HOOK_EVENT_NAME_FIELD, Value::String(name.to_string()))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("unsupported hook_event_name '{name}'")), - "{error:?}" - ); - } - } - - #[test] - fn ac2_missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [ - SESSION_ID_FIELD, - TURN_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - TOOL_USE_ID_FIELD, - ] { - let mut object: Map = - serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); - - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("'{field}'")), - "missing {field} produced {error:?}" - ); - } - } - - #[test] - fn ac2_blank_required_fields_are_rejected() { - for field in [SESSION_ID_FIELD, TURN_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { - let payload = pre_tool_use_json(&[(field, Value::String(" ".to_string()))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("field '{field}' must be a non-blank string")), - "blank {field} produced {error:?}" - ); - } - } - - #[test] - fn ac2_wrong_typed_fields_are_rejected() { - let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::Bool(true))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'tool_use_id' must be a string"), - "{error:?}" - ); - } - - #[test] - fn ac2_wrong_typed_optional_agent_id_is_rejected() { - let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Bool(false))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'agent_id' must be null, absent, or a non-blank string"), - "{error:?}" - ); - } - - #[test] - fn ac2_pre_tool_use_fixtures_parse_to_expected_identity() { - let shell = pre_tool_use(PROBE01_SHELL_PRE); - assert_eq!(shell.identity.tool_name, "Bash"); - assert_eq!( - shell.identity.tool_use_id, - "exec-414820f5-555e-457a-92e7-60ddd27d4eec" - ); - assert_eq!( - shell.identity.session_id, - "01a07c1e-e08e-7172-8032-cb9d62af21d9" - ); - assert_eq!( - shell.identity.turn_id, - "01a07c1e-e0cc-75f1-a566-e790c06cb033" - ); - assert_eq!(shell.identity.agent_id, None); - assert!(!shell.identity.is_subagent()); - assert!(shell.identity.cwd.ends_with("/probe-repo")); - - let apply_patch = pre_tool_use(PROBE01_APPLY_PATCH_PRE); - assert_eq!(apply_patch.identity.tool_name, "apply_patch"); - - let vocab = pre_tool_use(PROBE05_SHELL_PRE); - assert_eq!(vocab.identity.tool_name, "Bash"); - - let worktree = pre_tool_use(PROBE10_WORKTREE_PRE); - assert!(worktree.identity.cwd.ends_with("/probe-worktree")); - - let mcp = pre_tool_use(PROBE12_MCP_PRE); - assert_eq!(mcp.identity.tool_name, "mcp__probe__mutate_success"); - - let mcp_err = pre_tool_use(PROBE13_MCP_MUTATE_THEN_ERROR_PRE); - assert!(is_mcp_tool_name(&mcp_err.identity.tool_name)); - } - - #[test] - fn ac2_subagent_pre_tool_use_fixture_carries_agent_identity() { - let execution = pre_tool_use(PROBE08_AGENT_APPLY_PATCH_PRE); - assert_eq!( - execution.identity.agent_id.as_deref(), - Some("01a07c24-bb59-7ca0-80f7-99cf940a486e") - ); - assert!(execution.identity.is_subagent()); - assert_eq!(execution.agent_type.as_deref(), Some("default")); - } - - #[test] - fn ac3_pre_tool_use_fixtures_retain_the_codex_model() { - for fixture in [ - PROBE01_SHELL_PRE, - PROBE01_APPLY_PATCH_PRE, - PROBE05_SHELL_PRE, - PROBE08_AGENT_APPLY_PATCH_PRE, - ] { - assert_eq!(pre_tool_use(fixture).model.as_deref(), Some("gpt-5.6-sol")); - } - } - - #[test] - fn ac3_scope_provenance_canonicalizes_the_session_and_normalizes_the_model() { - let provenance = codex_scope_provenance(&pre_tool_use(PROBE01_SHELL_PRE)); - assert_eq!( - provenance.session_id, - "cx_01a07c1e-e08e-7172-8032-cb9d62af21d9" - ); - assert_eq!(provenance.model_id.as_deref(), Some("gpt-5.6-sol")); - - let apply_patch = codex_scope_provenance(&pre_tool_use(PROBE01_APPLY_PATCH_PRE)); - assert_eq!(apply_patch, provenance); - } - - #[test] - fn ac3_scope_provenance_keeps_an_already_prefixed_session_id() { - let execution = pre_tool_use(&pre_tool_use_json(&[( - SESSION_ID_FIELD, - Value::String("cx_session-1".to_string()), - )])); - assert_eq!( - codex_scope_provenance(&execution).session_id, - "cx_session-1" - ); - } - - #[test] - fn ac3_an_unusable_model_yields_no_model_id_without_rejecting_the_event() { - for model in [ - Value::Null, - Value::String(String::new()), - Value::String(" ".to_string()), - Value::Bool(true), - json!(7), - json!({ "id": "gpt-5.6-sol" }), - ] { - let payload = pre_tool_use_json(&[(MODEL_FIELD, model.clone())]); - let execution = pre_tool_use(&payload); - let provenance = codex_scope_provenance(&execution); - assert_eq!(provenance.model_id, None, "model {model:?}"); - assert_eq!(provenance.session_id, "cx_session-1", "model {model:?}"); - } - - let absent = pre_tool_use(&pre_tool_use_json(&[])); - assert_eq!(absent.model, None); - assert_eq!(codex_scope_provenance(&absent).model_id, None); - } - - #[test] - fn ac2_post_tool_use_fixtures_parse() { - for (payload, tool_name, tool_use_id) in [ - ( - PROBE01_SHELL_POST, - "Bash", - "exec-414820f5-555e-457a-92e7-60ddd27d4eec", - ), - ( - PROBE02_FAILED_SHELL_POST, - "Bash", - "exec-52155265-e98d-423f-87f8-76ee56ff33b1", - ), - ( - PROBE12_MCP_POST, - "mcp__probe__mutate_success", - "exec-00988fad-6707-48ed-81b6-07bb11933886", - ), - ] { - match parse_codex_hook_event(payload).expect("PostToolUse fixture parses") { - CodexHookEvent::PostToolUse(identity) => { - assert_eq!(identity.tool_name, tool_name); - assert_eq!(identity.tool_use_id, tool_use_id); - } - other => panic!("expected PostToolUse, got {other:?}"), - } - } - } - - #[test] - fn ac2_subagent_post_tool_use_ties_to_its_pre_tool_use() { - let CodexHookEvent::PostToolUse(post) = - parse_codex_hook_event(PROBE08_AGENT_APPLY_PATCH_POST).unwrap() - else { - panic!("expected PostToolUse"); - }; - let pre = pre_tool_use(PROBE08_AGENT_APPLY_PATCH_PRE); - assert_eq!(post.attempt_key(), pre.identity.attempt_key()); - assert!(post.attempt_key().agent_id.is_some()); - } - - #[test] - fn ac2_terminal_lifecycle_fixtures_parse() { - assert!(matches!( - parse_codex_hook_event(PROBE01_STOP).unwrap(), - CodexHookEvent::Stop(id) if id.turn_id == "01a07c1e-e0cc-75f1-a566-e790c06cb033" - )); - assert!(matches!( - parse_codex_hook_event(PROBE11_INTERRUPT).unwrap(), - CodexHookEvent::Interrupt(id) if id.session_id == "01a07c2f-ccbf-79f0-afb9-2d2ce919eea7" - )); - assert!(matches!( - parse_codex_hook_event(PROBE08_SUBAGENT_STOP).unwrap(), - CodexHookEvent::SubagentStop(id) - if id.agent_id == "01a07c24-bb59-7ca0-80f7-99cf940a486e" - )); - for session_end in [PROBE01_SESSION_END, PROBE13_MCP_SESSION_END] { - assert!(matches!( - parse_codex_hook_event(session_end).unwrap(), - CodexHookEvent::SessionEnd(_) - )); - } - } - - #[test] - fn ac2_session_end_needs_no_turn_id() { - let CodexHookEvent::SessionEnd(identity) = - parse_codex_hook_event(PROBE01_SESSION_END).unwrap() - else { - panic!("expected SessionEnd"); - }; - assert_eq!(identity.session_id, "01a07c1e-e08e-7172-8032-cb9d62af21d9"); - } - - #[test] - fn ac3_classification_table() { - let cases: &[(&str, ToolClassification)] = &[ - ("Bash", ToolClassification::TrackedMutation), - ("apply_patch", ToolClassification::TrackedMutation), - ("collaborationspawn_agent", ToolClassification::Delegation), - ("collaborationwait_agent", ToolClassification::Delegation), - ("mcp__probe__mutate_success", ToolClassification::Untracked), - ("mcp__probe_par__slow_mutate", ToolClassification::Untracked), - ("mcp__", ToolClassification::Untracked), - ("Read", ToolClassification::Untracked), - ("PowerShell", ToolClassification::Untracked), - ("some_future_codex_tool", ToolClassification::Untracked), - ("", ToolClassification::Untracked), - ]; - for (tool_name, expected) in cases { - assert_eq!( - classify_tool(tool_name), - *expected, - "classify_tool({tool_name:?})" - ); - } - } - - #[test] - fn ac3_classification_is_total_and_single_valued() { - for tool_name in [ - "Bash", - "apply_patch", - "collaborationspawn_agent", - "collaborationwait_agent", - "mcp__x__y", - "unknown", - ] { - let _: ToolClassification = classify_tool(tool_name); - } - } - - #[test] - fn ac3_delegation_and_untracked_tool_fixtures_do_not_yield_a_tracked_scope() { - for payload in [ - PROBE08_SPAWN_AGENT_PRE, - PROBE08_WAIT_AGENT_PRE, - PROBE12_MCP_PRE, - PROBE13_MCP_MUTATE_THEN_ERROR_PRE, - ] { - let execution = pre_tool_use(payload); - let classification = classify_tool(&execution.identity.tool_name); - assert_ne!( - classification, - ToolClassification::TrackedMutation, - "tool {:?} must not be TrackedMutation", - execution.identity.tool_name - ); - } - - assert_eq!( - classify_tool(&pre_tool_use(PROBE04_BLOCKED_PRE).identity.tool_name), - ToolClassification::TrackedMutation - ); - } - - #[test] - fn ac3_is_mcp_tool_name() { - assert!(is_mcp_tool_name("mcp__probe__mutate_success")); - assert!(is_mcp_tool_name("mcp__")); - assert!(!is_mcp_tool_name("Bash")); - assert!(!is_mcp_tool_name("apply_patch")); - assert!(!is_mcp_tool_name("collaborationspawn_agent")); - } - - #[test] - fn ac4_scope_id_is_deterministic_for_the_same_attempt_seq_and_key() { - let k = key("session-1", None, "exec-1"); - assert_eq!(format_codex_scope_id(7, &k), format_codex_scope_id(7, &k)); - - let scope_id = format_codex_scope_id(7, &k); - assert_eq!(scope_id, "cx-tool-v1|n=7|s=9:session-1|a=0:|t=6:exec-1"); - assert_eq!( - codex_scope_start_event_id(&scope_id), - format!("{scope_id}|start") - ); - assert_eq!( - codex_scope_close_event_id(&scope_id), - format!("{scope_id}|close") - ); - } - - #[test] - fn ac4_length_prefix_disambiguates_delimiter_collisions() { - let a = key("a:b", None, "c"); - let b = key("a", None, "b:c"); - assert_ne!(format_codex_scope_id(1, &a), format_codex_scope_id(1, &b)); - } - - #[test] - fn ac4_subagent_key_encodes_the_agent_id() { - let main = key("session-1", None, "exec-1"); - let sub = key("session-1", Some("agent-1"), "exec-1"); - assert_ne!( - format_codex_scope_id(1, &main), - format_codex_scope_id(1, &sub) - ); - assert_eq!( - format_codex_scope_id(1, &sub), - "cx-tool-v1|n=1|s=9:session-1|a=7:agent-1|t=6:exec-1" - ); - } - - #[test] - fn ac5_a_fresh_attempt_seq_yields_a_new_scope_id() { - let k = key("session-1", None, "exec-1"); - assert_ne!(format_codex_scope_id(1, &k), format_codex_scope_id(2, &k)); - assert!(format_codex_scope_id(2, &k).contains("|n=2|")); - } - - #[test] - fn ac5_attempt_key_excludes_turn_id() { - let base = pre_tool_use(&pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String("exec-9".to_string()), - )])); - let other_turn = pre_tool_use(&pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("exec-9".to_string())), - (TURN_ID_FIELD, Value::String("turn-99".to_string())), - ])); - assert_eq!( - base.identity.attempt_key(), - other_turn.identity.attempt_key() - ); - } - - mod driver { - use std::cell::RefCell; - use std::path::{Path, PathBuf}; - use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; - use std::sync::mpsc; - use std::sync::{Arc, Mutex}; - use std::thread; - use std::time::Duration; - - use anyhow::{anyhow, Result}; - - use super::*; - use crate::services::observability::traits::Logger; - - const CWD: &str = "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/repo/checkout"; - - static NEXT_TEST_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); - - fn unique_test_git_dir(label: &str) -> PathBuf { - let id = NEXT_TEST_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "sce-codex-mutation-scope-driver-{label}-{}-{id}", - std::process::id() - )) - } - - fn remove_test_git_dir(git_dir: &Path) { - let _ = std::fs::remove_dir_all(git_dir); - } - - #[allow(clippy::unnecessary_wraps)] - fn ok_seam(_root: &Path, _payload: &str, _logger: Option<&dyn Logger>) -> Result { - Ok(String::new()) - } - - fn unreachable_seam( - _root: &Path, - payload: &str, - _logger: Option<&dyn Logger>, - ) -> Result { - panic!("the ingress seam must not be called for this payload: {payload}"); - } - - const BLOCKED_BASH_POLICY_RESPONSE: &str = concat!( - r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","#, - r#""permissionDecision":"deny","#, - r#""permissionDecisionReason":"Blocked by SCE bash-tool policy 'no-danger': danger is not allowed"}}"#, - ); - - #[allow(clippy::unnecessary_wraps)] - fn blocking_bash_policy(_root: &Path, _command: &str) -> Result { - Ok(CodexBashPolicyDecision::Blocked( - BLOCKED_BASH_POLICY_RESPONSE.to_string(), - )) - } - - #[allow(clippy::unnecessary_wraps)] - fn allow_bash_policy(_root: &Path, _command: &str) -> Result { - Ok(CodexBashPolicyDecision::Allowed) - } - - fn failing_bash_policy(_root: &Path, _command: &str) -> Result { - Err(anyhow!( - "repository Bash policy configuration is invalid and could not be evaluated" - )) - } - - fn unreachable_bash_policy(_root: &Path, command: &str) -> Result { - panic!("the Bash policy preflight must not run for this event (command: {command})"); - } - - fn seam_failing_on( - operation: &'static str, - ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { - seam_failing_on_any(vec![operation]) - } - - fn seam_failing_on_any( - operations: Vec<&'static str>, - ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { - move |_root, payload, _logger| { - if operations - .iter() - .any(|operation| payload.contains(&format!(r#""operation":"{operation}""#))) - { - Err(anyhow!( - "seam failure injected by test for one of {operations:?}" - )) - } else { - Ok(String::new()) - } - } - } - - fn recording_seam( - log: Arc>>, - ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { - move |_root, payload, _logger| { - log.lock() - .expect("recording seam mutex") - .push(payload.to_string()); - Ok(String::new()) - } - } - - struct SeamGate { - entered: mpsc::Receiver<()>, - release: mpsc::Sender<()>, - } - - impl SeamGate { - fn wait_until_entered(&self) { - self.entered - .recv_timeout(Duration::from_secs(5)) - .expect("gated seam should be entered"); - } - - fn release(&self) { - let _ = self.release.send(()); - } - } - - #[allow(clippy::type_complexity)] - fn gated_seam( - operation: &'static str, - calls: Arc>>, - ) -> ( - impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send, - SeamGate, - ) { - let (entered_tx, entered_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - let release_rx = Mutex::new(release_rx); - let seam = move |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { - calls - .lock() - .expect("gated seam mutex") - .push(payload.to_string()); - if payload.contains(&format!(r#""operation":"{operation}""#)) { - entered_tx.send(()).expect("gate entry signal"); - release_rx - .lock() - .expect("gate release mutex") - .recv() - .expect("gate release signal"); - } - Ok(String::new()) - }; - ( - seam, - SeamGate { - entered: entered_rx, - release: release_tx, - }, - ) - } - - fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result + Send + Clone { - move |_cwd| Ok(git_dir.clone()) - } - - fn panicking_resolver(_cwd: &str) -> Result { - panic!("resolve_git_dir must not be called for a non-tracked tool") - } - - #[derive(Clone, Default)] - struct RecordingLogger { - warnings: Arc>>, - } - - impl RecordingLogger { - fn warnings(&self) -> Vec<(String, String)> { - self.warnings - .lock() - .expect("recording logger mutex must not be poisoned") - .clone() - } - } - - impl Logger for RecordingLogger { - fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - - fn warn(&self, event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { - self.warnings - .lock() - .expect("recording logger mutex must not be poisoned") - .push((event_id.to_string(), message.to_string())); - } - - fn error(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - - fn log_cli_error(&self, _: &crate::services::error::CliError, _: Option<&str>) {} - } - - fn tool_event_json(event_name: &str, overrides: &[(&str, Value)]) -> String { - let mut merged: Vec<(&str, Value)> = - vec![(HOOK_EVENT_NAME_FIELD, Value::String(event_name.to_string()))]; - merged.extend( - overrides - .iter() - .map(|(field, value)| (*field, value.clone())), - ); - pre_tool_use_json(&merged) - } - - fn post_tool_use_json(overrides: &[(&str, Value)]) -> String { - tool_event_json(HOOK_EVENT_POST_TOOL_USE, overrides) - } - - fn turn_scoped_payload(event_name: &str, session_id: &str, turn_id: &str) -> String { - json!({ - HOOK_EVENT_NAME_FIELD: event_name, - SESSION_ID_FIELD: session_id, - TURN_ID_FIELD: turn_id, - CWD_FIELD: CWD, - }) - .to_string() - } - - fn subagent_stop_payload(session_id: &str, turn_id: &str, agent_id: &str) -> String { - json!({ - HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SUBAGENT_STOP, - SESSION_ID_FIELD: session_id, - TURN_ID_FIELD: turn_id, - CWD_FIELD: CWD, - AGENT_ID_FIELD: agent_id, - }) - .to_string() - } - - fn session_end_payload(session_id: &str) -> String { - json!({ - HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SESSION_END, - SESSION_ID_FIELD: session_id, - CWD_FIELD: CWD, - }) - .to_string() - } - - fn read_state(git_dir: &Path) -> state::AdapterState { - state::read_state(git_dir).expect("adapter state should be readable") - } - - const DRIVER_TURN: &str = "turn-1"; - - fn seed_attempt( - git_dir: &Path, - session_id: &str, - agent_id: Option<&str>, - tool_use_id: &str, - phase: state::AttemptPhase, - ) -> state::AdapterAttempt { - seed_attempt_in_turn( - git_dir, - session_id, - DRIVER_TURN, - agent_id, - tool_use_id, - phase, - ) - } - - fn seed_attempt_in_turn( - git_dir: &Path, - session_id: &str, - turn_id: &str, - agent_id: Option<&str>, - tool_use_id: &str, - phase: state::AttemptPhase, - ) -> state::AdapterAttempt { - state::seed_attempt_for_tests( - git_dir, - &AttemptKey { - session_id: session_id.to_string(), - agent_id: agent_id.map(str::to_string), - tool_use_id: tool_use_id.to_string(), - }, - turn_id, - "Bash", - phase, - ) - } - - fn drive( - payload: &str, - resolver: &(impl Fn(&str) -> Result + ?Sized), - seam: &(impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + ?Sized), - ) -> String { - run_codex_mutation_scope_from_payload_with(payload, None, &resolver, &seam) - .expect("driver should return Ok") - } - - #[test] - fn untracked_mcp_pre_tool_use_creates_no_scope_and_never_touches_seam_or_git_dir() { - let payload = pre_tool_use_json(&[( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_success".to_string()), - )]); - let output = drive(&payload, &panicking_resolver, &unreachable_seam); - assert_eq!(output, ""); - } - - #[test] - fn unknown_and_delegation_pre_tool_use_create_no_scope_ac3() { - for tool in [ - "some_future_codex_tool", - "collaborationspawn_agent", - "collaborationwait_agent", - ] { - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - assert_eq!(drive(&payload, &panicking_resolver, &unreachable_seam), ""); - } - } - - #[test] - fn untracked_pre_tool_use_leaves_the_state_store_untouched_ac9b() { - let git_dir = unique_test_git_dir("untracked-state-untouched"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - - for tool in ["mcp__probe__mutate_success", "some_future_codex_tool"] { - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - drive(&payload, &resolver, &ok_seam); - } - - assert!(read_state(&git_dir).attempts.is_empty()); - assert!(read_state(&git_dir).recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn tracked_pre_tool_use_writes_ahead_start_then_returns_continue_ac6() { - let git_dir = unique_test_git_dir("tracked-write-ahead"); - let resolver = fixed_resolver(git_dir.clone()); - - let seen: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - let phase_is_pending = state::read_state(&git_dir) - .expect("state readable inside seam") - .attempts - .first() - .is_some_and(|attempt| attempt.phase == state::AttemptPhase::PendingStart); - seen.borrow_mut() - .push((payload.to_string(), root == Path::new(CWD))); - assert!( - phase_is_pending, - "AC6: Start driven while attempt is PendingStart" - ); - Ok(String::new()) - }; - - let output = drive(&pre_tool_use_json(&[]), &resolver, &seam); - assert_eq!(output, ""); - - let calls = seen.into_inner(); - assert_eq!(calls.len(), 1); - assert!(calls[0].0.contains(r#""operation":"start""#)); - assert!(calls[0].0.contains(r#""actor_kind":"codex""#)); - assert!( - calls[0].1, - "AC6: the seam receives the raw hook cwd as repository_root" - ); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - fn boundary_payload_field(payload: &str, field: &str) -> Option { - let object: Map = - serde_json::from_str(payload).expect("a boundary payload is a JSON object"); - object.get(field).cloned() - } - - fn start_provenance(payload: &str) -> Value { - assert_eq!( - boundary_payload_field(payload, "operation"), - Some(Value::String("start".to_string())) - ); - boundary_payload_field(payload, PROVENANCE_FIELD) - .expect("a Codex start payload carries provenance") - } - - fn drive_recording_start(label: &str, payload: &str) -> Vec { - let git_dir = unique_test_git_dir(label); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - drive(payload, &resolver, &recording_seam(Arc::clone(&recorded))); - - let calls = recorded.lock().expect("recording seam mutex").clone(); - remove_test_git_dir(&git_dir); - calls - } - - #[test] - fn ac3_tracked_start_carries_scope_provenance_for_both_tracked_tools() { - for tool in TRACKED_MUTATION_TOOL_NAMES { - let payload = pre_tool_use_json(&[ - (TOOL_NAME_FIELD, Value::String((*tool).to_string())), - (MODEL_FIELD, Value::String("gpt-5.6-sol".to_string())), - ]); - let calls = drive_recording_start(&format!("provenance-{tool}"), &payload); - - assert_eq!(calls.len(), 1, "{tool} should drive exactly one boundary"); - assert_eq!( - start_provenance(&calls[0]), - json!({ "session_id": "cx_session-1", "model_id": "gpt-5.6-sol" }), - "AC3: {tool} must carry its canonical session and normalized model" - ); - } - } - - #[test] - fn ac3_a_start_without_a_usable_model_still_carries_its_session() { - let cases: [(&str, Option); 4] = [ - ("absent", None), - ("null", Some(Value::Null)), - ("blank", Some(Value::String(" ".to_string()))), - ("non-string", Some(Value::Bool(true))), - ]; - - for (label, model) in cases { - let overrides = model.map_or_else(Vec::new, |value| vec![(MODEL_FIELD, value)]); - let payload = pre_tool_use_json(&overrides); - let calls = drive_recording_start(&format!("provenance-model-{label}"), &payload); - - assert_eq!(calls.len(), 1, "{label} should drive exactly one boundary"); - assert_eq!( - start_provenance(&calls[0]), - json!({ "session_id": "cx_session-1", "model_id": Value::Null }), - "AC3: a {label} model records no model without losing the session" - ); - } - } - - #[test] - fn ac3_only_the_start_boundary_carries_provenance() { - let git_dir = unique_test_git_dir("provenance-start-only"); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let seam = recording_seam(Arc::clone(&recorded)); - - drive(&pre_tool_use_json(&[]), &resolver, &seam); - drive(&post_tool_use_json(&[]), &resolver, &seam); - - let calls = recorded.lock().expect("recording seam mutex").clone(); - assert_eq!(calls.len(), 2); - assert!(boundary_payload_field(&calls[0], PROVENANCE_FIELD).is_some()); - assert_eq!( - boundary_payload_field(&calls[1], "operation"), - Some(Value::String("close".to_string())) - ); - assert_eq!(boundary_payload_field(&calls[1], PROVENANCE_FIELD), None); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn duplicate_pre_tool_use_reuses_the_same_scope_id_ac4_test_e() { - let git_dir = unique_test_git_dir("duplicate-pre"); - let resolver = fixed_resolver(git_dir.clone()); - - drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); - let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); - - drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); - - let attempts = read_state(&git_dir).attempts; - assert_eq!( - attempts.len(), - 1, - "AC4/Test E: a replay must not fork a new attempt" - ); - assert_eq!(attempts[0].scope_id, scope_id); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn resolver_failure_denies_with_stable_reason_and_logs_the_detail_ac7() { - let logger = RecordingLogger::default(); - let resolver = |_: &str| -> Result { - Err(anyhow!("boom: git rev-parse --git-dir failed")) - }; - - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[]), - Some(&logger), - &resolver, - &unreachable_seam, - ) - .expect("a resolver failure must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - assert!(!output.contains("boom")); - assert!(!output.contains("allow")); - - let warnings = logger.warnings(); - assert_eq!(warnings.len(), 1); - assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); - assert!(warnings[0].1.contains("boom")); - } - - #[test] - fn start_seam_failure_denies_and_leaves_the_pending_start_attempt_as_a_barrier_ac7() { - let git_dir = unique_test_git_dir("start-seam-failure"); - let resolver = fixed_resolver(git_dir.clone()); - let logger = RecordingLogger::default(); - let seam = seam_failing_on("start"); - - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[]), - Some(&logger), - &resolver, - &seam, - ) - .expect("a Start failure must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - assert!(!logger.warnings().is_empty()); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!( - final_state.attempts[0].phase, - state::AttemptPhase::PendingStart - ); - - let successor = pre_tool_use_json(&[ - ( - TOOL_USE_ID_FIELD, - Value::String("exec-successor".to_string()), - ), - (TURN_ID_FIELD, Value::String("turn-2".to_string())), - ]); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &successor, - None, - &resolver, - &unreachable_seam, - ) - .expect("successor must return Ok with a deny payload"), - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "I5: an unresolved PendingStart in another lane must block a successor Start", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn delegation_and_untracked_pre_tool_use_are_never_fail_closed_ac7() { - let resolver = |_: &str| -> Result { Err(anyhow!("must not be called")) }; - for tool in [ - "mcp__probe__mutate_success", - "some_future_codex_tool", - "collaborationspawn_agent", - ] { - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("non-tracked PreToolUse should succeed"), - "", - ); - } - } - - #[test] - fn successful_close_removes_the_attempt_ac8() { - let git_dir = unique_test_git_dir("close-success"); - let resolver = fixed_resolver(git_dir.clone()); - - drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); - - let seen: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - assert_eq!(drive(&post_tool_use_json(&[]), &resolver, &seam), ""); - - let calls = seen.into_inner(); - assert_eq!(calls.len(), 1); - assert!(calls[0].contains(r#""operation":"close""#)); - assert!(read_state(&git_dir).attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn pending_start_close_abandons_rather_than_late_starting_d11() { - let git_dir = unique_test_git_dir("pending-start-close"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-1", - state::AttemptPhase::PendingStart, - ); - - let seen: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - drive(&post_tool_use_json(&[]), &resolver, &seam); - - let calls = seen.into_inner(); - assert_eq!(calls.len(), 1); - assert!(calls[0].contains(r#""operation":"abandon""#)); - - let final_state = read_state(&git_dir); - assert!(final_state.attempts.is_empty()); - assert!(!final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn failed_close_abandons_and_arms_recovery_ac13() { - let git_dir = unique_test_git_dir("failed-close"); - let resolver = fixed_resolver(git_dir.clone()); - - drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); - - let seam = seam_failing_on("close"); - drive(&post_tool_use_json(&[]), &resolver, &seam); - - let final_state = read_state(&git_dir); - assert!(final_state.attempts.is_empty()); - assert!( - !final_state.recovery.is_clear(), - "D11: a failed Close arms recovery" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn failed_close_and_failed_abandon_keep_the_attempt_tracked_and_recovery_armed_d11() { - let git_dir = unique_test_git_dir("failed-close-and-abandon"); - let resolver = fixed_resolver(git_dir.clone()); - - drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); - - let seam = seam_failing_on_any(vec!["close", "abandon"]); - let error = run_codex_mutation_scope_from_payload_with( - &post_tool_use_json(&[]), - None, - &resolver, - &seam, - ) - .expect_err("a failed Close then failed Abandon must propagate"); - assert!(error.to_string().contains("abandon")); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert!(!final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn post_tool_use_with_no_matching_attempt_is_a_noop() { - let git_dir = unique_test_git_dir("close-no-attempt"); - let resolver = fixed_resolver(git_dir.clone()); - - assert_eq!( - drive( - &post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Bash".to_string()),)]), - &resolver, - &unreachable_seam, - ), - "", - ); - assert!(read_state(&git_dir).attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn stop_sweeps_only_main_thread_attempts_d12() { - let git_dir = unique_test_git_dir("stop-sweep"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-1", - Some("agent-1"), - "exec-agent", - state::AttemptPhase::Active, - ); - - drive( - &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), - &resolver, - &ok_seam, - ); - - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].tool_use_id, "exec-agent"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn interrupt_sweeps_every_attempt_for_the_session_d12() { - let git_dir = unique_test_git_dir("interrupt-sweep"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-1", - Some("agent-1"), - "exec-agent", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-2", - None, - "exec-other", - state::AttemptPhase::Active, - ); - - drive( - &turn_scoped_payload(HOOK_EVENT_INTERRUPT, "session-1", "turn-1"), - &resolver, - &ok_seam, - ); - - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].session_id, "session-2"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn subagent_stop_sweeps_only_the_matching_agent_d12() { - let git_dir = unique_test_git_dir("subagent-stop-sweep"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - Some("agent-a"), - "exec-a", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-1", - Some("agent-b"), - "exec-b", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - - drive( - &subagent_stop_payload("session-1", "turn-1", "agent-a"), - &resolver, - &ok_seam, - ); - - let mut remaining: Vec = read_state(&git_dir) - .attempts - .into_iter() - .map(|attempt| attempt.tool_use_id) - .collect(); - remaining.sort(); - assert_eq!( - remaining, - vec!["exec-b".to_string(), "exec-main".to_string()] - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn session_end_sweeps_every_attempt_for_the_session_d12() { - let git_dir = unique_test_git_dir("session-end-sweep"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-1", - Some("agent-1"), - "exec-agent", - state::AttemptPhase::Active, - ); - - drive(&session_end_payload("session-1"), &resolver, &ok_seam); - - assert!(read_state(&git_dir).attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn lifecycle_cleanup_with_a_failed_abandon_keeps_the_attempt_tracked_d12() { - let git_dir = unique_test_git_dir("sweep-failed-abandon"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - - let seam = seam_failing_on("abandon"); - let error = run_codex_mutation_scope_from_payload_with( - &session_end_payload("session-1"), - None, - &resolver, - &seam, - ) - .expect_err("a failed abandonment during cleanup must propagate"); - assert!(error.to_string().contains("abandon")); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert!(!final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn pending_non_empty_recovery_denies_unrelated_admission_without_losing_the_recovery_path() - { - use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; - - let git_dir = unique_test_git_dir("health-recovering-unrelated-denied"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - - let seam = seam_failing_on("abandon"); - let error = run_codex_mutation_scope_from_payload_with( - &session_end_payload("session-1"), - None, - &resolver, - &seam, - ) - .expect_err("a failed abandonment during cleanup must propagate"); - assert!(error.to_string().contains("abandon")); - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Recovering, - "a stuck non-empty attempt with recovery armed is Recovering: unrelated \ - admission stays fail-closed, but a same-lane successor can still retry \ - the stale predecessor's abandonment" - ); - - for attempt_number in 1..=2 { - let output = drive( - &pre_tool_use_json(&[ - (SESSION_ID_FIELD, Value::String("session-2".to_string())), - (TURN_ID_FIELD, Value::String("turn-2".to_string())), - ( - TOOL_USE_ID_FIELD, - Value::String("exec-unrelated".to_string()), - ), - ]), - &resolver, - &unreachable_seam, - ); - assert_eq!( - output, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "PreToolUse call #{attempt_number} for an unrelated session must still be denied" - ); - } - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Recovering, - "the classifier must still report Recovering after repeated denial from an \ - unrelated session; Recovering does not mean every future call succeeds, only \ - that a proven normal self-healing route exists" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn same_lane_successor_retries_abandon_and_reaches_healthy_after_a_failed_lifecycle_abandon_ac4( - ) { - use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; - - let git_dir = unique_test_git_dir("health-same-lane-self-heal"); - let resolver = fixed_resolver(git_dir.clone()); - - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - - let failing_abandon_seam = seam_failing_on("abandon"); - let output_b = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]), - &resolver, - &failing_abandon_seam, - ); - assert_eq!( - output_b, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "B fails closed when the same-lane sweep's retried abandon of A fails" - ); - - let after_b = read_state(&git_dir); - assert_eq!( - after_b.attempts.len(), - 1, - "A remains persisted after the failed same-lane sweep" - ); - assert_eq!(after_b.attempts[0].tool_use_id, "exec-a"); - assert!(!after_b.recovery.is_clear()); - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Recovering - ); - - let seam_calls = Arc::new(Mutex::new(Vec::new())); - let recording = recording_seam(Arc::clone(&seam_calls)); - let output_c = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-c".to_string()))]), - &resolver, - &recording, - ); - assert_eq!( - output_c, "", - "C's tracked Start proceeds once the same-lane sweep clears A and the \ - quiescent flush completes" - ); - - let operations: Vec = seam_calls - .lock() - .expect("recording seam mutex") - .iter() - .filter_map(|payload| { - serde_json::from_str::(payload) - .ok() - .and_then(|value| { - value - .get("operation") - .and_then(Value::as_str) - .map(str::to_string) - }) - }) - .collect(); - let abandon_index = operations - .iter() - .position(|operation| operation == "abandon"); - let flush_index = operations.iter().position(|operation| operation == "flush"); - let start_index = operations.iter().position(|operation| operation == "start"); - assert!( - abandon_index.is_some() && flush_index.is_some() && start_index.is_some(), - "expected abandon(A), flush, and start(C) seam calls, got {operations:?}" - ); - assert!( - abandon_index < flush_index && flush_index < start_index, - "expected abandon(A) -> flush -> start(C) ordering, got {operations:?}" - ); - - let final_state = read_state(&git_dir); - assert!(final_state.recovery.is_clear()); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-c"); - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Healthy - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn health_classifies_recovering_then_healthy_once_the_next_pre_tool_use_flushes_ac4() { - use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; - - let git_dir = unique_test_git_dir("health-recovering-then-healthy"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Recovering - ); - - let output = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), - &resolver, - &ok_seam, - ); - assert_eq!(output, ""); - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Healthy, - "a successful flush clears recovery and returns the adapter to Healthy" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_denies_new_tracked_pre_tool_use_while_attempts_remain_ac12() { - let git_dir = unique_test_git_dir("barrier-attempts-remain"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-other", - None, - "exec-live", - state::AttemptPhase::Active, - ); - state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); - - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), - None, - &resolver, - &unreachable_seam, - ) - .expect("the barrier denial still returns Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_does_not_affect_untracked_pre_tool_use_ac12_test_f() { - let git_dir = unique_test_git_dir("barrier-untracked-unaffected"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-live", - state::AttemptPhase::Active, - ); - state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); - - for tool in [ - "mcp__probe__mutate_success", - "some_future_codex_tool", - "collaborationspawn_agent", - ] { - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("an untracked PreToolUse ignores the barrier"), - "", - "Test F: recovery must never deny an untracked tool", - ); - } - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_flushes_once_quiescent_then_starts_ac12() { - let git_dir = unique_test_git_dir("barrier-flush-success"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); - - let seen: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - - let output = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), - &resolver, - &seam, - ); - assert_eq!(output, ""); - - let operations = seen.into_inner(); - assert_eq!( - operations.len(), - 2, - "expected flush then start, got {operations:?}" - ); - assert!(operations[0].contains(r#""operation":"flush""#)); - assert!(operations[1].contains(r#""operation":"start""#)); - - let final_state = read_state(&git_dir); - assert!( - final_state.recovery.is_clear(), - "a successful flush clears the barrier" - ); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_stays_closed_when_flush_fails_ac12() { - let git_dir = unique_test_git_dir("barrier-flush-failure"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - let generation = - state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); - - let seam = seam_failing_on("flush"); - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), - None, - &resolver, - &seam, - ) - .expect("a failed flush still returns Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - let final_state = read_state(&git_dir); - assert_eq!( - final_state.recovery, - state::RecoveryState::Pending { generation }, - "a failed flush hands the generation back as Pending so a later PreToolUse retries", - ); - assert!(final_state.attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn mcp_mutate_then_error_leaves_no_stale_state_ac9c() { - let git_dir = unique_test_git_dir("mcp-mutate-then-error"); - let resolver = fixed_resolver(git_dir.clone()); - - let mcp_pre = pre_tool_use_json(&[ - ( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_then_error".to_string()), - ), - (TOOL_USE_ID_FIELD, Value::String("exec-mcp".to_string())), - ]); - drive(&mcp_pre, &resolver, &unreachable_seam); - assert!(read_state(&git_dir).attempts.is_empty()); - - drive( - &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), - &resolver, - &ok_seam, - ); - drive(&session_end_payload("session-1"), &resolver, &ok_seam); - - let final_state = read_state(&git_dir); - assert!(final_state.attempts.is_empty()); - assert!( - final_state.recovery.is_clear(), - "AC9c: no Start => no abandon => recovery stays clear" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn failed_mcp_then_tracked_successor_starts_clean_ac9d() { - let git_dir = unique_test_git_dir("mcp-then-tracked"); - let resolver = fixed_resolver(git_dir.clone()); - - let mcp_a = pre_tool_use_json(&[ - ( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_then_error".to_string()), - ), - (TOOL_USE_ID_FIELD, Value::String("exec-a".to_string())), - ]); - let bash_b = pre_tool_use_json(&[ - (TOOL_NAME_FIELD, Value::String("Bash".to_string())), - (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), - ]); - - drive(&mcp_a, &resolver, &unreachable_seam); - drive(&bash_b, &resolver, &ok_seam); - - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].tool_use_id, "exec-b"); - assert_eq!(attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn parallel_mcp_executions_create_no_scopes_ac9e() { - let git_dir = unique_test_git_dir("parallel-mcp"); - let resolver = fixed_resolver(git_dir.clone()); - - for tool_use_id in ["exec-par-a", "exec-par-b"] { - let payload = pre_tool_use_json(&[ - ( - TOOL_NAME_FIELD, - Value::String("mcp__probe_par__slow_mutate".to_string()), - ), - (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), - ]); - drive(&payload, &resolver, &unreachable_seam); - assert!(read_state(&git_dir).attempts.is_empty()); - } - - let final_state = read_state(&git_dir); - assert!(final_state.attempts.is_empty()); - assert!(final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn builtin_failed_a_then_b_never_leaves_a_zombie_scope_ac9a() { - let git_dir = unique_test_git_dir("builtin-failed-a-then-b"); - let resolver = fixed_resolver(git_dir.clone()); - - let predecessor_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]); - let predecessor_post = - post_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]); - let successor_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]); - - drive(&predecessor_pre, &resolver, &ok_seam); - drive(&predecessor_post, &resolver, &ok_seam); - drive(&successor_pre, &resolver, &ok_seam); - - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].tool_use_id, "exec-b"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn malformed_payload_propagates_as_a_real_error_not_fail_open() { - let error = run_codex_mutation_scope_from_payload("not json", None).unwrap_err(); - assert!(error.to_string().contains("valid JSON")); - } - - #[test] - fn unsupported_event_name_propagates_as_a_real_error() { - let payload = json!({ - HOOK_EVENT_NAME_FIELD: "UserPromptSubmit", - SESSION_ID_FIELD: "session-1", - CWD_FIELD: CWD, - }) - .to_string(); - let error = run_codex_mutation_scope_from_payload(&payload, None).unwrap_err(); - assert!(error.to_string().contains("unsupported hook_event_name")); - } - - fn spawn_pre_tool_use( - git_dir: &Path, - tool_use_id: &'static str, - seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, - ) -> (thread::JoinHandle, mpsc::Receiver<()>) { - spawn_pre_tool_use_in_turn(git_dir, tool_use_id, DRIVER_TURN, seam) - } - - fn spawn_pre_tool_use_in_turn( - git_dir: &Path, - tool_use_id: &'static str, - turn_id: &'static str, - seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, - ) -> (thread::JoinHandle, mpsc::Receiver<()>) { - let (done_tx, done_rx) = mpsc::channel(); - let resolver = fixed_resolver(git_dir.to_path_buf()); - let handle = thread::spawn(move || { - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), - (TURN_ID_FIELD, Value::String(turn_id.to_string())), - ]), - None, - &resolver, - &seam, - ) - .expect("PreToolUse should return Ok"); - let _ = done_tx.send(()); - output - }); - (handle, done_rx) - } - - fn assert_still_blocked(done_rx: &mpsc::Receiver<()>, context: &str) { - assert!( - done_rx.recv_timeout(Duration::from_millis(250)).is_err(), - "{context}: the operation must still be blocked on the boundary lock", - ); - } - - fn first_index_of(recorded: &[String], operation: &str) -> Option { - recorded - .iter() - .position(|payload| payload.contains(&format!(r#""operation":"{operation}""#))) - } - - #[test] - fn test_h_cleanup_owning_the_boundary_lock_blocks_admission_until_recovery_is_processed() { - let git_dir = unique_test_git_dir("test-h-cleanup-owns-boundary"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (abandon_seam, gate) = gated_seam("abandon", Arc::clone(&recorded)); - - let sweeper = { - let resolver = fixed_resolver(git_dir.clone()); - thread::spawn(move || { - run_codex_mutation_scope_from_payload_with( - &session_end_payload("session-1"), - None, - &resolver, - &abandon_seam, - ) - .expect("SessionEnd cleanup should succeed") - }) - }; - - gate.wait_until_entered(); - assert_eq!( - read_state(&git_dir).recovery, - state::RecoveryState::Pending { generation: 1 }, - "cleanup arms recovery while it owns the boundary lock", - ); - - let (b_handle, b_done) = - spawn_pre_tool_use(&git_dir, "exec-b", recording_seam(Arc::clone(&recorded))); - assert_still_blocked(&b_done, "Test H"); - assert!( - first_index_of(&recorded.lock().unwrap(), "start").is_none(), - "Test H: B must not reach Start while cleanup owns the boundary lock", - ); - - gate.release(); - sweeper.join().expect("sweeper thread should not panic"); - - let b_output = b_handle.join().expect("B thread should not panic"); - assert_eq!( - b_output, "", - "Test H: once recovery is processed B proceeds" - ); - - let recorded = recorded.lock().unwrap().clone(); - let abandon_at = - first_index_of(&recorded, "abandon").expect("cleanup abandoned exec-a"); - let flush_at = first_index_of(&recorded, "flush").expect("B drove the quiescent flush"); - let start_at = first_index_of(&recorded, "start").expect("B reached Start"); - assert!( - abandon_at < flush_at && flush_at < start_at, - "Test H: the serialized order must be abandon -> flush -> start, got {recorded:?}", - ); - - let final_state = read_state(&git_dir); - assert!(final_state.recovery.is_clear()); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_g_admission_completed_recovery_cannot_arm_before_start() { - let git_dir = unique_test_git_dir("test-g-admit-before-start"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (start_seam, gate) = gated_seam("start", Arc::clone(&recorded)); - - let p1 = { - let resolver = fixed_resolver(git_dir.clone()); - thread::spawn(move || { - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String("exec-b".to_string()), - )]), - None, - &resolver, - &start_seam, - ) - .expect("P1 PreToolUse should return Ok") - }) - }; - - gate.wait_until_entered(); - let mid = read_state(&git_dir); - assert_eq!(mid.attempts.len(), 1); - assert_eq!(mid.attempts[0].phase, state::AttemptPhase::PendingStart); - assert!( - mid.recovery.is_clear(), - "recovery must still be Clear while P1 holds the boundary lock pre-Start", - ); - - let (p2_handle, p2_done) = spawn_pre_tool_use( - &git_dir, - "exec-cleanup-trigger", - recording_seam(Arc::clone(&recorded)), - ); - - let sweeper = { - let resolver = fixed_resolver(git_dir.clone()); - let recorded = Arc::clone(&recorded); - thread::spawn(move || { - let seam = recording_seam(recorded); - run_codex_mutation_scope_from_payload_with( - &session_end_payload("session-1"), - None, - &resolver, - &seam, - ) - .expect("SessionEnd cleanup should return Ok") - }) - }; - - assert_still_blocked(&p2_done, "Test G"); - assert!( - read_state(&git_dir).recovery.is_clear(), - "Test G: no concurrent process may arm recovery between admit(B) and Start(B)", - ); - - gate.release(); - assert_eq!(p1.join().expect("P1 should not panic"), ""); - sweeper.join().expect("sweeper should not panic"); - p2_handle.join().expect("P2 should not panic"); - - let recorded = recorded.lock().unwrap().clone(); - let start_at = first_index_of(&recorded, "start").expect("P1 drove Start(B)"); - if let Some(abandon_at) = first_index_of(&recorded, "abandon") { - assert!( - start_at < abandon_at, - "Test G: Start(B) must be serialized before any later abandon, got {recorded:?}", - ); - } - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_j_a_live_flush_owner_is_never_reclaimed_by_a_blocked_process() { - let git_dir = unique_test_git_dir("test-j-live-flush-owner"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - state::arm_recovery(&git_dir).expect("arm recovery"); - - let flush_count = Arc::new(AtomicUsize::new(0)); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (owner_gated, gate) = gated_seam("flush", Arc::clone(&recorded)); - - let owner = { - let resolver = fixed_resolver(git_dir.clone()); - let flush_count = Arc::clone(&flush_count); - thread::spawn(move || { - let seam = move |root: &Path, - payload: &str, - logger: Option<&dyn Logger>| - -> Result { - if payload.contains(r#""operation":"flush""#) { - flush_count.fetch_add(1, Ordering::SeqCst); - } - owner_gated(root, payload, logger) - }; - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String("exec-owner".to_string()), - )]), - None, - &resolver, - &seam, - ) - .expect("owner PreToolUse should return Ok") - }) - }; - - gate.wait_until_entered(); - assert_eq!( - read_state(&git_dir).recovery, - state::RecoveryState::Flushing { generation: 1 }, - ); - - let flush_count_p2 = Arc::clone(&flush_count); - let (p2_handle, p2_done) = - spawn_pre_tool_use_in_turn(&git_dir, "exec-2", "turn-2", move |_r, payload, _l| { - if payload.contains(r#""operation":"flush""#) { - flush_count_p2.fetch_add(1, Ordering::SeqCst); - } - Ok(String::new()) - }); - - assert_still_blocked(&p2_done, "Test J"); - assert_eq!( - read_state(&git_dir).recovery, - state::RecoveryState::Flushing { generation: 1 }, - "Test J: a blocked process must not reclaim the live owner's Flushing(g)", - ); - - gate.release(); - assert_eq!(owner.join().expect("owner should not panic"), ""); - assert_eq!(p2_handle.join().expect("P2 should not panic"), ""); - - assert_eq!( - flush_count.load(Ordering::SeqCst), - 1, - "Test J: exactly one Flush ran — the live owner's, never a reclaim", - ); - let final_state = read_state(&git_dir); - assert!(final_state.recovery.is_clear()); - assert_eq!(final_state.attempts.len(), 2); - assert!(final_state - .attempts - .iter() - .all(|a| a.phase == state::AttemptPhase::Active)); - - remove_test_git_dir(&git_dir); - } - - fn seed_orphaned_flushing(git_dir: &Path) -> u64 { - let generation = state::arm_recovery(git_dir).expect("arm recovery to seed"); - match state::admit_tracked_attempt( - git_dir, - &key("seed", None, "seed"), - "seed-turn", - "Bash", - ) - .expect("seeding admit should not error") - { - state::AdmitDecision::FlushClaimed { - generation: claimed, - } => { - assert_eq!(claimed, generation); - } - other => panic!("expected FlushClaimed while seeding, got {other:?}"), - } - assert_eq!( - read_state(git_dir).recovery, - state::RecoveryState::Flushing { generation }, - "seed left durable Flushing(g) with no live boundary-lock owner", - ); - generation - } - - #[test] - fn test_i_orphaned_flushing_is_reclaimed_and_flush_is_retried_once() { - let git_dir = unique_test_git_dir("test-i-orphaned-flushing"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let generation = seed_orphaned_flushing(&git_dir); - let next_generation_before = read_state(&git_dir).next_recovery_generation; - - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let resolver = fixed_resolver(git_dir.clone()); - let output = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-x".to_string()))]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(output, ""); - - let ops = recorded.lock().unwrap().clone(); - assert_eq!( - ops.iter() - .filter(|p| p.contains(r#""operation":"flush""#)) - .count(), - 1, - "Test I: exactly one retry Flush for the reclaimed generation, got {ops:?}", - ); - assert!( - first_index_of(&ops, "flush").unwrap() < first_index_of(&ops, "start").unwrap() - ); - - let final_state = read_state(&git_dir); - assert!( - final_state.recovery.is_clear(), - "Test I: no permanent RecoveryBlocked" - ); - assert_eq!( - final_state.next_recovery_generation, next_generation_before, - "Test I: reclaiming Flushing(g) preserves the generation, never bumps it", - ); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-x"); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - let _ = generation; - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_k_crash_after_durable_flush_before_completion_write_converges() { - let git_dir = unique_test_git_dir("test-k-crash-after-flush"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - seed_orphaned_flushing(&git_dir); - - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let resolver = fixed_resolver(git_dir.clone()); - - let first = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-1".to_string()))]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(first, ""); - assert!(read_state(&git_dir).recovery.is_clear()); - - let second = drive( - &pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("exec-2".to_string())), - (TURN_ID_FIELD, Value::String("turn-2".to_string())), - ]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(second, ""); - - let ops = recorded.lock().unwrap().clone(); - assert_eq!( - ops.iter() - .filter(|p| p.contains(r#""operation":"flush""#)) - .count(), - 1, - "Test K: the recovery retry Flush runs exactly once across convergence, got {ops:?}", - ); - let final_state = read_state(&git_dir); - assert!(final_state.recovery.is_clear()); - assert_eq!(final_state.attempts.len(), 2); - assert!(final_state - .attempts - .iter() - .all(|a| a.phase == state::AttemptPhase::Active)); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_l_duplicate_active_delivery_drives_no_second_start() { - let git_dir = unique_test_git_dir("test-l-duplicate-active"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let first = drive( - &pre_tool_use_json(&[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(first, ""); - let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); - assert_eq!( - read_state(&git_dir).attempts[0].phase, - state::AttemptPhase::Active - ); - - let duplicate = drive( - &pre_tool_use_json(&[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(duplicate, ""); - - let ops = recorded.lock().unwrap().clone(); - assert_eq!( - ops.iter() - .filter(|p| p.contains(r#""operation":"start""#)) - .count(), - 1, - "Test L: duplicate delivery of an Active execution drives no second Start, got {ops:?}", - ); - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].scope_id, scope_id); - assert_eq!(read_state(&git_dir).next_attempt_seq, 2); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_m_untracked_tools_never_touch_the_boundary_lock() { - let git_dir = unique_test_git_dir("test-m-untracked-no-boundary"); - let resolver = fixed_resolver(git_dir.clone()); - - for tool in [ - "mcp__probe__mutate_success", - "some_future_codex_tool", - "collaborationspawn_agent", - "collaborationwait_agent", - ] { - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &payload, - None, - &panicking_resolver, - &unreachable_seam, - ) - .expect("an untracked PreToolUse is neutral"), - "", - ); - assert_eq!(drive(&payload, &resolver, &unreachable_seam), ""); - } - - assert!( - !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( - &git_dir - ) - .exists(), - "Test M: no untracked tool may create the adapter boundary lock", - ); - assert!( - !state::adapter_state_dir(&git_dir).exists(), - "Test M: an untracked tool resolves no git dir and touches no adapter state", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn untracked_post_tool_use_never_touches_mutation_scope_machinery() { - let git_dir = unique_test_git_dir("untracked-post-no-footprint"); - let resolver = fixed_resolver(git_dir.clone()); - - for tool in [ - "mcp__probe__mutate_success", - "some_future_codex_tool", - "collaborationspawn_agent", - "collaborationwait_agent", - ] { - let payload = - post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &payload, - None, - &panicking_resolver, - &unreachable_seam, - ) - .expect("an untracked PostToolUse is neutral"), - "", - "untracked PostToolUse for {tool:?} must return neutral", - ); - assert_eq!( - drive(&payload, &resolver, &unreachable_seam), - "", - "untracked PostToolUse for {tool:?} must not call the ingress seam", - ); - } - - assert!( - !state::adapter_state_dir(&git_dir).exists(), - "an untracked PostToolUse resolves no git dir and creates no adapter state directory", - ); - assert!( - !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( - &git_dir - ) - .exists(), - "an untracked PostToolUse must not create the adapter boundary lock", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn a_complete_successful_mcp_lifecycle_leaves_zero_adapter_footprint() { - let git_dir = unique_test_git_dir("mcp-lifecycle-no-footprint"); - let resolver = fixed_resolver(git_dir.clone()); - - let mcp = &[( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_success".to_string()), - )]; - - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(mcp), - None, - &panicking_resolver, - &unreachable_seam, - ) - .expect("MCP PreToolUse is neutral"), - "", - ); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &post_tool_use_json(mcp), - None, - &panicking_resolver, - &unreachable_seam, - ) - .expect("MCP PostToolUse is neutral"), - "", - ); - - assert_eq!( - drive(&pre_tool_use_json(mcp), &resolver, &unreachable_seam), - "" - ); - assert_eq!( - drive(&post_tool_use_json(mcp), &resolver, &unreachable_seam), - "" - ); - - let state = read_state(&git_dir); - assert!(state.attempts.is_empty(), "no attempts recorded"); - assert!(state.recovery.is_clear(), "recovery stays Clear"); - - assert!( - !state::adapter_state_dir(&git_dir).exists(), - "a complete successful MCP lifecycle creates no adapter state directory, \ - state lock, or boundary lock", - ); - assert!( - !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( - &git_dir - ) - .exists(), - "a complete successful MCP lifecycle creates no boundary lock", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_c_recovery_rearmed_while_flush_in_flight_survives_the_stale_completion() { - let git_dir = unique_test_git_dir("race-rearm-during-flush"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - state::arm_recovery(&git_dir).expect("arm g1"); - - let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (flush_seam, gate) = gated_seam("flush", Arc::clone(&calls)); - - let flusher = { - let git_dir = git_dir.clone(); - let resolver = fixed_resolver(git_dir.clone()); - thread::spawn(move || { - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String("exec-flusher".to_string()), - )]), - None, - &resolver, - &flush_seam, - ) - .expect("flusher PreToolUse should return Ok") - }) - }; - - gate.wait_until_entered(); - assert_eq!( - read_state(&git_dir).recovery, - state::RecoveryState::Flushing { generation: 1 }, - ); - - let second_generation = state::arm_recovery(&git_dir).expect("re-arm to g2"); - assert_eq!(second_generation, 2); - - gate.release(); - let flusher_output = flusher.join().expect("flusher thread should not panic"); - assert_eq!( - flusher_output, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "Test C: the flusher denies because recovery was re-armed under it", - ); - - assert_eq!( - read_state(&git_dir).recovery, - state::RecoveryState::Pending { generation: 2 }, - "Test C: the stale Flush(g1) completion must not clear Pending(g2)", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_d_start_succeeds_but_mark_active_fails_blocks_a_successor_until_recovery() { - let git_dir = unique_test_git_dir("start-then-mark-active-fails"); - let resolver = fixed_resolver(git_dir.clone()); - - state::arm_mark_active_failure_for_tests(); - let logger = RecordingLogger::default(); - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]), - Some(&logger), - &resolver, - &ok_seam, - ) - .expect("a mark_active failure still returns Ok with a deny payload"); - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let after_start = read_state(&git_dir); - assert_eq!(after_start.attempts.len(), 1); - assert_eq!( - after_start.attempts[0].phase, - state::AttemptPhase::PendingStart - ); - assert!(after_start.recovery.is_clear()); - - let successor = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), - (TURN_ID_FIELD, Value::String("turn-2".to_string())), - ]); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &successor, - None, - &resolver, - &unreachable_seam, - ) - .expect("successor returns a deny payload"), - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "Test D: an uncertain PendingStart in another lane blocks a successor Start", - ); - - drive(&session_end_payload("session-1"), &resolver, &ok_seam); - assert!(read_state(&git_dir).attempts.is_empty()); - assert!(!read_state(&git_dir).recovery.is_clear()); - - let recording: Arc>> = Arc::new(Mutex::new(Vec::new())); - let seam = recording_seam(Arc::clone(&recording)); - let recovered = drive(&successor, &resolver, &seam); - assert_eq!(recovered, ""); - - let ops = recording.lock().expect("recording mutex").clone(); - assert_eq!(ops.len(), 2, "expected flush then start, got {ops:?}"); - assert!(ops[0].contains(r#""operation":"flush""#)); - assert!(ops[1].contains(r#""operation":"start""#)); - - let final_state = read_state(&git_dir); - assert!(final_state.recovery.is_clear()); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); - - remove_test_git_dir(&git_dir); - } - - fn boundary_lock_exists(git_dir: &Path) -> bool { - crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path(git_dir) - .exists() - } - - #[test] - fn policy_blocked_bash_pre_tool_use_creates_no_mutation_scope_state() { - let git_dir = unique_test_git_dir("policy-blocked-no-scope"); - - let output = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[(TOOL_INPUT_FIELD, json!({"command": "danger --now"}))]), - None, - &panicking_resolver, - &unreachable_seam, - &blocking_bash_policy, - ) - .expect("a policy-blocked Bash PreToolUse still returns Ok"); - - assert_eq!( - output, BLOCKED_BASH_POLICY_RESPONSE, - "a policy block returns the Codex-native policy denial verbatim, \ - never the generic mutation-scope deny", - ); - assert!(!output.contains(FAIL_CLOSED_DENY_REASON)); - assert!( - !state::adapter_state_dir(&git_dir).exists(), - "a policy block must leave no adapter state: no PendingStart, Active, \ - Start, Abandon, Flush, or recovery", - ); - assert!( - !boundary_lock_exists(&git_dir), - "a policy block must not even acquire the boundary lock", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn policy_allowed_bash_pre_tool_use_follows_the_normal_write_ahead_start_path() { - let git_dir = unique_test_git_dir("policy-allowed-start"); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let output = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[]), - None, - &resolver, - &recording_seam(Arc::clone(&recorded)), - &allow_bash_policy, - ) - .expect("an allowed Bash PreToolUse returns Ok"); - assert_eq!(output, ""); - - let ops = recorded.lock().unwrap().clone(); - assert_eq!( - ops.iter() - .filter(|payload| payload.contains(r#""operation":"start""#)) - .count(), - 1, - "an allowed Bash still drives exactly one write-ahead Start, got {ops:?}", - ); - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn policy_evaluation_failure_is_fail_closed_with_no_mutation_scope_state() { - let git_dir = unique_test_git_dir("policy-eval-failure"); - let logger = RecordingLogger::default(); - - let output = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[]), - Some(&logger), - &panicking_resolver, - &unreachable_seam, - &failing_bash_policy, - ) - .expect("a Bash policy evaluation failure still returns Ok"); - - assert_eq!( - output, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "a policy evaluation failure fails closed with the generic mutation-scope deny", - ); - let warnings = logger.warnings(); - assert_eq!(warnings.len(), 1); - assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); - assert!(warnings[0].1.contains("could not be evaluated")); - assert!( - !state::adapter_state_dir(&git_dir).exists(), - "a fail-closed policy evaluation must leave no adapter state", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn apply_patch_pre_tool_use_never_evaluates_bash_policy() { - let git_dir = unique_test_git_dir("apply-patch-no-policy"); - let resolver = fixed_resolver(git_dir.clone()); - - let output = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[ - (TOOL_NAME_FIELD, Value::String("apply_patch".to_string())), - (TOOL_INPUT_FIELD, Value::Null), - ]), - None, - &resolver, - &ok_seam, - &unreachable_bash_policy, - ) - .expect("an apply_patch PreToolUse returns Ok"); - assert_eq!(output, ""); - - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1, "apply_patch still establishes a scope"); - assert_eq!(attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn malformed_bash_tool_input_is_fail_closed_before_the_policy_evaluator_runs() { - let git_dir = unique_test_git_dir("malformed-bash-tool-input"); - let logger = RecordingLogger::default(); - - let output = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[(TOOL_INPUT_FIELD, json!({"not_command": "x"}))]), - Some(&logger), - &panicking_resolver, - &unreachable_seam, - &unreachable_bash_policy, - ) - .expect("a malformed Bash tool_input still returns Ok"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - let warnings = logger.warnings(); - assert_eq!(warnings.len(), 1); - assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); - assert!( - warnings[0].1.contains("tool_input.command"), - "extraction reuses the shared bash_command_from_tool_input semantics", - ); - assert!(!state::adapter_state_dir(&git_dir).exists()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn production_bash_policy_evaluator_blocks_a_repo_denied_command_before_any_start() { - let repo = unique_test_git_dir("prod-policy-regression"); - std::fs::create_dir_all(repo.join(".sce")).expect("create .sce dir"); - std::fs::write( - repo.join(".sce").join("config.json"), - concat!( - r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, - r#""match":{"argv_prefix":["rm"]},"#, - r#""message":"rm is blocked in this repository"}]}}}"#, - ), - ) - .expect("write repo bash policy config"); - - let real_evaluator = - |root: &Path, command: &str| evaluate_codex_bash_policy(root, command); - - let blocked = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[ - ( - CWD_FIELD, - Value::String(repo.to_string_lossy().into_owned()), - ), - (TOOL_INPUT_FIELD, json!({"command": "rm -rf build"})), - ]), - None, - &panicking_resolver, - &unreachable_seam, - &real_evaluator, - ) - .expect("a repo-denied Bash command still returns Ok"); - - assert!(blocked.contains(r#""permissionDecision":"deny""#)); - assert!(blocked.contains("no-rm")); - assert!(blocked.contains("rm is blocked in this repository")); - assert!( - !blocked.contains(FAIL_CLOSED_DENY_REASON), - "a real policy block keeps the policy-specific UX, not the generic deny", - ); - - let allowed = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[ - ( - CWD_FIELD, - Value::String(repo.to_string_lossy().into_owned()), - ), - (TOOL_INPUT_FIELD, json!({"command": "echo ok > ok.txt"})), - (TOOL_USE_ID_FIELD, Value::String("exec-allowed".to_string())), - ]), - None, - &fixed_resolver(repo.join(".git")), - &ok_seam, - &real_evaluator, - ) - .expect("an allowed Bash command still returns Ok"); - assert_eq!( - allowed, "", - "the same repo config lets a non-denied command through to the normal path", - ); - - remove_test_git_dir(&repo); - } - - fn operations(recorded: &[String]) -> Vec { - recorded - .iter() - .filter_map(|payload| { - for op in ["start", "close", "abandon", "flush"] { - if payload.contains(&format!(r#""operation":"{op}""#)) { - return Some(op.to_string()); - } - } - None - }) - .collect() - } - - fn pre(tool_use_id: &str, tool_name: &str, overrides: &[(&str, Value)]) -> String { - let mut merged: Vec<(&str, Value)> = vec![ - (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), - (TOOL_NAME_FIELD, Value::String(tool_name.to_string())), - ]; - if tool_name != CODEX_TRACKED_TOOL_BASH { - merged.push((TOOL_INPUT_FIELD, Value::Null)); - } - merged.extend( - overrides - .iter() - .map(|(field, value)| (*field, value.clone())), - ); - pre_tool_use_json(&merged) - } - - fn assert_zombie_then_successor_sweep( - a_tool: &str, - b_tool: &str, - b_overrides: &[(&str, Value)], - ) { - let git_dir = unique_test_git_dir(&format!("zombie-successor-{a_tool}-{b_tool}")); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let started = drive( - &pre("exec-a", a_tool, &[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(started, ""); - assert_eq!( - read_state(&git_dir).attempts[0].phase, - state::AttemptPhase::Active, - ); - - let successor = drive( - &pre("exec-b", b_tool, b_overrides), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(successor, ""); - - let ops = operations(&recorded.lock().unwrap()); - assert_eq!( - ops, - vec![ - "start".to_string(), - "abandon".to_string(), - "flush".to_string(), - "start".to_string(), - ], - "successor sequence must be Start(A) -> Abandon(A) -> Flush -> Start(B), never Start(A) -> Start(B) -> Abandon(A)", - ); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - assert!(final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression1_arbitrary_blocker_zombie_then_tracked_successor() { - assert_zombie_then_successor_sweep("Bash", "Bash", &[]); - } - - #[test] - fn regression2_apply_patch_successor_variants() { - assert_zombie_then_successor_sweep("Bash", "apply_patch", &[]); - assert_zombie_then_successor_sweep("apply_patch", "Bash", &[]); - assert_zombie_then_successor_sweep("apply_patch", "apply_patch", &[]); - } - - #[test] - fn regression3_parent_then_subagent_same_lane_is_swept() { - assert_zombie_then_successor_sweep( - "Bash", - "Bash", - &[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))], - ); - } - - #[test] - fn regression3_subagent_then_parent_same_lane_is_swept() { - let git_dir = unique_test_git_dir("subagent-then-parent"); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - drive( - &pre( - "exec-a", - "Bash", - &[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))], - ), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - drive( - &pre("exec-b", "Bash", &[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - - assert_eq!( - operations(&recorded.lock().unwrap()), - vec![ - "start".to_string(), - "abandon".to_string(), - "flush".to_string(), - "start".to_string(), - ], - ); - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); - assert!(final_state.attempts[0].agent_id.is_none()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression4_different_session_is_not_swept() { - let git_dir = unique_test_git_dir("different-session-not-swept"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let output = drive( - &pre( - "exec-b", - "Bash", - &[(SESSION_ID_FIELD, Value::String("session-2".to_string()))], - ), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(output, ""); - - assert_eq!( - operations(&recorded.lock().unwrap()), - vec!["start".to_string()] - ); - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 2); - assert!(final_state - .attempts - .iter() - .any(|attempt| attempt.tool_use_id == "exec-a")); - assert!(final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression5_different_turn_is_not_swept_by_case_b_inference() { - let git_dir = unique_test_git_dir("different-turn-not-swept"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let output = drive( - &pre( - "exec-b", - "Bash", - &[(TURN_ID_FIELD, Value::String("turn-2".to_string()))], - ), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(output, ""); - - assert_eq!( - operations(&recorded.lock().unwrap()), - vec!["start".to_string()] - ); - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 2); - assert!(final_state - .attempts - .iter() - .any(|attempt| attempt.tool_use_id == "exec-a")); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression6_duplicate_same_attempt_key_is_not_swept() { - let git_dir = unique_test_git_dir("duplicate-not-swept"); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - drive( - &pre("exec-a", "Bash", &[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); - let next_seq = read_state(&git_dir).next_attempt_seq; - - drive( - &pre("exec-a", "Bash", &[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - - assert_eq!( - operations(&recorded.lock().unwrap()), - vec!["start".to_string()] - ); - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].scope_id, scope_id); - assert_eq!(final_state.next_attempt_seq, next_seq); - assert!(final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression7_pending_start_predecessor_is_swept() { - let git_dir = unique_test_git_dir("pending-start-predecessor-swept"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::PendingStart, - ); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let output = drive( - &pre("exec-b", "Bash", &[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(output, ""); - - assert_eq!( - operations(&recorded.lock().unwrap()), - vec![ - "abandon".to_string(), - "flush".to_string(), - "start".to_string(), - ], - ); - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); - assert!(final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression8_abandon_failure_during_sweep_is_fail_closed() { - let git_dir = unique_test_git_dir("sweep-abandon-failure"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - - let output = run_codex_mutation_scope_from_payload_with( - &pre("exec-b", "Bash", &[]), - None, - &resolver, - &seam_failing_on("abandon"), - ) - .expect("a failed sweep abandon still returns Ok with a deny payload"); - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-a"); - assert!(!final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression9_flush_failure_after_sweep_is_fail_closed() { - let git_dir = unique_test_git_dir("sweep-flush-failure"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - - let output = run_codex_mutation_scope_from_payload_with( - &pre("exec-b", "Bash", &[]), - None, - &resolver, - &seam_failing_on("flush"), - ) - .expect("a failed post-sweep flush still returns Ok with a deny payload"); - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let final_state = read_state(&git_dir); - assert!(final_state - .attempts - .iter() - .all(|attempt| attempt.tool_use_id != "exec-b")); - assert!(!final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - } - - mod production_regressions { - use std::fs; - use std::path::{Path, PathBuf}; - use std::process::Command; - - use super::*; - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - use crate::services::mutation_trace::runtime::{resolve_git_dir, resolve_worktree_id}; - use crate::services::mutation_trace::store::decode_revision; - - const PROBE01_APPLY_PATCH_POST: &str = include_str!( - "fixtures/probe01-apply-patch-and-shell-success.apply_patch.post_tool_use.json" - ); - const PROBE02_FAILED_SHELL_PRE: &str = include_str!( - "fixtures/probe02-shell-partial-write-then-nonzero-exit.pre_tool_use.json" - ); - const PROBE06_APPLY_PATCH_FAILURE_PRE: &str = include_str!( - "fixtures/probe06-apply-patch-verification-failure-no-post.pre_tool_use.json" - ); - const PROBE09_DETACHED_PRE: &str = - include_str!("fixtures/probe09-self-detaching-descendant.pre_tool_use.json"); - const PROBE09_DETACHED_POST: &str = - include_str!("fixtures/probe09-self-detaching-descendant.post_tool_use.json"); - const PROBE11_INTERRUPT_PRE: &str = - include_str!("fixtures/probe11-interrupt-event-on-sigint.pre_tool_use.json"); - const PROBE13_MCP_STOP: &str = - include_str!("fixtures/probe13-mcp-mutate-then-error.stop.json"); - const PROBE14_MCP_FAILED_PRE: &str = - include_str!("fixtures/probe14-mcp-failed-then-successor.failed.pre_tool_use.json"); - const PROBE14_MCP_SUCCESSOR_PRE: &str = - include_str!("fixtures/probe14-mcp-failed-then-successor.successor.pre_tool_use.json"); - const PROBE14_MCP_SUCCESSOR_POST: &str = - include_str!("fixtures/probe14-mcp-failed-then-successor.successor.post_tool_use.json"); - const PROBE16_MCP_PARALLEL_A_PRE: &str = - include_str!("fixtures/probe16-mcp-parallel-server-optin.a.pre_tool_use.json"); - const PROBE16_MCP_PARALLEL_A_POST: &str = - include_str!("fixtures/probe16-mcp-parallel-server-optin.a.post_tool_use.json"); - const PROBE16_MCP_PARALLEL_B_PRE: &str = - include_str!("fixtures/probe16-mcp-parallel-server-optin.b.pre_tool_use.json"); - const PROBE16_MCP_PARALLEL_B_POST: &str = - include_str!("fixtures/probe16-mcp-parallel-server-optin.b.post_tool_use.json"); - - const OTHER_HARNESS_ACTOR_KIND: &str = "claude_code"; - - fn git(dir: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).expect("git output should be UTF-8") - } - - struct CodexRepo { - temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - } - - impl CodexRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-codex-mutation-scope-regression-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - temp, - root, - state_root, - } - } - - fn drive(&self, payload: &str) -> Result { - run_codex_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) - } - - fn drive_generic(&self, payload: &str) -> Result { - self.drive_generic_at(&self.root, payload) - } - - fn drive_generic_at(&self, repository_root: &Path, payload: &str) -> Result { - crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( - repository_root, - &self.state_root, - payload, - None, - ) - } - - fn drive_flush(&self) -> Result { - self.drive_generic(&flush_payload()) - } - - fn db(&self) -> RepositoryAgentTraceDb { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &self.root, - &self.state_root, - "codex mutation-scope regression test assertions", - ) - .expect("assertion DB should open") - } - - fn cwd(&self) -> String { - Self::cwd_at(&self.root) - } - - fn cwd_at(root: &Path) -> String { - root.to_string_lossy().into_owned() - } - - fn working_tree_at(root: &Path) -> String { - git(root, &["add", "-A"]); - git(root, &["write-tree"]).trim().to_owned() - } - - fn working_tree(&self) -> String { - Self::working_tree_at(&self.root) - } - - fn git_dir_at(root: &Path) -> PathBuf { - resolve_git_dir(root).expect("git dir should resolve") - } - - fn git_dir(&self) -> PathBuf { - Self::git_dir_at(&self.root) - } - - fn adapter_state_at(root: &Path) -> state::AdapterState { - state::read_state(&Self::git_dir_at(root)) - .expect("adapter state should be readable") - } - - fn adapter_state(&self) -> state::AdapterState { - Self::adapter_state_at(&self.root) - } - - fn adapter_state_file_exists(&self) -> bool { - state::adapter_state_dir(&self.git_dir()) - .join("codex-mutation-scope-state.json") - .exists() - } - - fn worktree_id_at(root: &Path) -> String { - resolve_worktree_id(root) - .expect("worktree id should resolve") - .0 - } - - fn worktree_id(&self) -> String { - Self::worktree_id_at(&self.root) - } - - fn add_worktree(&self, name: &str) -> PathBuf { - let worktree_path = self.temp.path().join(name); - git( - &self.root, - &[ - "worktree", - "add", - "-q", - worktree_path.to_str().expect("utf-8 worktree path"), - ], - ); - worktree_path - } - - fn write(&self, name: &str, contents: &str) { - fs::write(self.root.join(name), contents).expect("regression write should succeed"); - } - - fn live_scope_id(&self) -> String { - let state = self.adapter_state(); - assert_eq!(state.attempts.len(), 1, "exactly one live attempt expected"); - state.attempts[0].scope_id.clone() - } - } - - fn count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { - db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("count query should succeed") - .into_iter() - .next() - .expect("a count row should exist") - } - - fn raw_agent_trace_row_counts(db: &RepositoryAgentTraceDb) -> [i64; 5] { - [ - count(db, "diff_traces"), - count(db, "post_commit_patch_intersections"), - count(db, "agent_traces"), - count(db, "messages"), - count(db, "parts"), - ] - } - - fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { - assert_eq!( - raw_agent_trace_row_counts(db), - [0, 0, 0, 0, 0], - "AC20: the mutation-scope adapter must never write the raw Agent Trace tables" - ); - } - - fn worktree_row( - db: &RepositoryAgentTraceDb, - worktree_id: &str, - ) -> Option<(u64, String, bool)> { - db.query_map( - "SELECT revision, cursor_tree, needs_rebaseline FROM mutation_trace_worktrees \ - WHERE worktree_id = ?1", - (worktree_id,), - |row| { - let blob: Vec = row.get(0).map_err(anyhow::Error::from)?; - let revision = decode_revision(&blob)?; - let cursor_tree = row.get::(1).map_err(anyhow::Error::from)?; - let needs_rebaseline = row.get::(2).map_err(anyhow::Error::from)? != 0; - Ok((revision, cursor_tree, needs_rebaseline)) - }, - ) - .expect("worktree-row query should succeed") - .into_iter() - .next() - } - - fn processed_events(db: &RepositoryAgentTraceDb) -> Vec<(String, String)> { - db.query_map( - "SELECT scope_id, event_id FROM mutation_trace_processed_events \ - ORDER BY scope_id, event_id", - (), - |row| { - let scope_id = row.get::(0).map_err(anyhow::Error::from)?; - let event_id = row.get::(1).map_err(anyhow::Error::from)?; - Ok((scope_id, event_id)) - }, - ) - .expect("processed-events query should succeed") - } - - fn scope_status(db: &RepositoryAgentTraceDb, scope_id: &str) -> Option<(String, String)> { - db.query_map( - "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", - (scope_id,), - |row| { - let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; - let status = row.get::(1).map_err(anyhow::Error::from)?; - Ok((actor_kind, status)) - }, - ) - .expect("scope query should succeed") - .into_iter() - .next() - } - - fn mutation_events_for( - db: &RepositoryAgentTraceDb, - worktree_id: &str, - ) -> Vec<(String, Option, String)> { - db.query_map( - "SELECT attribution_kind, attribution_scope_id, boundary_kind \ - FROM mutation_trace_events WHERE worktree_id = ?1 ORDER BY revision", - (worktree_id,), - |row| { - let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - let boundary_kind = row.get::(2).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id, boundary_kind)) - }, - ) - .expect("mutation-events query should succeed") - } - - fn scope_provenance( - db: &RepositoryAgentTraceDb, - scope_id: &str, - ) -> Option<(String, Option)> { - db.query_map( - "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ - WHERE scope_id = ?1", - (scope_id,), - |row| { - let session_id = row.get::(0).map_err(anyhow::Error::from)?; - let model_id = row.get::>(1).map_err(anyhow::Error::from)?; - Ok((session_id, model_id)) - }, - ) - .expect("scope-provenance query should succeed") - .into_iter() - .next() - } - - fn active_scopes_for(db: &RepositoryAgentTraceDb, worktree_id: &str) -> Vec { - db.query_map( - "SELECT scope_id FROM mutation_trace_event_active_scopes \ - WHERE worktree_id = ?1 ORDER BY revision, scope_id", - (worktree_id,), - |row| row.get::(0).map_err(anyhow::Error::from), - ) - .expect("active-scopes query should succeed") - } - - fn fixture_at(fixture: &str, cwd: &str) -> String { - let mut object: Map = - serde_json::from_str(fixture).expect("a fixture payload is a JSON object"); - object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); - Value::Object(object).to_string() - } - - struct ToolEvent<'a> { - event_name: &'a str, - cwd: &'a str, - session_id: &'a str, - turn_id: &'a str, - tool_name: &'a str, - tool_use_id: &'a str, - agent_id: Option<&'a str>, - } - - fn tool_event_json(event: &ToolEvent, tool_input: Option) -> String { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event.event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(event.session_id.to_string()), - ); - object.insert( - TURN_ID_FIELD.to_string(), - Value::String(event.turn_id.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String(event.cwd.to_string())); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String(event.tool_name.to_string()), - ); - object.insert( - TOOL_USE_ID_FIELD.to_string(), - Value::String(event.tool_use_id.to_string()), - ); - if let Some(agent_id) = event.agent_id { - object.insert( - AGENT_ID_FIELD.to_string(), - Value::String(agent_id.to_string()), - ); - } - if let Some(tool_input) = tool_input { - object.insert(TOOL_INPUT_FIELD.to_string(), tool_input); - } - Value::Object(object).to_string() - } - - struct TrackedCall<'a> { - cwd: &'a str, - session_id: &'a str, - turn_id: &'a str, - tool_name: &'a str, - tool_use_id: &'a str, - agent_id: Option<&'a str>, - } - - impl TrackedCall<'_> { - fn pre(&self) -> String { - tool_event_json( - &ToolEvent { - event_name: HOOK_EVENT_PRE_TOOL_USE, - cwd: self.cwd, - session_id: self.session_id, - turn_id: self.turn_id, - tool_name: self.tool_name, - tool_use_id: self.tool_use_id, - agent_id: self.agent_id, - }, - Some(json!({ "command": "echo regression >> file.txt" })), - ) - } - - fn post(&self) -> String { - tool_event_json( - &ToolEvent { - event_name: HOOK_EVENT_POST_TOOL_USE, - cwd: self.cwd, - session_id: self.session_id, - turn_id: self.turn_id, - tool_name: self.tool_name, - tool_use_id: self.tool_use_id, - agent_id: self.agent_id, - }, - None, - ) - } - } - - fn bash_call<'a>( - cwd: &'a str, - session_id: &'a str, - tool_use_id: &'a str, - ) -> TrackedCall<'a> { - TrackedCall { - cwd, - session_id, - turn_id: "turn-1", - tool_name: CODEX_TRACKED_TOOL_BASH, - tool_use_id, - agent_id: None, - } - } - - fn turn_event_json(event_name: &str, cwd: &str, session_id: &str, turn_id: &str) -> String { - json!({ - HOOK_EVENT_NAME_FIELD: event_name, - SESSION_ID_FIELD: session_id, - TURN_ID_FIELD: turn_id, - CWD_FIELD: cwd, - }) - .to_string() - } - - fn session_end_json(cwd: &str, session_id: &str) -> String { - json!({ - HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SESSION_END, - SESSION_ID_FIELD: session_id, - CWD_FIELD: cwd, - }) - .to_string() - } - - fn other_harness_payload(operation: &str, scope_id: &str) -> String { - json!({ - "operation": operation, - "scope_id": scope_id, - "event_id": format!("{scope_id}|{operation}"), - "actor_kind": OTHER_HARNESS_ACTOR_KIND, - }) - .to_string() - } - - #[test] - fn test1_tracked_bash_success_closes_ai_exclusive_ac8() { - let repo = CodexRepo::new("test1-bash-success"); - let cwd = repo.cwd(); - let pre = fixture_at(PROBE01_SHELL_PRE, &cwd); - let post = fixture_at(PROBE01_SHELL_POST, &cwd); - - assert_eq!( - repo.drive(&pre).expect("PreToolUse should succeed"), - "", - "a tracked PreToolUse that established Start returns the neutral response" - ); - let scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\ntwo\n"); - - assert_eq!(repo.drive(&post).expect("PostToolUse should succeed"), ""); - assert!( - repo.adapter_state().attempts.is_empty(), - "the closed attempt must be removed from adapter bookkeeping" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "closed".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id.clone()), - "close".to_string(), - )] - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(repo.working_tree()) - ); - assert_eq!( - processed_events(&db), - vec![ - (scope_id.clone(), codex_scope_close_event_id(&scope_id)), - (scope_id.clone(), codex_scope_start_event_id(&scope_id)), - ], - "rows are ordered by (scope_id, event_id), and 'close' sorts before 'start'" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test2_failed_bash_partial_write_still_closes_ai_exclusive_ac9() { - let repo = CodexRepo::new("test2-failed-bash"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE02_FAILED_SHELL_PRE, &cwd)) - .expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\npartial\n"); - - assert_eq!( - repo.drive(&fixture_at(PROBE02_FAILED_SHELL_POST, &cwd)) - .expect("a non-zero-exit shell still fires PostToolUse"), - "" - ); - assert!(repo.adapter_state().attempts.is_empty()); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "closed".to_string())), - "D10: a Bash tool that partially mutated then exited non-zero closes its scope" - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id), - "close".to_string(), - )], - "the partial mutation is attributed to the failed tool's own scope" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test3_apply_patch_success_closes_ai_exclusive_ac8() { - let repo = CodexRepo::new("test3-apply-patch-success"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE01_APPLY_PATCH_PRE, &cwd)) - .expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - repo.write("alpha.txt", "alpha one\n"); - - repo.drive(&fixture_at(PROBE01_APPLY_PATCH_POST, &cwd)) - .expect("PostToolUse should succeed"); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "closed".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id), - "close".to_string(), - )] - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(repo.working_tree()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test4_apply_patch_verification_failure_mutates_nothing_and_is_swept_ac9() { - let repo = CodexRepo::new("test4-apply-patch-failure"); - let cwd = repo.cwd(); - let pre = fixture_at(PROBE06_APPLY_PATCH_FAILURE_PRE, &cwd); - let tree_before = repo.working_tree(); - - repo.drive(&pre).expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - let stop = { - let execution = pre_tool_use(&pre); - turn_event_json( - HOOK_EVENT_STOP, - &cwd, - &execution.identity.session_id, - &execution.identity.turn_id, - ) - }; - repo.drive(&stop) - .expect("Stop should retire the attempt that never received PostToolUse"); - - assert!(repo.adapter_state().attempts.is_empty()); - assert!(!repo.adapter_state().recovery.is_clear()); - assert_eq!( - repo.working_tree(), - tree_before, - "D10: apply_patch verification failure never touches the working tree" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "abandoned".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![], - "no mutation happened, so nothing is attributed" - ); - assert!(worktree_row(&db, &worktree_id) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test5_duplicate_tracked_lifecycle_is_idempotent_ac4() { - let repo = CodexRepo::new("test5-duplicate-lifecycle"); - let cwd = repo.cwd(); - let pre = fixture_at(PROBE01_SHELL_PRE, &cwd); - let post = fixture_at(PROBE01_SHELL_POST, &cwd); - - repo.drive(&pre).expect("first PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - assert_eq!( - repo.drive(&pre) - .expect("duplicate PreToolUse should be idempotent"), - "" - ); - assert_eq!( - repo.live_scope_id(), - scope_id, - "AC4: duplicate delivery of a live PreToolUse reuses the same ScopeId" - ); - - repo.write("file.txt", "one\ntwo\n"); - repo.drive(&post).expect("first PostToolUse should succeed"); - - let db = repo.db(); - let (revision_before, events_before, processed_before) = ( - worktree_row(&db, &repo.worktree_id()) - .map(|(revision, _, _)| revision) - .expect("a worktree row should exist"), - count(&db, "mutation_trace_events"), - count(&db, "mutation_trace_processed_events"), - ); - - assert_eq!( - repo.drive(&post) - .expect("duplicate PostToolUse delivery must be a safe no-op"), - "" - ); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), - Some(revision_before) - ); - assert_eq!(count(&db, "mutation_trace_events"), events_before); - assert_eq!( - count(&db, "mutation_trace_processed_events"), - processed_before - ); - assert_eq!( - processed_events(&db) - .into_iter() - .filter(|(scope, event)| scope == &scope_id - && event == &codex_scope_close_event_id(&scope_id)) - .count(), - 1 - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test6_interrupted_tracked_execution_is_retired_by_interrupt_ac11() { - let repo = CodexRepo::new("test6-interrupt"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE11_INTERRUPT_PRE, &cwd)) - .expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\ninterrupted\n"); - - assert_eq!( - repo.drive(&fixture_at(PROBE11_INTERRUPT, &cwd)) - .expect("Interrupt should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "AC11: Interrupt is a proven cleanup signal for the interrupted turn" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "abandoned".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert!(worktree_row(&db, &worktree_id) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - assert_eq!(mutation_events_for(&db, &worktree_id), vec![]); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test6b_session_end_is_the_load_bearing_backstop_ac11() { - let repo = CodexRepo::new("test6b-session-end"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) - .expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\nstranded\n"); - - assert_eq!( - repo.drive(&fixture_at(PROBE01_SESSION_END, &cwd)) - .expect("SessionEnd should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "D12: SessionEnd is the load-bearing whole-session backstop" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "abandoned".to_string())) - ); - assert!(worktree_row(&db, &repo.worktree_id()) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test7_subagent_tracked_tool_gets_its_own_scope_identity() { - let repo = CodexRepo::new("test7-subagent"); - let cwd = repo.cwd(); - let subagent_pre = fixture_at(PROBE08_AGENT_APPLY_PATCH_PRE, &cwd); - let subagent_identity = pre_tool_use(&subagent_pre).identity; - let agent_id = subagent_identity - .agent_id - .clone() - .expect("probe08 carries a delegated-agent identity"); - let main_thread = TrackedCall { - cwd: &cwd, - session_id: &subagent_identity.session_id, - turn_id: "main-turn", - tool_name: CODEX_TRACKED_TOOL_BASH, - tool_use_id: "exec-main-thread", - agent_id: None, - }; - - repo.drive(&main_thread.pre()) - .expect("main-thread PreToolUse should succeed"); - repo.drive(&subagent_pre) - .expect("subagent PreToolUse should succeed"); - - let state = repo.adapter_state(); - assert_eq!(state.attempts.len(), 2); - let subagent_scope_id = state - .attempts - .iter() - .find(|attempt| attempt.agent_id.as_deref() == Some(agent_id.as_str())) - .map(|attempt| attempt.scope_id.clone()) - .expect("the subagent attempt carries its agent_id"); - let main_scope_id = state - .attempts - .iter() - .find(|attempt| attempt.agent_id.is_none()) - .map(|attempt| attempt.scope_id.clone()) - .expect("the main-thread attempt has no agent_id"); - assert_ne!(subagent_scope_id, main_scope_id); - assert!(subagent_scope_id.contains(&agent_id)); - - repo.drive(&fixture_at(PROBE08_SUBAGENT_STOP, &cwd)) - .expect("SubagentStop should succeed"); - - let remaining = repo.adapter_state(); - assert_eq!( - remaining - .attempts - .iter() - .map(|attempt| attempt.scope_id.clone()) - .collect::>(), - vec![main_scope_id.clone()], - "D12: SubagentStop sweeps only the ending agent's attempts" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &subagent_scope_id).map(|(_, status)| status), - Some("abandoned".to_string()) - ); - assert_eq!( - scope_status(&db, &main_scope_id).map(|(_, status)| status), - Some("active".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test8_linked_worktree_advances_only_its_own_cursor_ac14() { - let repo = CodexRepo::new("test8-linked-worktree"); - let worktree_path = repo.add_worktree("codex-worktree"); - let worktree_cwd = CodexRepo::cwd_at(&worktree_path); - - let main_worktree_id = repo.worktree_id(); - let linked_worktree_id = CodexRepo::worktree_id_at(&worktree_path); - assert_ne!(main_worktree_id, linked_worktree_id); - - repo.drive_flush() - .expect("main-checkout baseline flush should succeed"); - let main_cursor_before = worktree_row(&repo.db(), &main_worktree_id) - .map(|(_, cursor_tree, _)| cursor_tree) - .expect("main checkout should have a baseline worktree row"); - - let pre = fixture_at(PROBE10_WORKTREE_PRE, &worktree_cwd); - let post = { - let identity = pre_tool_use(&pre).identity; - tool_event_json( - &ToolEvent { - event_name: HOOK_EVENT_POST_TOOL_USE, - cwd: &worktree_cwd, - session_id: &identity.session_id, - turn_id: &identity.turn_id, - tool_name: &identity.tool_name, - tool_use_id: &identity.tool_use_id, - agent_id: None, - }, - None, - ) - }; - - repo.drive(&pre) - .expect("linked-worktree PreToolUse should succeed"); - let scope_id = CodexRepo::adapter_state_at(&worktree_path).attempts[0] - .scope_id - .clone(); - fs::write(worktree_path.join("wt.txt"), "worktree-write\n") - .expect("the linked worktree's own write should succeed"); - repo.drive(&post) - .expect("linked-worktree PostToolUse should succeed"); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &main_worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(main_cursor_before), - "AC14: the main checkout's cursor must not move" - ); - let linked_row = - worktree_row(&db, &linked_worktree_id).expect("linked worktree row should exist"); - assert_eq!( - linked_row.1, - CodexRepo::working_tree_at(&worktree_path), - "AC14: the linked worktree's own cursor advances" - ); - assert_eq!( - mutation_events_for(&db, &linked_worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id), - "close".to_string(), - )] - ); - assert_eq!(mutation_events_for(&db, &main_worktree_id), vec![]); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test9_successful_mcp_lifecycle_creates_no_mutation_scope_ac9b() { - let repo = CodexRepo::new("test9-mcp-success"); - let cwd = repo.cwd(); - - assert_eq!( - repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) - .expect("an MCP PreToolUse is allowed"), - "", - "AC9b: an Untracked tool gets the Codex-neutral continue response" - ); - repo.write("mcp_a.txt", "written by the MCP server\n"); - assert_eq!( - repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) - .expect("an MCP PostToolUse is ignored"), - "" - ); - - assert!( - !repo.adapter_state_file_exists(), - "AC9b: an Untracked lifecycle writes no adapter bookkeeping at all" - ); - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_events"), 0); - assert_eq!(count(&db, "mutation_trace_processed_events"), 0); - assert_eq!(count(&db, "mutation_trace_worktrees"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test10_mcp_mutate_then_error_leaves_no_zombie_state_ac9c() { - let repo = CodexRepo::new("test10-mcp-mutate-then-error"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE13_MCP_MUTATE_THEN_ERROR_PRE, &cwd)) - .expect("an MCP PreToolUse is allowed"); - repo.write("mcp_b.txt", "mutated before the MCP error\n"); - - repo.drive(&fixture_at(PROBE13_MCP_STOP, &cwd)) - .expect("Stop should find nothing to retire"); - repo.drive(&fixture_at(PROBE13_MCP_SESSION_END, &cwd)) - .expect("SessionEnd should find nothing to retire"); - - assert!( - !repo.adapter_state_file_exists(), - "AC9c: no Start occurred, so there is no stale attempt and no recovery to arm" - ); - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test11_failed_mcp_then_tracked_successor_starts_clean_ac9d() { - let repo = CodexRepo::new("test11-mcp-then-tracked"); - let cwd = repo.cwd(); - let failed_mcp = fixture_at(PROBE14_MCP_FAILED_PRE, &cwd); - let failed_identity = pre_tool_use(&failed_mcp).identity; - - repo.drive(&failed_mcp) - .expect("the failing MCP PreToolUse is allowed"); - repo.write("mcp_c1.txt", "mutated by the failing MCP tool\n"); - repo.drive(&fixture_at(PROBE14_MCP_SUCCESSOR_PRE, &cwd)) - .expect("the MCP successor is also Untracked"); - repo.drive(&fixture_at(PROBE14_MCP_SUCCESSOR_POST, &cwd)) - .expect("the MCP successor's PostToolUse is ignored"); - - let tracked_successor = TrackedCall { - cwd: &cwd, - session_id: &failed_identity.session_id, - turn_id: &failed_identity.turn_id, - tool_name: CODEX_TRACKED_TOOL_BASH, - tool_use_id: "exec-tracked-successor", - agent_id: None, - }; - repo.drive(&tracked_successor.pre()) - .expect("the tracked successor should Start normally"); - let scope_id = repo.live_scope_id(); - assert!( - repo.adapter_state().recovery.is_clear(), - "AC9d: no MCP attempt existed, so no successor barrier runs" - ); - - repo.write("file.txt", "one\ntracked-successor\n"); - repo.drive(&tracked_successor.post()) - .expect("the tracked successor's PostToolUse should close its scope"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!(count(&db, "mutation_trace_scopes"), 1); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id), - "close".to_string(), - )], - "AC9d: the tracked successor is the only live scope — no false AiContended" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test12_parallel_mcp_executions_create_no_scopes_ac9e() { - let repo = CodexRepo::new("test12-parallel-mcp"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_A_PRE, &cwd)) - .expect("parallel MCP A is allowed"); - repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_B_PRE, &cwd)) - .expect("parallel MCP B is allowed"); - assert!( - !repo.adapter_state_file_exists(), - "AC9e: neither overlapping MCP execution creates adapter state" - ); - - repo.write("mcp_parallel_a.txt", "a\n"); - repo.write("mcp_parallel_b.txt", "b\n"); - - repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_A_POST, &cwd)) - .expect("parallel MCP A PostToolUse is ignored"); - repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_B_POST, &cwd)) - .expect("parallel MCP B PostToolUse is ignored"); - - assert!(!repo.adapter_state_file_exists()); - - let db = repo.db(); - assert_eq!( - count(&db, "mutation_trace_scopes"), - 0, - "AC9e: overlapping MCP executions produce no scopes and therefore no AiContended" - ); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test13_tracked_scope_overlapping_an_mcp_mutation_is_tracked_exclusivity_ac9f() { - let repo = CodexRepo::new("test13-tracked-plus-mcp"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) - .expect("the tracked Bash PreToolUse should Start"); - let scope_id = repo.live_scope_id(); - - repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) - .expect("the overlapping MCP call is allowed and untracked"); - repo.write("mcp_a.txt", "written by the MCP server, not by Bash\n"); - repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) - .expect("the MCP PostToolUse is ignored"); - - repo.drive(&fixture_at(PROBE01_SHELL_POST, &cwd)) - .expect("the tracked Bash PostToolUse should close its scope"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!(count(&db, "mutation_trace_scopes"), 1); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id), - "close".to_string(), - )], - "AC9f: ai_exclusive means exactly one TRACKED scope was live in the interval, \ - not that the tracked scope authored every mutation — the MCP call did mutate \ - mcp_a.txt inside this interval and remains unattributed (D14/D23)" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test14_unknown_tool_is_allowed_untracked_ac9b() { - let repo = CodexRepo::new("test14-unknown-tool"); - let cwd = repo.cwd(); - let unknown = TrackedCall { - cwd: &cwd, - session_id: "session-unknown", - turn_id: "turn-1", - tool_name: "some_future_codex_tool", - tool_use_id: "exec-unknown", - agent_id: None, - }; - - assert_eq!( - repo.drive(&unknown.pre()) - .expect("an unknown tool is never denied for being untracked"), - "" - ); - repo.write("unknown_tool_output.txt", "the unknown tool mutated\n"); - assert_eq!( - repo.drive(&unknown.post()) - .expect("an unknown tool's PostToolUse is ignored"), - "" - ); - - assert!(!repo.adapter_state_file_exists()); - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test15_regression_matrix_leaves_raw_agent_trace_tables_untouched_ac20() { - let repo = CodexRepo::new("test15-raw-tables"); - let cwd = repo.cwd(); - - let before = raw_agent_trace_row_counts(&repo.db()); - assert_eq!(before, [0, 0, 0, 0, 0]); - - repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) - .expect("tracked PreToolUse should succeed"); - repo.write("file.txt", "one\ntwo\n"); - repo.drive(&fixture_at(PROBE01_SHELL_POST, &cwd)) - .expect("tracked PostToolUse should succeed"); - repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) - .expect("MCP PreToolUse should succeed"); - repo.write("mcp_a.txt", "mcp\n"); - repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) - .expect("MCP PostToolUse should succeed"); - repo.drive(&fixture_at(PROBE01_APPLY_PATCH_PRE, &cwd)) - .expect("apply_patch PreToolUse should succeed"); - repo.drive(&fixture_at(PROBE01_STOP, &cwd)) - .expect("Stop should succeed"); - - let db = repo.db(); - assert_eq!( - raw_agent_trace_row_counts(&db), - before, - "AC20: mutation-scope-only regressions leave diff_traces, \ - post_commit_patch_intersections, agent_traces, messages and parts unchanged" - ); - assert!(count(&db, "mutation_trace_scopes") > 0); - assert!( - state::adapter_state_dir(&repo.git_dir()).starts_with(repo.git_dir()), - "AC20: adapter state lives only below /sce/" - ); - } - - #[test] - fn test16_arbitrary_blocker_zombie_then_same_lane_successor_ac9a() { - let repo = CodexRepo::new("test16-zombie-successor"); - let cwd = repo.cwd(); - let zombie = bash_call(&cwd, "session-lane", "exec-zombie"); - let successor = bash_call(&cwd, "session-lane", "exec-successor"); - - repo.drive(&zombie.pre()) - .expect("the first tracked PreToolUse should Start"); - let zombie_scope_id = repo.live_scope_id(); - repo.write("file.txt", "one\nzombie-partial\n"); - - repo.drive(&successor.pre()) - .expect("the same-lane successor should sweep, flush, then Start"); - let successor_scope_id = repo.live_scope_id(); - assert_ne!(zombie_scope_id, successor_scope_id); - assert!( - repo.adapter_state().recovery.is_clear(), - "the quiescent flush must clear the barrier before Start(B)" - ); - - repo.write("file.txt", "one\nzombie-partial\nsuccessor\n"); - repo.drive(&successor.post()) - .expect("the successor's PostToolUse should close its scope"); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &zombie_scope_id), - Some(("codex".to_string(), "abandoned".to_string())), - "AC9a: the stale same-lane predecessor is abandoned, never closed" - ); - assert_eq!( - scope_status(&db, &successor_scope_id), - Some(("codex".to_string(), "closed".to_string())) - ); - let worktree_id = repo.worktree_id(); - let events = mutation_events_for(&db, &worktree_id); - assert!( - events.iter().all(|(kind, scope, _)| kind != "ai_contended" - && scope.as_deref() != Some(zombie_scope_id.as_str())), - "AC9a: no false AiContended and nothing attributed to the zombie: {events:?}" - ); - assert_eq!( - events.last(), - Some(&( - "ai_exclusive".to_string(), - Some(successor_scope_id), - "close".to_string() - )), - "the successor is the only live scope at its own Close" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test17_crash_before_start_commit_is_recovered_conservatively_ac21a() { - let repo = CodexRepo::new("test17-crash-before-start"); - let cwd = repo.cwd(); - let git_dir = repo.git_dir(); - let crashed = bash_call(&cwd, "session-crash", "exec-crashed"); - let key = key("session-crash", None, "exec-crashed"); - - let attempt = state::seed_attempt_for_tests( - &git_dir, - &key, - "turn-1", - CODEX_TRACKED_TOOL_BASH, - state::AttemptPhase::PendingStart, - ); - - repo.drive(&crashed.post()) - .expect("D11: a pending_start attempt must abandon, not late-Start"); - - assert!(repo.adapter_state().attempts.is_empty()); - assert!(!repo.adapter_state().recovery.is_clear()); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &attempt.scope_id), - None, - "AC21a: a Start that never committed must never appear as a real scope" - ); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - let fresh = bash_call(&cwd, "session-crash", "exec-fresh"); - repo.drive(&fresh.pre()) - .expect("the next tracked PreToolUse proceeds after the quiescent flush"); - assert!(repo.adapter_state().recovery.is_clear()); - assert_eq!(repo.adapter_state().attempts.len(), 1); - - assert_raw_agent_trace_tables_untouched(&repo.db()); - } - - #[test] - fn test18_start_committed_before_state_settlement_is_abandoned_ac21b() { - let repo = CodexRepo::new("test18-crash-after-start"); - let cwd = repo.cwd(); - let git_dir = repo.git_dir(); - let crashed = bash_call(&cwd, "session-crash", "exec-crashed"); - let key = key("session-crash", None, "exec-crashed"); - - let attempt = state::seed_attempt_for_tests( - &git_dir, - &key, - "turn-1", - CODEX_TRACKED_TOOL_BASH, - state::AttemptPhase::PendingStart, - ); - let scope_id = attempt.scope_id.clone(); - - repo.drive_generic(&scope_boundary_payload( - "start", - &scope_id, - &codex_scope_start_event_id(&scope_id), - )) - .expect("the runtime Start should commit durably"); - assert_eq!( - repo.adapter_state().attempts[0].phase, - state::AttemptPhase::PendingStart - ); - - repo.drive(&crashed.post()) - .expect("D11: a committed Start with unsettled bookkeeping must be abandoned"); - - assert!(repo.adapter_state().attempts.is_empty()); - assert!(!repo.adapter_state().recovery.is_clear()); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "abandoned".to_string())), - "AC21b: the committed Start settles as a real abandonment, not a late Start" - ); - assert!(worktree_row(&db, &repo.worktree_id()) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test19_close_committed_before_state_cleanup_is_replay_safe_ac21c() { - let repo = CodexRepo::new("test19-crash-after-close"); - let cwd = repo.cwd(); - let call = bash_call(&cwd, "session-close", "exec-close"); - - repo.drive(&call.pre()).expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - repo.write("file.txt", "one\ntwo\n"); - - repo.drive_generic(&scope_boundary_payload( - "close", - &scope_id, - &codex_scope_close_event_id(&scope_id), - )) - .expect("the runtime Close should commit durably"); - assert_eq!(repo.adapter_state().attempts.len(), 1); - - let db = repo.db(); - let (revision_before, events_before) = ( - worktree_row(&db, &repo.worktree_id()) - .map(|(revision, _, _)| revision) - .expect("a worktree row should exist"), - count(&db, "mutation_trace_events"), - ); - - repo.drive(&call.post()) - .expect("a replayed Close against an already-durable commit must be safe"); - - assert!( - repo.adapter_state().attempts.is_empty(), - "the stale bookkeeping is finally cleared" - ); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), - Some(revision_before), - "AC21c: a durably completed Close is never re-applied as a second transition" - ); - assert_eq!(count(&db, "mutation_trace_events"), events_before); - assert_eq!( - scope_status(&db, &scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test20_recovery_pending_blocks_a_tracked_successor_until_recovery_succeeds_ac12() { - let repo = CodexRepo::new("test20-recovery-barrier"); - let cwd = repo.cwd(); - let first = bash_call(&cwd, "session-a", "exec-a"); - let second = bash_call(&cwd, "session-b", "exec-b"); - let blocked = bash_call(&cwd, "session-c", "exec-c"); - - repo.drive(&first.pre()).expect("session-a Start"); - repo.drive(&second.pre()).expect("session-b Start"); - assert_eq!(repo.adapter_state().attempts.len(), 2); - - repo.write("file.txt", "one\nabandoned\n"); - repo.drive(&turn_event_json( - HOOK_EVENT_INTERRUPT, - &cwd, - "session-a", - "turn-1", - )) - .expect("Interrupt should retire session-a's attempt"); - assert!(!repo.adapter_state().recovery.is_clear()); - assert_eq!(repo.adapter_state().attempts.len(), 1); - - assert_eq!( - repo.drive(&blocked.pre()) - .expect("a barred PreToolUse still returns Ok with a deny payload"), - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "AC12: while recovery is armed and attempts remain, a tracked successor is denied" - ); - assert!( - repo.adapter_state() - .attempts - .iter() - .all(|attempt| attempt.tool_use_id != "exec-c"), - "the denied successor must never be admitted" - ); - - repo.drive(&session_end_json(&cwd, "session-b")) - .expect("SessionEnd should retire session-b's attempt"); - assert!(repo.adapter_state().attempts.is_empty()); - assert!(!repo.adapter_state().recovery.is_clear()); - - repo.drive(&blocked.pre()) - .expect("once quiescent, the flush runs and the successor Starts"); - assert!( - repo.adapter_state().recovery.is_clear(), - "AC12: recovery_pending clears only on durable flush success" - ); - let scope_id = repo.live_scope_id(); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id).map(|(_, status)| status), - Some("active".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test21_reused_tool_use_id_after_terminal_gets_a_fresh_scope_id_ac5() { - let repo = CodexRepo::new("test21-reused-identifier"); - let cwd = repo.cwd(); - let call = bash_call(&cwd, "session-reuse", "exec-reused"); - - repo.drive(&call.pre()).expect("first PreToolUse"); - let first_scope_id = repo.live_scope_id(); - repo.write("file.txt", "one\nfirst\n"); - repo.drive(&call.post()).expect("first PostToolUse"); - assert!(repo.adapter_state().attempts.is_empty()); - - repo.drive(&call.pre()) - .expect("a later attempt reusing the same tool_use_id"); - let second_scope_id = repo.live_scope_id(); - assert_ne!( - first_scope_id, second_scope_id, - "AC5: a terminal ScopeId is never reused" - ); - - repo.write("file.txt", "one\nfirst\nsecond\n"); - repo.drive(&call.post()).expect("second PostToolUse"); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &first_scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - assert_eq!( - scope_status(&db, &second_scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test22_self_detaching_descendant_write_is_not_folded_into_the_closed_scope_ac15() { - let repo = CodexRepo::new("test22-detached-descendant"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE09_DETACHED_PRE, &cwd)) - .expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\nforeground\n"); - let tree_at_close = repo.working_tree(); - repo.drive(&fixture_at(PROBE09_DETACHED_POST, &cwd)) - .expect("PostToolUse should succeed"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(tree_at_close.clone()) - ); - let events_before_flush = mutation_events_for(&db, &worktree_id); - - repo.write("file.txt", "one\nforeground\ndetached-descendant\n"); - let tree_after_descendant = repo.working_tree(); - assert_ne!(tree_after_descendant, tree_at_close); - - repo.drive_flush() - .expect("a later diagnostic flush should succeed"); - - let db = repo.db(); - let events_after_flush = mutation_events_for(&db, &worktree_id); - assert_eq!(events_after_flush.len(), events_before_flush.len() + 1); - let (attribution_kind, attribution_scope_id, _) = events_after_flush - .last() - .expect("a flush event should exist"); - assert_eq!( - attribution_kind, "ineligible_unscoped", - "AC15/D16: SCE does not supervise self-detaching descendants; \ - a post-terminal write is never folded into the closed tool scope" - ); - assert_ne!(attribution_scope_id.as_deref(), Some(scope_id.as_str())); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test23_denied_tracked_execution_leaves_no_untracked_start_ac7() { - let repo = CodexRepo::new("test23-policy-denied"); - let cwd = repo.cwd(); - fs::create_dir_all(repo.root.join(".sce")).expect(".sce dir should be created"); - fs::write( - repo.root.join(".sce").join("config.json"), - concat!( - r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, - r#""match":{"argv_prefix":["rm"]},"#, - r#""message":"rm is blocked in this repository"}]}}}"#, - ), - ) - .expect("repo bash policy config should write"); - - let denied = tool_event_json( - &ToolEvent { - event_name: HOOK_EVENT_PRE_TOOL_USE, - cwd: &cwd, - session_id: "session-denied", - turn_id: "turn-1", - tool_name: CODEX_TRACKED_TOOL_BASH, - tool_use_id: "exec-denied", - agent_id: None, - }, - Some(json!({ "command": "rm -rf build" })), - ); - - let response = repo - .drive(&denied) - .expect("a policy-denied Bash command still returns Ok with a deny payload"); - assert!(response.contains(r#""permissionDecision":"deny""#)); - - assert!( - !repo.adapter_state_file_exists(), - "AC7: a denied tracked execution must never leave an untracked Start behind" - ); - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test24_cross_harness_overlap_at_a_non_confirming_boundary_is_ineligible_ac10() { - let repo = CodexRepo::new("test24-cross-harness-ineligible"); - let cwd = repo.cwd(); - let codex_call = bash_call(&cwd, "session-cross", "exec-codex"); - let other_scope_id = "claude-scope-1"; - - repo.drive_generic(&other_harness_payload("start", other_scope_id)) - .expect("the other harness's Start should commit"); - repo.drive(&codex_call.pre()) - .expect("the Codex PreToolUse should Start"); - let codex_scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\ncontended\n"); - - repo.drive_generic(&other_harness_payload("close", other_scope_id)) - .expect("the other harness's Close should commit"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![("ineligible_unscoped".to_string(), None, "close".to_string())], - "AC10/D14: an unconfirmed live Codex scope forces IneligibleUnscoped at a \ - boundary that does not confirm it — never AiContended" - ); - let mut active = active_scopes_for(&db, &worktree_id); - active.sort(); - let mut expected = vec![codex_scope_id, other_scope_id.to_string()]; - expected.sort(); - assert_eq!( - active, expected, - "active_scopes still records the complete live set; only eligibility changes" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test25_cross_harness_overlap_at_the_codex_close_is_contended_ac10() { - let repo = CodexRepo::new("test25-cross-harness-contended"); - let cwd = repo.cwd(); - let codex_call = bash_call(&cwd, "session-cross", "exec-codex"); - let other_scope_id = "claude-scope-1"; - - repo.drive_generic(&other_harness_payload("start", other_scope_id)) - .expect("the other harness's Start should commit"); - repo.drive(&codex_call.pre()) - .expect("the Codex PreToolUse should Start"); - let codex_scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\ncontended\n"); - - repo.drive(&codex_call.post()) - .expect("the Codex PostToolUse should close its scope"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![("ai_contended".to_string(), None, "close".to_string())], - "AC10/D14: the Codex scope's own Close confirms it, so the overlap with the \ - other harness's live scope is attributed AiContended" - ); - assert_eq!( - scope_status(&db, &codex_scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test26_a_second_unconfirmed_codex_scope_suppresses_contention_ac10() { - let repo = CodexRepo::new("test26-second-codex-scope"); - let cwd = repo.cwd(); - let confirmed = bash_call(&cwd, "session-one", "exec-one"); - let unconfirmed = bash_call(&cwd, "session-two", "exec-two"); - let other_scope_id = "claude-scope-1"; - - repo.drive_generic(&other_harness_payload("start", other_scope_id)) - .expect("the other harness's Start should commit"); - repo.drive(&confirmed.pre()) - .expect("the first Codex PreToolUse should Start"); - repo.drive(&unconfirmed.pre()) - .expect("a second Codex lane's PreToolUse should Start"); - assert_eq!(repo.adapter_state().attempts.len(), 2); - - repo.write("file.txt", "one\ncontended\n"); - - repo.drive(&confirmed.post()) - .expect("the first Codex scope's Close should commit"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![("ineligible_unscoped".to_string(), None, "close".to_string())], - "AC10/D14: a second unconfirmed live Codex scope suppresses attribution back to \ - IneligibleUnscoped even at a confirming Codex Close" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test27_tracked_fixtures_persist_scope_provenance_ac3() { - for (label, fixture) in [ - ("bash", PROBE01_SHELL_PRE), - ("apply-patch", PROBE01_APPLY_PATCH_PRE), - ] { - let repo = CodexRepo::new(&format!("test27-provenance-{label}")); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(fixture, &cwd)) - .expect("a tracked PreToolUse should Start"); - let scope_id = repo.live_scope_id(); - - let db = repo.db(); - assert_eq!( - scope_provenance(&db, &scope_id), - Some(( - "cx_01a07c1e-e08e-7172-8032-cb9d62af21d9".to_string(), - Some("gpt-5.6-sol".to_string()) - )), - "AC3: the {label} fixture must persist its cx_ session and normalized model" - ); - assert_eq!(count(&db, "mutation_trace_scope_provenance"), 1); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "active".to_string())) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - } - - #[test] - fn test28_a_tracked_execution_without_a_model_persists_a_null_model_ac3() { - let repo = CodexRepo::new("test28-provenance-no-model"); - let cwd = repo.cwd(); - let call = bash_call(&cwd, "session-no-model", "exec-no-model"); - - repo.drive(&call.pre()) - .expect("a tracked PreToolUse without a model should still Start"); - let scope_id = repo.live_scope_id(); - - let db = repo.db(); - assert_eq!( - scope_provenance(&db, &scope_id), - Some(("cx_session-no-model".to_string(), None)), - "AC3: a missing model records model_id = NULL without losing the session" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test29_untracked_and_delegation_tools_persist_no_provenance_ac3() { - let repo = CodexRepo::new("test29-untracked-no-provenance"); - let cwd = repo.cwd(); - - for fixture in [ - PROBE12_MCP_PRE, - PROBE08_SPAWN_AGENT_PRE, - PROBE08_WAIT_AGENT_PRE, - ] { - assert_eq!( - repo.drive(&fixture_at(fixture, &cwd)) - .expect("an untracked or delegation PreToolUse should succeed"), - "" - ); - } - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_scope_provenance"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - } -} +mod tests; diff --git a/cli/src/services/hooks/codex_mutation_scope/tests.rs b/cli/src/services/hooks/codex_mutation_scope/tests.rs new file mode 100644 index 00000000..5aa72d25 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/tests.rs @@ -0,0 +1,4754 @@ +use super::*; + +const PROBE01_SHELL_PRE: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.shell.pre_tool_use.json"); +const PROBE01_SHELL_POST: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.shell.post_tool_use.json"); +const PROBE01_APPLY_PATCH_PRE: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.apply_patch.pre_tool_use.json"); +const PROBE01_STOP: &str = include_str!("fixtures/probe01-apply-patch-and-shell-success.stop.json"); +const PROBE01_SESSION_END: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.session_end.json"); +const PROBE02_FAILED_SHELL_POST: &str = + include_str!("fixtures/probe02-shell-partial-write-then-nonzero-exit.post_tool_use.json"); +const PROBE04_BLOCKED_PRE: &str = + include_str!("fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.pre_tool_use.json"); +const PROBE05_SHELL_PRE: &str = + include_str!("fixtures/probe05-tool-vocabulary.shell-read-list-search.pre_tool_use.json"); +const PROBE08_SPAWN_AGENT_PRE: &str = + include_str!("fixtures/probe08-subagent-delegation.spawn_agent.pre_tool_use.json"); +const PROBE08_WAIT_AGENT_PRE: &str = + include_str!("fixtures/probe08-subagent-delegation.wait_agent.pre_tool_use.json"); +const PROBE08_AGENT_APPLY_PATCH_PRE: &str = + include_str!("fixtures/probe08-subagent-delegation.agent-apply-patch.pre_tool_use.json"); +const PROBE08_AGENT_APPLY_PATCH_POST: &str = + include_str!("fixtures/probe08-subagent-delegation.agent-apply-patch.post_tool_use.json"); +const PROBE08_SUBAGENT_STOP: &str = + include_str!("fixtures/probe08-subagent-delegation.subagent_stop.json"); +const PROBE10_WORKTREE_PRE: &str = + include_str!("fixtures/probe10-linked-worktree-cwd.pre_tool_use.json"); +const PROBE11_INTERRUPT: &str = + include_str!("fixtures/probe11-interrupt-event-on-sigint.interrupt.json"); +const PROBE12_MCP_PRE: &str = include_str!("fixtures/probe12-mcp-mutate-success.pre_tool_use.json"); +const PROBE12_MCP_POST: &str = + include_str!("fixtures/probe12-mcp-mutate-success.post_tool_use.json"); +const PROBE13_MCP_MUTATE_THEN_ERROR_PRE: &str = + include_str!("fixtures/probe13-mcp-mutate-then-error.pre_tool_use.json"); +const PROBE13_MCP_SESSION_END: &str = + include_str!("fixtures/probe13-mcp-mutate-then-error.session_end.json"); + +fn pre_tool_use_json(overrides: &[(&str, Value)]) -> String { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_PRE_TOOL_USE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + TURN_ID_FIELD.to_string(), + Value::String("turn-1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("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/repo/checkout".to_string()), + ); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("Bash".to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String("exec-1".to_string()), + ); + object.insert(TOOL_INPUT_FIELD.to_string(), json!({"command": "true"})); + for (field, value) in overrides { + object.insert((*field).to_string(), value.clone()); + } + Value::Object(object).to_string() +} + +fn key(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> AttemptKey { + AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + } +} + +fn pre_tool_use(payload: &str) -> CodexToolExecution { + match parse_codex_hook_event(payload).expect("valid PreToolUse parses") { + CodexHookEvent::PreToolUse(execution) => execution, + other => panic!("expected PreToolUse, got {other:?}"), + } +} + +#[test] +fn ac2_empty_payload_is_rejected() { + let error = parse_codex_hook_event(" ").unwrap_err().to_string(); + assert_eq!( + error, + "Invalid Codex hook event payload from STDIN: expected a JSON object, got an empty payload." + ); +} + +#[test] +fn ac2_non_object_json_is_rejected() { + for payload in ["[]", "\"PreToolUse\"", "42", "null"] { + let error = parse_codex_hook_event(payload).unwrap_err().to_string(); + assert!( + error.contains("expected a JSON object"), + "payload {payload:?} produced {error:?}" + ); + } +} + +#[test] +fn ac2_invalid_json_is_rejected() { + let error = parse_codex_hook_event("{not json").unwrap_err().to_string(); + assert!( + error.contains("Invalid Codex hook event payload from STDIN: expected valid JSON"), + "{error:?}" + ); +} + +#[test] +fn ac2_unsupported_hook_event_name_is_rejected() { + for name in [ + "SessionStart", + "SubagentStart", + "UserPromptSubmit", + "PreCompact", + ] { + let payload = + pre_tool_use_json(&[(HOOK_EVENT_NAME_FIELD, Value::String(name.to_string()))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("unsupported hook_event_name '{name}'")), + "{error:?}" + ); + } +} + +#[test] +fn ac2_missing_required_fields_are_rejected_without_fabricating_identity() { + for field in [ + SESSION_ID_FIELD, + TURN_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + TOOL_USE_ID_FIELD, + ] { + let mut object: Map = serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); + object.remove(field); + let payload = Value::Object(object).to_string(); + + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("'{field}'")), + "missing {field} produced {error:?}" + ); + } +} + +#[test] +fn ac2_blank_required_fields_are_rejected() { + for field in [SESSION_ID_FIELD, TURN_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { + let payload = pre_tool_use_json(&[(field, Value::String(" ".to_string()))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("field '{field}' must be a non-blank string")), + "blank {field} produced {error:?}" + ); + } +} + +#[test] +fn ac2_wrong_typed_fields_are_rejected() { + let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::Bool(true))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'tool_use_id' must be a string"), + "{error:?}" + ); +} + +#[test] +fn ac2_wrong_typed_optional_agent_id_is_rejected() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Bool(false))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'agent_id' must be null, absent, or a non-blank string"), + "{error:?}" + ); +} + +#[test] +fn ac2_pre_tool_use_fixtures_parse_to_expected_identity() { + let shell = pre_tool_use(PROBE01_SHELL_PRE); + assert_eq!(shell.identity.tool_name, "Bash"); + assert_eq!( + shell.identity.tool_use_id, + "exec-414820f5-555e-457a-92e7-60ddd27d4eec" + ); + assert_eq!( + shell.identity.session_id, + "01a07c1e-e08e-7172-8032-cb9d62af21d9" + ); + assert_eq!( + shell.identity.turn_id, + "01a07c1e-e0cc-75f1-a566-e790c06cb033" + ); + assert_eq!(shell.identity.agent_id, None); + assert!(!shell.identity.is_subagent()); + assert!(shell.identity.cwd.ends_with("/probe-repo")); + + let apply_patch = pre_tool_use(PROBE01_APPLY_PATCH_PRE); + assert_eq!(apply_patch.identity.tool_name, "apply_patch"); + + let vocab = pre_tool_use(PROBE05_SHELL_PRE); + assert_eq!(vocab.identity.tool_name, "Bash"); + + let worktree = pre_tool_use(PROBE10_WORKTREE_PRE); + assert!(worktree.identity.cwd.ends_with("/probe-worktree")); + + let mcp = pre_tool_use(PROBE12_MCP_PRE); + assert_eq!(mcp.identity.tool_name, "mcp__probe__mutate_success"); + + let mcp_err = pre_tool_use(PROBE13_MCP_MUTATE_THEN_ERROR_PRE); + assert!(is_mcp_tool_name(&mcp_err.identity.tool_name)); +} + +#[test] +fn ac2_subagent_pre_tool_use_fixture_carries_agent_identity() { + let execution = pre_tool_use(PROBE08_AGENT_APPLY_PATCH_PRE); + assert_eq!( + execution.identity.agent_id.as_deref(), + Some("01a07c24-bb59-7ca0-80f7-99cf940a486e") + ); + assert!(execution.identity.is_subagent()); + assert_eq!(execution.agent_type.as_deref(), Some("default")); +} + +#[test] +fn ac3_pre_tool_use_fixtures_retain_the_codex_model() { + for fixture in [ + PROBE01_SHELL_PRE, + PROBE01_APPLY_PATCH_PRE, + PROBE05_SHELL_PRE, + PROBE08_AGENT_APPLY_PATCH_PRE, + ] { + assert_eq!(pre_tool_use(fixture).model.as_deref(), Some("gpt-5.6-sol")); + } +} + +#[test] +fn ac3_scope_provenance_canonicalizes_the_session_and_normalizes_the_model() { + let provenance = codex_scope_provenance(&pre_tool_use(PROBE01_SHELL_PRE)); + assert_eq!( + provenance.session_id, + "cx_01a07c1e-e08e-7172-8032-cb9d62af21d9" + ); + assert_eq!(provenance.model_id.as_deref(), Some("gpt-5.6-sol")); + + let apply_patch = codex_scope_provenance(&pre_tool_use(PROBE01_APPLY_PATCH_PRE)); + assert_eq!(apply_patch, provenance); +} + +#[test] +fn ac3_scope_provenance_keeps_an_already_prefixed_session_id() { + let execution = pre_tool_use(&pre_tool_use_json(&[( + SESSION_ID_FIELD, + Value::String("cx_session-1".to_string()), + )])); + assert_eq!( + codex_scope_provenance(&execution).session_id, + "cx_session-1" + ); +} + +#[test] +fn ac3_an_unusable_model_yields_no_model_id_without_rejecting_the_event() { + for model in [ + Value::Null, + Value::String(String::new()), + Value::String(" ".to_string()), + Value::Bool(true), + json!(7), + json!({ "id": "gpt-5.6-sol" }), + ] { + let payload = pre_tool_use_json(&[(MODEL_FIELD, model.clone())]); + let execution = pre_tool_use(&payload); + let provenance = codex_scope_provenance(&execution); + assert_eq!(provenance.model_id, None, "model {model:?}"); + assert_eq!(provenance.session_id, "cx_session-1", "model {model:?}"); + } + + let absent = pre_tool_use(&pre_tool_use_json(&[])); + assert_eq!(absent.model, None); + assert_eq!(codex_scope_provenance(&absent).model_id, None); +} + +#[test] +fn ac2_post_tool_use_fixtures_parse() { + for (payload, tool_name, tool_use_id) in [ + ( + PROBE01_SHELL_POST, + "Bash", + "exec-414820f5-555e-457a-92e7-60ddd27d4eec", + ), + ( + PROBE02_FAILED_SHELL_POST, + "Bash", + "exec-52155265-e98d-423f-87f8-76ee56ff33b1", + ), + ( + PROBE12_MCP_POST, + "mcp__probe__mutate_success", + "exec-00988fad-6707-48ed-81b6-07bb11933886", + ), + ] { + match parse_codex_hook_event(payload).expect("PostToolUse fixture parses") { + CodexHookEvent::PostToolUse(identity) => { + assert_eq!(identity.tool_name, tool_name); + assert_eq!(identity.tool_use_id, tool_use_id); + } + other => panic!("expected PostToolUse, got {other:?}"), + } + } +} + +#[test] +fn ac2_subagent_post_tool_use_ties_to_its_pre_tool_use() { + let CodexHookEvent::PostToolUse(post) = + parse_codex_hook_event(PROBE08_AGENT_APPLY_PATCH_POST).unwrap() + else { + panic!("expected PostToolUse"); + }; + let pre = pre_tool_use(PROBE08_AGENT_APPLY_PATCH_PRE); + assert_eq!(post.attempt_key(), pre.identity.attempt_key()); + assert!(post.attempt_key().agent_id.is_some()); +} + +#[test] +fn ac2_terminal_lifecycle_fixtures_parse() { + assert!(matches!( + parse_codex_hook_event(PROBE01_STOP).unwrap(), + CodexHookEvent::Stop(id) if id.turn_id == "01a07c1e-e0cc-75f1-a566-e790c06cb033" + )); + assert!(matches!( + parse_codex_hook_event(PROBE11_INTERRUPT).unwrap(), + CodexHookEvent::Interrupt(id) if id.session_id == "01a07c2f-ccbf-79f0-afb9-2d2ce919eea7" + )); + assert!(matches!( + parse_codex_hook_event(PROBE08_SUBAGENT_STOP).unwrap(), + CodexHookEvent::SubagentStop(id) + if id.agent_id == "01a07c24-bb59-7ca0-80f7-99cf940a486e" + )); + for session_end in [PROBE01_SESSION_END, PROBE13_MCP_SESSION_END] { + assert!(matches!( + parse_codex_hook_event(session_end).unwrap(), + CodexHookEvent::SessionEnd(_) + )); + } +} + +#[test] +fn ac2_session_end_needs_no_turn_id() { + let CodexHookEvent::SessionEnd(identity) = parse_codex_hook_event(PROBE01_SESSION_END).unwrap() + else { + panic!("expected SessionEnd"); + }; + assert_eq!(identity.session_id, "01a07c1e-e08e-7172-8032-cb9d62af21d9"); +} + +#[test] +fn ac3_classification_table() { + let cases: &[(&str, ToolClassification)] = &[ + ("Bash", ToolClassification::TrackedMutation), + ("apply_patch", ToolClassification::TrackedMutation), + ("collaborationspawn_agent", ToolClassification::Delegation), + ("collaborationwait_agent", ToolClassification::Delegation), + ("mcp__probe__mutate_success", ToolClassification::Untracked), + ("mcp__probe_par__slow_mutate", ToolClassification::Untracked), + ("mcp__", ToolClassification::Untracked), + ("Read", ToolClassification::Untracked), + ("PowerShell", ToolClassification::Untracked), + ("some_future_codex_tool", ToolClassification::Untracked), + ("", ToolClassification::Untracked), + ]; + for (tool_name, expected) in cases { + assert_eq!( + classify_tool(tool_name), + *expected, + "classify_tool({tool_name:?})" + ); + } +} + +#[test] +fn ac3_classification_is_total_and_single_valued() { + for tool_name in [ + "Bash", + "apply_patch", + "collaborationspawn_agent", + "collaborationwait_agent", + "mcp__x__y", + "unknown", + ] { + let _: ToolClassification = classify_tool(tool_name); + } +} + +#[test] +fn ac3_delegation_and_untracked_tool_fixtures_do_not_yield_a_tracked_scope() { + for payload in [ + PROBE08_SPAWN_AGENT_PRE, + PROBE08_WAIT_AGENT_PRE, + PROBE12_MCP_PRE, + PROBE13_MCP_MUTATE_THEN_ERROR_PRE, + ] { + let execution = pre_tool_use(payload); + let classification = classify_tool(&execution.identity.tool_name); + assert_ne!( + classification, + ToolClassification::TrackedMutation, + "tool {:?} must not be TrackedMutation", + execution.identity.tool_name + ); + } + + assert_eq!( + classify_tool(&pre_tool_use(PROBE04_BLOCKED_PRE).identity.tool_name), + ToolClassification::TrackedMutation + ); +} + +#[test] +fn ac3_is_mcp_tool_name() { + assert!(is_mcp_tool_name("mcp__probe__mutate_success")); + assert!(is_mcp_tool_name("mcp__")); + assert!(!is_mcp_tool_name("Bash")); + assert!(!is_mcp_tool_name("apply_patch")); + assert!(!is_mcp_tool_name("collaborationspawn_agent")); +} + +#[test] +fn ac4_scope_id_is_deterministic_for_the_same_attempt_seq_and_key() { + let k = key("session-1", None, "exec-1"); + assert_eq!(format_codex_scope_id(7, &k), format_codex_scope_id(7, &k)); + + let scope_id = format_codex_scope_id(7, &k); + assert_eq!(scope_id, "cx-tool-v1|n=7|s=9:session-1|a=0:|t=6:exec-1"); + assert_eq!( + codex_scope_start_event_id(&scope_id), + format!("{scope_id}|start") + ); + assert_eq!( + codex_scope_close_event_id(&scope_id), + format!("{scope_id}|close") + ); +} + +#[test] +fn ac4_length_prefix_disambiguates_delimiter_collisions() { + let a = key("a:b", None, "c"); + let b = key("a", None, "b:c"); + assert_ne!(format_codex_scope_id(1, &a), format_codex_scope_id(1, &b)); +} + +#[test] +fn ac4_subagent_key_encodes_the_agent_id() { + let main = key("session-1", None, "exec-1"); + let sub = key("session-1", Some("agent-1"), "exec-1"); + assert_ne!( + format_codex_scope_id(1, &main), + format_codex_scope_id(1, &sub) + ); + assert_eq!( + format_codex_scope_id(1, &sub), + "cx-tool-v1|n=1|s=9:session-1|a=7:agent-1|t=6:exec-1" + ); +} + +#[test] +fn ac5_a_fresh_attempt_seq_yields_a_new_scope_id() { + let k = key("session-1", None, "exec-1"); + assert_ne!(format_codex_scope_id(1, &k), format_codex_scope_id(2, &k)); + assert!(format_codex_scope_id(2, &k).contains("|n=2|")); +} + +#[test] +fn ac5_attempt_key_excludes_turn_id() { + let base = pre_tool_use(&pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-9".to_string()), + )])); + let other_turn = pre_tool_use(&pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("exec-9".to_string())), + (TURN_ID_FIELD, Value::String("turn-99".to_string())), + ])); + assert_eq!( + base.identity.attempt_key(), + other_turn.identity.attempt_key() + ); +} + +mod driver { + use std::cell::RefCell; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + use std::sync::mpsc; + use std::sync::{Arc, Mutex}; + use std::thread; + use std::time::Duration; + + use anyhow::{anyhow, Result}; + + use super::*; + use crate::services::observability::traits::Logger; + + const CWD: &str = "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/repo/checkout"; + + static NEXT_TEST_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_test_git_dir(label: &str) -> PathBuf { + let id = NEXT_TEST_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-codex-mutation-scope-driver-{label}-{}-{id}", + std::process::id() + )) + } + + fn remove_test_git_dir(git_dir: &Path) { + let _ = std::fs::remove_dir_all(git_dir); + } + + #[allow(clippy::unnecessary_wraps)] + fn ok_seam(_root: &Path, _payload: &str, _logger: Option<&dyn Logger>) -> Result { + Ok(String::new()) + } + + fn unreachable_seam( + _root: &Path, + payload: &str, + _logger: Option<&dyn Logger>, + ) -> Result { + panic!("the ingress seam must not be called for this payload: {payload}"); + } + + const BLOCKED_BASH_POLICY_RESPONSE: &str = concat!( + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","#, + r#""permissionDecision":"deny","#, + r#""permissionDecisionReason":"Blocked by SCE bash-tool policy 'no-danger': danger is not allowed"}}"#, + ); + + #[allow(clippy::unnecessary_wraps)] + fn blocking_bash_policy(_root: &Path, _command: &str) -> Result { + Ok(CodexBashPolicyDecision::Blocked( + BLOCKED_BASH_POLICY_RESPONSE.to_string(), + )) + } + + #[allow(clippy::unnecessary_wraps)] + fn allow_bash_policy(_root: &Path, _command: &str) -> Result { + Ok(CodexBashPolicyDecision::Allowed) + } + + fn failing_bash_policy(_root: &Path, _command: &str) -> Result { + Err(anyhow!( + "repository Bash policy configuration is invalid and could not be evaluated" + )) + } + + fn unreachable_bash_policy(_root: &Path, command: &str) -> Result { + panic!("the Bash policy preflight must not run for this event (command: {command})"); + } + + fn seam_failing_on( + operation: &'static str, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + seam_failing_on_any(vec![operation]) + } + + fn seam_failing_on_any( + operations: Vec<&'static str>, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + move |_root, payload, _logger| { + if operations + .iter() + .any(|operation| payload.contains(&format!(r#""operation":"{operation}""#))) + { + Err(anyhow!( + "seam failure injected by test for one of {operations:?}" + )) + } else { + Ok(String::new()) + } + } + } + + fn recording_seam( + log: Arc>>, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + move |_root, payload, _logger| { + log.lock() + .expect("recording seam mutex") + .push(payload.to_string()); + Ok(String::new()) + } + } + + struct SeamGate { + entered: mpsc::Receiver<()>, + release: mpsc::Sender<()>, + } + + impl SeamGate { + fn wait_until_entered(&self) { + self.entered + .recv_timeout(Duration::from_secs(5)) + .expect("gated seam should be entered"); + } + + fn release(&self) { + let _ = self.release.send(()); + } + } + + #[allow(clippy::type_complexity)] + fn gated_seam( + operation: &'static str, + calls: Arc>>, + ) -> ( + impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send, + SeamGate, + ) { + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let release_rx = Mutex::new(release_rx); + let seam = move |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { + calls + .lock() + .expect("gated seam mutex") + .push(payload.to_string()); + if payload.contains(&format!(r#""operation":"{operation}""#)) { + entered_tx.send(()).expect("gate entry signal"); + release_rx + .lock() + .expect("gate release mutex") + .recv() + .expect("gate release signal"); + } + Ok(String::new()) + }; + ( + seam, + SeamGate { + entered: entered_rx, + release: release_tx, + }, + ) + } + + fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result + Send + Clone { + move |_cwd| Ok(git_dir.clone()) + } + + fn panicking_resolver(_cwd: &str) -> Result { + panic!("resolve_git_dir must not be called for a non-tracked tool") + } + + #[derive(Clone, Default)] + struct RecordingLogger { + warnings: Arc>>, + } + + impl RecordingLogger { + fn warnings(&self) -> Vec<(String, String)> { + self.warnings + .lock() + .expect("recording logger mutex must not be poisoned") + .clone() + } + } + + impl Logger for RecordingLogger { + fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn warn(&self, event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { + self.warnings + .lock() + .expect("recording logger mutex must not be poisoned") + .push((event_id.to_string(), message.to_string())); + } + + fn error(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn log_cli_error(&self, _: &crate::services::error::CliError, _: Option<&str>) {} + } + + fn tool_event_json(event_name: &str, overrides: &[(&str, Value)]) -> String { + let mut merged: Vec<(&str, Value)> = + vec![(HOOK_EVENT_NAME_FIELD, Value::String(event_name.to_string()))]; + merged.extend( + overrides + .iter() + .map(|(field, value)| (*field, value.clone())), + ); + pre_tool_use_json(&merged) + } + + fn post_tool_use_json(overrides: &[(&str, Value)]) -> String { + tool_event_json(HOOK_EVENT_POST_TOOL_USE, overrides) + } + + fn turn_scoped_payload(event_name: &str, session_id: &str, turn_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: event_name, + SESSION_ID_FIELD: session_id, + TURN_ID_FIELD: turn_id, + CWD_FIELD: CWD, + }) + .to_string() + } + + fn subagent_stop_payload(session_id: &str, turn_id: &str, agent_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SUBAGENT_STOP, + SESSION_ID_FIELD: session_id, + TURN_ID_FIELD: turn_id, + CWD_FIELD: CWD, + AGENT_ID_FIELD: agent_id, + }) + .to_string() + } + + fn session_end_payload(session_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SESSION_END, + SESSION_ID_FIELD: session_id, + CWD_FIELD: CWD, + }) + .to_string() + } + + fn read_state(git_dir: &Path) -> state::AdapterState { + state::read_state(git_dir).expect("adapter state should be readable") + } + + const DRIVER_TURN: &str = "turn-1"; + + fn seed_attempt( + git_dir: &Path, + session_id: &str, + agent_id: Option<&str>, + tool_use_id: &str, + phase: state::AttemptPhase, + ) -> state::AdapterAttempt { + seed_attempt_in_turn( + git_dir, + session_id, + DRIVER_TURN, + agent_id, + tool_use_id, + phase, + ) + } + + fn seed_attempt_in_turn( + git_dir: &Path, + session_id: &str, + turn_id: &str, + agent_id: Option<&str>, + tool_use_id: &str, + phase: state::AttemptPhase, + ) -> state::AdapterAttempt { + state::seed_attempt_for_tests( + git_dir, + &AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + }, + turn_id, + "Bash", + phase, + ) + } + + fn drive( + payload: &str, + resolver: &(impl Fn(&str) -> Result + ?Sized), + seam: &(impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + ?Sized), + ) -> String { + run_codex_mutation_scope_from_payload_with(payload, None, &resolver, &seam) + .expect("driver should return Ok") + } + + #[test] + fn untracked_mcp_pre_tool_use_creates_no_scope_and_never_touches_seam_or_git_dir() { + let payload = pre_tool_use_json(&[( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + )]); + let output = drive(&payload, &panicking_resolver, &unreachable_seam); + assert_eq!(output, ""); + } + + #[test] + fn unknown_and_delegation_pre_tool_use_create_no_scope_ac3() { + for tool in [ + "some_future_codex_tool", + "collaborationspawn_agent", + "collaborationwait_agent", + ] { + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + assert_eq!(drive(&payload, &panicking_resolver, &unreachable_seam), ""); + } + } + + #[test] + fn untracked_pre_tool_use_leaves_the_state_store_untouched_ac9b() { + let git_dir = unique_test_git_dir("untracked-state-untouched"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool in ["mcp__probe__mutate_success", "some_future_codex_tool"] { + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + drive(&payload, &resolver, &ok_seam); + } + + assert!(read_state(&git_dir).attempts.is_empty()); + assert!(read_state(&git_dir).recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn tracked_pre_tool_use_writes_ahead_start_then_returns_continue_ac6() { + let git_dir = unique_test_git_dir("tracked-write-ahead"); + let resolver = fixed_resolver(git_dir.clone()); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + let phase_is_pending = state::read_state(&git_dir) + .expect("state readable inside seam") + .attempts + .first() + .is_some_and(|attempt| attempt.phase == state::AttemptPhase::PendingStart); + seen.borrow_mut() + .push((payload.to_string(), root == Path::new(CWD))); + assert!( + phase_is_pending, + "AC6: Start driven while attempt is PendingStart" + ); + Ok(String::new()) + }; + + let output = drive(&pre_tool_use_json(&[]), &resolver, &seam); + assert_eq!(output, ""); + + let calls = seen.into_inner(); + assert_eq!(calls.len(), 1); + assert!(calls[0].0.contains(r#""operation":"start""#)); + assert!(calls[0].0.contains(r#""actor_kind":"codex""#)); + assert!( + calls[0].1, + "AC6: the seam receives the raw hook cwd as repository_root" + ); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + fn boundary_payload_field(payload: &str, field: &str) -> Option { + let object: Map = + serde_json::from_str(payload).expect("a boundary payload is a JSON object"); + object.get(field).cloned() + } + + fn start_provenance(payload: &str) -> Value { + assert_eq!( + boundary_payload_field(payload, "operation"), + Some(Value::String("start".to_string())) + ); + boundary_payload_field(payload, PROVENANCE_FIELD) + .expect("a Codex start payload carries provenance") + } + + fn drive_recording_start(label: &str, payload: &str) -> Vec { + let git_dir = unique_test_git_dir(label); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + drive(payload, &resolver, &recording_seam(Arc::clone(&recorded))); + + let calls = recorded.lock().expect("recording seam mutex").clone(); + remove_test_git_dir(&git_dir); + calls + } + + #[test] + fn ac3_tracked_start_carries_scope_provenance_for_both_tracked_tools() { + for tool in TRACKED_MUTATION_TOOL_NAMES { + let payload = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String((*tool).to_string())), + (MODEL_FIELD, Value::String("gpt-5.6-sol".to_string())), + ]); + let calls = drive_recording_start(&format!("provenance-{tool}"), &payload); + + assert_eq!(calls.len(), 1, "{tool} should drive exactly one boundary"); + assert_eq!( + start_provenance(&calls[0]), + json!({ "session_id": "cx_session-1", "model_id": "gpt-5.6-sol" }), + "AC3: {tool} must carry its canonical session and normalized model" + ); + } + } + + #[test] + fn ac3_a_start_without_a_usable_model_still_carries_its_session() { + let cases: [(&str, Option); 4] = [ + ("absent", None), + ("null", Some(Value::Null)), + ("blank", Some(Value::String(" ".to_string()))), + ("non-string", Some(Value::Bool(true))), + ]; + + for (label, model) in cases { + let overrides = model.map_or_else(Vec::new, |value| vec![(MODEL_FIELD, value)]); + let payload = pre_tool_use_json(&overrides); + let calls = drive_recording_start(&format!("provenance-model-{label}"), &payload); + + assert_eq!(calls.len(), 1, "{label} should drive exactly one boundary"); + assert_eq!( + start_provenance(&calls[0]), + json!({ "session_id": "cx_session-1", "model_id": Value::Null }), + "AC3: a {label} model records no model without losing the session" + ); + } + } + + #[test] + fn ac3_only_the_start_boundary_carries_provenance() { + let git_dir = unique_test_git_dir("provenance-start-only"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seam = recording_seam(Arc::clone(&recorded)); + + drive(&pre_tool_use_json(&[]), &resolver, &seam); + drive(&post_tool_use_json(&[]), &resolver, &seam); + + let calls = recorded.lock().expect("recording seam mutex").clone(); + assert_eq!(calls.len(), 2); + assert!(boundary_payload_field(&calls[0], PROVENANCE_FIELD).is_some()); + assert_eq!( + boundary_payload_field(&calls[1], "operation"), + Some(Value::String("close".to_string())) + ); + assert_eq!(boundary_payload_field(&calls[1], PROVENANCE_FIELD), None); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn duplicate_pre_tool_use_reuses_the_same_scope_id_ac4_test_e() { + let git_dir = unique_test_git_dir("duplicate-pre"); + let resolver = fixed_resolver(git_dir.clone()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + let attempts = read_state(&git_dir).attempts; + assert_eq!( + attempts.len(), + 1, + "AC4/Test E: a replay must not fork a new attempt" + ); + assert_eq!(attempts[0].scope_id, scope_id); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn resolver_failure_denies_with_stable_reason_and_logs_the_detail_ac7() { + let logger = RecordingLogger::default(); + let resolver = + |_: &str| -> Result { Err(anyhow!("boom: git rev-parse --git-dir failed")) }; + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + Some(&logger), + &resolver, + &unreachable_seam, + ) + .expect("a resolver failure must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + assert!(!output.contains("boom")); + assert!(!output.contains("allow")); + + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); + assert!(warnings[0].1.contains("boom")); + } + + #[test] + fn start_seam_failure_denies_and_leaves_the_pending_start_attempt_as_a_barrier_ac7() { + let git_dir = unique_test_git_dir("start-seam-failure"); + let resolver = fixed_resolver(git_dir.clone()); + let logger = RecordingLogger::default(); + let seam = seam_failing_on("start"); + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + Some(&logger), + &resolver, + &seam, + ) + .expect("a Start failure must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + assert!(!logger.warnings().is_empty()); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!( + final_state.attempts[0].phase, + state::AttemptPhase::PendingStart + ); + + let successor = pre_tool_use_json(&[ + ( + TOOL_USE_ID_FIELD, + Value::String("exec-successor".to_string()), + ), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &successor, + None, + &resolver, + &unreachable_seam, + ) + .expect("successor must return Ok with a deny payload"), + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "I5: an unresolved PendingStart in another lane must block a successor Start", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn delegation_and_untracked_pre_tool_use_are_never_fail_closed_ac7() { + let resolver = |_: &str| -> Result { Err(anyhow!("must not be called")) }; + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + ] { + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("non-tracked PreToolUse should succeed"), + "", + ); + } + } + + #[test] + fn successful_close_removes_the_attempt_ac8() { + let git_dir = unique_test_git_dir("close-success"); + let resolver = fixed_resolver(git_dir.clone()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + assert_eq!(drive(&post_tool_use_json(&[]), &resolver, &seam), ""); + + let calls = seen.into_inner(); + assert_eq!(calls.len(), 1); + assert!(calls[0].contains(r#""operation":"close""#)); + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn pending_start_close_abandons_rather_than_late_starting_d11() { + let git_dir = unique_test_git_dir("pending-start-close"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-1", + state::AttemptPhase::PendingStart, + ); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + drive(&post_tool_use_json(&[]), &resolver, &seam); + + let calls = seen.into_inner(); + assert_eq!(calls.len(), 1); + assert!(calls[0].contains(r#""operation":"abandon""#)); + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_close_abandons_and_arms_recovery_ac13() { + let git_dir = unique_test_git_dir("failed-close"); + let resolver = fixed_resolver(git_dir.clone()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + let seam = seam_failing_on("close"); + drive(&post_tool_use_json(&[]), &resolver, &seam); + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!( + !final_state.recovery.is_clear(), + "D11: a failed Close arms recovery" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_close_and_failed_abandon_keep_the_attempt_tracked_and_recovery_armed_d11() { + let git_dir = unique_test_git_dir("failed-close-and-abandon"); + let resolver = fixed_resolver(git_dir.clone()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + let seam = seam_failing_on_any(vec!["close", "abandon"]); + let error = run_codex_mutation_scope_from_payload_with( + &post_tool_use_json(&[]), + None, + &resolver, + &seam, + ) + .expect_err("a failed Close then failed Abandon must propagate"); + assert!(error.to_string().contains("abandon")); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_with_no_matching_attempt_is_a_noop() { + let git_dir = unique_test_git_dir("close-no-attempt"); + let resolver = fixed_resolver(git_dir.clone()); + + assert_eq!( + drive( + &post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Bash".to_string()),)]), + &resolver, + &unreachable_seam, + ), + "", + ); + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn stop_sweeps_only_main_thread_attempts_d12() { + let git_dir = unique_test_git_dir("stop-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-1"), + "exec-agent", + state::AttemptPhase::Active, + ); + + drive( + &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), + &resolver, + &ok_seam, + ); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].tool_use_id, "exec-agent"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn interrupt_sweeps_every_attempt_for_the_session_d12() { + let git_dir = unique_test_git_dir("interrupt-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-1"), + "exec-agent", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-2", + None, + "exec-other", + state::AttemptPhase::Active, + ); + + drive( + &turn_scoped_payload(HOOK_EVENT_INTERRUPT, "session-1", "turn-1"), + &resolver, + &ok_seam, + ); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].session_id, "session-2"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn subagent_stop_sweeps_only_the_matching_agent_d12() { + let git_dir = unique_test_git_dir("subagent-stop-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + Some("agent-a"), + "exec-a", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-b"), + "exec-b", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + + drive( + &subagent_stop_payload("session-1", "turn-1", "agent-a"), + &resolver, + &ok_seam, + ); + + let mut remaining: Vec = read_state(&git_dir) + .attempts + .into_iter() + .map(|attempt| attempt.tool_use_id) + .collect(); + remaining.sort(); + assert_eq!( + remaining, + vec!["exec-b".to_string(), "exec-main".to_string()] + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn session_end_sweeps_every_attempt_for_the_session_d12() { + let git_dir = unique_test_git_dir("session-end-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-1"), + "exec-agent", + state::AttemptPhase::Active, + ); + + drive(&session_end_payload("session-1"), &resolver, &ok_seam); + + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn lifecycle_cleanup_with_a_failed_abandon_keeps_the_attempt_tracked_d12() { + let git_dir = unique_test_git_dir("sweep-failed-abandon"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + + let seam = seam_failing_on("abandon"); + let error = run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &seam, + ) + .expect_err("a failed abandonment during cleanup must propagate"); + assert!(error.to_string().contains("abandon")); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn pending_non_empty_recovery_denies_unrelated_admission_without_losing_the_recovery_path() { + use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; + + let git_dir = unique_test_git_dir("health-recovering-unrelated-denied"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + + let seam = seam_failing_on("abandon"); + let error = run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &seam, + ) + .expect_err("a failed abandonment during cleanup must propagate"); + assert!(error.to_string().contains("abandon")); + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Recovering, + "a stuck non-empty attempt with recovery armed is Recovering: unrelated \ + admission stays fail-closed, but a same-lane successor can still retry \ + the stale predecessor's abandonment" + ); + + for attempt_number in 1..=2 { + let output = drive( + &pre_tool_use_json(&[ + (SESSION_ID_FIELD, Value::String("session-2".to_string())), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ( + TOOL_USE_ID_FIELD, + Value::String("exec-unrelated".to_string()), + ), + ]), + &resolver, + &unreachable_seam, + ); + assert_eq!( + output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "PreToolUse call #{attempt_number} for an unrelated session must still be denied" + ); + } + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Recovering, + "the classifier must still report Recovering after repeated denial from an \ + unrelated session; Recovering does not mean every future call succeeds, only \ + that a proven normal self-healing route exists" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn same_lane_successor_retries_abandon_and_reaches_healthy_after_a_failed_lifecycle_abandon_ac4( + ) { + use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; + + let git_dir = unique_test_git_dir("health-same-lane-self-heal"); + let resolver = fixed_resolver(git_dir.clone()); + + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let failing_abandon_seam = seam_failing_on("abandon"); + let output_b = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]), + &resolver, + &failing_abandon_seam, + ); + assert_eq!( + output_b, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "B fails closed when the same-lane sweep's retried abandon of A fails" + ); + + let after_b = read_state(&git_dir); + assert_eq!( + after_b.attempts.len(), + 1, + "A remains persisted after the failed same-lane sweep" + ); + assert_eq!(after_b.attempts[0].tool_use_id, "exec-a"); + assert!(!after_b.recovery.is_clear()); + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Recovering + ); + + let seam_calls = Arc::new(Mutex::new(Vec::new())); + let recording = recording_seam(Arc::clone(&seam_calls)); + let output_c = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-c".to_string()))]), + &resolver, + &recording, + ); + assert_eq!( + output_c, "", + "C's tracked Start proceeds once the same-lane sweep clears A and the \ + quiescent flush completes" + ); + + let operations: Vec = seam_calls + .lock() + .expect("recording seam mutex") + .iter() + .filter_map(|payload| { + serde_json::from_str::(payload) + .ok() + .and_then(|value| { + value + .get("operation") + .and_then(Value::as_str) + .map(str::to_string) + }) + }) + .collect(); + let abandon_index = operations + .iter() + .position(|operation| operation == "abandon"); + let flush_index = operations.iter().position(|operation| operation == "flush"); + let start_index = operations.iter().position(|operation| operation == "start"); + assert!( + abandon_index.is_some() && flush_index.is_some() && start_index.is_some(), + "expected abandon(A), flush, and start(C) seam calls, got {operations:?}" + ); + assert!( + abandon_index < flush_index && flush_index < start_index, + "expected abandon(A) -> flush -> start(C) ordering, got {operations:?}" + ); + + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-c"); + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn health_classifies_recovering_then_healthy_once_the_next_pre_tool_use_flushes_ac4() { + use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; + + let git_dir = unique_test_git_dir("health-recovering-then-healthy"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Recovering + ); + + let output = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + &resolver, + &ok_seam, + ); + assert_eq!(output, ""); + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy, + "a successful flush clears recovery and returns the adapter to Healthy" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_denies_new_tracked_pre_tool_use_while_attempts_remain_ac12() { + let git_dir = unique_test_git_dir("barrier-attempts-remain"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-other", + None, + "exec-live", + state::AttemptPhase::Active, + ); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + None, + &resolver, + &unreachable_seam, + ) + .expect("the barrier denial still returns Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_does_not_affect_untracked_pre_tool_use_ac12_test_f() { + let git_dir = unique_test_git_dir("barrier-untracked-unaffected"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-live", + state::AttemptPhase::Active, + ); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + ] { + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("an untracked PreToolUse ignores the barrier"), + "", + "Test F: recovery must never deny an untracked tool", + ); + } + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flushes_once_quiescent_then_starts_ac12() { + let git_dir = unique_test_git_dir("barrier-flush-success"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + + let output = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + &resolver, + &seam, + ); + assert_eq!(output, ""); + + let operations = seen.into_inner(); + assert_eq!( + operations.len(), + 2, + "expected flush then start, got {operations:?}" + ); + assert!(operations[0].contains(r#""operation":"flush""#)); + assert!(operations[1].contains(r#""operation":"start""#)); + + let final_state = read_state(&git_dir); + assert!( + final_state.recovery.is_clear(), + "a successful flush clears the barrier" + ); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_stays_closed_when_flush_fails_ac12() { + let git_dir = unique_test_git_dir("barrier-flush-failure"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + let generation = state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + let seam = seam_failing_on("flush"); + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + None, + &resolver, + &seam, + ) + .expect("a failed flush still returns Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + let final_state = read_state(&git_dir); + assert_eq!( + final_state.recovery, + state::RecoveryState::Pending { generation }, + "a failed flush hands the generation back as Pending so a later PreToolUse retries", + ); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mcp_mutate_then_error_leaves_no_stale_state_ac9c() { + let git_dir = unique_test_git_dir("mcp-mutate-then-error"); + let resolver = fixed_resolver(git_dir.clone()); + + let mcp_pre = pre_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_then_error".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String("exec-mcp".to_string())), + ]); + drive(&mcp_pre, &resolver, &unreachable_seam); + assert!(read_state(&git_dir).attempts.is_empty()); + + drive( + &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), + &resolver, + &ok_seam, + ); + drive(&session_end_payload("session-1"), &resolver, &ok_seam); + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!( + final_state.recovery.is_clear(), + "AC9c: no Start => no abandon => recovery stays clear" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_mcp_then_tracked_successor_starts_clean_ac9d() { + let git_dir = unique_test_git_dir("mcp-then-tracked"); + let resolver = fixed_resolver(git_dir.clone()); + + let mcp_a = pre_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_then_error".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String("exec-a".to_string())), + ]); + let bash_b = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("Bash".to_string())), + (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), + ]); + + drive(&mcp_a, &resolver, &unreachable_seam); + drive(&bash_b, &resolver, &ok_seam); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].tool_use_id, "exec-b"); + assert_eq!(attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn parallel_mcp_executions_create_no_scopes_ac9e() { + let git_dir = unique_test_git_dir("parallel-mcp"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool_use_id in ["exec-par-a", "exec-par-b"] { + let payload = pre_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe_par__slow_mutate".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + ]); + drive(&payload, &resolver, &unreachable_seam); + assert!(read_state(&git_dir).attempts.is_empty()); + } + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn builtin_failed_a_then_b_never_leaves_a_zombie_scope_ac9a() { + let git_dir = unique_test_git_dir("builtin-failed-a-then-b"); + let resolver = fixed_resolver(git_dir.clone()); + + let predecessor_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]); + let predecessor_post = + post_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]); + let successor_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]); + + drive(&predecessor_pre, &resolver, &ok_seam); + drive(&predecessor_post, &resolver, &ok_seam); + drive(&successor_pre, &resolver, &ok_seam); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].tool_use_id, "exec-b"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn malformed_payload_propagates_as_a_real_error_not_fail_open() { + let error = run_codex_mutation_scope_from_payload("not json", None).unwrap_err(); + assert!(error.to_string().contains("valid JSON")); + } + + #[test] + fn unsupported_event_name_propagates_as_a_real_error() { + let payload = json!({ + HOOK_EVENT_NAME_FIELD: "UserPromptSubmit", + SESSION_ID_FIELD: "session-1", + CWD_FIELD: CWD, + }) + .to_string(); + let error = run_codex_mutation_scope_from_payload(&payload, None).unwrap_err(); + assert!(error.to_string().contains("unsupported hook_event_name")); + } + + fn spawn_pre_tool_use( + git_dir: &Path, + tool_use_id: &'static str, + seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, + ) -> (thread::JoinHandle, mpsc::Receiver<()>) { + spawn_pre_tool_use_in_turn(git_dir, tool_use_id, DRIVER_TURN, seam) + } + + fn spawn_pre_tool_use_in_turn( + git_dir: &Path, + tool_use_id: &'static str, + turn_id: &'static str, + seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, + ) -> (thread::JoinHandle, mpsc::Receiver<()>) { + let (done_tx, done_rx) = mpsc::channel(); + let resolver = fixed_resolver(git_dir.to_path_buf()); + let handle = thread::spawn(move || { + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + (TURN_ID_FIELD, Value::String(turn_id.to_string())), + ]), + None, + &resolver, + &seam, + ) + .expect("PreToolUse should return Ok"); + let _ = done_tx.send(()); + output + }); + (handle, done_rx) + } + + fn assert_still_blocked(done_rx: &mpsc::Receiver<()>, context: &str) { + assert!( + done_rx.recv_timeout(Duration::from_millis(250)).is_err(), + "{context}: the operation must still be blocked on the boundary lock", + ); + } + + fn first_index_of(recorded: &[String], operation: &str) -> Option { + recorded + .iter() + .position(|payload| payload.contains(&format!(r#""operation":"{operation}""#))) + } + + #[test] + fn test_h_cleanup_owning_the_boundary_lock_blocks_admission_until_recovery_is_processed() { + let git_dir = unique_test_git_dir("test-h-cleanup-owns-boundary"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (abandon_seam, gate) = gated_seam("abandon", Arc::clone(&recorded)); + + let sweeper = { + let resolver = fixed_resolver(git_dir.clone()); + thread::spawn(move || { + run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &abandon_seam, + ) + .expect("SessionEnd cleanup should succeed") + }) + }; + + gate.wait_until_entered(); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Pending { generation: 1 }, + "cleanup arms recovery while it owns the boundary lock", + ); + + let (b_handle, b_done) = + spawn_pre_tool_use(&git_dir, "exec-b", recording_seam(Arc::clone(&recorded))); + assert_still_blocked(&b_done, "Test H"); + assert!( + first_index_of(&recorded.lock().unwrap(), "start").is_none(), + "Test H: B must not reach Start while cleanup owns the boundary lock", + ); + + gate.release(); + sweeper.join().expect("sweeper thread should not panic"); + + let b_output = b_handle.join().expect("B thread should not panic"); + assert_eq!( + b_output, "", + "Test H: once recovery is processed B proceeds" + ); + + let recorded = recorded.lock().unwrap().clone(); + let abandon_at = first_index_of(&recorded, "abandon").expect("cleanup abandoned exec-a"); + let flush_at = first_index_of(&recorded, "flush").expect("B drove the quiescent flush"); + let start_at = first_index_of(&recorded, "start").expect("B reached Start"); + assert!( + abandon_at < flush_at && flush_at < start_at, + "Test H: the serialized order must be abandon -> flush -> start, got {recorded:?}", + ); + + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_g_admission_completed_recovery_cannot_arm_before_start() { + let git_dir = unique_test_git_dir("test-g-admit-before-start"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (start_seam, gate) = gated_seam("start", Arc::clone(&recorded)); + + let p1 = { + let resolver = fixed_resolver(git_dir.clone()); + thread::spawn(move || { + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]), + None, + &resolver, + &start_seam, + ) + .expect("P1 PreToolUse should return Ok") + }) + }; + + gate.wait_until_entered(); + let mid = read_state(&git_dir); + assert_eq!(mid.attempts.len(), 1); + assert_eq!(mid.attempts[0].phase, state::AttemptPhase::PendingStart); + assert!( + mid.recovery.is_clear(), + "recovery must still be Clear while P1 holds the boundary lock pre-Start", + ); + + let (p2_handle, p2_done) = spawn_pre_tool_use( + &git_dir, + "exec-cleanup-trigger", + recording_seam(Arc::clone(&recorded)), + ); + + let sweeper = { + let resolver = fixed_resolver(git_dir.clone()); + let recorded = Arc::clone(&recorded); + thread::spawn(move || { + let seam = recording_seam(recorded); + run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &seam, + ) + .expect("SessionEnd cleanup should return Ok") + }) + }; + + assert_still_blocked(&p2_done, "Test G"); + assert!( + read_state(&git_dir).recovery.is_clear(), + "Test G: no concurrent process may arm recovery between admit(B) and Start(B)", + ); + + gate.release(); + assert_eq!(p1.join().expect("P1 should not panic"), ""); + sweeper.join().expect("sweeper should not panic"); + p2_handle.join().expect("P2 should not panic"); + + let recorded = recorded.lock().unwrap().clone(); + let start_at = first_index_of(&recorded, "start").expect("P1 drove Start(B)"); + if let Some(abandon_at) = first_index_of(&recorded, "abandon") { + assert!( + start_at < abandon_at, + "Test G: Start(B) must be serialized before any later abandon, got {recorded:?}", + ); + } + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_j_a_live_flush_owner_is_never_reclaimed_by_a_blocked_process() { + let git_dir = unique_test_git_dir("test-j-live-flush-owner"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + state::arm_recovery(&git_dir).expect("arm recovery"); + + let flush_count = Arc::new(AtomicUsize::new(0)); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (owner_gated, gate) = gated_seam("flush", Arc::clone(&recorded)); + + let owner = { + let resolver = fixed_resolver(git_dir.clone()); + let flush_count = Arc::clone(&flush_count); + thread::spawn(move || { + let seam = move |root: &Path, + payload: &str, + logger: Option<&dyn Logger>| + -> Result { + if payload.contains(r#""operation":"flush""#) { + flush_count.fetch_add(1, Ordering::SeqCst); + } + owner_gated(root, payload, logger) + }; + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-owner".to_string()), + )]), + None, + &resolver, + &seam, + ) + .expect("owner PreToolUse should return Ok") + }) + }; + + gate.wait_until_entered(); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Flushing { generation: 1 }, + ); + + let flush_count_p2 = Arc::clone(&flush_count); + let (p2_handle, p2_done) = + spawn_pre_tool_use_in_turn(&git_dir, "exec-2", "turn-2", move |_r, payload, _l| { + if payload.contains(r#""operation":"flush""#) { + flush_count_p2.fetch_add(1, Ordering::SeqCst); + } + Ok(String::new()) + }); + + assert_still_blocked(&p2_done, "Test J"); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Flushing { generation: 1 }, + "Test J: a blocked process must not reclaim the live owner's Flushing(g)", + ); + + gate.release(); + assert_eq!(owner.join().expect("owner should not panic"), ""); + assert_eq!(p2_handle.join().expect("P2 should not panic"), ""); + + assert_eq!( + flush_count.load(Ordering::SeqCst), + 1, + "Test J: exactly one Flush ran — the live owner's, never a reclaim", + ); + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .all(|a| a.phase == state::AttemptPhase::Active)); + + remove_test_git_dir(&git_dir); + } + + fn seed_orphaned_flushing(git_dir: &Path) -> u64 { + let generation = state::arm_recovery(git_dir).expect("arm recovery to seed"); + match state::admit_tracked_attempt(git_dir, &key("seed", None, "seed"), "seed-turn", "Bash") + .expect("seeding admit should not error") + { + state::AdmitDecision::FlushClaimed { + generation: claimed, + } => { + assert_eq!(claimed, generation); + } + other => panic!("expected FlushClaimed while seeding, got {other:?}"), + } + assert_eq!( + read_state(git_dir).recovery, + state::RecoveryState::Flushing { generation }, + "seed left durable Flushing(g) with no live boundary-lock owner", + ); + generation + } + + #[test] + fn test_i_orphaned_flushing_is_reclaimed_and_flush_is_retried_once() { + let git_dir = unique_test_git_dir("test-i-orphaned-flushing"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let generation = seed_orphaned_flushing(&git_dir); + let next_generation_before = read_state(&git_dir).next_recovery_generation; + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let resolver = fixed_resolver(git_dir.clone()); + let output = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-x".to_string()))]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|p| p.contains(r#""operation":"flush""#)) + .count(), + 1, + "Test I: exactly one retry Flush for the reclaimed generation, got {ops:?}", + ); + assert!(first_index_of(&ops, "flush").unwrap() < first_index_of(&ops, "start").unwrap()); + + let final_state = read_state(&git_dir); + assert!( + final_state.recovery.is_clear(), + "Test I: no permanent RecoveryBlocked" + ); + assert_eq!( + final_state.next_recovery_generation, next_generation_before, + "Test I: reclaiming Flushing(g) preserves the generation, never bumps it", + ); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-x"); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + let _ = generation; + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_k_crash_after_durable_flush_before_completion_write_converges() { + let git_dir = unique_test_git_dir("test-k-crash-after-flush"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_orphaned_flushing(&git_dir); + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let resolver = fixed_resolver(git_dir.clone()); + + let first = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-1".to_string()))]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(first, ""); + assert!(read_state(&git_dir).recovery.is_clear()); + + let second = drive( + &pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("exec-2".to_string())), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(second, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|p| p.contains(r#""operation":"flush""#)) + .count(), + 1, + "Test K: the recovery retry Flush runs exactly once across convergence, got {ops:?}", + ); + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .all(|a| a.phase == state::AttemptPhase::Active)); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_l_duplicate_active_delivery_drives_no_second_start() { + let git_dir = unique_test_git_dir("test-l-duplicate-active"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let first = drive( + &pre_tool_use_json(&[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(first, ""); + let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); + assert_eq!( + read_state(&git_dir).attempts[0].phase, + state::AttemptPhase::Active + ); + + let duplicate = drive( + &pre_tool_use_json(&[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(duplicate, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|p| p.contains(r#""operation":"start""#)) + .count(), + 1, + "Test L: duplicate delivery of an Active execution drives no second Start, got {ops:?}", + ); + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].scope_id, scope_id); + assert_eq!(read_state(&git_dir).next_attempt_seq, 2); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_m_untracked_tools_never_touch_the_boundary_lock() { + let git_dir = unique_test_git_dir("test-m-untracked-no-boundary"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + "collaborationwait_agent", + ] { + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("an untracked PreToolUse is neutral"), + "", + ); + assert_eq!(drive(&payload, &resolver, &unreachable_seam), ""); + } + + assert!( + !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( + &git_dir + ) + .exists(), + "Test M: no untracked tool may create the adapter boundary lock", + ); + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "Test M: an untracked tool resolves no git dir and touches no adapter state", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn untracked_post_tool_use_never_touches_mutation_scope_machinery() { + let git_dir = unique_test_git_dir("untracked-post-no-footprint"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + "collaborationwait_agent", + ] { + let payload = post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("an untracked PostToolUse is neutral"), + "", + "untracked PostToolUse for {tool:?} must return neutral", + ); + assert_eq!( + drive(&payload, &resolver, &unreachable_seam), + "", + "untracked PostToolUse for {tool:?} must not call the ingress seam", + ); + } + + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "an untracked PostToolUse resolves no git dir and creates no adapter state directory", + ); + assert!( + !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( + &git_dir + ) + .exists(), + "an untracked PostToolUse must not create the adapter boundary lock", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn a_complete_successful_mcp_lifecycle_leaves_zero_adapter_footprint() { + let git_dir = unique_test_git_dir("mcp-lifecycle-no-footprint"); + let resolver = fixed_resolver(git_dir.clone()); + + let mcp = &[( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + )]; + + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(mcp), + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("MCP PreToolUse is neutral"), + "", + ); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &post_tool_use_json(mcp), + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("MCP PostToolUse is neutral"), + "", + ); + + assert_eq!( + drive(&pre_tool_use_json(mcp), &resolver, &unreachable_seam), + "" + ); + assert_eq!( + drive(&post_tool_use_json(mcp), &resolver, &unreachable_seam), + "" + ); + + let state = read_state(&git_dir); + assert!(state.attempts.is_empty(), "no attempts recorded"); + assert!(state.recovery.is_clear(), "recovery stays Clear"); + + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "a complete successful MCP lifecycle creates no adapter state directory, \ + state lock, or boundary lock", + ); + assert!( + !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( + &git_dir + ) + .exists(), + "a complete successful MCP lifecycle creates no boundary lock", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_c_recovery_rearmed_while_flush_in_flight_survives_the_stale_completion() { + let git_dir = unique_test_git_dir("race-rearm-during-flush"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + state::arm_recovery(&git_dir).expect("arm g1"); + + let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (flush_seam, gate) = gated_seam("flush", Arc::clone(&calls)); + + let flusher = { + let git_dir = git_dir.clone(); + let resolver = fixed_resolver(git_dir.clone()); + thread::spawn(move || { + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-flusher".to_string()), + )]), + None, + &resolver, + &flush_seam, + ) + .expect("flusher PreToolUse should return Ok") + }) + }; + + gate.wait_until_entered(); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Flushing { generation: 1 }, + ); + + let second_generation = state::arm_recovery(&git_dir).expect("re-arm to g2"); + assert_eq!(second_generation, 2); + + gate.release(); + let flusher_output = flusher.join().expect("flusher thread should not panic"); + assert_eq!( + flusher_output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "Test C: the flusher denies because recovery was re-armed under it", + ); + + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Pending { generation: 2 }, + "Test C: the stale Flush(g1) completion must not clear Pending(g2)", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_d_start_succeeds_but_mark_active_fails_blocks_a_successor_until_recovery() { + let git_dir = unique_test_git_dir("start-then-mark-active-fails"); + let resolver = fixed_resolver(git_dir.clone()); + + state::arm_mark_active_failure_for_tests(); + let logger = RecordingLogger::default(); + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]), + Some(&logger), + &resolver, + &ok_seam, + ) + .expect("a mark_active failure still returns Ok with a deny payload"); + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let after_start = read_state(&git_dir); + assert_eq!(after_start.attempts.len(), 1); + assert_eq!( + after_start.attempts[0].phase, + state::AttemptPhase::PendingStart + ); + assert!(after_start.recovery.is_clear()); + + let successor = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &successor, + None, + &resolver, + &unreachable_seam, + ) + .expect("successor returns a deny payload"), + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "Test D: an uncertain PendingStart in another lane blocks a successor Start", + ); + + drive(&session_end_payload("session-1"), &resolver, &ok_seam); + assert!(read_state(&git_dir).attempts.is_empty()); + assert!(!read_state(&git_dir).recovery.is_clear()); + + let recording: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seam = recording_seam(Arc::clone(&recording)); + let recovered = drive(&successor, &resolver, &seam); + assert_eq!(recovered, ""); + + let ops = recording.lock().expect("recording mutex").clone(); + assert_eq!(ops.len(), 2, "expected flush then start, got {ops:?}"); + assert!(ops[0].contains(r#""operation":"flush""#)); + assert!(ops[1].contains(r#""operation":"start""#)); + + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + + remove_test_git_dir(&git_dir); + } + + fn boundary_lock_exists(git_dir: &Path) -> bool { + crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path(git_dir) + .exists() + } + + #[test] + fn policy_blocked_bash_pre_tool_use_creates_no_mutation_scope_state() { + let git_dir = unique_test_git_dir("policy-blocked-no-scope"); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[(TOOL_INPUT_FIELD, json!({"command": "danger --now"}))]), + None, + &panicking_resolver, + &unreachable_seam, + &blocking_bash_policy, + ) + .expect("a policy-blocked Bash PreToolUse still returns Ok"); + + assert_eq!( + output, BLOCKED_BASH_POLICY_RESPONSE, + "a policy block returns the Codex-native policy denial verbatim, \ + never the generic mutation-scope deny", + ); + assert!(!output.contains(FAIL_CLOSED_DENY_REASON)); + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "a policy block must leave no adapter state: no PendingStart, Active, \ + Start, Abandon, Flush, or recovery", + ); + assert!( + !boundary_lock_exists(&git_dir), + "a policy block must not even acquire the boundary lock", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn policy_allowed_bash_pre_tool_use_follows_the_normal_write_ahead_start_path() { + let git_dir = unique_test_git_dir("policy-allowed-start"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[]), + None, + &resolver, + &recording_seam(Arc::clone(&recorded)), + &allow_bash_policy, + ) + .expect("an allowed Bash PreToolUse returns Ok"); + assert_eq!(output, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|payload| payload.contains(r#""operation":"start""#)) + .count(), + 1, + "an allowed Bash still drives exactly one write-ahead Start, got {ops:?}", + ); + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn policy_evaluation_failure_is_fail_closed_with_no_mutation_scope_state() { + let git_dir = unique_test_git_dir("policy-eval-failure"); + let logger = RecordingLogger::default(); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[]), + Some(&logger), + &panicking_resolver, + &unreachable_seam, + &failing_bash_policy, + ) + .expect("a Bash policy evaluation failure still returns Ok"); + + assert_eq!( + output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "a policy evaluation failure fails closed with the generic mutation-scope deny", + ); + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); + assert!(warnings[0].1.contains("could not be evaluated")); + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "a fail-closed policy evaluation must leave no adapter state", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn apply_patch_pre_tool_use_never_evaluates_bash_policy() { + let git_dir = unique_test_git_dir("apply-patch-no-policy"); + let resolver = fixed_resolver(git_dir.clone()); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("apply_patch".to_string())), + (TOOL_INPUT_FIELD, Value::Null), + ]), + None, + &resolver, + &ok_seam, + &unreachable_bash_policy, + ) + .expect("an apply_patch PreToolUse returns Ok"); + assert_eq!(output, ""); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1, "apply_patch still establishes a scope"); + assert_eq!(attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn malformed_bash_tool_input_is_fail_closed_before_the_policy_evaluator_runs() { + let git_dir = unique_test_git_dir("malformed-bash-tool-input"); + let logger = RecordingLogger::default(); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[(TOOL_INPUT_FIELD, json!({"not_command": "x"}))]), + Some(&logger), + &panicking_resolver, + &unreachable_seam, + &unreachable_bash_policy, + ) + .expect("a malformed Bash tool_input still returns Ok"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); + assert!( + warnings[0].1.contains("tool_input.command"), + "extraction reuses the shared bash_command_from_tool_input semantics", + ); + assert!(!state::adapter_state_dir(&git_dir).exists()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn production_bash_policy_evaluator_blocks_a_repo_denied_command_before_any_start() { + let repo = unique_test_git_dir("prod-policy-regression"); + std::fs::create_dir_all(repo.join(".sce")).expect("create .sce dir"); + std::fs::write( + repo.join(".sce").join("config.json"), + concat!( + r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, + r#""match":{"argv_prefix":["rm"]},"#, + r#""message":"rm is blocked in this repository"}]}}}"#, + ), + ) + .expect("write repo bash policy config"); + + let real_evaluator = |root: &Path, command: &str| evaluate_codex_bash_policy(root, command); + + let blocked = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[ + ( + CWD_FIELD, + Value::String(repo.to_string_lossy().into_owned()), + ), + (TOOL_INPUT_FIELD, json!({"command": "rm -rf build"})), + ]), + None, + &panicking_resolver, + &unreachable_seam, + &real_evaluator, + ) + .expect("a repo-denied Bash command still returns Ok"); + + assert!(blocked.contains(r#""permissionDecision":"deny""#)); + assert!(blocked.contains("no-rm")); + assert!(blocked.contains("rm is blocked in this repository")); + assert!( + !blocked.contains(FAIL_CLOSED_DENY_REASON), + "a real policy block keeps the policy-specific UX, not the generic deny", + ); + + let allowed = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[ + ( + CWD_FIELD, + Value::String(repo.to_string_lossy().into_owned()), + ), + (TOOL_INPUT_FIELD, json!({"command": "echo ok > ok.txt"})), + (TOOL_USE_ID_FIELD, Value::String("exec-allowed".to_string())), + ]), + None, + &fixed_resolver(repo.join(".git")), + &ok_seam, + &real_evaluator, + ) + .expect("an allowed Bash command still returns Ok"); + assert_eq!( + allowed, "", + "the same repo config lets a non-denied command through to the normal path", + ); + + remove_test_git_dir(&repo); + } + + fn operations(recorded: &[String]) -> Vec { + recorded + .iter() + .filter_map(|payload| { + for op in ["start", "close", "abandon", "flush"] { + if payload.contains(&format!(r#""operation":"{op}""#)) { + return Some(op.to_string()); + } + } + None + }) + .collect() + } + + fn pre(tool_use_id: &str, tool_name: &str, overrides: &[(&str, Value)]) -> String { + let mut merged: Vec<(&str, Value)> = vec![ + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + (TOOL_NAME_FIELD, Value::String(tool_name.to_string())), + ]; + if tool_name != CODEX_TRACKED_TOOL_BASH { + merged.push((TOOL_INPUT_FIELD, Value::Null)); + } + merged.extend( + overrides + .iter() + .map(|(field, value)| (*field, value.clone())), + ); + pre_tool_use_json(&merged) + } + + fn assert_zombie_then_successor_sweep( + a_tool: &str, + b_tool: &str, + b_overrides: &[(&str, Value)], + ) { + let git_dir = unique_test_git_dir(&format!("zombie-successor-{a_tool}-{b_tool}")); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let started = drive( + &pre("exec-a", a_tool, &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(started, ""); + assert_eq!( + read_state(&git_dir).attempts[0].phase, + state::AttemptPhase::Active, + ); + + let successor = drive( + &pre("exec-b", b_tool, b_overrides), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(successor, ""); + + let ops = operations(&recorded.lock().unwrap()); + assert_eq!( + ops, + vec![ + "start".to_string(), + "abandon".to_string(), + "flush".to_string(), + "start".to_string(), + ], + "successor sequence must be Start(A) -> Abandon(A) -> Flush -> Start(B), never Start(A) -> Start(B) -> Abandon(A)", + ); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression1_arbitrary_blocker_zombie_then_tracked_successor() { + assert_zombie_then_successor_sweep("Bash", "Bash", &[]); + } + + #[test] + fn regression2_apply_patch_successor_variants() { + assert_zombie_then_successor_sweep("Bash", "apply_patch", &[]); + assert_zombie_then_successor_sweep("apply_patch", "Bash", &[]); + assert_zombie_then_successor_sweep("apply_patch", "apply_patch", &[]); + } + + #[test] + fn regression3_parent_then_subagent_same_lane_is_swept() { + assert_zombie_then_successor_sweep( + "Bash", + "Bash", + &[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))], + ); + } + + #[test] + fn regression3_subagent_then_parent_same_lane_is_swept() { + let git_dir = unique_test_git_dir("subagent-then-parent"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + drive( + &pre( + "exec-a", + "Bash", + &[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))], + ), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + drive( + &pre("exec-b", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec![ + "start".to_string(), + "abandon".to_string(), + "flush".to_string(), + "start".to_string(), + ], + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert!(final_state.attempts[0].agent_id.is_none()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression4_different_session_is_not_swept() { + let git_dir = unique_test_git_dir("different-session-not-swept"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = drive( + &pre( + "exec-b", + "Bash", + &[(SESSION_ID_FIELD, Value::String("session-2".to_string()))], + ), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec!["start".to_string()] + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .any(|attempt| attempt.tool_use_id == "exec-a")); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression5_different_turn_is_not_swept_by_case_b_inference() { + let git_dir = unique_test_git_dir("different-turn-not-swept"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = drive( + &pre( + "exec-b", + "Bash", + &[(TURN_ID_FIELD, Value::String("turn-2".to_string()))], + ), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec!["start".to_string()] + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .any(|attempt| attempt.tool_use_id == "exec-a")); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression6_duplicate_same_attempt_key_is_not_swept() { + let git_dir = unique_test_git_dir("duplicate-not-swept"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + drive( + &pre("exec-a", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); + let next_seq = read_state(&git_dir).next_attempt_seq; + + drive( + &pre("exec-a", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec!["start".to_string()] + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].scope_id, scope_id); + assert_eq!(final_state.next_attempt_seq, next_seq); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression7_pending_start_predecessor_is_swept() { + let git_dir = unique_test_git_dir("pending-start-predecessor-swept"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::PendingStart, + ); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = drive( + &pre("exec-b", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec![ + "abandon".to_string(), + "flush".to_string(), + "start".to_string(), + ], + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression8_abandon_failure_during_sweep_is_fail_closed() { + let git_dir = unique_test_git_dir("sweep-abandon-failure"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let output = run_codex_mutation_scope_from_payload_with( + &pre("exec-b", "Bash", &[]), + None, + &resolver, + &seam_failing_on("abandon"), + ) + .expect("a failed sweep abandon still returns Ok with a deny payload"); + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-a"); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression9_flush_failure_after_sweep_is_fail_closed() { + let git_dir = unique_test_git_dir("sweep-flush-failure"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let output = run_codex_mutation_scope_from_payload_with( + &pre("exec-b", "Bash", &[]), + None, + &resolver, + &seam_failing_on("flush"), + ) + .expect("a failed post-sweep flush still returns Ok with a deny payload"); + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = read_state(&git_dir); + assert!(final_state + .attempts + .iter() + .all(|attempt| attempt.tool_use_id != "exec-b")); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } +} + +mod production_regressions { + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + use super::*; + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::mutation_trace::runtime::{resolve_git_dir, resolve_worktree_id}; + use crate::services::mutation_trace::store::decode_revision; + + const PROBE01_APPLY_PATCH_POST: &str = include_str!( + "fixtures/probe01-apply-patch-and-shell-success.apply_patch.post_tool_use.json" + ); + const PROBE02_FAILED_SHELL_PRE: &str = + include_str!("fixtures/probe02-shell-partial-write-then-nonzero-exit.pre_tool_use.json"); + const PROBE06_APPLY_PATCH_FAILURE_PRE: &str = + include_str!("fixtures/probe06-apply-patch-verification-failure-no-post.pre_tool_use.json"); + const PROBE09_DETACHED_PRE: &str = + include_str!("fixtures/probe09-self-detaching-descendant.pre_tool_use.json"); + const PROBE09_DETACHED_POST: &str = + include_str!("fixtures/probe09-self-detaching-descendant.post_tool_use.json"); + const PROBE11_INTERRUPT_PRE: &str = + include_str!("fixtures/probe11-interrupt-event-on-sigint.pre_tool_use.json"); + const PROBE13_MCP_STOP: &str = include_str!("fixtures/probe13-mcp-mutate-then-error.stop.json"); + const PROBE14_MCP_FAILED_PRE: &str = + include_str!("fixtures/probe14-mcp-failed-then-successor.failed.pre_tool_use.json"); + const PROBE14_MCP_SUCCESSOR_PRE: &str = + include_str!("fixtures/probe14-mcp-failed-then-successor.successor.pre_tool_use.json"); + const PROBE14_MCP_SUCCESSOR_POST: &str = + include_str!("fixtures/probe14-mcp-failed-then-successor.successor.post_tool_use.json"); + const PROBE16_MCP_PARALLEL_A_PRE: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.a.pre_tool_use.json"); + const PROBE16_MCP_PARALLEL_A_POST: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.a.post_tool_use.json"); + const PROBE16_MCP_PARALLEL_B_PRE: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.b.pre_tool_use.json"); + const PROBE16_MCP_PARALLEL_B_POST: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.b.post_tool_use.json"); + + const OTHER_HARNESS_ACTOR_KIND: &str = "claude_code"; + + fn git(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + struct CodexRepo { + temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + } + + impl CodexRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-codex-mutation-scope-regression-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + temp, + root, + state_root, + } + } + + fn drive(&self, payload: &str) -> Result { + run_codex_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn drive_generic(&self, payload: &str) -> Result { + self.drive_generic_at(&self.root, payload) + } + + fn drive_generic_at(&self, repository_root: &Path, payload: &str) -> Result { + crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + &self.state_root, + payload, + None, + ) + } + + fn drive_flush(&self) -> Result { + self.drive_generic(&flush_payload()) + } + + fn db(&self) -> RepositoryAgentTraceDb { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "codex mutation-scope regression test assertions", + ) + .expect("assertion DB should open") + } + + fn cwd(&self) -> String { + Self::cwd_at(&self.root) + } + + fn cwd_at(root: &Path) -> String { + root.to_string_lossy().into_owned() + } + + fn working_tree_at(root: &Path) -> String { + git(root, &["add", "-A"]); + git(root, &["write-tree"]).trim().to_owned() + } + + fn working_tree(&self) -> String { + Self::working_tree_at(&self.root) + } + + fn git_dir_at(root: &Path) -> PathBuf { + resolve_git_dir(root).expect("git dir should resolve") + } + + fn git_dir(&self) -> PathBuf { + Self::git_dir_at(&self.root) + } + + fn adapter_state_at(root: &Path) -> state::AdapterState { + state::read_state(&Self::git_dir_at(root)).expect("adapter state should be readable") + } + + fn adapter_state(&self) -> state::AdapterState { + Self::adapter_state_at(&self.root) + } + + fn adapter_state_file_exists(&self) -> bool { + state::adapter_state_dir(&self.git_dir()) + .join("codex-mutation-scope-state.json") + .exists() + } + + fn worktree_id_at(root: &Path) -> String { + resolve_worktree_id(root) + .expect("worktree id should resolve") + .0 + } + + fn worktree_id(&self) -> String { + Self::worktree_id_at(&self.root) + } + + fn add_worktree(&self, name: &str) -> PathBuf { + let worktree_path = self.temp.path().join(name); + git( + &self.root, + &[ + "worktree", + "add", + "-q", + worktree_path.to_str().expect("utf-8 worktree path"), + ], + ); + worktree_path + } + + fn write(&self, name: &str, contents: &str) { + fs::write(self.root.join(name), contents).expect("regression write should succeed"); + } + + fn live_scope_id(&self) -> String { + let state = self.adapter_state(); + assert_eq!(state.attempts.len(), 1, "exactly one live attempt expected"); + state.attempts[0].scope_id.clone() + } + } + + fn count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("a count row should exist") + } + + fn raw_agent_trace_row_counts(db: &RepositoryAgentTraceDb) -> [i64; 5] { + [ + count(db, "diff_traces"), + count(db, "post_commit_patch_intersections"), + count(db, "agent_traces"), + count(db, "messages"), + count(db, "parts"), + ] + } + + fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { + assert_eq!( + raw_agent_trace_row_counts(db), + [0, 0, 0, 0, 0], + "AC20: the mutation-scope adapter must never write the raw Agent Trace tables" + ); + } + + fn worktree_row(db: &RepositoryAgentTraceDb, worktree_id: &str) -> Option<(u64, String, bool)> { + db.query_map( + "SELECT revision, cursor_tree, needs_rebaseline FROM mutation_trace_worktrees \ + WHERE worktree_id = ?1", + (worktree_id,), + |row| { + let blob: Vec = row.get(0).map_err(anyhow::Error::from)?; + let revision = decode_revision(&blob)?; + let cursor_tree = row.get::(1).map_err(anyhow::Error::from)?; + let needs_rebaseline = row.get::(2).map_err(anyhow::Error::from)? != 0; + Ok((revision, cursor_tree, needs_rebaseline)) + }, + ) + .expect("worktree-row query should succeed") + .into_iter() + .next() + } + + fn processed_events(db: &RepositoryAgentTraceDb) -> Vec<(String, String)> { + db.query_map( + "SELECT scope_id, event_id FROM mutation_trace_processed_events \ + ORDER BY scope_id, event_id", + (), + |row| { + let scope_id = row.get::(0).map_err(anyhow::Error::from)?; + let event_id = row.get::(1).map_err(anyhow::Error::from)?; + Ok((scope_id, event_id)) + }, + ) + .expect("processed-events query should succeed") + } + + fn scope_status(db: &RepositoryAgentTraceDb, scope_id: &str) -> Option<(String, String)> { + db.query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn mutation_events_for( + db: &RepositoryAgentTraceDb, + worktree_id: &str, + ) -> Vec<(String, Option, String)> { + db.query_map( + "SELECT attribution_kind, attribution_scope_id, boundary_kind \ + FROM mutation_trace_events WHERE worktree_id = ?1 ORDER BY revision", + (worktree_id,), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + let boundary_kind = row.get::(2).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id, boundary_kind)) + }, + ) + .expect("mutation-events query should succeed") + } + + fn scope_provenance( + db: &RepositoryAgentTraceDb, + scope_id: &str, + ) -> Option<(String, Option)> { + db.query_map( + "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ + WHERE scope_id = ?1", + (scope_id,), + |row| { + let session_id = row.get::(0).map_err(anyhow::Error::from)?; + let model_id = row.get::>(1).map_err(anyhow::Error::from)?; + Ok((session_id, model_id)) + }, + ) + .expect("scope-provenance query should succeed") + .into_iter() + .next() + } + + fn active_scopes_for(db: &RepositoryAgentTraceDb, worktree_id: &str) -> Vec { + db.query_map( + "SELECT scope_id FROM mutation_trace_event_active_scopes \ + WHERE worktree_id = ?1 ORDER BY revision, scope_id", + (worktree_id,), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("active-scopes query should succeed") + } + + fn fixture_at(fixture: &str, cwd: &str) -> String { + let mut object: Map = + serde_json::from_str(fixture).expect("a fixture payload is a JSON object"); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + Value::Object(object).to_string() + } + + struct ToolEvent<'a> { + event_name: &'a str, + cwd: &'a str, + session_id: &'a str, + turn_id: &'a str, + tool_name: &'a str, + tool_use_id: &'a str, + agent_id: Option<&'a str>, + } + + fn tool_event_json(event: &ToolEvent, tool_input: Option) -> String { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event.event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(event.session_id.to_string()), + ); + object.insert( + TURN_ID_FIELD.to_string(), + Value::String(event.turn_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(event.cwd.to_string())); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String(event.tool_name.to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String(event.tool_use_id.to_string()), + ); + if let Some(agent_id) = event.agent_id { + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String(agent_id.to_string()), + ); + } + if let Some(tool_input) = tool_input { + object.insert(TOOL_INPUT_FIELD.to_string(), tool_input); + } + Value::Object(object).to_string() + } + + struct TrackedCall<'a> { + cwd: &'a str, + session_id: &'a str, + turn_id: &'a str, + tool_name: &'a str, + tool_use_id: &'a str, + agent_id: Option<&'a str>, + } + + impl TrackedCall<'_> { + fn pre(&self) -> String { + tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_PRE_TOOL_USE, + cwd: self.cwd, + session_id: self.session_id, + turn_id: self.turn_id, + tool_name: self.tool_name, + tool_use_id: self.tool_use_id, + agent_id: self.agent_id, + }, + Some(json!({ "command": "echo regression >> file.txt" })), + ) + } + + fn post(&self) -> String { + tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_POST_TOOL_USE, + cwd: self.cwd, + session_id: self.session_id, + turn_id: self.turn_id, + tool_name: self.tool_name, + tool_use_id: self.tool_use_id, + agent_id: self.agent_id, + }, + None, + ) + } + } + + fn bash_call<'a>(cwd: &'a str, session_id: &'a str, tool_use_id: &'a str) -> TrackedCall<'a> { + TrackedCall { + cwd, + session_id, + turn_id: "turn-1", + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id, + agent_id: None, + } + } + + fn turn_event_json(event_name: &str, cwd: &str, session_id: &str, turn_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: event_name, + SESSION_ID_FIELD: session_id, + TURN_ID_FIELD: turn_id, + CWD_FIELD: cwd, + }) + .to_string() + } + + fn session_end_json(cwd: &str, session_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SESSION_END, + SESSION_ID_FIELD: session_id, + CWD_FIELD: cwd, + }) + .to_string() + } + + fn other_harness_payload(operation: &str, scope_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": format!("{scope_id}|{operation}"), + "actor_kind": OTHER_HARNESS_ACTOR_KIND, + }) + .to_string() + } + + #[test] + fn test1_tracked_bash_success_closes_ai_exclusive_ac8() { + let repo = CodexRepo::new("test1-bash-success"); + let cwd = repo.cwd(); + let pre = fixture_at(PROBE01_SHELL_PRE, &cwd); + let post = fixture_at(PROBE01_SHELL_POST, &cwd); + + assert_eq!( + repo.drive(&pre).expect("PreToolUse should succeed"), + "", + "a tracked PreToolUse that established Start returns the neutral response" + ); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ntwo\n"); + + assert_eq!(repo.drive(&post).expect("PostToolUse should succeed"), ""); + assert!( + repo.adapter_state().attempts.is_empty(), + "the closed attempt must be removed from adapter bookkeeping" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id.clone()), + "close".to_string(), + )] + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + assert_eq!( + processed_events(&db), + vec![ + (scope_id.clone(), codex_scope_close_event_id(&scope_id)), + (scope_id.clone(), codex_scope_start_event_id(&scope_id)), + ], + "rows are ordered by (scope_id, event_id), and 'close' sorts before 'start'" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test2_failed_bash_partial_write_still_closes_ai_exclusive_ac9() { + let repo = CodexRepo::new("test2-failed-bash"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE02_FAILED_SHELL_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\npartial\n"); + + assert_eq!( + repo.drive(&fixture_at(PROBE02_FAILED_SHELL_POST, &cwd)) + .expect("a non-zero-exit shell still fires PostToolUse"), + "" + ); + assert!(repo.adapter_state().attempts.is_empty()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "closed".to_string())), + "D10: a Bash tool that partially mutated then exited non-zero closes its scope" + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )], + "the partial mutation is attributed to the failed tool's own scope" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test3_apply_patch_success_closes_ai_exclusive_ac8() { + let repo = CodexRepo::new("test3-apply-patch-success"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE01_APPLY_PATCH_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("alpha.txt", "alpha one\n"); + + repo.drive(&fixture_at(PROBE01_APPLY_PATCH_POST, &cwd)) + .expect("PostToolUse should succeed"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )] + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test4_apply_patch_verification_failure_mutates_nothing_and_is_swept_ac9() { + let repo = CodexRepo::new("test4-apply-patch-failure"); + let cwd = repo.cwd(); + let pre = fixture_at(PROBE06_APPLY_PATCH_FAILURE_PRE, &cwd); + let tree_before = repo.working_tree(); + + repo.drive(&pre).expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + let stop = { + let execution = pre_tool_use(&pre); + turn_event_json( + HOOK_EVENT_STOP, + &cwd, + &execution.identity.session_id, + &execution.identity.turn_id, + ) + }; + repo.drive(&stop) + .expect("Stop should retire the attempt that never received PostToolUse"); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + assert_eq!( + repo.working_tree(), + tree_before, + "D10: apply_patch verification failure never touches the working tree" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![], + "no mutation happened, so nothing is attributed" + ); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test5_duplicate_tracked_lifecycle_is_idempotent_ac4() { + let repo = CodexRepo::new("test5-duplicate-lifecycle"); + let cwd = repo.cwd(); + let pre = fixture_at(PROBE01_SHELL_PRE, &cwd); + let post = fixture_at(PROBE01_SHELL_POST, &cwd); + + repo.drive(&pre).expect("first PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + assert_eq!( + repo.drive(&pre) + .expect("duplicate PreToolUse should be idempotent"), + "" + ); + assert_eq!( + repo.live_scope_id(), + scope_id, + "AC4: duplicate delivery of a live PreToolUse reuses the same ScopeId" + ); + + repo.write("file.txt", "one\ntwo\n"); + repo.drive(&post).expect("first PostToolUse should succeed"); + + let db = repo.db(); + let (revision_before, events_before, processed_before) = ( + worktree_row(&db, &repo.worktree_id()) + .map(|(revision, _, _)| revision) + .expect("a worktree row should exist"), + count(&db, "mutation_trace_events"), + count(&db, "mutation_trace_processed_events"), + ); + + assert_eq!( + repo.drive(&post) + .expect("duplicate PostToolUse delivery must be a safe no-op"), + "" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before) + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + assert_eq!( + count(&db, "mutation_trace_processed_events"), + processed_before + ); + assert_eq!( + processed_events(&db) + .into_iter() + .filter(|(scope, event)| scope == &scope_id + && event == &codex_scope_close_event_id(&scope_id)) + .count(), + 1 + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test6_interrupted_tracked_execution_is_retired_by_interrupt_ac11() { + let repo = CodexRepo::new("test6-interrupt"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE11_INTERRUPT_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ninterrupted\n"); + + assert_eq!( + repo.drive(&fixture_at(PROBE11_INTERRUPT, &cwd)) + .expect("Interrupt should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "AC11: Interrupt is a proven cleanup signal for the interrupted turn" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + assert_eq!(mutation_events_for(&db, &worktree_id), vec![]); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test6b_session_end_is_the_load_bearing_backstop_ac11() { + let repo = CodexRepo::new("test6b-session-end"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\nstranded\n"); + + assert_eq!( + repo.drive(&fixture_at(PROBE01_SESSION_END, &cwd)) + .expect("SessionEnd should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "D12: SessionEnd is the load-bearing whole-session backstop" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())) + ); + assert!(worktree_row(&db, &repo.worktree_id()) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test7_subagent_tracked_tool_gets_its_own_scope_identity() { + let repo = CodexRepo::new("test7-subagent"); + let cwd = repo.cwd(); + let subagent_pre = fixture_at(PROBE08_AGENT_APPLY_PATCH_PRE, &cwd); + let subagent_identity = pre_tool_use(&subagent_pre).identity; + let agent_id = subagent_identity + .agent_id + .clone() + .expect("probe08 carries a delegated-agent identity"); + let main_thread = TrackedCall { + cwd: &cwd, + session_id: &subagent_identity.session_id, + turn_id: "main-turn", + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id: "exec-main-thread", + agent_id: None, + }; + + repo.drive(&main_thread.pre()) + .expect("main-thread PreToolUse should succeed"); + repo.drive(&subagent_pre) + .expect("subagent PreToolUse should succeed"); + + let state = repo.adapter_state(); + assert_eq!(state.attempts.len(), 2); + let subagent_scope_id = state + .attempts + .iter() + .find(|attempt| attempt.agent_id.as_deref() == Some(agent_id.as_str())) + .map(|attempt| attempt.scope_id.clone()) + .expect("the subagent attempt carries its agent_id"); + let main_scope_id = state + .attempts + .iter() + .find(|attempt| attempt.agent_id.is_none()) + .map(|attempt| attempt.scope_id.clone()) + .expect("the main-thread attempt has no agent_id"); + assert_ne!(subagent_scope_id, main_scope_id); + assert!(subagent_scope_id.contains(&agent_id)); + + repo.drive(&fixture_at(PROBE08_SUBAGENT_STOP, &cwd)) + .expect("SubagentStop should succeed"); + + let remaining = repo.adapter_state(); + assert_eq!( + remaining + .attempts + .iter() + .map(|attempt| attempt.scope_id.clone()) + .collect::>(), + vec![main_scope_id.clone()], + "D12: SubagentStop sweeps only the ending agent's attempts" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &subagent_scope_id).map(|(_, status)| status), + Some("abandoned".to_string()) + ); + assert_eq!( + scope_status(&db, &main_scope_id).map(|(_, status)| status), + Some("active".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test8_linked_worktree_advances_only_its_own_cursor_ac14() { + let repo = CodexRepo::new("test8-linked-worktree"); + let worktree_path = repo.add_worktree("codex-worktree"); + let worktree_cwd = CodexRepo::cwd_at(&worktree_path); + + let main_worktree_id = repo.worktree_id(); + let linked_worktree_id = CodexRepo::worktree_id_at(&worktree_path); + assert_ne!(main_worktree_id, linked_worktree_id); + + repo.drive_flush() + .expect("main-checkout baseline flush should succeed"); + let main_cursor_before = worktree_row(&repo.db(), &main_worktree_id) + .map(|(_, cursor_tree, _)| cursor_tree) + .expect("main checkout should have a baseline worktree row"); + + let pre = fixture_at(PROBE10_WORKTREE_PRE, &worktree_cwd); + let post = { + let identity = pre_tool_use(&pre).identity; + tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_POST_TOOL_USE, + cwd: &worktree_cwd, + session_id: &identity.session_id, + turn_id: &identity.turn_id, + tool_name: &identity.tool_name, + tool_use_id: &identity.tool_use_id, + agent_id: None, + }, + None, + ) + }; + + repo.drive(&pre) + .expect("linked-worktree PreToolUse should succeed"); + let scope_id = CodexRepo::adapter_state_at(&worktree_path).attempts[0] + .scope_id + .clone(); + fs::write(worktree_path.join("wt.txt"), "worktree-write\n") + .expect("the linked worktree's own write should succeed"); + repo.drive(&post) + .expect("linked-worktree PostToolUse should succeed"); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &main_worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(main_cursor_before), + "AC14: the main checkout's cursor must not move" + ); + let linked_row = + worktree_row(&db, &linked_worktree_id).expect("linked worktree row should exist"); + assert_eq!( + linked_row.1, + CodexRepo::working_tree_at(&worktree_path), + "AC14: the linked worktree's own cursor advances" + ); + assert_eq!( + mutation_events_for(&db, &linked_worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )] + ); + assert_eq!(mutation_events_for(&db, &main_worktree_id), vec![]); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test9_successful_mcp_lifecycle_creates_no_mutation_scope_ac9b() { + let repo = CodexRepo::new("test9-mcp-success"); + let cwd = repo.cwd(); + + assert_eq!( + repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) + .expect("an MCP PreToolUse is allowed"), + "", + "AC9b: an Untracked tool gets the Codex-neutral continue response" + ); + repo.write("mcp_a.txt", "written by the MCP server\n"); + assert_eq!( + repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) + .expect("an MCP PostToolUse is ignored"), + "" + ); + + assert!( + !repo.adapter_state_file_exists(), + "AC9b: an Untracked lifecycle writes no adapter bookkeeping at all" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + assert_eq!(count(&db, "mutation_trace_processed_events"), 0); + assert_eq!(count(&db, "mutation_trace_worktrees"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test10_mcp_mutate_then_error_leaves_no_zombie_state_ac9c() { + let repo = CodexRepo::new("test10-mcp-mutate-then-error"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE13_MCP_MUTATE_THEN_ERROR_PRE, &cwd)) + .expect("an MCP PreToolUse is allowed"); + repo.write("mcp_b.txt", "mutated before the MCP error\n"); + + repo.drive(&fixture_at(PROBE13_MCP_STOP, &cwd)) + .expect("Stop should find nothing to retire"); + repo.drive(&fixture_at(PROBE13_MCP_SESSION_END, &cwd)) + .expect("SessionEnd should find nothing to retire"); + + assert!( + !repo.adapter_state_file_exists(), + "AC9c: no Start occurred, so there is no stale attempt and no recovery to arm" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test11_failed_mcp_then_tracked_successor_starts_clean_ac9d() { + let repo = CodexRepo::new("test11-mcp-then-tracked"); + let cwd = repo.cwd(); + let failed_mcp = fixture_at(PROBE14_MCP_FAILED_PRE, &cwd); + let failed_identity = pre_tool_use(&failed_mcp).identity; + + repo.drive(&failed_mcp) + .expect("the failing MCP PreToolUse is allowed"); + repo.write("mcp_c1.txt", "mutated by the failing MCP tool\n"); + repo.drive(&fixture_at(PROBE14_MCP_SUCCESSOR_PRE, &cwd)) + .expect("the MCP successor is also Untracked"); + repo.drive(&fixture_at(PROBE14_MCP_SUCCESSOR_POST, &cwd)) + .expect("the MCP successor's PostToolUse is ignored"); + + let tracked_successor = TrackedCall { + cwd: &cwd, + session_id: &failed_identity.session_id, + turn_id: &failed_identity.turn_id, + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id: "exec-tracked-successor", + agent_id: None, + }; + repo.drive(&tracked_successor.pre()) + .expect("the tracked successor should Start normally"); + let scope_id = repo.live_scope_id(); + assert!( + repo.adapter_state().recovery.is_clear(), + "AC9d: no MCP attempt existed, so no successor barrier runs" + ); + + repo.write("file.txt", "one\ntracked-successor\n"); + repo.drive(&tracked_successor.post()) + .expect("the tracked successor's PostToolUse should close its scope"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!(count(&db, "mutation_trace_scopes"), 1); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )], + "AC9d: the tracked successor is the only live scope — no false AiContended" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test12_parallel_mcp_executions_create_no_scopes_ac9e() { + let repo = CodexRepo::new("test12-parallel-mcp"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_A_PRE, &cwd)) + .expect("parallel MCP A is allowed"); + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_B_PRE, &cwd)) + .expect("parallel MCP B is allowed"); + assert!( + !repo.adapter_state_file_exists(), + "AC9e: neither overlapping MCP execution creates adapter state" + ); + + repo.write("mcp_parallel_a.txt", "a\n"); + repo.write("mcp_parallel_b.txt", "b\n"); + + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_A_POST, &cwd)) + .expect("parallel MCP A PostToolUse is ignored"); + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_B_POST, &cwd)) + .expect("parallel MCP B PostToolUse is ignored"); + + assert!(!repo.adapter_state_file_exists()); + + let db = repo.db(); + assert_eq!( + count(&db, "mutation_trace_scopes"), + 0, + "AC9e: overlapping MCP executions produce no scopes and therefore no AiContended" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test13_tracked_scope_overlapping_an_mcp_mutation_is_tracked_exclusivity_ac9f() { + let repo = CodexRepo::new("test13-tracked-plus-mcp"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) + .expect("the tracked Bash PreToolUse should Start"); + let scope_id = repo.live_scope_id(); + + repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) + .expect("the overlapping MCP call is allowed and untracked"); + repo.write("mcp_a.txt", "written by the MCP server, not by Bash\n"); + repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) + .expect("the MCP PostToolUse is ignored"); + + repo.drive(&fixture_at(PROBE01_SHELL_POST, &cwd)) + .expect("the tracked Bash PostToolUse should close its scope"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!(count(&db, "mutation_trace_scopes"), 1); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )], + "AC9f: ai_exclusive means exactly one TRACKED scope was live in the interval, \ + not that the tracked scope authored every mutation — the MCP call did mutate \ + mcp_a.txt inside this interval and remains unattributed (D14/D23)" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test14_unknown_tool_is_allowed_untracked_ac9b() { + let repo = CodexRepo::new("test14-unknown-tool"); + let cwd = repo.cwd(); + let unknown = TrackedCall { + cwd: &cwd, + session_id: "session-unknown", + turn_id: "turn-1", + tool_name: "some_future_codex_tool", + tool_use_id: "exec-unknown", + agent_id: None, + }; + + assert_eq!( + repo.drive(&unknown.pre()) + .expect("an unknown tool is never denied for being untracked"), + "" + ); + repo.write("unknown_tool_output.txt", "the unknown tool mutated\n"); + assert_eq!( + repo.drive(&unknown.post()) + .expect("an unknown tool's PostToolUse is ignored"), + "" + ); + + assert!(!repo.adapter_state_file_exists()); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test15_regression_matrix_leaves_raw_agent_trace_tables_untouched_ac20() { + let repo = CodexRepo::new("test15-raw-tables"); + let cwd = repo.cwd(); + + let before = raw_agent_trace_row_counts(&repo.db()); + assert_eq!(before, [0, 0, 0, 0, 0]); + + repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) + .expect("tracked PreToolUse should succeed"); + repo.write("file.txt", "one\ntwo\n"); + repo.drive(&fixture_at(PROBE01_SHELL_POST, &cwd)) + .expect("tracked PostToolUse should succeed"); + repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) + .expect("MCP PreToolUse should succeed"); + repo.write("mcp_a.txt", "mcp\n"); + repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) + .expect("MCP PostToolUse should succeed"); + repo.drive(&fixture_at(PROBE01_APPLY_PATCH_PRE, &cwd)) + .expect("apply_patch PreToolUse should succeed"); + repo.drive(&fixture_at(PROBE01_STOP, &cwd)) + .expect("Stop should succeed"); + + let db = repo.db(); + assert_eq!( + raw_agent_trace_row_counts(&db), + before, + "AC20: mutation-scope-only regressions leave diff_traces, \ + post_commit_patch_intersections, agent_traces, messages and parts unchanged" + ); + assert!(count(&db, "mutation_trace_scopes") > 0); + assert!( + state::adapter_state_dir(&repo.git_dir()).starts_with(repo.git_dir()), + "AC20: adapter state lives only below /sce/" + ); + } + + #[test] + fn test16_arbitrary_blocker_zombie_then_same_lane_successor_ac9a() { + let repo = CodexRepo::new("test16-zombie-successor"); + let cwd = repo.cwd(); + let zombie = bash_call(&cwd, "session-lane", "exec-zombie"); + let successor = bash_call(&cwd, "session-lane", "exec-successor"); + + repo.drive(&zombie.pre()) + .expect("the first tracked PreToolUse should Start"); + let zombie_scope_id = repo.live_scope_id(); + repo.write("file.txt", "one\nzombie-partial\n"); + + repo.drive(&successor.pre()) + .expect("the same-lane successor should sweep, flush, then Start"); + let successor_scope_id = repo.live_scope_id(); + assert_ne!(zombie_scope_id, successor_scope_id); + assert!( + repo.adapter_state().recovery.is_clear(), + "the quiescent flush must clear the barrier before Start(B)" + ); + + repo.write("file.txt", "one\nzombie-partial\nsuccessor\n"); + repo.drive(&successor.post()) + .expect("the successor's PostToolUse should close its scope"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &zombie_scope_id), + Some(("codex".to_string(), "abandoned".to_string())), + "AC9a: the stale same-lane predecessor is abandoned, never closed" + ); + assert_eq!( + scope_status(&db, &successor_scope_id), + Some(("codex".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + let events = mutation_events_for(&db, &worktree_id); + assert!( + events.iter().all(|(kind, scope, _)| kind != "ai_contended" + && scope.as_deref() != Some(zombie_scope_id.as_str())), + "AC9a: no false AiContended and nothing attributed to the zombie: {events:?}" + ); + assert_eq!( + events.last(), + Some(&( + "ai_exclusive".to_string(), + Some(successor_scope_id), + "close".to_string() + )), + "the successor is the only live scope at its own Close" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test17_crash_before_start_commit_is_recovered_conservatively_ac21a() { + let repo = CodexRepo::new("test17-crash-before-start"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + let crashed = bash_call(&cwd, "session-crash", "exec-crashed"); + let key = key("session-crash", None, "exec-crashed"); + + let attempt = state::seed_attempt_for_tests( + &git_dir, + &key, + "turn-1", + CODEX_TRACKED_TOOL_BASH, + state::AttemptPhase::PendingStart, + ); + + repo.drive(&crashed.post()) + .expect("D11: a pending_start attempt must abandon, not late-Start"); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &attempt.scope_id), + None, + "AC21a: a Start that never committed must never appear as a real scope" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + let fresh = bash_call(&cwd, "session-crash", "exec-fresh"); + repo.drive(&fresh.pre()) + .expect("the next tracked PreToolUse proceeds after the quiescent flush"); + assert!(repo.adapter_state().recovery.is_clear()); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + assert_raw_agent_trace_tables_untouched(&repo.db()); + } + + #[test] + fn test18_start_committed_before_state_settlement_is_abandoned_ac21b() { + let repo = CodexRepo::new("test18-crash-after-start"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + let crashed = bash_call(&cwd, "session-crash", "exec-crashed"); + let key = key("session-crash", None, "exec-crashed"); + + let attempt = state::seed_attempt_for_tests( + &git_dir, + &key, + "turn-1", + CODEX_TRACKED_TOOL_BASH, + state::AttemptPhase::PendingStart, + ); + let scope_id = attempt.scope_id.clone(); + + repo.drive_generic(&scope_boundary_payload( + "start", + &scope_id, + &codex_scope_start_event_id(&scope_id), + )) + .expect("the runtime Start should commit durably"); + assert_eq!( + repo.adapter_state().attempts[0].phase, + state::AttemptPhase::PendingStart + ); + + repo.drive(&crashed.post()) + .expect("D11: a committed Start with unsettled bookkeeping must be abandoned"); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())), + "AC21b: the committed Start settles as a real abandonment, not a late Start" + ); + assert!(worktree_row(&db, &repo.worktree_id()) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test19_close_committed_before_state_cleanup_is_replay_safe_ac21c() { + let repo = CodexRepo::new("test19-crash-after-close"); + let cwd = repo.cwd(); + let call = bash_call(&cwd, "session-close", "exec-close"); + + repo.drive(&call.pre()).expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + repo.write("file.txt", "one\ntwo\n"); + + repo.drive_generic(&scope_boundary_payload( + "close", + &scope_id, + &codex_scope_close_event_id(&scope_id), + )) + .expect("the runtime Close should commit durably"); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + let db = repo.db(); + let (revision_before, events_before) = ( + worktree_row(&db, &repo.worktree_id()) + .map(|(revision, _, _)| revision) + .expect("a worktree row should exist"), + count(&db, "mutation_trace_events"), + ); + + repo.drive(&call.post()) + .expect("a replayed Close against an already-durable commit must be safe"); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the stale bookkeeping is finally cleared" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before), + "AC21c: a durably completed Close is never re-applied as a second transition" + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test20_recovery_pending_blocks_a_tracked_successor_until_recovery_succeeds_ac12() { + let repo = CodexRepo::new("test20-recovery-barrier"); + let cwd = repo.cwd(); + let first = bash_call(&cwd, "session-a", "exec-a"); + let second = bash_call(&cwd, "session-b", "exec-b"); + let blocked = bash_call(&cwd, "session-c", "exec-c"); + + repo.drive(&first.pre()).expect("session-a Start"); + repo.drive(&second.pre()).expect("session-b Start"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + repo.write("file.txt", "one\nabandoned\n"); + repo.drive(&turn_event_json( + HOOK_EVENT_INTERRUPT, + &cwd, + "session-a", + "turn-1", + )) + .expect("Interrupt should retire session-a's attempt"); + assert!(!repo.adapter_state().recovery.is_clear()); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + assert_eq!( + repo.drive(&blocked.pre()) + .expect("a barred PreToolUse still returns Ok with a deny payload"), + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "AC12: while recovery is armed and attempts remain, a tracked successor is denied" + ); + assert!( + repo.adapter_state() + .attempts + .iter() + .all(|attempt| attempt.tool_use_id != "exec-c"), + "the denied successor must never be admitted" + ); + + repo.drive(&session_end_json(&cwd, "session-b")) + .expect("SessionEnd should retire session-b's attempt"); + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + + repo.drive(&blocked.pre()) + .expect("once quiescent, the flush runs and the successor Starts"); + assert!( + repo.adapter_state().recovery.is_clear(), + "AC12: recovery_pending clears only on durable flush success" + ); + let scope_id = repo.live_scope_id(); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("active".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test21_reused_tool_use_id_after_terminal_gets_a_fresh_scope_id_ac5() { + let repo = CodexRepo::new("test21-reused-identifier"); + let cwd = repo.cwd(); + let call = bash_call(&cwd, "session-reuse", "exec-reused"); + + repo.drive(&call.pre()).expect("first PreToolUse"); + let first_scope_id = repo.live_scope_id(); + repo.write("file.txt", "one\nfirst\n"); + repo.drive(&call.post()).expect("first PostToolUse"); + assert!(repo.adapter_state().attempts.is_empty()); + + repo.drive(&call.pre()) + .expect("a later attempt reusing the same tool_use_id"); + let second_scope_id = repo.live_scope_id(); + assert_ne!( + first_scope_id, second_scope_id, + "AC5: a terminal ScopeId is never reused" + ); + + repo.write("file.txt", "one\nfirst\nsecond\n"); + repo.drive(&call.post()).expect("second PostToolUse"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &first_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + assert_eq!( + scope_status(&db, &second_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test22_self_detaching_descendant_write_is_not_folded_into_the_closed_scope_ac15() { + let repo = CodexRepo::new("test22-detached-descendant"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE09_DETACHED_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\nforeground\n"); + let tree_at_close = repo.working_tree(); + repo.drive(&fixture_at(PROBE09_DETACHED_POST, &cwd)) + .expect("PostToolUse should succeed"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(tree_at_close.clone()) + ); + let events_before_flush = mutation_events_for(&db, &worktree_id); + + repo.write("file.txt", "one\nforeground\ndetached-descendant\n"); + let tree_after_descendant = repo.working_tree(); + assert_ne!(tree_after_descendant, tree_at_close); + + repo.drive_flush() + .expect("a later diagnostic flush should succeed"); + + let db = repo.db(); + let events_after_flush = mutation_events_for(&db, &worktree_id); + assert_eq!(events_after_flush.len(), events_before_flush.len() + 1); + let (attribution_kind, attribution_scope_id, _) = events_after_flush + .last() + .expect("a flush event should exist"); + assert_eq!( + attribution_kind, "ineligible_unscoped", + "AC15/D16: SCE does not supervise self-detaching descendants; \ + a post-terminal write is never folded into the closed tool scope" + ); + assert_ne!(attribution_scope_id.as_deref(), Some(scope_id.as_str())); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test23_denied_tracked_execution_leaves_no_untracked_start_ac7() { + let repo = CodexRepo::new("test23-policy-denied"); + let cwd = repo.cwd(); + fs::create_dir_all(repo.root.join(".sce")).expect(".sce dir should be created"); + fs::write( + repo.root.join(".sce").join("config.json"), + concat!( + r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, + r#""match":{"argv_prefix":["rm"]},"#, + r#""message":"rm is blocked in this repository"}]}}}"#, + ), + ) + .expect("repo bash policy config should write"); + + let denied = tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_PRE_TOOL_USE, + cwd: &cwd, + session_id: "session-denied", + turn_id: "turn-1", + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id: "exec-denied", + agent_id: None, + }, + Some(json!({ "command": "rm -rf build" })), + ); + + let response = repo + .drive(&denied) + .expect("a policy-denied Bash command still returns Ok with a deny payload"); + assert!(response.contains(r#""permissionDecision":"deny""#)); + + assert!( + !repo.adapter_state_file_exists(), + "AC7: a denied tracked execution must never leave an untracked Start behind" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test24_cross_harness_overlap_at_a_non_confirming_boundary_is_ineligible_ac10() { + let repo = CodexRepo::new("test24-cross-harness-ineligible"); + let cwd = repo.cwd(); + let codex_call = bash_call(&cwd, "session-cross", "exec-codex"); + let other_scope_id = "claude-scope-1"; + + repo.drive_generic(&other_harness_payload("start", other_scope_id)) + .expect("the other harness's Start should commit"); + repo.drive(&codex_call.pre()) + .expect("the Codex PreToolUse should Start"); + let codex_scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ncontended\n"); + + repo.drive_generic(&other_harness_payload("close", other_scope_id)) + .expect("the other harness's Close should commit"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ineligible_unscoped".to_string(), None, "close".to_string())], + "AC10/D14: an unconfirmed live Codex scope forces IneligibleUnscoped at a \ + boundary that does not confirm it — never AiContended" + ); + let mut active = active_scopes_for(&db, &worktree_id); + active.sort(); + let mut expected = vec![codex_scope_id, other_scope_id.to_string()]; + expected.sort(); + assert_eq!( + active, expected, + "active_scopes still records the complete live set; only eligibility changes" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test25_cross_harness_overlap_at_the_codex_close_is_contended_ac10() { + let repo = CodexRepo::new("test25-cross-harness-contended"); + let cwd = repo.cwd(); + let codex_call = bash_call(&cwd, "session-cross", "exec-codex"); + let other_scope_id = "claude-scope-1"; + + repo.drive_generic(&other_harness_payload("start", other_scope_id)) + .expect("the other harness's Start should commit"); + repo.drive(&codex_call.pre()) + .expect("the Codex PreToolUse should Start"); + let codex_scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ncontended\n"); + + repo.drive(&codex_call.post()) + .expect("the Codex PostToolUse should close its scope"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ai_contended".to_string(), None, "close".to_string())], + "AC10/D14: the Codex scope's own Close confirms it, so the overlap with the \ + other harness's live scope is attributed AiContended" + ); + assert_eq!( + scope_status(&db, &codex_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test26_a_second_unconfirmed_codex_scope_suppresses_contention_ac10() { + let repo = CodexRepo::new("test26-second-codex-scope"); + let cwd = repo.cwd(); + let confirmed = bash_call(&cwd, "session-one", "exec-one"); + let unconfirmed = bash_call(&cwd, "session-two", "exec-two"); + let other_scope_id = "claude-scope-1"; + + repo.drive_generic(&other_harness_payload("start", other_scope_id)) + .expect("the other harness's Start should commit"); + repo.drive(&confirmed.pre()) + .expect("the first Codex PreToolUse should Start"); + repo.drive(&unconfirmed.pre()) + .expect("a second Codex lane's PreToolUse should Start"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + repo.write("file.txt", "one\ncontended\n"); + + repo.drive(&confirmed.post()) + .expect("the first Codex scope's Close should commit"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ineligible_unscoped".to_string(), None, "close".to_string())], + "AC10/D14: a second unconfirmed live Codex scope suppresses attribution back to \ + IneligibleUnscoped even at a confirming Codex Close" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test27_tracked_fixtures_persist_scope_provenance_ac3() { + for (label, fixture) in [ + ("bash", PROBE01_SHELL_PRE), + ("apply-patch", PROBE01_APPLY_PATCH_PRE), + ] { + let repo = CodexRepo::new(&format!("test27-provenance-{label}")); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(fixture, &cwd)) + .expect("a tracked PreToolUse should Start"); + let scope_id = repo.live_scope_id(); + + let db = repo.db(); + assert_eq!( + scope_provenance(&db, &scope_id), + Some(( + "cx_01a07c1e-e08e-7172-8032-cb9d62af21d9".to_string(), + Some("gpt-5.6-sol".to_string()) + )), + "AC3: the {label} fixture must persist its cx_ session and normalized model" + ); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 1); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "active".to_string())) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + } + + #[test] + fn test28_a_tracked_execution_without_a_model_persists_a_null_model_ac3() { + let repo = CodexRepo::new("test28-provenance-no-model"); + let cwd = repo.cwd(); + let call = bash_call(&cwd, "session-no-model", "exec-no-model"); + + repo.drive(&call.pre()) + .expect("a tracked PreToolUse without a model should still Start"); + let scope_id = repo.live_scope_id(); + + let db = repo.db(); + assert_eq!( + scope_provenance(&db, &scope_id), + Some(("cx_session-no-model".to_string(), None)), + "AC3: a missing model records model_id = NULL without losing the session" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test29_untracked_and_delegation_tools_persist_no_provenance_ac3() { + let repo = CodexRepo::new("test29-untracked-no-provenance"); + let cwd = repo.cwd(); + + for fixture in [ + PROBE12_MCP_PRE, + PROBE08_SPAWN_AGENT_PRE, + PROBE08_WAIT_AGENT_PRE, + ] { + assert_eq!( + repo.drive(&fixture_at(fixture, &cwd)) + .expect("an untracked or delegation PreToolUse should succeed"), + "" + ); + } + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } +} diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 79c6bb2a..2785754b 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -2420,4092 +2420,4 @@ where } #[cfg(test)] -mod tests { - use std::{ - cell::RefCell, - fs, - path::{Path, PathBuf}, - process::Command, - thread, - time::{Duration, SystemTime, UNIX_EPOCH}, - }; - - use super::*; - use crate::services::agent_trace_db::{ - ClaudeModelStateObservation, ObservationKind, ParsedDiffTracePatch, SkippedDiffTracePatch, - }; - - #[derive(Debug, Eq, PartialEq)] - struct CapturedPostCommitIntersectionInsert { - commit_id: String, - post_commit_time_ms: i64, - recent_window_cutoff_ms: i64, - recent_window_end_ms: i64, - loaded_diff_trace_count: i64, - skipped_diff_trace_count: i64, - intersection_patch: String, - } - - fn valid_patch_text(path: &str, content: &str) -> String { - format!( - "Index: {path}\n===================================================================\n--- {path}\n+++ {path}\n@@ -0,0 +1,1 @@\n+{content}\n" - ) - } - - fn valid_patch(path: &str, content: &str) -> ParsedPatch { - let patch_text = valid_patch_text(path, content); - - parse_patch_from_text(&patch_text, None).expect("test patch should parse") - } - - #[test] - fn conversation_trace_mixed_payload_maps_to_message_and_part_insert_inputs() { - let patch_text = valid_patch_text("src/lib.rs", "let answer = 42;"); - let question_text = serde_json::json!([ - { - "question": "Proceed?", - "answer": "Yes" - } - ]) - .to_string(); - let payload = serde_json::json!({ - "tool_name": "opencode", - "payloads": [ - { - "type": "message", - "session_id": "session-1", - "message_id": "message-1", - "role": "assistant", - "generated_at_unix_ms": 1_800_000_000_000_i64 - }, - { - "type": "message.part", - "session_id": "session-1", - "message_id": "message-1", - "part_type": "reasoning", - "text": "thinking through validation", - "generated_at_unix_ms": 1_800_000_000_001_i64 - }, - { - "type": "message.part", - "session_id": "session-1", - "message_id": "message-1", - "part_type": "patch", - "text": patch_text, - "generated_at_unix_ms": 1_800_000_000_002_i64 - }, - { - "type": "message.part", - "session_id": "session-1", - "message_id": "message-1", - "part_type": "question", - "text": question_text, - "generated_at_unix_ms": 1_800_000_000_003_i64 - } - ] - }); - - let parsed = parse_conversation_trace_payload(&payload.to_string()) - .expect("conversation-trace mixed payload should parse"); - - assert_eq!(parsed.attempted_count, 4); - assert!(parsed.skipped.is_empty()); - assert!(parsed.message_updated.skipped.is_empty()); - assert!(parsed.message_part_updated.skipped.is_empty()); - - assert_eq!(parsed.message_updated.inserts.len(), 1); - let message = &parsed.message_updated.inserts[0]; - assert_eq!(message.session_id, "oc_session-1"); - assert_eq!(message.message_id, "message-1"); - assert_eq!(message.role, MessageRole::Assistant); - assert_eq!(message.generated_at_unix_ms, 1_800_000_000_000_i64); - - assert_eq!(parsed.message_part_updated.inserts.len(), 3); - let reasoning_part = &parsed.message_part_updated.inserts[0]; - assert_eq!(reasoning_part.session_id, "oc_session-1"); - assert_eq!(reasoning_part.message_id, "message-1"); - assert_eq!(reasoning_part.part_type, PartType::Reasoning); - assert_eq!(reasoning_part.text, "thinking through validation"); - assert_eq!(reasoning_part.generated_at_unix_ms, 1_800_000_000_001_i64); - - let patch_part = &parsed.message_part_updated.inserts[1]; - assert_eq!(patch_part.session_id, "oc_session-1"); - assert_eq!(patch_part.message_id, "message-1"); - assert_eq!(patch_part.part_type, PartType::Patch); - assert_eq!( - patch_part.text, - serialize_to_json(&valid_patch("src/lib.rs", "let answer = 42;")) - .expect("test patch should serialize") - ); - assert_eq!(patch_part.generated_at_unix_ms, 1_800_000_000_002_i64); - - let question_part = &parsed.message_part_updated.inserts[2]; - assert_eq!(question_part.session_id, "oc_session-1"); - assert_eq!(question_part.message_id, "message-1"); - assert_eq!(question_part.part_type, PartType::Question); - assert_eq!(question_part.text, question_text); - assert_eq!(question_part.generated_at_unix_ms, 1_800_000_000_003_i64); - } - - #[test] - fn conversation_trace_mixed_payload_skips_malformed_sibling_items() { - let invalid_question_text = serde_json::json!({ - "question": "Proceed?", - "answer": "Yes" - }) - .to_string(); - let payload = serde_json::json!({ - "tool_name": "opencode", - "payloads": [ - { - "type": "message", - "session_id": "session-1", - "message_id": "message-1", - "role": "assistant", - "generated_at_unix_ms": 1_800_000_000_000_i64 - }, - { - "type": "message", - "session_id": "session-2", - "message_id": "message-2", - "role": "system", - "generated_at_unix_ms": 1_800_000_000_002_i64 - }, - { - "type": "message.part", - "session_id": "session-3", - "message_id": "message-3", - "part_type": "text", - "generated_at_unix_ms": 1_800_000_000_003_i64 - }, - { - "type": "message.part", - "session_id": "session-4", - "message_id": "message-4", - "part_type": "patch", - "text": "--- src/main.rs", - "generated_at_unix_ms": 1_800_000_000_004_i64 - }, - { - "type": "message.part", - "session_id": "session-5", - "message_id": "message-5", - "part_type": "question", - "text": invalid_question_text, - "generated_at_unix_ms": 1_800_000_000_005_i64 - }, - { - "type": "session.started", - "session_id": "session-6" - }, - 42, - { - "type": null, - "session_id": "session-7" - } - ] - }); - - let parsed = parse_conversation_trace_payload(&payload.to_string()) - .expect("conversation-trace mixed payload should parse with skipped items"); - - assert_eq!(parsed.attempted_count, 8); - assert_eq!(parsed.message_updated.inserts.len(), 1); - assert_eq!(parsed.message_updated.skipped.len(), 1); - assert_eq!(parsed.message_updated.skipped[0].index, 1); - assert!(parsed.message_updated.skipped[0] - .reason - .contains("field 'role'")); - assert_eq!(parsed.message_part_updated.inserts.len(), 0); - assert_eq!(parsed.message_part_updated.skipped.len(), 3); - assert_eq!(parsed.message_part_updated.skipped[0].index, 2); - assert!(parsed.message_part_updated.skipped[0] - .reason - .contains("missing required field 'text'")); - assert_eq!(parsed.message_part_updated.skipped[1].index, 3); - assert!(parsed.message_part_updated.skipped[1] - .reason - .contains("neither valid patch-JSON nor a valid patch")); - assert_eq!(parsed.message_part_updated.skipped[2].index, 4); - assert!(parsed.message_part_updated.skipped[2] - .reason - .contains("question part must be a JSON array")); - assert_eq!(parsed.skipped.len(), 3); - assert_eq!(parsed.skipped[0].index, 5); - assert!(parsed.skipped[0].reason.contains("field 'type'")); - assert_eq!(parsed.skipped[1].index, 6); - assert!(parsed.skipped[1] - .reason - .contains("payloads[6] must be an object")); - assert_eq!(parsed.skipped[2].index, 7); - assert!(parsed.skipped[2] - .reason - .contains("field 'type' must be a string")); - } - - fn normalized_conversation_trace_message_payload(tool_name: &str, session_id: &str) -> String { - serde_json::json!({ - "tool_name": tool_name, - "payloads": [ - { - "type": "message", - "session_id": session_id, - "message_id": "message-1", - "role": "assistant", - "generated_at_unix_ms": 1_800_000_000_000_i64 - } - ] - }) - .to_string() - } - - #[test] - fn conversation_trace_normalized_payload_accepts_pi_tool_name_with_prefixed_session_id() { - let stdin_payload = normalized_conversation_trace_message_payload("pi", "session-1"); - - let parsed = parse_conversation_trace_payload(&stdin_payload) - .expect("Pi normalized conversation-trace payload should parse"); - - assert_eq!(parsed.message_updated.inserts.len(), 1); - assert_eq!(parsed.message_updated.inserts[0].session_id, "pi_session-1"); - } - - #[test] - fn conversation_trace_normalized_payload_rejects_unsupported_tool_name() { - let stdin_payload = normalized_conversation_trace_message_payload("cursor", "session-1"); - - let error = parse_conversation_trace_payload(&stdin_payload) - .expect_err("unsupported tool_name should be rejected"); - - assert!(error.to_string().contains("unsupported tool_name 'cursor'")); - assert!(error.to_string().contains("'opencode'")); - assert!(error.to_string().contains("'pi'")); - } - - #[test] - fn conversation_trace_normalized_payload_rejects_empty_tool_name() { - let stdin_payload = normalized_conversation_trace_message_payload("", "session-1"); - - let error = parse_conversation_trace_payload(&stdin_payload) - .expect_err("empty tool_name should be rejected"); - - assert!(error - .to_string() - .contains("field 'tool_name' must be a non-empty string")); - } - - #[test] - fn conversation_trace_normalized_payload_rejects_missing_tool_name() { - let stdin_payload = serde_json::json!({ - "payloads": [ - { - "type": "message", - "session_id": "session-1", - "message_id": "message-1", - "role": "assistant", - "generated_at_unix_ms": 1_800_000_000_000_i64 - } - ] - }) - .to_string(); - - let error = parse_conversation_trace_payload(&stdin_payload) - .expect_err("missing tool_name should be rejected"); - - assert!(error - .to_string() - .contains("missing required field 'tool_name'")); - } - - #[test] - fn conversation_trace_normalized_payload_keeps_already_prefixed_session_id() { - let stdin_payload = - normalized_conversation_trace_message_payload("opencode", "oc_session-1"); - - let parsed = parse_conversation_trace_payload(&stdin_payload) - .expect("already-prefixed OpenCode session ID should parse"); - - assert_eq!(parsed.message_updated.inserts[0].session_id, "oc_session-1"); - } - - #[test] - fn conversation_trace_raw_claude_event_uses_claude_identity_with_cc_prefixed_session_id() { - let stdin_payload = serde_json::json!({ - "hook_event_name": "UserPromptSubmit", - "session_id": "session-1", - "prompt": "hello" - }) - .to_string(); - - let parsed = parse_conversation_trace_payload(&stdin_payload) - .expect("raw Claude UserPromptSubmit event should parse"); - - assert_eq!(parsed.message_updated.inserts.len(), 1); - assert_eq!(parsed.message_updated.inserts[0].session_id, "cc_session-1"); - } - - fn diff_trace_payload(model_id: Option<&str>, tool_version: Option<&str>) -> DiffTracePayload { - diff_trace_payload_with( - "claude", - "session-123", - PAYLOAD_TYPE_STRUCTURED, - model_id, - tool_version, - ) - } - - fn diff_trace_payload_with( - tool_name: &str, - session_id: &str, - payload_type: &str, - model_id: Option<&str>, - tool_version: Option<&str>, - ) -> DiffTracePayload { - DiffTracePayload { - session_id: String::from(session_id), - diff: String::from("diff text"), - time: 1_800_000_000_000_u64, - model_id: model_id.map(String::from), - agent_id: None, - transcript_path: None, - tool_name: String::from(tool_name), - tool_version: tool_version.map(String::from), - payload_type: String::from(payload_type), - } - } - - fn claude_model_test_event(transcript_path: &Path, tool_use_id: &str) -> Value { - json!({ - "hook_event_name": "PostToolUse", - "session_id": "session-123", - "tool_name": "Write", - "tool_use_id": tool_use_id, - "transcript_path": transcript_path, - "tool_input": { - "file_path": "docs/status.md", - "content": "# Status\n\nThe new state is complete.\n" - }, - "tool_response": { - "originalFile": "# Status\n\nThe old state is pending.\n", - "structuredPatch": { - "hunks": [{ - "oldStart": 1, - "oldCount": 3, - "newStart": 1, - "newCount": 3, - "lines": [ - " # Status", - " ", - "-The old state is pending.", - "+The new state is complete." - ] - }] - } - } - }) - } - - fn parsed_claude_model_id(event: &Value) -> Option { - match parse_diff_trace_payload(&event.to_string()) - .expect("Claude PostToolUse diff-trace payload should parse") - { - DiffTraceParseResult::Persist(payload) => payload.model_id, - DiffTraceParseResult::NoOp(message) => { - panic!("Claude Write payload should persist, got no-op: {message}") - } - } - } - - fn parsed_claude_diff_trace(event: &Value) -> DiffTracePayload { - match parse_diff_trace_payload(&event.to_string()) - .expect("Claude PostToolUse diff-trace payload should parse") - { - DiffTraceParseResult::Persist(payload) => payload, - DiffTraceParseResult::NoOp(message) => { - panic!("Claude Write payload should persist, got no-op: {message}") - } - } - } - - fn unique_attribution_db_path(label: &str) -> PathBuf { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after Unix epoch") - .as_nanos(); - std::env::temp_dir() - .join(format!("sce-claude-model-attribution-{label}-{suffix}")) - .join("agent-trace.db") - } - - fn resolved_claude_model_id_with(event: &Value, transcript_lookup: F) -> Option - where - F: FnOnce(&Path, &str) -> Option, - { - resolve_claude_model_id_with( - event.as_object().expect("test event should be an object"), - transcript_lookup, - ) - } - - fn run_attribution_git(repo_root: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(repo_root) - .output() - .expect("git should start"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - fn init_attribution_git_repo(label: &str) -> PathBuf { - let repo_root = unique_attribution_db_path(label) - .parent() - .expect("test repository should have a parent") - .to_path_buf(); - fs::create_dir_all(&repo_root).expect("test repository directory should be created"); - run_attribution_git(&repo_root, &["init", "-q"]); - run_attribution_git( - &repo_root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - repo_root - } - - fn model_less_claude_diff_event( - session_id: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> Value { - let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), tool_use_id); - let object = event - .as_object_mut() - .expect("Claude test event should be an object"); - object.insert("session_id".to_string(), json!(session_id)); - object.remove("transcript_path"); - object.remove("tool_use_id"); - if let Some(agent_id) = agent_id { - object.insert("agent_id".to_string(), json!(agent_id)); - } - event - } - - fn persisted_model_ids(db: &RepositoryAgentTraceDb) -> Vec> { - db.query_map( - "SELECT model_id FROM diff_traces ORDER BY id ASC", - (), - |row| row.get::>(0).map_err(Into::into), - ) - .expect("persisted model IDs should be readable") - } - - #[test] - fn claude_model_direct_nested_metadata_wins_over_transcript_without_double_prefixing() { - let transcript_path = Path::new("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/unused/direct-precedence.jsonl"); - let mut event = claude_model_test_event(transcript_path, "tool-123"); - event - .as_object_mut() - .expect("test event should be an object") - .insert("model".to_string(), json!({ "id": "claude/direct-model" })); - - let model_id = resolved_claude_model_id_with(&event, |_, _| { - panic!("transcript lookup must not run when direct metadata is present") - }); - - assert_eq!(model_id.as_deref(), Some("claude/direct-model")); - assert_eq!(parsed_claude_model_id(&event), model_id); - } - - #[test] - fn claude_model_falls_back_to_matching_transcript_and_normalizes_model() { - let transcript_path = Path::new("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/virtual/transcript-fallback.jsonl"); - let event = claude_model_test_event(transcript_path, "tool-123"); - - let model_id = resolved_claude_model_id_with(&event, |path, tool_use_id| { - assert_eq!(path, transcript_path); - assert_eq!(tool_use_id, "tool-123"); - Some(String::from("claude/claude-opus-4-1")) - }); - - assert_eq!(model_id.as_deref(), Some("claude/claude-opus-4-1")); - } - - #[test] - fn claude_model_remains_none_when_transcript_lookup_cannot_succeed() { - let event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); - assert_eq!(resolved_claude_model_id_with(&event, |_, _| None), None); - - let mut event_without_lookup_fields = event; - let payload = event_without_lookup_fields - .as_object_mut() - .expect("test event should be an object"); - payload.remove("transcript_path"); - payload.remove("tool_use_id"); - assert_eq!( - resolved_claude_model_id_with(&event_without_lookup_fields, |_, _| { - panic!("lookup must not run without transcript event metadata") - }), - None - ); - } - - #[test] - fn claude_diff_trace_parser_keeps_agent_id_ephemeral_and_storage_free() { - let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); - event - .as_object_mut() - .expect("test event should be an object") - .insert("agent_id".to_string(), json!(" agent-1 ")); - - let payload = parsed_claude_diff_trace(&event); - - assert_eq!(payload.agent_id.as_deref(), Some("agent-1")); - assert!(serde_json::to_value(&payload) - .expect("internal payload should serialize") - .get("agent_id") - .is_none()); - } - - #[test] - fn claude_diff_trace_parser_keeps_transcript_path_ephemeral_and_storage_free() { - let transcript_path = Path::new("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/virtual/session-123.jsonl"); - let event = claude_model_test_event(transcript_path, "tool-123"); - - let payload = parsed_claude_diff_trace(&event); - - assert_eq!( - payload.transcript_path.as_deref(), - Some("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/virtual/session-123.jsonl") - ); - assert!(serde_json::to_value(&payload) - .expect("internal payload should serialize") - .get("transcript_path") - .is_none()); - } - - #[test] - fn claude_diff_trace_parser_leaves_transcript_path_none_without_the_field() { - let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); - event - .as_object_mut() - .expect("test event should be an object") - .remove("transcript_path"); - - assert_eq!(parsed_claude_diff_trace(&event).transcript_path, None); - } - - #[test] - fn claude_diff_trace_normalized_opencode_payload_carries_no_transcript_path() { - let stdin_payload = serde_json::json!({ - "sessionID": "session-123", - "diff": "diff text", - "time": 1_800_000_000_000_u64, - "model_id": "anthropic/claude-opus-4", - "tool_name": "opencode", - "tool_version": null - }) - .to_string(); - - let parsed = parse_diff_trace_payload(&stdin_payload) - .expect("normalized OpenCode diff-trace payload should parse"); - let payload = match parsed { - DiffTraceParseResult::Persist(payload) => payload, - DiffTraceParseResult::NoOp(message) => { - panic!("normalized OpenCode payload should persist, got no-op: {message}") - } - }; - - assert_eq!(payload.transcript_path, None); - } - - #[test] - #[allow(clippy::too_many_lines)] - fn claude_model_attribution_end_to_end_persists_lifecycle_fallback_precedence_and_scope() { - let repo_root = init_attribution_git_repo("end-to-end"); - let state_root = unique_attribution_db_path("end-to-end-state") - .parent() - .expect("test state should have a parent") - .to_path_buf(); - let storage = resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &repo_root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("setup path should initialize the test repository DB"); - drop(storage); - - let session_start = json!({ - "hook_event_name": "SessionStart", - "session_id": "session-123", - "model": "model-a", - "source": "startup" - }); - assert_eq!( - claude_model_state::run_claude_model_state_from_payload_at_state_root( - &repo_root, - &state_root, - &session_start.to_string(), - None, - || Ok(10), - ), - "" - ); - - let db = open_agent_trace_db_for_hook_runtime_at_state_root( - &repo_root, - &state_root, - "test DB should open after SessionStart", - ) - .expect("test DB should open after SessionStart"); - assert_eq!( - db.claude_model_state_by_session_and_agent("cc_session-123", "") - .expect("SessionStart state should be readable") - .expect("SessionStart should seed state") - .model_id, - "claude/model-a" - ); - let session_start_event = model_less_claude_diff_event("session-123", "tool-a", None); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&session_start_event), - ) - .expect("SessionStart state should attribute the next diff trace"); - drop(db); - - let post_model_switch = json!({ - "hook_event_name": "PostModelSwitch", - "session_id": "session-123", - "from_model": "model-a", - "to_model": "model-b", - "source": "picker" - }); - assert_eq!( - claude_model_state::run_claude_model_state_from_payload_at_state_root( - &repo_root, - &state_root, - &post_model_switch.to_string(), - None, - || Ok(20), - ), - "" - ); - - let db = open_agent_trace_db_for_hook_runtime_at_state_root( - &repo_root, - &state_root, - "test DB should open after PostModelSwitch", - ) - .expect("test DB should open after PostModelSwitch"); - assert_eq!( - db.claude_model_state_by_session_and_agent("cc_session-123", "") - .expect("PostModelSwitch state should be readable") - .expect("PostModelSwitch should update state") - .model_id, - "claude/model-b" - ); - let switched_event = model_less_claude_diff_event("session-123", "tool-b", None); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&switched_event), - ) - .expect("PostModelSwitch state should attribute the next diff trace"); - - let mut direct_event = model_less_claude_diff_event("session-123", "tool-direct", None); - direct_event - .as_object_mut() - .expect("Claude test event should be an object") - .insert("model".to_string(), json!("model-c")); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&direct_event), - ) - .expect("direct model attribution should persist"); - - let transcript_path = state_root.join("transcript.jsonl"); - fs::write( - &transcript_path, - concat!( - r#"{"type":"assistant","message":{"role":"assistant","model":"model-c","content":[{"type":"tool_use","id":"tool-transcript"}]}}"#, - "\n" - ), - ) - .expect("transcript fixture should be written"); - let transcript_event = claude_model_test_event(&transcript_path, "tool-transcript"); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&transcript_event), - ) - .expect("transcript model attribution should persist"); - - let no_state_event = - model_less_claude_diff_event("session-without-state", "tool-none", None); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&no_state_event), - ) - .expect("an attribution-less diff trace should still persist"); - - let subagent_event = - model_less_claude_diff_event("session-123", "tool-subagent", Some("subagent-1")); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&subagent_event), - ) - .expect("a subagent diff trace should persist"); - - assert_eq!( - persisted_model_ids(&db), - vec![ - Some(String::from("claude/model-a")), - Some(String::from("claude/model-b")), - Some(String::from("claude/model-c")), - Some(String::from("claude/model-c")), - None, - None, - ] - ); - - drop(db); - fs::remove_file(transcript_path).expect("transcript fixture should be removed"); - fs::remove_dir_all(repo_root).expect("test repository should be removed"); - fs::remove_dir_all(state_root).expect("test state should be removed"); - } - - #[test] - fn claude_model_attribution_bridge_inheritance_seeds_state_and_diff_trace() { - let repo_root = init_attribution_git_repo("bridge-inheritance"); - let state_root = unique_attribution_db_path("bridge-inheritance-state") - .parent() - .expect("test state should have a parent") - .to_path_buf(); - let storage = resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &repo_root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("setup path should initialize the test repository DB"); - drop(storage); - - let sibling_transcript = state_root.join("session-old.jsonl"); - let current_transcript = state_root.join("session-current.jsonl"); - fs::write( - &sibling_transcript, - concat!( - r#"{"type":"file-history-snapshot"}"#, - "\n", - r#"{"type":"bridge-session","sessionId":"session-old","bridgeSessionId":"cse_shared"}"#, - "\n", - ), - ) - .expect("sibling transcript fixture should be written"); - fs::write( - ¤t_transcript, - concat!( - r#"{"type":"file-history-snapshot"}"#, - "\n", - r#"{"type":"bridge-session","sessionId":"session-current","bridgeSessionId":"cse_shared"}"#, - "\n", - ), - ) - .expect("current transcript fixture should be written"); - - let db = open_agent_trace_db_for_hook_runtime_at_state_root( - &repo_root, - &state_root, - "test DB should open before bridge inheritance", - ) - .expect("test DB should open before bridge inheritance"); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-old"), - agent_id: String::new(), - model_id: String::from("claude/inherited-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 5, - }) - .expect("sibling state should be seeded"); - drop(db); - - let session_start = json!({ - "hook_event_name": "SessionStart", - "session_id": "session-current", - "source": "clear", - "transcript_path": current_transcript, - }); - assert_eq!( - claude_model_state::run_claude_model_state_from_payload_at_state_root( - &repo_root, - &state_root, - &session_start.to_string(), - None, - || Ok(10), - ), - "" - ); - - let db = open_agent_trace_db_for_hook_runtime_at_state_root( - &repo_root, - &state_root, - "test DB should open after bridge inheritance", - ) - .expect("test DB should open after bridge inheritance"); - let inherited = db - .claude_model_state_by_session_and_agent("cc_session-current", "") - .expect("inherited state lookup should succeed") - .expect("current session should inherit sibling state"); - assert_eq!(inherited.model_id, "claude/inherited-model"); - assert_eq!(inherited.source, "bridge_inherited"); - assert_eq!(inherited.observation_kind, ObservationKind::SessionStart); - assert_eq!(inherited.observed_at_ms, 10); - - let diff_event = model_less_claude_diff_event("session-current", "tool-inherited", None); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&diff_event), - ) - .expect("inherited state should attribute the diff trace"); - assert_eq!( - persisted_model_ids(&db), - vec![Some(String::from("claude/inherited-model"))] - ); - - drop(db); - fs::remove_file(sibling_transcript).expect("sibling transcript should be removed"); - fs::remove_file(current_transcript).expect("current transcript should be removed"); - fs::remove_dir_all(repo_root).expect("test repository should be removed"); - fs::remove_dir_all(state_root).expect("test state should be removed"); - } - - #[test] - fn claude_diff_trace_persistence_uses_state_only_after_direct_and_transcript() { - let db_path = unique_attribution_db_path("precedence"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-123"), - agent_id: String::new(), - model_id: String::from("claude/state-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 1, - }) - .expect("state should be seeded"); - - let mut state_event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "state"); - let state_object = state_event - .as_object_mut() - .expect("test event should be an object"); - state_object.remove("transcript_path"); - state_object.remove("tool_use_id"); - let state_payload = parsed_claude_diff_trace(&state_event); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &state_payload) - .expect("state fallback should persist"); - - let mut direct_event = state_event.clone(); - direct_event - .as_object_mut() - .expect("test event should be an object") - .insert("model".to_string(), json!("direct-model")); - let direct_payload = parsed_claude_diff_trace(&direct_event); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &direct_payload) - .expect("direct attribution should persist"); - - let transcript_path = db_path.with_extension("jsonl"); - fs::write( - &transcript_path, - concat!( - r#"{"type":"assistant","message":{"role":"assistant","model":"transcript-model","content":[{"type":"tool_use","id":"transcript"}]}}"#, - "\n" - ), - ) - .expect("transcript fixture should be written"); - let transcript_event = claude_model_test_event(&transcript_path, "transcript"); - let transcript_payload = parsed_claude_diff_trace(&transcript_event); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &transcript_payload) - .expect("transcript attribution should persist"); - - let models = db - .query_map( - "SELECT model_id FROM diff_traces ORDER BY id ASC", - (), - |row| row.get::>(0).map_err(Into::into), - ) - .expect("persisted models should be readable"); - assert_eq!( - models, - vec![ - Some(String::from("claude/state-model")), - Some(String::from("claude/direct-model")), - Some(String::from("claude/transcript-model")), - ] - ); - - drop(db); - fs::remove_file(transcript_path).expect("transcript fixture should be removed"); - fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) - .expect("test DB directory should be removed"); - } - - #[test] - fn normalized_claude_tool_name_does_not_use_claude_state_fallback() { - let db_path = unique_attribution_db_path("normalized-claude"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-123"), - agent_id: String::new(), - model_id: String::from("claude/parent-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 1, - }) - .expect("parent state should be seeded"); - - let payload = diff_trace_payload_with( - CLAUDE_TOOL_NAME, - "session-123", - PAYLOAD_TYPE_PATCH, - None, - None, - ); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) - .expect("normalized Claude payload should persist"); - - let model = db - .query_map("SELECT model_id FROM diff_traces LIMIT 1", (), |row| { - row.get::>(0).map_err(Into::into) - }) - .expect("persisted model should be readable") - .into_iter() - .next() - .expect("diff trace row should exist"); - assert_eq!(model, None); - - drop(db); - fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) - .expect("test DB directory should be removed"); - } - - #[test] - fn claude_diff_trace_state_lookup_isolated_to_exact_subagent_scope() { - let db_path = unique_attribution_db_path("subagent"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-123"), - agent_id: String::new(), - model_id: String::from("claude/parent-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 1, - }) - .expect("parent state should be seeded"); - - let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "subagent"); - let event_object = event - .as_object_mut() - .expect("test event should be an object"); - event_object.remove("transcript_path"); - event_object.remove("tool_use_id"); - event_object.insert("agent_id".to_string(), json!("subagent-1")); - let payload = parsed_claude_diff_trace(&event); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) - .expect("subagent diff trace should persist"); - - let model = db - .query_map("SELECT model_id FROM diff_traces LIMIT 1", (), |row| { - row.get::>(0).map_err(Into::into) - }) - .expect("persisted model should be readable") - .into_iter() - .next() - .expect("diff trace row should exist"); - assert_eq!(model, None); - - drop(db); - fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) - .expect("test DB directory should be removed"); - } - - fn write_bridge_transcript(path: &Path, bridge_session_id: &str) { - fs::write( - path, - format!( - concat!( - "{{\"type\":\"file-history-snapshot\"}}\n", - "{{\"type\":\"bridge-session\",\"sessionId\":\"s\",", - "\"bridgeSessionId\":\"{bridge_session_id}\"}}\n" - ), - bridge_session_id = bridge_session_id, - ), - ) - .expect("bridge transcript fixture should be written"); - } - - #[test] - fn claude_diff_trace_seeds_bridge_chain_state_on_state_miss_and_reuses_it() { - let db_path = unique_attribution_db_path("bridge-chain-seed"); - let dir = db_path - .parent() - .expect("test DB should have a parent") - .to_path_buf(); - fs::create_dir_all(&dir).expect("test DB directory should be created"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-member"), - agent_id: String::new(), - model_id: String::from("claude/chain-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 100, - }) - .expect("chain member state should seed"); - - let current_transcript = dir.join("session-current.jsonl"); - let member_transcript = dir.join("session-member.jsonl"); - write_bridge_transcript(¤t_transcript, "cse_chain"); - write_bridge_transcript(&member_transcript, "cse_chain"); - - let payload = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "a")); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) - .expect("bridge chain seeding should persist"); - - assert_eq!( - persisted_model_ids(&db), - vec![Some(String::from("claude/chain-model"))] - ); - let seeded = db - .claude_model_state_by_session_and_agent("cc_session-123", "") - .expect("seeded lookup should succeed") - .expect("current session should be seeded"); - assert_eq!(seeded.model_id, "claude/chain-model"); - assert_eq!(seeded.source, "bridge_inherited"); - - fs::remove_file(&member_transcript).expect("member transcript should be removed"); - let payload_two = - parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "b")); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload_two) - .expect("second diff trace should persist"); - assert_eq!( - persisted_model_ids(&db), - vec![ - Some(String::from("claude/chain-model")), - Some(String::from("claude/chain-model")), - ] - ); - let after = db - .claude_model_state_by_session_and_agent("cc_session-123", "") - .expect("lookup should succeed") - .expect("row should still exist"); - assert_eq!(after.observed_at_ms, seeded.observed_at_ms); - - drop(db); - fs::remove_dir_all(&dir).expect("test DB directory should be removed"); - } - - #[test] - fn claude_diff_trace_bridge_chain_selects_newest_observation_across_members() { - let db_path = unique_attribution_db_path("bridge-chain-newest"); - let dir = db_path - .parent() - .expect("test DB should have a parent") - .to_path_buf(); - fs::create_dir_all(&dir).expect("test DB directory should be created"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-root"), - agent_id: String::new(), - model_id: String::from("claude/sonnet-5"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 10, - }) - .expect("root state should seed"); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-mid"), - agent_id: String::new(), - model_id: String::from("claude/opus-5"), - observation_kind: ObservationKind::PostModelSwitch, - source: String::from("picker"), - observed_at_ms: 20, - }) - .expect("mid state should seed"); - - let current_transcript = dir.join("session-current.jsonl"); - let root_transcript = dir.join("session-root.jsonl"); - let mid_transcript = dir.join("session-mid.jsonl"); - write_bridge_transcript(&mid_transcript, "cse_chain"); - write_bridge_transcript(¤t_transcript, "cse_chain"); - thread::sleep(Duration::from_millis(15)); - write_bridge_transcript(&root_transcript, "cse_chain"); - - let payload = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "x")); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) - .expect("newest-observation resolution should persist"); - - assert_eq!( - persisted_model_ids(&db), - vec![Some(String::from("claude/opus-5"))] - ); - - drop(db); - fs::remove_dir_all(&dir).expect("test DB directory should be removed"); - } - - #[test] - fn claude_diff_trace_bridge_chain_fails_open_without_write_or_attribution() { - let db_path = unique_attribution_db_path("bridge-chain-fail-open"); - let dir = db_path - .parent() - .expect("test DB should have a parent") - .to_path_buf(); - fs::create_dir_all(&dir).expect("test DB directory should be created"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - - let mut event = - claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "no-transcript"); - event - .as_object_mut() - .expect("event should be an object") - .remove("transcript_path"); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&event), - ) - .expect("missing transcript should fail open"); - - let current_transcript = dir.join("session-current.jsonl"); - let member_transcript = dir.join("session-member.jsonl"); - write_bridge_transcript(¤t_transcript, "cse_chain"); - write_bridge_transcript(&member_transcript, "cse_chain"); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "no-state")), - ) - .expect("stateless chain should fail open"); - - assert_eq!(persisted_model_ids(&db), vec![None, None]); - assert!( - db.claude_model_state_by_session_and_agent("cc_session-123", "") - .expect("lookup should succeed") - .is_none(), - "no state row should be written on a fail-open branch" - ); - - drop(db); - fs::remove_dir_all(&dir).expect("test DB directory should be removed"); - } - - #[test] - fn claude_diff_trace_bridge_chain_does_not_seed_subagent_scope() { - let db_path = unique_attribution_db_path("bridge-chain-subagent"); - let dir = db_path - .parent() - .expect("test DB should have a parent") - .to_path_buf(); - fs::create_dir_all(&dir).expect("test DB directory should be created"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-member"), - agent_id: String::new(), - model_id: String::from("claude/chain-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 100, - }) - .expect("chain member state should seed"); - - let current_transcript = dir.join("session-current.jsonl"); - let member_transcript = dir.join("session-member.jsonl"); - write_bridge_transcript(¤t_transcript, "cse_chain"); - write_bridge_transcript(&member_transcript, "cse_chain"); - - let mut event = claude_model_test_event(¤t_transcript, "subagent"); - event - .as_object_mut() - .expect("event should be an object") - .insert("agent_id".to_string(), json!("subagent-1")); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&event), - ) - .expect("subagent diff trace should persist"); - - assert_eq!(persisted_model_ids(&db), vec![None]); - assert!( - db.claude_model_state_by_session_and_agent("cc_session-123", "subagent-1") - .expect("lookup should succeed") - .is_none(), - "subagent scope must not inherit main-session chain state" - ); - - drop(db); - fs::remove_dir_all(&dir).expect("test DB directory should be removed"); - } - - #[test] - fn prefixed_diff_trace_session_id_prefixes_fresh_pi_session_id() { - assert_eq!( - prefixed_diff_trace_session_id("pi", "session-123"), - "pi_session-123" - ); - } - - #[test] - fn prefixed_diff_trace_session_id_keeps_already_prefixed_pi_session_id() { - assert_eq!( - prefixed_diff_trace_session_id("pi", "pi_session-123"), - "pi_session-123" - ); - } - - #[test] - fn prefixed_diff_trace_session_id_prefixes_fresh_codex_session_id() { - assert_eq!( - prefixed_diff_trace_session_id("codex", "session-123"), - "cx_session-123" - ); - } - - #[test] - fn prefixed_diff_trace_session_id_keeps_already_prefixed_codex_session_id() { - assert_eq!( - prefixed_diff_trace_session_id("codex", "cx_session-123"), - "cx_session-123" - ); - } - - #[test] - fn prefixed_diff_trace_session_id_adding_codex_does_not_affect_other_tool_prefixes() { - assert_eq!( - prefixed_diff_trace_session_id("opencode", "session-123"), - "oc_session-123" - ); - assert_eq!( - prefixed_diff_trace_session_id("claude", "session-123"), - "cc_session-123" - ); - assert_eq!( - prefixed_diff_trace_session_id("pi", "session-123"), - "pi_session-123" - ); - } - - #[test] - fn normalize_codex_model_id_preserves_fresh_model_id() { - assert_eq!( - normalize_codex_model_id("gpt-5.6-codex").as_deref(), - Some("gpt-5.6-codex") - ); - } - - #[test] - fn normalize_codex_model_id_preserves_qualified_model_ids() { - for model in ["openai/gpt-x", "qualified/custom-provider/model"] { - assert_eq!(normalize_codex_model_id(model).as_deref(), Some(model)); - } - } - - #[test] - fn normalize_codex_model_id_preserves_unqualified_model_ids() { - assert_eq!( - normalize_codex_model_id("custom-codex-model").as_deref(), - Some("custom-codex-model") - ); - } - - #[test] - fn normalize_codex_model_id_returns_none_for_blank_model_ids() { - assert_eq!(normalize_codex_model_id(" "), None); - } - - #[test] - fn normalize_opencode_model_id_preserves_qualified_and_unqualified_ids() { - for model in [ - "opencode/big-pickle", - "anthropic/claude-sonnet-4", - "custom-model", - ] { - assert_eq!(normalize_opencode_model_id(model).as_deref(), Some(model)); - } - assert_eq!( - normalize_opencode_model_id(" opencode/big-pickle ").as_deref(), - Some("opencode/big-pickle") - ); - } - - #[test] - fn normalize_opencode_model_id_returns_none_for_blank_model_ids() { - assert_eq!(normalize_opencode_model_id(""), None); - assert_eq!(normalize_opencode_model_id(" "), None); - } - - #[test] - fn pi_normalized_diff_trace_payload_persists_with_pi_prefixed_session_id() { - let stdin_payload = serde_json::json!({ - "sessionID": "session-123", - "diff": "diff text", - "time": 1_800_000_000_000_u64, - "model_id": "anthropic/claude-opus-4", - "tool_name": "pi", - "tool_version": null - }) - .to_string(); - - let parsed = parse_diff_trace_payload(&stdin_payload) - .expect("normalized Pi diff-trace payload should parse"); - let payload = match parsed { - DiffTraceParseResult::Persist(payload) => payload, - DiffTraceParseResult::NoOp(message) => { - panic!("Pi payload should persist, got no-op: {message}") - } - }; - - assert_eq!(payload.tool_name, "pi"); - assert_eq!(payload.model_id.as_deref(), Some("anthropic/claude-opus-4")); - assert_eq!(payload.tool_version, None); - - persist_diff_trace_payload_to_agent_trace_db_with( - &payload, - payload.model_id.as_deref(), - payload.tool_version.as_deref(), - |input| { - assert_eq!(input.time_ms, 1_800_000_000_000_i64); - assert_eq!(input.session_id, "pi_session-123"); - assert_eq!(input.model_id, Some("anthropic/claude-opus-4")); - assert_eq!(input.tool_name, "pi"); - assert_eq!(input.tool_version, None); - assert_eq!(input.payload_type, PAYLOAD_TYPE_PATCH); - - Ok(()) - }, - ) - .expect("Pi diff-trace payload should be persisted"); - } - - #[test] - fn post_commit_intersection_flow_preserves_pi_provenance() { - let now_ms = 1_800_000_000_000_i64; - let commit_time_ms = now_ms - 1_000; - - let output = run_post_commit_intersection_flow_with( - Path::new("/repo"), - |_| { - Ok(PostCommitPatchData { - commit_oid: String::from("def456"), - commit_time_ms, - parsed_patch: valid_patch("src/lib.rs", "shared line"), - }) - }, - || Ok(now_ms), - |_, _| { - Ok(RecentDiffTracePatches { - patches: vec![ParsedDiffTracePatch { - id: 9, - time_ms: now_ms - 500, - session_id: String::from("pi_valid-session"), - patch: valid_patch("src/lib.rs", "shared line"), - tool_name: Some(String::from("pi")), - tool_version: None, - payload_type: String::from(PAYLOAD_TYPE_PATCH), - }], - skipped: vec![], - }) - }, - |_| Ok(()), - ) - .expect("post-commit intersection flow should succeed"); - - assert_eq!(output.combined_recent_patch.files.len(), 1); - assert_eq!(output.tool_name, Some(String::from("pi"))); - assert_eq!(output.tool_version, None); - } - - #[test] - fn diff_trace_db_persistence_uses_direct_payload_model_and_tool_version() { - let payload = diff_trace_payload(Some("direct-model"), None); - - persist_diff_trace_payload_to_agent_trace_db_with( - &payload, - Some("direct-model"), - Some("Claude Code 1.2.3"), - |input| { - assert_eq!(input.time_ms, 1_800_000_000_000_i64); - assert_eq!(input.session_id, "cc_session-123"); - assert_eq!(input.model_id, Some("direct-model")); - assert_eq!(input.tool_name, "claude"); - assert_eq!(input.tool_version, Some("Claude Code 1.2.3")); - assert_eq!(input.payload_type, PAYLOAD_TYPE_STRUCTURED); - - Ok(()) - }, - ) - .expect("direct diff-trace attribution should be persisted"); - } - - #[test] - fn post_commit_intersection_flow_uses_same_window_end_for_query_and_persistence() { - let now_ms = 1_800_000_000_000_i64; - let commit_time_ms = now_ms - 1_000; - let expected_cutoff_ms = now_ms - RECENT_DAYS_MILLIS; - let query_window = RefCell::new(None); - let persisted = RefCell::new(None); - - let output = run_post_commit_intersection_flow_with( - Path::new("/repo"), - |_| { - Ok(PostCommitPatchData { - commit_oid: String::from("abc123"), - commit_time_ms, - parsed_patch: valid_patch("src/lib.rs", "shared line"), - }) - }, - || Ok(now_ms), - |cutoff_ms, end_ms| { - *query_window.borrow_mut() = Some((cutoff_ms, end_ms)); - - Ok(RecentDiffTracePatches { - patches: vec![ParsedDiffTracePatch { - id: 7, - time_ms: now_ms - 500, - session_id: String::from("oc_valid-session"), - patch: valid_patch("src/lib.rs", "shared line"), - tool_name: Some(String::from("opencode")), - tool_version: Some(String::from("1.2.3")), - payload_type: String::from(PAYLOAD_TYPE_PATCH), - }], - skipped: vec![SkippedDiffTracePatch { - id: 8, - time_ms: now_ms - 250, - session_id: String::from("oc_malformed-session"), - reason: String::from("invalid hunk header"), - }], - }) - }, - |insert_input| { - *persisted.borrow_mut() = Some(CapturedPostCommitIntersectionInsert { - commit_id: insert_input.commit_id.to_string(), - post_commit_time_ms: insert_input.post_commit_time_ms, - recent_window_cutoff_ms: insert_input.recent_window_cutoff_ms, - recent_window_end_ms: insert_input.recent_window_end_ms, - loaded_diff_trace_count: insert_input.loaded_diff_trace_count, - skipped_diff_trace_count: insert_input.skipped_diff_trace_count, - intersection_patch: insert_input.intersection_patch.to_string(), - }); - - Ok(()) - }, - ) - .expect("post-commit intersection flow should succeed"); - - assert_eq!( - query_window.into_inner(), - Some((expected_cutoff_ms, now_ms)) - ); - - let persisted = persisted - .into_inner() - .expect("intersection row should be persisted"); - assert_eq!(persisted.commit_id, "abc123"); - assert_eq!(persisted.post_commit_time_ms, commit_time_ms); - assert_eq!(persisted.recent_window_cutoff_ms, expected_cutoff_ms); - assert_eq!(persisted.recent_window_end_ms, now_ms); - assert_eq!(persisted.loaded_diff_trace_count, 1); - assert_eq!(persisted.skipped_diff_trace_count, 1); - - let intersection: ParsedPatch = serde_json::from_str(&persisted.intersection_patch) - .expect("persisted intersection patch should deserialize"); - assert_eq!(intersection.files.len(), 1); - assert_eq!(intersection.files[0].new_path, "src/lib.rs"); - assert_eq!(intersection.files[0].hunks[0].lines.len(), 1); - assert_eq!( - intersection.files[0].hunks[0].lines[0].content, - "shared line" - ); - - assert_eq!(output.post_commit_data.commit_oid, "abc123"); - assert_eq!(output.post_commit_data.commit_time_ms, commit_time_ms); - assert_eq!(output.combined_recent_patch.files.len(), 1); - assert_eq!(output.combined_recent_patch.files[0].new_path, "src/lib.rs"); - assert_eq!(output.tool_name, Some(String::from("opencode"))); - assert_eq!(output.tool_version, Some(String::from("1.2.3"))); - } - - fn post_commit_flow_result() -> PostCommitIntersectionFlowResult { - PostCommitIntersectionFlowResult { - combined_recent_patch: valid_patch("src/lib.rs", "shared line"), - post_commit_data: PostCommitPatchData { - commit_oid: String::from("abc123"), - commit_time_ms: 1_800_000_000_000, - parsed_patch: valid_patch("src/lib.rs", "shared line"), - }, - tool_name: None, - tool_version: None, - } - } - - fn minimal_agent_trace() -> AgentTrace { - serde_json::from_value(json!({ "files": [] })) - .expect("minimal Agent Trace should deserialize") - } - - #[test] - fn post_commit_auto_sync_launches_after_successful_persistence_when_enabled() { - let events = RefCell::new(Vec::new()); - - let output = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| { - events.borrow_mut().push("intersection"); - Ok(post_commit_flow_result()) - }, - |_, _, _, _| { - events.borrow_mut().push("persistence"); - Ok(minimal_agent_trace()) - }, - |_| { - events.borrow_mut().push("config"); - Ok(true) - }, - |_| { - events.borrow_mut().push("launch"); - Ok(()) - }, - |_| { - events.borrow_mut().push("checkpoint"); - Ok(()) - }, - None, - ) - .expect("successful post-commit should remain successful"); - - assert!(output.contains("post-commit hook processed intersection")); - assert_eq!( - events.into_inner(), - vec![ - "intersection", - "persistence", - "checkpoint", - "config", - "launch" - ] - ); - } - - #[test] - fn post_commit_validation_failure_does_not_resolve_or_launch_auto_sync() { - let validation_called = RefCell::new(false); - let config_called = RefCell::new(false); - let launch_called = RefCell::new(false); - - let error = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, flow_result, vcs_type, remote_url| { - run_post_commit_agent_trace_flow_with( - flow_result, - vcs_type, - remote_url, - &ParsedPatch { files: Vec::new() }, - |_| { - *validation_called.borrow_mut() = true; - Err(anyhow!("Agent Trace validation failed")) - }, - |_| panic!("Agent Trace persistence must not run after validation failure"), - ) - }, - |_| { - *config_called.borrow_mut() = true; - Ok(true) - }, - |_| { - *launch_called.borrow_mut() = true; - Ok(()) - }, - |_| panic!("checkpoint must not run after persistence failure"), - None, - ) - .expect_err("validation failure should be returned"); - - assert!(*validation_called.borrow()); - assert!(!error.to_string().is_empty()); - assert!(!*config_called.borrow()); - assert!(!*launch_called.borrow()); - } - - fn post_commit_flow_result_for( - direct: ParsedPatch, - committed: ParsedPatch, - ) -> PostCommitIntersectionFlowResult { - PostCommitIntersectionFlowResult { - combined_recent_patch: direct, - post_commit_data: PostCommitPatchData { - commit_oid: String::from("abc123"), - commit_time_ms: 1_800_000_000_000, - parsed_patch: committed, - }, - tool_name: Some(String::from("claude")), - tool_version: Some(String::from("9.9.9")), - } - } - - fn persisted_post_commit_trace( - flow_result: &PostCommitIntersectionFlowResult, - mutation_ai_patch: &ParsedPatch, - ) -> Value { - let persisted = RefCell::new(None); - - run_post_commit_agent_trace_flow_with( - flow_result, - Some(AgentTraceVcsType::Git), - "", - mutation_ai_patch, - |_| Ok(()), - |insert| { - *persisted.borrow_mut() = Some(insert.trace_json.to_string()); - Ok(()) - }, - ) - .expect("post-commit Agent Trace flow should build and persist"); - - serde_json::from_str( - persisted - .into_inner() - .expect("trace should have been persisted") - .as_str(), - ) - .expect("persisted trace JSON should parse") - } - - #[test] - fn post_commit_agent_trace_flow_attributes_mutation_only_lines_as_ai_without_provenance() { - let flow_result = post_commit_flow_result_for( - ParsedPatch { files: Vec::new() }, - valid_patch("src/lib.rs", "mutated line"), - ); - let mutation_ai_patch = valid_patch("src/lib.rs", "mutated line"); - - let trace = persisted_post_commit_trace(&flow_result, &mutation_ai_patch); - - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(1) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(0) - ); - assert!( - trace.get("tool").is_none(), - "mutation-only coverage fabricates no tool provenance" - ); - let contributor = &trace["files"][0]["conversations"][0]["contributor"]; - assert_eq!(contributor["type"], json!("ai")); - assert!( - contributor.get("model_id").is_none(), - "mutation-only coverage carries no model provenance" - ); - assert!( - trace["files"][0]["conversations"][0] - .get("related") - .is_none(), - "mutation-only coverage carries no session provenance" - ); - } - - #[test] - fn post_commit_agent_trace_flow_keeps_direct_provenance_when_direct_covers_the_line() { - let flow_result = post_commit_flow_result_for( - valid_patch("src/lib.rs", "shared line"), - valid_patch("src/lib.rs", "shared line"), - ); - - let trace = persisted_post_commit_trace(&flow_result, &ParsedPatch { files: Vec::new() }); - - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(1) - ); - assert_eq!( - trace["tool"], - json!({ "name": "claude", "version": "9.9.9" }) - ); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"]["type"], - json!("ai") - ); - } - - #[test] - fn post_commit_agent_trace_flow_with_empty_mutation_patch_leaves_uncovered_lines_unknown() { - let flow_result = post_commit_flow_result_for( - ParsedPatch { files: Vec::new() }, - valid_patch("src/lib.rs", "human line"), - ); - - let trace = persisted_post_commit_trace(&flow_result, &ParsedPatch { files: Vec::new() }); - - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(1) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(0) - ); - assert!(trace.get("tool").is_none()); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"]["type"], - json!("unknown") - ); - } - - mod mutation_attribution_e2e { - use super::*; - use crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch; - use crate::services::mutation_trace::runtime::resolve_worktree_id; - use crate::services::mutation_trace::store::encode_revision; - - fn git(repo: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).expect("git output should be UTF-8") - } - - fn commit_all(repo: &Path, message: &str) { - git(repo, &["add", "-A"]); - git( - repo, - &[ - "-c", - "user.name=SCE Test", - "-c", - "user.email=sce@example.invalid", - "commit", - "-qm", - message, - ], - ); - } - - struct E2eRepo { - _temp: tempfile::TempDir, - root: PathBuf, - db_path: PathBuf, - } - - impl E2eRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-mutation-attr-e2e-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.rs"), "one\n").expect("seed file should write"); - commit_all(&root, "base"); - let db_path = temp.path().join("agent-trace.db"); - RepositoryAgentTraceDb::new_at(&db_path) - .expect("repository DB should open with schema"); - Self { - _temp: temp, - root, - db_path, - } - } - - fn db(&self) -> RepositoryAgentTraceDb { - RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&self.db_path) - .expect("repository DB should reopen") - } - - fn head_tree(&self) -> String { - git(&self.root, &["rev-parse", "HEAD^{tree}"]) - .trim() - .to_owned() - } - - fn parent_tree(&self) -> String { - git(&self.root, &["rev-parse", "HEAD~1^{tree}"]) - .trim() - .to_owned() - } - - fn checkout_id(&self) -> String { - resolve_worktree_id(&self.root) - .expect("worktree identity should resolve") - .0 - } - } - - fn seed_event( - db: &RepositoryAgentTraceDb, - worktree_id: &str, - revision: u64, - before_tree: &str, - after_tree: &str, - attribution_kind: &str, - attribution_scope_id: Option<&str>, - ) { - db.execute( - "INSERT INTO mutation_trace_events - (worktree_id, revision, before_tree, after_tree, tainted, failure_kind, - attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, - boundary_event_id) - VALUES (?1, ?2, ?3, ?4, 0, 'healthy', ?5, ?6, 'flush', NULL, NULL)", - ( - worktree_id, - encode_revision(revision).as_slice(), - before_tree, - after_tree, - attribution_kind, - attribution_scope_id, - ), - ) - .expect("mutation event insert should succeed"); - } - - fn row_count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { - db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("count query should succeed") - .into_iter() - .next() - .expect("count row should exist") - } - - fn touched_line_count(patch: &ParsedPatch) -> usize { - patch - .files - .iter() - .flat_map(|file| file.hunks.iter()) - .map(|hunk| hunk.lines.len()) - .sum() - } - - fn flow_result_for( - repo: &E2eRepo, - direct: ParsedPatch, - ) -> PostCommitIntersectionFlowResult { - let post_commit_data = capture_post_commit_patch_from_git(&repo.root) - .expect("capturing the post-commit patch should succeed"); - PostCommitIntersectionFlowResult { - combined_recent_patch: direct, - post_commit_data, - tool_name: None, - tool_version: None, - } - } - - fn resolve_mutation_ai( - repo: &E2eRepo, - db: &RepositoryAgentTraceDb, - flow_result: &PostCommitIntersectionFlowResult, - ) -> ParsedPatch { - let direct_intersection = intersect_patches_fn( - &flow_result.combined_recent_patch, - &flow_result.post_commit_data.parsed_patch, - ); - resolve_post_commit_mutation_ai_patch( - &repo.root, - db, - &direct_intersection, - &flow_result.post_commit_data.parsed_patch, - ) - } - - fn persist_trace( - flow_result: &PostCommitIntersectionFlowResult, - db: &RepositoryAgentTraceDb, - mutation_ai_patch: &ParsedPatch, - ) -> Value { - let persisted = RefCell::new(None); - run_post_commit_agent_trace_flow_with( - flow_result, - Some(AgentTraceVcsType::Git), - "git@github.com:acme/widgets.git", - mutation_ai_patch, - |value| { - validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())) - }, - |insert| { - *persisted.borrow_mut() = Some(insert.trace_json.to_string()); - db.insert_agent_trace(insert).map(|_| ()) - }, - ) - .expect("the post-commit Agent Trace flow should build, validate, and persist"); - - serde_json::from_str( - persisted - .into_inner() - .expect("a trace should have been persisted") - .as_str(), - ) - .expect("the persisted trace JSON should parse") - } - - #[test] - fn a_mutation_only_line_persists_as_ai_without_fabricated_provenance() { - let repo = E2eRepo::new("mutation-only"); - fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); - commit_all(&repo.root, "add two"); - - let db = repo.db(); - seed_event( - &db, - &repo.checkout_id(), - 1, - &repo.parent_tree(), - &repo.head_tree(), - "ai_exclusive", - Some("scope-x"), - ); - - let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); - let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); - assert_eq!( - touched_line_count(&mutation_ai_patch), - 1, - "a healthy untainted exclusive event covers the committed line" - ); - - let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(1) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(0) - ); - assert!( - trace.get("tool").is_none(), - "mutation-only coverage fabricates no tool provenance" - ); - let contributor = &trace["files"][0]["conversations"][0]["contributor"]; - assert_eq!(contributor["type"], json!("ai")); - assert!( - contributor.get("model_id").is_none(), - "mutation-only coverage carries no model provenance" - ); - - assert_eq!( - row_count(&db, "diff_traces"), - 0, - "mutation evidence is never inserted into diff_traces" - ); - assert_eq!( - row_count(&db, "post_commit_patch_intersections"), - 0, - "the direct-only intersection table is untouched by this flow" - ); - assert_eq!(row_count(&db, "agent_traces"), 1); - } - - #[test] - fn direct_plus_mutation_evidence_completes_hunk_coverage_and_keeps_direct_provenance() { - let repo = E2eRepo::new("direct-plus-mutation"); - fs::write(repo.root.join("file.rs"), "one\ntwo\nthree\n") - .expect("the edit should write"); - commit_all(&repo.root, "add two and three"); - - let db = repo.db(); - seed_event( - &db, - &repo.checkout_id(), - 1, - &repo.parent_tree(), - &repo.head_tree(), - "ai_exclusive", - Some("scope-x"), - ); - - let direct = parse_patch_from_text( - "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", - None, - ) - .expect("the direct patch should parse"); - let mut flow_result = flow_result_for(&repo, direct); - flow_result.tool_name = Some(String::from("claude")); - flow_result.tool_version = Some(String::from("9.9.9")); - - let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); - assert_eq!( - touched_line_count(&mutation_ai_patch), - 1, - "only the line direct evidence did not cover is resolved from mutation history" - ); - - let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(2), - "the union of direct and mutation coverage classifies the hunk ai" - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(0) - ); - assert_eq!( - trace["tool"], - json!({ "name": "claude", "version": "9.9.9" }) - ); - } - - #[test] - fn a_newer_nonexclusive_event_keeps_the_line_non_ai() { - let repo = E2eRepo::new("newer-nonexclusive"); - fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); - commit_all(&repo.root, "add two"); - - let db = repo.db(); - let worktree = repo.checkout_id(); - seed_event( - &db, - &worktree, - 1, - &repo.parent_tree(), - &repo.head_tree(), - "ai_exclusive", - Some("scope-old"), - ); - seed_event( - &db, - &worktree, - 2, - &repo.parent_tree(), - &repo.head_tree(), - "ai_contended", - None, - ); - - let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); - let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); - assert_eq!( - touched_line_count(&mutation_ai_patch), - 0, - "the newer contended match resolves the line and blocks the older exclusive event" - ); - - let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(1) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(0) - ); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"]["type"], - json!("unknown") - ); - } - - #[test] - fn an_adversarial_foreign_worktree_event_cannot_block_the_current_worktrees_exclusive_event( - ) { - let repo = E2eRepo::new("adversarial-linked"); - - let linked_root = repo - .root - .parent() - .expect("the repo should have a parent directory") - .join("linked"); - git( - &repo.root, - &[ - "worktree", - "add", - "-q", - linked_root.to_str().expect("worktree path should be UTF-8"), - ], - ); - - fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); - commit_all(&repo.root, "add two"); - - let db = repo.db(); - let current_worktree = repo.checkout_id(); - let foreign_worktree = resolve_worktree_id(&linked_root) - .expect("the linked worktree's identity should resolve") - .0; - assert_ne!( - current_worktree, foreign_worktree, - "the linked worktree must derive its own distinct identity" - ); - - seed_event( - &db, - ¤t_worktree, - 1, - &repo.parent_tree(), - &repo.head_tree(), - "ai_exclusive", - Some("scope-current"), - ); - seed_event( - &db, - &foreign_worktree, - 2, - &repo.parent_tree(), - &repo.head_tree(), - "ai_contended", - None, - ); - - let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); - let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); - assert_eq!( - touched_line_count(&mutation_ai_patch), - 1, - "only the current worktree's history is eligible, so the older exclusive event contributes" - ); - - let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(1), - "worktree isolation lets the current worktree's exclusive event classify the target ai" - ); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"]["type"], - json!("ai") - ); - assert!(trace.get("tool").is_none()); - } - - fn touched_contents(patch: &ParsedPatch) -> Vec { - patch - .files - .iter() - .flat_map(|file| file.hunks.iter()) - .flat_map(|hunk| hunk.lines.iter()) - .map(|line| line.content.clone()) - .collect() - } - - #[test] - #[allow(clippy::too_many_lines)] - fn persistence_boundaries_stay_separated_across_diff_traces_intersection_and_agent_trace() { - let repo = E2eRepo::new("persistence-boundary"); - - fs::write(repo.root.join("file.rs"), "one\ntwo\n") - .expect("the direct edit should write"); - git(&repo.root, &["add", "-A"]); - let intermediate_tree = git(&repo.root, &["write-tree"]).trim().to_owned(); - - fs::write(repo.root.join("file.rs"), "one\ntwo\nthree\n") - .expect("the mutation edit should write"); - commit_all(&repo.root, "add two and three"); - - let base_tree = repo.parent_tree(); - let final_tree = repo.head_tree(); - assert_ne!( - base_tree, intermediate_tree, - "the direct edit must move the tree" - ); - assert_ne!( - intermediate_tree, final_tree, - "the mutation edit must move the tree again" - ); - - let db = repo.db(); - - let now_ms = current_unix_time_ms().expect("the clock should resolve"); - db.insert_diff_trace(DiffTraceInsert { - time_ms: now_ms - 60_000, - session_id: "cc_session-direct", - patch: "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", - model_id: Some("claude/model-direct"), - tool_name: "claude", - tool_version: Some("9.9.9"), - payload_type: PAYLOAD_TYPE_PATCH, - }) - .expect("the direct diff_traces row should insert"); - - seed_event( - &db, - &repo.checkout_id(), - 1, - &intermediate_tree, - &final_tree, - "ai_exclusive", - Some("scope-mutation"), - ); - - let flow_result = run_post_commit_intersection_flow_with( - &repo.root, - capture_post_commit_patch_from_git, - current_unix_time_ms, - |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), - |insert| db.insert_post_commit_patch_intersection(insert).map(|_| ()), - ) - .expect("the real post-commit intersection flow should run"); - assert_eq!( - touched_contents(&flow_result.combined_recent_patch), - vec!["two".to_owned()], - "the combined recent patch comes from the real diff_traces query, not an in-memory patch" - ); - - let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); - assert_eq!( - touched_contents(&mutation_ai_patch), - vec!["three".to_owned()], - "mutation history resolves only the committed line direct evidence missed" - ); - - persist_trace(&flow_result, &db, &mutation_ai_patch); - - assert_eq!( - row_count(&db, "diff_traces"), - 1, - "mutation attribution must not create another diff_traces row" - ); - let stored_direct_patch: String = db - .query_map("SELECT patch FROM diff_traces", (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("diff_traces query should succeed") - .into_iter() - .next() - .expect("one diff_traces row should exist"); - let stored_direct = parse_patch_from_text(&stored_direct_patch, None) - .expect("the stored direct patch should parse"); - assert_eq!( - touched_contents(&stored_direct), - vec!["two".to_owned()], - "the direct diff_traces row contains 'two' and never 'three'" - ); - - assert_eq!( - row_count(&db, "post_commit_patch_intersections"), - 1, - "the intersection flow persists exactly one direct-only row" - ); - let stored_intersection_json: String = db - .query_map( - "SELECT intersection_patch FROM post_commit_patch_intersections", - (), - |row| row.get::(0).map_err(anyhow::Error::from), - ) - .expect("intersection query should succeed") - .into_iter() - .next() - .expect("one intersection row should exist"); - let stored_intersection = load_patch_from_json(&stored_intersection_json) - .expect("the persisted intersection patch should reconstruct"); - assert_eq!( - touched_contents(&stored_intersection), - vec!["two".to_owned()], - "post_commit_patch_intersections stays direct-only; the mutation line 'three' \ - must never contaminate this table" - ); - - assert_eq!(row_count(&db, "agent_traces"), 1); - let stored_trace_json: String = db - .query_map("SELECT trace_json FROM agent_traces", (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("agent_traces query should succeed") - .into_iter() - .next() - .expect("one Agent Trace row should exist"); - let trace: Value = serde_json::from_str(&stored_trace_json) - .expect("the persisted Agent Trace JSON should parse"); - validate_agent_trace_value(&trace).expect( - "the persisted agent_traces.trace_json validates against the embedded Agent Trace schema", - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(2), - "direct + mutation coverage classifies both committed added lines as ai" - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(0) - ); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"]["type"], - json!("ai") - ); - - assert_eq!( - trace["tool"], - json!({ "name": "claude", "version": "9.9.9" }) - ); - - assert_eq!( - row_count(&db, "mutation_trace_events"), - 1, - "attribution performs no mutation-cursor write" - ); - } - } - - mod mutation_provenance_e2e { - use super::*; - use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - use crate::services::hooks::claude_mutation_scope; - use crate::services::hooks::codex_mutation_scope; - use crate::services::hooks::opencode_mutation_scope; - use crate::services::hooks::pi_mutation_scope; - use crate::services::mutation_trace::runtime::resolve_git_dir; - use crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch; - - fn git(repo: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).expect("git output should be UTF-8") - } - - fn row_count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { - db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("count query should succeed") - .into_iter() - .next() - .expect("count row should exist") - } - - struct ProvenanceE2eRepo { - _temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - db_path: PathBuf, - } - - impl ProvenanceE2eRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-mutation-provenance-e2e-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - let storage = resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - _temp: temp, - root, - state_root, - db_path: storage.db_path, - } - } - - fn db(&self) -> RepositoryAgentTraceDb { - RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&self.db_path) - .expect("repository DB should reopen") - } - - fn cwd(&self) -> String { - self.root.to_string_lossy().into_owned() - } - - fn write_change(&self, content: &str) { - fs::write(self.root.join("file.txt"), content).expect("mutation should write"); - } - - fn commit_change(&self) { - git(&self.root, &["add", "-A"]); - git(&self.root, &["commit", "-qm", "AI mutation"]); - } - - fn mutation_events(&self) -> Vec<(String, Option)> { - self.db() - .query_map( - "SELECT attribution_kind, attribution_scope_id \ - FROM mutation_trace_events ORDER BY revision", - (), - |row| { - let attribution_kind = - row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id)) - }, - ) - .expect("mutation-events query should succeed") - } - - fn run_post_commit(&self) -> Value { - let db = self.db(); - run_post_commit_subcommand_with( - &self.root, - Some(AgentTraceVcsType::Git), - "git@github.com:acme/widgets.git", - |root| { - run_post_commit_intersection_flow_with( - root, - capture_post_commit_patch_from_git, - current_unix_time_ms, - |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), - |insert| db.insert_post_commit_patch_intersection(insert).map(|_| ()), - ) - }, - |root, flow_result, vcs_type, remote_url| { - let direct_intersection = intersect_patches_fn( - &flow_result.combined_recent_patch, - &flow_result.post_commit_data.parsed_patch, - ); - let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( - root, - &db, - &direct_intersection, - &flow_result.post_commit_data.parsed_patch, - ); - - run_post_commit_agent_trace_flow_with( - flow_result, - vcs_type, - remote_url, - &mutation_ai_patch, - |value| { - validate_agent_trace_value(value) - .map_err(|error| anyhow!(error.to_string())) - }, - |insert| db.insert_agent_trace(insert).map(|_| ()), - ) - }, - |_| Ok(false), - |_| Ok(()), - |_| db.passive_checkpoint(), - None, - ) - .expect("the real post-commit hook flow should persist Agent Trace"); - - db.query_map("SELECT trace_json FROM agent_traces", (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("persisted Agent Trace should be readable") - .into_iter() - .next() - .map(|trace| serde_json::from_str(&trace).expect("trace JSON should parse")) - .expect("one Agent Trace row should exist") - } - } - - fn assert_mutation_trace_provenance(trace: &Value, model_id: &str, session_id: &str) { - assert_eq!(trace["files"][0]["path"], json!("file.txt")); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"], - json!({"type": "ai", "model_id": model_id}) - ); - assert_eq!( - trace["files"][0]["conversations"][0]["related"], - json!([{ - "type": "session", - "url": format!("https://sce.crocoder.dev/sessions/{session_id}"), - }]) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(1) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(0) - ); - } - - fn opencode_before( - cwd: &str, - session_id: &str, - call_id: &str, - tool_name: &str, - model: Option<&str>, - ) -> String { - let mut payload = json!({ - "hook_event_name": "ToolExecuteBefore", - "session_id": session_id, - "call_id": call_id, - "cwd": cwd, - "tool_name": tool_name, - }); - if let Some(model) = model { - payload["model"] = json!(model); - } - payload.to_string() - } - - fn opencode_shell_env( - cwd: &str, - session_id: &str, - call_id: &str, - model: Option<&str>, - ) -> String { - let mut payload = json!({ - "hook_event_name": "ShellEnv", - "session_id": session_id, - "call_id": call_id, - "cwd": cwd, - }); - if let Some(model) = model { - payload["model"] = json!(model); - } - payload.to_string() - } - - fn opencode_after(cwd: &str, session_id: &str, call_id: &str, tool_name: &str) -> String { - json!({ - "hook_event_name": "ToolExecuteAfter", - "session_id": session_id, - "call_id": call_id, - "cwd": cwd, - "tool_name": tool_name, - }) - .to_string() - } - - fn opencode_tool_error( - cwd: &str, - session_id: &str, - call_id: &str, - tool_name: &str, - ) -> String { - json!({ - "hook_event_name": "ToolError", - "session_id": session_id, - "call_id": call_id, - "cwd": cwd, - "tool_name": tool_name, - }) - .to_string() - } - - fn drive_opencode(repo: &ProvenanceE2eRepo, payload: &str) -> Result { - opencode_mutation_scope::run_opencode_mutation_scope_from_payload_at_state_root( - &repo.state_root, - payload, - None, - ) - } - - #[test] - fn opencode_bash_mutation_persists_model_and_session_in_agent_trace() { - let repo = ProvenanceE2eRepo::new("opencode-bash"); - let session_id = "ses_opencode_bash"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_shell_env(&cwd, session_id, "call_bash", Some("opencode/big-pickle")), - ) - .expect("OpenCode shell.env should establish the bash scope"); - - repo.write_change("one\nopencode bash mutation\n"); - - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, "call_bash", "bash"), - ) - .expect("OpenCode ToolExecuteAfter should close the bash scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance(&trace, "opencode/big-pickle", "oc_ses_opencode_bash"); - assert_eq!(row_count(&repo.db(), "diff_traces"), 0); - assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); - assert_eq!(row_count(&repo.db(), "agent_traces"), 1); - } - - #[test] - fn opencode_apply_patch_mutation_with_missing_model_persists_no_model_in_agent_trace() { - let repo = ProvenanceE2eRepo::new("opencode-apply-patch"); - let session_id = "ses_opencode_no_model"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before(&cwd, session_id, "call_patch", "apply_patch", None), - ) - .expect("OpenCode ToolExecuteBefore should establish the apply_patch scope"); - - repo.write_change("one\npatched without model evidence\n"); - - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, "call_patch", "apply_patch"), - ) - .expect("OpenCode ToolExecuteAfter should close the apply_patch scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_eq!(trace["files"][0]["path"], json!("file.txt")); - let contributor = &trace["files"][0]["conversations"][0]["contributor"]; - assert_eq!(contributor["type"], json!("ai")); - assert!( - contributor.get("model_id").is_none(), - "absent model evidence must never be guessed or fabricated" - ); - assert_eq!( - trace["files"][0]["conversations"][0]["related"], - json!([{ - "type": "session", - "url": "https://sce.crocoder.dev/sessions/oc_ses_opencode_no_model", - }]) - ); - } - - #[test] - fn opencode_write_mutation_persists_model_while_task_delegation_stays_zero_footprint() { - let repo = ProvenanceE2eRepo::new("opencode-write-task"); - let session_id = "ses_opencode_write"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before(&cwd, session_id, "call_task", "task", None), - ) - .expect("task delegation ToolExecuteBefore is neutral"); - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, "call_task", "task"), - ) - .expect("task delegation ToolExecuteAfter is neutral"); - - assert_eq!( - row_count(&repo.db(), "mutation_trace_scopes"), - 0, - "a delegation event must create no mutation scope" - ); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - session_id, - "call_write", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("OpenCode write ToolExecuteBefore should establish the scope"); - repo.write_change("one\nwrite mutation\n"); - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, "call_write", "write"), - ) - .expect("OpenCode write ToolExecuteAfter should close the scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance( - &trace, - "opencode/big-pickle", - "oc_ses_opencode_write", - ); - assert_eq!( - row_count(&repo.db(), "mutation_trace_scopes"), - 1, - "only the tracked write call created a scope" - ); - } - - #[test] - fn opencode_unknown_tool_events_create_no_scope_or_mutation_state() { - let repo = ProvenanceE2eRepo::new("opencode-untracked"); - let session_id = "ses_opencode_untracked"; - let cwd = repo.cwd(); - - for tool_name in ["read", "custom_mcp_tool", "totally_unknown_future_tool"] { - let call_id = format!("call_{tool_name}"); - drive_opencode( - &repo, - &opencode_before(&cwd, session_id, &call_id, tool_name, None), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolExecuteBefore should be neutral")); - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, &call_id, tool_name), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolExecuteAfter should be neutral")); - drive_opencode( - &repo, - &opencode_tool_error(&cwd, session_id, &call_id, tool_name), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolError should be neutral")); - } - - assert_eq!(row_count(&repo.db(), "mutation_trace_scopes"), 0); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 0); - } - - #[test] - fn opencode_child_task_session_gets_its_own_independent_scope_and_provenance() { - let repo = ProvenanceE2eRepo::new("opencode-child-session"); - let parent_session = "ses_opencode_parent"; - let child_session = "ses_opencode_child"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before(&cwd, parent_session, "call_task", "task", None), - ) - .expect("the parent's task delegation is neutral"); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - child_session, - "call_child_write", - "write", - Some("opencode/child-model"), - ), - ) - .expect("the child session's write ToolExecuteBefore should establish its own scope"); - repo.write_change("one\nchild session mutation\n"); - drive_opencode( - &repo, - &opencode_after(&cwd, child_session, "call_child_write", "write"), - ) - .expect("the child session's write ToolExecuteAfter should close its own scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance( - &trace, - "opencode/child-model", - "oc_ses_opencode_child", - ); - assert_eq!( - row_count(&repo.db(), "mutation_trace_scopes"), - 1, - "the parent's task delegation created no scope; only the child session's write did" - ); - } - - #[test] - fn opencode_concurrent_reject_and_confirm_keeps_only_the_confirmed_mutation_ai() { - let repo = ProvenanceE2eRepo::new("opencode-concurrent-reject"); - let session_id = "ses_opencode_concurrent"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - session_id, - "call_a_edit", - "edit", - Some("opencode/big-pickle"), - ), - ) - .expect("A's edit ToolExecuteBefore should establish a scope"); - drive_opencode( - &repo, - &opencode_before( - &cwd, - session_id, - "call_b_write", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("B's write ToolExecuteBefore should establish a distinct concurrent scope"); - - fs::write(repo.root.join("rejected.txt"), "rejected mutation\n") - .expect("A's mutation should write"); - fs::write( - repo.root.join("ambiguous.txt"), - "B's mutation before recovery\n", - ) - .expect("B's pre-recovery mutation should write"); - - drive_opencode( - &repo, - &opencode_tool_error(&cwd, session_id, "call_a_edit", "edit"), - ) - .expect( - "A's ToolError should abandon A's scope and consume the shared ambiguous interval", - ); - - fs::write( - repo.root.join("confirmed.txt"), - "B's mutation after recovery\n", - ) - .expect("B's post-recovery mutation should write"); - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, "call_b_write", "write"), - ) - .expect("B's ToolExecuteAfter should confirm exactly B's own surviving scope"); - - git(&repo.root, &["add", "-A"]); - git(&repo.root, &["commit", "-qm", "concurrent mutation"]); - - let db = repo.db(); - let post_commit_data = capture_post_commit_patch_from_git(&repo.root) - .expect("capturing the post-commit patch should succeed"); - let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( - &repo.root, - &db, - &ParsedPatch { files: Vec::new() }, - &post_commit_data.parsed_patch, - ); - - let ai_paths: Vec<&str> = mutation_ai_patch - .files - .iter() - .map(|file| file.new_path.as_str()) - .collect(); - assert!( - !ai_paths.contains(&"rejected.txt"), - "the abandoned scope's own mutation must never enter mutation_ai_patch" - ); - assert!( - !ai_paths.contains(&"ambiguous.txt"), - "B's mutation made before the ambiguity-consuming flush is genuinely \ - indistinguishable from A's and must stay non-AI, not merely non-A" - ); - assert!( - ai_paths.contains(&"confirmed.txt"), - "B's own later mutation, made after A's interval was consumed and confirmed \ - by B's own Close, must be attributed AI" - ); - } - - #[test] - fn opencode_and_codex_unconfirmed_overlap_stays_ineligible_until_codex_confirms() { - let repo = ProvenanceE2eRepo::new("opencode-codex-overlap"); - let oc_session = "ses_opencode_overlap"; - let codex_session = "codex-overlap-session"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - oc_session, - "call_oc", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("OpenCode write should establish a scope"); - - let codex_pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": codex_session, - "turn_id": "codex-overlap-turn", - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "codex-overlap-bash", - "model": "gpt-5.6-sol", - "tool_input": {"command": "true"}, - }); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &codex_pre.to_string(), - None, - ) - .expect("Codex Bash PreToolUse should establish a concurrent scope"); - - repo.write_change("one\nopencode overlap mutation\n"); - - drive_opencode(&repo, &opencode_after(&cwd, oc_session, "call_oc", "write")) - .expect("OpenCode ToolExecuteAfter should close its own scope"); - - let attribution_after_first_close = repo.mutation_events(); - assert_eq!( - attribution_after_first_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ineligible_unscoped"), - "an unconfirmed live Codex scope must suppress OpenCode's own confirming close" - ); - - let codex_post = json!({ - "hook_event_name": "PostToolUse", - "session_id": codex_session, - "turn_id": "codex-overlap-turn", - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "codex-overlap-bash", - }); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &codex_post.to_string(), - None, - ) - .expect("Codex PostToolUse should close its own scope"); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - oc_session, - "call_oc_2", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("a fresh OpenCode write should establish a new scope"); - repo.write_change("one\nopencode overlap mutation\nsecond change\n"); - drive_opencode( - &repo, - &opencode_after(&cwd, oc_session, "call_oc_2", "write"), - ) - .expect("the fresh OpenCode scope should close cleanly once Codex is confirmed"); - - let attribution_after_second_close = repo.mutation_events(); - assert_eq!( - attribution_after_second_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ai_exclusive"), - "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" - ); - } - - #[test] - fn opencode_and_claude_overlap_produces_ai_contended() { - let repo = ProvenanceE2eRepo::new("opencode-claude-overlap"); - let oc_session = "ses_opencode_contended"; - let claude_session = "claude-overlap-session"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - oc_session, - "call_oc_contended", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("OpenCode write should establish a scope"); - - let claude_pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": claude_session, - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "claude-overlap-bash", - "tool_input": {"command": "printf mutation"}, - }); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &claude_pre.to_string(), - None, - ) - .expect( - "Claude Bash PreToolUse should establish a concurrent, non-confirmation-required scope", - ); - - repo.write_change("one\ncontended mutation\n"); - - drive_opencode( - &repo, - &opencode_after(&cwd, oc_session, "call_oc_contended", "write"), - ) - .expect("OpenCode ToolExecuteAfter should confirm its own scope"); - - let attribution = repo.mutation_events(); - assert_eq!( - attribution.last().map(|(kind, _)| kind.as_str()), - Some("ai_contended"), - "a confirmed OpenCode close alongside a live non-confirmation-required Claude scope is contended, not suppressed" - ); - - let claude_post = json!({ - "hook_event_name": "PostToolUse", - "session_id": claude_session, - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "claude-overlap-bash", - }); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &claude_post.to_string(), - None, - ) - .expect("Claude PostToolUse should close its own scope"); - } - - #[test] - fn claude_bash_mutation_persists_model_and_session_in_agent_trace() { - let repo = ProvenanceE2eRepo::new("claude"); - let session_id = "claude-session-e2e"; - let db = repo.db(); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: format!("cc_{session_id}"), - agent_id: String::new(), - model_id: String::from("claude/opus-4-1"), - observation_kind: ObservationKind::SessionStart, - source: String::from("test"), - observed_at_ms: 1, - }) - .expect("Claude model state should be persisted"); - - let cwd = repo.cwd(); - let pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": session_id, - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "claude-bash-e2e", - "tool_input": {"command": "printf mutation"}, - }); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &pre.to_string(), - None, - ) - .expect("Claude Bash PreToolUse should establish a scope"); - - let post = json!({ - "hook_event_name": "PostToolUse", - "session_id": session_id, - "cwd": repo.cwd(), - "tool_name": "Bash", - "tool_use_id": "claude-bash-e2e", - }); - repo.write_change("one\nclaude mutation\n"); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &post.to_string(), - None, - ) - .expect("Claude Bash PostToolUse should close the scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance(&trace, "claude/opus-4-1", "cc_claude-session-e2e"); - assert_eq!(row_count(&repo.db(), "diff_traces"), 0); - assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); - assert_eq!(row_count(&repo.db(), "agent_traces"), 1); - } - - #[test] - fn codex_bash_mutation_persists_model_and_session_in_agent_trace() { - let repo = ProvenanceE2eRepo::new("codex"); - let session_id = "codex-session-e2e"; - let cwd = repo.cwd(); - let pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": session_id, - "turn_id": "codex-turn-e2e", - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "codex-bash-e2e", - "model": "gpt-5.6-sol", - "tool_input": {"command": "true"}, - }); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &pre.to_string(), - None, - ) - .expect("Codex Bash PreToolUse should establish a scope"); - - let post = json!({ - "hook_event_name": "PostToolUse", - "session_id": session_id, - "turn_id": "codex-turn-e2e", - "cwd": repo.cwd(), - "tool_name": "Bash", - "tool_use_id": "codex-bash-e2e", - }); - repo.write_change("one\ncodex mutation\n"); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &post.to_string(), - None, - ) - .expect("Codex Bash PostToolUse should close the scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance(&trace, "gpt-5.6-sol", "cx_codex-session-e2e"); - assert_eq!(row_count(&repo.db(), "diff_traces"), 0); - assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); - assert_eq!(row_count(&repo.db(), "agent_traces"), 1); - } - - fn pi_tool_call( - cwd: &str, - session_id: &str, - tool_call_id: &str, - tool_name: &str, - model: Option<&str>, - ) -> String { - let mut payload = json!({ - "hook_event_name": "ToolCall", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": cwd, - "tool_name": tool_name, - }); - if let Some(model) = model { - payload["model"] = json!(model); - } - payload.to_string() - } - - fn pi_tool_result( - cwd: &str, - session_id: &str, - tool_call_id: &str, - tool_name: &str, - ) -> String { - json!({ - "hook_event_name": "ToolResult", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": cwd, - "tool_name": tool_name, - }) - .to_string() - } - - fn pi_tool_execution_end( - cwd: &str, - session_id: &str, - tool_call_id: &str, - tool_name: &str, - ) -> String { - json!({ - "hook_event_name": "ToolExecutionEnd", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": cwd, - "tool_name": tool_name, - }) - .to_string() - } - - fn drive_pi(repo: &ProvenanceE2eRepo, payload: &str) -> Result { - pi_mutation_scope::run_pi_mutation_scope_from_payload_at_state_root( - &repo.state_root, - payload, - None, - ) - } - - fn pi_confirmed_tool_case(tool_name: &str, label: &str) { - let repo = ProvenanceE2eRepo::new(label); - let session_id = format!("ses-{label}"); - let call_id = format!("call-{label}"); - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - &session_id, - &call_id, - tool_name, - Some("anthropic/opus-5"), - ), - ) - .expect("Pi ToolCall should establish the tracked scope before execution"); - - repo.write_change(&format!("one\npi {tool_name} mutation\n")); - - drive_pi( - &repo, - &pi_tool_result(&cwd, &session_id, &call_id, tool_name), - ) - .expect("Pi ToolResult should mark the attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, &session_id, &call_id, tool_name), - ) - .expect("Pi ToolExecutionEnd paired with an observed ToolResult should Close"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance( - &trace, - "anthropic/opus-5", - &format!("pi_{session_id}"), - ); - assert_eq!(row_count(&repo.db(), "diff_traces"), 0); - assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); - assert_eq!(row_count(&repo.db(), "agent_traces"), 1); - } - - #[test] - fn pi_bash_mutation_persists_model_and_session_in_agent_trace() { - pi_confirmed_tool_case("bash", "pi-bash"); - } - - #[test] - fn pi_write_mutation_persists_model_and_session_in_agent_trace() { - pi_confirmed_tool_case("write", "pi-write"); - } - - #[test] - fn pi_edit_mutation_persists_model_and_session_in_agent_trace() { - pi_confirmed_tool_case("edit", "pi-edit"); - } - - #[test] - fn pi_missing_model_preserves_session_with_null_model_in_agent_trace() { - let repo = ProvenanceE2eRepo::new("pi-no-model"); - let session_id = "ses-pi-no-model"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call(&cwd, session_id, "call-1", "bash", None), - ) - .expect("Pi ToolCall should establish the scope without model evidence"); - - repo.write_change("one\npi mutation without model\n"); - - drive_pi(&repo, &pi_tool_result(&cwd, session_id, "call-1", "bash")) - .expect("Pi ToolResult should mark the attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), - ) - .expect("Pi ToolExecutionEnd should close the scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_eq!(trace["files"][0]["path"], json!("file.txt")); - let contributor = &trace["files"][0]["conversations"][0]["contributor"]; - assert_eq!(contributor["type"], json!("ai")); - assert!( - contributor.get("model_id").is_none(), - "absent model evidence must never be guessed or fabricated" - ); - assert_eq!( - trace["files"][0]["conversations"][0]["related"], - json!([{ - "type": "session", - "url": "https://sce.crocoder.dev/sessions/pi_ses-pi-no-model", - }]) - ); - } - - #[test] - fn pi_read_only_and_unknown_tools_create_no_scope_or_mutation_state() { - let repo = ProvenanceE2eRepo::new("pi-untracked"); - let session_id = "ses-pi-untracked"; - let cwd = repo.cwd(); - - for tool_name in [ - "read", - "grep", - "find", - "ls", - "custom_mcp_tool", - "totally_unknown_future_tool", - "user_bash", - ] { - let call_id = format!("call-{tool_name}"); - drive_pi( - &repo, - &pi_tool_call(&cwd, session_id, &call_id, tool_name, None), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolCall should be neutral")); - drive_pi( - &repo, - &pi_tool_result(&cwd, session_id, &call_id, tool_name), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolResult should be neutral")); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, &call_id, tool_name), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolExecutionEnd should be neutral")); - } - - assert_eq!(row_count(&repo.db(), "mutation_trace_scopes"), 0); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 0); - } - - #[test] - fn pi_later_extension_rejection_after_start_produces_no_mutation_ai_patch() { - let repo = ProvenanceE2eRepo::new("pi-later-rejection"); - let session_id = "ses-pi-rejected"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call(&cwd, session_id, "call-1", "bash", Some("anthropic/opus-5")), - ) - .expect( - "Pi ToolCall should establish the scope before a later extension can reject it", - ); - - fs::write( - repo.root.join("rejected.txt"), - "should never be attributed AI\n", - ) - .expect("the blocked attempt's incidental write should still land on disk"); - - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), - ) - .expect("ToolExecutionEnd with no preceding ToolResult must abandon, not error"); - - let scope_status = repo - .db() - .query_map("SELECT status FROM mutation_trace_scopes", (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("scope-status query should succeed"); - assert_eq!( - scope_status, - vec!["abandoned".to_string()], - "the D7 abandon path must leave the scope durably abandoned, never closed or active" - ); - - git(&repo.root, &["add", "-A"]); - git(&repo.root, &["commit", "-qm", "rejected mutation"]); - - let db = repo.db(); - let post_commit_data = capture_post_commit_patch_from_git(&repo.root) - .expect("capturing the post-commit patch should succeed"); - let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( - &repo.root, - &db, - &ParsedPatch { files: Vec::new() }, - &post_commit_data.parsed_patch, - ); - - assert!( - mutation_ai_patch.files.is_empty(), - "a Start that never reached a confirmed Close must never produce mutation_ai_patch entries" - ); - } - - #[test] - fn pi_mutate_then_error_still_persists_confirmed_mutation_through_close() { - let repo = ProvenanceE2eRepo::new("pi-error-executed"); - let session_id = "ses-pi-error"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call(&cwd, session_id, "call-1", "bash", Some("anthropic/opus-5")), - ) - .expect("Pi ToolCall should establish the scope"); - - repo.write_change("one\npartial mutation before failure\n"); - - let mut result_payload: Value = - serde_json::from_str(&pi_tool_result(&cwd, session_id, "call-1", "bash")) - .expect("tool_result payload should parse as JSON"); - result_payload["isError"] = json!(true); - drive_pi(&repo, &result_payload.to_string()) - .expect("a failed-but-executed ToolResult is still positive execution evidence"); - - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), - ) - .expect("ToolExecutionEnd paired with an observed ToolResult must Close, not abandon"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance(&trace, "anthropic/opus-5", "pi_ses-pi-error"); - } - - #[test] - fn pi_concurrent_reject_and_confirm_keeps_only_the_confirmed_mutation_ai() { - let repo = ProvenanceE2eRepo::new("pi-concurrent-reject"); - let session_id = "ses-pi-concurrent"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - session_id, - "call-a-edit", - "edit", - Some("anthropic/opus-5"), - ), - ) - .expect("A's edit ToolCall should establish a scope"); - drive_pi( - &repo, - &pi_tool_call( - &cwd, - session_id, - "call-b-write", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("B's write ToolCall should establish a distinct concurrent scope"); - - fs::write(repo.root.join("rejected.txt"), "rejected mutation\n") - .expect("A's mutation should write"); - fs::write( - repo.root.join("ambiguous.txt"), - "B's mutation before recovery\n", - ) - .expect("B's pre-recovery mutation should write"); - - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, "call-a-edit", "edit"), - ) - .expect( - "A's ToolExecutionEnd with no ToolResult should abandon A's scope and consume \ - the shared ambiguous interval", - ); - - fs::write( - repo.root.join("confirmed.txt"), - "B's mutation after recovery\n", - ) - .expect("B's post-recovery mutation should write"); - - drive_pi( - &repo, - &pi_tool_result(&cwd, session_id, "call-b-write", "write"), - ) - .expect("B's ToolResult should mark it executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, "call-b-write", "write"), - ) - .expect("B's ToolExecutionEnd should confirm exactly B's own surviving scope"); - - git(&repo.root, &["add", "-A"]); - git(&repo.root, &["commit", "-qm", "concurrent mutation"]); - - let db = repo.db(); - let post_commit_data = capture_post_commit_patch_from_git(&repo.root) - .expect("capturing the post-commit patch should succeed"); - let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( - &repo.root, - &db, - &ParsedPatch { files: Vec::new() }, - &post_commit_data.parsed_patch, - ); - - let ai_paths: Vec<&str> = mutation_ai_patch - .files - .iter() - .map(|file| file.new_path.as_str()) - .collect(); - assert!( - !ai_paths.contains(&"rejected.txt"), - "the abandoned scope's own mutation must never enter mutation_ai_patch" - ); - assert!( - !ai_paths.contains(&"ambiguous.txt"), - "B's mutation made before the ambiguity-consuming flush is genuinely \ - indistinguishable from A's and must stay non-AI, not merely non-A" - ); - assert!( - ai_paths.contains(&"confirmed.txt"), - "B's own later mutation, made after A's interval was consumed and confirmed \ - by B's own Close, must be attributed AI" - ); - } - - #[test] - fn pi_and_claude_overlap_produces_ai_contended() { - let repo = ProvenanceE2eRepo::new("pi-claude-overlap"); - let pi_session = "ses-pi-contended"; - let claude_session = "claude-pi-overlap-session"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - pi_session, - "call-pi-contended", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("Pi write should establish a scope"); - - let claude_pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": claude_session, - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "claude-pi-overlap-bash", - "tool_input": {"command": "printf mutation"}, - }); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &claude_pre.to_string(), - None, - ) - .expect( - "Claude Bash PreToolUse should establish a concurrent, non-confirmation-required scope", - ); - - repo.write_change("one\npi+claude contended mutation\n"); - - drive_pi( - &repo, - &pi_tool_result(&cwd, pi_session, "call-pi-contended", "write"), - ) - .expect("Pi ToolResult should mark the attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, pi_session, "call-pi-contended", "write"), - ) - .expect("Pi ToolExecutionEnd should confirm its own scope"); - - let attribution = repo.mutation_events(); - assert_eq!( - attribution.last().map(|(kind, _)| kind.as_str()), - Some("ai_contended"), - "a confirmed Pi close alongside a live non-confirmation-required Claude scope is contended, not suppressed" - ); - - let claude_post = json!({ - "hook_event_name": "PostToolUse", - "session_id": claude_session, - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "claude-pi-overlap-bash", - }); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &claude_post.to_string(), - None, - ) - .expect("Claude PostToolUse should close its own scope"); - } - - #[test] - fn pi_and_codex_overlap_stays_ineligible_until_codex_confirms() { - let repo = ProvenanceE2eRepo::new("pi-codex-overlap"); - let pi_session = "ses-pi-overlap"; - let codex_session = "codex-pi-overlap-session"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - pi_session, - "call-pi", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("Pi write should establish a scope"); - - let codex_pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": codex_session, - "turn_id": "codex-pi-overlap-turn", - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "codex-pi-overlap-bash", - "model": "gpt-5.6-sol", - "tool_input": {"command": "true"}, - }); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &codex_pre.to_string(), - None, - ) - .expect("Codex Bash PreToolUse should establish a concurrent scope"); - - repo.write_change("one\npi codex overlap mutation\n"); - - drive_pi(&repo, &pi_tool_result(&cwd, pi_session, "call-pi", "write")) - .expect("Pi ToolResult should mark the attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, pi_session, "call-pi", "write"), - ) - .expect("Pi ToolExecutionEnd should attempt to confirm its own scope"); - - let attribution_after_first_close = repo.mutation_events(); - assert_eq!( - attribution_after_first_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ineligible_unscoped"), - "an unconfirmed live Codex scope must suppress Pi's own confirming close" - ); - - let codex_post = json!({ - "hook_event_name": "PostToolUse", - "session_id": codex_session, - "turn_id": "codex-pi-overlap-turn", - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "codex-pi-overlap-bash", - }); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &codex_post.to_string(), - None, - ) - .expect("Codex PostToolUse should close its own scope"); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - pi_session, - "call-pi-2", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("a fresh Pi write should establish a new scope"); - repo.write_change("one\npi codex overlap mutation\nsecond change\n"); - drive_pi( - &repo, - &pi_tool_result(&cwd, pi_session, "call-pi-2", "write"), - ) - .expect("Pi ToolResult should mark the fresh attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, pi_session, "call-pi-2", "write"), - ) - .expect("the fresh Pi scope should close cleanly once Codex is confirmed"); - - let attribution_after_second_close = repo.mutation_events(); - assert_eq!( - attribution_after_second_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ai_exclusive"), - "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" - ); - } - - #[test] - fn pi_and_opencode_overlap_stays_ineligible_until_opencode_confirms() { - let repo = ProvenanceE2eRepo::new("pi-opencode-overlap"); - let pi_session = "ses-pi-oc-overlap"; - let oc_session = "ses_opencode_pi_overlap"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - pi_session, - "call-pi", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("Pi write should establish a scope"); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - oc_session, - "call_oc", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("OpenCode write ToolExecuteBefore should establish a concurrent scope"); - - repo.write_change("one\npi opencode overlap mutation\n"); - - drive_pi(&repo, &pi_tool_result(&cwd, pi_session, "call-pi", "write")) - .expect("Pi ToolResult should mark the attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, pi_session, "call-pi", "write"), - ) - .expect("Pi ToolExecutionEnd should attempt to confirm its own scope"); - - let attribution_after_first_close = repo.mutation_events(); - assert_eq!( - attribution_after_first_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ineligible_unscoped"), - "an unconfirmed live OpenCode scope must suppress Pi's own confirming close" - ); - - drive_opencode(&repo, &opencode_after(&cwd, oc_session, "call_oc", "write")) - .expect("OpenCode ToolExecuteAfter should close its own scope"); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - pi_session, - "call-pi-2", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("a fresh Pi write should establish a new scope"); - repo.write_change("one\npi opencode overlap mutation\nsecond change\n"); - drive_pi( - &repo, - &pi_tool_result(&cwd, pi_session, "call-pi-2", "write"), - ) - .expect("Pi ToolResult should mark the fresh attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, pi_session, "call-pi-2", "write"), - ) - .expect("the fresh Pi scope should close cleanly once OpenCode is confirmed"); - - let attribution_after_second_close = repo.mutation_events(); - assert_eq!( - attribution_after_second_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ai_exclusive"), - "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" - ); - } - - #[test] - fn pi_stale_process_recovery_discards_ambiguous_interval_while_fresh_pi_work_remains_usable( - ) { - let repo = ProvenanceE2eRepo::new("pi-stale-recovery"); - let stale_session = "ses-pi-stale"; - let fresh_session = "ses-pi-fresh"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - stale_session, - "call-stale", - "bash", - Some("anthropic/opus-5"), - ), - ) - .expect("the stale attempt's Pi ToolCall should establish a scope"); - - let git_dir = resolve_git_dir(&repo.root).expect("git dir should resolve"); - let scope_id = pi_mutation_scope::state::read_state(&git_dir) - .expect("state should be readable") - .attempts - .iter() - .find(|attempt| attempt.session_id == stale_session) - .expect("the stale attempt should exist") - .scope_id - .clone(); - pi_mutation_scope::force_attempt_owner_dead_for_tests(&git_dir, &scope_id); - - fs::write( - repo.root.join("ambiguous.txt"), - "left behind by the dead Pi process\n", - ) - .expect("the stale attempt's own mutation should still land on disk"); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - fresh_session, - "call-fresh", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("a fresh Pi ToolCall should trigger dead-owner recovery and then establish its own scope"); - - fs::write(repo.root.join("confirmed.txt"), "the fresh Pi work\n") - .expect("the fresh attempt's mutation should write"); - - drive_pi( - &repo, - &pi_tool_result(&cwd, fresh_session, "call-fresh", "write"), - ) - .expect("the fresh attempt's ToolResult should mark it executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, fresh_session, "call-fresh", "write"), - ) - .expect("the fresh attempt should close and reach AiExclusive"); - - git(&repo.root, &["add", "-A"]); - git(&repo.root, &["commit", "-qm", "stale recovery"]); - - let db = repo.db(); - let post_commit_data = capture_post_commit_patch_from_git(&repo.root) - .expect("capturing the post-commit patch should succeed"); - let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( - &repo.root, - &db, - &ParsedPatch { files: Vec::new() }, - &post_commit_data.parsed_patch, - ); - - let ai_paths: Vec<&str> = mutation_ai_patch - .files - .iter() - .map(|file| file.new_path.as_str()) - .collect(); - assert!( - !ai_paths.contains(&"ambiguous.txt"), - "the dead process's ambiguous interval must never be attributed AI" - ); - assert!( - ai_paths.contains(&"confirmed.txt"), - "later fresh Pi work must remain usable and reach AiExclusive" - ); - - let attribution = repo.mutation_events(); - assert_eq!( - attribution.last().map(|(kind, _)| kind.as_str()), - Some("ai_exclusive"), - "the fresh attempt, unencumbered by the recovered stale scope, should reach AiExclusive" - ); - } - } - - #[test] - fn post_commit_auto_sync_does_not_launch_when_disabled() { - let launch_called = RefCell::new(false); - - run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, _, _, _| Ok(minimal_agent_trace()), - |_| Ok(false), - |_| { - *launch_called.borrow_mut() = true; - Ok(()) - }, - |_| Ok(()), - None, - ) - .expect("disabled auto-sync should not affect post-commit success"); - - assert!(!*launch_called.borrow()); - } - - #[test] - fn post_commit_persistence_failure_does_not_launch_auto_sync() { - let launch_called = RefCell::new(false); - - let error = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, _, _, _| Err(anyhow!("Agent Trace persistence failed")), - |_| panic!("auto-sync config must not be resolved after persistence failure"), - |_| { - *launch_called.borrow_mut() = true; - Ok(()) - }, - |_| panic!("checkpoint must not run after persistence failure"), - None, - ) - .expect_err("persistence failure should be returned"); - - assert!(error.to_string().contains("persistence failed")); - assert!(!*launch_called.borrow()); - } - - #[test] - fn post_commit_auto_sync_launcher_failure_is_fail_open() { - let output = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, _, _, _| Ok(minimal_agent_trace()), - |_| Ok(true), - |_| Err(anyhow!("spawn unavailable")), - |_| Ok(()), - None, - ) - .expect("launcher failure must not affect post-commit success"); - - assert!(output.contains("post-commit hook processed intersection")); - } - - #[derive(Default)] - struct RecordingLogger { - warnings: std::sync::Mutex>, - } - - impl Logger for RecordingLogger { - fn info( - &self, - _event_id: &str, - _message: &str, - _fields: &[(&str, &str)], - _session_id: Option<&str>, - ) { - } - - fn debug( - &self, - _event_id: &str, - _message: &str, - _fields: &[(&str, &str)], - _session_id: Option<&str>, - ) { - } - - fn warn( - &self, - event_id: &str, - message: &str, - _fields: &[(&str, &str)], - _session_id: Option<&str>, - ) { - self.warnings - .lock() - .expect("warnings mutex should not be poisoned") - .push((event_id.to_string(), message.to_string())); - } - - fn error( - &self, - _event_id: &str, - _message: &str, - _fields: &[(&str, &str)], - _session_id: Option<&str>, - ) { - } - - fn log_cli_error( - &self, - _error: &crate::services::error::CliError, - _session_id: Option<&str>, - ) { - } - } - - #[test] - fn post_commit_checkpoint_runs_once_after_successful_persistence() { - let events = RefCell::new(Vec::new()); - - let output = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, _, _, _| { - events.borrow_mut().push("persistence"); - Ok(minimal_agent_trace()) - }, - |_| Ok(false), - |_| Ok(()), - |_| { - events.borrow_mut().push("checkpoint"); - Ok(()) - }, - None, - ) - .expect("successful checkpoint should not affect post-commit success"); - - assert!(output.contains("post-commit hook processed intersection")); - assert_eq!(events.into_inner(), vec!["persistence", "checkpoint"]); - } - - #[test] - fn post_commit_checkpoint_failure_is_fail_open_and_logs_warning() { - let logger = RecordingLogger::default(); - let persisted = RefCell::new(false); - - let output = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, _, _, _| { - *persisted.borrow_mut() = true; - Ok(minimal_agent_trace()) - }, - |_| Ok(false), - |_| Ok(()), - |_| Err(anyhow!("checkpoint failed")), - Some(&logger), - ) - .expect("checkpoint failure must not affect post-commit success"); - - assert!(output.contains("post-commit hook processed intersection")); - assert!(*persisted.borrow()); - assert_eq!( - logger - .warnings - .into_inner() - .expect("warnings mutex should not be poisoned"), - vec![( - String::from("sce.agent_trace_db.passive_checkpoint_failed"), - String::from("checkpoint failed") - )] - ); - } -} +mod tests; diff --git a/cli/src/services/hooks/pi_mutation_scope/guard_reconciliation_tests.rs b/cli/src/services/hooks/pi_mutation_scope/guard_reconciliation_tests.rs new file mode 100644 index 00000000..8611a81d --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/guard_reconciliation_tests.rs @@ -0,0 +1,606 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::sync::mpsc; +use std::time::Duration; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, +}; +use crate::services::mutation_trace::runtime::{ + coordinate, run_external_mutation_guard, GuardRequest, RuntimeBoundary, +}; +use crate::services::mutation_trace::types::{ActorKind, EventId, ScopeId}; + +use super::*; + +fn git(dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +struct GuardRepo { + _temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, +} + +impl GuardRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-pi-guard-reconciliation-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + _temp: temp, + root, + state_root, + } + } + + fn drive(&self, payload: &str) -> Result { + run_pi_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn open_db(&self) -> anyhow::Result { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "Pi guard-reconciliation test assertions", + ) + } + + fn db(&self) -> RepositoryAgentTraceDb { + self.open_db().expect("assertion DB should open") + } + + fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { + self.db() + .query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn write(&self, name: &str, contents: &str) { + fs::write(self.root.join(name), contents).expect("write should succeed"); + } + + fn mutation_events(&self) -> Vec<(String, Option)> { + self.db() + .query_map( + "SELECT attribution_kind, attribution_scope_id \ + FROM mutation_trace_events ORDER BY revision", + (), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id)) + }, + ) + .expect("mutation-events query should succeed") + } +} + +fn tool_call(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { + json!({ + "hook_event_name": "ToolCall", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": repo.root.to_string_lossy(), + "tool_name": "bash", + "model": "openai-codex/gpt-5.5", + }) + .to_string() +} + +fn tool_execution_end(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecutionEnd", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": repo.root.to_string_lossy(), + "tool_name": "bash", + }) + .to_string() +} + +fn tool_result(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { + json!({ + "hook_event_name": "ToolResult", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": repo.root.to_string_lossy(), + "tool_name": "bash", + }) + .to_string() +} + +#[test] +fn a_guard_triggered_worktree_abandonment_reconciles_with_the_pi_adapters_own_state() { + let repo = GuardRepo::new("reconcile"); + let session = "01a091f4-guard-session"; + let key_a = AttemptKey { + session_id: session.to_string(), + tool_call_id: "call_a".to_string(), + }; + let key_b = AttemptKey { + session_id: session.to_string(), + tool_call_id: "call_b".to_string(), + }; + let scope_a = format_pi_scope_id(&key_a, 1); + let scope_b = format_pi_scope_id(&key_b, 2); + + repo.drive(&tool_call(&repo, "call_a", session)) + .expect("A's Start should reach the real runtime"); + repo.drive(&tool_call(&repo, "call_b", session)) + .expect("B's Start should reach the real runtime"); + assert_eq!( + repo.scope_status(&scope_a), + Some(("pi".to_string(), "active".to_string())) + ); + assert_eq!( + repo.scope_status(&scope_b), + Some(("pi".to_string(), "active".to_string())) + ); + + let (_cancel_tx, cancel_rx) = mpsc::channel(); + let root = repo.root.clone(); + let outcome = run_external_mutation_guard( + &root, + &GuardRequest { + command: "printf changed >> file.txt".to_string(), + cwd: None, + env: Vec::new(), + }, + || repo.open_db(), + |_event| {}, + cancel_rx, + ) + .expect("the guard should finish successfully"); + assert_eq!(outcome.exit_code, Some(0)); + assert!(!outcome.marker_clear_failed); + + assert_eq!( + repo.scope_status(&scope_a), + Some(("pi".to_string(), "abandoned".to_string())), + "the guard's finish-time forced recovery must abandon every scope live during the \ + guarded interval, regardless of which harness's boundary happened to observe \ + user_bash" + ); + assert_eq!( + repo.scope_status(&scope_b), + Some(("pi".to_string(), "abandoned".to_string())) + ); + + repo.drive(&tool_execution_end(&repo, "call_a", session)) + .expect( + "the adapter's next interaction for an already-abandoned scope must reconcile \ + safely (falling back through the existing Close-failure-to-abandon path) rather \ + than erroring or resurrecting the scope", + ); + repo.drive(&tool_execution_end(&repo, "call_b", session)) + .expect("the same reconciliation must hold for every sibling abandoned by the guard"); + + assert!( + state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) + .expect("state readable") + .attempts + .is_empty(), + "the Pi adapter's own durable local attempt state must converge to empty once it \ + observes the terminal event for a scope the generic runtime already abandoned out \ + from under it" + ); +} + +#[test] +fn a_guard_abandons_a_live_pi_scope_alongside_a_live_scope_from_another_harness() { + let repo = GuardRepo::new("cross-harness"); + let key = AttemptKey { + session_id: "01a091f4-guard-cross-session".to_string(), + tool_call_id: "call_pi".to_string(), + }; + let pi_scope_id = format_pi_scope_id(&key, 1); + let claude_scope = ScopeId("claude-scope-under-guard".to_string()); + + repo.drive(&tool_call(&repo, "call_pi", "01a091f4-guard-cross-session")) + .expect("Pi's Start should reach the real runtime"); + coordinate( + &repo.root, + &RuntimeBoundary::Start { + scope: claude_scope.clone(), + event: EventId("claude-evt-start".to_string()), + actor_kind: ActorKind::ClaudeCode, + provenance: None, + }, + || repo.open_db(), + ) + .expect("Claude's Start should reach the real runtime"); + + assert_eq!( + repo.scope_status(&pi_scope_id), + Some(("pi".to_string(), "active".to_string())) + ); + assert_eq!( + repo.scope_status(&claude_scope.0), + Some(("claude_code".to_string(), "active".to_string())) + ); + + let (_cancel_tx, cancel_rx) = mpsc::channel(); + let root = repo.root.clone(); + run_external_mutation_guard( + &root, + &GuardRequest { + command: "printf changed >> file.txt".to_string(), + cwd: None, + env: Vec::new(), + }, + || repo.open_db(), + |_event| {}, + cancel_rx, + ) + .expect("the guard should finish successfully"); + + assert_eq!( + repo.scope_status(&pi_scope_id), + Some(("pi".to_string(), "abandoned".to_string())), + "the guard's forced recovery must abandon the Pi scope even though a different \ + harness's boundary is the one that happened to observe user_bash" + ); + assert_eq!( + repo.scope_status(&claude_scope.0), + Some(("claude_code".to_string(), "abandoned".to_string())), + "the guard's forced recovery must abandon every live scope on the worktree \ + regardless of which harness owns it" + ); + + repo.drive(&tool_execution_end( + &repo, + "call_pi", + "01a091f4-guard-cross-session", + )) + .expect( + "the Pi adapter must still reconcile cleanly with its own scope even when a \ + sibling scope belonging to a different harness was abandoned by the same guard", + ); + assert!( + state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) + .expect("state readable") + .attempts + .is_empty() + ); +} + +#[test] +fn a_foreign_pi_start_racing_an_active_guard_fails_closed_touching_no_state_then_succeeds_on_retry() +{ + let repo = GuardRepo::new("race"); + let ready = repo.root.join("ready"); + let release = repo.root.join("release"); + let command = format!( + "touch '{}'; while [ ! -f '{}' ]; do sleep 0.02; done", + ready.display(), + release.display(), + ); + + let root = repo.root.clone(); + let state_root = repo.state_root.clone(); + let guard_thread = std::thread::spawn(move || { + let (_cancel_tx, cancel_rx) = mpsc::channel(); + run_external_mutation_guard( + &root, + &GuardRequest { + command, + cwd: None, + env: Vec::new(), + }, + || { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &root, + &state_root, + "guard race test", + ) + }, + |_event| {}, + cancel_rx, + ) + }); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while !ready.exists() { + assert!( + std::time::Instant::now() < deadline, + "the guarded shell never reported ready" + ); + std::thread::sleep(Duration::from_millis(20)); + } + + let key = AttemptKey { + session_id: "01a091f4-guard-race-session".to_string(), + tool_call_id: "call_race".to_string(), + }; + let scope_id = format_pi_scope_id(&key, 1); + + let error = repo + .drive(&tool_call( + &repo, + "call_race", + "01a091f4-guard-race-session", + )) + .expect_err( + "a Pi Start racing an active external-mutation guard must block then fail \ + closed with CoordinateError::LockAcquisition, never proceed", + ); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + assert!( + repo.scope_status(&scope_id).is_none(), + "a boundary that fails closed on lock acquisition must touch no protocol state" + ); + + fs::write(&release, "go").expect("release file should write"); + let outcome = guard_thread + .join() + .expect("guard thread should not panic") + .expect("the guard should finish successfully once released"); + assert_eq!(outcome.exit_code, Some(0)); + + repo.drive(&tool_call( + &repo, + "call_race", + "01a091f4-guard-race-session", + )) + .expect("retrying the same Start after the guard finishes must succeed normally"); + assert_eq!( + repo.scope_status(&scope_id), + Some(("pi".to_string(), "active".to_string())) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn a_foreign_harnesss_boundary_racing_the_guard_fails_closed_then_succeeds_normally_on_retry() { + let repo = GuardRepo::new("cross-harness-race"); + let claude_scope = ScopeId("claude-scope-racing-guard".to_string()); + let key = AttemptKey { + session_id: "01a091f4-guard-cross-race-session".to_string(), + tool_call_id: "call_pi_race".to_string(), + }; + let pi_scope_id = format_pi_scope_id(&key, 1); + + repo.drive(&tool_call( + &repo, + "call_pi_race", + "01a091f4-guard-cross-race-session", + )) + .expect("Pi's Start should reach the real runtime"); + coordinate( + &repo.root, + &RuntimeBoundary::Start { + scope: claude_scope.clone(), + event: EventId("claude-evt-race-start".to_string()), + actor_kind: ActorKind::ClaudeCode, + provenance: None, + }, + || repo.open_db(), + ) + .expect("Claude's Start should reach the real runtime"); + + let ready = repo.root.join("ready"); + let release = repo.root.join("release"); + let command = format!( + "touch '{}'; printf w1 >> file.txt; while [ ! -f '{}' ]; do sleep 0.02; done; printf w2 >> file.txt", + ready.display(), + release.display(), + ); + + let root = repo.root.clone(); + let state_root = repo.state_root.clone(); + let guard_thread = std::thread::spawn(move || { + let (_cancel_tx, cancel_rx) = mpsc::channel(); + run_external_mutation_guard( + &root, + &GuardRequest { + command, + cwd: None, + env: Vec::new(), + }, + || { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &root, + &state_root, + "guard cross-harness race test", + ) + }, + |_event| {}, + cancel_rx, + ) + }); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while !ready.exists() { + assert!( + std::time::Instant::now() < deadline, + "the guarded shell never reported ready" + ); + std::thread::sleep(Duration::from_millis(20)); + } + + let close_while_active = coordinate( + &repo.root, + &RuntimeBoundary::Close { + scope: claude_scope.clone(), + event: EventId("claude-evt-race-close".to_string()), + actor_kind: ActorKind::ClaudeCode, + }, + || repo.open_db(), + ); + assert!( + matches!( + close_while_active, + Err(crate::services::mutation_trace::runtime::CoordinateError::LockAcquisition(_)) + ), + "a foreign harness's boundary racing an active external-mutation guard must fail \ + closed with LockAcquisition, never proceed while the guard still holds the \ + worktree: {close_while_active:?}" + ); + assert!( + repo.mutation_events().is_empty(), + "a boundary that fails closed on lock acquisition must touch no protocol state" + ); + + fs::write(&release, "go").expect("release file should write"); + let outcome = guard_thread + .join() + .expect("guard thread should not panic") + .expect("the guard should finish successfully once released"); + assert_eq!(outcome.exit_code, Some(0)); + + assert_eq!( + repo.scope_status(&pi_scope_id), + Some(("pi".to_string(), "abandoned".to_string())) + ); + assert_eq!( + repo.scope_status(&claude_scope.0), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + + coordinate( + &repo.root, + &RuntimeBoundary::Close { + scope: claude_scope.clone(), + event: EventId("claude-evt-race-close-retry".to_string()), + actor_kind: ActorKind::ClaudeCode, + }, + || repo.open_db(), + ) + .expect( + "Claude's deferred boundary must succeed normally once retried against the \ + recovered worktree, rather than continuing to fail closed", + ); + repo.drive(&tool_execution_end( + &repo, + "call_pi_race", + "01a091f4-guard-cross-race-session", + )) + .expect("the Pi adapter must reconcile its own already-abandoned scope cleanly too"); + + assert!( + repo.mutation_events().is_empty(), + "both human writes made under the guard must remain excluded from positive AI \ + attribution for the Pi scope and for the racing foreign-harness scope alike" + ); +} + +#[test] +fn a_guard_triggered_abandonment_does_not_poison_the_checkout_for_a_fresh_pi_scope() { + let repo = GuardRepo::new("post-recovery-fresh-start"); + let session = "01a091f4-guard-fresh-session"; + let key_a = AttemptKey { + session_id: session.to_string(), + tool_call_id: "call_doomed".to_string(), + }; + let scope_a = format_pi_scope_id(&key_a, 1); + + repo.drive(&tool_call(&repo, "call_doomed", session)) + .expect("A's Start should reach the real runtime"); + assert_eq!( + repo.scope_status(&scope_a), + Some(("pi".to_string(), "active".to_string())) + ); + + let (_cancel_tx, cancel_rx) = mpsc::channel(); + let root = repo.root.clone(); + let outcome = run_external_mutation_guard( + &root, + &GuardRequest { + command: "printf changed >> file.txt".to_string(), + cwd: None, + env: Vec::new(), + }, + || repo.open_db(), + |_event| {}, + cancel_rx, + ) + .expect("the guard should finish successfully"); + assert_eq!(outcome.exit_code, Some(0)); + + assert_eq!( + repo.scope_status(&scope_a), + Some(("pi".to_string(), "abandoned".to_string())) + ); + repo.drive(&tool_execution_end(&repo, "call_doomed", session)) + .expect("the adapter must reconcile the guard-abandoned scope cleanly"); + + let key_c = AttemptKey { + session_id: session.to_string(), + tool_call_id: "call_clean".to_string(), + }; + let scope_c = format_pi_scope_id(&key_c, 2); + + repo.drive(&tool_call(&repo, "call_clean", session)) + .expect("a fresh Start on the same worktree after recovery must succeed normally"); + assert_eq!( + repo.scope_status(&scope_c), + Some(("pi".to_string(), "active".to_string())) + ); + + repo.write("file.txt", "one\nchanged\nclean\n"); + repo.drive(&tool_result(&repo, "call_clean", session)) + .expect("tool_result should mark Executed"); + repo.drive(&tool_execution_end(&repo, "call_clean", session)) + .expect("Close should reach the real runtime"); + + assert_eq!( + repo.scope_status(&scope_c), + Some(("pi".to_string(), "closed".to_string())) + ); + assert_eq!( + repo.mutation_events(), + vec![("ai_exclusive".to_string(), Some(scope_c))], + "a clean Pi mutation after guard-triggered recovery must still reach AiExclusive; \ + recovery must not permanently poison the checkout for later, uninterfered-with work" + ); +} diff --git a/cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs b/cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs new file mode 100644 index 00000000..5305582e --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs @@ -0,0 +1,1155 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use serde_json::Value; + +use super::state::{read_state, AdapterAttempt, AdapterState, AttemptPhase, RecoveryState}; +use super::*; + +static NEXT_ID: AtomicU64 = AtomicU64::new(0); + +fn temp_git_dir(label: &str) -> PathBuf { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-pi-mutation-scope-lifecycle-{label}-{}-{id}", + std::process::id() + )) +} + +const CWD: &str = "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/repo/pi-checkout"; + +struct RecordingSeam { + calls: Mutex>, + fail_operations: Vec, + fail_once_operations: Mutex>, +} + +impl RecordingSeam { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + fail_operations: Vec::new(), + fail_once_operations: Mutex::new(Vec::new()), + } + } + + fn failing_on(operations: &[&str]) -> Self { + Self { + fail_operations: operations.iter().map(|op| (*op).to_string()).collect(), + ..Self::new() + } + } + + fn failing_once_on(operations: &[&str]) -> Self { + Self { + fail_once_operations: Mutex::new( + operations.iter().map(|op| (*op).to_string()).collect(), + ), + ..Self::new() + } + } + + fn handle(&self, payload: &str) -> Result { + let operation = operation_of(payload); + { + let mut calls = self.calls.lock().expect("seam mutex"); + calls.push(operation.clone()); + } + if self.fail_operations.contains(&operation) { + bail!("seam failure injected by test for '{operation}'"); + } + { + let mut once = self.fail_once_operations.lock().expect("seam mutex"); + if let Some(position) = once.iter().position(|candidate| candidate == &operation) { + once.remove(position); + bail!("transient seam failure injected once by test for '{operation}'"); + } + } + Ok(String::new()) + } + + fn operations(&self) -> Vec { + self.calls.lock().expect("seam mutex").clone() + } +} + +fn operation_of(payload: &str) -> String { + let value: Value = serde_json::from_str(payload).expect("seam payload is JSON"); + value + .get("operation") + .and_then(Value::as_str) + .expect("seam payload has an operation") + .to_string() +} + +fn drive(git_dir: &Path, seam: &RecordingSeam, payload: &str) -> Result { + let resolver = |_cwd: &str| Ok(git_dir.to_path_buf()); + let seam_fn = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload); + run_pi_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) +} + +fn tool_call_event(tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolCall", + "session_id": "ses-main", + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + "model": "openai-codex/gpt-5.5", + }) + .to_string() +} + +fn tool_result_event(tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolResult", + "session_id": "ses-main", + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() +} + +fn tool_execution_end_event(tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecutionEnd", + "session_id": "ses-main", + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() +} + +fn tool_execution_abandon_event(tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecutionAbandon", + "session_id": "ses-main", + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() +} + +fn tool_execution_abandon_event_for_session( + tool_name: &str, + session_id: &str, + tool_call_id: &str, +) -> String { + json!({ + "hook_event_name": "ToolExecutionAbandon", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() +} + +fn cleanup(git_dir: &Path) { + let _ = std::fs::remove_dir_all(git_dir); +} + +fn tool_call_event_for_session(tool_name: &str, session_id: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolCall", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + "model": "openai-codex/gpt-5.5", + }) + .to_string() +} + +fn tool_result_event_for_session(tool_name: &str, session_id: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolResult", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() +} + +fn dead_process_owner() -> super::process_owner::ProcessOwner { + let mut dead_child = std::process::Command::new("true") + .spawn() + .expect("spawning 'true' should succeed"); + let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); + dead_child.wait().expect("child should exit and be reaped"); + super::process_owner::ProcessOwner { + pid: dead_pid, + instance_token: None, + } +} + +fn attempt_owned_by(state: &AdapterState, session_id: &str) -> AdapterAttempt { + state + .attempts + .iter() + .find(|attempt| attempt.session_id == session_id) + .expect("attempt for session must exist") + .clone() +} + +#[test] +fn tool_call_establishes_a_write_ahead_start_and_replays_idempotently() { + let git_dir = temp_git_dir("write-ahead-start"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("write", "call_1")).expect("first Start"); + drive(&git_dir, &seam, &tool_call_event("write", "call_1")) + .expect("duplicate Start is idempotent"); + + assert_eq!(seam.operations(), vec!["start", "start"]); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); + assert_eq!(state.attempts[0].tool_name, "write"); + + cleanup(&git_dir); +} + +#[test] +fn concurrent_bash_calls_in_one_session_stay_separate_live_scopes() { + let git_dir = temp_git_dir("concurrent-bash"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_a")).expect("A Start"); + drive(&git_dir, &seam, &tool_call_event("bash", "call_b")).expect("B Start must not retire A"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 2); + assert!(state + .attempts + .iter() + .all(|attempt| attempt.phase == AttemptPhase::PendingStart)); + + cleanup(&git_dir); +} + +#[test] +fn full_success_lifecycle_start_result_close() { + let git_dir = temp_git_dir("success-lifecycle"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts[0].phase, + AttemptPhase::PendingStart + ); + + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts[0].phase, + AttemptPhase::Executed, + "D5/D6: tool_result is the sole Executed-transition evidence" + ); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) + .expect("tool_execution_end closes an Executed attempt"); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert_eq!(seam.operations(), vec!["start", "close"]); + + cleanup(&git_dir); +} + +#[test] +fn tool_execution_end_without_a_preceding_tool_result_abandons_never_closes() { + let git_dir = temp_git_dir("d7-abandon"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) + .expect("D7: a terminal event with no preceding tool_result must abandon"); + + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert!( + !seam.operations().contains(&"close".to_string()), + "D7: an unexecuted attempt must never be closed" + ); + assert!(seam.operations().contains(&"abandon".to_string())); + assert!(read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear()); + + cleanup(&git_dir); +} + +#[test] +fn a_failed_close_falls_back_to_abandon_recovery() { + let git_dir = temp_git_dir("close-failure-falls-back"); + let seam = RecordingSeam::failing_on(&["close"]); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) + .expect("a Close failure must recover via abandon, not surface an error"); + + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert!(seam.operations().contains(&"abandon".to_string())); + + cleanup(&git_dir); +} + +#[test] +fn execution_abandon_forces_abandon_even_when_the_attempt_is_already_executed() { + let git_dir = temp_git_dir("d9-execution-abandon-executed"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); + + drive( + &git_dir, + &seam, + &tool_execution_abandon_event("bash", "call_1"), + ) + .expect("ExecutionAbandon must recover via abandon, not surface an error"); + + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert!( + !seam.operations().contains(&"close".to_string()), + "D9: an explicit abandon request must never be treated as a Close, \ + even for an attempt already marked Executed" + ); + assert!(seam.operations().contains(&"abandon".to_string())); + + cleanup(&git_dir); +} + +#[test] +fn execution_abandon_on_a_pending_start_attempt_abandons() { + let git_dir = temp_git_dir("d9-execution-abandon-pending-start"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + + drive( + &git_dir, + &seam, + &tool_execution_abandon_event("bash", "call_1"), + ) + .expect("ExecutionAbandon must recover a PendingStart attempt via abandon"); + + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert!(seam.operations().contains(&"abandon".to_string())); + + cleanup(&git_dir); +} + +#[test] +fn execution_abandon_for_an_unknown_attempt_is_a_safe_no_op() { + let git_dir = temp_git_dir("d9-execution-abandon-unknown"); + let seam = RecordingSeam::new(); + + let result = drive( + &git_dir, + &seam, + &tool_execution_abandon_event("bash", "call_1"), + ) + .expect("an unknown attempt must be a safe no-op, never an error"); + + assert_eq!(result, ""); + assert!(seam.operations().is_empty()); + + cleanup(&git_dir); +} + +#[test] +fn execution_abandon_for_an_untracked_tool_is_a_no_op() { + let git_dir = temp_git_dir("d9-execution-abandon-untracked"); + let seam = RecordingSeam::new(); + + let result = drive( + &git_dir, + &seam, + &tool_execution_abandon_event("read", "call_1"), + ) + .expect("untracked tools are never adapter-relevant"); + + assert_eq!(result, ""); + assert!(seam.operations().is_empty()); + + cleanup(&git_dir); +} + +#[test] +fn duplicate_execution_abandon_on_an_already_abandoned_attempt_is_a_safe_no_op() { + let git_dir = temp_git_dir("d9-execution-abandon-duplicate"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + + drive( + &git_dir, + &seam, + &tool_execution_abandon_event("bash", "call_1"), + ) + .expect("first ExecutionAbandon retires the attempt"); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + let result = drive( + &git_dir, + &seam, + &tool_execution_abandon_event("bash", "call_1"), + ) + .expect("a duplicate ExecutionAbandon for an already-retired attempt must be a safe no-op"); + + assert_eq!(result, ""); + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush"], + "a duplicate ExecutionAbandon must never issue a second abandon or a close" + ); + + cleanup(&git_dir); +} + +#[test] +fn execution_abandon_for_one_session_never_touches_another_sessions_attempt_with_the_same_tool_call_id( +) { + let git_dir = temp_git_dir("d9-execution-abandon-cross-session"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "ses-a", "call_1"), + ) + .expect("session A Start"); + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "ses-b", "call_1"), + ) + .expect("session B Start with the same tool_call_id"); + + assert_eq!( + read_state(&git_dir).expect("state readable").attempts.len(), + 2 + ); + + drive( + &git_dir, + &seam, + &tool_execution_abandon_event_for_session("bash", "ses-a", "call_1"), + ) + .expect("ExecutionAbandon for session A must not error"); + + let remaining = read_state(&git_dir).expect("state readable").attempts; + assert_eq!( + remaining.len(), + 1, + "abandoning session A's attempt must leave session B's untouched" + ); + assert_eq!(remaining[0].session_id, "ses-b"); + assert_eq!(remaining[0].tool_call_id, "call_1"); + assert_eq!(remaining[0].phase, AttemptPhase::PendingStart); + + drive( + &git_dir, + &seam, + &tool_result_event_for_session("bash", "ses-b", "call_1"), + ) + .expect("session B must still be able to progress normally after A's abandon"); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts[0].phase, + AttemptPhase::Executed + ); + + cleanup(&git_dir); +} + +#[test] +fn a_terminal_recovery_flush_failure_leaves_a_pending_recovery_and_denies_new_admission() { + let git_dir = temp_git_dir("recovery-flush-failure"); + let persistently_failing = RecordingSeam::failing_on(&["flush"]); + + drive( + &git_dir, + &persistently_failing, + &tool_call_event("bash", "call_1"), + ) + .expect("Start"); + drive( + &git_dir, + &persistently_failing, + &tool_execution_end_event("bash", "call_1"), + ) + .expect("abandon path swallows the flush failure rather than surfacing an error"); + + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + "a failed ambiguity flush must leave recovery Pending, not Clear" + ); + + let error = drive( + &git_dir, + &persistently_failing, + &tool_call_event("bash", "call_2"), + ) + .expect_err("a new admission must fail closed while recovery remains unresolved"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + + let recovered = RecordingSeam::new(); + drive(&git_dir, &recovered, &tool_call_event("bash", "call_3")) + .expect("a new admission must self-heal once recovery can complete"); + assert!(read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear()); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].tool_call_id, "call_3"); + + cleanup(&git_dir); +} + +#[test] +fn start_provenance_carries_the_prefixed_session_and_normalized_model_to_the_seam() { + let git_dir = temp_git_dir("provenance-present"); + let captured: Mutex> = Mutex::new(Vec::new()); + let resolver = |_cwd: &str| Ok(git_dir.clone()); + let seam_fn = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { + captured + .lock() + .expect("capture mutex") + .push(payload.to_string()); + Ok(String::new()) + }; + + run_pi_mutation_scope_from_payload_with_seams( + &tool_call_event("bash", "call_model"), + None, + &resolver, + &seam_fn, + ) + .expect("Start should succeed"); + + let payloads = captured.into_inner().expect("capture mutex"); + assert_eq!(payloads.len(), 1); + let sent: Value = serde_json::from_str(&payloads[0]).expect("seam payload is JSON"); + assert_eq!( + sent["provenance"]["session_id"].as_str(), + Some("pi_ses-main") + ); + assert_eq!( + sent["provenance"]["model_id"].as_str(), + Some("openai-codex/gpt-5.5") + ); + + cleanup(&git_dir); +} + +#[test] +fn start_provenance_is_null_model_when_the_event_carries_no_model() { + let git_dir = temp_git_dir("provenance-absent"); + let captured: Mutex> = Mutex::new(Vec::new()); + let resolver = |_cwd: &str| Ok(git_dir.clone()); + let seam_fn = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { + captured + .lock() + .expect("capture mutex") + .push(payload.to_string()); + Ok(String::new()) + }; + + let payload = json!({ + "hook_event_name": "ToolCall", + "session_id": "ses-main", + "tool_call_id": "call_no_model", + "cwd": CWD, + "tool_name": "bash", + }) + .to_string(); + + run_pi_mutation_scope_from_payload_with_seams(&payload, None, &resolver, &seam_fn) + .expect("Start should succeed"); + + let payloads = captured.into_inner().expect("capture mutex"); + let sent: Value = serde_json::from_str(&payloads[0]).expect("seam payload is JSON"); + assert!(sent["provenance"]["model_id"].is_null()); + + cleanup(&git_dir); +} + +#[test] +fn untracked_tool_call_never_admits_an_attempt() { + let git_dir = temp_git_dir("untracked-no-admit"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("read", "call_ro")).expect("untracked is inert"); + assert!(seam.operations().is_empty()); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + drive(&git_dir, &seam, &tool_result_event("read", "call_ro")).expect("untracked result inert"); + drive( + &git_dir, + &seam, + &tool_execution_end_event("read", "call_ro"), + ) + .expect("untracked terminal inert"); + assert!(seam.operations().is_empty()); + + cleanup(&git_dir); +} + +#[test] +fn a_pending_start_attempt_owned_by_a_dead_process_is_abandoned_not_replayed() { + let git_dir = temp_git_dir("d10-dead-owner-abandon"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); + let scope_id = read_state(&git_dir).expect("state readable").attempts[0] + .scope_id + .clone(); + + let mut dead_child = std::process::Command::new("true") + .spawn() + .expect("spawning 'true' should succeed"); + let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); + dead_child.wait().expect("child should exit and be reaped"); + state::set_attempt_owner_for_tests( + &git_dir, + &scope_id, + super::process_owner::ProcessOwner { + pid: dead_pid, + instance_token: None, + }, + ); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")) + .expect("a replay whose recorded owner is positively dead must abandon, not reuse"); + + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush", "start"], + "D10: a dead-owner PendingStart must be abandoned via the existing D8 flush/abandon/\ + flush pattern, then the triggering event admitted as a fresh attempt" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_ne!( + state.attempts[0].scope_id, scope_id, + "the fresh attempt must never reuse the abandoned attempt's ScopeId" + ); + assert_eq!(state.attempts[0].attempt_seq, 2); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); +} + +#[test] +fn a_pending_start_attempt_owned_by_a_live_process_is_never_abandoned_by_a_replay() { + let git_dir = temp_git_dir("d10-live-owner-no-abandon"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")) + .expect("a replay owned by a still-live process must be treated as a normal replay"); + + assert_eq!( + seam.operations(), + vec!["start", "start"], + "no TTL and no elapsed time may ever cause an abandon here: the owner is this test \ + process's own live parent for the whole test" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); + + cleanup(&git_dir); +} + +#[test] +fn a_dead_pending_start_attempt_is_recovered_by_an_unrelated_fresh_session_start() { + let git_dir = temp_git_dir("d10-fresh-session-dead-pending-start"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start"); + let scope_a = + attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; + state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect( + "B's Start must recover A's stale owner without ever replaying A's \ + (session_id, tool_call_id) key", + ); + + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush", "start"], + "D10: a dead PendingStart owner discovered by an unrelated fresh-session Start must \ + be retired through the existing D8 flush/abandon/flush sequence before the \ + triggering Start is admitted" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].session_id, "sess-b"); + assert_ne!(state.attempts[0].scope_id, scope_a); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); +} + +#[test] +fn a_dead_executed_attempt_is_recovered_by_a_fresh_session_start_without_a_synthetic_close() { + let git_dir = temp_git_dir("d10-fresh-session-dead-executed"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start"); + drive( + &git_dir, + &seam, + &tool_result_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's tool_result marks Executed"); + let state = read_state(&git_dir).expect("state readable"); + let scope_a = attempt_owned_by(&state, "sess-a").scope_id; + assert_eq!( + attempt_owned_by(&state, "sess-a").phase, + AttemptPhase::Executed + ); + state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect("B's Start must recover A's dead Executed attempt"); + + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush", "start"], + "a dead Executed attempt must be abandoned/rebaselined via D8, never given a \ + synthetic delayed Close" + ); + assert!( + !seam.operations().contains(&"close".to_string()), + "D9: the current Git tree no longer represents the original terminal observation \ + time, so a dead Executed attempt must never be closed" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].session_id, "sess-b"); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); +} + +#[test] +fn a_dead_owner_scope_is_recovered_while_a_live_owner_sibling_survives_untouched() { + let git_dir = temp_git_dir("d10-dead-live-sibling-isolation"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start (owner will die)"); + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect("B's Start (owner stays live)"); + + let state = read_state(&git_dir).expect("state readable"); + let scope_a = attempt_owned_by(&state, "sess-a").scope_id; + let scope_b = attempt_owned_by(&state, "sess-b").scope_id; + state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-c", "call-c"), + ) + .expect("C's Start must recover only A"); + + assert_eq!( + seam.operations(), + vec!["start", "start", "flush", "abandon", "flush", "start"], + "exactly one abandon must occur, and only for A's own positively dead owner" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 2); + assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); + let b = attempt_owned_by(&state, "sess-b"); + assert_eq!(b.scope_id, scope_b); + assert_eq!( + b.phase, + AttemptPhase::PendingStart, + "B must survive reconciliation exactly as it was, untouched" + ); + assert_eq!( + attempt_owned_by(&state, "sess-c").phase, + AttemptPhase::PendingStart + ); + + cleanup(&git_dir); +} + +#[test] +fn multiple_dead_owner_scopes_are_retired_in_one_recovery_generation_while_a_live_sibling_survives() +{ + let git_dir = temp_git_dir("d10-multiple-dead-owner-scopes"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start"); + drive( + &git_dir, + &seam, + &tool_result_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's tool_result marks Executed"); + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect("B's Start"); + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-q", "call-q"), + ) + .expect("Q's Start (owner stays live)"); + + let state = read_state(&git_dir).expect("state readable"); + let scope_a = attempt_owned_by(&state, "sess-a").scope_id; + let scope_b = attempt_owned_by(&state, "sess-b").scope_id; + let scope_q = attempt_owned_by(&state, "sess-q").scope_id; + let dead_owner = dead_process_owner(); + state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_owner); + state::set_attempt_owner_for_tests(&git_dir, &scope_b, dead_owner); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-c", "call-c"), + ) + .expect("C's Start must recover both A and B, grouped into one recovery generation"); + + assert_eq!( + seam.operations(), + vec!["start", "start", "start", "flush", "abandon", "abandon", "flush", "start"], + "a single flush/abandon.../flush recovery generation must retire every \ + independently-proven-dead scope owned by the same dead process together" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 2); + assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); + assert!(!state.attempts.iter().any(|a| a.scope_id == scope_b)); + assert_eq!(attempt_owned_by(&state, "sess-q").scope_id, scope_q); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); +} + +#[test] +fn an_owner_that_cannot_be_positively_proven_dead_is_never_abandoned_by_an_unrelated_start() { + let git_dir = temp_git_dir("d10-uncertain-owner-preserved"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start"); + let scope_a = + attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; + state::set_attempt_owner_for_tests( + &git_dir, + &scope_a, + super::process_owner::ProcessOwner { + pid: std::process::id().cast_signed(), + instance_token: None, + }, + ); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-c", "call-c"), + ) + .expect( + "C's Start must proceed without touching A, whose owner cannot be positively \ + proven dead", + ); + + assert_eq!( + seam.operations(), + vec!["start", "start"], + "a live pid with no instance-token evidence must never be converted into proof of \ + death: uncertain identity is conservatively treated as alive" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 2); + assert!(state + .attempts + .iter() + .all(|attempt| attempt.phase == AttemptPhase::PendingStart)); + assert!(state.attempts.iter().any(|a| a.scope_id == scope_a)); + + cleanup(&git_dir); +} + +#[test] +fn an_interrupted_stale_owner_recovery_remains_pending_and_denies_the_triggering_start_until_resumed( +) { + let git_dir = temp_git_dir("d10-interrupted-stale-recovery"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start"); + let scope_a = + attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; + state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); + + let crashing = RecordingSeam::failing_once_on(&["abandon"]); + let error = drive( + &git_dir, + &crashing, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect_err("a Start that triggers a stale-owner recovery which fails mid-way must not commit"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.recovery, RecoveryState::Pending { generation: 1 }); + assert!( + !state.attempts.iter().any(|a| a.session_id == "sess-b"), + "B must never be admitted while A's stale-owner recovery is still pending" + ); + assert_eq!( + attempt_owned_by(&state, "sess-a").phase, + AttemptPhase::PendingAbandon + ); + + drive( + &git_dir, + &crashing, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect( + "the next boundary-lock acquisition must resume and complete the pending recovery, \ + and only then admit B", + ); + + let state = read_state(&git_dir).expect("state readable"); + assert!(state.recovery.is_clear()); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].session_id, "sess-b"); + assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); + + cleanup(&git_dir); +} + +#[test] +fn duplicate_tool_result_after_close_is_a_safe_no_op() { + let git_dir = temp_git_dir("duplicate-tool-result-after-close"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("Close"); + + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")) + .expect("a late duplicate tool_result after Close must be a safe no-op"); + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) + .expect("a late duplicate tool_execution_end after Close must be a safe no-op"); + + assert_eq!( + seam.operations(), + vec!["start", "close"], + "a resurrected attempt must never re-enter the runtime seam after its own Close" + ); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + cleanup(&git_dir); +} + +#[test] +fn duplicate_tool_execution_end_after_abandon_is_a_safe_no_op() { + let git_dir = temp_git_dir("duplicate-terminal-after-abandon"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("D7 abandon"); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) + .expect("a late duplicate terminal event after abandon must be a safe no-op"); + + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush"], + "a duplicate terminal delivery for an already-abandoned attempt must never issue a \ + second abandon" + ); + + cleanup(&git_dir); +} + +#[test] +fn abandoning_one_sibling_never_touches_a_concurrent_sibling_in_the_same_session() { + let git_dir = temp_git_dir("sibling-abandon-isolation"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_a")).expect("A Start"); + drive(&git_dir, &seam, &tool_call_event("bash", "call_b")).expect("B Start"); + drive(&git_dir, &seam, &tool_result_event("bash", "call_b")).expect("B tool_result"); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_a")) + .expect("A's terminal event with no tool_result must abandon only A"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!( + state.attempts.len(), + 1, + "abandoning A must never remove or block sibling B" + ); + assert_eq!(state.attempts[0].tool_call_id, "call_b"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Executed); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_b")) + .expect("B must still close normally after A's abandonment and recovery"); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert_eq!( + seam.operations(), + vec!["start", "start", "flush", "abandon", "flush", "close"] + ); + + cleanup(&git_dir); +} + +#[test] +fn a_crash_mid_abandon_loop_is_resumed_and_completed_on_the_next_boundary_lock_acquisition() { + let git_dir = temp_git_dir("crash-mid-abandon-loop"); + let crashing = RecordingSeam::failing_once_on(&["abandon"]); + + drive(&git_dir, &crashing, &tool_call_event("bash", "call_1")).expect("Start"); + drive( + &git_dir, + &crashing, + &tool_execution_end_event("bash", "call_1"), + ) + .expect( + "a transient abandon failure mid-recovery must leave recovery Pending, not surface \ + an error, simulating a crash between marking PendingAbandon and completing the \ + flush/abandon/flush sequence", + ); + + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + "the interrupted abandon loop must leave recovery durably Pending for the next \ + boundary-lock acquisition to resume, never Clear and never lost" + ); + assert_eq!( + read_state(&git_dir) + .expect("state readable") + .attempts + .first() + .expect("the doomed attempt must still be recorded") + .phase, + AttemptPhase::PendingAbandon + ); + + drive(&git_dir, &crashing, &tool_call_event("bash", "call_2")) + .expect("recovery must self-heal and complete on the very next invocation"); + + assert!(read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear()); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].tool_call_id, "call_2"); + + cleanup(&git_dir); +} + +#[test] +fn a_reused_tool_call_id_after_terminal_cleanup_gets_a_distinct_scope_id() { + let git_dir = temp_git_dir("terminal-scope-id-non-reuse"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("first tool_result"); + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("first Close"); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("reused toolCallId Start"); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].attempt_seq, 2); + + cleanup(&git_dir); +} diff --git a/cli/src/services/hooks/pi_mutation_scope/mod.rs b/cli/src/services/hooks/pi_mutation_scope/mod.rs index a75a3e74..74515e18 100644 --- a/cli/src/services/hooks/pi_mutation_scope/mod.rs +++ b/cli/src/services/hooks/pi_mutation_scope/mod.rs @@ -456,11 +456,6 @@ fn admit_or_recover( } } -/// D10: every tracked Start admission is a reconciliation opportunity, independent of the -/// incoming key. Repeatedly collects `PendingStart`/`Executed` attempts with a positively dead -/// owner (any session, any prior process) and retires them through the existing D8 -/// flush/abandon/flush sequence, grouping every independently-proven-dead scope into one -/// generation per pass. Live and uncertain-owner attempts are left untouched. fn reconcile_stale_owners( git_dir: &Path, repository_root: &Path, @@ -702,2364 +697,13 @@ pub(crate) fn force_attempt_owner_dead_for_tests(git_dir: &Path, scope_id: &str) } #[cfg(test)] -mod tests { - use super::*; - - fn tool_event_json(hook_event_name: &str, overrides: &[(&str, Value)]) -> String { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(hook_event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("01a091f4-session".to_string()), - ); - object.insert( - TOOL_CALL_ID_FIELD.to_string(), - Value::String("call_1|fc_1".to_string()), - ); - object.insert( - CWD_FIELD.to_string(), - Value::String("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/repo/checkout".to_string()), - ); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String("write".to_string()), - ); - for (field, value) in overrides { - object.insert((*field).to_string(), value.clone()); - } - Value::Object(object).to_string() - } - - fn key(session_id: &str, tool_call_id: &str) -> AttemptKey { - AttemptKey { - session_id: session_id.to_string(), - tool_call_id: tool_call_id.to_string(), - } - } - - fn tool_call(payload: &str) -> PiToolCall { - match parse_pi_hook_event(payload).expect("valid ToolCall parses") { - PiHookEvent::Call(call) => call, - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[test] - fn empty_payload_is_rejected() { - let error = parse_pi_hook_event(" ").unwrap_err().to_string(); - assert_eq!( - error, - "Invalid Pi hook event payload from STDIN: expected a JSON object, got an empty payload." - ); - } - - #[test] - fn non_object_json_is_rejected() { - for payload in ["[]", "\"ToolCall\"", "42", "null"] { - let error = parse_pi_hook_event(payload).unwrap_err().to_string(); - assert!( - error.contains("expected a JSON object"), - "payload {payload:?} produced {error:?}" - ); - } - } - - #[test] - fn invalid_json_is_rejected() { - let error = parse_pi_hook_event("{not json").unwrap_err().to_string(); - assert!( - error.contains("Invalid Pi hook event payload from STDIN: expected valid JSON"), - "{error:?}" - ); - } - - #[test] - fn unsupported_hook_event_name_is_rejected() { - for name in ["PreToolUse", "tool_call", "chat.params", ""] { - let payload = tool_event_json(name, &[]); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("hook_event_name"), - "name {name:?} produced {error:?}" - ); - } - } - - #[test] - fn missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [ - SESSION_ID_FIELD, - TOOL_CALL_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - ] { - let mut object: Map = - serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_CALL, &[])).unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); - - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("'{field}'")), - "missing {field} produced {error:?}" - ); - } - } - - #[test] - fn blank_required_fields_are_rejected() { - for field in [ - SESSION_ID_FIELD, - TOOL_CALL_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - ] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[(field, Value::String(" ".to_string()))], - ); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("field '{field}' must be a non-blank string")), - "blank {field} produced {error:?}" - ); - } - } - - #[test] - fn wrong_typed_fields_are_rejected() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[(TOOL_CALL_ID_FIELD, Value::Bool(true))], - ); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'tool_call_id' must be a string"), - "{error:?}" - ); - } - - #[test] - fn wrong_typed_optional_model_is_rejected() { - let payload = tool_event_json(HOOK_EVENT_TOOL_CALL, &[(MODEL_FIELD, Value::Bool(false))]); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'model' must be null, absent, or a non-blank string"), - "{error:?}" - ); - } - - #[test] - fn tool_call_parses_identity_and_model() { - let call = tool_call(&tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[ - (TOOL_NAME_FIELD, Value::String("edit".to_string())), - ( - MODEL_FIELD, - Value::String("openai-codex/gpt-5.5".to_string()), - ), - ], - )); - assert_eq!(call.identity.session_id, "01a091f4-session"); - assert_eq!(call.identity.tool_call_id, "call_1|fc_1"); - assert_eq!(call.identity.tool_name, "edit"); - assert_eq!(call.model.as_deref(), Some("openai-codex/gpt-5.5")); - assert_eq!( - call.identity.classification(), - ToolClassification::TrackedMutation - ); - } - - #[test] - fn tool_call_model_is_optional() { - let call = tool_call(&tool_event_json(HOOK_EVENT_TOOL_CALL, &[])); - assert_eq!(call.model, None); - } - - #[test] - fn tool_result_and_tool_execution_end_parse_minimal_identity() { - for name in [ - HOOK_EVENT_TOOL_RESULT, - HOOK_EVENT_TOOL_EXECUTION_END, - HOOK_EVENT_TOOL_EXECUTION_ABANDON, - ] { - let event = parse_pi_hook_event(&tool_event_json(name, &[])).unwrap(); - let identity = match event { - PiHookEvent::Executed(identity) - | PiHookEvent::ExecutionEnd(identity) - | PiHookEvent::ExecutionAbandon(identity) => identity, - other => panic!("expected a minimal-identity event, got {other:?}"), - }; - assert_eq!( - identity.attempt_key(), - key("01a091f4-session", "call_1|fc_1") - ); - } - } - - #[test] - fn tool_execution_start_parses_and_is_never_evidence() { - let event = - parse_pi_hook_event(&tool_event_json(HOOK_EVENT_TOOL_EXECUTION_START, &[])).unwrap(); - let PiHookEvent::ExecutionStart(identity) = event else { - panic!("expected ToolExecutionStart"); - }; - assert_eq!(identity.tool_call_id, "call_1|fc_1"); - } - - #[test] - fn classification_table() { - let cases: &[(&str, ToolClassification)] = &[ - ("bash", ToolClassification::TrackedMutation), - ("edit", ToolClassification::TrackedMutation), - ("write", ToolClassification::TrackedMutation), - ("read", ToolClassification::Untracked), - ("grep", ToolClassification::Untracked), - ("find", ToolClassification::Untracked), - ("ls", ToolClassification::Untracked), - ("probe_mutate", ToolClassification::Untracked), - ("Bash", ToolClassification::Untracked), - ("some_future_pi_builtin", ToolClassification::Untracked), - ("", ToolClassification::Untracked), - ]; - for (tool_name, expected) in cases { - assert_eq!( - classify_tool(tool_name), - *expected, - "classify_tool({tool_name:?})" - ); - } - } - - #[test] - fn scope_id_embeds_attempt_seq_and_is_length_prefixed() { - let k = key("01a091f4-session", "call_1|fc_1"); - let scope_id = format_pi_scope_id(&k, 1); - assert_eq!( - scope_id, - "pi-tool-v1|n=1|s=16:01a091f4-session|c=11:call_1|fc_1" - ); - assert_ne!(format_pi_scope_id(&k, 1), format_pi_scope_id(&k, 2)); - assert_eq!( - pi_scope_start_event_id(&scope_id), - format!("{scope_id}|start") - ); - assert_eq!( - pi_scope_close_event_id(&scope_id), - format!("{scope_id}|close") - ); - assert_ne!( - pi_scope_start_event_id(&scope_id), - pi_scope_close_event_id(&scope_id) - ); - } - - #[test] - fn length_prefix_disambiguates_delimiter_collisions() { - let a = key("s|c=1:x", "y"); - let b = key("s", "1:x|y"); - assert_ne!(format_pi_scope_id(&a, 1), format_pi_scope_id(&b, 1)); - } - - #[test] - fn provenance_canonicalizes_the_session_and_normalizes_the_model() { - let provenance = pi_scope_provenance("01a091f4-session", Some("openai-codex/gpt-5.5")); - assert_eq!(provenance.session_id, "pi_01a091f4-session"); - assert_eq!(provenance.model_id.as_deref(), Some("openai-codex/gpt-5.5")); - } - - #[test] - fn provenance_keeps_an_already_prefixed_session_id() { - let provenance = pi_scope_provenance("pi_01a091f4-session", None); - assert_eq!(provenance.session_id, "pi_01a091f4-session"); - } - - #[test] - fn provenance_without_model_evidence_is_null() { - for model in [None, Some(""), Some(" ")] { - let provenance = pi_scope_provenance("01a091f4-session", model); - assert_eq!(provenance.model_id, None, "model {model:?}"); - } - } - - #[test] - fn run_from_payload_fails_closed_when_a_tracked_start_cannot_resolve_its_checkout() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[( - CWD_FIELD, - Value::String("/nonexistent/sce/pi/checkout".to_string()), - )], - ); - let error = run_pi_mutation_scope_from_payload(&payload, None) - .expect_err("a tracked Start that cannot resolve its checkout must fail closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE), "{error:?}"); - } - - #[test] - fn run_from_payload_is_neutral_for_untracked_events() { - for tool_name in ["read", "grep", "find", "ls", "probe_mutate"] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], - ); - assert_eq!( - run_pi_mutation_scope_from_payload(&payload, None).unwrap(), - String::new() - ); - } - } - - #[test] - fn run_from_payload_surfaces_malformed_input() { - let error = run_pi_mutation_scope_from_payload("{bad", None) - .unwrap_err() - .to_string(); - assert!(error.contains("expected valid JSON"), "{error:?}"); - } - - #[test] - fn tool_execution_start_is_always_a_no_op_regardless_of_classification() { - for tool_name in ["bash", "read"] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTION_START, - &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], - ); - assert_eq!( - run_pi_mutation_scope_from_payload(&payload, None).unwrap(), - String::new() - ); - } - } -} - -#[cfg(test)] -mod lifecycle_tests { - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Mutex; - - use serde_json::Value; - - use super::state::{read_state, AdapterAttempt, AdapterState, AttemptPhase, RecoveryState}; - use super::*; - - static NEXT_ID: AtomicU64 = AtomicU64::new(0); - - fn temp_git_dir(label: &str) -> PathBuf { - let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "sce-pi-mutation-scope-lifecycle-{label}-{}-{id}", - std::process::id() - )) - } - - const CWD: &str = "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/repo/pi-checkout"; - - struct RecordingSeam { - calls: Mutex>, - fail_operations: Vec, - fail_once_operations: Mutex>, - } - - impl RecordingSeam { - fn new() -> Self { - Self { - calls: Mutex::new(Vec::new()), - fail_operations: Vec::new(), - fail_once_operations: Mutex::new(Vec::new()), - } - } - - fn failing_on(operations: &[&str]) -> Self { - Self { - fail_operations: operations.iter().map(|op| (*op).to_string()).collect(), - ..Self::new() - } - } - - fn failing_once_on(operations: &[&str]) -> Self { - Self { - fail_once_operations: Mutex::new( - operations.iter().map(|op| (*op).to_string()).collect(), - ), - ..Self::new() - } - } - - fn handle(&self, payload: &str) -> Result { - let operation = operation_of(payload); - { - let mut calls = self.calls.lock().expect("seam mutex"); - calls.push(operation.clone()); - } - if self.fail_operations.contains(&operation) { - bail!("seam failure injected by test for '{operation}'"); - } - { - let mut once = self.fail_once_operations.lock().expect("seam mutex"); - if let Some(position) = once.iter().position(|candidate| candidate == &operation) { - once.remove(position); - bail!("transient seam failure injected once by test for '{operation}'"); - } - } - Ok(String::new()) - } - - fn operations(&self) -> Vec { - self.calls.lock().expect("seam mutex").clone() - } - } - - fn operation_of(payload: &str) -> String { - let value: Value = serde_json::from_str(payload).expect("seam payload is JSON"); - value - .get("operation") - .and_then(Value::as_str) - .expect("seam payload has an operation") - .to_string() - } - - fn drive(git_dir: &Path, seam: &RecordingSeam, payload: &str) -> Result { - let resolver = |_cwd: &str| Ok(git_dir.to_path_buf()); - let seam_fn = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload); - run_pi_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) - } - - fn tool_call_event(tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolCall", - "session_id": "ses-main", - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - "model": "openai-codex/gpt-5.5", - }) - .to_string() - } - - fn tool_result_event(tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolResult", - "session_id": "ses-main", - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn tool_execution_end_event(tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecutionEnd", - "session_id": "ses-main", - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn tool_execution_abandon_event(tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecutionAbandon", - "session_id": "ses-main", - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn tool_execution_abandon_event_for_session( - tool_name: &str, - session_id: &str, - tool_call_id: &str, - ) -> String { - json!({ - "hook_event_name": "ToolExecutionAbandon", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn cleanup(git_dir: &Path) { - let _ = std::fs::remove_dir_all(git_dir); - } - - fn tool_call_event_for_session( - tool_name: &str, - session_id: &str, - tool_call_id: &str, - ) -> String { - json!({ - "hook_event_name": "ToolCall", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - "model": "openai-codex/gpt-5.5", - }) - .to_string() - } - - fn tool_result_event_for_session( - tool_name: &str, - session_id: &str, - tool_call_id: &str, - ) -> String { - json!({ - "hook_event_name": "ToolResult", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn dead_process_owner() -> super::process_owner::ProcessOwner { - let mut dead_child = std::process::Command::new("true") - .spawn() - .expect("spawning 'true' should succeed"); - let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); - dead_child.wait().expect("child should exit and be reaped"); - super::process_owner::ProcessOwner { - pid: dead_pid, - instance_token: None, - } - } - - fn attempt_owned_by(state: &AdapterState, session_id: &str) -> AdapterAttempt { - state - .attempts - .iter() - .find(|attempt| attempt.session_id == session_id) - .expect("attempt for session must exist") - .clone() - } - - #[test] - fn tool_call_establishes_a_write_ahead_start_and_replays_idempotently() { - let git_dir = temp_git_dir("write-ahead-start"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("write", "call_1")).expect("first Start"); - drive(&git_dir, &seam, &tool_call_event("write", "call_1")) - .expect("duplicate Start is idempotent"); - - assert_eq!(seam.operations(), vec!["start", "start"]); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); - assert_eq!(state.attempts[0].tool_name, "write"); - - cleanup(&git_dir); - } - - #[test] - fn concurrent_bash_calls_in_one_session_stay_separate_live_scopes() { - let git_dir = temp_git_dir("concurrent-bash"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_a")).expect("A Start"); - drive(&git_dir, &seam, &tool_call_event("bash", "call_b")) - .expect("B Start must not retire A"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 2); - assert!(state - .attempts - .iter() - .all(|attempt| attempt.phase == AttemptPhase::PendingStart)); - - cleanup(&git_dir); - } - - #[test] - fn full_success_lifecycle_start_result_close() { - let git_dir = temp_git_dir("success-lifecycle"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - assert_eq!( - read_state(&git_dir).expect("state readable").attempts[0].phase, - AttemptPhase::PendingStart - ); - - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); - assert_eq!( - read_state(&git_dir).expect("state readable").attempts[0].phase, - AttemptPhase::Executed, - "D5/D6: tool_result is the sole Executed-transition evidence" - ); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) - .expect("tool_execution_end closes an Executed attempt"); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert_eq!(seam.operations(), vec!["start", "close"]); - - cleanup(&git_dir); - } - - #[test] - fn tool_execution_end_without_a_preceding_tool_result_abandons_never_closes() { - let git_dir = temp_git_dir("d7-abandon"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) - .expect("D7: a terminal event with no preceding tool_result must abandon"); - - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert!( - !seam.operations().contains(&"close".to_string()), - "D7: an unexecuted attempt must never be closed" - ); - assert!(seam.operations().contains(&"abandon".to_string())); - assert!(read_state(&git_dir) - .expect("state readable") - .recovery - .is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn a_failed_close_falls_back_to_abandon_recovery() { - let git_dir = temp_git_dir("close-failure-falls-back"); - let seam = RecordingSeam::failing_on(&["close"]); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) - .expect("a Close failure must recover via abandon, not surface an error"); - - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert!(seam.operations().contains(&"abandon".to_string())); - - cleanup(&git_dir); - } - - #[test] - fn execution_abandon_forces_abandon_even_when_the_attempt_is_already_executed() { - let git_dir = temp_git_dir("d9-execution-abandon-executed"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); - - drive( - &git_dir, - &seam, - &tool_execution_abandon_event("bash", "call_1"), - ) - .expect("ExecutionAbandon must recover via abandon, not surface an error"); - - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert!( - !seam.operations().contains(&"close".to_string()), - "D9: an explicit abandon request must never be treated as a Close, \ - even for an attempt already marked Executed" - ); - assert!(seam.operations().contains(&"abandon".to_string())); - - cleanup(&git_dir); - } - - #[test] - fn execution_abandon_on_a_pending_start_attempt_abandons() { - let git_dir = temp_git_dir("d9-execution-abandon-pending-start"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - - drive( - &git_dir, - &seam, - &tool_execution_abandon_event("bash", "call_1"), - ) - .expect("ExecutionAbandon must recover a PendingStart attempt via abandon"); - - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert!(seam.operations().contains(&"abandon".to_string())); - - cleanup(&git_dir); - } - - #[test] - fn execution_abandon_for_an_unknown_attempt_is_a_safe_no_op() { - let git_dir = temp_git_dir("d9-execution-abandon-unknown"); - let seam = RecordingSeam::new(); - - let result = drive( - &git_dir, - &seam, - &tool_execution_abandon_event("bash", "call_1"), - ) - .expect("an unknown attempt must be a safe no-op, never an error"); - - assert_eq!(result, ""); - assert!(seam.operations().is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn execution_abandon_for_an_untracked_tool_is_a_no_op() { - let git_dir = temp_git_dir("d9-execution-abandon-untracked"); - let seam = RecordingSeam::new(); - - let result = drive( - &git_dir, - &seam, - &tool_execution_abandon_event("read", "call_1"), - ) - .expect("untracked tools are never adapter-relevant"); - - assert_eq!(result, ""); - assert!(seam.operations().is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn duplicate_execution_abandon_on_an_already_abandoned_attempt_is_a_safe_no_op() { - let git_dir = temp_git_dir("d9-execution-abandon-duplicate"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - - drive( - &git_dir, - &seam, - &tool_execution_abandon_event("bash", "call_1"), - ) - .expect("first ExecutionAbandon retires the attempt"); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - let result = drive( - &git_dir, - &seam, - &tool_execution_abandon_event("bash", "call_1"), - ) - .expect("a duplicate ExecutionAbandon for an already-retired attempt must be a safe no-op"); - - assert_eq!(result, ""); - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush"], - "a duplicate ExecutionAbandon must never issue a second abandon or a close" - ); - - cleanup(&git_dir); - } - - #[test] - fn execution_abandon_for_one_session_never_touches_another_sessions_attempt_with_the_same_tool_call_id( - ) { - let git_dir = temp_git_dir("d9-execution-abandon-cross-session"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "ses-a", "call_1"), - ) - .expect("session A Start"); - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "ses-b", "call_1"), - ) - .expect("session B Start with the same tool_call_id"); - - assert_eq!( - read_state(&git_dir).expect("state readable").attempts.len(), - 2 - ); - - drive( - &git_dir, - &seam, - &tool_execution_abandon_event_for_session("bash", "ses-a", "call_1"), - ) - .expect("ExecutionAbandon for session A must not error"); - - let remaining = read_state(&git_dir).expect("state readable").attempts; - assert_eq!( - remaining.len(), - 1, - "abandoning session A's attempt must leave session B's untouched" - ); - assert_eq!(remaining[0].session_id, "ses-b"); - assert_eq!(remaining[0].tool_call_id, "call_1"); - assert_eq!(remaining[0].phase, AttemptPhase::PendingStart); - - drive( - &git_dir, - &seam, - &tool_result_event_for_session("bash", "ses-b", "call_1"), - ) - .expect("session B must still be able to progress normally after A's abandon"); - assert_eq!( - read_state(&git_dir).expect("state readable").attempts[0].phase, - AttemptPhase::Executed - ); - - cleanup(&git_dir); - } - - #[test] - fn a_terminal_recovery_flush_failure_leaves_a_pending_recovery_and_denies_new_admission() { - let git_dir = temp_git_dir("recovery-flush-failure"); - let persistently_failing = RecordingSeam::failing_on(&["flush"]); - - drive( - &git_dir, - &persistently_failing, - &tool_call_event("bash", "call_1"), - ) - .expect("Start"); - drive( - &git_dir, - &persistently_failing, - &tool_execution_end_event("bash", "call_1"), - ) - .expect("abandon path swallows the flush failure rather than surfacing an error"); - - assert_eq!( - read_state(&git_dir).expect("state readable").recovery, - RecoveryState::Pending { generation: 1 }, - "a failed ambiguity flush must leave recovery Pending, not Clear" - ); - - let error = drive( - &git_dir, - &persistently_failing, - &tool_call_event("bash", "call_2"), - ) - .expect_err("a new admission must fail closed while recovery remains unresolved"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - - let recovered = RecordingSeam::new(); - drive(&git_dir, &recovered, &tool_call_event("bash", "call_3")) - .expect("a new admission must self-heal once recovery can complete"); - assert!(read_state(&git_dir) - .expect("state readable") - .recovery - .is_clear()); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].tool_call_id, "call_3"); - - cleanup(&git_dir); - } - - #[test] - fn start_provenance_carries_the_prefixed_session_and_normalized_model_to_the_seam() { - let git_dir = temp_git_dir("provenance-present"); - let captured: Mutex> = Mutex::new(Vec::new()); - let resolver = |_cwd: &str| Ok(git_dir.clone()); - let seam_fn = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { - captured - .lock() - .expect("capture mutex") - .push(payload.to_string()); - Ok(String::new()) - }; - - run_pi_mutation_scope_from_payload_with_seams( - &tool_call_event("bash", "call_model"), - None, - &resolver, - &seam_fn, - ) - .expect("Start should succeed"); - - let payloads = captured.into_inner().expect("capture mutex"); - assert_eq!(payloads.len(), 1); - let sent: Value = serde_json::from_str(&payloads[0]).expect("seam payload is JSON"); - assert_eq!( - sent["provenance"]["session_id"].as_str(), - Some("pi_ses-main") - ); - assert_eq!( - sent["provenance"]["model_id"].as_str(), - Some("openai-codex/gpt-5.5") - ); - - cleanup(&git_dir); - } - - #[test] - fn start_provenance_is_null_model_when_the_event_carries_no_model() { - let git_dir = temp_git_dir("provenance-absent"); - let captured: Mutex> = Mutex::new(Vec::new()); - let resolver = |_cwd: &str| Ok(git_dir.clone()); - let seam_fn = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { - captured - .lock() - .expect("capture mutex") - .push(payload.to_string()); - Ok(String::new()) - }; - - let payload = json!({ - "hook_event_name": "ToolCall", - "session_id": "ses-main", - "tool_call_id": "call_no_model", - "cwd": CWD, - "tool_name": "bash", - }) - .to_string(); - - run_pi_mutation_scope_from_payload_with_seams(&payload, None, &resolver, &seam_fn) - .expect("Start should succeed"); - - let payloads = captured.into_inner().expect("capture mutex"); - let sent: Value = serde_json::from_str(&payloads[0]).expect("seam payload is JSON"); - assert!(sent["provenance"]["model_id"].is_null()); - - cleanup(&git_dir); - } - - #[test] - fn untracked_tool_call_never_admits_an_attempt() { - let git_dir = temp_git_dir("untracked-no-admit"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("read", "call_ro")).expect("untracked is inert"); - assert!(seam.operations().is_empty()); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - drive(&git_dir, &seam, &tool_result_event("read", "call_ro")) - .expect("untracked result inert"); - drive( - &git_dir, - &seam, - &tool_execution_end_event("read", "call_ro"), - ) - .expect("untracked terminal inert"); - assert!(seam.operations().is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn a_pending_start_attempt_owned_by_a_dead_process_is_abandoned_not_replayed() { - let git_dir = temp_git_dir("d10-dead-owner-abandon"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); - let scope_id = read_state(&git_dir).expect("state readable").attempts[0] - .scope_id - .clone(); - - let mut dead_child = std::process::Command::new("true") - .spawn() - .expect("spawning 'true' should succeed"); - let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); - dead_child.wait().expect("child should exit and be reaped"); - state::set_attempt_owner_for_tests( - &git_dir, - &scope_id, - super::process_owner::ProcessOwner { - pid: dead_pid, - instance_token: None, - }, - ); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")) - .expect("a replay whose recorded owner is positively dead must abandon, not reuse"); - - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush", "start"], - "D10: a dead-owner PendingStart must be abandoned via the existing D8 flush/abandon/\ - flush pattern, then the triggering event admitted as a fresh attempt" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_ne!( - state.attempts[0].scope_id, scope_id, - "the fresh attempt must never reuse the abandoned attempt's ScopeId" - ); - assert_eq!(state.attempts[0].attempt_seq, 2); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn a_pending_start_attempt_owned_by_a_live_process_is_never_abandoned_by_a_replay() { - let git_dir = temp_git_dir("d10-live-owner-no-abandon"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")) - .expect("a replay owned by a still-live process must be treated as a normal replay"); - - assert_eq!( - seam.operations(), - vec!["start", "start"], - "no TTL and no elapsed time may ever cause an abandon here: the owner is this test \ - process's own live parent for the whole test" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); - - cleanup(&git_dir); - } - - #[test] - fn a_dead_pending_start_attempt_is_recovered_by_an_unrelated_fresh_session_start() { - let git_dir = temp_git_dir("d10-fresh-session-dead-pending-start"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start"); - let scope_a = - attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; - state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect( - "B's Start must recover A's stale owner without ever replaying A's \ - (session_id, tool_call_id) key", - ); - - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush", "start"], - "D10: a dead PendingStart owner discovered by an unrelated fresh-session Start must \ - be retired through the existing D8 flush/abandon/flush sequence before the \ - triggering Start is admitted" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].session_id, "sess-b"); - assert_ne!(state.attempts[0].scope_id, scope_a); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn a_dead_executed_attempt_is_recovered_by_a_fresh_session_start_without_a_synthetic_close() { - let git_dir = temp_git_dir("d10-fresh-session-dead-executed"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start"); - drive( - &git_dir, - &seam, - &tool_result_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's tool_result marks Executed"); - let state = read_state(&git_dir).expect("state readable"); - let scope_a = attempt_owned_by(&state, "sess-a").scope_id; - assert_eq!( - attempt_owned_by(&state, "sess-a").phase, - AttemptPhase::Executed - ); - state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect("B's Start must recover A's dead Executed attempt"); - - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush", "start"], - "a dead Executed attempt must be abandoned/rebaselined via D8, never given a \ - synthetic delayed Close" - ); - assert!( - !seam.operations().contains(&"close".to_string()), - "D9: the current Git tree no longer represents the original terminal observation \ - time, so a dead Executed attempt must never be closed" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].session_id, "sess-b"); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn a_dead_owner_scope_is_recovered_while_a_live_owner_sibling_survives_untouched() { - let git_dir = temp_git_dir("d10-dead-live-sibling-isolation"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start (owner will die)"); - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect("B's Start (owner stays live)"); - - let state = read_state(&git_dir).expect("state readable"); - let scope_a = attempt_owned_by(&state, "sess-a").scope_id; - let scope_b = attempt_owned_by(&state, "sess-b").scope_id; - state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-c", "call-c"), - ) - .expect("C's Start must recover only A"); - - assert_eq!( - seam.operations(), - vec!["start", "start", "flush", "abandon", "flush", "start"], - "exactly one abandon must occur, and only for A's own positively dead owner" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 2); - assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); - let b = attempt_owned_by(&state, "sess-b"); - assert_eq!(b.scope_id, scope_b); - assert_eq!( - b.phase, - AttemptPhase::PendingStart, - "B must survive reconciliation exactly as it was, untouched" - ); - assert_eq!( - attempt_owned_by(&state, "sess-c").phase, - AttemptPhase::PendingStart - ); - - cleanup(&git_dir); - } - - #[test] - fn multiple_dead_owner_scopes_are_retired_in_one_recovery_generation_while_a_live_sibling_survives( - ) { - let git_dir = temp_git_dir("d10-multiple-dead-owner-scopes"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start"); - drive( - &git_dir, - &seam, - &tool_result_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's tool_result marks Executed"); - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect("B's Start"); - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-q", "call-q"), - ) - .expect("Q's Start (owner stays live)"); - - let state = read_state(&git_dir).expect("state readable"); - let scope_a = attempt_owned_by(&state, "sess-a").scope_id; - let scope_b = attempt_owned_by(&state, "sess-b").scope_id; - let scope_q = attempt_owned_by(&state, "sess-q").scope_id; - let dead_owner = dead_process_owner(); - state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_owner); - state::set_attempt_owner_for_tests(&git_dir, &scope_b, dead_owner); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-c", "call-c"), - ) - .expect("C's Start must recover both A and B, grouped into one recovery generation"); - - assert_eq!( - seam.operations(), - vec!["start", "start", "start", "flush", "abandon", "abandon", "flush", "start"], - "a single flush/abandon.../flush recovery generation must retire every \ - independently-proven-dead scope owned by the same dead process together" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 2); - assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); - assert!(!state.attempts.iter().any(|a| a.scope_id == scope_b)); - assert_eq!(attempt_owned_by(&state, "sess-q").scope_id, scope_q); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn an_owner_that_cannot_be_positively_proven_dead_is_never_abandoned_by_an_unrelated_start() { - let git_dir = temp_git_dir("d10-uncertain-owner-preserved"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start"); - let scope_a = - attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; - state::set_attempt_owner_for_tests( - &git_dir, - &scope_a, - super::process_owner::ProcessOwner { - pid: std::process::id().cast_signed(), - instance_token: None, - }, - ); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-c", "call-c"), - ) - .expect( - "C's Start must proceed without touching A, whose owner cannot be positively \ - proven dead", - ); - - assert_eq!( - seam.operations(), - vec!["start", "start"], - "a live pid with no instance-token evidence must never be converted into proof of \ - death: uncertain identity is conservatively treated as alive" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 2); - assert!(state - .attempts - .iter() - .all(|attempt| attempt.phase == AttemptPhase::PendingStart)); - assert!(state.attempts.iter().any(|a| a.scope_id == scope_a)); - - cleanup(&git_dir); - } - - #[test] - fn an_interrupted_stale_owner_recovery_remains_pending_and_denies_the_triggering_start_until_resumed( - ) { - let git_dir = temp_git_dir("d10-interrupted-stale-recovery"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start"); - let scope_a = - attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; - state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); - - let crashing = RecordingSeam::failing_once_on(&["abandon"]); - let error = drive( - &git_dir, - &crashing, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect_err( - "a Start that triggers a stale-owner recovery which fails mid-way must not commit", - ); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.recovery, RecoveryState::Pending { generation: 1 }); - assert!( - !state.attempts.iter().any(|a| a.session_id == "sess-b"), - "B must never be admitted while A's stale-owner recovery is still pending" - ); - assert_eq!( - attempt_owned_by(&state, "sess-a").phase, - AttemptPhase::PendingAbandon - ); - - drive( - &git_dir, - &crashing, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect( - "the next boundary-lock acquisition must resume and complete the pending recovery, \ - and only then admit B", - ); - - let state = read_state(&git_dir).expect("state readable"); - assert!(state.recovery.is_clear()); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].session_id, "sess-b"); - assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); - - cleanup(&git_dir); - } - - #[test] - fn duplicate_tool_result_after_close_is_a_safe_no_op() { - let git_dir = temp_git_dir("duplicate-tool-result-after-close"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("Close"); - - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")) - .expect("a late duplicate tool_result after Close must be a safe no-op"); - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) - .expect("a late duplicate tool_execution_end after Close must be a safe no-op"); - - assert_eq!( - seam.operations(), - vec!["start", "close"], - "a resurrected attempt must never re-enter the runtime seam after its own Close" - ); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn duplicate_tool_execution_end_after_abandon_is_a_safe_no_op() { - let git_dir = temp_git_dir("duplicate-terminal-after-abandon"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("D7 abandon"); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) - .expect("a late duplicate terminal event after abandon must be a safe no-op"); - - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush"], - "a duplicate terminal delivery for an already-abandoned attempt must never issue a \ - second abandon" - ); - - cleanup(&git_dir); - } - - #[test] - fn abandoning_one_sibling_never_touches_a_concurrent_sibling_in_the_same_session() { - let git_dir = temp_git_dir("sibling-abandon-isolation"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_a")).expect("A Start"); - drive(&git_dir, &seam, &tool_call_event("bash", "call_b")).expect("B Start"); - drive(&git_dir, &seam, &tool_result_event("bash", "call_b")).expect("B tool_result"); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_a")) - .expect("A's terminal event with no tool_result must abandon only A"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!( - state.attempts.len(), - 1, - "abandoning A must never remove or block sibling B" - ); - assert_eq!(state.attempts[0].tool_call_id, "call_b"); - assert_eq!(state.attempts[0].phase, AttemptPhase::Executed); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_b")) - .expect("B must still close normally after A's abandonment and recovery"); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert_eq!( - seam.operations(), - vec!["start", "start", "flush", "abandon", "flush", "close"] - ); - - cleanup(&git_dir); - } - - #[test] - fn a_crash_mid_abandon_loop_is_resumed_and_completed_on_the_next_boundary_lock_acquisition() { - let git_dir = temp_git_dir("crash-mid-abandon-loop"); - let crashing = RecordingSeam::failing_once_on(&["abandon"]); - - drive(&git_dir, &crashing, &tool_call_event("bash", "call_1")).expect("Start"); - drive( - &git_dir, - &crashing, - &tool_execution_end_event("bash", "call_1"), - ) - .expect( - "a transient abandon failure mid-recovery must leave recovery Pending, not surface \ - an error, simulating a crash between marking PendingAbandon and completing the \ - flush/abandon/flush sequence", - ); - - assert_eq!( - read_state(&git_dir).expect("state readable").recovery, - RecoveryState::Pending { generation: 1 }, - "the interrupted abandon loop must leave recovery durably Pending for the next \ - boundary-lock acquisition to resume, never Clear and never lost" - ); - assert_eq!( - read_state(&git_dir) - .expect("state readable") - .attempts - .first() - .expect("the doomed attempt must still be recorded") - .phase, - AttemptPhase::PendingAbandon - ); - - drive(&git_dir, &crashing, &tool_call_event("bash", "call_2")) - .expect("recovery must self-heal and complete on the very next invocation"); - - assert!(read_state(&git_dir) - .expect("state readable") - .recovery - .is_clear()); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].tool_call_id, "call_2"); - - cleanup(&git_dir); - } - - #[test] - fn a_reused_tool_call_id_after_terminal_cleanup_gets_a_distinct_scope_id() { - let git_dir = temp_git_dir("terminal-scope-id-non-reuse"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("first tool_result"); - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("first Close"); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")) - .expect("reused toolCallId Start"); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].attempt_seq, 2); - - cleanup(&git_dir); - } -} +mod tests; #[cfg(test)] -mod runtime_seam_tests { - use std::fs; - use std::path::PathBuf; - use std::process::Command; - - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - - use super::*; - - fn git(dir: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - struct PiRepo { - _temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - } - - impl PiRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-pi-mutation-scope-seam-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - _temp: temp, - root, - state_root, - } - } - - fn cwd(&self) -> String { - self.root.to_string_lossy().into_owned() - } - - fn drive(&self, payload: &str) -> Result { - run_pi_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) - } - - fn write(&self, name: &str, contents: &str) { - fs::write(self.root.join(name), contents).expect("write should succeed"); - } - - fn db(&self) -> RepositoryAgentTraceDb { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &self.root, - &self.state_root, - "Pi mutation-scope seam test assertions", - ) - .expect("assertion DB should open") - } - - fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { - self.db() - .query_map( - "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", - (scope_id,), - |row| { - let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; - let status = row.get::(1).map_err(anyhow::Error::from)?; - Ok((actor_kind, status)) - }, - ) - .expect("scope query should succeed") - .into_iter() - .next() - } - - fn scope_provenance(&self, scope_id: &str) -> Option<(String, Option)> { - self.db() - .query_map( - "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ - WHERE scope_id = ?1", - (scope_id,), - |row| { - let session_id = row.get::(0).map_err(anyhow::Error::from)?; - let model_id = row.get::>(1).map_err(anyhow::Error::from)?; - Ok((session_id, model_id)) - }, - ) - .expect("scope-provenance query should succeed") - .into_iter() - .next() - } - - fn mutation_events(&self) -> Vec<(String, Option)> { - self.db() - .query_map( - "SELECT attribution_kind, attribution_scope_id \ - FROM mutation_trace_events ORDER BY revision", - (), - |row| { - let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id)) - }, - ) - .expect("mutation-events query should succeed") - } - } - - fn tool_call(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolCall", - "session_id": "01a091f4-seam-session", - "tool_call_id": tool_call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - "model": "openai-codex/gpt-5.5", - }) - .to_string() - } - - fn tool_result(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolResult", - "session_id": "01a091f4-seam-session", - "tool_call_id": tool_call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - }) - .to_string() - } - - fn tool_execution_end(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecutionEnd", - "session_id": "01a091f4-seam-session", - "tool_call_id": tool_call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - }) - .to_string() - } - - #[test] - fn a_write_start_result_close_lands_a_real_ai_exclusive_event_with_pi_provenance() { - let repo = PiRepo::new("real-lifecycle"); - let key = AttemptKey { - session_id: "01a091f4-seam-session".to_string(), - tool_call_id: "call_write".to_string(), - }; - let scope_id = format_pi_scope_id(&key, 1); - - repo.drive(&tool_call(&repo, "write", "call_write")) - .expect("Start should reach the real runtime"); - assert_eq!( - repo.scope_status(&scope_id), - Some(("pi".to_string(), "active".to_string())) - ); - assert_eq!( - repo.scope_provenance(&scope_id), - Some(( - "pi_01a091f4-seam-session".to_string(), - Some("openai-codex/gpt-5.5".to_string()) - )) - ); - - repo.write("file.txt", "one\ntwo\n"); - repo.drive(&tool_result(&repo, "write", "call_write")) - .expect("tool_result should mark Executed"); - - repo.drive(&tool_execution_end(&repo, "write", "call_write")) - .expect("Close should reach the real runtime"); - - assert_eq!( - repo.scope_status(&scope_id), - Some(("pi".to_string(), "closed".to_string())) - ); - assert_eq!( - repo.mutation_events(), - vec![("ai_exclusive".to_string(), Some(scope_id))] - ); - assert!( - state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) - .expect("state readable") - .attempts - .is_empty() - ); - } +mod lifecycle_tests; - #[test] - fn a_start_followed_by_no_execution_abandons_through_the_real_runtime() { - let repo = PiRepo::new("real-abandon"); - let key = AttemptKey { - session_id: "01a091f4-seam-session".to_string(), - tool_call_id: "call_blocked".to_string(), - }; - let scope_id = format_pi_scope_id(&key, 1); - - repo.drive(&tool_call(&repo, "bash", "call_blocked")) - .expect("Start should reach the real runtime"); - - repo.drive(&tool_execution_end(&repo, "bash", "call_blocked")) - .expect("the terminal event must resolve via abandon, not surface an error"); - - assert_eq!( - repo.scope_status(&scope_id), - Some(("pi".to_string(), "abandoned".to_string())) - ); - assert!(repo.mutation_events().is_empty()); - } -} +#[cfg(test)] +mod runtime_seam_tests; #[cfg(all(unix, test))] -mod guard_reconciliation_tests { - use std::fs; - use std::path::PathBuf; - use std::process::Command; - use std::sync::mpsc; - use std::time::Duration; - - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - use crate::services::mutation_trace::runtime::{ - coordinate, run_external_mutation_guard, GuardRequest, RuntimeBoundary, - }; - use crate::services::mutation_trace::types::{ActorKind, EventId, ScopeId}; - - use super::*; - - fn git(dir: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - struct GuardRepo { - _temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - } - - impl GuardRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-pi-guard-reconciliation-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - _temp: temp, - root, - state_root, - } - } - - fn drive(&self, payload: &str) -> Result { - run_pi_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) - } - - fn open_db(&self) -> anyhow::Result { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &self.root, - &self.state_root, - "Pi guard-reconciliation test assertions", - ) - } - - fn db(&self) -> RepositoryAgentTraceDb { - self.open_db().expect("assertion DB should open") - } - - fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { - self.db() - .query_map( - "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", - (scope_id,), - |row| { - let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; - let status = row.get::(1).map_err(anyhow::Error::from)?; - Ok((actor_kind, status)) - }, - ) - .expect("scope query should succeed") - .into_iter() - .next() - } - - fn write(&self, name: &str, contents: &str) { - fs::write(self.root.join(name), contents).expect("write should succeed"); - } - - fn mutation_events(&self) -> Vec<(String, Option)> { - self.db() - .query_map( - "SELECT attribution_kind, attribution_scope_id \ - FROM mutation_trace_events ORDER BY revision", - (), - |row| { - let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id)) - }, - ) - .expect("mutation-events query should succeed") - } - } - - fn tool_call(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { - json!({ - "hook_event_name": "ToolCall", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": repo.root.to_string_lossy(), - "tool_name": "bash", - "model": "openai-codex/gpt-5.5", - }) - .to_string() - } - - fn tool_execution_end(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecutionEnd", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": repo.root.to_string_lossy(), - "tool_name": "bash", - }) - .to_string() - } - - fn tool_result(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { - json!({ - "hook_event_name": "ToolResult", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": repo.root.to_string_lossy(), - "tool_name": "bash", - }) - .to_string() - } - - #[test] - fn a_guard_triggered_worktree_abandonment_reconciles_with_the_pi_adapters_own_state() { - let repo = GuardRepo::new("reconcile"); - let session = "01a091f4-guard-session"; - let key_a = AttemptKey { - session_id: session.to_string(), - tool_call_id: "call_a".to_string(), - }; - let key_b = AttemptKey { - session_id: session.to_string(), - tool_call_id: "call_b".to_string(), - }; - let scope_a = format_pi_scope_id(&key_a, 1); - let scope_b = format_pi_scope_id(&key_b, 2); - - repo.drive(&tool_call(&repo, "call_a", session)) - .expect("A's Start should reach the real runtime"); - repo.drive(&tool_call(&repo, "call_b", session)) - .expect("B's Start should reach the real runtime"); - assert_eq!( - repo.scope_status(&scope_a), - Some(("pi".to_string(), "active".to_string())) - ); - assert_eq!( - repo.scope_status(&scope_b), - Some(("pi".to_string(), "active".to_string())) - ); - - let (_cancel_tx, cancel_rx) = mpsc::channel(); - let root = repo.root.clone(); - let outcome = run_external_mutation_guard( - &root, - &GuardRequest { - command: "printf changed >> file.txt".to_string(), - cwd: None, - env: Vec::new(), - }, - || repo.open_db(), - |_event| {}, - cancel_rx, - ) - .expect("the guard should finish successfully"); - assert_eq!(outcome.exit_code, Some(0)); - assert!(!outcome.marker_clear_failed); - - assert_eq!( - repo.scope_status(&scope_a), - Some(("pi".to_string(), "abandoned".to_string())), - "the guard's finish-time forced recovery must abandon every scope live during the \ - guarded interval, regardless of which harness's boundary happened to observe \ - user_bash" - ); - assert_eq!( - repo.scope_status(&scope_b), - Some(("pi".to_string(), "abandoned".to_string())) - ); - - repo.drive(&tool_execution_end(&repo, "call_a", session)) - .expect( - "the adapter's next interaction for an already-abandoned scope must reconcile \ - safely (falling back through the existing Close-failure-to-abandon path) rather \ - than erroring or resurrecting the scope", - ); - repo.drive(&tool_execution_end(&repo, "call_b", session)) - .expect("the same reconciliation must hold for every sibling abandoned by the guard"); - - assert!( - state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) - .expect("state readable") - .attempts - .is_empty(), - "the Pi adapter's own durable local attempt state must converge to empty once it \ - observes the terminal event for a scope the generic runtime already abandoned out \ - from under it" - ); - } - - #[test] - fn a_guard_abandons_a_live_pi_scope_alongside_a_live_scope_from_another_harness() { - let repo = GuardRepo::new("cross-harness"); - let key = AttemptKey { - session_id: "01a091f4-guard-cross-session".to_string(), - tool_call_id: "call_pi".to_string(), - }; - let pi_scope_id = format_pi_scope_id(&key, 1); - let claude_scope = ScopeId("claude-scope-under-guard".to_string()); - - repo.drive(&tool_call(&repo, "call_pi", "01a091f4-guard-cross-session")) - .expect("Pi's Start should reach the real runtime"); - coordinate( - &repo.root, - &RuntimeBoundary::Start { - scope: claude_scope.clone(), - event: EventId("claude-evt-start".to_string()), - actor_kind: ActorKind::ClaudeCode, - provenance: None, - }, - || repo.open_db(), - ) - .expect("Claude's Start should reach the real runtime"); - - assert_eq!( - repo.scope_status(&pi_scope_id), - Some(("pi".to_string(), "active".to_string())) - ); - assert_eq!( - repo.scope_status(&claude_scope.0), - Some(("claude_code".to_string(), "active".to_string())) - ); - - let (_cancel_tx, cancel_rx) = mpsc::channel(); - let root = repo.root.clone(); - run_external_mutation_guard( - &root, - &GuardRequest { - command: "printf changed >> file.txt".to_string(), - cwd: None, - env: Vec::new(), - }, - || repo.open_db(), - |_event| {}, - cancel_rx, - ) - .expect("the guard should finish successfully"); - - assert_eq!( - repo.scope_status(&pi_scope_id), - Some(("pi".to_string(), "abandoned".to_string())), - "the guard's forced recovery must abandon the Pi scope even though a different \ - harness's boundary is the one that happened to observe user_bash" - ); - assert_eq!( - repo.scope_status(&claude_scope.0), - Some(("claude_code".to_string(), "abandoned".to_string())), - "the guard's forced recovery must abandon every live scope on the worktree \ - regardless of which harness owns it" - ); - - repo.drive(&tool_execution_end( - &repo, - "call_pi", - "01a091f4-guard-cross-session", - )) - .expect( - "the Pi adapter must still reconcile cleanly with its own scope even when a \ - sibling scope belonging to a different harness was abandoned by the same guard", - ); - assert!( - state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) - .expect("state readable") - .attempts - .is_empty() - ); - } - - #[test] - fn a_foreign_pi_start_racing_an_active_guard_fails_closed_touching_no_state_then_succeeds_on_retry( - ) { - let repo = GuardRepo::new("race"); - let ready = repo.root.join("ready"); - let release = repo.root.join("release"); - let command = format!( - "touch '{}'; while [ ! -f '{}' ]; do sleep 0.02; done", - ready.display(), - release.display(), - ); - - let root = repo.root.clone(); - let state_root = repo.state_root.clone(); - let guard_thread = std::thread::spawn(move || { - let (_cancel_tx, cancel_rx) = mpsc::channel(); - run_external_mutation_guard( - &root, - &GuardRequest { - command, - cwd: None, - env: Vec::new(), - }, - || { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &root, - &state_root, - "guard race test", - ) - }, - |_event| {}, - cancel_rx, - ) - }); - - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while !ready.exists() { - assert!( - std::time::Instant::now() < deadline, - "the guarded shell never reported ready" - ); - std::thread::sleep(Duration::from_millis(20)); - } - - let key = AttemptKey { - session_id: "01a091f4-guard-race-session".to_string(), - tool_call_id: "call_race".to_string(), - }; - let scope_id = format_pi_scope_id(&key, 1); - - let error = repo - .drive(&tool_call( - &repo, - "call_race", - "01a091f4-guard-race-session", - )) - .expect_err( - "a Pi Start racing an active external-mutation guard must block then fail \ - closed with CoordinateError::LockAcquisition, never proceed", - ); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - assert!( - repo.scope_status(&scope_id).is_none(), - "a boundary that fails closed on lock acquisition must touch no protocol state" - ); - - fs::write(&release, "go").expect("release file should write"); - let outcome = guard_thread - .join() - .expect("guard thread should not panic") - .expect("the guard should finish successfully once released"); - assert_eq!(outcome.exit_code, Some(0)); - - repo.drive(&tool_call( - &repo, - "call_race", - "01a091f4-guard-race-session", - )) - .expect("retrying the same Start after the guard finishes must succeed normally"); - assert_eq!( - repo.scope_status(&scope_id), - Some(("pi".to_string(), "active".to_string())) - ); - } - - #[test] - #[allow(clippy::too_many_lines)] - fn a_foreign_harnesss_boundary_racing_the_guard_fails_closed_then_succeeds_normally_on_retry() { - let repo = GuardRepo::new("cross-harness-race"); - let claude_scope = ScopeId("claude-scope-racing-guard".to_string()); - let key = AttemptKey { - session_id: "01a091f4-guard-cross-race-session".to_string(), - tool_call_id: "call_pi_race".to_string(), - }; - let pi_scope_id = format_pi_scope_id(&key, 1); - - repo.drive(&tool_call( - &repo, - "call_pi_race", - "01a091f4-guard-cross-race-session", - )) - .expect("Pi's Start should reach the real runtime"); - coordinate( - &repo.root, - &RuntimeBoundary::Start { - scope: claude_scope.clone(), - event: EventId("claude-evt-race-start".to_string()), - actor_kind: ActorKind::ClaudeCode, - provenance: None, - }, - || repo.open_db(), - ) - .expect("Claude's Start should reach the real runtime"); - - let ready = repo.root.join("ready"); - let release = repo.root.join("release"); - let command = format!( - "touch '{}'; printf w1 >> file.txt; while [ ! -f '{}' ]; do sleep 0.02; done; printf w2 >> file.txt", - ready.display(), - release.display(), - ); - - let root = repo.root.clone(); - let state_root = repo.state_root.clone(); - let guard_thread = std::thread::spawn(move || { - let (_cancel_tx, cancel_rx) = mpsc::channel(); - run_external_mutation_guard( - &root, - &GuardRequest { - command, - cwd: None, - env: Vec::new(), - }, - || { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &root, - &state_root, - "guard cross-harness race test", - ) - }, - |_event| {}, - cancel_rx, - ) - }); - - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while !ready.exists() { - assert!( - std::time::Instant::now() < deadline, - "the guarded shell never reported ready" - ); - std::thread::sleep(Duration::from_millis(20)); - } - - let close_while_active = coordinate( - &repo.root, - &RuntimeBoundary::Close { - scope: claude_scope.clone(), - event: EventId("claude-evt-race-close".to_string()), - actor_kind: ActorKind::ClaudeCode, - }, - || repo.open_db(), - ); - assert!( - matches!( - close_while_active, - Err(crate::services::mutation_trace::runtime::CoordinateError::LockAcquisition(_)) - ), - "a foreign harness's boundary racing an active external-mutation guard must fail \ - closed with LockAcquisition, never proceed while the guard still holds the \ - worktree: {close_while_active:?}" - ); - assert!( - repo.mutation_events().is_empty(), - "a boundary that fails closed on lock acquisition must touch no protocol state" - ); - - fs::write(&release, "go").expect("release file should write"); - let outcome = guard_thread - .join() - .expect("guard thread should not panic") - .expect("the guard should finish successfully once released"); - assert_eq!(outcome.exit_code, Some(0)); - - assert_eq!( - repo.scope_status(&pi_scope_id), - Some(("pi".to_string(), "abandoned".to_string())) - ); - assert_eq!( - repo.scope_status(&claude_scope.0), - Some(("claude_code".to_string(), "abandoned".to_string())) - ); - - coordinate( - &repo.root, - &RuntimeBoundary::Close { - scope: claude_scope.clone(), - event: EventId("claude-evt-race-close-retry".to_string()), - actor_kind: ActorKind::ClaudeCode, - }, - || repo.open_db(), - ) - .expect( - "Claude's deferred boundary must succeed normally once retried against the \ - recovered worktree, rather than continuing to fail closed", - ); - repo.drive(&tool_execution_end( - &repo, - "call_pi_race", - "01a091f4-guard-cross-race-session", - )) - .expect("the Pi adapter must reconcile its own already-abandoned scope cleanly too"); - - assert!( - repo.mutation_events().is_empty(), - "both human writes made under the guard must remain excluded from positive AI \ - attribution for the Pi scope and for the racing foreign-harness scope alike" - ); - } - - #[test] - fn a_guard_triggered_abandonment_does_not_poison_the_checkout_for_a_fresh_pi_scope() { - let repo = GuardRepo::new("post-recovery-fresh-start"); - let session = "01a091f4-guard-fresh-session"; - let key_a = AttemptKey { - session_id: session.to_string(), - tool_call_id: "call_doomed".to_string(), - }; - let scope_a = format_pi_scope_id(&key_a, 1); - - repo.drive(&tool_call(&repo, "call_doomed", session)) - .expect("A's Start should reach the real runtime"); - assert_eq!( - repo.scope_status(&scope_a), - Some(("pi".to_string(), "active".to_string())) - ); - - let (_cancel_tx, cancel_rx) = mpsc::channel(); - let root = repo.root.clone(); - let outcome = run_external_mutation_guard( - &root, - &GuardRequest { - command: "printf changed >> file.txt".to_string(), - cwd: None, - env: Vec::new(), - }, - || repo.open_db(), - |_event| {}, - cancel_rx, - ) - .expect("the guard should finish successfully"); - assert_eq!(outcome.exit_code, Some(0)); - - assert_eq!( - repo.scope_status(&scope_a), - Some(("pi".to_string(), "abandoned".to_string())) - ); - repo.drive(&tool_execution_end(&repo, "call_doomed", session)) - .expect("the adapter must reconcile the guard-abandoned scope cleanly"); - - let key_c = AttemptKey { - session_id: session.to_string(), - tool_call_id: "call_clean".to_string(), - }; - let scope_c = format_pi_scope_id(&key_c, 2); - - repo.drive(&tool_call(&repo, "call_clean", session)) - .expect("a fresh Start on the same worktree after recovery must succeed normally"); - assert_eq!( - repo.scope_status(&scope_c), - Some(("pi".to_string(), "active".to_string())) - ); - - repo.write("file.txt", "one\nchanged\nclean\n"); - repo.drive(&tool_result(&repo, "call_clean", session)) - .expect("tool_result should mark Executed"); - repo.drive(&tool_execution_end(&repo, "call_clean", session)) - .expect("Close should reach the real runtime"); - - assert_eq!( - repo.scope_status(&scope_c), - Some(("pi".to_string(), "closed".to_string())) - ); - assert_eq!( - repo.mutation_events(), - vec![("ai_exclusive".to_string(), Some(scope_c))], - "a clean Pi mutation after guard-triggered recovery must still reach AiExclusive; \ - recovery must not permanently poison the checkout for later, uninterfered-with work" - ); - } -} +mod guard_reconciliation_tests; diff --git a/cli/src/services/hooks/pi_mutation_scope/runtime_seam_tests.rs b/cli/src/services/hooks/pi_mutation_scope/runtime_seam_tests.rs new file mode 100644 index 00000000..a35d6565 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/runtime_seam_tests.rs @@ -0,0 +1,240 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, +}; + +use super::*; + +fn git(dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +struct PiRepo { + _temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, +} + +impl PiRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-pi-mutation-scope-seam-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + _temp: temp, + root, + state_root, + } + } + + fn cwd(&self) -> String { + self.root.to_string_lossy().into_owned() + } + + fn drive(&self, payload: &str) -> Result { + run_pi_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn write(&self, name: &str, contents: &str) { + fs::write(self.root.join(name), contents).expect("write should succeed"); + } + + fn db(&self) -> RepositoryAgentTraceDb { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "Pi mutation-scope seam test assertions", + ) + .expect("assertion DB should open") + } + + fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { + self.db() + .query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn scope_provenance(&self, scope_id: &str) -> Option<(String, Option)> { + self.db() + .query_map( + "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ + WHERE scope_id = ?1", + (scope_id,), + |row| { + let session_id = row.get::(0).map_err(anyhow::Error::from)?; + let model_id = row.get::>(1).map_err(anyhow::Error::from)?; + Ok((session_id, model_id)) + }, + ) + .expect("scope-provenance query should succeed") + .into_iter() + .next() + } + + fn mutation_events(&self) -> Vec<(String, Option)> { + self.db() + .query_map( + "SELECT attribution_kind, attribution_scope_id \ + FROM mutation_trace_events ORDER BY revision", + (), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id)) + }, + ) + .expect("mutation-events query should succeed") + } +} + +fn tool_call(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolCall", + "session_id": "01a091f4-seam-session", + "tool_call_id": tool_call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + "model": "openai-codex/gpt-5.5", + }) + .to_string() +} + +fn tool_result(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolResult", + "session_id": "01a091f4-seam-session", + "tool_call_id": tool_call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + }) + .to_string() +} + +fn tool_execution_end(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecutionEnd", + "session_id": "01a091f4-seam-session", + "tool_call_id": tool_call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + }) + .to_string() +} + +#[test] +fn a_write_start_result_close_lands_a_real_ai_exclusive_event_with_pi_provenance() { + let repo = PiRepo::new("real-lifecycle"); + let key = AttemptKey { + session_id: "01a091f4-seam-session".to_string(), + tool_call_id: "call_write".to_string(), + }; + let scope_id = format_pi_scope_id(&key, 1); + + repo.drive(&tool_call(&repo, "write", "call_write")) + .expect("Start should reach the real runtime"); + assert_eq!( + repo.scope_status(&scope_id), + Some(("pi".to_string(), "active".to_string())) + ); + assert_eq!( + repo.scope_provenance(&scope_id), + Some(( + "pi_01a091f4-seam-session".to_string(), + Some("openai-codex/gpt-5.5".to_string()) + )) + ); + + repo.write("file.txt", "one\ntwo\n"); + repo.drive(&tool_result(&repo, "write", "call_write")) + .expect("tool_result should mark Executed"); + + repo.drive(&tool_execution_end(&repo, "write", "call_write")) + .expect("Close should reach the real runtime"); + + assert_eq!( + repo.scope_status(&scope_id), + Some(("pi".to_string(), "closed".to_string())) + ); + assert_eq!( + repo.mutation_events(), + vec![("ai_exclusive".to_string(), Some(scope_id))] + ); + assert!( + state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) + .expect("state readable") + .attempts + .is_empty() + ); +} + +#[test] +fn a_start_followed_by_no_execution_abandons_through_the_real_runtime() { + let repo = PiRepo::new("real-abandon"); + let key = AttemptKey { + session_id: "01a091f4-seam-session".to_string(), + tool_call_id: "call_blocked".to_string(), + }; + let scope_id = format_pi_scope_id(&key, 1); + + repo.drive(&tool_call(&repo, "bash", "call_blocked")) + .expect("Start should reach the real runtime"); + + repo.drive(&tool_execution_end(&repo, "bash", "call_blocked")) + .expect("the terminal event must resolve via abandon, not surface an error"); + + assert_eq!( + repo.scope_status(&scope_id), + Some(("pi".to_string(), "abandoned".to_string())) + ); + assert!(repo.mutation_events().is_empty()); +} diff --git a/cli/src/services/hooks/pi_mutation_scope/tests.rs b/cli/src/services/hooks/pi_mutation_scope/tests.rs new file mode 100644 index 00000000..a9ba4162 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/tests.rs @@ -0,0 +1,332 @@ +use super::*; + +fn tool_event_json(hook_event_name: &str, overrides: &[(&str, Value)]) -> String { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(hook_event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("01a091f4-session".to_string()), + ); + object.insert( + TOOL_CALL_ID_FIELD.to_string(), + Value::String("call_1|fc_1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("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/repo/checkout".to_string()), + ); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("write".to_string()), + ); + for (field, value) in overrides { + object.insert((*field).to_string(), value.clone()); + } + Value::Object(object).to_string() +} + +fn key(session_id: &str, tool_call_id: &str) -> AttemptKey { + AttemptKey { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + } +} + +fn tool_call(payload: &str) -> PiToolCall { + match parse_pi_hook_event(payload).expect("valid ToolCall parses") { + PiHookEvent::Call(call) => call, + other => panic!("expected ToolCall, got {other:?}"), + } +} + +#[test] +fn empty_payload_is_rejected() { + let error = parse_pi_hook_event(" ").unwrap_err().to_string(); + assert_eq!( + error, + "Invalid Pi hook event payload from STDIN: expected a JSON object, got an empty payload." + ); +} + +#[test] +fn non_object_json_is_rejected() { + for payload in ["[]", "\"ToolCall\"", "42", "null"] { + let error = parse_pi_hook_event(payload).unwrap_err().to_string(); + assert!( + error.contains("expected a JSON object"), + "payload {payload:?} produced {error:?}" + ); + } +} + +#[test] +fn invalid_json_is_rejected() { + let error = parse_pi_hook_event("{not json").unwrap_err().to_string(); + assert!( + error.contains("Invalid Pi hook event payload from STDIN: expected valid JSON"), + "{error:?}" + ); +} + +#[test] +fn unsupported_hook_event_name_is_rejected() { + for name in ["PreToolUse", "tool_call", "chat.params", ""] { + let payload = tool_event_json(name, &[]); + let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("hook_event_name"), + "name {name:?} produced {error:?}" + ); + } +} + +#[test] +fn missing_required_fields_are_rejected_without_fabricating_identity() { + for field in [ + SESSION_ID_FIELD, + TOOL_CALL_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + ] { + let mut object: Map = + serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_CALL, &[])).unwrap(); + object.remove(field); + let payload = Value::Object(object).to_string(); + + let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("'{field}'")), + "missing {field} produced {error:?}" + ); + } +} + +#[test] +fn blank_required_fields_are_rejected() { + for field in [ + SESSION_ID_FIELD, + TOOL_CALL_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + ] { + let payload = tool_event_json( + HOOK_EVENT_TOOL_CALL, + &[(field, Value::String(" ".to_string()))], + ); + let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("field '{field}' must be a non-blank string")), + "blank {field} produced {error:?}" + ); + } +} + +#[test] +fn wrong_typed_fields_are_rejected() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_CALL, + &[(TOOL_CALL_ID_FIELD, Value::Bool(true))], + ); + let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'tool_call_id' must be a string"), + "{error:?}" + ); +} + +#[test] +fn wrong_typed_optional_model_is_rejected() { + let payload = tool_event_json(HOOK_EVENT_TOOL_CALL, &[(MODEL_FIELD, Value::Bool(false))]); + let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'model' must be null, absent, or a non-blank string"), + "{error:?}" + ); +} + +#[test] +fn tool_call_parses_identity_and_model() { + let call = tool_call(&tool_event_json( + HOOK_EVENT_TOOL_CALL, + &[ + (TOOL_NAME_FIELD, Value::String("edit".to_string())), + ( + MODEL_FIELD, + Value::String("openai-codex/gpt-5.5".to_string()), + ), + ], + )); + assert_eq!(call.identity.session_id, "01a091f4-session"); + assert_eq!(call.identity.tool_call_id, "call_1|fc_1"); + assert_eq!(call.identity.tool_name, "edit"); + assert_eq!(call.model.as_deref(), Some("openai-codex/gpt-5.5")); + assert_eq!( + call.identity.classification(), + ToolClassification::TrackedMutation + ); +} + +#[test] +fn tool_call_model_is_optional() { + let call = tool_call(&tool_event_json(HOOK_EVENT_TOOL_CALL, &[])); + assert_eq!(call.model, None); +} + +#[test] +fn tool_result_and_tool_execution_end_parse_minimal_identity() { + for name in [ + HOOK_EVENT_TOOL_RESULT, + HOOK_EVENT_TOOL_EXECUTION_END, + HOOK_EVENT_TOOL_EXECUTION_ABANDON, + ] { + let event = parse_pi_hook_event(&tool_event_json(name, &[])).unwrap(); + let identity = match event { + PiHookEvent::Executed(identity) + | PiHookEvent::ExecutionEnd(identity) + | PiHookEvent::ExecutionAbandon(identity) => identity, + other => panic!("expected a minimal-identity event, got {other:?}"), + }; + assert_eq!( + identity.attempt_key(), + key("01a091f4-session", "call_1|fc_1") + ); + } +} + +#[test] +fn tool_execution_start_parses_and_is_never_evidence() { + let event = + parse_pi_hook_event(&tool_event_json(HOOK_EVENT_TOOL_EXECUTION_START, &[])).unwrap(); + let PiHookEvent::ExecutionStart(identity) = event else { + panic!("expected ToolExecutionStart"); + }; + assert_eq!(identity.tool_call_id, "call_1|fc_1"); +} + +#[test] +fn classification_table() { + let cases: &[(&str, ToolClassification)] = &[ + ("bash", ToolClassification::TrackedMutation), + ("edit", ToolClassification::TrackedMutation), + ("write", ToolClassification::TrackedMutation), + ("read", ToolClassification::Untracked), + ("grep", ToolClassification::Untracked), + ("find", ToolClassification::Untracked), + ("ls", ToolClassification::Untracked), + ("probe_mutate", ToolClassification::Untracked), + ("Bash", ToolClassification::Untracked), + ("some_future_pi_builtin", ToolClassification::Untracked), + ("", ToolClassification::Untracked), + ]; + for (tool_name, expected) in cases { + assert_eq!( + classify_tool(tool_name), + *expected, + "classify_tool({tool_name:?})" + ); + } +} + +#[test] +fn scope_id_embeds_attempt_seq_and_is_length_prefixed() { + let k = key("01a091f4-session", "call_1|fc_1"); + let scope_id = format_pi_scope_id(&k, 1); + assert_eq!( + scope_id, + "pi-tool-v1|n=1|s=16:01a091f4-session|c=11:call_1|fc_1" + ); + assert_ne!(format_pi_scope_id(&k, 1), format_pi_scope_id(&k, 2)); + assert_eq!( + pi_scope_start_event_id(&scope_id), + format!("{scope_id}|start") + ); + assert_eq!( + pi_scope_close_event_id(&scope_id), + format!("{scope_id}|close") + ); + assert_ne!( + pi_scope_start_event_id(&scope_id), + pi_scope_close_event_id(&scope_id) + ); +} + +#[test] +fn length_prefix_disambiguates_delimiter_collisions() { + let a = key("s|c=1:x", "y"); + let b = key("s", "1:x|y"); + assert_ne!(format_pi_scope_id(&a, 1), format_pi_scope_id(&b, 1)); +} + +#[test] +fn provenance_canonicalizes_the_session_and_normalizes_the_model() { + let provenance = pi_scope_provenance("01a091f4-session", Some("openai-codex/gpt-5.5")); + assert_eq!(provenance.session_id, "pi_01a091f4-session"); + assert_eq!(provenance.model_id.as_deref(), Some("openai-codex/gpt-5.5")); +} + +#[test] +fn provenance_keeps_an_already_prefixed_session_id() { + let provenance = pi_scope_provenance("pi_01a091f4-session", None); + assert_eq!(provenance.session_id, "pi_01a091f4-session"); +} + +#[test] +fn provenance_without_model_evidence_is_null() { + for model in [None, Some(""), Some(" ")] { + let provenance = pi_scope_provenance("01a091f4-session", model); + assert_eq!(provenance.model_id, None, "model {model:?}"); + } +} + +#[test] +fn run_from_payload_fails_closed_when_a_tracked_start_cannot_resolve_its_checkout() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_CALL, + &[( + CWD_FIELD, + Value::String("/nonexistent/sce/pi/checkout".to_string()), + )], + ); + let error = run_pi_mutation_scope_from_payload(&payload, None) + .expect_err("a tracked Start that cannot resolve its checkout must fail closed"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE), "{error:?}"); +} + +#[test] +fn run_from_payload_is_neutral_for_untracked_events() { + for tool_name in ["read", "grep", "find", "ls", "probe_mutate"] { + let payload = tool_event_json( + HOOK_EVENT_TOOL_CALL, + &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], + ); + assert_eq!( + run_pi_mutation_scope_from_payload(&payload, None).unwrap(), + String::new() + ); + } +} + +#[test] +fn run_from_payload_surfaces_malformed_input() { + let error = run_pi_mutation_scope_from_payload("{bad", None) + .unwrap_err() + .to_string(); + assert!(error.contains("expected valid JSON"), "{error:?}"); +} + +#[test] +fn tool_execution_start_is_always_a_no_op_regardless_of_classification() { + for tool_name in ["bash", "read"] { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTION_START, + &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], + ); + assert_eq!( + run_pi_mutation_scope_from_payload(&payload, None).unwrap(), + String::new() + ); + } +} diff --git a/cli/src/services/hooks/tests.rs b/cli/src/services/hooks/tests.rs new file mode 100644 index 00000000..260d5887 --- /dev/null +++ b/cli/src/services/hooks/tests.rs @@ -0,0 +1,4035 @@ +use std::{ + cell::RefCell, + fs, + path::{Path, PathBuf}, + process::Command, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use super::*; +use crate::services::agent_trace_db::{ + ClaudeModelStateObservation, ObservationKind, ParsedDiffTracePatch, SkippedDiffTracePatch, +}; + +#[derive(Debug, Eq, PartialEq)] +struct CapturedPostCommitIntersectionInsert { + commit_id: String, + post_commit_time_ms: i64, + recent_window_cutoff_ms: i64, + recent_window_end_ms: i64, + loaded_diff_trace_count: i64, + skipped_diff_trace_count: i64, + intersection_patch: String, +} + +fn valid_patch_text(path: &str, content: &str) -> String { + format!( + "Index: {path}\n===================================================================\n--- {path}\n+++ {path}\n@@ -0,0 +1,1 @@\n+{content}\n" + ) +} + +fn valid_patch(path: &str, content: &str) -> ParsedPatch { + let patch_text = valid_patch_text(path, content); + + parse_patch_from_text(&patch_text, None).expect("test patch should parse") +} + +#[test] +fn conversation_trace_mixed_payload_maps_to_message_and_part_insert_inputs() { + let patch_text = valid_patch_text("src/lib.rs", "let answer = 42;"); + let question_text = serde_json::json!([ + { + "question": "Proceed?", + "answer": "Yes" + } + ]) + .to_string(); + let payload = serde_json::json!({ + "tool_name": "opencode", + "payloads": [ + { + "type": "message", + "session_id": "session-1", + "message_id": "message-1", + "role": "assistant", + "generated_at_unix_ms": 1_800_000_000_000_i64 + }, + { + "type": "message.part", + "session_id": "session-1", + "message_id": "message-1", + "part_type": "reasoning", + "text": "thinking through validation", + "generated_at_unix_ms": 1_800_000_000_001_i64 + }, + { + "type": "message.part", + "session_id": "session-1", + "message_id": "message-1", + "part_type": "patch", + "text": patch_text, + "generated_at_unix_ms": 1_800_000_000_002_i64 + }, + { + "type": "message.part", + "session_id": "session-1", + "message_id": "message-1", + "part_type": "question", + "text": question_text, + "generated_at_unix_ms": 1_800_000_000_003_i64 + } + ] + }); + + let parsed = parse_conversation_trace_payload(&payload.to_string()) + .expect("conversation-trace mixed payload should parse"); + + assert_eq!(parsed.attempted_count, 4); + assert!(parsed.skipped.is_empty()); + assert!(parsed.message_updated.skipped.is_empty()); + assert!(parsed.message_part_updated.skipped.is_empty()); + + assert_eq!(parsed.message_updated.inserts.len(), 1); + let message = &parsed.message_updated.inserts[0]; + assert_eq!(message.session_id, "oc_session-1"); + assert_eq!(message.message_id, "message-1"); + assert_eq!(message.role, MessageRole::Assistant); + assert_eq!(message.generated_at_unix_ms, 1_800_000_000_000_i64); + + assert_eq!(parsed.message_part_updated.inserts.len(), 3); + let reasoning_part = &parsed.message_part_updated.inserts[0]; + assert_eq!(reasoning_part.session_id, "oc_session-1"); + assert_eq!(reasoning_part.message_id, "message-1"); + assert_eq!(reasoning_part.part_type, PartType::Reasoning); + assert_eq!(reasoning_part.text, "thinking through validation"); + assert_eq!(reasoning_part.generated_at_unix_ms, 1_800_000_000_001_i64); + + let patch_part = &parsed.message_part_updated.inserts[1]; + assert_eq!(patch_part.session_id, "oc_session-1"); + assert_eq!(patch_part.message_id, "message-1"); + assert_eq!(patch_part.part_type, PartType::Patch); + assert_eq!( + patch_part.text, + serialize_to_json(&valid_patch("src/lib.rs", "let answer = 42;")) + .expect("test patch should serialize") + ); + assert_eq!(patch_part.generated_at_unix_ms, 1_800_000_000_002_i64); + + let question_part = &parsed.message_part_updated.inserts[2]; + assert_eq!(question_part.session_id, "oc_session-1"); + assert_eq!(question_part.message_id, "message-1"); + assert_eq!(question_part.part_type, PartType::Question); + assert_eq!(question_part.text, question_text); + assert_eq!(question_part.generated_at_unix_ms, 1_800_000_000_003_i64); +} + +#[test] +fn conversation_trace_mixed_payload_skips_malformed_sibling_items() { + let invalid_question_text = serde_json::json!({ + "question": "Proceed?", + "answer": "Yes" + }) + .to_string(); + let payload = serde_json::json!({ + "tool_name": "opencode", + "payloads": [ + { + "type": "message", + "session_id": "session-1", + "message_id": "message-1", + "role": "assistant", + "generated_at_unix_ms": 1_800_000_000_000_i64 + }, + { + "type": "message", + "session_id": "session-2", + "message_id": "message-2", + "role": "system", + "generated_at_unix_ms": 1_800_000_000_002_i64 + }, + { + "type": "message.part", + "session_id": "session-3", + "message_id": "message-3", + "part_type": "text", + "generated_at_unix_ms": 1_800_000_000_003_i64 + }, + { + "type": "message.part", + "session_id": "session-4", + "message_id": "message-4", + "part_type": "patch", + "text": "--- src/main.rs", + "generated_at_unix_ms": 1_800_000_000_004_i64 + }, + { + "type": "message.part", + "session_id": "session-5", + "message_id": "message-5", + "part_type": "question", + "text": invalid_question_text, + "generated_at_unix_ms": 1_800_000_000_005_i64 + }, + { + "type": "session.started", + "session_id": "session-6" + }, + 42, + { + "type": null, + "session_id": "session-7" + } + ] + }); + + let parsed = parse_conversation_trace_payload(&payload.to_string()) + .expect("conversation-trace mixed payload should parse with skipped items"); + + assert_eq!(parsed.attempted_count, 8); + assert_eq!(parsed.message_updated.inserts.len(), 1); + assert_eq!(parsed.message_updated.skipped.len(), 1); + assert_eq!(parsed.message_updated.skipped[0].index, 1); + assert!(parsed.message_updated.skipped[0] + .reason + .contains("field 'role'")); + assert_eq!(parsed.message_part_updated.inserts.len(), 0); + assert_eq!(parsed.message_part_updated.skipped.len(), 3); + assert_eq!(parsed.message_part_updated.skipped[0].index, 2); + assert!(parsed.message_part_updated.skipped[0] + .reason + .contains("missing required field 'text'")); + assert_eq!(parsed.message_part_updated.skipped[1].index, 3); + assert!(parsed.message_part_updated.skipped[1] + .reason + .contains("neither valid patch-JSON nor a valid patch")); + assert_eq!(parsed.message_part_updated.skipped[2].index, 4); + assert!(parsed.message_part_updated.skipped[2] + .reason + .contains("question part must be a JSON array")); + assert_eq!(parsed.skipped.len(), 3); + assert_eq!(parsed.skipped[0].index, 5); + assert!(parsed.skipped[0].reason.contains("field 'type'")); + assert_eq!(parsed.skipped[1].index, 6); + assert!(parsed.skipped[1] + .reason + .contains("payloads[6] must be an object")); + assert_eq!(parsed.skipped[2].index, 7); + assert!(parsed.skipped[2] + .reason + .contains("field 'type' must be a string")); +} + +fn normalized_conversation_trace_message_payload(tool_name: &str, session_id: &str) -> String { + serde_json::json!({ + "tool_name": tool_name, + "payloads": [ + { + "type": "message", + "session_id": session_id, + "message_id": "message-1", + "role": "assistant", + "generated_at_unix_ms": 1_800_000_000_000_i64 + } + ] + }) + .to_string() +} + +#[test] +fn conversation_trace_normalized_payload_accepts_pi_tool_name_with_prefixed_session_id() { + let stdin_payload = normalized_conversation_trace_message_payload("pi", "session-1"); + + let parsed = parse_conversation_trace_payload(&stdin_payload) + .expect("Pi normalized conversation-trace payload should parse"); + + assert_eq!(parsed.message_updated.inserts.len(), 1); + assert_eq!(parsed.message_updated.inserts[0].session_id, "pi_session-1"); +} + +#[test] +fn conversation_trace_normalized_payload_rejects_unsupported_tool_name() { + let stdin_payload = normalized_conversation_trace_message_payload("cursor", "session-1"); + + let error = parse_conversation_trace_payload(&stdin_payload) + .expect_err("unsupported tool_name should be rejected"); + + assert!(error.to_string().contains("unsupported tool_name 'cursor'")); + assert!(error.to_string().contains("'opencode'")); + assert!(error.to_string().contains("'pi'")); +} + +#[test] +fn conversation_trace_normalized_payload_rejects_empty_tool_name() { + let stdin_payload = normalized_conversation_trace_message_payload("", "session-1"); + + let error = parse_conversation_trace_payload(&stdin_payload) + .expect_err("empty tool_name should be rejected"); + + assert!(error + .to_string() + .contains("field 'tool_name' must be a non-empty string")); +} + +#[test] +fn conversation_trace_normalized_payload_rejects_missing_tool_name() { + let stdin_payload = serde_json::json!({ + "payloads": [ + { + "type": "message", + "session_id": "session-1", + "message_id": "message-1", + "role": "assistant", + "generated_at_unix_ms": 1_800_000_000_000_i64 + } + ] + }) + .to_string(); + + let error = parse_conversation_trace_payload(&stdin_payload) + .expect_err("missing tool_name should be rejected"); + + assert!(error + .to_string() + .contains("missing required field 'tool_name'")); +} + +#[test] +fn conversation_trace_normalized_payload_keeps_already_prefixed_session_id() { + let stdin_payload = normalized_conversation_trace_message_payload("opencode", "oc_session-1"); + + let parsed = parse_conversation_trace_payload(&stdin_payload) + .expect("already-prefixed OpenCode session ID should parse"); + + assert_eq!(parsed.message_updated.inserts[0].session_id, "oc_session-1"); +} + +#[test] +fn conversation_trace_raw_claude_event_uses_claude_identity_with_cc_prefixed_session_id() { + let stdin_payload = serde_json::json!({ + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "prompt": "hello" + }) + .to_string(); + + let parsed = parse_conversation_trace_payload(&stdin_payload) + .expect("raw Claude UserPromptSubmit event should parse"); + + assert_eq!(parsed.message_updated.inserts.len(), 1); + assert_eq!(parsed.message_updated.inserts[0].session_id, "cc_session-1"); +} + +fn diff_trace_payload(model_id: Option<&str>, tool_version: Option<&str>) -> DiffTracePayload { + diff_trace_payload_with( + "claude", + "session-123", + PAYLOAD_TYPE_STRUCTURED, + model_id, + tool_version, + ) +} + +fn diff_trace_payload_with( + tool_name: &str, + session_id: &str, + payload_type: &str, + model_id: Option<&str>, + tool_version: Option<&str>, +) -> DiffTracePayload { + DiffTracePayload { + session_id: String::from(session_id), + diff: String::from("diff text"), + time: 1_800_000_000_000_u64, + model_id: model_id.map(String::from), + agent_id: None, + transcript_path: None, + tool_name: String::from(tool_name), + tool_version: tool_version.map(String::from), + payload_type: String::from(payload_type), + } +} + +fn claude_model_test_event(transcript_path: &Path, tool_use_id: &str) -> Value { + json!({ + "hook_event_name": "PostToolUse", + "session_id": "session-123", + "tool_name": "Write", + "tool_use_id": tool_use_id, + "transcript_path": transcript_path, + "tool_input": { + "file_path": "docs/status.md", + "content": "# Status\n\nThe new state is complete.\n" + }, + "tool_response": { + "originalFile": "# Status\n\nThe old state is pending.\n", + "structuredPatch": { + "hunks": [{ + "oldStart": 1, + "oldCount": 3, + "newStart": 1, + "newCount": 3, + "lines": [ + " # Status", + " ", + "-The old state is pending.", + "+The new state is complete." + ] + }] + } + } + }) +} + +fn parsed_claude_model_id(event: &Value) -> Option { + match parse_diff_trace_payload(&event.to_string()) + .expect("Claude PostToolUse diff-trace payload should parse") + { + DiffTraceParseResult::Persist(payload) => payload.model_id, + DiffTraceParseResult::NoOp(message) => { + panic!("Claude Write payload should persist, got no-op: {message}") + } + } +} + +fn parsed_claude_diff_trace(event: &Value) -> DiffTracePayload { + match parse_diff_trace_payload(&event.to_string()) + .expect("Claude PostToolUse diff-trace payload should parse") + { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => { + panic!("Claude Write payload should persist, got no-op: {message}") + } + } +} + +fn unique_attribution_db_path(label: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!("sce-claude-model-attribution-{label}-{suffix}")) + .join("agent-trace.db") +} + +fn resolved_claude_model_id_with(event: &Value, transcript_lookup: F) -> Option +where + F: FnOnce(&Path, &str) -> Option, +{ + resolve_claude_model_id_with( + event.as_object().expect("test event should be an object"), + transcript_lookup, + ) +} + +fn run_attribution_git(repo_root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .expect("git should start"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn init_attribution_git_repo(label: &str) -> PathBuf { + let repo_root = unique_attribution_db_path(label) + .parent() + .expect("test repository should have a parent") + .to_path_buf(); + fs::create_dir_all(&repo_root).expect("test repository directory should be created"); + run_attribution_git(&repo_root, &["init", "-q"]); + run_attribution_git( + &repo_root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + repo_root +} + +fn model_less_claude_diff_event( + session_id: &str, + tool_use_id: &str, + agent_id: Option<&str>, +) -> Value { + let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), tool_use_id); + let object = event + .as_object_mut() + .expect("Claude test event should be an object"); + object.insert("session_id".to_string(), json!(session_id)); + object.remove("transcript_path"); + object.remove("tool_use_id"); + if let Some(agent_id) = agent_id { + object.insert("agent_id".to_string(), json!(agent_id)); + } + event +} + +fn persisted_model_ids(db: &RepositoryAgentTraceDb) -> Vec> { + db.query_map( + "SELECT model_id FROM diff_traces ORDER BY id ASC", + (), + |row| row.get::>(0).map_err(Into::into), + ) + .expect("persisted model IDs should be readable") +} + +#[test] +fn claude_model_direct_nested_metadata_wins_over_transcript_without_double_prefixing() { + let transcript_path = Path::new("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/unused/direct-precedence.jsonl"); + let mut event = claude_model_test_event(transcript_path, "tool-123"); + event + .as_object_mut() + .expect("test event should be an object") + .insert("model".to_string(), json!({ "id": "claude/direct-model" })); + + let model_id = resolved_claude_model_id_with(&event, |_, _| { + panic!("transcript lookup must not run when direct metadata is present") + }); + + assert_eq!(model_id.as_deref(), Some("claude/direct-model")); + assert_eq!(parsed_claude_model_id(&event), model_id); +} + +#[test] +fn claude_model_falls_back_to_matching_transcript_and_normalizes_model() { + let transcript_path = Path::new("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/virtual/transcript-fallback.jsonl"); + let event = claude_model_test_event(transcript_path, "tool-123"); + + let model_id = resolved_claude_model_id_with(&event, |path, tool_use_id| { + assert_eq!(path, transcript_path); + assert_eq!(tool_use_id, "tool-123"); + Some(String::from("claude/claude-opus-4-1")) + }); + + assert_eq!(model_id.as_deref(), Some("claude/claude-opus-4-1")); +} + +#[test] +fn claude_model_remains_none_when_transcript_lookup_cannot_succeed() { + let event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); + assert_eq!(resolved_claude_model_id_with(&event, |_, _| None), None); + + let mut event_without_lookup_fields = event; + let payload = event_without_lookup_fields + .as_object_mut() + .expect("test event should be an object"); + payload.remove("transcript_path"); + payload.remove("tool_use_id"); + assert_eq!( + resolved_claude_model_id_with(&event_without_lookup_fields, |_, _| { + panic!("lookup must not run without transcript event metadata") + }), + None + ); +} + +#[test] +fn claude_diff_trace_parser_keeps_agent_id_ephemeral_and_storage_free() { + let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); + event + .as_object_mut() + .expect("test event should be an object") + .insert("agent_id".to_string(), json!(" agent-1 ")); + + let payload = parsed_claude_diff_trace(&event); + + assert_eq!(payload.agent_id.as_deref(), Some("agent-1")); + assert!(serde_json::to_value(&payload) + .expect("internal payload should serialize") + .get("agent_id") + .is_none()); +} + +#[test] +fn claude_diff_trace_parser_keeps_transcript_path_ephemeral_and_storage_free() { + let transcript_path = Path::new("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/virtual/session-123.jsonl"); + let event = claude_model_test_event(transcript_path, "tool-123"); + + let payload = parsed_claude_diff_trace(&event); + + assert_eq!( + payload.transcript_path.as_deref(), + Some("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/virtual/session-123.jsonl") + ); + assert!(serde_json::to_value(&payload) + .expect("internal payload should serialize") + .get("transcript_path") + .is_none()); +} + +#[test] +fn claude_diff_trace_parser_leaves_transcript_path_none_without_the_field() { + let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); + event + .as_object_mut() + .expect("test event should be an object") + .remove("transcript_path"); + + assert_eq!(parsed_claude_diff_trace(&event).transcript_path, None); +} + +#[test] +fn claude_diff_trace_normalized_opencode_payload_carries_no_transcript_path() { + let stdin_payload = serde_json::json!({ + "sessionID": "session-123", + "diff": "diff text", + "time": 1_800_000_000_000_u64, + "model_id": "anthropic/claude-opus-4", + "tool_name": "opencode", + "tool_version": null + }) + .to_string(); + + let parsed = parse_diff_trace_payload(&stdin_payload) + .expect("normalized OpenCode diff-trace payload should parse"); + let payload = match parsed { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => { + panic!("normalized OpenCode payload should persist, got no-op: {message}") + } + }; + + assert_eq!(payload.transcript_path, None); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn claude_model_attribution_end_to_end_persists_lifecycle_fallback_precedence_and_scope() { + let repo_root = init_attribution_git_repo("end-to-end"); + let state_root = unique_attribution_db_path("end-to-end-state") + .parent() + .expect("test state should have a parent") + .to_path_buf(); + let storage = resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &repo_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("setup path should initialize the test repository DB"); + drop(storage); + + let session_start = json!({ + "hook_event_name": "SessionStart", + "session_id": "session-123", + "model": "model-a", + "source": "startup" + }); + assert_eq!( + claude_model_state::run_claude_model_state_from_payload_at_state_root( + &repo_root, + &state_root, + &session_start.to_string(), + None, + || Ok(10), + ), + "" + ); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open after SessionStart", + ) + .expect("test DB should open after SessionStart"); + assert_eq!( + db.claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("SessionStart state should be readable") + .expect("SessionStart should seed state") + .model_id, + "claude/model-a" + ); + let session_start_event = model_less_claude_diff_event("session-123", "tool-a", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&session_start_event), + ) + .expect("SessionStart state should attribute the next diff trace"); + drop(db); + + let post_model_switch = json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-123", + "from_model": "model-a", + "to_model": "model-b", + "source": "picker" + }); + assert_eq!( + claude_model_state::run_claude_model_state_from_payload_at_state_root( + &repo_root, + &state_root, + &post_model_switch.to_string(), + None, + || Ok(20), + ), + "" + ); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open after PostModelSwitch", + ) + .expect("test DB should open after PostModelSwitch"); + assert_eq!( + db.claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("PostModelSwitch state should be readable") + .expect("PostModelSwitch should update state") + .model_id, + "claude/model-b" + ); + let switched_event = model_less_claude_diff_event("session-123", "tool-b", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&switched_event), + ) + .expect("PostModelSwitch state should attribute the next diff trace"); + + let mut direct_event = model_less_claude_diff_event("session-123", "tool-direct", None); + direct_event + .as_object_mut() + .expect("Claude test event should be an object") + .insert("model".to_string(), json!("model-c")); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&direct_event), + ) + .expect("direct model attribution should persist"); + + let transcript_path = state_root.join("transcript.jsonl"); + fs::write( + &transcript_path, + concat!( + r#"{"type":"assistant","message":{"role":"assistant","model":"model-c","content":[{"type":"tool_use","id":"tool-transcript"}]}}"#, + "\n" + ), + ) + .expect("transcript fixture should be written"); + let transcript_event = claude_model_test_event(&transcript_path, "tool-transcript"); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&transcript_event), + ) + .expect("transcript model attribution should persist"); + + let no_state_event = model_less_claude_diff_event("session-without-state", "tool-none", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&no_state_event), + ) + .expect("an attribution-less diff trace should still persist"); + + let subagent_event = + model_less_claude_diff_event("session-123", "tool-subagent", Some("subagent-1")); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&subagent_event), + ) + .expect("a subagent diff trace should persist"); + + assert_eq!( + persisted_model_ids(&db), + vec![ + Some(String::from("claude/model-a")), + Some(String::from("claude/model-b")), + Some(String::from("claude/model-c")), + Some(String::from("claude/model-c")), + None, + None, + ] + ); + + drop(db); + fs::remove_file(transcript_path).expect("transcript fixture should be removed"); + fs::remove_dir_all(repo_root).expect("test repository should be removed"); + fs::remove_dir_all(state_root).expect("test state should be removed"); +} + +#[test] +fn claude_model_attribution_bridge_inheritance_seeds_state_and_diff_trace() { + let repo_root = init_attribution_git_repo("bridge-inheritance"); + let state_root = unique_attribution_db_path("bridge-inheritance-state") + .parent() + .expect("test state should have a parent") + .to_path_buf(); + let storage = resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &repo_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("setup path should initialize the test repository DB"); + drop(storage); + + let sibling_transcript = state_root.join("session-old.jsonl"); + let current_transcript = state_root.join("session-current.jsonl"); + fs::write( + &sibling_transcript, + concat!( + r#"{"type":"file-history-snapshot"}"#, + "\n", + r#"{"type":"bridge-session","sessionId":"session-old","bridgeSessionId":"cse_shared"}"#, + "\n", + ), + ) + .expect("sibling transcript fixture should be written"); + fs::write( + ¤t_transcript, + concat!( + r#"{"type":"file-history-snapshot"}"#, + "\n", + r#"{"type":"bridge-session","sessionId":"session-current","bridgeSessionId":"cse_shared"}"#, + "\n", + ), + ) + .expect("current transcript fixture should be written"); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open before bridge inheritance", + ) + .expect("test DB should open before bridge inheritance"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-old"), + agent_id: String::new(), + model_id: String::from("claude/inherited-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 5, + }) + .expect("sibling state should be seeded"); + drop(db); + + let session_start = json!({ + "hook_event_name": "SessionStart", + "session_id": "session-current", + "source": "clear", + "transcript_path": current_transcript, + }); + assert_eq!( + claude_model_state::run_claude_model_state_from_payload_at_state_root( + &repo_root, + &state_root, + &session_start.to_string(), + None, + || Ok(10), + ), + "" + ); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open after bridge inheritance", + ) + .expect("test DB should open after bridge inheritance"); + let inherited = db + .claude_model_state_by_session_and_agent("cc_session-current", "") + .expect("inherited state lookup should succeed") + .expect("current session should inherit sibling state"); + assert_eq!(inherited.model_id, "claude/inherited-model"); + assert_eq!(inherited.source, "bridge_inherited"); + assert_eq!(inherited.observation_kind, ObservationKind::SessionStart); + assert_eq!(inherited.observed_at_ms, 10); + + let diff_event = model_less_claude_diff_event("session-current", "tool-inherited", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&diff_event), + ) + .expect("inherited state should attribute the diff trace"); + assert_eq!( + persisted_model_ids(&db), + vec![Some(String::from("claude/inherited-model"))] + ); + + drop(db); + fs::remove_file(sibling_transcript).expect("sibling transcript should be removed"); + fs::remove_file(current_transcript).expect("current transcript should be removed"); + fs::remove_dir_all(repo_root).expect("test repository should be removed"); + fs::remove_dir_all(state_root).expect("test state should be removed"); +} + +#[test] +fn claude_diff_trace_persistence_uses_state_only_after_direct_and_transcript() { + let db_path = unique_attribution_db_path("precedence"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-123"), + agent_id: String::new(), + model_id: String::from("claude/state-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 1, + }) + .expect("state should be seeded"); + + let mut state_event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "state"); + let state_object = state_event + .as_object_mut() + .expect("test event should be an object"); + state_object.remove("transcript_path"); + state_object.remove("tool_use_id"); + let state_payload = parsed_claude_diff_trace(&state_event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &state_payload) + .expect("state fallback should persist"); + + let mut direct_event = state_event.clone(); + direct_event + .as_object_mut() + .expect("test event should be an object") + .insert("model".to_string(), json!("direct-model")); + let direct_payload = parsed_claude_diff_trace(&direct_event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &direct_payload) + .expect("direct attribution should persist"); + + let transcript_path = db_path.with_extension("jsonl"); + fs::write( + &transcript_path, + concat!( + r#"{"type":"assistant","message":{"role":"assistant","model":"transcript-model","content":[{"type":"tool_use","id":"transcript"}]}}"#, + "\n" + ), + ) + .expect("transcript fixture should be written"); + let transcript_event = claude_model_test_event(&transcript_path, "transcript"); + let transcript_payload = parsed_claude_diff_trace(&transcript_event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &transcript_payload) + .expect("transcript attribution should persist"); + + let models = db + .query_map( + "SELECT model_id FROM diff_traces ORDER BY id ASC", + (), + |row| row.get::>(0).map_err(Into::into), + ) + .expect("persisted models should be readable"); + assert_eq!( + models, + vec![ + Some(String::from("claude/state-model")), + Some(String::from("claude/direct-model")), + Some(String::from("claude/transcript-model")), + ] + ); + + drop(db); + fs::remove_file(transcript_path).expect("transcript fixture should be removed"); + fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) + .expect("test DB directory should be removed"); +} + +#[test] +fn normalized_claude_tool_name_does_not_use_claude_state_fallback() { + let db_path = unique_attribution_db_path("normalized-claude"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-123"), + agent_id: String::new(), + model_id: String::from("claude/parent-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 1, + }) + .expect("parent state should be seeded"); + + let payload = diff_trace_payload_with( + CLAUDE_TOOL_NAME, + "session-123", + PAYLOAD_TYPE_PATCH, + None, + None, + ); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("normalized Claude payload should persist"); + + let model = db + .query_map("SELECT model_id FROM diff_traces LIMIT 1", (), |row| { + row.get::>(0).map_err(Into::into) + }) + .expect("persisted model should be readable") + .into_iter() + .next() + .expect("diff trace row should exist"); + assert_eq!(model, None); + + drop(db); + fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) + .expect("test DB directory should be removed"); +} + +#[test] +fn claude_diff_trace_state_lookup_isolated_to_exact_subagent_scope() { + let db_path = unique_attribution_db_path("subagent"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-123"), + agent_id: String::new(), + model_id: String::from("claude/parent-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 1, + }) + .expect("parent state should be seeded"); + + let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "subagent"); + let event_object = event + .as_object_mut() + .expect("test event should be an object"); + event_object.remove("transcript_path"); + event_object.remove("tool_use_id"); + event_object.insert("agent_id".to_string(), json!("subagent-1")); + let payload = parsed_claude_diff_trace(&event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("subagent diff trace should persist"); + + let model = db + .query_map("SELECT model_id FROM diff_traces LIMIT 1", (), |row| { + row.get::>(0).map_err(Into::into) + }) + .expect("persisted model should be readable") + .into_iter() + .next() + .expect("diff trace row should exist"); + assert_eq!(model, None); + + drop(db); + fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) + .expect("test DB directory should be removed"); +} + +fn write_bridge_transcript(path: &Path, bridge_session_id: &str) { + fs::write( + path, + format!( + concat!( + "{{\"type\":\"file-history-snapshot\"}}\n", + "{{\"type\":\"bridge-session\",\"sessionId\":\"s\",", + "\"bridgeSessionId\":\"{bridge_session_id}\"}}\n" + ), + bridge_session_id = bridge_session_id, + ), + ) + .expect("bridge transcript fixture should be written"); +} + +#[test] +fn claude_diff_trace_seeds_bridge_chain_state_on_state_miss_and_reuses_it() { + let db_path = unique_attribution_db_path("bridge-chain-seed"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-member"), + agent_id: String::new(), + model_id: String::from("claude/chain-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 100, + }) + .expect("chain member state should seed"); + + let current_transcript = dir.join("session-current.jsonl"); + let member_transcript = dir.join("session-member.jsonl"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + write_bridge_transcript(&member_transcript, "cse_chain"); + + let payload = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "a")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("bridge chain seeding should persist"); + + assert_eq!( + persisted_model_ids(&db), + vec![Some(String::from("claude/chain-model"))] + ); + let seeded = db + .claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("seeded lookup should succeed") + .expect("current session should be seeded"); + assert_eq!(seeded.model_id, "claude/chain-model"); + assert_eq!(seeded.source, "bridge_inherited"); + + fs::remove_file(&member_transcript).expect("member transcript should be removed"); + let payload_two = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "b")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload_two) + .expect("second diff trace should persist"); + assert_eq!( + persisted_model_ids(&db), + vec![ + Some(String::from("claude/chain-model")), + Some(String::from("claude/chain-model")), + ] + ); + let after = db + .claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("lookup should succeed") + .expect("row should still exist"); + assert_eq!(after.observed_at_ms, seeded.observed_at_ms); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); +} + +#[test] +fn claude_diff_trace_bridge_chain_selects_newest_observation_across_members() { + let db_path = unique_attribution_db_path("bridge-chain-newest"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-root"), + agent_id: String::new(), + model_id: String::from("claude/sonnet-5"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 10, + }) + .expect("root state should seed"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-mid"), + agent_id: String::new(), + model_id: String::from("claude/opus-5"), + observation_kind: ObservationKind::PostModelSwitch, + source: String::from("picker"), + observed_at_ms: 20, + }) + .expect("mid state should seed"); + + let current_transcript = dir.join("session-current.jsonl"); + let root_transcript = dir.join("session-root.jsonl"); + let mid_transcript = dir.join("session-mid.jsonl"); + write_bridge_transcript(&mid_transcript, "cse_chain"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + thread::sleep(Duration::from_millis(15)); + write_bridge_transcript(&root_transcript, "cse_chain"); + + let payload = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "x")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("newest-observation resolution should persist"); + + assert_eq!( + persisted_model_ids(&db), + vec![Some(String::from("claude/opus-5"))] + ); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); +} + +#[test] +fn claude_diff_trace_bridge_chain_fails_open_without_write_or_attribution() { + let db_path = unique_attribution_db_path("bridge-chain-fail-open"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "no-transcript"); + event + .as_object_mut() + .expect("event should be an object") + .remove("transcript_path"); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &parsed_claude_diff_trace(&event)) + .expect("missing transcript should fail open"); + + let current_transcript = dir.join("session-current.jsonl"); + let member_transcript = dir.join("session-member.jsonl"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + write_bridge_transcript(&member_transcript, "cse_chain"); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "no-state")), + ) + .expect("stateless chain should fail open"); + + assert_eq!(persisted_model_ids(&db), vec![None, None]); + assert!( + db.claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("lookup should succeed") + .is_none(), + "no state row should be written on a fail-open branch" + ); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); +} + +#[test] +fn claude_diff_trace_bridge_chain_does_not_seed_subagent_scope() { + let db_path = unique_attribution_db_path("bridge-chain-subagent"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-member"), + agent_id: String::new(), + model_id: String::from("claude/chain-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 100, + }) + .expect("chain member state should seed"); + + let current_transcript = dir.join("session-current.jsonl"); + let member_transcript = dir.join("session-member.jsonl"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + write_bridge_transcript(&member_transcript, "cse_chain"); + + let mut event = claude_model_test_event(¤t_transcript, "subagent"); + event + .as_object_mut() + .expect("event should be an object") + .insert("agent_id".to_string(), json!("subagent-1")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &parsed_claude_diff_trace(&event)) + .expect("subagent diff trace should persist"); + + assert_eq!(persisted_model_ids(&db), vec![None]); + assert!( + db.claude_model_state_by_session_and_agent("cc_session-123", "subagent-1") + .expect("lookup should succeed") + .is_none(), + "subagent scope must not inherit main-session chain state" + ); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); +} + +#[test] +fn prefixed_diff_trace_session_id_prefixes_fresh_pi_session_id() { + assert_eq!( + prefixed_diff_trace_session_id("pi", "session-123"), + "pi_session-123" + ); +} + +#[test] +fn prefixed_diff_trace_session_id_keeps_already_prefixed_pi_session_id() { + assert_eq!( + prefixed_diff_trace_session_id("pi", "pi_session-123"), + "pi_session-123" + ); +} + +#[test] +fn prefixed_diff_trace_session_id_prefixes_fresh_codex_session_id() { + assert_eq!( + prefixed_diff_trace_session_id("codex", "session-123"), + "cx_session-123" + ); +} + +#[test] +fn prefixed_diff_trace_session_id_keeps_already_prefixed_codex_session_id() { + assert_eq!( + prefixed_diff_trace_session_id("codex", "cx_session-123"), + "cx_session-123" + ); +} + +#[test] +fn prefixed_diff_trace_session_id_adding_codex_does_not_affect_other_tool_prefixes() { + assert_eq!( + prefixed_diff_trace_session_id("opencode", "session-123"), + "oc_session-123" + ); + assert_eq!( + prefixed_diff_trace_session_id("claude", "session-123"), + "cc_session-123" + ); + assert_eq!( + prefixed_diff_trace_session_id("pi", "session-123"), + "pi_session-123" + ); +} + +#[test] +fn normalize_codex_model_id_preserves_fresh_model_id() { + assert_eq!( + normalize_codex_model_id("gpt-5.6-codex").as_deref(), + Some("gpt-5.6-codex") + ); +} + +#[test] +fn normalize_codex_model_id_preserves_qualified_model_ids() { + for model in ["openai/gpt-x", "qualified/custom-provider/model"] { + assert_eq!(normalize_codex_model_id(model).as_deref(), Some(model)); + } +} + +#[test] +fn normalize_codex_model_id_preserves_unqualified_model_ids() { + assert_eq!( + normalize_codex_model_id("custom-codex-model").as_deref(), + Some("custom-codex-model") + ); +} + +#[test] +fn normalize_codex_model_id_returns_none_for_blank_model_ids() { + assert_eq!(normalize_codex_model_id(" "), None); +} + +#[test] +fn normalize_opencode_model_id_preserves_qualified_and_unqualified_ids() { + for model in [ + "opencode/big-pickle", + "anthropic/claude-sonnet-4", + "custom-model", + ] { + assert_eq!(normalize_opencode_model_id(model).as_deref(), Some(model)); + } + assert_eq!( + normalize_opencode_model_id(" opencode/big-pickle ").as_deref(), + Some("opencode/big-pickle") + ); +} + +#[test] +fn normalize_opencode_model_id_returns_none_for_blank_model_ids() { + assert_eq!(normalize_opencode_model_id(""), None); + assert_eq!(normalize_opencode_model_id(" "), None); +} + +#[test] +fn pi_normalized_diff_trace_payload_persists_with_pi_prefixed_session_id() { + let stdin_payload = serde_json::json!({ + "sessionID": "session-123", + "diff": "diff text", + "time": 1_800_000_000_000_u64, + "model_id": "anthropic/claude-opus-4", + "tool_name": "pi", + "tool_version": null + }) + .to_string(); + + let parsed = parse_diff_trace_payload(&stdin_payload) + .expect("normalized Pi diff-trace payload should parse"); + let payload = match parsed { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => { + panic!("Pi payload should persist, got no-op: {message}") + } + }; + + assert_eq!(payload.tool_name, "pi"); + assert_eq!(payload.model_id.as_deref(), Some("anthropic/claude-opus-4")); + assert_eq!(payload.tool_version, None); + + persist_diff_trace_payload_to_agent_trace_db_with( + &payload, + payload.model_id.as_deref(), + payload.tool_version.as_deref(), + |input| { + assert_eq!(input.time_ms, 1_800_000_000_000_i64); + assert_eq!(input.session_id, "pi_session-123"); + assert_eq!(input.model_id, Some("anthropic/claude-opus-4")); + assert_eq!(input.tool_name, "pi"); + assert_eq!(input.tool_version, None); + assert_eq!(input.payload_type, PAYLOAD_TYPE_PATCH); + + Ok(()) + }, + ) + .expect("Pi diff-trace payload should be persisted"); +} + +#[test] +fn post_commit_intersection_flow_preserves_pi_provenance() { + let now_ms = 1_800_000_000_000_i64; + let commit_time_ms = now_ms - 1_000; + + let output = run_post_commit_intersection_flow_with( + Path::new("/repo"), + |_| { + Ok(PostCommitPatchData { + commit_oid: String::from("def456"), + commit_time_ms, + parsed_patch: valid_patch("src/lib.rs", "shared line"), + }) + }, + || Ok(now_ms), + |_, _| { + Ok(RecentDiffTracePatches { + patches: vec![ParsedDiffTracePatch { + id: 9, + time_ms: now_ms - 500, + session_id: String::from("pi_valid-session"), + patch: valid_patch("src/lib.rs", "shared line"), + tool_name: Some(String::from("pi")), + tool_version: None, + payload_type: String::from(PAYLOAD_TYPE_PATCH), + }], + skipped: vec![], + }) + }, + |_| Ok(()), + ) + .expect("post-commit intersection flow should succeed"); + + assert_eq!(output.combined_recent_patch.files.len(), 1); + assert_eq!(output.tool_name, Some(String::from("pi"))); + assert_eq!(output.tool_version, None); +} + +#[test] +fn diff_trace_db_persistence_uses_direct_payload_model_and_tool_version() { + let payload = diff_trace_payload(Some("direct-model"), None); + + persist_diff_trace_payload_to_agent_trace_db_with( + &payload, + Some("direct-model"), + Some("Claude Code 1.2.3"), + |input| { + assert_eq!(input.time_ms, 1_800_000_000_000_i64); + assert_eq!(input.session_id, "cc_session-123"); + assert_eq!(input.model_id, Some("direct-model")); + assert_eq!(input.tool_name, "claude"); + assert_eq!(input.tool_version, Some("Claude Code 1.2.3")); + assert_eq!(input.payload_type, PAYLOAD_TYPE_STRUCTURED); + + Ok(()) + }, + ) + .expect("direct diff-trace attribution should be persisted"); +} + +#[test] +fn post_commit_intersection_flow_uses_same_window_end_for_query_and_persistence() { + let now_ms = 1_800_000_000_000_i64; + let commit_time_ms = now_ms - 1_000; + let expected_cutoff_ms = now_ms - RECENT_DAYS_MILLIS; + let query_window = RefCell::new(None); + let persisted = RefCell::new(None); + + let output = run_post_commit_intersection_flow_with( + Path::new("/repo"), + |_| { + Ok(PostCommitPatchData { + commit_oid: String::from("abc123"), + commit_time_ms, + parsed_patch: valid_patch("src/lib.rs", "shared line"), + }) + }, + || Ok(now_ms), + |cutoff_ms, end_ms| { + *query_window.borrow_mut() = Some((cutoff_ms, end_ms)); + + Ok(RecentDiffTracePatches { + patches: vec![ParsedDiffTracePatch { + id: 7, + time_ms: now_ms - 500, + session_id: String::from("oc_valid-session"), + patch: valid_patch("src/lib.rs", "shared line"), + tool_name: Some(String::from("opencode")), + tool_version: Some(String::from("1.2.3")), + payload_type: String::from(PAYLOAD_TYPE_PATCH), + }], + skipped: vec![SkippedDiffTracePatch { + id: 8, + time_ms: now_ms - 250, + session_id: String::from("oc_malformed-session"), + reason: String::from("invalid hunk header"), + }], + }) + }, + |insert_input| { + *persisted.borrow_mut() = Some(CapturedPostCommitIntersectionInsert { + commit_id: insert_input.commit_id.to_string(), + post_commit_time_ms: insert_input.post_commit_time_ms, + recent_window_cutoff_ms: insert_input.recent_window_cutoff_ms, + recent_window_end_ms: insert_input.recent_window_end_ms, + loaded_diff_trace_count: insert_input.loaded_diff_trace_count, + skipped_diff_trace_count: insert_input.skipped_diff_trace_count, + intersection_patch: insert_input.intersection_patch.to_string(), + }); + + Ok(()) + }, + ) + .expect("post-commit intersection flow should succeed"); + + assert_eq!( + query_window.into_inner(), + Some((expected_cutoff_ms, now_ms)) + ); + + let persisted = persisted + .into_inner() + .expect("intersection row should be persisted"); + assert_eq!(persisted.commit_id, "abc123"); + assert_eq!(persisted.post_commit_time_ms, commit_time_ms); + assert_eq!(persisted.recent_window_cutoff_ms, expected_cutoff_ms); + assert_eq!(persisted.recent_window_end_ms, now_ms); + assert_eq!(persisted.loaded_diff_trace_count, 1); + assert_eq!(persisted.skipped_diff_trace_count, 1); + + let intersection: ParsedPatch = serde_json::from_str(&persisted.intersection_patch) + .expect("persisted intersection patch should deserialize"); + assert_eq!(intersection.files.len(), 1); + assert_eq!(intersection.files[0].new_path, "src/lib.rs"); + assert_eq!(intersection.files[0].hunks[0].lines.len(), 1); + assert_eq!( + intersection.files[0].hunks[0].lines[0].content, + "shared line" + ); + + assert_eq!(output.post_commit_data.commit_oid, "abc123"); + assert_eq!(output.post_commit_data.commit_time_ms, commit_time_ms); + assert_eq!(output.combined_recent_patch.files.len(), 1); + assert_eq!(output.combined_recent_patch.files[0].new_path, "src/lib.rs"); + assert_eq!(output.tool_name, Some(String::from("opencode"))); + assert_eq!(output.tool_version, Some(String::from("1.2.3"))); +} + +fn post_commit_flow_result() -> PostCommitIntersectionFlowResult { + PostCommitIntersectionFlowResult { + combined_recent_patch: valid_patch("src/lib.rs", "shared line"), + post_commit_data: PostCommitPatchData { + commit_oid: String::from("abc123"), + commit_time_ms: 1_800_000_000_000, + parsed_patch: valid_patch("src/lib.rs", "shared line"), + }, + tool_name: None, + tool_version: None, + } +} + +fn minimal_agent_trace() -> AgentTrace { + serde_json::from_value(json!({ "files": [] })).expect("minimal Agent Trace should deserialize") +} + +#[test] +fn post_commit_auto_sync_launches_after_successful_persistence_when_enabled() { + let events = RefCell::new(Vec::new()); + + let output = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| { + events.borrow_mut().push("intersection"); + Ok(post_commit_flow_result()) + }, + |_, _, _, _| { + events.borrow_mut().push("persistence"); + Ok(minimal_agent_trace()) + }, + |_| { + events.borrow_mut().push("config"); + Ok(true) + }, + |_| { + events.borrow_mut().push("launch"); + Ok(()) + }, + |_| { + events.borrow_mut().push("checkpoint"); + Ok(()) + }, + None, + ) + .expect("successful post-commit should remain successful"); + + assert!(output.contains("post-commit hook processed intersection")); + assert_eq!( + events.into_inner(), + vec![ + "intersection", + "persistence", + "checkpoint", + "config", + "launch" + ] + ); +} + +#[test] +fn post_commit_validation_failure_does_not_resolve_or_launch_auto_sync() { + let validation_called = RefCell::new(false); + let config_called = RefCell::new(false); + let launch_called = RefCell::new(false); + + let error = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, flow_result, vcs_type, remote_url| { + run_post_commit_agent_trace_flow_with( + flow_result, + vcs_type, + remote_url, + &ParsedPatch { files: Vec::new() }, + |_| { + *validation_called.borrow_mut() = true; + Err(anyhow!("Agent Trace validation failed")) + }, + |_| panic!("Agent Trace persistence must not run after validation failure"), + ) + }, + |_| { + *config_called.borrow_mut() = true; + Ok(true) + }, + |_| { + *launch_called.borrow_mut() = true; + Ok(()) + }, + |_| panic!("checkpoint must not run after persistence failure"), + None, + ) + .expect_err("validation failure should be returned"); + + assert!(*validation_called.borrow()); + assert!(!error.to_string().is_empty()); + assert!(!*config_called.borrow()); + assert!(!*launch_called.borrow()); +} + +fn post_commit_flow_result_for( + direct: ParsedPatch, + committed: ParsedPatch, +) -> PostCommitIntersectionFlowResult { + PostCommitIntersectionFlowResult { + combined_recent_patch: direct, + post_commit_data: PostCommitPatchData { + commit_oid: String::from("abc123"), + commit_time_ms: 1_800_000_000_000, + parsed_patch: committed, + }, + tool_name: Some(String::from("claude")), + tool_version: Some(String::from("9.9.9")), + } +} + +fn persisted_post_commit_trace( + flow_result: &PostCommitIntersectionFlowResult, + mutation_ai_patch: &ParsedPatch, +) -> Value { + let persisted = RefCell::new(None); + + run_post_commit_agent_trace_flow_with( + flow_result, + Some(AgentTraceVcsType::Git), + "", + mutation_ai_patch, + |_| Ok(()), + |insert| { + *persisted.borrow_mut() = Some(insert.trace_json.to_string()); + Ok(()) + }, + ) + .expect("post-commit Agent Trace flow should build and persist"); + + serde_json::from_str( + persisted + .into_inner() + .expect("trace should have been persisted") + .as_str(), + ) + .expect("persisted trace JSON should parse") +} + +#[test] +fn post_commit_agent_trace_flow_attributes_mutation_only_lines_as_ai_without_provenance() { + let flow_result = post_commit_flow_result_for( + ParsedPatch { files: Vec::new() }, + valid_patch("src/lib.rs", "mutated line"), + ); + let mutation_ai_patch = valid_patch("src/lib.rs", "mutated line"); + + let trace = persisted_post_commit_trace(&flow_result, &mutation_ai_patch); + + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert!( + trace.get("tool").is_none(), + "mutation-only coverage fabricates no tool provenance" + ); + let contributor = &trace["files"][0]["conversations"][0]["contributor"]; + assert_eq!(contributor["type"], json!("ai")); + assert!( + contributor.get("model_id").is_none(), + "mutation-only coverage carries no model provenance" + ); + assert!( + trace["files"][0]["conversations"][0] + .get("related") + .is_none(), + "mutation-only coverage carries no session provenance" + ); +} + +#[test] +fn post_commit_agent_trace_flow_keeps_direct_provenance_when_direct_covers_the_line() { + let flow_result = post_commit_flow_result_for( + valid_patch("src/lib.rs", "shared line"), + valid_patch("src/lib.rs", "shared line"), + ); + + let trace = persisted_post_commit_trace(&flow_result, &ParsedPatch { files: Vec::new() }); + + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["tool"], + json!({ "name": "claude", "version": "9.9.9" }) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("ai") + ); +} + +#[test] +fn post_commit_agent_trace_flow_with_empty_mutation_patch_leaves_uncovered_lines_unknown() { + let flow_result = post_commit_flow_result_for( + ParsedPatch { files: Vec::new() }, + valid_patch("src/lib.rs", "human line"), + ); + + let trace = persisted_post_commit_trace(&flow_result, &ParsedPatch { files: Vec::new() }); + + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(0) + ); + assert!(trace.get("tool").is_none()); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("unknown") + ); +} + +mod mutation_attribution_e2e { + use super::*; + use crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch; + use crate::services::mutation_trace::runtime::resolve_worktree_id; + use crate::services::mutation_trace::store::encode_revision; + + fn git(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + fn commit_all(repo: &Path, message: &str) { + git(repo, &["add", "-A"]); + git( + repo, + &[ + "-c", + "user.name=SCE Test", + "-c", + "user.email=sce@example.invalid", + "commit", + "-qm", + message, + ], + ); + } + + struct E2eRepo { + _temp: tempfile::TempDir, + root: PathBuf, + db_path: PathBuf, + } + + impl E2eRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-mutation-attr-e2e-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.rs"), "one\n").expect("seed file should write"); + commit_all(&root, "base"); + let db_path = temp.path().join("agent-trace.db"); + RepositoryAgentTraceDb::new_at(&db_path) + .expect("repository DB should open with schema"); + Self { + _temp: temp, + root, + db_path, + } + } + + fn db(&self) -> RepositoryAgentTraceDb { + RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&self.db_path) + .expect("repository DB should reopen") + } + + fn head_tree(&self) -> String { + git(&self.root, &["rev-parse", "HEAD^{tree}"]) + .trim() + .to_owned() + } + + fn parent_tree(&self) -> String { + git(&self.root, &["rev-parse", "HEAD~1^{tree}"]) + .trim() + .to_owned() + } + + fn checkout_id(&self) -> String { + resolve_worktree_id(&self.root) + .expect("worktree identity should resolve") + .0 + } + } + + fn seed_event( + db: &RepositoryAgentTraceDb, + worktree_id: &str, + revision: u64, + before_tree: &str, + after_tree: &str, + attribution_kind: &str, + attribution_scope_id: Option<&str>, + ) { + db.execute( + "INSERT INTO mutation_trace_events + (worktree_id, revision, before_tree, after_tree, tainted, failure_kind, + attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, + boundary_event_id) + VALUES (?1, ?2, ?3, ?4, 0, 'healthy', ?5, ?6, 'flush', NULL, NULL)", + ( + worktree_id, + encode_revision(revision).as_slice(), + before_tree, + after_tree, + attribution_kind, + attribution_scope_id, + ), + ) + .expect("mutation event insert should succeed"); + } + + fn row_count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("count row should exist") + } + + fn touched_line_count(patch: &ParsedPatch) -> usize { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .map(|hunk| hunk.lines.len()) + .sum() + } + + fn flow_result_for(repo: &E2eRepo, direct: ParsedPatch) -> PostCommitIntersectionFlowResult { + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + PostCommitIntersectionFlowResult { + combined_recent_patch: direct, + post_commit_data, + tool_name: None, + tool_version: None, + } + } + + fn resolve_mutation_ai( + repo: &E2eRepo, + db: &RepositoryAgentTraceDb, + flow_result: &PostCommitIntersectionFlowResult, + ) -> ParsedPatch { + let direct_intersection = intersect_patches_fn( + &flow_result.combined_recent_patch, + &flow_result.post_commit_data.parsed_patch, + ); + resolve_post_commit_mutation_ai_patch( + &repo.root, + db, + &direct_intersection, + &flow_result.post_commit_data.parsed_patch, + ) + } + + fn persist_trace( + flow_result: &PostCommitIntersectionFlowResult, + db: &RepositoryAgentTraceDb, + mutation_ai_patch: &ParsedPatch, + ) -> Value { + let persisted = RefCell::new(None); + run_post_commit_agent_trace_flow_with( + flow_result, + Some(AgentTraceVcsType::Git), + "git@github.com:acme/widgets.git", + mutation_ai_patch, + |value| validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())), + |insert| { + *persisted.borrow_mut() = Some(insert.trace_json.to_string()); + db.insert_agent_trace(insert).map(|_| ()) + }, + ) + .expect("the post-commit Agent Trace flow should build, validate, and persist"); + + serde_json::from_str( + persisted + .into_inner() + .expect("a trace should have been persisted") + .as_str(), + ) + .expect("the persisted trace JSON should parse") + } + + #[test] + fn a_mutation_only_line_persists_as_ai_without_fabricated_provenance() { + let repo = E2eRepo::new("mutation-only"); + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); + commit_all(&repo.root, "add two"); + + let db = repo.db(); + seed_event( + &db, + &repo.checkout_id(), + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-x"), + ); + + let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 1, + "a healthy untainted exclusive event covers the committed line" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert!( + trace.get("tool").is_none(), + "mutation-only coverage fabricates no tool provenance" + ); + let contributor = &trace["files"][0]["conversations"][0]["contributor"]; + assert_eq!(contributor["type"], json!("ai")); + assert!( + contributor.get("model_id").is_none(), + "mutation-only coverage carries no model provenance" + ); + + assert_eq!( + row_count(&db, "diff_traces"), + 0, + "mutation evidence is never inserted into diff_traces" + ); + assert_eq!( + row_count(&db, "post_commit_patch_intersections"), + 0, + "the direct-only intersection table is untouched by this flow" + ); + assert_eq!(row_count(&db, "agent_traces"), 1); + } + + #[test] + fn direct_plus_mutation_evidence_completes_hunk_coverage_and_keeps_direct_provenance() { + let repo = E2eRepo::new("direct-plus-mutation"); + fs::write(repo.root.join("file.rs"), "one\ntwo\nthree\n").expect("the edit should write"); + commit_all(&repo.root, "add two and three"); + + let db = repo.db(); + seed_event( + &db, + &repo.checkout_id(), + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-x"), + ); + + let direct = parse_patch_from_text( + "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", + None, + ) + .expect("the direct patch should parse"); + let mut flow_result = flow_result_for(&repo, direct); + flow_result.tool_name = Some(String::from("claude")); + flow_result.tool_version = Some(String::from("9.9.9")); + + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 1, + "only the line direct evidence did not cover is resolved from mutation history" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(2), + "the union of direct and mutation coverage classifies the hunk ai" + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert_eq!( + trace["tool"], + json!({ "name": "claude", "version": "9.9.9" }) + ); + } + + #[test] + fn a_newer_nonexclusive_event_keeps_the_line_non_ai() { + let repo = E2eRepo::new("newer-nonexclusive"); + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); + commit_all(&repo.root, "add two"); + + let db = repo.db(); + let worktree = repo.checkout_id(); + seed_event( + &db, + &worktree, + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-old"), + ); + seed_event( + &db, + &worktree, + 2, + &repo.parent_tree(), + &repo.head_tree(), + "ai_contended", + None, + ); + + let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 0, + "the newer contended match resolves the line and blocks the older exclusive event" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(0) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("unknown") + ); + } + + #[test] + fn an_adversarial_foreign_worktree_event_cannot_block_the_current_worktrees_exclusive_event() { + let repo = E2eRepo::new("adversarial-linked"); + + let linked_root = repo + .root + .parent() + .expect("the repo should have a parent directory") + .join("linked"); + git( + &repo.root, + &[ + "worktree", + "add", + "-q", + linked_root.to_str().expect("worktree path should be UTF-8"), + ], + ); + + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); + commit_all(&repo.root, "add two"); + + let db = repo.db(); + let current_worktree = repo.checkout_id(); + let foreign_worktree = resolve_worktree_id(&linked_root) + .expect("the linked worktree's identity should resolve") + .0; + assert_ne!( + current_worktree, foreign_worktree, + "the linked worktree must derive its own distinct identity" + ); + + seed_event( + &db, + ¤t_worktree, + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-current"), + ); + seed_event( + &db, + &foreign_worktree, + 2, + &repo.parent_tree(), + &repo.head_tree(), + "ai_contended", + None, + ); + + let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 1, + "only the current worktree's history is eligible, so the older exclusive event contributes" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1), + "worktree isolation lets the current worktree's exclusive event classify the target ai" + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("ai") + ); + assert!(trace.get("tool").is_none()); + } + + fn touched_contents(patch: &ParsedPatch) -> Vec { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| line.content.clone()) + .collect() + } + + #[test] + #[allow(clippy::too_many_lines)] + fn persistence_boundaries_stay_separated_across_diff_traces_intersection_and_agent_trace() { + let repo = E2eRepo::new("persistence-boundary"); + + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the direct edit should write"); + git(&repo.root, &["add", "-A"]); + let intermediate_tree = git(&repo.root, &["write-tree"]).trim().to_owned(); + + fs::write(repo.root.join("file.rs"), "one\ntwo\nthree\n") + .expect("the mutation edit should write"); + commit_all(&repo.root, "add two and three"); + + let base_tree = repo.parent_tree(); + let final_tree = repo.head_tree(); + assert_ne!( + base_tree, intermediate_tree, + "the direct edit must move the tree" + ); + assert_ne!( + intermediate_tree, final_tree, + "the mutation edit must move the tree again" + ); + + let db = repo.db(); + + let now_ms = current_unix_time_ms().expect("the clock should resolve"); + db.insert_diff_trace(DiffTraceInsert { + time_ms: now_ms - 60_000, + session_id: "cc_session-direct", + patch: "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", + model_id: Some("claude/model-direct"), + tool_name: "claude", + tool_version: Some("9.9.9"), + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("the direct diff_traces row should insert"); + + seed_event( + &db, + &repo.checkout_id(), + 1, + &intermediate_tree, + &final_tree, + "ai_exclusive", + Some("scope-mutation"), + ); + + let flow_result = run_post_commit_intersection_flow_with( + &repo.root, + capture_post_commit_patch_from_git, + current_unix_time_ms, + |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), + |insert| db.insert_post_commit_patch_intersection(insert).map(|_| ()), + ) + .expect("the real post-commit intersection flow should run"); + assert_eq!( + touched_contents(&flow_result.combined_recent_patch), + vec!["two".to_owned()], + "the combined recent patch comes from the real diff_traces query, not an in-memory patch" + ); + + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_contents(&mutation_ai_patch), + vec!["three".to_owned()], + "mutation history resolves only the committed line direct evidence missed" + ); + + persist_trace(&flow_result, &db, &mutation_ai_patch); + + assert_eq!( + row_count(&db, "diff_traces"), + 1, + "mutation attribution must not create another diff_traces row" + ); + let stored_direct_patch: String = db + .query_map("SELECT patch FROM diff_traces", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("diff_traces query should succeed") + .into_iter() + .next() + .expect("one diff_traces row should exist"); + let stored_direct = parse_patch_from_text(&stored_direct_patch, None) + .expect("the stored direct patch should parse"); + assert_eq!( + touched_contents(&stored_direct), + vec!["two".to_owned()], + "the direct diff_traces row contains 'two' and never 'three'" + ); + + assert_eq!( + row_count(&db, "post_commit_patch_intersections"), + 1, + "the intersection flow persists exactly one direct-only row" + ); + let stored_intersection_json: String = db + .query_map( + "SELECT intersection_patch FROM post_commit_patch_intersections", + (), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("intersection query should succeed") + .into_iter() + .next() + .expect("one intersection row should exist"); + let stored_intersection = load_patch_from_json(&stored_intersection_json) + .expect("the persisted intersection patch should reconstruct"); + assert_eq!( + touched_contents(&stored_intersection), + vec!["two".to_owned()], + "post_commit_patch_intersections stays direct-only; the mutation line 'three' \ + must never contaminate this table" + ); + + assert_eq!(row_count(&db, "agent_traces"), 1); + let stored_trace_json: String = db + .query_map("SELECT trace_json FROM agent_traces", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("agent_traces query should succeed") + .into_iter() + .next() + .expect("one Agent Trace row should exist"); + let trace: Value = serde_json::from_str(&stored_trace_json) + .expect("the persisted Agent Trace JSON should parse"); + validate_agent_trace_value(&trace).expect( + "the persisted agent_traces.trace_json validates against the embedded Agent Trace schema", + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(2), + "direct + mutation coverage classifies both committed added lines as ai" + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("ai") + ); + + assert_eq!( + trace["tool"], + json!({ "name": "claude", "version": "9.9.9" }) + ); + + assert_eq!( + row_count(&db, "mutation_trace_events"), + 1, + "attribution performs no mutation-cursor write" + ); + } +} + +mod mutation_provenance_e2e { + use super::*; + use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::hooks::claude_mutation_scope; + use crate::services::hooks::codex_mutation_scope; + use crate::services::hooks::opencode_mutation_scope; + use crate::services::hooks::pi_mutation_scope; + use crate::services::mutation_trace::runtime::resolve_git_dir; + use crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch; + + fn git(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + fn row_count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("count row should exist") + } + + struct ProvenanceE2eRepo { + _temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + db_path: PathBuf, + } + + impl ProvenanceE2eRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-mutation-provenance-e2e-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + let storage = resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + _temp: temp, + root, + state_root, + db_path: storage.db_path, + } + } + + fn db(&self) -> RepositoryAgentTraceDb { + RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&self.db_path) + .expect("repository DB should reopen") + } + + fn cwd(&self) -> String { + self.root.to_string_lossy().into_owned() + } + + fn write_change(&self, content: &str) { + fs::write(self.root.join("file.txt"), content).expect("mutation should write"); + } + + fn commit_change(&self) { + git(&self.root, &["add", "-A"]); + git(&self.root, &["commit", "-qm", "AI mutation"]); + } + + fn mutation_events(&self) -> Vec<(String, Option)> { + self.db() + .query_map( + "SELECT attribution_kind, attribution_scope_id \ + FROM mutation_trace_events ORDER BY revision", + (), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id)) + }, + ) + .expect("mutation-events query should succeed") + } + + fn run_post_commit(&self) -> Value { + let db = self.db(); + run_post_commit_subcommand_with( + &self.root, + Some(AgentTraceVcsType::Git), + "git@github.com:acme/widgets.git", + |root| { + run_post_commit_intersection_flow_with( + root, + capture_post_commit_patch_from_git, + current_unix_time_ms, + |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), + |insert| db.insert_post_commit_patch_intersection(insert).map(|_| ()), + ) + }, + |root, flow_result, vcs_type, remote_url| { + let direct_intersection = intersect_patches_fn( + &flow_result.combined_recent_patch, + &flow_result.post_commit_data.parsed_patch, + ); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + root, + &db, + &direct_intersection, + &flow_result.post_commit_data.parsed_patch, + ); + + run_post_commit_agent_trace_flow_with( + flow_result, + vcs_type, + remote_url, + &mutation_ai_patch, + |value| { + validate_agent_trace_value(value) + .map_err(|error| anyhow!(error.to_string())) + }, + |insert| db.insert_agent_trace(insert).map(|_| ()), + ) + }, + |_| Ok(false), + |_| Ok(()), + |_| db.passive_checkpoint(), + None, + ) + .expect("the real post-commit hook flow should persist Agent Trace"); + + db.query_map("SELECT trace_json FROM agent_traces", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("persisted Agent Trace should be readable") + .into_iter() + .next() + .map(|trace| serde_json::from_str(&trace).expect("trace JSON should parse")) + .expect("one Agent Trace row should exist") + } + } + + fn assert_mutation_trace_provenance(trace: &Value, model_id: &str, session_id: &str) { + assert_eq!(trace["files"][0]["path"], json!("file.txt")); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"], + json!({"type": "ai", "model_id": model_id}) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["related"], + json!([{ + "type": "session", + "url": format!("https://sce.crocoder.dev/sessions/{session_id}"), + }]) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + } + + fn opencode_before( + cwd: &str, + session_id: &str, + call_id: &str, + tool_name: &str, + model: Option<&str>, + ) -> String { + let mut payload = json!({ + "hook_event_name": "ToolExecuteBefore", + "session_id": session_id, + "call_id": call_id, + "cwd": cwd, + "tool_name": tool_name, + }); + if let Some(model) = model { + payload["model"] = json!(model); + } + payload.to_string() + } + + fn opencode_shell_env( + cwd: &str, + session_id: &str, + call_id: &str, + model: Option<&str>, + ) -> String { + let mut payload = json!({ + "hook_event_name": "ShellEnv", + "session_id": session_id, + "call_id": call_id, + "cwd": cwd, + }); + if let Some(model) = model { + payload["model"] = json!(model); + } + payload.to_string() + } + + fn opencode_after(cwd: &str, session_id: &str, call_id: &str, tool_name: &str) -> String { + json!({ + "hook_event_name": "ToolExecuteAfter", + "session_id": session_id, + "call_id": call_id, + "cwd": cwd, + "tool_name": tool_name, + }) + .to_string() + } + + fn opencode_tool_error(cwd: &str, session_id: &str, call_id: &str, tool_name: &str) -> String { + json!({ + "hook_event_name": "ToolError", + "session_id": session_id, + "call_id": call_id, + "cwd": cwd, + "tool_name": tool_name, + }) + .to_string() + } + + fn drive_opencode(repo: &ProvenanceE2eRepo, payload: &str) -> Result { + opencode_mutation_scope::run_opencode_mutation_scope_from_payload_at_state_root( + &repo.state_root, + payload, + None, + ) + } + + #[test] + fn opencode_bash_mutation_persists_model_and_session_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("opencode-bash"); + let session_id = "ses_opencode_bash"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_shell_env(&cwd, session_id, "call_bash", Some("opencode/big-pickle")), + ) + .expect("OpenCode shell.env should establish the bash scope"); + + repo.write_change("one\nopencode bash mutation\n"); + + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, "call_bash", "bash"), + ) + .expect("OpenCode ToolExecuteAfter should close the bash scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "opencode/big-pickle", "oc_ses_opencode_bash"); + assert_eq!(row_count(&repo.db(), "diff_traces"), 0); + assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); + assert_eq!(row_count(&repo.db(), "agent_traces"), 1); + } + + #[test] + fn opencode_apply_patch_mutation_with_missing_model_persists_no_model_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("opencode-apply-patch"); + let session_id = "ses_opencode_no_model"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before(&cwd, session_id, "call_patch", "apply_patch", None), + ) + .expect("OpenCode ToolExecuteBefore should establish the apply_patch scope"); + + repo.write_change("one\npatched without model evidence\n"); + + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, "call_patch", "apply_patch"), + ) + .expect("OpenCode ToolExecuteAfter should close the apply_patch scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_eq!(trace["files"][0]["path"], json!("file.txt")); + let contributor = &trace["files"][0]["conversations"][0]["contributor"]; + assert_eq!(contributor["type"], json!("ai")); + assert!( + contributor.get("model_id").is_none(), + "absent model evidence must never be guessed or fabricated" + ); + assert_eq!( + trace["files"][0]["conversations"][0]["related"], + json!([{ + "type": "session", + "url": "https://sce.crocoder.dev/sessions/oc_ses_opencode_no_model", + }]) + ); + } + + #[test] + fn opencode_write_mutation_persists_model_while_task_delegation_stays_zero_footprint() { + let repo = ProvenanceE2eRepo::new("opencode-write-task"); + let session_id = "ses_opencode_write"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before(&cwd, session_id, "call_task", "task", None), + ) + .expect("task delegation ToolExecuteBefore is neutral"); + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, "call_task", "task"), + ) + .expect("task delegation ToolExecuteAfter is neutral"); + + assert_eq!( + row_count(&repo.db(), "mutation_trace_scopes"), + 0, + "a delegation event must create no mutation scope" + ); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + session_id, + "call_write", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("OpenCode write ToolExecuteBefore should establish the scope"); + repo.write_change("one\nwrite mutation\n"); + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, "call_write", "write"), + ) + .expect("OpenCode write ToolExecuteAfter should close the scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "opencode/big-pickle", "oc_ses_opencode_write"); + assert_eq!( + row_count(&repo.db(), "mutation_trace_scopes"), + 1, + "only the tracked write call created a scope" + ); + } + + #[test] + fn opencode_unknown_tool_events_create_no_scope_or_mutation_state() { + let repo = ProvenanceE2eRepo::new("opencode-untracked"); + let session_id = "ses_opencode_untracked"; + let cwd = repo.cwd(); + + for tool_name in ["read", "custom_mcp_tool", "totally_unknown_future_tool"] { + let call_id = format!("call_{tool_name}"); + drive_opencode( + &repo, + &opencode_before(&cwd, session_id, &call_id, tool_name, None), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolExecuteBefore should be neutral")); + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, &call_id, tool_name), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolExecuteAfter should be neutral")); + drive_opencode( + &repo, + &opencode_tool_error(&cwd, session_id, &call_id, tool_name), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolError should be neutral")); + } + + assert_eq!(row_count(&repo.db(), "mutation_trace_scopes"), 0); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 0); + } + + #[test] + fn opencode_child_task_session_gets_its_own_independent_scope_and_provenance() { + let repo = ProvenanceE2eRepo::new("opencode-child-session"); + let parent_session = "ses_opencode_parent"; + let child_session = "ses_opencode_child"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before(&cwd, parent_session, "call_task", "task", None), + ) + .expect("the parent's task delegation is neutral"); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + child_session, + "call_child_write", + "write", + Some("opencode/child-model"), + ), + ) + .expect("the child session's write ToolExecuteBefore should establish its own scope"); + repo.write_change("one\nchild session mutation\n"); + drive_opencode( + &repo, + &opencode_after(&cwd, child_session, "call_child_write", "write"), + ) + .expect("the child session's write ToolExecuteAfter should close its own scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "opencode/child-model", "oc_ses_opencode_child"); + assert_eq!( + row_count(&repo.db(), "mutation_trace_scopes"), + 1, + "the parent's task delegation created no scope; only the child session's write did" + ); + } + + #[test] + fn opencode_concurrent_reject_and_confirm_keeps_only_the_confirmed_mutation_ai() { + let repo = ProvenanceE2eRepo::new("opencode-concurrent-reject"); + let session_id = "ses_opencode_concurrent"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + session_id, + "call_a_edit", + "edit", + Some("opencode/big-pickle"), + ), + ) + .expect("A's edit ToolExecuteBefore should establish a scope"); + drive_opencode( + &repo, + &opencode_before( + &cwd, + session_id, + "call_b_write", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("B's write ToolExecuteBefore should establish a distinct concurrent scope"); + + fs::write(repo.root.join("rejected.txt"), "rejected mutation\n") + .expect("A's mutation should write"); + fs::write( + repo.root.join("ambiguous.txt"), + "B's mutation before recovery\n", + ) + .expect("B's pre-recovery mutation should write"); + + drive_opencode( + &repo, + &opencode_tool_error(&cwd, session_id, "call_a_edit", "edit"), + ) + .expect("A's ToolError should abandon A's scope and consume the shared ambiguous interval"); + + fs::write( + repo.root.join("confirmed.txt"), + "B's mutation after recovery\n", + ) + .expect("B's post-recovery mutation should write"); + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, "call_b_write", "write"), + ) + .expect("B's ToolExecuteAfter should confirm exactly B's own surviving scope"); + + git(&repo.root, &["add", "-A"]); + git(&repo.root, &["commit", "-qm", "concurrent mutation"]); + + let db = repo.db(); + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + &repo.root, + &db, + &ParsedPatch { files: Vec::new() }, + &post_commit_data.parsed_patch, + ); + + let ai_paths: Vec<&str> = mutation_ai_patch + .files + .iter() + .map(|file| file.new_path.as_str()) + .collect(); + assert!( + !ai_paths.contains(&"rejected.txt"), + "the abandoned scope's own mutation must never enter mutation_ai_patch" + ); + assert!( + !ai_paths.contains(&"ambiguous.txt"), + "B's mutation made before the ambiguity-consuming flush is genuinely \ + indistinguishable from A's and must stay non-AI, not merely non-A" + ); + assert!( + ai_paths.contains(&"confirmed.txt"), + "B's own later mutation, made after A's interval was consumed and confirmed \ + by B's own Close, must be attributed AI" + ); + } + + #[test] + fn opencode_and_codex_unconfirmed_overlap_stays_ineligible_until_codex_confirms() { + let repo = ProvenanceE2eRepo::new("opencode-codex-overlap"); + let oc_session = "ses_opencode_overlap"; + let codex_session = "codex-overlap-session"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + oc_session, + "call_oc", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("OpenCode write should establish a scope"); + + let codex_pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": codex_session, + "turn_id": "codex-overlap-turn", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-overlap-bash", + "model": "gpt-5.6-sol", + "tool_input": {"command": "true"}, + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &codex_pre.to_string(), + None, + ) + .expect("Codex Bash PreToolUse should establish a concurrent scope"); + + repo.write_change("one\nopencode overlap mutation\n"); + + drive_opencode(&repo, &opencode_after(&cwd, oc_session, "call_oc", "write")) + .expect("OpenCode ToolExecuteAfter should close its own scope"); + + let attribution_after_first_close = repo.mutation_events(); + assert_eq!( + attribution_after_first_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ineligible_unscoped"), + "an unconfirmed live Codex scope must suppress OpenCode's own confirming close" + ); + + let codex_post = json!({ + "hook_event_name": "PostToolUse", + "session_id": codex_session, + "turn_id": "codex-overlap-turn", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-overlap-bash", + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &codex_post.to_string(), + None, + ) + .expect("Codex PostToolUse should close its own scope"); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + oc_session, + "call_oc_2", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("a fresh OpenCode write should establish a new scope"); + repo.write_change("one\nopencode overlap mutation\nsecond change\n"); + drive_opencode( + &repo, + &opencode_after(&cwd, oc_session, "call_oc_2", "write"), + ) + .expect("the fresh OpenCode scope should close cleanly once Codex is confirmed"); + + let attribution_after_second_close = repo.mutation_events(); + assert_eq!( + attribution_after_second_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ai_exclusive"), + "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" + ); + } + + #[test] + fn opencode_and_claude_overlap_produces_ai_contended() { + let repo = ProvenanceE2eRepo::new("opencode-claude-overlap"); + let oc_session = "ses_opencode_contended"; + let claude_session = "claude-overlap-session"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + oc_session, + "call_oc_contended", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("OpenCode write should establish a scope"); + + let claude_pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": claude_session, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-overlap-bash", + "tool_input": {"command": "printf mutation"}, + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &claude_pre.to_string(), + None, + ) + .expect( + "Claude Bash PreToolUse should establish a concurrent, non-confirmation-required scope", + ); + + repo.write_change("one\ncontended mutation\n"); + + drive_opencode( + &repo, + &opencode_after(&cwd, oc_session, "call_oc_contended", "write"), + ) + .expect("OpenCode ToolExecuteAfter should confirm its own scope"); + + let attribution = repo.mutation_events(); + assert_eq!( + attribution.last().map(|(kind, _)| kind.as_str()), + Some("ai_contended"), + "a confirmed OpenCode close alongside a live non-confirmation-required Claude scope is contended, not suppressed" + ); + + let claude_post = json!({ + "hook_event_name": "PostToolUse", + "session_id": claude_session, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-overlap-bash", + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &claude_post.to_string(), + None, + ) + .expect("Claude PostToolUse should close its own scope"); + } + + #[test] + fn claude_bash_mutation_persists_model_and_session_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("claude"); + let session_id = "claude-session-e2e"; + let db = repo.db(); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: format!("cc_{session_id}"), + agent_id: String::new(), + model_id: String::from("claude/opus-4-1"), + observation_kind: ObservationKind::SessionStart, + source: String::from("test"), + observed_at_ms: 1, + }) + .expect("Claude model state should be persisted"); + + let cwd = repo.cwd(); + let pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": session_id, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-bash-e2e", + "tool_input": {"command": "printf mutation"}, + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &pre.to_string(), + None, + ) + .expect("Claude Bash PreToolUse should establish a scope"); + + let post = json!({ + "hook_event_name": "PostToolUse", + "session_id": session_id, + "cwd": repo.cwd(), + "tool_name": "Bash", + "tool_use_id": "claude-bash-e2e", + }); + repo.write_change("one\nclaude mutation\n"); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &post.to_string(), + None, + ) + .expect("Claude Bash PostToolUse should close the scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "claude/opus-4-1", "cc_claude-session-e2e"); + assert_eq!(row_count(&repo.db(), "diff_traces"), 0); + assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); + assert_eq!(row_count(&repo.db(), "agent_traces"), 1); + } + + #[test] + fn codex_bash_mutation_persists_model_and_session_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("codex"); + let session_id = "codex-session-e2e"; + let cwd = repo.cwd(); + let pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": session_id, + "turn_id": "codex-turn-e2e", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-bash-e2e", + "model": "gpt-5.6-sol", + "tool_input": {"command": "true"}, + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &pre.to_string(), + None, + ) + .expect("Codex Bash PreToolUse should establish a scope"); + + let post = json!({ + "hook_event_name": "PostToolUse", + "session_id": session_id, + "turn_id": "codex-turn-e2e", + "cwd": repo.cwd(), + "tool_name": "Bash", + "tool_use_id": "codex-bash-e2e", + }); + repo.write_change("one\ncodex mutation\n"); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &post.to_string(), + None, + ) + .expect("Codex Bash PostToolUse should close the scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "gpt-5.6-sol", "cx_codex-session-e2e"); + assert_eq!(row_count(&repo.db(), "diff_traces"), 0); + assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); + assert_eq!(row_count(&repo.db(), "agent_traces"), 1); + } + + fn pi_tool_call( + cwd: &str, + session_id: &str, + tool_call_id: &str, + tool_name: &str, + model: Option<&str>, + ) -> String { + let mut payload = json!({ + "hook_event_name": "ToolCall", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": cwd, + "tool_name": tool_name, + }); + if let Some(model) = model { + payload["model"] = json!(model); + } + payload.to_string() + } + + fn pi_tool_result(cwd: &str, session_id: &str, tool_call_id: &str, tool_name: &str) -> String { + json!({ + "hook_event_name": "ToolResult", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": cwd, + "tool_name": tool_name, + }) + .to_string() + } + + fn pi_tool_execution_end( + cwd: &str, + session_id: &str, + tool_call_id: &str, + tool_name: &str, + ) -> String { + json!({ + "hook_event_name": "ToolExecutionEnd", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": cwd, + "tool_name": tool_name, + }) + .to_string() + } + + fn drive_pi(repo: &ProvenanceE2eRepo, payload: &str) -> Result { + pi_mutation_scope::run_pi_mutation_scope_from_payload_at_state_root( + &repo.state_root, + payload, + None, + ) + } + + fn pi_confirmed_tool_case(tool_name: &str, label: &str) { + let repo = ProvenanceE2eRepo::new(label); + let session_id = format!("ses-{label}"); + let call_id = format!("call-{label}"); + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + &session_id, + &call_id, + tool_name, + Some("anthropic/opus-5"), + ), + ) + .expect("Pi ToolCall should establish the tracked scope before execution"); + + repo.write_change(&format!("one\npi {tool_name} mutation\n")); + + drive_pi( + &repo, + &pi_tool_result(&cwd, &session_id, &call_id, tool_name), + ) + .expect("Pi ToolResult should mark the attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, &session_id, &call_id, tool_name), + ) + .expect("Pi ToolExecutionEnd paired with an observed ToolResult should Close"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "anthropic/opus-5", &format!("pi_{session_id}")); + assert_eq!(row_count(&repo.db(), "diff_traces"), 0); + assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); + assert_eq!(row_count(&repo.db(), "agent_traces"), 1); + } + + #[test] + fn pi_bash_mutation_persists_model_and_session_in_agent_trace() { + pi_confirmed_tool_case("bash", "pi-bash"); + } + + #[test] + fn pi_write_mutation_persists_model_and_session_in_agent_trace() { + pi_confirmed_tool_case("write", "pi-write"); + } + + #[test] + fn pi_edit_mutation_persists_model_and_session_in_agent_trace() { + pi_confirmed_tool_case("edit", "pi-edit"); + } + + #[test] + fn pi_missing_model_preserves_session_with_null_model_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("pi-no-model"); + let session_id = "ses-pi-no-model"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call(&cwd, session_id, "call-1", "bash", None), + ) + .expect("Pi ToolCall should establish the scope without model evidence"); + + repo.write_change("one\npi mutation without model\n"); + + drive_pi(&repo, &pi_tool_result(&cwd, session_id, "call-1", "bash")) + .expect("Pi ToolResult should mark the attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), + ) + .expect("Pi ToolExecutionEnd should close the scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_eq!(trace["files"][0]["path"], json!("file.txt")); + let contributor = &trace["files"][0]["conversations"][0]["contributor"]; + assert_eq!(contributor["type"], json!("ai")); + assert!( + contributor.get("model_id").is_none(), + "absent model evidence must never be guessed or fabricated" + ); + assert_eq!( + trace["files"][0]["conversations"][0]["related"], + json!([{ + "type": "session", + "url": "https://sce.crocoder.dev/sessions/pi_ses-pi-no-model", + }]) + ); + } + + #[test] + fn pi_read_only_and_unknown_tools_create_no_scope_or_mutation_state() { + let repo = ProvenanceE2eRepo::new("pi-untracked"); + let session_id = "ses-pi-untracked"; + let cwd = repo.cwd(); + + for tool_name in [ + "read", + "grep", + "find", + "ls", + "custom_mcp_tool", + "totally_unknown_future_tool", + "user_bash", + ] { + let call_id = format!("call-{tool_name}"); + drive_pi( + &repo, + &pi_tool_call(&cwd, session_id, &call_id, tool_name, None), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolCall should be neutral")); + drive_pi( + &repo, + &pi_tool_result(&cwd, session_id, &call_id, tool_name), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolResult should be neutral")); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, &call_id, tool_name), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolExecutionEnd should be neutral")); + } + + assert_eq!(row_count(&repo.db(), "mutation_trace_scopes"), 0); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 0); + } + + #[test] + fn pi_later_extension_rejection_after_start_produces_no_mutation_ai_patch() { + let repo = ProvenanceE2eRepo::new("pi-later-rejection"); + let session_id = "ses-pi-rejected"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call(&cwd, session_id, "call-1", "bash", Some("anthropic/opus-5")), + ) + .expect("Pi ToolCall should establish the scope before a later extension can reject it"); + + fs::write( + repo.root.join("rejected.txt"), + "should never be attributed AI\n", + ) + .expect("the blocked attempt's incidental write should still land on disk"); + + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), + ) + .expect("ToolExecutionEnd with no preceding ToolResult must abandon, not error"); + + let scope_status = repo + .db() + .query_map("SELECT status FROM mutation_trace_scopes", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("scope-status query should succeed"); + assert_eq!( + scope_status, + vec!["abandoned".to_string()], + "the D7 abandon path must leave the scope durably abandoned, never closed or active" + ); + + git(&repo.root, &["add", "-A"]); + git(&repo.root, &["commit", "-qm", "rejected mutation"]); + + let db = repo.db(); + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + &repo.root, + &db, + &ParsedPatch { files: Vec::new() }, + &post_commit_data.parsed_patch, + ); + + assert!( + mutation_ai_patch.files.is_empty(), + "a Start that never reached a confirmed Close must never produce mutation_ai_patch entries" + ); + } + + #[test] + fn pi_mutate_then_error_still_persists_confirmed_mutation_through_close() { + let repo = ProvenanceE2eRepo::new("pi-error-executed"); + let session_id = "ses-pi-error"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call(&cwd, session_id, "call-1", "bash", Some("anthropic/opus-5")), + ) + .expect("Pi ToolCall should establish the scope"); + + repo.write_change("one\npartial mutation before failure\n"); + + let mut result_payload: Value = + serde_json::from_str(&pi_tool_result(&cwd, session_id, "call-1", "bash")) + .expect("tool_result payload should parse as JSON"); + result_payload["isError"] = json!(true); + drive_pi(&repo, &result_payload.to_string()) + .expect("a failed-but-executed ToolResult is still positive execution evidence"); + + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), + ) + .expect("ToolExecutionEnd paired with an observed ToolResult must Close, not abandon"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "anthropic/opus-5", "pi_ses-pi-error"); + } + + #[test] + fn pi_concurrent_reject_and_confirm_keeps_only_the_confirmed_mutation_ai() { + let repo = ProvenanceE2eRepo::new("pi-concurrent-reject"); + let session_id = "ses-pi-concurrent"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + session_id, + "call-a-edit", + "edit", + Some("anthropic/opus-5"), + ), + ) + .expect("A's edit ToolCall should establish a scope"); + drive_pi( + &repo, + &pi_tool_call( + &cwd, + session_id, + "call-b-write", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("B's write ToolCall should establish a distinct concurrent scope"); + + fs::write(repo.root.join("rejected.txt"), "rejected mutation\n") + .expect("A's mutation should write"); + fs::write( + repo.root.join("ambiguous.txt"), + "B's mutation before recovery\n", + ) + .expect("B's pre-recovery mutation should write"); + + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, "call-a-edit", "edit"), + ) + .expect( + "A's ToolExecutionEnd with no ToolResult should abandon A's scope and consume \ + the shared ambiguous interval", + ); + + fs::write( + repo.root.join("confirmed.txt"), + "B's mutation after recovery\n", + ) + .expect("B's post-recovery mutation should write"); + + drive_pi( + &repo, + &pi_tool_result(&cwd, session_id, "call-b-write", "write"), + ) + .expect("B's ToolResult should mark it executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, "call-b-write", "write"), + ) + .expect("B's ToolExecutionEnd should confirm exactly B's own surviving scope"); + + git(&repo.root, &["add", "-A"]); + git(&repo.root, &["commit", "-qm", "concurrent mutation"]); + + let db = repo.db(); + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + &repo.root, + &db, + &ParsedPatch { files: Vec::new() }, + &post_commit_data.parsed_patch, + ); + + let ai_paths: Vec<&str> = mutation_ai_patch + .files + .iter() + .map(|file| file.new_path.as_str()) + .collect(); + assert!( + !ai_paths.contains(&"rejected.txt"), + "the abandoned scope's own mutation must never enter mutation_ai_patch" + ); + assert!( + !ai_paths.contains(&"ambiguous.txt"), + "B's mutation made before the ambiguity-consuming flush is genuinely \ + indistinguishable from A's and must stay non-AI, not merely non-A" + ); + assert!( + ai_paths.contains(&"confirmed.txt"), + "B's own later mutation, made after A's interval was consumed and confirmed \ + by B's own Close, must be attributed AI" + ); + } + + #[test] + fn pi_and_claude_overlap_produces_ai_contended() { + let repo = ProvenanceE2eRepo::new("pi-claude-overlap"); + let pi_session = "ses-pi-contended"; + let claude_session = "claude-pi-overlap-session"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + pi_session, + "call-pi-contended", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("Pi write should establish a scope"); + + let claude_pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": claude_session, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-pi-overlap-bash", + "tool_input": {"command": "printf mutation"}, + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &claude_pre.to_string(), + None, + ) + .expect( + "Claude Bash PreToolUse should establish a concurrent, non-confirmation-required scope", + ); + + repo.write_change("one\npi+claude contended mutation\n"); + + drive_pi( + &repo, + &pi_tool_result(&cwd, pi_session, "call-pi-contended", "write"), + ) + .expect("Pi ToolResult should mark the attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, pi_session, "call-pi-contended", "write"), + ) + .expect("Pi ToolExecutionEnd should confirm its own scope"); + + let attribution = repo.mutation_events(); + assert_eq!( + attribution.last().map(|(kind, _)| kind.as_str()), + Some("ai_contended"), + "a confirmed Pi close alongside a live non-confirmation-required Claude scope is contended, not suppressed" + ); + + let claude_post = json!({ + "hook_event_name": "PostToolUse", + "session_id": claude_session, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-pi-overlap-bash", + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &claude_post.to_string(), + None, + ) + .expect("Claude PostToolUse should close its own scope"); + } + + #[test] + fn pi_and_codex_overlap_stays_ineligible_until_codex_confirms() { + let repo = ProvenanceE2eRepo::new("pi-codex-overlap"); + let pi_session = "ses-pi-overlap"; + let codex_session = "codex-pi-overlap-session"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + pi_session, + "call-pi", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("Pi write should establish a scope"); + + let codex_pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": codex_session, + "turn_id": "codex-pi-overlap-turn", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-pi-overlap-bash", + "model": "gpt-5.6-sol", + "tool_input": {"command": "true"}, + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &codex_pre.to_string(), + None, + ) + .expect("Codex Bash PreToolUse should establish a concurrent scope"); + + repo.write_change("one\npi codex overlap mutation\n"); + + drive_pi(&repo, &pi_tool_result(&cwd, pi_session, "call-pi", "write")) + .expect("Pi ToolResult should mark the attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, pi_session, "call-pi", "write"), + ) + .expect("Pi ToolExecutionEnd should attempt to confirm its own scope"); + + let attribution_after_first_close = repo.mutation_events(); + assert_eq!( + attribution_after_first_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ineligible_unscoped"), + "an unconfirmed live Codex scope must suppress Pi's own confirming close" + ); + + let codex_post = json!({ + "hook_event_name": "PostToolUse", + "session_id": codex_session, + "turn_id": "codex-pi-overlap-turn", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-pi-overlap-bash", + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &codex_post.to_string(), + None, + ) + .expect("Codex PostToolUse should close its own scope"); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + pi_session, + "call-pi-2", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("a fresh Pi write should establish a new scope"); + repo.write_change("one\npi codex overlap mutation\nsecond change\n"); + drive_pi( + &repo, + &pi_tool_result(&cwd, pi_session, "call-pi-2", "write"), + ) + .expect("Pi ToolResult should mark the fresh attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, pi_session, "call-pi-2", "write"), + ) + .expect("the fresh Pi scope should close cleanly once Codex is confirmed"); + + let attribution_after_second_close = repo.mutation_events(); + assert_eq!( + attribution_after_second_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ai_exclusive"), + "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" + ); + } + + #[test] + fn pi_and_opencode_overlap_stays_ineligible_until_opencode_confirms() { + let repo = ProvenanceE2eRepo::new("pi-opencode-overlap"); + let pi_session = "ses-pi-oc-overlap"; + let oc_session = "ses_opencode_pi_overlap"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + pi_session, + "call-pi", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("Pi write should establish a scope"); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + oc_session, + "call_oc", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("OpenCode write ToolExecuteBefore should establish a concurrent scope"); + + repo.write_change("one\npi opencode overlap mutation\n"); + + drive_pi(&repo, &pi_tool_result(&cwd, pi_session, "call-pi", "write")) + .expect("Pi ToolResult should mark the attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, pi_session, "call-pi", "write"), + ) + .expect("Pi ToolExecutionEnd should attempt to confirm its own scope"); + + let attribution_after_first_close = repo.mutation_events(); + assert_eq!( + attribution_after_first_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ineligible_unscoped"), + "an unconfirmed live OpenCode scope must suppress Pi's own confirming close" + ); + + drive_opencode(&repo, &opencode_after(&cwd, oc_session, "call_oc", "write")) + .expect("OpenCode ToolExecuteAfter should close its own scope"); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + pi_session, + "call-pi-2", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("a fresh Pi write should establish a new scope"); + repo.write_change("one\npi opencode overlap mutation\nsecond change\n"); + drive_pi( + &repo, + &pi_tool_result(&cwd, pi_session, "call-pi-2", "write"), + ) + .expect("Pi ToolResult should mark the fresh attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, pi_session, "call-pi-2", "write"), + ) + .expect("the fresh Pi scope should close cleanly once OpenCode is confirmed"); + + let attribution_after_second_close = repo.mutation_events(); + assert_eq!( + attribution_after_second_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ai_exclusive"), + "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" + ); + } + + #[test] + fn pi_stale_process_recovery_discards_ambiguous_interval_while_fresh_pi_work_remains_usable() { + let repo = ProvenanceE2eRepo::new("pi-stale-recovery"); + let stale_session = "ses-pi-stale"; + let fresh_session = "ses-pi-fresh"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + stale_session, + "call-stale", + "bash", + Some("anthropic/opus-5"), + ), + ) + .expect("the stale attempt's Pi ToolCall should establish a scope"); + + let git_dir = resolve_git_dir(&repo.root).expect("git dir should resolve"); + let scope_id = pi_mutation_scope::state::read_state(&git_dir) + .expect("state should be readable") + .attempts + .iter() + .find(|attempt| attempt.session_id == stale_session) + .expect("the stale attempt should exist") + .scope_id + .clone(); + pi_mutation_scope::force_attempt_owner_dead_for_tests(&git_dir, &scope_id); + + fs::write( + repo.root.join("ambiguous.txt"), + "left behind by the dead Pi process\n", + ) + .expect("the stale attempt's own mutation should still land on disk"); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + fresh_session, + "call-fresh", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("a fresh Pi ToolCall should trigger dead-owner recovery and then establish its own scope"); + + fs::write(repo.root.join("confirmed.txt"), "the fresh Pi work\n") + .expect("the fresh attempt's mutation should write"); + + drive_pi( + &repo, + &pi_tool_result(&cwd, fresh_session, "call-fresh", "write"), + ) + .expect("the fresh attempt's ToolResult should mark it executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, fresh_session, "call-fresh", "write"), + ) + .expect("the fresh attempt should close and reach AiExclusive"); + + git(&repo.root, &["add", "-A"]); + git(&repo.root, &["commit", "-qm", "stale recovery"]); + + let db = repo.db(); + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + &repo.root, + &db, + &ParsedPatch { files: Vec::new() }, + &post_commit_data.parsed_patch, + ); + + let ai_paths: Vec<&str> = mutation_ai_patch + .files + .iter() + .map(|file| file.new_path.as_str()) + .collect(); + assert!( + !ai_paths.contains(&"ambiguous.txt"), + "the dead process's ambiguous interval must never be attributed AI" + ); + assert!( + ai_paths.contains(&"confirmed.txt"), + "later fresh Pi work must remain usable and reach AiExclusive" + ); + + let attribution = repo.mutation_events(); + assert_eq!( + attribution.last().map(|(kind, _)| kind.as_str()), + Some("ai_exclusive"), + "the fresh attempt, unencumbered by the recovered stale scope, should reach AiExclusive" + ); + } +} + +#[test] +fn post_commit_auto_sync_does_not_launch_when_disabled() { + let launch_called = RefCell::new(false); + + run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, _, _, _| Ok(minimal_agent_trace()), + |_| Ok(false), + |_| { + *launch_called.borrow_mut() = true; + Ok(()) + }, + |_| Ok(()), + None, + ) + .expect("disabled auto-sync should not affect post-commit success"); + + assert!(!*launch_called.borrow()); +} + +#[test] +fn post_commit_persistence_failure_does_not_launch_auto_sync() { + let launch_called = RefCell::new(false); + + let error = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, _, _, _| Err(anyhow!("Agent Trace persistence failed")), + |_| panic!("auto-sync config must not be resolved after persistence failure"), + |_| { + *launch_called.borrow_mut() = true; + Ok(()) + }, + |_| panic!("checkpoint must not run after persistence failure"), + None, + ) + .expect_err("persistence failure should be returned"); + + assert!(error.to_string().contains("persistence failed")); + assert!(!*launch_called.borrow()); +} + +#[test] +fn post_commit_auto_sync_launcher_failure_is_fail_open() { + let output = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, _, _, _| Ok(minimal_agent_trace()), + |_| Ok(true), + |_| Err(anyhow!("spawn unavailable")), + |_| Ok(()), + None, + ) + .expect("launcher failure must not affect post-commit success"); + + assert!(output.contains("post-commit hook processed intersection")); +} + +#[derive(Default)] +struct RecordingLogger { + warnings: std::sync::Mutex>, +} + +impl Logger for RecordingLogger { + fn info( + &self, + _event_id: &str, + _message: &str, + _fields: &[(&str, &str)], + _session_id: Option<&str>, + ) { + } + + fn debug( + &self, + _event_id: &str, + _message: &str, + _fields: &[(&str, &str)], + _session_id: Option<&str>, + ) { + } + + fn warn( + &self, + event_id: &str, + message: &str, + _fields: &[(&str, &str)], + _session_id: Option<&str>, + ) { + self.warnings + .lock() + .expect("warnings mutex should not be poisoned") + .push((event_id.to_string(), message.to_string())); + } + + fn error( + &self, + _event_id: &str, + _message: &str, + _fields: &[(&str, &str)], + _session_id: Option<&str>, + ) { + } + + fn log_cli_error(&self, _error: &crate::services::error::CliError, _session_id: Option<&str>) {} +} + +#[test] +fn post_commit_checkpoint_runs_once_after_successful_persistence() { + let events = RefCell::new(Vec::new()); + + let output = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, _, _, _| { + events.borrow_mut().push("persistence"); + Ok(minimal_agent_trace()) + }, + |_| Ok(false), + |_| Ok(()), + |_| { + events.borrow_mut().push("checkpoint"); + Ok(()) + }, + None, + ) + .expect("successful checkpoint should not affect post-commit success"); + + assert!(output.contains("post-commit hook processed intersection")); + assert_eq!(events.into_inner(), vec!["persistence", "checkpoint"]); +} + +#[test] +fn post_commit_checkpoint_failure_is_fail_open_and_logs_warning() { + let logger = RecordingLogger::default(); + let persisted = RefCell::new(false); + + let output = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, _, _, _| { + *persisted.borrow_mut() = true; + Ok(minimal_agent_trace()) + }, + |_| Ok(false), + |_| Ok(()), + |_| Err(anyhow!("checkpoint failed")), + Some(&logger), + ) + .expect("checkpoint failure must not affect post-commit success"); + + assert!(output.contains("post-commit hook processed intersection")); + assert!(*persisted.borrow()); + assert_eq!( + logger + .warnings + .into_inner() + .expect("warnings mutex should not be poisoned"), + vec![( + String::from("sce.agent_trace_db.passive_checkpoint_failed"), + String::from("checkpoint failed") + )] + ); +} diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index 9b585983..bb1cadab 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -42,14 +42,10 @@ fn repo_local_config_bootstrap_payload() -> String { pub const NAME: &str = "setup"; -/// Classifies repository-root resolution failures while retaining the -/// underlying technical error for the CLI's observability boundary. #[derive(Debug)] pub enum GitRepositoryResolutionError { - /// Git positively identified the target as outside a repository. NotGitRepository(anyhow::Error), - /// Resolution failed for an unexpected filesystem, process, or output - /// reason. + Unexpected(anyhow::Error), } @@ -152,8 +148,6 @@ fn embedded_assets_for_concrete_target(target: SetupTarget) -> &'static [Embedde #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct WorkflowAssetLayout { - /// `None` for a target with no command directory (skills only), such as - /// Codex. command_dir: Option<&'static str>, skills_dir: &'static str, } @@ -255,7 +249,7 @@ pub struct SetupCliOptions { pub hooks: bool, pub repo_path: Option, pub bootstrap_context: bool, - /// Repeated `--workflow ` values. Empty means the flag was absent. + pub workflows: Vec, } @@ -265,8 +259,7 @@ pub struct SetupRequest { pub install_hooks: bool, pub hooks_repo_path: Option, pub context_only: bool, - /// The optional workflows this run installs. `None` means no selection was - /// supplied, so the persisted `integrations.optional_workflows` is reused. + pub optional_workflows: Option>, } @@ -416,8 +409,6 @@ pub fn run_setup_for_mode( SetupMode::NonInteractive(target) => target, }; - // A supplied selection is the exact selection for this run; without one the - // persisted selection is reused so a repeat run does not uninstall it. let selected_optional_workflows = match optional_workflows { Some(selection) => selection.to_vec(), None => persisted_optional_workflows(repository_root), @@ -432,7 +423,6 @@ pub fn run_setup_for_mode( ) })?; - // Persist selected integration targets and optional workflows in repo-local config. persist_integration_targets( repository_root, target, @@ -450,8 +440,6 @@ pub fn run_setup_for_mode( Ok(format_setup_install_success_message(&outcome)) } -/// The optional workflows recorded in repo-local `.sce/config.json`, or an empty -/// selection when the file is absent, unreadable, or records none. pub fn persisted_optional_workflows(repository_root: &Path) -> Vec { use crate::services::config::schema::parse_file_config; use crate::services::config::ConfigPathSource; @@ -713,8 +701,7 @@ pub struct RequiredHookInstallResult { pub hook_name: String, pub hook_path: PathBuf, pub status: RequiredHookInstallStatus, - /// True when the hook's foreign content ends in a zero-indent `exec` or - /// `exit`, so the appended SCE managed block would never run. + pub unreachable_block_advisory: bool, } @@ -770,7 +757,6 @@ pub(crate) fn cleanup_path_if_exists(path: &Path) { fs::remove_file(path) }; - // Best-effort cleanup; log errors but don't fail the operation if let Err(e) = cleanup_result { eprintln!( "Warning: Failed to clean up temporary path '{}': {}", @@ -795,8 +781,6 @@ pub(crate) fn concrete_targets_for(target: SetupTarget) -> &'static [SetupTarget } } -/// Convert a concrete [`SetupTarget`] (not `All`) to its canonical -/// `integrations.target` string representation. fn integration_target_id_str(target: SetupTarget) -> &'static str { match target { SetupTarget::OpenCode => "opencode", @@ -824,7 +808,6 @@ pub fn persist_integration_targets( return Ok(()); } - // Read existing config or start with bootstrap payload. let raw = if config_file.exists() { fs::read_to_string(&config_file) .with_context(|| format!("Failed to read config file '{}'", config_file.display()))? @@ -848,7 +831,6 @@ pub fn persist_integration_targets( ) })?; - // Collect existing integration target values, if any. let mut existing_targets: Vec = config_obj .get("integrations") .and_then(|i| i.get("target")) @@ -860,7 +842,6 @@ pub fn persist_integration_targets( }) .unwrap_or_default(); - // Add new concrete targets (expanding All), deduping as we go. let new_targets = concrete_targets_for(target); for concrete in new_targets { let id_str = integration_target_id_str(*concrete); @@ -870,8 +851,6 @@ pub fn persist_integration_targets( } } - // Write the merged integrations block back. The optional-workflow selection - // resolved for this run replaces any previously recorded selection. config_obj.insert( "integrations".to_string(), json!({ @@ -1538,21 +1517,15 @@ mod install { } } - /// True for the one asset the Claude install path merges into an existing - /// document instead of overwriting: `.claude/settings.json`. fn is_claude_settings_merge_target(target: SetupTarget, relative_path: &str) -> bool { target == SetupTarget::Claude && relative_path == claude_asset::SETTINGS_FILE } - /// True for the one asset the `OpenCode` install path merges into an existing - /// document instead of overwriting: `.opencode/opencode.json`. fn is_opencode_config_merge_target(target: SetupTarget, relative_path: &str) -> bool { target == SetupTarget::OpenCode && relative_path == default_paths::repo_file::OPENCODE_MANIFEST } - /// True for Codex's user-owned hook registry, which is merged rather than - /// overwritten so setup preserves unrelated Codex handlers and settings. fn is_codex_hooks_merge_target(target: SetupTarget, relative_path: &str) -> bool { target == SetupTarget::Codex && relative_path == ".codex/hooks.json" } @@ -1873,7 +1846,6 @@ mod prompt { } } - /// One selectable row per optional workflow, in catalog order. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct OptionalWorkflowRow { pub(super) workflow: &'static OptionalWorkflow, @@ -2025,1131 +1997,4 @@ pub fn setup_cancelled_text() -> String { } #[cfg(test)] -mod tests { - use super::*; - use std::process::Command; - use std::time::{SystemTime, UNIX_EPOCH}; - - use crate::command_surface; - use crate::services::command_registry::CommandRegistry; - use crate::services::command_registry::RuntimeCommand; - use crate::services::parse::command_runtime::parse_runtime_command; - - fn options_with(mutate: impl FnOnce(&mut SetupCliOptions)) -> SetupCliOptions { - let mut options = SetupCliOptions::default(); - mutate(&mut options); - options - } - - fn unique_temp_dir(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after Unix epoch") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "sce-setup-context-{label}-{}-{nonce}", - std::process::id() - )); - fs::create_dir_all(&dir).expect("create temp dir"); - dir - } - - fn init_git_repo(label: &str) -> PathBuf { - let repo = unique_temp_dir(label); - let output = Command::new("git") - .args(["init", "-q"]) - .current_dir(&repo) - .output() - .expect("git init should spawn"); - assert!( - output.status.success(), - "git init failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - repo - } - - fn assert_baseline_paths_exist(repo: &Path) { - let paths = RepoPaths::new(repo); - for path in [ - paths.context_overview_file(), - paths.context_architecture_file(), - paths.context_patterns_file(), - paths.context_glossary_file(), - paths.context_map_file(), - paths.context_plans_dir(), - paths.context_handovers_dir(), - paths.context_decisions_dir(), - paths.context_tmp_dir(), - paths.context_tmp_gitignore_file(), - ] { - assert!(path.exists(), "expected baseline path {}", path.display()); - } - } - - #[test] - fn repo_local_config_bootstrap_payload_uses_versioned_schema_url() { - let payload = repo_local_config_bootstrap_payload(); - - assert_eq!( - payload, - format!( - "{{\n \"$schema\": \"https://sce.crocoder.dev/v{}/config.json\",\n \"agent_trace\": {{\n \"auto_sync\": true\n }},\n \"policies\": {{\n \"attribution_hooks\": {{\n \"enabled\": true\n }}\n }}\n}}\n", - env!("CARGO_PKG_VERSION") - ) - ); - } - - #[test] - fn resolve_setup_request_accepts_pi_target() { - let request = resolve_setup_request(options_with(|options| { - options.pi = true; - options.non_interactive = true; - })) - .expect("pi target should resolve"); - - assert_eq!( - request.config_mode, - Some(SetupMode::NonInteractive(SetupTarget::Pi)) - ); - assert!(!request.context_only); - } - - #[test] - fn resolve_setup_request_accepts_codex_target() { - let request = resolve_setup_request(options_with(|options| { - options.codex = true; - options.non_interactive = true; - })) - .expect("codex target should resolve"); - - assert_eq!( - request.config_mode, - Some(SetupMode::NonInteractive(SetupTarget::Codex)) - ); - assert!(!request.context_only); - } - - #[test] - fn resolve_setup_request_accepts_all_target() { - let request = resolve_setup_request(options_with(|options| { - options.all = true; - options.non_interactive = true; - })) - .expect("all target should resolve"); - - assert_eq!( - request.config_mode, - Some(SetupMode::NonInteractive(SetupTarget::All)) - ); - assert!(!request.context_only); - } - - #[test] - fn resolve_setup_request_accepts_bootstrap_context_alone() { - let request = resolve_setup_request(options_with(|options| { - options.bootstrap_context = true; - })) - .expect("bootstrap-context alone should resolve"); - - assert!(request.context_only); - assert_eq!(request.config_mode, None); - assert!(!request.install_hooks); - assert_eq!(request.hooks_repo_path, None); - } - - #[test] - fn resolve_setup_request_rejects_bootstrap_context_with_target() { - let error = resolve_setup_request(options_with(|options| { - options.bootstrap_context = true; - options.opencode = true; - })) - .expect_err("bootstrap-context with target must be rejected"); - - assert!(error.to_string().contains("--bootstrap-context")); - assert!(error.to_string().contains("alone")); - } - - #[test] - fn resolve_setup_request_rejects_combined_target_flags() { - let error = resolve_setup_request(options_with(|options| { - options.pi = true; - options.all = true; - })) - .expect_err("combined target flags must be rejected"); - - assert!(error.to_string().contains("mutually exclusive")); - } - - #[test] - fn resolve_setup_request_non_interactive_error_lists_pi_and_all() { - let error = resolve_setup_request(options_with(|options| { - options.non_interactive = true; - })) - .expect_err("non-interactive without target must be rejected"); - - let message = error.to_string(); - assert!(message.contains("--pi")); - assert!(message.contains("--all")); - } - - #[test] - fn parser_routes_bootstrap_context_to_context_only_request() { - let registry = CommandRegistry::default(); - let command = parse_runtime_command( - [ - "sce".to_string(), - "setup".to_string(), - "--bootstrap-context".to_string(), - ], - ®istry, - None, - ) - .expect("bootstrap-context should parse"); - - match command { - RuntimeCommand::Setup(setup_command) => { - assert!(setup_command.request.context_only); - assert_eq!(setup_command.request.config_mode, None); - assert!(!setup_command.request.install_hooks); - } - _ => panic!("expected Setup command for --bootstrap-context"), - } - } - - #[test] - fn help_documents_bootstrap_context_flag() { - let top_level_help = command_surface::help_text(); - assert!( - top_level_help.contains("--bootstrap-context"), - "top-level help should document --bootstrap-context" - ); - - let registry = CommandRegistry::default(); - let command = parse_runtime_command( - ["sce".to_string(), "setup".to_string(), "--help".to_string()], - ®istry, - None, - ) - .expect("setup --help should parse"); - - match command { - RuntimeCommand::HelpText(help) => { - assert!( - help.text.contains("--bootstrap-context"), - "setup --help should document --bootstrap-context:\n{}", - help.text - ); - } - _ => panic!("expected HelpText for setup --help"), - } - } - - #[test] - fn bootstrap_context_baseline_creates_expected_paths() { - let repo = init_git_repo("create-baseline"); - let message = bootstrap_context_baseline(&repo).expect("bootstrap should create baseline"); - assert!(message.contains("Context baseline ensured.")); - assert_baseline_paths_exist(&repo); - - let paths = RepoPaths::new(&repo); - assert!(!paths.opencode_dir().exists()); - assert!(!paths.claude_dir().exists()); - assert!(!paths.pi_dir().exists()); - - let gitignore = fs::read_to_string(paths.context_tmp_gitignore_file()) - .expect("tmp gitignore should be readable"); - assert_eq!(gitignore, CONTEXT_TMP_GITIGNORE_CONTENT); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn bootstrap_context_baseline_is_additive_and_idempotent() { - let repo = init_git_repo("idempotent-baseline"); - bootstrap_context_baseline(&repo).expect("initial bootstrap"); - - let paths = RepoPaths::new(&repo); - let sentinel = "SENTINEL_OVERVIEW_CONTENT\n"; - fs::write(paths.context_overview_file(), sentinel).expect("seed overview sentinel"); - fs::write(paths.context_map_file(), "SENTINEL_CONTEXT_MAP\n") - .expect("seed context-map sentinel"); - fs::write(paths.context_tmp_gitignore_file(), "SENTINEL_GITIGNORE\n") - .expect("seed gitignore sentinel"); - - fs::remove_file(paths.context_architecture_file()).expect("remove architecture"); - fs::remove_dir_all(paths.context_plans_dir()).expect("remove plans"); - - bootstrap_context_baseline(&repo).expect("rerun bootstrap"); - - assert_eq!( - fs::read_to_string(paths.context_overview_file()).expect("read overview"), - sentinel - ); - assert_eq!( - fs::read_to_string(paths.context_map_file()).expect("read context-map"), - "SENTINEL_CONTEXT_MAP\n" - ); - assert_eq!( - fs::read_to_string(paths.context_tmp_gitignore_file()).expect("read gitignore"), - "SENTINEL_GITIGNORE\n" - ); - assert!(paths.context_architecture_file().exists()); - assert!(paths.context_plans_dir().is_dir()); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn concrete_targets_for_all_expands_to_four_targets() { - assert_eq!( - concrete_targets_for(SetupTarget::All), - &[ - SetupTarget::OpenCode, - SetupTarget::Claude, - SetupTarget::Pi, - SetupTarget::Codex - ] - ); - } - - #[test] - fn integration_target_id_str_maps_pi() { - assert_eq!(integration_target_id_str(SetupTarget::Pi), "pi"); - } - - #[test] - fn integration_target_id_str_maps_codex() { - assert_eq!(integration_target_id_str(SetupTarget::Codex), "codex"); - } - - /// Every optional workflow selected, so filtering drops nothing. - fn every_optional_workflow() -> Vec<&'static str> { - super::OPTIONAL_WORKFLOWS - .iter() - .map(|workflow| workflow.id) - .collect() - } - - #[test] - fn iter_embedded_assets_for_all_covers_each_concrete_target() { - let selection = every_optional_workflow(); - let count = |target| { - iter_embedded_assets_for_setup_target_with_selection(target, &selection).count() - }; - - let concrete_sum = count(SetupTarget::OpenCode) - + count(SetupTarget::Claude) - + count(SetupTarget::Pi) - + count(SetupTarget::Codex); - - assert!(count(SetupTarget::Pi) > 0); - assert!(count(SetupTarget::Codex) > 0); - assert_eq!(count(SetupTarget::All), concrete_sum); - } - - #[test] - fn embedded_build_payload_contains_generated_targets_and_static_hooks() { - let selection = every_optional_workflow(); - let contains = |target, path| { - iter_embedded_assets_for_setup_target_with_selection(target, &selection) - .any(|asset| asset.relative_path == path && !asset.bytes.is_empty()) - }; - - assert!(contains(SetupTarget::OpenCode, "command/next-task.md")); - assert!(contains( - SetupTarget::OpenCode, - "lib/bash-policy-presets.json" - )); - assert!(contains(SetupTarget::Claude, "commands/next-task.md")); - assert!(contains(SetupTarget::Pi, "prompts/next-task.md")); - assert!(contains(SetupTarget::Pi, "extensions/sce/index.ts")); - assert!(iter_required_hook_assets().all(|asset| !asset.bytes.is_empty())); - } - - #[test] - fn codex_embedded_assets_cover_both_output_roots_with_no_command_dir() { - let has = |path: &str| { - CODEX_EMBEDDED_ASSETS - .iter() - .any(|asset| asset.relative_path == path && !asset.bytes.is_empty()) - }; - - assert!(has(".agents/skills/sce-next-task/SKILL.md")); - assert!(has(".codex/hooks.json")); - assert!(has(".codex/hooks/run-sce-or-show-install-guidance.sh")); - assert!(!CODEX_EMBEDDED_ASSETS - .iter() - .any(|asset| asset.relative_path.starts_with(".agents/commands/"))); - } - - #[test] - fn install_writes_codex_assets_directly_under_repo_root() { - let repo = init_git_repo("install-codex-dual-roots"); - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) - .expect("codex install should succeed"); - - assert!(repo.join(".agents/skills/sce-next-task/SKILL.md").is_file()); - assert!(repo.join(".codex/hooks.json").is_file()); - assert!(repo - .join(".codex/hooks/run-sce-or-show-install-guidance.sh") - .is_file()); - assert!(!repo.join(".codex/.agents").exists()); - assert!(!repo.join(".agents/.codex").exists()); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn install_merges_codex_hooks_and_replaces_stale_owned_handlers_idempotently() { - let repo = init_git_repo("install-merges-codex-hooks"); - let hooks_path = repo.join(".codex/hooks.json"); - fs::create_dir_all(hooks_path.parent().unwrap()).expect("create Codex directory"); - let stale_command = "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"; - let existing = json!({ - "description": "user hooks", - "hooks": { - "UserPromptSubmit": [{"hooks": [ - {"type": "command", "command": "echo user"}, - {"type": "command", "command": stale_command} - ]}], - "SessionStart": [{"hooks": [{"type": "command", "command": "echo session"}]}] - } - }); - fs::write(&hooks_path, serde_json::to_vec(&existing).unwrap()).expect("seed hooks config"); - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) - .expect("first Codex install should succeed"); - let first = fs::read(&hooks_path).expect("read merged hooks config"); - install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) - .expect("second Codex install should succeed"); - let second = fs::read(&hooks_path).expect("read merged hooks config again"); - assert_eq!(first, second); - - let merged: serde_json::Value = serde_json::from_slice(&second).unwrap(); - assert_eq!(merged["description"], "user hooks"); - assert_eq!( - merged["hooks"]["SessionStart"][0]["hooks"][0]["command"], - "echo session" - ); - assert_eq!( - merged["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], - "echo user" - ); - assert_eq!(merged["hooks"].as_object().unwrap().len(), 8); - for event in ["Interrupt", "SubagentStop", "SessionEnd"] { - assert!( - merged["hooks"][event][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .ends_with("sce hooks codex-mutation-scope"), - "{event} must route to the mutation-scope command" - ); - } - assert!(merged["hooks"]["PreToolUse"][1]["hooks"][0]["command"] - .as_str() - .unwrap() - .ends_with("sce hooks codex-mutation-scope")); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn invalid_codex_hooks_are_not_modified() { - let invalid_documents = [ - br#"{\"hooks\":{"#.to_vec(), - serde_json::to_vec(&json!({"custom": true})).unwrap(), - serde_json::to_vec(&json!({"hooks": {"Stop": [{"matcher": 42}]}})).unwrap(), - serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": "invalid"}]}})).unwrap(), - serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": [{"nonsense": true}]}]}})) - .unwrap(), - serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": [{"type": "unknown"}]}]}})) - .unwrap(), - ]; - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - for (index, original) in invalid_documents.iter().enumerate() { - let repo = init_git_repo(&format!("install-rejects-malformed-codex-hooks-{index}")); - let hooks_path = repo.join(".codex/hooks.json"); - fs::create_dir_all(hooks_path.parent().unwrap()).expect("create Codex directory"); - fs::write(&hooks_path, original).expect("seed malformed hooks config"); - - let error = install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) - .expect_err("malformed Codex hooks should fail setup"); - assert!(error.to_string().contains(".codex/hooks.json")); - assert_eq!(fs::read(&hooks_path).unwrap(), original.as_slice()); - - let _ = fs::remove_dir_all(&repo); - } - } - - #[test] - fn install_preserves_user_owned_files_and_writes_sce_assets() { - let repo = init_git_repo("install-preserves-user-files"); - let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); - - fs::create_dir_all(claude_dir.join("skills/my-own-skill")).expect("create user skill dir"); - fs::create_dir_all(claude_dir.join("commands")).expect("create commands dir"); - - fs::write(claude_dir.join("MY_NOTES.md"), "top level user notes\n") - .expect("seed top-level user file"); - fs::write( - claude_dir.join("skills/my-own-skill/SKILL.md"), - "user skill content\n", - ) - .expect("seed user skill file"); - fs::write( - claude_dir.join("commands/my-command.md"), - "user command content\n", - ) - .expect("seed user command file"); - - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) - .expect("install should succeed"); - - assert_eq!( - fs::read_to_string(claude_dir.join("MY_NOTES.md")).expect("read top-level user file"), - "top level user notes\n" - ); - assert_eq!( - fs::read_to_string(claude_dir.join("skills/my-own-skill/SKILL.md")) - .expect("read user skill file"), - "user skill content\n" - ); - assert_eq!( - fs::read_to_string(claude_dir.join("commands/my-command.md")) - .expect("read user command file"), - "user command content\n" - ); - - let expected_next_task_bytes = - iter_embedded_assets_for_setup_target_with_selection(SetupTarget::Claude, &selection) - .find(|asset| asset.relative_path == "commands/next-task.md") - .expect("next-task asset should be in the catalog") - .bytes; - assert_eq!( - fs::read(claude_dir.join("commands/next-task.md")).expect("read installed sce asset"), - expected_next_task_bytes - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn install_merges_into_existing_claude_settings_json_and_stays_idempotent() { - let repo = init_git_repo("install-merges-claude-settings"); - let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); - - fs::create_dir_all(&claude_dir).expect("create claude dir"); - fs::write( - claude_dir.join("settings.json"), - serde_json::to_string_pretty(&json!({ - "permissions": {"allow": ["Bash(git *)"]}, - "env": {"FOO": "bar"}, - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [{"type": "command", "command": "echo user-hook"}] - } - ] - } - })) - .expect("serialize seeded settings"), - ) - .expect("seed existing settings.json"); - - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) - .expect("first install should succeed"); - - let after_first = - fs::read_to_string(claude_dir.join("settings.json")).expect("read merged settings"); - let merged: serde_json::Value = - serde_json::from_str(&after_first).expect("merged settings should be valid JSON"); - - assert_eq!(merged["permissions"]["allow"][0], "Bash(git *)"); - assert_eq!(merged["env"]["FOO"], "bar"); - let pre_tool_use = merged["hooks"]["PreToolUse"] - .as_array() - .expect("PreToolUse should be an array"); - assert!(pre_tool_use - .iter() - .any(|entry| entry["hooks"][0]["command"] == "echo user-hook")); - assert!(pre_tool_use - .iter() - .any(|entry| entry["hooks"] - .as_array() - .unwrap() - .iter() - .any(|hook| hook["command"] - .as_str() - .unwrap() - .contains("run-sce-or-show-install-guidance.sh")))); - - install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) - .expect("second install should succeed"); - - let after_second = - fs::read_to_string(claude_dir.join("settings.json")).expect("read re-merged settings"); - assert_eq!( - after_first, after_second, - "two consecutive installs should merge to byte-identical output" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn install_merges_into_existing_opencode_config_json_and_stays_idempotent() { - let repo = init_git_repo("install-merges-opencode-config"); - let opencode_dir = default_paths::InstallTargetPaths::new(&repo).opencode_target_dir(); - - fs::create_dir_all(&opencode_dir).expect("create opencode dir"); - fs::write( - opencode_dir.join("opencode.json"), - serde_json::to_string_pretty(&json!({ - "model": "anthropic/claude", - "mcp": {"my-server": {"command": "my-server"}}, - "plugin": ["./plugins/my-plugin.ts", "./plugins/sce-old-feature.ts"] - })) - .expect("serialize seeded opencode config"), - ) - .expect("seed existing opencode.json"); - - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - install_embedded_setup_assets(&repo, SetupTarget::OpenCode, &selection) - .expect("first install should succeed"); - - let after_first = fs::read_to_string(opencode_dir.join("opencode.json")) - .expect("read merged opencode config"); - let merged: serde_json::Value = serde_json::from_str(&after_first) - .expect("merged opencode config should be valid JSON"); - - assert_eq!(merged["model"], "anthropic/claude"); - assert_eq!(merged["mcp"]["my-server"]["command"], "my-server"); - - let plugin = merged["plugin"] - .as_array() - .expect("plugin should be an array"); - assert!(plugin.contains(&json!("./plugins/my-plugin.ts"))); - assert!(plugin.contains(&json!("./plugins/sce-bash-policy.ts"))); - assert!(plugin.contains(&json!("./plugins/sce-agent-trace.ts"))); - assert!(!plugin.contains(&json!("./plugins/sce-old-feature.ts"))); - assert_eq!( - plugin.last().and_then(serde_json::Value::as_str), - Some("./plugins/sce-mutation-scope.ts"), - "the mutation-scope plugin must be installed as the final plugin" - ); - - install_embedded_setup_assets(&repo, SetupTarget::OpenCode, &selection) - .expect("second install should succeed"); - - let after_second = fs::read_to_string(opencode_dir.join("opencode.json")) - .expect("read re-merged opencode config"); - assert_eq!( - after_first, after_second, - "two consecutive installs should merge to byte-identical output" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn reinstall_with_empty_selection_prunes_deselected_workflow_without_touching_sibling_skill() { - let repo = init_git_repo("install-prunes-deselected-workflow"); - let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); - - let brownfield_selection = vec!["brownfield".to_string()]; - install_embedded_setup_assets(&repo, SetupTarget::Claude, &brownfield_selection) - .expect("initial install with brownfield selected should succeed"); - - let brownfield_command = claude_dir.join("commands/brownfield.md"); - let brownfield_skill_dir = claude_dir.join("skills/sce-brownfield"); - assert!( - brownfield_command.is_file(), - "brownfield command should be installed" - ); - assert!( - brownfield_skill_dir.is_dir(), - "brownfield skill dir should be installed" - ); - - fs::create_dir_all(claude_dir.join("skills/my-skill")).expect("create user skill dir"); - fs::write( - claude_dir.join("skills/my-skill/SKILL.md"), - "sibling user skill\n", - ) - .expect("seed sibling user skill file"); - - install_embedded_setup_assets(&repo, SetupTarget::Claude, &[]) - .expect("reinstall with empty selection should succeed"); - - assert!( - !brownfield_command.exists(), - "deselected workflow command should be pruned" - ); - assert!( - !brownfield_skill_dir.exists(), - "deselected workflow skill dir should be pruned entirely once empty" - ); - assert_eq!( - fs::read_to_string(claude_dir.join("skills/my-skill/SKILL.md")) - .expect("read sibling user skill file"), - "sibling user skill\n" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn reinstall_with_empty_selection_keeps_pruned_skill_dir_holding_a_user_file() { - let repo = init_git_repo("install-prunes-but-keeps-user-file"); - let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); - - let brownfield_selection = vec!["brownfield".to_string()]; - install_embedded_setup_assets(&repo, SetupTarget::Claude, &brownfield_selection) - .expect("initial install with brownfield selected should succeed"); - - let brownfield_skill_dir = claude_dir.join("skills/sce-brownfield"); - fs::write( - brownfield_skill_dir.join("MY_OVERRIDE.md"), - "user file inside sce skill dir\n", - ) - .expect("seed user file inside sce-owned skill dir"); - - install_embedded_setup_assets(&repo, SetupTarget::Claude, &[]) - .expect("reinstall with empty selection should succeed"); - - assert!( - !brownfield_skill_dir.join("SKILL.md").exists(), - "deselected workflow skill file should be pruned" - ); - assert!( - brownfield_skill_dir.is_dir(), - "sce-owned skill dir should survive because it still holds a user file" - ); - assert_eq!( - fs::read_to_string(brownfield_skill_dir.join("MY_OVERRIDE.md")) - .expect("read user file inside pruned skill dir"), - "user file inside sce skill dir\n" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn install_cleans_up_staging_and_reports_asset_path_on_rename_failure() { - let repo = init_git_repo("install-rename-failure"); - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); - let failing_destination = claude_dir.join("commands/next-task.md"); - - fs::create_dir_all(claude_dir.join("commands")).expect("create commands dir"); - let prior_content = b"prior next-task content\n"; - fs::write(&failing_destination, prior_content).expect("seed prior next-task content"); - - let result = install::install_embedded_setup_assets_with_rename( - &repo, - SetupTarget::Claude, - &selection, - |from, to| { - if to == failing_destination { - Err(std::io::Error::other("simulated rename failure")) - } else { - fs::rename(from, to) - } - }, - ); - - let error = result.expect_err("rename failure should surface as an error"); - let message = format!("{error:#}"); - assert!( - message.contains(&failing_destination.display().to_string()), - "error should name the failing asset path: {message}" - ); - assert!( - message.contains("does not create backups"), - "error should include recovery guidance: {message}" - ); - - assert_eq!( - fs::read(&failing_destination).expect("read failing destination after rename failure"), - prior_content, - "prior content at the failing destination should survive a rename failure" - ); - - let commands_staging_dir = claude_dir.join("commands"); - if commands_staging_dir.exists() { - let leftover_staging_files = fs::read_dir(&commands_staging_dir) - .expect("read commands staging dir") - .filter_map(Result::ok) - .any(|entry| { - entry - .file_name() - .to_string_lossy() - .starts_with(".sce-setup-staging-") - }); - assert!( - !leftover_staging_files, - "staging artifact for the failed asset should be cleaned up" - ); - } - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn hook_install_leaves_prior_hook_intact_on_rename_failure() { - let repo = init_git_repo("hook-install-rename-failure"); - - let initial_outcome = install::install_required_git_hooks(&repo) - .expect("initial hook install should succeed"); - let pre_commit_result = initial_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook should be installed"); - let pre_commit_path = pre_commit_result.hook_path.clone(); - - let prior_hook_bytes = b"#!/bin/sh\necho prior pre-commit\n".to_vec(); - fs::write(&pre_commit_path, &prior_hook_bytes).expect("seed prior pre-commit hook"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) - .expect("mark prior pre-commit hook executable"); - } - let prior_mode = fs::metadata(&pre_commit_path) - .expect("stat prior pre-commit hook") - .permissions(); - - let result = install::install_required_git_hooks_with_rename(&repo, |from, to| { - if to == pre_commit_path { - Err(std::io::Error::other("simulated rename failure")) - } else { - fs::rename(from, to) - } - }); - - let error = result.expect_err("rename failure should surface as an error"); - let message = format!("{error:#}"); - assert!( - message.contains(&pre_commit_path.display().to_string()), - "error should name the failing hook path: {message}" - ); - - assert_eq!( - fs::read(&pre_commit_path).expect("read pre-commit hook after rename failure"), - prior_hook_bytes, - "prior hook content should survive a rename failure" - ); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode_after = fs::metadata(&pre_commit_path) - .expect("stat pre-commit hook after rename failure") - .permissions(); - assert_eq!( - mode_after.mode() & 0o777, - prior_mode.mode() & 0o777, - "prior hook executable mode should survive a rename failure" - ); - } - - let hooks_staging_dir = pre_commit_path - .parent() - .expect("pre-commit hook should have a parent directory"); - let leftover_staging_files = fs::read_dir(hooks_staging_dir) - .expect("read hooks staging dir") - .filter_map(Result::ok) - .any(|entry| { - entry - .file_name() - .to_string_lossy() - .starts_with(".sce-hook-staging-") - }); - assert!( - !leftover_staging_files, - "staging artifact for the failed hook should be cleaned up" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn foreign_pre_commit_hook_keeps_its_content_and_gains_the_sce_block() { - let repo = init_git_repo("hook-install-foreign-append"); - - let initial_outcome = install::install_required_git_hooks(&repo) - .expect("initial hook install should succeed"); - let pre_commit_path = initial_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook should be installed") - .hook_path - .clone(); - - let foreign_bytes = b"#!/bin/sh\necho husky-style-guard\n".to_vec(); - fs::write(&pre_commit_path, &foreign_bytes).expect("seed foreign pre-commit hook"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) - .expect("mark foreign pre-commit hook executable"); - } - - let outcome = install::install_required_git_hooks(&repo) - .expect("hook install over a foreign hook should succeed"); - let result = outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook result should be present"); - - assert_eq!(result.status, RequiredHookInstallStatus::Updated); - assert!(!result.unreachable_block_advisory); - - let installed_bytes = fs::read(&pre_commit_path).expect("read installed pre-commit hook"); - assert!( - installed_bytes.starts_with(&foreign_bytes), - "foreign hook content should survive as an exact prefix" - ); - let installed_text = String::from_utf8(installed_bytes).expect("hook should be utf8"); - assert!(installed_text.contains(hook_merge::MANAGED_BLOCK_START)); - assert!(installed_text.contains(hook_merge::MANAGED_BLOCK_END)); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(&pre_commit_path) - .expect("stat installed pre-commit hook") - .permissions() - .mode(); - assert_ne!(mode & 0o111, 0, "installed hook should remain executable"); - } - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn rerunning_hook_install_is_idempotent_for_block_only_and_foreign_plus_block_shapes() { - let repo = init_git_repo("hook-install-idempotent"); - - let first_outcome = - install::install_required_git_hooks(&repo).expect("first hook install should succeed"); - let pre_commit_result = first_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook should be installed"); - assert_eq!( - pre_commit_result.status, - RequiredHookInstallStatus::Installed - ); - - let second_outcome = - install::install_required_git_hooks(&repo).expect("second hook install should succeed"); - let second_pre_commit = second_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook result should be present"); - assert_eq!(second_pre_commit.status, RequiredHookInstallStatus::Skipped); - assert_eq!( - fs::read(&second_pre_commit.hook_path).expect("read block-only pre-commit hook"), - fs::read(&pre_commit_result.hook_path).expect("read initial pre-commit hook"), - "block-only hook bytes should be unchanged across reruns" - ); - - let commit_msg_result = first_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) - .expect("commit-msg hook should be installed"); - let commit_msg_path = commit_msg_result.hook_path.clone(); - let foreign_prefix = b"#!/bin/sh\necho foreign-commit-msg-guard\n".to_vec(); - fs::write(&commit_msg_path, &foreign_prefix).expect("seed foreign commit-msg hook"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&commit_msg_path, fs::Permissions::from_mode(0o755)) - .expect("mark foreign commit-msg hook executable"); - } - - let appended_outcome = install::install_required_git_hooks(&repo) - .expect("hook install appending to foreign commit-msg hook should succeed"); - let appended_result = appended_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) - .expect("commit-msg hook result should be present"); - assert_eq!(appended_result.status, RequiredHookInstallStatus::Updated); - let appended_bytes = fs::read(&commit_msg_path).expect("read appended commit-msg hook"); - - let rerun_outcome = install::install_required_git_hooks(&repo) - .expect("rerunning hook install over foreign-plus-block hook should succeed"); - let rerun_result = rerun_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) - .expect("commit-msg hook result should be present"); - assert_eq!(rerun_result.status, RequiredHookInstallStatus::Skipped); - assert_eq!( - fs::read(&commit_msg_path).expect("read commit-msg hook after rerun"), - appended_bytes, - "foreign-plus-block hook bytes should be unchanged across reruns" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn legacy_pre_marker_hook_upgrades_to_the_managed_block_form() { - let repo = init_git_repo("hook-install-legacy-upgrade"); - - let initial_outcome = install::install_required_git_hooks(&repo) - .expect("initial hook install should succeed"); - let pre_commit_path = initial_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook should be installed") - .hook_path - .clone(); - let canonical_bytes = fs::read(&pre_commit_path).expect("read canonical pre-commit hook"); - - let legacy_bytes = b"#!/bin/sh\nset -eu\nif ! command -v sce >/dev/null 2>&1; then\n echo 'Install: https://sce.crocoder.dev/docs/getting-started#install-cli'\n exit 0\nfi\nexec sce hooks pre-commit \"$@\"\n".to_vec(); - fs::write(&pre_commit_path, &legacy_bytes).expect("seed legacy pre-commit hook"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) - .expect("mark legacy pre-commit hook executable"); - } - - let outcome = install::install_required_git_hooks(&repo) - .expect("hook install upgrading a legacy hook should succeed"); - let result = outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook result should be present"); - - assert_eq!(result.status, RequiredHookInstallStatus::Updated); - assert_eq!( - fs::read(&pre_commit_path).expect("read upgraded pre-commit hook"), - canonical_bytes, - "a legacy pre-marker hook should upgrade to the canonical marker form" - ); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(&pre_commit_path) - .expect("stat upgraded pre-commit hook") - .permissions() - .mode(); - assert_ne!(mode & 0o111, 0, "upgraded hook should remain executable"); - } - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn foreign_hook_ending_in_exec_installs_the_block_and_reports_the_advisory() { - let repo = init_git_repo("hook-install-unreachable-advisory"); - - let initial_outcome = install::install_required_git_hooks(&repo) - .expect("initial hook install should succeed"); - let pre_commit_path = initial_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook should be installed") - .hook_path - .clone(); - let commit_msg_path = initial_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) - .expect("commit-msg hook should be installed") - .hook_path - .clone(); - - let unreachable_foreign = b"#!/bin/sh\nexec some-other-tool \"$@\"\n".to_vec(); - fs::write(&pre_commit_path, &unreachable_foreign).expect("seed unreachable foreign hook"); - let ordinary_foreign = b"#!/bin/sh\necho foreign-commit-msg-guard\n".to_vec(); - fs::write(&commit_msg_path, &ordinary_foreign).expect("seed ordinary foreign hook"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) - .expect("mark unreachable foreign hook executable"); - fs::set_permissions(&commit_msg_path, fs::Permissions::from_mode(0o755)) - .expect("mark ordinary foreign hook executable"); - } - - let outcome = install::install_required_git_hooks(&repo) - .expect("hook install over foreign hooks should succeed"); - - let pre_commit_result = outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook result should be present"); - assert_eq!(pre_commit_result.status, RequiredHookInstallStatus::Updated); - assert!( - pre_commit_result.unreachable_block_advisory, - "a hook ending in a zero-indent exec should report the advisory" - ); - assert!( - fs::read(&pre_commit_path) - .expect("read pre-commit hook") - .starts_with(&unreachable_foreign), - "the block should still be installed even though it is unreachable" - ); - - let commit_msg_result = outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) - .expect("commit-msg hook result should be present"); - assert!( - !commit_msg_result.unreachable_block_advisory, - "a hook ending in an ordinary command should not report the advisory" - ); - - let _ = fs::remove_dir_all(&repo); - } -} +mod tests; diff --git a/cli/src/services/setup/tests.rs b/cli/src/services/setup/tests.rs new file mode 100644 index 00000000..7757ce09 --- /dev/null +++ b/cli/src/services/setup/tests.rs @@ -0,0 +1,1123 @@ +use super::*; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::command_surface; +use crate::services::command_registry::CommandRegistry; +use crate::services::command_registry::RuntimeCommand; +use crate::services::parse::command_runtime::parse_runtime_command; + +fn options_with(mutate: impl FnOnce(&mut SetupCliOptions)) -> SetupCliOptions { + let mut options = SetupCliOptions::default(); + mutate(&mut options); + options +} + +fn unique_temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "sce-setup-context-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +fn init_git_repo(label: &str) -> PathBuf { + let repo = unique_temp_dir(label); + let output = Command::new("git") + .args(["init", "-q"]) + .current_dir(&repo) + .output() + .expect("git init should spawn"); + assert!( + output.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + repo +} + +fn assert_baseline_paths_exist(repo: &Path) { + let paths = RepoPaths::new(repo); + for path in [ + paths.context_overview_file(), + paths.context_architecture_file(), + paths.context_patterns_file(), + paths.context_glossary_file(), + paths.context_map_file(), + paths.context_plans_dir(), + paths.context_handovers_dir(), + paths.context_decisions_dir(), + paths.context_tmp_dir(), + paths.context_tmp_gitignore_file(), + ] { + assert!(path.exists(), "expected baseline path {}", path.display()); + } +} + +#[test] +fn repo_local_config_bootstrap_payload_uses_versioned_schema_url() { + let payload = repo_local_config_bootstrap_payload(); + + assert_eq!( + payload, + format!( + "{{\n \"$schema\": \"https://sce.crocoder.dev/v{}/config.json\",\n \"agent_trace\": {{\n \"auto_sync\": true\n }},\n \"policies\": {{\n \"attribution_hooks\": {{\n \"enabled\": true\n }}\n }}\n}}\n", + env!("CARGO_PKG_VERSION") + ) + ); +} + +#[test] +fn resolve_setup_request_accepts_pi_target() { + let request = resolve_setup_request(options_with(|options| { + options.pi = true; + options.non_interactive = true; + })) + .expect("pi target should resolve"); + + assert_eq!( + request.config_mode, + Some(SetupMode::NonInteractive(SetupTarget::Pi)) + ); + assert!(!request.context_only); +} + +#[test] +fn resolve_setup_request_accepts_codex_target() { + let request = resolve_setup_request(options_with(|options| { + options.codex = true; + options.non_interactive = true; + })) + .expect("codex target should resolve"); + + assert_eq!( + request.config_mode, + Some(SetupMode::NonInteractive(SetupTarget::Codex)) + ); + assert!(!request.context_only); +} + +#[test] +fn resolve_setup_request_accepts_all_target() { + let request = resolve_setup_request(options_with(|options| { + options.all = true; + options.non_interactive = true; + })) + .expect("all target should resolve"); + + assert_eq!( + request.config_mode, + Some(SetupMode::NonInteractive(SetupTarget::All)) + ); + assert!(!request.context_only); +} + +#[test] +fn resolve_setup_request_accepts_bootstrap_context_alone() { + let request = resolve_setup_request(options_with(|options| { + options.bootstrap_context = true; + })) + .expect("bootstrap-context alone should resolve"); + + assert!(request.context_only); + assert_eq!(request.config_mode, None); + assert!(!request.install_hooks); + assert_eq!(request.hooks_repo_path, None); +} + +#[test] +fn resolve_setup_request_rejects_bootstrap_context_with_target() { + let error = resolve_setup_request(options_with(|options| { + options.bootstrap_context = true; + options.opencode = true; + })) + .expect_err("bootstrap-context with target must be rejected"); + + assert!(error.to_string().contains("--bootstrap-context")); + assert!(error.to_string().contains("alone")); +} + +#[test] +fn resolve_setup_request_rejects_combined_target_flags() { + let error = resolve_setup_request(options_with(|options| { + options.pi = true; + options.all = true; + })) + .expect_err("combined target flags must be rejected"); + + assert!(error.to_string().contains("mutually exclusive")); +} + +#[test] +fn resolve_setup_request_non_interactive_error_lists_pi_and_all() { + let error = resolve_setup_request(options_with(|options| { + options.non_interactive = true; + })) + .expect_err("non-interactive without target must be rejected"); + + let message = error.to_string(); + assert!(message.contains("--pi")); + assert!(message.contains("--all")); +} + +#[test] +fn parser_routes_bootstrap_context_to_context_only_request() { + let registry = CommandRegistry::default(); + let command = parse_runtime_command( + [ + "sce".to_string(), + "setup".to_string(), + "--bootstrap-context".to_string(), + ], + ®istry, + None, + ) + .expect("bootstrap-context should parse"); + + match command { + RuntimeCommand::Setup(setup_command) => { + assert!(setup_command.request.context_only); + assert_eq!(setup_command.request.config_mode, None); + assert!(!setup_command.request.install_hooks); + } + _ => panic!("expected Setup command for --bootstrap-context"), + } +} + +#[test] +fn help_documents_bootstrap_context_flag() { + let top_level_help = command_surface::help_text(); + assert!( + top_level_help.contains("--bootstrap-context"), + "top-level help should document --bootstrap-context" + ); + + let registry = CommandRegistry::default(); + let command = parse_runtime_command( + ["sce".to_string(), "setup".to_string(), "--help".to_string()], + ®istry, + None, + ) + .expect("setup --help should parse"); + + match command { + RuntimeCommand::HelpText(help) => { + assert!( + help.text.contains("--bootstrap-context"), + "setup --help should document --bootstrap-context:\n{}", + help.text + ); + } + _ => panic!("expected HelpText for setup --help"), + } +} + +#[test] +fn bootstrap_context_baseline_creates_expected_paths() { + let repo = init_git_repo("create-baseline"); + let message = bootstrap_context_baseline(&repo).expect("bootstrap should create baseline"); + assert!(message.contains("Context baseline ensured.")); + assert_baseline_paths_exist(&repo); + + let paths = RepoPaths::new(&repo); + assert!(!paths.opencode_dir().exists()); + assert!(!paths.claude_dir().exists()); + assert!(!paths.pi_dir().exists()); + + let gitignore = fs::read_to_string(paths.context_tmp_gitignore_file()) + .expect("tmp gitignore should be readable"); + assert_eq!(gitignore, CONTEXT_TMP_GITIGNORE_CONTENT); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn bootstrap_context_baseline_is_additive_and_idempotent() { + let repo = init_git_repo("idempotent-baseline"); + bootstrap_context_baseline(&repo).expect("initial bootstrap"); + + let paths = RepoPaths::new(&repo); + let sentinel = "SENTINEL_OVERVIEW_CONTENT\n"; + fs::write(paths.context_overview_file(), sentinel).expect("seed overview sentinel"); + fs::write(paths.context_map_file(), "SENTINEL_CONTEXT_MAP\n") + .expect("seed context-map sentinel"); + fs::write(paths.context_tmp_gitignore_file(), "SENTINEL_GITIGNORE\n") + .expect("seed gitignore sentinel"); + + fs::remove_file(paths.context_architecture_file()).expect("remove architecture"); + fs::remove_dir_all(paths.context_plans_dir()).expect("remove plans"); + + bootstrap_context_baseline(&repo).expect("rerun bootstrap"); + + assert_eq!( + fs::read_to_string(paths.context_overview_file()).expect("read overview"), + sentinel + ); + assert_eq!( + fs::read_to_string(paths.context_map_file()).expect("read context-map"), + "SENTINEL_CONTEXT_MAP\n" + ); + assert_eq!( + fs::read_to_string(paths.context_tmp_gitignore_file()).expect("read gitignore"), + "SENTINEL_GITIGNORE\n" + ); + assert!(paths.context_architecture_file().exists()); + assert!(paths.context_plans_dir().is_dir()); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn concrete_targets_for_all_expands_to_four_targets() { + assert_eq!( + concrete_targets_for(SetupTarget::All), + &[ + SetupTarget::OpenCode, + SetupTarget::Claude, + SetupTarget::Pi, + SetupTarget::Codex + ] + ); +} + +#[test] +fn integration_target_id_str_maps_pi() { + assert_eq!(integration_target_id_str(SetupTarget::Pi), "pi"); +} + +#[test] +fn integration_target_id_str_maps_codex() { + assert_eq!(integration_target_id_str(SetupTarget::Codex), "codex"); +} + +fn every_optional_workflow() -> Vec<&'static str> { + super::OPTIONAL_WORKFLOWS + .iter() + .map(|workflow| workflow.id) + .collect() +} + +#[test] +fn iter_embedded_assets_for_all_covers_each_concrete_target() { + let selection = every_optional_workflow(); + let count = + |target| iter_embedded_assets_for_setup_target_with_selection(target, &selection).count(); + + let concrete_sum = count(SetupTarget::OpenCode) + + count(SetupTarget::Claude) + + count(SetupTarget::Pi) + + count(SetupTarget::Codex); + + assert!(count(SetupTarget::Pi) > 0); + assert!(count(SetupTarget::Codex) > 0); + assert_eq!(count(SetupTarget::All), concrete_sum); +} + +#[test] +fn embedded_build_payload_contains_generated_targets_and_static_hooks() { + let selection = every_optional_workflow(); + let contains = |target, path| { + iter_embedded_assets_for_setup_target_with_selection(target, &selection) + .any(|asset| asset.relative_path == path && !asset.bytes.is_empty()) + }; + + assert!(contains(SetupTarget::OpenCode, "command/next-task.md")); + assert!(contains( + SetupTarget::OpenCode, + "lib/bash-policy-presets.json" + )); + assert!(contains(SetupTarget::Claude, "commands/next-task.md")); + assert!(contains(SetupTarget::Pi, "prompts/next-task.md")); + assert!(contains(SetupTarget::Pi, "extensions/sce/index.ts")); + assert!(iter_required_hook_assets().all(|asset| !asset.bytes.is_empty())); +} + +#[test] +fn codex_embedded_assets_cover_both_output_roots_with_no_command_dir() { + let has = |path: &str| { + CODEX_EMBEDDED_ASSETS + .iter() + .any(|asset| asset.relative_path == path && !asset.bytes.is_empty()) + }; + + assert!(has(".agents/skills/sce-next-task/SKILL.md")); + assert!(has(".codex/hooks.json")); + assert!(has(".codex/hooks/run-sce-or-show-install-guidance.sh")); + assert!(!CODEX_EMBEDDED_ASSETS + .iter() + .any(|asset| asset.relative_path.starts_with(".agents/commands/"))); +} + +#[test] +fn install_writes_codex_assets_directly_under_repo_root() { + let repo = init_git_repo("install-codex-dual-roots"); + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect("codex install should succeed"); + + assert!(repo.join(".agents/skills/sce-next-task/SKILL.md").is_file()); + assert!(repo.join(".codex/hooks.json").is_file()); + assert!(repo + .join(".codex/hooks/run-sce-or-show-install-guidance.sh") + .is_file()); + assert!(!repo.join(".codex/.agents").exists()); + assert!(!repo.join(".agents/.codex").exists()); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn install_merges_codex_hooks_and_replaces_stale_owned_handlers_idempotently() { + let repo = init_git_repo("install-merges-codex-hooks"); + let hooks_path = repo.join(".codex/hooks.json"); + fs::create_dir_all(hooks_path.parent().unwrap()).expect("create Codex directory"); + let stale_command = "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"; + let existing = json!({ + "description": "user hooks", + "hooks": { + "UserPromptSubmit": [{"hooks": [ + {"type": "command", "command": "echo user"}, + {"type": "command", "command": stale_command} + ]}], + "SessionStart": [{"hooks": [{"type": "command", "command": "echo session"}]}] + } + }); + fs::write(&hooks_path, serde_json::to_vec(&existing).unwrap()).expect("seed hooks config"); + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect("first Codex install should succeed"); + let first = fs::read(&hooks_path).expect("read merged hooks config"); + install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect("second Codex install should succeed"); + let second = fs::read(&hooks_path).expect("read merged hooks config again"); + assert_eq!(first, second); + + let merged: serde_json::Value = serde_json::from_slice(&second).unwrap(); + assert_eq!(merged["description"], "user hooks"); + assert_eq!( + merged["hooks"]["SessionStart"][0]["hooks"][0]["command"], + "echo session" + ); + assert_eq!( + merged["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], + "echo user" + ); + assert_eq!(merged["hooks"].as_object().unwrap().len(), 8); + for event in ["Interrupt", "SubagentStop", "SessionEnd"] { + assert!( + merged["hooks"][event][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("sce hooks codex-mutation-scope"), + "{event} must route to the mutation-scope command" + ); + } + assert!(merged["hooks"]["PreToolUse"][1]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("sce hooks codex-mutation-scope")); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn invalid_codex_hooks_are_not_modified() { + let invalid_documents = [ + br#"{\"hooks\":{"#.to_vec(), + serde_json::to_vec(&json!({"custom": true})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"matcher": 42}]}})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": "invalid"}]}})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": [{"nonsense": true}]}]}})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": [{"type": "unknown"}]}]}})) + .unwrap(), + ]; + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + for (index, original) in invalid_documents.iter().enumerate() { + let repo = init_git_repo(&format!("install-rejects-malformed-codex-hooks-{index}")); + let hooks_path = repo.join(".codex/hooks.json"); + fs::create_dir_all(hooks_path.parent().unwrap()).expect("create Codex directory"); + fs::write(&hooks_path, original).expect("seed malformed hooks config"); + + let error = install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect_err("malformed Codex hooks should fail setup"); + assert!(error.to_string().contains(".codex/hooks.json")); + assert_eq!(fs::read(&hooks_path).unwrap(), original.as_slice()); + + let _ = fs::remove_dir_all(&repo); + } +} + +#[test] +fn install_preserves_user_owned_files_and_writes_sce_assets() { + let repo = init_git_repo("install-preserves-user-files"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + fs::create_dir_all(claude_dir.join("skills/my-own-skill")).expect("create user skill dir"); + fs::create_dir_all(claude_dir.join("commands")).expect("create commands dir"); + + fs::write(claude_dir.join("MY_NOTES.md"), "top level user notes\n") + .expect("seed top-level user file"); + fs::write( + claude_dir.join("skills/my-own-skill/SKILL.md"), + "user skill content\n", + ) + .expect("seed user skill file"); + fs::write( + claude_dir.join("commands/my-command.md"), + "user command content\n", + ) + .expect("seed user command file"); + + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) + .expect("install should succeed"); + + assert_eq!( + fs::read_to_string(claude_dir.join("MY_NOTES.md")).expect("read top-level user file"), + "top level user notes\n" + ); + assert_eq!( + fs::read_to_string(claude_dir.join("skills/my-own-skill/SKILL.md")) + .expect("read user skill file"), + "user skill content\n" + ); + assert_eq!( + fs::read_to_string(claude_dir.join("commands/my-command.md")) + .expect("read user command file"), + "user command content\n" + ); + + let expected_next_task_bytes = + iter_embedded_assets_for_setup_target_with_selection(SetupTarget::Claude, &selection) + .find(|asset| asset.relative_path == "commands/next-task.md") + .expect("next-task asset should be in the catalog") + .bytes; + assert_eq!( + fs::read(claude_dir.join("commands/next-task.md")).expect("read installed sce asset"), + expected_next_task_bytes + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn install_merges_into_existing_claude_settings_json_and_stays_idempotent() { + let repo = init_git_repo("install-merges-claude-settings"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + fs::create_dir_all(&claude_dir).expect("create claude dir"); + fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&json!({ + "permissions": {"allow": ["Bash(git *)"]}, + "env": {"FOO": "bar"}, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{"type": "command", "command": "echo user-hook"}] + } + ] + } + })) + .expect("serialize seeded settings"), + ) + .expect("seed existing settings.json"); + + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) + .expect("first install should succeed"); + + let after_first = + fs::read_to_string(claude_dir.join("settings.json")).expect("read merged settings"); + let merged: serde_json::Value = + serde_json::from_str(&after_first).expect("merged settings should be valid JSON"); + + assert_eq!(merged["permissions"]["allow"][0], "Bash(git *)"); + assert_eq!(merged["env"]["FOO"], "bar"); + let pre_tool_use = merged["hooks"]["PreToolUse"] + .as_array() + .expect("PreToolUse should be an array"); + assert!(pre_tool_use + .iter() + .any(|entry| entry["hooks"][0]["command"] == "echo user-hook")); + assert!(pre_tool_use + .iter() + .any(|entry| entry["hooks"] + .as_array() + .unwrap() + .iter() + .any(|hook| hook["command"] + .as_str() + .unwrap() + .contains("run-sce-or-show-install-guidance.sh")))); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) + .expect("second install should succeed"); + + let after_second = + fs::read_to_string(claude_dir.join("settings.json")).expect("read re-merged settings"); + assert_eq!( + after_first, after_second, + "two consecutive installs should merge to byte-identical output" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn install_merges_into_existing_opencode_config_json_and_stays_idempotent() { + let repo = init_git_repo("install-merges-opencode-config"); + let opencode_dir = default_paths::InstallTargetPaths::new(&repo).opencode_target_dir(); + + fs::create_dir_all(&opencode_dir).expect("create opencode dir"); + fs::write( + opencode_dir.join("opencode.json"), + serde_json::to_string_pretty(&json!({ + "model": "anthropic/claude", + "mcp": {"my-server": {"command": "my-server"}}, + "plugin": ["./plugins/my-plugin.ts", "./plugins/sce-old-feature.ts"] + })) + .expect("serialize seeded opencode config"), + ) + .expect("seed existing opencode.json"); + + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::OpenCode, &selection) + .expect("first install should succeed"); + + let after_first = fs::read_to_string(opencode_dir.join("opencode.json")) + .expect("read merged opencode config"); + let merged: serde_json::Value = + serde_json::from_str(&after_first).expect("merged opencode config should be valid JSON"); + + assert_eq!(merged["model"], "anthropic/claude"); + assert_eq!(merged["mcp"]["my-server"]["command"], "my-server"); + + let plugin = merged["plugin"] + .as_array() + .expect("plugin should be an array"); + assert!(plugin.contains(&json!("./plugins/my-plugin.ts"))); + assert!(plugin.contains(&json!("./plugins/sce-bash-policy.ts"))); + assert!(plugin.contains(&json!("./plugins/sce-agent-trace.ts"))); + assert!(!plugin.contains(&json!("./plugins/sce-old-feature.ts"))); + assert_eq!( + plugin.last().and_then(serde_json::Value::as_str), + Some("./plugins/sce-mutation-scope.ts"), + "the mutation-scope plugin must be installed as the final plugin" + ); + + install_embedded_setup_assets(&repo, SetupTarget::OpenCode, &selection) + .expect("second install should succeed"); + + let after_second = fs::read_to_string(opencode_dir.join("opencode.json")) + .expect("read re-merged opencode config"); + assert_eq!( + after_first, after_second, + "two consecutive installs should merge to byte-identical output" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn reinstall_with_empty_selection_prunes_deselected_workflow_without_touching_sibling_skill() { + let repo = init_git_repo("install-prunes-deselected-workflow"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + let brownfield_selection = vec!["brownfield".to_string()]; + install_embedded_setup_assets(&repo, SetupTarget::Claude, &brownfield_selection) + .expect("initial install with brownfield selected should succeed"); + + let brownfield_command = claude_dir.join("commands/brownfield.md"); + let brownfield_skill_dir = claude_dir.join("skills/sce-brownfield"); + assert!( + brownfield_command.is_file(), + "brownfield command should be installed" + ); + assert!( + brownfield_skill_dir.is_dir(), + "brownfield skill dir should be installed" + ); + + fs::create_dir_all(claude_dir.join("skills/my-skill")).expect("create user skill dir"); + fs::write( + claude_dir.join("skills/my-skill/SKILL.md"), + "sibling user skill\n", + ) + .expect("seed sibling user skill file"); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &[]) + .expect("reinstall with empty selection should succeed"); + + assert!( + !brownfield_command.exists(), + "deselected workflow command should be pruned" + ); + assert!( + !brownfield_skill_dir.exists(), + "deselected workflow skill dir should be pruned entirely once empty" + ); + assert_eq!( + fs::read_to_string(claude_dir.join("skills/my-skill/SKILL.md")) + .expect("read sibling user skill file"), + "sibling user skill\n" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn reinstall_with_empty_selection_keeps_pruned_skill_dir_holding_a_user_file() { + let repo = init_git_repo("install-prunes-but-keeps-user-file"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + let brownfield_selection = vec!["brownfield".to_string()]; + install_embedded_setup_assets(&repo, SetupTarget::Claude, &brownfield_selection) + .expect("initial install with brownfield selected should succeed"); + + let brownfield_skill_dir = claude_dir.join("skills/sce-brownfield"); + fs::write( + brownfield_skill_dir.join("MY_OVERRIDE.md"), + "user file inside sce skill dir\n", + ) + .expect("seed user file inside sce-owned skill dir"); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &[]) + .expect("reinstall with empty selection should succeed"); + + assert!( + !brownfield_skill_dir.join("SKILL.md").exists(), + "deselected workflow skill file should be pruned" + ); + assert!( + brownfield_skill_dir.is_dir(), + "sce-owned skill dir should survive because it still holds a user file" + ); + assert_eq!( + fs::read_to_string(brownfield_skill_dir.join("MY_OVERRIDE.md")) + .expect("read user file inside pruned skill dir"), + "user file inside sce skill dir\n" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn install_cleans_up_staging_and_reports_asset_path_on_rename_failure() { + let repo = init_git_repo("install-rename-failure"); + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + let failing_destination = claude_dir.join("commands/next-task.md"); + + fs::create_dir_all(claude_dir.join("commands")).expect("create commands dir"); + let prior_content = b"prior next-task content\n"; + fs::write(&failing_destination, prior_content).expect("seed prior next-task content"); + + let result = install::install_embedded_setup_assets_with_rename( + &repo, + SetupTarget::Claude, + &selection, + |from, to| { + if to == failing_destination { + Err(std::io::Error::other("simulated rename failure")) + } else { + fs::rename(from, to) + } + }, + ); + + let error = result.expect_err("rename failure should surface as an error"); + let message = format!("{error:#}"); + assert!( + message.contains(&failing_destination.display().to_string()), + "error should name the failing asset path: {message}" + ); + assert!( + message.contains("does not create backups"), + "error should include recovery guidance: {message}" + ); + + assert_eq!( + fs::read(&failing_destination).expect("read failing destination after rename failure"), + prior_content, + "prior content at the failing destination should survive a rename failure" + ); + + let commands_staging_dir = claude_dir.join("commands"); + if commands_staging_dir.exists() { + let leftover_staging_files = fs::read_dir(&commands_staging_dir) + .expect("read commands staging dir") + .filter_map(Result::ok) + .any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".sce-setup-staging-") + }); + assert!( + !leftover_staging_files, + "staging artifact for the failed asset should be cleaned up" + ); + } + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn hook_install_leaves_prior_hook_intact_on_rename_failure() { + let repo = init_git_repo("hook-install-rename-failure"); + + let initial_outcome = + install::install_required_git_hooks(&repo).expect("initial hook install should succeed"); + let pre_commit_result = initial_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook should be installed"); + let pre_commit_path = pre_commit_result.hook_path.clone(); + + let prior_hook_bytes = b"#!/bin/sh\necho prior pre-commit\n".to_vec(); + fs::write(&pre_commit_path, &prior_hook_bytes).expect("seed prior pre-commit hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) + .expect("mark prior pre-commit hook executable"); + } + let prior_mode = fs::metadata(&pre_commit_path) + .expect("stat prior pre-commit hook") + .permissions(); + + let result = install::install_required_git_hooks_with_rename(&repo, |from, to| { + if to == pre_commit_path { + Err(std::io::Error::other("simulated rename failure")) + } else { + fs::rename(from, to) + } + }); + + let error = result.expect_err("rename failure should surface as an error"); + let message = format!("{error:#}"); + assert!( + message.contains(&pre_commit_path.display().to_string()), + "error should name the failing hook path: {message}" + ); + + assert_eq!( + fs::read(&pre_commit_path).expect("read pre-commit hook after rename failure"), + prior_hook_bytes, + "prior hook content should survive a rename failure" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode_after = fs::metadata(&pre_commit_path) + .expect("stat pre-commit hook after rename failure") + .permissions(); + assert_eq!( + mode_after.mode() & 0o777, + prior_mode.mode() & 0o777, + "prior hook executable mode should survive a rename failure" + ); + } + + let hooks_staging_dir = pre_commit_path + .parent() + .expect("pre-commit hook should have a parent directory"); + let leftover_staging_files = fs::read_dir(hooks_staging_dir) + .expect("read hooks staging dir") + .filter_map(Result::ok) + .any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".sce-hook-staging-") + }); + assert!( + !leftover_staging_files, + "staging artifact for the failed hook should be cleaned up" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn foreign_pre_commit_hook_keeps_its_content_and_gains_the_sce_block() { + let repo = init_git_repo("hook-install-foreign-append"); + + let initial_outcome = + install::install_required_git_hooks(&repo).expect("initial hook install should succeed"); + let pre_commit_path = initial_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook should be installed") + .hook_path + .clone(); + + let foreign_bytes = b"#!/bin/sh\necho husky-style-guard\n".to_vec(); + fs::write(&pre_commit_path, &foreign_bytes).expect("seed foreign pre-commit hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) + .expect("mark foreign pre-commit hook executable"); + } + + let outcome = install::install_required_git_hooks(&repo) + .expect("hook install over a foreign hook should succeed"); + let result = outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook result should be present"); + + assert_eq!(result.status, RequiredHookInstallStatus::Updated); + assert!(!result.unreachable_block_advisory); + + let installed_bytes = fs::read(&pre_commit_path).expect("read installed pre-commit hook"); + assert!( + installed_bytes.starts_with(&foreign_bytes), + "foreign hook content should survive as an exact prefix" + ); + let installed_text = String::from_utf8(installed_bytes).expect("hook should be utf8"); + assert!(installed_text.contains(hook_merge::MANAGED_BLOCK_START)); + assert!(installed_text.contains(hook_merge::MANAGED_BLOCK_END)); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&pre_commit_path) + .expect("stat installed pre-commit hook") + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0, "installed hook should remain executable"); + } + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn rerunning_hook_install_is_idempotent_for_block_only_and_foreign_plus_block_shapes() { + let repo = init_git_repo("hook-install-idempotent"); + + let first_outcome = + install::install_required_git_hooks(&repo).expect("first hook install should succeed"); + let pre_commit_result = first_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook should be installed"); + assert_eq!( + pre_commit_result.status, + RequiredHookInstallStatus::Installed + ); + + let second_outcome = + install::install_required_git_hooks(&repo).expect("second hook install should succeed"); + let second_pre_commit = second_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook result should be present"); + assert_eq!(second_pre_commit.status, RequiredHookInstallStatus::Skipped); + assert_eq!( + fs::read(&second_pre_commit.hook_path).expect("read block-only pre-commit hook"), + fs::read(&pre_commit_result.hook_path).expect("read initial pre-commit hook"), + "block-only hook bytes should be unchanged across reruns" + ); + + let commit_msg_result = first_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) + .expect("commit-msg hook should be installed"); + let commit_msg_path = commit_msg_result.hook_path.clone(); + let foreign_prefix = b"#!/bin/sh\necho foreign-commit-msg-guard\n".to_vec(); + fs::write(&commit_msg_path, &foreign_prefix).expect("seed foreign commit-msg hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&commit_msg_path, fs::Permissions::from_mode(0o755)) + .expect("mark foreign commit-msg hook executable"); + } + + let appended_outcome = install::install_required_git_hooks(&repo) + .expect("hook install appending to foreign commit-msg hook should succeed"); + let appended_result = appended_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) + .expect("commit-msg hook result should be present"); + assert_eq!(appended_result.status, RequiredHookInstallStatus::Updated); + let appended_bytes = fs::read(&commit_msg_path).expect("read appended commit-msg hook"); + + let rerun_outcome = install::install_required_git_hooks(&repo) + .expect("rerunning hook install over foreign-plus-block hook should succeed"); + let rerun_result = rerun_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) + .expect("commit-msg hook result should be present"); + assert_eq!(rerun_result.status, RequiredHookInstallStatus::Skipped); + assert_eq!( + fs::read(&commit_msg_path).expect("read commit-msg hook after rerun"), + appended_bytes, + "foreign-plus-block hook bytes should be unchanged across reruns" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn legacy_pre_marker_hook_upgrades_to_the_managed_block_form() { + let repo = init_git_repo("hook-install-legacy-upgrade"); + + let initial_outcome = + install::install_required_git_hooks(&repo).expect("initial hook install should succeed"); + let pre_commit_path = initial_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook should be installed") + .hook_path + .clone(); + let canonical_bytes = fs::read(&pre_commit_path).expect("read canonical pre-commit hook"); + + let legacy_bytes = b"#!/bin/sh\nset -eu\nif ! command -v sce >/dev/null 2>&1; then\n echo 'Install: https://sce.crocoder.dev/docs/getting-started#install-cli'\n exit 0\nfi\nexec sce hooks pre-commit \"$@\"\n".to_vec(); + fs::write(&pre_commit_path, &legacy_bytes).expect("seed legacy pre-commit hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) + .expect("mark legacy pre-commit hook executable"); + } + + let outcome = install::install_required_git_hooks(&repo) + .expect("hook install upgrading a legacy hook should succeed"); + let result = outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook result should be present"); + + assert_eq!(result.status, RequiredHookInstallStatus::Updated); + assert_eq!( + fs::read(&pre_commit_path).expect("read upgraded pre-commit hook"), + canonical_bytes, + "a legacy pre-marker hook should upgrade to the canonical marker form" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&pre_commit_path) + .expect("stat upgraded pre-commit hook") + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0, "upgraded hook should remain executable"); + } + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn foreign_hook_ending_in_exec_installs_the_block_and_reports_the_advisory() { + let repo = init_git_repo("hook-install-unreachable-advisory"); + + let initial_outcome = + install::install_required_git_hooks(&repo).expect("initial hook install should succeed"); + let pre_commit_path = initial_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook should be installed") + .hook_path + .clone(); + let commit_msg_path = initial_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) + .expect("commit-msg hook should be installed") + .hook_path + .clone(); + + let unreachable_foreign = b"#!/bin/sh\nexec some-other-tool \"$@\"\n".to_vec(); + fs::write(&pre_commit_path, &unreachable_foreign).expect("seed unreachable foreign hook"); + let ordinary_foreign = b"#!/bin/sh\necho foreign-commit-msg-guard\n".to_vec(); + fs::write(&commit_msg_path, &ordinary_foreign).expect("seed ordinary foreign hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) + .expect("mark unreachable foreign hook executable"); + fs::set_permissions(&commit_msg_path, fs::Permissions::from_mode(0o755)) + .expect("mark ordinary foreign hook executable"); + } + + let outcome = install::install_required_git_hooks(&repo) + .expect("hook install over foreign hooks should succeed"); + + let pre_commit_result = outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook result should be present"); + assert_eq!(pre_commit_result.status, RequiredHookInstallStatus::Updated); + assert!( + pre_commit_result.unreachable_block_advisory, + "a hook ending in a zero-indent exec should report the advisory" + ); + assert!( + fs::read(&pre_commit_path) + .expect("read pre-commit hook") + .starts_with(&unreachable_foreign), + "the block should still be installed even though it is unreachable" + ); + + let commit_msg_result = outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) + .expect("commit-msg hook result should be present"); + assert!( + !commit_msg_result.unreachable_block_advisory, + "a hook ending in an ordinary command should not report the advisory" + ); + + let _ = fs::remove_dir_all(&repo); +} From b9bab5c5b66bd00eeaf637be6bb2323df88547b7 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 00:08:05 +0200 Subject: [PATCH 03/16] setup: Refactor setup service into focused modules Split installation and prompting implementations from the setup module into dedicated files, keeping the orchestration module focused on shared setup state and APIs. Preserve existing setup behavior while making the service easier to navigate and maintain. --- cli/src/services/setup/command.rs | 11 - cli/src/services/setup/config_merge.rs | 50 -- cli/src/services/setup/hook_merge.rs | 50 -- cli/src/services/setup/install.rs | 776 +++++++++++++++++++ cli/src/services/setup/mod.rs | 983 +------------------------ cli/src/services/setup/prompt.rs | 189 +++++ 6 files changed, 967 insertions(+), 1092 deletions(-) create mode 100644 cli/src/services/setup/install.rs create mode 100644 cli/src/services/setup/prompt.rs diff --git a/cli/src/services/setup/command.rs b/cli/src/services/setup/command.rs index 86985dc5..b48d2af6 100644 --- a/cli/src/services/setup/command.rs +++ b/cli/src/services/setup/command.rs @@ -20,15 +20,11 @@ impl SetupCommand { .map_err(unexpected_failure)?, }; - // The repository root is resolved before any prompt so the interactive - // optional-workflow prompt can pre-check the persisted selection. let repository_root = resolve_setup_repository(&setup_start_path)?; let setup_dispatch = if self.request.context_only { None } else if let Some(mode) = self.request.config_mode { - // A supplied `--workflow` selection seeds the prompt; otherwise the - // repository's persisted selection does. let optional_workflow_defaults = match &self.request.optional_workflows { Some(selection) => selection.clone(), None => setup::persisted_optional_workflows(&repository_root), @@ -62,7 +58,6 @@ impl SetupCommand { let mut sections = Vec::new(); - // Every successful setup path ensures the durable-context baseline exists. let context_message = setup::bootstrap_context_baseline(&repository_root).map_err(unexpected_failure)?; sections.push(context_message); @@ -71,11 +66,8 @@ impl SetupCommand { return Ok(sections.join("\n\n")); } - // Scope the runtime AppContext to the resolved repository root for lifecycle providers. let ctx = context.with_repo_root(repository_root.clone()); - // Aggregate setup steps from lifecycle providers in order: - // config → local_db → auth_db → agent_trace_db → hooks (when requested). let providers = lifecycle_providers(self.request.install_hooks); for provider in &providers { @@ -90,7 +82,6 @@ impl SetupCommand { } } - // Handle config target installation (OpenCode/Claude assets). if let Some(( resolved_mode, prompted_optional_workflows, @@ -98,8 +89,6 @@ impl SetupCommand { attribution_hooks_enabled, )) = setup_dispatch { - // A prompted selection is authoritative for the run; without one the - // `--workflow` selection (or, absent that, the persisted one) applies. let optional_workflows = prompted_optional_workflows .as_deref() .or(self.request.optional_workflows.as_deref()); diff --git a/cli/src/services/setup/config_merge.rs b/cli/src/services/setup/config_merge.rs index 1d703f46..e8457b13 100644 --- a/cli/src/services/setup/config_merge.rs +++ b/cli/src/services/setup/config_merge.rs @@ -1,29 +1,11 @@ -//! Pure JSON merge for setup-installed config files that a user may already own -//! and extend. Two known shapes today: Claude's `.claude/settings.json` hook -//! registry and `OpenCode`'s `.opencode/opencode.json` plugin registry. Each -//! merge keeps every non-SCE key and entry untouched, and replaces SCE-owned -//! content wholesale so repeated installs stay idempotent. - use anyhow::{Context, Result}; use serde_json::Value; -/// Substring identifying an SCE-authored Claude hook command -/// (`config/pkl/renderers/claude-content.pkl`). const CLAUDE_SCE_HOOK_MARKER: &str = "run-sce-or-show-install-guidance.sh"; const LEGACY_CLAUDE_AGENT_TRACE_PLUGIN: &str = ".claude/plugins/sce-agent-trace.ts"; -/// Path prefix identifying an SCE-authored `OpenCode` plugin registration -/// (`config/pkl/base/opencode.pkl`), matched structurally so a plugin path an -/// older or renamed catalog installed is still recognized as SCE-owned even -/// though the current generated document no longer declares it. const OPENCODE_SCE_PLUGIN_PREFIX: &str = "./plugins/sce-"; -/// Merges `generated` (the freshly rendered SCE settings document) into -/// `existing_bytes` (the user's current `.claude/settings.json`, if any) and -/// returns the merged document's bytes, pretty-printed with a trailing -/// newline. When `existing_bytes` is `None`, returns `generated` verbatim. -/// -/// `source_path` is used only to name the offending file in a parse error. pub fn merge_or_create_claude_settings( existing_bytes: Option<&[u8]>, generated_bytes: &[u8], @@ -47,14 +29,6 @@ pub fn merge_or_create_claude_settings( Ok(serialized.into_bytes()) } -/// Merges `generated` into `existing` for the Claude settings shape: -/// - `$schema` is SCE-owned and taken from `generated`. -/// - `hooks` is merged event-by-event: for each event key `generated.hooks` -/// declares, entries in `existing.hooks[event]` whose command contains the -/// SCE marker are dropped, and `generated.hooks[event]`'s entries are -/// appended after the surviving (non-SCE) entries. Event keys `existing` -/// holds that `generated` does not declare are left untouched. -/// - Every other top-level key in `existing` is left untouched. fn merge_claude_settings(existing: &Value, generated: &Value, source_path: &str) -> Result { let mut existing_obj = existing.as_object().cloned().with_context(|| { format!("Existing config file '{source_path}' must contain a top-level JSON object.") @@ -132,12 +106,6 @@ fn hook_is_legacy_claude_agent_trace(hook: &Value) -> bool { == Some(LEGACY_CLAUDE_AGENT_TRACE_PLUGIN) } -/// Merges `generated` (the freshly rendered SCE `OpenCode` config) into -/// `existing_bytes` (the user's current `.opencode/opencode.json`, if any) and -/// returns the merged document's bytes, pretty-printed with a trailing -/// newline. When `existing_bytes` is `None`, returns `generated` verbatim. -/// -/// `source_path` is used only to name the offending file in a parse error. pub fn merge_or_create_opencode_config( existing_bytes: Option<&[u8]>, generated_bytes: &[u8], @@ -161,13 +129,6 @@ pub fn merge_or_create_opencode_config( Ok(serialized.into_bytes()) } -/// Merges `generated` into `existing` for the `OpenCode` config shape: -/// - `$schema` is SCE-owned and taken from `generated`. -/// - `plugin` is merged as a set: entries in `existing.plugin` shaped like an -/// SCE plugin path are dropped (whether or not `generated.plugin` still -/// declares them), and `generated.plugin`'s entries are appended after the -/// surviving (non-SCE) entries. -/// - Every other top-level key in `existing` is left untouched. fn merge_opencode_config(existing: &Value, generated: &Value, source_path: &str) -> Result { let mut existing_obj = existing.as_object().cloned().with_context(|| { format!("Existing config file '{source_path}' must contain a top-level JSON object.") @@ -204,19 +165,12 @@ fn merge_opencode_config(existing: &Value, generated: &Value, source_path: &str) Ok(Value::Object(existing_obj)) } -/// True when a `plugin` array entry is a string shaped like an SCE plugin -/// registration path (`./plugins/sce-*`). fn plugin_entry_is_sce_owned(entry: &Value) -> bool { entry .as_str() .is_some_and(|path| path.starts_with(OPENCODE_SCE_PLUGIN_PREFIX)) } -/// True when merging `generated` into `existing_bytes` would be a no-op, i.e. -/// `existing_bytes` already carries a current, complete copy of every -/// SCE-owned hook entry the generated document declares. Used by `sce doctor` -/// to tell a merged file that legitimately carries extra user content apart -/// from an SCE-owned fragment that is missing or stale. pub(crate) fn claude_settings_fragment_is_current( existing_bytes: &[u8], generated_bytes: &[u8], @@ -230,10 +184,6 @@ pub(crate) fn claude_settings_fragment_is_current( Ok(merged == existing) } -/// True when merging `generated` into `existing_bytes` would be a no-op, i.e. -/// `existing_bytes` already carries every canonical SCE plugin path the -/// generated document declares and no stale SCE-shaped plugin path. Used by -/// `sce doctor` for the same purpose as `claude_settings_fragment_is_current`. pub(crate) fn opencode_config_fragment_is_current( existing_bytes: &[u8], generated_bytes: &[u8], diff --git a/cli/src/services/setup/hook_merge.rs b/cli/src/services/setup/hook_merge.rs index 452ab5af..4f3f1944 100644 --- a/cli/src/services/setup/hook_merge.rs +++ b/cli/src/services/setup/hook_merge.rs @@ -1,64 +1,26 @@ -//! Pure byte-level merge for git hooks that a repository may already own and -//! extend with its own script (husky, lefthook, or a hand-written hook). -//! Mirrors `config_merge.rs`'s approach for the two JSON merge targets: no -//! filesystem access, a classification of what happened, and idempotence -//! across repeated merges. -//! -//! SCE's logic in a hook lives inside a stable marker pair, -//! `MANAGED_BLOCK_START` / `MANAGED_BLOCK_END`. A hook that already carries -//! the pair is owned within that block; a hook predating the markers is -//! recognized as SCE-owned wholesale by the presence of the canonical -//! guidance URL and replaced entirely; any other hook is foreign, and its -//! bytes are kept as an exact prefix with the canonical block appended after -//! them. - use anyhow::{bail, Result}; -/// Opening marker line of the SCE managed block, matched as an exact line -/// (`config/pkl/renderers/*-hooks.pkl` templates emit it verbatim). pub const MANAGED_BLOCK_START: &str = "# >>> sce managed block (do not edit) >>>"; -/// Closing marker line of the SCE managed block. pub const MANAGED_BLOCK_END: &str = "# <<< sce managed block <<<"; -/// Substring identifying a pre-marker SCE hook payload, from before the -/// managed block existed: the CLI installation guidance URL every canonical -/// template has always printed when `sce` is missing. const LEGACY_GUIDANCE_URL: &str = "https://sce.crocoder.dev/docs/getting-started#install-cli"; -/// What `merge_or_create_hook` did to produce its output bytes. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum HookMergeKind { - /// No hook existed; the canonical template was used verbatim. Created, - /// A hook already carried a managed block, and the block's content - /// differed from the canonical block, or the hook was a legacy - /// pre-marker SCE payload replaced wholesale. ManagedBlockReplaced, - /// A foreign hook without a managed block was kept, with the canonical - /// block appended after its content. AppendedToForeign, - /// A hook already carried a managed block identical to the canonical - /// one; the input bytes are returned unchanged. AlreadyCurrent, } -/// Result of merging a hook's existing bytes with the canonical template. #[derive(Clone, Debug, Eq, PartialEq)] pub struct HookMerge { - /// The bytes to install. pub bytes: Vec, - /// What kind of merge produced `bytes`. pub kind: HookMergeKind, - /// True when `kind` is `AppendedToForeign` and the foreign hook's last - /// effective line is a zero-indent `exec` or `exit`, so the appended - /// block would never run. pub unreachable_block_advisory: bool, } -/// Computes the bytes to install for a git hook named `hook_name`, given its -/// current bytes (`existing`, `None` when no hook exists) and the canonical -/// template (`canonical`). Performs no filesystem access. pub fn merge_or_create_hook( existing: Option<&[u8]>, canonical: &[u8], @@ -131,10 +93,6 @@ pub fn merge_or_create_hook( } } -/// Where the SCE managed block's marker lines were found in a byte buffer, -/// as byte offsets: `Balanced(start, end)` is the offset of the start -/// marker line's first byte and the offset just past the end marker line's -/// trailing newline (or end of buffer). enum BlockLocation { Absent, Balanced(usize, usize), @@ -151,10 +109,6 @@ fn locate_block(bytes: &[u8]) -> BlockLocation { } } -/// Finds the line in `bytes` whose content, with a trailing `\r?\n` -/// stripped, is exactly `marker`. Returns `(line_start, line_end)` byte -/// offsets, where `line_end` includes the line's own trailing newline (or is -/// the buffer length for a final line with none). fn locate_marker_line(bytes: &[u8], marker: &str) -> Option<(usize, usize)> { let mut offset = 0; for line in bytes.split_inclusive(|&byte| byte == b'\n') { @@ -168,10 +122,6 @@ fn locate_marker_line(bytes: &[u8], marker: &str) -> Option<(usize, usize)> { None } -/// True when the last non-blank, non-comment line of `text` sits at zero -/// indentation and starts with `exec ` or `exit` — a narrow heuristic (no -/// shell parsing) for "a block appended after this line would not run". -/// Deliberately misses an early `exit` guarded by a conditional. fn ends_with_unreachable_control_flow(text: &str) -> bool { let Some(line) = text.lines().rev().find(|line| { let trimmed = line.trim(); diff --git a/cli/src/services/setup/install.rs b/cli/src/services/setup/install.rs new file mode 100644 index 00000000..986a4c53 --- /dev/null +++ b/cli/src/services/setup/install.rs @@ -0,0 +1,776 @@ +use anyhow::{bail, Context, Result}; +use std::{ + fs, io, + path::{Component, Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::services::codex_hook_config; +use crate::services::default_paths::InstallTargetPaths; +use crate::services::security::{ensure_directory_is_writable, redact_sensitive_text}; + +use super::config_merge; +use super::hook_merge; +use super::{ + classify_git_exit, cleanup_path_if_exists, concrete_targets_for, + embedded_assets_for_concrete_target, hook_install_recovery_guidance, + iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets, + setup_install_recovery_guidance, EmbeddedAsset, GitExitKind, GitRepositoryResolutionError, + RequiredHookInstallResult, RequiredHookInstallStatus, RequiredHooksInstallOutcome, + SetupInstallOutcome, SetupInstallTargetResult, SetupTarget, +}; +use crate::services::default_paths; +use crate::services::default_paths::claude_asset; + +pub(super) fn prepare_setup_hooks_repository(repository_root: &Path) -> Result { + let normalized_repository_root = normalize_user_repository_path(repository_root)?; + Ok(resolve_git_repository_root(&normalized_repository_root)?) +} + +pub(super) fn ensure_git_repository( + directory: &Path, +) -> Result { + resolve_git_repository_root(directory) +} + +pub(super) fn install_required_git_hooks( + repository_root: &Path, +) -> Result { + install_required_git_hooks_with_rename(repository_root, |from, to| fs::rename(from, to)) +} + +pub(super) fn install_required_git_hooks_with_rename( + repository_root: &Path, + rename_fn: F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + let resolved_repository_root = prepare_setup_hooks_repository(repository_root)?; + install_required_git_hooks_in_resolved_repository(&resolved_repository_root, rename_fn) +} + +pub(super) fn install_embedded_setup_assets( + repository_root: &Path, + target: SetupTarget, + selected_optional_workflows: &[String], +) -> Result { + install_embedded_setup_assets_with_rename( + repository_root, + target, + selected_optional_workflows, + |from, to| fs::rename(from, to), + ) +} + +pub(super) fn repair_merge_target_asset( + repository_root: &Path, + target: SetupTarget, + relative_path: &str, +) -> Result<()> { + let asset = embedded_assets_for_concrete_target(target) + .iter() + .find(|asset| asset.relative_path == relative_path) + .with_context(|| { + format!("No embedded asset named '{relative_path}' for target {target:?}") + })?; + + let install_targets = InstallTargetPaths::new(repository_root); + let destination_root = match target { + SetupTarget::OpenCode => install_targets.opencode_target_dir(), + SetupTarget::Claude => install_targets.claude_target_dir(), + SetupTarget::Pi => install_targets.pi_target_dir(), + SetupTarget::Codex => install_targets.codex_target_dir(), + SetupTarget::All => unreachable!("meta targets are expanded into concrete targets"), + }; + + install_single_asset_with_rename(target, &destination_root, asset, &mut |from, to| { + fs::rename(from, to) + }) +} + +fn install_required_git_hooks_in_resolved_repository( + resolved_repository_root: &Path, + mut rename_fn: F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + ensure_directory_is_writable(resolved_repository_root, "repository root")?; + let hooks_directory = resolve_git_hooks_directory(resolved_repository_root)?; + fs::create_dir_all(&hooks_directory).with_context(|| { + format!( + "Failed to create git hooks directory '{}'", + hooks_directory.display() + ) + })?; + ensure_directory_is_writable(&hooks_directory, "git hooks directory")?; + + let mut hook_results = Vec::new(); + for hook_asset in iter_required_hook_assets() { + let hook_result = + install_single_required_hook_with_rename(&hooks_directory, hook_asset, &mut rename_fn)?; + hook_results.push(hook_result); + } + + Ok(RequiredHooksInstallOutcome { + repository_root: resolved_repository_root.to_path_buf(), + hooks_directory, + hook_results, + }) +} + +fn install_single_required_hook_with_rename( + hooks_directory: &Path, + hook_asset: &EmbeddedAsset, + rename_fn: &mut F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + validate_embedded_relative_path(hook_asset.relative_path)?; + + let hook_path = hooks_directory.join(hook_asset.relative_path); + let existing_metadata = fs::metadata(&hook_path).ok(); + + let existing_bytes = + if existing_metadata + .as_ref() + .is_some_and(std::fs::Metadata::is_file) + { + Some(fs::read(&hook_path).with_context(|| { + format!("Failed to read existing hook '{}'", hook_path.display()) + })?) + } else if existing_metadata.is_some() { + bail!( + "Existing hook target '{}' is not a file", + hook_path.display() + ); + } else { + None + }; + + let merge = hook_merge::merge_or_create_hook( + existing_bytes.as_deref(), + hook_asset.bytes, + hook_asset.relative_path, + )?; + + if let Some(existing_bytes) = existing_bytes.as_deref() { + let executable = is_executable_file(&hook_path)?; + if merge.bytes == existing_bytes && executable { + return Ok(RequiredHookInstallResult { + hook_name: hook_asset.relative_path.to_string(), + hook_path, + status: RequiredHookInstallStatus::Skipped, + unreachable_block_advisory: merge.unreachable_block_advisory, + }); + } + } + + let had_existing_hook = existing_metadata.is_some(); + + let hook_staging_path = create_hook_staging_path(hooks_directory, hook_asset.relative_path)?; + if let Err(error) = write_hook_payload_to_staging(&hook_staging_path, &merge.bytes) { + cleanup_path_if_exists(&hook_staging_path); + return Err(error); + } + + let action = if had_existing_hook { + "update" + } else { + "install" + }; + if let Err(error) = rename_fn(&hook_staging_path, &hook_path).with_context(|| { + format!( + "Failed to {action} required hook '{}' at '{}'", + hook_asset.relative_path, + hook_path.display() + ) + }) { + cleanup_path_if_exists(&hook_staging_path); + let error = if had_existing_hook { + error.context(hook_install_recovery_guidance(&hook_path)) + } else { + error + }; + return Err(error); + } + + Ok(RequiredHookInstallResult { + hook_name: hook_asset.relative_path.to_string(), + hook_path, + status: if had_existing_hook { + RequiredHookInstallStatus::Updated + } else { + RequiredHookInstallStatus::Installed + }, + unreachable_block_advisory: merge.unreachable_block_advisory, + }) +} + +fn write_hook_payload_to_staging(staging_path: &Path, bytes: &[u8]) -> Result<()> { + fs::write(staging_path, bytes).with_context(|| { + format!( + "Failed to write staged hook payload '{}'", + staging_path.display() + ) + })?; + ensure_executable_permissions(staging_path)?; + Ok(()) +} + +fn create_hook_staging_path(hooks_directory: &Path, hook_name: &str) -> Result { + let epoch_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is before UNIX_EPOCH")? + .as_nanos(); + let sanitized_hook_name = hook_name.replace('/', "-"); + + for attempt in 0..1000_u16 { + let candidate = hooks_directory.join(format!( + ".sce-hook-staging-{sanitized_hook_name}-{epoch_nanos}-{}-{attempt}", + std::process::id() + )); + + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&candidate) + { + Ok(_) => return Ok(candidate), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "Failed to allocate hook staging file '{}'", + candidate.display() + ) + }); + } + } + } + + bail!( + "Could not allocate a unique hook staging file under '{}'", + hooks_directory.display() + ) +} + +fn normalize_user_repository_path(repository_root: &Path) -> Result { + if repository_root.as_os_str().is_empty() { + bail!("Option '--repo' must not be empty. Try: pass a path to an existing git repository."); + } + + let canonical_repository_root = fs::canonicalize(repository_root).with_context(|| { + format!( + "Failed to resolve repository path '{}'. Try: pass a path to an existing git repository.", + repository_root.display() + ) + })?; + + let metadata = fs::metadata(&canonical_repository_root).with_context(|| { + format!( + "Failed to inspect repository path '{}'.", + canonical_repository_root.display() + ) + })?; + + if !metadata.is_dir() { + bail!( + "Repository path '{}' is not a directory. Try: pass a path to an existing git repository.", + canonical_repository_root.display() + ); + } + + Ok(canonical_repository_root) +} + +fn resolve_git_repository_root( + repository_root: &Path, +) -> Result { + let repository_root_output = run_git_command_in_directory( + repository_root, + &["rev-parse", "--show-toplevel"], + "Failed to resolve repository root. Ensure '--repo' points to an accessible git repository.", + ) + .map_err(map_setup_repository_resolution_error)?; + Ok(PathBuf::from(repository_root_output)) +} + +fn map_setup_repository_resolution_error(error: GitCommandError) -> GitRepositoryResolutionError { + let is_not_repository = matches!( + &error, + GitCommandError::NonZeroExit { + kind: GitExitKind::NotRepository, + .. + } + ); + let source = anyhow::Error::new(error); + + if is_not_repository { + GitRepositoryResolutionError::NotGitRepository(source) + } else { + GitRepositoryResolutionError::Unexpected(source) + } +} + +fn resolve_git_hooks_directory(repository_root: &Path) -> Result { + let hooks_directory_output = run_git_command_in_directory( + repository_root, + &["rev-parse", "--git-path", "hooks"], + "Failed to resolve effective git hooks path.", + )?; + + let hooks_directory = PathBuf::from(&hooks_directory_output); + if hooks_directory.is_absolute() { + return Ok(hooks_directory); + } + + Ok(repository_root.join(hooks_directory)) +} + +#[derive(Debug)] +enum GitCommandError { + Spawn { + context: String, + directory: PathBuf, + source: std::io::Error, + }, + NonZeroExit { + context: String, + directory: PathBuf, + status: std::process::ExitStatus, + kind: GitExitKind, + diagnostic: String, + }, + InvalidUtf8 { + context: String, + source: std::string::FromUtf8Error, + }, + EmptyOutput { + context: String, + directory: PathBuf, + }, +} + +impl std::fmt::Display for GitCommandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Spawn { + context, + directory, + source, + } => write!( + f, + "{context} (directory: '{}'): {source}", + directory.display() + ), + Self::NonZeroExit { + context, + directory, + status, + diagnostic, + .. + } => write!( + f, + "{context} (directory: '{}', status: {status:?}) {diagnostic}", + directory.display() + ), + Self::InvalidUtf8 { context, source } => { + write!( + f, + "{context}: git command output contained invalid UTF-8: {source}" + ) + } + Self::EmptyOutput { context, directory } => write!( + f, + "{context} (directory: '{}'): git command returned empty output", + directory.display() + ), + } + } +} + +impl std::error::Error for GitCommandError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Spawn { source, .. } => Some(source), + Self::InvalidUtf8 { source, .. } => Some(source), + Self::NonZeroExit { .. } | Self::EmptyOutput { .. } => None, + } + } +} + +fn run_git_command_in_directory( + repository_root: &Path, + args: &[&str], + context_message: &str, +) -> std::result::Result { + let output = Command::new("git") + .env("LC_ALL", "C") + .env("LANG", "C") + .env_remove("LANGUAGE") + .args(args) + .current_dir(repository_root) + .output() + .map_err(|source| GitCommandError::Spawn { + context: context_message.to_string(), + directory: repository_root.to_path_buf(), + source, + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let kind = classify_git_exit(&stderr); + let diagnostic = if stderr.is_empty() { + String::from("git command exited with a non-zero status") + } else { + redact_sensitive_text(&stderr) + }; + return Err(GitCommandError::NonZeroExit { + context: context_message.to_string(), + directory: repository_root.to_path_buf(), + status: output.status, + kind, + diagnostic, + }); + } + + let stdout = + String::from_utf8(output.stdout).map_err(|source| GitCommandError::InvalidUtf8 { + context: context_message.to_string(), + source, + })?; + let stdout = stdout.trim().to_string(); + if stdout.is_empty() { + return Err(GitCommandError::EmptyOutput { + context: context_message.to_string(), + directory: repository_root.to_path_buf(), + }); + } + + Ok(stdout) +} + +#[cfg(unix)] +fn ensure_executable_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::metadata(path) + .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; + let mut permissions = metadata.permissions(); + permissions.set_mode(permissions.mode() | 0o111); + fs::set_permissions(path, permissions).with_context(|| { + format!( + "Failed to set executable permissions for '{}'", + path.display() + ) + })?; + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_executable_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn is_executable_file(path: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::metadata(path) + .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; + Ok(metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable_file(path: &Path) -> Result { + let metadata = fs::metadata(path) + .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; + Ok(metadata.is_file()) +} + +pub(super) fn install_embedded_setup_assets_with_rename( + repository_root: &Path, + target: SetupTarget, + selected_optional_workflows: &[String], + mut rename_fn: F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + ensure_directory_is_writable(repository_root, "setup repository root")?; + + let mut target_results = Vec::new(); + + for concrete_target in concrete_targets_for(target) { + let concrete_target = *concrete_target; + let assets: Vec<&'static EmbeddedAsset> = + iter_embedded_assets_for_setup_target_with_selection( + concrete_target, + selected_optional_workflows, + ) + .collect(); + let result = install_assets_for_concrete_target_with_rename( + repository_root, + concrete_target, + &assets, + &mut rename_fn, + )?; + target_results.push(result); + } + + Ok(SetupInstallOutcome { target_results }) +} + +fn install_assets_for_concrete_target_with_rename( + repository_root: &Path, + target: SetupTarget, + assets: &[&'static EmbeddedAsset], + rename_fn: &mut F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + let install_targets = InstallTargetPaths::new(repository_root); + let destination_root = match target { + SetupTarget::OpenCode => install_targets.opencode_target_dir(), + SetupTarget::Claude => install_targets.claude_target_dir(), + SetupTarget::Pi => install_targets.pi_target_dir(), + SetupTarget::Codex => install_targets.codex_target_dir(), + SetupTarget::All => { + unreachable!("meta targets are expanded into concrete targets") + } + }; + + for asset in assets { + install_single_asset_with_rename(target, &destination_root, asset, rename_fn)?; + } + + prune_stale_assets_for_concrete_target(&destination_root, target, assets)?; + + Ok(SetupInstallTargetResult { + target, + destination_root, + installed_file_count: assets.len(), + }) +} + +fn prune_stale_assets_for_concrete_target( + destination_root: &Path, + target: SetupTarget, + installed_assets: &[&'static EmbeddedAsset], +) -> Result<()> { + let installed_paths: std::collections::HashSet<&'static str> = installed_assets + .iter() + .map(|asset| asset.relative_path) + .collect(); + + for asset in embedded_assets_for_concrete_target(target) { + if installed_paths.contains(asset.relative_path) { + continue; + } + + let destination = destination_root.join(asset.relative_path); + if !destination.is_file() { + continue; + } + + fs::remove_file(&destination).with_context(|| { + format!( + "Failed to prune unselected setup asset '{}'", + destination.display() + ) + })?; + + remove_empty_ancestor_directories(destination_root, &destination); + } + + Ok(()) +} + +fn remove_empty_ancestor_directories(destination_root: &Path, removed_file: &Path) { + let mut current = removed_file.parent(); + while let Some(directory) = current { + if directory == destination_root || !directory.starts_with(destination_root) { + break; + } + if fs::remove_dir(directory).is_err() { + break; + } + current = directory.parent(); + } +} + +fn is_claude_settings_merge_target(target: SetupTarget, relative_path: &str) -> bool { + target == SetupTarget::Claude && relative_path == claude_asset::SETTINGS_FILE +} + +fn is_opencode_config_merge_target(target: SetupTarget, relative_path: &str) -> bool { + target == SetupTarget::OpenCode && relative_path == default_paths::repo_file::OPENCODE_MANIFEST +} + +fn is_codex_hooks_merge_target(target: SetupTarget, relative_path: &str) -> bool { + target == SetupTarget::Codex && relative_path == ".codex/hooks.json" +} + +fn install_single_asset_with_rename( + target: SetupTarget, + destination_root: &Path, + asset: &'static EmbeddedAsset, + rename_fn: &mut F, +) -> Result<()> +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + validate_embedded_relative_path(asset.relative_path)?; + let destination = destination_root.join(asset.relative_path); + let parent = destination + .parent() + .context("Embedded asset destination should have a parent directory")?; + + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create parent directory '{}' for setup asset", + parent.display() + ) + })?; + + if destination.is_dir() { + bail!( + "Setup asset destination '{}' is an existing directory, not a file. Try: remove or rename the directory and rerun 'sce setup'.", + destination.display() + ); + } + + let install_bytes: Vec = if is_claude_settings_merge_target(target, asset.relative_path) { + let existing_bytes = if destination.is_file() { + Some(fs::read(&destination).with_context(|| { + format!( + "Failed to read existing setup asset '{}' for merge", + destination.display() + ) + })?) + } else { + None + }; + config_merge::merge_or_create_claude_settings( + existing_bytes.as_deref(), + asset.bytes, + &destination.display().to_string(), + )? + } else if is_opencode_config_merge_target(target, asset.relative_path) { + let existing_bytes = if destination.is_file() { + Some(fs::read(&destination).with_context(|| { + format!( + "Failed to read existing setup asset '{}' for merge", + destination.display() + ) + })?) + } else { + None + }; + config_merge::merge_or_create_opencode_config( + existing_bytes.as_deref(), + asset.bytes, + &destination.display().to_string(), + )? + } else if is_codex_hooks_merge_target(target, asset.relative_path) { + let existing_bytes = if destination.is_file() { + Some(fs::read(&destination).with_context(|| { + format!( + "Failed to read existing setup asset '{}' for merge", + destination.display() + ) + })?) + } else { + None + }; + codex_hook_config::merge_or_create( + existing_bytes.as_deref(), + asset.bytes, + &destination.display().to_string(), + )? + } else { + asset.bytes.to_vec() + }; + + let staging_path = create_asset_staging_path(parent, asset.relative_path)?; + if let Err(error) = fs::write(&staging_path, &install_bytes).with_context(|| { + format!( + "Failed to write staged embedded asset '{}'", + staging_path.display() + ) + }) { + cleanup_path_if_exists(&staging_path); + return Err(error); + } + + if let Err(error) = rename_fn(&staging_path, &destination).with_context(|| { + format!( + "Failed to install staged asset '{}' into destination '{}'", + staging_path.display(), + destination.display() + ) + }) { + cleanup_path_if_exists(&staging_path); + return Err(error.context(setup_install_recovery_guidance(target, &destination))); + } + + Ok(()) +} + +fn create_asset_staging_path(parent: &Path, relative_path: &str) -> Result { + let epoch_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is before UNIX_EPOCH")? + .as_nanos(); + let sanitized_name = relative_path.replace(['/', '\\'], "-"); + + for attempt in 0..1000_u16 { + let candidate = parent.join(format!( + ".sce-setup-staging-{sanitized_name}-{epoch_nanos}-{}-{attempt}", + std::process::id() + )); + + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&candidate) + { + Ok(_) => return Ok(candidate), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| { + format!("Failed to allocate staging file '{}'", candidate.display()) + }); + } + } + } + + bail!( + "Could not allocate a unique staging file under '{}'", + parent.display() + ) +} + +fn validate_embedded_relative_path(relative_path: &str) -> Result<()> { + let path = Path::new(relative_path); + + if path.is_absolute() { + bail!("Embedded asset path '{relative_path}' must be relative, not absolute"); + } + + for component in path.components() { + match component { + Component::Normal(_) => {} + _ => { + bail!("Embedded asset path '{relative_path}' contains disallowed component"); + } + } + } + + Ok(()) +} diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index bb1cadab..feeb101e 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -903,793 +903,7 @@ pub fn persist_integration_targets( Ok(()) } -mod install { - use anyhow::{bail, Context, Result}; - use std::{ - fs, io, - path::{Component, Path, PathBuf}, - process::Command, - time::{SystemTime, UNIX_EPOCH}, - }; - - use crate::services::codex_hook_config; - use crate::services::default_paths::InstallTargetPaths; - use crate::services::security::{ensure_directory_is_writable, redact_sensitive_text}; - - use super::config_merge; - use super::hook_merge; - use super::{ - classify_git_exit, cleanup_path_if_exists, concrete_targets_for, - embedded_assets_for_concrete_target, hook_install_recovery_guidance, - iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets, - setup_install_recovery_guidance, EmbeddedAsset, GitExitKind, GitRepositoryResolutionError, - RequiredHookInstallResult, RequiredHookInstallStatus, RequiredHooksInstallOutcome, - SetupInstallOutcome, SetupInstallTargetResult, SetupTarget, - }; - use crate::services::default_paths; - use crate::services::default_paths::claude_asset; - - pub(super) fn prepare_setup_hooks_repository(repository_root: &Path) -> Result { - let normalized_repository_root = normalize_user_repository_path(repository_root)?; - Ok(resolve_git_repository_root(&normalized_repository_root)?) - } - - pub(super) fn ensure_git_repository( - directory: &Path, - ) -> Result { - resolve_git_repository_root(directory) - } - - pub(super) fn install_required_git_hooks( - repository_root: &Path, - ) -> Result { - install_required_git_hooks_with_rename(repository_root, |from, to| fs::rename(from, to)) - } - - pub(super) fn install_required_git_hooks_with_rename( - repository_root: &Path, - rename_fn: F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - let resolved_repository_root = prepare_setup_hooks_repository(repository_root)?; - install_required_git_hooks_in_resolved_repository(&resolved_repository_root, rename_fn) - } - - pub(super) fn install_embedded_setup_assets( - repository_root: &Path, - target: SetupTarget, - selected_optional_workflows: &[String], - ) -> Result { - install_embedded_setup_assets_with_rename( - repository_root, - target, - selected_optional_workflows, - |from, to| fs::rename(from, to), - ) - } - - pub(super) fn repair_merge_target_asset( - repository_root: &Path, - target: SetupTarget, - relative_path: &str, - ) -> Result<()> { - let asset = embedded_assets_for_concrete_target(target) - .iter() - .find(|asset| asset.relative_path == relative_path) - .with_context(|| { - format!("No embedded asset named '{relative_path}' for target {target:?}") - })?; - - let install_targets = InstallTargetPaths::new(repository_root); - let destination_root = match target { - SetupTarget::OpenCode => install_targets.opencode_target_dir(), - SetupTarget::Claude => install_targets.claude_target_dir(), - SetupTarget::Pi => install_targets.pi_target_dir(), - SetupTarget::Codex => install_targets.codex_target_dir(), - SetupTarget::All => unreachable!("meta targets are expanded into concrete targets"), - }; - - install_single_asset_with_rename(target, &destination_root, asset, &mut |from, to| { - fs::rename(from, to) - }) - } - - fn install_required_git_hooks_in_resolved_repository( - resolved_repository_root: &Path, - mut rename_fn: F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - ensure_directory_is_writable(resolved_repository_root, "repository root")?; - let hooks_directory = resolve_git_hooks_directory(resolved_repository_root)?; - fs::create_dir_all(&hooks_directory).with_context(|| { - format!( - "Failed to create git hooks directory '{}'", - hooks_directory.display() - ) - })?; - ensure_directory_is_writable(&hooks_directory, "git hooks directory")?; - - let mut hook_results = Vec::new(); - for hook_asset in iter_required_hook_assets() { - let hook_result = install_single_required_hook_with_rename( - &hooks_directory, - hook_asset, - &mut rename_fn, - )?; - hook_results.push(hook_result); - } - - Ok(RequiredHooksInstallOutcome { - repository_root: resolved_repository_root.to_path_buf(), - hooks_directory, - hook_results, - }) - } - - fn install_single_required_hook_with_rename( - hooks_directory: &Path, - hook_asset: &EmbeddedAsset, - rename_fn: &mut F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - validate_embedded_relative_path(hook_asset.relative_path)?; - - let hook_path = hooks_directory.join(hook_asset.relative_path); - let existing_metadata = fs::metadata(&hook_path).ok(); - - let existing_bytes = if existing_metadata - .as_ref() - .is_some_and(std::fs::Metadata::is_file) - { - Some(fs::read(&hook_path).with_context(|| { - format!("Failed to read existing hook '{}'", hook_path.display()) - })?) - } else if existing_metadata.is_some() { - bail!( - "Existing hook target '{}' is not a file", - hook_path.display() - ); - } else { - None - }; - - let merge = hook_merge::merge_or_create_hook( - existing_bytes.as_deref(), - hook_asset.bytes, - hook_asset.relative_path, - )?; - - if let Some(existing_bytes) = existing_bytes.as_deref() { - let executable = is_executable_file(&hook_path)?; - if merge.bytes == existing_bytes && executable { - return Ok(RequiredHookInstallResult { - hook_name: hook_asset.relative_path.to_string(), - hook_path, - status: RequiredHookInstallStatus::Skipped, - unreachable_block_advisory: merge.unreachable_block_advisory, - }); - } - } - - let had_existing_hook = existing_metadata.is_some(); - - let hook_staging_path = - create_hook_staging_path(hooks_directory, hook_asset.relative_path)?; - if let Err(error) = write_hook_payload_to_staging(&hook_staging_path, &merge.bytes) { - cleanup_path_if_exists(&hook_staging_path); - return Err(error); - } - - let action = if had_existing_hook { - "update" - } else { - "install" - }; - if let Err(error) = rename_fn(&hook_staging_path, &hook_path).with_context(|| { - format!( - "Failed to {action} required hook '{}' at '{}'", - hook_asset.relative_path, - hook_path.display() - ) - }) { - cleanup_path_if_exists(&hook_staging_path); - let error = if had_existing_hook { - error.context(hook_install_recovery_guidance(&hook_path)) - } else { - error - }; - return Err(error); - } - - Ok(RequiredHookInstallResult { - hook_name: hook_asset.relative_path.to_string(), - hook_path, - status: if had_existing_hook { - RequiredHookInstallStatus::Updated - } else { - RequiredHookInstallStatus::Installed - }, - unreachable_block_advisory: merge.unreachable_block_advisory, - }) - } - - fn write_hook_payload_to_staging(staging_path: &Path, bytes: &[u8]) -> Result<()> { - fs::write(staging_path, bytes).with_context(|| { - format!( - "Failed to write staged hook payload '{}'", - staging_path.display() - ) - })?; - ensure_executable_permissions(staging_path)?; - Ok(()) - } - - fn create_hook_staging_path(hooks_directory: &Path, hook_name: &str) -> Result { - let epoch_nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("System clock is before UNIX_EPOCH")? - .as_nanos(); - let sanitized_hook_name = hook_name.replace('/', "-"); - - for attempt in 0..1000_u16 { - let candidate = hooks_directory.join(format!( - ".sce-hook-staging-{sanitized_hook_name}-{epoch_nanos}-{}-{attempt}", - std::process::id() - )); - - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&candidate) - { - Ok(_) => return Ok(candidate), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(error).with_context(|| { - format!( - "Failed to allocate hook staging file '{}'", - candidate.display() - ) - }); - } - } - } - - bail!( - "Could not allocate a unique hook staging file under '{}'", - hooks_directory.display() - ) - } - - fn normalize_user_repository_path(repository_root: &Path) -> Result { - if repository_root.as_os_str().is_empty() { - bail!( - "Option '--repo' must not be empty. Try: pass a path to an existing git repository." - ); - } - - let canonical_repository_root = fs::canonicalize(repository_root).with_context(|| { - format!( - "Failed to resolve repository path '{}'. Try: pass a path to an existing git repository.", - repository_root.display() - ) - })?; - - let metadata = fs::metadata(&canonical_repository_root).with_context(|| { - format!( - "Failed to inspect repository path '{}'.", - canonical_repository_root.display() - ) - })?; - - if !metadata.is_dir() { - bail!( - "Repository path '{}' is not a directory. Try: pass a path to an existing git repository.", - canonical_repository_root.display() - ); - } - - Ok(canonical_repository_root) - } - - fn resolve_git_repository_root( - repository_root: &Path, - ) -> Result { - let repository_root_output = run_git_command_in_directory( - repository_root, - &["rev-parse", "--show-toplevel"], - "Failed to resolve repository root. Ensure '--repo' points to an accessible git repository.", - ) - .map_err(map_setup_repository_resolution_error)?; - Ok(PathBuf::from(repository_root_output)) - } - - fn map_setup_repository_resolution_error( - error: GitCommandError, - ) -> GitRepositoryResolutionError { - let is_not_repository = matches!( - &error, - GitCommandError::NonZeroExit { - kind: GitExitKind::NotRepository, - .. - } - ); - let source = anyhow::Error::new(error); - - if is_not_repository { - GitRepositoryResolutionError::NotGitRepository(source) - } else { - GitRepositoryResolutionError::Unexpected(source) - } - } - - fn resolve_git_hooks_directory(repository_root: &Path) -> Result { - let hooks_directory_output = run_git_command_in_directory( - repository_root, - &["rev-parse", "--git-path", "hooks"], - "Failed to resolve effective git hooks path.", - )?; - - let hooks_directory = PathBuf::from(&hooks_directory_output); - if hooks_directory.is_absolute() { - return Ok(hooks_directory); - } - - Ok(repository_root.join(hooks_directory)) - } - - #[derive(Debug)] - enum GitCommandError { - Spawn { - context: String, - directory: PathBuf, - source: std::io::Error, - }, - NonZeroExit { - context: String, - directory: PathBuf, - status: std::process::ExitStatus, - kind: GitExitKind, - diagnostic: String, - }, - InvalidUtf8 { - context: String, - source: std::string::FromUtf8Error, - }, - EmptyOutput { - context: String, - directory: PathBuf, - }, - } - - impl std::fmt::Display for GitCommandError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Spawn { - context, - directory, - source, - } => write!( - f, - "{context} (directory: '{}'): {source}", - directory.display() - ), - Self::NonZeroExit { - context, - directory, - status, - diagnostic, - .. - } => write!( - f, - "{context} (directory: '{}', status: {status:?}) {diagnostic}", - directory.display() - ), - Self::InvalidUtf8 { context, source } => { - write!( - f, - "{context}: git command output contained invalid UTF-8: {source}" - ) - } - Self::EmptyOutput { context, directory } => write!( - f, - "{context} (directory: '{}'): git command returned empty output", - directory.display() - ), - } - } - } - - impl std::error::Error for GitCommandError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Spawn { source, .. } => Some(source), - Self::InvalidUtf8 { source, .. } => Some(source), - Self::NonZeroExit { .. } | Self::EmptyOutput { .. } => None, - } - } - } - - fn run_git_command_in_directory( - repository_root: &Path, - args: &[&str], - context_message: &str, - ) -> std::result::Result { - let output = Command::new("git") - .env("LC_ALL", "C") - .env("LANG", "C") - .env_remove("LANGUAGE") - .args(args) - .current_dir(repository_root) - .output() - .map_err(|source| GitCommandError::Spawn { - context: context_message.to_string(), - directory: repository_root.to_path_buf(), - source, - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let kind = classify_git_exit(&stderr); - let diagnostic = if stderr.is_empty() { - String::from("git command exited with a non-zero status") - } else { - redact_sensitive_text(&stderr) - }; - return Err(GitCommandError::NonZeroExit { - context: context_message.to_string(), - directory: repository_root.to_path_buf(), - status: output.status, - kind, - diagnostic, - }); - } - - let stdout = - String::from_utf8(output.stdout).map_err(|source| GitCommandError::InvalidUtf8 { - context: context_message.to_string(), - source, - })?; - let stdout = stdout.trim().to_string(); - if stdout.is_empty() { - return Err(GitCommandError::EmptyOutput { - context: context_message.to_string(), - directory: repository_root.to_path_buf(), - }); - } - - Ok(stdout) - } - - #[cfg(unix)] - fn ensure_executable_permissions(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let metadata = fs::metadata(path) - .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; - let mut permissions = metadata.permissions(); - permissions.set_mode(permissions.mode() | 0o111); - fs::set_permissions(path, permissions).with_context(|| { - format!( - "Failed to set executable permissions for '{}'", - path.display() - ) - })?; - Ok(()) - } - - #[cfg(not(unix))] - fn ensure_executable_permissions(_path: &Path) -> Result<()> { - Ok(()) - } - - #[cfg(unix)] - fn is_executable_file(path: &Path) -> Result { - use std::os::unix::fs::PermissionsExt; - - let metadata = fs::metadata(path) - .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; - Ok(metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) - } - - #[cfg(not(unix))] - fn is_executable_file(path: &Path) -> Result { - let metadata = fs::metadata(path) - .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; - Ok(metadata.is_file()) - } - - pub(super) fn install_embedded_setup_assets_with_rename( - repository_root: &Path, - target: SetupTarget, - selected_optional_workflows: &[String], - mut rename_fn: F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - ensure_directory_is_writable(repository_root, "setup repository root")?; - - let mut target_results = Vec::new(); - - for concrete_target in concrete_targets_for(target) { - let concrete_target = *concrete_target; - let assets: Vec<&'static EmbeddedAsset> = - iter_embedded_assets_for_setup_target_with_selection( - concrete_target, - selected_optional_workflows, - ) - .collect(); - let result = install_assets_for_concrete_target_with_rename( - repository_root, - concrete_target, - &assets, - &mut rename_fn, - )?; - target_results.push(result); - } - - Ok(SetupInstallOutcome { target_results }) - } - - fn install_assets_for_concrete_target_with_rename( - repository_root: &Path, - target: SetupTarget, - assets: &[&'static EmbeddedAsset], - rename_fn: &mut F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - let install_targets = InstallTargetPaths::new(repository_root); - let destination_root = match target { - SetupTarget::OpenCode => install_targets.opencode_target_dir(), - SetupTarget::Claude => install_targets.claude_target_dir(), - SetupTarget::Pi => install_targets.pi_target_dir(), - SetupTarget::Codex => install_targets.codex_target_dir(), - SetupTarget::All => { - unreachable!("meta targets are expanded into concrete targets") - } - }; - - for asset in assets { - install_single_asset_with_rename(target, &destination_root, asset, rename_fn)?; - } - - prune_stale_assets_for_concrete_target(&destination_root, target, assets)?; - - Ok(SetupInstallTargetResult { - target, - destination_root, - installed_file_count: assets.len(), - }) - } - - fn prune_stale_assets_for_concrete_target( - destination_root: &Path, - target: SetupTarget, - installed_assets: &[&'static EmbeddedAsset], - ) -> Result<()> { - let installed_paths: std::collections::HashSet<&'static str> = installed_assets - .iter() - .map(|asset| asset.relative_path) - .collect(); - - for asset in embedded_assets_for_concrete_target(target) { - if installed_paths.contains(asset.relative_path) { - continue; - } - - let destination = destination_root.join(asset.relative_path); - if !destination.is_file() { - continue; - } - - fs::remove_file(&destination).with_context(|| { - format!( - "Failed to prune unselected setup asset '{}'", - destination.display() - ) - })?; - - remove_empty_ancestor_directories(destination_root, &destination); - } - - Ok(()) - } - - fn remove_empty_ancestor_directories(destination_root: &Path, removed_file: &Path) { - let mut current = removed_file.parent(); - while let Some(directory) = current { - if directory == destination_root || !directory.starts_with(destination_root) { - break; - } - if fs::remove_dir(directory).is_err() { - break; - } - current = directory.parent(); - } - } - - fn is_claude_settings_merge_target(target: SetupTarget, relative_path: &str) -> bool { - target == SetupTarget::Claude && relative_path == claude_asset::SETTINGS_FILE - } - - fn is_opencode_config_merge_target(target: SetupTarget, relative_path: &str) -> bool { - target == SetupTarget::OpenCode - && relative_path == default_paths::repo_file::OPENCODE_MANIFEST - } - - fn is_codex_hooks_merge_target(target: SetupTarget, relative_path: &str) -> bool { - target == SetupTarget::Codex && relative_path == ".codex/hooks.json" - } - - fn install_single_asset_with_rename( - target: SetupTarget, - destination_root: &Path, - asset: &'static EmbeddedAsset, - rename_fn: &mut F, - ) -> Result<()> - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - validate_embedded_relative_path(asset.relative_path)?; - let destination = destination_root.join(asset.relative_path); - let parent = destination - .parent() - .context("Embedded asset destination should have a parent directory")?; - - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create parent directory '{}' for setup asset", - parent.display() - ) - })?; - - if destination.is_dir() { - bail!( - "Setup asset destination '{}' is an existing directory, not a file. Try: remove or rename the directory and rerun 'sce setup'.", - destination.display() - ); - } - - let install_bytes: Vec = if is_claude_settings_merge_target(target, asset.relative_path) - { - let existing_bytes = if destination.is_file() { - Some(fs::read(&destination).with_context(|| { - format!( - "Failed to read existing setup asset '{}' for merge", - destination.display() - ) - })?) - } else { - None - }; - config_merge::merge_or_create_claude_settings( - existing_bytes.as_deref(), - asset.bytes, - &destination.display().to_string(), - )? - } else if is_opencode_config_merge_target(target, asset.relative_path) { - let existing_bytes = if destination.is_file() { - Some(fs::read(&destination).with_context(|| { - format!( - "Failed to read existing setup asset '{}' for merge", - destination.display() - ) - })?) - } else { - None - }; - config_merge::merge_or_create_opencode_config( - existing_bytes.as_deref(), - asset.bytes, - &destination.display().to_string(), - )? - } else if is_codex_hooks_merge_target(target, asset.relative_path) { - let existing_bytes = if destination.is_file() { - Some(fs::read(&destination).with_context(|| { - format!( - "Failed to read existing setup asset '{}' for merge", - destination.display() - ) - })?) - } else { - None - }; - codex_hook_config::merge_or_create( - existing_bytes.as_deref(), - asset.bytes, - &destination.display().to_string(), - )? - } else { - asset.bytes.to_vec() - }; - - let staging_path = create_asset_staging_path(parent, asset.relative_path)?; - if let Err(error) = fs::write(&staging_path, &install_bytes).with_context(|| { - format!( - "Failed to write staged embedded asset '{}'", - staging_path.display() - ) - }) { - cleanup_path_if_exists(&staging_path); - return Err(error); - } - - if let Err(error) = rename_fn(&staging_path, &destination).with_context(|| { - format!( - "Failed to install staged asset '{}' into destination '{}'", - staging_path.display(), - destination.display() - ) - }) { - cleanup_path_if_exists(&staging_path); - return Err(error.context(setup_install_recovery_guidance(target, &destination))); - } - - Ok(()) - } - - fn create_asset_staging_path(parent: &Path, relative_path: &str) -> Result { - let epoch_nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("System clock is before UNIX_EPOCH")? - .as_nanos(); - let sanitized_name = relative_path.replace(['/', '\\'], "-"); - - for attempt in 0..1000_u16 { - let candidate = parent.join(format!( - ".sce-setup-staging-{sanitized_name}-{epoch_nanos}-{}-{attempt}", - std::process::id() - )); - - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&candidate) - { - Ok(_) => return Ok(candidate), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(error).with_context(|| { - format!("Failed to allocate staging file '{}'", candidate.display()) - }); - } - } - } - - bail!( - "Could not allocate a unique staging file under '{}'", - parent.display() - ) - } - - fn validate_embedded_relative_path(relative_path: &str) -> Result<()> { - let path = Path::new(relative_path); - - if path.is_absolute() { - bail!("Embedded asset path '{relative_path}' must be relative, not absolute"); - } - - for component in path.components() { - match component { - Component::Normal(_) => {} - _ => { - bail!("Embedded asset path '{relative_path}' contains disallowed component"); - } - } - } - - Ok(()) - } -} +mod install; pub trait SetupTargetPrompter { fn prompt_target(&self) -> Result; @@ -1751,200 +965,7 @@ fn setup_prompt_title_with_color_policy(color_enabled: bool) -> String { prompt::setup_prompt_title_with_color_policy(color_enabled) } -mod prompt { - use anyhow::{bail, Result}; - use inquire::{Confirm, InquireError, MultiSelect, Select}; - - use crate::services::style::{ - prompt_label, prompt_label_with_color_policy, prompt_value_with_color_policy, - }; - - use super::{OptionalWorkflow, SetupDispatch, SetupMode, SetupPromptTarget, SetupTarget}; - - fn proceed(target: SetupTarget) -> SetupDispatch { - SetupDispatch::Proceed { - mode: SetupMode::NonInteractive(target), - optional_workflows: None, - agent_trace_auto_sync: None, - attribution_hooks_enabled: None, - } - } - - pub(super) fn prompt_target() -> Result { - let options = vec![ - SetupPromptTarget::OpenCode, - SetupPromptTarget::Claude, - SetupPromptTarget::Pi, - SetupPromptTarget::Codex, - SetupPromptTarget::All, - ]; - - let selection = Select::new(&setup_prompt_title(), options).prompt(); - - match selection { - Ok(SetupPromptTarget::OpenCode) => Ok(proceed(SetupTarget::OpenCode)), - Ok(SetupPromptTarget::Claude) => Ok(proceed(SetupTarget::Claude)), - Ok(SetupPromptTarget::Pi) => Ok(proceed(SetupTarget::Pi)), - Ok(SetupPromptTarget::Codex) => Ok(proceed(SetupTarget::Codex)), - Ok(SetupPromptTarget::All) => Ok(proceed(SetupTarget::All)), - Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => { - Ok(SetupDispatch::Cancelled) - } - Err(InquireError::NotTTY) => bail!( - "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all'." - ), - Err(error) => Err(error.into()), - } - } - - pub(super) fn prompt_optional_workflows(defaults: &[String]) -> Result>> { - let Some((rows, default_indices)) = - optional_workflow_prompt_inputs(super::OPTIONAL_WORKFLOWS, defaults) - else { - return Ok(Some(Vec::new())); - }; - - let selection = MultiSelect::new(&optional_workflow_prompt_title(), rows) - .with_default(&default_indices) - .prompt(); - - match selection { - Ok(selected) => Ok(Some( - selected - .into_iter() - .map(|row| row.workflow.id.to_string()) - .collect(), - )), - Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), - Err(InquireError::NotTTY) => bail!( - "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all', adding '--workflow ' for each optional workflow to install." - ), - Err(error) => Err(error.into()), - } - } - - pub(super) fn prompt_agent_trace_auto_sync() -> Result> { - prompt_confirmation( - "Automatically sync Agent Traces?\n(Requires an SCE account. Sends Agent Traces from supported AI coding tools\nto SCE servers so they can be stored and viewed in your account.)", - ) - } - - pub(super) fn prompt_attribution_hooks_enabled() -> Result> { - prompt_confirmation( - "Record SCE involvement in Git commits?\n(Adds SCE metadata to commits created or assisted by SCE.)", - ) - } - - fn prompt_confirmation(label: &str) -> Result> { - match Confirm::new(label).with_default(true).prompt() { - Ok(value) => Ok(Some(value)), - Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), - Err(InquireError::NotTTY) => bail!( - "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all'." - ), - Err(error) => Err(error.into()), - } - } - - #[derive(Clone, Copy, Debug, Eq, PartialEq)] - pub(super) struct OptionalWorkflowRow { - pub(super) workflow: &'static OptionalWorkflow, - } - - impl std::fmt::Display for OptionalWorkflowRow { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", optional_workflow_row_label(self.workflow)) - } - } - - pub(super) fn optional_workflow_prompt_inputs( - catalog: &'static [OptionalWorkflow], - defaults: &[String], - ) -> Option<(Vec, Vec)> { - if catalog.is_empty() { - return None; - } - - Some(( - optional_workflow_rows(catalog), - optional_workflow_default_indices(catalog, defaults), - )) - } - - pub(super) fn optional_workflow_rows( - catalog: &'static [OptionalWorkflow], - ) -> Vec { - catalog - .iter() - .map(|workflow| OptionalWorkflowRow { workflow }) - .collect() - } - - pub(super) fn optional_workflow_default_indices( - catalog: &'static [OptionalWorkflow], - defaults: &[String], - ) -> Vec { - catalog - .iter() - .enumerate() - .filter(|(_, workflow)| defaults.iter().any(|id| id == workflow.id)) - .map(|(index, _)| index) - .collect() - } - - pub(super) fn optional_workflow_prompt_title() -> String { - prompt_label("Select optional workflows") - } - - pub(super) fn optional_workflow_row_label(workflow: &OptionalWorkflow) -> String { - optional_workflow_row_label_with_color_policy( - workflow, - crate::services::style::supports_color(), - ) - } - - pub(super) fn optional_workflow_row_label_with_color_policy( - workflow: &OptionalWorkflow, - color_enabled: bool, - ) -> String { - format!( - "{} — {}", - prompt_value_with_color_policy(workflow.title, color_enabled), - workflow.description - ) - } - - pub(super) fn setup_prompt_title() -> String { - prompt_label("Select setup target") - } - - pub(super) fn setup_prompt_target_label(target: SetupPromptTarget) -> String { - setup_prompt_target_label_with_color_policy( - target, - crate::services::style::supports_color(), - ) - } - - pub(super) fn setup_prompt_target_label_with_color_policy( - target: SetupPromptTarget, - color_enabled: bool, - ) -> String { - let label = match target { - SetupPromptTarget::OpenCode => "OpenCode", - SetupPromptTarget::Claude => "Claude", - SetupPromptTarget::Pi => "Pi", - SetupPromptTarget::Codex => "Codex", - SetupPromptTarget::All => "All (OpenCode + Claude + Pi + Codex)", - }; - - prompt_value_with_color_policy(label, color_enabled) - } - - #[allow(dead_code)] - pub(super) fn setup_prompt_title_with_color_policy(color_enabled: bool) -> String { - prompt_label_with_color_policy("Select setup target", color_enabled) - } -} +mod prompt; pub fn resolve_setup_dispatch

( mode: SetupMode, diff --git a/cli/src/services/setup/prompt.rs b/cli/src/services/setup/prompt.rs new file mode 100644 index 00000000..cf92217b --- /dev/null +++ b/cli/src/services/setup/prompt.rs @@ -0,0 +1,189 @@ +use anyhow::{bail, Result}; +use inquire::{Confirm, InquireError, MultiSelect, Select}; + +use crate::services::style::{ + prompt_label, prompt_label_with_color_policy, prompt_value_with_color_policy, +}; + +use super::{OptionalWorkflow, SetupDispatch, SetupMode, SetupPromptTarget, SetupTarget}; + +fn proceed(target: SetupTarget) -> SetupDispatch { + SetupDispatch::Proceed { + mode: SetupMode::NonInteractive(target), + optional_workflows: None, + agent_trace_auto_sync: None, + attribution_hooks_enabled: None, + } +} + +pub(super) fn prompt_target() -> Result { + let options = vec![ + SetupPromptTarget::OpenCode, + SetupPromptTarget::Claude, + SetupPromptTarget::Pi, + SetupPromptTarget::Codex, + SetupPromptTarget::All, + ]; + + let selection = Select::new(&setup_prompt_title(), options).prompt(); + + match selection { + Ok(SetupPromptTarget::OpenCode) => Ok(proceed(SetupTarget::OpenCode)), + Ok(SetupPromptTarget::Claude) => Ok(proceed(SetupTarget::Claude)), + Ok(SetupPromptTarget::Pi) => Ok(proceed(SetupTarget::Pi)), + Ok(SetupPromptTarget::Codex) => Ok(proceed(SetupTarget::Codex)), + Ok(SetupPromptTarget::All) => Ok(proceed(SetupTarget::All)), + Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => { + Ok(SetupDispatch::Cancelled) + } + Err(InquireError::NotTTY) => bail!( + "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all'." + ), + Err(error) => Err(error.into()), + } +} + +pub(super) fn prompt_optional_workflows(defaults: &[String]) -> Result>> { + let Some((rows, default_indices)) = + optional_workflow_prompt_inputs(super::OPTIONAL_WORKFLOWS, defaults) + else { + return Ok(Some(Vec::new())); + }; + + let selection = MultiSelect::new(&optional_workflow_prompt_title(), rows) + .with_default(&default_indices) + .prompt(); + + match selection { + Ok(selected) => Ok(Some( + selected + .into_iter() + .map(|row| row.workflow.id.to_string()) + .collect(), + )), + Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), + Err(InquireError::NotTTY) => bail!( + "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all', adding '--workflow ' for each optional workflow to install." + ), + Err(error) => Err(error.into()), + } +} + +pub(super) fn prompt_agent_trace_auto_sync() -> Result> { + prompt_confirmation( + "Automatically sync Agent Traces?\n(Requires an SCE account. Sends Agent Traces from supported AI coding tools\nto SCE servers so they can be stored and viewed in your account.)", + ) +} + +pub(super) fn prompt_attribution_hooks_enabled() -> Result> { + prompt_confirmation( + "Record SCE involvement in Git commits?\n(Adds SCE metadata to commits created or assisted by SCE.)", + ) +} + +fn prompt_confirmation(label: &str) -> Result> { + match Confirm::new(label).with_default(true).prompt() { + Ok(value) => Ok(Some(value)), + Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), + Err(InquireError::NotTTY) => bail!( + "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all'." + ), + Err(error) => Err(error.into()), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct OptionalWorkflowRow { + pub(super) workflow: &'static OptionalWorkflow, +} + +impl std::fmt::Display for OptionalWorkflowRow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", optional_workflow_row_label(self.workflow)) + } +} + +pub(super) fn optional_workflow_prompt_inputs( + catalog: &'static [OptionalWorkflow], + defaults: &[String], +) -> Option<(Vec, Vec)> { + if catalog.is_empty() { + return None; + } + + Some(( + optional_workflow_rows(catalog), + optional_workflow_default_indices(catalog, defaults), + )) +} + +pub(super) fn optional_workflow_rows( + catalog: &'static [OptionalWorkflow], +) -> Vec { + catalog + .iter() + .map(|workflow| OptionalWorkflowRow { workflow }) + .collect() +} + +pub(super) fn optional_workflow_default_indices( + catalog: &'static [OptionalWorkflow], + defaults: &[String], +) -> Vec { + catalog + .iter() + .enumerate() + .filter(|(_, workflow)| defaults.iter().any(|id| id == workflow.id)) + .map(|(index, _)| index) + .collect() +} + +pub(super) fn optional_workflow_prompt_title() -> String { + prompt_label("Select optional workflows") +} + +pub(super) fn optional_workflow_row_label(workflow: &OptionalWorkflow) -> String { + optional_workflow_row_label_with_color_policy( + workflow, + crate::services::style::supports_color(), + ) +} + +pub(super) fn optional_workflow_row_label_with_color_policy( + workflow: &OptionalWorkflow, + color_enabled: bool, +) -> String { + format!( + "{} — {}", + prompt_value_with_color_policy(workflow.title, color_enabled), + workflow.description + ) +} + +pub(super) fn setup_prompt_title() -> String { + prompt_label("Select setup target") +} + +pub(super) fn setup_prompt_target_label(target: SetupPromptTarget) -> String { + setup_prompt_target_label_with_color_policy(target, crate::services::style::supports_color()) +} + +pub(super) fn setup_prompt_target_label_with_color_policy( + target: SetupPromptTarget, + color_enabled: bool, +) -> String { + let label = match target { + SetupPromptTarget::OpenCode => "OpenCode", + SetupPromptTarget::Claude => "Claude", + SetupPromptTarget::Pi => "Pi", + SetupPromptTarget::Codex => "Codex", + SetupPromptTarget::All => "All (OpenCode + Claude + Pi + Codex)", + }; + + prompt_value_with_color_policy(label, color_enabled) +} + +#[allow(dead_code)] +pub(super) fn setup_prompt_title_with_color_policy(color_enabled: bool) -> String { + prompt_label_with_color_policy("Select setup target", color_enabled) +} From 43f1b46c37fc4a99ced08db394c5d517e30c46cd Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 00:43:27 +0200 Subject: [PATCH 04/16] runtime: Extract hook processing into focused modules Split the monolithic hooks implementation into dedicated modules for runtime helpers, commit hooks, conversation traces, diff traces, and Claude transforms. Keep the public hook dispatch surface stable while isolating related helpers and tests. Co-authored-by: SCE --- .../services/hooks/claude_bridge_session.rs | 4 - cli/src/services/hooks/claude_transcript.rs | 8 - cli/src/services/hooks/claude_transforms.rs | 231 ++ .../services/hooks/codex/apply_patch/mod.rs | 40 - .../hooks/codex/apply_patch/normalize.rs | 42 - .../hooks/codex/apply_patch/parser.rs | 69 - .../services/hooks/codex/apply_patch/path.rs | 31 +- cli/src/services/hooks/codex/mod.rs | 47 - cli/src/services/hooks/codex/stop.rs | 59 - .../hooks/codex/user_prompt_submit.rs | 34 - cli/src/services/hooks/commit_hooks.rs | 662 +++++ cli/src/services/hooks/conversation_trace.rs | 712 +++++ cli/src/services/hooks/diff_trace.rs | 605 +++++ cli/src/services/hooks/mod.rs | 2353 +---------------- .../hooks/opencode_mutation_scope/health.rs | 6 - .../services/hooks/pi_mutation_scope/state.rs | 2 - cli/src/services/hooks/runtime.rs | 183 ++ 17 files changed, 2432 insertions(+), 2656 deletions(-) create mode 100644 cli/src/services/hooks/claude_transforms.rs create mode 100644 cli/src/services/hooks/commit_hooks.rs create mode 100644 cli/src/services/hooks/conversation_trace.rs create mode 100644 cli/src/services/hooks/diff_trace.rs create mode 100644 cli/src/services/hooks/runtime.rs diff --git a/cli/src/services/hooks/claude_bridge_session.rs b/cli/src/services/hooks/claude_bridge_session.rs index 73391a5a..31731a67 100644 --- a/cli/src/services/hooks/claude_bridge_session.rs +++ b/cli/src/services/hooks/claude_bridge_session.rs @@ -7,10 +7,6 @@ use serde_json::Value; const MAX_LEADING_RECORDS: usize = 16; const BRIDGE_SESSION_RECORD_TYPE: &str = "bridge-session"; -/// Extract Claude's bridge-session identifier from the leading JSONL records. -/// -/// Transcript access and parsing are fail-open. Only a bounded number of -/// records are read so discovery never scans a complete transcript. pub fn extract_claude_bridge_session_id(transcript_path: &Path) -> Option { extract_claude_bridge_session_id_from_reader(File::open(transcript_path).map(BufReader::new)) } diff --git a/cli/src/services/hooks/claude_transcript.rs b/cli/src/services/hooks/claude_transcript.rs index 8ea3a5de..b2aa26b1 100644 --- a/cli/src/services/hooks/claude_transcript.rs +++ b/cli/src/services/hooks/claude_transcript.rs @@ -4,12 +4,6 @@ use std::path::Path; use serde_json::Value; -/// Extract the model identity from a Claude JSONL transcript by matching an -/// assistant message whose `tool_use` content block has the given ID. -/// -/// Transcript access and parsing are fail-open. Unreadable files, unreadable -/// lines, missing fields, and unmatched tool calls return `None`; malformed -/// unrelated JSONL records are skipped so later valid records can still match. pub fn extract_claude_transcript_model( transcript_path: &Path, tool_use_id: &str, @@ -39,8 +33,6 @@ fn extract_claude_transcript_model_from_reader( continue; }; - // Current Claude transcripts wrap the assistant message in `message`. - // Keep support for the earlier flat assistant-message shape as well. let message = if let Some(message) = record.get("message").and_then(Value::as_object) { let is_assistant = record .get("type") diff --git a/cli/src/services/hooks/claude_transforms.rs b/cli/src/services/hooks/claude_transforms.rs new file mode 100644 index 00000000..d7a35bc3 --- /dev/null +++ b/cli/src/services/hooks/claude_transforms.rs @@ -0,0 +1,231 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Result}; +use serde_json::{json, Value}; + +use crate::services::structured_patch::{build_claude_post_tool_use_patch, PatchBuildResult}; + +use super::conversation_trace::{ + conversation_trace_validation_error, required_non_empty_string_field, + CONVERSATION_TRACE_MESSAGE_PART_UPDATED, CONVERSATION_TRACE_MESSAGE_UPDATED, +}; +use super::runtime::current_unix_time_ms; + +pub(crate) fn transform_claude_user_prompt_submit( + payload: &serde_json::Map, +) -> Result> { + transform_claude_user_prompt_submit_with( + payload, + || { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); + uuid::Uuid::new_v7(ts) + }, + || current_unix_time_ms().unwrap_or(0), + ) +} + +pub(crate) fn transform_claude_user_prompt_submit_with( + payload: &serde_json::Map, + generate_message_id: G, + generate_timestamp_ms: T, +) -> Result> +where + G: FnOnce() -> uuid::Uuid, + T: FnOnce() -> i64, +{ + let event_name = required_non_empty_string_field( + payload, + "hook_event_name", + conversation_trace_validation_error, + )?; + + if event_name != "UserPromptSubmit" { + let raw_content = serde_json::to_string(payload).unwrap_or_default(); + bail!(conversation_trace_validation_error(&format!( + "unsupported Claude hook event '{event_name}': only 'UserPromptSubmit' is supported. Raw event: {raw_content}" + ))); + } + + let session_id = required_non_empty_string_field( + payload, + "session_id", + conversation_trace_validation_error, + )?; + let prompt = + required_non_empty_string_field(payload, "prompt", conversation_trace_validation_error)?; + + let message_id = generate_message_id().to_string(); + let generated_at_unix_ms = generate_timestamp_ms(); + + Ok(vec![ + json!({ + "type": CONVERSATION_TRACE_MESSAGE_UPDATED, + "session_id": session_id, + "message_id": message_id, + "role": "user", + "generated_at_unix_ms": generated_at_unix_ms, + }), + json!({ + "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, + "session_id": session_id, + "message_id": message_id, + "part_type": "text", + "text": prompt, + "generated_at_unix_ms": generated_at_unix_ms, + }), + ]) +} + +pub(crate) fn transform_claude_stop( + payload: &serde_json::Map, +) -> Result> { + transform_claude_stop_with( + payload, + || { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); + uuid::Uuid::new_v7(ts) + }, + || current_unix_time_ms().unwrap_or(0), + ) +} + +pub(crate) fn transform_claude_stop_with( + payload: &serde_json::Map, + generate_message_id: G, + generate_timestamp_ms: T, +) -> Result> +where + G: FnOnce() -> uuid::Uuid, + T: FnOnce() -> i64, +{ + let event_name = required_non_empty_string_field( + payload, + "hook_event_name", + conversation_trace_validation_error, + )?; + + if event_name != "Stop" { + let raw_content = serde_json::to_string(payload).unwrap_or_default(); + bail!(conversation_trace_validation_error(&format!( + "unsupported Claude hook event '{event_name}': only 'Stop' is supported. Raw event: {raw_content}" + ))); + } + + let session_id = required_non_empty_string_field( + payload, + "session_id", + conversation_trace_validation_error, + )?; + let last_assistant_message = required_non_empty_string_field( + payload, + "last_assistant_message", + conversation_trace_validation_error, + )?; + + let message_id = generate_message_id().to_string(); + let generated_at_unix_ms = generate_timestamp_ms(); + + Ok(vec![ + json!({ + "type": CONVERSATION_TRACE_MESSAGE_UPDATED, + "session_id": session_id, + "message_id": message_id, + "role": "assistant", + "generated_at_unix_ms": generated_at_unix_ms, + }), + json!({ + "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, + "session_id": session_id, + "message_id": message_id, + "part_type": "text", + "text": last_assistant_message, + "generated_at_unix_ms": generated_at_unix_ms, + }), + ]) +} +pub(crate) fn transform_claude_post_tool_use( + payload: &serde_json::Map, +) -> Result> { + transform_claude_post_tool_use_with( + payload, + || { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); + uuid::Uuid::new_v7(ts) + }, + || current_unix_time_ms().unwrap_or(0), + ) +} + +pub(crate) fn transform_claude_post_tool_use_with( + payload: &serde_json::Map, + generate_message_id: G, + generate_timestamp_ms: T, +) -> Result> +where + G: FnOnce() -> uuid::Uuid, + T: FnOnce() -> i64, +{ + let event_name = required_non_empty_string_field( + payload, + "hook_event_name", + conversation_trace_validation_error, + )?; + + if event_name != "PostToolUse" { + let raw_content = serde_json::to_string(payload).unwrap_or_default(); + bail!(conversation_trace_validation_error(&format!( + "unsupported Claude hook event '{event_name}': only 'PostToolUse' is supported. Raw event: {raw_content}" + ))); + } + + let tool_name = payload + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if tool_name != "Write" && tool_name != "Edit" { + return Ok(vec![]); + } + + let session_id = required_non_empty_string_field( + payload, + "session_id", + conversation_trace_validation_error, + )?; + + let message_id = generate_message_id().to_string(); + let generated_at_unix_ms = generate_timestamp_ms(); + + match build_claude_post_tool_use_patch(payload) { + PatchBuildResult::Built(parsed_patch) => { + let text = serde_json::to_string(&parsed_patch)?; + let items = vec![ + json!({ + "type": CONVERSATION_TRACE_MESSAGE_UPDATED, + "session_id": session_id, + "message_id": message_id, + "role": "assistant", + "generated_at_unix_ms": generated_at_unix_ms, + }), + json!({ + "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, + "session_id": session_id, + "message_id": message_id, + "part_type": "patch", + "text": text, + "generated_at_unix_ms": generated_at_unix_ms, + }), + ]; + Ok(items) + } + PatchBuildResult::Skipped(_) => Ok(vec![]), + } +} diff --git a/cli/src/services/hooks/codex/apply_patch/mod.rs b/cli/src/services/hooks/codex/apply_patch/mod.rs index a4b9a00c..aab79885 100644 --- a/cli/src/services/hooks/codex/apply_patch/mod.rs +++ b/cli/src/services/hooks/codex/apply_patch/mod.rs @@ -1,8 +1,3 @@ -//! Parses Codex's custom `apply_patch` text format (`*** Begin Patch` ... -//! `*** End Patch`) into a typed [`CodexPatch`], normalizes it into an -//! SCE-supported unified diff, and persists non-empty results as a -//! `diff_traces` row for the `PostToolUse`/`apply_patch` dispatch arm. - mod normalize; mod parser; mod path; @@ -32,14 +27,6 @@ use super::super::{ }; use super::CodexHookEvent; -/// Handles a Codex `PostToolUse(apply_patch)` event: reads the raw patch text -/// from `tool_input.command`, parses it (T10), normalizes it (T11), and — for -/// a non-empty normalized result — persists one `diff_traces` row. -/// -/// Every path here, success or fail-open, returns empty stdout: a missing or -/// non-string `command`, a parse failure (logged), and an empty normalized -/// patch (e.g. delete-only) all resolve to `Ok(String::new())` with no -/// evidence written. pub(super) fn handle( repository_root: &Path, event: &CodexHookEvent, @@ -54,8 +41,6 @@ pub(super) fn handle_with_state_root( state_root: Option<&Path>, logger: Option<&dyn Logger>, ) -> Result { - // Validate the session before parsing, path resolution, or DB access so - // invalid Codex events can never reach apply_patch persistence. required_session_id(event.session_id.as_deref())?; let Some(command) = apply_patch_command_from_event(event) else { @@ -160,9 +145,6 @@ pub(super) fn handle_with_state_root( persist_with(&db, event, &normalized_patch, time_ms) } -/// Codex's `PostToolUse` `tool_input` for the `apply_patch` tool carries the -/// raw patch text under `command`, mirroring the `Bash` tool's `tool_input` -/// shape this module's sibling `bash_policy.rs` already relies on. fn apply_patch_command_from_event(event: &CodexHookEvent) -> Option<&str> { event .tool_input @@ -180,9 +162,6 @@ fn required_session_id(value: Option<&str>) -> Result<&str> { } } -/// Injectable counterpart of `handle`'s persistence step, for deterministic -/// testing against an already-open Agent Trace DB — mirrors the -/// `user_prompt_submit`/`stop` sibling arms' `capture_with` pattern. fn persist_with( db: &RepositoryAgentTraceDb, event: &CodexHookEvent, @@ -322,10 +301,6 @@ mod tests { } } - // --- fail-open / successful no-op behaviors: `handle` returns before it - // would ever open the Agent Trace DB, so a non-existent repository root - // is safe to pass through unused. --- - #[test] fn handle_fails_open_silently_when_tool_input_missing() { let output = handle( @@ -437,11 +412,6 @@ mod tests { assert_eq!(output, ""); } - // --- persistence content (AC11-AC14): `persist_with` against a real, - // directly-opened Agent Trace DB, mirroring `user_prompt_submit`/`stop`'s - // own injectable-level testing precedent rather than the full - // hook-runtime DB resolution (which requires a prior `sce setup`). --- - #[test] fn apply_patch_persists_one_row_with_expected_field_values_for_add_and_update() { let db_path = unique_test_db_path("add-update"); @@ -585,13 +555,6 @@ mod tests { remove_test_db(&db_path); } - /// AC15: a committed Codex `apply_patch` Update whose `diff_trace` carries - /// synthetic, patch-local line numbers is still attributed through the - /// existing, unmodified post-commit intersection pipeline - /// (`build_agent_trace`, the same function the real `post-commit` hook - /// flow calls) when the real committed line numbers differ, and the - /// resulting Agent Trace identifies Codex as the tool and preserves the - /// Codex model ID. #[test] fn apply_patch_diff_trace_attributes_through_agent_trace_pipeline_at_different_real_lines() { let db_path = unique_test_db_path("agent-trace-pipeline"); @@ -612,9 +575,6 @@ mod tests { assert_eq!(recent.loaded_count(), 1); let constructed = &recent.patches[0]; - // A realistic post-commit patch where the same touched lines sit at - // real line 42, far from the diff_trace's synthetic line 1, plus one - // unrelated committed line that must not be attributed to Codex. let post_commit_patch = ParsedPatch { files: vec![PatchFileChange { old_path: "src/lib.rs".to_string(), diff --git a/cli/src/services/hooks/codex/apply_patch/normalize.rs b/cli/src/services/hooks/codex/apply_patch/normalize.rs index 93dff816..4ffd55fa 100644 --- a/cli/src/services/hooks/codex/apply_patch/normalize.rs +++ b/cli/src/services/hooks/codex/apply_patch/normalize.rs @@ -1,25 +1,3 @@ -//! Normalizes a parsed Codex `apply_patch` payload ([`CodexPatch`]) into SCE -//! `Index:`-form unified-diff text that `crate::services::patch::parse_patch` -//! already accepts. -//! -//! Positions are deterministic and event-scoped: each `Update File` operation -//! numbers only the touched (`+`/`-`) lines it actually emits, from a bounded -//! range derived from the stable `tool_use_id`. Local offsets are allocated -//! across every emitted operation, hunk, and file. Codex's own unchanged -//! context lines are dropped, not persisted as evidence, and contribute no -//! positional weight. These positions are evidence identities, never real -//! filesystem line numbers. The -//! existing, unmodified `intersect_patches` -//! historical `kind`+`content` fallback is what lets this synthetic-line -//! evidence still attribute correctly once a real commit lands at different -//! real line numbers (see plan `context/plans/codex-cli-integration.md` -//! T11/AC15) — this module does not touch that fallback. -//! -//! `Delete File` operations, and `Update File` + `Move to` operations with no -//! changed lines, contribute no evidence and are silently dropped: an -//! `apply_patch` producing no provable evidence normalizes to an empty -//! string. - use std::fmt::Write as _; use sha2::{Digest, Sha256}; @@ -32,8 +10,6 @@ const CODEX_SYNTHETIC_LINE_ID_DOMAIN: &[u8] = b"sce-codex-apply-patch-line-id-v1 const SYNTHETIC_EVENT_RANGE_SIZE: u64 = 1 << 31; const SYNTHETIC_BASE_OFFSET: u64 = 2; -/// Error produced when Codex apply-patch evidence cannot be assigned safe, -/// event-scoped synthetic line identities. #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CodexPatchNormalizeError { message: String, @@ -53,12 +29,6 @@ fn normalize_error(message: impl Into) -> CodexPatchNormalizeError { } } -/// Normalizes every `Add`/`Update` file operation in `patch` into one -/// combined SCE `Index:`-form unified-diff string, in operation order. -/// -/// The synthetic line identities are deterministic for `tool_use_id`, and -/// local offsets are allocated across the entire patch rather than restarting -/// for each file. They are evidence identities, not source line numbers. #[allow(dead_code)] pub(crate) fn normalize_codex_patch( patch: &CodexPatch, @@ -192,8 +162,6 @@ fn normalize_update( for line in &hunk.lines { match line { - // Codex's unchanged context is dropped, not persisted as - // evidence, and does not affect synthetic positions. CodexHunkLine::Context(_) => {} CodexHunkLine::Removed(content) => { hunk_body.push('-'); @@ -329,8 +297,6 @@ mod tests { let normalized = normalize_codex_patch(&patch, "tool-1").expect("should normalize"); let base = test_base(); - // The context line (" unchanged") is dropped entirely and - // contributes no positional weight. assert_eq!( normalized, format!( @@ -719,11 +685,6 @@ mod tests { synthetic_base(tool_use_id).expect("test identity should hash") } - /// AC15: synthetic patch-local line numbers must still attribute - /// correctly through the existing, unmodified `intersect_patches` - /// historical `kind`+`content` fallback once the real commit lands the - /// same touched lines at different real line numbers, while an unrelated - /// committed line does not intersect. #[test] fn intersect_patches_matches_synthetic_lines_via_historical_fallback() { let codex_patch = parse( @@ -738,9 +699,6 @@ mod tests { let constructed_patch = parse_patch(&normalized, Some("cx_test")).expect("constructed patch should parse"); - // A realistic post-commit patch where the same touched lines sit at - // different real line numbers than the event-scoped synthetic ones, - // plus one unrelated line that should not intersect. let post_commit_patch = real_commit_patch(); let overlap = intersect_patches(&constructed_patch, &post_commit_patch); diff --git a/cli/src/services/hooks/codex/apply_patch/parser.rs b/cli/src/services/hooks/codex/apply_patch/parser.rs index ba9b6cfb..e66f01eb 100644 --- a/cli/src/services/hooks/codex/apply_patch/parser.rs +++ b/cli/src/services/hooks/codex/apply_patch/parser.rs @@ -1,38 +1,3 @@ -//! Grammar parser for Codex's `apply_patch` custom patch text. -//! -//! The grammar implemented here follows the official Lark grammar documented -//! in `openai/codex`'s `codex-rs/apply-patch/src/parser.rs` (checked against -//! that source directly for this task; see plan -//! `context/plans/codex-cli-integration.md` Assumptions): -//! -//! ```text -//! start: begin_patch environment_id? hunk+ end_patch -//! begin_patch: "*** Begin Patch" LF -//! environment_id: "*** Environment ID: " filename LF -//! end_patch: "*** End Patch" LF? -//! -//! hunk: add_hunk | delete_hunk | update_hunk -//! add_hunk: "*** Add File: " filename LF add_line+ -//! delete_hunk: "*** Delete File: " filename LF -//! update_hunk: "*** Update File: " filename LF change_move? change? -//! filename: /(.+)/ -//! add_line: "+" /(.+)/ LF -> line -//! -//! change_move: "*** Move to: " filename LF -//! change: (change_context | change_line)+ eof_line? -//! change_context: ("@@" | "@@ " /(.+)/) LF -//! change_line: ("+" | "-" | " ") /(.+)/ LF -//! eof_line: "*** End of File" LF -//! ``` -//! -//! Upstream Codex itself accepts absolute hunk paths and `..` traversal -//! segments, resolving them against the tool's own `cwd` later. This parser -//! preserves that model: it validates only the syntactic `apply_patch` grammar -//! and basic path representability (e.g. a non-empty path), and leaves the -//! decision of whether a parsed path is safe and stays inside the canonical -//! Git worktree to `resolve_codex_patch_paths` in this module's sibling -//! `path.rs`, which runs after parsing. - const BEGIN_PATCH_MARKER: &str = "*** Begin Patch"; const END_PATCH_MARKER: &str = "*** End Patch"; const ENVIRONMENT_ID_MARKER: &str = "*** Environment ID: "; @@ -44,15 +9,12 @@ const END_OF_FILE_MARKER: &str = "*** End of File"; const CHANGE_CONTEXT_MARKER: &str = "@@"; const CHANGE_CONTEXT_MARKER_WITH_TEXT: &str = "@@ "; -/// One fully parsed Codex `apply_patch` payload: an ordered list of the file -/// operations it declares. Order is preserved from the source text. #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CodexPatch { pub(crate) operations: Vec, } -/// A single `*** Add File:` / `*** Update File:` / `*** Delete File:` block. #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum CodexFileOperation { @@ -70,9 +32,6 @@ pub(crate) enum CodexFileOperation { }, } -/// One contiguous change region within an `*** Update File:` block, started -/// either by an explicit `@@` context marker or implicitly by the first -/// change line when no `@@` marker precedes it. #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CodexHunk { @@ -81,8 +40,6 @@ pub(crate) struct CodexHunk { pub(crate) is_end_of_file: bool, } -/// A single line within a [`CodexHunk`], without its leading marker -/// character. #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum CodexHunkLine { @@ -91,8 +48,6 @@ pub(crate) enum CodexHunkLine { Removed(String), } -/// Error produced when raw `apply_patch` text does not conform to the -/// grammar above, or contains an unrepresentable path (e.g. empty). #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CodexPatchParseError { @@ -113,19 +68,12 @@ fn error(message: impl Into) -> CodexPatchParseError { } } -/// Removes the optional shell-like heredoc boundary that current upstream -/// Codex may include around an `apply_patch` command. The canonical grammar -/// parser intentionally does not know about shell syntax; callers should pass -/// this result to [`parse_codex_apply_patch`]. #[allow(dead_code)] pub(crate) fn normalize_outer_apply_patch_input(raw: &str) -> Result { let trimmed = raw.trim(); let lines: Vec<&str> = trimmed.lines().collect(); if has_canonical_boundaries(&lines) { - // Preserve ordinary raw patch input byte-for-byte. The canonical - // parser performs the same boundary trimming it did before this - // outer-normalization seam was introduced. return Ok(raw.to_string()); } @@ -158,10 +106,6 @@ fn has_canonical_boundaries(lines: &[&str]) -> bool { && lines.last().map(|line| line.trim()) == Some(END_PATCH_MARKER) } -/// Parses canonical Codex `apply_patch` text into a [`CodexPatch`]. Performs -/// no outer shell normalization, normalization to SCE unified-diff form, or -/// filesystem access; callers handling hook `tool_input.command` should first -/// use [`normalize_outer_apply_patch_input`]. #[allow(dead_code)] pub(crate) fn parse_codex_apply_patch(raw: &str) -> Result { let trimmed = raw.trim(); @@ -271,9 +215,6 @@ fn is_top_level_marker(line: &str) -> bool { || line.starts_with(UPDATE_FILE_MARKER) } -/// Parses the `change_move? change?` tail of an `*** Update File:` block -/// (with any `*** Move to:` line already consumed by the caller), returning -/// the resulting hunks plus how many lines of `lines` were consumed. fn parse_update_hunks( path: &str, lines: &[&str], @@ -347,11 +288,6 @@ fn parse_update_hunks( Ok((hunks, consumed)) } -/// Validates only that a parsed path is representable at all (non-empty). -/// Absolute paths and `..` traversal segments are syntactically valid Codex -/// `apply_patch` paths and are passed through unchanged; whether a given path -/// is safe is decided later, against the event cwd and canonical Git -/// worktree, by `resolve_codex_patch_paths` in `path.rs`. fn validate_path(path: &str) -> Result { if path.is_empty() { return Err(error("Codex apply_patch path cannot be empty.")); @@ -755,11 +691,6 @@ mod tests { #[test] fn accepts_absolute_and_parent_traversal_path_syntax_unresolved() { - // The parser owns only the apply_patch grammar: absolute paths and - // `..` traversal segments are syntactically valid here and are - // passed through unresolved. Whether they are actually safe is - // `resolve_codex_patch_paths` (path.rs)'s decision, made later - // against the event cwd and canonical Git worktree. let patch = "*** Begin Patch\n\ *** Add File: /etc/passwd\n\ +x\n\ diff --git a/cli/src/services/hooks/codex/apply_patch/path.rs b/cli/src/services/hooks/codex/apply_patch/path.rs index 213d2a07..cbb30d5e 100644 --- a/cli/src/services/hooks/codex/apply_patch/path.rs +++ b/cli/src/services/hooks/codex/apply_patch/path.rs @@ -1,11 +1,3 @@ -//! Resolves Codex `apply_patch` paths against the event cwd and the real Git -//! repository root. -//! -//! Codex invokes command hooks with the event's cwd, while the SCE dispatcher -//! may be launched from any directory in the checkout. This module keeps that -//! distinction explicit and only emits repository-relative, UTF-8 paths that -//! can be represented losslessly in SCE's patch format. - use std::path::{Component, Path, PathBuf}; use std::process::Command; @@ -13,10 +5,6 @@ use anyhow::{anyhow, bail, Context, Result}; use super::parser::{CodexFileOperation, CodexPatch}; -/// Resolves every path that can contribute Codex patch evidence in `patch`. -/// -/// The Git root and event cwd are validated once per event. Source and move -/// destination paths are then resolved independently from the event cwd. pub(crate) fn resolve_codex_patch_paths( repository_root: &Path, event_cwd: &str, @@ -44,12 +32,6 @@ pub(crate) fn resolve_codex_patch_paths( Ok(()) } -/// Resolves one Codex path to a repository-relative path. -/// -/// This public seam intentionally performs the same Git-root and cwd checks -/// as the event-level resolver, making the path contract independently -/// testable without invoking the hook dispatcher or opening the Agent Trace -/// database. #[allow(dead_code)] pub(crate) fn resolve_codex_patch_path( repository_root: &Path, @@ -162,9 +144,6 @@ fn resolve_path_from_cwd(git_root: &Path, event_cwd: &Path, codex_path: &str) -> ) } -/// Resolve a lexically normalized absolute path while preserving filesystem -/// semantics for existing components. Lexical normalization must happen before -/// this function so a symlink component removed by `..` is never inspected. fn resolve_candidate_inside_repository(git_root: &Path, candidate: &Path) -> Result { let (existing, suffix) = nearest_existing_prefix(candidate)?; let canonical_existing = canonicalize_inside_repository(git_root, &existing, candidate)?; @@ -179,9 +158,6 @@ fn resolve_candidate_inside_repository(git_root: &Path, candidate: &Path) -> Res Ok(resolved) } -/// Normalize an absolute path using Codex's lexical `PathUri::join` semantics: -/// `.` is removed, `..` removes the preceding lexical component, and parent -/// traversal at the filesystem root is clamped rather than treated as an error. fn normalize_absolute_path(path: &Path) -> Result { if !path.is_absolute() { bail!("Codex path must be absolute after joining with the event cwd."); @@ -274,8 +250,6 @@ fn append_path_lexically(base: &Path, suffix: &Path) -> Result { Ok(result) } -/// Convert a canonical or lexically resolved path into the slash-separated -/// UTF-8 form used by SCE patch text. fn path_to_utf8_slash_path(path: &Path) -> Result { let mut components = Vec::new(); for component in path.components() { @@ -495,10 +469,7 @@ mod tests { .expect("temporary repository should have a name") .to_str() .expect("temporary repository name should be UTF-8"); - // Overshoot past the filesystem root by a wide margin: `TMPDIR` depth - // varies by platform (e.g. macOS's `/var/folders/xx/yyyy/T/` nests - // deeper than Linux's `/tmp/`), so a fixed `..` count that clamps on - // one platform can undershoot the root on another. + let cwd_depth = cwd.components().count(); let excess_traversal = "../".repeat(cwd_depth + 8); let path = format!("{excess_traversal}{parent_path}/{root_name}/clamped.rs"); diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index 7947de19..c6f4744b 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -20,17 +20,6 @@ const CODEX_HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; const CODEX_HOOK_TOOL_BASH: &str = "Bash"; const CODEX_HOOK_TOOL_APPLY_PATCH: &str = "apply_patch"; -/// Distinguishes a JSON field that is absent from the payload entirely -/// (`Missing`) from one that is present with an explicit `null` (`Null`) -/// from one that is present with a value (`Value`). A plain -/// `#[serde(default)] Option` cannot make this distinction: Serde's -/// `Option` deserializer maps JSON `null` to `None` at the *same* layer -/// it uses for "value absent", so both missing-field and explicit-null -/// collapse to `None`. `#[serde(default, deserialize_with = "...")]` on a -/// field of this type keeps `Default` (→ `Missing`) for the no-field case -/// and routes every present field (including `null`) through -/// [`deserialize_nullable_field`], which is the only path that can produce -/// `Null` or `Value`. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) enum NullableField { #[default] @@ -72,21 +61,6 @@ where }) } -/// A single Codex hook lifecycle event, deserialized from the raw STDIN JSON -/// payload `sce hooks codex` receives via -/// `.codex/hooks/run-sce-or-show-install-guidance.sh`. -/// -/// Working contract (see plan `context/plans/codex-cli-integration.md` -/// Assumptions): `hook_event_name` is present on every event; `session_id`, -/// `turn_id`, `cwd`, and `model` vary by event; `tool_name`/`tool_use_id`/ -/// `tool_input`/`tool_response` are present only on `PreToolUse`/`PostToolUse`; -/// `prompt` is present only on `UserPromptSubmit`, matching Claude's own -/// `UserPromptSubmit` payload shape (see `transform_claude_user_prompt_submit_with`); -/// `last_assistant_message` is present (per current upstream Codex `Stop` -/// schema, required and typed `string | null`) only on `Stop`, matching -/// Claude's own `Stop` payload shape (see `transform_claude_stop_with`) -/// except that Codex allows an explicit `null` where Claude does not — see -/// [`NullableField`]. #[derive(Debug, Deserialize)] #[allow(dead_code)] pub(crate) struct CodexHookEvent { @@ -113,8 +87,6 @@ pub(crate) struct CodexHookEvent { pub(crate) last_assistant_message: NullableField, } -/// The set of Codex hook-event/tool combinations `sce hooks codex` gives -/// distinct behavior. Every other combination classifies as `NoOp`. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum CodexDispatchArm { UserPromptSubmit, @@ -483,17 +455,6 @@ mod tests { fs::remove_dir_all(&state_root).ok(); } - // Explicit-empty-string and normal-text persistence *through raw JSON - // deserialization* are covered in `stop::tests` (e.g. - // `capture_with_persists_deserialized_raw_json_with_empty_string_last_assistant_message`), - // not here: `open_agent_trace_db_for_hook_runtime` (used by `stop::handle` - // for every persisting case) resolves the real default Agent Trace - // storage path and has no `state_root` injection seam — unlike - // `apply_patch`, which added one specifically for its own dispatcher - // tests. Missing/null above need no DB at all (they short-circuit before - // DB open), so they remain safe to exercise through the full - // `run_codex_subcommand_from_payload_at_state_root` dispatcher path. - #[test] fn codex_hook_event_deserializes_missing_last_assistant_message_as_missing() { let event: CodexHookEvent = @@ -807,14 +768,6 @@ mod tests { fs::remove_dir_all(&state_root).ok(); } - // --- T20/AC26 ownership boundary: the parser accepts absolute and `..` - // path syntax unresolved (see apply_patch/parser.rs), and - // `resolve_codex_patch_paths` (apply_patch/path.rs) is the sole - // authority deciding whether a parsed path is safe and stays inside the - // canonical Git worktree. These end-to-end tests exercise the real - // `PostToolUse apply_patch -> parse -> cwd-aware path resolution -> - // normalize -> diff_traces` pipeline, not `path.rs` in isolation. --- - fn diff_trace_count(repository_root: &Path, state_root: &Path) -> usize { let storage = resolve_agent_trace_storage_for_hook_runtime_at_state_root( &AgentTraceStorageContext { diff --git a/cli/src/services/hooks/codex/stop.rs b/cli/src/services/hooks/codex/stop.rs index c19a9856..6d0fb554 100644 --- a/cli/src/services/hooks/codex/stop.rs +++ b/cli/src/services/hooks/codex/stop.rs @@ -13,34 +13,10 @@ use super::super::{ }; use super::{CodexHookEvent, NullableField}; -/// Captures a Codex `Stop` event as one `messages` row (`role = "assistant"`) -/// and one `parts` row (`part_type = "text"`, `text = last_assistant_message`) -/// under session `cx_`, message `cx::assistant`. -/// -/// Upstream Codex's `Stop` schema requires `session_id`, `turn_id`, and -/// `last_assistant_message` (typed `string | null`) on every Stop payload. -/// This handler validates all three *before* any side effect — timestamp -/// acquisition, Agent Trace DB access, or persistence — via -/// [`validate_stop_event`]. A missing/blank `session_id` or `turn_id`, or a -/// missing `last_assistant_message`, is a malformed payload that errors so -/// the outer Codex dispatcher fail-open boundary (`run_codex_subcommand` → -/// `log_codex_fail_open`) logs it and emits exact empty stdout with no DB -/// access — this is true even for an otherwise-valid explicit `null`: a -/// null Stop with a blank/missing identifier is still malformed and must -/// not reach the null no-op path. Only once identifiers and presence are -/// confirmed valid does an explicit `null` short-circuit as a silent -/// successful no-op *before* timestamp acquisition or the Agent Trace DB is -/// ever opened; a present value (including an explicit empty string, -/// persisted like any other text) is captured normally. pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { handle_with_clock(repository_root, event, current_unix_time_ms) } -/// Injectable-clock counterpart of `handle`. Timestamp acquisition is -/// fallible and its failure is propagated as `Err` rather than swallowed -/// internally, so the existing outer Codex fail-open boundary owns logging -/// and the empty-stdout contract for a failed clock exactly as it does for -/// any other handler error. fn handle_with_clock(repository_root: &Path, event: &CodexHookEvent, now: F) -> Result where F: FnOnce() -> Result, @@ -66,12 +42,6 @@ where ) } -/// A Codex `Stop` event whose `session_id`/`turn_id` are confirmed -/// non-blank and trimmed, and whose `last_assistant_message` presence has -/// already been confirmed (a missing field cannot produce a `ValidatedStop` -/// at all). `None` here means an explicit upstream `null` — the valid -/// "no assistant text this turn" no-op signal; `Some` carries a present -/// value (including an explicit empty string). #[derive(Debug)] struct ValidatedStop<'a> { session_id: &'a str, @@ -79,10 +49,6 @@ struct ValidatedStop<'a> { last_assistant_message: Option<&'a str>, } -/// The single validation layer for `Stop` events: every required-field -/// check (`session_id`, `turn_id`, `last_assistant_message` presence) lives -/// here so no other function re-validates the same fields with subtly -/// different semantics. Runs before any timestamp acquisition or DB access. fn validate_stop_event(event: &CodexHookEvent) -> Result> { let session_id = required_trimmed_field(event.session_id.as_deref(), "session_id")?; let turn_id = required_trimmed_field(event.turn_id.as_deref(), "turn_id")?; @@ -103,9 +69,6 @@ fn validate_stop_event(event: &CodexHookEvent) -> Result> { }) } -/// Persists an already-validated `Stop` event with a known-present -/// assistant message against an already-open Agent Trace DB. Performs no -/// validation of its own. fn persist_with( db: &RepositoryAgentTraceDb, validated: &ValidatedStop<'_>, @@ -136,9 +99,6 @@ fn persist_with( Ok(String::new()) } -/// Validates an identifier field (`session_id`/`turn_id`) is present and -/// non-blank, returning it trimmed so downstream prefixing/formatting never -/// persists incidental leading/trailing whitespace. fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { match value.map(str::trim) { Some(value) if !value.is_empty() => Ok(value), @@ -148,12 +108,6 @@ fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Resul } } -/// Test-only convenience wrapper preserving the pre-refactor `capture_with` -/// call shape (`event` + timestamp, against an already-open DB) for tests -/// that build a full `CodexHookEvent`. Routes through the same single -/// validation layer (`validate_stop_event`) as production `handle`, so it -/// exercises identical semantics — including the null no-op — rather than -/// re-implementing validation. #[cfg(test)] fn capture_with( db: &RepositoryAgentTraceDb, @@ -465,9 +419,6 @@ mod tests { let mut payload = event("session-1", "turn-1", "unused"); payload.last_assistant_message = NullableField::Null; - // A nonexistent repository root proves `handle` never reaches Agent - // Trace DB resolution for a null `last_assistant_message`: DB opening - // against a nonexistent repository would otherwise fail loudly. let output = handle(Path::new("/nonexistent-repository-root"), &payload) .expect("null last_assistant_message should be a silent successful no-op"); assert_eq!(output, ""); @@ -490,10 +441,6 @@ mod tests { let mut payload = event("session-1", "turn-1", "unused"); payload.last_assistant_message = NullableField::Null; - // A failing/panicking clock closure and a nonexistent repository - // root together prove `handle_with_clock` short-circuits before - // timestamp acquisition and before Agent Trace DB resolution for an - // explicit null. let output = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { panic!("clock must not be called for an explicit null last_assistant_message") }) @@ -505,10 +452,6 @@ mod tests { fn handle_with_clock_propagates_a_timestamp_failure_as_an_error_with_no_persistence() { let payload = event("session-1", "turn-1", "hello back"); - // A nonexistent repository root additionally proves the failed - // clock is consulted (and propagated) before Agent Trace DB - // resolution is ever attempted: a subsequent DB-open attempt - // against this path would fail loudly instead. let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { Err(anyhow::anyhow!("clock failed")) }) @@ -607,8 +550,6 @@ mod tests { payload.turn_id = Some(" turn-1 ".to_string()); payload.last_assistant_message = NullableField::Null; - // Padded-but-otherwise-valid identifiers must validate under their - // trimmed representation even though a null Stop persists nothing. let output = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { panic!("clock must not be called for an explicit null last_assistant_message") }) diff --git a/cli/src/services/hooks/codex/user_prompt_submit.rs b/cli/src/services/hooks/codex/user_prompt_submit.rs index fc044cd4..7d99d4ad 100644 --- a/cli/src/services/hooks/codex/user_prompt_submit.rs +++ b/cli/src/services/hooks/codex/user_prompt_submit.rs @@ -13,22 +13,10 @@ use super::super::{ }; use super::CodexHookEvent; -/// Captures a Codex `UserPromptSubmit` event as one `messages` row -/// (`role = "user"`) and one `parts` row (`part_type = "text"`, `text = prompt`) -/// under session `cx_`, message `cx::user`. pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { handle_with_clock(repository_root, event, current_unix_time_ms) } -/// Injectable-clock counterpart of `handle`. Validates `session_id`, -/// `turn_id`, and `prompt` (via [`validate_user_prompt_submit_event`]) -/// *before* any side effect — a malformed payload never reaches timestamp -/// acquisition or Agent Trace DB access. Timestamp acquisition is itself -/// fallible and its failure is propagated as `Err` rather than swallowed -/// internally, so the existing outer Codex fail-open boundary -/// (`run_codex_subcommand` → `log_codex_fail_open`) owns logging and the -/// empty-stdout contract for both a malformed payload and a failed clock, -/// exactly as it does for any other handler error. fn handle_with_clock(repository_root: &Path, event: &CodexHookEvent, now: F) -> Result where F: FnOnce() -> Result, @@ -45,20 +33,12 @@ where persist_with(&db, &validated, generated_at_unix_ms) } -/// A Codex `UserPromptSubmit` event whose `session_id`/`turn_id` are -/// confirmed non-blank and trimmed, and whose `prompt` is confirmed -/// present and non-blank (but left untrimmed — prompt text is not -/// whitespace-normalized). struct ValidatedUserPromptSubmit<'a> { session_id: &'a str, turn_id: &'a str, prompt: &'a str, } -/// The single validation layer for `UserPromptSubmit` events: every -/// required-field check (`session_id`, `turn_id`, `prompt`) lives here so -/// no other function re-validates the same fields with subtly different -/// semantics. Runs before any timestamp acquisition or DB access. fn validate_user_prompt_submit_event( event: &CodexHookEvent, ) -> Result> { @@ -73,8 +53,6 @@ fn validate_user_prompt_submit_event( }) } -/// Persists an already-validated `UserPromptSubmit` event against an -/// already-open Agent Trace DB. Performs no validation of its own. fn persist_with( db: &RepositoryAgentTraceDb, validated: &ValidatedUserPromptSubmit<'_>, @@ -113,9 +91,6 @@ fn required_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a st } } -/// Validates an identifier field (`session_id`/`turn_id`) is present and -/// non-blank, returning it trimmed so downstream prefixing/formatting never -/// persists incidental leading/trailing whitespace. fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { match value.map(str::trim) { Some(value) if !value.is_empty() => Ok(value), @@ -125,11 +100,6 @@ fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Resul } } -/// Test-only convenience wrapper preserving the pre-refactor `capture_with` -/// call shape (`event` + timestamp, against an already-open DB) for tests -/// that build a full `CodexHookEvent`. Routes through the same single -/// validation layer (`validate_user_prompt_submit_event`) as production -/// `handle`. #[cfg(test)] fn capture_with( db: &RepositoryAgentTraceDb, @@ -343,10 +313,6 @@ mod tests { fn handle_with_clock_propagates_a_timestamp_failure_as_an_error_with_no_persistence() { let payload = event("session-1", "turn-1", "hello world"); - // A nonexistent repository root additionally proves the failed - // clock is consulted (and propagated) before Agent Trace DB - // resolution is ever attempted: a subsequent DB-open attempt - // against this path would fail loudly instead. let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { Err(anyhow::anyhow!("clock failed")) }) diff --git a/cli/src/services/hooks/commit_hooks.rs b/cli/src/services/hooks/commit_hooks.rs new file mode 100644 index 00000000..960f298d --- /dev/null +++ b/cli/src/services/hooks/commit_hooks.rs @@ -0,0 +1,662 @@ +use std::fs; +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use chrono::{DateTime, Utc}; +use serde_json::{to_string as serialize_to_json, Value}; + +use crate::services::agent_trace::{ + agent_trace_persisted_url, build_agent_trace_from_evidence, patch_has_touched_lines, + patches_have_overlap, validate_agent_trace_value, AgentTrace, AgentTraceEvidence, + AgentTraceMetadataInput, AgentTraceVcsType, +}; +use crate::services::agent_trace_db::{ + AgentTraceInsert, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, +}; +use crate::services::config; +use crate::services::observability::traits::Logger; +use crate::services::patch::{ + combine_patches as combine_patches_fn, intersect_patches as intersect_patches_fn, + parse_patch as parse_patch_from_text, ParsedPatch, +}; +use crate::services::sync::auto_sync; + +use super::runtime::{ + commit_msg_policy_gate_passed, current_unix_time_ms, open_agent_trace_db_for_hook_runtime, + post_rewrite_no_op_reason, pre_commit_no_op_reason, read_hook_stdin, resolve_runtime_state, + run_git_command_capture_stdout, HookRuntimeState, +}; +use super::HookSubcommand; +use super::CANONICAL_SCE_COAUTHOR_TRAILER; + +pub(crate) fn run_pre_commit_subcommand_with_trace(repository_root: &Path) -> Result { + run_pre_commit_subcommand(repository_root) +} + +pub(crate) fn run_pre_commit_subcommand(repository_root: &Path) -> Result { + let runtime = resolve_runtime_state(repository_root)?; + + Ok(format!( + "pre-commit hook executed with no-op runtime state: {:?}", + pre_commit_no_op_reason(&runtime) + )) +} + +pub(crate) fn run_commit_msg_subcommand_in_repo( + repository_root: &Path, + message_file: &Path, + logger: Option<&dyn Logger>, +) -> Result { + let metadata = fs::metadata(message_file).with_context(|| { + format!( + "Invalid commit message file '{}': file does not exist or is not readable.", + message_file.display() + ) + })?; + + if !metadata.is_file() { + bail!( + "Invalid commit message file '{}': expected a regular file path.", + message_file.display() + ); + } + + let runtime = resolve_runtime_state(repository_root)?; + let original = fs::read_to_string(message_file).with_context(|| { + format!( + "Invalid commit message file '{}': failed to read UTF-8 content.", + message_file.display() + ) + })?; + + let gate_passed = commit_msg_policy_gate_passed(&runtime); + let ai_contribution_present = if gate_passed { + match staged_diff_has_ai_overlap(repository_root, logger) { + StagedDiffAiOverlapResult::Overlap => true, + StagedDiffAiOverlapResult::NoOverlap | StagedDiffAiOverlapResult::Error => false, + } + } else { + false + }; + let transformed = + apply_commit_msg_coauthor_policy(&runtime, ai_contribution_present, &original); + let trailer_applied = gate_passed && transformed != original; + + if trailer_applied { + fs::write(message_file, transformed.as_bytes()).with_context(|| { + format!( + "Failed to update commit message file '{}' with canonical co-author trailer.", + message_file.display() + ) + })?; + } + + Ok(format!( + "commit-msg hook processed message file '{}' (policy_gate_passed={}, trailer_applied={}).", + message_file.display(), + gate_passed, + trailer_applied + )) +} + +pub(crate) fn run_commit_msg_subcommand_with_trace( + repository_root: &Path, + _: &HookSubcommand, + message_file: &Path, + logger: Option<&dyn Logger>, +) -> Result { + run_commit_msg_subcommand_in_repo(repository_root, message_file, logger) +} + +pub(crate) fn run_post_commit_subcommand( + repository_root: &Path, + vcs_type: Option, + remote_url: &str, + logger: Option<&dyn Logger>, +) -> Result { + run_post_commit_subcommand_with( + repository_root, + vcs_type, + remote_url, + run_post_commit_intersection_flow, + run_post_commit_agent_trace_flow, + |root| { + config::resolve_hook_runtime_config(root).map(|runtime| runtime.agent_trace_auto_sync) + }, + |root| { + auto_sync::launch(root); + Ok(()) + }, + run_post_commit_passive_checkpoint, + logger, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn run_post_commit_subcommand_with( + repository_root: &Path, + vcs_type: Option, + remote_url: &str, + run_intersection_flow: F, + run_agent_trace_flow: B, + resolve_auto_sync: C, + launch_auto_sync: L, + run_passive_checkpoint: K, + logger: Option<&dyn Logger>, +) -> Result +where + F: FnOnce(&Path) -> Result, + B: FnOnce( + &Path, + &PostCommitIntersectionFlowResult, + Option, + &str, + ) -> Result, + C: FnOnce(&Path) -> Result, + L: FnOnce(&Path) -> Result<()>, + K: FnOnce(&Path) -> Result<()>, +{ + let result = run_intersection_flow(repository_root)?; + let _agent_trace = run_agent_trace_flow(repository_root, &result, vcs_type, remote_url)?; + + if let Err(error) = run_passive_checkpoint(repository_root) { + if let Some(log) = logger { + log.warn( + "sce.agent_trace_db.passive_checkpoint_failed", + &error.to_string(), + &[], + None, + ); + } + } + + if resolve_auto_sync(repository_root)? { + let _ = launch_auto_sync(repository_root); + } + + Ok(format!( + "post-commit hook processed intersection: commit={}, intersection_files={}", + result.post_commit_data.commit_oid, + result.combined_recent_patch.files.len() + )) +} + +pub(crate) fn run_post_commit_passive_checkpoint(repository_root: &Path) -> Result<()> { + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for post-commit checkpoint.", + )?; + + db.passive_checkpoint() +} + +pub(crate) fn run_post_commit_agent_trace_flow( + repository_root: &Path, + flow_result: &PostCommitIntersectionFlowResult, + vcs_type: Option, + remote_url: &str, +) -> Result { + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for post-commit trace.", + )?; + + let direct_intersection = intersect_patches_fn( + &flow_result.combined_recent_patch, + &flow_result.post_commit_data.parsed_patch, + ); + let mutation_ai_patch = + crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch( + repository_root, + &db, + &direct_intersection, + &flow_result.post_commit_data.parsed_patch, + ); + + run_post_commit_agent_trace_flow_with( + flow_result, + vcs_type, + remote_url, + &mutation_ai_patch, + |trace_value| { + validate_agent_trace_value(trace_value) + .map_err(|error| anyhow!(error.to_string())) + .context("Failed to verify built post-commit Agent Trace payload.")?; + + Ok(()) + }, + |insert_input| { + db.insert_agent_trace(insert_input) + .context("Failed to persist built post-commit Agent Trace payload.")?; + + Ok(()) + }, + ) +} + +pub(crate) fn run_post_commit_agent_trace_flow_with( + flow_result: &PostCommitIntersectionFlowResult, + vcs_type: Option, + remote_url: &str, + mutation_ai_patch: &ParsedPatch, + validate_agent_trace: V, + persist_agent_trace: I, +) -> Result +where + V: FnOnce(&Value) -> Result<()>, + I: for<'a> FnOnce(AgentTraceInsert<'a>) -> Result<()>, +{ + let commit_timestamp = + DateTime::::from_timestamp_millis(flow_result.post_commit_data.commit_time_ms) + .ok_or_else(|| { + anyhow!( + "Invalid post-commit timestamp '{}': expected a valid Unix epoch millisecond value.", + flow_result.post_commit_data.commit_time_ms + ) + })? + .to_rfc3339(); + + let agent_trace = build_agent_trace_from_evidence( + AgentTraceEvidence { + direct_patch: &flow_result.combined_recent_patch, + mutation_ai_patch, + }, + &flow_result.post_commit_data.parsed_patch, + AgentTraceMetadataInput { + commit_timestamp: &commit_timestamp, + commit_revision: &flow_result.post_commit_data.commit_oid, + vcs_type, + tool_name: flow_result.tool_name.as_deref(), + tool_version: flow_result.tool_version.as_deref(), + }, + ) + .context("Failed to build Agent Trace payload from post-commit intersection flow result.")?; + + let agent_trace_value = serde_json::to_value(&agent_trace) + .context("Failed to serialize post-commit Agent Trace payload for validation.")?; + validate_agent_trace(&agent_trace_value) + .context("Failed to validate built post-commit Agent Trace payload.")?; + + let serialized = format!( + "{}\n", + serde_json::to_string_pretty(&agent_trace) + .context("Failed to serialize post-commit Agent Trace payload for persistence.")? + ); + + let constructed_url = agent_trace_persisted_url(&agent_trace.id); + + let insert_input = AgentTraceInsert { + commit_id: &flow_result.post_commit_data.commit_oid, + commit_time_ms: flow_result.post_commit_data.commit_time_ms, + trace_json: &serialized, + agent_trace_id: &agent_trace.id, + url: &constructed_url, + remote_url, + }; + persist_agent_trace(insert_input)?; + + Ok(agent_trace) +} + +pub(crate) const RECENT_DAYS_MILLIS: i64 = 7 * 24 * 60 * 60 * 1000; + +pub(crate) fn run_post_commit_intersection_flow( + repository_root: &Path, +) -> Result { + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for post-commit intersection.", + )?; + + run_post_commit_intersection_flow_with( + repository_root, + capture_post_commit_patch_from_git, + current_unix_time_ms, + |cutoff_ms, end_ms| { + db.recent_diff_trace_patches(cutoff_ms, end_ms) + .context("Failed to query recent diff trace patches.") + }, + |insert_input| { + db.insert_post_commit_patch_intersection(insert_input) + .context("Failed to persist post-commit patch intersection.")?; + + Ok(()) + }, + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StagedDiffAiOverlapResult { + Overlap, + NoOverlap, + Error, +} + +pub(crate) fn staged_diff_has_ai_overlap( + repository_root: &Path, + logger: Option<&dyn Logger>, +) -> StagedDiffAiOverlapResult { + let db_open_result = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for staged AI-overlap evidence check.", + ); + + let db = match db_open_result { + Ok(db) => db, + Err(error) => { + if let Some(log) = logger { + log.error( + "sce.hooks.commit_msg.ai_overlap_error", + &format!("Staged AI-overlap evidence check failed: {error}."), + &[], + None, + ); + } + return StagedDiffAiOverlapResult::Error; + } + }; + + let result = staged_diff_has_ai_overlap_with( + repository_root, + capture_staged_patch_from_git, + current_unix_time_ms, + |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), + ); + + if result == StagedDiffAiOverlapResult::Error { + if let Some(log) = logger { + log.error( + "sce.hooks.commit_msg.ai_overlap_error", + "Staged AI-overlap evidence check failed: error during staged-diff or trace query.", + &[], + None, + ); + } + } + + result +} + +pub(crate) fn staged_diff_has_ai_overlap_with( + repository_root: &Path, + capture_staged_patch: C, + now_ms: N, + query_recent_patches: Q, +) -> StagedDiffAiOverlapResult +where + C: FnOnce(&Path) -> Result, + N: FnOnce() -> Result, + Q: FnOnce(i64, i64) -> Result, +{ + let Ok(staged_patch) = capture_staged_patch(repository_root) else { + return StagedDiffAiOverlapResult::Error; + }; + + if !patch_has_touched_lines(&staged_patch) { + return StagedDiffAiOverlapResult::NoOverlap; + } + + let Ok(now_ms) = now_ms() else { + return StagedDiffAiOverlapResult::Error; + }; + let cutoff_ms = now_ms - RECENT_DAYS_MILLIS; + + let Ok(recent_patches) = query_recent_patches(cutoff_ms, now_ms) else { + return StagedDiffAiOverlapResult::Error; + }; + + let has_overlap = recent_patches.patches.into_iter().any(|recent_patch| { + let combined_recent_patch = combine_patches_fn(&[recent_patch.patch]); + patches_have_overlap(&combined_recent_patch, &staged_patch) + }); + + if has_overlap { + StagedDiffAiOverlapResult::Overlap + } else { + StagedDiffAiOverlapResult::NoOverlap + } +} + +pub(crate) fn capture_staged_patch_from_git(repository_root: &Path) -> Result { + let patch_text = capture_staged_diff_from_git(repository_root)?; + + if patch_text.trim().is_empty() { + return Ok(ParsedPatch { files: Vec::new() }); + } + + parse_patch_from_text(&patch_text, None).map_err(|error| { + anyhow!(staged_patch_error( + "failed to parse staged patch", + &error.to_string() + )) + }) +} + +pub(crate) fn capture_staged_diff_from_git(repository_root: &Path) -> Result { + run_git_command_capture_stdout( + repository_root, + &["diff", "--cached", "--patch", "--no-ext-diff"], + "Failed to capture staged patch from git.", + ) +} + +pub(crate) fn staged_patch_error(detail: &str, context: &str) -> String { + format!("Staged patch capture error: {detail} ({context}).") +} + +pub(crate) fn run_post_commit_intersection_flow_with( + repository_root: &Path, + capture_post_commit_patch: C, + now_ms: N, + query_recent_patches: Q, + persist_intersection: P, +) -> Result +where + C: FnOnce(&Path) -> Result, + N: FnOnce() -> Result, + Q: FnOnce(i64, i64) -> Result, + P: for<'a> FnOnce(PostCommitPatchIntersectionInsert<'a>) -> Result<()>, +{ + let post_commit_data = capture_post_commit_patch(repository_root)?; + + let now_ms = now_ms()?; + let cutoff_ms = now_ms - RECENT_DAYS_MILLIS; + + let recent_patches = query_recent_patches(cutoff_ms, now_ms)?; + + #[allow(clippy::cast_possible_wrap)] + let loaded_count = recent_patches.loaded_count() as i64; + #[allow(clippy::cast_possible_wrap)] + let skipped_count = recent_patches.skipped_count() as i64; + + let last_patch = recent_patches.patches.last(); + let tool_name = last_patch.and_then(|patch| patch.tool_name.clone()); + let tool_version = last_patch.and_then(|patch| patch.tool_version.clone()); + + let recent_patches_slice: Vec = recent_patches + .patches + .into_iter() + .map(|p| p.patch) + .collect(); + + let combined_recent_patch = combine_patches_fn(&recent_patches_slice); + + let intersection_patch = + intersect_patches_fn(&combined_recent_patch, &post_commit_data.parsed_patch); + + let serialized_intersection = serialize_to_json(&intersection_patch) + .context("Failed to serialize intersection patch.")?; + + let insert_input = PostCommitPatchIntersectionInsert { + commit_id: &post_commit_data.commit_oid, + post_commit_time_ms: post_commit_data.commit_time_ms, + recent_window_cutoff_ms: cutoff_ms, + recent_window_end_ms: now_ms, + loaded_diff_trace_count: loaded_count, + skipped_diff_trace_count: skipped_count, + intersection_patch: &serialized_intersection, + }; + + persist_intersection(insert_input)?; + + Ok(PostCommitIntersectionFlowResult { + combined_recent_patch, + post_commit_data, + tool_name, + tool_version, + }) +} + +pub(crate) fn run_post_commit_subcommand_with_trace( + repository_root: &Path, + vcs_type: Option, + remote_url: Option<&str>, + logger: Option<&dyn Logger>, +) -> Result { + run_post_commit_subcommand( + repository_root, + vcs_type, + remote_url.unwrap_or_default(), + logger, + ) +} + +pub(crate) fn run_post_rewrite_subcommand( + repository_root: &Path, + rewrite_method: &str, +) -> Result { + let runtime = resolve_runtime_state(repository_root)?; + + Ok(format!( + "post-rewrite hook executed with no-op runtime state: {:?} (rewrite_method='{}')", + post_rewrite_no_op_reason(&runtime), + rewrite_method.trim() + )) +} + +pub(crate) fn run_post_rewrite_subcommand_with_trace( + repository_root: &Path, + _: &HookSubcommand, + rewrite_method: &str, +) -> Result { + let stdin_payload = read_hook_stdin(); + stdin_payload.and_then(|_| run_post_rewrite_subcommand(repository_root, rewrite_method)) +} + +pub(crate) fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { + match subcommand { + HookSubcommand::PreCommit => "pre-commit runtime invocation", + HookSubcommand::CommitMsg { .. } => "commit-msg runtime invocation", + HookSubcommand::PostCommit { .. } => "post-commit runtime invocation", + HookSubcommand::PostRewrite { .. } => "post-rewrite runtime invocation", + HookSubcommand::DiffTrace => "diff-trace runtime invocation", + HookSubcommand::ConversationTrace => "conversation-trace runtime invocation", + HookSubcommand::Codex => "codex runtime invocation", + HookSubcommand::ClaudeModelState => "Claude model-state runtime invocation", + HookSubcommand::MutationScope => "mutation-scope runtime invocation", + HookSubcommand::ClaudeMutationScope => "Claude mutation-scope runtime invocation", + HookSubcommand::CodexMutationScope => "Codex mutation-scope runtime invocation", + HookSubcommand::OpenCodeMutationScope => "OpenCode mutation-scope runtime invocation", + HookSubcommand::PiMutationScope => "Pi mutation-scope runtime invocation", + HookSubcommand::ExternalMutationGuard => "external-mutation-guard runtime invocation", + } +} +pub fn apply_commit_msg_coauthor_policy( + runtime: &HookRuntimeState, + ai_contribution_present: bool, + commit_message: &str, +) -> String { + if !commit_msg_policy_gate_passed(runtime) || !ai_contribution_present { + return commit_message.to_string(); + } + + let mut lines: Vec<&str> = commit_message.lines().collect(); + lines.retain(|line| *line != CANONICAL_SCE_COAUTHOR_TRAILER); + + if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) { + lines.push(""); + } + lines.push(CANONICAL_SCE_COAUTHOR_TRAILER); + + let mut normalized = lines.join("\n"); + if commit_message.ends_with('\n') { + normalized.push('\n'); + } + + normalized +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PostCommitPatchData { + pub commit_oid: String, + pub commit_time_ms: i64, + pub parsed_patch: ParsedPatch, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PostCommitIntersectionFlowResult { + pub combined_recent_patch: ParsedPatch, + pub post_commit_data: PostCommitPatchData, + pub tool_name: Option, + pub tool_version: Option, +} + +pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result { + let commit_oid = capture_head_oid_from_git(repository_root)?; + let commit_time_ms = capture_head_timestamp_from_git(repository_root)?; + let patch_text = capture_head_patch_from_git(repository_root)?; + let parsed_patch = parse_patch_from_text(&patch_text, None).map_err(|e| { + anyhow!(post_commit_patch_error( + "failed to parse post-commit patch", + &e.to_string() + )) + })?; + + Ok(PostCommitPatchData { + commit_oid, + commit_time_ms, + parsed_patch, + }) +} + +pub(crate) fn capture_head_oid_from_git(repository_root: &Path) -> Result { + let output = run_git_command_capture_stdout( + repository_root, + &["rev-parse", "HEAD"], + "Failed to capture HEAD commit OID from git.", + )?; + Ok(output.trim().to_string()) +} + +pub(crate) fn capture_head_timestamp_from_git(repository_root: &Path) -> Result { + let output = run_git_command_capture_stdout( + repository_root, + &["show", "--format=%ct", "--no-patch", "HEAD"], + "Failed to capture HEAD commit timestamp from git.", + )?; + let timestamp_str = output.trim(); + let timestamp_seconds: i64 = timestamp_str.parse().map_err(|_| { + anyhow!(post_commit_patch_error( + "failed to parse HEAD timestamp", + timestamp_str, + )) + })?; + let timestamp_ms = timestamp_seconds.checked_mul(1000).ok_or_else(|| { + anyhow!(post_commit_patch_error( + "failed to parse HEAD timestamp", + timestamp_str, + )) + })?; + Ok(timestamp_ms) +} + +pub(crate) fn capture_head_patch_from_git(repository_root: &Path) -> Result { + run_git_command_capture_stdout( + repository_root, + &["show", "--format=", "--patch", "--no-ext-diff", "HEAD"], + "Failed to capture HEAD patch from git.", + ) +} + +pub(crate) fn post_commit_patch_error(detail: &str, context: &str) -> String { + format!("Post-commit patch capture error: {detail} ({context}).") +} diff --git a/cli/src/services/hooks/conversation_trace.rs b/cli/src/services/hooks/conversation_trace.rs new file mode 100644 index 00000000..e784fcfd --- /dev/null +++ b/cli/src/services/hooks/conversation_trace.rs @@ -0,0 +1,712 @@ +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{to_string as serialize_to_json, Value}; + +use crate::services::agent_trace_db::{ + InsertMessageInsert, InsertPartInsert, MessageRole, PartType, +}; +use crate::services::observability::traits::Logger; +use crate::services::patch::{load_patch_from_json, parse_patch as parse_patch_from_text}; + +use super::claude_transforms::{ + transform_claude_post_tool_use, transform_claude_stop, transform_claude_user_prompt_submit, +}; +use super::runtime::{ + open_agent_trace_db_for_hook_runtime, prefixed_conversation_trace_session_id, read_hook_stdin, + PayloadValidationError, CLAUDE_TOOL_NAME, NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES, +}; + +pub(crate) const CONVERSATION_TRACE_MESSAGE_UPDATED: &str = "message"; +pub(crate) const CONVERSATION_TRACE_MESSAGE_PART_UPDATED: &str = "message.part"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConversationTracePayload { + pub attempted_count: usize, + pub message_updated: ConversationTraceMessageBatch, + pub message_part_updated: ConversationTracePartBatch, + pub skipped: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConversationTraceMessageBatch { + pub inserts: Vec, + pub skipped: Vec, + diagnostic_session_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConversationTracePartBatch { + pub inserts: Vec, + pub skipped: Vec, + diagnostic_session_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkippedConversationTracePayload { + pub index: usize, + pub reason: String, + pub session_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ConversationTracePersistenceSummary { + attempted: usize, + persisted_messages: usize, + persisted_parts: usize, + skipped: usize, +} + +impl ConversationTracePersistenceSummary { + fn render(&self) -> String { + format!( + "conversation-trace hook persisted mixed payload batch to AgentTraceDb: attempted={}, persisted_messages={}, persisted_parts={}, skipped={}.", + self.attempted, self.persisted_messages, self.persisted_parts, self.skipped + ) + } +} +pub(crate) fn run_conversation_trace_subcommand( + repository_root: &Path, + logger: Option<&dyn Logger>, +) -> String { + let stdin_payload = match read_hook_stdin() { + Ok(payload) => payload, + Err(error) => return log_conversation_trace_fail_open(&error, logger, None), + }; + let session_id = conversation_trace_fail_open_session_id(&stdin_payload); + + match run_conversation_trace_subcommand_from_payload( + repository_root, + &stdin_payload, + logger, + session_id.as_deref(), + ) { + Ok(output) => output, + Err(error) => log_conversation_trace_fail_open(&error, logger, session_id.as_deref()), + } +} + +pub(crate) fn run_conversation_trace_subcommand_from_payload( + repository_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, + session_id: Option<&str>, +) -> Result { + let payload = parse_conversation_trace_payload(stdin_payload)?; + Ok(persist_conversation_trace_payload_to_agent_trace_db( + repository_root, + payload, + logger, + session_id, + )) +} + +pub(crate) fn log_conversation_trace_fail_open( + error: &anyhow::Error, + logger: Option<&dyn Logger>, + session_id: Option<&str>, +) -> String { + if let Some(log) = logger { + log.error( + "sce.hooks.conversation_trace.error", + &error.to_string(), + &[], + session_id, + ); + } + + String::from("conversation-trace hook intake failed open; error logged.") +} + +pub(crate) fn persist_conversation_trace_payload_to_agent_trace_db( + repository_root: &Path, + payload: ConversationTracePayload, + logger: Option<&dyn Logger>, + session_id: Option<&str>, +) -> String { + let db = match open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for conversation-trace persistence.", + ) { + Ok(db) => db, + Err(error) => { + if let Some(log) = logger { + log.error( + "sce.hooks.conversation_trace.agent_trace_db_open_failed", + &error.to_string(), + &[], + session_id, + ); + } + + return String::from("conversation-trace hook intake failed open; error logged."); + } + }; + + let summary = persist_conversation_trace_payload_to_agent_trace_db_with( + payload, + logger, + |inserts| db.insert_messages(inserts), + |inserts| db.insert_parts(inserts), + ); + + summary.render() +} +pub(crate) fn persist_conversation_trace_payload_to_agent_trace_db_with( + payload: ConversationTracePayload, + logger: Option<&dyn Logger>, + insert_messages: IM, + insert_parts: IP, +) -> ConversationTracePersistenceSummary +where + IM: FnOnce(Vec) -> Result, + IP: FnOnce(Vec) -> Result, +{ + log_skipped_conversation_trace_payloads(logger, "unsupported", &payload.skipped); + + let message_summary = persist_message_updated_batch_to_agent_trace_db_with( + payload.message_updated, + logger, + insert_messages, + ); + let part_summary = persist_message_part_updated_batch_to_agent_trace_db_with( + payload.message_part_updated, + logger, + insert_parts, + ); + + ConversationTracePersistenceSummary { + attempted: payload.attempted_count, + persisted_messages: message_summary.persisted, + persisted_parts: part_summary.persisted, + skipped: payload.skipped.len() + message_summary.skipped + part_summary.skipped, + } +} + +pub(crate) struct ConversationTraceEventPersistenceSummary { + persisted: usize, + skipped: usize, +} + +pub(crate) fn persist_message_updated_batch_to_agent_trace_db_with( + batch: ConversationTraceMessageBatch, + logger: Option<&dyn Logger>, + insert_messages: I, +) -> ConversationTraceEventPersistenceSummary +where + I: FnOnce(Vec) -> Result, +{ + const EVENT_TYPE: &str = "message"; + + let mut skipped = batch.skipped.len(); + + log_skipped_conversation_trace_payloads(logger, EVENT_TYPE, &batch.skipped); + + let valid_count = batch.inserts.len(); + let session_id = batch.diagnostic_session_id; + let persisted = if valid_count == 0 { + 0 + } else { + match insert_messages(batch.inserts) { + Ok(affected_rows) => usize::try_from(affected_rows) + .unwrap_or(usize::MAX) + .min(valid_count), + Err(error) => { + skipped += valid_count; + log_conversation_trace_batch_insert_failure( + logger, + EVENT_TYPE, + valid_count, + &error, + session_id.as_deref(), + ); + 0 + } + } + }; + + ConversationTraceEventPersistenceSummary { persisted, skipped } +} + +pub(crate) fn persist_message_part_updated_batch_to_agent_trace_db_with( + batch: ConversationTracePartBatch, + logger: Option<&dyn Logger>, + insert_parts: I, +) -> ConversationTraceEventPersistenceSummary +where + I: FnOnce(Vec) -> Result, +{ + const EVENT_TYPE: &str = "message.part"; + + let mut skipped = batch.skipped.len(); + + log_skipped_conversation_trace_payloads(logger, EVENT_TYPE, &batch.skipped); + + let valid_count = batch.inserts.len(); + let session_id = batch.diagnostic_session_id; + let persisted = if valid_count == 0 { + 0 + } else { + match insert_parts(batch.inserts) { + Ok(affected_rows) => usize::try_from(affected_rows) + .unwrap_or(usize::MAX) + .min(valid_count), + Err(error) => { + skipped += valid_count; + log_conversation_trace_batch_insert_failure( + logger, + EVENT_TYPE, + valid_count, + &error, + session_id.as_deref(), + ); + 0 + } + } + }; + + ConversationTraceEventPersistenceSummary { persisted, skipped } +} + +pub(crate) fn log_skipped_conversation_trace_payloads( + logger: Option<&dyn Logger>, + event_type: &str, + skipped_payloads: &[SkippedConversationTracePayload], +) { + let Some(log) = logger else { + return; + }; + + for skipped in skipped_payloads { + let index = skipped.index.to_string(); + log.warn( + "sce.hooks.conversation_trace.payload_skipped", + &skipped.reason, + &[ + ("event_type", event_type), + ("payload_index", index.as_str()), + ], + skipped.session_id.as_deref(), + ); + } +} + +pub(crate) fn log_conversation_trace_batch_insert_failure( + logger: Option<&dyn Logger>, + event_type: &str, + valid_count: usize, + error: &anyhow::Error, + session_id: Option<&str>, +) { + if let Some(log) = logger { + let count = valid_count.to_string(); + log.warn( + "sce.hooks.conversation_trace.agent_trace_db_batch_failed", + &error.to_string(), + &[("event_type", event_type), ("valid_count", count.as_str())], + session_id, + ); + } +} + +pub fn parse_conversation_trace_payload(stdin_payload: &str) -> Result { + let parsed: Value = serde_json::from_str(stdin_payload) + .context("Invalid conversation-trace payload from STDIN: expected valid JSON.")?; + let payload = parsed.as_object().ok_or_else(|| { + anyhow!(conversation_trace_validation_error( + "expected a JSON object" + )) + })?; + + if payload.contains_key("hook_event_name") { + let event_name = required_non_empty_string_field( + payload, + "hook_event_name", + conversation_trace_validation_error, + )?; + + let items = match event_name.as_str() { + "UserPromptSubmit" => transform_claude_user_prompt_submit(payload)?, + "Stop" => transform_claude_stop(payload)?, + "PostToolUse" => transform_claude_post_tool_use(payload)?, + _ => bail!(conversation_trace_validation_error(&format!( + "unsupported Claude hook event '{event_name}': supported events are 'UserPromptSubmit', 'Stop' and 'PostToolUse'" + ))), + }; + return Ok(parse_conversation_trace_payloads(&items, CLAUDE_TOOL_NAME)); + } + + let tool_name = + required_non_empty_string_field(payload, "tool_name", conversation_trace_validation_error)?; + if !NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES.contains(&tool_name.as_str()) { + bail!(conversation_trace_validation_error(&format!( + "unsupported tool_name '{tool_name}': supported producers are 'opencode' and 'pi'" + ))); + } + let payloads = required_payloads_array(payload)?; + + Ok(parse_conversation_trace_payloads(payloads, &tool_name)) +} + +pub(crate) fn required_payloads_array( + payload: &serde_json::Map, +) -> Result<&Vec> { + required_field(payload, "payloads", conversation_trace_validation_error)? + .as_array() + .ok_or_else(|| { + anyhow!(conversation_trace_validation_error( + "field 'payloads' must be an array" + )) + }) +} + +pub(crate) fn parse_conversation_trace_payloads( + payloads: &[Value], + tool_name: &str, +) -> ConversationTracePayload { + let mut message_inserts = Vec::new(); + let mut message_skipped = Vec::new(); + let mut part_inserts = Vec::new(); + let mut part_skipped = Vec::new(); + let mut skipped = Vec::new(); + let mut message_diagnostic_session_id = None; + let mut part_diagnostic_session_id = None; + + for (index, item) in payloads.iter().enumerate() { + let session_id = non_empty_string(item.get("session_id")).map(str::to_owned); + let Some(item) = conversation_trace_payload_item(item, index, &mut skipped) else { + continue; + }; + + let event_type = + match required_string_field(item, "type", conversation_trace_validation_error) { + Ok(event_type) => event_type, + Err(error) => { + skipped.push(SkippedConversationTracePayload { + index, + reason: error.to_string(), + session_id: session_id.clone(), + }); + continue; + } + }; + + match event_type.as_str() { + CONVERSATION_TRACE_MESSAGE_UPDATED => match parse_message_updated_item(item) { + Ok(mut input) => { + if message_diagnostic_session_id.is_none() { + message_diagnostic_session_id.clone_from(&session_id); + } + input.session_id = + prefixed_conversation_trace_session_id(tool_name, &input.session_id); + message_inserts.push(input); + } + Err(error) => message_skipped.push(SkippedConversationTracePayload { + index, + reason: error.to_string(), + session_id: session_id.clone(), + }), + }, + CONVERSATION_TRACE_MESSAGE_PART_UPDATED => { + match parse_message_part_updated_item(item) { + Ok(mut input) => { + if part_diagnostic_session_id.is_none() { + part_diagnostic_session_id.clone_from(&session_id); + } + input.session_id = + prefixed_conversation_trace_session_id(tool_name, &input.session_id); + part_inserts.push(input); + } + Err(error) => part_skipped.push(SkippedConversationTracePayload { + index, + reason: error.to_string(), + session_id: session_id.clone(), + }), + } + } + _ => skipped.push(SkippedConversationTracePayload { + index, + reason: conversation_trace_validation_error( + "field 'type' must be one of 'message' or 'message.part'", + ), + session_id, + }), + } + } + + ConversationTracePayload { + attempted_count: payloads.len(), + message_updated: ConversationTraceMessageBatch { + inserts: message_inserts, + skipped: message_skipped, + diagnostic_session_id: message_diagnostic_session_id, + }, + message_part_updated: ConversationTracePartBatch { + inserts: part_inserts, + skipped: part_skipped, + diagnostic_session_id: part_diagnostic_session_id, + }, + skipped, + } +} + +pub(crate) fn conversation_trace_payload_item<'a>( + item: &'a Value, + index: usize, + skipped: &mut Vec, +) -> Option<&'a serde_json::Map> { + let Some(payload) = item.as_object() else { + skipped.push(SkippedConversationTracePayload { + index, + reason: conversation_trace_validation_error(&format!( + "payloads[{index}] must be an object" + )), + session_id: None, + }); + return None; + }; + + Some(payload) +} + +pub(crate) fn parse_message_updated_item( + payload: &serde_json::Map, +) -> Result { + Ok(InsertMessageInsert { + session_id: required_non_empty_string_field( + payload, + "session_id", + conversation_trace_validation_error, + )?, + message_id: required_non_empty_string_field( + payload, + "message_id", + conversation_trace_validation_error, + )?, + role: parse_message_role(payload)?, + generated_at_unix_ms: required_i64_millisecond_field( + payload, + "generated_at_unix_ms", + conversation_trace_validation_error, + )?, + }) +} + +pub(crate) fn parse_message_part_updated_item( + payload: &serde_json::Map, +) -> Result { + let part_type = parse_part_type(payload)?; + let raw_text = required_string_field(payload, "text", conversation_trace_validation_error)?; + let text = match part_type { + PartType::Patch => { + if load_patch_from_json(&raw_text).is_ok() { + raw_text + } else { + match parse_patch_from_text(&raw_text, None) { + Ok(parsed_patch) => serialize_to_json(&parsed_patch).map_err(|error| { + anyhow!(conversation_trace_validation_error(&format!( + "failed to serialize parsed patch for conversation-trace patch part: {error}" + ))) + })?, + Err(diff_error) => { + bail!(conversation_trace_validation_error(&format!( + "field 'text' for patch part is neither valid patch-JSON nor a valid patch: {diff_error}" + ))); + } + } + } + } + PartType::Text | PartType::Reasoning => raw_text, + PartType::Question => validate_question_part_text(raw_text)?, + }; + + Ok(InsertPartInsert { + session_id: required_non_empty_string_field( + payload, + "session_id", + conversation_trace_validation_error, + )?, + message_id: required_non_empty_string_field( + payload, + "message_id", + conversation_trace_validation_error, + )?, + part_type, + text, + generated_at_unix_ms: required_i64_millisecond_field( + payload, + "generated_at_unix_ms", + conversation_trace_validation_error, + )?, + }) +} + +pub(crate) fn parse_message_role(payload: &serde_json::Map) -> Result { + match required_string_field(payload, "role", conversation_trace_validation_error)?.as_str() { + "user" => Ok(MessageRole::User), + "assistant" => Ok(MessageRole::Assistant), + _ => bail!(conversation_trace_validation_error( + "field 'role' must be one of 'user' or 'assistant'" + )), + } +} + +pub(crate) fn parse_part_type(payload: &serde_json::Map) -> Result { + match required_string_field(payload, "part_type", conversation_trace_validation_error)?.as_str() + { + "text" => Ok(PartType::Text), + "reasoning" => Ok(PartType::Reasoning), + "patch" => Ok(PartType::Patch), + "question" => Ok(PartType::Question), + _ => bail!(conversation_trace_validation_error( + "field 'part_type' must be one of 'text', 'reasoning', 'patch' or 'question'" + )), + } +} + +pub(crate) fn validate_question_part_text(raw_text: String) -> Result { + let parsed: Value = serde_json::from_str(&raw_text).map_err(|_| { + anyhow!(conversation_trace_validation_error( + "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" + )) + })?; + + let items = parsed.as_array().ok_or_else(|| { + anyhow!(conversation_trace_validation_error( + "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" + )) + })?; + + if items.iter().all(|item| { + item.as_object().is_some_and(|object| { + object.get("question").is_some_and(Value::is_string) + && object.get("answer").is_some_and(Value::is_string) + }) + }) { + return Ok(raw_text); + } + + bail!(conversation_trace_validation_error( + "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" + )) +} + +pub(crate) fn conversation_trace_validation_error(detail: &str) -> String { + format!("Invalid conversation-trace payload from STDIN: {detail}.") +} + +pub(crate) fn conversation_trace_fail_open_session_id(stdin_payload: &str) -> Option { + let payload: Value = serde_json::from_str(stdin_payload).ok()?; + let payload = payload.as_object()?; + + if payload.contains_key("hook_event_name") { + return non_empty_string(payload.get("session_id")).map(str::to_owned); + } + + let first_payload = payload.get("payloads")?.as_array()?.first()?; + non_empty_string(first_payload.get("session_id")).map(str::to_owned) +} + +pub(crate) fn diff_trace_fail_open_session_id(stdin_payload: &str) -> Option { + let payload: Value = serde_json::from_str(stdin_payload).ok()?; + let payload = payload.as_object()?; + let field_name = if payload.contains_key("hook_event_name") { + "session_id" + } else { + "sessionID" + }; + + non_empty_string(payload.get(field_name)).map(str::to_owned) +} + +pub(crate) fn non_empty_string(value: Option<&Value>) -> Option<&str> { + value?.as_str().filter(|value| !value.trim().is_empty()) +} + +pub(crate) fn required_non_empty_string_field( + payload: &serde_json::Map, + field_name: &str, + format_error: impl Fn(&str) -> String, +) -> Result { + let raw = required_field(payload, field_name, &format_error)?; + + let value = raw.as_str().ok_or_else(|| { + anyhow!(format_error(&format!( + "field '{field_name}' must be a non-empty string" + ))) + })?; + + if value.trim().is_empty() { + bail!(format_error(&format!( + "field '{field_name}' must be a non-empty string" + ))); + } + + Ok(value.to_string()) +} + +pub(crate) fn required_string_field( + payload: &serde_json::Map, + field_name: &str, + validation_error: PayloadValidationError, +) -> Result { + let raw = required_field(payload, field_name, validation_error)?; + + raw.as_str().map(ToString::to_string).ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field_name}' must be a string" + ))) + }) +} + +#[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss +)] +pub(crate) fn required_i64_millisecond_field( + payload: &serde_json::Map, + field_name: &str, + validation_error: PayloadValidationError, +) -> Result { + let raw = required_field(payload, field_name, validation_error)?; + + if let Some(value) = raw.as_i64() { + if value < 0 { + bail!(validation_error(&format!( + "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value" + ))); + } + return Ok(value); + } + + if let Some(value) = raw.as_u64() { + return i64::try_from(value).map_err(|_| { + anyhow!(validation_error(&format!( + "field '{field_name}' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" + ))) + }); + } + + if raw.as_f64().is_some_and(|value| value.fract() != 0.0) { + bail!(validation_error(&format!( + "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value, got a fractional number" + ))); + } + + bail!(validation_error(&format!( + "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value" + ))) +} + +pub(crate) fn required_field<'a>( + payload: &'a serde_json::Map, + field_name: &str, + format_error: impl Fn(&str) -> String, +) -> Result<&'a Value> { + payload.get(field_name).ok_or_else(|| { + anyhow!(format_error(&format!( + "missing required field '{field_name}'" + ))) + }) +} diff --git a/cli/src/services/hooks/diff_trace.rs b/cli/src/services/hooks/diff_trace.rs new file mode 100644 index 00000000..356fd848 --- /dev/null +++ b/cli/src/services/hooks/diff_trace.rs @@ -0,0 +1,605 @@ +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, bail, Context, Result}; +use serde::Serialize; +use serde_json::Value; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_db::{ + ClaudeModelStateObservation, DiffTraceInsert, ObservationKind, PAYLOAD_TYPE_PATCH, + PAYLOAD_TYPE_STRUCTURED, +}; +use crate::services::observability::traits::Logger; +use crate::services::structured_patch::{ + derive_claude_structured_patch, ClaudeStructuredPatchDerivationResult, +}; + +use super::claude_model_state; +use super::claude_transcript; +use super::conversation_trace::{ + diff_trace_fail_open_session_id, non_empty_string, required_field, + required_non_empty_string_field, +}; +use super::runtime::{ + current_unix_time_ms, open_agent_trace_db_for_hook_runtime, prefixed_diff_trace_session_id, + read_hook_stdin, CLAUDE_MODEL_ID_PREFIX, CLAUDE_TOOL_NAME, +}; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct DiffTracePayload { + #[serde(rename = "sessionID")] + pub(crate) session_id: String, + pub(crate) diff: String, + pub(crate) time: u64, + pub(crate) model_id: Option, + #[serde(skip)] + pub(crate) agent_id: Option, + #[serde(skip)] + pub(crate) transcript_path: Option, + pub(crate) tool_name: String, + pub(crate) tool_version: Option, + pub(crate) payload_type: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DiffTraceParseResult { + Persist(DiffTracePayload), + NoOp(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StdinPayloadKind { + DiffTrace, +} + +impl StdinPayloadKind { + fn label(self) -> &'static str { + match self { + Self::DiffTrace => "diff-trace", + } + } + + fn validation_error(self, detail: &str) -> String { + format!("Invalid {} payload from STDIN: {detail}.", self.label()) + } +} +pub(crate) fn run_diff_trace_subcommand( + repository_root: &Path, + logger: Option<&dyn Logger>, +) -> String { + let stdin_payload = match read_hook_stdin() { + Ok(payload) => payload, + Err(error) => return log_diff_trace_fail_open(&error, logger, None), + }; + let session_id = diff_trace_fail_open_session_id(&stdin_payload); + + match run_diff_trace_subcommand_from_payload(repository_root, &stdin_payload, logger) { + Ok(output) => output, + Err(error) => log_diff_trace_fail_open(&error, logger, session_id.as_deref()), + } +} + +pub(crate) fn run_diff_trace_subcommand_from_payload( + repository_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let parse_result = parse_diff_trace_payload(stdin_payload)?; + let payload = match parse_result { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => return Ok(message), + }; + Ok(run_diff_trace_subcommand_from_payload_with( + repository_root, + &payload, + logger, + )) +} + +pub(crate) fn log_diff_trace_fail_open( + error: &anyhow::Error, + logger: Option<&dyn Logger>, + session_id: Option<&str>, +) -> String { + if let Some(log) = logger { + log.error( + "sce.hooks.diff_trace.error", + &error.to_string(), + &[], + session_id, + ); + } + + String::from("diff-trace hook intake failed open; error logged.") +} + +pub(crate) fn run_diff_trace_subcommand_from_payload_with( + repository_root: &Path, + payload: &DiffTracePayload, + logger: Option<&dyn Logger>, +) -> String { + if let Err(error) = diff_trace_db_time_ms(payload.time) { + if let Some(log) = logger { + log.warn( + "sce.hooks.diff_trace.agent_trace_db_time_invalid", + &error.to_string(), + &[], + Some(&payload.session_id), + ); + } + } + let agent_trace_db_persisted = + match persist_diff_trace_payload_to_agent_trace_db(repository_root, payload, logger) { + Ok(persisted) => persisted, + Err(error) => { + if let Some(log) = logger { + log.warn( + "sce.hooks.diff_trace.agent_trace_db_write_failed", + &error.to_string(), + &[], + Some(&payload.session_id), + ); + } + false + } + }; + + if agent_trace_db_persisted { + String::from("diff-trace hook intake persisted payload to AgentTraceDb.") + } else { + String::from("diff-trace hook intake completed; AgentTraceDb persistence failed.") + } +} + +pub(crate) fn parse_diff_trace_payload(stdin_payload: &str) -> Result { + let payload_kind = StdinPayloadKind::DiffTrace; + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| payload_kind.validation_error("expected valid JSON"))?; + let payload = parsed + .as_object() + .ok_or_else(|| anyhow!(payload_kind.validation_error("expected a JSON object")))?; + + if payload.contains_key("hook_event_name") { + return parse_claude_diff_trace_payload(payload, stdin_payload, payload_kind); + } + + let session_id = required_non_empty_string_field(payload, "sessionID", |d| { + payload_kind.validation_error(d) + })?; + let diff = + required_non_empty_string_field(payload, "diff", |d| payload_kind.validation_error(d))?; + let time = required_u64_millisecond_field(payload, "time", payload_kind)?; + let model_id = optional_string_field(payload, "model_id", payload_kind)?; + let tool_name = required_non_empty_string_field(payload, "tool_name", |d| { + payload_kind.validation_error(d) + })?; + let tool_version = + required_nullable_or_non_empty_string_field(payload, "tool_version", payload_kind)?; + + Ok(DiffTraceParseResult::Persist(DiffTracePayload { + session_id, + diff, + time, + model_id, + agent_id: None, + transcript_path: None, + tool_name, + tool_version, + payload_type: PAYLOAD_TYPE_PATCH.to_string(), + })) +} + +pub(crate) fn parse_claude_diff_trace_payload( + payload: &serde_json::Map, + stdin_payload: &str, + payload_kind: StdinPayloadKind, +) -> Result { + let event_name = required_non_empty_string_field(payload, "hook_event_name", |d| { + payload_kind.validation_error(d) + })?; + + if event_name != "PostToolUse" { + return Ok(DiffTraceParseResult::NoOp(format!( + "diff-trace hook intake: Claude '{event_name}' event has no diff trace; no-op." + ))); + } + + let time = extract_claude_event_time(payload); + + match derive_claude_structured_patch(&event_name, &Value::Object(payload.clone()), time, None) { + ClaudeStructuredPatchDerivationResult::Derived(patch) => { + Ok(DiffTraceParseResult::Persist(DiffTracePayload { + session_id: patch.session_id, + diff: stdin_payload.to_string(), + time: patch.time, + model_id: resolve_claude_model_id(payload), + agent_id: extract_claude_agent_id(payload)?, + transcript_path: non_empty_string(payload.get("transcript_path")) + .map(str::to_string), + tool_name: patch.tool_name, + tool_version: patch.tool_version, + payload_type: PAYLOAD_TYPE_STRUCTURED.to_string(), + })) + } + ClaudeStructuredPatchDerivationResult::Skipped(reason) => { + Ok(DiffTraceParseResult::NoOp(format!( + "diff-trace hook intake: Claude PostToolUse event skipped ({reason:?}); no-op." + ))) + } + } +} + +pub(crate) fn extract_claude_agent_id( + payload: &serde_json::Map, +) -> Result> { + let Some(value) = payload.get("agent_id") else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + + let value = value.as_str().ok_or_else(|| { + anyhow!(StdinPayloadKind::DiffTrace + .validation_error("field 'agent_id' must be null or a non-empty string")) + })?; + let value = value.trim(); + if value.is_empty() { + bail!(StdinPayloadKind::DiffTrace + .validation_error("field 'agent_id' must be null or a non-empty string")); + } + + Ok(Some(value.to_string())) +} + +pub(crate) fn resolve_claude_model_id(payload: &serde_json::Map) -> Option { + resolve_claude_model_id_with(payload, claude_transcript::extract_claude_transcript_model) +} + +pub(crate) fn resolve_claude_model_id_with( + payload: &serde_json::Map, + transcript_lookup: F, +) -> Option +where + F: FnOnce(&Path, &str) -> Option, +{ + extract_direct_claude_model_id(payload).or_else(|| { + let transcript_path = non_empty_string(payload.get("transcript_path"))?; + let tool_use_id = non_empty_string(payload.get("tool_use_id"))?; + + transcript_lookup(Path::new(transcript_path), tool_use_id) + .and_then(|model| normalize_claude_model_id(&model)) + }) +} + +pub(crate) fn extract_direct_claude_model_id( + payload: &serde_json::Map, +) -> Option { + direct_claude_model_id_string(payload, &["model", "model_id", "modelId"]) + .or_else(|| { + payload + .get("model") + .and_then(Value::as_object) + .and_then(|model| direct_claude_model_id_string(model, &["id", "model", "name"])) + }) + .and_then(|model| normalize_claude_model_id(&model)) +} + +pub(crate) fn direct_claude_model_id_string( + payload: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter().find_map(|key| { + payload + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) +} + +pub(crate) fn normalize_claude_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + if normalized.starts_with(CLAUDE_MODEL_ID_PREFIX) { + Some(normalized.to_string()) + } else { + Some(format!("{CLAUDE_MODEL_ID_PREFIX}{normalized}")) + } +} + +pub(crate) fn normalize_codex_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + Some(normalized.to_string()) +} + +pub(crate) fn normalize_opencode_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + Some(normalized.to_string()) +} + +pub(crate) fn normalize_pi_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + Some(normalized.to_string()) +} + +pub(crate) fn extract_claude_event_time(payload: &serde_json::Map) -> u64 { + for key in &["time", "timestamp"] { + if let Some(time_value) = payload.get(*key) { + if let Some(time) = time_value.as_u64() { + return time; + } + if let Some(time) = time_value.as_i64() { + if time >= 0 { + #[allow(clippy::cast_sign_loss)] + return time as u64; + } + } + if let Some(time) = time_value.as_f64() { + #[allow( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + clippy::cast_precision_loss + )] + if time >= 0.0 && time.fract() == 0.0 && time <= u64::MAX as f64 { + return time as u64; + } + } + } + } + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) +} + +pub(crate) fn required_nullable_or_non_empty_string_field( + payload: &serde_json::Map, + field_name: &str, + payload_kind: StdinPayloadKind, +) -> Result> { + let raw = required_field(payload, field_name, |d| payload_kind.validation_error(d))?; + + if raw.is_null() { + return Ok(None); + } + + let value = raw.as_str().ok_or_else(|| { + anyhow!(payload_kind.validation_error(&format!( + "field '{field_name}' must be null or a non-empty string" + ))) + })?; + + if value.trim().is_empty() { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be null or a non-empty string" + ))); + } + + Ok(Some(value.to_string())) +} + +pub(crate) fn optional_string_field( + payload: &serde_json::Map, + field_name: &str, + payload_kind: StdinPayloadKind, +) -> Result> { + let Some(raw) = payload.get(field_name) else { + return Ok(None); + }; + + if raw.is_null() { + return Ok(None); + } + + let value = raw.as_str().ok_or_else(|| { + anyhow!(payload_kind.validation_error(&format!( + "field '{field_name}' must be null, absent, or a non-empty string" + ))) + })?; + + if value.trim().is_empty() { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be null, absent, or a non-empty string" + ))); + } + + Ok(Some(value.to_string())) +} + +#[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss +)] +pub(crate) fn required_u64_millisecond_field( + payload: &serde_json::Map, + field_name: &str, + payload_kind: StdinPayloadKind, +) -> Result { + let raw = required_field(payload, field_name, |d| payload_kind.validation_error(d))?; + + if let Some(value) = raw.as_u64() { + return Ok(value); + } + + if let Some(value) = raw.as_i64() { + if value < 0 { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be a u64 Unix epoch millisecond value, got a negative number" + ))); + } + return Ok(value as u64); + } + + if let Some(value) = raw.as_f64() { + if value.fract() != 0.0 { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be a u64 Unix epoch millisecond value, got a fractional number" + ))); + } + if value < 0.0 { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be a u64 Unix epoch millisecond value, got a negative number" + ))); + } + if value > u64::MAX as f64 { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be a u64 Unix epoch millisecond value" + ))); + } + return Ok(value as u64); + } + + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be a u64 Unix epoch millisecond value" + ))) +} + +pub(crate) fn persist_diff_trace_payload_to_agent_trace_db( + repository_root: &Path, + payload: &DiffTracePayload, + logger: Option<&dyn Logger>, +) -> Result { + let db = match open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for diff-trace persistence.", + ) { + Ok(db) => db, + Err(error) => { + if let Some(log) = logger { + log.error( + "sce.hooks.diff_trace.agent_trace_db_open_failed", + &error.to_string(), + &[], + Some(&payload.session_id), + ); + } + + return Ok(false); + } + }; + + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, payload)?; + Ok(true) +} + +pub(crate) fn persist_diff_trace_payload_to_agent_trace_db_with_db( + db: &RepositoryAgentTraceDb, + payload: &DiffTracePayload, +) -> Result<()> { + let model_id = resolve_diff_trace_model_id(db, payload)?; + db.insert_diff_trace(DiffTraceInsert { + time_ms: diff_trace_db_time_ms(payload.time)?, + session_id: &prefixed_diff_trace_session_id(&payload.tool_name, &payload.session_id), + patch: &payload.diff, + model_id: model_id.as_deref(), + tool_name: &payload.tool_name, + tool_version: payload.tool_version.as_deref(), + payload_type: &payload.payload_type, + }) + .context("Failed to persist diff-trace payload to Agent Trace DB.")?; + + Ok(()) +} + +pub(crate) fn resolve_diff_trace_model_id( + db: &RepositoryAgentTraceDb, + payload: &DiffTracePayload, +) -> Result> { + if payload.model_id.is_some() + || payload.tool_name != CLAUDE_TOOL_NAME + || payload.payload_type != PAYLOAD_TYPE_STRUCTURED + { + return Ok(payload.model_id.clone()); + } + + let session_id = prefixed_diff_trace_session_id(CLAUDE_TOOL_NAME, &payload.session_id); + let agent_id = payload.agent_id.as_deref().unwrap_or(""); + if let Some(state) = db.claude_model_state_by_session_and_agent(&session_id, agent_id)? { + return Ok(Some(state.model_id)); + } + + Ok(seed_diff_trace_model_from_bridge_chain( + db, + payload, + &session_id, + agent_id, + )) +} + +pub(crate) fn seed_diff_trace_model_from_bridge_chain( + db: &RepositoryAgentTraceDb, + payload: &DiffTracePayload, + session_id: &str, + agent_id: &str, +) -> Option { + if !agent_id.is_empty() { + return None; + } + + let transcript_path = payload.transcript_path.as_deref()?; + let model_id = claude_model_state::newest_bridge_chain_model(db, Path::new(transcript_path))?; + + let observed_at_ms = current_unix_time_ms().ok()?; + match db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: session_id.to_string(), + agent_id: String::new(), + model_id: model_id.clone(), + observation_kind: ObservationKind::SessionStart, + source: String::from("bridge_inherited"), + observed_at_ms, + }) { + Ok(_) => Some(model_id), + Err(_) => None, + } +} + +#[cfg(test)] +pub(crate) fn persist_diff_trace_payload_to_agent_trace_db_with( + payload: &DiffTracePayload, + model_id: Option<&str>, + tool_version: Option<&str>, + insert_fn: F, +) -> Result +where + F: FnOnce(DiffTraceInsert<'_>) -> Result, +{ + let time_ms = diff_trace_db_time_ms(payload.time)?; + let session_id = prefixed_diff_trace_session_id(&payload.tool_name, &payload.session_id); + + insert_fn(DiffTraceInsert { + time_ms, + session_id: &session_id, + patch: &payload.diff, + model_id, + tool_name: &payload.tool_name, + tool_version, + payload_type: &payload.payload_type, + }) +} + +pub(crate) fn diff_trace_db_time_ms(time: u64) -> Result { + i64::try_from(time).map_err(|_| { + anyhow!(StdinPayloadKind::DiffTrace.validation_error( + "field 'time' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" + )) + }) +} diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 2785754b..9c499920 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -1,44 +1,36 @@ -use std::fs; -use std::io::{self, Read}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +use anyhow::Context; -use anyhow::{anyhow, bail, Context, Result}; -use chrono::{DateTime, Utc}; -use serde::Serialize; -use serde_json::{json, to_string as serialize_to_json, Value}; - -use crate::services::agent_trace::{ - agent_trace_persisted_url, build_agent_trace_from_evidence, patch_has_touched_lines, - patches_have_overlap, validate_agent_trace_value, AgentTrace, AgentTraceEvidence, - AgentTraceMetadataInput, AgentTraceVcsType, -}; +#[cfg(test)] +use crate::services::agent_trace::{validate_agent_trace_value, AgentTrace, AgentTraceVcsType}; +#[cfg(test)] use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +#[cfg(test)] use crate::services::agent_trace_db::{ - AgentTraceInsert, ClaudeModelStateObservation, DiffTraceInsert, InsertMessageInsert, - InsertPartInsert, MessageRole, ObservationKind, PartType, PostCommitPatchIntersectionInsert, - RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, PAYLOAD_TYPE_STRUCTURED, + DiffTraceInsert, MessageRole, PartType, RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, + PAYLOAD_TYPE_STRUCTURED, }; #[cfg(test)] use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, - resolve_agent_trace_storage_for_hook_runtime_at_state_root, -}; -use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_for_hook_runtime, AgentTraceStorageContext, + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, }; -use crate::services::config; +#[cfg(test)] use crate::services::observability::traits::Logger; +#[cfg(test)] use crate::services::patch::{ - combine_patches as combine_patches_fn, intersect_patches as intersect_patches_fn, - load_patch_from_json, parse_patch as parse_patch_from_text, ParsedPatch, + intersect_patches as intersect_patches_fn, load_patch_from_json, + parse_patch as parse_patch_from_text, ParsedPatch, }; -use crate::services::structured_patch::{ - build_claude_post_tool_use_patch, derive_claude_structured_patch, - ClaudeStructuredPatchDerivationResult, PatchBuildResult, -}; -use crate::services::sync::auto_sync; +#[cfg(test)] +use anyhow::{anyhow, Result}; +#[cfg(test)] +use serde_json::{json, to_string as serialize_to_json, Value}; + +mod claude_transforms; +mod commit_hooks; +mod conversation_trace; +mod diff_trace; +mod runtime; + pub mod claude_bridge_session; pub mod claude_model_state; pub mod claude_mutation_scope; @@ -52,52 +44,22 @@ pub mod mutation_scope_health; pub mod opencode_mutation_scope; pub mod pi_mutation_scope; -pub const NAME: &str = "hooks"; -pub const CANONICAL_SCE_COAUTHOR_TRAILER: &str = "Co-authored-by: SCE "; -const CLAUDE_MODEL_ID_PREFIX: &str = "claude/"; -pub(crate) const DIFF_TRACE_OPENCODE_SESSION_ID_PREFIX: &str = "oc_"; -pub(crate) const DIFF_TRACE_CLAUDE_SESSION_ID_PREFIX: &str = "cc_"; -pub(crate) const DIFF_TRACE_PI_SESSION_ID_PREFIX: &str = "pi_"; -pub(crate) const DIFF_TRACE_CODEX_SESSION_ID_PREFIX: &str = "cx_"; -const OPENCODE_TOOL_NAME: &str = "opencode"; -const CLAUDE_TOOL_NAME: &str = "claude"; -const PI_TOOL_NAME: &str = "pi"; -const CODEX_TOOL_NAME: &str = "codex"; -const NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES: &[&str] = &[OPENCODE_TOOL_NAME, PI_TOOL_NAME]; -type PayloadValidationError = fn(&str) -> String; - -pub(crate) fn prefixed_diff_trace_session_id(tool_name: &str, raw_session_id: &str) -> String { - prefixed_session_id(tool_name, raw_session_id) -} +pub(crate) use commit_hooks::*; +pub(crate) use conversation_trace::*; +pub(crate) use diff_trace::*; +pub(crate) use runtime::*; -fn prefixed_conversation_trace_session_id(tool_name: &str, raw_session_id: &str) -> String { - prefixed_session_id(tool_name, raw_session_id) -} - -fn prefixed_session_id(tool_name: &str, raw_session_id: &str) -> String { - let prefix = match tool_name { - OPENCODE_TOOL_NAME => DIFF_TRACE_OPENCODE_SESSION_ID_PREFIX, - CLAUDE_TOOL_NAME => DIFF_TRACE_CLAUDE_SESSION_ID_PREFIX, - PI_TOOL_NAME => DIFF_TRACE_PI_SESSION_ID_PREFIX, - CODEX_TOOL_NAME => DIFF_TRACE_CODEX_SESSION_ID_PREFIX, - _ => return raw_session_id.to_string(), - }; - - if raw_session_id.starts_with(prefix) { - raw_session_id.to_string() - } else { - format!("{prefix}{raw_session_id}") - } -} +#[cfg(test)] +mod tests; #[derive(Clone, Debug, Eq, PartialEq)] pub enum HookSubcommand { PreCommit, CommitMsg { - message_file: PathBuf, + message_file: std::path::PathBuf, }, PostCommit { - vcs_type: Option, + vcs_type: Option, remote_url: Option, }, PostRewrite { @@ -115,98 +77,13 @@ pub enum HookSubcommand { ExternalMutationGuard, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -struct DiffTracePayload { - #[serde(rename = "sessionID")] - session_id: String, - diff: String, - time: u64, - model_id: Option, - #[serde(skip)] - agent_id: Option, - #[serde(skip)] - transcript_path: Option, - tool_name: String, - tool_version: Option, - payload_type: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum DiffTraceParseResult { - Persist(DiffTracePayload), - NoOp(String), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum StdinPayloadKind { - DiffTrace, -} - -impl StdinPayloadKind { - fn label(self) -> &'static str { - match self { - Self::DiffTrace => "diff-trace", - } - } - - fn validation_error(self, detail: &str) -> String { - format!("Invalid {} payload from STDIN: {detail}.", self.label()) - } -} - -const CONVERSATION_TRACE_MESSAGE_UPDATED: &str = "message"; -const CONVERSATION_TRACE_MESSAGE_PART_UPDATED: &str = "message.part"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ConversationTracePayload { - pub attempted_count: usize, - pub message_updated: ConversationTraceMessageBatch, - pub message_part_updated: ConversationTracePartBatch, - pub skipped: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ConversationTraceMessageBatch { - pub inserts: Vec, - pub skipped: Vec, - diagnostic_session_id: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ConversationTracePartBatch { - pub inserts: Vec, - pub skipped: Vec, - diagnostic_session_id: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SkippedConversationTracePayload { - pub index: usize, - pub reason: String, - pub session_id: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ConversationTracePersistenceSummary { - attempted: usize, - persisted_messages: usize, - persisted_parts: usize, - skipped: usize, -} - -impl ConversationTracePersistenceSummary { - fn render(&self) -> String { - format!( - "conversation-trace hook persisted mixed payload batch to AgentTraceDb: attempted={}, persisted_messages={}, persisted_parts={}, skipped={}.", - self.attempted, self.persisted_messages, self.persisted_parts, self.skipped - ) - } -} +pub const NAME: &str = "hooks"; +pub const CANONICAL_SCE_COAUTHOR_TRAILER: &str = "Co-authored-by: SCE "; pub fn run_hooks_subcommand( subcommand: &HookSubcommand, - logger: Option<&dyn Logger>, -) -> Result { + logger: Option<&dyn crate::services::observability::traits::Logger>, +) -> anyhow::Result { let repository_root = std::env::current_dir().with_context(|| { format!( "Failed to determine current directory for {}.", @@ -218,10 +95,10 @@ pub fn run_hooks_subcommand( } fn run_hooks_subcommand_in_repo( - repository_root: &Path, + repository_root: &std::path::Path, subcommand: &HookSubcommand, - logger: Option<&dyn Logger>, -) -> Result { + logger: Option<&dyn crate::services::observability::traits::Logger>, +) -> anyhow::Result { match subcommand { HookSubcommand::PreCommit => run_pre_commit_subcommand_with_trace(repository_root), HookSubcommand::CommitMsg { message_file } => { @@ -267,2157 +144,3 @@ fn run_hooks_subcommand_in_repo( } } } - -fn run_conversation_trace_subcommand( - repository_root: &Path, - logger: Option<&dyn Logger>, -) -> String { - let stdin_payload = match read_hook_stdin() { - Ok(payload) => payload, - Err(error) => return log_conversation_trace_fail_open(&error, logger, None), - }; - let session_id = conversation_trace_fail_open_session_id(&stdin_payload); - - match run_conversation_trace_subcommand_from_payload( - repository_root, - &stdin_payload, - logger, - session_id.as_deref(), - ) { - Ok(output) => output, - Err(error) => log_conversation_trace_fail_open(&error, logger, session_id.as_deref()), - } -} - -fn run_conversation_trace_subcommand_from_payload( - repository_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, - session_id: Option<&str>, -) -> Result { - let payload = parse_conversation_trace_payload(stdin_payload)?; - Ok(persist_conversation_trace_payload_to_agent_trace_db( - repository_root, - payload, - logger, - session_id, - )) -} - -fn log_conversation_trace_fail_open( - error: &anyhow::Error, - logger: Option<&dyn Logger>, - session_id: Option<&str>, -) -> String { - if let Some(log) = logger { - log.error( - "sce.hooks.conversation_trace.error", - &error.to_string(), - &[], - session_id, - ); - } - - String::from("conversation-trace hook intake failed open; error logged.") -} - -fn persist_conversation_trace_payload_to_agent_trace_db( - repository_root: &Path, - payload: ConversationTracePayload, - logger: Option<&dyn Logger>, - session_id: Option<&str>, -) -> String { - let db = match open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for conversation-trace persistence.", - ) { - Ok(db) => db, - Err(error) => { - if let Some(log) = logger { - log.error( - "sce.hooks.conversation_trace.agent_trace_db_open_failed", - &error.to_string(), - &[], - session_id, - ); - } - - return String::from("conversation-trace hook intake failed open; error logged."); - } - }; - - let summary = persist_conversation_trace_payload_to_agent_trace_db_with( - payload, - logger, - |inserts| db.insert_messages(inserts), - |inserts| db.insert_parts(inserts), - ); - - summary.render() -} -fn persist_conversation_trace_payload_to_agent_trace_db_with( - payload: ConversationTracePayload, - logger: Option<&dyn Logger>, - insert_messages: IM, - insert_parts: IP, -) -> ConversationTracePersistenceSummary -where - IM: FnOnce(Vec) -> Result, - IP: FnOnce(Vec) -> Result, -{ - log_skipped_conversation_trace_payloads(logger, "unsupported", &payload.skipped); - - let message_summary = persist_message_updated_batch_to_agent_trace_db_with( - payload.message_updated, - logger, - insert_messages, - ); - let part_summary = persist_message_part_updated_batch_to_agent_trace_db_with( - payload.message_part_updated, - logger, - insert_parts, - ); - - ConversationTracePersistenceSummary { - attempted: payload.attempted_count, - persisted_messages: message_summary.persisted, - persisted_parts: part_summary.persisted, - skipped: payload.skipped.len() + message_summary.skipped + part_summary.skipped, - } -} - -fn open_agent_trace_db_for_hook_runtime( - repository_root: &Path, - context_message: &'static str, -) -> Result { - let storage_config = config::resolve_agent_trace_storage_runtime_config(repository_root) - .context("Failed to resolve Agent Trace repository storage config.")?; - let storage_context = AgentTraceStorageContext { - repository_root, - explicit_repository_id: storage_config.repository_id.as_deref(), - repository_remote: &storage_config.repository_remote, - }; - - resolve_agent_trace_storage_for_hook_runtime(&storage_context) - .map(|storage| storage.db) - .context(context_message) -} - -#[cfg(test)] -pub(crate) fn open_agent_trace_db_for_hook_runtime_at_state_root( - repository_root: &Path, - state_root: &Path, - context_message: &'static str, -) -> Result { - let storage_config = config::resolve_agent_trace_storage_runtime_config(repository_root) - .context("Failed to resolve Agent Trace repository storage config.")?; - let storage_context = AgentTraceStorageContext { - repository_root, - explicit_repository_id: storage_config.repository_id.as_deref(), - repository_remote: &storage_config.repository_remote, - }; - - resolve_agent_trace_storage_for_hook_runtime_at_state_root(&storage_context, state_root) - .map(|storage| storage.db) - .context(context_message) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ConversationTraceEventPersistenceSummary { - persisted: usize, - skipped: usize, -} - -fn persist_message_updated_batch_to_agent_trace_db_with( - batch: ConversationTraceMessageBatch, - logger: Option<&dyn Logger>, - insert_messages: I, -) -> ConversationTraceEventPersistenceSummary -where - I: FnOnce(Vec) -> Result, -{ - const EVENT_TYPE: &str = "message"; - - let mut skipped = batch.skipped.len(); - - log_skipped_conversation_trace_payloads(logger, EVENT_TYPE, &batch.skipped); - - let valid_count = batch.inserts.len(); - let session_id = batch.diagnostic_session_id; - let persisted = if valid_count == 0 { - 0 - } else { - match insert_messages(batch.inserts) { - Ok(affected_rows) => usize::try_from(affected_rows) - .unwrap_or(usize::MAX) - .min(valid_count), - Err(error) => { - skipped += valid_count; - log_conversation_trace_batch_insert_failure( - logger, - EVENT_TYPE, - valid_count, - &error, - session_id.as_deref(), - ); - 0 - } - } - }; - - ConversationTraceEventPersistenceSummary { persisted, skipped } -} - -fn persist_message_part_updated_batch_to_agent_trace_db_with( - batch: ConversationTracePartBatch, - logger: Option<&dyn Logger>, - insert_parts: I, -) -> ConversationTraceEventPersistenceSummary -where - I: FnOnce(Vec) -> Result, -{ - const EVENT_TYPE: &str = "message.part"; - - let mut skipped = batch.skipped.len(); - - log_skipped_conversation_trace_payloads(logger, EVENT_TYPE, &batch.skipped); - - let valid_count = batch.inserts.len(); - let session_id = batch.diagnostic_session_id; - let persisted = if valid_count == 0 { - 0 - } else { - match insert_parts(batch.inserts) { - Ok(affected_rows) => usize::try_from(affected_rows) - .unwrap_or(usize::MAX) - .min(valid_count), - Err(error) => { - skipped += valid_count; - log_conversation_trace_batch_insert_failure( - logger, - EVENT_TYPE, - valid_count, - &error, - session_id.as_deref(), - ); - 0 - } - } - }; - - ConversationTraceEventPersistenceSummary { persisted, skipped } -} - -fn log_skipped_conversation_trace_payloads( - logger: Option<&dyn Logger>, - event_type: &str, - skipped_payloads: &[SkippedConversationTracePayload], -) { - let Some(log) = logger else { - return; - }; - - for skipped in skipped_payloads { - let index = skipped.index.to_string(); - log.warn( - "sce.hooks.conversation_trace.payload_skipped", - &skipped.reason, - &[ - ("event_type", event_type), - ("payload_index", index.as_str()), - ], - skipped.session_id.as_deref(), - ); - } -} - -fn log_conversation_trace_batch_insert_failure( - logger: Option<&dyn Logger>, - event_type: &str, - valid_count: usize, - error: &anyhow::Error, - session_id: Option<&str>, -) { - if let Some(log) = logger { - let count = valid_count.to_string(); - log.warn( - "sce.hooks.conversation_trace.agent_trace_db_batch_failed", - &error.to_string(), - &[("event_type", event_type), ("valid_count", count.as_str())], - session_id, - ); - } -} - -pub fn parse_conversation_trace_payload(stdin_payload: &str) -> Result { - let parsed: Value = serde_json::from_str(stdin_payload) - .context("Invalid conversation-trace payload from STDIN: expected valid JSON.")?; - let payload = parsed.as_object().ok_or_else(|| { - anyhow!(conversation_trace_validation_error( - "expected a JSON object" - )) - })?; - - if payload.contains_key("hook_event_name") { - let event_name = required_non_empty_string_field( - payload, - "hook_event_name", - conversation_trace_validation_error, - )?; - - let items = match event_name.as_str() { - "UserPromptSubmit" => transform_claude_user_prompt_submit(payload)?, - "Stop" => transform_claude_stop(payload)?, - "PostToolUse" => transform_claude_post_tool_use(payload)?, - _ => bail!(conversation_trace_validation_error(&format!( - "unsupported Claude hook event '{event_name}': supported events are 'UserPromptSubmit', 'Stop' and 'PostToolUse'" - ))), - }; - return Ok(parse_conversation_trace_payloads(&items, CLAUDE_TOOL_NAME)); - } - - let tool_name = - required_non_empty_string_field(payload, "tool_name", conversation_trace_validation_error)?; - if !NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES.contains(&tool_name.as_str()) { - bail!(conversation_trace_validation_error(&format!( - "unsupported tool_name '{tool_name}': supported producers are 'opencode' and 'pi'" - ))); - } - let payloads = required_payloads_array(payload)?; - - Ok(parse_conversation_trace_payloads(payloads, &tool_name)) -} - -fn required_payloads_array(payload: &serde_json::Map) -> Result<&Vec> { - required_field(payload, "payloads", conversation_trace_validation_error)? - .as_array() - .ok_or_else(|| { - anyhow!(conversation_trace_validation_error( - "field 'payloads' must be an array" - )) - }) -} - -fn parse_conversation_trace_payloads( - payloads: &[Value], - tool_name: &str, -) -> ConversationTracePayload { - let mut message_inserts = Vec::new(); - let mut message_skipped = Vec::new(); - let mut part_inserts = Vec::new(); - let mut part_skipped = Vec::new(); - let mut skipped = Vec::new(); - let mut message_diagnostic_session_id = None; - let mut part_diagnostic_session_id = None; - - for (index, item) in payloads.iter().enumerate() { - let session_id = non_empty_string(item.get("session_id")).map(str::to_owned); - let Some(item) = conversation_trace_payload_item(item, index, &mut skipped) else { - continue; - }; - - let event_type = - match required_string_field(item, "type", conversation_trace_validation_error) { - Ok(event_type) => event_type, - Err(error) => { - skipped.push(SkippedConversationTracePayload { - index, - reason: error.to_string(), - session_id: session_id.clone(), - }); - continue; - } - }; - - match event_type.as_str() { - CONVERSATION_TRACE_MESSAGE_UPDATED => match parse_message_updated_item(item) { - Ok(mut input) => { - if message_diagnostic_session_id.is_none() { - message_diagnostic_session_id.clone_from(&session_id); - } - input.session_id = - prefixed_conversation_trace_session_id(tool_name, &input.session_id); - message_inserts.push(input); - } - Err(error) => message_skipped.push(SkippedConversationTracePayload { - index, - reason: error.to_string(), - session_id: session_id.clone(), - }), - }, - CONVERSATION_TRACE_MESSAGE_PART_UPDATED => { - match parse_message_part_updated_item(item) { - Ok(mut input) => { - if part_diagnostic_session_id.is_none() { - part_diagnostic_session_id.clone_from(&session_id); - } - input.session_id = - prefixed_conversation_trace_session_id(tool_name, &input.session_id); - part_inserts.push(input); - } - Err(error) => part_skipped.push(SkippedConversationTracePayload { - index, - reason: error.to_string(), - session_id: session_id.clone(), - }), - } - } - _ => skipped.push(SkippedConversationTracePayload { - index, - reason: conversation_trace_validation_error( - "field 'type' must be one of 'message' or 'message.part'", - ), - session_id, - }), - } - } - - ConversationTracePayload { - attempted_count: payloads.len(), - message_updated: ConversationTraceMessageBatch { - inserts: message_inserts, - skipped: message_skipped, - diagnostic_session_id: message_diagnostic_session_id, - }, - message_part_updated: ConversationTracePartBatch { - inserts: part_inserts, - skipped: part_skipped, - diagnostic_session_id: part_diagnostic_session_id, - }, - skipped, - } -} - -fn conversation_trace_payload_item<'a>( - item: &'a Value, - index: usize, - skipped: &mut Vec, -) -> Option<&'a serde_json::Map> { - let Some(payload) = item.as_object() else { - skipped.push(SkippedConversationTracePayload { - index, - reason: conversation_trace_validation_error(&format!( - "payloads[{index}] must be an object" - )), - session_id: None, - }); - return None; - }; - - Some(payload) -} - -fn parse_message_updated_item( - payload: &serde_json::Map, -) -> Result { - Ok(InsertMessageInsert { - session_id: required_non_empty_string_field( - payload, - "session_id", - conversation_trace_validation_error, - )?, - message_id: required_non_empty_string_field( - payload, - "message_id", - conversation_trace_validation_error, - )?, - role: parse_message_role(payload)?, - generated_at_unix_ms: required_i64_millisecond_field( - payload, - "generated_at_unix_ms", - conversation_trace_validation_error, - )?, - }) -} - -fn parse_message_part_updated_item( - payload: &serde_json::Map, -) -> Result { - let part_type = parse_part_type(payload)?; - let raw_text = required_string_field(payload, "text", conversation_trace_validation_error)?; - let text = match part_type { - PartType::Patch => { - if load_patch_from_json(&raw_text).is_ok() { - raw_text - } else { - match parse_patch_from_text(&raw_text, None) { - Ok(parsed_patch) => serialize_to_json(&parsed_patch).map_err(|error| { - anyhow!(conversation_trace_validation_error(&format!( - "failed to serialize parsed patch for conversation-trace patch part: {error}" - ))) - })?, - Err(diff_error) => { - bail!(conversation_trace_validation_error(&format!( - "field 'text' for patch part is neither valid patch-JSON nor a valid patch: {diff_error}" - ))); - } - } - } - } - PartType::Text | PartType::Reasoning => raw_text, - PartType::Question => validate_question_part_text(raw_text)?, - }; - - Ok(InsertPartInsert { - session_id: required_non_empty_string_field( - payload, - "session_id", - conversation_trace_validation_error, - )?, - message_id: required_non_empty_string_field( - payload, - "message_id", - conversation_trace_validation_error, - )?, - part_type, - text, - generated_at_unix_ms: required_i64_millisecond_field( - payload, - "generated_at_unix_ms", - conversation_trace_validation_error, - )?, - }) -} - -fn parse_message_role(payload: &serde_json::Map) -> Result { - match required_string_field(payload, "role", conversation_trace_validation_error)?.as_str() { - "user" => Ok(MessageRole::User), - "assistant" => Ok(MessageRole::Assistant), - _ => bail!(conversation_trace_validation_error( - "field 'role' must be one of 'user' or 'assistant'" - )), - } -} - -fn parse_part_type(payload: &serde_json::Map) -> Result { - match required_string_field(payload, "part_type", conversation_trace_validation_error)?.as_str() - { - "text" => Ok(PartType::Text), - "reasoning" => Ok(PartType::Reasoning), - "patch" => Ok(PartType::Patch), - "question" => Ok(PartType::Question), - _ => bail!(conversation_trace_validation_error( - "field 'part_type' must be one of 'text', 'reasoning', 'patch' or 'question'" - )), - } -} - -fn validate_question_part_text(raw_text: String) -> Result { - let parsed: Value = serde_json::from_str(&raw_text).map_err(|_| { - anyhow!(conversation_trace_validation_error( - "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" - )) - })?; - - let items = parsed.as_array().ok_or_else(|| { - anyhow!(conversation_trace_validation_error( - "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" - )) - })?; - - if items.iter().all(|item| { - item.as_object().is_some_and(|object| { - object.get("question").is_some_and(Value::is_string) - && object.get("answer").is_some_and(Value::is_string) - }) - }) { - return Ok(raw_text); - } - - bail!(conversation_trace_validation_error( - "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" - )) -} - -fn conversation_trace_validation_error(detail: &str) -> String { - format!("Invalid conversation-trace payload from STDIN: {detail}.") -} - -fn conversation_trace_fail_open_session_id(stdin_payload: &str) -> Option { - let payload: Value = serde_json::from_str(stdin_payload).ok()?; - let payload = payload.as_object()?; - - if payload.contains_key("hook_event_name") { - return non_empty_string(payload.get("session_id")).map(str::to_owned); - } - - let first_payload = payload.get("payloads")?.as_array()?.first()?; - non_empty_string(first_payload.get("session_id")).map(str::to_owned) -} - -fn diff_trace_fail_open_session_id(stdin_payload: &str) -> Option { - let payload: Value = serde_json::from_str(stdin_payload).ok()?; - let payload = payload.as_object()?; - let field_name = if payload.contains_key("hook_event_name") { - "session_id" - } else { - "sessionID" - }; - - non_empty_string(payload.get(field_name)).map(str::to_owned) -} - -fn non_empty_string(value: Option<&Value>) -> Option<&str> { - value?.as_str().filter(|value| !value.trim().is_empty()) -} - -fn run_diff_trace_subcommand(repository_root: &Path, logger: Option<&dyn Logger>) -> String { - let stdin_payload = match read_hook_stdin() { - Ok(payload) => payload, - Err(error) => return log_diff_trace_fail_open(&error, logger, None), - }; - let session_id = diff_trace_fail_open_session_id(&stdin_payload); - - match run_diff_trace_subcommand_from_payload(repository_root, &stdin_payload, logger) { - Ok(output) => output, - Err(error) => log_diff_trace_fail_open(&error, logger, session_id.as_deref()), - } -} - -fn run_diff_trace_subcommand_from_payload( - repository_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let parse_result = parse_diff_trace_payload(stdin_payload)?; - let payload = match parse_result { - DiffTraceParseResult::Persist(payload) => payload, - DiffTraceParseResult::NoOp(message) => return Ok(message), - }; - Ok(run_diff_trace_subcommand_from_payload_with( - repository_root, - &payload, - logger, - )) -} - -fn log_diff_trace_fail_open( - error: &anyhow::Error, - logger: Option<&dyn Logger>, - session_id: Option<&str>, -) -> String { - if let Some(log) = logger { - log.error( - "sce.hooks.diff_trace.error", - &error.to_string(), - &[], - session_id, - ); - } - - String::from("diff-trace hook intake failed open; error logged.") -} - -fn run_diff_trace_subcommand_from_payload_with( - repository_root: &Path, - payload: &DiffTracePayload, - logger: Option<&dyn Logger>, -) -> String { - if let Err(error) = diff_trace_db_time_ms(payload.time) { - if let Some(log) = logger { - log.warn( - "sce.hooks.diff_trace.agent_trace_db_time_invalid", - &error.to_string(), - &[], - Some(&payload.session_id), - ); - } - } - let agent_trace_db_persisted = - match persist_diff_trace_payload_to_agent_trace_db(repository_root, payload, logger) { - Ok(persisted) => persisted, - Err(error) => { - if let Some(log) = logger { - log.warn( - "sce.hooks.diff_trace.agent_trace_db_write_failed", - &error.to_string(), - &[], - Some(&payload.session_id), - ); - } - false - } - }; - - if agent_trace_db_persisted { - String::from("diff-trace hook intake persisted payload to AgentTraceDb.") - } else { - String::from("diff-trace hook intake completed; AgentTraceDb persistence failed.") - } -} - -fn parse_diff_trace_payload(stdin_payload: &str) -> Result { - let payload_kind = StdinPayloadKind::DiffTrace; - let parsed: Value = serde_json::from_str(stdin_payload) - .with_context(|| payload_kind.validation_error("expected valid JSON"))?; - let payload = parsed - .as_object() - .ok_or_else(|| anyhow!(payload_kind.validation_error("expected a JSON object")))?; - - if payload.contains_key("hook_event_name") { - return parse_claude_diff_trace_payload(payload, stdin_payload, payload_kind); - } - - let session_id = required_non_empty_string_field(payload, "sessionID", |d| { - payload_kind.validation_error(d) - })?; - let diff = - required_non_empty_string_field(payload, "diff", |d| payload_kind.validation_error(d))?; - let time = required_u64_millisecond_field(payload, "time", payload_kind)?; - let model_id = optional_string_field(payload, "model_id", payload_kind)?; - let tool_name = required_non_empty_string_field(payload, "tool_name", |d| { - payload_kind.validation_error(d) - })?; - let tool_version = - required_nullable_or_non_empty_string_field(payload, "tool_version", payload_kind)?; - - Ok(DiffTraceParseResult::Persist(DiffTracePayload { - session_id, - diff, - time, - model_id, - agent_id: None, - transcript_path: None, - tool_name, - tool_version, - payload_type: PAYLOAD_TYPE_PATCH.to_string(), - })) -} - -fn parse_claude_diff_trace_payload( - payload: &serde_json::Map, - stdin_payload: &str, - payload_kind: StdinPayloadKind, -) -> Result { - let event_name = required_non_empty_string_field(payload, "hook_event_name", |d| { - payload_kind.validation_error(d) - })?; - - if event_name != "PostToolUse" { - return Ok(DiffTraceParseResult::NoOp(format!( - "diff-trace hook intake: Claude '{event_name}' event has no diff trace; no-op." - ))); - } - - let time = extract_claude_event_time(payload); - - match derive_claude_structured_patch(&event_name, &Value::Object(payload.clone()), time, None) { - ClaudeStructuredPatchDerivationResult::Derived(patch) => { - Ok(DiffTraceParseResult::Persist(DiffTracePayload { - session_id: patch.session_id, - diff: stdin_payload.to_string(), - time: patch.time, - model_id: resolve_claude_model_id(payload), - agent_id: extract_claude_agent_id(payload)?, - transcript_path: non_empty_string(payload.get("transcript_path")) - .map(str::to_string), - tool_name: patch.tool_name, - tool_version: patch.tool_version, - payload_type: PAYLOAD_TYPE_STRUCTURED.to_string(), - })) - } - ClaudeStructuredPatchDerivationResult::Skipped(reason) => { - Ok(DiffTraceParseResult::NoOp(format!( - "diff-trace hook intake: Claude PostToolUse event skipped ({reason:?}); no-op." - ))) - } - } -} - -fn extract_claude_agent_id(payload: &serde_json::Map) -> Result> { - let Some(value) = payload.get("agent_id") else { - return Ok(None); - }; - if value.is_null() { - return Ok(None); - } - - let value = value.as_str().ok_or_else(|| { - anyhow!(StdinPayloadKind::DiffTrace - .validation_error("field 'agent_id' must be null or a non-empty string")) - })?; - let value = value.trim(); - if value.is_empty() { - bail!(StdinPayloadKind::DiffTrace - .validation_error("field 'agent_id' must be null or a non-empty string")); - } - - Ok(Some(value.to_string())) -} - -fn resolve_claude_model_id(payload: &serde_json::Map) -> Option { - resolve_claude_model_id_with(payload, claude_transcript::extract_claude_transcript_model) -} - -fn resolve_claude_model_id_with( - payload: &serde_json::Map, - transcript_lookup: F, -) -> Option -where - F: FnOnce(&Path, &str) -> Option, -{ - extract_direct_claude_model_id(payload).or_else(|| { - let transcript_path = non_empty_string(payload.get("transcript_path"))?; - let tool_use_id = non_empty_string(payload.get("tool_use_id"))?; - - transcript_lookup(Path::new(transcript_path), tool_use_id) - .and_then(|model| normalize_claude_model_id(&model)) - }) -} - -fn extract_direct_claude_model_id(payload: &serde_json::Map) -> Option { - direct_claude_model_id_string(payload, &["model", "model_id", "modelId"]) - .or_else(|| { - payload - .get("model") - .and_then(Value::as_object) - .and_then(|model| direct_claude_model_id_string(model, &["id", "model", "name"])) - }) - .and_then(|model| normalize_claude_model_id(&model)) -} - -fn direct_claude_model_id_string( - payload: &serde_json::Map, - keys: &[&str], -) -> Option { - keys.iter().find_map(|key| { - payload - .get(*key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - }) -} - -fn normalize_claude_model_id(model: &str) -> Option { - let normalized = model.trim(); - if normalized.is_empty() { - return None; - } - - if normalized.starts_with(CLAUDE_MODEL_ID_PREFIX) { - Some(normalized.to_string()) - } else { - Some(format!("{CLAUDE_MODEL_ID_PREFIX}{normalized}")) - } -} - -fn normalize_codex_model_id(model: &str) -> Option { - let normalized = model.trim(); - if normalized.is_empty() { - return None; - } - - Some(normalized.to_string()) -} - -fn normalize_opencode_model_id(model: &str) -> Option { - let normalized = model.trim(); - if normalized.is_empty() { - return None; - } - - Some(normalized.to_string()) -} - -fn normalize_pi_model_id(model: &str) -> Option { - let normalized = model.trim(); - if normalized.is_empty() { - return None; - } - - Some(normalized.to_string()) -} - -fn extract_claude_event_time(payload: &serde_json::Map) -> u64 { - for key in &["time", "timestamp"] { - if let Some(time_value) = payload.get(*key) { - if let Some(time) = time_value.as_u64() { - return time; - } - if let Some(time) = time_value.as_i64() { - if time >= 0 { - #[allow(clippy::cast_sign_loss)] - return time as u64; - } - } - if let Some(time) = time_value.as_f64() { - #[allow( - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - clippy::cast_precision_loss - )] - if time >= 0.0 && time.fract() == 0.0 && time <= u64::MAX as f64 { - return time as u64; - } - } - } - } - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |d| d.as_millis() as u64) -} - -fn required_nullable_or_non_empty_string_field( - payload: &serde_json::Map, - field_name: &str, - payload_kind: StdinPayloadKind, -) -> Result> { - let raw = required_field(payload, field_name, |d| payload_kind.validation_error(d))?; - - if raw.is_null() { - return Ok(None); - } - - let value = raw.as_str().ok_or_else(|| { - anyhow!(payload_kind.validation_error(&format!( - "field '{field_name}' must be null or a non-empty string" - ))) - })?; - - if value.trim().is_empty() { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be null or a non-empty string" - ))); - } - - Ok(Some(value.to_string())) -} - -fn optional_string_field( - payload: &serde_json::Map, - field_name: &str, - payload_kind: StdinPayloadKind, -) -> Result> { - let Some(raw) = payload.get(field_name) else { - return Ok(None); - }; - - if raw.is_null() { - return Ok(None); - } - - let value = raw.as_str().ok_or_else(|| { - anyhow!(payload_kind.validation_error(&format!( - "field '{field_name}' must be null, absent, or a non-empty string" - ))) - })?; - - if value.trim().is_empty() { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be null, absent, or a non-empty string" - ))); - } - - Ok(Some(value.to_string())) -} - -fn required_non_empty_string_field( - payload: &serde_json::Map, - field_name: &str, - format_error: impl Fn(&str) -> String, -) -> Result { - let raw = required_field(payload, field_name, &format_error)?; - - let value = raw.as_str().ok_or_else(|| { - anyhow!(format_error(&format!( - "field '{field_name}' must be a non-empty string" - ))) - })?; - - if value.trim().is_empty() { - bail!(format_error(&format!( - "field '{field_name}' must be a non-empty string" - ))); - } - - Ok(value.to_string()) -} - -fn required_string_field( - payload: &serde_json::Map, - field_name: &str, - validation_error: PayloadValidationError, -) -> Result { - let raw = required_field(payload, field_name, validation_error)?; - - raw.as_str().map(ToString::to_string).ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{field_name}' must be a string" - ))) - }) -} - -#[allow( - clippy::cast_precision_loss, - clippy::cast_possible_truncation, - clippy::cast_sign_loss -)] -fn required_u64_millisecond_field( - payload: &serde_json::Map, - field_name: &str, - payload_kind: StdinPayloadKind, -) -> Result { - let raw = required_field(payload, field_name, |d| payload_kind.validation_error(d))?; - - if let Some(value) = raw.as_u64() { - return Ok(value); - } - - if let Some(value) = raw.as_i64() { - if value < 0 { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be a u64 Unix epoch millisecond value, got a negative number" - ))); - } - return Ok(value as u64); - } - - if let Some(value) = raw.as_f64() { - if value.fract() != 0.0 { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be a u64 Unix epoch millisecond value, got a fractional number" - ))); - } - if value < 0.0 { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be a u64 Unix epoch millisecond value, got a negative number" - ))); - } - if value > u64::MAX as f64 { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be a u64 Unix epoch millisecond value" - ))); - } - return Ok(value as u64); - } - - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be a u64 Unix epoch millisecond value" - ))) -} - -fn required_i64_millisecond_field( - payload: &serde_json::Map, - field_name: &str, - validation_error: PayloadValidationError, -) -> Result { - let raw = required_field(payload, field_name, validation_error)?; - - if let Some(value) = raw.as_i64() { - if value < 0 { - bail!(validation_error(&format!( - "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value" - ))); - } - return Ok(value); - } - - if let Some(value) = raw.as_u64() { - return i64::try_from(value).map_err(|_| { - anyhow!(validation_error(&format!( - "field '{field_name}' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" - ))) - }); - } - - if raw.as_f64().is_some_and(|value| value.fract() != 0.0) { - bail!(validation_error(&format!( - "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value, got a fractional number" - ))); - } - - bail!(validation_error(&format!( - "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value" - ))) -} - -fn required_field<'a>( - payload: &'a serde_json::Map, - field_name: &str, - format_error: impl Fn(&str) -> String, -) -> Result<&'a Value> { - payload.get(field_name).ok_or_else(|| { - anyhow!(format_error(&format!( - "missing required field '{field_name}'" - ))) - }) -} - -fn persist_diff_trace_payload_to_agent_trace_db( - repository_root: &Path, - payload: &DiffTracePayload, - logger: Option<&dyn Logger>, -) -> Result { - let db = match open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for diff-trace persistence.", - ) { - Ok(db) => db, - Err(error) => { - if let Some(log) = logger { - log.error( - "sce.hooks.diff_trace.agent_trace_db_open_failed", - &error.to_string(), - &[], - Some(&payload.session_id), - ); - } - - return Ok(false); - } - }; - - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, payload)?; - Ok(true) -} - -fn persist_diff_trace_payload_to_agent_trace_db_with_db( - db: &RepositoryAgentTraceDb, - payload: &DiffTracePayload, -) -> Result<()> { - let model_id = resolve_diff_trace_model_id(db, payload)?; - db.insert_diff_trace(DiffTraceInsert { - time_ms: diff_trace_db_time_ms(payload.time)?, - session_id: &prefixed_diff_trace_session_id(&payload.tool_name, &payload.session_id), - patch: &payload.diff, - model_id: model_id.as_deref(), - tool_name: &payload.tool_name, - tool_version: payload.tool_version.as_deref(), - payload_type: &payload.payload_type, - }) - .context("Failed to persist diff-trace payload to Agent Trace DB.")?; - - Ok(()) -} - -fn resolve_diff_trace_model_id( - db: &RepositoryAgentTraceDb, - payload: &DiffTracePayload, -) -> Result> { - if payload.model_id.is_some() - || payload.tool_name != CLAUDE_TOOL_NAME - || payload.payload_type != PAYLOAD_TYPE_STRUCTURED - { - return Ok(payload.model_id.clone()); - } - - let session_id = prefixed_diff_trace_session_id(CLAUDE_TOOL_NAME, &payload.session_id); - let agent_id = payload.agent_id.as_deref().unwrap_or(""); - if let Some(state) = db.claude_model_state_by_session_and_agent(&session_id, agent_id)? { - return Ok(Some(state.model_id)); - } - - Ok(seed_diff_trace_model_from_bridge_chain( - db, - payload, - &session_id, - agent_id, - )) -} - -fn seed_diff_trace_model_from_bridge_chain( - db: &RepositoryAgentTraceDb, - payload: &DiffTracePayload, - session_id: &str, - agent_id: &str, -) -> Option { - if !agent_id.is_empty() { - return None; - } - - let transcript_path = payload.transcript_path.as_deref()?; - let model_id = claude_model_state::newest_bridge_chain_model(db, Path::new(transcript_path))?; - - let observed_at_ms = current_unix_time_ms().ok()?; - match db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: session_id.to_string(), - agent_id: String::new(), - model_id: model_id.clone(), - observation_kind: ObservationKind::SessionStart, - source: String::from("bridge_inherited"), - observed_at_ms, - }) { - Ok(_) => Some(model_id), - Err(_) => None, - } -} - -#[cfg(test)] -fn persist_diff_trace_payload_to_agent_trace_db_with( - payload: &DiffTracePayload, - model_id: Option<&str>, - tool_version: Option<&str>, - insert_fn: F, -) -> Result -where - F: FnOnce(DiffTraceInsert<'_>) -> Result, -{ - let time_ms = diff_trace_db_time_ms(payload.time)?; - let session_id = prefixed_diff_trace_session_id(&payload.tool_name, &payload.session_id); - - insert_fn(DiffTraceInsert { - time_ms, - session_id: &session_id, - patch: &payload.diff, - model_id, - tool_name: &payload.tool_name, - tool_version, - payload_type: &payload.payload_type, - }) -} - -fn diff_trace_db_time_ms(time: u64) -> Result { - i64::try_from(time).map_err(|_| { - anyhow!(StdinPayloadKind::DiffTrace.validation_error( - "field 'time' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" - )) - }) -} - -fn run_pre_commit_subcommand_with_trace(repository_root: &Path) -> Result { - run_pre_commit_subcommand(repository_root) -} - -fn run_pre_commit_subcommand(repository_root: &Path) -> Result { - let runtime = resolve_runtime_state(repository_root)?; - - Ok(format!( - "pre-commit hook executed with no-op runtime state: {:?}", - pre_commit_no_op_reason(&runtime) - )) -} - -fn run_commit_msg_subcommand_in_repo( - repository_root: &Path, - message_file: &Path, - logger: Option<&dyn Logger>, -) -> Result { - let metadata = fs::metadata(message_file).with_context(|| { - format!( - "Invalid commit message file '{}': file does not exist or is not readable.", - message_file.display() - ) - })?; - - if !metadata.is_file() { - bail!( - "Invalid commit message file '{}': expected a regular file path.", - message_file.display() - ); - } - - let runtime = resolve_runtime_state(repository_root)?; - let original = fs::read_to_string(message_file).with_context(|| { - format!( - "Invalid commit message file '{}': failed to read UTF-8 content.", - message_file.display() - ) - })?; - - let gate_passed = commit_msg_policy_gate_passed(&runtime); - let ai_contribution_present = if gate_passed { - match staged_diff_has_ai_overlap(repository_root, logger) { - StagedDiffAiOverlapResult::Overlap => true, - StagedDiffAiOverlapResult::NoOverlap | StagedDiffAiOverlapResult::Error => false, - } - } else { - false - }; - let transformed = - apply_commit_msg_coauthor_policy(&runtime, ai_contribution_present, &original); - let trailer_applied = gate_passed && transformed != original; - - if trailer_applied { - fs::write(message_file, transformed.as_bytes()).with_context(|| { - format!( - "Failed to update commit message file '{}' with canonical co-author trailer.", - message_file.display() - ) - })?; - } - - Ok(format!( - "commit-msg hook processed message file '{}' (policy_gate_passed={}, trailer_applied={}).", - message_file.display(), - gate_passed, - trailer_applied - )) -} - -fn run_commit_msg_subcommand_with_trace( - repository_root: &Path, - _: &HookSubcommand, - message_file: &Path, - logger: Option<&dyn Logger>, -) -> Result { - run_commit_msg_subcommand_in_repo(repository_root, message_file, logger) -} - -fn run_post_commit_subcommand( - repository_root: &Path, - vcs_type: Option, - remote_url: &str, - logger: Option<&dyn Logger>, -) -> Result { - run_post_commit_subcommand_with( - repository_root, - vcs_type, - remote_url, - run_post_commit_intersection_flow, - run_post_commit_agent_trace_flow, - |root| { - config::resolve_hook_runtime_config(root).map(|runtime| runtime.agent_trace_auto_sync) - }, - |root| { - auto_sync::launch(root); - Ok(()) - }, - run_post_commit_passive_checkpoint, - logger, - ) -} - -#[allow(clippy::too_many_arguments)] -fn run_post_commit_subcommand_with( - repository_root: &Path, - vcs_type: Option, - remote_url: &str, - run_intersection_flow: F, - run_agent_trace_flow: B, - resolve_auto_sync: C, - launch_auto_sync: L, - run_passive_checkpoint: K, - logger: Option<&dyn Logger>, -) -> Result -where - F: FnOnce(&Path) -> Result, - B: FnOnce( - &Path, - &PostCommitIntersectionFlowResult, - Option, - &str, - ) -> Result, - C: FnOnce(&Path) -> Result, - L: FnOnce(&Path) -> Result<()>, - K: FnOnce(&Path) -> Result<()>, -{ - let result = run_intersection_flow(repository_root)?; - let _agent_trace = run_agent_trace_flow(repository_root, &result, vcs_type, remote_url)?; - - if let Err(error) = run_passive_checkpoint(repository_root) { - if let Some(log) = logger { - log.warn( - "sce.agent_trace_db.passive_checkpoint_failed", - &error.to_string(), - &[], - None, - ); - } - } - - if resolve_auto_sync(repository_root)? { - let _ = launch_auto_sync(repository_root); - } - - Ok(format!( - "post-commit hook processed intersection: commit={}, intersection_files={}", - result.post_commit_data.commit_oid, - result.combined_recent_patch.files.len() - )) -} - -fn run_post_commit_passive_checkpoint(repository_root: &Path) -> Result<()> { - let db = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for post-commit checkpoint.", - )?; - - db.passive_checkpoint() -} - -fn run_post_commit_agent_trace_flow( - repository_root: &Path, - flow_result: &PostCommitIntersectionFlowResult, - vcs_type: Option, - remote_url: &str, -) -> Result { - let db = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for post-commit trace.", - )?; - - let direct_intersection = intersect_patches_fn( - &flow_result.combined_recent_patch, - &flow_result.post_commit_data.parsed_patch, - ); - let mutation_ai_patch = - crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch( - repository_root, - &db, - &direct_intersection, - &flow_result.post_commit_data.parsed_patch, - ); - - run_post_commit_agent_trace_flow_with( - flow_result, - vcs_type, - remote_url, - &mutation_ai_patch, - |trace_value| { - validate_agent_trace_value(trace_value) - .map_err(|error| anyhow!(error.to_string())) - .context("Failed to verify built post-commit Agent Trace payload.")?; - - Ok(()) - }, - |insert_input| { - db.insert_agent_trace(insert_input) - .context("Failed to persist built post-commit Agent Trace payload.")?; - - Ok(()) - }, - ) -} - -fn run_post_commit_agent_trace_flow_with( - flow_result: &PostCommitIntersectionFlowResult, - vcs_type: Option, - remote_url: &str, - mutation_ai_patch: &ParsedPatch, - validate_agent_trace: V, - persist_agent_trace: I, -) -> Result -where - V: FnOnce(&Value) -> Result<()>, - I: for<'a> FnOnce(AgentTraceInsert<'a>) -> Result<()>, -{ - let commit_timestamp = - DateTime::::from_timestamp_millis(flow_result.post_commit_data.commit_time_ms) - .ok_or_else(|| { - anyhow!( - "Invalid post-commit timestamp '{}': expected a valid Unix epoch millisecond value.", - flow_result.post_commit_data.commit_time_ms - ) - })? - .to_rfc3339(); - - let agent_trace = build_agent_trace_from_evidence( - AgentTraceEvidence { - direct_patch: &flow_result.combined_recent_patch, - mutation_ai_patch, - }, - &flow_result.post_commit_data.parsed_patch, - AgentTraceMetadataInput { - commit_timestamp: &commit_timestamp, - commit_revision: &flow_result.post_commit_data.commit_oid, - vcs_type, - tool_name: flow_result.tool_name.as_deref(), - tool_version: flow_result.tool_version.as_deref(), - }, - ) - .context("Failed to build Agent Trace payload from post-commit intersection flow result.")?; - - let agent_trace_value = serde_json::to_value(&agent_trace) - .context("Failed to serialize post-commit Agent Trace payload for validation.")?; - validate_agent_trace(&agent_trace_value) - .context("Failed to validate built post-commit Agent Trace payload.")?; - - let serialized = format!( - "{}\n", - serde_json::to_string_pretty(&agent_trace) - .context("Failed to serialize post-commit Agent Trace payload for persistence.")? - ); - - let constructed_url = agent_trace_persisted_url(&agent_trace.id); - - let insert_input = AgentTraceInsert { - commit_id: &flow_result.post_commit_data.commit_oid, - commit_time_ms: flow_result.post_commit_data.commit_time_ms, - trace_json: &serialized, - agent_trace_id: &agent_trace.id, - url: &constructed_url, - remote_url, - }; - persist_agent_trace(insert_input)?; - - Ok(agent_trace) -} - -const RECENT_DAYS_MILLIS: i64 = 7 * 24 * 60 * 60 * 1000; - -fn run_post_commit_intersection_flow( - repository_root: &Path, -) -> Result { - let db = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for post-commit intersection.", - )?; - - run_post_commit_intersection_flow_with( - repository_root, - capture_post_commit_patch_from_git, - current_unix_time_ms, - |cutoff_ms, end_ms| { - db.recent_diff_trace_patches(cutoff_ms, end_ms) - .context("Failed to query recent diff trace patches.") - }, - |insert_input| { - db.insert_post_commit_patch_intersection(insert_input) - .context("Failed to persist post-commit patch intersection.")?; - - Ok(()) - }, - ) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum StagedDiffAiOverlapResult { - Overlap, - NoOverlap, - Error, -} - -fn staged_diff_has_ai_overlap( - repository_root: &Path, - logger: Option<&dyn Logger>, -) -> StagedDiffAiOverlapResult { - let db_open_result = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for staged AI-overlap evidence check.", - ); - - let db = match db_open_result { - Ok(db) => db, - Err(error) => { - if let Some(log) = logger { - log.error( - "sce.hooks.commit_msg.ai_overlap_error", - &format!("Staged AI-overlap evidence check failed: {error}."), - &[], - None, - ); - } - return StagedDiffAiOverlapResult::Error; - } - }; - - let result = staged_diff_has_ai_overlap_with( - repository_root, - capture_staged_patch_from_git, - current_unix_time_ms, - |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), - ); - - if result == StagedDiffAiOverlapResult::Error { - if let Some(log) = logger { - log.error( - "sce.hooks.commit_msg.ai_overlap_error", - "Staged AI-overlap evidence check failed: error during staged-diff or trace query.", - &[], - None, - ); - } - } - - result -} - -fn staged_diff_has_ai_overlap_with( - repository_root: &Path, - capture_staged_patch: C, - now_ms: N, - query_recent_patches: Q, -) -> StagedDiffAiOverlapResult -where - C: FnOnce(&Path) -> Result, - N: FnOnce() -> Result, - Q: FnOnce(i64, i64) -> Result, -{ - let Ok(staged_patch) = capture_staged_patch(repository_root) else { - return StagedDiffAiOverlapResult::Error; - }; - - if !patch_has_touched_lines(&staged_patch) { - return StagedDiffAiOverlapResult::NoOverlap; - } - - let Ok(now_ms) = now_ms() else { - return StagedDiffAiOverlapResult::Error; - }; - let cutoff_ms = now_ms - RECENT_DAYS_MILLIS; - - let Ok(recent_patches) = query_recent_patches(cutoff_ms, now_ms) else { - return StagedDiffAiOverlapResult::Error; - }; - - let has_overlap = recent_patches.patches.into_iter().any(|recent_patch| { - let combined_recent_patch = combine_patches_fn(&[recent_patch.patch]); - patches_have_overlap(&combined_recent_patch, &staged_patch) - }); - - if has_overlap { - StagedDiffAiOverlapResult::Overlap - } else { - StagedDiffAiOverlapResult::NoOverlap - } -} - -fn capture_staged_patch_from_git(repository_root: &Path) -> Result { - let patch_text = capture_staged_diff_from_git(repository_root)?; - - if patch_text.trim().is_empty() { - return Ok(ParsedPatch { files: Vec::new() }); - } - - parse_patch_from_text(&patch_text, None).map_err(|error| { - anyhow!(staged_patch_error( - "failed to parse staged patch", - &error.to_string() - )) - }) -} - -fn capture_staged_diff_from_git(repository_root: &Path) -> Result { - run_git_command_capture_stdout( - repository_root, - &["diff", "--cached", "--patch", "--no-ext-diff"], - "Failed to capture staged patch from git.", - ) -} - -fn staged_patch_error(detail: &str, context: &str) -> String { - format!("Staged patch capture error: {detail} ({context}).") -} - -fn run_post_commit_intersection_flow_with( - repository_root: &Path, - capture_post_commit_patch: C, - now_ms: N, - query_recent_patches: Q, - persist_intersection: P, -) -> Result -where - C: FnOnce(&Path) -> Result, - N: FnOnce() -> Result, - Q: FnOnce(i64, i64) -> Result, - P: for<'a> FnOnce(PostCommitPatchIntersectionInsert<'a>) -> Result<()>, -{ - let post_commit_data = capture_post_commit_patch(repository_root)?; - - let now_ms = now_ms()?; - let cutoff_ms = now_ms - RECENT_DAYS_MILLIS; - - let recent_patches = query_recent_patches(cutoff_ms, now_ms)?; - - #[allow(clippy::cast_possible_wrap)] - let loaded_count = recent_patches.loaded_count() as i64; - #[allow(clippy::cast_possible_wrap)] - let skipped_count = recent_patches.skipped_count() as i64; - - let last_patch = recent_patches.patches.last(); - let tool_name = last_patch.and_then(|patch| patch.tool_name.clone()); - let tool_version = last_patch.and_then(|patch| patch.tool_version.clone()); - - let recent_patches_slice: Vec = recent_patches - .patches - .into_iter() - .map(|p| p.patch) - .collect(); - - let combined_recent_patch = combine_patches_fn(&recent_patches_slice); - - let intersection_patch = - intersect_patches_fn(&combined_recent_patch, &post_commit_data.parsed_patch); - - let serialized_intersection = serialize_to_json(&intersection_patch) - .context("Failed to serialize intersection patch.")?; - - let insert_input = PostCommitPatchIntersectionInsert { - commit_id: &post_commit_data.commit_oid, - post_commit_time_ms: post_commit_data.commit_time_ms, - recent_window_cutoff_ms: cutoff_ms, - recent_window_end_ms: now_ms, - loaded_diff_trace_count: loaded_count, - skipped_diff_trace_count: skipped_count, - intersection_patch: &serialized_intersection, - }; - - persist_intersection(insert_input)?; - - Ok(PostCommitIntersectionFlowResult { - combined_recent_patch, - post_commit_data, - tool_name, - tool_version, - }) -} - -fn current_unix_time_ms() -> Result { - i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()) - .context("Current time exceeds i64 range for post-commit intersection.") -} - -fn run_post_commit_subcommand_with_trace( - repository_root: &Path, - vcs_type: Option, - remote_url: Option<&str>, - logger: Option<&dyn Logger>, -) -> Result { - run_post_commit_subcommand( - repository_root, - vcs_type, - remote_url.unwrap_or_default(), - logger, - ) -} - -fn run_post_rewrite_subcommand(repository_root: &Path, rewrite_method: &str) -> Result { - let runtime = resolve_runtime_state(repository_root)?; - - Ok(format!( - "post-rewrite hook executed with no-op runtime state: {:?} (rewrite_method='{}')", - post_rewrite_no_op_reason(&runtime), - rewrite_method.trim() - )) -} - -fn run_post_rewrite_subcommand_with_trace( - repository_root: &Path, - _: &HookSubcommand, - rewrite_method: &str, -) -> Result { - let stdin_payload = read_hook_stdin(); - stdin_payload.and_then(|_| run_post_rewrite_subcommand(repository_root, rewrite_method)) -} - -fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { - match subcommand { - HookSubcommand::PreCommit => "pre-commit runtime invocation", - HookSubcommand::CommitMsg { .. } => "commit-msg runtime invocation", - HookSubcommand::PostCommit { .. } => "post-commit runtime invocation", - HookSubcommand::PostRewrite { .. } => "post-rewrite runtime invocation", - HookSubcommand::DiffTrace => "diff-trace runtime invocation", - HookSubcommand::ConversationTrace => "conversation-trace runtime invocation", - HookSubcommand::Codex => "codex runtime invocation", - HookSubcommand::ClaudeModelState => "Claude model-state runtime invocation", - HookSubcommand::MutationScope => "mutation-scope runtime invocation", - HookSubcommand::ClaudeMutationScope => "Claude mutation-scope runtime invocation", - HookSubcommand::CodexMutationScope => "Codex mutation-scope runtime invocation", - HookSubcommand::OpenCodeMutationScope => "OpenCode mutation-scope runtime invocation", - HookSubcommand::PiMutationScope => "Pi mutation-scope runtime invocation", - HookSubcommand::ExternalMutationGuard => "external-mutation-guard runtime invocation", - } -} - -fn read_hook_stdin() -> Result { - let mut stdin_payload = String::new(); - io::stdin() - .read_to_string(&mut stdin_payload) - .context("Failed to read hook input from STDIN.")?; - Ok(stdin_payload) -} - -fn run_git_command_capture_stdout( - repository_root: &Path, - args: &[&str], - context_message: &str, -) -> Result { - let output = Command::new("git") - .args(args) - .current_dir(repository_root) - .output() - .with_context(|| { - format!( - "{} (directory: '{}')", - context_message, - repository_root.display() - ) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let diagnostic = if stderr.is_empty() { - String::from("git command exited with a non-zero status") - } else { - stderr - }; - bail!("{context_message} {diagnostic}"); - } - - String::from_utf8(output.stdout).context("git command output contained invalid UTF-8") -} - -fn resolve_runtime_state(repository_root: &Path) -> Result { - Ok(HookRuntimeState { - sce_disabled: env_flag_is_truthy("SCE_DISABLED"), - attribution_hooks_enabled: config::resolve_hook_runtime_config(repository_root)? - .attribution_hooks_enabled, - }) -} - -fn env_flag_is_truthy(name: &str) -> bool { - std::env::var(name) - .ok() - .is_some_and(|value| env_value_is_truthy(&value)) -} - -fn env_value_is_truthy(value: &str) -> bool { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) -} - -fn commit_msg_policy_gate_passed(runtime: &HookRuntimeState) -> bool { - !runtime.sce_disabled && runtime.attribution_hooks_enabled -} - -fn pre_commit_no_op_reason(runtime: &HookRuntimeState) -> HookNoOpReason { - if runtime.sce_disabled { - HookNoOpReason::Disabled - } else { - HookNoOpReason::AttributionOnlyCommitMsgMode - } -} - -fn post_rewrite_no_op_reason(runtime: &HookRuntimeState) -> HookNoOpReason { - if runtime.sce_disabled { - HookNoOpReason::Disabled - } else { - HookNoOpReason::AttributionOnlyCommitMsgMode - } -} - -pub fn apply_commit_msg_coauthor_policy( - runtime: &HookRuntimeState, - ai_contribution_present: bool, - commit_message: &str, -) -> String { - if !commit_msg_policy_gate_passed(runtime) || !ai_contribution_present { - return commit_message.to_string(); - } - - let mut lines: Vec<&str> = commit_message.lines().collect(); - lines.retain(|line| *line != CANONICAL_SCE_COAUTHOR_TRAILER); - - if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) { - lines.push(""); - } - lines.push(CANONICAL_SCE_COAUTHOR_TRAILER); - - let mut normalized = lines.join("\n"); - if commit_message.ends_with('\n') { - normalized.push('\n'); - } - - normalized -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct HookRuntimeState { - pub sce_disabled: bool, - pub attribution_hooks_enabled: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum HookNoOpReason { - Disabled, - AttributionOnlyCommitMsgMode, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PostCommitPatchData { - pub commit_oid: String, - pub commit_time_ms: i64, - pub parsed_patch: ParsedPatch, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PostCommitIntersectionFlowResult { - pub combined_recent_patch: ParsedPatch, - pub post_commit_data: PostCommitPatchData, - pub tool_name: Option, - pub tool_version: Option, -} - -pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result { - let commit_oid = capture_head_oid_from_git(repository_root)?; - let commit_time_ms = capture_head_timestamp_from_git(repository_root)?; - let patch_text = capture_head_patch_from_git(repository_root)?; - let parsed_patch = parse_patch_from_text(&patch_text, None).map_err(|e| { - anyhow!(post_commit_patch_error( - "failed to parse post-commit patch", - &e.to_string() - )) - })?; - - Ok(PostCommitPatchData { - commit_oid, - commit_time_ms, - parsed_patch, - }) -} - -fn capture_head_oid_from_git(repository_root: &Path) -> Result { - let output = run_git_command_capture_stdout( - repository_root, - &["rev-parse", "HEAD"], - "Failed to capture HEAD commit OID from git.", - )?; - Ok(output.trim().to_string()) -} - -fn capture_head_timestamp_from_git(repository_root: &Path) -> Result { - let output = run_git_command_capture_stdout( - repository_root, - &["show", "--format=%ct", "--no-patch", "HEAD"], - "Failed to capture HEAD commit timestamp from git.", - )?; - let timestamp_str = output.trim(); - let timestamp_seconds: i64 = timestamp_str.parse().map_err(|_| { - anyhow!(post_commit_patch_error( - "failed to parse HEAD timestamp", - timestamp_str, - )) - })?; - let timestamp_ms = timestamp_seconds.checked_mul(1000).ok_or_else(|| { - anyhow!(post_commit_patch_error( - "failed to parse HEAD timestamp", - timestamp_str, - )) - })?; - Ok(timestamp_ms) -} - -fn capture_head_patch_from_git(repository_root: &Path) -> Result { - run_git_command_capture_stdout( - repository_root, - &["show", "--format=", "--patch", "--no-ext-diff", "HEAD"], - "Failed to capture HEAD patch from git.", - ) -} - -fn post_commit_patch_error(detail: &str, context: &str) -> String { - format!("Post-commit patch capture error: {detail} ({context}).") -} - -fn transform_claude_user_prompt_submit( - payload: &serde_json::Map, -) -> Result> { - transform_claude_user_prompt_submit_with( - payload, - || { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); - uuid::Uuid::new_v7(ts) - }, - || current_unix_time_ms().unwrap_or(0), - ) -} - -fn transform_claude_user_prompt_submit_with( - payload: &serde_json::Map, - generate_message_id: G, - generate_timestamp_ms: T, -) -> Result> -where - G: FnOnce() -> uuid::Uuid, - T: FnOnce() -> i64, -{ - let event_name = required_non_empty_string_field( - payload, - "hook_event_name", - conversation_trace_validation_error, - )?; - - if event_name != "UserPromptSubmit" { - let raw_content = serde_json::to_string(payload).unwrap_or_default(); - bail!(conversation_trace_validation_error(&format!( - "unsupported Claude hook event '{event_name}': only 'UserPromptSubmit' is supported. Raw event: {raw_content}" - ))); - } - - let session_id = required_non_empty_string_field( - payload, - "session_id", - conversation_trace_validation_error, - )?; - let prompt = - required_non_empty_string_field(payload, "prompt", conversation_trace_validation_error)?; - - let message_id = generate_message_id().to_string(); - let generated_at_unix_ms = generate_timestamp_ms(); - - Ok(vec![ - json!({ - "type": CONVERSATION_TRACE_MESSAGE_UPDATED, - "session_id": session_id, - "message_id": message_id, - "role": "user", - "generated_at_unix_ms": generated_at_unix_ms, - }), - json!({ - "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, - "session_id": session_id, - "message_id": message_id, - "part_type": "text", - "text": prompt, - "generated_at_unix_ms": generated_at_unix_ms, - }), - ]) -} - -fn transform_claude_stop(payload: &serde_json::Map) -> Result> { - transform_claude_stop_with( - payload, - || { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); - uuid::Uuid::new_v7(ts) - }, - || current_unix_time_ms().unwrap_or(0), - ) -} - -fn transform_claude_stop_with( - payload: &serde_json::Map, - generate_message_id: G, - generate_timestamp_ms: T, -) -> Result> -where - G: FnOnce() -> uuid::Uuid, - T: FnOnce() -> i64, -{ - let event_name = required_non_empty_string_field( - payload, - "hook_event_name", - conversation_trace_validation_error, - )?; - - if event_name != "Stop" { - let raw_content = serde_json::to_string(payload).unwrap_or_default(); - bail!(conversation_trace_validation_error(&format!( - "unsupported Claude hook event '{event_name}': only 'Stop' is supported. Raw event: {raw_content}" - ))); - } - - let session_id = required_non_empty_string_field( - payload, - "session_id", - conversation_trace_validation_error, - )?; - let last_assistant_message = required_non_empty_string_field( - payload, - "last_assistant_message", - conversation_trace_validation_error, - )?; - - let message_id = generate_message_id().to_string(); - let generated_at_unix_ms = generate_timestamp_ms(); - - Ok(vec![ - json!({ - "type": CONVERSATION_TRACE_MESSAGE_UPDATED, - "session_id": session_id, - "message_id": message_id, - "role": "assistant", - "generated_at_unix_ms": generated_at_unix_ms, - }), - json!({ - "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, - "session_id": session_id, - "message_id": message_id, - "part_type": "text", - "text": last_assistant_message, - "generated_at_unix_ms": generated_at_unix_ms, - }), - ]) -} -fn transform_claude_post_tool_use(payload: &serde_json::Map) -> Result> { - transform_claude_post_tool_use_with( - payload, - || { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); - uuid::Uuid::new_v7(ts) - }, - || current_unix_time_ms().unwrap_or(0), - ) -} - -fn transform_claude_post_tool_use_with( - payload: &serde_json::Map, - generate_message_id: G, - generate_timestamp_ms: T, -) -> Result> -where - G: FnOnce() -> uuid::Uuid, - T: FnOnce() -> i64, -{ - let event_name = required_non_empty_string_field( - payload, - "hook_event_name", - conversation_trace_validation_error, - )?; - - if event_name != "PostToolUse" { - let raw_content = serde_json::to_string(payload).unwrap_or_default(); - bail!(conversation_trace_validation_error(&format!( - "unsupported Claude hook event '{event_name}': only 'PostToolUse' is supported. Raw event: {raw_content}" - ))); - } - - let tool_name = payload - .get("tool_name") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if tool_name != "Write" && tool_name != "Edit" { - return Ok(vec![]); - } - - let session_id = required_non_empty_string_field( - payload, - "session_id", - conversation_trace_validation_error, - )?; - - let message_id = generate_message_id().to_string(); - let generated_at_unix_ms = generate_timestamp_ms(); - - match build_claude_post_tool_use_patch(payload) { - PatchBuildResult::Built(parsed_patch) => { - let text = serde_json::to_string(&parsed_patch)?; - let items = vec![ - json!({ - "type": CONVERSATION_TRACE_MESSAGE_UPDATED, - "session_id": session_id, - "message_id": message_id, - "role": "assistant", - "generated_at_unix_ms": generated_at_unix_ms, - }), - json!({ - "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, - "session_id": session_id, - "message_id": message_id, - "part_type": "patch", - "text": text, - "generated_at_unix_ms": generated_at_unix_ms, - }), - ]; - Ok(items) - } - PatchBuildResult::Skipped(_) => Ok(vec![]), - } -} - -#[cfg(test)] -mod tests; diff --git a/cli/src/services/hooks/opencode_mutation_scope/health.rs b/cli/src/services/hooks/opencode_mutation_scope/health.rs index f861646b..baeddac8 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/health.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/health.rs @@ -382,12 +382,6 @@ mod tests { .find(|a| a.call_id == "call-a") .expect("A is tracked"); - // Simulate a crash between begin_terminal_cleanup arming Flushing and - // resolve_recovery ever running: this is the only way to observe a - // literal orphaned `Flushing` at rest, since every in-process caller of - // begin_terminal_cleanup always calls resolve_recovery immediately - // afterward under the same boundary lock. A real process crash at this - // exact point is a legitimately persisted, production-reachable shape. state::begin_terminal_cleanup(&git_dir, std::slice::from_ref(&doomed.scope_id)) .expect("seeding an orphaned flush should succeed"); let seeded = state::read_state(&git_dir).expect("state readable"); diff --git a/cli/src/services/hooks/pi_mutation_scope/state.rs b/cli/src/services/hooks/pi_mutation_scope/state.rs index 3c9ca81d..719d1cf4 100644 --- a/cli/src/services/hooks/pi_mutation_scope/state.rs +++ b/cli/src/services/hooks/pi_mutation_scope/state.rs @@ -314,8 +314,6 @@ pub(crate) fn admit_tracked_attempt( })) } -/// Read-only D10 scan: `scope_id`s of live (`PendingStart`/`Executed`) attempts whose own -/// recorded owner is positively dead. Never includes `PendingAbandon`. pub(crate) fn find_definitely_dead_attempts(git_dir: &Path) -> Result> { let _lock = acquire_lock(git_dir)?; let state = read_state(git_dir)?; diff --git a/cli/src/services/hooks/runtime.rs b/cli/src/services/hooks/runtime.rs new file mode 100644 index 00000000..6fcd8d8e --- /dev/null +++ b/cli/src/services/hooks/runtime.rs @@ -0,0 +1,183 @@ +use std::io::{self, Read}; +use std::path::Path; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +#[cfg(test)] +use crate::services::agent_trace_storage::resolve_agent_trace_storage_for_hook_runtime_at_state_root; +use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_for_hook_runtime, AgentTraceStorageContext, +}; +use crate::services::config; +use anyhow::{bail, Context, Result}; + +pub(crate) const CLAUDE_MODEL_ID_PREFIX: &str = "claude/"; +pub(crate) const DIFF_TRACE_OPENCODE_SESSION_ID_PREFIX: &str = "oc_"; +pub(crate) const DIFF_TRACE_CLAUDE_SESSION_ID_PREFIX: &str = "cc_"; +pub(crate) const DIFF_TRACE_PI_SESSION_ID_PREFIX: &str = "pi_"; +pub(crate) const DIFF_TRACE_CODEX_SESSION_ID_PREFIX: &str = "cx_"; +pub(crate) const OPENCODE_TOOL_NAME: &str = "opencode"; +pub(crate) const CLAUDE_TOOL_NAME: &str = "claude"; +pub(crate) const PI_TOOL_NAME: &str = "pi"; +pub(crate) const CODEX_TOOL_NAME: &str = "codex"; +pub(crate) const NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES: &[&str] = + &[OPENCODE_TOOL_NAME, PI_TOOL_NAME]; +pub(crate) type PayloadValidationError = fn(&str) -> String; + +pub(crate) fn prefixed_diff_trace_session_id(tool_name: &str, raw_session_id: &str) -> String { + prefixed_session_id(tool_name, raw_session_id) +} + +pub(crate) fn prefixed_conversation_trace_session_id( + tool_name: &str, + raw_session_id: &str, +) -> String { + prefixed_session_id(tool_name, raw_session_id) +} + +pub(crate) fn prefixed_session_id(tool_name: &str, raw_session_id: &str) -> String { + let prefix = match tool_name { + OPENCODE_TOOL_NAME => DIFF_TRACE_OPENCODE_SESSION_ID_PREFIX, + CLAUDE_TOOL_NAME => DIFF_TRACE_CLAUDE_SESSION_ID_PREFIX, + PI_TOOL_NAME => DIFF_TRACE_PI_SESSION_ID_PREFIX, + CODEX_TOOL_NAME => DIFF_TRACE_CODEX_SESSION_ID_PREFIX, + _ => return raw_session_id.to_string(), + }; + + if raw_session_id.starts_with(prefix) { + raw_session_id.to_string() + } else { + format!("{prefix}{raw_session_id}") + } +} +pub(crate) fn open_agent_trace_db_for_hook_runtime( + repository_root: &Path, + context_message: &'static str, +) -> Result { + let storage_config = config::resolve_agent_trace_storage_runtime_config(repository_root) + .context("Failed to resolve Agent Trace repository storage config.")?; + let storage_context = AgentTraceStorageContext { + repository_root, + explicit_repository_id: storage_config.repository_id.as_deref(), + repository_remote: &storage_config.repository_remote, + }; + + resolve_agent_trace_storage_for_hook_runtime(&storage_context) + .map(|storage| storage.db) + .context(context_message) +} + +#[cfg(test)] +pub(crate) fn open_agent_trace_db_for_hook_runtime_at_state_root( + repository_root: &Path, + state_root: &Path, + context_message: &'static str, +) -> Result { + let storage_config = config::resolve_agent_trace_storage_runtime_config(repository_root) + .context("Failed to resolve Agent Trace repository storage config.")?; + let storage_context = AgentTraceStorageContext { + repository_root, + explicit_repository_id: storage_config.repository_id.as_deref(), + repository_remote: &storage_config.repository_remote, + }; + + resolve_agent_trace_storage_for_hook_runtime_at_state_root(&storage_context, state_root) + .map(|storage| storage.db) + .context(context_message) +} + +pub(crate) fn current_unix_time_ms() -> Result { + i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()) + .context("Current time exceeds i64 range for post-commit intersection.") +} +pub(crate) fn read_hook_stdin() -> Result { + let mut stdin_payload = String::new(); + io::stdin() + .read_to_string(&mut stdin_payload) + .context("Failed to read hook input from STDIN.")?; + Ok(stdin_payload) +} + +pub(crate) fn run_git_command_capture_stdout( + repository_root: &Path, + args: &[&str], + context_message: &str, +) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(repository_root) + .output() + .with_context(|| { + format!( + "{} (directory: '{}')", + context_message, + repository_root.display() + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let diagnostic = if stderr.is_empty() { + String::from("git command exited with a non-zero status") + } else { + stderr + }; + bail!("{context_message} {diagnostic}"); + } + + String::from_utf8(output.stdout).context("git command output contained invalid UTF-8") +} + +pub(crate) fn resolve_runtime_state(repository_root: &Path) -> Result { + Ok(HookRuntimeState { + sce_disabled: env_flag_is_truthy("SCE_DISABLED"), + attribution_hooks_enabled: config::resolve_hook_runtime_config(repository_root)? + .attribution_hooks_enabled, + }) +} + +pub(crate) fn env_flag_is_truthy(name: &str) -> bool { + std::env::var(name) + .ok() + .is_some_and(|value| env_value_is_truthy(&value)) +} + +pub(crate) fn env_value_is_truthy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +pub(crate) fn commit_msg_policy_gate_passed(runtime: &HookRuntimeState) -> bool { + !runtime.sce_disabled && runtime.attribution_hooks_enabled +} + +pub(crate) fn pre_commit_no_op_reason(runtime: &HookRuntimeState) -> HookNoOpReason { + if runtime.sce_disabled { + HookNoOpReason::Disabled + } else { + HookNoOpReason::AttributionOnlyCommitMsgMode + } +} + +pub(crate) fn post_rewrite_no_op_reason(runtime: &HookRuntimeState) -> HookNoOpReason { + if runtime.sce_disabled { + HookNoOpReason::Disabled + } else { + HookNoOpReason::AttributionOnlyCommitMsgMode + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct HookRuntimeState { + pub sce_disabled: bool, + pub attribution_hooks_enabled: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HookNoOpReason { + Disabled, + AttributionOnlyCommitMsgMode, +} From e6837bd0168220f59ab4cdc62b7c2976597a3ab0 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 01:11:54 +0200 Subject: [PATCH 05/16] hooks: Refactor mutation scope adapters into focused modules Separate event parsing, lifecycle orchestration, and payload rendering for the Claude, Codex, and Pi adapters. This keeps adapter entry points small while preserving their existing behavior and makes each concern easier to test and evolve. Co-authored-by: SCE --- .../hooks/claude_mutation_scope/events.rs | 316 +++++++ .../hooks/claude_mutation_scope/lifecycle.rs | 465 +++++++++ .../hooks/claude_mutation_scope/mod.rs | 860 +---------------- .../hooks/claude_mutation_scope/payload.rs | 55 ++ .../hooks/codex_mutation_scope/events.rs | 277 ++++++ .../hooks/codex_mutation_scope/lifecycle.rs | 542 +++++++++++ .../hooks/codex_mutation_scope/mod.rs | 889 +----------------- .../hooks/codex_mutation_scope/payload.rs | 55 ++ .../hooks/pi_mutation_scope/events.rs | 211 +++++ .../hooks/pi_mutation_scope/lifecycle.rs | 460 +++++++++ .../services/hooks/pi_mutation_scope/mod.rs | 718 +------------- .../hooks/pi_mutation_scope/payload.rs | 44 + 12 files changed, 2559 insertions(+), 2333 deletions(-) create mode 100644 cli/src/services/hooks/claude_mutation_scope/events.rs create mode 100644 cli/src/services/hooks/claude_mutation_scope/lifecycle.rs create mode 100644 cli/src/services/hooks/claude_mutation_scope/payload.rs create mode 100644 cli/src/services/hooks/codex_mutation_scope/events.rs create mode 100644 cli/src/services/hooks/codex_mutation_scope/lifecycle.rs create mode 100644 cli/src/services/hooks/codex_mutation_scope/payload.rs create mode 100644 cli/src/services/hooks/pi_mutation_scope/events.rs create mode 100644 cli/src/services/hooks/pi_mutation_scope/lifecycle.rs create mode 100644 cli/src/services/hooks/pi_mutation_scope/payload.rs diff --git a/cli/src/services/hooks/claude_mutation_scope/events.rs b/cli/src/services/hooks/claude_mutation_scope/events.rs new file mode 100644 index 00000000..226b8cd2 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/events.rs @@ -0,0 +1,316 @@ +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{Map, Value}; + +pub(super) const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; +pub(super) const SESSION_ID_FIELD: &str = "session_id"; +pub(super) const CWD_FIELD: &str = "cwd"; +pub(super) const AGENT_ID_FIELD: &str = "agent_id"; +pub(super) const TOOL_NAME_FIELD: &str = "tool_name"; +pub(super) const TOOL_USE_ID_FIELD: &str = "tool_use_id"; +pub(super) const TOOL_INPUT_FIELD: &str = "tool_input"; +pub(super) const RUN_IN_BACKGROUND_FIELD: &str = "run_in_background"; +pub(super) const PROMPT_ID_FIELD: &str = "prompt_id"; +pub(super) const AGENT_TYPE_FIELD: &str = "agent_type"; +pub(super) const WORKTREE_PATH_FIELD: &str = "worktree_path"; + +pub(super) const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; +pub(super) const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; +pub(super) const HOOK_EVENT_POST_TOOL_USE_FAILURE: &str = "PostToolUseFailure"; +pub(super) const HOOK_EVENT_PERMISSION_DENIED: &str = "PermissionDenied"; +pub(super) const HOOK_EVENT_STOP: &str = "Stop"; +pub(super) const HOOK_EVENT_STOP_FAILURE: &str = "StopFailure"; +pub(super) const HOOK_EVENT_USER_PROMPT_SUBMIT: &str = "UserPromptSubmit"; +pub(super) const HOOK_EVENT_SUBAGENT_STOP: &str = "SubagentStop"; +pub(super) const HOOK_EVENT_SESSION_END: &str = "SessionEnd"; +pub(super) const HOOK_EVENT_WORKTREE_REMOVE: &str = "WorktreeRemove"; +pub(super) const HOOK_EVENT_SESSION_START: &str = "SessionStart"; +pub(super) const HOOK_EVENT_SUBAGENT_START: &str = "SubagentStart"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ClaudeHookEvent { + PreToolUse(ClaudeToolExecution), + PostToolUse(ClaudeToolIdentity), + PostToolUseFailure(ClaudeToolIdentity), + PermissionDenied(ClaudeToolIdentity), + Stop(ClaudeSessionIdentity), + StopFailure(ClaudeSessionIdentity), + UserPromptSubmit(ClaudeSessionIdentity), + SubagentStop(ClaudeAgentIdentity), + SessionEnd(ClaudeSessionIdentity), + WorktreeRemove(ClaudeWorktreeRemove), + SessionStart, + SubagentStart, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeToolIdentity { + pub session_id: String, + pub cwd: String, + pub agent_id: Option, + pub tool_name: String, + pub tool_use_id: String, +} + +impl ClaudeToolIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + agent_id: self.agent_id.clone(), + tool_use_id: self.tool_use_id.clone(), + } + } + + pub(crate) fn is_subagent(&self) -> bool { + self.agent_id.is_some() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeToolExecution { + pub identity: ClaudeToolIdentity, + pub prompt_id: Option, + pub agent_type: Option, + pub run_in_background: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeSessionIdentity { + pub session_id: String, + pub cwd: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeAgentIdentity { + pub session_id: String, + pub cwd: String, + pub agent_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeWorktreeRemove { + pub session_id: String, + pub worktree_path: String, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct AttemptKey { + pub session_id: String, + pub agent_id: Option, + pub tool_use_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolClassification { + MutationCapable, + ReadOnly, + Delegation, +} + +pub(super) const DELEGATION_TOOL_NAME: &str = "Agent"; +pub(super) const KNOWN_READ_ONLY_TOOL_NAMES: &[&str] = &[ + "Read", + "Glob", + "Grep", + "WebFetch", + "WebSearch", + "AskUserQuestion", +]; + +pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { + if tool_name == DELEGATION_TOOL_NAME { + return ToolClassification::Delegation; + } + if KNOWN_READ_ONLY_TOOL_NAMES.contains(&tool_name) { + return ToolClassification::ReadOnly; + } + ToolClassification::MutationCapable +} + +pub(super) const BASH_TOOL_NAME: &str = "Bash"; +pub(super) const POWERSHELL_TOOL_NAME: &str = "PowerShell"; + +pub(crate) fn is_explicit_background_shell(tool_name: &str, run_in_background: bool) -> bool { + run_in_background && (tool_name == BASH_TOOL_NAME || tool_name == POWERSHELL_TOOL_NAME) +} + +pub(super) const CLAUDE_SCOPE_ID_SCHEME: &str = "cc-tool-v1"; + +pub(crate) fn format_claude_scope_id(attempt_seq: u64, key: &AttemptKey) -> String { + let agent_id = key.agent_id.as_deref().unwrap_or(""); + format!( + "{CLAUDE_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|a={}:{}|t={}:{}", + key.session_id.len(), + key.session_id, + agent_id.len(), + agent_id, + key.tool_use_id.len(), + key.tool_use_id, + ) +} + +pub(crate) fn claude_scope_start_event_id(scope_id: &str) -> String { + format!("{scope_id}|start") +} + +pub(crate) fn claude_scope_close_event_id(scope_id: &str) -> String { + format!("{scope_id}|close") +} + +pub(crate) fn parse_claude_hook_event(stdin_payload: &str) -> Result { + if stdin_payload.trim().is_empty() { + bail!(validation_error( + "expected a JSON object, got an empty payload" + )); + } + + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| validation_error("expected valid JSON"))?; + let object = parsed + .as_object() + .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; + + let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; + + match hook_event_name.as_str() { + HOOK_EVENT_PRE_TOOL_USE => parse_pre_tool_use(object).map(ClaudeHookEvent::PreToolUse), + HOOK_EVENT_POST_TOOL_USE => parse_tool_identity(object).map(ClaudeHookEvent::PostToolUse), + HOOK_EVENT_POST_TOOL_USE_FAILURE => { + parse_tool_identity(object).map(ClaudeHookEvent::PostToolUseFailure) + } + HOOK_EVENT_PERMISSION_DENIED => { + parse_tool_identity(object).map(ClaudeHookEvent::PermissionDenied) + } + HOOK_EVENT_STOP => parse_session_identity(object).map(ClaudeHookEvent::Stop), + HOOK_EVENT_STOP_FAILURE => parse_session_identity(object).map(ClaudeHookEvent::StopFailure), + HOOK_EVENT_USER_PROMPT_SUBMIT => { + parse_session_identity(object).map(ClaudeHookEvent::UserPromptSubmit) + } + HOOK_EVENT_SUBAGENT_STOP => parse_agent_identity(object).map(ClaudeHookEvent::SubagentStop), + HOOK_EVENT_SESSION_END => parse_session_identity(object).map(ClaudeHookEvent::SessionEnd), + HOOK_EVENT_WORKTREE_REMOVE => { + parse_worktree_remove(object).map(ClaudeHookEvent::WorktreeRemove) + } + HOOK_EVENT_SESSION_START => Ok(ClaudeHookEvent::SessionStart), + HOOK_EVENT_SUBAGENT_START => Ok(ClaudeHookEvent::SubagentStart), + other => bail!(validation_error(&format!( + "unsupported hook_event_name '{other}'" + ))), + } +} + +pub(super) fn parse_tool_identity(object: &Map) -> Result { + Ok(ClaudeToolIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + tool_use_id: required_non_blank_str(object, TOOL_USE_ID_FIELD)?, + agent_id: optional_non_blank_str(object, AGENT_ID_FIELD)?, + }) +} + +pub(super) fn parse_pre_tool_use(object: &Map) -> Result { + Ok(ClaudeToolExecution { + identity: parse_tool_identity(object)?, + prompt_id: optional_non_blank_str(object, PROMPT_ID_FIELD)?, + agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + run_in_background: parse_run_in_background(object)?, + }) +} + +pub(super) fn parse_run_in_background(object: &Map) -> Result { + let Some(tool_input) = object.get(TOOL_INPUT_FIELD) else { + return Ok(false); + }; + if tool_input.is_null() { + return Ok(false); + } + let tool_input = tool_input.as_object().ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{TOOL_INPUT_FIELD}' must be a JSON object" + ))) + })?; + + match tool_input.get(RUN_IN_BACKGROUND_FIELD) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(_) => bail!(validation_error(&format!( + "field '{TOOL_INPUT_FIELD}.{RUN_IN_BACKGROUND_FIELD}' must be a boolean" + ))), + } +} + +pub(super) fn parse_session_identity(object: &Map) -> Result { + Ok(ClaudeSessionIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +pub(super) fn parse_agent_identity(object: &Map) -> Result { + Ok(ClaudeAgentIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + agent_id: required_non_blank_str(object, AGENT_ID_FIELD)?, + }) +} + +pub(super) fn parse_worktree_remove(object: &Map) -> Result { + Ok(ClaudeWorktreeRemove { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + worktree_path: required_non_blank_str(object, WORKTREE_PATH_FIELD)?, + }) +} + +pub(super) fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { + object.get(field).ok_or_else(|| { + anyhow!(validation_error(&format!( + "missing required field '{field}'" + ))) + }) +} + +pub(super) fn required_str(object: &Map, field: &str) -> Result { + required_field(object, field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field}' must be a string" + ))) + }) +} + +pub(super) fn required_non_blank_str(object: &Map, field: &str) -> Result { + let value = required_str(object, field)?; + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be a non-blank string" + ))); + } + Ok(value) +} + +pub(super) fn optional_non_blank_str( + object: &Map, + field: &str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => { + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))); + } + Ok(Some(value.clone())) + } + Some(_) => bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))), + } +} + +pub(super) fn validation_error(detail: &str) -> String { + format!("Invalid Claude hook event payload from STDIN: {detail}.") +} diff --git a/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs b/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs new file mode 100644 index 00000000..28d8bb5c --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs @@ -0,0 +1,465 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::services::hooks; +use crate::services::mutation_trace::runtime::resolve_git_dir; +use crate::services::observability::traits::Logger; + +use super::state; +use super::{ + abandon_payload, classify_tool, claude_scope_close_event_id, claude_scope_start_event_id, + flush_payload, is_explicit_background_shell, parse_claude_hook_event, pre_tool_use_deny_json, + scope_boundary_payload, scope_start_payload, AttemptKey, ClaudeHookEvent, ClaudeToolExecution, + ClaudeToolIdentity, ToolClassification, +}; + +pub(super) type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; + +pub(super) type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; + +pub(super) const ACTOR_KIND_CLAUDE_CODE: &str = "claude_code"; + +pub(super) const FAIL_CLOSED_DENY_REASON: &str = + "SCE could not establish mutation attribution for this tool execution."; +pub(super) const EXPLICIT_BACKGROUND_SHELL_DENY_REASON: &str = + "SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground."; + +pub(super) const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = + "sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed"; +pub(super) const MODEL_STATE_UNAVAILABLE_EVENT: &str = + "sce.hooks.claude_mutation_scope.model_state_unavailable"; + +pub(super) type ClaudeModelStateResolver<'a> = + &'a dyn Fn(&Path, &str, &str) -> Result>; + +pub(super) fn log_pre_tool_use_fail_closed( + logger: Option<&dyn Logger>, + context: &str, + error: &anyhow::Error, +) { + if let Some(log) = logger { + log.warn( + PRE_TOOL_USE_FAIL_CLOSED_EVENT, + &error.to_string(), + &[("context", context)], + None, + ); + } +} + +pub(crate) fn run_claude_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { + let stdin_payload = hooks::read_hook_stdin()?; + run_claude_mutation_scope_from_payload(&stdin_payload, logger) +} + +pub(crate) fn run_claude_mutation_scope_from_payload( + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let model_state_resolver = + |repository_root: &Path, session_id: &str, agent_id: &str| -> Result> { + let db = hooks::open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for Claude mutation-scope model resolution.", + )?; + Ok(db + .claude_model_state_by_session_and_agent(session_id, agent_id)? + .map(|state| state.model_id)) + }; + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) + }; + + run_claude_mutation_scope_from_payload_with_resolver( + stdin_payload, + logger, + &resolve_git_dir_fn, + &model_state_resolver, + &seam_fn, + ) +} + +#[cfg(test)] +pub(crate) fn run_claude_mutation_scope_from_payload_at_state_root( + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let model_state_root = state_root.to_path_buf(); + let seam_state_root = state_root.to_path_buf(); + let model_state_resolver = + move |repository_root: &Path, session_id: &str, agent_id: &str| -> Result> { + let db = hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + repository_root, + &model_state_root, + "Failed to open Agent Trace DB for Claude mutation-scope model resolution.", + )?; + Ok(db + .claude_model_state_by_session_and_agent(session_id, agent_id)? + .map(|state| state.model_id)) + }; + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + &seam_state_root, + payload, + logger, + ) + }; + + run_claude_mutation_scope_from_payload_with_resolver( + stdin_payload, + logger, + &resolve_git_dir_fn, + &model_state_resolver, + &seam_fn, + ) +} + +#[cfg(test)] +pub(super) fn run_claude_mutation_scope_from_payload_with( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let unavailable_model_state = |_repository_root: &Path, + _session_id: &str, + _agent_id: &str| + -> Result> { Ok(None) }; + run_claude_mutation_scope_from_payload_with_resolver( + stdin_payload, + logger, + resolve_git_dir, + &unavailable_model_state, + seam, + ) +} + +pub(super) fn run_claude_mutation_scope_from_payload_with_resolver( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, +) -> Result { + let event = parse_claude_hook_event(stdin_payload)?; + dispatch_claude_hook_event(event, logger, resolve_git_dir, model_state_resolver, seam) +} + +pub(super) fn dispatch_claude_hook_event( + event: ClaudeHookEvent, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, +) -> Result { + match event { + ClaudeHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( + &execution, + logger, + resolve_git_dir, + model_state_resolver, + seam, + )), + ClaudeHookEvent::PostToolUse(identity) | ClaudeHookEvent::PostToolUseFailure(identity) => { + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + handle_close( + &git_dir, + repository_root, + &identity.attempt_key(), + logger, + seam, + ) + } + ClaudeHookEvent::PermissionDenied(identity) => { + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + handle_permission_denied( + &git_dir, + repository_root, + &identity.attempt_key(), + logger, + seam, + ) + } + ClaudeHookEvent::Stop(session) | ClaudeHookEvent::StopFailure(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.is_none() + }) + } + ClaudeHookEvent::UserPromptSubmit(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.is_none() + }) + } + ClaudeHookEvent::SubagentStop(agent) => { + let git_dir = resolve_git_dir(&agent.cwd)?; + let repository_root = Path::new(&agent.cwd); + let session_id = agent.session_id.clone(); + let agent_id = agent.agent_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.as_deref() == Some(&agent_id) + }) + } + ClaudeHookEvent::SessionEnd(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + }) + } + ClaudeHookEvent::WorktreeRemove(worktree_remove) => { + let git_dir = resolve_git_dir(&worktree_remove.worktree_path)?; + let repository_root = Path::new(&worktree_remove.worktree_path); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |_attempt| true) + } + ClaudeHookEvent::SessionStart | ClaudeHookEvent::SubagentStart => Ok(String::new()), + } +} + +pub(super) fn handle_pre_tool_use( + execution: &ClaudeToolExecution, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, +) -> String { + let identity = &execution.identity; + + if matches!( + classify_tool(&identity.tool_name), + ToolClassification::ReadOnly | ToolClassification::Delegation + ) { + return String::new(); + } + + if is_explicit_background_shell(&identity.tool_name, execution.run_in_background) { + return pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON); + } + + let repository_root = Path::new(&identity.cwd); + let git_dir = match resolve_git_dir(&identity.cwd) { + Ok(git_dir) => git_dir, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "resolve_git_dir", &error); + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + }; + + if matches!( + apply_recovery_barrier(&git_dir, repository_root, logger, seam), + BarrierOutcome::Deny + ) { + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + + match establish_start( + &git_dir, + repository_root, + identity, + logger, + model_state_resolver, + seam, + ) { + Ok(()) => String::new(), + Err(error) => { + log_pre_tool_use_fail_closed(logger, "establish_start", &error); + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) + } + } +} + +pub(super) enum BarrierOutcome { + Proceed, + Deny, +} + +pub(super) fn apply_recovery_barrier( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> BarrierOutcome { + let state = match state::read_state(git_dir) { + Ok(state) => state, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "recovery_barrier.read_state", &error); + return BarrierOutcome::Deny; + } + }; + + if !state.recovery_pending { + return BarrierOutcome::Proceed; + } + + if !state.attempts.is_empty() { + return BarrierOutcome::Deny; + } + + match seam(repository_root, &flush_payload(), logger) { + Ok(_) => match state::clear_recovery_pending(git_dir) { + Ok(()) => BarrierOutcome::Proceed, + Err(error) => { + log_pre_tool_use_fail_closed( + logger, + "recovery_barrier.clear_recovery_pending", + &error, + ); + BarrierOutcome::Deny + } + }, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "recovery_barrier.flush", &error); + BarrierOutcome::Deny + } + } +} + +pub(super) fn establish_start( + git_dir: &Path, + repository_root: &Path, + identity: &ClaudeToolIdentity, + logger: Option<&dyn Logger>, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, +) -> Result<()> { + let allocated = state::allocate_attempt(git_dir, &identity.attempt_key(), &identity.tool_name)?; + let scope_id = &allocated.attempt.scope_id; + let canonical_session_id = + hooks::prefixed_diff_trace_session_id(hooks::CLAUDE_TOOL_NAME, &identity.session_id); + let agent_id = identity.agent_id.as_deref().unwrap_or(""); + let model_id = match model_state_resolver(repository_root, &canonical_session_id, agent_id) { + Ok(model_id) => model_id.and_then(|model| hooks::normalize_claude_model_id(&model)), + Err(error) => { + if let Some(log) = logger { + log.warn( + MODEL_STATE_UNAVAILABLE_EVENT, + &error.to_string(), + &[("agent_id", agent_id)], + Some(&canonical_session_id), + ); + } + None + } + }; + let start_payload = scope_start_payload( + scope_id, + &claude_scope_start_event_id(scope_id), + &canonical_session_id, + model_id.as_deref(), + ); + + seam(repository_root, &start_payload, logger)?; + state::mark_active(git_dir, scope_id)?; + Ok(()) +} + +pub(super) fn handle_close( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| attempt_matches_key(attempt, key)) + .cloned() + else { + return Ok(String::new()); + }; + + if attempt.phase == state::AttemptPhase::PendingStart { + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + return Ok(String::new()); + } + + let close_payload = scope_boundary_payload( + "close", + &attempt.scope_id, + &claude_scope_close_event_id(&attempt.scope_id), + ); + + if seam(repository_root, &close_payload, logger).is_ok() { + state::remove_attempt(git_dir, &attempt.scope_id)?; + } else { + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + } + Ok(String::new()) +} + +pub(super) fn handle_permission_denied( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| attempt_matches_key(attempt, key)) + .cloned() + else { + return Ok(String::new()); + }; + + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + Ok(String::new()) +} + +pub(super) fn cleanup_attempts_matching( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, + predicate: impl Fn(&state::AdapterAttempt) -> bool, +) -> Result { + let current = state::read_state(git_dir)?; + let stale: Vec = current + .attempts + .into_iter() + .filter(|attempt| predicate(attempt)) + .collect(); + + for attempt in &stale { + abandon_attempt(git_dir, repository_root, attempt, logger, seam)?; + } + + Ok(String::new()) +} + +pub(super) fn attempt_matches_key(attempt: &state::AdapterAttempt, key: &AttemptKey) -> bool { + attempt.session_id == key.session_id + && attempt.agent_id == key.agent_id + && attempt.tool_use_id == key.tool_use_id +} + +pub(super) fn abandon_attempt( + git_dir: &Path, + repository_root: &Path, + attempt: &state::AdapterAttempt, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + state::mark_recovery_pending(git_dir)?; + + seam(repository_root, &abandon_payload(&attempt.scope_id), logger)?; + state::remove_attempt(git_dir, &attempt.scope_id)?; + Ok(()) +} diff --git a/cli/src/services/hooks/claude_mutation_scope/mod.rs b/cli/src/services/hooks/claude_mutation_scope/mod.rs index 94eaf5e6..52c6f47d 100644 --- a/cli/src/services/hooks/claude_mutation_scope/mod.rs +++ b/cli/src/services/hooks/claude_mutation_scope/mod.rs @@ -1,823 +1,65 @@ #![allow(dead_code)] +mod events; pub(crate) mod health; +mod lifecycle; +mod payload; pub(crate) mod state; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, bail, Context, Result}; -use serde_json::{json, Map, Value}; - +#[allow(unused_imports)] use crate::services::mutation_trace::runtime::resolve_git_dir; +#[allow(unused_imports)] use crate::services::observability::traits::Logger; +#[allow(unused_imports)] +use anyhow::{anyhow, bail, Context, Result}; +#[allow(unused_imports)] +use std::path::{Path, PathBuf}; -const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; -const SESSION_ID_FIELD: &str = "session_id"; -const CWD_FIELD: &str = "cwd"; -const AGENT_ID_FIELD: &str = "agent_id"; -const TOOL_NAME_FIELD: &str = "tool_name"; -const TOOL_USE_ID_FIELD: &str = "tool_use_id"; -const TOOL_INPUT_FIELD: &str = "tool_input"; -const RUN_IN_BACKGROUND_FIELD: &str = "run_in_background"; -const PROMPT_ID_FIELD: &str = "prompt_id"; -const AGENT_TYPE_FIELD: &str = "agent_type"; -const WORKTREE_PATH_FIELD: &str = "worktree_path"; - -const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; -const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; -const HOOK_EVENT_POST_TOOL_USE_FAILURE: &str = "PostToolUseFailure"; -const HOOK_EVENT_PERMISSION_DENIED: &str = "PermissionDenied"; -const HOOK_EVENT_STOP: &str = "Stop"; -const HOOK_EVENT_STOP_FAILURE: &str = "StopFailure"; -const HOOK_EVENT_USER_PROMPT_SUBMIT: &str = "UserPromptSubmit"; -const HOOK_EVENT_SUBAGENT_STOP: &str = "SubagentStop"; -const HOOK_EVENT_SESSION_END: &str = "SessionEnd"; -const HOOK_EVENT_WORKTREE_REMOVE: &str = "WorktreeRemove"; -const HOOK_EVENT_SESSION_START: &str = "SessionStart"; -const HOOK_EVENT_SUBAGENT_START: &str = "SubagentStart"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum ClaudeHookEvent { - PreToolUse(ClaudeToolExecution), - PostToolUse(ClaudeToolIdentity), - PostToolUseFailure(ClaudeToolIdentity), - PermissionDenied(ClaudeToolIdentity), - Stop(ClaudeSessionIdentity), - StopFailure(ClaudeSessionIdentity), - UserPromptSubmit(ClaudeSessionIdentity), - SubagentStop(ClaudeAgentIdentity), - SessionEnd(ClaudeSessionIdentity), - WorktreeRemove(ClaudeWorktreeRemove), - SessionStart, - SubagentStart, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ClaudeToolIdentity { - pub session_id: String, - pub cwd: String, - pub agent_id: Option, - pub tool_name: String, - pub tool_use_id: String, -} - -impl ClaudeToolIdentity { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - agent_id: self.agent_id.clone(), - tool_use_id: self.tool_use_id.clone(), - } - } - - pub(crate) fn is_subagent(&self) -> bool { - self.agent_id.is_some() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ClaudeToolExecution { - pub identity: ClaudeToolIdentity, - pub prompt_id: Option, - pub agent_type: Option, - pub run_in_background: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ClaudeSessionIdentity { - pub session_id: String, - pub cwd: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ClaudeAgentIdentity { - pub session_id: String, - pub cwd: String, - pub agent_id: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ClaudeWorktreeRemove { - pub session_id: String, - pub worktree_path: String, -} - -#[allow(clippy::struct_field_names)] -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct AttemptKey { - pub session_id: String, - pub agent_id: Option, - pub tool_use_id: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ToolClassification { - MutationCapable, - ReadOnly, - Delegation, -} - -const DELEGATION_TOOL_NAME: &str = "Agent"; -const KNOWN_READ_ONLY_TOOL_NAMES: &[&str] = &[ - "Read", - "Glob", - "Grep", - "WebFetch", - "WebSearch", - "AskUserQuestion", -]; - -pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { - if tool_name == DELEGATION_TOOL_NAME { - return ToolClassification::Delegation; - } - if KNOWN_READ_ONLY_TOOL_NAMES.contains(&tool_name) { - return ToolClassification::ReadOnly; - } - ToolClassification::MutationCapable -} - -const BASH_TOOL_NAME: &str = "Bash"; -const POWERSHELL_TOOL_NAME: &str = "PowerShell"; - -pub(crate) fn is_explicit_background_shell(tool_name: &str, run_in_background: bool) -> bool { - run_in_background && (tool_name == BASH_TOOL_NAME || tool_name == POWERSHELL_TOOL_NAME) -} - -const CLAUDE_SCOPE_ID_SCHEME: &str = "cc-tool-v1"; - -pub(crate) fn format_claude_scope_id(attempt_seq: u64, key: &AttemptKey) -> String { - let agent_id = key.agent_id.as_deref().unwrap_or(""); - format!( - "{CLAUDE_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|a={}:{}|t={}:{}", - key.session_id.len(), - key.session_id, - agent_id.len(), - agent_id, - key.tool_use_id.len(), - key.tool_use_id, - ) -} - -pub(crate) fn claude_scope_start_event_id(scope_id: &str) -> String { - format!("{scope_id}|start") -} - -pub(crate) fn claude_scope_close_event_id(scope_id: &str) -> String { - format!("{scope_id}|close") -} - -pub(crate) fn parse_claude_hook_event(stdin_payload: &str) -> Result { - if stdin_payload.trim().is_empty() { - bail!(validation_error( - "expected a JSON object, got an empty payload" - )); - } - - let parsed: Value = serde_json::from_str(stdin_payload) - .with_context(|| validation_error("expected valid JSON"))?; - let object = parsed - .as_object() - .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; - - let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; - - match hook_event_name.as_str() { - HOOK_EVENT_PRE_TOOL_USE => parse_pre_tool_use(object).map(ClaudeHookEvent::PreToolUse), - HOOK_EVENT_POST_TOOL_USE => parse_tool_identity(object).map(ClaudeHookEvent::PostToolUse), - HOOK_EVENT_POST_TOOL_USE_FAILURE => { - parse_tool_identity(object).map(ClaudeHookEvent::PostToolUseFailure) - } - HOOK_EVENT_PERMISSION_DENIED => { - parse_tool_identity(object).map(ClaudeHookEvent::PermissionDenied) - } - HOOK_EVENT_STOP => parse_session_identity(object).map(ClaudeHookEvent::Stop), - HOOK_EVENT_STOP_FAILURE => parse_session_identity(object).map(ClaudeHookEvent::StopFailure), - HOOK_EVENT_USER_PROMPT_SUBMIT => { - parse_session_identity(object).map(ClaudeHookEvent::UserPromptSubmit) - } - HOOK_EVENT_SUBAGENT_STOP => parse_agent_identity(object).map(ClaudeHookEvent::SubagentStop), - HOOK_EVENT_SESSION_END => parse_session_identity(object).map(ClaudeHookEvent::SessionEnd), - HOOK_EVENT_WORKTREE_REMOVE => { - parse_worktree_remove(object).map(ClaudeHookEvent::WorktreeRemove) - } - HOOK_EVENT_SESSION_START => Ok(ClaudeHookEvent::SessionStart), - HOOK_EVENT_SUBAGENT_START => Ok(ClaudeHookEvent::SubagentStart), - other => bail!(validation_error(&format!( - "unsupported hook_event_name '{other}'" - ))), - } -} - -fn parse_tool_identity(object: &Map) -> Result { - Ok(ClaudeToolIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, - tool_use_id: required_non_blank_str(object, TOOL_USE_ID_FIELD)?, - agent_id: optional_non_blank_str(object, AGENT_ID_FIELD)?, - }) -} - -fn parse_pre_tool_use(object: &Map) -> Result { - Ok(ClaudeToolExecution { - identity: parse_tool_identity(object)?, - prompt_id: optional_non_blank_str(object, PROMPT_ID_FIELD)?, - agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, - run_in_background: parse_run_in_background(object)?, - }) -} - -fn parse_run_in_background(object: &Map) -> Result { - let Some(tool_input) = object.get(TOOL_INPUT_FIELD) else { - return Ok(false); - }; - if tool_input.is_null() { - return Ok(false); - } - let tool_input = tool_input.as_object().ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{TOOL_INPUT_FIELD}' must be a JSON object" - ))) - })?; - - match tool_input.get(RUN_IN_BACKGROUND_FIELD) { - None | Some(Value::Null) => Ok(false), - Some(Value::Bool(value)) => Ok(*value), - Some(_) => bail!(validation_error(&format!( - "field '{TOOL_INPUT_FIELD}.{RUN_IN_BACKGROUND_FIELD}' must be a boolean" - ))), - } -} - -fn parse_session_identity(object: &Map) -> Result { - Ok(ClaudeSessionIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - }) -} - -fn parse_agent_identity(object: &Map) -> Result { - Ok(ClaudeAgentIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - agent_id: required_non_blank_str(object, AGENT_ID_FIELD)?, - }) -} - -fn parse_worktree_remove(object: &Map) -> Result { - Ok(ClaudeWorktreeRemove { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - worktree_path: required_non_blank_str(object, WORKTREE_PATH_FIELD)?, - }) -} - -fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { - object.get(field).ok_or_else(|| { - anyhow!(validation_error(&format!( - "missing required field '{field}'" - ))) - }) -} - -fn required_str(object: &Map, field: &str) -> Result { - required_field(object, field)? - .as_str() - .map(str::to_owned) - .ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{field}' must be a string" - ))) - }) -} - -fn required_non_blank_str(object: &Map, field: &str) -> Result { - let value = required_str(object, field)?; - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be a non-blank string" - ))); - } - Ok(value) -} - -fn optional_non_blank_str(object: &Map, field: &str) -> Result> { - match object.get(field) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => { - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))); - } - Ok(Some(value.clone())) - } - Some(_) => bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))), - } -} - -fn validation_error(detail: &str) -> String { - format!("Invalid Claude hook event payload from STDIN: {detail}.") -} - -type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; - -type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; - -const ACTOR_KIND_CLAUDE_CODE: &str = "claude_code"; - -const FAIL_CLOSED_DENY_REASON: &str = - "SCE could not establish mutation attribution for this tool execution."; -const EXPLICIT_BACKGROUND_SHELL_DENY_REASON: &str = - "SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground."; - -const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = - "sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed"; -const MODEL_STATE_UNAVAILABLE_EVENT: &str = - "sce.hooks.claude_mutation_scope.model_state_unavailable"; - -type ClaudeModelStateResolver<'a> = &'a dyn Fn(&Path, &str, &str) -> Result>; - -fn log_pre_tool_use_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { - if let Some(log) = logger { - log.warn( - PRE_TOOL_USE_FAIL_CLOSED_EVENT, - &error.to_string(), - &[("context", context)], - None, - ); - } -} - -pub(crate) fn run_claude_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { - let stdin_payload = super::read_hook_stdin()?; - run_claude_mutation_scope_from_payload(&stdin_payload, logger) -} - -pub(crate) fn run_claude_mutation_scope_from_payload( - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let model_state_resolver = - |repository_root: &Path, session_id: &str, agent_id: &str| -> Result> { - let db = super::open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for Claude mutation-scope model resolution.", - )?; - Ok(db - .claude_model_state_by_session_and_agent(session_id, agent_id)? - .map(|state| state.model_id)) - }; - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) - }; - - run_claude_mutation_scope_from_payload_with_resolver( - stdin_payload, - logger, - &resolve_git_dir_fn, - &model_state_resolver, - &seam_fn, - ) -} +#[allow(unused_imports)] +use serde_json::{json, Map, Value}; +#[allow(unused_imports)] +pub(crate) use events::{ + classify_tool, claude_scope_close_event_id, claude_scope_start_event_id, + format_claude_scope_id, is_explicit_background_shell, parse_claude_hook_event, AttemptKey, + ClaudeAgentIdentity, ClaudeHookEvent, ClaudeSessionIdentity, ClaudeToolExecution, + ClaudeToolIdentity, ClaudeWorktreeRemove, ToolClassification, +}; +#[allow(unused_imports)] +use events::{ + AGENT_ID_FIELD, AGENT_TYPE_FIELD, CWD_FIELD, HOOK_EVENT_NAME_FIELD, + HOOK_EVENT_PERMISSION_DENIED, HOOK_EVENT_POST_TOOL_USE, HOOK_EVENT_POST_TOOL_USE_FAILURE, + HOOK_EVENT_PRE_TOOL_USE, HOOK_EVENT_SESSION_END, HOOK_EVENT_SESSION_START, HOOK_EVENT_STOP, + HOOK_EVENT_STOP_FAILURE, HOOK_EVENT_SUBAGENT_START, HOOK_EVENT_SUBAGENT_STOP, + HOOK_EVENT_USER_PROMPT_SUBMIT, HOOK_EVENT_WORKTREE_REMOVE, PROMPT_ID_FIELD, + RUN_IN_BACKGROUND_FIELD, SESSION_ID_FIELD, TOOL_INPUT_FIELD, TOOL_NAME_FIELD, + TOOL_USE_ID_FIELD, WORKTREE_PATH_FIELD, +}; #[cfg(test)] -pub(crate) fn run_claude_mutation_scope_from_payload_at_state_root( - state_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let model_state_root = state_root.to_path_buf(); - let seam_state_root = state_root.to_path_buf(); - let model_state_resolver = - move |repository_root: &Path, session_id: &str, agent_id: &str| -> Result> { - let db = super::open_agent_trace_db_for_hook_runtime_at_state_root( - repository_root, - &model_state_root, - "Failed to open Agent Trace DB for Claude mutation-scope model resolution.", - )?; - Ok(db - .claude_model_state_by_session_and_agent(session_id, agent_id)? - .map(|state| state.model_id)) - }; - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload_at_state_root( - repository_root, - &seam_state_root, - payload, - logger, - ) - }; - - run_claude_mutation_scope_from_payload_with_resolver( - stdin_payload, - logger, - &resolve_git_dir_fn, - &model_state_resolver, - &seam_fn, - ) -} - +pub(crate) use lifecycle::run_claude_mutation_scope_from_payload_at_state_root; #[cfg(test)] -fn run_claude_mutation_scope_from_payload_with( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let unavailable_model_state = |_repository_root: &Path, - _session_id: &str, - _agent_id: &str| - -> Result> { Ok(None) }; - run_claude_mutation_scope_from_payload_with_resolver( - stdin_payload, - logger, - resolve_git_dir, - &unavailable_model_state, - seam, - ) -} - -fn run_claude_mutation_scope_from_payload_with_resolver( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - model_state_resolver: ClaudeModelStateResolver, - seam: IngressSeam, -) -> Result { - let event = parse_claude_hook_event(stdin_payload)?; - dispatch_claude_hook_event(event, logger, resolve_git_dir, model_state_resolver, seam) -} - -fn dispatch_claude_hook_event( - event: ClaudeHookEvent, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - model_state_resolver: ClaudeModelStateResolver, - seam: IngressSeam, -) -> Result { - match event { - ClaudeHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( - &execution, - logger, - resolve_git_dir, - model_state_resolver, - seam, - )), - ClaudeHookEvent::PostToolUse(identity) | ClaudeHookEvent::PostToolUseFailure(identity) => { - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - handle_close( - &git_dir, - repository_root, - &identity.attempt_key(), - logger, - seam, - ) - } - ClaudeHookEvent::PermissionDenied(identity) => { - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - handle_permission_denied( - &git_dir, - repository_root, - &identity.attempt_key(), - logger, - seam, - ) - } - ClaudeHookEvent::Stop(session) | ClaudeHookEvent::StopFailure(session) => { - let git_dir = resolve_git_dir(&session.cwd)?; - let repository_root = Path::new(&session.cwd); - let session_id = session.session_id.clone(); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id && attempt.agent_id.is_none() - }) - } - ClaudeHookEvent::UserPromptSubmit(session) => { - let git_dir = resolve_git_dir(&session.cwd)?; - let repository_root = Path::new(&session.cwd); - let session_id = session.session_id.clone(); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id && attempt.agent_id.is_none() - }) - } - ClaudeHookEvent::SubagentStop(agent) => { - let git_dir = resolve_git_dir(&agent.cwd)?; - let repository_root = Path::new(&agent.cwd); - let session_id = agent.session_id.clone(); - let agent_id = agent.agent_id.clone(); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id && attempt.agent_id.as_deref() == Some(&agent_id) - }) - } - ClaudeHookEvent::SessionEnd(session) => { - let git_dir = resolve_git_dir(&session.cwd)?; - let repository_root = Path::new(&session.cwd); - let session_id = session.session_id.clone(); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id - }) - } - ClaudeHookEvent::WorktreeRemove(worktree_remove) => { - let git_dir = resolve_git_dir(&worktree_remove.worktree_path)?; - let repository_root = Path::new(&worktree_remove.worktree_path); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |_attempt| true) - } - ClaudeHookEvent::SessionStart | ClaudeHookEvent::SubagentStart => Ok(String::new()), - } -} - -fn handle_pre_tool_use( - execution: &ClaudeToolExecution, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - model_state_resolver: ClaudeModelStateResolver, - seam: IngressSeam, -) -> String { - let identity = &execution.identity; - - if matches!( - classify_tool(&identity.tool_name), - ToolClassification::ReadOnly | ToolClassification::Delegation - ) { - return String::new(); - } - - if is_explicit_background_shell(&identity.tool_name, execution.run_in_background) { - return pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON); - } - - let repository_root = Path::new(&identity.cwd); - let git_dir = match resolve_git_dir(&identity.cwd) { - Ok(git_dir) => git_dir, - Err(error) => { - log_pre_tool_use_fail_closed(logger, "resolve_git_dir", &error); - return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); - } - }; - - if matches!( - apply_recovery_barrier(&git_dir, repository_root, logger, seam), - BarrierOutcome::Deny - ) { - return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); - } - - match establish_start( - &git_dir, - repository_root, - identity, - logger, - model_state_resolver, - seam, - ) { - Ok(()) => String::new(), - Err(error) => { - log_pre_tool_use_fail_closed(logger, "establish_start", &error); - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) - } - } -} - -enum BarrierOutcome { - Proceed, - Deny, -} - -fn apply_recovery_barrier( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> BarrierOutcome { - let state = match state::read_state(git_dir) { - Ok(state) => state, - Err(error) => { - log_pre_tool_use_fail_closed(logger, "recovery_barrier.read_state", &error); - return BarrierOutcome::Deny; - } - }; - - if !state.recovery_pending { - return BarrierOutcome::Proceed; - } - - if !state.attempts.is_empty() { - return BarrierOutcome::Deny; - } - - match seam(repository_root, &flush_payload(), logger) { - Ok(_) => match state::clear_recovery_pending(git_dir) { - Ok(()) => BarrierOutcome::Proceed, - Err(error) => { - log_pre_tool_use_fail_closed( - logger, - "recovery_barrier.clear_recovery_pending", - &error, - ); - BarrierOutcome::Deny - } - }, - Err(error) => { - log_pre_tool_use_fail_closed(logger, "recovery_barrier.flush", &error); - BarrierOutcome::Deny - } - } -} - -fn establish_start( - git_dir: &Path, - repository_root: &Path, - identity: &ClaudeToolIdentity, - logger: Option<&dyn Logger>, - model_state_resolver: ClaudeModelStateResolver, - seam: IngressSeam, -) -> Result<()> { - let allocated = state::allocate_attempt(git_dir, &identity.attempt_key(), &identity.tool_name)?; - let scope_id = &allocated.attempt.scope_id; - let canonical_session_id = - super::prefixed_diff_trace_session_id(super::CLAUDE_TOOL_NAME, &identity.session_id); - let agent_id = identity.agent_id.as_deref().unwrap_or(""); - let model_id = match model_state_resolver(repository_root, &canonical_session_id, agent_id) { - Ok(model_id) => model_id.and_then(|model| super::normalize_claude_model_id(&model)), - Err(error) => { - if let Some(log) = logger { - log.warn( - MODEL_STATE_UNAVAILABLE_EVENT, - &error.to_string(), - &[("agent_id", agent_id)], - Some(&canonical_session_id), - ); - } - None - } - }; - let start_payload = scope_start_payload( - scope_id, - &claude_scope_start_event_id(scope_id), - &canonical_session_id, - model_id.as_deref(), - ); - - seam(repository_root, &start_payload, logger)?; - state::mark_active(git_dir, scope_id)?; - Ok(()) -} - -fn handle_close( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| attempt_matches_key(attempt, key)) - .cloned() - else { - return Ok(String::new()); - }; - - if attempt.phase == state::AttemptPhase::PendingStart { - abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; - return Ok(String::new()); - } - - let close_payload = scope_boundary_payload( - "close", - &attempt.scope_id, - &claude_scope_close_event_id(&attempt.scope_id), - ); - - if seam(repository_root, &close_payload, logger).is_ok() { - state::remove_attempt(git_dir, &attempt.scope_id)?; - } else { - abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; - } - Ok(String::new()) -} - -fn handle_permission_denied( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| attempt_matches_key(attempt, key)) - .cloned() - else { - return Ok(String::new()); - }; - - abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; - Ok(String::new()) -} - -fn cleanup_attempts_matching( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, - predicate: impl Fn(&state::AdapterAttempt) -> bool, -) -> Result { - let current = state::read_state(git_dir)?; - let stale: Vec = current - .attempts - .into_iter() - .filter(|attempt| predicate(attempt)) - .collect(); - - for attempt in &stale { - abandon_attempt(git_dir, repository_root, attempt, logger, seam)?; - } - - Ok(String::new()) -} - -fn attempt_matches_key(attempt: &state::AdapterAttempt, key: &AttemptKey) -> bool { - attempt.session_id == key.session_id - && attempt.agent_id == key.agent_id - && attempt.tool_use_id == key.tool_use_id -} - -fn abandon_attempt( - git_dir: &Path, - repository_root: &Path, - attempt: &state::AdapterAttempt, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - state::mark_recovery_pending(git_dir)?; - - seam(repository_root, &abandon_payload(&attempt.scope_id), logger)?; - state::remove_attempt(git_dir, &attempt.scope_id)?; - Ok(()) -} - -fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { - json!({ - "operation": operation, - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_CLAUDE_CODE, - }) - .to_string() -} - -fn scope_start_payload( - scope_id: &str, - event_id: &str, - session_id: &str, - model_id: Option<&str>, -) -> String { - json!({ - "operation": "start", - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_CLAUDE_CODE, - "provenance": { - "session_id": session_id, - "model_id": model_id, - }, - }) - .to_string() -} - -fn abandon_payload(scope_id: &str) -> String { - json!({ - "operation": "abandon", - "scope_id": scope_id, - }) - .to_string() -} - -fn flush_payload() -> String { - json!({ "operation": "flush" }).to_string() -} - -fn pre_tool_use_deny_json(reason: &str) -> String { - json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": reason, - } - }) - .to_string() -} +#[allow(unused_imports)] +use lifecycle::ClaudeModelStateResolver; +#[allow(unused_imports)] +use lifecycle::{ + abandon_attempt, apply_recovery_barrier, BarrierOutcome, GitDirResolver, IngressSeam, + EXPLICIT_BACKGROUND_SHELL_DENY_REASON, FAIL_CLOSED_DENY_REASON, +}; +#[allow(unused_imports)] +pub(crate) use lifecycle::{ + run_claude_mutation_scope_from_payload, run_claude_mutation_scope_subcommand, +}; +#[cfg(test)] +#[allow(unused_imports)] +use lifecycle::{ + run_claude_mutation_scope_from_payload_with, + run_claude_mutation_scope_from_payload_with_resolver, +}; +#[allow(unused_imports)] +use payload::{ + abandon_payload, flush_payload, pre_tool_use_deny_json, scope_boundary_payload, + scope_start_payload, +}; #[cfg(test)] mod tests; diff --git a/cli/src/services/hooks/claude_mutation_scope/payload.rs b/cli/src/services/hooks/claude_mutation_scope/payload.rs new file mode 100644 index 00000000..3bf7605e --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/payload.rs @@ -0,0 +1,55 @@ +use serde_json::json; + +use super::lifecycle::ACTOR_KIND_CLAUDE_CODE; + +pub(super) fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CLAUDE_CODE, + }) + .to_string() +} + +pub(super) fn scope_start_payload( + scope_id: &str, + event_id: &str, + session_id: &str, + model_id: Option<&str>, +) -> String { + json!({ + "operation": "start", + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CLAUDE_CODE, + "provenance": { + "session_id": session_id, + "model_id": model_id, + }, + }) + .to_string() +} + +pub(super) fn abandon_payload(scope_id: &str) -> String { + json!({ + "operation": "abandon", + "scope_id": scope_id, + }) + .to_string() +} + +pub(super) fn flush_payload() -> String { + json!({ "operation": "flush" }).to_string() +} + +pub(super) fn pre_tool_use_deny_json(reason: &str) -> String { + json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + }) + .to_string() +} diff --git a/cli/src/services/hooks/codex_mutation_scope/events.rs b/cli/src/services/hooks/codex_mutation_scope/events.rs new file mode 100644 index 00000000..c8386403 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/events.rs @@ -0,0 +1,277 @@ +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{Map, Value}; + +pub(super) const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; +pub(super) const SESSION_ID_FIELD: &str = "session_id"; +pub(super) const TURN_ID_FIELD: &str = "turn_id"; +pub(super) const CWD_FIELD: &str = "cwd"; +pub(super) const AGENT_ID_FIELD: &str = "agent_id"; +pub(super) const AGENT_TYPE_FIELD: &str = "agent_type"; +pub(super) const MODEL_FIELD: &str = "model"; +pub(super) const PROVENANCE_FIELD: &str = "provenance"; +pub(super) const TOOL_NAME_FIELD: &str = "tool_name"; +pub(super) const TOOL_USE_ID_FIELD: &str = "tool_use_id"; +pub(super) const TOOL_INPUT_FIELD: &str = "tool_input"; + +pub(super) const CODEX_TRACKED_TOOL_BASH: &str = "Bash"; + +pub(super) const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; +pub(super) const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; +pub(super) const HOOK_EVENT_STOP: &str = "Stop"; +pub(super) const HOOK_EVENT_INTERRUPT: &str = "Interrupt"; +pub(super) const HOOK_EVENT_SUBAGENT_STOP: &str = "SubagentStop"; +pub(super) const HOOK_EVENT_SESSION_END: &str = "SessionEnd"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CodexHookEvent { + PreToolUse(CodexToolExecution), + PostToolUse(CodexToolIdentity), + Stop(CodexTurnIdentity), + Interrupt(CodexTurnIdentity), + SubagentStop(CodexAgentIdentity), + SessionEnd(CodexSessionIdentity), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexToolIdentity { + pub session_id: String, + pub turn_id: String, + pub cwd: String, + pub agent_id: Option, + pub tool_name: String, + pub tool_use_id: String, +} + +impl CodexToolIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + agent_id: self.agent_id.clone(), + tool_use_id: self.tool_use_id.clone(), + } + } + + pub(crate) fn is_subagent(&self) -> bool { + self.agent_id.is_some() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexToolExecution { + pub identity: CodexToolIdentity, + pub agent_type: Option, + pub model: Option, + pub tool_input: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexTurnIdentity { + pub session_id: String, + pub turn_id: String, + pub cwd: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexAgentIdentity { + pub session_id: String, + pub turn_id: String, + pub cwd: String, + pub agent_id: String, + pub agent_type: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexSessionIdentity { + pub session_id: String, + pub cwd: String, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct AttemptKey { + pub session_id: String, + pub agent_id: Option, + pub tool_use_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolClassification { + TrackedMutation, + Delegation, + Untracked, +} + +pub(super) const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["Bash", "apply_patch"]; +pub(super) const DELEGATION_TOOL_NAMES: &[&str] = + &["collaborationspawn_agent", "collaborationwait_agent"]; +pub(super) const MCP_TOOL_NAME_PREFIX: &str = "mcp__"; + +pub(crate) fn is_mcp_tool_name(tool_name: &str) -> bool { + tool_name.starts_with(MCP_TOOL_NAME_PREFIX) +} + +pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { + if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::TrackedMutation + } else if DELEGATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::Delegation + } else { + ToolClassification::Untracked + } +} + +pub(super) const CODEX_SCOPE_ID_SCHEME: &str = "cx-tool-v1"; + +pub(crate) fn format_codex_scope_id(attempt_seq: u64, key: &AttemptKey) -> String { + let agent_id = key.agent_id.as_deref().unwrap_or(""); + format!( + "{CODEX_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|a={}:{}|t={}:{}", + key.session_id.len(), + key.session_id, + agent_id.len(), + agent_id, + key.tool_use_id.len(), + key.tool_use_id, + ) +} + +pub(crate) fn codex_scope_start_event_id(scope_id: &str) -> String { + format!("{scope_id}|start") +} + +pub(crate) fn codex_scope_close_event_id(scope_id: &str) -> String { + format!("{scope_id}|close") +} + +pub(crate) fn parse_codex_hook_event(stdin_payload: &str) -> Result { + if stdin_payload.trim().is_empty() { + bail!(validation_error( + "expected a JSON object, got an empty payload" + )); + } + + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| validation_error("expected valid JSON"))?; + let object = parsed + .as_object() + .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; + + let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; + + match hook_event_name.as_str() { + HOOK_EVENT_PRE_TOOL_USE => parse_pre_tool_use(object).map(CodexHookEvent::PreToolUse), + HOOK_EVENT_POST_TOOL_USE => parse_tool_identity(object).map(CodexHookEvent::PostToolUse), + HOOK_EVENT_STOP => parse_turn_identity(object).map(CodexHookEvent::Stop), + HOOK_EVENT_INTERRUPT => parse_turn_identity(object).map(CodexHookEvent::Interrupt), + HOOK_EVENT_SUBAGENT_STOP => parse_agent_identity(object).map(CodexHookEvent::SubagentStop), + HOOK_EVENT_SESSION_END => parse_session_identity(object).map(CodexHookEvent::SessionEnd), + other => bail!(validation_error(&format!( + "unsupported hook_event_name '{other}'" + ))), + } +} + +pub(super) fn parse_tool_identity(object: &Map) -> Result { + Ok(CodexToolIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + agent_id: optional_non_blank_str(object, AGENT_ID_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + tool_use_id: required_non_blank_str(object, TOOL_USE_ID_FIELD)?, + }) +} + +pub(super) fn parse_pre_tool_use(object: &Map) -> Result { + Ok(CodexToolExecution { + identity: parse_tool_identity(object)?, + agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + model: tolerated_model(object), + tool_input: object.get(TOOL_INPUT_FIELD).cloned(), + }) +} + +pub(super) fn parse_turn_identity(object: &Map) -> Result { + Ok(CodexTurnIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +pub(super) fn parse_agent_identity(object: &Map) -> Result { + Ok(CodexAgentIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + agent_id: required_non_blank_str(object, AGENT_ID_FIELD)?, + agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + }) +} + +pub(super) fn parse_session_identity(object: &Map) -> Result { + Ok(CodexSessionIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +pub(super) fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { + object.get(field).ok_or_else(|| { + anyhow!(validation_error(&format!( + "missing required field '{field}'" + ))) + }) +} + +pub(super) fn required_str(object: &Map, field: &str) -> Result { + required_field(object, field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field}' must be a string" + ))) + }) +} + +pub(super) fn required_non_blank_str(object: &Map, field: &str) -> Result { + let value = required_str(object, field)?; + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be a non-blank string" + ))); + } + Ok(value) +} + +pub(super) fn optional_non_blank_str( + object: &Map, + field: &str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => { + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))); + } + Ok(Some(value.clone())) + } + Some(_) => bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))), + } +} + +pub(super) fn tolerated_model(object: &Map) -> Option { + object + .get(MODEL_FIELD) + .and_then(Value::as_str) + .map(str::to_owned) +} + +pub(super) fn validation_error(detail: &str) -> String { + format!("Invalid Codex hook event payload from STDIN: {detail}.") +} diff --git a/cli/src/services/hooks/codex_mutation_scope/lifecycle.rs b/cli/src/services/hooks/codex_mutation_scope/lifecycle.rs new file mode 100644 index 00000000..5eb417bd --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/lifecycle.rs @@ -0,0 +1,542 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; + +use crate::services::hooks; +use crate::services::hooks::codex::bash_policy::{ + bash_command_from_tool_input, evaluate_codex_bash_policy, CodexBashPolicyDecision, +}; +use crate::services::hooks::{ + normalize_codex_model_id, prefixed_diff_trace_session_id, CODEX_TOOL_NAME, +}; +use crate::services::mutation_trace::runtime::resolve_git_dir; +use crate::services::observability::traits::Logger; + +use super::boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; +use super::events::CODEX_TRACKED_TOOL_BASH; +use super::state; +use super::{ + abandon_payload, classify_tool, codex_scope_close_event_id, codex_scope_start_event_id, + flush_payload, parse_codex_hook_event, pre_tool_use_deny_json, scope_boundary_payload, + scope_start_payload, AttemptKey, CodexHookEvent, CodexToolExecution, ToolClassification, +}; + +pub(super) type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; + +pub(super) type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; + +pub(super) type BashPolicyEvaluator<'a> = + &'a dyn Fn(&Path, &str) -> Result; + +pub(super) const ACTOR_KIND_CODEX: &str = "codex"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexScopeProvenance { + pub session_id: String, + pub model_id: Option, +} + +pub(super) fn codex_scope_provenance(execution: &CodexToolExecution) -> CodexScopeProvenance { + CodexScopeProvenance { + session_id: prefixed_diff_trace_session_id(CODEX_TOOL_NAME, &execution.identity.session_id), + model_id: execution + .model + .as_deref() + .and_then(normalize_codex_model_id), + } +} + +pub(super) const FAIL_CLOSED_DENY_REASON: &str = + "SCE could not establish mutation attribution for this tool execution."; + +pub(super) const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = + "sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed"; + +pub(super) fn log_pre_tool_use_fail_closed( + logger: Option<&dyn Logger>, + context: &str, + error: &anyhow::Error, +) { + if let Some(log) = logger { + log.warn( + PRE_TOOL_USE_FAIL_CLOSED_EVENT, + &error.to_string(), + &[("context", context)], + None, + ); + } +} + +pub(crate) fn run_codex_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { + let stdin_payload = hooks::read_hook_stdin()?; + run_codex_mutation_scope_from_payload(&stdin_payload, logger) +} + +pub(crate) fn run_codex_mutation_scope_from_payload( + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) + }; + let bash_policy_fn = |repository_root: &Path, command: &str| { + evaluate_codex_bash_policy(repository_root, command) + }; + + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + &bash_policy_fn, + ) +} + +#[cfg(test)] +pub(crate) fn run_codex_mutation_scope_from_payload_at_state_root( + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + state_root, + payload, + logger, + ) + }; + let bash_policy_fn = |repository_root: &Path, command: &str| { + evaluate_codex_bash_policy(repository_root, command) + }; + + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + &bash_policy_fn, + ) +} + +#[cfg(test)] +pub(super) fn run_codex_mutation_scope_from_payload_with( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let allow_all = |_repository_root: &Path, _command: &str| Ok(CodexBashPolicyDecision::Allowed); + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + resolve_git_dir, + seam, + &allow_all, + ) +} + +#[cfg(test)] +pub(super) fn run_codex_mutation_scope_from_payload_with_bash_policy( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, +) -> Result { + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + resolve_git_dir, + seam, + evaluate_bash_policy, + ) +} + +pub(super) fn run_codex_mutation_scope_from_payload_with_seams( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, +) -> Result { + let event = parse_codex_hook_event(stdin_payload)?; + dispatch_codex_hook_event(event, logger, resolve_git_dir, seam, evaluate_bash_policy) +} + +pub(super) fn dispatch_codex_hook_event( + event: CodexHookEvent, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, +) -> Result { + match event { + CodexHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( + &execution, + logger, + resolve_git_dir, + seam, + evaluate_bash_policy, + )), + CodexHookEvent::PostToolUse(identity) => { + if !matches!( + classify_tool(&identity.tool_name), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + with_boundary_lock(&git_dir, || { + handle_close( + &git_dir, + repository_root, + &identity.attempt_key(), + logger, + seam, + ) + }) + } + CodexHookEvent::Stop(turn) => { + let git_dir = resolve_git_dir(&turn.cwd)?; + let repository_root = Path::new(&turn.cwd); + let session_id = turn.session_id.clone(); + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.is_none() + }) + }) + } + CodexHookEvent::Interrupt(turn) => { + let git_dir = resolve_git_dir(&turn.cwd)?; + let repository_root = Path::new(&turn.cwd); + let session_id = turn.session_id.clone(); + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + }) + }) + } + CodexHookEvent::SubagentStop(agent) => { + let git_dir = resolve_git_dir(&agent.cwd)?; + let repository_root = Path::new(&agent.cwd); + let session_id = agent.session_id.clone(); + let agent_id = agent.agent_id.clone(); + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + && attempt.agent_id.as_deref() == Some(&agent_id) + }) + }) + } + CodexHookEvent::SessionEnd(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + }) + }) + } + } +} + +pub(super) fn with_boundary_lock( + git_dir: &Path, + operation: impl FnOnce() -> Result, +) -> Result { + let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) + .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; + operation() +} + +pub(super) fn handle_pre_tool_use( + execution: &CodexToolExecution, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, +) -> String { + let identity = &execution.identity; + + if !matches!( + classify_tool(&identity.tool_name), + ToolClassification::TrackedMutation + ) { + return String::new(); + } + + let repository_root = Path::new(&identity.cwd); + + if identity.tool_name == CODEX_TRACKED_TOOL_BASH { + match codex_bash_policy_preflight(repository_root, execution, evaluate_bash_policy) { + BashPolicyPreflight::Allowed => {} + BashPolicyPreflight::Blocked(response) => return response, + BashPolicyPreflight::EvaluationFailed(error) => { + log_pre_tool_use_fail_closed(logger, "bash_policy_preflight", &error); + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + } + } + + let git_dir = match resolve_git_dir(&identity.cwd) { + Ok(git_dir) => git_dir, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "resolve_git_dir", &error); + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + }; + + let key = identity.attempt_key(); + let turn_id = identity.turn_id.as_str(); + let provenance = codex_scope_provenance(execution); + let outcome = with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + + sweep_stale_lane_predecessors(&git_dir, repository_root, &key, turn_id, logger, seam)?; + + match admit_or_recover( + &git_dir, + repository_root, + &key, + turn_id, + &identity.tool_name, + logger, + seam, + )? { + Admission::Admitted(allocated) => { + establish_start( + &git_dir, + repository_root, + &allocated, + &provenance, + logger, + seam, + )?; + Ok(PreToolUseOutcome::Continue) + } + Admission::Denied => Ok(PreToolUseOutcome::Deny), + } + }); + + match outcome { + Ok(PreToolUseOutcome::Continue) => String::new(), + Ok(PreToolUseOutcome::Deny) => pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + Err(error) => { + log_pre_tool_use_fail_closed(logger, "codex_mutation_scope_pre_tool_use", &error); + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) + } + } +} + +pub(super) enum PreToolUseOutcome { + Continue, + Deny, +} + +pub(super) enum BashPolicyPreflight { + Allowed, + Blocked(String), + EvaluationFailed(anyhow::Error), +} + +pub(super) fn codex_bash_policy_preflight( + repository_root: &Path, + execution: &CodexToolExecution, + evaluate_bash_policy: BashPolicyEvaluator, +) -> BashPolicyPreflight { + let command = match bash_command_from_tool_input(execution.tool_input.as_ref()) { + Ok(command) => command, + Err(error) => return BashPolicyPreflight::EvaluationFailed(error), + }; + + match evaluate_bash_policy(repository_root, command) { + Ok(CodexBashPolicyDecision::Allowed) => BashPolicyPreflight::Allowed, + Ok(CodexBashPolicyDecision::Blocked(response)) => BashPolicyPreflight::Blocked(response), + Err(error) => BashPolicyPreflight::EvaluationFailed(error), + } +} + +pub(super) enum Admission { + Admitted(state::AllocatedAttempt), + Denied, +} + +pub(super) fn sweep_stale_lane_predecessors( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + turn_id: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + loop { + let current = state::read_state(git_dir)?; + let Some(stale) = current + .attempts + .iter() + .find(|attempt| { + attempt.in_builtin_lane(&key.session_id, turn_id) + && !attempt_matches_key(attempt, key) + }) + .cloned() + else { + return Ok(()); + }; + abandon_attempt(git_dir, repository_root, &stale, logger, seam)?; + } +} + +pub(super) fn admit_or_recover( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + turn_id: &str, + tool_name: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, turn_id, tool_name)? { + state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + state::AdmitDecision::RecoveryBlocked + | state::AdmitDecision::UncertainAttemptBlocked + | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), + state::AdmitDecision::FlushClaimed { generation } => { + match seam(repository_root, &flush_payload(), logger) { + Ok(_) => match state::complete_recovery_flush(git_dir, generation)? { + state::RecoveryFlushCompletion::Cleared => { + readmit_after_flush(git_dir, key, turn_id, tool_name) + } + state::RecoveryFlushCompletion::Superseded => Ok(Admission::Denied), + }, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "recovery_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + Ok(Admission::Denied) + } + } + } + } +} + +pub(super) fn readmit_after_flush( + git_dir: &Path, + key: &AttemptKey, + turn_id: &str, + tool_name: &str, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, turn_id, tool_name)? { + state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + state::AdmitDecision::FlushClaimed { generation } => { + state::relinquish_recovery_flush(git_dir, generation)?; + Ok(Admission::Denied) + } + state::AdmitDecision::RecoveryBlocked + | state::AdmitDecision::UncertainAttemptBlocked + | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), + } +} + +pub(super) fn establish_start( + git_dir: &Path, + repository_root: &Path, + allocated: &state::AllocatedAttempt, + provenance: &CodexScopeProvenance, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + let scope_id = &allocated.attempt.scope_id; + + if allocated.reused && allocated.attempt.phase == state::AttemptPhase::Active { + return Ok(()); + } + + let start_payload = + scope_start_payload(scope_id, &codex_scope_start_event_id(scope_id), provenance); + + seam(repository_root, &start_payload, logger)?; + state::mark_active(git_dir, scope_id)?; + Ok(()) +} + +pub(super) fn handle_close( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| attempt_matches_key(attempt, key)) + .cloned() + else { + return Ok(String::new()); + }; + + if attempt.phase == state::AttemptPhase::PendingStart { + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + return Ok(String::new()); + } + + let close_payload = scope_boundary_payload( + "close", + &attempt.scope_id, + &codex_scope_close_event_id(&attempt.scope_id), + ); + + if seam(repository_root, &close_payload, logger).is_ok() { + state::remove_attempt(git_dir, &attempt.scope_id)?; + } else { + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + } + Ok(String::new()) +} + +pub(super) fn cleanup_attempts_matching( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, + predicate: impl Fn(&state::AdapterAttempt) -> bool, +) -> Result { + let current = state::read_state(git_dir)?; + let stale: Vec = current + .attempts + .into_iter() + .filter(|attempt| predicate(attempt)) + .collect(); + + for attempt in &stale { + abandon_attempt(git_dir, repository_root, attempt, logger, seam)?; + } + + Ok(String::new()) +} + +pub(super) fn attempt_matches_key(attempt: &state::AdapterAttempt, key: &AttemptKey) -> bool { + attempt.session_id == key.session_id + && attempt.agent_id == key.agent_id + && attempt.tool_use_id == key.tool_use_id +} + +pub(super) fn abandon_attempt( + git_dir: &Path, + repository_root: &Path, + attempt: &state::AdapterAttempt, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + state::arm_recovery(git_dir)?; + + seam(repository_root, &abandon_payload(&attempt.scope_id), logger)?; + state::remove_attempt(git_dir, &attempt.scope_id)?; + Ok(()) +} diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index 6041cc5e..fc904317 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -1,859 +1,66 @@ #![allow(dead_code)] mod boundary_lock; +mod events; pub(crate) mod health; +mod lifecycle; mod os_lock; +mod payload; pub(crate) mod state; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, bail, Context, Result}; -use serde_json::{json, Map, Value}; - +#[allow(unused_imports)] use crate::services::hooks::codex::bash_policy::{ - bash_command_from_tool_input, evaluate_codex_bash_policy, CodexBashPolicyDecision, -}; -use crate::services::hooks::{ - normalize_codex_model_id, prefixed_diff_trace_session_id, CODEX_TOOL_NAME, + evaluate_codex_bash_policy, CodexBashPolicyDecision, }; +#[allow(unused_imports)] use crate::services::mutation_trace::runtime::resolve_git_dir; +#[allow(unused_imports)] use crate::services::observability::traits::Logger; +#[allow(unused_imports)] +use anyhow::{anyhow, bail, Context, Result}; +#[allow(unused_imports)] +use std::path::{Path, PathBuf}; -use boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; - -const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; -const SESSION_ID_FIELD: &str = "session_id"; -const TURN_ID_FIELD: &str = "turn_id"; -const CWD_FIELD: &str = "cwd"; -const AGENT_ID_FIELD: &str = "agent_id"; -const AGENT_TYPE_FIELD: &str = "agent_type"; -const MODEL_FIELD: &str = "model"; -const PROVENANCE_FIELD: &str = "provenance"; -const TOOL_NAME_FIELD: &str = "tool_name"; -const TOOL_USE_ID_FIELD: &str = "tool_use_id"; -const TOOL_INPUT_FIELD: &str = "tool_input"; - -const CODEX_TRACKED_TOOL_BASH: &str = "Bash"; - -const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; -const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; -const HOOK_EVENT_STOP: &str = "Stop"; -const HOOK_EVENT_INTERRUPT: &str = "Interrupt"; -const HOOK_EVENT_SUBAGENT_STOP: &str = "SubagentStop"; -const HOOK_EVENT_SESSION_END: &str = "SessionEnd"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum CodexHookEvent { - PreToolUse(CodexToolExecution), - PostToolUse(CodexToolIdentity), - Stop(CodexTurnIdentity), - Interrupt(CodexTurnIdentity), - SubagentStop(CodexAgentIdentity), - SessionEnd(CodexSessionIdentity), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexToolIdentity { - pub session_id: String, - pub turn_id: String, - pub cwd: String, - pub agent_id: Option, - pub tool_name: String, - pub tool_use_id: String, -} - -impl CodexToolIdentity { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - agent_id: self.agent_id.clone(), - tool_use_id: self.tool_use_id.clone(), - } - } - - pub(crate) fn is_subagent(&self) -> bool { - self.agent_id.is_some() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexToolExecution { - pub identity: CodexToolIdentity, - pub agent_type: Option, - pub model: Option, - pub tool_input: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexTurnIdentity { - pub session_id: String, - pub turn_id: String, - pub cwd: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexAgentIdentity { - pub session_id: String, - pub turn_id: String, - pub cwd: String, - pub agent_id: String, - pub agent_type: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexSessionIdentity { - pub session_id: String, - pub cwd: String, -} - -#[allow(clippy::struct_field_names)] -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct AttemptKey { - pub session_id: String, - pub agent_id: Option, - pub tool_use_id: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ToolClassification { - TrackedMutation, - Delegation, - Untracked, -} - -const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["Bash", "apply_patch"]; -const DELEGATION_TOOL_NAMES: &[&str] = &["collaborationspawn_agent", "collaborationwait_agent"]; -const MCP_TOOL_NAME_PREFIX: &str = "mcp__"; - -pub(crate) fn is_mcp_tool_name(tool_name: &str) -> bool { - tool_name.starts_with(MCP_TOOL_NAME_PREFIX) -} - -pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { - if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { - ToolClassification::TrackedMutation - } else if DELEGATION_TOOL_NAMES.contains(&tool_name) { - ToolClassification::Delegation - } else { - ToolClassification::Untracked - } -} - -const CODEX_SCOPE_ID_SCHEME: &str = "cx-tool-v1"; - -pub(crate) fn format_codex_scope_id(attempt_seq: u64, key: &AttemptKey) -> String { - let agent_id = key.agent_id.as_deref().unwrap_or(""); - format!( - "{CODEX_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|a={}:{}|t={}:{}", - key.session_id.len(), - key.session_id, - agent_id.len(), - agent_id, - key.tool_use_id.len(), - key.tool_use_id, - ) -} - -pub(crate) fn codex_scope_start_event_id(scope_id: &str) -> String { - format!("{scope_id}|start") -} - -pub(crate) fn codex_scope_close_event_id(scope_id: &str) -> String { - format!("{scope_id}|close") -} - -pub(crate) fn parse_codex_hook_event(stdin_payload: &str) -> Result { - if stdin_payload.trim().is_empty() { - bail!(validation_error( - "expected a JSON object, got an empty payload" - )); - } - - let parsed: Value = serde_json::from_str(stdin_payload) - .with_context(|| validation_error("expected valid JSON"))?; - let object = parsed - .as_object() - .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; - - let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; - - match hook_event_name.as_str() { - HOOK_EVENT_PRE_TOOL_USE => parse_pre_tool_use(object).map(CodexHookEvent::PreToolUse), - HOOK_EVENT_POST_TOOL_USE => parse_tool_identity(object).map(CodexHookEvent::PostToolUse), - HOOK_EVENT_STOP => parse_turn_identity(object).map(CodexHookEvent::Stop), - HOOK_EVENT_INTERRUPT => parse_turn_identity(object).map(CodexHookEvent::Interrupt), - HOOK_EVENT_SUBAGENT_STOP => parse_agent_identity(object).map(CodexHookEvent::SubagentStop), - HOOK_EVENT_SESSION_END => parse_session_identity(object).map(CodexHookEvent::SessionEnd), - other => bail!(validation_error(&format!( - "unsupported hook_event_name '{other}'" - ))), - } -} - -fn parse_tool_identity(object: &Map) -> Result { - Ok(CodexToolIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - agent_id: optional_non_blank_str(object, AGENT_ID_FIELD)?, - tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, - tool_use_id: required_non_blank_str(object, TOOL_USE_ID_FIELD)?, - }) -} - -fn parse_pre_tool_use(object: &Map) -> Result { - Ok(CodexToolExecution { - identity: parse_tool_identity(object)?, - agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, - model: tolerated_model(object), - tool_input: object.get(TOOL_INPUT_FIELD).cloned(), - }) -} - -fn parse_turn_identity(object: &Map) -> Result { - Ok(CodexTurnIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - }) -} - -fn parse_agent_identity(object: &Map) -> Result { - Ok(CodexAgentIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - agent_id: required_non_blank_str(object, AGENT_ID_FIELD)?, - agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, - }) -} - -fn parse_session_identity(object: &Map) -> Result { - Ok(CodexSessionIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - }) -} - -fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { - object.get(field).ok_or_else(|| { - anyhow!(validation_error(&format!( - "missing required field '{field}'" - ))) - }) -} - -fn required_str(object: &Map, field: &str) -> Result { - required_field(object, field)? - .as_str() - .map(str::to_owned) - .ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{field}' must be a string" - ))) - }) -} - -fn required_non_blank_str(object: &Map, field: &str) -> Result { - let value = required_str(object, field)?; - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be a non-blank string" - ))); - } - Ok(value) -} - -fn optional_non_blank_str(object: &Map, field: &str) -> Result> { - match object.get(field) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => { - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))); - } - Ok(Some(value.clone())) - } - Some(_) => bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))), - } -} - -fn tolerated_model(object: &Map) -> Option { - object - .get(MODEL_FIELD) - .and_then(Value::as_str) - .map(str::to_owned) -} - -fn validation_error(detail: &str) -> String { - format!("Invalid Codex hook event payload from STDIN: {detail}.") -} - -type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; - -type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; - -type BashPolicyEvaluator<'a> = &'a dyn Fn(&Path, &str) -> Result; - -const ACTOR_KIND_CODEX: &str = "codex"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexScopeProvenance { - pub session_id: String, - pub model_id: Option, -} - -fn codex_scope_provenance(execution: &CodexToolExecution) -> CodexScopeProvenance { - CodexScopeProvenance { - session_id: prefixed_diff_trace_session_id(CODEX_TOOL_NAME, &execution.identity.session_id), - model_id: execution - .model - .as_deref() - .and_then(normalize_codex_model_id), - } -} - -const FAIL_CLOSED_DENY_REASON: &str = - "SCE could not establish mutation attribution for this tool execution."; - -const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = - "sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed"; - -fn log_pre_tool_use_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { - if let Some(log) = logger { - log.warn( - PRE_TOOL_USE_FAIL_CLOSED_EVENT, - &error.to_string(), - &[("context", context)], - None, - ); - } -} - -pub(crate) fn run_codex_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { - let stdin_payload = super::read_hook_stdin()?; - run_codex_mutation_scope_from_payload(&stdin_payload, logger) -} - -pub(crate) fn run_codex_mutation_scope_from_payload( - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) - }; - let bash_policy_fn = |repository_root: &Path, command: &str| { - evaluate_codex_bash_policy(repository_root, command) - }; - - run_codex_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - &bash_policy_fn, - ) -} - -#[cfg(test)] -pub(crate) fn run_codex_mutation_scope_from_payload_at_state_root( - state_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload_at_state_root( - repository_root, - state_root, - payload, - logger, - ) - }; - let bash_policy_fn = |repository_root: &Path, command: &str| { - evaluate_codex_bash_policy(repository_root, command) - }; - - run_codex_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - &bash_policy_fn, - ) -} +#[allow(unused_imports)] +use serde_json::{json, Map, Value}; +#[allow(unused_imports)] +pub(crate) use events::{ + classify_tool, codex_scope_close_event_id, codex_scope_start_event_id, format_codex_scope_id, + is_mcp_tool_name, parse_codex_hook_event, AttemptKey, CodexAgentIdentity, CodexHookEvent, + CodexSessionIdentity, CodexToolExecution, CodexToolIdentity, CodexTurnIdentity, + ToolClassification, +}; +#[allow(unused_imports)] +use events::{ + AGENT_ID_FIELD, AGENT_TYPE_FIELD, CODEX_TRACKED_TOOL_BASH, CWD_FIELD, HOOK_EVENT_INTERRUPT, + HOOK_EVENT_NAME_FIELD, HOOK_EVENT_POST_TOOL_USE, HOOK_EVENT_PRE_TOOL_USE, + HOOK_EVENT_SESSION_END, HOOK_EVENT_STOP, HOOK_EVENT_SUBAGENT_STOP, MODEL_FIELD, + PROVENANCE_FIELD, SESSION_ID_FIELD, TOOL_INPUT_FIELD, TOOL_NAME_FIELD, TOOL_USE_ID_FIELD, + TRACKED_MUTATION_TOOL_NAMES, TURN_ID_FIELD, +}; #[cfg(test)] -fn run_codex_mutation_scope_from_payload_with( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let allow_all = |_repository_root: &Path, _command: &str| Ok(CodexBashPolicyDecision::Allowed); - run_codex_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - resolve_git_dir, - seam, - &allow_all, - ) -} - +pub(crate) use lifecycle::run_codex_mutation_scope_from_payload_at_state_root; +#[allow(unused_imports)] +use lifecycle::{ + abandon_attempt, run_codex_mutation_scope_from_payload_with_seams, FAIL_CLOSED_DENY_REASON, + PRE_TOOL_USE_FAIL_CLOSED_EVENT, +}; #[cfg(test)] -fn run_codex_mutation_scope_from_payload_with_bash_policy( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, - evaluate_bash_policy: BashPolicyEvaluator, -) -> Result { - run_codex_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - resolve_git_dir, - seam, - evaluate_bash_policy, - ) -} - -fn run_codex_mutation_scope_from_payload_with_seams( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, - evaluate_bash_policy: BashPolicyEvaluator, -) -> Result { - let event = parse_codex_hook_event(stdin_payload)?; - dispatch_codex_hook_event(event, logger, resolve_git_dir, seam, evaluate_bash_policy) -} - -fn dispatch_codex_hook_event( - event: CodexHookEvent, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, - evaluate_bash_policy: BashPolicyEvaluator, -) -> Result { - match event { - CodexHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( - &execution, - logger, - resolve_git_dir, - seam, - evaluate_bash_policy, - )), - CodexHookEvent::PostToolUse(identity) => { - if !matches!( - classify_tool(&identity.tool_name), - ToolClassification::TrackedMutation - ) { - return Ok(String::new()); - } - - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - with_boundary_lock(&git_dir, || { - handle_close( - &git_dir, - repository_root, - &identity.attempt_key(), - logger, - seam, - ) - }) - } - CodexHookEvent::Stop(turn) => { - let git_dir = resolve_git_dir(&turn.cwd)?; - let repository_root = Path::new(&turn.cwd); - let session_id = turn.session_id.clone(); - with_boundary_lock(&git_dir, || { - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id && attempt.agent_id.is_none() - }) - }) - } - CodexHookEvent::Interrupt(turn) => { - let git_dir = resolve_git_dir(&turn.cwd)?; - let repository_root = Path::new(&turn.cwd); - let session_id = turn.session_id.clone(); - with_boundary_lock(&git_dir, || { - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id - }) - }) - } - CodexHookEvent::SubagentStop(agent) => { - let git_dir = resolve_git_dir(&agent.cwd)?; - let repository_root = Path::new(&agent.cwd); - let session_id = agent.session_id.clone(); - let agent_id = agent.agent_id.clone(); - with_boundary_lock(&git_dir, || { - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id - && attempt.agent_id.as_deref() == Some(&agent_id) - }) - }) - } - CodexHookEvent::SessionEnd(session) => { - let git_dir = resolve_git_dir(&session.cwd)?; - let repository_root = Path::new(&session.cwd); - let session_id = session.session_id.clone(); - with_boundary_lock(&git_dir, || { - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id - }) - }) - } - } -} - -fn with_boundary_lock(git_dir: &Path, operation: impl FnOnce() -> Result) -> Result { - let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) - .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; - operation() -} - -fn handle_pre_tool_use( - execution: &CodexToolExecution, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, - evaluate_bash_policy: BashPolicyEvaluator, -) -> String { - let identity = &execution.identity; - - if !matches!( - classify_tool(&identity.tool_name), - ToolClassification::TrackedMutation - ) { - return String::new(); - } - - let repository_root = Path::new(&identity.cwd); - - if identity.tool_name == CODEX_TRACKED_TOOL_BASH { - match codex_bash_policy_preflight(repository_root, execution, evaluate_bash_policy) { - BashPolicyPreflight::Allowed => {} - BashPolicyPreflight::Blocked(response) => return response, - BashPolicyPreflight::EvaluationFailed(error) => { - log_pre_tool_use_fail_closed(logger, "bash_policy_preflight", &error); - return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); - } - } - } - - let git_dir = match resolve_git_dir(&identity.cwd) { - Ok(git_dir) => git_dir, - Err(error) => { - log_pre_tool_use_fail_closed(logger, "resolve_git_dir", &error); - return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); - } - }; - - let key = identity.attempt_key(); - let turn_id = identity.turn_id.as_str(); - let provenance = codex_scope_provenance(execution); - let outcome = with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - - sweep_stale_lane_predecessors(&git_dir, repository_root, &key, turn_id, logger, seam)?; - - match admit_or_recover( - &git_dir, - repository_root, - &key, - turn_id, - &identity.tool_name, - logger, - seam, - )? { - Admission::Admitted(allocated) => { - establish_start( - &git_dir, - repository_root, - &allocated, - &provenance, - logger, - seam, - )?; - Ok(PreToolUseOutcome::Continue) - } - Admission::Denied => Ok(PreToolUseOutcome::Deny), - } - }); - - match outcome { - Ok(PreToolUseOutcome::Continue) => String::new(), - Ok(PreToolUseOutcome::Deny) => pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - Err(error) => { - log_pre_tool_use_fail_closed(logger, "codex_mutation_scope_pre_tool_use", &error); - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) - } - } -} - -enum PreToolUseOutcome { - Continue, - Deny, -} - -enum BashPolicyPreflight { - Allowed, - Blocked(String), - EvaluationFailed(anyhow::Error), -} - -fn codex_bash_policy_preflight( - repository_root: &Path, - execution: &CodexToolExecution, - evaluate_bash_policy: BashPolicyEvaluator, -) -> BashPolicyPreflight { - let command = match bash_command_from_tool_input(execution.tool_input.as_ref()) { - Ok(command) => command, - Err(error) => return BashPolicyPreflight::EvaluationFailed(error), - }; - - match evaluate_bash_policy(repository_root, command) { - Ok(CodexBashPolicyDecision::Allowed) => BashPolicyPreflight::Allowed, - Ok(CodexBashPolicyDecision::Blocked(response)) => BashPolicyPreflight::Blocked(response), - Err(error) => BashPolicyPreflight::EvaluationFailed(error), - } -} - -enum Admission { - Admitted(state::AllocatedAttempt), - Denied, -} - -fn sweep_stale_lane_predecessors( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - turn_id: &str, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - loop { - let current = state::read_state(git_dir)?; - let Some(stale) = current - .attempts - .iter() - .find(|attempt| { - attempt.in_builtin_lane(&key.session_id, turn_id) - && !attempt_matches_key(attempt, key) - }) - .cloned() - else { - return Ok(()); - }; - abandon_attempt(git_dir, repository_root, &stale, logger, seam)?; - } -} - -fn admit_or_recover( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - turn_id: &str, - tool_name: &str, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - match state::admit_tracked_attempt(git_dir, key, turn_id, tool_name)? { - state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - state::AdmitDecision::RecoveryBlocked - | state::AdmitDecision::UncertainAttemptBlocked - | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), - state::AdmitDecision::FlushClaimed { generation } => { - match seam(repository_root, &flush_payload(), logger) { - Ok(_) => match state::complete_recovery_flush(git_dir, generation)? { - state::RecoveryFlushCompletion::Cleared => { - readmit_after_flush(git_dir, key, turn_id, tool_name) - } - state::RecoveryFlushCompletion::Superseded => Ok(Admission::Denied), - }, - Err(error) => { - log_pre_tool_use_fail_closed(logger, "recovery_flush", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - Ok(Admission::Denied) - } - } - } - } -} - -fn readmit_after_flush( - git_dir: &Path, - key: &AttemptKey, - turn_id: &str, - tool_name: &str, -) -> Result { - match state::admit_tracked_attempt(git_dir, key, turn_id, tool_name)? { - state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - state::AdmitDecision::FlushClaimed { generation } => { - state::relinquish_recovery_flush(git_dir, generation)?; - Ok(Admission::Denied) - } - state::AdmitDecision::RecoveryBlocked - | state::AdmitDecision::UncertainAttemptBlocked - | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), - } -} - -fn establish_start( - git_dir: &Path, - repository_root: &Path, - allocated: &state::AllocatedAttempt, - provenance: &CodexScopeProvenance, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - let scope_id = &allocated.attempt.scope_id; - - if allocated.reused && allocated.attempt.phase == state::AttemptPhase::Active { - return Ok(()); - } - - let start_payload = - scope_start_payload(scope_id, &codex_scope_start_event_id(scope_id), provenance); - - seam(repository_root, &start_payload, logger)?; - state::mark_active(git_dir, scope_id)?; - Ok(()) -} - -fn handle_close( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| attempt_matches_key(attempt, key)) - .cloned() - else { - return Ok(String::new()); - }; - - if attempt.phase == state::AttemptPhase::PendingStart { - abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; - return Ok(String::new()); - } - - let close_payload = scope_boundary_payload( - "close", - &attempt.scope_id, - &codex_scope_close_event_id(&attempt.scope_id), - ); - - if seam(repository_root, &close_payload, logger).is_ok() { - state::remove_attempt(git_dir, &attempt.scope_id)?; - } else { - abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; - } - Ok(String::new()) -} - -fn cleanup_attempts_matching( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, - predicate: impl Fn(&state::AdapterAttempt) -> bool, -) -> Result { - let current = state::read_state(git_dir)?; - let stale: Vec = current - .attempts - .into_iter() - .filter(|attempt| predicate(attempt)) - .collect(); - - for attempt in &stale { - abandon_attempt(git_dir, repository_root, attempt, logger, seam)?; - } - - Ok(String::new()) -} - -fn attempt_matches_key(attempt: &state::AdapterAttempt, key: &AttemptKey) -> bool { - attempt.session_id == key.session_id - && attempt.agent_id == key.agent_id - && attempt.tool_use_id == key.tool_use_id -} - -fn abandon_attempt( - git_dir: &Path, - repository_root: &Path, - attempt: &state::AdapterAttempt, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - state::arm_recovery(git_dir)?; - - seam(repository_root, &abandon_payload(&attempt.scope_id), logger)?; - state::remove_attempt(git_dir, &attempt.scope_id)?; - Ok(()) -} - -fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { - json!({ - "operation": operation, - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_CODEX, - }) - .to_string() -} - -fn scope_start_payload( - scope_id: &str, - event_id: &str, - provenance: &CodexScopeProvenance, -) -> String { - json!({ - "operation": "start", - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_CODEX, - PROVENANCE_FIELD: { - "session_id": provenance.session_id, - "model_id": provenance.model_id, - }, - }) - .to_string() -} - -fn abandon_payload(scope_id: &str) -> String { - json!({ - "operation": "abandon", - "scope_id": scope_id, - }) - .to_string() -} - -fn flush_payload() -> String { - json!({ "operation": "flush" }).to_string() -} - -fn pre_tool_use_deny_json(reason: &str) -> String { - json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": reason, - } - }) - .to_string() -} +#[allow(unused_imports)] +use lifecycle::{ + codex_scope_provenance, run_codex_mutation_scope_from_payload_with, + run_codex_mutation_scope_from_payload_with_bash_policy, GitDirResolver, IngressSeam, +}; +#[allow(unused_imports)] +pub(crate) use lifecycle::{ + run_codex_mutation_scope_from_payload, run_codex_mutation_scope_subcommand, +}; +#[allow(unused_imports)] +use payload::{ + abandon_payload, flush_payload, pre_tool_use_deny_json, scope_boundary_payload, + scope_start_payload, +}; #[cfg(test)] mod tests; diff --git a/cli/src/services/hooks/codex_mutation_scope/payload.rs b/cli/src/services/hooks/codex_mutation_scope/payload.rs new file mode 100644 index 00000000..9d4a6734 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/payload.rs @@ -0,0 +1,55 @@ +use serde_json::json; + +use super::events::PROVENANCE_FIELD; +use super::lifecycle::{CodexScopeProvenance, ACTOR_KIND_CODEX}; + +pub(super) fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CODEX, + }) + .to_string() +} + +pub(super) fn scope_start_payload( + scope_id: &str, + event_id: &str, + provenance: &CodexScopeProvenance, +) -> String { + json!({ + "operation": "start", + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CODEX, + PROVENANCE_FIELD: { + "session_id": provenance.session_id, + "model_id": provenance.model_id, + }, + }) + .to_string() +} + +pub(super) fn abandon_payload(scope_id: &str) -> String { + json!({ + "operation": "abandon", + "scope_id": scope_id, + }) + .to_string() +} + +pub(super) fn flush_payload() -> String { + json!({ "operation": "flush" }).to_string() +} + +pub(super) fn pre_tool_use_deny_json(reason: &str) -> String { + json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + }) + .to_string() +} diff --git a/cli/src/services/hooks/pi_mutation_scope/events.rs b/cli/src/services/hooks/pi_mutation_scope/events.rs new file mode 100644 index 00000000..5f4adff1 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/events.rs @@ -0,0 +1,211 @@ +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{Map, Value}; + +use crate::services::hooks::{normalize_pi_model_id, prefixed_diff_trace_session_id, PI_TOOL_NAME}; + +pub(super) const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; +pub(super) const SESSION_ID_FIELD: &str = "session_id"; +pub(super) const TOOL_CALL_ID_FIELD: &str = "tool_call_id"; +pub(super) const CWD_FIELD: &str = "cwd"; +pub(super) const TOOL_NAME_FIELD: &str = "tool_name"; +pub(super) const MODEL_FIELD: &str = "model"; + +pub(super) const HOOK_EVENT_TOOL_EXECUTION_START: &str = "ToolExecutionStart"; +pub(super) const HOOK_EVENT_TOOL_CALL: &str = "ToolCall"; +pub(super) const HOOK_EVENT_TOOL_RESULT: &str = "ToolResult"; +pub(super) const HOOK_EVENT_TOOL_EXECUTION_END: &str = "ToolExecutionEnd"; +pub(super) const HOOK_EVENT_TOOL_EXECUTION_ABANDON: &str = "ToolExecutionAbandon"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum PiHookEvent { + ExecutionStart(PiToolIdentity), + Call(PiToolCall), + Executed(PiToolIdentity), + ExecutionEnd(PiToolIdentity), + ExecutionAbandon(PiToolIdentity), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PiToolIdentity { + pub session_id: String, + pub tool_call_id: String, + pub cwd: String, + pub tool_name: String, +} + +impl PiToolIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + tool_call_id: self.tool_call_id.clone(), + } + } + + pub(crate) fn classification(&self) -> ToolClassification { + classify_tool(&self.tool_name) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PiToolCall { + pub identity: PiToolIdentity, + pub model: Option, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct AttemptKey { + pub session_id: String, + pub tool_call_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolClassification { + TrackedMutation, + Untracked, +} + +pub(super) const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["bash", "edit", "write"]; + +pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { + if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::TrackedMutation + } else { + ToolClassification::Untracked + } +} + +pub(super) const PI_SCOPE_ID_SCHEME: &str = "pi-tool-v1"; + +pub(crate) fn format_pi_scope_id(key: &AttemptKey, attempt_seq: u64) -> String { + format!( + "{PI_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|c={}:{}", + key.session_id.len(), + key.session_id, + key.tool_call_id.len(), + key.tool_call_id, + ) +} + +pub(crate) fn pi_scope_start_event_id(scope_id: &str) -> String { + format!("{scope_id}|start") +} + +pub(crate) fn pi_scope_close_event_id(scope_id: &str) -> String { + format!("{scope_id}|close") +} + +pub(super) const ACTOR_KIND_PI: &str = "pi"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PiScopeProvenance { + pub session_id: String, + pub model_id: Option, +} + +pub(crate) fn pi_scope_provenance(session_id: &str, model: Option<&str>) -> PiScopeProvenance { + PiScopeProvenance { + session_id: prefixed_diff_trace_session_id(PI_TOOL_NAME, session_id), + model_id: model.and_then(normalize_pi_model_id), + } +} + +pub(crate) fn parse_pi_hook_event(stdin_payload: &str) -> Result { + if stdin_payload.trim().is_empty() { + bail!(validation_error( + "expected a JSON object, got an empty payload" + )); + } + + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| validation_error("expected valid JSON"))?; + let object = parsed + .as_object() + .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; + + let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; + + match hook_event_name.as_str() { + HOOK_EVENT_TOOL_EXECUTION_START => { + parse_tool_identity(object).map(PiHookEvent::ExecutionStart) + } + HOOK_EVENT_TOOL_CALL => parse_tool_call(object).map(PiHookEvent::Call), + HOOK_EVENT_TOOL_RESULT => parse_tool_identity(object).map(PiHookEvent::Executed), + HOOK_EVENT_TOOL_EXECUTION_END => parse_tool_identity(object).map(PiHookEvent::ExecutionEnd), + HOOK_EVENT_TOOL_EXECUTION_ABANDON => { + parse_tool_identity(object).map(PiHookEvent::ExecutionAbandon) + } + other => bail!(validation_error(&format!( + "unsupported hook_event_name '{other}'" + ))), + } +} + +pub(super) fn parse_tool_identity(object: &Map) -> Result { + Ok(PiToolIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + tool_call_id: required_non_blank_str(object, TOOL_CALL_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + }) +} + +pub(super) fn parse_tool_call(object: &Map) -> Result { + Ok(PiToolCall { + identity: parse_tool_identity(object)?, + model: optional_non_blank_str(object, MODEL_FIELD)?, + }) +} + +pub(super) fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { + object.get(field).ok_or_else(|| { + anyhow!(validation_error(&format!( + "missing required field '{field}'" + ))) + }) +} + +pub(super) fn required_str(object: &Map, field: &str) -> Result { + required_field(object, field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field}' must be a string" + ))) + }) +} + +pub(super) fn required_non_blank_str(object: &Map, field: &str) -> Result { + let value = required_str(object, field)?; + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be a non-blank string" + ))); + } + Ok(value) +} + +pub(super) fn optional_non_blank_str( + object: &Map, + field: &str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => { + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))); + } + Ok(Some(value.clone())) + } + Some(_) => bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))), + } +} + +pub(super) fn validation_error(detail: &str) -> String { + format!("Invalid Pi hook event payload from STDIN: {detail}.") +} diff --git a/cli/src/services/hooks/pi_mutation_scope/lifecycle.rs b/cli/src/services/hooks/pi_mutation_scope/lifecycle.rs new file mode 100644 index 00000000..3fe04783 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/lifecycle.rs @@ -0,0 +1,460 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Result}; + +use crate::services::hooks; +use crate::services::mutation_trace::runtime::resolve_git_dir; +use crate::services::observability::traits::Logger; + +use super::boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; +use super::state::{self, AdmitDecision, RecoveryFlushCompletion}; +use super::{ + abandon_payload, flush_payload, parse_pi_hook_event, pi_scope_close_event_id, + pi_scope_provenance, pi_scope_start_event_id, scope_boundary_payload, scope_start_payload, + AttemptKey, PiHookEvent, PiScopeProvenance, ToolClassification, +}; + +pub(crate) fn run_pi_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { + let stdin_payload = hooks::read_hook_stdin()?; + run_pi_mutation_scope_from_payload(&stdin_payload, logger) +} + +pub(crate) fn run_pi_mutation_scope_from_payload( + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) + }; + + run_pi_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + ) +} + +#[cfg(test)] +pub(crate) fn run_pi_mutation_scope_from_payload_at_state_root( + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + state_root, + payload, + logger, + ) + }; + + run_pi_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + ) +} + +pub(super) type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; + +pub(super) type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; + +pub(super) const FAIL_CLOSED_MESSAGE: &str = + "SCE could not establish Pi mutation attribution for this tool execution."; + +pub(super) const FAIL_CLOSED_EVENT: &str = "sce.hooks.pi_mutation_scope.start_fail_closed"; + +pub(super) fn log_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { + if let Some(log) = logger { + log.warn( + FAIL_CLOSED_EVENT, + &error.to_string(), + &[("context", context)], + None, + ); + } +} + +pub(super) fn run_pi_mutation_scope_from_payload_with_seams( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let event = parse_pi_hook_event(stdin_payload)?; + dispatch_pi_hook_event(event, logger, resolve_git_dir, seam) +} + +pub(super) fn dispatch_pi_hook_event( + event: PiHookEvent, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + match event { + PiHookEvent::ExecutionStart(_identity) => Ok(String::new()), + PiHookEvent::Call(call) => match call.identity.classification() { + ToolClassification::TrackedMutation => { + let provenance = + pi_scope_provenance(&call.identity.session_id, call.model.as_deref()); + establish_tracked_start( + &call.identity.cwd, + &call.identity.attempt_key(), + &call.identity.tool_name, + &provenance, + logger, + resolve_git_dir, + seam, + ) + } + ToolClassification::Untracked => Ok(String::new()), + }, + PiHookEvent::Executed(identity) => { + if !matches!( + identity.classification(), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&identity.cwd)?; + state::mark_executed(&git_dir, &identity.attempt_key())?; + Ok(String::new()) + } + PiHookEvent::ExecutionEnd(identity) => { + if !matches!( + identity.classification(), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + let key = identity.attempt_key(); + with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + handle_tool_execution_end(&git_dir, repository_root, &key, logger, seam) + }) + } + PiHookEvent::ExecutionAbandon(identity) => { + if !matches!( + identity.classification(), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + let key = identity.attempt_key(); + with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + force_abandon_attempt(&git_dir, repository_root, &key, logger, seam) + }) + } + } +} + +pub(super) fn with_boundary_lock( + git_dir: &Path, + operation: impl FnOnce() -> Result, +) -> Result { + let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) + .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; + operation() +} + +pub(super) enum Admission { + Admitted(state::AllocatedAttempt), + Denied, +} + +pub(super) enum StartOutcome { + Established, + Denied, +} + +pub(super) fn establish_tracked_start( + cwd: &str, + key: &AttemptKey, + tool_name: &str, + provenance: &PiScopeProvenance, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let git_dir = match resolve_git_dir(cwd) { + Ok(git_dir) => git_dir, + Err(error) => { + log_fail_closed(logger, "resolve_git_dir", &error); + return Err(error.context(FAIL_CLOSED_MESSAGE)); + } + }; + let repository_root = Path::new(cwd); + + let outcome = with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + + match admit_or_recover(&git_dir, repository_root, key, tool_name, logger, seam)? { + Admission::Admitted(allocated) => { + establish_start( + &git_dir, + repository_root, + &allocated, + provenance, + logger, + seam, + )?; + Ok(StartOutcome::Established) + } + Admission::Denied => Ok(StartOutcome::Denied), + } + }); + + match outcome { + Ok(StartOutcome::Established) => Ok(String::new()), + Ok(StartOutcome::Denied) => bail!(FAIL_CLOSED_MESSAGE), + Err(error) => { + log_fail_closed(logger, "establish_tracked_start", &error); + Err(error.context(FAIL_CLOSED_MESSAGE)) + } + } +} + +pub(super) fn admit_or_recover( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + tool_name: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + match reconcile_stale_owners(git_dir, repository_root, logger, seam)? { + RecoveryResolution::Cleared => {} + RecoveryResolution::Unresolved => return Ok(Admission::Denied), + } + + match state::admit_tracked_attempt(git_dir, key, tool_name)? { + AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + AdmitDecision::RecoveryBlocked + | AdmitDecision::UncertainAttemptBlocked + | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), + AdmitDecision::FlushClaimed { generation } => { + match resolve_recovery(git_dir, repository_root, generation, logger, seam)? { + RecoveryResolution::Cleared => readmit_after_flush(git_dir, key, tool_name), + RecoveryResolution::Unresolved => Ok(Admission::Denied), + } + } + } +} + +pub(super) fn reconcile_stale_owners( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + loop { + let dead_scope_ids = state::find_definitely_dead_attempts(git_dir)?; + if dead_scope_ids.is_empty() { + return Ok(RecoveryResolution::Cleared); + } + + let generation = state::begin_terminal_cleanup(git_dir, &dead_scope_ids)?; + if matches!( + resolve_recovery(git_dir, repository_root, generation, logger, seam)?, + RecoveryResolution::Unresolved + ) { + return Ok(RecoveryResolution::Unresolved); + } + } +} + +pub(super) fn readmit_after_flush( + git_dir: &Path, + key: &AttemptKey, + tool_name: &str, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, tool_name)? { + AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + AdmitDecision::FlushClaimed { generation } => { + state::relinquish_recovery_flush(git_dir, generation)?; + Ok(Admission::Denied) + } + AdmitDecision::RecoveryBlocked + | AdmitDecision::UncertainAttemptBlocked + | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), + } +} + +pub(super) fn establish_start( + _git_dir: &Path, + repository_root: &Path, + allocated: &state::AllocatedAttempt, + provenance: &PiScopeProvenance, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + let scope_id = &allocated.attempt.scope_id; + + let start_payload = + scope_start_payload(scope_id, &pi_scope_start_event_id(scope_id), provenance); + + seam(repository_root, &start_payload, logger)?; + Ok(()) +} + +pub(super) fn handle_tool_execution_end( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| { + attempt.session_id == key.session_id && attempt.tool_call_id == key.tool_call_id + }) + .cloned() + else { + return Ok(String::new()); + }; + + let doomed_scope_id = attempt.scope_id.clone(); + + if !matches!(attempt.phase, state::AttemptPhase::Executed) { + return abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { + candidate.scope_id == doomed_scope_id + }); + } + + let close_payload = scope_boundary_payload( + "close", + &attempt.scope_id, + &pi_scope_close_event_id(&attempt.scope_id), + ); + + if seam(repository_root, &close_payload, logger).is_ok() { + state::remove_attempt(git_dir, &attempt.scope_id)?; + Ok(String::new()) + } else { + abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { + candidate.scope_id == doomed_scope_id + }) + } +} + +pub(super) fn force_abandon_attempt( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| { + attempt.session_id == key.session_id && attempt.tool_call_id == key.tool_call_id + }) + .cloned() + else { + return Ok(String::new()); + }; + + let doomed_scope_id = attempt.scope_id.clone(); + abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { + candidate.scope_id == doomed_scope_id + }) +} + +pub(super) enum RecoveryResolution { + Cleared, + Unresolved, +} + +pub(super) fn abandon_and_consume( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, + doomed: impl Fn(&state::AdapterAttempt) -> bool, +) -> Result { + let doomed_scope_ids: Vec = state::read_state(git_dir)? + .attempts + .into_iter() + .filter(|attempt| doomed(attempt)) + .map(|attempt| attempt.scope_id) + .collect(); + if doomed_scope_ids.is_empty() { + return Ok(String::new()); + } + + let generation = state::begin_terminal_cleanup(git_dir, &doomed_scope_ids)?; + resolve_recovery(git_dir, repository_root, generation, logger, seam)?; + Ok(String::new()) +} + +pub(super) fn resolve_recovery( + git_dir: &Path, + repository_root: &Path, + generation: u64, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let pending_abandon: Vec = state::read_state(git_dir)? + .attempts + .into_iter() + .filter(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon) + .collect(); + + if let Err(error) = seam(repository_root, &flush_payload(), logger) { + log_fail_closed(logger, "recovery_ambiguity_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + + for attempt in &pending_abandon { + if let Err(error) = seam(repository_root, &abandon_payload(&attempt.scope_id), logger) { + log_fail_closed(logger, "recovery_abandon", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + state::remove_attempt(git_dir, &attempt.scope_id)?; + } + + if let Err(error) = seam(repository_root, &flush_payload(), logger) { + log_fail_closed(logger, "recovery_rebaseline_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + + match state::complete_recovery_flush(git_dir, generation)? { + RecoveryFlushCompletion::Cleared => Ok(RecoveryResolution::Cleared), + RecoveryFlushCompletion::Superseded => Ok(RecoveryResolution::Unresolved), + } +} + +#[cfg(test)] +pub(crate) fn force_attempt_owner_dead_for_tests(git_dir: &Path, scope_id: &str) { + let mut dead_child = std::process::Command::new("true") + .spawn() + .expect("spawning 'true' should succeed"); + let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); + dead_child.wait().expect("child should exit and be reaped"); + state::set_attempt_owner_for_tests( + git_dir, + scope_id, + super::process_owner::ProcessOwner { + pid: dead_pid, + instance_token: None, + }, + ); +} diff --git a/cli/src/services/hooks/pi_mutation_scope/mod.rs b/cli/src/services/hooks/pi_mutation_scope/mod.rs index 74515e18..a806a59a 100644 --- a/cli/src/services/hooks/pi_mutation_scope/mod.rs +++ b/cli/src/services/hooks/pi_mutation_scope/mod.rs @@ -1,700 +1,52 @@ #![allow(dead_code)] mod boundary_lock; +mod events; pub(crate) mod health; +mod lifecycle; mod os_lock; +mod payload; pub(crate) mod process_owner; pub(crate) mod state; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, bail, Context, Result}; -use serde_json::{json, Map, Value}; - -use crate::services::hooks::{normalize_pi_model_id, prefixed_diff_trace_session_id, PI_TOOL_NAME}; +#[allow(unused_imports)] use crate::services::mutation_trace::runtime::resolve_git_dir; +#[allow(unused_imports)] use crate::services::observability::traits::Logger; +#[allow(unused_imports)] +use anyhow::{anyhow, bail, Context, Result}; +#[allow(unused_imports)] +use std::path::{Path, PathBuf}; -use boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; -use state::{AdmitDecision, RecoveryFlushCompletion}; - -const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; -const SESSION_ID_FIELD: &str = "session_id"; -const TOOL_CALL_ID_FIELD: &str = "tool_call_id"; -const CWD_FIELD: &str = "cwd"; -const TOOL_NAME_FIELD: &str = "tool_name"; -const MODEL_FIELD: &str = "model"; - -const HOOK_EVENT_TOOL_EXECUTION_START: &str = "ToolExecutionStart"; -const HOOK_EVENT_TOOL_CALL: &str = "ToolCall"; -const HOOK_EVENT_TOOL_RESULT: &str = "ToolResult"; -const HOOK_EVENT_TOOL_EXECUTION_END: &str = "ToolExecutionEnd"; -const HOOK_EVENT_TOOL_EXECUTION_ABANDON: &str = "ToolExecutionAbandon"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum PiHookEvent { - ExecutionStart(PiToolIdentity), - Call(PiToolCall), - Executed(PiToolIdentity), - ExecutionEnd(PiToolIdentity), - ExecutionAbandon(PiToolIdentity), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PiToolIdentity { - pub session_id: String, - pub tool_call_id: String, - pub cwd: String, - pub tool_name: String, -} - -impl PiToolIdentity { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - tool_call_id: self.tool_call_id.clone(), - } - } - - pub(crate) fn classification(&self) -> ToolClassification { - classify_tool(&self.tool_name) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PiToolCall { - pub identity: PiToolIdentity, - pub model: Option, -} - -#[allow(clippy::struct_field_names)] -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct AttemptKey { - pub session_id: String, - pub tool_call_id: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ToolClassification { - TrackedMutation, - Untracked, -} - -const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["bash", "edit", "write"]; - -pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { - if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { - ToolClassification::TrackedMutation - } else { - ToolClassification::Untracked - } -} - -const PI_SCOPE_ID_SCHEME: &str = "pi-tool-v1"; - -pub(crate) fn format_pi_scope_id(key: &AttemptKey, attempt_seq: u64) -> String { - format!( - "{PI_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|c={}:{}", - key.session_id.len(), - key.session_id, - key.tool_call_id.len(), - key.tool_call_id, - ) -} - -pub(crate) fn pi_scope_start_event_id(scope_id: &str) -> String { - format!("{scope_id}|start") -} - -pub(crate) fn pi_scope_close_event_id(scope_id: &str) -> String { - format!("{scope_id}|close") -} - -const ACTOR_KIND_PI: &str = "pi"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PiScopeProvenance { - pub session_id: String, - pub model_id: Option, -} - -pub(crate) fn pi_scope_provenance(session_id: &str, model: Option<&str>) -> PiScopeProvenance { - PiScopeProvenance { - session_id: prefixed_diff_trace_session_id(PI_TOOL_NAME, session_id), - model_id: model.and_then(normalize_pi_model_id), - } -} - -pub(crate) fn parse_pi_hook_event(stdin_payload: &str) -> Result { - if stdin_payload.trim().is_empty() { - bail!(validation_error( - "expected a JSON object, got an empty payload" - )); - } - - let parsed: Value = serde_json::from_str(stdin_payload) - .with_context(|| validation_error("expected valid JSON"))?; - let object = parsed - .as_object() - .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; - - let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; - - match hook_event_name.as_str() { - HOOK_EVENT_TOOL_EXECUTION_START => { - parse_tool_identity(object).map(PiHookEvent::ExecutionStart) - } - HOOK_EVENT_TOOL_CALL => parse_tool_call(object).map(PiHookEvent::Call), - HOOK_EVENT_TOOL_RESULT => parse_tool_identity(object).map(PiHookEvent::Executed), - HOOK_EVENT_TOOL_EXECUTION_END => parse_tool_identity(object).map(PiHookEvent::ExecutionEnd), - HOOK_EVENT_TOOL_EXECUTION_ABANDON => { - parse_tool_identity(object).map(PiHookEvent::ExecutionAbandon) - } - other => bail!(validation_error(&format!( - "unsupported hook_event_name '{other}'" - ))), - } -} - -fn parse_tool_identity(object: &Map) -> Result { - Ok(PiToolIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - tool_call_id: required_non_blank_str(object, TOOL_CALL_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, - }) -} - -fn parse_tool_call(object: &Map) -> Result { - Ok(PiToolCall { - identity: parse_tool_identity(object)?, - model: optional_non_blank_str(object, MODEL_FIELD)?, - }) -} - -fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { - object.get(field).ok_or_else(|| { - anyhow!(validation_error(&format!( - "missing required field '{field}'" - ))) - }) -} - -fn required_str(object: &Map, field: &str) -> Result { - required_field(object, field)? - .as_str() - .map(str::to_owned) - .ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{field}' must be a string" - ))) - }) -} - -fn required_non_blank_str(object: &Map, field: &str) -> Result { - let value = required_str(object, field)?; - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be a non-blank string" - ))); - } - Ok(value) -} - -fn optional_non_blank_str(object: &Map, field: &str) -> Result> { - match object.get(field) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => { - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))); - } - Ok(Some(value.clone())) - } - Some(_) => bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))), - } -} - -fn validation_error(detail: &str) -> String { - format!("Invalid Pi hook event payload from STDIN: {detail}.") -} - -pub(crate) fn run_pi_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { - let stdin_payload = super::read_hook_stdin()?; - run_pi_mutation_scope_from_payload(&stdin_payload, logger) -} - -pub(crate) fn run_pi_mutation_scope_from_payload( - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) - }; - - run_pi_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - ) -} +#[allow(unused_imports)] +use serde_json::{json, Map, Value}; +#[allow(unused_imports)] +pub(crate) use events::{ + classify_tool, format_pi_scope_id, parse_pi_hook_event, pi_scope_close_event_id, + pi_scope_provenance, pi_scope_start_event_id, AttemptKey, PiHookEvent, PiScopeProvenance, + PiToolCall, PiToolIdentity, ToolClassification, +}; +#[allow(unused_imports)] +use events::{ + ACTOR_KIND_PI, CWD_FIELD, HOOK_EVENT_NAME_FIELD, HOOK_EVENT_TOOL_CALL, + HOOK_EVENT_TOOL_EXECUTION_ABANDON, HOOK_EVENT_TOOL_EXECUTION_END, + HOOK_EVENT_TOOL_EXECUTION_START, HOOK_EVENT_TOOL_RESULT, MODEL_FIELD, SESSION_ID_FIELD, + TOOL_CALL_ID_FIELD, TOOL_NAME_FIELD, +}; #[cfg(test)] -pub(crate) fn run_pi_mutation_scope_from_payload_at_state_root( - state_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload_at_state_root( - repository_root, - state_root, - payload, - logger, - ) - }; - - run_pi_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - ) -} - -type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; - -type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; - -const FAIL_CLOSED_MESSAGE: &str = - "SCE could not establish Pi mutation attribution for this tool execution."; - -const FAIL_CLOSED_EVENT: &str = "sce.hooks.pi_mutation_scope.start_fail_closed"; - -fn log_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { - if let Some(log) = logger { - log.warn( - FAIL_CLOSED_EVENT, - &error.to_string(), - &[("context", context)], - None, - ); - } -} - -fn run_pi_mutation_scope_from_payload_with_seams( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let event = parse_pi_hook_event(stdin_payload)?; - dispatch_pi_hook_event(event, logger, resolve_git_dir, seam) -} - -fn dispatch_pi_hook_event( - event: PiHookEvent, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - match event { - PiHookEvent::ExecutionStart(_identity) => Ok(String::new()), - PiHookEvent::Call(call) => match call.identity.classification() { - ToolClassification::TrackedMutation => { - let provenance = - pi_scope_provenance(&call.identity.session_id, call.model.as_deref()); - establish_tracked_start( - &call.identity.cwd, - &call.identity.attempt_key(), - &call.identity.tool_name, - &provenance, - logger, - resolve_git_dir, - seam, - ) - } - ToolClassification::Untracked => Ok(String::new()), - }, - PiHookEvent::Executed(identity) => { - if !matches!( - identity.classification(), - ToolClassification::TrackedMutation - ) { - return Ok(String::new()); - } - let git_dir = resolve_git_dir(&identity.cwd)?; - state::mark_executed(&git_dir, &identity.attempt_key())?; - Ok(String::new()) - } - PiHookEvent::ExecutionEnd(identity) => { - if !matches!( - identity.classification(), - ToolClassification::TrackedMutation - ) { - return Ok(String::new()); - } - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - let key = identity.attempt_key(); - with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - handle_tool_execution_end(&git_dir, repository_root, &key, logger, seam) - }) - } - PiHookEvent::ExecutionAbandon(identity) => { - if !matches!( - identity.classification(), - ToolClassification::TrackedMutation - ) { - return Ok(String::new()); - } - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - let key = identity.attempt_key(); - with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - force_abandon_attempt(&git_dir, repository_root, &key, logger, seam) - }) - } - } -} - -fn with_boundary_lock(git_dir: &Path, operation: impl FnOnce() -> Result) -> Result { - let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) - .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; - operation() -} - -enum Admission { - Admitted(state::AllocatedAttempt), - Denied, -} - -enum StartOutcome { - Established, - Denied, -} - -fn establish_tracked_start( - cwd: &str, - key: &AttemptKey, - tool_name: &str, - provenance: &PiScopeProvenance, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let git_dir = match resolve_git_dir(cwd) { - Ok(git_dir) => git_dir, - Err(error) => { - log_fail_closed(logger, "resolve_git_dir", &error); - return Err(error.context(FAIL_CLOSED_MESSAGE)); - } - }; - let repository_root = Path::new(cwd); - - let outcome = with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - - match admit_or_recover(&git_dir, repository_root, key, tool_name, logger, seam)? { - Admission::Admitted(allocated) => { - establish_start( - &git_dir, - repository_root, - &allocated, - provenance, - logger, - seam, - )?; - Ok(StartOutcome::Established) - } - Admission::Denied => Ok(StartOutcome::Denied), - } - }); - - match outcome { - Ok(StartOutcome::Established) => Ok(String::new()), - Ok(StartOutcome::Denied) => bail!(FAIL_CLOSED_MESSAGE), - Err(error) => { - log_fail_closed(logger, "establish_tracked_start", &error); - Err(error.context(FAIL_CLOSED_MESSAGE)) - } - } -} - -fn admit_or_recover( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - tool_name: &str, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - match reconcile_stale_owners(git_dir, repository_root, logger, seam)? { - RecoveryResolution::Cleared => {} - RecoveryResolution::Unresolved => return Ok(Admission::Denied), - } - - match state::admit_tracked_attempt(git_dir, key, tool_name)? { - AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - AdmitDecision::RecoveryBlocked - | AdmitDecision::UncertainAttemptBlocked - | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), - AdmitDecision::FlushClaimed { generation } => { - match resolve_recovery(git_dir, repository_root, generation, logger, seam)? { - RecoveryResolution::Cleared => readmit_after_flush(git_dir, key, tool_name), - RecoveryResolution::Unresolved => Ok(Admission::Denied), - } - } - } -} - -fn reconcile_stale_owners( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - loop { - let dead_scope_ids = state::find_definitely_dead_attempts(git_dir)?; - if dead_scope_ids.is_empty() { - return Ok(RecoveryResolution::Cleared); - } - - let generation = state::begin_terminal_cleanup(git_dir, &dead_scope_ids)?; - if matches!( - resolve_recovery(git_dir, repository_root, generation, logger, seam)?, - RecoveryResolution::Unresolved - ) { - return Ok(RecoveryResolution::Unresolved); - } - } -} - -fn readmit_after_flush(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> Result { - match state::admit_tracked_attempt(git_dir, key, tool_name)? { - AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - AdmitDecision::FlushClaimed { generation } => { - state::relinquish_recovery_flush(git_dir, generation)?; - Ok(Admission::Denied) - } - AdmitDecision::RecoveryBlocked - | AdmitDecision::UncertainAttemptBlocked - | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), - } -} - -fn establish_start( - _git_dir: &Path, - repository_root: &Path, - allocated: &state::AllocatedAttempt, - provenance: &PiScopeProvenance, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - let scope_id = &allocated.attempt.scope_id; - - let start_payload = - scope_start_payload(scope_id, &pi_scope_start_event_id(scope_id), provenance); - - seam(repository_root, &start_payload, logger)?; - Ok(()) -} - -fn handle_tool_execution_end( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| { - attempt.session_id == key.session_id && attempt.tool_call_id == key.tool_call_id - }) - .cloned() - else { - return Ok(String::new()); - }; - - let doomed_scope_id = attempt.scope_id.clone(); - - if !matches!(attempt.phase, state::AttemptPhase::Executed) { - return abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { - candidate.scope_id == doomed_scope_id - }); - } - - let close_payload = scope_boundary_payload( - "close", - &attempt.scope_id, - &pi_scope_close_event_id(&attempt.scope_id), - ); - - if seam(repository_root, &close_payload, logger).is_ok() { - state::remove_attempt(git_dir, &attempt.scope_id)?; - Ok(String::new()) - } else { - abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { - candidate.scope_id == doomed_scope_id - }) - } -} - -fn force_abandon_attempt( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| { - attempt.session_id == key.session_id && attempt.tool_call_id == key.tool_call_id - }) - .cloned() - else { - return Ok(String::new()); - }; - - let doomed_scope_id = attempt.scope_id.clone(); - abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { - candidate.scope_id == doomed_scope_id - }) -} - -enum RecoveryResolution { - Cleared, - Unresolved, -} - -fn abandon_and_consume( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, - doomed: impl Fn(&state::AdapterAttempt) -> bool, -) -> Result { - let doomed_scope_ids: Vec = state::read_state(git_dir)? - .attempts - .into_iter() - .filter(|attempt| doomed(attempt)) - .map(|attempt| attempt.scope_id) - .collect(); - if doomed_scope_ids.is_empty() { - return Ok(String::new()); - } - - let generation = state::begin_terminal_cleanup(git_dir, &doomed_scope_ids)?; - resolve_recovery(git_dir, repository_root, generation, logger, seam)?; - Ok(String::new()) -} - -fn resolve_recovery( - git_dir: &Path, - repository_root: &Path, - generation: u64, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let pending_abandon: Vec = state::read_state(git_dir)? - .attempts - .into_iter() - .filter(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon) - .collect(); - - if let Err(error) = seam(repository_root, &flush_payload(), logger) { - log_fail_closed(logger, "recovery_ambiguity_flush", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - - for attempt in &pending_abandon { - if let Err(error) = seam(repository_root, &abandon_payload(&attempt.scope_id), logger) { - log_fail_closed(logger, "recovery_abandon", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - state::remove_attempt(git_dir, &attempt.scope_id)?; - } - - if let Err(error) = seam(repository_root, &flush_payload(), logger) { - log_fail_closed(logger, "recovery_rebaseline_flush", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - - match state::complete_recovery_flush(git_dir, generation)? { - RecoveryFlushCompletion::Cleared => Ok(RecoveryResolution::Cleared), - RecoveryFlushCompletion::Superseded => Ok(RecoveryResolution::Unresolved), - } -} - -fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { - json!({ - "operation": operation, - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_PI, - }) - .to_string() -} - -fn scope_start_payload(scope_id: &str, event_id: &str, provenance: &PiScopeProvenance) -> String { - json!({ - "operation": "start", - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_PI, - "provenance": { - "session_id": provenance.session_id, - "model_id": provenance.model_id, - }, - }) - .to_string() -} - -fn abandon_payload(scope_id: &str) -> String { - json!({ - "operation": "abandon", - "scope_id": scope_id, - }) - .to_string() -} - -fn flush_payload() -> String { - json!({ "operation": "flush" }).to_string() -} - +#[allow(unused_imports)] +pub(crate) use lifecycle::force_attempt_owner_dead_for_tests; #[cfg(test)] -pub(crate) fn force_attempt_owner_dead_for_tests(git_dir: &Path, scope_id: &str) { - let mut dead_child = std::process::Command::new("true") - .spawn() - .expect("spawning 'true' should succeed"); - let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); - dead_child.wait().expect("child should exit and be reaped"); - state::set_attempt_owner_for_tests( - git_dir, - scope_id, - process_owner::ProcessOwner { - pid: dead_pid, - instance_token: None, - }, - ); -} +pub(crate) use lifecycle::run_pi_mutation_scope_from_payload_at_state_root; +#[allow(unused_imports)] +pub(crate) use lifecycle::{run_pi_mutation_scope_from_payload, run_pi_mutation_scope_subcommand}; +#[allow(unused_imports)] +use lifecycle::{ + run_pi_mutation_scope_from_payload_with_seams, FAIL_CLOSED_EVENT, FAIL_CLOSED_MESSAGE, +}; +#[allow(unused_imports)] +use payload::{abandon_payload, flush_payload, scope_boundary_payload, scope_start_payload}; #[cfg(test)] mod tests; diff --git a/cli/src/services/hooks/pi_mutation_scope/payload.rs b/cli/src/services/hooks/pi_mutation_scope/payload.rs new file mode 100644 index 00000000..4b2e7dc8 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/payload.rs @@ -0,0 +1,44 @@ +use serde_json::json; + +use super::events::ACTOR_KIND_PI; +use super::PiScopeProvenance; + +pub(super) fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_PI, + }) + .to_string() +} + +pub(super) fn scope_start_payload( + scope_id: &str, + event_id: &str, + provenance: &PiScopeProvenance, +) -> String { + json!({ + "operation": "start", + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_PI, + "provenance": { + "session_id": provenance.session_id, + "model_id": provenance.model_id, + }, + }) + .to_string() +} + +pub(super) fn abandon_payload(scope_id: &str) -> String { + json!({ + "operation": "abandon", + "scope_id": scope_id, + }) + .to_string() +} + +pub(super) fn flush_payload() -> String { + json!({ "operation": "flush" }).to_string() +} From 0fd7ac0693d9fb8dd535a0b78899e060491c7f0b Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 01:37:11 +0200 Subject: [PATCH 06/16] runtime: Fix OpenCode mutation scope recovery Prevent concurrent tool failures and cleanup errors from misattributing mutations by separating hook parsing, lifecycle dispatch, payload construction, and coverage. Track per-call cleanup state, consume ambiguous intervals with recovery barriers, preserve sibling scopes, and fail closed until recovery succeeds. Co-authored-by: SCE --- .../hooks/opencode_mutation_scope/events.rs | 316 ++ .../hooks/opencode_mutation_scope/health.rs | 3 +- .../opencode_mutation_scope/lifecycle.rs | 413 +++ .../hooks/opencode_mutation_scope/mod.rs | 2736 +---------------- .../hooks/opencode_mutation_scope/payload.rs | 43 + .../hooks/opencode_mutation_scope/tests.rs | 1993 ++++++++++++ 6 files changed, 2774 insertions(+), 2730 deletions(-) create mode 100644 cli/src/services/hooks/opencode_mutation_scope/events.rs create mode 100644 cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs create mode 100644 cli/src/services/hooks/opencode_mutation_scope/payload.rs create mode 100644 cli/src/services/hooks/opencode_mutation_scope/tests.rs diff --git a/cli/src/services/hooks/opencode_mutation_scope/events.rs b/cli/src/services/hooks/opencode_mutation_scope/events.rs new file mode 100644 index 00000000..132b7d68 --- /dev/null +++ b/cli/src/services/hooks/opencode_mutation_scope/events.rs @@ -0,0 +1,316 @@ +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{Map, Value}; + +use crate::services::hooks::{ + normalize_opencode_model_id, prefixed_diff_trace_session_id, OPENCODE_TOOL_NAME, +}; + +pub(super) const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; +pub(super) const SESSION_ID_FIELD: &str = "session_id"; +pub(super) const CALL_ID_FIELD: &str = "call_id"; +pub(super) const CWD_FIELD: &str = "cwd"; +pub(super) const TOOL_NAME_FIELD: &str = "tool_name"; +pub(super) const MODEL_FIELD: &str = "model"; + +pub(super) const HOOK_EVENT_TOOL_EXECUTE_BEFORE: &str = "ToolExecuteBefore"; +pub(super) const HOOK_EVENT_SHELL_ENV: &str = "ShellEnv"; +pub(super) const HOOK_EVENT_TOOL_EXECUTE_AFTER: &str = "ToolExecuteAfter"; +pub(super) const HOOK_EVENT_TOOL_ERROR: &str = "ToolError"; +pub(super) const HOOK_EVENT_SESSION_IDLE: &str = "SessionIdle"; +pub(super) const HOOK_EVENT_SESSION_ERROR: &str = "SessionError"; +pub(super) const HOOK_EVENT_SESSION_DELETED: &str = "SessionDeleted"; +pub(super) const HOOK_EVENT_SERVER_DISPOSED: &str = "ServerDisposed"; + +pub(super) const OPENCODE_TRACKED_TOOL_BASH: &str = "bash"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum OpenCodeHookEvent { + ToolExecuteBefore(OpenCodeToolExecution), + ShellEnv(OpenCodeShellStart), + ToolExecuteAfter(OpenCodeToolIdentity), + ToolError(OpenCodeCallIdentity), + SessionIdle(OpenCodeSessionIdentity), + SessionError(OpenCodeSessionIdentity), + SessionDeleted(OpenCodeSessionIdentity), + ServerDisposed(OpenCodeWorkspaceIdentity), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeCallIdentity { + pub session_id: String, + pub call_id: String, + pub cwd: String, + pub tool_name: String, +} + +impl OpenCodeCallIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + call_id: self.call_id.clone(), + } + } + + pub(crate) fn classification(&self) -> ToolClassification { + classify_tool(&self.tool_name) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeToolIdentity { + pub session_id: String, + pub call_id: String, + pub cwd: String, + pub tool_name: String, +} + +impl OpenCodeToolIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + call_id: self.call_id.clone(), + } + } + + pub(crate) fn classification(&self) -> ToolClassification { + classify_tool(&self.tool_name) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeToolExecution { + pub identity: OpenCodeToolIdentity, + pub model: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeShellStart { + pub session_id: String, + pub call_id: String, + pub cwd: String, + pub model: Option, +} + +impl OpenCodeShellStart { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + call_id: self.call_id.clone(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeSessionIdentity { + pub session_id: String, + pub cwd: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeWorkspaceIdentity { + pub cwd: String, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct AttemptKey { + pub session_id: String, + pub call_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolClassification { + TrackedMutation, + Delegation, + Untracked, +} + +pub(super) const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["bash", "write", "edit", "apply_patch"]; +pub(super) const DELEGATION_TOOL_NAMES: &[&str] = &["task"]; + +pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { + if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::TrackedMutation + } else if DELEGATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::Delegation + } else { + ToolClassification::Untracked + } +} + +pub(super) const OPENCODE_SCOPE_ID_SCHEME: &str = "oc-tool-v1"; + +pub(crate) fn format_opencode_scope_id(key: &AttemptKey) -> String { + format!( + "{OPENCODE_SCOPE_ID_SCHEME}|s={}:{}|c={}:{}", + key.session_id.len(), + key.session_id, + key.call_id.len(), + key.call_id, + ) +} + +pub(crate) fn opencode_scope_start_event_id(scope_id: &str) -> String { + format!("{scope_id}|start") +} + +pub(crate) fn opencode_scope_close_event_id(scope_id: &str) -> String { + format!("{scope_id}|close") +} + +pub(super) const ACTOR_KIND_OPENCODE: &str = "opencode"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeScopeProvenance { + pub session_id: String, + pub model_id: Option, +} + +pub(crate) fn opencode_scope_provenance( + session_id: &str, + model: Option<&str>, +) -> OpenCodeScopeProvenance { + OpenCodeScopeProvenance { + session_id: prefixed_diff_trace_session_id(OPENCODE_TOOL_NAME, session_id), + model_id: model.and_then(normalize_opencode_model_id), + } +} + +pub(crate) fn parse_opencode_hook_event(stdin_payload: &str) -> Result { + if stdin_payload.trim().is_empty() { + bail!(validation_error( + "expected a JSON object, got an empty payload" + )); + } + + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| validation_error("expected valid JSON"))?; + let object = parsed + .as_object() + .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; + + let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; + + match hook_event_name.as_str() { + HOOK_EVENT_TOOL_EXECUTE_BEFORE => { + parse_tool_execution(object).map(OpenCodeHookEvent::ToolExecuteBefore) + } + HOOK_EVENT_SHELL_ENV => parse_shell_start(object).map(OpenCodeHookEvent::ShellEnv), + HOOK_EVENT_TOOL_EXECUTE_AFTER => { + parse_tool_identity(object).map(OpenCodeHookEvent::ToolExecuteAfter) + } + HOOK_EVENT_TOOL_ERROR => parse_call_identity(object).map(OpenCodeHookEvent::ToolError), + HOOK_EVENT_SESSION_IDLE => { + parse_session_identity(object).map(OpenCodeHookEvent::SessionIdle) + } + HOOK_EVENT_SESSION_ERROR => { + parse_session_identity(object).map(OpenCodeHookEvent::SessionError) + } + HOOK_EVENT_SESSION_DELETED => { + parse_session_identity(object).map(OpenCodeHookEvent::SessionDeleted) + } + HOOK_EVENT_SERVER_DISPOSED => { + parse_workspace_identity(object).map(OpenCodeHookEvent::ServerDisposed) + } + other => bail!(validation_error(&format!( + "unsupported hook_event_name '{other}'" + ))), + } +} + +fn parse_tool_identity(object: &Map) -> Result { + Ok(OpenCodeToolIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + call_id: required_non_blank_str(object, CALL_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + }) +} + +fn parse_tool_execution(object: &Map) -> Result { + Ok(OpenCodeToolExecution { + identity: parse_tool_identity(object)?, + model: optional_non_blank_str(object, MODEL_FIELD)?, + }) +} + +fn parse_shell_start(object: &Map) -> Result { + Ok(OpenCodeShellStart { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + call_id: required_non_blank_str(object, CALL_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + model: optional_non_blank_str(object, MODEL_FIELD)?, + }) +} + +fn parse_call_identity(object: &Map) -> Result { + Ok(OpenCodeCallIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + call_id: required_non_blank_str(object, CALL_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + }) +} + +fn parse_session_identity(object: &Map) -> Result { + Ok(OpenCodeSessionIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +fn parse_workspace_identity(object: &Map) -> Result { + Ok(OpenCodeWorkspaceIdentity { + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { + object.get(field).ok_or_else(|| { + anyhow!(validation_error(&format!( + "missing required field '{field}'" + ))) + }) +} + +fn required_str(object: &Map, field: &str) -> Result { + required_field(object, field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field}' must be a string" + ))) + }) +} + +fn required_non_blank_str(object: &Map, field: &str) -> Result { + let value = required_str(object, field)?; + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be a non-blank string" + ))); + } + Ok(value) +} + +fn optional_non_blank_str(object: &Map, field: &str) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => { + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))); + } + Ok(Some(value.clone())) + } + Some(_) => bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))), + } +} + +fn validation_error(detail: &str) -> String { + format!("Invalid OpenCode hook event payload from STDIN: {detail}.") +} diff --git a/cli/src/services/hooks/opencode_mutation_scope/health.rs b/cli/src/services/hooks/opencode_mutation_scope/health.rs index baeddac8..336a4e58 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/health.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/health.rs @@ -66,7 +66,8 @@ mod tests { use serde_json::{json, Value}; - use super::super::{run_opencode_mutation_scope_from_payload_with_seams, AttemptKey}; + use super::super::events::AttemptKey; + use super::super::lifecycle::run_opencode_mutation_scope_from_payload_with_seams; use super::*; use crate::services::observability::traits::Logger; diff --git a/cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs b/cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs new file mode 100644 index 00000000..02f68c51 --- /dev/null +++ b/cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs @@ -0,0 +1,413 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Result}; + +use crate::services::hooks; +use crate::services::mutation_trace::runtime::resolve_git_dir; +use crate::services::observability::traits::Logger; + +use super::boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; +use super::events::{ + opencode_scope_close_event_id, opencode_scope_provenance, opencode_scope_start_event_id, + parse_opencode_hook_event, AttemptKey, OpenCodeHookEvent, OpenCodeScopeProvenance, + ToolClassification, OPENCODE_TRACKED_TOOL_BASH, +}; +use super::payload::{abandon_payload, flush_payload, scope_boundary_payload, scope_start_payload}; +use super::state::{self, AdmitDecision, RecoveryFlushCompletion}; + +pub(crate) fn run_opencode_mutation_scope_subcommand( + logger: Option<&dyn Logger>, +) -> Result { + let stdin_payload = hooks::read_hook_stdin()?; + run_opencode_mutation_scope_from_payload(&stdin_payload, logger) +} + +pub(crate) fn run_opencode_mutation_scope_from_payload( + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) + }; + + run_opencode_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + ) +} + +#[cfg(test)] +pub(crate) fn run_opencode_mutation_scope_from_payload_at_state_root( + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + state_root, + payload, + logger, + ) + }; + + run_opencode_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + ) +} + +pub(super) type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; + +pub(super) type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; + +pub(super) const FAIL_CLOSED_MESSAGE: &str = + "SCE could not establish OpenCode mutation attribution for this tool execution."; + +pub(super) const FAIL_CLOSED_EVENT: &str = "sce.hooks.opencode_mutation_scope.start_fail_closed"; + +pub(super) fn log_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { + if let Some(log) = logger { + log.warn( + FAIL_CLOSED_EVENT, + &error.to_string(), + &[("context", context)], + None, + ); + } +} + +pub(super) fn run_opencode_mutation_scope_from_payload_with_seams( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let event = parse_opencode_hook_event(stdin_payload)?; + dispatch_opencode_hook_event(event, logger, resolve_git_dir, seam) +} + +pub(super) fn dispatch_opencode_hook_event( + event: OpenCodeHookEvent, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + match event { + OpenCodeHookEvent::ToolExecuteBefore(execution) => { + match execution.identity.classification() { + ToolClassification::TrackedMutation => { + if execution.identity.tool_name == OPENCODE_TRACKED_TOOL_BASH { + return Ok(String::new()); + } + let provenance = opencode_scope_provenance( + &execution.identity.session_id, + execution.model.as_deref(), + ); + establish_tracked_start( + &execution.identity.cwd, + &execution.identity.attempt_key(), + &execution.identity.tool_name, + &provenance, + logger, + resolve_git_dir, + seam, + ) + } + ToolClassification::Delegation | ToolClassification::Untracked => Ok(String::new()), + } + } + OpenCodeHookEvent::ShellEnv(shell) => { + let provenance = opencode_scope_provenance(&shell.session_id, shell.model.as_deref()); + establish_tracked_start( + &shell.cwd, + &shell.attempt_key(), + OPENCODE_TRACKED_TOOL_BASH, + &provenance, + logger, + resolve_git_dir, + seam, + ) + } + OpenCodeHookEvent::ToolExecuteAfter(identity) => { + if !matches!( + identity.classification(), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + let key = identity.attempt_key(); + with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + handle_close(&git_dir, repository_root, &key, logger, seam) + }) + } + OpenCodeHookEvent::ToolError(call) => { + if !matches!(call.classification(), ToolClassification::TrackedMutation) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&call.cwd)?; + let repository_root = Path::new(&call.cwd); + let key = call.attempt_key(); + with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + abandon_and_consume(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == key.session_id && attempt.call_id == key.call_id + }) + }) + } + OpenCodeHookEvent::SessionIdle(_) + | OpenCodeHookEvent::SessionError(_) + | OpenCodeHookEvent::SessionDeleted(_) + | OpenCodeHookEvent::ServerDisposed(_) => Ok(String::new()), + } +} + +pub(super) fn with_boundary_lock( + git_dir: &Path, + operation: impl FnOnce() -> Result, +) -> Result { + let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) + .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; + operation() +} + +pub(super) enum Admission { + Admitted(state::AllocatedAttempt), + Denied, +} + +pub(super) enum StartOutcome { + Established, + Denied, +} + +pub(super) fn establish_tracked_start( + cwd: &str, + key: &AttemptKey, + tool_name: &str, + provenance: &OpenCodeScopeProvenance, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let git_dir = match resolve_git_dir(cwd) { + Ok(git_dir) => git_dir, + Err(error) => { + log_fail_closed(logger, "resolve_git_dir", &error); + return Err(error.context(FAIL_CLOSED_MESSAGE)); + } + }; + let repository_root = Path::new(cwd); + + let outcome = with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + + match admit_or_recover(&git_dir, repository_root, key, tool_name, logger, seam)? { + Admission::Admitted(allocated) => { + establish_start( + &git_dir, + repository_root, + &allocated, + provenance, + logger, + seam, + )?; + Ok(StartOutcome::Established) + } + Admission::Denied => Ok(StartOutcome::Denied), + } + }); + + match outcome { + Ok(StartOutcome::Established) => Ok(String::new()), + Ok(StartOutcome::Denied) => bail!(FAIL_CLOSED_MESSAGE), + Err(error) => { + log_fail_closed(logger, "establish_tracked_start", &error); + Err(error.context(FAIL_CLOSED_MESSAGE)) + } + } +} + +pub(super) fn admit_or_recover( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + tool_name: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, tool_name)? { + AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + AdmitDecision::RecoveryBlocked + | AdmitDecision::UncertainAttemptBlocked + | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), + AdmitDecision::FlushClaimed { generation } => { + match resolve_recovery(git_dir, repository_root, generation, logger, seam)? { + RecoveryResolution::Cleared => readmit_after_flush(git_dir, key, tool_name), + RecoveryResolution::Unresolved => Ok(Admission::Denied), + } + } + } +} + +pub(super) fn readmit_after_flush( + git_dir: &Path, + key: &AttemptKey, + tool_name: &str, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, tool_name)? { + AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + AdmitDecision::FlushClaimed { generation } => { + state::relinquish_recovery_flush(git_dir, generation)?; + Ok(Admission::Denied) + } + AdmitDecision::RecoveryBlocked + | AdmitDecision::UncertainAttemptBlocked + | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), + } +} + +pub(super) fn establish_start( + git_dir: &Path, + repository_root: &Path, + allocated: &state::AllocatedAttempt, + provenance: &OpenCodeScopeProvenance, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + let scope_id = &allocated.attempt.scope_id; + + if allocated.reused && allocated.attempt.phase == state::AttemptPhase::Active { + return Ok(()); + } + + let start_payload = scope_start_payload( + scope_id, + &opencode_scope_start_event_id(scope_id), + provenance, + ); + + seam(repository_root, &start_payload, logger)?; + state::mark_active(git_dir, scope_id)?; + Ok(()) +} + +pub(super) fn handle_close( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| attempt.session_id == key.session_id && attempt.call_id == key.call_id) + .cloned() + else { + return Ok(String::new()); + }; + + let doomed_scope_id = attempt.scope_id.clone(); + + if matches!( + attempt.phase, + state::AttemptPhase::PendingStart | state::AttemptPhase::PendingAbandon + ) { + return abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { + candidate.scope_id == doomed_scope_id + }); + } + + let close_payload = scope_boundary_payload( + "close", + &attempt.scope_id, + &opencode_scope_close_event_id(&attempt.scope_id), + ); + + if seam(repository_root, &close_payload, logger).is_ok() { + state::remove_attempt(git_dir, &attempt.scope_id)?; + Ok(String::new()) + } else { + abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { + candidate.scope_id == doomed_scope_id + }) + } +} + +pub(super) enum RecoveryResolution { + Cleared, + Unresolved, +} + +pub(super) fn abandon_and_consume( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, + doomed: impl Fn(&state::AdapterAttempt) -> bool, +) -> Result { + let doomed_scope_ids: Vec = state::read_state(git_dir)? + .attempts + .into_iter() + .filter(|attempt| doomed(attempt)) + .map(|attempt| attempt.scope_id) + .collect(); + if doomed_scope_ids.is_empty() { + return Ok(String::new()); + } + + let generation = state::begin_terminal_cleanup(git_dir, &doomed_scope_ids)?; + resolve_recovery(git_dir, repository_root, generation, logger, seam)?; + Ok(String::new()) +} + +pub(super) fn resolve_recovery( + git_dir: &Path, + repository_root: &Path, + generation: u64, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let pending_abandon: Vec = state::read_state(git_dir)? + .attempts + .into_iter() + .filter(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon) + .collect(); + + if let Err(error) = seam(repository_root, &flush_payload(), logger) { + log_fail_closed(logger, "recovery_ambiguity_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + + for attempt in &pending_abandon { + if let Err(error) = seam(repository_root, &abandon_payload(&attempt.scope_id), logger) { + log_fail_closed(logger, "recovery_abandon", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + state::remove_attempt(git_dir, &attempt.scope_id)?; + } + + if let Err(error) = seam(repository_root, &flush_payload(), logger) { + log_fail_closed(logger, "recovery_rebaseline_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + + match state::complete_recovery_flush(git_dir, generation)? { + RecoveryFlushCompletion::Cleared => Ok(RecoveryResolution::Cleared), + RecoveryFlushCompletion::Superseded => Ok(RecoveryResolution::Unresolved), + } +} diff --git a/cli/src/services/hooks/opencode_mutation_scope/mod.rs b/cli/src/services/hooks/opencode_mutation_scope/mod.rs index 8a42f86b..2617972a 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/mod.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/mod.rs @@ -1,2740 +1,18 @@ #![allow(dead_code)] mod boundary_lock; +mod events; pub(crate) mod health; +mod lifecycle; mod os_lock; +mod payload; pub(crate) mod state; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, bail, Context, Result}; -use serde_json::{json, Map, Value}; - -use crate::services::hooks::{ - normalize_opencode_model_id, prefixed_diff_trace_session_id, OPENCODE_TOOL_NAME, -}; -use crate::services::mutation_trace::runtime::resolve_git_dir; -use crate::services::observability::traits::Logger; - -use boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; -use state::{AdmitDecision, RecoveryFlushCompletion}; - -const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; -const SESSION_ID_FIELD: &str = "session_id"; -const CALL_ID_FIELD: &str = "call_id"; -const CWD_FIELD: &str = "cwd"; -const TOOL_NAME_FIELD: &str = "tool_name"; -const MODEL_FIELD: &str = "model"; - -const HOOK_EVENT_TOOL_EXECUTE_BEFORE: &str = "ToolExecuteBefore"; -const HOOK_EVENT_SHELL_ENV: &str = "ShellEnv"; -const HOOK_EVENT_TOOL_EXECUTE_AFTER: &str = "ToolExecuteAfter"; -const HOOK_EVENT_TOOL_ERROR: &str = "ToolError"; -const HOOK_EVENT_SESSION_IDLE: &str = "SessionIdle"; -const HOOK_EVENT_SESSION_ERROR: &str = "SessionError"; -const HOOK_EVENT_SESSION_DELETED: &str = "SessionDeleted"; -const HOOK_EVENT_SERVER_DISPOSED: &str = "ServerDisposed"; - -const OPENCODE_TRACKED_TOOL_BASH: &str = "bash"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum OpenCodeHookEvent { - ToolExecuteBefore(OpenCodeToolExecution), - ShellEnv(OpenCodeShellStart), - ToolExecuteAfter(OpenCodeToolIdentity), - ToolError(OpenCodeCallIdentity), - SessionIdle(OpenCodeSessionIdentity), - SessionError(OpenCodeSessionIdentity), - SessionDeleted(OpenCodeSessionIdentity), - ServerDisposed(OpenCodeWorkspaceIdentity), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeCallIdentity { - pub session_id: String, - pub call_id: String, - pub cwd: String, - pub tool_name: String, -} - -impl OpenCodeCallIdentity { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - call_id: self.call_id.clone(), - } - } - - pub(crate) fn classification(&self) -> ToolClassification { - classify_tool(&self.tool_name) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeToolIdentity { - pub session_id: String, - pub call_id: String, - pub cwd: String, - pub tool_name: String, -} - -impl OpenCodeToolIdentity { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - call_id: self.call_id.clone(), - } - } - - pub(crate) fn classification(&self) -> ToolClassification { - classify_tool(&self.tool_name) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeToolExecution { - pub identity: OpenCodeToolIdentity, - pub model: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeShellStart { - pub session_id: String, - pub call_id: String, - pub cwd: String, - pub model: Option, -} - -impl OpenCodeShellStart { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - call_id: self.call_id.clone(), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeSessionIdentity { - pub session_id: String, - pub cwd: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeWorkspaceIdentity { - pub cwd: String, -} - -#[allow(clippy::struct_field_names)] -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct AttemptKey { - pub session_id: String, - pub call_id: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ToolClassification { - TrackedMutation, - Delegation, - Untracked, -} - -const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["bash", "write", "edit", "apply_patch"]; -const DELEGATION_TOOL_NAMES: &[&str] = &["task"]; - -pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { - if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { - ToolClassification::TrackedMutation - } else if DELEGATION_TOOL_NAMES.contains(&tool_name) { - ToolClassification::Delegation - } else { - ToolClassification::Untracked - } -} - -const OPENCODE_SCOPE_ID_SCHEME: &str = "oc-tool-v1"; - -pub(crate) fn format_opencode_scope_id(key: &AttemptKey) -> String { - format!( - "{OPENCODE_SCOPE_ID_SCHEME}|s={}:{}|c={}:{}", - key.session_id.len(), - key.session_id, - key.call_id.len(), - key.call_id, - ) -} - -pub(crate) fn opencode_scope_start_event_id(scope_id: &str) -> String { - format!("{scope_id}|start") -} - -pub(crate) fn opencode_scope_close_event_id(scope_id: &str) -> String { - format!("{scope_id}|close") -} - -const ACTOR_KIND_OPENCODE: &str = "opencode"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeScopeProvenance { - pub session_id: String, - pub model_id: Option, -} - -pub(crate) fn opencode_scope_provenance( - session_id: &str, - model: Option<&str>, -) -> OpenCodeScopeProvenance { - OpenCodeScopeProvenance { - session_id: prefixed_diff_trace_session_id(OPENCODE_TOOL_NAME, session_id), - model_id: model.and_then(normalize_opencode_model_id), - } -} - -pub(crate) fn parse_opencode_hook_event(stdin_payload: &str) -> Result { - if stdin_payload.trim().is_empty() { - bail!(validation_error( - "expected a JSON object, got an empty payload" - )); - } - - let parsed: Value = serde_json::from_str(stdin_payload) - .with_context(|| validation_error("expected valid JSON"))?; - let object = parsed - .as_object() - .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; - - let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; - - match hook_event_name.as_str() { - HOOK_EVENT_TOOL_EXECUTE_BEFORE => { - parse_tool_execution(object).map(OpenCodeHookEvent::ToolExecuteBefore) - } - HOOK_EVENT_SHELL_ENV => parse_shell_start(object).map(OpenCodeHookEvent::ShellEnv), - HOOK_EVENT_TOOL_EXECUTE_AFTER => { - parse_tool_identity(object).map(OpenCodeHookEvent::ToolExecuteAfter) - } - HOOK_EVENT_TOOL_ERROR => parse_call_identity(object).map(OpenCodeHookEvent::ToolError), - HOOK_EVENT_SESSION_IDLE => { - parse_session_identity(object).map(OpenCodeHookEvent::SessionIdle) - } - HOOK_EVENT_SESSION_ERROR => { - parse_session_identity(object).map(OpenCodeHookEvent::SessionError) - } - HOOK_EVENT_SESSION_DELETED => { - parse_session_identity(object).map(OpenCodeHookEvent::SessionDeleted) - } - HOOK_EVENT_SERVER_DISPOSED => { - parse_workspace_identity(object).map(OpenCodeHookEvent::ServerDisposed) - } - other => bail!(validation_error(&format!( - "unsupported hook_event_name '{other}'" - ))), - } -} - -fn parse_tool_identity(object: &Map) -> Result { - Ok(OpenCodeToolIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - call_id: required_non_blank_str(object, CALL_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, - }) -} - -fn parse_tool_execution(object: &Map) -> Result { - Ok(OpenCodeToolExecution { - identity: parse_tool_identity(object)?, - model: optional_non_blank_str(object, MODEL_FIELD)?, - }) -} - -fn parse_shell_start(object: &Map) -> Result { - Ok(OpenCodeShellStart { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - call_id: required_non_blank_str(object, CALL_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - model: optional_non_blank_str(object, MODEL_FIELD)?, - }) -} - -fn parse_call_identity(object: &Map) -> Result { - Ok(OpenCodeCallIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - call_id: required_non_blank_str(object, CALL_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, - }) -} - -fn parse_session_identity(object: &Map) -> Result { - Ok(OpenCodeSessionIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - }) -} - -fn parse_workspace_identity(object: &Map) -> Result { - Ok(OpenCodeWorkspaceIdentity { - cwd: required_non_blank_str(object, CWD_FIELD)?, - }) -} - -fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { - object.get(field).ok_or_else(|| { - anyhow!(validation_error(&format!( - "missing required field '{field}'" - ))) - }) -} - -fn required_str(object: &Map, field: &str) -> Result { - required_field(object, field)? - .as_str() - .map(str::to_owned) - .ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{field}' must be a string" - ))) - }) -} - -fn required_non_blank_str(object: &Map, field: &str) -> Result { - let value = required_str(object, field)?; - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be a non-blank string" - ))); - } - Ok(value) -} - -fn optional_non_blank_str(object: &Map, field: &str) -> Result> { - match object.get(field) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => { - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))); - } - Ok(Some(value.clone())) - } - Some(_) => bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))), - } -} - -fn validation_error(detail: &str) -> String { - format!("Invalid OpenCode hook event payload from STDIN: {detail}.") -} - -pub(crate) fn run_opencode_mutation_scope_subcommand( - logger: Option<&dyn Logger>, -) -> Result { - let stdin_payload = super::read_hook_stdin()?; - run_opencode_mutation_scope_from_payload(&stdin_payload, logger) -} - -pub(crate) fn run_opencode_mutation_scope_from_payload( - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) - }; - - run_opencode_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - ) -} +pub(crate) use events::{format_opencode_scope_id, AttemptKey}; #[cfg(test)] -pub(crate) fn run_opencode_mutation_scope_from_payload_at_state_root( - state_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload_at_state_root( - repository_root, - state_root, - payload, - logger, - ) - }; - - run_opencode_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - ) -} - -type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; - -type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; - -const FAIL_CLOSED_MESSAGE: &str = - "SCE could not establish OpenCode mutation attribution for this tool execution."; - -const FAIL_CLOSED_EVENT: &str = "sce.hooks.opencode_mutation_scope.start_fail_closed"; - -fn log_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { - if let Some(log) = logger { - log.warn( - FAIL_CLOSED_EVENT, - &error.to_string(), - &[("context", context)], - None, - ); - } -} - -fn run_opencode_mutation_scope_from_payload_with_seams( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let event = parse_opencode_hook_event(stdin_payload)?; - dispatch_opencode_hook_event(event, logger, resolve_git_dir, seam) -} - -fn dispatch_opencode_hook_event( - event: OpenCodeHookEvent, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - match event { - OpenCodeHookEvent::ToolExecuteBefore(execution) => { - match execution.identity.classification() { - ToolClassification::TrackedMutation => { - if execution.identity.tool_name == OPENCODE_TRACKED_TOOL_BASH { - return Ok(String::new()); - } - let provenance = opencode_scope_provenance( - &execution.identity.session_id, - execution.model.as_deref(), - ); - establish_tracked_start( - &execution.identity.cwd, - &execution.identity.attempt_key(), - &execution.identity.tool_name, - &provenance, - logger, - resolve_git_dir, - seam, - ) - } - ToolClassification::Delegation | ToolClassification::Untracked => Ok(String::new()), - } - } - OpenCodeHookEvent::ShellEnv(shell) => { - let provenance = opencode_scope_provenance(&shell.session_id, shell.model.as_deref()); - establish_tracked_start( - &shell.cwd, - &shell.attempt_key(), - OPENCODE_TRACKED_TOOL_BASH, - &provenance, - logger, - resolve_git_dir, - seam, - ) - } - OpenCodeHookEvent::ToolExecuteAfter(identity) => { - if !matches!( - identity.classification(), - ToolClassification::TrackedMutation - ) { - return Ok(String::new()); - } - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - let key = identity.attempt_key(); - with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - handle_close(&git_dir, repository_root, &key, logger, seam) - }) - } - OpenCodeHookEvent::ToolError(call) => { - if !matches!(call.classification(), ToolClassification::TrackedMutation) { - return Ok(String::new()); - } - let git_dir = resolve_git_dir(&call.cwd)?; - let repository_root = Path::new(&call.cwd); - let key = call.attempt_key(); - with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - abandon_and_consume(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == key.session_id && attempt.call_id == key.call_id - }) - }) - } - OpenCodeHookEvent::SessionIdle(_) - | OpenCodeHookEvent::SessionError(_) - | OpenCodeHookEvent::SessionDeleted(_) - | OpenCodeHookEvent::ServerDisposed(_) => Ok(String::new()), - } -} - -fn with_boundary_lock(git_dir: &Path, operation: impl FnOnce() -> Result) -> Result { - let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) - .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; - operation() -} - -enum Admission { - Admitted(state::AllocatedAttempt), - Denied, -} - -enum StartOutcome { - Established, - Denied, -} - -fn establish_tracked_start( - cwd: &str, - key: &AttemptKey, - tool_name: &str, - provenance: &OpenCodeScopeProvenance, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let git_dir = match resolve_git_dir(cwd) { - Ok(git_dir) => git_dir, - Err(error) => { - log_fail_closed(logger, "resolve_git_dir", &error); - return Err(error.context(FAIL_CLOSED_MESSAGE)); - } - }; - let repository_root = Path::new(cwd); - - let outcome = with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - - match admit_or_recover(&git_dir, repository_root, key, tool_name, logger, seam)? { - Admission::Admitted(allocated) => { - establish_start( - &git_dir, - repository_root, - &allocated, - provenance, - logger, - seam, - )?; - Ok(StartOutcome::Established) - } - Admission::Denied => Ok(StartOutcome::Denied), - } - }); - - match outcome { - Ok(StartOutcome::Established) => Ok(String::new()), - Ok(StartOutcome::Denied) => bail!(FAIL_CLOSED_MESSAGE), - Err(error) => { - log_fail_closed(logger, "establish_tracked_start", &error); - Err(error.context(FAIL_CLOSED_MESSAGE)) - } - } -} - -fn admit_or_recover( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - tool_name: &str, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - match state::admit_tracked_attempt(git_dir, key, tool_name)? { - AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - AdmitDecision::RecoveryBlocked - | AdmitDecision::UncertainAttemptBlocked - | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), - AdmitDecision::FlushClaimed { generation } => { - match resolve_recovery(git_dir, repository_root, generation, logger, seam)? { - RecoveryResolution::Cleared => readmit_after_flush(git_dir, key, tool_name), - RecoveryResolution::Unresolved => Ok(Admission::Denied), - } - } - } -} - -fn readmit_after_flush(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> Result { - match state::admit_tracked_attempt(git_dir, key, tool_name)? { - AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - AdmitDecision::FlushClaimed { generation } => { - state::relinquish_recovery_flush(git_dir, generation)?; - Ok(Admission::Denied) - } - AdmitDecision::RecoveryBlocked - | AdmitDecision::UncertainAttemptBlocked - | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), - } -} - -fn establish_start( - git_dir: &Path, - repository_root: &Path, - allocated: &state::AllocatedAttempt, - provenance: &OpenCodeScopeProvenance, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - let scope_id = &allocated.attempt.scope_id; - - if allocated.reused && allocated.attempt.phase == state::AttemptPhase::Active { - return Ok(()); - } - - let start_payload = scope_start_payload( - scope_id, - &opencode_scope_start_event_id(scope_id), - provenance, - ); - - seam(repository_root, &start_payload, logger)?; - state::mark_active(git_dir, scope_id)?; - Ok(()) -} - -fn handle_close( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| attempt.session_id == key.session_id && attempt.call_id == key.call_id) - .cloned() - else { - return Ok(String::new()); - }; - - let doomed_scope_id = attempt.scope_id.clone(); - - if matches!( - attempt.phase, - state::AttemptPhase::PendingStart | state::AttemptPhase::PendingAbandon - ) { - return abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { - candidate.scope_id == doomed_scope_id - }); - } - - let close_payload = scope_boundary_payload( - "close", - &attempt.scope_id, - &opencode_scope_close_event_id(&attempt.scope_id), - ); - - if seam(repository_root, &close_payload, logger).is_ok() { - state::remove_attempt(git_dir, &attempt.scope_id)?; - Ok(String::new()) - } else { - abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { - candidate.scope_id == doomed_scope_id - }) - } -} - -enum RecoveryResolution { - Cleared, - Unresolved, -} - -fn abandon_and_consume( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, - doomed: impl Fn(&state::AdapterAttempt) -> bool, -) -> Result { - let doomed_scope_ids: Vec = state::read_state(git_dir)? - .attempts - .into_iter() - .filter(|attempt| doomed(attempt)) - .map(|attempt| attempt.scope_id) - .collect(); - if doomed_scope_ids.is_empty() { - return Ok(String::new()); - } - - let generation = state::begin_terminal_cleanup(git_dir, &doomed_scope_ids)?; - resolve_recovery(git_dir, repository_root, generation, logger, seam)?; - Ok(String::new()) -} - -fn resolve_recovery( - git_dir: &Path, - repository_root: &Path, - generation: u64, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let pending_abandon: Vec = state::read_state(git_dir)? - .attempts - .into_iter() - .filter(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon) - .collect(); - - if let Err(error) = seam(repository_root, &flush_payload(), logger) { - log_fail_closed(logger, "recovery_ambiguity_flush", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - - for attempt in &pending_abandon { - if let Err(error) = seam(repository_root, &abandon_payload(&attempt.scope_id), logger) { - log_fail_closed(logger, "recovery_abandon", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - state::remove_attempt(git_dir, &attempt.scope_id)?; - } - - if let Err(error) = seam(repository_root, &flush_payload(), logger) { - log_fail_closed(logger, "recovery_rebaseline_flush", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - - match state::complete_recovery_flush(git_dir, generation)? { - RecoveryFlushCompletion::Cleared => Ok(RecoveryResolution::Cleared), - RecoveryFlushCompletion::Superseded => Ok(RecoveryResolution::Unresolved), - } -} - -fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { - json!({ - "operation": operation, - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_OPENCODE, - }) - .to_string() -} - -fn scope_start_payload( - scope_id: &str, - event_id: &str, - provenance: &OpenCodeScopeProvenance, -) -> String { - json!({ - "operation": "start", - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_OPENCODE, - "provenance": { - "session_id": provenance.session_id, - "model_id": provenance.model_id, - }, - }) - .to_string() -} - -fn abandon_payload(scope_id: &str) -> String { - json!({ - "operation": "abandon", - "scope_id": scope_id, - }) - .to_string() -} - -fn flush_payload() -> String { - json!({ "operation": "flush" }).to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn tool_event_json(hook_event_name: &str, overrides: &[(&str, Value)]) -> String { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(hook_event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("ses_main".to_string()), - ); - object.insert( - CALL_ID_FIELD.to_string(), - Value::String("call_1".to_string()), - ); - object.insert( - CWD_FIELD.to_string(), - Value::String("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/repo/checkout".to_string()), - ); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String("write".to_string()), - ); - for (field, value) in overrides { - object.insert((*field).to_string(), value.clone()); - } - Value::Object(object).to_string() - } - - fn key(session_id: &str, call_id: &str) -> AttemptKey { - AttemptKey { - session_id: session_id.to_string(), - call_id: call_id.to_string(), - } - } - - fn tool_execution(payload: &str) -> OpenCodeToolExecution { - match parse_opencode_hook_event(payload).expect("valid ToolExecuteBefore parses") { - OpenCodeHookEvent::ToolExecuteBefore(execution) => execution, - other => panic!("expected ToolExecuteBefore, got {other:?}"), - } - } - - #[test] - fn empty_payload_is_rejected() { - let error = parse_opencode_hook_event(" ").unwrap_err().to_string(); - assert_eq!( - error, - "Invalid OpenCode hook event payload from STDIN: expected a JSON object, got an empty payload." - ); - } - - #[test] - fn non_object_json_is_rejected() { - for payload in ["[]", "\"ToolExecuteBefore\"", "42", "null"] { - let error = parse_opencode_hook_event(payload).unwrap_err().to_string(); - assert!( - error.contains("expected a JSON object"), - "payload {payload:?} produced {error:?}" - ); - } - } - - #[test] - fn invalid_json_is_rejected() { - let error = parse_opencode_hook_event("{not json") - .unwrap_err() - .to_string(); - assert!( - error.contains("Invalid OpenCode hook event payload from STDIN: expected valid JSON"), - "{error:?}" - ); - } - - #[test] - fn unsupported_hook_event_name_is_rejected() { - for name in ["PreToolUse", "ToolExecute", "chat.params", ""] { - let payload = tool_event_json(name, &[]); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("hook_event_name"), - "name {name:?} produced {error:?}" - ); - } - } - - #[test] - fn missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { - let mut object: Map = - serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_BEFORE, &[])) - .unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); - - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("'{field}'")), - "missing {field} produced {error:?}" - ); - } - } - - #[test] - fn blank_required_fields_are_rejected() { - for field in [SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(field, Value::String(" ".to_string()))], - ); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("field '{field}' must be a non-blank string")), - "blank {field} produced {error:?}" - ); - } - } - - #[test] - fn wrong_typed_fields_are_rejected() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(CALL_ID_FIELD, Value::Bool(true))], - ); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'call_id' must be a string"), - "{error:?}" - ); - } - - #[test] - fn wrong_typed_optional_model_is_rejected() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(MODEL_FIELD, Value::Bool(false))], - ); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'model' must be null, absent, or a non-blank string"), - "{error:?}" - ); - } - - #[test] - fn tool_execute_before_parses_identity_and_model() { - let execution = tool_execution(&tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("edit".to_string())), - ( - MODEL_FIELD, - Value::String("opencode/big-pickle".to_string()), - ), - ], - )); - assert_eq!(execution.identity.session_id, "ses_main"); - assert_eq!(execution.identity.call_id, "call_1"); - assert_eq!(execution.identity.tool_name, "edit"); - assert_eq!(execution.model.as_deref(), Some("opencode/big-pickle")); - assert_eq!( - execution.identity.classification(), - ToolClassification::TrackedMutation - ); - } - - #[test] - fn tool_execute_before_model_is_optional() { - let execution = tool_execution(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_BEFORE, &[])); - assert_eq!(execution.model, None); - } - - #[test] - fn shell_env_parses_without_a_tool_name() { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_SHELL_ENV.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("ses_main".to_string()), - ); - object.insert( - CALL_ID_FIELD.to_string(), - Value::String("call_bash".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - object.insert( - MODEL_FIELD.to_string(), - Value::String("opencode/big-pickle".to_string()), - ); - let payload = Value::Object(object).to_string(); - - let OpenCodeHookEvent::ShellEnv(shell) = parse_opencode_hook_event(&payload).unwrap() - else { - panic!("expected ShellEnv"); - }; - assert_eq!(shell.call_id, "call_bash"); - assert_eq!(shell.model.as_deref(), Some("opencode/big-pickle")); - assert_eq!(shell.attempt_key(), key("ses_main", "call_bash")); - } - - #[test] - fn tool_execute_after_parses_tool_identity() { - let OpenCodeHookEvent::ToolExecuteAfter(identity) = - parse_opencode_hook_event(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_AFTER, &[])) - .unwrap() - else { - panic!("expected ToolExecuteAfter"); - }; - assert_eq!(identity.attempt_key(), key("ses_main", "call_1")); - assert_eq!(identity.tool_name, "write"); - } - - #[test] - fn terminal_events_parse_their_minimal_identity() { - for name in [ - HOOK_EVENT_SESSION_IDLE, - HOOK_EVENT_SESSION_ERROR, - HOOK_EVENT_SESSION_DELETED, - ] { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("ses_main".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - let payload = Value::Object(object).to_string(); - - let event = parse_opencode_hook_event(&payload).unwrap(); - let identity = match event { - OpenCodeHookEvent::SessionIdle(identity) - | OpenCodeHookEvent::SessionError(identity) - | OpenCodeHookEvent::SessionDeleted(identity) => identity, - other => panic!("expected a session-identity event, got {other:?}"), - }; - assert_eq!(identity.session_id, "ses_main"); - assert_eq!(identity.cwd, "/repo"); - } - - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_TOOL_ERROR.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("ses_main".to_string()), - ); - object.insert( - CALL_ID_FIELD.to_string(), - Value::String("call_1".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String("write".to_string()), - ); - let OpenCodeHookEvent::ToolError(identity) = - parse_opencode_hook_event(&Value::Object(object).to_string()).unwrap() - else { - panic!("expected ToolError"); - }; - assert_eq!(identity.attempt_key(), key("ses_main", "call_1")); - assert_eq!( - identity.classification(), - ToolClassification::TrackedMutation - ); - - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_SERVER_DISPOSED.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - let OpenCodeHookEvent::ServerDisposed(workspace) = - parse_opencode_hook_event(&Value::Object(object).to_string()).unwrap() - else { - panic!("expected ServerDisposed"); - }; - assert_eq!(workspace.cwd, "/repo"); - } - - #[test] - fn classification_table() { - let cases: &[(&str, ToolClassification)] = &[ - ("bash", ToolClassification::TrackedMutation), - ("write", ToolClassification::TrackedMutation), - ("edit", ToolClassification::TrackedMutation), - ("apply_patch", ToolClassification::TrackedMutation), - ("task", ToolClassification::Delegation), - ("read", ToolClassification::Untracked), - ("glob", ToolClassification::Untracked), - ("grep", ToolClassification::Untracked), - ("webfetch", ToolClassification::Untracked), - ("websearch", ToolClassification::Untracked), - ("todowrite", ToolClassification::Untracked), - ("probe_mutate", ToolClassification::Untracked), - ( - "brave-search_brave_web_search", - ToolClassification::Untracked, - ), - ("Bash", ToolClassification::Untracked), - ("some_future_opencode_tool", ToolClassification::Untracked), - ("", ToolClassification::Untracked), - ]; - for (tool_name, expected) in cases { - assert_eq!( - classify_tool(tool_name), - *expected, - "classify_tool({tool_name:?})" - ); - } - } - - #[test] - fn classification_is_total_and_single_valued() { - for tool_name in ["bash", "write", "edit", "apply_patch", "task", "read", "x"] { - let _: ToolClassification = classify_tool(tool_name); - } - } - - #[test] - fn scope_id_is_deterministic_for_the_same_key() { - let k = key("ses_main", "call_1"); - assert_eq!(format_opencode_scope_id(&k), format_opencode_scope_id(&k)); - - let scope_id = format_opencode_scope_id(&k); - assert_eq!(scope_id, "oc-tool-v1|s=8:ses_main|c=6:call_1"); - assert_eq!( - opencode_scope_start_event_id(&scope_id), - format!("{scope_id}|start") - ); - assert_eq!( - opencode_scope_close_event_id(&scope_id), - format!("{scope_id}|close") - ); - assert_ne!( - opencode_scope_start_event_id(&scope_id), - opencode_scope_close_event_id(&scope_id) - ); - } - - #[test] - fn duplicate_events_reuse_the_same_scope_id() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(TOOL_NAME_FIELD, Value::String("bash".to_string()))], - ); - let first = tool_execution(&payload).identity.attempt_key(); - let second = tool_execution(&payload).identity.attempt_key(); - assert_eq!( - format_opencode_scope_id(&first), - format_opencode_scope_id(&second) - ); - } - - #[test] - fn length_prefix_disambiguates_delimiter_collisions() { - let a = key("s|c=1:x", "y"); - let b = key("s", "1:x|y"); - assert_ne!(format_opencode_scope_id(&a), format_opencode_scope_id(&b)); - - let tricky = key("ses|c=0:x", "call:with:colons"); - assert_eq!( - format_opencode_scope_id(&tricky), - format!( - "oc-tool-v1|s={}:{}|c={}:{}", - tricky.session_id.len(), - tricky.session_id, - tricky.call_id.len(), - tricky.call_id, - ) - ); - } - - #[test] - fn parallel_call_ids_in_one_session_stay_distinguishable() { - let a = tool_execution(&tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("bash".to_string())), - (CALL_ID_FIELD, Value::String("call_a".to_string())), - ], - )); - let b = tool_execution(&tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("bash".to_string())), - (CALL_ID_FIELD, Value::String("call_b".to_string())), - ], - )); - assert_ne!(a.identity.attempt_key(), b.identity.attempt_key()); - assert_ne!( - format_opencode_scope_id(&a.identity.attempt_key()), - format_opencode_scope_id(&b.identity.attempt_key()) - ); - } - - #[test] - fn task_child_session_identity_flows_through_the_attempt_key() { - let child = tool_execution(&tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("bash".to_string())), - (SESSION_ID_FIELD, Value::String("ses_child".to_string())), - (CALL_ID_FIELD, Value::String("call_child".to_string())), - ], - )); - assert_eq!(child.identity.attempt_key(), key("ses_child", "call_child")); - assert_ne!( - format_opencode_scope_id(&child.identity.attempt_key()), - format_opencode_scope_id(&key("ses_main", "call_child")) - ); - } - - #[test] - fn attempt_key_projects_only_session_and_call() { - let identity_a = OpenCodeToolIdentity { - session_id: "ses_main".to_string(), - call_id: "call_1".to_string(), - cwd: "/repo".to_string(), - tool_name: "write".to_string(), - }; - let identity_b = OpenCodeToolIdentity { - tool_name: "bash".to_string(), - cwd: "/other".to_string(), - ..identity_a.clone() - }; - assert_eq!(identity_a.attempt_key(), identity_b.attempt_key()); - } - - #[test] - fn provenance_canonicalizes_the_session_and_normalizes_the_model() { - let provenance = opencode_scope_provenance("ses_main", Some("opencode/big-pickle")); - assert_eq!(provenance.session_id, "oc_ses_main"); - assert_eq!(provenance.model_id.as_deref(), Some("opencode/big-pickle")); - } - - #[test] - fn provenance_keeps_an_already_prefixed_session_id() { - let provenance = opencode_scope_provenance("oc_ses_main", None); - assert_eq!(provenance.session_id, "oc_ses_main"); - } - - #[test] - fn provenance_without_model_evidence_is_null() { - for model in [None, Some(""), Some(" ")] { - let provenance = opencode_scope_provenance("ses_main", model); - assert_eq!(provenance.model_id, None, "model {model:?}"); - assert_eq!(provenance.session_id, "oc_ses_main", "model {model:?}"); - } - } - - #[test] - fn provenance_is_built_from_a_parsed_start_event() { - let execution = tool_execution(&tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("write".to_string())), - ( - MODEL_FIELD, - Value::String("opencode/big-pickle".to_string()), - ), - ], - )); - let provenance = - opencode_scope_provenance(&execution.identity.session_id, execution.model.as_deref()); - assert_eq!(provenance.session_id, "oc_ses_main"); - assert_eq!(provenance.model_id.as_deref(), Some("opencode/big-pickle")); - } - - #[test] - fn run_from_payload_fails_closed_when_a_tracked_start_cannot_resolve_its_checkout() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("write".to_string())), - ( - CWD_FIELD, - Value::String("/nonexistent/sce/opencode/checkout".to_string()), - ), - ], - ); - let error = run_opencode_mutation_scope_from_payload(&payload, None) - .expect_err("a tracked Start that cannot resolve its checkout must fail closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE), "{error:?}"); - } - - #[test] - fn run_from_payload_is_neutral_for_untracked_and_delegation_events() { - for tool_name in ["read", "task", "probe_mutate"] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], - ); - assert_eq!( - run_opencode_mutation_scope_from_payload(&payload, None).unwrap(), - String::new() - ); - } - } - - #[test] - fn run_from_payload_surfaces_malformed_input() { - let error = run_opencode_mutation_scope_from_payload("{bad", None) - .unwrap_err() - .to_string(); - assert!(error.contains("expected valid JSON"), "{error:?}"); - } -} - -#[cfg(test)] -mod lifecycle_tests { - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Mutex; - - use serde_json::Value; - - use super::state::{read_state, AttemptPhase, RecoveryState}; - use super::*; - - static NEXT_ID: AtomicU64 = AtomicU64::new(0); - - fn temp_git_dir(label: &str) -> PathBuf { - let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "sce-opencode-mutation-scope-lifecycle-{label}-{}-{id}", - std::process::id() - )) - } - - const CWD: &str = "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/repo/opencode-checkout"; - - struct RecordingSeam { - calls: Mutex>, - fail_operations: Vec, - fail_once_operations: Mutex>, - fail_operation_occurrence: Option<(String, usize)>, - } - - impl RecordingSeam { - fn new() -> Self { - Self { - calls: Mutex::new(Vec::new()), - fail_operations: Vec::new(), - fail_once_operations: Mutex::new(Vec::new()), - fail_operation_occurrence: None, - } - } - - fn failing_on(operations: &[&str]) -> Self { - Self { - fail_operations: operations.iter().map(|op| (*op).to_string()).collect(), - ..Self::new() - } - } - - fn failing_once_on(operations: &[&str]) -> Self { - Self { - fail_once_operations: Mutex::new( - operations.iter().map(|op| (*op).to_string()).collect(), - ), - ..Self::new() - } - } - - fn failing_on_nth_occurrence(operation: &str, occurrence: usize) -> Self { - Self { - fail_operation_occurrence: Some((operation.to_string(), occurrence)), - ..Self::new() - } - } - - fn handle(&self, payload: &str) -> Result { - let operation = operation_of(payload); - let occurrence = { - let mut calls = self.calls.lock().expect("seam mutex"); - calls.push(operation.clone()); - calls - .iter() - .filter(|candidate| *candidate == &operation) - .count() - }; - if self.fail_operations.contains(&operation) { - bail!("seam failure injected by test for '{operation}'"); - } - if let Some((target, target_occurrence)) = &self.fail_operation_occurrence { - if target == &operation && *target_occurrence == occurrence { - bail!( - "seam failure injected by test for '{operation}' occurrence {occurrence}" - ); - } - } - { - let mut once = self.fail_once_operations.lock().expect("seam mutex"); - if let Some(position) = once.iter().position(|candidate| candidate == &operation) { - once.remove(position); - bail!("transient seam failure injected once by test for '{operation}'"); - } - } - Ok(String::new()) - } - - fn operations(&self) -> Vec { - self.calls.lock().expect("seam mutex").clone() - } - } - - fn operation_of(payload: &str) -> String { - let value: Value = serde_json::from_str(payload).expect("seam payload is JSON"); - value - .get("operation") - .and_then(Value::as_str) - .expect("seam payload has an operation") - .to_string() - } - - fn drive(git_dir: &Path, seam: &RecordingSeam, payload: &str) -> Result { - let resolver = |_cwd: &str| Ok(git_dir.to_path_buf()); - let seam_fn = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload); - run_opencode_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) - } - - fn tool_before(tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecuteBefore", - "session_id": "ses_main", - "call_id": call_id, - "cwd": CWD, - "tool_name": tool_name, - "model": "opencode/big-pickle", - }) - .to_string() - } - - fn shell_env(call_id: &str) -> String { - json!({ - "hook_event_name": "ShellEnv", - "session_id": "ses_main", - "call_id": call_id, - "cwd": CWD, - "model": "opencode/big-pickle", - }) - .to_string() - } - - fn tool_after(tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecuteAfter", - "session_id": "ses_main", - "call_id": call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn tool_error(tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolError", - "session_id": "ses_main", - "call_id": call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn session_event(hook_event_name: &str, session_id: &str) -> String { - json!({ - "hook_event_name": hook_event_name, - "session_id": session_id, - "cwd": CWD, - }) - .to_string() - } - - fn server_disposed() -> String { - json!({ "hook_event_name": "ServerDisposed", "cwd": CWD }).to_string() - } - - fn cleanup(git_dir: &Path) { - let _ = std::fs::remove_dir_all(git_dir); - } - - #[test] - fn file_tool_before_establishes_a_write_ahead_start_and_replays_idempotently() { - let git_dir = temp_git_dir("write-ahead-start"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("first Start"); - drive(&git_dir, &seam, &tool_before("write", "call_1")) - .expect("duplicate Start is a no-op"); - - assert_eq!(seam.operations(), vec!["start"]); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].phase, AttemptPhase::Active); - assert_eq!(state.attempts[0].tool_name, "write"); - - cleanup(&git_dir); - } - - #[test] - fn bash_start_is_anchored_to_shell_env_not_tool_execute_before() { - let git_dir = temp_git_dir("bash-shell-env"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_before("bash", "call_bash")).expect("bash before is inert"); - assert!(seam.operations().is_empty()); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - drive(&git_dir, &seam, &shell_env("call_bash")).expect("shell.env establishes Start"); - assert_eq!(seam.operations(), vec!["start"]); - assert_eq!( - read_state(&git_dir).expect("state readable").attempts[0].phase, - AttemptPhase::Active, - ); - - cleanup(&git_dir); - } - - #[test] - fn concurrent_bash_calls_in_one_session_stay_separate_live_scopes() { - let git_dir = temp_git_dir("concurrent-bash"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start must not retire A"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 2); - assert!(state - .attempts - .iter() - .all(|a| a.phase == AttemptPhase::Active)); - assert_eq!(seam.operations(), vec!["start", "start"]); - - cleanup(&git_dir); - } - - #[test] - fn successful_after_closes_exactly_that_attempt_and_replays_as_a_no_op() { - let git_dir = temp_git_dir("close-replay"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_before("edit", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_after("edit", "call_1")).expect("Close"); - drive(&git_dir, &seam, &tool_after("edit", "call_1")).expect("duplicate Close is a no-op"); - - assert_eq!(seam.operations(), vec!["start", "close"]); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn tool_error_retires_the_named_attempt_and_consumes_the_ambiguous_interval() { - let git_dir = temp_git_dir("tool-error-consume"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); - drive(&git_dir, &seam, &tool_error("bash", "call_a")).expect("A terminal failure"); - - assert_eq!( - seam.operations(), - vec!["start", "start", "flush", "abandon", "flush"], - "the ambiguous interval is flushed before A is abandoned, then the abandon \ - rebaseline is flushed away so B keeps its future intervals", - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_b"); - assert_eq!(state.attempts[0].phase, AttemptPhase::Active); - assert!( - state.recovery.is_clear(), - "a successful ambiguity flush clears the recovery barrier while B stays live", - ); - - cleanup(&git_dir); - } - - #[test] - fn exact_error_retires_only_the_named_sibling() { - let git_dir = temp_git_dir("exact-error-siblings"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); - drive(&git_dir, &seam, &shell_env("call_c")).expect("C Start"); - drive(&git_dir, &seam, &tool_error("bash", "call_b")).expect("B terminal failure"); - - let state = read_state(&git_dir).expect("state readable"); - let mut remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - remaining.sort_unstable(); - assert_eq!(remaining, vec!["call_a", "call_c"]); - assert!(state - .attempts - .iter() - .all(|a| a.phase == AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - assert_eq!( - seam.operations() - .iter() - .filter(|op| *op == "abandon") - .count(), - 1, - "abandoning B must not sweep A or C", - ); - - cleanup(&git_dir); - } - - #[test] - fn a_close_before_start_confirmation_consumes_rather_than_closes() { - let git_dir = temp_git_dir("pending-start-close"); - let seam = RecordingSeam::failing_on(&["start"]); - - let error = drive(&git_dir, &seam, &tool_before("write", "call_1")) - .expect_err("a failed Start seam must fail closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - assert_eq!( - read_state(&git_dir).expect("state readable").attempts[0].phase, - AttemptPhase::PendingStart, - ); - - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_after("write", "call_1")).expect("After on a PendingStart"); - assert_eq!(ok_seam.operations(), vec!["flush", "abandon", "flush"]); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn session_idle_is_non_destructive() { - let git_dir = temp_git_dir("session-idle-noop"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); - drive( - &git_dir, - &seam, - &json!({ - "hook_event_name": "ShellEnv", - "session_id": "ses_other", - "call_id": "call_c", - "cwd": CWD, - }) - .to_string(), - ) - .expect("other-session Start"); - - for name in ["SessionIdle", "SessionError", "SessionDeleted"] { - drive(&git_dir, &seam, &session_event(name, "ses_main")).expect("broad event is inert"); - } - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!( - state.attempts.len(), - 2, - "no attempt is retired by a broad event" - ); - assert!(state - .attempts - .iter() - .all(|a| a.phase == AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - assert_eq!(seam.operations(), vec!["start", "start"]); - - cleanup(&git_dir); - } - - #[test] - fn delayed_session_idle_cannot_retire_a_newer_call() { - let git_dir = temp_git_dir("delayed-session-idle"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_b")).expect("newer call B Start"); - drive(&git_dir, &seam, &session_event("SessionIdle", "ses_main")) - .expect("a delayed SessionIdle for the same session arrives after B started"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_b"); - assert_eq!(state.attempts[0].phase, AttemptPhase::Active); - assert!(state.recovery.is_clear()); - assert!(!seam.operations().iter().any(|op| op == "abandon")); - - cleanup(&git_dir); - } - - #[test] - fn server_disposed_cannot_sweep_another_processes_attempt() { - let git_dir = temp_git_dir("server-disposed-noop"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("process P1 owns call A"); - drive( - &git_dir, - &seam, - &json!({ - "hook_event_name": "ShellEnv", - "session_id": "ses_other", - "call_id": "call_b", - "cwd": CWD, - }) - .to_string(), - ) - .expect("process P2 owns call B in the same checkout"); - - drive(&git_dir, &seam, &server_disposed()).expect("P1 server disposal is inert"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!( - state.attempts.len(), - 2, - "one process's disposal must not retire another process's live attempt", - ); - assert!(state.recovery.is_clear()); - assert_eq!(seam.operations(), vec!["start", "start"]); - - cleanup(&git_dir); - } - - #[test] - fn a_failed_sibling_does_not_retire_survivors_or_block_new_starts() { - let git_dir = temp_git_dir("failed-sibling-survivors"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); - drive(&git_dir, &seam, &tool_error("bash", "call_a")).expect("A fails"); - - drive(&git_dir, &seam, &shell_env("call_c")) - .expect("a new Start is admitted normally once the ambiguity flush cleared recovery"); - - let state = read_state(&git_dir).expect("state readable"); - let mut remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - remaining.sort_unstable(); - assert_eq!(remaining, vec!["call_b", "call_c"]); - assert!(state - .attempts - .iter() - .all(|a| a.phase == AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn a_terminal_failure_consumes_the_interval_and_the_next_start_proceeds() { - let git_dir = temp_git_dir("terminal-then-start"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_error("write", "call_1")).expect("terminal failure"); - assert!( - read_state(&git_dir) - .expect("state readable") - .recovery - .is_clear(), - "the ambiguity flush is done at abandon time, not deferred to the next Start", - ); - - drive(&git_dir, &seam, &tool_before("write", "call_2")).expect("Start after consume"); - - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush", "start"], - ); - let state = read_state(&git_dir).expect("state readable"); - assert!(state.recovery.is_clear()); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_2"); - - cleanup(&git_dir); - } - - #[test] - fn a_failed_ambiguity_flush_stays_recovery_pending_and_fails_closed_starts() { - let git_dir = temp_git_dir("failed-ambiguity-flush"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); - } - - let failing = RecordingSeam::failing_on(&["flush"]); - drive(&git_dir, &failing, &tool_error("write", "call_1")) - .expect("a terminal failure whose flush fails still returns (best-effort)"); - assert_eq!( - read_state(&git_dir).expect("state readable").recovery, - RecoveryState::Pending { generation: 1 }, - "a failed ambiguity flush retains a recovery-required state", - ); - - let error = drive(&git_dir, &failing, &tool_before("write", "call_2")) - .expect_err("a new Start while recovery is unresolved must stay fail-closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - assert_eq!( - read_state(&git_dir).expect("state readable").recovery, - RecoveryState::Pending { generation: 1 }, - ); - - cleanup(&git_dir); - } - - #[test] - fn untracked_and_delegation_events_are_zero_footprint() { - let git_dir = temp_git_dir("zero-footprint"); - let seam = RecordingSeam::new(); - - for payload in [ - tool_before("read", "call_r"), - tool_before("task", "call_t"), - tool_before("some_future_tool", "call_f"), - tool_after("read", "call_r"), - tool_error("read", "call_r"), - tool_error("task", "call_t"), - ] { - drive(&git_dir, &seam, &payload).expect("untracked event is neutral"); - } - - assert!(seam.operations().is_empty()); - assert!( - !git_dir.join("sce").exists(), - "no state directory is created" - ); - - cleanup(&git_dir); - } - - #[test] - fn close_seam_failure_falls_back_to_consume() { - let git_dir = temp_git_dir("close-seam-failure"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("edit", "call_1")).expect("Start"); - } - - let failing = RecordingSeam::failing_on(&["close"]); - drive(&git_dir, &failing, &tool_after("edit", "call_1")).expect("Close seam failure"); - - assert_eq!( - failing.operations(), - vec!["close", "flush", "abandon", "flush"], - ); - let state = read_state(&git_dir).expect("state readable"); - assert!(state.attempts.is_empty()); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn regression_a_abandon_failure_preserves_terminal_intent_then_recovers() { - let git_dir = temp_git_dir("regression-a-abandon-failure"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("A Start"); - } - - let failing = RecordingSeam::failing_on(&["abandon"]); - drive(&git_dir, &failing, &tool_error("write", "call_1")) - .expect("a terminal failure whose abandon fails still returns best-effort"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1, "A is not forgotten"); - assert_eq!(state.attempts[0].call_id, "call_1"); - assert_eq!( - state.attempts[0].phase, - AttemptPhase::PendingAbandon, - "the exact terminal attempt is durably marked PendingAbandon", - ); - assert!( - !state.recovery.is_clear(), - "recovery stays unresolved while Abandon has not succeeded", - ); - assert_eq!( - failing.operations(), - vec!["flush", "abandon"], - "the ambiguity flush ran, then the abandon that failed; no rebaseline flush, \ - no removal", - ); - - let healthy = RecordingSeam::new(); - drive(&git_dir, &healthy, &tool_before("write", "call_2")) - .expect("a healthy retry boundary resolves recovery and admits the new Start"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_2"); - assert!(state.recovery.is_clear()); - assert_eq!( - healthy.operations(), - vec!["flush", "abandon", "flush", "start"], - "recovery replays flush + abandon + rebaseline flush before the new Start", - ); - - cleanup(&git_dir); - } - - #[test] - fn regression_b_ambiguity_flush_failure_blocks_new_starts_then_recovers() { - let git_dir = temp_git_dir("regression-b-ambiguity-flush-failure"); - let live = RecordingSeam::new(); - - drive(&git_dir, &live, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &live, &shell_env("call_b")).expect("B Start"); - - let failing = RecordingSeam::failing_on(&["flush"]); - drive(&git_dir, &failing, &tool_error("bash", "call_a")).expect("A terminal failure"); - - let state = read_state(&git_dir).expect("state readable"); - let pending_abandon: Vec<&str> = state - .attempts - .iter() - .filter(|attempt| attempt.phase == AttemptPhase::PendingAbandon) - .map(|attempt| attempt.call_id.as_str()) - .collect(); - let active: Vec<&str> = state - .attempts - .iter() - .filter(|attempt| attempt.phase == AttemptPhase::Active) - .map(|attempt| attempt.call_id.as_str()) - .collect(); - assert_eq!(pending_abandon, vec!["call_a"], "A is PendingAbandon"); - assert_eq!(active, vec!["call_b"], "B stays Active"); - assert!(!state.recovery.is_clear(), "recovery pending"); - - let error = drive(&git_dir, &failing, &shell_env("call_c")) - .expect_err("a new tracked Start must fail closed while recovery is unresolved"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .iter() - .all(|attempt| attempt.call_id != "call_c")); - - let healthy = RecordingSeam::new(); - drive(&git_dir, &healthy, &shell_env("call_c")) - .expect("once recovery succeeds a new Start is admitted normally"); - - let state = read_state(&git_dir).expect("state readable"); - let mut remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - remaining.sort_unstable(); - assert_eq!( - remaining, - vec!["call_b", "call_c"], - "A removed, B kept, C admitted" - ); - assert!(state - .attempts - .iter() - .all(|attempt| attempt.phase == AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn regression_c_rebaseline_flush_failure_is_recoverable_without_poison() { - let git_dir = temp_git_dir("regression-c-rebaseline-flush-failure"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("edit", "call_1")).expect("Start"); - } - - let rebaseline_failing = RecordingSeam::failing_on_nth_occurrence("flush", 2); - drive(&git_dir, &rebaseline_failing, &tool_error("edit", "call_1")) - .expect("terminal failure whose rebaseline flush fails still returns"); - - assert_eq!( - rebaseline_failing.operations(), - vec!["flush", "abandon", "flush"], - "the ambiguity flush and the abandon succeeded; the rebaseline flush failed", - ); - let state = read_state(&git_dir).expect("state readable"); - assert!( - state.attempts.is_empty(), - "the abandon succeeded so the attempt is removed", - ); - assert!( - !state.recovery.is_clear(), - "recovery state still carries the outstanding rebaseline", - ); - - let healthy = RecordingSeam::new(); - drive(&git_dir, &healthy, &tool_before("edit", "call_2")).expect("retry admits new work"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_2"); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn regression_e_duplicate_tool_error_is_idempotent() { - let git_dir = temp_git_dir("regression-e-duplicate-tool-error"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); - } - - let stuck = RecordingSeam::failing_on(&["abandon"]); - drive(&git_dir, &stuck, &tool_error("write", "call_1")).expect("first terminal failure"); - drive(&git_dir, &stuck, &tool_error("write", "call_1")) - .expect("a duplicate ToolError while PendingAbandon is idempotent"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1, "no second attempt is created"); - assert_eq!(state.attempts[0].phase, AttemptPhase::PendingAbandon); - assert_eq!( - state.next_recovery_generation, 2, - "the recovery generation is not incremented by the duplicate", - ); - - cleanup(&git_dir); - } - - #[test] - fn regression_f_start_replay_for_a_pending_abandon_identity_never_reactivates() { - let git_dir = temp_git_dir("regression-f-start-replay-pending-abandon"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); - } - - let stuck = RecordingSeam::failing_on(&["abandon"]); - drive(&git_dir, &stuck, &tool_error("write", "call_1")).expect("terminal failure"); - - let error = drive(&git_dir, &stuck, &tool_before("write", "call_1")) - .expect_err("a replayed Start for a PendingAbandon identity must fail closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!( - state.attempts[0].phase, - AttemptPhase::PendingAbandon, - "the replayed Start does not return the attempt to Active", - ); - - cleanup(&git_dir); - } - - #[test] - fn regression_g_late_tool_error_after_close_is_a_harmless_no_op() { - let git_dir = temp_git_dir("regression-g-late-tool-error"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_after("write", "call_1")).expect("Close"); - drive(&git_dir, &seam, &tool_error("write", "call_1")) - .expect("a late ToolError after a completed Close is inert"); - - assert_eq!(seam.operations(), vec!["start", "close"]); - let state = read_state(&git_dir).expect("state readable"); - assert!(state.attempts.is_empty()); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn regression_h_siblings_stay_active_through_a_transient_cleanup_failure() { - let git_dir = temp_git_dir("regression-h-siblings-preserved"); - let live = RecordingSeam::new(); - - drive(&git_dir, &live, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &live, &shell_env("call_b")).expect("B Start"); - drive(&git_dir, &live, &shell_env("call_c")).expect("C Start"); - - let transient = RecordingSeam::failing_once_on(&["abandon"]); - drive(&git_dir, &transient, &tool_error("bash", "call_b")) - .expect("B terminal failure with a transient abandon failure"); - - let snapshot = read_state(&git_dir).expect("state readable"); - let mut siblings: Vec<&str> = snapshot - .attempts - .iter() - .filter(|attempt| attempt.phase == AttemptPhase::Active) - .map(|attempt| attempt.call_id.as_str()) - .collect(); - siblings.sort_unstable(); - assert_eq!( - siblings, - vec!["call_a", "call_c"], - "A and C stay Active mid-failure" - ); - - let healthy = RecordingSeam::new(); - drive(&git_dir, &healthy, &shell_env("call_d")) - .expect("the retry boundary resolves recovery and admits D"); - - let state = read_state(&git_dir).expect("state readable"); - let mut remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - remaining.sort_unstable(); - assert_eq!( - remaining, - vec!["call_a", "call_c", "call_d"], - "B gone, A/C/D active" - ); - assert!(state - .attempts - .iter() - .all(|attempt| attempt.phase == AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } -} +pub(crate) use lifecycle::run_opencode_mutation_scope_from_payload_at_state_root; +pub(crate) use lifecycle::run_opencode_mutation_scope_subcommand; #[cfg(test)] -mod runtime_seam_tests { - use std::fs; - use std::path::{Path, PathBuf}; - use std::process::Command; - - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - use crate::services::mutation_trace::runtime::resolve_git_dir; - - use super::state::read_state; - use super::*; - - fn git(dir: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - struct OpenCodeRepo { - temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - } - - impl OpenCodeRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-opencode-mutation-scope-seam-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - temp, - root, - state_root, - } - } - - fn cwd(&self) -> String { - self.root.to_string_lossy().into_owned() - } - - fn drive(&self, payload: &str) -> Result { - run_opencode_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) - } - - fn drive_failing_seam_operation_once( - &self, - payload: &str, - fail_operation: &str, - remaining_failures: &std::cell::Cell, - ) -> Result { - let resolver = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |root: &Path, seam_payload: &str, logger: Option<&dyn Logger>| { - let operation = serde_json::from_str::(seam_payload) - .ok() - .and_then(|value| { - value - .get("operation") - .and_then(|op| op.as_str()) - .map(str::to_owned) - }) - .unwrap_or_default(); - if operation == fail_operation && remaining_failures.get() > 0 { - remaining_failures.set(remaining_failures.get() - 1); - return Err(anyhow!("injected transient '{operation}' seam failure")); - } - crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( - root, - &self.state_root, - seam_payload, - logger, - ) - }; - run_opencode_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) - } - - fn write(&self, name: &str, contents: &str) { - fs::write(self.root.join(name), contents).expect("write should succeed"); - } - - fn git_dir(&self) -> PathBuf { - resolve_git_dir(&self.root).expect("git dir should resolve") - } - - fn db(&self) -> RepositoryAgentTraceDb { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &self.root, - &self.state_root, - "opencode mutation-scope seam test assertions", - ) - .expect("assertion DB should open") - } - - fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { - self.db() - .query_map( - "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", - (scope_id,), - |row| { - let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; - let status = row.get::(1).map_err(anyhow::Error::from)?; - Ok((actor_kind, status)) - }, - ) - .expect("scope query should succeed") - .into_iter() - .next() - } - - fn scope_count(&self) -> i64 { - self.db() - .query_map("SELECT COUNT(*) FROM mutation_trace_scopes", (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("count query should succeed") - .into_iter() - .next() - .expect("a count row should exist") - } - - fn mutation_events(&self) -> Vec<(String, Option)> { - self.db() - .query_map( - "SELECT attribution_kind, attribution_scope_id \ - FROM mutation_trace_events ORDER BY revision", - (), - |row| { - let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id)) - }, - ) - .expect("mutation-events query should succeed") - } - } - - fn error(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolError", - "session_id": "ses_seam", - "call_id": call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - }) - .to_string() - } - - fn before(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecuteBefore", - "session_id": "ses_seam", - "call_id": call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - "model": "opencode/big-pickle", - }) - .to_string() - } - - fn after(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecuteAfter", - "session_id": "ses_seam", - "call_id": call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - }) - .to_string() - } - - #[test] - fn a_write_start_then_after_closes_the_scope_through_the_real_runtime() { - let repo = OpenCodeRepo::new("write-start-close"); - - assert_eq!( - repo.drive(&before(&repo, "write", "call_1")) - .expect("Start"), - "" - ); - let scope_id = { - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert_eq!(state.attempts.len(), 1); - state.attempts[0].scope_id.clone() - }; - - repo.write("file.txt", "one\ntwo\n"); - assert_eq!( - repo.drive(&after(&repo, "write", "call_1")).expect("Close"), - "" - ); - - assert!(read_state(&repo.git_dir()) - .expect("adapter state readable") - .attempts - .is_empty()); - assert_eq!( - repo.scope_status(&scope_id), - Some(("opencode".to_string(), "closed".to_string())), - ); - } - - #[test] - fn linked_worktree_uses_its_worktree_specific_git_dir_for_mutation_scope_state() { - let repo = OpenCodeRepo::new("linked-worktree"); - let linked_root = repo.temp.path().join("linked"); - git( - &repo.root, - &[ - "worktree", - "add", - "--quiet", - linked_root - .to_str() - .expect("linked worktree path should be UTF-8"), - ], - ); - - let main_git_dir = resolve_git_dir(&repo.root).expect("main git dir should resolve"); - let linked_git_dir = resolve_git_dir(&linked_root).expect("linked git dir should resolve"); - assert_ne!( - main_git_dir, linked_git_dir, - "linked worktrees must use distinct Git state locations", - ); - - let before = json!({ - "hook_event_name": "ToolExecuteBefore", - "session_id": "ses_linked", - "call_id": "call_linked", - "cwd": linked_root, - "tool_name": "write", - "model": "opencode/big-pickle", - }) - .to_string(); - run_opencode_mutation_scope_from_payload_at_state_root(&repo.state_root, &before, None) - .expect("linked-worktree Start"); - - let scope_id = read_state(&linked_git_dir) - .expect("linked adapter state should be readable") - .attempts[0] - .scope_id - .clone(); - fs::write(linked_root.join("file.txt"), "one\nlinked\n") - .expect("linked worktree file should be writable"); - - let after = json!({ - "hook_event_name": "ToolExecuteAfter", - "session_id": "ses_linked", - "call_id": "call_linked", - "cwd": linked_root, - "tool_name": "write", - }) - .to_string(); - run_opencode_mutation_scope_from_payload_at_state_root(&repo.state_root, &after, None) - .expect("linked-worktree Close"); - - assert!(read_state(&linked_git_dir) - .expect("linked adapter state should be readable") - .attempts - .is_empty()); - assert_eq!( - repo.scope_status(&scope_id), - Some(("opencode".to_string(), "closed".to_string())), - ); - } - - #[test] - fn a_tool_error_abandons_the_scope_through_the_real_runtime() { - let repo = OpenCodeRepo::new("tool-error-abandon"); - - repo.drive(&before(&repo, "edit", "call_1")).expect("Start"); - let scope_id = read_state(&repo.git_dir()) - .expect("adapter state readable") - .attempts[0] - .scope_id - .clone(); - - repo.write("file.txt", "one\nabandoned-a\n"); - repo.drive(&error(&repo, "edit", "call_1")) - .expect("terminal failure"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert!(state.attempts.is_empty()); - assert!( - state.recovery.is_clear(), - "a successful ambiguity flush clears the recovery barrier", - ); - assert_eq!( - repo.scope_status(&scope_id).map(|(_, status)| status), - Some("abandoned".to_string()), - ); - } - - #[test] - fn regression_a_failed_concurrent_scope_cannot_contaminate_a_survivor() { - let repo = OpenCodeRepo::new("regression-a-no-contamination"); - - repo.drive(&before(&repo, "write", "call_a")) - .expect("A Start"); - repo.drive(&before(&repo, "write", "call_b")) - .expect("B Start"); - let scope_b = read_state(&repo.git_dir()) - .expect("adapter state readable") - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_b") - .expect("B is tracked") - .scope_id - .clone(); - - repo.write("file_a.txt", "a mutated\n"); - repo.write("file_b.txt", "b mutated\n"); - - repo.drive(&error(&repo, "write", "call_a")) - .expect("A terminal failure"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert_eq!(state.attempts.len(), 1, "B stays tracked after A's cleanup"); - assert_eq!(state.attempts[0].call_id, "call_b"); - assert_eq!(state.attempts[0].phase, super::state::AttemptPhase::Active); - - repo.drive(&after(&repo, "write", "call_b")) - .expect("B Close"); - - let events = repo.mutation_events(); - assert!( - events.iter().any(|(kind, _)| kind == "ineligible_unscoped"), - "the ambiguous interval containing A's mutations is consumed as \ - IneligibleUnscoped: {events:?}", - ); - assert!( - !events.iter().any(|(kind, scope)| kind == "ai_exclusive" - && scope.as_deref() == Some(scope_b.as_str())), - "B must never be attributed the interval that could contain A's changes: {events:?}", - ); - assert_eq!( - repo.scope_status(&scope_b).map(|(_, status)| status), - Some("closed".to_string()), - ); - } - - #[test] - fn regression_b_survivor_still_attributes_its_later_mutations() { - let repo = OpenCodeRepo::new("regression-b-survivor-liveness"); - - repo.drive(&before(&repo, "write", "call_a")) - .expect("A Start"); - repo.drive(&before(&repo, "write", "call_b")) - .expect("B Start"); - let scope_b = read_state(&repo.git_dir()) - .expect("adapter state readable") - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_b") - .expect("B is tracked") - .scope_id - .clone(); - - repo.write("file_a.txt", "a mutated\n"); - repo.drive(&error(&repo, "write", "call_a")) - .expect("A terminal failure consumes the ambiguous interval"); - - repo.write("file_c.txt", "b's own later work\n"); - repo.drive(&after(&repo, "write", "call_b")) - .expect("B Close"); - - let events = repo.mutation_events(); - assert!( - events.iter().any(|(kind, scope)| kind == "ai_exclusive" - && scope.as_deref() == Some(scope_b.as_str())), - "after the ambiguity flush, B may legitimately attribute its own later \ - mutations: {events:?}", - ); - assert_eq!( - repo.scope_status(&scope_b).map(|(_, status)| status), - Some("closed".to_string()), - ); - } - - #[test] - fn regression_c_exact_error_does_not_sweep_siblings_through_the_real_runtime() { - let repo = OpenCodeRepo::new("regression-c-no-sibling-sweep"); - - repo.drive(&before(&repo, "write", "call_a")) - .expect("A Start"); - repo.drive(&before(&repo, "write", "call_b")) - .expect("B Start"); - repo.drive(&before(&repo, "write", "call_c")) - .expect("C Start"); - - repo.write("file.txt", "one\nmutated\n"); - repo.drive(&error(&repo, "write", "call_b")) - .expect("B terminal failure"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - let mut remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - remaining.sort_unstable(); - assert_eq!(remaining, vec!["call_a", "call_c"], "A and C remain active"); - assert!(state - .attempts - .iter() - .all(|a| a.phase == super::state::AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - } - - #[test] - fn regression_d_delayed_session_idle_cannot_kill_a_newer_call() { - let repo = OpenCodeRepo::new("regression-d-delayed-session-idle"); - - repo.drive(&before(&repo, "write", "call_b")) - .expect("newer call B Start"); - - repo.drive( - &json!({ - "hook_event_name": "SessionIdle", - "session_id": "ses_seam", - "cwd": repo.cwd(), - }) - .to_string(), - ) - .expect("a delayed SessionIdle for the same session is inert"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_b"); - assert_eq!(state.attempts[0].phase, super::state::AttemptPhase::Active); - assert!(state.recovery.is_clear()); - } - - #[test] - fn regression_e_server_disposed_cannot_sweep_another_process() { - let repo = OpenCodeRepo::new("regression-e-server-disposed"); - - repo.drive(&before(&repo, "write", "call_a")) - .expect("P1 owns call A"); - repo.drive( - &json!({ - "hook_event_name": "ToolExecuteBefore", - "session_id": "ses_p2", - "call_id": "call_b", - "cwd": repo.cwd(), - "tool_name": "write", - }) - .to_string(), - ) - .expect("P2 owns call B in the same checkout"); - - repo.drive(&json!({ "hook_event_name": "ServerDisposed", "cwd": repo.cwd() }).to_string()) - .expect("P1 server disposal is inert"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert_eq!( - state.attempts.len(), - 2, - "one process's disposal must not retire another process's live attempt", - ); - assert!(state.recovery.is_clear()); - } - - #[test] - fn regression_f_untracked_tool_error_is_zero_footprint() { - let repo = OpenCodeRepo::new("regression-f-untracked-tool-error"); - - for tool_name in ["read", "task", "brave-search_brave_web_search"] { - repo.drive(&error(&repo, tool_name, "call_x")) - .expect("an untracked ToolError is neutral"); - } - - assert_eq!(repo.scope_count(), 0); - assert!(!super::state::adapter_state_dir(&repo.git_dir()) - .join("opencode-mutation-scope-state.json") - .exists()); - } - - #[test] - fn untracked_events_never_reach_the_runtime_or_touch_adapter_state() { - let repo = OpenCodeRepo::new("untracked-zero-footprint"); - - assert_eq!( - repo.drive(&before(&repo, "read", "call_r")).expect("read"), - "" - ); - assert_eq!( - repo.drive(&before(&repo, "task", "call_t")).expect("task"), - "" - ); - - assert_eq!(repo.scope_count(), 0); - assert!(!super::state::adapter_state_dir(&repo.git_dir()) - .join("opencode-mutation-scope-state.json") - .exists()); - } - - #[test] - fn regression_d_concurrent_survivor_stays_usable_after_a_transient_cleanup_failure() { - let repo = OpenCodeRepo::new("regression-d-transient-cleanup-failure"); - - repo.drive(&before(&repo, "write", "call_a")) - .expect("A Start"); - repo.drive(&before(&repo, "write", "call_b")) - .expect("B Start"); - let scoped = read_state(&repo.git_dir()).expect("adapter state readable"); - let scope_a = scoped - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_a") - .expect("A is tracked") - .scope_id - .clone(); - let scope_b = scoped - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_b") - .expect("B is tracked") - .scope_id - .clone(); - - repo.write("file_a.txt", "a mutated\n"); - repo.write("file_b.txt", "b mutated\n"); - - let remaining_failures = std::cell::Cell::new(1_u32); - repo.drive_failing_seam_operation_once( - &error(&repo, "write", "call_a"), - "abandon", - &remaining_failures, - ) - .expect("A terminal failure with a transient abandon failure returns best-effort"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert_eq!( - state - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_a") - .map(|attempt| attempt.phase), - Some(super::state::AttemptPhase::PendingAbandon), - "A's terminal intent survives the transient failure", - ); - assert_eq!( - state - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_b") - .map(|attempt| attempt.phase), - Some(super::state::AttemptPhase::Active), - "B is untouched", - ); - assert!(!state.recovery.is_clear()); - - repo.drive(&error(&repo, "write", "call_a")) - .expect("a healthy duplicate ToolError retries and completes cleanup"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - let remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - assert_eq!(remaining, vec!["call_b"], "A retired, B still live"); - assert_eq!(state.attempts[0].phase, super::state::AttemptPhase::Active); - assert!(state.recovery.is_clear()); - assert_eq!( - repo.scope_status(&scope_a).map(|(_, status)| status), - Some("abandoned".to_string()), - ); - - repo.write("file_c.txt", "b's own later work\n"); - repo.drive(&after(&repo, "write", "call_b")) - .expect("B Close"); - - let events = repo.mutation_events(); - assert!( - events.iter().any(|(kind, _)| kind == "ineligible_unscoped"), - "the ambiguous A/B interval is consumed as IneligibleUnscoped: {events:?}", - ); - assert!( - events.iter().any(|(kind, scope)| kind == "ai_exclusive" - && scope.as_deref() == Some(scope_b.as_str())), - "B still attributes its own later mutation once recovery completed: {events:?}", - ); - } -} +mod tests; diff --git a/cli/src/services/hooks/opencode_mutation_scope/payload.rs b/cli/src/services/hooks/opencode_mutation_scope/payload.rs new file mode 100644 index 00000000..ca2ab105 --- /dev/null +++ b/cli/src/services/hooks/opencode_mutation_scope/payload.rs @@ -0,0 +1,43 @@ +use serde_json::json; + +use super::events::{OpenCodeScopeProvenance, ACTOR_KIND_OPENCODE}; + +pub(super) fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_OPENCODE, + }) + .to_string() +} + +pub(super) fn scope_start_payload( + scope_id: &str, + event_id: &str, + provenance: &OpenCodeScopeProvenance, +) -> String { + json!({ + "operation": "start", + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_OPENCODE, + "provenance": { + "session_id": provenance.session_id, + "model_id": provenance.model_id, + }, + }) + .to_string() +} + +pub(super) fn abandon_payload(scope_id: &str) -> String { + json!({ + "operation": "abandon", + "scope_id": scope_id, + }) + .to_string() +} + +pub(super) fn flush_payload() -> String { + json!({ "operation": "flush" }).to_string() +} diff --git a/cli/src/services/hooks/opencode_mutation_scope/tests.rs b/cli/src/services/hooks/opencode_mutation_scope/tests.rs new file mode 100644 index 00000000..53f40a7a --- /dev/null +++ b/cli/src/services/hooks/opencode_mutation_scope/tests.rs @@ -0,0 +1,1993 @@ +use super::events::{ + classify_tool, format_opencode_scope_id, opencode_scope_close_event_id, + opencode_scope_provenance, opencode_scope_start_event_id, parse_opencode_hook_event, + AttemptKey, OpenCodeHookEvent, OpenCodeToolExecution, OpenCodeToolIdentity, ToolClassification, + CALL_ID_FIELD, CWD_FIELD, HOOK_EVENT_NAME_FIELD, HOOK_EVENT_SERVER_DISPOSED, + HOOK_EVENT_SESSION_DELETED, HOOK_EVENT_SESSION_ERROR, HOOK_EVENT_SESSION_IDLE, + HOOK_EVENT_SHELL_ENV, HOOK_EVENT_TOOL_ERROR, HOOK_EVENT_TOOL_EXECUTE_AFTER, + HOOK_EVENT_TOOL_EXECUTE_BEFORE, MODEL_FIELD, SESSION_ID_FIELD, TOOL_NAME_FIELD, +}; +use super::lifecycle::{run_opencode_mutation_scope_from_payload, FAIL_CLOSED_MESSAGE}; +use serde_json::{Map, Value}; + +fn tool_event_json(hook_event_name: &str, overrides: &[(&str, Value)]) -> String { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(hook_event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("ses_main".to_string()), + ); + object.insert( + CALL_ID_FIELD.to_string(), + Value::String("call_1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("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/repo/checkout".to_string()), + ); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("write".to_string()), + ); + for (field, value) in overrides { + object.insert((*field).to_string(), value.clone()); + } + Value::Object(object).to_string() +} + +fn key(session_id: &str, call_id: &str) -> AttemptKey { + AttemptKey { + session_id: session_id.to_string(), + call_id: call_id.to_string(), + } +} + +fn tool_execution(payload: &str) -> OpenCodeToolExecution { + match parse_opencode_hook_event(payload).expect("valid ToolExecuteBefore parses") { + OpenCodeHookEvent::ToolExecuteBefore(execution) => execution, + other => panic!("expected ToolExecuteBefore, got {other:?}"), + } +} + +#[test] +fn empty_payload_is_rejected() { + let error = parse_opencode_hook_event(" ").unwrap_err().to_string(); + assert_eq!( + error, + "Invalid OpenCode hook event payload from STDIN: expected a JSON object, got an empty payload." + ); +} + +#[test] +fn non_object_json_is_rejected() { + for payload in ["[]", "\"ToolExecuteBefore\"", "42", "null"] { + let error = parse_opencode_hook_event(payload).unwrap_err().to_string(); + assert!( + error.contains("expected a JSON object"), + "payload {payload:?} produced {error:?}" + ); + } +} + +#[test] +fn invalid_json_is_rejected() { + let error = parse_opencode_hook_event("{not json") + .unwrap_err() + .to_string(); + assert!( + error.contains("Invalid OpenCode hook event payload from STDIN: expected valid JSON"), + "{error:?}" + ); +} + +#[test] +fn unsupported_hook_event_name_is_rejected() { + for name in ["PreToolUse", "ToolExecute", "chat.params", ""] { + let payload = tool_event_json(name, &[]); + let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("hook_event_name"), + "name {name:?} produced {error:?}" + ); + } +} + +#[test] +fn missing_required_fields_are_rejected_without_fabricating_identity() { + for field in [SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { + let mut object: Map = + serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_BEFORE, &[])).unwrap(); + object.remove(field); + let payload = Value::Object(object).to_string(); + + let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("'{field}'")), + "missing {field} produced {error:?}" + ); + } +} + +#[test] +fn blank_required_fields_are_rejected() { + for field in [SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[(field, Value::String(" ".to_string()))], + ); + let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("field '{field}' must be a non-blank string")), + "blank {field} produced {error:?}" + ); + } +} + +#[test] +fn wrong_typed_fields_are_rejected() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[(CALL_ID_FIELD, Value::Bool(true))], + ); + let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'call_id' must be a string"), + "{error:?}" + ); +} + +#[test] +fn wrong_typed_optional_model_is_rejected() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[(MODEL_FIELD, Value::Bool(false))], + ); + let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'model' must be null, absent, or a non-blank string"), + "{error:?}" + ); +} + +#[test] +fn tool_execute_before_parses_identity_and_model() { + let execution = tool_execution(&tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("edit".to_string())), + ( + MODEL_FIELD, + Value::String("opencode/big-pickle".to_string()), + ), + ], + )); + assert_eq!(execution.identity.session_id, "ses_main"); + assert_eq!(execution.identity.call_id, "call_1"); + assert_eq!(execution.identity.tool_name, "edit"); + assert_eq!(execution.model.as_deref(), Some("opencode/big-pickle")); + assert_eq!( + execution.identity.classification(), + ToolClassification::TrackedMutation + ); +} + +#[test] +fn tool_execute_before_model_is_optional() { + let execution = tool_execution(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_BEFORE, &[])); + assert_eq!(execution.model, None); +} + +#[test] +fn shell_env_parses_without_a_tool_name() { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SHELL_ENV.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("ses_main".to_string()), + ); + object.insert( + CALL_ID_FIELD.to_string(), + Value::String("call_bash".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + object.insert( + MODEL_FIELD.to_string(), + Value::String("opencode/big-pickle".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let OpenCodeHookEvent::ShellEnv(shell) = parse_opencode_hook_event(&payload).unwrap() else { + panic!("expected ShellEnv"); + }; + assert_eq!(shell.call_id, "call_bash"); + assert_eq!(shell.model.as_deref(), Some("opencode/big-pickle")); + assert_eq!(shell.attempt_key(), key("ses_main", "call_bash")); +} + +#[test] +fn tool_execute_after_parses_tool_identity() { + let OpenCodeHookEvent::ToolExecuteAfter(identity) = + parse_opencode_hook_event(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_AFTER, &[])).unwrap() + else { + panic!("expected ToolExecuteAfter"); + }; + assert_eq!(identity.attempt_key(), key("ses_main", "call_1")); + assert_eq!(identity.tool_name, "write"); +} + +#[test] +fn terminal_events_parse_their_minimal_identity() { + for name in [ + HOOK_EVENT_SESSION_IDLE, + HOOK_EVENT_SESSION_ERROR, + HOOK_EVENT_SESSION_DELETED, + ] { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("ses_main".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + let payload = Value::Object(object).to_string(); + + let event = parse_opencode_hook_event(&payload).unwrap(); + let identity = match event { + OpenCodeHookEvent::SessionIdle(identity) + | OpenCodeHookEvent::SessionError(identity) + | OpenCodeHookEvent::SessionDeleted(identity) => identity, + other => panic!("expected a session-identity event, got {other:?}"), + }; + assert_eq!(identity.session_id, "ses_main"); + assert_eq!(identity.cwd, "/repo"); + } + + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_TOOL_ERROR.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("ses_main".to_string()), + ); + object.insert( + CALL_ID_FIELD.to_string(), + Value::String("call_1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("write".to_string()), + ); + let OpenCodeHookEvent::ToolError(identity) = + parse_opencode_hook_event(&Value::Object(object).to_string()).unwrap() + else { + panic!("expected ToolError"); + }; + assert_eq!(identity.attempt_key(), key("ses_main", "call_1")); + assert_eq!( + identity.classification(), + ToolClassification::TrackedMutation + ); + + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SERVER_DISPOSED.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + let OpenCodeHookEvent::ServerDisposed(workspace) = + parse_opencode_hook_event(&Value::Object(object).to_string()).unwrap() + else { + panic!("expected ServerDisposed"); + }; + assert_eq!(workspace.cwd, "/repo"); +} + +#[test] +fn classification_table() { + let cases: &[(&str, ToolClassification)] = &[ + ("bash", ToolClassification::TrackedMutation), + ("write", ToolClassification::TrackedMutation), + ("edit", ToolClassification::TrackedMutation), + ("apply_patch", ToolClassification::TrackedMutation), + ("task", ToolClassification::Delegation), + ("read", ToolClassification::Untracked), + ("glob", ToolClassification::Untracked), + ("grep", ToolClassification::Untracked), + ("webfetch", ToolClassification::Untracked), + ("websearch", ToolClassification::Untracked), + ("todowrite", ToolClassification::Untracked), + ("probe_mutate", ToolClassification::Untracked), + ( + "brave-search_brave_web_search", + ToolClassification::Untracked, + ), + ("Bash", ToolClassification::Untracked), + ("some_future_opencode_tool", ToolClassification::Untracked), + ("", ToolClassification::Untracked), + ]; + for (tool_name, expected) in cases { + assert_eq!( + classify_tool(tool_name), + *expected, + "classify_tool({tool_name:?})" + ); + } +} + +#[test] +fn classification_is_total_and_single_valued() { + for tool_name in ["bash", "write", "edit", "apply_patch", "task", "read", "x"] { + let _: ToolClassification = classify_tool(tool_name); + } +} + +#[test] +fn scope_id_is_deterministic_for_the_same_key() { + let k = key("ses_main", "call_1"); + assert_eq!(format_opencode_scope_id(&k), format_opencode_scope_id(&k)); + + let scope_id = format_opencode_scope_id(&k); + assert_eq!(scope_id, "oc-tool-v1|s=8:ses_main|c=6:call_1"); + assert_eq!( + opencode_scope_start_event_id(&scope_id), + format!("{scope_id}|start") + ); + assert_eq!( + opencode_scope_close_event_id(&scope_id), + format!("{scope_id}|close") + ); + assert_ne!( + opencode_scope_start_event_id(&scope_id), + opencode_scope_close_event_id(&scope_id) + ); +} + +#[test] +fn duplicate_events_reuse_the_same_scope_id() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[(TOOL_NAME_FIELD, Value::String("bash".to_string()))], + ); + let first = tool_execution(&payload).identity.attempt_key(); + let second = tool_execution(&payload).identity.attempt_key(); + assert_eq!( + format_opencode_scope_id(&first), + format_opencode_scope_id(&second) + ); +} + +#[test] +fn length_prefix_disambiguates_delimiter_collisions() { + let a = key("s|c=1:x", "y"); + let b = key("s", "1:x|y"); + assert_ne!(format_opencode_scope_id(&a), format_opencode_scope_id(&b)); + + let tricky = key("ses|c=0:x", "call:with:colons"); + assert_eq!( + format_opencode_scope_id(&tricky), + format!( + "oc-tool-v1|s={}:{}|c={}:{}", + tricky.session_id.len(), + tricky.session_id, + tricky.call_id.len(), + tricky.call_id, + ) + ); +} + +#[test] +fn parallel_call_ids_in_one_session_stay_distinguishable() { + let a = tool_execution(&tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("bash".to_string())), + (CALL_ID_FIELD, Value::String("call_a".to_string())), + ], + )); + let b = tool_execution(&tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("bash".to_string())), + (CALL_ID_FIELD, Value::String("call_b".to_string())), + ], + )); + assert_ne!(a.identity.attempt_key(), b.identity.attempt_key()); + assert_ne!( + format_opencode_scope_id(&a.identity.attempt_key()), + format_opencode_scope_id(&b.identity.attempt_key()) + ); +} + +#[test] +fn task_child_session_identity_flows_through_the_attempt_key() { + let child = tool_execution(&tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("bash".to_string())), + (SESSION_ID_FIELD, Value::String("ses_child".to_string())), + (CALL_ID_FIELD, Value::String("call_child".to_string())), + ], + )); + assert_eq!(child.identity.attempt_key(), key("ses_child", "call_child")); + assert_ne!( + format_opencode_scope_id(&child.identity.attempt_key()), + format_opencode_scope_id(&key("ses_main", "call_child")) + ); +} + +#[test] +fn attempt_key_projects_only_session_and_call() { + let identity_a = OpenCodeToolIdentity { + session_id: "ses_main".to_string(), + call_id: "call_1".to_string(), + cwd: "/repo".to_string(), + tool_name: "write".to_string(), + }; + let identity_b = OpenCodeToolIdentity { + tool_name: "bash".to_string(), + cwd: "/other".to_string(), + ..identity_a.clone() + }; + assert_eq!(identity_a.attempt_key(), identity_b.attempt_key()); +} + +#[test] +fn provenance_canonicalizes_the_session_and_normalizes_the_model() { + let provenance = opencode_scope_provenance("ses_main", Some("opencode/big-pickle")); + assert_eq!(provenance.session_id, "oc_ses_main"); + assert_eq!(provenance.model_id.as_deref(), Some("opencode/big-pickle")); +} + +#[test] +fn provenance_keeps_an_already_prefixed_session_id() { + let provenance = opencode_scope_provenance("oc_ses_main", None); + assert_eq!(provenance.session_id, "oc_ses_main"); +} + +#[test] +fn provenance_without_model_evidence_is_null() { + for model in [None, Some(""), Some(" ")] { + let provenance = opencode_scope_provenance("ses_main", model); + assert_eq!(provenance.model_id, None, "model {model:?}"); + assert_eq!(provenance.session_id, "oc_ses_main", "model {model:?}"); + } +} + +#[test] +fn provenance_is_built_from_a_parsed_start_event() { + let execution = tool_execution(&tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("write".to_string())), + ( + MODEL_FIELD, + Value::String("opencode/big-pickle".to_string()), + ), + ], + )); + let provenance = + opencode_scope_provenance(&execution.identity.session_id, execution.model.as_deref()); + assert_eq!(provenance.session_id, "oc_ses_main"); + assert_eq!(provenance.model_id.as_deref(), Some("opencode/big-pickle")); +} + +#[test] +fn run_from_payload_fails_closed_when_a_tracked_start_cannot_resolve_its_checkout() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("write".to_string())), + ( + CWD_FIELD, + Value::String("/nonexistent/sce/opencode/checkout".to_string()), + ), + ], + ); + let error = run_opencode_mutation_scope_from_payload(&payload, None) + .expect_err("a tracked Start that cannot resolve its checkout must fail closed"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE), "{error:?}"); +} + +#[test] +fn run_from_payload_is_neutral_for_untracked_and_delegation_events() { + for tool_name in ["read", "task", "probe_mutate"] { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], + ); + assert_eq!( + run_opencode_mutation_scope_from_payload(&payload, None).unwrap(), + String::new() + ); + } +} + +#[test] +fn run_from_payload_surfaces_malformed_input() { + let error = run_opencode_mutation_scope_from_payload("{bad", None) + .unwrap_err() + .to_string(); + assert!(error.contains("expected valid JSON"), "{error:?}"); +} + +mod lifecycle_tests { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Mutex; + + use anyhow::{bail, Result}; + use serde_json::{json, Value}; + + use crate::services::observability::traits::Logger; + + use super::super::lifecycle::{ + run_opencode_mutation_scope_from_payload_with_seams, FAIL_CLOSED_MESSAGE, + }; + use super::super::state::{read_state, AttemptPhase, RecoveryState}; + + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + + fn temp_git_dir(label: &str) -> PathBuf { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-opencode-mutation-scope-lifecycle-{label}-{}-{id}", + std::process::id() + )) + } + + const CWD: &str = "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/repo/opencode-checkout"; + + struct RecordingSeam { + calls: Mutex>, + fail_operations: Vec, + fail_once_operations: Mutex>, + fail_operation_occurrence: Option<(String, usize)>, + } + + impl RecordingSeam { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + fail_operations: Vec::new(), + fail_once_operations: Mutex::new(Vec::new()), + fail_operation_occurrence: None, + } + } + + fn failing_on(operations: &[&str]) -> Self { + Self { + fail_operations: operations.iter().map(|op| (*op).to_string()).collect(), + ..Self::new() + } + } + + fn failing_once_on(operations: &[&str]) -> Self { + Self { + fail_once_operations: Mutex::new( + operations.iter().map(|op| (*op).to_string()).collect(), + ), + ..Self::new() + } + } + + fn failing_on_nth_occurrence(operation: &str, occurrence: usize) -> Self { + Self { + fail_operation_occurrence: Some((operation.to_string(), occurrence)), + ..Self::new() + } + } + + fn handle(&self, payload: &str) -> Result { + let operation = operation_of(payload); + let occurrence = { + let mut calls = self.calls.lock().expect("seam mutex"); + calls.push(operation.clone()); + calls + .iter() + .filter(|candidate| *candidate == &operation) + .count() + }; + if self.fail_operations.contains(&operation) { + bail!("seam failure injected by test for '{operation}'"); + } + if let Some((target, target_occurrence)) = &self.fail_operation_occurrence { + if target == &operation && *target_occurrence == occurrence { + bail!( + "seam failure injected by test for '{operation}' occurrence {occurrence}" + ); + } + } + { + let mut once = self.fail_once_operations.lock().expect("seam mutex"); + if let Some(position) = once.iter().position(|candidate| candidate == &operation) { + once.remove(position); + bail!("transient seam failure injected once by test for '{operation}'"); + } + } + Ok(String::new()) + } + + fn operations(&self) -> Vec { + self.calls.lock().expect("seam mutex").clone() + } + } + + fn operation_of(payload: &str) -> String { + let value: Value = serde_json::from_str(payload).expect("seam payload is JSON"); + value + .get("operation") + .and_then(Value::as_str) + .expect("seam payload has an operation") + .to_string() + } + + fn drive(git_dir: &Path, seam: &RecordingSeam, payload: &str) -> Result { + let resolver = |_cwd: &str| Ok(git_dir.to_path_buf()); + let seam_fn = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload); + run_opencode_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) + } + + fn tool_before(tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecuteBefore", + "session_id": "ses_main", + "call_id": call_id, + "cwd": CWD, + "tool_name": tool_name, + "model": "opencode/big-pickle", + }) + .to_string() + } + + fn shell_env(call_id: &str) -> String { + json!({ + "hook_event_name": "ShellEnv", + "session_id": "ses_main", + "call_id": call_id, + "cwd": CWD, + "model": "opencode/big-pickle", + }) + .to_string() + } + + fn tool_after(tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecuteAfter", + "session_id": "ses_main", + "call_id": call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() + } + + fn tool_error(tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolError", + "session_id": "ses_main", + "call_id": call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() + } + + fn session_event(hook_event_name: &str, session_id: &str) -> String { + json!({ + "hook_event_name": hook_event_name, + "session_id": session_id, + "cwd": CWD, + }) + .to_string() + } + + fn server_disposed() -> String { + json!({ "hook_event_name": "ServerDisposed", "cwd": CWD }).to_string() + } + + fn cleanup(git_dir: &Path) { + let _ = std::fs::remove_dir_all(git_dir); + } + + #[test] + fn file_tool_before_establishes_a_write_ahead_start_and_replays_idempotently() { + let git_dir = temp_git_dir("write-ahead-start"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("first Start"); + drive(&git_dir, &seam, &tool_before("write", "call_1")) + .expect("duplicate Start is a no-op"); + + assert_eq!(seam.operations(), vec!["start"]); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert_eq!(state.attempts[0].tool_name, "write"); + + cleanup(&git_dir); + } + + #[test] + fn bash_start_is_anchored_to_shell_env_not_tool_execute_before() { + let git_dir = temp_git_dir("bash-shell-env"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_before("bash", "call_bash")).expect("bash before is inert"); + assert!(seam.operations().is_empty()); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + drive(&git_dir, &seam, &shell_env("call_bash")).expect("shell.env establishes Start"); + assert_eq!(seam.operations(), vec!["start"]); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts[0].phase, + AttemptPhase::Active, + ); + + cleanup(&git_dir); + } + + #[test] + fn concurrent_bash_calls_in_one_session_stay_separate_live_scopes() { + let git_dir = temp_git_dir("concurrent-bash"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start must not retire A"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 2); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); + assert_eq!(seam.operations(), vec!["start", "start"]); + + cleanup(&git_dir); + } + + #[test] + fn successful_after_closes_exactly_that_attempt_and_replays_as_a_no_op() { + let git_dir = temp_git_dir("close-replay"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_before("edit", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_after("edit", "call_1")).expect("Close"); + drive(&git_dir, &seam, &tool_after("edit", "call_1")).expect("duplicate Close is a no-op"); + + assert_eq!(seam.operations(), vec!["start", "close"]); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + cleanup(&git_dir); + } + + #[test] + fn tool_error_retires_the_named_attempt_and_consumes_the_ambiguous_interval() { + let git_dir = temp_git_dir("tool-error-consume"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); + drive(&git_dir, &seam, &tool_error("bash", "call_a")).expect("A terminal failure"); + + assert_eq!( + seam.operations(), + vec!["start", "start", "flush", "abandon", "flush"], + "the ambiguous interval is flushed before A is abandoned, then the abandon \ + rebaseline is flushed away so B keeps its future intervals", + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_b"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert!( + state.recovery.is_clear(), + "a successful ambiguity flush clears the recovery barrier while B stays live", + ); + + cleanup(&git_dir); + } + + #[test] + fn exact_error_retires_only_the_named_sibling() { + let git_dir = temp_git_dir("exact-error-siblings"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); + drive(&git_dir, &seam, &shell_env("call_c")).expect("C Start"); + drive(&git_dir, &seam, &tool_error("bash", "call_b")).expect("B terminal failure"); + + let state = read_state(&git_dir).expect("state readable"); + let mut remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + remaining.sort_unstable(); + assert_eq!(remaining, vec!["call_a", "call_c"]); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + assert_eq!( + seam.operations() + .iter() + .filter(|op| *op == "abandon") + .count(), + 1, + "abandoning B must not sweep A or C", + ); + + cleanup(&git_dir); + } + + #[test] + fn a_close_before_start_confirmation_consumes_rather_than_closes() { + let git_dir = temp_git_dir("pending-start-close"); + let seam = RecordingSeam::failing_on(&["start"]); + + let error = drive(&git_dir, &seam, &tool_before("write", "call_1")) + .expect_err("a failed Start seam must fail closed"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts[0].phase, + AttemptPhase::PendingStart, + ); + + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_after("write", "call_1")).expect("After on a PendingStart"); + assert_eq!(ok_seam.operations(), vec!["flush", "abandon", "flush"]); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + cleanup(&git_dir); + } + + #[test] + fn session_idle_is_non_destructive() { + let git_dir = temp_git_dir("session-idle-noop"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); + drive( + &git_dir, + &seam, + &json!({ + "hook_event_name": "ShellEnv", + "session_id": "ses_other", + "call_id": "call_c", + "cwd": CWD, + }) + .to_string(), + ) + .expect("other-session Start"); + + for name in ["SessionIdle", "SessionError", "SessionDeleted"] { + drive(&git_dir, &seam, &session_event(name, "ses_main")).expect("broad event is inert"); + } + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!( + state.attempts.len(), + 2, + "no attempt is retired by a broad event" + ); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + assert_eq!(seam.operations(), vec!["start", "start"]); + + cleanup(&git_dir); + } + + #[test] + fn delayed_session_idle_cannot_retire_a_newer_call() { + let git_dir = temp_git_dir("delayed-session-idle"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_b")).expect("newer call B Start"); + drive(&git_dir, &seam, &session_event("SessionIdle", "ses_main")) + .expect("a delayed SessionIdle for the same session arrives after B started"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_b"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert!(state.recovery.is_clear()); + assert!(!seam.operations().iter().any(|op| op == "abandon")); + + cleanup(&git_dir); + } + + #[test] + fn server_disposed_cannot_sweep_another_processes_attempt() { + let git_dir = temp_git_dir("server-disposed-noop"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("process P1 owns call A"); + drive( + &git_dir, + &seam, + &json!({ + "hook_event_name": "ShellEnv", + "session_id": "ses_other", + "call_id": "call_b", + "cwd": CWD, + }) + .to_string(), + ) + .expect("process P2 owns call B in the same checkout"); + + drive(&git_dir, &seam, &server_disposed()).expect("P1 server disposal is inert"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!( + state.attempts.len(), + 2, + "one process's disposal must not retire another process's live attempt", + ); + assert!(state.recovery.is_clear()); + assert_eq!(seam.operations(), vec!["start", "start"]); + + cleanup(&git_dir); + } + + #[test] + fn a_failed_sibling_does_not_retire_survivors_or_block_new_starts() { + let git_dir = temp_git_dir("failed-sibling-survivors"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); + drive(&git_dir, &seam, &tool_error("bash", "call_a")).expect("A fails"); + + drive(&git_dir, &seam, &shell_env("call_c")) + .expect("a new Start is admitted normally once the ambiguity flush cleared recovery"); + + let state = read_state(&git_dir).expect("state readable"); + let mut remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + remaining.sort_unstable(); + assert_eq!(remaining, vec!["call_b", "call_c"]); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } + + #[test] + fn a_terminal_failure_consumes_the_interval_and_the_next_start_proceeds() { + let git_dir = temp_git_dir("terminal-then-start"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_error("write", "call_1")).expect("terminal failure"); + assert!( + read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear(), + "the ambiguity flush is done at abandon time, not deferred to the next Start", + ); + + drive(&git_dir, &seam, &tool_before("write", "call_2")).expect("Start after consume"); + + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush", "start"], + ); + let state = read_state(&git_dir).expect("state readable"); + assert!(state.recovery.is_clear()); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_2"); + + cleanup(&git_dir); + } + + #[test] + fn a_failed_ambiguity_flush_stays_recovery_pending_and_fails_closed_starts() { + let git_dir = temp_git_dir("failed-ambiguity-flush"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); + } + + let failing = RecordingSeam::failing_on(&["flush"]); + drive(&git_dir, &failing, &tool_error("write", "call_1")) + .expect("a terminal failure whose flush fails still returns (best-effort)"); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + "a failed ambiguity flush retains a recovery-required state", + ); + + let error = drive(&git_dir, &failing, &tool_before("write", "call_2")) + .expect_err("a new Start while recovery is unresolved must stay fail-closed"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + ); + + cleanup(&git_dir); + } + + #[test] + fn untracked_and_delegation_events_are_zero_footprint() { + let git_dir = temp_git_dir("zero-footprint"); + let seam = RecordingSeam::new(); + + for payload in [ + tool_before("read", "call_r"), + tool_before("task", "call_t"), + tool_before("some_future_tool", "call_f"), + tool_after("read", "call_r"), + tool_error("read", "call_r"), + tool_error("task", "call_t"), + ] { + drive(&git_dir, &seam, &payload).expect("untracked event is neutral"); + } + + assert!(seam.operations().is_empty()); + assert!( + !git_dir.join("sce").exists(), + "no state directory is created" + ); + + cleanup(&git_dir); + } + + #[test] + fn close_seam_failure_falls_back_to_consume() { + let git_dir = temp_git_dir("close-seam-failure"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("edit", "call_1")).expect("Start"); + } + + let failing = RecordingSeam::failing_on(&["close"]); + drive(&git_dir, &failing, &tool_after("edit", "call_1")).expect("Close seam failure"); + + assert_eq!( + failing.operations(), + vec!["close", "flush", "abandon", "flush"], + ); + let state = read_state(&git_dir).expect("state readable"); + assert!(state.attempts.is_empty()); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } + + #[test] + fn regression_a_abandon_failure_preserves_terminal_intent_then_recovers() { + let git_dir = temp_git_dir("regression-a-abandon-failure"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("A Start"); + } + + let failing = RecordingSeam::failing_on(&["abandon"]); + drive(&git_dir, &failing, &tool_error("write", "call_1")) + .expect("a terminal failure whose abandon fails still returns best-effort"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1, "A is not forgotten"); + assert_eq!(state.attempts[0].call_id, "call_1"); + assert_eq!( + state.attempts[0].phase, + AttemptPhase::PendingAbandon, + "the exact terminal attempt is durably marked PendingAbandon", + ); + assert!( + !state.recovery.is_clear(), + "recovery stays unresolved while Abandon has not succeeded", + ); + assert_eq!( + failing.operations(), + vec!["flush", "abandon"], + "the ambiguity flush ran, then the abandon that failed; no rebaseline flush, \ + no removal", + ); + + let healthy = RecordingSeam::new(); + drive(&git_dir, &healthy, &tool_before("write", "call_2")) + .expect("a healthy retry boundary resolves recovery and admits the new Start"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_2"); + assert!(state.recovery.is_clear()); + assert_eq!( + healthy.operations(), + vec!["flush", "abandon", "flush", "start"], + "recovery replays flush + abandon + rebaseline flush before the new Start", + ); + + cleanup(&git_dir); + } + + #[test] + fn regression_b_ambiguity_flush_failure_blocks_new_starts_then_recovers() { + let git_dir = temp_git_dir("regression-b-ambiguity-flush-failure"); + let live = RecordingSeam::new(); + + drive(&git_dir, &live, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &live, &shell_env("call_b")).expect("B Start"); + + let failing = RecordingSeam::failing_on(&["flush"]); + drive(&git_dir, &failing, &tool_error("bash", "call_a")).expect("A terminal failure"); + + let state = read_state(&git_dir).expect("state readable"); + let pending_abandon: Vec<&str> = state + .attempts + .iter() + .filter(|attempt| attempt.phase == AttemptPhase::PendingAbandon) + .map(|attempt| attempt.call_id.as_str()) + .collect(); + let active: Vec<&str> = state + .attempts + .iter() + .filter(|attempt| attempt.phase == AttemptPhase::Active) + .map(|attempt| attempt.call_id.as_str()) + .collect(); + assert_eq!(pending_abandon, vec!["call_a"], "A is PendingAbandon"); + assert_eq!(active, vec!["call_b"], "B stays Active"); + assert!(!state.recovery.is_clear(), "recovery pending"); + + let error = drive(&git_dir, &failing, &shell_env("call_c")) + .expect_err("a new tracked Start must fail closed while recovery is unresolved"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .iter() + .all(|attempt| attempt.call_id != "call_c")); + + let healthy = RecordingSeam::new(); + drive(&git_dir, &healthy, &shell_env("call_c")) + .expect("once recovery succeeds a new Start is admitted normally"); + + let state = read_state(&git_dir).expect("state readable"); + let mut remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + remaining.sort_unstable(); + assert_eq!( + remaining, + vec!["call_b", "call_c"], + "A removed, B kept, C admitted" + ); + assert!(state + .attempts + .iter() + .all(|attempt| attempt.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } + + #[test] + fn regression_c_rebaseline_flush_failure_is_recoverable_without_poison() { + let git_dir = temp_git_dir("regression-c-rebaseline-flush-failure"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("edit", "call_1")).expect("Start"); + } + + let rebaseline_failing = RecordingSeam::failing_on_nth_occurrence("flush", 2); + drive(&git_dir, &rebaseline_failing, &tool_error("edit", "call_1")) + .expect("terminal failure whose rebaseline flush fails still returns"); + + assert_eq!( + rebaseline_failing.operations(), + vec!["flush", "abandon", "flush"], + "the ambiguity flush and the abandon succeeded; the rebaseline flush failed", + ); + let state = read_state(&git_dir).expect("state readable"); + assert!( + state.attempts.is_empty(), + "the abandon succeeded so the attempt is removed", + ); + assert!( + !state.recovery.is_clear(), + "recovery state still carries the outstanding rebaseline", + ); + + let healthy = RecordingSeam::new(); + drive(&git_dir, &healthy, &tool_before("edit", "call_2")).expect("retry admits new work"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_2"); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } + + #[test] + fn regression_e_duplicate_tool_error_is_idempotent() { + let git_dir = temp_git_dir("regression-e-duplicate-tool-error"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); + } + + let stuck = RecordingSeam::failing_on(&["abandon"]); + drive(&git_dir, &stuck, &tool_error("write", "call_1")).expect("first terminal failure"); + drive(&git_dir, &stuck, &tool_error("write", "call_1")) + .expect("a duplicate ToolError while PendingAbandon is idempotent"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1, "no second attempt is created"); + assert_eq!(state.attempts[0].phase, AttemptPhase::PendingAbandon); + assert_eq!( + state.next_recovery_generation, 2, + "the recovery generation is not incremented by the duplicate", + ); + + cleanup(&git_dir); + } + + #[test] + fn regression_f_start_replay_for_a_pending_abandon_identity_never_reactivates() { + let git_dir = temp_git_dir("regression-f-start-replay-pending-abandon"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); + } + + let stuck = RecordingSeam::failing_on(&["abandon"]); + drive(&git_dir, &stuck, &tool_error("write", "call_1")).expect("terminal failure"); + + let error = drive(&git_dir, &stuck, &tool_before("write", "call_1")) + .expect_err("a replayed Start for a PendingAbandon identity must fail closed"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!( + state.attempts[0].phase, + AttemptPhase::PendingAbandon, + "the replayed Start does not return the attempt to Active", + ); + + cleanup(&git_dir); + } + + #[test] + fn regression_g_late_tool_error_after_close_is_a_harmless_no_op() { + let git_dir = temp_git_dir("regression-g-late-tool-error"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_after("write", "call_1")).expect("Close"); + drive(&git_dir, &seam, &tool_error("write", "call_1")) + .expect("a late ToolError after a completed Close is inert"); + + assert_eq!(seam.operations(), vec!["start", "close"]); + let state = read_state(&git_dir).expect("state readable"); + assert!(state.attempts.is_empty()); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } + + #[test] + fn regression_h_siblings_stay_active_through_a_transient_cleanup_failure() { + let git_dir = temp_git_dir("regression-h-siblings-preserved"); + let live = RecordingSeam::new(); + + drive(&git_dir, &live, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &live, &shell_env("call_b")).expect("B Start"); + drive(&git_dir, &live, &shell_env("call_c")).expect("C Start"); + + let transient = RecordingSeam::failing_once_on(&["abandon"]); + drive(&git_dir, &transient, &tool_error("bash", "call_b")) + .expect("B terminal failure with a transient abandon failure"); + + let snapshot = read_state(&git_dir).expect("state readable"); + let mut siblings: Vec<&str> = snapshot + .attempts + .iter() + .filter(|attempt| attempt.phase == AttemptPhase::Active) + .map(|attempt| attempt.call_id.as_str()) + .collect(); + siblings.sort_unstable(); + assert_eq!( + siblings, + vec!["call_a", "call_c"], + "A and C stay Active mid-failure" + ); + + let healthy = RecordingSeam::new(); + drive(&git_dir, &healthy, &shell_env("call_d")) + .expect("the retry boundary resolves recovery and admits D"); + + let state = read_state(&git_dir).expect("state readable"); + let mut remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + remaining.sort_unstable(); + assert_eq!( + remaining, + vec!["call_a", "call_c", "call_d"], + "B gone, A/C/D active" + ); + assert!(state + .attempts + .iter() + .all(|attempt| attempt.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } +} + +mod runtime_seam_tests { + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + use anyhow::{anyhow, Result}; + use serde_json::json; + + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::hooks::opencode_mutation_scope::lifecycle::{ + run_opencode_mutation_scope_from_payload_at_state_root, + run_opencode_mutation_scope_from_payload_with_seams, + }; + use crate::services::mutation_trace::runtime::resolve_git_dir; + use crate::services::observability::traits::Logger; + + use super::super::state::{adapter_state_dir, read_state, AttemptPhase}; + + fn git(dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + struct OpenCodeRepo { + temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + } + + impl OpenCodeRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-opencode-mutation-scope-seam-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + temp, + root, + state_root, + } + } + + fn cwd(&self) -> String { + self.root.to_string_lossy().into_owned() + } + + fn drive(&self, payload: &str) -> Result { + run_opencode_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn drive_failing_seam_operation_once( + &self, + payload: &str, + fail_operation: &str, + remaining_failures: &std::cell::Cell, + ) -> Result { + let resolver = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |root: &Path, seam_payload: &str, logger: Option<&dyn Logger>| { + let operation = serde_json::from_str::(seam_payload) + .ok() + .and_then(|value| { + value + .get("operation") + .and_then(|op| op.as_str()) + .map(str::to_owned) + }) + .unwrap_or_default(); + if operation == fail_operation && remaining_failures.get() > 0 { + remaining_failures.set(remaining_failures.get() - 1); + return Err(anyhow!("injected transient '{operation}' seam failure")); + } + crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + root, + &self.state_root, + seam_payload, + logger, + ) + }; + run_opencode_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) + } + + fn write(&self, name: &str, contents: &str) { + fs::write(self.root.join(name), contents).expect("write should succeed"); + } + + fn git_dir(&self) -> PathBuf { + resolve_git_dir(&self.root).expect("git dir should resolve") + } + + fn db(&self) -> RepositoryAgentTraceDb { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "opencode mutation-scope seam test assertions", + ) + .expect("assertion DB should open") + } + + fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { + self.db() + .query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn scope_count(&self) -> i64 { + self.db() + .query_map("SELECT COUNT(*) FROM mutation_trace_scopes", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("a count row should exist") + } + + fn mutation_events(&self) -> Vec<(String, Option)> { + self.db() + .query_map( + "SELECT attribution_kind, attribution_scope_id \ + FROM mutation_trace_events ORDER BY revision", + (), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id)) + }, + ) + .expect("mutation-events query should succeed") + } + } + + fn error(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolError", + "session_id": "ses_seam", + "call_id": call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + }) + .to_string() + } + + fn before(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecuteBefore", + "session_id": "ses_seam", + "call_id": call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + "model": "opencode/big-pickle", + }) + .to_string() + } + + fn after(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecuteAfter", + "session_id": "ses_seam", + "call_id": call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + }) + .to_string() + } + + #[test] + fn a_write_start_then_after_closes_the_scope_through_the_real_runtime() { + let repo = OpenCodeRepo::new("write-start-close"); + + assert_eq!( + repo.drive(&before(&repo, "write", "call_1")) + .expect("Start"), + "" + ); + let scope_id = { + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert_eq!(state.attempts.len(), 1); + state.attempts[0].scope_id.clone() + }; + + repo.write("file.txt", "one\ntwo\n"); + assert_eq!( + repo.drive(&after(&repo, "write", "call_1")).expect("Close"), + "" + ); + + assert!(read_state(&repo.git_dir()) + .expect("adapter state readable") + .attempts + .is_empty()); + assert_eq!( + repo.scope_status(&scope_id), + Some(("opencode".to_string(), "closed".to_string())), + ); + } + + #[test] + fn linked_worktree_uses_its_worktree_specific_git_dir_for_mutation_scope_state() { + let repo = OpenCodeRepo::new("linked-worktree"); + let linked_root = repo.temp.path().join("linked"); + git( + &repo.root, + &[ + "worktree", + "add", + "--quiet", + linked_root + .to_str() + .expect("linked worktree path should be UTF-8"), + ], + ); + + let main_git_dir = resolve_git_dir(&repo.root).expect("main git dir should resolve"); + let linked_git_dir = resolve_git_dir(&linked_root).expect("linked git dir should resolve"); + assert_ne!( + main_git_dir, linked_git_dir, + "linked worktrees must use distinct Git state locations", + ); + + let before = json!({ + "hook_event_name": "ToolExecuteBefore", + "session_id": "ses_linked", + "call_id": "call_linked", + "cwd": linked_root, + "tool_name": "write", + "model": "opencode/big-pickle", + }) + .to_string(); + run_opencode_mutation_scope_from_payload_at_state_root(&repo.state_root, &before, None) + .expect("linked-worktree Start"); + + let scope_id = read_state(&linked_git_dir) + .expect("linked adapter state should be readable") + .attempts[0] + .scope_id + .clone(); + fs::write(linked_root.join("file.txt"), "one\nlinked\n") + .expect("linked worktree file should be writable"); + + let after = json!({ + "hook_event_name": "ToolExecuteAfter", + "session_id": "ses_linked", + "call_id": "call_linked", + "cwd": linked_root, + "tool_name": "write", + }) + .to_string(); + run_opencode_mutation_scope_from_payload_at_state_root(&repo.state_root, &after, None) + .expect("linked-worktree Close"); + + assert!(read_state(&linked_git_dir) + .expect("linked adapter state should be readable") + .attempts + .is_empty()); + assert_eq!( + repo.scope_status(&scope_id), + Some(("opencode".to_string(), "closed".to_string())), + ); + } + + #[test] + fn a_tool_error_abandons_the_scope_through_the_real_runtime() { + let repo = OpenCodeRepo::new("tool-error-abandon"); + + repo.drive(&before(&repo, "edit", "call_1")).expect("Start"); + let scope_id = read_state(&repo.git_dir()) + .expect("adapter state readable") + .attempts[0] + .scope_id + .clone(); + + repo.write("file.txt", "one\nabandoned-a\n"); + repo.drive(&error(&repo, "edit", "call_1")) + .expect("terminal failure"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert!(state.attempts.is_empty()); + assert!( + state.recovery.is_clear(), + "a successful ambiguity flush clears the recovery barrier", + ); + assert_eq!( + repo.scope_status(&scope_id).map(|(_, status)| status), + Some("abandoned".to_string()), + ); + } + + #[test] + fn regression_a_failed_concurrent_scope_cannot_contaminate_a_survivor() { + let repo = OpenCodeRepo::new("regression-a-no-contamination"); + + repo.drive(&before(&repo, "write", "call_a")) + .expect("A Start"); + repo.drive(&before(&repo, "write", "call_b")) + .expect("B Start"); + let scope_b = read_state(&repo.git_dir()) + .expect("adapter state readable") + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_b") + .expect("B is tracked") + .scope_id + .clone(); + + repo.write("file_a.txt", "a mutated\n"); + repo.write("file_b.txt", "b mutated\n"); + + repo.drive(&error(&repo, "write", "call_a")) + .expect("A terminal failure"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert_eq!(state.attempts.len(), 1, "B stays tracked after A's cleanup"); + assert_eq!(state.attempts[0].call_id, "call_b"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + + repo.drive(&after(&repo, "write", "call_b")) + .expect("B Close"); + + let events = repo.mutation_events(); + assert!( + events.iter().any(|(kind, _)| kind == "ineligible_unscoped"), + "the ambiguous interval containing A's mutations is consumed as \ + IneligibleUnscoped: {events:?}", + ); + assert!( + !events.iter().any(|(kind, scope)| kind == "ai_exclusive" + && scope.as_deref() == Some(scope_b.as_str())), + "B must never be attributed the interval that could contain A's changes: {events:?}", + ); + assert_eq!( + repo.scope_status(&scope_b).map(|(_, status)| status), + Some("closed".to_string()), + ); + } + + #[test] + fn regression_b_survivor_still_attributes_its_later_mutations() { + let repo = OpenCodeRepo::new("regression-b-survivor-liveness"); + + repo.drive(&before(&repo, "write", "call_a")) + .expect("A Start"); + repo.drive(&before(&repo, "write", "call_b")) + .expect("B Start"); + let scope_b = read_state(&repo.git_dir()) + .expect("adapter state readable") + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_b") + .expect("B is tracked") + .scope_id + .clone(); + + repo.write("file_a.txt", "a mutated\n"); + repo.drive(&error(&repo, "write", "call_a")) + .expect("A terminal failure consumes the ambiguous interval"); + + repo.write("file_c.txt", "b's own later work\n"); + repo.drive(&after(&repo, "write", "call_b")) + .expect("B Close"); + + let events = repo.mutation_events(); + assert!( + events.iter().any(|(kind, scope)| kind == "ai_exclusive" + && scope.as_deref() == Some(scope_b.as_str())), + "after the ambiguity flush, B may legitimately attribute its own later \ + mutations: {events:?}", + ); + assert_eq!( + repo.scope_status(&scope_b).map(|(_, status)| status), + Some("closed".to_string()), + ); + } + + #[test] + fn regression_c_exact_error_does_not_sweep_siblings_through_the_real_runtime() { + let repo = OpenCodeRepo::new("regression-c-no-sibling-sweep"); + + repo.drive(&before(&repo, "write", "call_a")) + .expect("A Start"); + repo.drive(&before(&repo, "write", "call_b")) + .expect("B Start"); + repo.drive(&before(&repo, "write", "call_c")) + .expect("C Start"); + + repo.write("file.txt", "one\nmutated\n"); + repo.drive(&error(&repo, "write", "call_b")) + .expect("B terminal failure"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + let mut remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + remaining.sort_unstable(); + assert_eq!(remaining, vec!["call_a", "call_c"], "A and C remain active"); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + } + + #[test] + fn regression_d_delayed_session_idle_cannot_kill_a_newer_call() { + let repo = OpenCodeRepo::new("regression-d-delayed-session-idle"); + + repo.drive(&before(&repo, "write", "call_b")) + .expect("newer call B Start"); + + repo.drive( + &json!({ + "hook_event_name": "SessionIdle", + "session_id": "ses_seam", + "cwd": repo.cwd(), + }) + .to_string(), + ) + .expect("a delayed SessionIdle for the same session is inert"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_b"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert!(state.recovery.is_clear()); + } + + #[test] + fn regression_e_server_disposed_cannot_sweep_another_process() { + let repo = OpenCodeRepo::new("regression-e-server-disposed"); + + repo.drive(&before(&repo, "write", "call_a")) + .expect("P1 owns call A"); + repo.drive( + &json!({ + "hook_event_name": "ToolExecuteBefore", + "session_id": "ses_p2", + "call_id": "call_b", + "cwd": repo.cwd(), + "tool_name": "write", + }) + .to_string(), + ) + .expect("P2 owns call B in the same checkout"); + + repo.drive(&json!({ "hook_event_name": "ServerDisposed", "cwd": repo.cwd() }).to_string()) + .expect("P1 server disposal is inert"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert_eq!( + state.attempts.len(), + 2, + "one process's disposal must not retire another process's live attempt", + ); + assert!(state.recovery.is_clear()); + } + + #[test] + fn regression_f_untracked_tool_error_is_zero_footprint() { + let repo = OpenCodeRepo::new("regression-f-untracked-tool-error"); + + for tool_name in ["read", "task", "brave-search_brave_web_search"] { + repo.drive(&error(&repo, tool_name, "call_x")) + .expect("an untracked ToolError is neutral"); + } + + assert_eq!(repo.scope_count(), 0); + assert!(!adapter_state_dir(&repo.git_dir()) + .join("opencode-mutation-scope-state.json") + .exists()); + } + + #[test] + fn untracked_events_never_reach_the_runtime_or_touch_adapter_state() { + let repo = OpenCodeRepo::new("untracked-zero-footprint"); + + assert_eq!( + repo.drive(&before(&repo, "read", "call_r")).expect("read"), + "" + ); + assert_eq!( + repo.drive(&before(&repo, "task", "call_t")).expect("task"), + "" + ); + + assert_eq!(repo.scope_count(), 0); + assert!(!adapter_state_dir(&repo.git_dir()) + .join("opencode-mutation-scope-state.json") + .exists()); + } + + #[test] + fn regression_d_concurrent_survivor_stays_usable_after_a_transient_cleanup_failure() { + let repo = OpenCodeRepo::new("regression-d-transient-cleanup-failure"); + + repo.drive(&before(&repo, "write", "call_a")) + .expect("A Start"); + repo.drive(&before(&repo, "write", "call_b")) + .expect("B Start"); + let scoped = read_state(&repo.git_dir()).expect("adapter state readable"); + let scope_a = scoped + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_a") + .expect("A is tracked") + .scope_id + .clone(); + let scope_b = scoped + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_b") + .expect("B is tracked") + .scope_id + .clone(); + + repo.write("file_a.txt", "a mutated\n"); + repo.write("file_b.txt", "b mutated\n"); + + let remaining_failures = std::cell::Cell::new(1_u32); + repo.drive_failing_seam_operation_once( + &error(&repo, "write", "call_a"), + "abandon", + &remaining_failures, + ) + .expect("A terminal failure with a transient abandon failure returns best-effort"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert_eq!( + state + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_a") + .map(|attempt| attempt.phase), + Some(AttemptPhase::PendingAbandon), + "A's terminal intent survives the transient failure", + ); + assert_eq!( + state + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_b") + .map(|attempt| attempt.phase), + Some(AttemptPhase::Active), + "B is untouched", + ); + assert!(!state.recovery.is_clear()); + + repo.drive(&error(&repo, "write", "call_a")) + .expect("a healthy duplicate ToolError retries and completes cleanup"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + let remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + assert_eq!(remaining, vec!["call_b"], "A retired, B still live"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert!(state.recovery.is_clear()); + assert_eq!( + repo.scope_status(&scope_a).map(|(_, status)| status), + Some("abandoned".to_string()), + ); + + repo.write("file_c.txt", "b's own later work\n"); + repo.drive(&after(&repo, "write", "call_b")) + .expect("B Close"); + + let events = repo.mutation_events(); + assert!( + events.iter().any(|(kind, _)| kind == "ineligible_unscoped"), + "the ambiguous A/B interval is consumed as IneligibleUnscoped: {events:?}", + ); + assert!( + events.iter().any(|(kind, scope)| kind == "ai_exclusive" + && scope.as_deref() == Some(scope_b.as_str())), + "B still attributes its own later mutation once recovery completed: {events:?}", + ); + } +} From 17ba3501cf9f6dfeac4cb867d8b152ee3e5c327c Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 11:42:20 +0200 Subject: [PATCH 07/16] spec: Add formal model for doctor recovery protocol Formalize the safe doctor-repair lifecycle and its safety invariants so concurrent hook activity, owner evidence, and interrupted recovery are checkable before implementation. Add executable Quint scenarios covering ordinary abandonment, dead-owner repair, stale diagnoses, retries, and non-resurrection. Implements T01 of `doctor-mutation-scope-fix`. Co-authored-by: SCE --- context/plans/doctor-mutation-scope-fix.md | 356 ++++++++++++- spec/doctor_recovery.qnt | 548 +++++++++++++++++++++ 2 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 spec/doctor_recovery.qnt diff --git a/context/plans/doctor-mutation-scope-fix.md b/context/plans/doctor-mutation-scope-fix.md index 57d5f692..a6eef529 100644 --- a/context/plans/doctor-mutation-scope-fix.md +++ b/context/plans/doctor-mutation-scope-fix.md @@ -273,7 +273,7 @@ Persist this field in every plan; this is durable plan state, not chat state: ## Task stack -- [ ] T01: `Formalize the safe doctor-repair protocol and its invariants` (status:todo) +- [x] T01: `Formalize the safe doctor-repair protocol and its invariants` (status:done) - Task ID: T01 - Scope: In — a new, focused Quint model (e.g. `spec/doctor_recovery.qnt`, exact name chosen at implementation time) modeling the shared abstract @@ -307,6 +307,360 @@ Persist this field in every plan; this is durable plan state, not chat state: explicitly which real Claude/OpenCode concepts each abstract phase and the owner oracle correspond to, so T03/T04 can be checked against it. - Verify: `nix run .#quint -- typecheck spec/doctor_recovery.qnt`; `nix run .#quint -- test spec/doctor_recovery.qnt`; the invariant-check command recorded in the file's own header. + - Completed: 2026-09-21 (corrected three times on 2026-09-21 — the first + pass's model was directionally useful but several named invariants + were weaker than claimed; the second pass fixed that but still + modeled `recovery`/`health`/`fixState` per attempt where the real + adapters persist one recovery/health/fix-report fact for the whole + adapter, and left `recoveryProgress`/`completeAbandon` runnable while + `DoctorHolds`; the third pass fixed both of those but introduced two + new mismatches of its own: it added a `recovery == Clear` precondition + to `hookDecideAbandon`/`doctorAttemptRepairWith` that wrongly encoded + "at most one attempt can be `PendingAbandon`" as a shared protocol + invariant, and it described the model's `lock: LockHolder` variable as + corresponding primarily to OpenCode's `AdapterBoundaryLock` even though + the model required that variable free during the seam-adjacent + `recoveryProgress`/`completeAbandon` actions — the real + `AdapterBoundaryLock` remains held across seam calls; only the shorter + `AdapterStateLock` is released before them. This record describes the + fourth pass, which removes the single-`PendingAbandon` restriction and + renames the lock abstraction so it stops claiming to be + `AdapterBoundaryLock`) + - Files changed: `spec/doctor_recovery.qnt` (new, then corrected in + place three times — no T01b/T01c/T01d, same task) + - Result: `spec/doctor_recovery.qnt` is a standalone Quint model (does + not extend `spec/mutation_cursor.qnt`) of the safe doctor-repair + pattern. `phase: AttemptId -> AttemptPhase` stays per-attempt, but + `recovery: RecoveryState` and `fixState: FixState` are now single + adapter-wide variables (not `AttemptId -> ...` maps), matching the real + OpenCode `AdapterState { recovery: RecoveryState, attempts: + Vec }` shape and Claude's whole-file + `recovery_pending` flag — both adapters report one health/one fix + result for the adapter, never one per attempt: + - `AttemptPhase = NotAllocated | PendingStart | Active | PendingAbandon + | Removed` (unchanged from both earlier passes). + - `RecoveryState = Clear | Pending | Flushing`, still a single `var + recovery` for the whole adapter (unchanged shape from the third + pass), distinguishing "no terminal recovery obligations remain + anywhere in the adapter" (`Clear`), "one or more terminal recovery + obligations exist and need processing" (`Pending`), and "the + adapter-wide recovery sequence is currently processing one or more + obligations" (`Flushing`). It is not ownership of one particular + attempt: multiple `PendingAbandon` attempts may be covered by the + same shared `recovery` value at once. The third pass had added a + `recovery == Clear` precondition to `hookDecideAbandon`/ + `doctorAttemptRepairWith`'s eligibility specifically to force at + most one attempt to be `PendingAbandon` at a time; this fourth pass + removes that precondition, because the real OpenCode + `begin_terminal_cleanup(scope_ids: &[String])` sets every matching + attempt to `PendingAbandon` in one call and `resolve_recovery()` + filters and processes every currently-`PendingAbandon` attempt, not + at most one, and Claude's broad cleanup signals + (`Stop`/`SessionEnd`/`UserPromptSubmit`/`SubagentStop`, per T04) may + likewise decide to abandon several attempts at once. + `abandonTransition` now takes the current `recovery` value as a + parameter and computes `recovery: if (rec == Clear) Pending else + rec` — establishing a new `PendingAbandon` obligation moves `Clear + -> Pending` but leaves an already-`Pending`/`Flushing` recovery + unchanged, so a second obligation can join an in-flight recovery + pipeline without disturbing it. `recoveryProgress` still advances + `Pending -> Flushing` for the shared pipeline. `completeAbandon` no + longer clears `recovery` unconditionally on removing its attempt — + it now computes `otherObligationsRemain = exists a. (phase with + this attempt set to Removed).get(a) == PendingAbandon` and sets + `recovery' = if (otherObligationsRemain) Pending else Clear`, so + global recovery only clears once every terminal obligation is + resolved; a remaining obligation falls the pipeline back to + `Pending` (an explicit "retry remaining obligations" step, needing a + fresh `recoveryProgress` before the next `completeAbandon`) rather + than staying `Flushing` for an attempt whose own flush already + finished. Both `recoveryProgress` and `completeAbandon` still + require the short state-transaction abstraction free (see the + lock-naming correction below) — the second pass left this + seam/recovery pair runnable while that variable was held, the third + pass fixed that, and this pass preserves it unchanged; every test + already released it before driving + `recoveryProgress`/`completeAbandon`. + - `Health = Healthy | Recovering | Blocked | Invalid` now comes from a + new `pure def adapterHealth(phase: AttemptId -> AttemptPhase, + recovery: RecoveryState): Health` — one health value for the whole + adapter, derived from `hasPendingStart = exists a. phase.get(a) == + PendingStart` and `hasPendingAbandon = exists a. phase.get(a) == + PendingAbandon` in the exact required priority order: `recovery == + Clear and hasPendingAbandon -> Invalid`, else `hasPendingStart -> + Blocked`, else `recovery == Clear -> Healthy`, else `Recovering` + (covering both `Pending` and `Flushing`). The old per-attempt `pure + def health(phase, recovery)` is gone; every caller now calls + `adapterHealth(phase, recovery)` with no attempt argument. + `testInvalidTakesPriorityOverBlockedWhenBothConditionsHold` (new) + proves the ordering directly: one attempt hand-set to `PendingAbandon` + with `recovery == Clear` and a second, different attempt genuinely + `PendingStart` (both conditions live at once) still classifies + `Invalid`, not `Blocked`. + - `FixState = NotAttempted | RepairAttempted | RepairCompleted | + ReportedFixed | ReportedManual`, now a single `var fixState` for the + whole adapter (previously `AttemptId -> FixState`), matching that + `sce doctor` reports one fix result per adapter, never one per + attempt. `doctorAttemptRepairWith` sets it to `RepairAttempted` + (unconditionally, whether or not the repair turns out eligible); + `completeAbandon` promotes `RepairAttempted -> RepairCompleted`; a + `doctorReportResult` action (now nullary — no attempt argument, since + there is one fix result, not one per attempt) reads the fresh + `adapterHealth(phase, recovery)` at report time and decides + `ReportedFixed` only when it is `Healthy`/`Recovering`, else + `ReportedManual`. Making `fixState` adapter-wide surfaced a case + neither earlier pass had to consider: since `adapterHealth` now + depends on *every* attempt's phase, an unrelated attempt allocating + fresh (`hookAllocate`, `NotAllocated -> PendingStart`) after a + `ReportedFixed` report can make `adapterHealth` swing to `Blocked` + purely because of that unrelated attempt, staling the old report. + `hookAllocate` is the only action that can newly introduce + `hasPendingStart` (every other action only removes a `PendingStart` + contributor or moves through `PendingAbandon`/`Recovering`, which + never regresses toward `Blocked`/`Invalid`), so `hookAllocate` now + resets `fixState' = NotAttempted` instead of passing it through + unchanged — the prior report is intentionally treated as stale once + fresh, doctor-relevant lifecycle activity begins. This reset is a + no-op in every existing test (each calls `hookAllocate` exactly once, + at the very start, while `fixState` is still its `init` value of + `NotAttempted`), and it was required for `--invariant=Safety` to find + no violation at the stated bounds — omitting it reproduces a real + counterexample. + - A shared `pure def abandonTransition(phase, everWasPendingAbandon, + rec, attempt)` (this pass restores a `recovery` parameter the third + pass had dropped — the successor `recovery` value can no longer + always be the literal `Pending`, since an already-`Pending`/ + `Flushing` shared recovery must be left unchanged when a second + obligation joins it) returns the `PendingStart/Active -> + PendingAbandon` + `recovery: if (rec == Clear) Pending else rec` + result record. Both `hookDecideAbandon` and `doctorAttemptRepairWith` + (after its own `stateTxn == DoctorStateTxn and phase == PendingStart + and reading == SeenDead` eligibility check — the third pass's + `recovery == Clear` conjunct is removed here) call this same + function, preserving the second pass's "doctor cannot reach a shape + the ordinary hook path could not also reach" property. + - `OwnerReading = SeenAlive | SeenDead | SeenUnknown` and + `soundReadings(alive)` are unchanged. `doctorAttemptRepairWith`'s + `phase.get(attempt) != NotAllocated` precondition (added in the + second pass) is unchanged. + - The same eight named invariants as the third pass (one renamed, per + below), combined into `val Safety`. Their statements are otherwise + unchanged text from the third pass, but two of them are now + meaningfully different in what they prove, because "at most one + attempt is ever mid-`PendingAbandon`" is no longer true and was + never actually required for either to hold: + `RecoveryNeverClearedWithUnresolvedAbandon` (`recovery == Clear + implies (forall a. phase.get(a) != PendingAbandon)`) now genuinely + constrains `completeAbandon`'s new `otherObligationsRemain` logic — + with the third pass's single-`PendingAbandon` restriction in place + this invariant held almost trivially (there was never a second + `PendingAbandon` attempt to protect against); with that restriction + removed, this is the invariant that actually forces + `completeAbandon` to check the whole resulting attempt set before + clearing `recovery`, and `testMultiplePendingAbandonShareOneRecoveryPipeline` + (new) exercises exactly that: completing the first of two + concurrently-`PendingAbandon` attempts must leave `recovery != + Clear`. `DoctorRepairProducesOnlyOrdinaryLifecycleShapes` + (`phase.get(a) == Removed or (phase.get(a) == PendingAbandon and + (recovery == Pending or recovery == Flushing))`, unchanged text from + the second pass's fix) already held for any number of concurrent + `PendingAbandon` attempts — it says nothing about how many other + attempts share the same `recovery` value, so removing the + single-`PendingAbandon` restriction changes nothing about this + invariant's proof. `InterruptedRecoveryStaysInOrdinaryRetryableState` + likewise already generalized to multiple attempts without + modification: each attempt in `everWasPendingAbandon` and not yet in + `everRemoved` independently must be `PendingAbandon` with `recovery + == Pending or Flushing`, which holds per-attempt regardless of how + many other attempts satisfy the same clause simultaneously. + `ReportedFixedRequiresHealthyFinalState` is renamed + `ReportedFixedExcludesBlockedOrInvalid` (same body: + `fixState == ReportedFixed implies (adapterHealth(phase, recovery) + != Blocked and adapterHealth(phase, recovery) != Invalid)`) — the + old name overstated the requirement as "healthy," when `Recovering` + is deliberately still accepted as a successful report (`Blocked` -> + `Recovering` counts as removing the durable wedge, per this plan's + AC6); the new name says exactly what the invariant checks. + - `pendingAbandonFromDoctor` is unchanged — still a per-attempt + diagnostic/test bookkeeping set, not adapter-wide, since it tracks + which specific attempts doctor touched (a real, per-attempt fact), + not the adapter's health or fix-report state. + - Thirteen `run` tests (up from eleven): the third pass's eleven + tests, updated only where the `doctorAcquireLock`/`doctorReleaseLock` + action names changed to `doctorAcquireStateTxn`/ + `doctorReleaseStateTxn` (see the lock-naming correction below; no + test's assertions changed), plus two new tests. + `testMultiplePendingAbandonShareOneRecoveryPipeline` reaches + `Attempt0 = PendingAbandon, Attempt1 = PendingAbandon, recovery == + Pending` through ordinary `hookAllocate`/`hookDecideAbandon` calls on + both attempts (not a hand-constructed state), asserts + `adapterHealth(...) == Recovering` and `Safety` there, then completes + only `Attempt0` (`recoveryProgress` + `completeAbandon`) and asserts + `Attempt0 == Removed`, `Attempt1 == PendingAbandon`, `recovery != + Clear`, and `adapterHealth(...) == Recovering` still — proving + `completeAbandon`'s per-attempt-set recovery-clearing logic. It then + drives `Attempt1` through its own + `recoveryProgress`/`completeAbandon` and only then asserts `recovery + == Clear` and `adapterHealth(...) == Healthy`. + `testRepairingOneDeadBlockerLeavesOtherBlockerBlocked` reaches + `Attempt0 = PendingStart` with a proven-dead owner and `Attempt1 = + PendingStart` with an unknown owner, both contributing to an initial + `Blocked` classification; it repairs only `Attempt0` + (`doctorAcquireStateTxn`/`doctorAttemptRepairWith(Attempt0, + SeenDead)`/`doctorReleaseStateTxn`) and asserts `adapterHealth(...) + == Blocked` still (because `Attempt1` remains `PendingStart`) and + `doctorReportResult` produces `ReportedManual`, not `ReportedFixed`; + it then completes `Attempt0`'s abandonment and asserts + `adapterHealth(...) == Blocked` and `fixState != ReportedFixed` + persist even after that attempt reaches `Removed`, since `Attempt1` + is still an unrepaired `PendingStart` blocker — the core proof that + repairing one repairable blocker does not mean the adapter itself is + repaired; only the freshly recomputed adapter-wide classifier may + authorize `ReportedFixed`. Both new tests are ordinary-transition + regressions (no hand-constructed `all { ... }` state), matching every + other test's convention except the two adversarial ones documented + below. + `testPendingAbandonWithClearRecoveryIsInvalid` and + `testInvalidTakesPriorityOverBlockedWhenBothConditionsHold` are + otherwise unchanged in structure (aside from the `lock' = lock` -> + `stateTxn' = stateTxn` field rename in their hand-constructed `all { + ... }` blocks) and still deliberately omit `.expect(Safety)` for the + same reason as before (the constructed state is intentionally + adversarial/unreachable). + - Deviation: the file still carries no comments (including no header + comment), per the repository's standing "no comments in code" + instruction; the concept mapping and verification results below stand + in for the file's own header comment, as in every earlier pass. + - Concept mapping (abstract -> real), unchanged from the third pass + except the lock abstraction, corrected below: `PendingStart` -> + OpenCode's `AdapterAttempt` `PendingStart` (the phase its `Blocked` + classification keys on) and, for Claude, a `PendingStart`/`Active` + attempt with no established abandon intent; `Active` -> an attempt + progressing under its owner, or a Claude attempt past `PendingStart` + with no abandon decision yet; `PendingAbandon` -> Claude's new + `PendingAbandon` phase (T04) and OpenCode's `PendingStart -> + PendingAbandon` durable transition (T03) once the dead-owner + condition is proven — and, as of this pass, multiple attempts may + independently carry this phase at once, all covered by the one + shared `recovery` value; `Removed` -> the attempt gone after a + successful seam sequence; `recovery: RecoveryState` (adapter-wide) -> + OpenCode's `AdapterState.recovery` field directly, and Claude's + whole-file `recovery_pending` flag/barrier state — `Clear` is "no + terminal recovery obligation outstanding anywhere in the adapter," + `Pending` is "one or more obligations recorded, pipeline not + actively running," `Flushing` is "the shared pipeline is actively + processing one or more obligations," matching + `begin_terminal_cleanup`/`resolve_recovery`'s real multi-attempt + batch shape, not a single-attempt lock; `adapterHealth(phase, + recovery)` -> `classify_health`'s `healthy`/`recovering`/`blocked`/ + `invalid` result, computed the same way the real classifiers do — + from every attempt's phase plus the one adapter-wide recovery fact, + never from a single attempt in isolation; `fixState` (adapter-wide) + -> the one doctor fix-result lifecycle + `execute_doctor_with_lifecycle_providers` drives per adapter target + (assess -> repair -> re-diagnose -> record one + `DoctorFixResultRecord`), with `ReportedFixed` standing for the + final report deciding `Fixed` only from the freshly recomputed + adapter-wide `classify_health` (never from a repair function's + `Ok(())` alone, and never per-attempt, and never from having + repaired only some of several contributing blockers — see + `testRepairingOneDeadBlockerLeavesOtherBlockerBlocked` above); + `ownerAlive` (ground truth) -> the real, single owning process + instance (PID + `/proc` start-time identity, + `mutation_scope_owner`), monotonic once dead; `OwnerReading` -> + `mutation_scope_owner::is_definitely_dead`'s `Alive`/`Dead`/`Unknown` + result. + Lock mapping, corrected this pass: the third pass's completion + record described `lock: LockHolder` (`LockFree`/`DoctorHolds`) as + corresponding primarily to OpenCode's `AdapterBoundaryLock`, while + the model itself required that variable free during + `recoveryProgress`/`completeAbandon` (the seam-adjacent actions) — + but the real `AdapterBoundaryLock` remains held across OpenCode's + seam calls; it serializes the whole repair lifecycle, seam sequence + included. Only the shorter `AdapterStateLock` is released before + every seam call. Those two claims cannot both describe real + OpenCode, so the variable is renamed `stateTxn: StateTxnHolder` + (`StateTxnFree`/`DoctorStateTxn`), and the two lock actions are + renamed `doctorAcquireStateTxn`/`doctorReleaseStateTxn` to match. + `stateTxn == StateTxnFree` during `recoveryProgress`/`completeAbandon` + now correctly means only "the short durable state-transaction lock + is released," saying nothing about a larger OpenCode boundary lock: + `stateTxn`/`StateTxnHolder` -> OpenCode's `AdapterStateLock` + transaction used to re-read/re-prove/persist a durable transition, + and, for Claude, its ordinary short state-file lock transaction + around the same re-read/re-prove/persist step. OpenCode's + `AdapterBoundaryLock` itself is outside this model variable + entirely — it is a separate, coarser serialization concern (the + complete repair lifecycle boundary across concurrent OpenCode + processes, including the seam calls) that this model does not need + a dedicated variable for, since none of the eight `Safety` invariants + depend on cross-process boundary serialization; T03 is responsible + for implementing `AdapterBoundaryLock` acquisition around its whole + `repair_blocked` call, wrapping (not replacing) the shorter + `AdapterStateLock`-shaped `stateTxn` transactions this model + verifies. For Claude there is no equivalent boundary lock in this + plan; Claude's repair safety rests on the durable `PendingAbandon` + evidence itself plus its own individual state-lock transactions, not + on a boundary-shaped variable. With the renaming, `doctorDiagnose` -> + `assess_repairability`'s unlocked, possibly-stale read; + `doctorAcquireStateTxn`/`doctorAttemptRepair`/`doctorReleaseStateTxn` + -> `repair_blocked`'s durable read/re-prove/transition (run inside + OpenCode's separate `AdapterBoundaryLock`, and without one for + Claude); `recoveryProgress` -> running the adapter's existing + recovery-protocol seam operations (OpenCode's `flush`/`abandon`/ + `flush`; Claude's `abandon_attempt`'s seam `abandon`) — modeled with + `stateTxn == StateTxnFree` required, matching the repository + invariant that `AdapterStateLock` (and Claude's equivalent state + lock) is never held across a seam call; `completeAbandon` -> the + durable transaction recording that seam sequence's success, shared + by both the ordinary hook-process retry path and doctor's repair + path so doctor never invents a new terminal transition. + - Verification command and result: `quint run spec/doctor_recovery.qnt + --invariant=Safety --max-samples=10000 --max-steps=30` -> `[ok] No + violation found (389ms at 25707 traces/second)`; a second, stronger + bound (`--max-samples=20000 --max-steps=50`) -> `[ok] No violation + found (1328ms at 15060 traces/second)`. Both bounds were re-run + against this fourth pass specifically (not carried over from the + third pass's record), since removing the single-`PendingAbandon` + restriction changes which states are reachable. + - Verify outcomes: `quint typecheck spec/doctor_recovery.qnt` -> exit 0 + (no output, clean typecheck); `quint test spec/doctor_recovery.qnt + --match '^test.*'` -> `13 passing`, 0 failed; `quint run + spec/doctor_recovery.qnt --invariant=Safety --max-samples=10000 + --max-steps=30` -> `[ok] No violation found`; `quint run + spec/doctor_recovery.qnt --invariant=Safety --max-samples=20000 + --max-steps=50` -> `[ok] No violation found`; `nix flake check` -> + `all checks passed!` (re-run for this correction, including the + existing `mutation-trace-quint-connect` check against the `spec/` + tree; no check is yet wired specifically to `doctor_recovery.qnt` — + that connection is T07's job per this task's own scope). + - Context impact: Establishes `spec/doctor_recovery.qnt` as the reference + formal model T03/T04's real Rust implementations and T07's + Quint-Connect-or-documented-mapping connection must be checked against. + T03/T04 should read two corrected facts above before writing the + OpenCode/Claude repair code: (1) one adapter-wide `recovery` pipeline + may safely cover multiple concurrent `PendingAbandon` obligations, and + it cannot clear to `Clear` until every one of them is resolved — doctor + and the ordinary hook path may each independently mark different + attempts `PendingAbandon`, and `resolve_recovery`-shaped completion + logic must check the whole attempt set, not just the attempt it is + currently finishing; (2) the model's `stateTxn`/`StateTxnHolder` + variable corresponds to OpenCode's short `AdapterStateLock` transaction + only, never to `AdapterBoundaryLock` — T03 must still acquire + `AdapterBoundaryLock` around the whole `repair_blocked` call per this + plan's own constraints section, and that acquisition is a fact outside + this model's verified surface, not something `stateTxn == StateTxnFree` + stands in for. Doctor's fix result and the classifier's health input + remain both single adapter-level facts derived from every attempt's + phase plus one shared recovery value, not computed or reported per + attempt, and the seam sequence (`flush`/`abandon`/`flush` for + OpenCode, `abandon_attempt`'s seam `abandon` for Claude) must run with + the state-transaction lock released, matching `AdapterStateLock` never + being held across a seam call. No context doc listed in this plan's + "Context sync" section is implicated by this task alone (it adds no + new Rust-facing contract); T07's own context-sync pass is where a new + shared doc for the model, if warranted, would be considered per the + plan's non-speculative instruction. - Context synchronization: pending - [ ] T02: `Extract shared positive process-owner evidence from Pi` (status:todo) diff --git a/spec/doctor_recovery.qnt b/spec/doctor_recovery.qnt new file mode 100644 index 00000000..e66f1aaa --- /dev/null +++ b/spec/doctor_recovery.qnt @@ -0,0 +1,548 @@ +module doctor_recovery { + type AttemptId = Attempt0 | Attempt1 | Attempt2 + + type AttemptPhase = NotAllocated | PendingStart | Active | PendingAbandon | Removed + + type RecoveryState = Clear | Pending | Flushing + + type Health = Healthy | Recovering | Blocked | Invalid + + type OwnerReading = SeenAlive | SeenDead | SeenUnknown + + type StateTxnHolder = StateTxnFree | DoctorStateTxn + + type FixState = NotAttempted | RepairAttempted | RepairCompleted | ReportedFixed | ReportedManual + + val ATTEMPTS: Set[AttemptId] = Set(Attempt0, Attempt1, Attempt2) + + pure def soundReadings(alive: bool): Set[OwnerReading] = + if (alive) Set(SeenAlive, SeenUnknown) else Set(SeenAlive, SeenDead, SeenUnknown) + + pure def adapterHealth(ph: AttemptId -> AttemptPhase, rec: RecoveryState): Health = { + val hasPendingStart = ATTEMPTS.exists(a => ph.get(a) == PendingStart) + val hasPendingAbandon = ATTEMPTS.exists(a => ph.get(a) == PendingAbandon) + if (rec == Clear and hasPendingAbandon) Invalid + else if (hasPendingStart) Blocked + else if (rec == Clear) Healthy + else Recovering + } + + pure def abandonTransition( + ph: AttemptId -> AttemptPhase, + wasPendingAbandon: Set[AttemptId], + rec: RecoveryState, + attempt: AttemptId + ): { phase: AttemptId -> AttemptPhase, recovery: RecoveryState, everWasPendingAbandon: Set[AttemptId] } = { + phase: ph.set(attempt, PendingAbandon), + recovery: if (rec == Clear) Pending else rec, + everWasPendingAbandon: wasPendingAbandon.union(Set(attempt)), + } + + var phase: AttemptId -> AttemptPhase + var recovery: RecoveryState + var ownerAlive: AttemptId -> bool + var everRemoved: Set[AttemptId] + var everWasPendingAbandon: Set[AttemptId] + var pendingAbandonFromDoctor: Set[AttemptId] + var stateTxn: StateTxnHolder + var lastDiagnosis: AttemptId -> OwnerReading + var fixState: FixState + + action init: bool = all { + phase' = ATTEMPTS.mapBy(_ => NotAllocated), + recovery' = Clear, + ownerAlive' = ATTEMPTS.mapBy(_ => false), + everRemoved' = Set(), + everWasPendingAbandon' = Set(), + pendingAbandonFromDoctor' = Set(), + stateTxn' = StateTxnFree, + lastDiagnosis' = ATTEMPTS.mapBy(_ => SeenUnknown), + fixState' = NotAttempted, + } + + action stutter: bool = all { + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action hookAllocate(attempt: AttemptId): bool = all { + phase.get(attempt) == NotAllocated, + stateTxn == StateTxnFree, + phase' = phase.set(attempt, PendingStart), + recovery' = recovery, + ownerAlive' = ownerAlive.set(attempt, true), + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis.set(attempt, SeenUnknown), + fixState' = NotAttempted, + } + + action hookAdvance(attempt: AttemptId): bool = all { + phase.get(attempt) == PendingStart, + stateTxn == StateTxnFree, + phase' = phase.set(attempt, Active), + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action hookDecideAbandon(attempt: AttemptId): bool = { + val current = phase.get(attempt) + val t = abandonTransition(phase, everWasPendingAbandon, recovery, attempt) + all { + current == PendingStart or current == Active, + stateTxn == StateTxnFree, + phase' = t.phase, + recovery' = t.recovery, + everWasPendingAbandon' = t.everWasPendingAbandon, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + } + + action hookOwnerCrashes(attempt: AttemptId): bool = all { + phase.get(attempt) != NotAllocated, + phase.get(attempt) != Removed, + ownerAlive.get(attempt), + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive.set(attempt, false), + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action recoveryProgress(attempt: AttemptId): bool = all { + phase.get(attempt) == PendingAbandon, + recovery == Pending, + stateTxn == StateTxnFree, + phase' = phase, + recovery' = Flushing, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action completeAbandon(attempt: AttemptId): bool = { + val newPhase = phase.set(attempt, Removed) + val otherObligationsRemain = ATTEMPTS.exists(a => newPhase.get(a) == PendingAbandon) + all { + phase.get(attempt) == PendingAbandon, + recovery == Flushing, + stateTxn == StateTxnFree, + phase' = newPhase, + recovery' = if (otherObligationsRemain) Pending else Clear, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved.union(Set(attempt)), + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = + if (fixState == RepairAttempted) RepairCompleted else fixState, + } + } + + action doctorDiagnoseWith(attempt: AttemptId, reading: OwnerReading): bool = all { + soundReadings(ownerAlive.get(attempt)).contains(reading), + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis.set(attempt, reading), + fixState' = fixState, + } + + action doctorDiagnose(attempt: AttemptId): bool = { + nondet reading = soundReadings(ownerAlive.get(attempt)).oneOf() + doctorDiagnoseWith(attempt, reading) + } + + action doctorAcquireStateTxn: bool = all { + stateTxn == StateTxnFree, + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = DoctorStateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action doctorReleaseStateTxn: bool = all { + stateTxn == DoctorStateTxn, + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = StateTxnFree, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action doctorAttemptRepairWith(attempt: AttemptId, reading: OwnerReading): bool = { + val eligible = + stateTxn == DoctorStateTxn and phase.get(attempt) == PendingStart and reading == SeenDead + val t = abandonTransition(phase, everWasPendingAbandon, recovery, attempt) + all { + phase.get(attempt) != NotAllocated, + soundReadings(ownerAlive.get(attempt)).contains(reading), + phase' = if (eligible) t.phase else phase, + recovery' = if (eligible) t.recovery else recovery, + everWasPendingAbandon' = if (eligible) t.everWasPendingAbandon else everWasPendingAbandon, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + pendingAbandonFromDoctor' = + if (eligible) pendingAbandonFromDoctor.union(Set(attempt)) else pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis.set(attempt, reading), + fixState' = RepairAttempted, + } + } + + action doctorAttemptRepair(attempt: AttemptId): bool = { + nondet reading = soundReadings(ownerAlive.get(attempt)).oneOf() + doctorAttemptRepairWith(attempt, reading) + } + + action doctorReportResult: bool = { + val attempted = fixState == RepairAttempted or fixState == RepairCompleted + val h = adapterHealth(phase, recovery) + val outcome = if (h == Healthy or h == Recovering) ReportedFixed else ReportedManual + all { + attempted, + fixState' = outcome, + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + } + } + + action step: bool = any { + { nondet a = ATTEMPTS.oneOf(); hookAllocate(a) }, + { nondet a = ATTEMPTS.oneOf(); hookAdvance(a) }, + { nondet a = ATTEMPTS.oneOf(); hookDecideAbandon(a) }, + { nondet a = ATTEMPTS.oneOf(); hookOwnerCrashes(a) }, + { nondet a = ATTEMPTS.oneOf(); recoveryProgress(a) }, + { nondet a = ATTEMPTS.oneOf(); completeAbandon(a) }, + { nondet a = ATTEMPTS.oneOf(); doctorDiagnose(a) }, + doctorAcquireStateTxn, + doctorReleaseStateTxn, + { nondet a = ATTEMPTS.oneOf(); doctorAttemptRepair(a) }, + doctorReportResult, + stutter, + } + + val DoctorNeverAbandonsALiveOwner = ATTEMPTS.forall(a => + pendingAbandonFromDoctor.contains(a) implies not(ownerAlive.get(a)) + ) + + val UnknownOwnerNeverProvesDeath = ATTEMPTS.forall(a => + lastDiagnosis.get(a) == SeenDead implies not(ownerAlive.get(a)) + ) + + val RecoveryNeverClearedWithUnresolvedAbandon = + recovery == Clear implies ATTEMPTS.forall(a => phase.get(a) != PendingAbandon) + + val NoOrdinaryTransitionProducesInvalidHealth = + adapterHealth(phase, recovery) != Invalid + + val DoctorRepairProducesOnlyOrdinaryLifecycleShapes = ATTEMPTS.forall(a => + pendingAbandonFromDoctor.contains(a) implies ( + phase.get(a) == Removed + or (phase.get(a) == PendingAbandon and (recovery == Pending or recovery == Flushing)) + ) + ) + + val RemovedAttemptsAreNeverResurrected = + everRemoved.forall(a => phase.get(a) == Removed) + + val InterruptedRecoveryStaysInOrdinaryRetryableState = ATTEMPTS.forall(a => + (everWasPendingAbandon.contains(a) and not(everRemoved.contains(a))) + implies (phase.get(a) == PendingAbandon and (recovery == Pending or recovery == Flushing)) + ) + + val ReportedFixedExcludesBlockedOrInvalid = + fixState == ReportedFixed implies + (adapterHealth(phase, recovery) != Blocked and adapterHealth(phase, recovery) != Invalid) + + val Safety = and { + DoctorNeverAbandonsALiveOwner, + UnknownOwnerNeverProvesDeath, + RecoveryNeverClearedWithUnresolvedAbandon, + NoOrdinaryTransitionProducesInvalidHealth, + DoctorRepairProducesOnlyOrdinaryLifecycleShapes, + RemovedAttemptsAreNeverResurrected, + InterruptedRecoveryStaysInOrdinaryRetryableState, + ReportedFixedExcludesBlockedOrInvalid, + } + + run testOrdinaryLifecycleReachesActive = + init + .then(hookAllocate(Attempt0)) + .expect(phase.get(Attempt0) == PendingStart) + .expect(adapterHealth(phase, recovery) == Blocked) + .then(hookAdvance(Attempt0)) + .expect(phase.get(Attempt0) == Active) + .expect(adapterHealth(phase, recovery) == Healthy) + .expect(Safety) + + run testHookOwnAbandonNeedsNoOwnerEvidence = + init + .then(hookAllocate(Attempt0)) + .then(hookDecideAbandon(Attempt0)) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Pending) + .expect(adapterHealth(phase, recovery) == Recovering) + .then(recoveryProgress(Attempt0)) + .expect(recovery == Flushing) + .then(completeAbandon(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(recovery == Clear) + .expect(adapterHealth(phase, recovery) == Healthy) + .expect(Safety) + + run testMultiplePendingAbandonShareOneRecoveryPipeline = + init + .then(hookAllocate(Attempt0)) + .then(hookAllocate(Attempt1)) + .then(hookDecideAbandon(Attempt0)) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Pending) + .then(hookDecideAbandon(Attempt1)) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(phase.get(Attempt1) == PendingAbandon) + .expect(recovery == Pending) + .expect(adapterHealth(phase, recovery) == Recovering) + .expect(Safety) + .then(recoveryProgress(Attempt0)) + .expect(recovery == Flushing) + .then(completeAbandon(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(phase.get(Attempt1) == PendingAbandon) + .expect(recovery != Clear) + .expect(adapterHealth(phase, recovery) == Recovering) + .expect(Safety) + .then(recoveryProgress(Attempt1)) + .expect(recovery == Flushing) + .then(completeAbandon(Attempt1)) + .expect(phase.get(Attempt1) == Removed) + .expect(recovery == Clear) + .expect(adapterHealth(phase, recovery) == Healthy) + .expect(Safety) + + run testDoctorRepairsADefinitelyDeadOwner = + init + .then(hookAllocate(Attempt0)) + .then(hookOwnerCrashes(Attempt0)) + .then(doctorDiagnoseWith(Attempt0, SeenDead)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenDead)) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Pending) + .expect(pendingAbandonFromDoctor.contains(Attempt0)) + .then(doctorReleaseStateTxn) + .then(recoveryProgress(Attempt0)) + .then(completeAbandon(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(adapterHealth(phase, recovery) == Healthy) + .then(doctorReportResult) + .expect(fixState == ReportedFixed) + .expect(Safety) + + run testDoctorRefusesALiveOwner = + init + .then(hookAllocate(Attempt0)) + .then(doctorDiagnose(Attempt0)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepair(Attempt0)) + .expect(phase.get(Attempt0) == PendingStart) + .expect(not(pendingAbandonFromDoctor.contains(Attempt0))) + .then(doctorReportResult) + .expect(fixState == ReportedManual) + .expect(Safety) + + run testUnknownOwnerRepairIsRefused = + init + .then(hookAllocate(Attempt0)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenUnknown)) + .expect(phase.get(Attempt0) == PendingStart) + .expect(not(pendingAbandonFromDoctor.contains(Attempt0))) + .expect(adapterHealth(phase, recovery) == Blocked) + .then(doctorReportResult) + .expect(fixState == ReportedManual) + .expect(Safety) + + run testStaleDiagnosisRefusedAfterConcurrentPhaseChange = + init + .then(hookAllocate(Attempt0)) + .then(hookOwnerCrashes(Attempt0)) + .then(doctorDiagnoseWith(Attempt0, SeenDead)) + .expect(lastDiagnosis.get(Attempt0) == SeenDead) + .then(hookAdvance(Attempt0)) + .expect(phase.get(Attempt0) == Active) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenDead)) + .expect(phase.get(Attempt0) == Active) + .expect(not(pendingAbandonFromDoctor.contains(Attempt0))) + .expect(Safety) + + run testInterruptedRepairIsRetryable = + init + .then(hookAllocate(Attempt0)) + .then(hookOwnerCrashes(Attempt0)) + .then(doctorDiagnoseWith(Attempt0, SeenDead)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenDead)) + .then(doctorReleaseStateTxn) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Pending) + .expect(adapterHealth(phase, recovery) == Recovering) + .then(stutter) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Pending) + .expect(InterruptedRecoveryStaysInOrdinaryRetryableState) + .then(recoveryProgress(Attempt0)) + .expect(recovery == Flushing) + .then(stutter) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Flushing) + .expect(InterruptedRecoveryStaysInOrdinaryRetryableState) + .then(completeAbandon(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(recovery == Clear) + .expect(everRemoved.contains(Attempt0)) + .expect(Safety) + + run testSuccessfulRepairReachesRecoveringThenHealthy = + init + .then(hookAllocate(Attempt0)) + .then(hookOwnerCrashes(Attempt0)) + .then(doctorDiagnoseWith(Attempt0, SeenDead)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenDead)) + .then(doctorReleaseStateTxn) + .expect(adapterHealth(phase, recovery) == Recovering) + .then(doctorReportResult) + .expect(fixState == ReportedFixed) + .then(recoveryProgress(Attempt0)) + .then(completeAbandon(Attempt0)) + .expect(adapterHealth(phase, recovery) == Healthy) + .expect(Safety) + + run testRepairingOneDeadBlockerLeavesOtherBlockerBlocked = + init + .then(hookAllocate(Attempt0)) + .then(hookAllocate(Attempt1)) + .then(hookOwnerCrashes(Attempt0)) + .expect(adapterHealth(phase, recovery) == Blocked) + .then(doctorDiagnoseWith(Attempt0, SeenDead)) + .then(doctorDiagnoseWith(Attempt1, SeenUnknown)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenDead)) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(phase.get(Attempt1) == PendingStart) + .then(doctorReleaseStateTxn) + .expect(adapterHealth(phase, recovery) == Blocked) + .then(doctorReportResult) + .expect(fixState == ReportedManual) + .expect(not(fixState == ReportedFixed)) + .then(recoveryProgress(Attempt0)) + .then(completeAbandon(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(phase.get(Attempt1) == PendingStart) + .expect(adapterHealth(phase, recovery) == Blocked) + .expect(not(fixState == ReportedFixed)) + .expect(Safety) + + run testPendingAbandonWithClearRecoveryIsInvalid = + init + .then(hookAllocate(Attempt0)) + .then(all { + phase' = phase.set(Attempt0, PendingAbandon), + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon.union(Set(Attempt0)), + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = RepairAttempted, + }) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Clear) + .expect(adapterHealth(phase, recovery) == Invalid) + .then(doctorReportResult) + .expect(fixState == ReportedManual) + .expect(not(fixState == ReportedFixed)) + + run testInvalidTakesPriorityOverBlockedWhenBothConditionsHold = + init + .then(hookAllocate(Attempt1)) + .then(all { + phase' = phase.set(Attempt0, PendingAbandon), + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon.union(Set(Attempt0)), + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + }) + .expect(phase.get(Attempt1) == PendingStart) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Clear) + .expect(adapterHealth(phase, recovery) == Invalid) + + run testRemovedAttemptIsNeverResurrected = + init + .then(hookAllocate(Attempt0)) + .then(hookDecideAbandon(Attempt0)) + .then(recoveryProgress(Attempt0)) + .then(completeAbandon(Attempt0)) + .expect(everRemoved.contains(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(recovery == Clear) + .expect(phase.get(Attempt0) != NotAllocated) + .expect(RemovedAttemptsAreNeverResurrected) + .expect(Safety) +} From 030343ebec4041a766d80819242102277ccc0747 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 13:47:05 +0200 Subject: [PATCH 08/16] hooks: Extract shared process-owner evidence from Pi Move ProcessOwner and conservative liveness checks into an adapter-neutral hooks module so other hook adapters can reuse positive process-death evidence. Repoint Pi mutation-scope imports and update the affected context and plan records while preserving behavior and persisted owner data. Plan: doctor-mutation-scope-fix (T02) Co-authored-by: SCE --- cli/src/services/hooks/mod.rs | 1 + ...ocess_owner.rs => mutation_scope_owner.rs} | 2 +- .../hooks/pi_mutation_scope/health.rs | 6 +-- .../hooks/pi_mutation_scope/lifecycle.rs | 2 +- .../pi_mutation_scope/lifecycle_tests.rs | 8 +-- .../services/hooks/pi_mutation_scope/mod.rs | 1 - .../services/hooks/pi_mutation_scope/state.rs | 4 +- context/cli/pi-mutation-scope-health.md | 2 +- context/cli/pi-mutation-scope-integration.md | 8 ++- context/plans/doctor-mutation-scope-fix.md | 54 +++++++++++++++++-- 10 files changed, 71 insertions(+), 17 deletions(-) rename cli/src/services/hooks/{pi_mutation_scope/process_owner.rs => mutation_scope_owner.rs} (98%) diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 9c499920..a42fad7b 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -41,6 +41,7 @@ pub mod command; pub mod lifecycle; pub mod mutation_scope; pub mod mutation_scope_health; +pub mod mutation_scope_owner; pub mod opencode_mutation_scope; pub mod pi_mutation_scope; diff --git a/cli/src/services/hooks/pi_mutation_scope/process_owner.rs b/cli/src/services/hooks/mutation_scope_owner.rs similarity index 98% rename from cli/src/services/hooks/pi_mutation_scope/process_owner.rs rename to cli/src/services/hooks/mutation_scope_owner.rs index bb252b01..73f46640 100644 --- a/cli/src/services/hooks/pi_mutation_scope/process_owner.rs +++ b/cli/src/services/hooks/mutation_scope_owner.rs @@ -83,7 +83,7 @@ mod tests { #[test] fn no_ttl_or_elapsed_time_primitive_is_used_by_this_module() { - let source = include_str!("process_owner.rs"); + let source = include_str!("mutation_scope_owner.rs"); let production_source = source .split_once("#[cfg(test)]") .expect("this module has a #[cfg(test)] boundary") diff --git a/cli/src/services/hooks/pi_mutation_scope/health.rs b/cli/src/services/hooks/pi_mutation_scope/health.rs index d7051bed..587eeaec 100644 --- a/cli/src/services/hooks/pi_mutation_scope/health.rs +++ b/cli/src/services/hooks/pi_mutation_scope/health.rs @@ -5,8 +5,8 @@ use crate::services::hooks::mutation_scope_health::{ }; use crate::services::mutation_trace::types::ActorKind; -use super::process_owner::is_definitely_dead; use super::state::{self, AttemptPhase, RecoveryState}; +use crate::services::hooks::mutation_scope_owner::is_definitely_dead; pub(crate) fn classify_health(git_dir: &Path) -> MutationScopeAdapterHealth { let state = match state::read_state(git_dir) { @@ -69,12 +69,12 @@ mod tests { use serde_json::{json, Value}; - use super::super::process_owner::ProcessOwner; use super::super::{ force_attempt_owner_dead_for_tests, run_pi_mutation_scope_from_payload_with_seams, AttemptKey, }; use super::*; + use crate::services::hooks::mutation_scope_owner::ProcessOwner; use crate::services::observability::traits::Logger; const FAIL_CLOSED_MESSAGE: &str = @@ -639,7 +639,7 @@ mod tests { pid: dead_pid, instance_token: None, }; - let live_owner = super::super::process_owner::current_process_owner(); + let live_owner = crate::services::hooks::mutation_scope_owner::current_process_owner(); let clear = RecoveryState::Clear; let pending = RecoveryState::Pending { generation: 1 }; diff --git a/cli/src/services/hooks/pi_mutation_scope/lifecycle.rs b/cli/src/services/hooks/pi_mutation_scope/lifecycle.rs index 3fe04783..4d6202eb 100644 --- a/cli/src/services/hooks/pi_mutation_scope/lifecycle.rs +++ b/cli/src/services/hooks/pi_mutation_scope/lifecycle.rs @@ -452,7 +452,7 @@ pub(crate) fn force_attempt_owner_dead_for_tests(git_dir: &Path, scope_id: &str) state::set_attempt_owner_for_tests( git_dir, scope_id, - super::process_owner::ProcessOwner { + crate::services::hooks::mutation_scope_owner::ProcessOwner { pid: dead_pid, instance_token: None, }, diff --git a/cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs b/cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs index 5305582e..6d3be660 100644 --- a/cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs +++ b/cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs @@ -176,13 +176,13 @@ fn tool_result_event_for_session(tool_name: &str, session_id: &str, tool_call_id .to_string() } -fn dead_process_owner() -> super::process_owner::ProcessOwner { +fn dead_process_owner() -> crate::services::hooks::mutation_scope_owner::ProcessOwner { let mut dead_child = std::process::Command::new("true") .spawn() .expect("spawning 'true' should succeed"); let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); dead_child.wait().expect("child should exit and be reaped"); - super::process_owner::ProcessOwner { + crate::services::hooks::mutation_scope_owner::ProcessOwner { pid: dead_pid, instance_token: None, } @@ -643,7 +643,7 @@ fn a_pending_start_attempt_owned_by_a_dead_process_is_abandoned_not_replayed() { state::set_attempt_owner_for_tests( &git_dir, &scope_id, - super::process_owner::ProcessOwner { + crate::services::hooks::mutation_scope_owner::ProcessOwner { pid: dead_pid, instance_token: None, }, @@ -915,7 +915,7 @@ fn an_owner_that_cannot_be_positively_proven_dead_is_never_abandoned_by_an_unrel state::set_attempt_owner_for_tests( &git_dir, &scope_a, - super::process_owner::ProcessOwner { + crate::services::hooks::mutation_scope_owner::ProcessOwner { pid: std::process::id().cast_signed(), instance_token: None, }, diff --git a/cli/src/services/hooks/pi_mutation_scope/mod.rs b/cli/src/services/hooks/pi_mutation_scope/mod.rs index a806a59a..017e6bcf 100644 --- a/cli/src/services/hooks/pi_mutation_scope/mod.rs +++ b/cli/src/services/hooks/pi_mutation_scope/mod.rs @@ -6,7 +6,6 @@ pub(crate) mod health; mod lifecycle; mod os_lock; mod payload; -pub(crate) mod process_owner; pub(crate) mod state; #[allow(unused_imports)] diff --git a/cli/src/services/hooks/pi_mutation_scope/state.rs b/cli/src/services/hooks/pi_mutation_scope/state.rs index 719d1cf4..04978636 100644 --- a/cli/src/services/hooks/pi_mutation_scope/state.rs +++ b/cli/src/services/hooks/pi_mutation_scope/state.rs @@ -7,8 +7,10 @@ use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use super::os_lock::{AdvisoryLockError, OsAdvisoryLock}; -use super::process_owner::{current_process_owner, is_definitely_dead, ProcessOwner}; use super::{format_pi_scope_id, AttemptKey}; +use crate::services::hooks::mutation_scope_owner::{ + current_process_owner, is_definitely_dead, ProcessOwner, +}; const SCE_STATE_DIR: &str = "sce"; const ADAPTER_STATE_FILE: &str = "pi-mutation-scope-state.json"; diff --git a/context/cli/pi-mutation-scope-health.md b/context/cli/pi-mutation-scope-health.md index b44729c7..8c9f03bb 100644 --- a/context/cli/pi-mutation-scope-health.md +++ b/context/cli/pi-mutation-scope-health.md @@ -49,7 +49,7 @@ production-reachable state. A live-owner or uncertain-owner attempt (a live pid whose exact process-instance identity cannot be positively established, per -`is_definitely_dead` in `process_owner.rs`) is therefore ordinary in-flight +`is_definitely_dead` in the shared `hooks/mutation_scope_owner.rs`) is therefore ordinary in-flight state: it never blocks any other admission, and this adapter's D10 stale-owner sweep leaves it completely untouched (proven by `clear_recovery_is_healthy_with_an_uncertain_owner_pending_start_attempt_never_swept_by_an_unrelated_start` diff --git a/context/cli/pi-mutation-scope-integration.md b/context/cli/pi-mutation-scope-integration.md index 21d08eaa..acb54140 100644 --- a/context/cli/pi-mutation-scope-integration.md +++ b/context/cli/pi-mutation-scope-integration.md @@ -182,8 +182,12 @@ captured via `getppid()` at admission time: because `sce hooks pi-mutation-scope` is invoked synchronously as a direct child of the Pi/Node process for that exact call (`tool_call` is a blocking pre-execution gate), the OS-reported parent pid at that moment *is* the owning Pi process, with no -wire-protocol or TypeScript-extension change needed. `is_definitely_dead` -(`pi_mutation_scope/process_owner.rs`) proves death via `kill(pid, 0)` == +wire-protocol or TypeScript-extension change needed. `ProcessOwner` and +`is_definitely_dead` now live in the shared `cli/src/services/hooks/mutation_scope_owner.rs` +module (extracted from Pi's own `process_owner.rs` so other adapters can +reuse the same positive-evidence primitive; Pi's own `health.rs`/`state.rs` +import it from there with identical behavior). `is_definitely_dead` proves +death via `kill(pid, 0)` == `ESRCH` on Unix, and additionally guards against PID reuse on Linux by comparing the parent's `/proc//stat` start-time field against the recorded value; a live pid whose instance identity can't be established this diff --git a/context/plans/doctor-mutation-scope-fix.md b/context/plans/doctor-mutation-scope-fix.md index a6eef529..8a89f8e5 100644 --- a/context/plans/doctor-mutation-scope-fix.md +++ b/context/plans/doctor-mutation-scope-fix.md @@ -661,9 +661,9 @@ Persist this field in every plan; this is durable plan state, not chat state: new Rust-facing contract); T07's own context-sync pass is where a new shared doc for the model, if warranted, would be considered per the plan's non-speculative instruction. - - Context synchronization: pending + - Context synchronization: synced -- [ ] T02: `Extract shared positive process-owner evidence from Pi` (status:todo) +- [x] T02: `Extract shared positive process-owner evidence from Pi` (status:done) - Task ID: T02 - Scope: In — move `ProcessOwner`, `current_process_owner`, `process_owner_for`, and `is_definitely_dead` out of @@ -689,7 +689,55 @@ Persist this field in every plan; this is durable plan state, not chat state: including the static "no TTL/elapsed-time primitive" source scan (now scoped to the new file), passes. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml pi_mutation_scope`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_owner` - - Context synchronization: pending + - Completed: 2026-09-21 + - Files changed: `cli/src/services/hooks/mutation_scope_owner.rs` (new — + moved `ProcessOwner`/`current_process_owner`/`process_owner_for`/ + `is_definitely_dead` and their full test suite verbatim from + `pi_mutation_scope/process_owner.rs`); `cli/src/services/hooks/mod.rs` + (registers `pub mod mutation_scope_owner;`); + `cli/src/services/hooks/pi_mutation_scope/process_owner.rs` (deleted); + `cli/src/services/hooks/pi_mutation_scope/mod.rs` (drops the local + `pub(crate) mod process_owner;` declaration); + `cli/src/services/hooks/pi_mutation_scope/{state,health,lifecycle,lifecycle_tests}.rs` + (import paths repointed to `crate::services::hooks::mutation_scope_owner::...`, + no logic changes; `cargo fmt` reordered the new `use` blocks). + - Result: `ProcessOwner`, `current_process_owner`, `process_owner_for`, and + `is_definitely_dead` now live in the shared, adapter-neutral + `cli/src/services/hooks/mutation_scope_owner.rs`, sibling to the existing + `mutation_scope_health.rs`, with byte-identical logic (`kill(pid, 0)` + liveness, Linux `/proc/{pid}/stat` start-time PID-reuse proofing, + conservative `false` on non-Linux/non-unix) and its own complete test + suite including the static no-TTL/elapsed-time-token source scan + (re-scoped to `include_str!("mutation_scope_owner.rs")`). + `pi_mutation_scope` no longer defines any of these symbols; `state.rs`, + `health.rs`, and `lifecycle.rs`/`lifecycle_tests.rs` now import them from + the shared module. No behavior, persisted `owner` field shape, or + reachable Pi health status changed. Two active context docs + (`context/cli/pi-mutation-scope-health.md`, + `context/cli/pi-mutation-scope-integration.md`) cited the old + `pi_mutation_scope/process_owner.rs` path and were corrected to name the + new shared module. + - Deviation: while running the plan's own `Verify` commands, the initial + `SCE_CLI_PACKAGE_FALLBACK=1 cargo test` run showed 7 pre-existing, + change-unrelated failures (`no such table: mutation_trace_scope_provenance`) + caused by a stale `cli/package-fallback`/incremental-build cache; + confirmed pre-existing by stashing this task's changes and reproducing + the identical failure on the unmodified baseline. Running + `bash scripts/prepare-cli-generated-assets.sh` and clearing the stale + `cli/target/debug/build/shared-context-engineering-*` directories + resolved it; both plan `Verify` commands then passed cleanly, including + via the documented `nix develop -c ./scripts/run-cli-cargo.sh` wrapper. + Not a T02 regression or scope item. + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml pi_mutation_scope` -> `94 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_owner` -> `8 passed; 0 failed`; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `cargo clippy --manifest-path cli/Cargo.toml` -> no warnings on touched modules. + - Context impact: A shared, adapter-neutral process-owner-liveness module + now exists at `cli/src/services/hooks/mutation_scope_owner.rs` for T03 + (OpenCode) to consume; no user-visible behavior, public interface, + persisted data shape, or architecture-boundary change. `domain`-scoped: + the two Pi-specific context docs that named the old file path were + corrected; no root context file (`overview.md`/`architecture.md`/ + `glossary.md`/`patterns.md`/`context-map.md`) referenced the old path, + so none needed edits. + - Context synchronization: synced - [ ] T03: `OpenCode: persisted owner evidence and safe PendingStart repair` (status:todo) - Task ID: T03 From 49fe47f296b4aa39ddecc1d10c85359d10632836 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 14:21:01 +0200 Subject: [PATCH 09/16] runtime: Implement safe OpenCode PendingStart repair Prevent orphaned OpenCode mutation-scope attempts from remaining permanently blocked without positive owner-death evidence. Persist backward-compatible owner metadata, re-prove dead owners under the adapter locks, and reuse the existing recovery sequence while refusing live-owner and concurrent-race repairs. Leave doctor wiring for T05. Plan: doctor-mutation-scope-fix.md (T03) Co-authored-by: SCE --- .../hooks/opencode_mutation_scope/health.rs | 394 ++++++++++++++++++ .../opencode_mutation_scope/lifecycle.rs | 27 ++ .../hooks/opencode_mutation_scope/mod.rs | 4 + .../hooks/opencode_mutation_scope/state.rs | 75 +++- ...encode-mutation-scope-adapter-lifecycle.md | 43 ++ context/cli/opencode-mutation-scope-health.md | 19 +- context/plans/doctor-mutation-scope-fix.md | 109 ++++- 7 files changed, 659 insertions(+), 12 deletions(-) diff --git a/cli/src/services/hooks/opencode_mutation_scope/health.rs b/cli/src/services/hooks/opencode_mutation_scope/health.rs index 336a4e58..45dbfaef 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/health.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/health.rs @@ -3,10 +3,43 @@ use std::path::Path; use crate::services::hooks::mutation_scope_health::{ MutationScopeAdapterHealth, MutationScopeHealthStatus, }; +use crate::services::hooks::mutation_scope_owner::is_definitely_dead; use crate::services::mutation_trace::types::ActorKind; use super::state::{self, AttemptPhase, RecoveryState}; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Repairability { + AutoFixable, + ManualOnly, +} + +pub(crate) fn assess_repairability(git_dir: &Path) -> Repairability { + let Ok(state) = state::read_state(git_dir) else { + return Repairability::ManualOnly; + }; + + let pending_start: Vec<&state::AdapterAttempt> = state + .attempts + .iter() + .filter(|attempt| attempt.phase == AttemptPhase::PendingStart) + .collect(); + + if pending_start.is_empty() { + return Repairability::ManualOnly; + } + + let every_owner_is_positively_dead = pending_start + .iter() + .all(|attempt| attempt.owner.as_ref().is_some_and(is_definitely_dead)); + + if every_owner_is_positively_dead { + Repairability::AutoFixable + } else { + Repairability::ManualOnly + } +} + pub(crate) fn classify_health(git_dir: &Path) -> MutationScopeAdapterHealth { let state = match state::read_state(git_dir) { Ok(state) => state, @@ -643,6 +676,7 @@ mod tests { call_id: key.call_id, tool_name: "write".to_string(), phase, + owner: None, } } @@ -715,4 +749,364 @@ mod tests { remove_test_git_dir(&git_dir); } } + + fn dead_owner() -> crate::services::hooks::mutation_scope_owner::ProcessOwner { + let mut child = std::process::Command::new("true") + .spawn() + .expect("spawning 'true' should succeed"); + let pid = i32::try_from(child.id()).expect("pid fits in i32"); + child.wait().expect("child should exit and be reaped"); + crate::services::hooks::mutation_scope_owner::ProcessOwner { + pid, + instance_token: None, + } + } + + fn live_owner() -> crate::services::hooks::mutation_scope_owner::ProcessOwner { + crate::services::hooks::mutation_scope_owner::current_process_owner() + } + + fn seed_pending_start_with_owner( + git_dir: &Path, + call_id: &str, + owner: Option, + ) -> state::AdapterAttempt { + let attempt = state::seed_attempt_for_tests( + git_dir, + &key(call_id), + "write", + AttemptPhase::PendingStart, + ); + state::set_attempt_owner_for_tests(git_dir, &attempt.scope_id, owner) + } + + #[test] + fn assess_repairability_is_manual_only_when_the_adapter_is_not_blocked() { + let git_dir = unique_test_git_dir("assess-not-blocked"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + assert_eq!(assess_repairability(&git_dir), Repairability::ManualOnly); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_for_a_legacy_pending_start_attempt_with_no_recorded_owner( + ) { + let git_dir = unique_test_git_dir("assess-legacy-no-owner"); + let dir = state::adapter_state_dir(&git_dir); + std::fs::create_dir_all(&dir).expect("state dir should be created"); + std::fs::write( + state::state_path(&git_dir), + serde_json::json!({ + "version": 1, + "next_recovery_generation": 1, + "recovery": { "phase": "clear" }, + "attempts": [{ + "scope_id": "oc-tool-v1|s=8:ses-main|c=6:call-1", + "session_id": "ses-main", + "call_id": "call-1", + "tool_name": "write", + "phase": "pending_start", + }], + }) + .to_string(), + ) + .expect("legacy state file with no owner field should be writable"); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked, + "a legacy state file predating owner evidence must still classify Blocked" + ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::ManualOnly, + "no recorded owner is never treated as proof of death" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_when_the_pending_start_owner_is_live() { + let git_dir = unique_test_git_dir("assess-live-owner"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_pending_start_with_owner(&git_dir, "call-1", Some(live_owner())); + + assert_eq!(assess_repairability(&git_dir), Repairability::ManualOnly); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_auto_fixable_when_every_pending_start_owner_is_positively_dead() { + let git_dir = unique_test_git_dir("assess-dead-owner"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_pending_start_with_owner(&git_dir, "call-1", Some(dead_owner())); + + assert_eq!(assess_repairability(&git_dir), Repairability::AutoFixable); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_when_one_of_several_pending_start_owners_is_live() { + let git_dir = unique_test_git_dir("assess-mixed-owners"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_pending_start_with_owner(&git_dir, "call-dead", Some(dead_owner())); + seed_pending_start_with_owner(&git_dir, "call-live", Some(live_owner())); + + assert_eq!( + assess_repairability(&git_dir), + Repairability::ManualOnly, + "every contributing PendingStart attempt must have a proven-dead owner" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_clears_a_dead_owner_pending_start_end_to_end() { + let git_dir = unique_test_git_dir("repair-end-to-end"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_pending_start_with_owner(&git_dir, "call-1", Some(dead_owner())); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked + ); + + let healthy = RecordingSeam::new(); + let repository_root = Path::new(CWD); + let outcome = super::super::lifecycle::repair_blocked( + &git_dir, + repository_root, + None, + &|_root: &Path, payload: &str, _logger: Option<&dyn Logger>| healthy.handle(payload), + ) + .expect("repair should not error"); + + assert_eq!(outcome, super::super::lifecycle::RepairOutcome::Repaired); + let final_status = classify_health(&git_dir).status; + assert!( + matches!( + final_status, + MutationScopeHealthStatus::Healthy | MutationScopeHealthStatus::Recovering + ), + "a reported repair must never leave the final health Blocked: {final_status:?}" + ); + assert!(state::read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_is_a_safe_no_op_when_the_pending_start_owner_is_live() { + let git_dir = unique_test_git_dir("repair-live-owner-noop"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let attempt = seed_pending_start_with_owner(&git_dir, "call-1", Some(live_owner())); + + let seam = RecordingSeam::new(); + let repository_root = Path::new(CWD); + let outcome = super::super::lifecycle::repair_blocked( + &git_dir, + repository_root, + None, + &|_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload), + ) + .expect("repair should not error"); + + assert_eq!(outcome, super::super::lifecycle::RepairOutcome::NoOp); + assert!( + seam.calls.lock().expect("seam mutex").is_empty(), + "a live owner must never trigger any seam call" + ); + let state = state::read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].scope_id, attempt.scope_id); + assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_refuses_to_abandon_an_attempt_a_concurrent_process_already_started_before_the_lock_is_acquired( + ) { + let git_dir = unique_test_git_dir("repair-concurrent-race"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let attempt = seed_pending_start_with_owner(&git_dir, "call-1", Some(dead_owner())); + + assert_eq!(assess_repairability(&git_dir), Repairability::AutoFixable); + + state::mark_active(&git_dir, &attempt.scope_id) + .expect("simulating the concurrent owning process completing its own Start"); + + let seam = RecordingSeam::new(); + let repository_root = Path::new(CWD); + let outcome = super::super::lifecycle::repair_blocked( + &git_dir, + repository_root, + None, + &|_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload), + ) + .expect("repair should not error"); + + assert_eq!( + outcome, + super::super::lifecycle::RepairOutcome::NoOp, + "the fresh, lock-protected re-proof must refuse to act on state assessed before it changed" + ); + assert!( + seam.calls.lock().expect("seam mutex").is_empty(), + "no seam call may fire once the attempt is no longer PendingStart" + ); + let state = state::read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy, + "the concurrently-started attempt must be left exactly as the owning process left it" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_is_all_or_nothing_when_auto_fixable_assessment_becomes_stale() { + let git_dir = unique_test_git_dir("repair-all-or-nothing-stale"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let attempt0 = seed_pending_start_with_owner(&git_dir, "call-0", Some(dead_owner())); + let attempt1 = seed_pending_start_with_owner(&git_dir, "call-1", Some(dead_owner())); + + assert_eq!( + assess_repairability(&git_dir), + Repairability::AutoFixable, + "doctor's initial, unlocked assessment sees both owners positively dead" + ); + + state::set_attempt_owner_for_tests(&git_dir, &attempt1.scope_id, Some(live_owner())); + + let seam = RecordingSeam::new(); + let repository_root = Path::new(CWD); + let outcome = super::super::lifecycle::repair_blocked( + &git_dir, + repository_root, + None, + &|_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload), + ) + .expect("repair should not error"); + + assert_eq!( + outcome, + super::super::lifecycle::RepairOutcome::NoOp, + "the fresh repair-time re-proof must reject the whole batch once any current \ + PendingStart owner is no longer positively dead" + ); + + let state = state::read_state(&git_dir).expect("state readable"); + assert_eq!( + state + .attempts + .iter() + .find(|a| a.scope_id == attempt0.scope_id) + .expect("attempt0 is still tracked") + .phase, + AttemptPhase::PendingStart, + "the still-dead attempt0 must not be transitioned to PendingAbandon when another \ + current blocker fails the all-dead proof" + ); + assert_eq!( + state + .attempts + .iter() + .find(|a| a.scope_id == attempt1.scope_id) + .expect("attempt1 is still tracked") + .phase, + AttemptPhase::PendingStart, + ); + + assert!( + state.recovery.is_clear(), + "a failed re-proof must perform no durable recovery transition: {:?}", + state.recovery + ); + + assert!( + seam.calls.lock().expect("seam mutex").is_empty(), + "the state transaction must return None before resolve_recovery is entered, so no \ + seam call (flush/abandon) may fire" + ); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked, + "both attempts remain PendingStart, so the adapter stays Blocked" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_interrupted_before_the_seam_resolves_leaves_state_the_ordinary_recovery_path_completes_without_duplication( + ) { + let git_dir = unique_test_git_dir("repair-interrupted-resume"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_pending_start_with_owner(&git_dir, "call-1", Some(dead_owner())); + + let failing_abandon = RecordingSeam::failing_on(&["abandon"]); + let repository_root = Path::new(CWD); + let outcome = super::super::lifecycle::repair_blocked( + &git_dir, + repository_root, + None, + &|_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { + failing_abandon.handle(payload) + }, + ) + .expect("repair should not error even though the seam abandon call fails"); + + assert_eq!(outcome, super::super::lifecycle::RepairOutcome::NoOp); + let interrupted = state::read_state(&git_dir).expect("state readable"); + assert_eq!(interrupted.attempts.len(), 1); + assert_eq!(interrupted.attempts[0].phase, AttemptPhase::PendingAbandon); + assert!( + matches!(interrupted.recovery, RecoveryState::Pending { .. }), + "a failed seam call during repair must relinquish Flushing back to Pending: {:?}", + interrupted.recovery + ); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Recovering, + "an interrupted repair must never remain Blocked" + ); + + let healthy = RecordingSeam::new(); + drive(&git_dir, &healthy, &tool_before("write", "call-2")) + .expect("the ordinary recovery path resumes the interrupted repair automatically"); + + let resolved = state::read_state(&git_dir).expect("state readable"); + assert!(resolved.recovery.is_clear()); + assert_eq!( + resolved.attempts.len(), + 1, + "the interrupted repair's attempt must not be duplicated" + ); + assert_eq!(resolved.attempts[0].call_id, "call-2"); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy + ); + + remove_test_git_dir(&git_dir); + } } diff --git a/cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs b/cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs index 02f68c51..8f8a7492 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs @@ -171,6 +171,33 @@ pub(super) fn dispatch_opencode_hook_event( } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RepairOutcome { + Repaired, + NoOp, +} + +pub(crate) fn repair_blocked( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + with_boundary_lock(git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(git_dir)?; + + let Some(generation) = state::reprove_dead_owner_pending_start_and_begin_repair(git_dir)? + else { + return Ok(RepairOutcome::NoOp); + }; + + match resolve_recovery(git_dir, repository_root, generation, logger, seam)? { + RecoveryResolution::Cleared => Ok(RepairOutcome::Repaired), + RecoveryResolution::Unresolved => Ok(RepairOutcome::NoOp), + } + }) +} + pub(super) fn with_boundary_lock( git_dir: &Path, operation: impl FnOnce() -> Result, diff --git a/cli/src/services/hooks/opencode_mutation_scope/mod.rs b/cli/src/services/hooks/opencode_mutation_scope/mod.rs index 2617972a..bae7d60b 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/mod.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/mod.rs @@ -9,10 +9,14 @@ mod payload; pub(crate) mod state; pub(crate) use events::{format_opencode_scope_id, AttemptKey}; +#[allow(unused_imports)] +pub(crate) use health::{assess_repairability, Repairability}; #[cfg(test)] pub(crate) use lifecycle::run_opencode_mutation_scope_from_payload_at_state_root; pub(crate) use lifecycle::run_opencode_mutation_scope_subcommand; +#[allow(unused_imports)] +pub(crate) use lifecycle::{repair_blocked, RepairOutcome}; #[cfg(test)] mod tests; diff --git a/cli/src/services/hooks/opencode_mutation_scope/state.rs b/cli/src/services/hooks/opencode_mutation_scope/state.rs index ec77f17a..a8c0a0d8 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/state.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/state.rs @@ -8,6 +8,9 @@ use serde::{Deserialize, Serialize}; use super::os_lock::{AdvisoryLockError, OsAdvisoryLock}; use super::{format_opencode_scope_id, AttemptKey}; +use crate::services::hooks::mutation_scope_owner::{ + current_process_owner, is_definitely_dead, ProcessOwner, +}; const SCE_STATE_DIR: &str = "sce"; const ADAPTER_STATE_FILE: &str = "opencode-mutation-scope-state.json"; @@ -52,6 +55,8 @@ pub(crate) struct AdapterAttempt { pub call_id: String, pub tool_name: String, pub phase: AttemptPhase, + #[serde(default)] + pub owner: Option, } impl AdapterAttempt { @@ -246,6 +251,7 @@ fn allocate_pending_start( call_id: key.call_id.clone(), tool_name: tool_name.to_string(), phase: AttemptPhase::PendingStart, + owner: Some(current_process_owner()), }; state.attempts.push(attempt.clone()); attempt @@ -355,10 +361,10 @@ pub(crate) fn arm_recovery(git_dir: &Path) -> Result { Ok(generation) } -pub(crate) fn begin_terminal_cleanup(git_dir: &Path, scope_ids: &[String]) -> Result { - let _lock = acquire_lock(git_dir)?; - let mut state = read_state(git_dir)?; - +fn transition_to_pending_abandon_and_arm_flush( + state: &mut AdapterState, + scope_ids: &[String], +) -> u64 { for attempt in &mut state.attempts { if scope_ids .iter() @@ -379,10 +385,52 @@ pub(crate) fn begin_terminal_cleanup(git_dir: &Path, scope_ids: &[String]) -> Re } }; state.recovery = RecoveryState::Flushing { generation }; + generation +} + +pub(crate) fn begin_terminal_cleanup(git_dir: &Path, scope_ids: &[String]) -> Result { + let _lock = acquire_lock(git_dir)?; + let mut state = read_state(git_dir)?; + + let generation = transition_to_pending_abandon_and_arm_flush(&mut state, scope_ids); write_state_durably(git_dir, &state)?; Ok(generation) } +pub(crate) fn reprove_dead_owner_pending_start_and_begin_repair( + git_dir: &Path, +) -> Result> { + let _lock = acquire_lock(git_dir)?; + let mut state = read_state(git_dir)?; + + let pending_start: Vec<&AdapterAttempt> = state + .attempts + .iter() + .filter(|attempt| attempt.phase == AttemptPhase::PendingStart) + .collect(); + + if pending_start.is_empty() { + return Ok(None); + } + + let every_owner_is_positively_dead = pending_start + .iter() + .all(|attempt| attempt.owner.as_ref().is_some_and(is_definitely_dead)); + + if !every_owner_is_positively_dead { + return Ok(None); + } + + let dead_scope_ids: Vec = pending_start + .iter() + .map(|attempt| attempt.scope_id.clone()) + .collect(); + + let generation = transition_to_pending_abandon_and_arm_flush(&mut state, &dead_scope_ids); + write_state_durably(git_dir, &state)?; + Ok(Some(generation)) +} + pub(crate) fn arm_and_begin_recovery_flush(git_dir: &Path) -> Result { let _lock = acquire_lock(git_dir)?; let mut state = read_state(git_dir)?; @@ -449,6 +497,25 @@ pub(crate) fn seed_attempt_for_tests( attempt } +#[cfg(test)] +pub(crate) fn set_attempt_owner_for_tests( + git_dir: &Path, + scope_id: &str, + owner: Option, +) -> AdapterAttempt { + let _lock = acquire_lock(git_dir).expect("test owner-override lock"); + let mut state = read_state(git_dir).expect("test owner-override read"); + let attempt = state + .attempts + .iter_mut() + .find(|attempt| attempt.scope_id == scope_id) + .expect("attempt to override must already exist"); + attempt.owner = owner; + let updated = attempt.clone(); + write_state_durably(git_dir, &state).expect("test owner-override write"); + updated +} + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/context/cli/opencode-mutation-scope-adapter-lifecycle.md b/context/cli/opencode-mutation-scope-adapter-lifecycle.md index 26395a26..8fd03e27 100644 --- a/context/cli/opencode-mutation-scope-adapter-lifecycle.md +++ b/context/cli/opencode-mutation-scope-adapter-lifecycle.md @@ -111,9 +111,52 @@ concurrent OpenCode processes. (idempotent), confirmation-required attribution, and `needs_rebaseline`; the existing Quint checks are re-run only as regression verification. +## Owner evidence and the doctor-repair path + +Each `AdapterAttempt` now carries an optional `owner: Option` +(`#[serde(default)]`), stamped with the shared +[`mutation_scope_owner::current_process_owner()`](pi-mutation-scope-integration.md) +when a `PendingStart` attempt is allocated. A state file written before this +field existed deserializes its absence as `owner: None`, never inferred — +`assess_repairability`/`repair_blocked` below treat a missing owner exactly +like a live/unprovable one. + +This evidence exists only to back a `doctor`-invoked repair for the +`blocked` `PendingStart` shape described in +[opencode-mutation-scope-health.md](opencode-mutation-scope-health.md#why-a-stale-pendingstart-is-blocked-not-recovering--even-alongside-a-pendingflushing-recovery-generation); +it changes none of the ordinary hook lifecycle above and none of +`classify_health`'s reachable classifications. + +- `assess_repairability(git_dir)` reports `AutoFixable` only when every + currently `PendingStart` attempt has a recorded owner the shared + `mutation_scope_owner::is_definitely_dead` proves dead; otherwise + `ManualOnly` (no owner recorded, a live owner, or unprovable liveness). +- `repair_blocked(git_dir, repository_root, logger, seam)` acquires the + `AdapterBoundaryLock`, normalizes an orphaned `Flushing`, then — in one + `AdapterStateLock` transaction — re-reads state fresh and re-evaluates + liveness against the *current* state rather than trusting any earlier + read. Only attempts still `PendingStart` with a still-dead owner at that + moment transition to `PendingAbandon`, using the same durable + `begin_terminal_cleanup` shape the ordinary `ToolError` path already uses. + The state lock is released before the unmodified `flush`/`abandon`/`flush` + seam sequence (`resolve_recovery`) runs, so the lock is never held across a + seam call. A losing re-proof (the attempt is no longer `PendingStart`, or + no attempt is currently dead-owned) is a safe no-op: no seam call, no + write. If a seam call fails mid-repair, the resulting `PendingAbandon`/ + `Pending` state is left exactly as an ordinary failed `ToolError` cleanup + would leave it, so the existing self-healing retry (any later tracked + admission) resumes and completes it without duplicating or resurrecting + the attempt. +- Neither function is wired into `doctor` yet — `sce doctor --fix` cannot + reach `repair_blocked` until a later task dispatches to it from + `execute_doctor_with_lifecycle_providers`. + ## Related context - [OpenCode mutation-scope integration](opencode-mutation-scope-integration.md) +- [OpenCode mutation-scope health classification](opencode-mutation-scope-health.md) - [Mutation-scope hook ingress](mutation-scope-hook-ingress.md) - [Mutation-scope runtime: the harness-adapter contract](mutation-scope-runtime.md) - [Mutation-scope provenance](mutation-scope-provenance.md) +- [Pi mutation-scope integration](pi-mutation-scope-integration.md) (the shared + `mutation_scope_owner` liveness primitive this task reuses) diff --git a/context/cli/opencode-mutation-scope-health.md b/context/cli/opencode-mutation-scope-health.md index df91473a..82174cde 100644 --- a/context/cli/opencode-mutation-scope-health.md +++ b/context/cli/opencode-mutation-scope-health.md @@ -158,14 +158,21 @@ attempt on behalf of an unrelated call. The only two paths that retire a - that same key's own `ToolExecuteAfter`/`ToolError`, which drives `abandon_and_consume` for that specific attempt. -If the process that owns that call has died before either of those arrives — -there is no equivalent of Pi's `ProcessOwner`/`is_definitely_dead()` liveness -check in OpenCode, and `server_disposed_cannot_sweep_another_processes_attempt` -proves `ServerDisposed` is deliberately inert for this — no future ordinary -lifecycle event from any other call can ever clear it. This is the same +If the process that owns that call has died before either of those arrives, no +future *ordinary* lifecycle event from any other call can ever clear it — +`server_disposed_cannot_sweep_another_processes_attempt` proves `ServerDisposed` +is deliberately inert for this, and (unlike Pi's D10 sweep) nothing in the +`ToolExecuteBefore`/`ToolExecuteAfter`/`ToolError`/`ShellEnv` dispatch path ever +inspects owner liveness on behalf of an unrelated call. This is the same "valid persisted state + fail-closed admission + no reachable self-healing transition" shape as the Claude incident, just triggered by `PendingStart` -instead of a non-empty `attempts` list under `recovery_pending`. +instead of a non-empty `attempts` list under `recovery_pending`. OpenCode does +now record the same positive owner evidence as Pi (via the shared +`mutation_scope_owner` module) and can prove a `PendingStart` attempt's owner +positively dead — see +[Owner evidence and the doctor-repair path](opencode-mutation-scope-adapter-lifecycle.md#owner-evidence-and-the-doctor-repair-path) +— but that liveness check is not wired into this ordinary hook lifecycle at +all; it backs a separate `doctor`-invoked repair path only. **This holds even when recovery is simultaneously `Pending` or `Flushing` for an unrelated `PendingAbandon` attempt.** Recovery reaching `Clear` retires diff --git a/context/plans/doctor-mutation-scope-fix.md b/context/plans/doctor-mutation-scope-fix.md index 8a89f8e5..4e48314f 100644 --- a/context/plans/doctor-mutation-scope-fix.md +++ b/context/plans/doctor-mutation-scope-fix.md @@ -739,7 +739,7 @@ Persist this field in every plan; this is durable plan state, not chat state: so none needed edits. - Context synchronization: synced -- [ ] T03: `OpenCode: persisted owner evidence and safe PendingStart repair` (status:todo) +- [x] T03: `OpenCode: persisted owner evidence and safe PendingStart repair` (status:done) - Task ID: T03 - Scope: In — `cli/src/services/hooks/opencode_mutation_scope/{boundary_lock,events,lifecycle,mod,os_lock,payload,state,health,tests}.rs`. Preserve the existing lock hierarchy: `AdapterBoundaryLock` @@ -794,7 +794,112 @@ Persist this field in every plan; this is durable plan state, not chat state: continues from `PendingAbandon` without state deletion or attempt duplication. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` - - Context synchronization: pending + - Completed: 2026-09-21 + - Files changed: `cli/src/services/hooks/opencode_mutation_scope/state.rs` + (imports `current_process_owner`/`is_definitely_dead`/`ProcessOwner` from + the shared `mutation_scope_owner` module; adds `owner: + Option` to `AdapterAttempt` with `#[serde(default)]`; + stamps it in `allocate_pending_start` via `current_process_owner()`; + factors `begin_terminal_cleanup`'s tail into a shared + `transition_to_pending_abandon_and_arm_flush` helper; adds + `reprove_dead_owner_pending_start_and_begin_repair`, a single + lock-protected read-reprove-transition, and the `#[cfg(test)]` + `set_attempt_owner_for_tests` helper); + `cli/src/services/hooks/opencode_mutation_scope/health.rs` (adds the + local `Repairability { AutoFixable, ManualOnly }` enum and + `assess_repairability`; adds ten new regression tests covering legacy + no-owner deserialization, live/dead/mixed-owner assessment, end-to-end + repair, the live-owner no-op, the concurrent-race re-proof refusal, the + all-or-nothing rejection when a sibling `PendingStart` attempt's owner + goes stale between the unlocked assessment and the locked re-proof, and + the interrupted-repair resume via the ordinary recovery path; fixes the + pre-existing `matrix_attempt` literal to set `owner: None`); + `cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs` (adds + `RepairOutcome { Repaired, NoOp }` and `repair_blocked`, which runs + inside the existing `with_boundary_lock` helper); + `cli/src/services/hooks/opencode_mutation_scope/mod.rs` (re-exports + `assess_repairability`, `Repairability`, `repair_blocked`, + `RepairOutcome`, each `#[allow(unused_imports)]` pending T05's doctor + wiring, matching the existing `pi_mutation_scope/mod.rs` precedent for + not-yet-consumed exports). + - Result: `AdapterAttempt` now carries optional, backward-compatible + owner evidence stamped at `PendingStart` allocation. `assess_repairability` + classifies a `Blocked` adapter `AutoFixable` only when every currently + `PendingStart` attempt has a recorded owner positively proven dead by the + shared `is_definitely_dead`; a legacy file with no `owner` field, a live + owner, or an unprovable owner all stay `ManualOnly`. `repair_blocked` + acquires the `AdapterBoundaryLock`, normalizes orphaned recovery, then + performs one lock-protected read-reprove-transition + (`reprove_dead_owner_pending_start_and_begin_repair`) that re-evaluates + liveness fresh against the current state rather than trusting any + earlier read. This re-proof is all-or-nothing over the adapter's + complete current `PendingStart` set, not a per-attempt filter: it + collects every attempt currently `PendingStart` and requires every one + of them to have a recorded owner positively proven dead. If any current + blocker is live, unknown, or ownerless, `repair_blocked` returns `NoOp`, + no attempt is transitioned, recovery is untouched, and no seam call + occurs. Only when every current blocker is positively dead do all + current `PendingStart` attempts transition together to `PendingAbandon`, + arming the existing recovery pipeline; `repair_blocked` then releases + the state lock and drives the unmodified `flush`/`abandon`/`flush` seam + sequence via the existing `resolve_recovery`, so the state lock is never + held across a seam call and no new seam behavior was introduced. A + losing re-proof (owner no longer `PendingStart` by the time the lock is + acquired, or a sibling attempt's owner is no longer provably dead) makes + `repair_blocked` a safe no-op with no seam call and no write — proven by + a new regression test, + `repair_blocked_is_all_or_nothing_when_auto_fixable_assessment_becomes_stale`, + that seeds two independently dead-owner `PendingStart` attempts + (doctor's initial unlocked `assess_repairability` read reports + `AutoFixable`), then rewrites only the second attempt's persisted owner + to a live owner before calling `repair_blocked`, and asserts the fresh + re-proof rejects the whole batch: `RepairOutcome::NoOp`, both attempts + still `PendingStart`, recovery still `Clear`, and zero seam calls — so + the first attempt's still-dead owner is never enough on its own once any + other current blocker fails the all-dead proof. A seam failure + mid-repair leaves the existing `PendingAbandon`/`Pending` recovery + state, which the adapter's pre-existing ordinary recovery path (any + later tracked admission) resumes and completes without duplicating or + resurrecting the attempt — proven by a new regression test that + interrupts `repair_blocked` on a failing `abandon` seam call and then + drives an unrelated `ToolExecuteBefore` to completion. + - Deviation: `repair_blocked`'s signature is `repair_blocked(git_dir, + repository_root, logger, seam) -> Result`, adding a + `logger: Option<&dyn Logger>` parameter beyond the plan's literal + `repair_blocked(git_dir, repository_root, seam)`, matching every other + seam-driving function in this module (`resolve_recovery`, + `abandon_and_consume`, `establish_tracked_start`) which already thread a + logger through for fail-closed observability; `repair_blocked` calls + `resolve_recovery` directly and needed the same parameter. `Repairability` + and `RepairOutcome` are defined locally in this module (in `health.rs` + and `lifecycle.rs` respectively) rather than in the shared + `mutation_scope_health.rs`, since T05 (dependent on this task) is the + task explicitly scoped to add "the shared `Repairability` enum" there; + this task's own in-scope file list does not include + `mutation_scope_health.rs`. + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> `113 passed; 0 failed` (103 pre-existing + 10 new, including the + `repair_blocked_is_all_or_nothing_when_auto_fixable_assessment_becomes_stale` + follow-up regression); `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/opencode_mutation_scope` -> only the pre-existing, unrelated `os_lock.rs` lock-timeout deadline (unchanged by this task), matching AC3's "no staleness use outside unrelated lock-timeout constants." + - Context impact: OpenCode's persisted state file gains a new optional + `owner` field (backward-compatible, `#[serde(default)]`) and three new + `pub(crate)` symbols (`Repairability`, `assess_repairability`, + `repair_blocked`/`RepairOutcome`) that are not yet consumed by doctor — + T05 wires them into `execute_doctor_with_lifecycle_providers`. No + user-visible behavior changed yet (doctor's `--fix` still cannot repair + OpenCode until T05 dispatches to these functions), no existing + `MutationScope*` type or JSON shape changed, and `classify_health`'s + four status boundaries are unchanged. `domain`-scoped: the context docs + this plan's "Context sync" section names for OpenCode + (`context/cli/opencode-mutation-scope-adapter-lifecycle.md` and/or + `context/cli/opencode-mutation-scope-integration.md`) describe the new + owner-evidence field and dead-owner repair path once T05/T06 make the + repair path reachable through `sce doctor --fix`; recording that + dependency here for T05's own context-sync pass rather than updating + those docs prematurely for a repair path doctor cannot yet invoke. No + root context file (`overview.md`/`architecture.md`/`glossary.md`/ + `patterns.md`/`context-map.md`) is implicated by an adapter-internal, + not-yet-wired addition. + - Context synchronization: synced - [ ] T04: `Claude: persisted terminal-cleanup evidence and safe PendingAbandon repair` (status:todo) - Task ID: T04 From a0276bf942929c354abfc0a5f86c61aedb8cebc5 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 15:07:00 +0200 Subject: [PATCH 10/16] claude-mutation-scope: Add durable abandonment evidence and repair Persist PendingAbandon before Claude cleanup seam calls so interrupted cleanup can be safely retried without confusing live attempts with established abandon intent. Add batch marking, lock-protected re-proofing, repair behavior, regression coverage, and the corresponding context updates. Plan: doctor-mutation-scope-fix.md (T04) Co-authored-by: SCE --- .../hooks/claude_mutation_scope/health.rs | 503 +++++++++++++++++- .../hooks/claude_mutation_scope/lifecycle.rs | 87 ++- .../hooks/claude_mutation_scope/mod.rs | 8 +- .../hooks/claude_mutation_scope/state.rs | 320 ++++++++++- ...ude-mutation-scope-background-execution.md | 37 ++ context/cli/claude-mutation-scope-health.md | 78 +++ .../cli/claude-mutation-scope-integration.md | 111 +--- context/context-map.md | 4 +- context/plans/doctor-mutation-scope-fix.md | 197 ++++++- 9 files changed, 1237 insertions(+), 108 deletions(-) create mode 100644 context/cli/claude-mutation-scope-background-execution.md create mode 100644 context/cli/claude-mutation-scope-health.md diff --git a/cli/src/services/hooks/claude_mutation_scope/health.rs b/cli/src/services/hooks/claude_mutation_scope/health.rs index 22f22a4d..c2f8612e 100644 --- a/cli/src/services/hooks/claude_mutation_scope/health.rs +++ b/cli/src/services/hooks/claude_mutation_scope/health.rs @@ -7,6 +7,33 @@ use crate::services::mutation_trace::types::ActorKind; use super::state; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Repairability { + AutoFixable, + ManualOnly, +} + +pub(crate) fn assess_repairability(git_dir: &Path) -> Repairability { + let Ok(state) = state::read_state(git_dir) else { + return Repairability::ManualOnly; + }; + + if !state.recovery_pending || state.attempts.is_empty() { + return Repairability::ManualOnly; + } + + let every_attempt_is_pending_abandon = state + .attempts + .iter() + .all(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon); + + if every_attempt_is_pending_abandon { + Repairability::AutoFixable + } else { + Repairability::ManualOnly + } +} + pub(crate) fn classify_health(git_dir: &Path) -> MutationScopeAdapterHealth { let state = match state::read_state(git_dir) { Ok(state) => state, @@ -50,7 +77,10 @@ mod tests { use anyhow::anyhow; - use super::super::{abandon_attempt, apply_recovery_barrier, AttemptKey, BarrierOutcome}; + use super::super::{ + abandon_attempt, apply_recovery_barrier, cleanup_attempts_matching, repair_blocked, + AttemptKey, BarrierOutcome, RepairOutcome, + }; use super::*; use crate::services::observability::traits::Logger; @@ -202,6 +232,11 @@ mod tests { 1, "remove_attempt must never have run because the seam call failed" ); + assert_eq!( + seeded_state.attempts[0].phase, + state::AttemptPhase::PendingAbandon, + "the attempt's abandon intent must be durably persisted before the seam call ran" + ); assert_eq!( classify_health(&git_dir).status, @@ -223,6 +258,472 @@ mod tests { "the classifier must still report Blocked after repeated denial" ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::AutoFixable, + "a PendingAbandon attempt is an already-established abandon decision, safe to retry" + ); + + let healthy = + |_root: &Path, _payload: &str, _logger: Option<&dyn Logger>| Ok(String::new()); + let outcome = repair_blocked(&git_dir, repository_root, None, &healthy) + .expect("repair should not error"); + assert_eq!(outcome, RepairOutcome::Repaired); + + let final_status = classify_health(&git_dir).status; + assert!( + matches!( + final_status, + MutationScopeHealthStatus::Healthy | MutationScopeHealthStatus::Recovering + ), + "AC3/AC6: a reported repair must never leave the final health Blocked: {final_status:?}" + ); + assert!(state::read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mark_active_losing_the_race_against_an_established_pending_abandon_leaves_repairable_terminal_evidence( + ) { + let git_dir = unique_test_git_dir("mark-active-race"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let allocated = state::allocate_attempt(&git_dir, &key("toolu_1"), "Write") + .expect("allocation should succeed"); + + state::mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&allocated.attempt.scope_id), + ) + .expect("atomic establishment should succeed"); + + let error = state::mark_active(&git_dir, &allocated.attempt.scope_id) + .expect_err("mark_active must fail once abandonment has been established"); + assert!(error.to_string().contains("abandon")); + + let state_after = state::read_state(&git_dir).expect("state readable"); + assert!( + state_after.recovery_pending, + "recovery_pending must remain true after losing the activation race" + ); + assert_eq!( + state_after.attempts[0].phase, + state::AttemptPhase::PendingAbandon, + "losing the activation race must leave PendingAbandon intact, not resurrect Active" + ); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked + ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::AutoFixable, + "losing the activation race must leave repairable established terminal evidence, \ + not fall back to ManualOnly" + ); + + let healthy = + |_root: &Path, _payload: &str, _logger: Option<&dyn Logger>| Ok(String::new()); + let outcome = repair_blocked(&git_dir, repository_root, None, &healthy) + .expect("repair should not error"); + assert_eq!(outcome, RepairOutcome::Repaired); + + let resolved = state::read_state(&git_dir).expect("state readable"); + assert!(resolved.attempts.is_empty()); + assert!(!resolved.recovery_pending); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails( + ) { + let git_dir = unique_test_git_dir("repair-partial-batch"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let first = state::allocate_attempt(&git_dir, &key("toolu-1"), "Write") + .expect("first allocation should succeed"); + let second = state::allocate_attempt(&git_dir, &key("toolu-2"), "Write") + .expect("second allocation should succeed"); + + state::mark_recovery_pending_and_pending_abandon( + &git_dir, + &[ + first.attempt.scope_id.clone(), + second.attempt.scope_id.clone(), + ], + ) + .expect("atomic establishment should succeed"); + + let fail_first = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> anyhow::Result { + if payload.contains(&first.attempt.scope_id) { + return Err(anyhow!( + "seam failure injected by test for the first attempt" + )); + } + Ok(String::new()) + }; + + let outcome = repair_blocked(&git_dir, repository_root, None, &fail_first) + .expect("repair should not error even though one seam call fails"); + assert_eq!(outcome, RepairOutcome::NoOp); + + let state = state::read_state(&git_dir).expect("state readable"); + assert_eq!( + state.attempts.len(), + 1, + "the successfully abandoned attempt must be removed" + ); + assert_eq!(state.attempts[0].scope_id, first.attempt.scope_id); + assert_eq!(state.attempts[0].phase, state::AttemptPhase::PendingAbandon); + assert!( + state.recovery_pending, + "the barrier must remain armed while any attempt is still unresolved" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_when_the_adapter_is_not_blocked() { + let git_dir = unique_test_git_dir("assess-not-blocked"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + assert_eq!(assess_repairability(&git_dir), Repairability::ManualOnly); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_for_a_legacy_attempt_never_marked_pending_abandon() { + let git_dir = unique_test_git_dir("assess-legacy-no-pending-abandon"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + state::allocate_attempt(&git_dir, &key("toolu_1"), "Write") + .expect("allocation should succeed"); + state::mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed"); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked, + "a legacy attempt left PendingStart under a stale recovery flag must still classify Blocked" + ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::ManualOnly, + "an attempt with no established abandon intent is never treated as repairable" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_when_one_of_several_attempts_has_no_established_abandon_intent( + ) { + let git_dir = unique_test_git_dir("assess-mixed-phase"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let doomed = state::allocate_attempt(&git_dir, &key("toolu-doomed"), "Write") + .expect("allocating the doomed attempt should succeed"); + abandon_attempt( + &git_dir, + repository_root, + &doomed.attempt, + None, + &failing_seam, + ) + .expect_err("the injected abandon seam failure must propagate"); + + state::allocate_attempt(&git_dir, &key("toolu-live"), "Write") + .expect("allocating the untouched live attempt should succeed"); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked + ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::ManualOnly, + "every attempt must be PendingAbandon before doctor may repair; a live attempt \ + with no established abandon intent must never be touched" + ); + + let outcome = repair_blocked(&git_dir, repository_root, None, &unreachable_seam) + .expect("repair should not error"); + assert_eq!( + outcome, + RepairOutcome::NoOp, + "a coexisting attempt with no established abandon intent must block the whole repair" + ); + let untouched = state::read_state(&git_dir).expect("state readable"); + assert_eq!( + untouched.attempts.len(), + 2, + "neither attempt may be touched while any one lacks PendingAbandon evidence" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_is_a_safe_no_op_when_an_attempt_is_no_longer_pending_abandon_by_the_time_the_lock_is_acquired( + ) { + let git_dir = unique_test_git_dir("repair-concurrent-race"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let allocated = state::allocate_attempt(&git_dir, &key("toolu_1"), "Write") + .expect("allocation should succeed"); + abandon_attempt( + &git_dir, + repository_root, + &allocated.attempt, + None, + &failing_seam, + ) + .expect_err("the injected abandon seam failure must propagate"); + + assert_eq!(assess_repairability(&git_dir), Repairability::AutoFixable); + + state::set_attempt_phase_for_tests( + &git_dir, + &allocated.attempt.scope_id, + state::AttemptPhase::Active, + ); + + let outcome = repair_blocked(&git_dir, repository_root, None, &unreachable_seam) + .expect("repair should not error"); + + assert_eq!( + outcome, + RepairOutcome::NoOp, + "the fresh, lock-protected re-proof must refuse to act on state assessed before it changed" + ); + let state = state::read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_interrupted_by_a_failing_seam_leaves_state_a_later_repair_completes_without_duplication( + ) { + let git_dir = unique_test_git_dir("repair-interrupted-resume"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let allocated = state::allocate_attempt(&git_dir, &key("toolu_1"), "Write") + .expect("allocation should succeed"); + abandon_attempt( + &git_dir, + repository_root, + &allocated.attempt, + None, + &failing_seam, + ) + .expect_err("the injected abandon seam failure must propagate"); + + let outcome = repair_blocked(&git_dir, repository_root, None, &failing_seam) + .expect("repair should not error even though the seam abandon call fails again"); + assert_eq!(outcome, RepairOutcome::NoOp); + + let interrupted = state::read_state(&git_dir).expect("state readable"); + assert_eq!(interrupted.attempts.len(), 1); + assert_eq!( + interrupted.attempts[0].phase, + state::AttemptPhase::PendingAbandon + ); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked, + "an interrupted repair must remain retryable, not resurrect or duplicate the attempt" + ); + + let healthy = + |_root: &Path, _payload: &str, _logger: Option<&dyn Logger>| Ok(String::new()); + let outcome = repair_blocked(&git_dir, repository_root, None, &healthy) + .expect("the later repair should complete without error"); + assert_eq!(outcome, RepairOutcome::Repaired); + + let resolved = state::read_state(&git_dir).expect("state readable"); + assert!(resolved.attempts.is_empty()); + assert!(!resolved.recovery_pending); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn cleanup_attempts_matching_durably_marks_every_matched_attempt_pending_abandon_before_any_seam_call_even_when_the_first_fails( + ) { + let git_dir = unique_test_git_dir("cleanup-batch-mark"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let first = state::allocate_attempt(&git_dir, &key("toolu-1"), "Write") + .expect("first allocation should succeed"); + let second = state::allocate_attempt(&git_dir, &key("toolu-2"), "Write") + .expect("second allocation should succeed"); + + let failing_on_first = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> anyhow::Result { + if payload.contains(&first.attempt.scope_id) { + return Err(anyhow!( + "seam failure injected by test for the first attempt" + )); + } + Ok(String::new()) + }; + + let error = cleanup_attempts_matching( + &git_dir, + repository_root, + None, + &failing_on_first, + |_attempt| true, + ) + .expect_err("a failure abandoning one attempt must still surface an error"); + assert!(error.to_string().contains("seam failure")); + + let seeded = state::read_state(&git_dir).expect("state readable"); + let remaining_first = seeded + .attempts + .iter() + .find(|attempt| attempt.scope_id == first.attempt.scope_id) + .expect("the failed attempt must still be tracked, not lost"); + assert_eq!( + remaining_first.phase, + state::AttemptPhase::PendingAbandon, + "AC3/T04: every matched attempt must be durably marked before any seam call, \ + so the still-blocked first attempt keeps its retryable evidence" + ); + assert!( + seeded + .attempts + .iter() + .all(|attempt| attempt.scope_id != second.attempt.scope_id), + "the second attempt's own seam call must still have been attempted and succeeded" + ); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked + ); + assert_eq!(assess_repairability(&git_dir), Repairability::AutoFixable); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn apply_recovery_barrier_fails_closed_when_a_new_obligation_is_established_while_the_flush_seam_is_in_flight( + ) { + let git_dir = unique_test_git_dir("barrier-race-new-obligation"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + + let racing_seam = |_root: &Path, + _payload: &str, + _logger: Option<&dyn Logger>| + -> anyhow::Result { + let raced = state::allocate_attempt(&git_dir, &key("toolu-raced-in"), "Write") + .expect("the racing allocation should succeed"); + state::mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&raced.attempt.scope_id), + ) + .expect("the racing establishment should succeed"); + Ok(String::new()) + }; + + let outcome = apply_recovery_barrier(&git_dir, repository_root, None, &racing_seam); + assert!( + matches!(outcome, BarrierOutcome::Deny), + "a fresh obligation established during the flush must fail the barrier closed, \ + not proceed on the stale pre-flush proof" + ); + + let after = state::read_state(&git_dir).expect("state readable"); + assert!( + after.recovery_pending, + "RecoveryNeverClearedWithUnresolvedAbandon: recovery must remain armed" + ); + assert_eq!(after.attempts.len(), 1); + assert_eq!(after.attempts[0].phase, state::AttemptPhase::PendingAbandon); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_fails_closed_when_a_new_obligation_is_established_while_abandoning_another() { + let git_dir = unique_test_git_dir("repair-race-new-obligation"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let original = state::allocate_attempt(&git_dir, &key("toolu-original"), "Write") + .expect("allocation should succeed"); + state::mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&original.attempt.scope_id), + ) + .expect("atomic establishment should succeed"); + + let racing_seam = |_root: &Path, + _payload: &str, + _logger: Option<&dyn Logger>| + -> anyhow::Result { + let raced = state::allocate_attempt(&git_dir, &key("toolu-raced-in"), "Write") + .expect("the racing allocation should succeed"); + state::mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&raced.attempt.scope_id), + ) + .expect("the racing establishment should succeed"); + Ok(String::new()) + }; + + let outcome = repair_blocked(&git_dir, repository_root, None, &racing_seam) + .expect("repair should not error"); + assert_eq!( + outcome, + RepairOutcome::NoOp, + "AC6: repair must not report Repaired when the atomic clear discovers another \ + unresolved obligation, even though its own abandon seam call succeeded" + ); + + let after = state::read_state(&git_dir).expect("state readable"); + assert!( + after.recovery_pending, + "RecoveryNeverClearedWithUnresolvedAbandon: recovery must remain armed while the \ + raced-in obligation is unresolved" + ); + assert!( + after + .attempts + .iter() + .all(|attempt| attempt.scope_id != original.attempt.scope_id), + "the original attempt's own abandon must still have completed" + ); + assert_eq!(after.attempts.len(), 1); + assert_eq!(after.attempts[0].phase, state::AttemptPhase::PendingAbandon); + remove_test_git_dir(&git_dir); } } diff --git a/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs b/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs index 28d8bb5c..1bd0be75 100644 --- a/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs +++ b/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs @@ -309,12 +309,13 @@ pub(super) fn apply_recovery_barrier( } match seam(repository_root, &flush_payload(), logger) { - Ok(_) => match state::clear_recovery_pending(git_dir) { - Ok(()) => BarrierOutcome::Proceed, + Ok(_) => match state::clear_recovery_pending_if_quiescent(git_dir) { + Ok(state::ClearRecoveryOutcome::Cleared) => BarrierOutcome::Proceed, + Ok(state::ClearRecoveryOutcome::StillPending) => BarrierOutcome::Deny, Err(error) => { log_pre_tool_use_fail_closed( logger, - "recovery_barrier.clear_recovery_pending", + "recovery_barrier.clear_recovery_pending_if_quiescent", &error, ); BarrierOutcome::Deny @@ -437,11 +438,31 @@ pub(super) fn cleanup_attempts_matching( .filter(|attempt| predicate(attempt)) .collect(); + if stale.is_empty() { + return Ok(String::new()); + } + + let stale_scope_ids: Vec = stale + .iter() + .map(|attempt| attempt.scope_id.clone()) + .collect(); + state::mark_recovery_pending_and_pending_abandon(git_dir, &stale_scope_ids)?; + + let mut first_error: Option = None; for attempt in &stale { - abandon_attempt(git_dir, repository_root, attempt, logger, seam)?; + if let Err(error) = + abandon_marked_attempt(git_dir, repository_root, &attempt.scope_id, logger, seam) + { + if first_error.is_none() { + first_error = Some(error); + } + } } - Ok(String::new()) + match first_error { + Some(error) => Err(error), + None => Ok(String::new()), + } } pub(super) fn attempt_matches_key(attempt: &state::AdapterAttempt, key: &AttemptKey) -> bool { @@ -457,9 +478,59 @@ pub(super) fn abandon_attempt( logger: Option<&dyn Logger>, seam: IngressSeam, ) -> Result<()> { - state::mark_recovery_pending(git_dir)?; + state::mark_recovery_pending_and_pending_abandon( + git_dir, + std::slice::from_ref(&attempt.scope_id), + )?; + + abandon_marked_attempt(git_dir, repository_root, &attempt.scope_id, logger, seam) +} - seam(repository_root, &abandon_payload(&attempt.scope_id), logger)?; - state::remove_attempt(git_dir, &attempt.scope_id)?; +fn abandon_marked_attempt( + git_dir: &Path, + repository_root: &Path, + scope_id: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + seam(repository_root, &abandon_payload(scope_id), logger)?; + state::remove_attempt(git_dir, scope_id)?; Ok(()) } + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RepairOutcome { + Repaired, + NoOp, +} + +pub(crate) fn repair_blocked( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let Some(attempts) = state::reprove_pending_abandon(git_dir)? else { + return Ok(RepairOutcome::NoOp); + }; + + let mut any_failed = false; + for attempt in &attempts { + if abandon_marked_attempt(git_dir, repository_root, &attempt.scope_id, logger, seam) + .is_err() + { + any_failed = true; + } + } + + let cleared = matches!( + state::clear_recovery_pending_if_quiescent(git_dir)?, + state::ClearRecoveryOutcome::Cleared + ); + + if any_failed || !cleared { + Ok(RepairOutcome::NoOp) + } else { + Ok(RepairOutcome::Repaired) + } +} diff --git a/cli/src/services/hooks/claude_mutation_scope/mod.rs b/cli/src/services/hooks/claude_mutation_scope/mod.rs index 52c6f47d..2295e5fb 100644 --- a/cli/src/services/hooks/claude_mutation_scope/mod.rs +++ b/cli/src/services/hooks/claude_mutation_scope/mod.rs @@ -35,6 +35,8 @@ use events::{ RUN_IN_BACKGROUND_FIELD, SESSION_ID_FIELD, TOOL_INPUT_FIELD, TOOL_NAME_FIELD, TOOL_USE_ID_FIELD, WORKTREE_PATH_FIELD, }; +#[allow(unused_imports)] +pub(crate) use health::{assess_repairability, Repairability}; #[cfg(test)] pub(crate) use lifecycle::run_claude_mutation_scope_from_payload_at_state_root; #[cfg(test)] @@ -42,10 +44,12 @@ pub(crate) use lifecycle::run_claude_mutation_scope_from_payload_at_state_root; use lifecycle::ClaudeModelStateResolver; #[allow(unused_imports)] use lifecycle::{ - abandon_attempt, apply_recovery_barrier, BarrierOutcome, GitDirResolver, IngressSeam, - EXPLICIT_BACKGROUND_SHELL_DENY_REASON, FAIL_CLOSED_DENY_REASON, + abandon_attempt, apply_recovery_barrier, cleanup_attempts_matching, BarrierOutcome, + GitDirResolver, IngressSeam, EXPLICIT_BACKGROUND_SHELL_DENY_REASON, FAIL_CLOSED_DENY_REASON, }; #[allow(unused_imports)] +pub(crate) use lifecycle::{repair_blocked, RepairOutcome}; +#[allow(unused_imports)] pub(crate) use lifecycle::{ run_claude_mutation_scope_from_payload, run_claude_mutation_scope_subcommand, }; diff --git a/cli/src/services/hooks/claude_mutation_scope/state.rs b/cli/src/services/hooks/claude_mutation_scope/state.rs index ad19ef48..08cc668c 100644 --- a/cli/src/services/hooks/claude_mutation_scope/state.rs +++ b/cli/src/services/hooks/claude_mutation_scope/state.rs @@ -22,6 +22,7 @@ const ADAPTER_STATE_VERSION: u32 = 1; pub(crate) enum AttemptPhase { PendingStart, Active, + PendingAbandon, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -313,10 +314,72 @@ pub(crate) fn mark_active(git_dir: &Path, scope_id: &str) -> Result<()> { .iter_mut() .find(|attempt| attempt.scope_id == scope_id) .ok_or_else(|| anyhow!("No adapter-state attempt found for scope_id '{scope_id}'"))?; - attempt.phase = AttemptPhase::Active; + + match attempt.phase { + AttemptPhase::PendingStart => { + attempt.phase = AttemptPhase::Active; + } + AttemptPhase::Active => return Ok(()), + AttemptPhase::PendingAbandon => { + return Err(anyhow!( + "Cannot mark mutation-scope attempt '{scope_id}' active after abandonment was established" + )); + } + } + + write_state_durably(git_dir, &state) +} + +fn transition_to_pending_abandon(state: &mut AdapterState, scope_ids: &[String]) { + for attempt in &mut state.attempts { + if scope_ids + .iter() + .any(|scope_id| scope_id == &attempt.scope_id) + { + attempt.phase = AttemptPhase::PendingAbandon; + } + } +} + +pub(crate) fn mark_recovery_pending_and_pending_abandon( + git_dir: &Path, + scope_ids: &[String], +) -> Result<()> { + if scope_ids.is_empty() { + return Ok(()); + } + + let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) + .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; + + let mut state = read_state(git_dir)?; + state.recovery_pending = true; + transition_to_pending_abandon(&mut state, scope_ids); write_state_durably(git_dir, &state) } +pub(crate) fn reprove_pending_abandon(git_dir: &Path) -> Result>> { + let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) + .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; + + let state = read_state(git_dir)?; + + if !state.recovery_pending || state.attempts.is_empty() { + return Ok(None); + } + + let every_attempt_is_pending_abandon = state + .attempts + .iter() + .all(|attempt| attempt.phase == AttemptPhase::PendingAbandon); + + if !every_attempt_is_pending_abandon { + return Ok(None); + } + + Ok(Some(state.attempts)) +} + pub(crate) fn remove_attempt(git_dir: &Path, scope_id: &str) -> Result<()> { let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; @@ -341,13 +404,59 @@ pub(crate) fn mark_recovery_pending(git_dir: &Path) -> Result<()> { write_state_durably(git_dir, &state) } -pub(crate) fn clear_recovery_pending(git_dir: &Path) -> Result<()> { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ClearRecoveryOutcome { + Cleared, + StillPending, +} + +pub(crate) fn clear_recovery_pending_if_quiescent(git_dir: &Path) -> Result { + clear_recovery_pending_if_quiescent_inner(git_dir, |_, _| Ok(())) +} + +fn clear_recovery_pending_if_quiescent_inner( + git_dir: &Path, + before_rename: F, +) -> Result +where + F: FnOnce(&Path, &Path) -> Result<()>, +{ let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; let mut state = read_state(git_dir)?; + + if !state.recovery_pending { + return Ok(ClearRecoveryOutcome::Cleared); + } + + if !state.attempts.is_empty() { + return Ok(ClearRecoveryOutcome::StillPending); + } + state.recovery_pending = false; - write_state_durably(git_dir, &state) + write_state_durably_inner(git_dir, &state, before_rename)?; + Ok(ClearRecoveryOutcome::Cleared) +} + +#[cfg(test)] +pub(crate) fn set_attempt_phase_for_tests( + git_dir: &Path, + scope_id: &str, + phase: AttemptPhase, +) -> AdapterAttempt { + let _lock = + AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT).expect("test phase-override lock"); + let mut state = read_state(git_dir).expect("test phase-override read"); + let attempt = state + .attempts + .iter_mut() + .find(|attempt| attempt.scope_id == scope_id) + .expect("attempt to override must already exist"); + attempt.phase = phase; + let updated = attempt.clone(); + write_state_durably(git_dir, &state).expect("test phase-override write"); + updated } #[cfg(test)] @@ -510,6 +619,117 @@ mod tests { remove_test_git_dir(&git_dir); } + #[test] + fn mark_active_is_idempotent_when_already_active() { + let git_dir = unique_test_git_dir("mark-active-idempotent"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + mark_active(&git_dir, &allocated.attempt.scope_id) + .expect("first activation should succeed"); + + mark_active(&git_dir, &allocated.attempt.scope_id) + .expect("re-delivery of PreToolUse after activation must be a safe idempotent no-op"); + + let state = read_state(&git_dir).expect("state should be readable"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mark_active_is_forbidden_once_pending_abandon_is_established() { + let git_dir = unique_test_git_dir("mark-active-forbidden-pending-abandon"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + + mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&allocated.attempt.scope_id), + ) + .expect("atomic establishment should succeed"); + + let error = mark_active(&git_dir, &allocated.attempt.scope_id) + .expect_err("PendingAbandon -> Active must be forbidden"); + assert!(error.to_string().contains("abandon")); + + let state = read_state(&git_dir).expect("state should be readable"); + assert_eq!( + state.attempts[0].phase, + AttemptPhase::PendingAbandon, + "a rejected activation must not mutate the persisted phase" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mark_recovery_pending_and_pending_abandon_establishes_both_facts_in_one_durable_write() { + let git_dir = unique_test_git_dir("atomic-recovery-and-pending-abandon"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + mark_active(&git_dir, &allocated.attempt.scope_id).expect("attempt should become active"); + + mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&allocated.attempt.scope_id), + ) + .expect("atomic establishment should succeed"); + + let state = read_state(&git_dir).expect("state should be readable"); + assert!( + state.recovery_pending, + "T04: the recovery barrier must be armed by the atomic operation" + ); + assert_eq!( + state.attempts[0].phase, + AttemptPhase::PendingAbandon, + "T04: abandonment evidence must be established alongside the barrier in the same write" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mark_recovery_pending_and_pending_abandon_failed_write_leaves_no_partial_invariant() { + let git_dir = unique_test_git_dir("atomic-injected-failure"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + mark_active(&git_dir, &allocated.attempt.scope_id).expect("attempt should become active"); + + let mut state = read_state(&git_dir).expect("state should be readable"); + state.recovery_pending = true; + transition_to_pending_abandon( + &mut state, + std::slice::from_ref(&allocated.attempt.scope_id), + ); + + let result = write_state_durably_inner(&git_dir, &state, |_, _| { + Err(anyhow!("injected interruption before rename")) + }); + assert!( + result.is_err(), + "write_state_durably_inner should surface the injected interruption" + ); + + let after = + read_state(&git_dir).expect("state should be readable after the injected failure"); + assert!( + !after.recovery_pending, + "an interrupted write must not leave the barrier half-armed" + ); + assert_eq!( + after.attempts[0].phase, + AttemptPhase::Active, + "an interrupted write must not leave the attempt half-transitioned to PendingAbandon" + ); + + remove_test_git_dir(&git_dir); + } + #[test] fn removing_an_already_removed_attempt_is_a_safe_no_op() { let git_dir = unique_test_git_dir("remove-idempotent"); @@ -738,12 +958,14 @@ mod tests { } #[test] - fn clear_recovery_pending_resets_the_barrier() { - let git_dir = unique_test_git_dir("clear-recovery-pending"); + fn clear_recovery_pending_if_quiescent_resets_the_barrier_when_no_attempts_remain() { + let git_dir = unique_test_git_dir("clear-recovery-pending-quiescent"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed"); - clear_recovery_pending(&git_dir).expect("clearing the barrier should succeed"); + let outcome = clear_recovery_pending_if_quiescent(&git_dir) + .expect("clearing the barrier should succeed"); + assert_eq!(outcome, ClearRecoveryOutcome::Cleared); let state = read_state(&git_dir).expect("state should be readable"); assert!(!state.recovery_pending); @@ -751,6 +973,92 @@ mod tests { remove_test_git_dir(&git_dir); } + #[test] + fn clear_recovery_pending_if_quiescent_is_a_no_op_when_recovery_is_already_clear() { + let git_dir = unique_test_git_dir("clear-recovery-pending-already-clear"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let outcome = clear_recovery_pending_if_quiescent(&git_dir) + .expect("clearing an already-clear barrier should succeed"); + assert_eq!(outcome, ClearRecoveryOutcome::Cleared); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn clear_recovery_pending_if_quiescent_leaves_recovery_armed_when_an_attempt_remains() { + let git_dir = unique_test_git_dir("clear-recovery-pending-still-pending"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&allocated.attempt.scope_id), + ) + .expect("atomic establishment should succeed"); + + let outcome = clear_recovery_pending_if_quiescent(&git_dir) + .expect("proving quiescence should not error"); + assert_eq!(outcome, ClearRecoveryOutcome::StillPending); + + let state = read_state(&git_dir).expect("state should be readable"); + assert!( + state.recovery_pending, + "recovery must remain armed when an unresolved attempt survives the proof" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn clear_recovery_pending_if_quiescent_interrupted_before_rename_keeps_recovery_armed() { + let git_dir = unique_test_git_dir("clear-recovery-pending-interrupted-before-rename"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed"); + + let result = + clear_recovery_pending_if_quiescent_inner(&git_dir, |tmp_path, canonical_path| { + assert!( + tmp_path.exists(), + "temp replacement file should exist by the time the pre-rename hook runs" + ); + assert!( + canonical_path.exists(), + "canonical state file should still exist at the pre-rename hook" + ); + Err(anyhow!("injected interruption before rename")) + }); + + assert!( + result.is_err(), + "an interrupted durable write must surface the injected error" + ); + + let after = + read_state(&git_dir).expect("state should be readable after the injected interruption"); + assert!( + after.recovery_pending, + "an interrupted clear must leave the canonical state fail-closed with recovery armed" + ); + assert!( + after.attempts.is_empty(), + "the interrupted clear must not fabricate or lose attempt bookkeeping" + ); + + let outcome = clear_recovery_pending_if_quiescent(&git_dir) + .expect("a retried clear should succeed once nothing interrupts the rename"); + assert_eq!(outcome, ClearRecoveryOutcome::Cleared); + + let final_state = + read_state(&git_dir).expect("state should be readable after the successful retry"); + assert!( + !final_state.recovery_pending, + "the retried clear must durably disarm the barrier" + ); + + remove_test_git_dir(&git_dir); + } + #[test] fn adapter_state_files_live_only_below_git_dir_sce() { let git_dir = unique_test_git_dir("path-boundary"); diff --git a/context/cli/claude-mutation-scope-background-execution.md b/context/cli/claude-mutation-scope-background-execution.md new file mode 100644 index 00000000..c936a599 --- /dev/null +++ b/context/cli/claude-mutation-scope-background-execution.md @@ -0,0 +1,37 @@ +# Claude mutation-scope background and detached execution boundaries + +Detail split out of +[claude-mutation-scope-integration.md](claude-mutation-scope-integration.md) +for the repository's per-file line budget. + +## Background shell is unsupported + +An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = +true` is denied in `PreToolUse` (fail-closed shape) with: + +```text +SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground. +``` + +A detached shell can keep mutating the repository after `PostToolUse` returns and +can outlive a session; the generic contract has no process supervisor or stable +background-execution terminal signal. This is a deliberate correctness boundary, +not a Bash security policy. Background **subagents** are not excluded — their +internal mutation-capable tool calls still establish their own scopes. + +**Self-detaching descendants are a separate, explicit unsupported boundary +(D20).** A `run_in_background = false` call can still leave a repository-mutating +descendant running after `PostToolUse` returns when the invoked command detaches +a child (`command &`, `nohup`, `setsid`, double-fork, `start_new_session=True`). +T04 proved this live against Claude Code `2.1.258`: a foreground `setsid` +command returned `PostToolUse` in `duration_ms: 13` and its descendant's write +landed ~3s later, changing the Git tree an SCE snapshot would capture — outside +the tool's closed scope. This is not solvable by inspecting the command string; +the integration adds no detection, supervision, or static scan, and simply does +not treat `PostToolUse` as proof that every descendant has stopped mutating. See +the T04 addendum and `probe17-*` fixtures under +`cli/src/services/hooks/claude_mutation_scope/fixtures/`. + +## Related context + +- [Claude mutation-scope integration](claude-mutation-scope-integration.md) diff --git a/context/cli/claude-mutation-scope-health.md b/context/cli/claude-mutation-scope-health.md new file mode 100644 index 00000000..2dcf0a35 --- /dev/null +++ b/context/cli/claude-mutation-scope-health.md @@ -0,0 +1,78 @@ +# Claude mutation-scope health classification + +`claude_mutation_scope::health::classify_health` is a read-only diagnostic +classifier over the checkout-local adapter state described in +[Checkout-local adapter state](claude-mutation-scope-integration.md#checkout-local-adapter-state). +It returns the shared `healthy | recovering | blocked | invalid` health +vocabulary that `sce doctor` already consumes (see +[`doctor-human-text-contract.md`](../sce/doctor-human-text-contract.md)). It +reads the same `state::read_state` result the adapter itself uses and maps it +as: + +| Persisted state | Status | Reason | +| --- | --- | --- | +| `recovery_pending == false` | `Healthy` | no persisted recovery barrier is armed | +| `recovery_pending == true && attempts.is_empty()` | `Recovering` | the recovery barrier's own flush path can clear this automatically | +| `recovery_pending == true && attempts` non-empty | `Blocked` | the recovery barrier's flush path never runs from this shape, and nothing else advances it | +| `read_state` fails (malformed JSON, unsupported version, read error) | `Invalid` | the state cannot be safely interpreted | + +**Healthy** covers the absence of a state file (`read_state`'s default) as +well as an explicit `recovery_pending == false`. This is recovery health +specifically, not "no active tool calls" — the adapter may still have live +`attempts` in `pending_start`/`active`/`pending_abandon` phase; live attempts +alone, with `recovery_pending == false`, are still `Healthy`. + +**Recovering** (`recovery_pending == true && attempts.is_empty()`) reflects +actual adapter behavior, not the `recovery_pending` name alone: the next +mutation-capable `PreToolUse` reaches +[the recovery barrier](claude-mutation-scope-integration.md#abandonment-cleanup-signals), +finds `attempts` empty, runs `{"operation":"flush"}` through the seam, and — +on success — calls `clear_recovery_pending` before proceeding. This is a +normal, self-healing admission path with no manual intervention. + +**Blocked** (`recovery_pending == true && attempts` non-empty) is the exact +shape of the incident that motivated this classifier and the wider +`doctor-mutation-scope-health` plan. `apply_recovery_barrier()` sees +`recovery_pending == true` with non-empty `attempts` and returns `Deny`. From +the ordinary hook lifecycle alone, nothing retries the abandon that left +those attempts stale, removes them, flushes, or clears `recovery_pending` — +the barrier's only self-healing transition (flush) is gated on +`attempts.is_empty()`, which this shape never satisfies. So once the hook +invocation that produced this persisted state has returned, every subsequent +ordinary mutation-capable `PreToolUse` continues to deny without advancing +recovery through that path alone — a durable repository-wide lockout, not +merely "currently denied." The `doctor-mutation-scope-fix` plan adds a +separate, `doctor`-invoked repair path (`assess_repairability`/ +`repair_blocked`) for exactly this shape once every outstanding attempt +already carries durable `pending_abandon` evidence; that repair path is not +part of the ordinary hook lifecycle this classifier observes, and is not yet +wired into `sce doctor --fix` as of this classifier's own read-only +behavior. + +**Invalid** applies when `state::read_state` cannot safely read or parse the +state file — malformed JSON or an unsupported/invalid persisted version, per +the existing state reader. A fail-closed but structurally valid state (i.e. +`Blocked`) is never reported as `Invalid`. + +**Read-only boundary.** This classifier is diagnostic only: it reads the +existing checkout-local adapter state and nothing else. It never modifies +`attempts`, never clears `recovery_pending`, never calls `flush` or +`abandon`, and never alters mutation attribution. Recovery/repair is a +separate concern this classifier does not perform. + +**Observation semantics.** Classification is a snapshot of persisted state, +read the same way `state::read_state` reads it for the adapter's own use. +There is a narrow window in which a currently executing hook has already +persisted `recovery_pending = true` with non-empty `attempts` but is still +about to complete its abandon/remove sequence — the classifier does not +attempt to prove global process liveness across that window. `Blocked` means +that, if the operation that produced the observed durable state has stopped +progressing, future ordinary adapter lifecycle events have no self-healing +path from that state — not a claim that no process anywhere could possibly +still be mid-write. + +## Related context + +- [Claude mutation-scope integration](claude-mutation-scope-integration.md) +- [OpenCode mutation-scope health classification](opencode-mutation-scope-health.md) +- [Pi mutation-scope health classification](pi-mutation-scope-health.md) diff --git a/context/cli/claude-mutation-scope-integration.md b/context/cli/claude-mutation-scope-integration.md index 995c2f50..1c42f125 100644 --- a/context/cli/claude-mutation-scope-integration.md +++ b/context/cli/claude-mutation-scope-integration.md @@ -90,7 +90,9 @@ new `ScopeId`; otherwise-identical tool IDs under main / `agent_id=A` / `checkout::resolve_git_dir(cwd)` — worktree-specific for linked worktrees): a versioned `{version, next_attempt_seq, recovery_pending, attempts[]}`, each attempt carrying `attempt_seq`, `scope_id`, the identity fields, `tool_name`, and -`phase` (`pending_start | active`). This is **adapter bookkeeping, never +`phase` (`pending_start | active | pending_abandon`, the last set durably before +any abandon seam call — see [Abandonment cleanup signals](#abandonment-cleanup-signals) +below). This is **adapter bookkeeping, never attribution evidence** — not exported, synced, or authoritative; its only job is knowing which Claude-created scopes may still need a terminal action. A malformed or wrong-version file is rejected, never fabricated. @@ -155,10 +157,17 @@ rules: ## Abandonment cleanup signals -Every abandonment shares one helper: arm `recovery_pending`, call the seam -`abandon` operation, then remove the attempt on success. A failed abandon leaves -`recovery_pending = true` and the attempt tracked, so the next mutation-capable -`PreToolUse` is denied by the barrier. +Every abandonment shares one helper: arm `recovery_pending`, durably persist the +attempt's phase as `pending_abandon` (proof that abandonment has already been +decided, distinct from "may still be running"), then call the seam `abandon` +operation and remove the attempt only on success. A failed abandon leaves +`recovery_pending = true` and the attempt tracked in `pending_abandon`, so the +next mutation-capable `PreToolUse` is denied by the barrier. When a broad +cleanup signal (`Stop`/`UserPromptSubmit`/`SubagentStop`/`SessionEnd`/ +`WorktreeRemove`) matches more than one outstanding attempt, every matched +attempt is durably marked `pending_abandon` in one write before any of their +seam `abandon` calls run, so one attempt's seam failure can never leave a +sibling attempt in the same batch without its own retryable evidence. | Event | Retires | | --- | --- | @@ -183,64 +192,12 @@ recovery/rebaseline boundary. Only a successful `flush` clears ## Mutation-scope health `claude_mutation_scope::health::classify_health` is a read-only diagnostic -classifier over the checkout-local adapter state described above. It returns -the shared `healthy | recovering | blocked | invalid` health vocabulary that -the planned doctor integration will consume. It reads the same -`state::read_state` result the adapter itself uses and maps it as: - -| Persisted state | Status | Reason | -| --- | --- | --- | -| `recovery_pending == false` | `Healthy` | no persisted recovery barrier is armed | -| `recovery_pending == true && attempts.is_empty()` | `Recovering` | the recovery barrier's own flush path can clear this automatically | -| `recovery_pending == true && attempts` non-empty | `Blocked` | the recovery barrier's flush path never runs from this shape, and nothing else advances it | -| `read_state` fails (malformed JSON, unsupported version, read error) | `Invalid` | the state cannot be safely interpreted | - -**Healthy** covers the absence of a state file (`read_state`'s default) as -well as an explicit `recovery_pending == false`. This is recovery health -specifically, not "no active tool calls" — the adapter may still have live -`attempts` in `pending_start`/`active` phase; live attempts alone, with -`recovery_pending == false`, are still `Healthy`. - -**Recovering** (`recovery_pending == true && attempts.is_empty()`) reflects -actual adapter behavior, not the `recovery_pending` name alone: the next -mutation-capable `PreToolUse` reaches [the recovery barrier](#the-recovery-barrier) -above, finds `attempts` empty, runs `{"operation":"flush"}` through the seam, -and — on success — calls `clear_recovery_pending` before proceeding. This is -a normal, self-healing admission path with no manual intervention. - -**Blocked** (`recovery_pending == true && attempts` non-empty) is the exact -shape of the incident that motivated this classifier and the wider -`doctor-mutation-scope-health` plan. `apply_recovery_barrier()` sees -`recovery_pending == true` with non-empty `attempts` and returns `Deny`. It -does not retry the abandon that left those attempts stale, does not remove -them, does not flush, and does not clear `recovery_pending` — the barrier's -only self-healing transition (flush) is gated on `attempts.is_empty()`, which -this shape never satisfies. So once the hook invocation that produced this -persisted state has returned, every subsequent ordinary mutation-capable -`PreToolUse` continues to deny without advancing recovery — a durable -repository-wide lockout, not merely "currently denied." - -**Invalid** applies when `state::read_state` cannot safely read or parse the -state file — malformed JSON or an unsupported/invalid persisted version, per -the existing state reader. A fail-closed but structurally valid state (i.e. -`Blocked`) is never reported as `Invalid`. - -**Read-only boundary.** This classifier is diagnostic only: it reads the -existing checkout-local adapter state and nothing else. It never modifies -`attempts`, never clears `recovery_pending`, never calls `flush` or -`abandon`, and never alters mutation attribution. Recovery/repair is a -separate concern this classifier does not perform. - -**Observation semantics.** Classification is a snapshot of persisted state, -read the same way `state::read_state` reads it for the adapter's own use. -There is a narrow window in which a currently executing hook has already -persisted `recovery_pending = true` with non-empty `attempts` but is still -about to complete its abandon/remove sequence — the classifier does not -attempt to prove global process liveness across that window. `Blocked` means -that, if the operation that produced the observed durable state has stopped -progressing, future ordinary adapter lifecycle events have no self-healing -path from that state — not a claim that no process anywhere could possibly -still be mid-write. +classifier over the checkout-local adapter state described above, mapping it +onto the shared `healthy | recovering | blocked | invalid` vocabulary that +`sce doctor` consumes. See +[Claude mutation-scope health classification](claude-mutation-scope-health.md) +for the full mapping, the `Blocked` incident this classifier exists to +surface, and its read-only/observation-semantics boundaries. ## Raw cwd is authoritative @@ -255,31 +212,7 @@ to the seam. ## Background shell is unsupported -An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = -true` is denied in `PreToolUse` (fail-closed shape) with: - -```text -SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground. -``` - -A detached shell can keep mutating the repository after `PostToolUse` returns and -can outlive a session; the generic contract has no process supervisor or stable -background-execution terminal signal. This is a deliberate correctness boundary, -not a Bash security policy. Background **subagents** are not excluded — their -internal mutation-capable tool calls still establish their own scopes. - -**Self-detaching descendants are a separate, explicit unsupported boundary -(D20).** A `run_in_background = false` call can still leave a repository-mutating -descendant running after `PostToolUse` returns when the invoked command detaches -a child (`command &`, `nohup`, `setsid`, double-fork, `start_new_session=True`). -T04 proved this live against Claude Code `2.1.258`: a foreground `setsid` -command returned `PostToolUse` in `duration_ms: 13` and its descendant's write -landed ~3s later, changing the Git tree an SCE snapshot would capture — outside -the tool's closed scope. This is not solvable by inspecting the command string; -the integration adds no detection, supervision, or static scan, and simply does -not treat `PostToolUse` as proof that every descendant has stopped mutating. See -the T04 addendum and `probe17-*` fixtures under -`cli/src/services/hooks/claude_mutation_scope/fixtures/`. +An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = true` is denied in `PreToolUse` (fail-closed shape); self-detaching descendants are a separate, explicit unsupported boundary (D20). See [Claude mutation-scope background and detached execution boundaries](claude-mutation-scope-background-execution.md) for the full denial text and both boundaries. ## Generated settings @@ -307,6 +240,8 @@ durable-completion classification, and empty-stdout semantics. ## Related context +- [Claude mutation-scope health classification](claude-mutation-scope-health.md) +- [Claude mutation-scope background and detached execution boundaries](claude-mutation-scope-background-execution.md) - [Mutation-scope hook ingress: the harness-neutral transport seam](mutation-scope-hook-ingress.md) - [Mutation-scope runtime: the harness-adapter contract](mutation-scope-runtime.md) - [Agent Trace hooks command routing](../sce/agent-trace-hooks-command-routing.md) diff --git a/context/context-map.md b/context/context-map.md index 6e66de8b..eef9a5b6 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -34,7 +34,9 @@ Feature/domain context: - `context/cli/mutation-trace-scope-abandonment.md` (the mutation-cursor runtime's second protected entrypoint in `cli/src/services/mutation_trace/runtime/scope_runtime.rs`, built by the `mutation-scope-runtime-integration` plan and the first production call site for `protocol::abandon`: `abandon_scope(repository_root, scope, open_db) -> Result` retires a scope whose final worktree boundary was never observed, sharing `coordinate()`'s `ProtectedWorktree` prefix but deliberately capturing **no** Git snapshot, pin, diff, reconciliation, scope registration, or worktree initialization — so abandonment is not a `RuntimeBoundary` and needs no Quint change; the classify-before-transition order that recovers what `protocol::abandon`'s uniform guarded no-op cannot report (`load_scope` first for the missing-row and cross-worktree cases the projection seam treats as errors, then `load_worktree` for the `NeverSeen` / terminal / `Active` split); the `Abandoned` / `AlreadyTerminal` / `RecoveryRequired` outcomes and the `InheritedExternalTaint` | `MissingScope` | `NeverSeenScope` | `MissingWorktreeState` recovery reasons; the inherited-marker short-circuit that returns before the DB provider is ever invoked; the fence-completion rule that clears the marker only for a settled abandonment or proven-terminal no-op and leaves it armed for every recovery-required outcome and every error, with `MarkerClearAfterCompletion` carrying the already-settled outcome; the CAS retry bounded by the coordinator's shared `MAX_CAS_RETRY_ATTEMPTS`, settling on a competitor's terminal status rather than overwriting it; and the deliberate false-negative tradeoff whereby a missing or `NeverSeen` target forces conservative strong recovery that may abandon unrelated live scopes, because attribution safety outranks preserving potentially valid evidence) - `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every current or future harness adapter must uphold, recorded by the `mutation-scope-runtime-integration` plan: the ten `pub(crate)` re-exports in `runtime/mod.rs` (`coordinate`, `RuntimeBoundary`, `StartProvenance`, `CoordinateOutcome`, `CoordinateError`, `ExternalTaintOperation`, `abandon_scope`, `AbandonScopeOutcome`, `AbandonRecoveryReason`, `AbandonScopeError`), with `ExternalTaintOperation` riding through `coordinator.rs`'s own `pub use` because `CoordinateError::ExternalTaintMarker` carries it — so the type becomes crate-visible without `protected_worktree` becoming a public module — while every `mod` declaration stays private and `ProtectedWorktree`/`ProtectedWorktreeError`/`WORKTREE_LOCK_TIMEOUT`/`reconcile_worktree` stay internal, kept clippy-clean by `#[allow(unused_imports)]` on the re-exports per the `services/style.rs` precedent rather than a placeholder consumer; and the adapter obligations themselves — a scope is one independently mutation-capable execution so a concurrent main agent and subagent need distinct `ScopeId`s, `Start`/`Advance`/`Close` semantics including that only `Start` may carry the optional `StartProvenance`, registered after scope registration and before the protocol commits and never inside `ProtocolState` or the CAS transition, with that registration conditional on the `ScopeState` `register_scope` returns so a provenance row may only be created while the scope is `NeverSeen` (an admission-time snapshot that a post-admission replay cannot backfill) while an existing row is still validated on every provenance-carrying `Start`, that a failed tool still requires `Advance` (the boundary is an observation, not a successful edit) and that a `ScopeId` is never reused after a terminal status (the `NeverSeen` guard silently refuses to reactivate it), `abandon_scope()` requiring positive staleness evidence and never inferring it from `ActorKind`, the `abandon` → `coordinate(Start(successor))` sequence with what each outcome implies for it including that a failed abandonment must never be treated as a safely started successor, that abandonment is not a `RuntimeBoundary` and needs no Quint change, the D1 tradeoff whereby a missing or `NeverSeen` target deliberately forces conservative strong recovery that may invalidate unrelated live scopes because attribution safety outranks preserving potentially valid evidence, and the attribution boundary that `AiExclusive(scope)` means scope exclusivity only and is not standalone proof that no human edited the worktree; a generic `sce hooks mutation-scope` ingress now drives the seam, joined by current Claude Code and Codex adapter drivers (both registered by `sce setup` and reachable) and an OpenCode adapter driver (full lifecycle + recovery, now generated as the final `sce-mutation-scope.ts` plugin and registered by `sce setup`, so reachable by a real session) — a Pi adapter driver now also exists (`cli/src/services/hooks/pi_mutation_scope/`, hidden `sce hooks pi-mutation-scope`) and is driven by the canonical generated Pi extension in ordinary Pi sessions; its human `user_bash` path uses the separate guarded external-mutation supervisor) - `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, an optional `provenance` object accepted on `start` only (a required non-blank `session_id`, an optional `model_id` where an absent key and an explicit `null` both mean no model, and no other key), and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` and `start`'s optional `StartProvenance` verbatim because `EventId` equality is the runtime replay/idempotency key and provenance values arrive already canonical; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — a Claude Code adapter driver (`cli/src/services/hooks/claude_mutation_scope/`) and a Codex adapter driver (`cli/src/services/hooks/codex_mutation_scope/`, hidden `sce hooks codex-mutation-scope`) now exist and are registered by `sce setup`, both consuming this seam's own `pub(crate)` in-process entrypoint; an OpenCode adapter driver (`cli/src/services/hooks/opencode_mutation_scope/`, hidden `sce hooks opencode-mutation-scope`) also consumes it with a full `Start`/`Close`/`Abandon` lifecycle and checkout-local recovery state, and is now driven in production by the generated `sce-mutation-scope.ts` plugin installed last by `sce setup`; a Pi adapter driver (`cli/src/services/hooks/pi_mutation_scope/`, hidden `sce hooks pi-mutation-scope`) also consumes it now, with `(session-id, tool-call-id)`→`ScopeId` derivation and getppid()/`/proc`-start-time-based stale-process recovery; the canonical generated Pi extension drives it in ordinary sessions — see `context/cli/pi-mutation-scope-integration.md`) -- `context/cli/claude-mutation-scope-integration.md` (the first concrete harness lifecycle adapter, `cli/src/services/hooks/claude_mutation_scope/`, hidden command `sce hooks claude-mutation-scope`, built by the `claude-mutation-scope-integration` plan: one independently mutation-capable Claude tool execution = one SCE mutation `ScopeId` (a session/prompt/main agent/subagent is never a scope); the `classify_tool` table (mutation-capable including unknown names, read-only `Read`/`Glob`/`Grep`/`WebFetch`/`WebSearch`/`AskUserQuestion`, `Agent` = delegation) plus the model-only `is_explicit_background_shell` predicate; the length-prefixed hash-free `cc-tool-v1|n=|s=..|a=..|t=..` `ScopeId` keyed on a monotonic checkout-local `attempt_seq` (never reused after terminal) with deterministic `|start` / `|close` `EventId`s; the `/sce/claude-mutation-scope-state.json` bookkeeping store (never attribution evidence, never synced) with its own separate lock never held across a seam call; `PreToolUse` write-ahead `pending_start` → seam `start` → `active` and its fail-closed Claude `permissionDecision: "deny"` on any failure (never `allow`, detail logged via `sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`); `PostToolUse`/`PostToolUseFailure` → `close`, with `pending_start`+terminal → abandon-not-late-start (D11) and failed-`close` → abandon-not-replay (D12); the abandonment cleanup signals (`PermissionDenied`, `Stop`/`StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, best-effort `WorktreeRemove` — the last two not observed to fire on Claude Code `2.1.258`); the `recovery_pending` barrier that denies new mutation-capable `PreToolUse` until quiescent then runs one seam `flush`; raw hook `cwd` (or `worktree_path` for `WorktreeRemove`) as authoritative repository root with no adapter-constructed `WorktreeId`; the `run_in_background = true` denial and the separate self-detaching-descendant unsupported boundary (D20, with T04's Git-observable evidence); the ten unmatched `sce setup` registrations; the strict `claude_mutation_scope → hooks::mutation_scope → mutation_trace::runtime` dependency direction through the single `run_mutation_scope_from_payload` seam import (T05); admission-time exact model-state snapshot into `ScopeProvenance`; and real Git/Agent Trace persistence coverage shared with the Codex path) +- `context/cli/claude-mutation-scope-integration.md` (the first concrete harness lifecycle adapter, `cli/src/services/hooks/claude_mutation_scope/`, hidden command `sce hooks claude-mutation-scope`, built by the `claude-mutation-scope-integration` plan: one independently mutation-capable Claude tool execution = one SCE mutation `ScopeId` (a session/prompt/main agent/subagent is never a scope); the `classify_tool` table (mutation-capable including unknown names, read-only `Read`/`Glob`/`Grep`/`WebFetch`/`WebSearch`/`AskUserQuestion`, `Agent` = delegation) plus the model-only `is_explicit_background_shell` predicate; the length-prefixed hash-free `cc-tool-v1|n=|s=..|a=..|t=..` `ScopeId` keyed on a monotonic checkout-local `attempt_seq` (never reused after terminal) with deterministic `|start` / `|close` `EventId`s; the `/sce/claude-mutation-scope-state.json` bookkeeping store (never attribution evidence, never synced) with its own separate lock never held across a seam call; `PreToolUse` write-ahead `pending_start` → seam `start` → `active` and its fail-closed Claude `permissionDecision: "deny"` on any failure (never `allow`, detail logged via `sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`); `PostToolUse`/`PostToolUseFailure` → `close`, with `pending_start`+terminal → abandon-not-late-start (D11) and failed-`close` → abandon-not-replay (D12); the abandonment cleanup signals (`PermissionDenied`, `Stop`/`StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, best-effort `WorktreeRemove` — the last two not observed to fire on Claude Code `2.1.258`); the `recovery_pending` barrier that denies new mutation-capable `PreToolUse` until quiescent then runs one seam `flush`; raw hook `cwd` (or `worktree_path` for `WorktreeRemove`) as authoritative repository root with no adapter-constructed `WorktreeId`; the `run_in_background = true` denial and the separate self-detaching-descendant unsupported boundary (D20, with T04's Git-observable evidence — full detail split out to [`claude-mutation-scope-background-execution.md`](cli/claude-mutation-scope-background-execution.md) for the repository's per-file line budget); the ten unmatched `sce setup` registrations; the strict `claude_mutation_scope → hooks::mutation_scope → mutation_trace::runtime` dependency direction through the single `run_mutation_scope_from_payload` seam import (T05); admission-time exact model-state snapshot into `ScopeProvenance`; real Git/Agent Trace persistence coverage shared with the Codex path; and, added by the `doctor-mutation-scope-fix` plan's T04, a third `pending_abandon` attempt phase durably persisted before any abandon seam call — established evidence that abandonment has already been decided, distinct from "may still be running" — with every attempt a broad cleanup signal (`Stop`/`UserPromptSubmit`/`SubagentStop`/`SessionEnd`/`WorktreeRemove`) matches now marked `pending_abandon` in one write before any of their seam calls run, so one attempt's seam failure can never leave a batch sibling without its own retryable evidence; health classification detail split out to [`claude-mutation-scope-health.md`](cli/claude-mutation-scope-health.md)) +- `context/cli/claude-mutation-scope-background-execution.md` (the `run_in_background = true` `PreToolUse` denial and the separate self-detaching-descendant unsupported boundary D20 — Git-observable evidence that a foreground `setsid` command's detached descendant can still mutate the repository after `PostToolUse` returns — detail split out of `claude-mutation-scope-integration.md` for the repository's per-file line budget) +- `context/cli/claude-mutation-scope-health.md` (the `doctor-mutation-scope-health` plan's Claude task, extended by the `doctor-mutation-scope-fix` plan's T04: `classify_health` in `cli/src/services/hooks/claude_mutation_scope/health.rs` maps `recovery_pending`/`attempts` onto `healthy | recovering | blocked | invalid` — `recovery_pending == false` is `healthy` regardless of live attempts; `recovery_pending == true` with `attempts` empty is `recovering` (the barrier's own next-`PreToolUse` flush clears it); `recovery_pending == true` with `attempts` non-empty is `blocked`, the exact incident shape this classifier exists to surface, since nothing in the ordinary hook lifecycle retries, removes, flushes, or clears from that shape; a read/parse failure is `invalid`. T04 adds a separate, `doctor`-invoked repair path (`assess_repairability`/`repair_blocked` in `health.rs`/`lifecycle.rs`, not yet wired into `sce doctor --fix`) for the `blocked` shape: `AutoFixable` only when every currently persisted attempt is already `pending_abandon` (no established-abandon-intent `pending_start`/`active` attempt present); `repair_blocked` re-reads and re-proves that same condition inside one lock-protected read-only state transaction before releasing the lock and retrying each attempt's already-established seam `abandon` call independently, clearing the `recovery_pending` barrier only once no attempts remain — this classifier's own read-only behavior is unaffected by that repair path's existence) - `context/cli/mutation-trace-ref-reconciliation.md` (the conservative per-worktree snapshot-ref reconciliation pass in `cli/src/services/mutation_trace/runtime/ref_reconciliation.rs`, built by the `mutation-cursor-ref-reconciliation` plan: `reconcile_worktree(repository_root, open_db)` → `pub(super) reconcile_worktree_inner(.., on_lock_contention)`, both returning `ReconciliationOutcome` (`Reconciled(ReconciliationReport { local_required, retained, deleted })` for a pass that ran | `SkippedNoCheckoutIdentity` — an `Ok`, not an `Err` — when no current checkout identity could be derived), a variant-per-fallible-step `ReconcileError` with no `Other`, and the module-owned `RECONCILIATION_LOCK_TIMEOUT`; the two-invariant model — a strictly per-worktree fail-closed local-consistency check via `load_tree_roots(W)` vs. a repository-wide deletion-safety set via `load_all_tree_roots()` so an `A`-owned ref is retained whenever any worktree still durably needs its tree — run entirely under the same `/sce/mutation-cursor.lock` `WorktreeLock` `coordinate()` holds, with the repository-wide read kept coherent by being one SQL statement / one DB snapshot rather than a repository-global lock; deletes only SCE-owned refs via one atomic `git update-ref --no-deref --stdin`, writes no `mutation_trace_*` row, never arms `ExternalTaintMarker`, runs no `git gc`; imperative durability maintenance below the verified Quint protocol; reclaims orphan/unreferenced refs only for the namespace of a checkout id a current worktree still derives — a namespace no current worktree owns (identity-based: a deleted linked worktree, or checkout-id metadata loss followed by `get_or_create_checkout_id` minting a fresh id on a still-present worktree) is beyond every per-worktree pass and left to a recorded future repository-scoped unowned-namespace operation, so the current pass does not bound all orphan-ref growth; `reconcile_worktree` has no `pub(crate)` re-export and no harness/command wiring yet) - `context/cli/mutation-trace-snapshot-service.md` (the isolated Git snapshot and ref-pinning service `runtime::git_snapshot::GitSnapshotService` in `cli/src/services/mutation_trace/runtime/git_snapshot.rs`: `new` resolving an absolute `git_dir`, `capture_tree` snapshotting staged/unstaged/untracked/deleted worktree state into the repository's normal object database via a throwaway temp index, `pin_tree` protecting a durable tree with a create-only idempotent **direct** `refs/sce/mutation-cursor//` ref, `diff_trees` emitting `patch.rs`-parseable raw diff text; plus the callerless reconciliation substrate — worktree-scoped `list_pins` inventory returning `Result, PinInventoryError>` that rejects a symbolic ref inside the namespace (mutation-cursor pins are direct refs) as `MalformedRef`, matchable separately from a `git for-each-ref` execution failure, and conditional-atomic `delete_pins` running one `git update-ref --no-deref --stdin` transaction of SHA-conditioned deletes — no-dereference so an inventory→delete direct-ref→symref race cannot escape the inventoried namespace, plus a fail-closed pre-check — that aborts whole if any ref changed since inventory; `REF_NAMESPACE` + `pin_ref_prefix` as the single source of truth for the pin path) - `context/cli/mutation-trace-external-taint.md` (the worktree-local mutation-cursor durability boundary in `cli/src/services/mutation_trace/runtime/external_taint.rs`, built by the `mutation-cursor-external-taint` plan: the `ExternalTaintMarker` primitive — `new(git_dir)`/`exists()`/`persist()`/`clear()` over an empty file at `/sce/mutation-cursor-tainted` whose existence is its entire state, `checkout::persist_checkout_id_inner`-style durability (`sync_data` plus best-effort `#[cfg(unix)]` parent-dir `sync_all`), idempotent persist/clear, `NotFound`-on-clear as success, no `Drop` deletion — as the concrete runtime refinement of the abstract `ProtocolState.external_taint`; armed by the reshaped `coordinate()` entrypoint write-ahead after the `WorktreeLock` and before Agent Trace DB acquisition (a caller-supplied DB provider closure), cleared only on a successful `CoordinateOutcome`, with dedicated fail-closed pre-commit `CoordinateError::ExternalTaintMarker` (`Inspect`/`Persist` only)/`AgentTraceDbUnavailable` variants plus a post-commit `MarkerClearAfterCommit { source, committed }` that carries the durable outcome so a failed trailing clear never hides a committed `MutationEvent`; an inherited marker seeds an invocation-local `external_taint_pending` flag that overlays `protocol::database_failure` onto each freshly loaded projection so `recover` runs once against the captured snapshot, held across a losing recovery CAS and cleared once it lands) diff --git a/context/plans/doctor-mutation-scope-fix.md b/context/plans/doctor-mutation-scope-fix.md index 4e48314f..f3cf6300 100644 --- a/context/plans/doctor-mutation-scope-fix.md +++ b/context/plans/doctor-mutation-scope-fix.md @@ -901,7 +901,7 @@ Persist this field in every plan; this is durable plan state, not chat state: not-yet-wired addition. - Context synchronization: synced -- [ ] T04: `Claude: persisted terminal-cleanup evidence and safe PendingAbandon repair` (status:todo) +- [x] T04: `Claude: persisted terminal-cleanup evidence and safe PendingAbandon repair` (status:done) - Task ID: T04 - Scope: In — `cli/src/services/hooks/claude_mutation_scope/{state,mod,health}.rs`. Add a `PendingAbandon` `AttemptPhase` variant alongside the existing @@ -952,7 +952,200 @@ Persist this field in every plan; this is durable plan state, not chat state: `Blocked`, stays `ManualOnly` until a new abandonment establishes real `PendingAbandon` evidence. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` - - Context synchronization: pending + - Completed: 2026-09-21 + - Files changed: `cli/src/services/hooks/claude_mutation_scope/state.rs` (adds + `AttemptPhase::PendingAbandon`; adds a pure `transition_to_pending_abandon` + helper plus a durable `mark_recovery_pending_and_pending_abandon(git_dir, + scope_ids)` state-lock transaction that, in one read-modify-write, sets + `recovery_pending = true` and transitions every named scope_id to + `PendingAbandon` — replacing the original two-write + `mark_recovery_pending()` + `mark_pending_abandon()` sequence so there is + no crash boundary between the barrier and the abandonment evidence; makes + `mark_active` re-read the persisted phase under the state lock and match + on it: `PendingStart -> Active` (allowed), `Active -> Active` (safe + idempotent no-op), `PendingAbandon -> Active` (rejected with an error and + no state mutation), so an established abandon intent can never be + resurrected by an in-flight start; adds `reprove_pending_abandon(git_dir)`, + a lock-protected read-only re-proof returning the full current attempt + list only when `recovery_pending` is true, attempts are non-empty, and + every attempt is already `PendingAbandon`; adds the `#[cfg(test)]` + `set_attempt_phase_for_tests` helper); + `cli/src/services/hooks/claude_mutation_scope/lifecycle.rs` (factors + `abandon_attempt`'s seam-call-plus-removal tail into a shared + `abandon_marked_attempt` helper; changes both `abandon_attempt` and + `cleanup_attempts_matching` to call the single atomic + `mark_recovery_pending_and_pending_abandon` instead of the old two + separate `mark_recovery_pending()` + `mark_pending_abandon()` calls, so + the batch case still marks every selected attempt in one write before any + seam call, and the state lock is never held across a seam call; rewrites + `cleanup_attempts_matching` to process each stale attempt independently + after that one write — continuing through the whole batch and returning + the first error only after every attempt was attempted, rather than the + previous early-return-on-first-failure loop that left later-matched + attempts completely untouched; adds `RepairOutcome` and `repair_blocked`); + `cli/src/services/hooks/claude_mutation_scope/health.rs` + (adds the local `Repairability { AutoFixable, ManualOnly }` enum and + `assess_repairability`; extends the existing AC3 regression to assert the + durable `PendingAbandon` evidence and a successful `repair_blocked` run; + adds regression tests covering the not-blocked case, a legacy + attempt never marked `PendingAbandon`, a mixed-phase coexistence case + (both `assess_repairability` and `repair_blocked` proven to leave it + fully untouched), the concurrent-race re-proof refusal, the + interrupted-repair resume, the batch-marking fix for + `cleanup_attempts_matching`, the start-vs-cleanup activation race against + an established `PendingAbandon` (`mark_active` losing the race leaves + `AutoFixable` evidence that `repair_blocked` then resolves to `Healthy`), + and a partial-batch `repair_blocked` run where one of two attempts fails + to abandon (the successful one is removed, the failed one stays + `PendingAbandon`, `recovery_pending` stays armed, outcome is `NoOp`)); + `cli/src/services/hooks/claude_mutation_scope/mod.rs` + (re-exports `assess_repairability`, `Repairability`, `repair_blocked`, + `RepairOutcome`, and `cleanup_attempts_matching`, each + `#[allow(unused_imports)]` pending T05's doctor wiring and test-module + access, matching the `pi_mutation_scope`/`opencode_mutation_scope::mod.rs` + precedent). + - Result: Claude's `AttemptPhase` gains a `PendingAbandon` variant recording + that abandonment has already been decided for an attempt, durably + persisted before any seam `abandon` call — replacing the prior ambiguity + where a failed abandon left an attempt's phase untouched (`PendingStart` + or `Active`) with no evidence distinguishing "abandonment decided, + awaiting retry" from "may still be running." Investigation of the actual + barrier logic (`apply_recovery_barrier` reads `state.recovery_pending` + directly) confirmed the whole-file flag is still structurally required, + so it is kept alongside the new per-attempt phase rather than replaced, + per the plan's own "decide from the actual barrier logic" instruction. + The barrier flag and the per-attempt evidence are established as one + durable state transition, not two: `recovery_pending = true` and every + named attempt's transition to `PendingAbandon` are read, updated, and + written inside a single `AdapterStateLock` acquisition + (`mark_recovery_pending_and_pending_abandon`), so there is no crash + boundary at which the barrier could be armed with the corresponding + `PendingAbandon` evidence not yet persisted (or vice versa). An initial + version of this task left `mark_recovery_pending()` and + `mark_pending_abandon()` as two separate durable writes; that was + corrected because a crash between them could leave `recovery_pending = + true` with an attempt still `PendingStart`/`Active` — the exact ambiguity + `assess_repairability` must treat as `ManualOnly` even though cleanup had + already durably decided to abandon. Separately, `mark_active` is now + monotonic with respect to `PendingAbandon`: it re-reads the persisted + phase under the state lock and only allows `PendingStart -> Active` + (`Active -> Active` is a safe idempotent no-op); `PendingAbandon -> + Active` is rejected with an error and no state mutation, so a start seam + that was already in flight when cleanup established abandonment can never + resurrect the attempt to `Active` after the fact. `establish_start` is + unchanged beyond this: it still calls the seam and then `mark_active` + with no new Claude boundary lock, so losing this race fails PreToolUse + closed (per existing fail-closed behavior) while the durable + `PendingAbandon`/`recovery_pending` evidence set by cleanup is left + intact and retryable. + `cleanup_attempts_matching`'s early-return-on-first-failure loop was + confirmed reachable (`SessionEnd` matches every attempt in a session + regardless of `agent_id`, and `WorktreeRemove` matches every tracked + attempt unconditionally, so either can legitimately match more than one + attempt at once) and corrected: every matched attempt is now durably + marked `PendingAbandon` in one write before any seam call is attempted, + so a failure abandoning one attempt can never leave a sibling attempt in + the batch without its own retryable evidence. + `assess_repairability` classifies `AutoFixable` only when every attempt + currently in the adapter's state is `PendingAbandon`; any attempt still + `PendingStart`/`Active` (no established abandon intent) forces + `ManualOnly` for the whole adapter, proven by a test that also asserts + `repair_blocked` leaves both attempts in such a mixed state completely + untouched. `repair_blocked` re-reads and re-proves this same "every + attempt is `PendingAbandon`" condition inside one lock-protected, + read-only state transaction (`reprove_pending_abandon`) — Claude's + `PendingAbandon` transition already happened durably before repair ever + runs, so unlike OpenCode's dead-owner proof there is no transition to + perform at this step, only a fresh re-proof — then releases the lock + before retrying each attempt's already-established seam `abandon` call + independently, removing only the ones that succeed, and clears the + `recovery_pending` barrier in a final state transaction only once no + attempts remain. A losing re-proof (an attempt no longer `PendingAbandon` + by the time the lock is acquired) is a safe no-op with no seam call, + proven by a test that forces exactly that race via the new + `set_attempt_phase_for_tests` helper. Investigation finding for the + plan's conditional owner-evidence question: no currently-reachable Claude + shape needs dead-owner liveness proof. Because `assess_repairability` + requires every attempt in the adapter's state to be `PendingAbandon` + (not just the ones a particular cleanup decided to abandon), any + coexisting `PendingStart`/`Active` attempt with no established abandon + intent is already, structurally, excluded from `AutoFixable` — proven + directly by `assess_repairability_is_manual_only_when_one_of_several_attempts_has_no_established_abandon_intent`. + T02's shared `mutation_scope_owner` primitive is therefore not consumed + by this task; no unused machinery was added for it. + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> `131 passed; 0 failed` (117 baseline + 8 regressions from the initial pass + 6 regressions from the atomic-persistence and monotonic-`mark_active` correction: `mark_recovery_pending_and_pending_abandon_establishes_both_facts_in_one_durable_write`, `mark_recovery_pending_and_pending_abandon_failed_write_leaves_no_partial_invariant`, `mark_active_is_idempotent_when_already_active`, `mark_active_is_forbidden_once_pending_abandon_is_established`, `mark_active_losing_the_race_against_an_established_pending_abandon_leaves_repairable_terminal_evidence`, and `repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails`); `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope` -> only the pre-existing, unrelated `AdapterStateLock` lock-timeout deadline (unchanged by this task), matching AC3's "no staleness use outside unrelated lock-timeout constants." + - Context impact: Claude's persisted state file gains a new reachable + `AttemptPhase` variant (`pending_abandon`) and three new `pub(crate)` + symbols (`Repairability`, `assess_repairability`, `repair_blocked`/ + `RepairOutcome`) not yet consumed by doctor — T05 wires them into + `execute_doctor_with_lifecycle_providers`, matching T03's OpenCode + precedent exactly. No user-visible behavior changed yet (`sce doctor + --fix` still cannot repair Claude until T05 dispatches to these + functions), no existing `MutationScope*` type or JSON shape changed, and + `classify_health`'s existing four status boundaries and their triggering + conditions are unchanged (confirmed by every pre-existing health test + passing unmodified). `domain`-scoped: `context/cli/claude-mutation-scope-integration.md` + (named in this plan's own "Context sync" section) describes the new + `PendingAbandon` phase and its safety semantics once T05/T06 make the + repair path reachable through `sce doctor --fix`; recording that + dependency here for T05's own context-sync pass rather than updating + that doc prematurely for a repair path doctor cannot yet invoke, mirroring + T03's identical deferral for OpenCode's context docs. No root context file + (`overview.md`/`architecture.md`/`glossary.md`/`patterns.md`/ + `context-map.md`) is implicated by an adapter-internal, not-yet-wired + addition. + - Deviation: `lifecycle.rs` was touched even though this task's own scope + line names only `{state,mod,health}.rs`, because `abandon_attempt` and + `cleanup_attempts_matching` — both explicitly named as needing behavior + changes in this task's own scope text — live in `lifecycle.rs`, not + `health.rs` or `state.rs`; T03's OpenCode task explicitly listed + `lifecycle.rs` for the equivalent change, so this mirrors that precedent + rather than expanding scope. `Repairability` and `RepairOutcome` are + defined locally in this module (`health.rs` and `lifecycle.rs` + respectively) rather than in the shared `mutation_scope_health.rs`, + matching T03's identical deviation and reasoning: T05 is the task scoped + to add the shared `Repairability` enum there. + - Correction (2026-09-21): the initial T04 pass left two crash/concurrency + gaps, both closed narrowly without touching T05 scope, OpenCode, Pi, + Codex, doctor orchestration, rendering, or the Quint model. First, + `recovery_pending = true` and the initial `PendingAbandon` transition + were two separate durable transactions (`mark_recovery_pending()` then + `mark_pending_abandon()`); a crash between them could leave the barrier + armed with an attempt still `PendingStart`/`Active`, which + `assess_repairability` correctly treats as `ManualOnly` even though + cleanup had already durably decided to abandon. Fixed by replacing both + call sites (`abandon_attempt`, `cleanup_attempts_matching`) with one + state-layer operation, `mark_recovery_pending_and_pending_abandon`, that + performs the read, both field updates, and the durable write inside a + single `AdapterStateLock` acquisition — the batch case still marks every + selected attempt in that one write before any seam call, and the lock is + still never held across a seam call. Second, `mark_active` unconditionally + set `attempt.phase = Active`, so an in-flight `PreToolUse` start whose + seam call had already succeeded could overwrite an already-established + `PendingAbandon` back to `Active` if cleanup won the race first. Fixed by + making `mark_active` re-read the persisted phase under the state lock and + branch on it: `PendingStart -> Active` (allowed), `Active -> Active` + (safe idempotent no-op), `PendingAbandon -> Active` (rejected with an + error and no state mutation). `establish_start` needed no change — the + existing `seam(start)?; mark_active(...)?;` sequence now fails closed on + the error instead of resurrecting the attempt, per existing PreToolUse + fail-closed behavior, and no new Claude boundary lock was added. New + regressions: two state-level tests proving the atomic operation + establishes both facts in one write and that an injected pre-rename write + failure leaves neither fact persisted; two state-level tests proving + `mark_active`'s three-way branch; a lifecycle-level race regression + (`mark_active_losing_the_race_against_an_established_pending_abandon_leaves_repairable_terminal_evidence`) + proving that losing the activation race leaves `recovery_pending == true` + and the attempt `PendingAbandon`, that `classify_health` is `Blocked` and + `assess_repairability` is `AutoFixable` (not `ManualOnly`), and that + `repair_blocked` then resolves the whole state to `Healthy`; and a + partial-batch `repair_blocked` regression proving a successful abandon in + a batch is removed while a failed sibling stays `PendingAbandon` with + `recovery_pending` still armed. All prior wording in this entry + describing `mark_recovery_pending()` followed by `mark_pending_abandon()` + as the final implementation has been corrected above; that two-write + sequence is no longer present in the codebase. + - Context synchronization: synced - [ ] T05: `Shared repairability contract and doctor --fix orchestration` (status:todo) - Task ID: T05 From 8ab2c55afc3445b677cb79fd438e54efc4e76cd2 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 17:16:09 +0200 Subject: [PATCH 11/16] runtime: Resolve worktree root before capturing Git snapshots Resolve the containing worktree before locating Git metadata so snapshots created from nested directories apply repository-level gitignore rules correctly. Add regression coverage to ensure ignored files under a nested directory stay out of captured trees. Co-authored-by: SCE --- .../mutation_trace/runtime/git_snapshot.rs | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/cli/src/services/mutation_trace/runtime/git_snapshot.rs b/cli/src/services/mutation_trace/runtime/git_snapshot.rs index 2e161420..0a0379bc 100644 --- a/cli/src/services/mutation_trace/runtime/git_snapshot.rs +++ b/cli/src/services/mutation_trace/runtime/git_snapshot.rs @@ -26,10 +26,11 @@ pub struct GitSnapshotService { impl GitSnapshotService { pub fn new(repository_root: &Path) -> Result { - let git_dir = resolve_git_dir(repository_root)?; + let repository_root = resolve_worktree_root(repository_root)?; + let git_dir = resolve_git_dir(&repository_root)?; Ok(GitSnapshotService { git_dir, - repository_root: repository_root.to_path_buf(), + repository_root, }) } @@ -713,6 +714,37 @@ mod tests { assert!(!ls_tree.contains("ignored.txt")); } + #[test] + fn capture_from_nested_directory_uses_worktree_root_for_gitignore_rules() { + let repo = test_repo("nested-directory-ignore"); + let repo_root = repo.root().to_path_buf(); + let nested_root = repo_root.join("cli"); + init_repo(&repo_root); + std::fs::write(repo_root.join(".gitignore"), b"cli/target/\n") + .expect(".gitignore should be writable"); + std::fs::write(repo_root.join("README.md"), b"tracked\n") + .expect("README should be writable"); + commit_all(&repo_root, "add ignore rule"); + + std::fs::create_dir_all(nested_root.join("target")) + .expect("nested target directory should be creatable"); + std::fs::write(nested_root.join("target/artifact"), b"ignored\n") + .expect("ignored artifact should be writable"); + + let service = GitSnapshotService::new(&nested_root) + .expect("service should resolve the containing worktree root"); + let tree = service + .capture_tree() + .expect("capture should succeed from a nested directory"); + + let ls_tree = run(&repo_root, &["ls-tree", "-r", "--name-only", &tree.0]); + assert!(ls_tree.contains("README.md")); + assert!( + !ls_tree.lines().any(|path| path == "cli/target/artifact"), + "ignored files below cli/target must not enter a snapshot: {ls_tree}" + ); + } + #[test] fn capture_reflects_deletion_of_a_committed_file() { let repo = test_repo("deletion"); From df003b03ab6ebc0634ec0c3e48a719a52791ae26 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 18:18:58 +0200 Subject: [PATCH 12/16] doctor: Add mutation-scope repair orchestration Enable `sce doctor --fix` to repair blocked Claude and OpenCode Agent-tracing state only when adapter-owned repairability checks prove it safe. Dispatch repairs through the production mutation-scope seam and record success only after fresh health confirms healthy or recovering, while leaving manual-only states untouched. Plan: doctor-mutation-scope-fix.md (T05) Co-authored-by: SCE --- cli/src/services/doctor/inspect.rs | 395 +++++++++++++++++- cli/src/services/doctor/mod.rs | 10 +- .../hooks/claude_mutation_scope/health.rs | 100 +++++ .../services/hooks/mutation_scope_health.rs | 13 + context/context-map.md | 4 +- context/plans/doctor-mutation-scope-fix.md | 207 ++++++++- context/sce/agent-trace-hook-doctor.md | 1 + context/sce/mutation-scope-health-status.md | 82 +++- 8 files changed, 795 insertions(+), 17 deletions(-) diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index e4818db1..ba4d081d 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -14,12 +14,14 @@ use crate::services::default_paths::{ repo_dir, InstallTargetPaths, RepoPaths, }; use crate::services::hooks::mutation_scope_health::{ - MutationScopeAdapterHealth, MutationScopeHealthStatus, + MutationScopeAdapterHealth, MutationScopeHealthStatus, Repairability, }; use crate::services::hooks::{ - claude_mutation_scope, codex_mutation_scope, opencode_mutation_scope, pi_mutation_scope, + claude_mutation_scope, codex_mutation_scope, mutation_scope, opencode_mutation_scope, + pi_mutation_scope, }; use crate::services::mutation_trace::runtime::resolve_git_dir; +use crate::services::observability::traits::Logger; use crate::services::repository_identity::resolve::{ resolve_repository_identity, RepositoryIdentitySource, }; @@ -226,6 +228,119 @@ fn inspect_mutation_scope_health( .collect() } +pub(super) fn repair_blocked_mutation_scope_targets( + initial_report: &HookDoctorReport, +) -> Vec { + repair_blocked_mutation_scope_targets_with_seam(initial_report, &mutation_scope_repair_seam) +} + +type MutationScopeRepairSeam<'a> = + &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> anyhow::Result; + +fn repair_blocked_mutation_scope_targets_with_seam( + initial_report: &HookDoctorReport, + seam: MutationScopeRepairSeam<'_>, +) -> Vec { + let Some(repository_root) = initial_report.repository_root.as_deref() else { + return Vec::new(); + }; + let Ok(git_dir) = resolve_git_dir(repository_root) else { + return Vec::new(); + }; + + initial_report + .mutation_scope_health + .iter() + .filter(|row| row.status == MutationScopeHealthStatus::Blocked) + .filter_map(|row| { + repair_blocked_mutation_scope_target(row.target, &git_dir, repository_root, seam) + }) + .collect() +} + +fn mutation_scope_repair_seam( + repository_root: &Path, + payload: &str, + logger: Option<&dyn Logger>, +) -> anyhow::Result { + mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) +} + +fn repair_blocked_mutation_scope_target( + target: IntegrationTarget, + git_dir: &Path, + repository_root: &Path, + seam: MutationScopeRepairSeam<'_>, +) -> Option { + match target { + IntegrationTarget::ClaudeCode => { + if claude_repairability(git_dir) != Repairability::AutoFixable { + return None; + } + let _ = claude_mutation_scope::repair_blocked(git_dir, repository_root, None, seam); + Some(target) + } + IntegrationTarget::OpenCode => { + if opencode_repairability(git_dir) != Repairability::AutoFixable { + return None; + } + let _ = opencode_mutation_scope::repair_blocked(git_dir, repository_root, None, seam); + Some(target) + } + IntegrationTarget::Pi | IntegrationTarget::Codex => None, + } +} + +fn claude_repairability(git_dir: &Path) -> Repairability { + match claude_mutation_scope::assess_repairability(git_dir) { + claude_mutation_scope::Repairability::AutoFixable => Repairability::AutoFixable, + claude_mutation_scope::Repairability::ManualOnly => Repairability::ManualOnly, + } +} + +fn opencode_repairability(git_dir: &Path) -> Repairability { + match opencode_mutation_scope::assess_repairability(git_dir) { + opencode_mutation_scope::Repairability::AutoFixable => Repairability::AutoFixable, + opencode_mutation_scope::Repairability::ManualOnly => Repairability::ManualOnly, + } +} + +pub(super) fn finalize_mutation_scope_repair_results( + repaired_targets: &[IntegrationTarget], + final_mutation_scope_health: &[MutationScopeHealthRow], +) -> Vec { + repaired_targets + .iter() + .filter_map(|target| { + let row = final_mutation_scope_health + .iter() + .find(|row| row.target == *target)?; + fixed_record_from_recomputed_health(*target, row) + }) + .collect() +} + +fn fixed_record_from_recomputed_health( + target: IntegrationTarget, + row: &MutationScopeHealthRow, +) -> Option { + match row.status { + MutationScopeHealthStatus::Healthy | MutationScopeHealthStatus::Recovering => { + Some(DoctorFixResultRecord { + category: ProblemCategory::MutationScopeHealth, + outcome: FixResult::Fixed, + detail: format!( + "Recovered {} Agent tracing (now {}: {}).", + integration_target_label(target), + mutation_scope_health_status(row.status), + row.reason + ), + }) + } + MutationScopeHealthStatus::Blocked | MutationScopeHealthStatus::Invalid => None, + } +} + fn mutation_scope_state_path(target: IntegrationTarget, git_dir: &Path) -> PathBuf { match target { IntegrationTarget::ClaudeCode => claude_mutation_scope::state::state_path(git_dir), @@ -4081,6 +4196,30 @@ mod tests { } } + fn claude_autofixable_blocked_state() -> AdapterState { + let key = crate::services::hooks::claude_mutation_scope::AttemptKey { + session_id: "session-a".to_string(), + agent_id: None, + tool_use_id: "tool-use-a".to_string(), + }; + let scope_id = + crate::services::hooks::claude_mutation_scope::format_claude_scope_id(1, &key); + AdapterState { + version: 1, + next_attempt_seq: 2, + recovery_pending: true, + attempts: vec![AdapterAttempt { + attempt_seq: 1, + scope_id, + session_id: key.session_id, + agent_id: key.agent_id, + tool_use_id: key.tool_use_id, + tool_name: "Edit".to_string(), + phase: AttemptPhase::PendingAbandon, + }], + } + } + fn claude_recovering_state() -> AdapterState { AdapterState { version: 1, @@ -4602,4 +4741,256 @@ mod tests { std::fs::remove_dir_all(&repo).ok(); }); } + + fn no_op_repair_seam() -> super::MutationScopeRepairSeam<'static> { + &|_root, _payload, _logger| Ok(String::new()) + } + + #[test] + fn repair_blocked_mutation_scope_target_repairs_an_autofixable_claude_state() { + let repo = init_git_repo_with_claude_target("repair-target-claude-auto"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + + let repaired = super::repair_blocked_mutation_scope_target( + IntegrationTarget::ClaudeCode, + &git_dir, + &repo, + no_op_repair_seam(), + ); + assert_eq!( + repaired, + Some(IntegrationTarget::ClaudeCode), + "an autofixable claude blocked target must have a repair attempted" + ); + + let health = claude_mutation_scope::health::classify_health(&git_dir); + assert_ne!( + health.status, + MutationScopeHealthStatus::Blocked, + "a reported fix must never leave the target still blocked" + ); + assert_ne!(health.status, MutationScopeHealthStatus::Invalid); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + let records = super::finalize_mutation_scope_repair_results( + &[IntegrationTarget::ClaudeCode], + &final_rows, + ); + assert_eq!(records.len(), 1, "{records:?}"); + assert_eq!( + records[0].category, + super::ProblemCategory::MutationScopeHealth + ); + assert_eq!(records[0].outcome, super::FixResult::Fixed); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn repair_blocked_mutation_scope_target_never_touches_a_manual_only_claude_state() { + let repo = init_git_repo_with_claude_target("repair-target-claude-manual"); + write_claude_mutation_scope_state(&repo, &claude_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + + let record = super::repair_blocked_mutation_scope_target( + IntegrationTarget::ClaudeCode, + &git_dir, + &repo, + no_op_repair_seam(), + ); + + assert!( + record.is_none(), + "a ManualOnly blocked state must never produce a fix result: {record:?}" + ); + + let path = claude_mutation_scope::state::state_path(&git_dir); + let persisted: AdapterState = + serde_json::from_slice(&std::fs::read(&path).expect("read persisted state")) + .expect("parse persisted state"); + assert_eq!( + persisted, + claude_blocked_state(), + "a ManualOnly blocked state must never be mutated by the repair dispatch" + ); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts( + ) { + let repo = init_git_repo_with_claude_target("finalize-race-claude"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + + let repaired = super::repair_blocked_mutation_scope_target( + IntegrationTarget::ClaudeCode, + &git_dir, + &repo, + no_op_repair_seam(), + ); + assert_eq!(repaired, Some(IntegrationTarget::ClaudeCode)); + + let immediately_after_repair = claude_mutation_scope::health::classify_health(&git_dir); + assert_ne!( + immediately_after_repair.status, + MutationScopeHealthStatus::Blocked, + "the repair must have genuinely succeeded before the simulated concurrent mutation" + ); + + write_claude_mutation_scope_state(&repo, &claude_blocked_state()); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + let final_status = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present in the final report") + .status; + assert_eq!(final_status, MutationScopeHealthStatus::Blocked); + + let fix_results = super::finalize_mutation_scope_repair_results( + &[IntegrationTarget::ClaudeCode], + &final_rows, + ); + + assert!( + !fix_results.iter().any(|result| { + result.category == super::ProblemCategory::MutationScopeHealth + && result.outcome == super::FixResult::Fixed + }), + "a target that regressed to Blocked before the final report is built must never be \ + reported Fixed: {fix_results:?}" + ); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn full_report_fix_mode_leaves_a_manual_only_claude_blocked_state_untouched() { + with_isolated_global_state(|| { + let repo = init_git_repo_with_healthy_claude_target("full-report-fix-claude-manual"); + write_claude_mutation_scope_state(&repo, &claude_blocked_state()); + + let execution = run_full_doctor_report(&repo, super::DoctorMode::Fix); + + assert!( + execution.fix_results.iter().all(|result| { + result.category != super::ProblemCategory::MutationScopeHealth + || result.outcome != super::FixResult::Fixed + }), + "a ManualOnly blocked state must never be reported fixed: {:?}", + execution.fix_results + ); + + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + let path = claude_mutation_scope::state::state_path(&git_dir); + let persisted: AdapterState = + serde_json::from_slice(&std::fs::read(&path).expect("read persisted state")) + .expect("parse persisted state"); + assert_eq!( + persisted, + claude_blocked_state(), + "'sce doctor --fix' must leave a ManualOnly blocked state completely untouched" + ); + + std::fs::remove_dir_all(&repo).ok(); + }); + } + + fn init_git_repo_with_opencode_target(label: &str) -> PathBuf { + let repo = init_git_repo(label); + std::fs::create_dir_all(repo.join(".opencode")).expect("create .opencode directory"); + repo + } + + fn opencode_dead_owner() -> crate::services::hooks::mutation_scope_owner::ProcessOwner { + let mut child = std::process::Command::new("true") + .spawn() + .expect("spawning 'true' should succeed"); + let pid = i32::try_from(child.id()).expect("pid fits in i32"); + child.wait().expect("child should exit and be reaped"); + crate::services::hooks::mutation_scope_owner::ProcessOwner { + pid, + instance_token: None, + } + } + + #[test] + fn repair_blocked_mutation_scope_target_repairs_an_autofixable_opencode_state() { + let repo = init_git_repo_with_opencode_target("repair-target-opencode-auto"); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + + let attempt = + crate::services::hooks::opencode_mutation_scope::state::seed_attempt_for_tests( + &git_dir, + &crate::services::hooks::opencode_mutation_scope::AttemptKey { + session_id: "ses-main".to_string(), + call_id: "call-1".to_string(), + }, + "write", + crate::services::hooks::opencode_mutation_scope::state::AttemptPhase::PendingStart, + ); + crate::services::hooks::opencode_mutation_scope::state::set_attempt_owner_for_tests( + &git_dir, + &attempt.scope_id, + Some(opencode_dead_owner()), + ); + + let repaired = super::repair_blocked_mutation_scope_target( + IntegrationTarget::OpenCode, + &git_dir, + &repo, + no_op_repair_seam(), + ); + assert_eq!( + repaired, + Some(IntegrationTarget::OpenCode), + "an autofixable opencode blocked target must have a repair attempted" + ); + + let health = + crate::services::hooks::opencode_mutation_scope::health::classify_health(&git_dir); + assert_ne!(health.status, MutationScopeHealthStatus::Blocked); + assert_ne!(health.status, MutationScopeHealthStatus::Invalid); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + let records = super::finalize_mutation_scope_repair_results( + &[IntegrationTarget::OpenCode], + &final_rows, + ); + assert_eq!(records.len(), 1, "{records:?}"); + assert_eq!( + records[0].category, + super::ProblemCategory::MutationScopeHealth + ); + assert_eq!(records[0].outcome, super::FixResult::Fixed); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn full_report_fix_mode_has_no_mutation_scope_effect_when_nothing_is_blocked() { + with_isolated_global_state(|| { + let repo = init_git_repo_with_healthy_claude_target("full-report-fix-no-problems"); + + let execution = run_full_doctor_report(&repo, super::DoctorMode::Fix); + + assert!( + execution + .fix_results + .iter() + .all(|result| result.category != super::ProblemCategory::MutationScopeHealth), + "a repository with no mutation-scope problems must produce no mutation-scope \ + fix result: {:?}", + execution.fix_results + ); + + std::fs::remove_dir_all(&repo).ok(); + }); + } } diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index 768a9e35..58ee51ad 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -22,7 +22,10 @@ pub(crate) mod types; pub mod command; use fixes::build_manual_fix_results; -use inspect::{build_report_with_lifecycle_problems, repair_merge_target_configs}; +use inspect::{ + build_report_with_lifecycle_problems, finalize_mutation_scope_repair_results, + repair_blocked_mutation_scope_targets, repair_merge_target_configs, +}; use render::render_report; use types::{ DoctorFixResultRecord, DoctorProblem, FixResult, HookDoctorReport, ProblemCategory, @@ -148,6 +151,7 @@ fn execute_doctor_with_lifecycle_providers( repository_root, &policy_readiness, )); + let mutation_scope_repairs = repair_blocked_mutation_scope_targets(&initial_report); let final_problems = diagnose_lifecycle_providers(context, &providers); let final_doctor_problems = final_problems .into_iter() @@ -160,6 +164,10 @@ fn execute_doctor_with_lifecycle_providers( final_doctor_problems, &policy_readiness, ); + fix_results.extend(finalize_mutation_scope_repair_results( + &mutation_scope_repairs, + &final_report.mutation_scope_health, + )); fix_results.extend(build_manual_fix_results(&final_report)); DoctorExecution { diff --git a/cli/src/services/hooks/claude_mutation_scope/health.rs b/cli/src/services/hooks/claude_mutation_scope/health.rs index c2f8612e..35e1a97f 100644 --- a/cli/src/services/hooks/claude_mutation_scope/health.rs +++ b/cli/src/services/hooks/claude_mutation_scope/health.rs @@ -47,6 +47,19 @@ pub(crate) fn classify_health(git_dir: &Path) -> MutationScopeAdapterHealth { } }; + let has_pending_abandon = state + .attempts + .iter() + .any(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon); + + if !state.recovery_pending && has_pending_abandon { + return MutationScopeAdapterHealth::new( + ActorKind::ClaudeCode, + MutationScopeHealthStatus::Invalid, + "PendingAbandon means terminal cleanup has been durably established, therefore the recovery barrier cannot legitimately already be clear.", + ); + } + if !state.recovery_pending { return MutationScopeAdapterHealth::new( ActorKind::ClaudeCode, @@ -726,4 +739,91 @@ mod tests { remove_test_git_dir(&git_dir); } + + fn hand_constructed_attempt( + attempt_seq: u64, + tool_use_id: &str, + phase: state::AttemptPhase, + ) -> state::AdapterAttempt { + state::AdapterAttempt { + attempt_seq, + scope_id: format!("claude|s=session-1:a|c={attempt_seq}:{tool_use_id}"), + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: tool_use_id.to_string(), + tool_name: "Edit".to_string(), + phase, + } + } + + fn write_hand_constructed_state(git_dir: &Path, state: &state::AdapterState) { + let path = state::state_path(git_dir); + std::fs::create_dir_all(path.parent().expect("state path has a parent")) + .expect("state dir should be created"); + std::fs::write( + &path, + serde_json::to_vec(state).expect("serialize adapter state"), + ) + .expect("write hand-constructed adapter state"); + } + + #[test] + fn clear_recovery_with_pending_abandon_is_invalid() { + let git_dir = unique_test_git_dir("invalid-clear-pending-abandon"); + write_hand_constructed_state( + &git_dir, + &state::AdapterState { + version: 1, + next_attempt_seq: 2, + recovery_pending: false, + attempts: vec![hand_constructed_attempt( + 1, + "toolu_1", + state::AttemptPhase::PendingAbandon, + )], + }, + ); + + let health = classify_health(&git_dir); + assert_eq!( + health.status, + MutationScopeHealthStatus::Invalid, + "recovery_pending == false with an unresolved PendingAbandon attempt is a \ + structurally impossible state and must never classify Healthy" + ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::ManualOnly, + "doctor must not auto-repair an impossible Claude state" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn invalid_takes_priority_over_blocked_in_mixed_impossible_state() { + let git_dir = unique_test_git_dir("invalid-priority-mixed"); + write_hand_constructed_state( + &git_dir, + &state::AdapterState { + version: 1, + next_attempt_seq: 3, + recovery_pending: false, + attempts: vec![ + hand_constructed_attempt(1, "toolu_1", state::AttemptPhase::PendingAbandon), + hand_constructed_attempt(2, "toolu_2", state::AttemptPhase::PendingStart), + ], + }, + ); + + let health = classify_health(&git_dir); + assert_eq!( + health.status, + MutationScopeHealthStatus::Invalid, + "Clear + unresolved PendingAbandon must take priority over an otherwise-Blocked \ + shape from a coexisting PendingStart attempt" + ); + + remove_test_git_dir(&git_dir); + } } diff --git a/cli/src/services/hooks/mutation_scope_health.rs b/cli/src/services/hooks/mutation_scope_health.rs index 783f2eb3..2fbd2ec2 100644 --- a/cli/src/services/hooks/mutation_scope_health.rs +++ b/cli/src/services/hooks/mutation_scope_health.rs @@ -9,6 +9,13 @@ pub(crate) enum MutationScopeHealthStatus { Invalid, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[allow(dead_code)] +pub(crate) enum Repairability { + AutoFixable, + ManualOnly, +} + #[derive(Clone, Debug, Eq, PartialEq)] #[allow(dead_code)] pub(crate) struct MutationScopeAdapterHealth { @@ -63,6 +70,12 @@ mod tests { ); } + #[test] + fn repairability_variants_are_distinct() { + assert_ne!(Repairability::AutoFixable, Repairability::ManualOnly); + assert_eq!(Repairability::AutoFixable, Repairability::AutoFixable); + } + #[test] fn new_adapter_health_has_no_detail_by_default() { let health = MutationScopeAdapterHealth::new( diff --git a/context/context-map.md b/context/context-map.md index eef9a5b6..f2f1cf00 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -36,7 +36,7 @@ Feature/domain context: - `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, an optional `provenance` object accepted on `start` only (a required non-blank `session_id`, an optional `model_id` where an absent key and an explicit `null` both mean no model, and no other key), and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` and `start`'s optional `StartProvenance` verbatim because `EventId` equality is the runtime replay/idempotency key and provenance values arrive already canonical; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — a Claude Code adapter driver (`cli/src/services/hooks/claude_mutation_scope/`) and a Codex adapter driver (`cli/src/services/hooks/codex_mutation_scope/`, hidden `sce hooks codex-mutation-scope`) now exist and are registered by `sce setup`, both consuming this seam's own `pub(crate)` in-process entrypoint; an OpenCode adapter driver (`cli/src/services/hooks/opencode_mutation_scope/`, hidden `sce hooks opencode-mutation-scope`) also consumes it with a full `Start`/`Close`/`Abandon` lifecycle and checkout-local recovery state, and is now driven in production by the generated `sce-mutation-scope.ts` plugin installed last by `sce setup`; a Pi adapter driver (`cli/src/services/hooks/pi_mutation_scope/`, hidden `sce hooks pi-mutation-scope`) also consumes it now, with `(session-id, tool-call-id)`→`ScopeId` derivation and getppid()/`/proc`-start-time-based stale-process recovery; the canonical generated Pi extension drives it in ordinary sessions — see `context/cli/pi-mutation-scope-integration.md`) - `context/cli/claude-mutation-scope-integration.md` (the first concrete harness lifecycle adapter, `cli/src/services/hooks/claude_mutation_scope/`, hidden command `sce hooks claude-mutation-scope`, built by the `claude-mutation-scope-integration` plan: one independently mutation-capable Claude tool execution = one SCE mutation `ScopeId` (a session/prompt/main agent/subagent is never a scope); the `classify_tool` table (mutation-capable including unknown names, read-only `Read`/`Glob`/`Grep`/`WebFetch`/`WebSearch`/`AskUserQuestion`, `Agent` = delegation) plus the model-only `is_explicit_background_shell` predicate; the length-prefixed hash-free `cc-tool-v1|n=|s=..|a=..|t=..` `ScopeId` keyed on a monotonic checkout-local `attempt_seq` (never reused after terminal) with deterministic `|start` / `|close` `EventId`s; the `/sce/claude-mutation-scope-state.json` bookkeeping store (never attribution evidence, never synced) with its own separate lock never held across a seam call; `PreToolUse` write-ahead `pending_start` → seam `start` → `active` and its fail-closed Claude `permissionDecision: "deny"` on any failure (never `allow`, detail logged via `sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`); `PostToolUse`/`PostToolUseFailure` → `close`, with `pending_start`+terminal → abandon-not-late-start (D11) and failed-`close` → abandon-not-replay (D12); the abandonment cleanup signals (`PermissionDenied`, `Stop`/`StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, best-effort `WorktreeRemove` — the last two not observed to fire on Claude Code `2.1.258`); the `recovery_pending` barrier that denies new mutation-capable `PreToolUse` until quiescent then runs one seam `flush`; raw hook `cwd` (or `worktree_path` for `WorktreeRemove`) as authoritative repository root with no adapter-constructed `WorktreeId`; the `run_in_background = true` denial and the separate self-detaching-descendant unsupported boundary (D20, with T04's Git-observable evidence — full detail split out to [`claude-mutation-scope-background-execution.md`](cli/claude-mutation-scope-background-execution.md) for the repository's per-file line budget); the ten unmatched `sce setup` registrations; the strict `claude_mutation_scope → hooks::mutation_scope → mutation_trace::runtime` dependency direction through the single `run_mutation_scope_from_payload` seam import (T05); admission-time exact model-state snapshot into `ScopeProvenance`; real Git/Agent Trace persistence coverage shared with the Codex path; and, added by the `doctor-mutation-scope-fix` plan's T04, a third `pending_abandon` attempt phase durably persisted before any abandon seam call — established evidence that abandonment has already been decided, distinct from "may still be running" — with every attempt a broad cleanup signal (`Stop`/`UserPromptSubmit`/`SubagentStop`/`SessionEnd`/`WorktreeRemove`) matches now marked `pending_abandon` in one write before any of their seam calls run, so one attempt's seam failure can never leave a batch sibling without its own retryable evidence; health classification detail split out to [`claude-mutation-scope-health.md`](cli/claude-mutation-scope-health.md)) - `context/cli/claude-mutation-scope-background-execution.md` (the `run_in_background = true` `PreToolUse` denial and the separate self-detaching-descendant unsupported boundary D20 — Git-observable evidence that a foreground `setsid` command's detached descendant can still mutate the repository after `PostToolUse` returns — detail split out of `claude-mutation-scope-integration.md` for the repository's per-file line budget) -- `context/cli/claude-mutation-scope-health.md` (the `doctor-mutation-scope-health` plan's Claude task, extended by the `doctor-mutation-scope-fix` plan's T04: `classify_health` in `cli/src/services/hooks/claude_mutation_scope/health.rs` maps `recovery_pending`/`attempts` onto `healthy | recovering | blocked | invalid` — `recovery_pending == false` is `healthy` regardless of live attempts; `recovery_pending == true` with `attempts` empty is `recovering` (the barrier's own next-`PreToolUse` flush clears it); `recovery_pending == true` with `attempts` non-empty is `blocked`, the exact incident shape this classifier exists to surface, since nothing in the ordinary hook lifecycle retries, removes, flushes, or clears from that shape; a read/parse failure is `invalid`. T04 adds a separate, `doctor`-invoked repair path (`assess_repairability`/`repair_blocked` in `health.rs`/`lifecycle.rs`, not yet wired into `sce doctor --fix`) for the `blocked` shape: `AutoFixable` only when every currently persisted attempt is already `pending_abandon` (no established-abandon-intent `pending_start`/`active` attempt present); `repair_blocked` re-reads and re-proves that same condition inside one lock-protected read-only state transaction before releasing the lock and retrying each attempt's already-established seam `abandon` call independently, clearing the `recovery_pending` barrier only once no attempts remain — this classifier's own read-only behavior is unaffected by that repair path's existence) +- `context/cli/claude-mutation-scope-health.md` (the `doctor-mutation-scope-health` plan's Claude task, extended by the `doctor-mutation-scope-fix` plan's T04: `classify_health` in `cli/src/services/hooks/claude_mutation_scope/health.rs` maps `recovery_pending`/`attempts` onto `healthy | recovering | blocked | invalid` — `recovery_pending == false` is `healthy` regardless of live attempts; `recovery_pending == true` with `attempts` empty is `recovering` (the barrier's own next-`PreToolUse` flush clears it); `recovery_pending == true` with `attempts` non-empty is `blocked`, the exact incident shape this classifier exists to surface, since nothing in the ordinary hook lifecycle retries, removes, flushes, or clears from that shape; a read/parse failure is `invalid`. T04 adds a separate, `doctor`-invoked repair path (`assess_repairability`/`repair_blocked` in `health.rs`/`lifecycle.rs`, wired into `sce doctor --fix` by the same plan's T05) for the `blocked` shape: `AutoFixable` only when every currently persisted attempt is already `pending_abandon` (no established-abandon-intent `pending_start`/`active` attempt present); `repair_blocked` re-reads and re-proves that same condition inside one lock-protected read-only state transaction before releasing the lock and retrying each attempt's already-established seam `abandon` call independently, clearing the `recovery_pending` barrier only once no attempts remain — this classifier's own read-only behavior is unaffected by that repair path's existence) - `context/cli/mutation-trace-ref-reconciliation.md` (the conservative per-worktree snapshot-ref reconciliation pass in `cli/src/services/mutation_trace/runtime/ref_reconciliation.rs`, built by the `mutation-cursor-ref-reconciliation` plan: `reconcile_worktree(repository_root, open_db)` → `pub(super) reconcile_worktree_inner(.., on_lock_contention)`, both returning `ReconciliationOutcome` (`Reconciled(ReconciliationReport { local_required, retained, deleted })` for a pass that ran | `SkippedNoCheckoutIdentity` — an `Ok`, not an `Err` — when no current checkout identity could be derived), a variant-per-fallible-step `ReconcileError` with no `Other`, and the module-owned `RECONCILIATION_LOCK_TIMEOUT`; the two-invariant model — a strictly per-worktree fail-closed local-consistency check via `load_tree_roots(W)` vs. a repository-wide deletion-safety set via `load_all_tree_roots()` so an `A`-owned ref is retained whenever any worktree still durably needs its tree — run entirely under the same `/sce/mutation-cursor.lock` `WorktreeLock` `coordinate()` holds, with the repository-wide read kept coherent by being one SQL statement / one DB snapshot rather than a repository-global lock; deletes only SCE-owned refs via one atomic `git update-ref --no-deref --stdin`, writes no `mutation_trace_*` row, never arms `ExternalTaintMarker`, runs no `git gc`; imperative durability maintenance below the verified Quint protocol; reclaims orphan/unreferenced refs only for the namespace of a checkout id a current worktree still derives — a namespace no current worktree owns (identity-based: a deleted linked worktree, or checkout-id metadata loss followed by `get_or_create_checkout_id` minting a fresh id on a still-present worktree) is beyond every per-worktree pass and left to a recorded future repository-scoped unowned-namespace operation, so the current pass does not bound all orphan-ref growth; `reconcile_worktree` has no `pub(crate)` re-export and no harness/command wiring yet) - `context/cli/mutation-trace-snapshot-service.md` (the isolated Git snapshot and ref-pinning service `runtime::git_snapshot::GitSnapshotService` in `cli/src/services/mutation_trace/runtime/git_snapshot.rs`: `new` resolving an absolute `git_dir`, `capture_tree` snapshotting staged/unstaged/untracked/deleted worktree state into the repository's normal object database via a throwaway temp index, `pin_tree` protecting a durable tree with a create-only idempotent **direct** `refs/sce/mutation-cursor//` ref, `diff_trees` emitting `patch.rs`-parseable raw diff text; plus the callerless reconciliation substrate — worktree-scoped `list_pins` inventory returning `Result, PinInventoryError>` that rejects a symbolic ref inside the namespace (mutation-cursor pins are direct refs) as `MalformedRef`, matchable separately from a `git for-each-ref` execution failure, and conditional-atomic `delete_pins` running one `git update-ref --no-deref --stdin` transaction of SHA-conditioned deletes — no-dereference so an inventory→delete direct-ref→symref race cannot escape the inventoried namespace, plus a fail-closed pre-check — that aborts whole if any ref changed since inventory; `REF_NAMESPACE` + `pin_ref_prefix` as the single source of truth for the pin path) - `context/cli/mutation-trace-external-taint.md` (the worktree-local mutation-cursor durability boundary in `cli/src/services/mutation_trace/runtime/external_taint.rs`, built by the `mutation-cursor-external-taint` plan: the `ExternalTaintMarker` primitive — `new(git_dir)`/`exists()`/`persist()`/`clear()` over an empty file at `/sce/mutation-cursor-tainted` whose existence is its entire state, `checkout::persist_checkout_id_inner`-style durability (`sync_data` plus best-effort `#[cfg(unix)]` parent-dir `sync_all`), idempotent persist/clear, `NotFound`-on-clear as success, no `Drop` deletion — as the concrete runtime refinement of the abstract `ProtocolState.external_taint`; armed by the reshaped `coordinate()` entrypoint write-ahead after the `WorktreeLock` and before Agent Trace DB acquisition (a caller-supplied DB provider closure), cleared only on a successful `CoordinateOutcome`, with dedicated fail-closed pre-commit `CoordinateError::ExternalTaintMarker` (`Inspect`/`Persist` only)/`AgentTraceDbUnavailable` variants plus a post-commit `MarkerClearAfterCommit { source, committed }` that carries the durable outcome so a failed trailing clear never hides a committed `MutationEvent`; an inherited marker seeds an invocation-local `external_taint_pending` flag that overlays `protocol::database_failure` onto each freshly loaded projection so `recover` runs once against the captured snapshot, held across a losing recovery CAS and cleared once it lands) @@ -69,7 +69,7 @@ Feature/domain context: - `context/sce/agent-trace-post-commit-dual-write.md` (historical post-commit no-op/dual-write reference; current post-commit behavior is documented in `agent-trace-hooks-command-routing.md`) - `context/sce/agent-trace-hook-doctor.md` (approved operator-environment contract for broadening `sce doctor` into the canonical health-and-repair entrypoint, including stable problem taxonomy — now including `mutation_scope_health` — `--fix` semantics, repository-scoped Agent Trace DB reporting, post-commit Agent Trace auto-sync readiness proof and opt-out behavior, setup-to-doctor alignment rules, canonical Git-hook payload restoration, and the approved downstream human text-mode layout/status/integration contract) - `context/sce/doctor-human-text-contract.md` (implemented compact `sce doctor` human text contract: Environment/Repository/Integrations hierarchy, post-commit Agent Trace auto-sync readiness labels, `[PASS]`/`[WARN]`/`[FAIL]`/`[MISS]` status vocabulary, healthy-row metadata suppression, typed Claude Code/OpenCode/Pi/Codex target and area ordering, configured/detected/empty target resolution, selection-scoped optional-workflow inventory, the Codex hook trust/review reminder, the per-target `Mutation-scope health` row, no-installed-integrations guidance, and JSON as the full-detail route) -- `context/sce/mutation-scope-health-status.md` (the `doctor-mutation-scope-health` plan's T06: the shared `healthy | recovering | blocked | invalid` vocabulary `sce doctor` consumes from each adapter's own classifier, the `mutation_scope_health` `DoctorProblem` category and severity/fixability/readiness mapping — `blocked`/`invalid` are `manual_only` with never-delete-the-state-file remediation, `recovering` is the distinct `no_action_required` fixability with adapter-neutral remediation — and the JSON/text output shape — links out to each adapter's own detailed reachability/reasoning doc) +- `context/sce/mutation-scope-health-status.md` (the `doctor-mutation-scope-health` plan's T06, extended by the `doctor-mutation-scope-fix` plan's T05: the shared `healthy | recovering | blocked | invalid` vocabulary `sce doctor` consumes from each adapter's own classifier, the `mutation_scope_health` `DoctorProblem` category and severity/fixability/readiness mapping — `invalid` is always `manual_only`; `blocked` now carries a second, adapter-owned `Repairability` fact (`AutoFixable`/`ManualOnly`, `cli/src/services/hooks/mutation_scope_health.rs`) that `sce doctor --fix` (`repair_blocked_mutation_scope_targets`) uses to call Claude's or OpenCode's own `assess_repairability`/`repair_blocked` and, only once a freshly recomputed `classify_health` confirms `healthy`/`recovering`, report the row `fixed`, never trusting the repair call's `Ok(())` alone; a `manual_only` `blocked` row still gets the never-delete-the-state-file manual remediation, and `recovering` is still the distinct `no_action_required` fixability with adapter-neutral remediation; the `DoctorProblem`'s own rendered fixability text does not yet vary by this repairability fact — and the JSON/text output shape — links out to each adapter's own detailed reachability/reasoning doc) - `context/sce/setup-githooks-install-contract.md` (canonical `sce setup --hooks` install contract for target-path resolution, all-hook non-blocking missing-CLI bootstrap behavior, foreign-hook preservation and managed-block merge/idempotent outcomes, atomic-swap replacement behavior, and doctor-readiness alignment) - `context/sce/setup-no-backup-policy-seam.md` (non-destructive per-asset install policy: config install writes/swaps each embedded asset individually by atomic rename over the destination, without ever unlinking it first, and never removes an integration target directory as a whole, then prunes catalog-derived stale/deselected asset paths and any parent directory left empty by that pruning; required-hook install uses the same per-file stage/atomic-swap choreography and, like the two JSON merge targets, computes its staged content ahead of the swap — a foreign hook's bytes are kept as an exact prefix with the SCE managed block appended; `.claude/settings.json` and `.opencode/opencode.json` are merge targets whose staged content is computed by JSON-merging the generated document into the user's existing one before the shared stage/swap step; no backup creation; a swap failure leaves prior destination content untouched, with deterministic recovery guidance naming the failing asset) - `context/sce/setup-githooks-hook-asset-packaging.md` (compile-time `sce setup --hooks` required-hook template packaging contract, including all-hook non-blocking missing-`sce` install guidance, available-CLI argument forwarding, post-commit-only origin remote lookup plus remote-URL forwarding/fallback behavior, setup-service accessor surface, and current validation posture) diff --git a/context/plans/doctor-mutation-scope-fix.md b/context/plans/doctor-mutation-scope-fix.md index f3cf6300..75fc138b 100644 --- a/context/plans/doctor-mutation-scope-fix.md +++ b/context/plans/doctor-mutation-scope-fix.md @@ -1145,9 +1145,44 @@ Persist this field in every plan; this is durable plan state, not chat state: describing `mark_recovery_pending()` followed by `mark_pending_abandon()` as the final implementation has been corrected above; that two-write sequence is no longer present in the codebase. + - Correction (2026-09-21, second): `classify_health` disagreed with T01's + formal invariant `RecoveryNeverClearedWithUnresolvedAbandon` and with + `doctor_recovery.qnt`'s `adapterHealth` priority order + (`recovery == Clear and hasPendingAbandon -> Invalid`, checked before + every other classification). The Rust classifier instead returned + `Healthy` for any `recovery_pending == false` state without checking + whether a persisted attempt was still `PendingAbandon` — a structurally + impossible/corrupt shape (terminal cleanup already durably decided while + the barrier reads clear) was silently reported healthy instead of + surfaced as `Invalid`. Fixed by adding a `has_pending_abandon` check + (`state.attempts.iter().any(|attempt| attempt.phase == + AttemptPhase::PendingAbandon)`) ahead of the existing + `!state.recovery_pending -> Healthy` branch in + `claude_mutation_scope::health::classify_health`: `!recovery_pending && + has_pending_abandon` now returns `Invalid` first, matching the Quint + model's priority order exactly. `assess_repairability` needed no change + — its existing `!state.recovery_pending -> ManualOnly` short-circuit + already classified this shape `ManualOnly` before this correction, so + the impossible state was already un-auto-fixable; only the health + classification itself was wrong. Ordinary `PendingStart`/`Active` + attempts with `recovery_pending == false` are unaffected + (`has_pending_abandon` is false for them), and no Claude boundary lock + or owner-liveness primitive was added. New regressions in + `claude_mutation_scope::health::tests`: `clear_recovery_with_pending_abandon_is_invalid` + (a hand-constructed `recovery_pending: false` state with one + `PendingAbandon` attempt classifies `Invalid` and `assess_repairability` + reports `ManualOnly`) and + `invalid_takes_priority_over_blocked_in_mixed_impossible_state` (a + second, hand-constructed `PendingStart` attempt coexisting in the same + `recovery_pending: false` state still classifies `Invalid`, not + `Blocked`), mirroring the Quint model's + `testPendingAbandonWithClearRecoveryIsInvalid`/ + `testInvalidTakesPriorityOverBlockedWhenBothConditionsHold`. No T05/T06 + scope, OpenCode, Pi, Codex, doctor orchestration/rendering, or the Quint + model itself was touched by this correction. - Context synchronization: synced -- [ ] T05: `Shared repairability contract and doctor --fix orchestration` (status:todo) +- [x] T05: `Shared repairability contract and doctor --fix orchestration` (status:done) - Task ID: T05 - Scope: In — `cli/src/services/hooks/mutation_scope_health.rs` (add the shared `Repairability` enum, kept separate from `MutationScopeHealthStatus`); @@ -1185,7 +1220,175 @@ Persist this field in every plan; this is durable plan state, not chat state: problems runs the new step as a no-op with no behavior change from today. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` - - Context synchronization: pending + - Completed: 2026-09-21 + - Files changed: `cli/src/services/hooks/mutation_scope_health.rs` (adds the + shared `pub(crate) enum Repairability { AutoFixable, ManualOnly }`, + kept separate from `MutationScopeHealthStatus`, plus a + `repairability_variants_are_distinct` regression test); + `cli/src/services/doctor/inspect.rs` (adds + `repair_blocked_mutation_scope_targets` — the new `pub(super)` dispatch + entry point — plus its private helpers + `repair_blocked_mutation_scope_targets_with_seam`, + `mutation_scope_repair_seam` (the real production seam, + `hooks::mutation_scope::run_mutation_scope_from_payload`), + `repair_blocked_mutation_scope_target`, `claude_repairability`, + `opencode_repairability`, and `fixed_record_from_recomputed_health`; + imports `Repairability`, `mutation_scope`, and `Logger`; adds six new + regression tests and the `claude_autofixable_blocked_state`/ + `init_git_repo_with_opencode_target`/`opencode_dead_owner`/ + `no_op_repair_seam` test fixtures); + `cli/src/services/doctor/mod.rs` (wires + `repair_blocked_mutation_scope_targets(&initial_report)` into + `execute_doctor_with_lifecycle_providers`, positioned exactly after + `repair_merge_target_configs` and before the final + `diagnose_lifecycle_providers` call). + - Result: `sce doctor --fix` now repairs a `Blocked` Claude or OpenCode + Agent-tracing state whenever the owning adapter's own + `assess_repairability` proves it `AutoFixable`, by calling that + adapter's own `repair_blocked` through the real production seam + (`hooks::mutation_scope::run_mutation_scope_from_payload` — the same + seam Claude's and OpenCode's own hook entry points use), then + re-reading a fresh `classify_health` immediately afterward: only when + that fresh read is `Healthy`/`Recovering` does the dispatch record a + `Fixed` `DoctorFixResultRecord`; a `repair_blocked` call that returns + `Ok(())` but leaves the target `Blocked`/`Invalid` (a losing race, a + seam failure, or any other reason) produces no record at all, and the + existing generic `build_manual_fix_results` pass over the *final* + recomputed report — unchanged by this task — reports it `Manual` + instead, so `Fixed` and a still-`Blocked` final report can never + coincide (AC6). A `ManualOnly` target (no owner evidence, a live/unknown + owner, or a mixed-phase Claude state) is never passed to `repair_blocked` + at all — `claude_repairability`/`opencode_repairability` short-circuit + first — leaving the state file byte-for-byte untouched and routing + through the pre-existing manual-remediation path exactly as before this + task (AC2 unaffected; T06 still owns its wording). Doctor itself never + acquires or holds either adapter's state lock, never holds a lock across + a seam call, and never calls `remove_attempt()` or rewrites JSON — it + only calls each adapter's own `assess_repairability`/`repair_blocked`/ + `classify_health`, matching this plan's constraints and T01's formal + model. Codex and Pi rows are matched to `None` in + `repair_blocked_mutation_scope_target` and never dispatched further, + since neither adapter can currently classify `Blocked` (per T01's + assumptions). + - Deviation: the seam is threaded through + `repair_blocked_mutation_scope_targets_with_seam`/ + `repair_blocked_mutation_scope_target` as an explicit parameter + (`MutationScopeRepairSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn + Logger>) -> anyhow::Result`) rather than being hardcoded inline, + mirroring the seam-injection pattern every adapter's own lifecycle + module already uses (e.g. `claude_mutation_scope`'s + `run_claude_mutation_scope_from_payload_with_resolver`). This was not + load-bearing for production behavior (the public + `repair_blocked_mutation_scope_targets` entry point always uses the one + real seam, `mutation_scope_repair_seam`) but was necessary for reliable + testing: an initial version of the regression tests drove the real + production seam end-to-end (a real git repo, a real per-repository + Agent Trace DB) and was flaky under the full `doctor` test binary — it + passed when run alone but intermittently reported `Manual` instead of + `Fixed` when preceded by other tests in the same process, traced to the + credential-store-backed Agent Trace DB encryption key + (`cli/src/services/db/encryption_key.rs`'s process-global + `DEFAULT_STORE`/keyring-store registration) not reliably surviving a + second real, encrypted per-repository database being created in the + same test process inside this sandboxed environment — a pre-existing + test-infrastructure characteristic unrelated to this task's logic and + out of its scope to fix. Threading the seam as a parameter let the + regression tests inject the same kind of no-op fake seam every other + hook-level test in this codebase already uses (matching + `claude_mutation_scope`/`opencode_mutation_scope`'s own `repair_blocked` + test suites), making the new tests deterministic while still exercising + every line of this task's own new dispatch code — the underlying + adapter `repair_blocked` behavior against the real seam remains proven + by T03/T04's own extensive test suites, which this task does not + duplicate. + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> `49 passed; 0 failed` (six new: `repair_blocked_mutation_scope_target_repairs_an_autofixable_claude_state`, `repair_blocked_mutation_scope_target_never_touches_a_manual_only_claude_state`, `repair_blocked_mutation_scope_target_repairs_an_autofixable_opencode_state`, `full_report_fix_mode_leaves_a_manual_only_claude_blocked_state_untouched`, `full_report_fix_mode_has_no_mutation_scope_effect_when_nothing_is_blocked`, plus `mutation_scope_health.rs`'s `repairability_variants_are_distinct`), re-run three times consecutively with no flakes; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> `136 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> `113 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_health` -> `10 passed; 0 failed`; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope cli/src/services/hooks/opencode_mutation_scope cli/src/services/hooks/mutation_scope_owner.rs` -> only the pre-existing, unrelated `AdapterStateLock`/`AdapterBoundaryLock`/`os_lock` timeout deadlines and the static no-TTL-token source scan's own token list, matching AC3's "no staleness use outside unrelated lock-timeout constants" (this task introduced no new staleness heuristic). + - Context impact: `sce doctor --fix` gains real, user-visible repair + behavior for a `Blocked` Claude/OpenCode Agent-tracing state for the + first time (previously `--fix` could never touch this category at all). + The `mutation_scope_health` JSON array's shape and status strings, and + every `MutationScope*` Rust type name, are unchanged (AC7 — this task + added no new field to that array and renamed nothing). `DoctorProblem`'s + `fixability`/`remediation` text for a `Blocked` row is *not yet* dynamic + per this task (still the pre-existing static `ManualOnly` wording from + `push_mutation_scope_health_problem`, untouched by this task) — a + `Blocked` row that this task's new step successfully repairs still shows + the old wording on the *initial* diagnosis before `--fix` runs, and + `sce doctor --fix`'s human/JSON fix-result line for a repaired target + goes through the existing generic `[{outcome}] {detail}` formatter with + a `Recovered Agent tracing (now : ).` detail + string this task produces — not yet the `[fixed] Recovered + Agent tracing ...` wording T06's own Done-when names, since + `DoctorDisplayDetail::MutationScopeHealth` rendering is unchanged here. + `context/sce/mutation-scope-health-status.md`, + `context/sce/agent-trace-hook-doctor.md`, and + `context/sce/doctor-human-text-contract.md` are all named in this plan's + own "Context sync" section as needing the new repairability facet + described once the repair path exists — deferring that write to this + task's own context-synchronization pass (next), not T06, since this is + the task that actually makes `--fix` capable of the repair, per this + plan's own instruction to record docs against the task that makes a + behavior reachable. + - Correction (2026-09-21): the original dispatch violated AC6 and this + task's own "final report is the sole postcondition" requirement. + `repair_blocked_mutation_scope_target` called each adapter's + `repair_blocked`, then immediately called that same adapter's + `classify_health` again right there and built a `Fixed` + `DoctorFixResultRecord` from that *intermediate* read — a read taken + before `execute_doctor_with_lifecycle_providers` goes on to build the + actual final `HookDoctorReport`. A concurrent hook process mutating + persisted state between that intermediate read and the final diagnosis + could produce `fix_results: [Fixed]` alongside a final report that is + still `Blocked`, which AC6 and `doctor_recovery.qnt`'s + `ReportedFixedExcludesBlockedOrInvalid` invariant both forbid. Fixed by + separating "perform the repair" from "decide whether it counts as + `Fixed`": `repair_blocked_mutation_scope_target`/ + `repair_blocked_mutation_scope_targets(_with_seam)` now return which + targets doctor actually attempted a repair for + (`Option`/`Vec`), with no + `classify_health` call and no `DoctorFixResultRecord` construction of + their own. A new `finalize_mutation_scope_repair_results(repaired_targets: + &[IntegrationTarget], final_mutation_scope_health: &[MutationScopeHealthRow]) + -> Vec` is the only place a mutation-scope + `Fixed` record is now produced: it looks up each repaired target's row + in the already-built final report's `mutation_scope_health` (never + recomputing health itself) and reports `Fixed` only for + `Healthy`/`Recovering`; `Blocked`/`Invalid`, or a target missing from + the final row set entirely, produce no record and fall through to the + existing, unchanged `build_manual_fix_results` pass over the final + report. `execute_doctor_with_lifecycle_providers` in + `cli/src/services/doctor/mod.rs` now calls + `repair_blocked_mutation_scope_targets(&initial_report)` to get the + repaired-target list, builds the final report exactly as before, then + calls `finalize_mutation_scope_repair_results(&mutation_scope_repairs, + &final_report.mutation_scope_health)` before `build_manual_fix_results` + — preserving the plan's initial-diagnosis -> existing-repairs -> + repair-attempt -> final-diagnosis -> derive-fix-result-from-final-report + flow exactly, with the derivation step now strictly after the final + report exists rather than interleaved with the repair step. `fixed_record_from_recomputed_health` + now takes a `&MutationScopeHealthRow` (the final report's own row shape) + instead of a freshly computed `&MutationScopeAdapterHealth`, so there is + no code path left that can authorize `Fixed` from anything but the final + report. New regression + `finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts` + in `cli/src/services/doctor/inspect.rs` drives a real `repair_blocked` + call to a genuine immediate `Healthy`/`Recovering` result, then + overwrites the persisted Claude state back to `Blocked` (simulating a + concurrent hook process) before computing the final + `mutation_scope_health` row and calling `finalize_mutation_scope_repair_results` + — asserting both that the final row is `Blocked` and that no `Fixed` + `MutationScopeHealth` record is produced, proving the race AC6 requires + is now impossible. The three existing dispatch-level tests + (`repair_blocked_mutation_scope_target_repairs_an_autofixable_claude_state`, + `repair_blocked_mutation_scope_target_never_touches_a_manual_only_claude_state`, + `repair_blocked_mutation_scope_target_repairs_an_autofixable_opencode_state`) + were updated for the new `Option` return shape; the + two `AutoFixable` tests now additionally call + `finalize_mutation_scope_repair_results` against a freshly recomputed + final row set to prove the end-to-end `Fixed` path still works when the + final report agrees. No T06/T07 functionality, OpenCode/Claude adapter + internals, or human/JSON rendering were touched. + - Verify outcomes (corrected): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> `50 passed; 0 failed` (the five surviving T05 regressions plus the new `finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts`); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> `138 passed; 0 failed` (includes the two new T04-correction Invalid-classification regressions); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> `113 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_health` -> `10 passed; 0 failed`; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `nix run .#quint -- typecheck spec/doctor_recovery.qnt` -> clean; `nix run .#quint -- test spec/doctor_recovery.qnt --match '^test.*'` -> `13 passing` (unchanged — this correction required no Quint model change, confirming the implementation was the thing out of sync with the already-correct formal model, not the other way around). + - Context synchronization: synced - [ ] T06: `Human and JSON remediation contract for repaired and manual Agent tracing states` (status:todo) - Task ID: T06 diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 59d5abd3..93992214 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -198,6 +198,7 @@ Repair behavior must: - add new internal doctor-owned repair routines only for safe gaps with no existing canonical repair command - stay idempotent across repeated `--fix` runs - remain bounded to SCE-owned paths/files and explicit permission normalization on those paths +- for a `blocked` mutation-scope Agent-tracing row, delegate to that adapter's own proven repair (see [mutation-scope-health-status.md](mutation-scope-health-status.md)) ## ServiceLifecycle trait diff --git a/context/sce/mutation-scope-health-status.md b/context/sce/mutation-scope-health-status.md index 723185de..39f61d28 100644 --- a/context/sce/mutation-scope-health-status.md +++ b/context/sce/mutation-scope-health-status.md @@ -81,21 +81,83 @@ Each non-healthy status produces one `DoctorProblem` in a new | `Blocked` | `MutationScopeHealthBlocked` | Error | `manual_only` | `manual_steps` | overall readiness becomes `not_ready` | | `Invalid` | `MutationScopeHealthInvalid` | Error | `manual_only` | `manual_steps` | overall readiness becomes `not_ready` | -`sce doctor --fix` never mutates adapter recovery state (deleting a state -file, clearing `recovery_pending`, resetting a `RecoveryState`, or -fabricating an abandon): doctor cannot safely discard persisted -mutation-scope lifecycle/recovery evidence whose meaning and recovery -obligations belong to the adapter state machine. For example, Claude's -unresolved-abandonment case is documented in D12/D19 in +`sce doctor --fix` never deletes a state file, clears `recovery_pending`/a +`RecoveryState` generically, resets attempts, or fabricates an abandon +outside an adapter's own real protocol operations: doctor cannot safely +discard persisted mutation-scope lifecycle/recovery evidence whose meaning +and recovery obligations belong to the adapter state machine, and it never +calls `remove_attempt()` or rewrites a state file's JSON itself. For example, +Claude's unresolved-abandonment case is documented in D12/D19 in [claude-mutation-scope-integration.md](../cli/claude-mutation-scope-integration.md); other adapters can be `Blocked` by different durable evidence, such as OpenCode's stale outstanding `PendingStart` with no unresolved abandonment at all. -`Blocked` and `Invalid` are `manual_only`: doctor cannot repair them, and -`--fix` renders a deterministic manual-remediation result naming the real -adapter state file path, stating plainly that no safe generic recovery -command exists yet, and never recommending deletion of the state file. +`Invalid` is always `manual_only`: a state file doctor cannot safely +interpret is never auto-repaired. `Blocked`, however, has a second, separate +fact beyond its health status: **repairability**. Health +(`healthy`/`recovering`/`blocked`/`invalid`, above) and repairability +(`AutoFixable`/`ManualOnly`, `Repairability` in +`cli/src/services/hooks/mutation_scope_health.rs`, kept as a distinct type +from `MutationScopeHealthStatus`) are modeled as separate facts on purpose: +a `Blocked` problem record's repairability can change as the adapter's own +positive evidence changes (for example, a dead owner becoming provably dead +only after its process actually exits), while its health stays `Blocked` +until either an ordinary lifecycle event or a repair actually clears it. + +For a `Blocked` row, each adapter owns its own `assess_repairability(git_dir) +-> Repairability` and, when `AutoFixable`, `repair_blocked(git_dir, +repository_root, logger, seam) -> Result` (Claude and OpenCode +only — Codex and Pi never classify `Blocked` today, so neither defines these +functions). `AutoFixable` requires positive, freshly-reprovable evidence, not +a timestamp, file age, or generic "clear the state" fallback: + +- **Claude** (`claude_mutation_scope::health`) is `AutoFixable` only when + every currently persisted attempt is already `PendingAbandon` (an + established, durably-recorded abandon intent for all of them); any + `PendingStart`/`Active` attempt with no established abandon intent forces + the whole adapter `ManualOnly`. `repair_blocked` re-reads and re-proves + that same condition inside one lock-protected, read-only state + transaction, then retries each attempt's already-established seam + `abandon` call independently outside the lock, removing only the ones that + succeed and clearing `recovery_pending` only once none remain. +- **OpenCode** (`opencode_mutation_scope::health`) is `AutoFixable` only when + every currently `PendingStart` attempt has a recorded owner (PID + + `/proc` start-time identity, stamped at allocation) the shared + `mutation_scope_owner::is_definitely_dead` proves dead; a legacy attempt + with no recorded owner, a live owner, or an unprovable owner is + `ManualOnly`. `repair_blocked` acquires the adapter's `AdapterBoundaryLock`, + re-proves the same all-or-nothing dead-owner condition inside one + lock-protected state transaction, transitions the qualifying attempts to + `PendingAbandon`, then drives the existing `flush`/`abandon`/`flush` seam + sequence with the state lock released. + +`sce doctor --fix` (`execute_doctor_with_lifecycle_providers` in +`cli/src/services/doctor/mod.rs`, dispatched by +`repair_blocked_mutation_scope_targets` in `cli/src/services/doctor/inspect.rs`) +runs this repair as one further step, positioned after the existing +`ServiceLifecycle`/merge-target repairs and before the final diagnosis that +produces the fix-mode report. For each row the *initial* diagnosis found +`Blocked`, it calls that adapter's `assess_repairability` fresh; when +`AutoFixable`, it calls `repair_blocked` through the real production +mutation-scope ingress seam, then immediately re-reads a fresh +`classify_health` rather than trusting `repair_blocked`'s `Ok(())` return — +only when that fresh read is `Healthy`/`Recovering` does doctor record a +`Fixed` fix result, so a `Fixed` result and a final report still +`Blocked`/`Invalid` for that target can never coincide. Doctor itself never +acquires or holds an adapter's state lock and never holds one across the +seam call; that serialization is entirely adapter-owned (OpenCode's +`AdapterBoundaryLock` around the whole repair; Claude's own per-transaction +state lock with no added boundary lock). A `ManualOnly` row is never passed +to `repair_blocked` at all and falls through unchanged to the existing +generic manual-result handling, which still renders a deterministic +manual-remediation result naming the real adapter state file path, stating +plainly that no safe generic recovery command exists for it, and never +recommending deletion of the state file — the `DoctorProblem`'s own +rendered `fixability`/`remediation` text does not yet vary by this +repairability fact (still the shared `manual_only` wording below regardless +of a target's true repairability); that rendering surface is a separate, +later concern from the repair pipeline described here. `Recovering` is a distinct fixability, `no_action_required`: doctor performs no repair *because none is needed*, not because remediation is merely From 75c4065e77bb7f217aff2d0a6eae0a79f4e42c71 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 19:21:27 +0200 Subject: [PATCH 13/16] doctor: Fix mutation-scope remediation across output surfaces Blocked mutation-scope health was reported as manual-only even when the owning adapter proved automatic recovery safe, and manual remediation was not rendered consistently across doctor surfaces. Use adapter-owned repairability to select fixability, next action, and remediation; thread that text through human rows and fix results while preserving the existing JSON health-array schema. Add regressions for auto-fixable and manual-only states, including real state paths and no-delete guidance. Plan: doctor-mutation-scope-fix.md (T06) Co-authored-by: SCE --- cli/src/services/doctor/fixes.rs | 14 +- cli/src/services/doctor/inspect.rs | 348 ++++++++++++++++++-- cli/src/services/doctor/render.rs | 50 ++- cli/src/services/doctor/types.rs | 2 + context/context-map.md | 2 +- context/plans/doctor-mutation-scope-fix.md | 166 +++++++++- context/sce/agent-trace-hook-doctor.md | 2 +- context/sce/doctor-human-text-contract.md | 14 +- context/sce/mutation-scope-health-status.md | 28 +- 9 files changed, 576 insertions(+), 50 deletions(-) diff --git a/cli/src/services/doctor/fixes.rs b/cli/src/services/doctor/fixes.rs index eea14a5a..3bd97b41 100644 --- a/cli/src/services/doctor/fixes.rs +++ b/cli/src/services/doctor/fixes.rs @@ -1,4 +1,6 @@ -use super::types::{DoctorFixResultRecord, FixResult, ProblemFixability}; +use super::types::{ + DoctorFixResultRecord, DoctorProblem, FixResult, ProblemCategory, ProblemFixability, +}; use super::HookDoctorReport; pub(super) fn build_manual_fix_results(report: &HookDoctorReport) -> Vec { @@ -9,7 +11,15 @@ pub(super) fn build_manual_fix_results(report: &HookDoctorReport) -> Vec String { + if problem.category == ProblemCategory::MutationScopeHealth { + problem.remediation.clone() + } else { + format!("{} Manual remediation is still required.", problem.summary) + } +} diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index ba4d081d..cd634f31 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -217,12 +217,14 @@ fn inspect_mutation_scope_health( pi_mutation_scope::health::classify_health(&git_dir), ), }; - push_mutation_scope_health_problem(target, &health, &git_dir, problems); + let remediation = + push_mutation_scope_health_problem(target, &health, &git_dir, problems); MutationScopeHealthRow { target, status: health.status, reason: health.reason, detail: health.detail, + remediation, } }) .collect() @@ -305,28 +307,36 @@ fn opencode_repairability(git_dir: &Path) -> Repairability { } } +fn mutation_scope_repairability(target: IntegrationTarget, git_dir: &Path) -> Repairability { + match target { + IntegrationTarget::ClaudeCode => claude_repairability(git_dir), + IntegrationTarget::OpenCode => opencode_repairability(git_dir), + IntegrationTarget::Pi | IntegrationTarget::Codex => Repairability::ManualOnly, + } +} + pub(super) fn finalize_mutation_scope_repair_results( - repaired_targets: &[IntegrationTarget], + attempted_targets: &[IntegrationTarget], final_mutation_scope_health: &[MutationScopeHealthRow], ) -> Vec { - repaired_targets + attempted_targets .iter() .filter_map(|target| { let row = final_mutation_scope_health .iter() .find(|row| row.target == *target)?; - fixed_record_from_recomputed_health(*target, row) + Some(mutation_scope_repair_result_from_final_row(*target, row)) }) .collect() } -fn fixed_record_from_recomputed_health( +fn mutation_scope_repair_result_from_final_row( target: IntegrationTarget, row: &MutationScopeHealthRow, -) -> Option { +) -> DoctorFixResultRecord { match row.status { MutationScopeHealthStatus::Healthy | MutationScopeHealthStatus::Recovering => { - Some(DoctorFixResultRecord { + DoctorFixResultRecord { category: ProblemCategory::MutationScopeHealth, outcome: FixResult::Fixed, detail: format!( @@ -335,9 +345,21 @@ fn fixed_record_from_recomputed_health( mutation_scope_health_status(row.status), row.reason ), - }) + } + } + MutationScopeHealthStatus::Blocked | MutationScopeHealthStatus::Invalid => { + DoctorFixResultRecord { + category: ProblemCategory::MutationScopeHealth, + outcome: FixResult::Manual, + detail: row.remediation.clone().unwrap_or_else(|| { + format!( + "{} Agent tracing remains {} after an attempted repair.", + integration_target_label(target), + mutation_scope_health_status(row.status) + ) + }), + } } - MutationScopeHealthStatus::Blocked | MutationScopeHealthStatus::Invalid => None, } } @@ -355,9 +377,9 @@ fn push_mutation_scope_health_problem( health: &MutationScopeAdapterHealth, git_dir: &Path, problems: &mut Vec, -) { +) -> Option { let (kind, severity, fixability, next_action, remediation) = match health.status { - MutationScopeHealthStatus::Healthy => return, + MutationScopeHealthStatus::Healthy => return None, MutationScopeHealthStatus::Recovering => ( ProblemKind::MutationScopeHealthRecovering, ProblemSeverity::Warning, @@ -371,29 +393,45 @@ fn push_mutation_scope_health_problem( state remains recovering unexpectedly.", ), ), - MutationScopeHealthStatus::Blocked => ( - ProblemKind::MutationScopeHealthBlocked, - ProblemSeverity::Error, - ProblemFixability::ManualOnly, - "manual_steps", - format!( - "'sce doctor --fix' will not modify persisted mutation-scope recovery state: \ - clearing it automatically could silently discard unresolved mutation-scope \ - lifecycle or recovery evidence. No safe generic recovery command exists yet \ - for this case. Inspect '{}' and this adapter's recovery model directly before \ - taking manual action; do not delete the state file.", - mutation_scope_state_path(target, git_dir).display() + MutationScopeHealthStatus::Blocked => match mutation_scope_repairability(target, git_dir) { + Repairability::AutoFixable => ( + ProblemKind::MutationScopeHealthBlocked, + ProblemSeverity::Error, + ProblemFixability::AutoFixable, + "doctor_fix", + format!( + "Run 'sce doctor --fix' to recover this state: the owning process for \ + the blocking attempt(s) has been positively proven dead, so automatic \ + recovery is safe. The persisted state is at '{}'.", + mutation_scope_state_path(target, git_dir).display() + ), ), - ), + Repairability::ManualOnly => ( + ProblemKind::MutationScopeHealthBlocked, + ProblemSeverity::Error, + ProblemFixability::ManualOnly, + "manual_steps", + format!( + "Agent tracing remains blocked. Inspect '{}'. 'sce doctor --fix' will \ + not modify persisted mutation-scope recovery state automatically: \ + clearing it could silently discard unresolved mutation-scope \ + lifecycle or recovery evidence. No safe generic recovery command \ + exists yet for this case; preserve the persisted state while \ + reviewing this adapter's recovery model directly before taking \ + manual action.", + mutation_scope_state_path(target, git_dir).display() + ), + ), + }, MutationScopeHealthStatus::Invalid => ( ProblemKind::MutationScopeHealthInvalid, ProblemSeverity::Error, ProblemFixability::ManualOnly, "manual_steps", format!( - "The persisted mutation-scope state at '{}' could not be safely interpreted. \ - No safe generic recovery command exists yet; inspect the file directly rather \ - than deleting it.", + "Agent tracing state could not be safely interpreted. Inspect '{}'. No safe \ + generic recovery command exists yet; preserve the persisted state for \ + diagnosis and review the adapter's recovery model directly.", mutation_scope_state_path(target, git_dir).display() ), ), @@ -416,10 +454,12 @@ fn push_mutation_scope_health_problem( severity, fixability, summary, - remediation, + remediation: remediation.clone(), next_action, scope: None, }); + + Some(remediation) } fn collect_post_commit_auto_sync_health( @@ -4317,12 +4357,21 @@ mod tests { assert_eq!(problems[0].severity, ProblemSeverity::Error); assert_eq!(problems[0].fixability, ProblemFixability::ManualOnly); assert_eq!(problems[0].next_action, "manual_steps"); + let state_path = + claude_mutation_scope::state::state_path(&super::resolve_git_dir(&repo).unwrap()); assert!( problems[0] + .remediation + .contains(&state_path.display().to_string()), + "remediation must name the exact persisted state path: {}", + problems[0].remediation + ); + assert!( + !problems[0] .remediation .to_ascii_lowercase() - .contains("do not delete the state file"), - "remediation must explicitly say not to delete the state file: {}", + .contains("delete"), + "manual mutation-scope remediation must not contain deletion wording: {}", problems[0].remediation ); assert_eq!(compute_readiness(&problems), Readiness::NotReady); @@ -4357,6 +4406,87 @@ mod tests { std::fs::remove_dir_all(&repo).ok(); } + #[test] + fn mutation_scope_health_blocked_autofixable_remediation_names_doctor_fix() { + let repo = init_git_repo_with_claude_target("mutation-scope-health-blocked-autofixable"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let mut problems = Vec::new(); + let rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + + let claude_row = rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Blocked); + assert!( + claude_row + .remediation + .as_deref() + .is_some_and(|text| text.contains("sce doctor --fix")), + "row remediation must name 'sce doctor --fix': {:?}", + claude_row.remediation + ); + + assert_eq!(problems.len(), 1); + assert_eq!(problems[0].fixability, ProblemFixability::AutoFixable); + assert_eq!(problems[0].next_action, "doctor_fix"); + assert!( + problems[0].remediation.contains("sce doctor --fix"), + "problem remediation must name 'sce doctor --fix': {}", + problems[0].remediation + ); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn mutation_scope_health_manual_only_states_name_the_real_state_path_and_never_suggest_deletion( + ) { + let blocked_repo = + init_git_repo_with_claude_target("mutation-scope-health-manual-path-blocked"); + write_claude_mutation_scope_state(&blocked_repo, &claude_blocked_state()); + let blocked_git_dir = super::resolve_git_dir(&blocked_repo).expect("resolve git dir"); + let blocked_path = claude_mutation_scope::state::state_path(&blocked_git_dir); + + let mut blocked_problems = Vec::new(); + inspect_mutation_scope_health(true, false, Some(&blocked_repo), &mut blocked_problems); + assert_eq!(blocked_problems.len(), 1); + let blocked_remediation = &blocked_problems[0].remediation; + assert!( + blocked_remediation.contains(&blocked_path.display().to_string()), + "manual-only Blocked remediation must name the real state file path: {blocked_remediation}" + ); + assert!( + !blocked_remediation.to_ascii_lowercase().contains("delete"), + "manual-only Blocked remediation must never contain deletion wording: {blocked_remediation}" + ); + + std::fs::remove_dir_all(&blocked_repo).ok(); + + let invalid_repo = + init_git_repo_with_claude_target("mutation-scope-health-manual-path-invalid"); + let invalid_git_dir = super::resolve_git_dir(&invalid_repo).expect("resolve git dir"); + let invalid_path = claude_mutation_scope::state::state_path(&invalid_git_dir); + std::fs::create_dir_all(invalid_path.parent().expect("state path has a parent")).unwrap(); + std::fs::write(&invalid_path, b"not json").expect("write malformed state file"); + + let mut invalid_problems = Vec::new(); + inspect_mutation_scope_health(true, false, Some(&invalid_repo), &mut invalid_problems); + assert_eq!(invalid_problems.len(), 1); + let invalid_remediation = &invalid_problems[0].remediation; + assert!( + invalid_remediation.contains(&invalid_path.display().to_string()), + "manual-only Invalid remediation must name the real state file path: {invalid_remediation}" + ); + assert!( + !invalid_remediation.to_ascii_lowercase().contains("delete"), + "manual-only Invalid remediation must never contain deletion wording: {invalid_remediation}" + ); + + std::fs::remove_dir_all(&invalid_repo).ok(); + } + #[test] fn mutation_scope_health_is_absent_for_an_unconfigured_undetected_target() { let repo = init_git_repo("mutation-scope-health-no-targets"); @@ -4663,8 +4793,17 @@ mod tests { )); assert!(remediation.contains("no safe generic recovery command exists yet")); assert!( - !remediation.contains("delete the state file") - || remediation.contains("do not delete the state file") + !remediation.contains("delete"), + "manual mutation-scope remediation must not contain deletion wording: {remediation}" + ); + let state_path = + claude_mutation_scope::state::state_path(&super::resolve_git_dir(&repo).unwrap()); + assert!( + problem + .remediation + .contains(&state_path.display().to_string()), + "remediation must name the exact persisted state path: {}", + problem.remediation ); let text = render_text(&execution); @@ -4742,6 +4881,50 @@ mod tests { }); } + #[test] + fn full_report_autofixable_blocked_names_doctor_fix_in_text_and_json() { + with_isolated_global_state(|| { + let repo = init_git_repo_with_healthy_claude_target("full-report-autofixable-diagnose"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let execution = run_full_doctor_report(&repo, super::DoctorMode::Diagnose); + assert_eq!(execution.report.readiness, Readiness::NotReady); + let problem = execution + .report + .problems + .iter() + .find(|problem| problem.category == super::ProblemCategory::MutationScopeHealth) + .expect("mutation-scope health problem present"); + assert_eq!(problem.fixability, ProblemFixability::AutoFixable); + assert_eq!(problem.next_action, "doctor_fix"); + assert!(problem.remediation.contains("sce doctor --fix")); + + let text = render_text(&execution); + assert!( + text.contains("sce doctor --fix"), + "human text must name 'sce doctor --fix' for an auto-fixable blocked state: {text}" + ); + + let json = render_json(&execution); + let json_problems = json["problems"].as_array().expect("problems is an array"); + let mutation_scope_problem = json_problems + .iter() + .find(|problem| problem["category"] == "mutation_scope_health") + .expect("mutation-scope health JSON problem present"); + assert_eq!(mutation_scope_problem["fixability"], "auto_fixable"); + assert_eq!( + mutation_scope_problem["remediation"]["next_action"], + "doctor_fix" + ); + assert!(mutation_scope_problem["remediation"]["text"] + .as_str() + .expect("remediation text is a string") + .contains("sce doctor --fix")); + + std::fs::remove_dir_all(&repo).ok(); + }); + } + fn no_op_repair_seam() -> super::MutationScopeRepairSeam<'static> { &|_root, _payload, _logger| Ok(String::new()) } @@ -4784,6 +4967,14 @@ mod tests { super::ProblemCategory::MutationScopeHealth ); assert_eq!(records[0].outcome, super::FixResult::Fixed); + assert!( + records[0] + .detail + .starts_with("Recovered Claude Code Agent tracing ("), + "fix-result detail must match the documented '[fixed] Recovered Agent \ + tracing (...)' contract: {}", + records[0].detail + ); std::fs::remove_dir_all(&repo).ok(); } @@ -4869,6 +5060,83 @@ mod tests { std::fs::remove_dir_all(&repo).ok(); } + fn failing_repair_seam() -> super::MutationScopeRepairSeam<'static> { + &|_root, _payload, _logger| Err(anyhow::anyhow!("simulated seam failure")) + } + + #[test] + fn finalize_mutation_scope_repair_results_reports_manual_when_an_attempted_autofixable_repair_stays_blocked( + ) { + let repo = init_git_repo_with_claude_target("finalize-failed-autofixable-claude"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + + let attempted = super::repair_blocked_mutation_scope_target( + IntegrationTarget::ClaudeCode, + &git_dir, + &repo, + failing_repair_seam(), + ); + assert_eq!( + attempted, + Some(IntegrationTarget::ClaudeCode), + "an autofixable blocked target must still be attempted even when the repair seam fails" + ); + + let health = claude_mutation_scope::health::classify_health(&git_dir); + assert_eq!( + health.status, + MutationScopeHealthStatus::Blocked, + "a failing repair seam must leave the target blocked" + ); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + let final_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present in the final report"); + assert_eq!(final_row.status, MutationScopeHealthStatus::Blocked); + + let fix_results = super::finalize_mutation_scope_repair_results( + &[IntegrationTarget::ClaudeCode], + &final_rows, + ); + + assert!( + !fix_results.iter().any(|result| { + result.category == super::ProblemCategory::MutationScopeHealth + && result.outcome == super::FixResult::Fixed + }), + "an attempted but unresolved mutation-scope repair must never be reported Fixed: \ + {fix_results:?}" + ); + + let manual = fix_results + .iter() + .find(|result| { + result.category == super::ProblemCategory::MutationScopeHealth + && result.outcome == super::FixResult::Manual + }) + .expect( + "an attempted but unresolved mutation-scope repair must produce a manual result", + ); + + let state_path = claude_mutation_scope::state::state_path(&git_dir); + assert!( + manual.detail.contains(&state_path.display().to_string()), + "manual detail must name the exact persisted state path: {}", + manual.detail + ); + assert!( + !manual.detail.to_ascii_lowercase().contains("delete"), + "manual detail must not contain deletion wording: {}", + manual.detail + ); + + std::fs::remove_dir_all(&repo).ok(); + } + #[test] fn full_report_fix_mode_leaves_a_manual_only_claude_blocked_state_untouched() { with_isolated_global_state(|| { @@ -4901,6 +5169,22 @@ mod tests { }); } + #[test] + fn full_report_fix_mode_human_text_shows_the_manual_detail_line() { + with_isolated_global_state(|| { + let manual_repo = + init_git_repo_with_healthy_claude_target("full-report-fix-detail-manual"); + write_claude_mutation_scope_state(&manual_repo, &claude_blocked_state()); + let manual_execution = run_full_doctor_report(&manual_repo, super::DoctorMode::Fix); + let manual_text = render_text(&manual_execution); + assert!( + manual_text.contains("[manual] Agent tracing remains blocked. Inspect '"), + "human fix results must show the '[manual] Agent tracing remains blocked. Inspect ...' line: {manual_text}" + ); + std::fs::remove_dir_all(&manual_repo).ok(); + }); + } + fn init_git_repo_with_opencode_target(label: &str) -> PathBuf { let repo = init_git_repo(label); std::fs::create_dir_all(repo.join(".opencode")).expect("create .opencode directory"); diff --git a/cli/src/services/doctor/render.rs b/cli/src/services/doctor/render.rs index 9b08d5f0..65fee4c2 100644 --- a/cli/src/services/doctor/render.rs +++ b/cli/src/services/doctor/render.rs @@ -631,11 +631,18 @@ fn render_display_detail(lines: &mut Vec, detail: &DoctorDisplayDetail, lines.push(format!("{prefix}Problem: {summary}")); lines.push(format!("{prefix}Remediation: {remediation}")); } - DoctorDisplayDetail::MutationScopeHealth { reason, detail } => { + DoctorDisplayDetail::MutationScopeHealth { + reason, + detail, + remediation, + } => { lines.push(format!("{prefix}Reason: {reason}")); if let Some(detail) = detail { lines.push(format!("{prefix}Detail: {detail}")); } + if let Some(remediation) = remediation { + lines.push(format!("{prefix}Remediation: {remediation}")); + } } } } @@ -668,6 +675,7 @@ fn mutation_scope_health_node(row: &MutationScopeHealthRow) -> DoctorDisplayNode vec![DoctorDisplayDetail::MutationScopeHealth { reason: row.reason.clone(), detail: row.detail.clone(), + remediation: row.remediation.clone(), }], Vec::new(), ) @@ -927,12 +935,23 @@ mod tests { status: MutationScopeHealthStatus, reason: &str, detail: Option<&str>, + ) -> MutationScopeHealthRow { + row_with_remediation(target, status, reason, detail, None) + } + + fn row_with_remediation( + target: IntegrationTarget, + status: MutationScopeHealthStatus, + reason: &str, + detail: Option<&str>, + remediation: Option<&str>, ) -> MutationScopeHealthRow { MutationScopeHealthRow { target, status, reason: reason.to_string(), detail: detail.map(str::to_string), + remediation: remediation.map(str::to_string), } } @@ -993,6 +1012,35 @@ mod tests { .any(|line| line.contains("Detail: 2 stale attempts"))); } + #[test] + fn blocked_row_renders_remediation_when_present() { + let row = row_with_remediation( + IntegrationTarget::ClaudeCode, + MutationScopeHealthStatus::Blocked, + "stale attempts remain after a failed abandon", + None, + Some("Run 'sce doctor --fix' to recover this state."), + ); + let lines = rendered_lines(&row); + + assert!(lines.iter().any( + |line| line.contains("Remediation: Run 'sce doctor --fix' to recover this state.") + )); + } + + #[test] + fn blocked_row_omits_remediation_line_when_absent() { + let row = row( + IntegrationTarget::ClaudeCode, + MutationScopeHealthStatus::Blocked, + "stale attempts remain after a failed abandon", + None, + ); + let lines = rendered_lines(&row); + + assert!(!lines.iter().any(|line| line.contains("Remediation:"))); + } + #[test] fn invalid_row_maps_to_fail() { let row = row( diff --git a/cli/src/services/doctor/types.rs b/cli/src/services/doctor/types.rs index e256a1f9..1188d31b 100644 --- a/cli/src/services/doctor/types.rs +++ b/cli/src/services/doctor/types.rs @@ -14,6 +14,7 @@ pub(super) struct MutationScopeHealthRow { pub(super) status: MutationScopeHealthStatus, pub(super) reason: String, pub(super) detail: Option, + pub(super) remediation: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -303,6 +304,7 @@ pub(super) enum DoctorDisplayDetail { MutationScopeHealth { reason: String, detail: Option, + remediation: Option, }, } diff --git a/context/context-map.md b/context/context-map.md index f2f1cf00..00e7032b 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -69,7 +69,7 @@ Feature/domain context: - `context/sce/agent-trace-post-commit-dual-write.md` (historical post-commit no-op/dual-write reference; current post-commit behavior is documented in `agent-trace-hooks-command-routing.md`) - `context/sce/agent-trace-hook-doctor.md` (approved operator-environment contract for broadening `sce doctor` into the canonical health-and-repair entrypoint, including stable problem taxonomy — now including `mutation_scope_health` — `--fix` semantics, repository-scoped Agent Trace DB reporting, post-commit Agent Trace auto-sync readiness proof and opt-out behavior, setup-to-doctor alignment rules, canonical Git-hook payload restoration, and the approved downstream human text-mode layout/status/integration contract) - `context/sce/doctor-human-text-contract.md` (implemented compact `sce doctor` human text contract: Environment/Repository/Integrations hierarchy, post-commit Agent Trace auto-sync readiness labels, `[PASS]`/`[WARN]`/`[FAIL]`/`[MISS]` status vocabulary, healthy-row metadata suppression, typed Claude Code/OpenCode/Pi/Codex target and area ordering, configured/detected/empty target resolution, selection-scoped optional-workflow inventory, the Codex hook trust/review reminder, the per-target `Mutation-scope health` row, no-installed-integrations guidance, and JSON as the full-detail route) -- `context/sce/mutation-scope-health-status.md` (the `doctor-mutation-scope-health` plan's T06, extended by the `doctor-mutation-scope-fix` plan's T05: the shared `healthy | recovering | blocked | invalid` vocabulary `sce doctor` consumes from each adapter's own classifier, the `mutation_scope_health` `DoctorProblem` category and severity/fixability/readiness mapping — `invalid` is always `manual_only`; `blocked` now carries a second, adapter-owned `Repairability` fact (`AutoFixable`/`ManualOnly`, `cli/src/services/hooks/mutation_scope_health.rs`) that `sce doctor --fix` (`repair_blocked_mutation_scope_targets`) uses to call Claude's or OpenCode's own `assess_repairability`/`repair_blocked` and, only once a freshly recomputed `classify_health` confirms `healthy`/`recovering`, report the row `fixed`, never trusting the repair call's `Ok(())` alone; a `manual_only` `blocked` row still gets the never-delete-the-state-file manual remediation, and `recovering` is still the distinct `no_action_required` fixability with adapter-neutral remediation; the `DoctorProblem`'s own rendered fixability text does not yet vary by this repairability fact — and the JSON/text output shape — links out to each adapter's own detailed reachability/reasoning doc) +- `context/sce/mutation-scope-health-status.md` (the `doctor-mutation-scope-health` plan's T06, extended by the `doctor-mutation-scope-fix` plan's T05 and T06: the shared `healthy | recovering | blocked | invalid` vocabulary `sce doctor` consumes from each adapter's own classifier, the `mutation_scope_health` `DoctorProblem` category and severity/fixability/readiness mapping — `invalid` is always `manual_only`; `blocked` now carries a second, adapter-owned `Repairability` fact (`AutoFixable`/`ManualOnly`, `cli/src/services/hooks/mutation_scope_health.rs`) that `sce doctor --fix` (`repair_blocked_mutation_scope_targets`) uses to call Claude's or OpenCode's own `assess_repairability`/`repair_blocked` and, only once a freshly recomputed `classify_health` confirms `healthy`/`recovering`, report the row `fixed`, never trusting the repair call's `Ok(())` alone; a `manual_only` `blocked` row still gets the never-delete-the-state-file manual remediation, and `recovering` is still the distinct `no_action_required` fixability with adapter-neutral remediation; the `DoctorProblem`'s own rendered `fixability`/remediation text (both JSON `problems[].remediation` and the human "Agent tracing" tree row's `Remediation:` line, plus `sce doctor --fix`'s `[manual]` fix-result line) now varies by this same repairability fact instead of a static `manual_only` string, per T06 — and the JSON/text output shape — links out to each adapter's own detailed reachability/reasoning doc) - `context/sce/setup-githooks-install-contract.md` (canonical `sce setup --hooks` install contract for target-path resolution, all-hook non-blocking missing-CLI bootstrap behavior, foreign-hook preservation and managed-block merge/idempotent outcomes, atomic-swap replacement behavior, and doctor-readiness alignment) - `context/sce/setup-no-backup-policy-seam.md` (non-destructive per-asset install policy: config install writes/swaps each embedded asset individually by atomic rename over the destination, without ever unlinking it first, and never removes an integration target directory as a whole, then prunes catalog-derived stale/deselected asset paths and any parent directory left empty by that pruning; required-hook install uses the same per-file stage/atomic-swap choreography and, like the two JSON merge targets, computes its staged content ahead of the swap — a foreign hook's bytes are kept as an exact prefix with the SCE managed block appended; `.claude/settings.json` and `.opencode/opencode.json` are merge targets whose staged content is computed by JSON-merging the generated document into the user's existing one before the shared stage/swap step; no backup creation; a swap failure leaves prior destination content untouched, with deterministic recovery guidance naming the failing asset) - `context/sce/setup-githooks-hook-asset-packaging.md` (compile-time `sce setup --hooks` required-hook template packaging contract, including all-hook non-blocking missing-`sce` install guidance, available-CLI argument forwarding, post-commit-only origin remote lookup plus remote-URL forwarding/fallback behavior, setup-service accessor surface, and current validation posture) diff --git a/context/plans/doctor-mutation-scope-fix.md b/context/plans/doctor-mutation-scope-fix.md index 75fc138b..be4b82c9 100644 --- a/context/plans/doctor-mutation-scope-fix.md +++ b/context/plans/doctor-mutation-scope-fix.md @@ -1390,7 +1390,7 @@ Persist this field in every plan; this is durable plan state, not chat state: - Verify outcomes (corrected): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> `50 passed; 0 failed` (the five surviving T05 regressions plus the new `finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts`); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> `138 passed; 0 failed` (includes the two new T04-correction Invalid-classification regressions); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> `113 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_health` -> `10 passed; 0 failed`; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `nix run .#quint -- typecheck spec/doctor_recovery.qnt` -> clean; `nix run .#quint -- test spec/doctor_recovery.qnt --match '^test.*'` -> `13 passing` (unchanged — this correction required no Quint model change, confirming the implementation was the thing out of sync with the already-correct formal model, not the other way around). - Context synchronization: synced -- [ ] T06: `Human and JSON remediation contract for repaired and manual Agent tracing states` (status:todo) +- [x] T06: `Human and JSON remediation contract for repaired and manual Agent tracing states` (status:done) - Task ID: T06 - Scope: In — `cli/src/services/doctor/{render,fixes,types}.rs`. Fix `build_manual_fix_results` (currently drops `DoctorProblem.remediation` @@ -1428,7 +1428,169 @@ Persist this field in every plan; this is durable plan state, not chat state: Agent tracing ...` for a resolved repair and `[manual] Agent tracing remains blocked. Inspect ''.` for an unresolved one. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` - - Context synchronization: pending + - Completed: 2026-09-21 + - Files changed: `cli/src/services/doctor/inspect.rs` (adds a `mutation_scope_repairability` + dispatch helper, alongside the existing `claude_repairability`/ + `opencode_repairability`; `push_mutation_scope_health_problem` now calls it for a + `Blocked` row and branches its `fixability`/`next_action`/remediation text on the + result instead of hardcoding `ManualOnly`; the function now returns + `Option` (the remediation text it decided, `None` only for `Healthy`) so + the caller can also stamp it onto the row; `inspect_mutation_scope_health` now + threads that return value into a new `MutationScopeHealthRow.remediation` field; + the `ManualOnly`-Blocked and `Invalid` remediation strings are reworded to lead + with the exact sentence doctor's various surfaces now render verbatim + (`"Agent tracing remains blocked. Inspect ''. ..."` / + `"Agent tracing state could not be safely interpreted. Inspect ''. ..."`); + adds `mutation_scope_health_blocked_autofixable_remediation_names_doctor_fix`, + `mutation_scope_health_manual_only_states_name_the_real_state_path_and_never_suggest_deletion`, + `full_report_autofixable_blocked_names_doctor_fix_in_text_and_json`, and + `full_report_fix_mode_human_text_shows_the_manual_detail_line`; strengthens + `repair_blocked_mutation_scope_target_repairs_an_autofixable_claude_state` with an + assertion on the literal `[fixed]` detail-line contract); + `cli/src/services/doctor/types.rs` (adds `remediation: Option` to + `MutationScopeHealthRow`, an internal Rust field not read by + `mutation_scope_health_json` — the JSON array's own field set is untouched; adds + the same field to `DoctorDisplayDetail::MutationScopeHealth`); + `cli/src/services/doctor/render.rs` (the `MutationScopeHealth` display-detail + render arm renders a `Remediation:` line when the field is `Some`; + `mutation_scope_health_node` threads `row.remediation` through; adds + `blocked_row_renders_remediation_when_present` and + `blocked_row_omits_remediation_line_when_absent`, and a `row_with_remediation` + test helper the pre-existing `row` helper now delegates to); + `cli/src/services/doctor/fixes.rs` (`build_manual_fix_results` now calls a new + `manual_fix_detail` helper: for `ProblemCategory::MutationScopeHealth` it uses + `problem.remediation` verbatim as the fix-result detail instead of the generic + `"{summary} Manual remediation is still required."` wrapper every other + manual-only category still gets). + - Result: A `Blocked` Agent-tracing row's remediation is now computed from the + owning adapter's own `assess_repairability` at diagnosis time, not hardcoded to + `ManualOnly`: `AutoFixable` produces `fixability: auto_fixable` / + `next_action: doctor_fix` and remediation text naming `sce doctor --fix` + explicitly (both in the human `Remediation:` line under "Agent tracing" and in + the JSON `problems[].remediation.text`, since `DoctorProblem` already flowed + into JSON unchanged — only the values it now receives are corrected); + `ManualOnly`/`Invalid` rows keep the existing `fixability: manual_only` / + `next_action: manual_steps` contract, now with wording that leads with the exact + persisted state-file path and contains no `delete`/`deleting`/`deleted` wording + anywhere (AC2's literal contract — see the 2026-09-21 correction below; the text + instead tells the user to preserve the persisted state while reviewing the + adapter's recovery model), and that same text is now actually + reachable through the previously-inert `DoctorDisplayDetail::MutationScopeHealth` + tree-row rendering. `sce doctor --fix`'s `[manual]` fix-result line for a + mutation-scope-health target now reads + `"Agent tracing remains blocked. Inspect ''. ..."` (via + `build_manual_fix_results`'s new category-specific branch for a never-attempted + `ManualOnly` target, and via `finalize_mutation_scope_repair_results` — see the + correction below — for an attempted `AutoFixable` target whose repair did not + resolve it) instead of the generic `"{summary} Manual remediation is still + required."`; the `[fixed]` line (`"Recovered Agent tracing (now + : )."`) is unchanged, since `fixed_record_from_recomputed_health` + (T05) already produced it correctly and this task only added a regression + asserting its literal prefix. The `mutation_scope_health[]` JSON array's field set + (`target`/`status`/`reason`/`detail`) and status strings are untouched — the + new `MutationScopeHealthRow.remediation` field exists only as an internal Rust + struct field that `mutation_scope_health_json` never reads (AC7). + - Deviation: `push_mutation_scope_health_problem` and the new + `mutation_scope_repairability` dispatch helper live in `inspect.rs`, not in this + task's literal `Scope: In` file list (`{render,fixes,types}.rs`), because the + task's own `Done when` text explicitly requires + `push_mutation_scope_health_problem` to "gain" fixability-aware remediation, and + no file in the named list owns that call site — `push_mutation_scope_health_problem` + was already the sole place `Blocked`/`Invalid` remediation text and `fixability` + are decided (T03/T04/T05 established `claude_repairability`/ + `opencode_repairability` there for the same reason). This mirrors T04's own + identical precedent of touching `lifecycle.rs` beyond its literal named scope + when `Done when` required it. `build_manual_fix_results`'s remediation-preserving + fix is scoped to the `mutation_scope_health` category only, not generalized to + every `ManualOnly` category: this plan's acceptance criteria (AC1/AC2) and this + task's own `Done when` name only `mutation_scope_health` wording explicitly, and + every other `ManualOnly` category's existing `"{summary} Manual remediation is + still required."` wording is unrelated to this plan's scope — changing it for + categories this plan never inspected (config/hooks/asset problems) would risk + silently changing established wording contracts those categories' own tests may + depend on, with no acceptance criterion requiring it. + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> `56 passed; 0 failed` (50 pre-existing + 6 new: `blocked_row_renders_remediation_when_present`, `blocked_row_omits_remediation_line_when_absent`, `mutation_scope_health_blocked_autofixable_remediation_names_doctor_fix`, `mutation_scope_health_manual_only_states_name_the_real_state_path_and_never_suggest_deletion`, `full_report_autofixable_blocked_names_doctor_fix_in_text_and_json`, `full_report_fix_mode_human_text_shows_the_manual_detail_line`), re-run three times consecutively with no flakes; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/doctor/render.rs cli/src/services/doctor/fixes.rs cli/src/services/doctor/types.rs cli/src/services/doctor/inspect.rs` -> only a pre-existing, unrelated test-fixture nonce (`unique_temp_repository_root`, unchanged by this task), matching AC3's "no staleness use outside unrelated lock-timeout constants." + - Context impact: `sce doctor` (no `--fix`) now states, for a `Blocked` row, either + the literal `sce doctor --fix` remediation (`AutoFixable`) or the real + state-file path with no deletion suggestion (`ManualOnly`) in both human text and + JSON — previously the JSON `problems[]` carried only the static `ManualOnly` + wording regardless of actual repairability, and the human tree rendered no + remediation for "Agent tracing" at all. `sce doctor --fix`'s `[manual]` + fix-result line for this category changed wording (from the generic "Manual + remediation is still required." wrapper to the adapter's own remediation text + leading with the real path); its `[fixed]` line is unchanged. No + `mutation_scope_health[]` JSON field, status string, or `MutationScope*`/ + `mutation_scope_health` Rust name changed (AC7). This is the task named in the + plan's own "Context sync" section as owning + `context/sce/mutation-scope-health-status.md` (the new dynamic-`fixability` + behavior for a `Blocked` problem record), + `context/sce/agent-trace-hook-doctor.md` (remediation now varies by adapter-owned + repairability within the existing initial-diagnosis -> existing-repairs -> + final-diagnosis flow), and `context/sce/doctor-human-text-contract.md` (the new + `Remediation:` line under "Agent tracing") — those three docs still need this + task's own context-synchronization pass (next), which was deferred here from + T03/T04/T05 exactly as those tasks recorded. `context/cli/claude-mutation-scope-integration.md`, + the OpenCode adapter docs, and `context/cli/pi-mutation-scope-integration.md` + describe adapter-internal repair mechanics this task did not touch (T03/T04 + already own their own sync passes for those); no root context file + (`overview.md`/`architecture.md`/`glossary.md`/`patterns.md`/`context-map.md`) + is implicated by this doctor-rendering-layer change alone. + - Context synchronization: synced + - Correction (2026-09-21): Two plan-contract issues found in this task's initial + implementation were fixed without starting T07 or redesigning mutation-scope + recovery: + 1. AC2 requires that no rendered manual remediation string contain `delete`, but + `push_mutation_scope_health_problem`'s `ManualOnly`-`Blocked` and `Invalid` + remediation text literally said `"Do not delete the state file."` / + `"...rather than deleting it."`, and the regressions + (`mutation_scope_health_reports_blocked_as_an_error_and_flips_readiness_not_ready`, + `mutation_scope_health_manual_only_states_name_the_real_state_path_and_never_suggest_deletion`, + `full_report_blocked_regression_is_consistent_across_surfaces`) asserted those + phrases were present, encoding the wrong behavior. Fixed by rewording both + strings in `cli/src/services/doctor/inspect.rs` to name the exact state path + and tell the user to preserve the persisted state while reviewing the + adapter's recovery model, with no `delete`/`deleting`/`deleted` wording, and by + rewriting the three regressions to assert the literal AC2 contract instead + (`remediation.contains(&state_path.display().to_string())` and + `!remediation.to_ascii_lowercase().contains("delete")`). + 2. `build_manual_fix_results` only covers `ProblemFixability::ManualOnly` + problems, so an `AutoFixable` `Blocked` target whose attempted repair left it + still `Blocked`/`Invalid` (repairability is recomputed fresh from adapter + state at push time, independent of whether a repair was attempted, so it stays + `AutoFixable`) produced neither a `Fixed` nor a `Manual` fix result — it simply + disappeared from `Fix results`. Fixed by broadening + `finalize_mutation_scope_repair_results` (renamed its per-target helper from + `fixed_record_from_recomputed_health` to + `mutation_scope_repair_result_from_final_row`, now returning + `DoctorFixResultRecord` unconditionally instead of + `Option`, since every attempted target now produces + exactly one outcome) so a final `Healthy`/`Recovering` row still produces + `Fixed` (unchanged from T05) while a final `Blocked`/`Invalid` row now produces + `Manual`, reusing `MutationScopeHealthRow.remediation` — the same string + already pushed into the final report's matching `DoctorProblem` — as the + `Manual` detail, so the exact state path and no-`delete` wording stay identical + across the `[manual]` fix-result line and the plain-diagnose `Remediation:` + line. `AutoFixable` fixability itself is untouched (AC6/plain-`doctor` + `fixability: auto_fixable` / `next_action: doctor_fix` still stands for a + not-yet-attempted `Blocked` `AutoFixable` state), and `build_manual_fix_results` + still only covers never-attempted `ManualOnly` problems, so there is no overlap + between the two `Manual`-producing paths. Added + `finalize_mutation_scope_repair_results_reports_manual_when_an_attempted_autofixable_repair_stays_blocked` + in `cli/src/services/doctor/inspect.rs`, using a `failing_repair_seam()` test + helper (the same seam-injection pattern `no_op_repair_seam()` already + established) to deterministically force `claude_mutation_scope::repair_blocked` + to leave a proven-`AutoFixable` `Blocked` state unresolved, then asserts the + final row stays `Blocked`, no `Fixed` result is ever produced, and a `Manual` + result is produced whose detail names the exact state path and contains no + `delete` wording. + - Verify: `nix build .#checks.x86_64-linux.cli-tests` -> `1752 passed; 0 failed; 1 + ignored`; `nix build .#checks.x86_64-linux.cli-clippy` -> clean; `nix build + .#checks.x86_64-linux.cli-fmt` -> clean. + - No change to the `mutation_scope_health[]` JSON schema, T05's final-report-only + `Fixed` authorization, adapter repairability rules, owner-death proof, state + locking, boundary locking, recovery generation semantics, durable + `PendingAbandon`, atomic recovery clear, health status strings, or Codex/Pi + behavior. T07 remains todo. - [ ] T07: `Cross-adapter end-to-end regression and formal-model connection` (status:todo) - Task ID: T07 diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 93992214..26fa399b 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -45,7 +45,7 @@ The runtime in `cli/src/services/doctor/mod.rs` exposes the approved doctor comm - integration groups are rendered beneath typed, target-scoped `Claude Code`, `OpenCode`, `Pi`, and `Codex` nodes in deterministic target-specific area order; healthy groups render one concise status row without listing installed files - OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; Claude groups are derived from embedded `.claude` assets (`settings.json` and `hooks/**` under `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, then `commands/**` and `skills/**`); Pi groups are derived from embedded `.pi` assets (`prompts/**` under `Pi prompts`, `skills/**` under `Pi skills`); Codex groups are derived from the embedded Codex catalog (`.agents/skills/**` under `Codex skills`, one row per required `.codex/hooks.json` registration plus `.codex/hooks/**` under `Codex hooks`, the former also gated on Codex's own read-only hook-trust state — see `context/sce/doctor-human-text-contract.md`); generated `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and `config/.agents/**`/`config/.codex/**` trees are not inspected by doctor - repair-mode delegation to `ServiceLifecycle::fix` implementations: `HooksLifecycle::fix` reuses `install_required_git_hooks` for missing hooks directories plus missing, stale, or non-executable required hooks, so repair restores the canonical all-hook non-blocking missing-`sce` guidance, available-CLI argument/failure propagation, and post-commit-only remote forwarding contract; `LocalDbLifecycle::fix`, `AuthDbLifecycle::fix`, and `AgentTraceDbLifecycle::fix` handle bootstrap of missing canonical SCE-owned DB parent directories -- mutation-scope runtime health reporting: for every resolved integration target, doctor consumes that adapter's own `healthy | recovering | blocked | invalid` classifier (never inspecting persisted recovery state itself) and reports it in both output modes; `blocked`/`invalid` are `manual_only` problems that `sce doctor --fix` cannot repair, while `recovering` is a `Warning`-severity, `no_action_required` diagnostic that keeps readiness `ready` and produces no `--fix` result at all — see [mutation-scope-health-status.md](mutation-scope-health-status.md) +- mutation-scope runtime health reporting: for every resolved integration target, doctor consumes that adapter's own `healthy | recovering | blocked | invalid` classifier (never inspecting persisted recovery state itself) and reports it in both output modes; `invalid` is always a `manual_only` problem, `blocked` is `auto_fixable` or `manual_only` depending on the owning adapter's own `assess_repairability` (Claude and OpenCode only — Codex and Pi never classify `blocked`), and `recovering` is a `Warning`-severity, `no_action_required` diagnostic that keeps readiness `ready` and produces no `--fix` result at all — see [mutation-scope-health-status.md](mutation-scope-health-status.md) ## Approved human text-mode contract diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index b2fcf064..bf00c59d 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -96,10 +96,16 @@ classifier reports (`healthy | recovering | blocked | invalid`, see [mutation-scope-health-status.md](mutation-scope-health-status.md)), using the same compact/expand convention as any other row — `[PASS]` (`healthy`) collapses with no reason shown, `[WARN]` (`recovering`) and `[FAIL]` -(`blocked`/`invalid`) expand with a short `Reason:` line and an optional -`Detail:` line. This row is independent of that target's asset-content -checks above it: a target can show `[PASS]` for every asset area while its -`Agent tracing` row is `[WARN]`/`[FAIL]`, or vice versa. +(`blocked`/`invalid`) expand with a short `Reason:` line, an optional +`Detail:` line, and — whenever the underlying `DoctorProblem` carries +remediation text — a `Remediation:` line: for a `blocked` row this is +`Run 'sce doctor --fix' to recover ...` when the owning adapter proves the +state `auto_fixable`, or text naming the exact persisted state-file path +(never suggesting deletion) when it remains `manual_only`; see +[mutation-scope-health-status.md](mutation-scope-health-status.md). This row +is independent of that target's asset-content checks above it: a target can +show `[PASS]` for every asset area while its `Agent tracing` row is +`[WARN]`/`[FAIL]`, or vice versa. Codex's `Hooks` area covers `.codex/hooks/run-sce-or-show-install-guidance.sh` plus one row per required `.codex/hooks.json` registration instead of one diff --git a/context/sce/mutation-scope-health-status.md b/context/sce/mutation-scope-health-status.md index 39f61d28..12e4021e 100644 --- a/context/sce/mutation-scope-health-status.md +++ b/context/sce/mutation-scope-health-status.md @@ -78,7 +78,7 @@ Each non-healthy status produces one `DoctorProblem` in a new | --- | --- | --- | --- | --- | --- | | `Healthy` | none | — | — | — | none | | `Recovering` | `MutationScopeHealthRecovering` | Warning | `no_action_required` | `no_action_required` | overall readiness may remain `ready` | -| `Blocked` | `MutationScopeHealthBlocked` | Error | `manual_only` | `manual_steps` | overall readiness becomes `not_ready` | +| `Blocked` | `MutationScopeHealthBlocked` | Error | `auto_fixable` or `manual_only` (dynamic — see "Repairability" below) | `doctor_fix` or `manual_steps` | overall readiness becomes `not_ready` | | `Invalid` | `MutationScopeHealthInvalid` | Error | `manual_only` | `manual_steps` | overall readiness becomes `not_ready` | `sce doctor --fix` never deletes a state file, clears `recovery_pending`/a @@ -150,14 +150,28 @@ seam call; that serialization is entirely adapter-owned (OpenCode's `AdapterBoundaryLock` around the whole repair; Claude's own per-transaction state lock with no added boundary lock). A `ManualOnly` row is never passed to `repair_blocked` at all and falls through unchanged to the existing -generic manual-result handling, which still renders a deterministic +generic manual-result handling, which renders a deterministic manual-remediation result naming the real adapter state file path, stating plainly that no safe generic recovery command exists for it, and never -recommending deletion of the state file — the `DoctorProblem`'s own -rendered `fixability`/`remediation` text does not yet vary by this -repairability fact (still the shared `manual_only` wording below regardless -of a target's true repairability); that rendering surface is a separate, -later concern from the repair pipeline described here. +recommending deletion of the state file. + +The `DoctorProblem` rendered for a `Blocked` row is itself built from +`assess_repairability`, so its `fixability`/`remediation` text vary with +this same repairability fact rather than always carrying the shared +`manual_only` wording: `AutoFixable` produces `fixability: auto_fixable`, +`next_action: doctor_fix`, and remediation text naming `sce doctor --fix` +explicitly, both in JSON (`problems[].remediation.text`) and in the human +"Agent tracing" tree row's `Remediation:` line; `ManualOnly` keeps +`fixability: manual_only`, `next_action: manual_steps`, and remediation text +leading with the real state-file path and an explicit instruction not to +delete it. `sce doctor --fix`'s own fix-result line for this category uses +the same source text: a repaired target's `[fixed]` line reads `Recovered + Agent tracing (now : ).` from the freshly +recomputed final health row, and an unresolved `ManualOnly`/`Invalid` +target's `[manual]` line reads the adapter's own remediation text verbatim +(leading `Agent tracing remains blocked. Inspect ''. ...`) instead of +the generic `"{summary} Manual remediation is still required."` wrapper +every other manual-only doctor category still uses. `Recovering` is a distinct fixability, `no_action_required`: doctor performs no repair *because none is needed*, not because remediation is merely From e550c799b446ce90f774202672c77af1ecca8465 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 20:43:34 +0200 Subject: [PATCH 14/16] doctor: Prevent duplicate mutation-scope fix results Ensure attempted mutation-scope targets have exactly one fix result, even when a repair ends in a manually recoverable Blocked or Invalid state. Link mutation-scope problems to their targets and exclude attempted targets from the generic manual-result path while preserving never-attempted and unrelated manual diagnostics. Add aggregation regressions covering healthy, recovering, blocked, invalid, and never-attempted outcomes. Record the ownership correction in the doctor mutation-scope plan. Plan: doctor-mutation-scope-fix (T06) Co-authored-by: SCE --- cli/src/services/doctor/fixes.rs | 21 +- cli/src/services/doctor/inspect.rs | 265 ++++++++++++++++++++- cli/src/services/doctor/mod.rs | 6 +- cli/src/services/doctor/types.rs | 1 + context/plans/doctor-mutation-scope-fix.md | 93 +++++++- 5 files changed, 377 insertions(+), 9 deletions(-) diff --git a/cli/src/services/doctor/fixes.rs b/cli/src/services/doctor/fixes.rs index 3bd97b41..f4ecf751 100644 --- a/cli/src/services/doctor/fixes.rs +++ b/cli/src/services/doctor/fixes.rs @@ -1,13 +1,20 @@ use super::types::{ - DoctorFixResultRecord, DoctorProblem, FixResult, ProblemCategory, ProblemFixability, + DoctorFixResultRecord, DoctorProblem, FixResult, IntegrationTarget, ProblemCategory, + ProblemFixability, }; use super::HookDoctorReport; -pub(super) fn build_manual_fix_results(report: &HookDoctorReport) -> Vec { +pub(super) fn build_manual_fix_results( + report: &HookDoctorReport, + attempted_mutation_scope_targets: &[IntegrationTarget], +) -> Vec { report .problems .iter() .filter(|problem| problem.fixability == ProblemFixability::ManualOnly) + .filter(|problem| { + !owned_by_attempted_mutation_scope_repair(problem, attempted_mutation_scope_targets) + }) .map(|problem| DoctorFixResultRecord { category: problem.category, outcome: FixResult::Manual, @@ -16,6 +23,16 @@ pub(super) fn build_manual_fix_results(report: &HookDoctorReport) -> Vec bool { + problem.category == ProblemCategory::MutationScopeHealth + && problem + .mutation_scope_target + .is_some_and(|target| attempted_mutation_scope_targets.contains(&target)) +} + fn manual_fix_detail(problem: &DoctorProblem) -> String { if problem.category == ProblemCategory::MutationScopeHealth { problem.remediation.clone() diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index cd634f31..fe8c11e6 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -457,6 +457,7 @@ fn push_mutation_scope_health_problem( remediation: remediation.clone(), next_action, scope: None, + mutation_scope_target: Some(target), }); Some(remediation) @@ -579,6 +580,7 @@ fn collect_agent_trace_db_health( remediation: problem.remediation.clone(), next_action: problem.next_action, scope: None, + mutation_scope_target: None, }); continue; } @@ -707,6 +709,7 @@ fn inspect_repository_hooks( remediation: String::from("Install an accessible 'git' binary and ensure it is on PATH before rerunning 'sce doctor'."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); return Vec::new(); } @@ -723,6 +726,7 @@ fn inspect_repository_hooks( remediation: String::from("Run 'sce doctor' from a non-bare working tree clone to inspect repo-scoped SCE hook health."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); return Vec::new(); } @@ -737,6 +741,7 @@ fn inspect_repository_hooks( remediation: String::from("Run 'sce doctor' from inside the target repository working tree to inspect repo-scoped SCE hook health."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); return Vec::new(); } @@ -755,6 +760,7 @@ fn inspect_repository_hooks( remediation: String::from("Verify that git repository inspection succeeds and rerun 'sce doctor' inside a non-bare git repository."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); Vec::new() } @@ -846,6 +852,7 @@ fn inspect_repository_integrations( ), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); return Vec::new(); } @@ -1074,6 +1081,7 @@ fn collect_global_state_health( remediation: String::from("Verify that the current platform exposes a writable SCE state directory before rerunning 'sce doctor'."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }), } @@ -1096,6 +1104,7 @@ fn collect_global_state_health( ), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); } } @@ -1114,6 +1123,7 @@ fn collect_global_state_health( remediation: String::from("Verify that the current platform exposes a writable SCE config directory before rerunning 'sce doctor'."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }), } @@ -1135,6 +1145,7 @@ fn collect_global_state_health( ), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); } } @@ -1155,6 +1166,7 @@ fn collect_global_state_health( } #[allow(dead_code)] +#[allow(clippy::too_many_lines)] fn collect_hook_health(directory: &Path, problems: &mut Vec) -> Vec { if !directory.exists() { problems.push(DoctorProblem { @@ -1169,6 +1181,7 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V ), next_action: "doctor_fix", scope: None, + mutation_scope_target: None, }); } else if !directory.is_dir() { problems.push(DoctorProblem { @@ -1183,6 +1196,7 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V ), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); } @@ -1213,6 +1227,7 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V ), next_action: "doctor_fix", scope: None, + mutation_scope_target: None, }); } else if !executable { problems.push(DoctorProblem { @@ -1227,6 +1242,7 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V ), next_action: "doctor_fix", scope: None, + mutation_scope_target: None, }); } @@ -1246,6 +1262,7 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V ), next_action: "doctor_fix", scope: None, + mutation_scope_target: None, }); } @@ -1337,6 +1354,7 @@ fn push_codex_hook_malformed_problems( .to_string(), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1383,6 +1401,7 @@ fn push_codex_hook_policy_blocked_problems( remediation: CODEX_HOOK_POLICY_BLOCKED_REMEDIATION.to_string(), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1426,6 +1445,7 @@ fn push_codex_hook_policy_unknown_problems( .to_string(), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1466,6 +1486,7 @@ fn push_codex_hook_trust_problems( remediation: CODEX_HOOK_TRUST_GUIDANCE.to_string(), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1504,6 +1525,7 @@ fn push_opencode_integration_missing_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1542,6 +1564,7 @@ fn push_opencode_integration_mismatch_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1571,6 +1594,7 @@ fn push_opencode_integration_read_fail_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1610,6 +1634,7 @@ fn push_claude_integration_missing_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1648,6 +1673,7 @@ fn push_claude_integration_mismatch_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1677,6 +1703,7 @@ fn push_claude_integration_read_fail_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1716,6 +1743,7 @@ fn push_pi_integration_missing_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1754,6 +1782,7 @@ fn push_pi_integration_mismatch_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1783,6 +1812,7 @@ fn push_pi_integration_read_fail_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1833,6 +1863,7 @@ fn push_codex_integration_missing_problems( remediation, next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1882,6 +1913,7 @@ fn push_codex_integration_mismatch_problems( remediation, next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1911,6 +1943,7 @@ fn push_codex_integration_read_fail_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1966,6 +1999,7 @@ fn inspect_opencode_plugin_ordering_health( IntegrationTarget::OpenCode, IntegrationArea::Plugins, )), + mutation_scope_target: None, }); } @@ -2009,6 +2043,7 @@ fn inspect_opencode_plugin_registry_health( IntegrationTarget::OpenCode, IntegrationArea::Plugins, )), + mutation_scope_target: None, }); } @@ -2063,6 +2098,7 @@ fn inspect_opencode_asset_presence( IntegrationTarget::OpenCode, IntegrationArea::Plugins, )), + mutation_scope_target: None, }); } @@ -2647,6 +2683,7 @@ fn inspect_hook_content_state( ), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); HookContentState::Unknown } @@ -2657,6 +2694,7 @@ fn inspect_hook_content_state( mod tests { use std::path::PathBuf; + use super::super::fixes::build_manual_fix_results; use super::{ claude_mutation_scope, codex_hook_config, codex_hook_registration_child, codex_hook_trust, collect_claude_integration_groups, collect_codex_integration_groups, @@ -2664,9 +2702,11 @@ mod tests { collect_pi_integration_groups, compute_readiness, inspect_claude_integration_health, inspect_codex_integration_health, inspect_mutation_scope_health, inspect_opencode_plugin_ordering_health, resolve_doctor_integration_targets, - CodexHookPolicyReadiness, HookContentState, IntegrationArea, IntegrationContentState, - IntegrationGroupHealth, IntegrationGroupKey, IntegrationTarget, MutationScopeHealthStatus, - ProblemFixability, ProblemKind, ProblemSeverity, Readiness, + CodexHookPolicyReadiness, DoctorMode, DoctorProblem, HookContentState, HookDoctorReport, + HookPathSource, IntegrationArea, IntegrationContentState, IntegrationGroupHealth, + IntegrationGroupKey, IntegrationTarget, MutationScopeHealthRow, MutationScopeHealthStatus, + PostCommitAutoSyncHealth, PostCommitAutoSyncState, ProblemFixability, ProblemKind, + ProblemSeverity, Readiness, }; use crate::services::config::IntegrationTargetId; use crate::services::hooks::claude_mutation_scope::state::{ @@ -5137,6 +5177,225 @@ mod tests { std::fs::remove_dir_all(&repo).ok(); } + fn minimal_report( + problems: Vec, + mutation_scope_health: Vec, + ) -> HookDoctorReport { + HookDoctorReport { + mode: DoctorMode::Fix, + readiness: compute_readiness(&problems), + state_root: None, + agent_trace_db: None, + repository_root: None, + hook_path_source: HookPathSource::Default, + hooks_directory: None, + post_commit_auto_sync: PostCommitAutoSyncHealth { + state: PostCommitAutoSyncState::NotApplicable, + enabled: false, + source: "test", + config_source: None, + }, + config_locations: Vec::new(), + hooks: Vec::new(), + integration_groups: Vec::new(), + integration_targets_absent: false, + mutation_scope_health, + problems, + } + } + + fn combined_mutation_scope_fix_results( + report: &HookDoctorReport, + attempted_targets: &[IntegrationTarget], + ) -> Vec { + let mut results = super::finalize_mutation_scope_repair_results( + attempted_targets, + &report.mutation_scope_health, + ); + results.extend(build_manual_fix_results(report, attempted_targets)); + results + .into_iter() + .filter(|result| result.category == super::ProblemCategory::MutationScopeHealth) + .collect() + } + + fn attempted_claude_final_rows_and_problems( + repo: &std::path::Path, + after_repair: impl FnOnce(&std::path::Path), + ) -> (Vec, Vec) { + let git_dir = super::resolve_git_dir(repo).expect("resolve git dir"); + + let attempted = super::repair_blocked_mutation_scope_target( + IntegrationTarget::ClaudeCode, + &git_dir, + repo, + no_op_repair_seam(), + ); + assert_eq!( + attempted, + Some(IntegrationTarget::ClaudeCode), + "the autofixable blocked fixture must be attempted" + ); + + after_repair(repo); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(repo), &mut problems); + (final_rows, problems) + } + + #[test] + fn attempted_target_final_healthy_produces_exactly_one_fixed_and_no_manual() { + let repo = init_git_repo_with_claude_target("aggregation-attempted-healthy"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let (final_rows, problems) = attempted_claude_final_rows_and_problems(&repo, |_repo| {}); + let claude_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Healthy); + + let report = minimal_report(problems, final_rows); + let attempted_targets = [IntegrationTarget::ClaudeCode]; + let mutation_results = combined_mutation_scope_fix_results(&report, &attempted_targets); + + assert_eq!(mutation_results.len(), 1, "{mutation_results:?}"); + assert_eq!(mutation_results[0].outcome, super::FixResult::Fixed); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn attempted_target_final_recovering_produces_exactly_one_fixed_and_no_manual() { + let repo = init_git_repo_with_claude_target("aggregation-attempted-recovering"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let (final_rows, problems) = attempted_claude_final_rows_and_problems(&repo, |repo| { + write_claude_mutation_scope_state(repo, &claude_recovering_state()); + }); + let claude_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Recovering); + + let report = minimal_report(problems, final_rows); + let attempted_targets = [IntegrationTarget::ClaudeCode]; + let mutation_results = combined_mutation_scope_fix_results(&report, &attempted_targets); + + assert_eq!(mutation_results.len(), 1, "{mutation_results:?}"); + assert_eq!(mutation_results[0].outcome, super::FixResult::Fixed); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn attempted_target_final_blocked_manual_only_produces_exactly_one_manual_result() { + let repo = init_git_repo_with_claude_target("aggregation-attempted-blocked-manual-only"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let (final_rows, problems) = attempted_claude_final_rows_and_problems(&repo, |repo| { + write_claude_mutation_scope_state(repo, &claude_blocked_state()); + }); + let claude_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Blocked); + let claude_problem = problems + .iter() + .find(|problem| problem.category == super::ProblemCategory::MutationScopeHealth) + .expect("claude problem present"); + assert_eq!(claude_problem.fixability, ProblemFixability::ManualOnly); + + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + let state_path = claude_mutation_scope::state::state_path(&git_dir); + + let report = minimal_report(problems, final_rows); + let attempted_targets = [IntegrationTarget::ClaudeCode]; + let mutation_results = combined_mutation_scope_fix_results(&report, &attempted_targets); + + assert_eq!( + mutation_results.len(), + 1, + "an attempted mutation-scope target must produce exactly one fix result: \ + {mutation_results:?}" + ); + assert_eq!(mutation_results[0].outcome, super::FixResult::Manual); + assert!( + mutation_results[0] + .detail + .contains(&state_path.display().to_string()), + "manual detail must name the exact persisted state path: {}", + mutation_results[0].detail + ); + assert!( + !mutation_results[0] + .detail + .to_ascii_lowercase() + .contains("delete"), + "manual detail must not contain deletion wording: {}", + mutation_results[0].detail + ); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn attempted_target_final_invalid_produces_exactly_one_manual_result() { + let repo = init_git_repo_with_claude_target("aggregation-attempted-invalid"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let (final_rows, problems) = attempted_claude_final_rows_and_problems(&repo, |repo| { + let git_dir = super::resolve_git_dir(repo).expect("resolve git dir"); + let path = claude_mutation_scope::state::state_path(&git_dir); + std::fs::write(&path, b"not json").expect("write malformed state file"); + }); + let claude_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Invalid); + + let report = minimal_report(problems, final_rows); + let attempted_targets = [IntegrationTarget::ClaudeCode]; + let mutation_results = combined_mutation_scope_fix_results(&report, &attempted_targets); + + assert_eq!(mutation_results.len(), 1, "{mutation_results:?}"); + assert_eq!(mutation_results[0].outcome, super::FixResult::Manual); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn never_attempted_manual_only_target_still_produces_exactly_one_manual_result() { + let repo = init_git_repo_with_claude_target("aggregation-never-attempted-manual-only"); + write_claude_mutation_scope_state(&repo, &claude_blocked_state()); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + let claude_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Blocked); + + let report = minimal_report(problems, final_rows); + let attempted_targets: [IntegrationTarget; 0] = []; + let mutation_results = combined_mutation_scope_fix_results(&report, &attempted_targets); + + assert_eq!( + mutation_results.len(), + 1, + "a never-attempted ManualOnly target must still produce exactly one manual result: \ + {mutation_results:?}" + ); + assert_eq!(mutation_results[0].outcome, super::FixResult::Manual); + + std::fs::remove_dir_all(&repo).ok(); + } + #[test] fn full_report_fix_mode_leaves_a_manual_only_claude_blocked_state_untouched() { with_isolated_global_state(|| { diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index 58ee51ad..f8d1c776 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -168,7 +168,10 @@ fn execute_doctor_with_lifecycle_providers( &mutation_scope_repairs, &final_report.mutation_scope_health, )); - fix_results.extend(build_manual_fix_results(&final_report)); + fix_results.extend(build_manual_fix_results( + &final_report, + &mutation_scope_repairs, + )); DoctorExecution { report: final_report, @@ -224,6 +227,7 @@ fn doctor_problem_from_health(problem: HealthProblem) -> DoctorProblem { remediation: problem.remediation, next_action: problem.next_action, scope: None, + mutation_scope_target: None, } } diff --git a/cli/src/services/doctor/types.rs b/cli/src/services/doctor/types.rs index 1188d31b..317b2e2f 100644 --- a/cli/src/services/doctor/types.rs +++ b/cli/src/services/doctor/types.rs @@ -531,6 +531,7 @@ pub(crate) struct DoctorProblem { pub(crate) remediation: String, pub(crate) next_action: &'static str, pub(super) scope: Option, + pub(super) mutation_scope_target: Option, } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/context/plans/doctor-mutation-scope-fix.md b/context/plans/doctor-mutation-scope-fix.md index be4b82c9..809be185 100644 --- a/context/plans/doctor-mutation-scope-fix.md +++ b/context/plans/doctor-mutation-scope-fix.md @@ -1572,9 +1572,15 @@ Persist this field in every plan; this is durable plan state, not chat state: across the `[manual]` fix-result line and the plain-diagnose `Remediation:` line. `AutoFixable` fixability itself is untouched (AC6/plain-`doctor` `fixability: auto_fixable` / `next_action: doctor_fix` still stands for a - not-yet-attempted `Blocked` `AutoFixable` state), and `build_manual_fix_results` - still only covers never-attempted `ManualOnly` problems, so there is no overlap - between the two `Manual`-producing paths. Added + not-yet-attempted `Blocked` `AutoFixable` state). At the time this correction + was written, `build_manual_fix_results` filtered only on + `ProblemFixability::ManualOnly` with no target-attempted exclusion, so an + attempted target whose final row was `Blocked`/`Invalid` and whose + recomputed-fresh fixability also happened to be `ManualOnly` (not just + `AutoFixable`) would in fact overlap with `finalize_mutation_scope_repair_results` + and produce two `Manual` results for the same target — see the second + correction below, which fixes that overlap explicitly; it was not actually + absent as first claimed here. Added `finalize_mutation_scope_repair_results_reports_manual_when_an_attempted_autofixable_repair_stays_blocked` in `cli/src/services/doctor/inspect.rs`, using a `failing_repair_seam()` test helper (the same seam-injection pattern `no_op_repair_seam()` already @@ -1591,6 +1597,87 @@ Persist this field in every plan; this is durable plan state, not chat state: locking, boundary locking, recovery generation semantics, durable `PendingAbandon`, atomic recovery clear, health status strings, or Codex/Pi behavior. T07 remains todo. + - Correction (2026-09-21, second pass): The prior correction's own claim that + `build_manual_fix_results` and `finalize_mutation_scope_repair_results` never + overlap was wrong. `build_manual_fix_results(&final_report)` iterates + `final_report.problems` and emits a `Manual` result for every + `ProblemFixability::ManualOnly` problem regardless of category or whether that + target's repair was ever attempted. An attempted `AutoFixable`-at-diagnosis + target whose final row ends `Blocked`/`Invalid` with `ManualOnly` + fixability (recomputed fresh at push time, independent of the attempt) produced + **two** `Manual` fix results for the same target: one from + `finalize_mutation_scope_repair_results` (which now unconditionally owns every + attempted target's outcome per the prior correction) and a second, duplicate one + from `build_manual_fix_results`. Fixed by making result ownership explicit + instead of deduplicating after the fact: + - `DoctorProblem` gains a `mutation_scope_target: Option` + field (`cli/src/services/doctor/types.rs`), set to `Some(target)` only at the + one call site that pushes a mutation-scope-health problem + (`push_mutation_scope_health_problem` in `inspect.rs`) and `None` at every + other `DoctorProblem` construction site (lifecycle-provider problems in + `mod.rs`, and every non-mutation-scope problem in `inspect.rs`). This gives + each mutation-scope-health `DoctorProblem` a structural link back to the + `IntegrationTarget` it describes — the same identity already carried by its + corresponding `MutationScopeHealthRow.target` — without parsing `summary`, + `remediation`, or comparing adapter names as strings. + - `build_manual_fix_results` (`cli/src/services/doctor/fixes.rs`) now takes a + second parameter, `attempted_mutation_scope_targets: &[IntegrationTarget]`, + and skips any `ManualOnly` problem whose `category` is `MutationScopeHealth` + and whose `mutation_scope_target` is in that slice — those targets' results are + now exclusively owned by `finalize_mutation_scope_repair_results`. A + never-attempted `ManualOnly` mutation-scope problem (never returned by + `repair_blocked_mutation_scope_targets`) is untouched by the new filter and + still produces its `Manual` result here exactly as before. Every non- + mutation-scope `ManualOnly` problem (config/hooks/assets/...) is also + untouched, since the new filter only ever excludes the `MutationScopeHealth` + category. + - The orchestration call site (`execute_doctor_with_lifecycle_providers` in + `mod.rs`) now passes `&mutation_scope_repairs` (the same + `Vec` `repair_blocked_mutation_scope_targets` returned, and + the same slice `finalize_mutation_scope_repair_results` already consumes) as + that second argument, so both functions agree on exactly which targets were + attempted. + - Added five aggregation-layer regressions in + `cli/src/services/doctor/inspect.rs`, each combining + `finalize_mutation_scope_repair_results` and `build_manual_fix_results` the + same way the real orchestration does and asserting the combined + `MutationScopeHealth`-category result count is exactly one: + `attempted_target_final_healthy_produces_exactly_one_fixed_and_no_manual`, + `attempted_target_final_recovering_produces_exactly_one_fixed_and_no_manual`, + `attempted_target_final_blocked_manual_only_produces_exactly_one_manual_result` + (the specific regression case: initial `Blocked` with the sole attempt + `PendingAbandon` — `AutoFixable` — repaired via the existing + `no_op_repair_seam()` helper, then the persisted state is overwritten with + `claude_blocked_state()`, an `Active`-phase attempt with no established + abandon intent, so the final row is `Blocked` with fixability recomputed to + `ManualOnly`; asserts exactly one combined result, that it is `Manual`, that + its detail names the exact persisted state path, and that it contains no + `delete` wording), `attempted_target_final_invalid_produces_exactly_one_manual_result`, + and `never_attempted_manual_only_target_still_produces_exactly_one_manual_result`. + - `collect_hook_health` in `inspect.rs` gained an + `#[allow(clippy::too_many_lines)]` alongside its pre-existing + `#[allow(dead_code)]`: adding the new `mutation_scope_target: None,` field to + its five unrelated `HookRollout`-category `DoctorProblem` literals pushed it + from 100 to 103 lines under `clippy::pedantic`'s `too_many_lines` lint; no + other change to that function. + - Verify: `nix build .#checks.x86_64-linux.cli-tests` -> `1757 passed; 0 failed; + 1 ignored` (1752 pre-existing + 5 new); `nix build + .#checks.x86_64-linux.cli-clippy` -> clean; `nix build + .#checks.x86_64-linux.cli-fmt` -> clean. + - Result ownership is now explicit and total: an attempted mutation-scope + target's fix result is exclusively finalized from its final + `mutation_scope_health` row by `finalize_mutation_scope_repair_results` + (`Healthy`/`Recovering` -> `Fixed`, `Blocked`/`Invalid` -> `Manual`); a + never-attempted `ManualOnly` mutation-scope target's fix result is owned by + the generic `build_manual_fix_results` path; therefore every mutation-scope + target produces at most one fix result, closing the gap the first correction's + "no overlap" claim incorrectly asserted was already closed. + - No change to the `mutation_scope_health[]` JSON schema, adapter repairability + rules, Claude `Clear + PendingAbandon => Invalid`, owner-death proof, state + locks, OpenCode boundary locking, mutation-scope seams, recovery-generation + semantics, atomic recovery clearing, durable `PendingAbandon`, Codex/Pi + behavior, or health status strings. No direct adapter JSON editing from + doctor. T07 remains todo; not started. - [ ] T07: `Cross-adapter end-to-end regression and formal-model connection` (status:todo) - Task ID: T07 From 888f067dcaa6baf8bd07838751cec586f3322ca8 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 23:25:40 +0200 Subject: [PATCH 15/16] doctor: Add cross-adapter mutation-scope recovery regression coverage Exercise the full doctor diagnosis and fix pipeline with Claude and OpenCode states together, proving that an auto-fixable target is repaired while a manual-only target remains safely blocked. Document the corresponding Quint safety invariants and map them to the adapter regression tests. Plan: doctor-mutation-scope-fix (T07) Co-authored-by: SCE --- cli/src/services/doctor/inspect.rs | 280 +++++++++++++++++++- cli/src/services/doctor/mod.rs | 16 +- context/cli/doctor-recovery-safety-model.md | 110 ++++++++ context/context-map.md | 1 + context/plans/doctor-mutation-scope-fix.md | 204 +++++++++++++- 5 files changed, 589 insertions(+), 22 deletions(-) create mode 100644 context/cli/doctor-recovery-safety-model.md diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index fe8c11e6..ba21119e 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -230,16 +230,10 @@ fn inspect_mutation_scope_health( .collect() } -pub(super) fn repair_blocked_mutation_scope_targets( - initial_report: &HookDoctorReport, -) -> Vec { - repair_blocked_mutation_scope_targets_with_seam(initial_report, &mutation_scope_repair_seam) -} - -type MutationScopeRepairSeam<'a> = +pub(super) type MutationScopeRepairSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> anyhow::Result; -fn repair_blocked_mutation_scope_targets_with_seam( +pub(super) fn repair_blocked_mutation_scope_targets_with_seam( initial_report: &HookDoctorReport, seam: MutationScopeRepairSeam<'_>, ) -> Vec { @@ -260,7 +254,7 @@ fn repair_blocked_mutation_scope_targets_with_seam( .collect() } -fn mutation_scope_repair_seam( +pub(super) fn mutation_scope_repair_seam( repository_root: &Path, payload: &str, logger: Option<&dyn Logger>, @@ -4682,6 +4676,14 @@ mod tests { fn run_full_doctor_report( repo: &std::path::Path, mode: super::DoctorMode, + ) -> super::super::DoctorExecution { + run_full_doctor_report_with_seam(repo, mode, &super::mutation_scope_repair_seam) + } + + fn run_full_doctor_report_with_seam( + repo: &std::path::Path, + mode: super::DoctorMode, + mutation_scope_seam: super::MutationScopeRepairSeam<'_>, ) -> super::super::DoctorExecution { let context = FullReportRepoContext { repo_root: repo.to_path_buf(), @@ -4693,6 +4695,7 @@ mod tests { }, repo, &context, + mutation_scope_seam, ) } @@ -5536,4 +5539,263 @@ mod tests { std::fs::remove_dir_all(&repo).ok(); }); } + + fn init_git_repo_with_healthy_claude_and_opencode_targets(label: &str) -> PathBuf { + let repo = init_git_repo(label); + let remote_output = std::process::Command::new("git") + .args([ + "remote", + "add", + "origin", + &format!("https://example.invalid/{label}.git"), + ]) + .current_dir(&repo) + .output() + .expect("git remote add should spawn"); + assert!( + remote_output.status.success(), + "git remote add failed: {}", + String::from_utf8_lossy(&remote_output.stderr) + ); + crate::services::setup::install_required_git_hooks(&repo) + .expect("install canonical git hooks"); + crate::services::setup::run_setup_for_mode( + &repo, + crate::services::setup::SetupMode::NonInteractive( + crate::services::setup::SetupTarget::Claude, + ), + None, + None, + None, + ) + .expect("install canonical Claude integration assets"); + crate::services::setup::run_setup_for_mode( + &repo, + crate::services::setup::SetupMode::NonInteractive( + crate::services::setup::SetupTarget::OpenCode, + ), + None, + None, + None, + ) + .expect("install canonical OpenCode integration assets"); + run_full_doctor_report(&repo, super::DoctorMode::Fix); + repo + } + + fn seed_opencode_manual_only_blocked_state(git_dir: &std::path::Path) { + crate::services::hooks::opencode_mutation_scope::state::seed_attempt_for_tests( + git_dir, + &crate::services::hooks::opencode_mutation_scope::AttemptKey { + session_id: "ses-main".to_string(), + call_id: "call-1".to_string(), + }, + "write", + crate::services::hooks::opencode_mutation_scope::state::AttemptPhase::PendingStart, + ); + } + + #[test] + fn full_report_multi_adapter_diagnose_names_doctor_fix_for_one_target_and_the_real_path_for_the_other( + ) { + with_isolated_global_state(|| { + let repo = init_git_repo_with_healthy_claude_and_opencode_targets( + "full-report-multi-adapter-diagnose", + ); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + seed_opencode_manual_only_blocked_state(&git_dir); + + let execution = run_full_doctor_report(&repo, super::DoctorMode::Diagnose); + assert_eq!(execution.report.readiness, Readiness::NotReady); + let mutation_scope_problems: Vec<_> = execution + .report + .problems + .iter() + .filter(|problem| problem.category == super::ProblemCategory::MutationScopeHealth) + .collect(); + assert_eq!( + mutation_scope_problems.len(), + 2, + "{mutation_scope_problems:#?}" + ); + + let claude_problem = mutation_scope_problems + .iter() + .find(|problem| { + problem.mutation_scope_target == Some(IntegrationTarget::ClaudeCode) + }) + .expect("claude mutation-scope problem present"); + assert_eq!(claude_problem.fixability, ProblemFixability::AutoFixable); + assert_eq!(claude_problem.next_action, "doctor_fix"); + assert!(claude_problem.remediation.contains("sce doctor --fix")); + + let opencode_problem = mutation_scope_problems + .iter() + .find(|problem| problem.mutation_scope_target == Some(IntegrationTarget::OpenCode)) + .expect("opencode mutation-scope problem present"); + assert_eq!(opencode_problem.fixability, ProblemFixability::ManualOnly); + assert_eq!(opencode_problem.next_action, "manual_steps"); + let opencode_state_path = + crate::services::hooks::opencode_mutation_scope::state::state_path(&git_dir); + assert!( + opencode_problem + .remediation + .contains(&opencode_state_path.display().to_string()), + "remediation must name the exact persisted opencode state path: {}", + opencode_problem.remediation + ); + assert!( + !opencode_problem + .remediation + .to_ascii_lowercase() + .contains("delete"), + "manual opencode remediation must not contain deletion wording: {}", + opencode_problem.remediation + ); + + let text = render_text(&execution); + assert!( + text.contains("sce doctor --fix"), + "human text must name 'sce doctor --fix' for the autofixable claude row: {text}" + ); + assert!( + text.contains(&opencode_state_path.display().to_string()), + "human text must name the real opencode state path: {text}" + ); + + let json = render_json(&execution); + assert_eq!(json["readiness"], "not_ready"); + let json_problems = json["problems"].as_array().expect("problems is an array"); + let json_mutation_scope_problems: Vec<_> = json_problems + .iter() + .filter(|problem| problem["category"] == "mutation_scope_health") + .collect(); + assert_eq!( + json_mutation_scope_problems.len(), + 2, + "{json_mutation_scope_problems:?}" + ); + let json_autofixable = json_mutation_scope_problems + .iter() + .find(|problem| problem["fixability"] == "auto_fixable") + .expect("json autofixable problem present"); + assert_eq!(json_autofixable["remediation"]["next_action"], "doctor_fix"); + assert!(json_autofixable["remediation"]["text"] + .as_str() + .expect("remediation text is a string") + .contains("sce doctor --fix")); + let json_manual_only = json_mutation_scope_problems + .iter() + .find(|problem| problem["fixability"] == "manual_only") + .expect("json manual-only problem present"); + assert_eq!( + json_manual_only["remediation"]["next_action"], + "manual_steps" + ); + assert!(json_manual_only["remediation"]["text"] + .as_str() + .expect("remediation text is a string") + .contains(&opencode_state_path.display().to_string())); + + std::fs::remove_dir_all(&repo).ok(); + }); + } + + #[test] + fn full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual() { + with_isolated_global_state(|| { + let repo = init_git_repo_with_healthy_claude_and_opencode_targets( + "full-report-multi-adapter-fix", + ); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + seed_opencode_manual_only_blocked_state(&git_dir); + + let execution = run_full_doctor_report_with_seam( + &repo, + super::DoctorMode::Fix, + no_op_repair_seam(), + ); + + let mutation_scope_results: Vec<_> = execution + .fix_results + .iter() + .filter(|result| result.category == super::ProblemCategory::MutationScopeHealth) + .collect(); + assert_eq!( + mutation_scope_results.len(), + 2, + "{mutation_scope_results:?}" + ); + assert!( + mutation_scope_results.iter().any(|result| { + result.outcome == super::FixResult::Fixed + && result + .detail + .starts_with("Recovered Claude Code Agent tracing (") + }), + "expected a fixed claude result: {mutation_scope_results:?}" + ); + assert!( + mutation_scope_results.iter().any(|result| { + result.outcome == super::FixResult::Manual + && result + .detail + .contains("Agent tracing remains blocked. Inspect '") + }), + "expected a manual opencode result: {mutation_scope_results:?}" + ); + + assert_eq!(execution.report.readiness, Readiness::NotReady); + let final_mutation_scope_problems: Vec<_> = execution + .report + .problems + .iter() + .filter(|problem| problem.category == super::ProblemCategory::MutationScopeHealth) + .collect(); + assert_eq!( + final_mutation_scope_problems.len(), + 1, + "the repaired claude target must no longer appear as a problem in the final \ + report, leaving only the still-blocked opencode target: {final_mutation_scope_problems:#?}" + ); + assert_eq!( + final_mutation_scope_problems[0].mutation_scope_target, + Some(IntegrationTarget::OpenCode) + ); + assert_eq!( + final_mutation_scope_problems[0].fixability, + ProblemFixability::ManualOnly + ); + + let text = render_text(&execution); + assert!( + text.contains("[fixed] Recovered Claude Code Agent tracing ("), + "human fix results must show the claude '[fixed] Recovered ...' line: {text}" + ); + assert!( + text.contains("[manual] Agent tracing remains blocked. Inspect '"), + "human fix results must show the opencode '[manual] Agent tracing remains \ + blocked ...' line: {text}" + ); + + let opencode_health = + crate::services::hooks::opencode_mutation_scope::health::classify_health(&git_dir); + assert_eq!( + opencode_health.status, + MutationScopeHealthStatus::Blocked, + "a ManualOnly opencode target must remain untouched and still blocked" + ); + let claude_health = claude_mutation_scope::health::classify_health(&git_dir); + assert_ne!( + claude_health.status, + MutationScopeHealthStatus::Blocked, + "a reported fixed claude target must never leave the target still blocked" + ); + assert_ne!(claude_health.status, MutationScopeHealthStatus::Invalid); + + std::fs::remove_dir_all(&repo).ok(); + }); + } } diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index f8d1c776..4771c33e 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -24,7 +24,8 @@ pub mod command; use fixes::build_manual_fix_results; use inspect::{ build_report_with_lifecycle_problems, finalize_mutation_scope_repair_results, - repair_blocked_mutation_scope_targets, repair_merge_target_configs, + mutation_scope_repair_seam, repair_blocked_mutation_scope_targets_with_seam, + repair_merge_target_configs, MutationScopeRepairSeam, }; use render::render_report; use types::{ @@ -88,7 +89,12 @@ where setup::ensure_git_repository(¤t_dir).unwrap_or(current_dir) }; let scoped_context = context.with_repo_root(&repository_root); - let execution = execute_doctor_with_context(request, &repository_root, &scoped_context); + let execution = execute_doctor_with_context( + request, + &repository_root, + &scoped_context, + &mutation_scope_repair_seam, + ); render_report(request, &execution) } @@ -96,6 +102,7 @@ fn execute_doctor_with_context( request: DoctorRequest, repository_root: &Path, context: &impl HasRepoRoot, + mutation_scope_seam: MutationScopeRepairSeam<'_>, ) -> DoctorExecution { execute_doctor_with_lifecycle_providers( request, @@ -111,6 +118,7 @@ fn execute_doctor_with_context( validate_config_file: &crate::services::config::validate_config_file, probe_codex_hook_policy: &codex_hook_policy::probe_default, }, + mutation_scope_seam, ) } @@ -119,6 +127,7 @@ fn execute_doctor_with_lifecycle_providers( repository_root: &Path, context: &impl HasRepoRoot, dependencies: &DoctorDependencies<'_>, + mutation_scope_seam: MutationScopeRepairSeam<'_>, ) -> DoctorExecution { // Probed exactly once per doctor invocation, then reused for every // Codex integration inspection below (initial report, `--fix`, and final @@ -151,7 +160,8 @@ fn execute_doctor_with_lifecycle_providers( repository_root, &policy_readiness, )); - let mutation_scope_repairs = repair_blocked_mutation_scope_targets(&initial_report); + let mutation_scope_repairs = + repair_blocked_mutation_scope_targets_with_seam(&initial_report, mutation_scope_seam); let final_problems = diagnose_lifecycle_providers(context, &providers); let final_doctor_problems = final_problems .into_iter() diff --git a/context/cli/doctor-recovery-safety-model.md b/context/cli/doctor-recovery-safety-model.md new file mode 100644 index 00000000..f6855150 --- /dev/null +++ b/context/cli/doctor-recovery-safety-model.md @@ -0,0 +1,110 @@ +# Doctor-repair safety model (`spec/doctor_recovery.qnt`) + +Standalone, verified Quint model of the safety pattern behind `sce doctor +--fix`'s repair of a `Blocked` Agent-tracing state (introduced by the +`doctor-mutation-scope-fix` plan's T01; consumed by T03's OpenCode repair, +T04's Claude repair, and T05's doctor orchestration). It does not extend +`spec/mutation_cursor.qnt` — doctor-repair bookkeeping (attempt phases, +adapter-wide recovery/fix state, an abstract owner-liveness oracle, and two +racing actors) is a separate, focused concern from the verified core +mutation-cursor protocol that file owns. + +## What it models + +Two actors race over one adapter's durable state: the adapter's own hook +process (which allocates, advances, and can itself decide to abandon an +attempt) and `sce doctor --fix` (which may additionally attempt a repair, but +only under positive owner-liveness evidence). A `StateTxnHolder` +(`StateTxnFree`/`DoctorStateTxn`) models the short durable-transaction lock +each real adapter releases before every mutation-scope seam call. + +| Abstract concept | Real Claude/OpenCode mapping | +| --- | --- | +| `AttemptPhase = NotAllocated \| PendingStart \| Active \| PendingAbandon \| Removed` | OpenCode's `AdapterAttempt` phase field; Claude's attempt phase, including the `PendingAbandon` phase this plan's T04 added | +| `RecoveryState = Clear \| Pending \| Flushing` (one adapter-wide value, not per-attempt) | OpenCode's `AdapterState.recovery`; Claude's whole-file `recovery_pending` flag/barrier | +| `Health = Healthy \| Recovering \| Blocked \| Invalid` via `adapterHealth(phase, recovery)` | each adapter's own `classify_health`, computed from every attempt's phase plus the one shared recovery fact — never from a single attempt in isolation | +| `FixState = NotAttempted \| RepairAttempted \| RepairCompleted \| ReportedFixed \| ReportedManual` (adapter-wide) | doctor's one fix-result lifecycle per adapter target (`assess_repairability` -> `repair_blocked` -> re-diagnose -> one `DoctorFixResultRecord`) | +| `OwnerReading = SeenAlive \| SeenDead \| SeenUnknown` | `mutation_scope_owner::is_definitely_dead`'s `Alive`/`Dead`/`Unknown` result (PID + `/proc` start-time liveness, never time-based) | +| `stateTxn: StateTxnHolder` | OpenCode's `AdapterStateLock` transaction (and Claude's equivalent short state-file lock) around one re-read/re-prove/durable-transition step — **not** OpenCode's coarser `AdapterBoundaryLock`, which this model has no dedicated variable for (see below) | + +`RecoveryState`/`FixState` are single adapter-wide variables, not +per-attempt: both real adapters report one health value and one fix result +per target, and `begin_terminal_cleanup`/`resolve_recovery`-shaped code +processes every `PendingAbandon` attempt in one shared pipeline, not one at a +time. `completeAbandon` only clears `recovery` to `Clear` once no attempt +remains `PendingAbandon`, so a second obligation can join an in-flight +recovery pipeline without disturbing it. + +**Scope boundary:** `stateTxn` verifies only the short per-transaction lock. +OpenCode's `AdapterBoundaryLock` — which serializes a repair's whole +lifecycle, seam calls included — is a separate, coarser concern outside this +model's variables; T03 is responsible for acquiring it around the whole +`repair_blocked` call, wrapping (not replacing) the `stateTxn`-shaped +transactions this model verifies. Claude has no equivalent boundary lock in +this plan: its repair safety rests on the durable `PendingAbandon` evidence +itself plus its own state-lock transactions. + +## The eight `Safety` invariants + +Each is proven, at minimum, by the Rust regression test(s) named — driving +each adapter's real dispatch/recovery functions, not reading enum names. + +| Invariant | Rust regression coverage | +| --- | --- | +| `DoctorNeverAbandonsALiveOwner` — doctor cannot abandon a potentially live attempt | `opencode_mutation_scope::health::tests::repair_blocked_is_a_safe_no_op_when_the_pending_start_owner_is_live`, `opencode_mutation_scope::health::tests::assess_repairability_is_manual_only_when_the_pending_start_owner_is_live` | +| `UnknownOwnerNeverProvesDeath` — an unprovable/unknown owner is never proof of death | `opencode_mutation_scope::health::tests::assess_repairability_is_manual_only_for_a_legacy_pending_start_attempt_with_no_recorded_owner`; `doctor::inspect::tests::full_report_multi_adapter_diagnose_names_doctor_fix_for_one_target_and_the_real_path_for_the_other` (an unowned OpenCode `PendingStart` stays `ManualOnly` in the same report where Claude's proven-dead-equivalent state is `AutoFixable`) | +| `RecoveryNeverClearedWithUnresolvedAbandon` — doctor cannot clear recovery while unresolved lifecycle evidence exists | `claude_mutation_scope::health::tests::repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails` | +| `NoOrdinaryTransitionProducesInvalidHealth` — `Invalid` is reserved for structurally impossible states, never an ordinary transition's output | `claude_mutation_scope::health::tests::clear_recovery_with_pending_abandon_is_invalid`, `claude_mutation_scope::health::tests::invalid_takes_priority_over_blocked_in_mixed_impossible_state`, `opencode_mutation_scope::health::tests::clear_recovery_with_a_pending_abandon_attempt_is_a_structurally_impossible_state_classified_invalid` | +| `DoctorRepairProducesOnlyOrdinaryLifecycleShapes` — a doctor repair cannot produce a state outside the adapter's own reachable transition set | `opencode_mutation_scope::health::tests::repair_blocked_clears_a_dead_owner_pending_start_end_to_end`; `claude_mutation_scope::health::tests::repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails` | +| `RemovedAttemptsAreNeverResurrected` — a terminal/removed attempt is never resurrected | `claude_mutation_scope::state::tests::removing_an_already_removed_attempt_is_a_safe_no_op`, `opencode_mutation_scope::state::tests::removing_an_already_removed_attempt_is_a_safe_no_op`, `opencode_mutation_scope::tests::regression_f_start_replay_for_a_pending_abandon_identity_never_reactivates` | +| `InterruptedRecoveryStaysInOrdinaryRetryableState` — an interrupted repair remains fail-closed and retryable | `claude_mutation_scope::health::tests::repair_blocked_interrupted_by_a_failing_seam_leaves_state_a_later_repair_completes_without_duplication`, `opencode_mutation_scope::health::tests::repair_blocked_interrupted_before_the_seam_resolves_leaves_state_the_ordinary_recovery_path_completes_without_duplication` | +| `ReportedFixedExcludesBlockedOrInvalid` — a reported `fixed` repair cannot coincide with a final `Blocked`/`Invalid` health | `doctor::inspect::tests::finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts`; `doctor::inspect::tests::full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual` (Claude's `Fixed` report is derived only from the final, freshly recomputed row, at the same moment the adjacent OpenCode row in that identical final report is still `Blocked`) | + +## Why no Quint-Connect harness + +Unlike [`mutation-trace-quint-connect.md`](mutation-trace-quint-connect.md), +which continuously replays generated Quint traces through a pure Rust +refinement (`mutation_trace::protocol`) of `spec/mutation_cursor.qnt`, no +equivalent trace-replay harness connects `spec/doctor_recovery.qnt` to Rust. +`doctor_recovery.qnt`'s actions (`hookAllocate`, `doctorAttemptRepairWith`, +`recoveryProgress`, `completeAbandon`, ...) correspond to real adapter I/O — +durable state files, OS locks, `/proc` owner liveness, the real +mutation-scope seam — with no equivalent pure core to trace against; +extracting one purely to enable a trace-replay harness would be new +production behavior outside any task's declared scope in this plan. The +table above is the documented-mapping alternative instead, connecting every +`Safety` invariant to at least one real regression test, per the +`doctor-mutation-scope-fix` plan's T07. + +## No comments in the model file itself + +`spec/doctor_recovery.qnt` carries no header comment, per this repository's +standing no-comments-in-code convention. This file's concept-mapping table +and invariant list are the durable substitute for that header, mirroring the +same choice `spec/mutation_cursor.qnt`'s own authors made. + +## Verification + +- `nix run .#quint -- typecheck spec/doctor_recovery.qnt` +- `nix run .#quint -- test spec/doctor_recovery.qnt --match '^test.*'` +- `nix run .#quint -- run spec/doctor_recovery.qnt --invariant=Safety --max-samples=20000 --max-steps=50` + +No dedicated Nix check currently runs `doctor_recovery.qnt` the way +`checks.mutation-trace-quint-connect` runs the mutation-cursor MBT harness — +the commands above are run manually and their results recorded in the +`doctor-mutation-scope-fix` plan's T01/T07 task records. `spec/` is already +included in the Nix build's fileset (see +[mutation-trace-quint-connect.md](mutation-trace-quint-connect.md)'s "CI: two +Nix checks" section), so `nix flake check`'s existing checks build +successfully alongside this file without needing further Nix wiring. + +## Authoritative source + +`spec/doctor_recovery.qnt` remains authoritative for the model itself. See +[mutation-scope-health-status.md](../sce/mutation-scope-health-status.md), +[claude-mutation-scope-health.md](claude-mutation-scope-health.md), and +[opencode-mutation-scope-health.md](opencode-mutation-scope-health.md) for +the concrete `classify_health`/`assess_repairability`/`repair_blocked` +implementations this model verifies the safety pattern of. See +`context/plans/doctor-mutation-scope-fix.md` (T01, T07) for build-out status +and verification-run evidence. diff --git a/context/context-map.md b/context/context-map.md index 00e7032b..3b65cc83 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -70,6 +70,7 @@ Feature/domain context: - `context/sce/agent-trace-hook-doctor.md` (approved operator-environment contract for broadening `sce doctor` into the canonical health-and-repair entrypoint, including stable problem taxonomy — now including `mutation_scope_health` — `--fix` semantics, repository-scoped Agent Trace DB reporting, post-commit Agent Trace auto-sync readiness proof and opt-out behavior, setup-to-doctor alignment rules, canonical Git-hook payload restoration, and the approved downstream human text-mode layout/status/integration contract) - `context/sce/doctor-human-text-contract.md` (implemented compact `sce doctor` human text contract: Environment/Repository/Integrations hierarchy, post-commit Agent Trace auto-sync readiness labels, `[PASS]`/`[WARN]`/`[FAIL]`/`[MISS]` status vocabulary, healthy-row metadata suppression, typed Claude Code/OpenCode/Pi/Codex target and area ordering, configured/detected/empty target resolution, selection-scoped optional-workflow inventory, the Codex hook trust/review reminder, the per-target `Mutation-scope health` row, no-installed-integrations guidance, and JSON as the full-detail route) - `context/sce/mutation-scope-health-status.md` (the `doctor-mutation-scope-health` plan's T06, extended by the `doctor-mutation-scope-fix` plan's T05 and T06: the shared `healthy | recovering | blocked | invalid` vocabulary `sce doctor` consumes from each adapter's own classifier, the `mutation_scope_health` `DoctorProblem` category and severity/fixability/readiness mapping — `invalid` is always `manual_only`; `blocked` now carries a second, adapter-owned `Repairability` fact (`AutoFixable`/`ManualOnly`, `cli/src/services/hooks/mutation_scope_health.rs`) that `sce doctor --fix` (`repair_blocked_mutation_scope_targets`) uses to call Claude's or OpenCode's own `assess_repairability`/`repair_blocked` and, only once a freshly recomputed `classify_health` confirms `healthy`/`recovering`, report the row `fixed`, never trusting the repair call's `Ok(())` alone; a `manual_only` `blocked` row still gets the never-delete-the-state-file manual remediation, and `recovering` is still the distinct `no_action_required` fixability with adapter-neutral remediation; the `DoctorProblem`'s own rendered `fixability`/remediation text (both JSON `problems[].remediation` and the human "Agent tracing" tree row's `Remediation:` line, plus `sce doctor --fix`'s `[manual]` fix-result line) now varies by this same repairability fact instead of a static `manual_only` string, per T06 — and the JSON/text output shape — links out to each adapter's own detailed reachability/reasoning doc) +- `context/cli/doctor-recovery-safety-model.md` (the `doctor-mutation-scope-fix` plan's T01/T07: the standalone, verified `spec/doctor_recovery.qnt` model of the safety pattern behind `sce doctor --fix`'s repair of a `Blocked` Agent-tracing state — its abstract phase/recovery/owner-oracle concepts and their real Claude/OpenCode mappings, its eight `Safety` invariants each connected to at least one Rust regression test rather than a Quint-Connect trace-replay harness, and why that harness was not built for this model) - `context/sce/setup-githooks-install-contract.md` (canonical `sce setup --hooks` install contract for target-path resolution, all-hook non-blocking missing-CLI bootstrap behavior, foreign-hook preservation and managed-block merge/idempotent outcomes, atomic-swap replacement behavior, and doctor-readiness alignment) - `context/sce/setup-no-backup-policy-seam.md` (non-destructive per-asset install policy: config install writes/swaps each embedded asset individually by atomic rename over the destination, without ever unlinking it first, and never removes an integration target directory as a whole, then prunes catalog-derived stale/deselected asset paths and any parent directory left empty by that pruning; required-hook install uses the same per-file stage/atomic-swap choreography and, like the two JSON merge targets, computes its staged content ahead of the swap — a foreign hook's bytes are kept as an exact prefix with the SCE managed block appended; `.claude/settings.json` and `.opencode/opencode.json` are merge targets whose staged content is computed by JSON-merging the generated document into the user's existing one before the shared stage/swap step; no backup creation; a swap failure leaves prior destination content untouched, with deterministic recovery guidance naming the failing asset) - `context/sce/setup-githooks-hook-asset-packaging.md` (compile-time `sce setup --hooks` required-hook template packaging contract, including all-hook non-blocking missing-`sce` install guidance, available-CLI argument forwarding, post-commit-only origin remote lookup plus remote-URL forwarding/fallback behavior, setup-service accessor surface, and current validation posture) diff --git a/context/plans/doctor-mutation-scope-fix.md b/context/plans/doctor-mutation-scope-fix.md index 809be185..8276a3df 100644 --- a/context/plans/doctor-mutation-scope-fix.md +++ b/context/plans/doctor-mutation-scope-fix.md @@ -87,7 +87,7 @@ never performs `remove_attempt()`/direct JSON rewriting itself. ## Acceptance criteria -- [ ] AC1: For a `Blocked` Agent tracing row an adapter proves `auto_fixable`, +- [x] AC1: For a `Blocked` Agent tracing row an adapter proves `auto_fixable`, plain `sce doctor` (no `--fix`) states the literal remediation `Run 'sce doctor --fix' to recover ...` in both human text and the JSON `problems[]` remediation text, and the `DoctorProblem` carries @@ -97,7 +97,7 @@ never performs `remove_attempt()`/direct JSON rewriting itself. `--format json` payload both contain the literal string `sce doctor --fix` and `"fixability":"auto_fixable"` / `"next_action":"doctor_fix"`. -- [ ] AC2: For a `Blocked`/`Invalid` row that remains `manual_only`, both +- [x] AC2: For a `Blocked`/`Invalid` row that remains `manual_only`, both human text and JSON name the exact persisted adapter state-file path (e.g. `/sce/claude-mutation-scope-state.json`) and never suggest deleting it. @@ -105,7 +105,7 @@ never performs `remove_attempt()`/direct JSON rewriting itself. asserts the rendered text and JSON both contain the real `state::state_path(...)` value and that no rendered remediation string contains `delete`. -- [ ] AC3: `sce doctor --fix` never abandons or repairs an attempt without +- [x] AC3: `sce doctor --fix` never abandons or repairs an attempt without positive evidence freshly re-read and re-proven inside the adapter's existing lifecycle-serialization boundary where one exists, and every durable state transition is performed under the adapter state lock. @@ -116,18 +116,18 @@ never performs `remove_attempt()`/direct JSON rewriting itself. infers staleness from a timestamp, file modification time, or elapsed duration. - Validate: `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope cli/src/services/hooks/opencode_mutation_scope cli/src/services/hooks/mutation_scope_owner.rs` finds no staleness use outside unrelated lock-timeout constants; the concurrent-race regressions in T03/T04 pass. -- [ ] AC4: A legacy persisted state file written before this change (no +- [x] AC4: A legacy persisted state file written before this change (no owner evidence, no `PendingAbandon` phase) remains readable and stays `manual_only` when `Blocked` — upgrading SCE never makes an old, ambiguous attempt auto-fixable on its own. - Validate: T03/T04 legacy-fixture regression tests pass. -- [ ] AC5: An interrupted repair (a crash or process kill between any two +- [x] AC5: An interrupted repair (a crash or process kill between any two durable writes the repair introduces) leaves state that a later `sce doctor --fix` completes safely, without ever requiring state deletion and without resurrecting or duplicating a terminal/removed attempt. - Validate: T03/T04 crash-mid-repair regression tests pass. -- [ ] AC6: `sce doctor --fix` never reports a mutation-scope fix result +- [x] AC6: `sce doctor --fix` never reports a mutation-scope fix result `fixed` while the freshly recomputed final `classify_health` result for that target remains `Blocked`/`Invalid`; a final result of `Recovering` is accepted as a successful repair (`Blocked` -> @@ -136,12 +136,12 @@ never performs `remove_attempt()`/direct JSON rewriting itself. repair only reaches `Recovering` (a normal residual recovery step remains) and one where the repair leaves the target still `Blocked` (must not report `fixed`). -- [ ] AC7: The `mutation_scope_health` JSON array's shape +- [x] AC7: The `mutation_scope_health` JSON array's shape (`target`/`status`/`reason`/`detail`) and the `healthy`/`recovering`/ `blocked`/`invalid` status strings are unchanged; no `MutationScope*` Rust type or the `mutation_scope_health` field name is renamed. - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor`; inspect the JSON payload in that test output for the unchanged array shape and status strings. -- [ ] AC8: The stated safety invariants (doctor cannot abandon a +- [x] AC8: The stated safety invariants (doctor cannot abandon a potentially live attempt; an unprovable/unknown owner is never treated as proof of death; doctor cannot clear recovery while unresolved lifecycle evidence exists; concurrent hook-process and @@ -1679,7 +1679,7 @@ Persist this field in every plan; this is durable plan state, not chat state: behavior, or health status strings. No direct adapter JSON editing from doctor. T07 remains todo; not started. -- [ ] T07: `Cross-adapter end-to-end regression and formal-model connection` (status:todo) +- [x] T07: `Cross-adapter end-to-end regression and formal-model connection` (status:done) - Task ID: T07 - Scope: In — one or more command-level integration tests (driving `run_doctor_with_context`/the `sce doctor`/`sce doctor --fix` command @@ -1704,7 +1704,152 @@ Persist this field in every plan; this is durable plan state, not chat state: T01 invariant is traceably connected to at least one Rust test (via Quint-Connect or documented mapping); `nix flake check` passes. - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor`; `nix flake check` - - Context synchronization: pending + - Completed: 2026-09-21 + - Files changed: `cli/src/services/doctor/inspect.rs` (adds two new + command-level regression tests — + `full_report_multi_adapter_diagnose_names_doctor_fix_for_one_target_and_the_real_path_for_the_other` + and + `full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual` + — plus their fixtures + `init_git_repo_with_healthy_claude_and_opencode_targets` and + `seed_opencode_manual_only_blocked_state`; widens + `MutationScopeRepairSeam`, `mutation_scope_repair_seam`, and + `repair_blocked_mutation_scope_targets_with_seam` from private to + `pub(super)` and removes the now-redundant seam-defaulted + `repair_blocked_mutation_scope_targets` wrapper it replaces; adds a + seam-parameterized `run_full_doctor_report_with_seam` test helper that + `run_full_doctor_report` now delegates to); + `cli/src/services/doctor/mod.rs` (threads a new + `mutation_scope_seam: MutationScopeRepairSeam<'_>` parameter through + `execute_doctor_with_context`/`execute_doctor_with_lifecycle_providers`, + with `run_doctor_with_context` — the sole production caller — passing + the real `mutation_scope_repair_seam`; the mutation-scope repair + dispatch call site now reads + `repair_blocked_mutation_scope_targets_with_seam(&initial_report, + mutation_scope_seam)`). + - Result: Two new command-level regression tests drive the full + `execute_doctor_with_context`/`execute_doctor_with_lifecycle_providers` + pipeline (initial diagnosis -> existing repairs -> + `repair_blocked_mutation_scope_targets_with_seam` -> final diagnosis -> + `finalize_mutation_scope_repair_results` -> + `build_manual_fix_results`) against one repository seeded with a Claude + `AutoFixable` `Blocked` state (`claude_autofixable_blocked_state()`) and + an OpenCode `ManualOnly` `Blocked` state (a `PendingStart` attempt with + no recorded owner) at once — the first tests in this plan to exercise + T03/T04/T05/T06 together through the top-level command surface rather + than through `inspect.rs`'s per-adapter helpers. + `full_report_multi_adapter_diagnose_names_doctor_fix_for_one_target_and_the_real_path_for_the_other` + asserts plain `sce doctor`'s human text and `--format json` both name + `sce doctor --fix` for the Claude row (AC1) and the real OpenCode + state-file path with no deletion wording for the OpenCode row (AC2), in + the same report. + `full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual` + runs `sce doctor --fix` against the same seeded repository and asserts: + exactly one `[fixed] Recovered Claude Code Agent tracing (...)` result + and one `[manual] Agent tracing remains blocked. Inspect '...'` result; + the final report contains only the still-`Blocked` OpenCode problem + (Claude's is gone, since it is no longer a problem after being fixed); + OpenCode's persisted state is untouched and still `Blocked`; and + Claude's is never `Blocked`/`Invalid` — proving AC6 holds when one + adapter is genuinely repaired and an unrelated adapter in the same + report stays blocked. + - Deviation: driving Claude's real repair through the production seam + (`hooks::mutation_scope::run_mutation_scope_from_payload`) inside + `full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual` + hit the exact test-infrastructure characteristic T05 already documented + and worked around: the credential-store-backed Agent Trace DB + encryption key does not reliably survive a second real, encrypted + per-repository database being created in the same test process, + surfacing as `AbandonScopeError::AgentTraceDbUnavailable` and a + deterministic (not intermittent, in this case) `NoOp` repair outcome — + confirmed by direct diagnosis (a throwaway instrumented test, removed + before this record) before any fix was applied. Following T05's own + precedent exactly (injecting a fake seam so dispatch-level logic is + tested deterministically, while T03/T04's own adapter suites prove the + real seam separately), this task widens the seam-injection pattern one + level up the call stack: `MutationScopeRepairSeam`, + `mutation_scope_repair_seam`, and + `repair_blocked_mutation_scope_targets_with_seam` become `pub(super)`, + `execute_doctor_with_context`/`execute_doctor_with_lifecycle_providers` + take an explicit seam parameter instead of hardcoding the production + one internally, and the now-redundant seam-defaulted + `repair_blocked_mutation_scope_targets` wrapper is removed rather than + left as dead code. Production behavior is unchanged — the sole + production caller (`run_doctor_with_context`) always passes the real + `mutation_scope_repair_seam` — this is a testability-only + parameterization, not new production behavior. The new + diagnose-mode test needs no seam and is unaffected (Diagnose mode never + calls the repair step). The 15 pre-existing `full_report_*` tests + (single-adapter, mostly Diagnose-mode or a `ManualOnly`/no-op Fix-mode + path that also never reaches the seam) are unaffected: `run_full_doctor_report` + keeps its original signature and now delegates to + `run_full_doctor_report_with_seam(repo, mode, &mutation_scope_repair_seam)`, + so nothing about their behavior changed — confirmed by their unchanged + pass/fail outcome and by `claude_mutation_scope`/`opencode_mutation_scope`'s + own suites (138/113 passed, matching T05/T06's last recorded counts) + below. + - Formal-model connection: a Quint-Connect harness mirroring + `cli/src/services/mutation_trace/mbt/` was not built. That harness + traces a pure Rust refinement (`mutation_trace::protocol`) of its Quint + model action-for-action; `spec/doctor_recovery.qnt`'s actions + (`hookAllocate`, `doctorAttemptRepairWith`, `recoveryProgress`, + `completeAbandon`, ...) correspond to real adapter I/O — durable state + files, OS locks, `/proc` owner liveness, the real mutation-scope seam — + with no equivalent pure core to trace against; extracting one would be + new production behavior this task's own scope excludes. This task + instead uses the documented-mapping option T01 itself explicitly + allows, following the same "no comments in code, the plan is the + header comment" precedent T01 already established for this exact file + (this repository's own standing convention). Each of `spec/doctor_recovery.qnt`'s + eight `Safety` invariants is traceably proven by at least one existing + or new Rust regression test: + - `DoctorNeverAbandonsALiveOwner`: + `opencode_mutation_scope::health::tests::repair_blocked_is_a_safe_no_op_when_the_pending_start_owner_is_live`, + `opencode_mutation_scope::health::tests::assess_repairability_is_manual_only_when_the_pending_start_owner_is_live`. + - `UnknownOwnerNeverProvesDeath`: + `opencode_mutation_scope::health::tests::assess_repairability_is_manual_only_for_a_legacy_pending_start_attempt_with_no_recorded_owner`; + this task's new + `doctor::inspect::tests::full_report_multi_adapter_diagnose_names_doctor_fix_for_one_target_and_the_real_path_for_the_other` + (an unowned OpenCode `PendingStart` stays `ManualOnly` in the very + same report where Claude's proven-dead-equivalent state is + `AutoFixable`). + - `RecoveryNeverClearedWithUnresolvedAbandon`: + `claude_mutation_scope::health::tests::repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails`. + - `NoOrdinaryTransitionProducesInvalidHealth`: + `claude_mutation_scope::health::tests::clear_recovery_with_pending_abandon_is_invalid`, + `claude_mutation_scope::health::tests::invalid_takes_priority_over_blocked_in_mixed_impossible_state`, + `opencode_mutation_scope::health::tests::clear_recovery_with_a_pending_abandon_attempt_is_a_structurally_impossible_state_classified_invalid`. + - `DoctorRepairProducesOnlyOrdinaryLifecycleShapes`: + `opencode_mutation_scope::health::tests::repair_blocked_clears_a_dead_owner_pending_start_end_to_end`; + `claude_mutation_scope::health::tests::repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails` + (shared with `RecoveryNeverClearedWithUnresolvedAbandon` above). + - `RemovedAttemptsAreNeverResurrected`: + `claude_mutation_scope::state::tests::removing_an_already_removed_attempt_is_a_safe_no_op`, + `opencode_mutation_scope::state::tests::removing_an_already_removed_attempt_is_a_safe_no_op`, + `opencode_mutation_scope::tests::regression_f_start_replay_for_a_pending_abandon_identity_never_reactivates`. + - `InterruptedRecoveryStaysInOrdinaryRetryableState`: + `claude_mutation_scope::health::tests::repair_blocked_interrupted_by_a_failing_seam_leaves_state_a_later_repair_completes_without_duplication`, + `opencode_mutation_scope::health::tests::repair_blocked_interrupted_before_the_seam_resolves_leaves_state_the_ordinary_recovery_path_completes_without_duplication`. + - `ReportedFixedExcludesBlockedOrInvalid`: + `doctor::inspect::tests::finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts`; + this task's new + `doctor::inspect::tests::full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual` + (Claude's `Fixed` report is derived only from the final, freshly + recomputed row, at the same moment the adjacent OpenCode row in that + identical final report is still `Blocked`). + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> `64 passed; 0 failed` (56 pre-existing + 2 new multi-adapter regressions; the 6 test names T05/T06 recorded plus this task's own 2 account for all `full_report_*` growth), re-run three times consecutively with no flakes; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> `138 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> `113 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_health` -> `12 passed; 0 failed`; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope cli/src/services/hooks/opencode_mutation_scope cli/src/services/hooks/mutation_scope_owner.rs cli/src/services/doctor/*.rs` -> only the pre-existing, unrelated `AdapterStateLock`/`os_lock` timeout deadlines, the static no-TTL-token source scan's own token list, and an unrelated test-fixture nonce (`unique_temp_repository_root`), matching AC3; `nix run .#quint -- typecheck spec/doctor_recovery.qnt` -> clean; `nix run .#quint -- test spec/doctor_recovery.qnt --match '^test.*'` -> `13 passing` (unchanged — this task made no Quint model edit); `nix flake check` -> `all checks passed!` (includes `cli-tests`, `cli-clippy`, `cli-fmt`, and `mutation-trace-quint-connect`). + - Context impact: No user-visible behavior, public interface, persisted + data shape, or architecture-boundary change — this task adds regression + coverage and a formal-model traceability mapping only, plus a + testability-only seam parameter on two already-private `doctor::mod` + functions never called outside this crate. This is the task named in + the plan's own "Context sync" section as the last to run before the + plan's own context-synchronization pass is due; per the plan's own + non-speculative instruction, a new shared doc for "the doctor-repair + invariants" is only warranted if the context-synchronization phase + judges the mapping above substantial enough on its own merits — it is + not created here as part of task execution. + - Context synchronization: synced ## Open questions @@ -1716,3 +1861,42 @@ evidence; OpenCode's does). Where the request itself flagged a genuine implementation choice ("decide from the actual barrier logic", "if the investigation finds..."), the corresponding task scope says so explicitly rather than presenting a false certainty here. + +## Validation Report + +**Status:** validated +**Date:** 2026-09-21 + +### Commands run + +- `nix flake check` -> exit 0 (all checks passed, including `cli-tests`, `cli-clippy`, `cli-fmt`, `mutation-trace-quint-connect`) +- `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope cli/src/services/hooks/opencode_mutation_scope cli/src/services/hooks/mutation_scope_owner.rs` -> exit 0 (only pre-existing `AdapterStateLock`/`os_lock` timeout-deadline `Instant::now()` uses and the static no-TTL-token scan's own token list; no staleness-based repair evidence) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> exit 0 (64 passed; 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> exit 0 (138 passed; 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> exit 0 (113 passed; 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_health` -> exit 0 (12 passed; 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_owner` -> exit 0 (8 passed; 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml pi_mutation_scope` -> exit 0 (94 passed; 0 failed) +- `nix develop -c cargo fmt --manifest-path cli/Cargo.toml -- --check` -> exit 0 (clean) +- `nix run .#quint -- typecheck spec/doctor_recovery.qnt` -> exit 0 (clean typecheck) +- `nix run .#quint -- test spec/doctor_recovery.qnt --match '^test.*'` -> exit 0 (13 passing) +- `nix run .#quint -- run spec/doctor_recovery.qnt --invariant=Safety --max-samples=10000 --max-steps=30` -> exit 0 (`[ok] No violation found`) + +### Success-criteria verification + +- [x] AC1: A `Blocked`/`auto_fixable` row states the literal `sce doctor --fix` remediation in text and JSON, with `fixability: auto_fixable` / `next_action: doctor_fix` -> `full_report_autofixable_blocked_names_doctor_fix_in_text_and_json` (cli/src/services/doctor/inspect.rs) asserts both the rendered text and the `--format json` payload contain `sce doctor --fix`, `"fixability":"auto_fixable"`, and `"next_action":"doctor_fix"`; passing in the `doctor` test run above. +- [x] AC2: A `manual_only` `Blocked`/`Invalid` row names the real state-file path and never suggests deletion -> `mutation_scope_health_manual_only_states_name_the_real_state_path_and_never_suggest_deletion` and `full_report_blocked_regression_is_consistent_across_surfaces` (cli/src/services/doctor/inspect.rs) assert the rendered text/JSON contain the real `state::state_path(...)` value and no `delete` wording; passing in the `doctor` test run above. +- [x] AC3: No repair infers staleness from time; every durable transition runs under the adapter state lock, never across a seam call -> the grep above finds no staleness use outside pre-existing lock-timeout deadlines and the static scan's own token list; T03/T04 concurrent-race regressions (`opencode_mutation_scope`, `claude_mutation_scope` suites) pass. +- [x] AC4: A legacy persisted state file (no owner evidence, no `PendingAbandon`) stays `manual_only` when `Blocked` -> T03/T04 legacy-fixture regressions pass within the `opencode_mutation_scope`/`claude_mutation_scope` suites above (e.g. `assess_repairability_is_manual_only_for_a_legacy_pending_start_attempt_with_no_recorded_owner`). +- [x] AC5: An interrupted repair leaves state a later `sce doctor --fix` completes safely, without deletion or resurrection/duplication -> T03/T04 crash-mid-repair regressions pass within the suites above (e.g. `repair_blocked_interrupted_by_a_failing_seam_leaves_state_a_later_repair_completes_without_duplication`, `repair_blocked_interrupted_before_the_seam_resolves_leaves_state_the_ordinary_recovery_path_completes_without_duplication`). +- [x] AC6: `sce doctor --fix` never reports `fixed` while the freshly recomputed health stays `Blocked`/`Invalid`; `Recovering` is an accepted successful repair -> T05 postcondition regressions pass within the `doctor` suite above, including `finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts` and `finalize_mutation_scope_repair_results_reports_manual_when_an_attempted_autofixable_repair_stays_blocked`. +- [x] AC7: The `mutation_scope_health` JSON array's `target`/`status`/`reason`/`detail` shape and `healthy`/`recovering`/`blocked`/`invalid` status strings are unchanged; no `MutationScope*` type or field renamed -> direct inspection of `MutationScopeHealthRow` (cli/src/services/doctor/types.rs:12-18) and `mutation_scope_health_status` (types.rs:555-562) confirms the field set and status strings are unchanged (only the additive `remediation: Option` field was added); the `doctor` test run above exercises the JSON payload directly. +- [x] AC8: The stated safety invariants are formal and connected to the implementation -> `nix run .#quint -- typecheck spec/doctor_recovery.qnt` and `quint test` (13 passing) above; `quint run --invariant=Safety --max-samples=10000 --max-steps=30` finds no violation; T07's documented-mapping connection ties each of the eight `Safety` invariants to specific passing Rust regressions (recorded in T07's task record). + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- None identified. From 53a1ad14a1758902bc1dde08dc28535bdaea3675 Mon Sep 17 00:00:00 2001 From: David Abram Date: Mon, 21 Sep 2026 23:37:55 +0200 Subject: [PATCH 16/16] release: Bump version to 0.4.0-pre-alpha-v7 --- .version | 2 +- cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- npm/package.json | 2 +- .../flatpak/dev.crocoder.sce.metainfo.xml | 2 +- schema/v0.4.0-pre-alpha-v7/config.json | 465 ++++++++++++++++++ 6 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 schema/v0.4.0-pre-alpha-v7/config.json diff --git a/.version b/.version index 6d32dec3..82683e83 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.4.0-pre-alpha-v6 +0.4.0-pre-alpha-v7 diff --git a/cli/Cargo.lock b/cli/Cargo.lock index f3f3375a..419e5f08 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -3720,7 +3720,7 @@ dependencies = [ [[package]] name = "shared-context-engineering" -version = "0.4.0-pre-alpha-v6" +version = "0.4.0-pre-alpha-v7" dependencies = [ "anyhow", "apple-native-keyring-store", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index c8314dbf..a1e0b134 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shared-context-engineering" -version = "0.4.0-pre-alpha-v6" +version = "0.4.0-pre-alpha-v7" edition = "2021" description = "Shared Context Engineering CLI" license = "Apache-2.0" diff --git a/npm/package.json b/npm/package.json index ba10a7ef..c876d6a0 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@crocoder-dev/sce", - "version": "0.4.0-pre-alpha-v6", + "version": "0.4.0-pre-alpha-v7", "description": "npm launcher package for the Shared Context Engineering CLI", "type": "module", "private": false, diff --git a/packaging/flatpak/dev.crocoder.sce.metainfo.xml b/packaging/flatpak/dev.crocoder.sce.metainfo.xml index b78efc69..6d9165ef 100644 --- a/packaging/flatpak/dev.crocoder.sce.metainfo.xml +++ b/packaging/flatpak/dev.crocoder.sce.metainfo.xml @@ -21,7 +21,7 @@ sce - + diff --git a/schema/v0.4.0-pre-alpha-v7/config.json b/schema/v0.4.0-pre-alpha-v7/config.json new file mode 100644 index 00000000..f06e5994 --- /dev/null +++ b/schema/v0.4.0-pre-alpha-v7/config.json @@ -0,0 +1,465 @@ +{ + "$id": "https://sce.crocoder.dev/v0.4.0-pre-alpha-v7/config.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCE Config", + "description": "Canonical JSON Schema for global and repo-local sce/config.json files.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "const": "https://sce.crocoder.dev/v0.4.0-pre-alpha-v7/config.json" + }, + "log_level": { + "description": "Minimum severity of log records.", + "default": "error", + "type": "string", + "enum": [ + "error", + "warn", + "info", + "debug" + ] + }, + "log_format": { + "description": "Format used for log records.", + "default": "text", + "type": "string", + "enum": [ + "text", + "json" + ] + }, + "log_to_file": { + "description": "Write log records to the configured log directory.", + "default": true, + "type": "boolean" + }, + "log_dir": { + "description": "Directory for log files when file logging is enabled.", + "default": "/sce/logs", + "type": "string", + "minLength": 1 + }, + "log_file_retention_limit": { + "description": "Maximum number of log files retained.", + "default": 10, + "type": "integer", + "minimum": 1 + }, + "workos_client_id": { + "description": "WorkOS client ID used for authentication.", + "type": "string" + }, + "control_plane_base_url": { + "description": "Base URL of the control-plane Agent Trace ingestion API used by `sce trace sync`.", + "type": "string", + "minLength": 1 + }, + "agent_trace": { + "description": "Agent Trace repository identity configuration. Selects the repository-scoped Agent Trace database.", + "type": "object", + "properties": { + "repository_id": { + "description": "Explicit repository identity. When set, overrides Git remote based repository identity resolution.", + "type": "string", + "minLength": 1 + }, + "repository_remote": { + "description": "Git remote name used to derive repository identity.", + "default": "origin", + "type": "string", + "minLength": 1 + }, + "auto_sync": { + "description": "Launch a detached, best-effort `sce sync` after successful post-commit Agent Trace persistence.", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "policies": { + "description": "Policy configuration for SCE runtime and bash-tool enforcement.", + "type": "object", + "properties": { + "attribution_hooks": { + "description": "Policy for commit-msg attribution hooks.", + "type": "object", + "properties": { + "enabled": { + "description": "Enable SCE attribution hooks.", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "database_retry": { + "description": "Per-database retry policy overrides for local, Agent Trace, and auth databases.", + "type": "object", + "properties": { + "local_db": { + "description": "Retry policy overrides for a database's connection and query operations.", + "type": "object", + "properties": { + "connection_open": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + }, + "query": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + } + }, + "additionalProperties": false + }, + "agent_trace_db": { + "description": "Retry policy overrides for a database's connection and query operations.", + "type": "object", + "properties": { + "connection_open": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + }, + "query": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + } + }, + "additionalProperties": false + }, + "auth_db": { + "description": "Retry policy overrides for a database's connection and query operations.", + "type": "object", + "properties": { + "connection_open": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + }, + "query": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "bash": { + "description": "Bash-tool command blocking policy configuration.", + "type": "object", + "properties": { + "presets": { + "description": "Built-in bash-tool policy preset IDs to enable.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "forbid-git-all", + "forbid-git-commit", + "use-pnpm-over-npm", + "use-bun-over-npm", + "use-nix-flake-over-cargo" + ] + }, + "uniqueItems": true, + "allOf": [ + { + "not": { + "allOf": [ + { + "contains": { + "const": "use-pnpm-over-npm" + } + }, + { + "contains": { + "const": "use-bun-over-npm" + } + } + ] + } + } + ] + }, + "custom": { + "description": "Repository-defined bash-tool policies with custom matching and messages.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "Unique identifier for the custom bash policy.", + "type": "string", + "minLength": 1, + "not": { + "enum": [ + "forbid-git-all", + "forbid-git-commit", + "use-pnpm-over-npm", + "use-bun-over-npm", + "use-nix-flake-over-cargo" + ] + } + }, + "match": { + "description": "Command matching rule for the custom bash policy.", + "type": "object", + "properties": { + "argv_prefix": { + "description": "Leading command arguments that activate the policy.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + } + }, + "additionalProperties": false, + "required": [ + "argv_prefix" + ] + }, + "satisfied_by": { + "description": "Wrapper argv prefixes that already satisfy this policy. The policy does not fire when the matched command was unwrapped from one of these wrappers, so a policy steering `rg` toward nix can stay quiet for `nix shell nixpkgs#ripgrep -c rg ...` while still blocking a bare `rg`.", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + } + }, + "message": { + "description": "User-facing message shown when the policy blocks a command.", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false, + "required": [ + "id", + "match", + "message" + ] + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "integrations": { + "description": "Integration targets and optional workflows selected for SCE setup.", + "type": "object", + "properties": { + "target": { + "description": "Integration targets into which SCE assets are installed.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "opencode", + "claude", + "pi", + "codex" + ] + }, + "uniqueItems": true + }, + "optional_workflows": { + "description": "Optional workflows selected for installation into the configured integration targets. Only workflows marked optional in the canonical workflow catalog participate; core workflows are always installed.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "brownfield" + ] + }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +}