# Resolver Spec — daemon-level failure resolution policy

Audience: AI coding agents first. Canonical for `src/resolver.js` (A2), quota handling (X5), flaky-gate rule (W8). Plan context: `docs/plans/2026-07-07-design-gap-handoff.md` PART II/VI/VIII. Taxonomy source of truth: `src/fail-taxonomy.js` — extend it there, NEVER fork class lists here.

## Position in the stack — do NOT conflate two layers

1. **In-run repair ladder** (exists, `src/runner.js`): rung 1 deterministic → rung 2 fixer → rung 3 quarantine. Operates INSIDE a live runner process on a task attempt.
2. **Resolver** (this spec): operates in the DAEMON, on events the runner cannot handle from inside: non-zero runner exit, runner crash/dead pid, watchdog breach, land failure, quota park. Resolver decides whether/how to re-invoke `runplan` or escalate.

Resolver MUST NOT re-implement rung 1/2 logic. If re-invoking the runner would re-enter the ladder correctly, action = `resume` and the ladder does its job.

## Action set (closed — no other values exist)

| action | effect |
|---|---|
| `resume` | re-invoke `runplan <slug> --preset <preset>` (same flags as original launch; `--isolate` preserved). Runner reconciles journal + git itself. |
| `redo-task` | `git branch -D plan/<slug>--<taskId>` + `git worktree remove <wt> --force` (paths from journal), then `resume`. Discards committed draft. |
| `env-repair:<registry-id>` | run ONE named command from the deterministic repair registry (below), then `resume`. Canonical encoding everywhere (table, decision records, LLM answers): the single string `env-repair:<registry-id>` — no parenthesized or split-field form. |
| `reroute` | mark task's next attempt to use next eligible adapter (see X5 rules), then `resume`. |
| `park-retry` | set task `quota-parked` with `retryAt`; daemon re-`resume`s at `retryAt`. Siblings unaffected. |
| `accept-quarantine-continue` | acknowledge exit-4 partial run as terminal; proceed to land/report of non-quarantined work. |
| `escalate` | emit `decision.requested` + notification + handoff bundle (X11). No further automatic action on that scope. |

`weaken-gate` / `edit .warnignore` / `skip-review` are NOT actions. NEVER add them.

## Deterministic policy table

Input: `{failClass, scope, attemptCount}` where `attemptCount` = prior `resolve.decision` records for the same `(taskId|run, failClass)` + 1. Lookup order: exact row → LLM rung (if eligible) → `escalate`.

| failClass | scope | attempt 1 | attempt 2 | attempt ≥3 |
|---|---|---|---|---|
| `dep-provision-failed` | task | env-repair:provision-deps | escalate | — |
| `lockfile-out-of-sync` | task | env-repair:provision-deps | escalate | — |
| `commit-hook-rejected` | task | resume | escalate | — |
| `gate-red-code` | task | resume (ladder owns fixers) | LLM | escalate |
| `gate-red-env` | task | env-repair:provision-deps | LLM | escalate |
| `gate-not-green-after-fixer` | task | accept-quarantine-continue | — | — |
| `fixer-scope-violation` | task | redo-task | escalate | — |
| `merge-conflict` | task | redo-task | escalate | — |
| `wrapper-engine-down` | task | env-repair:adapter-restart | reroute | escalate |
| `wrapper-timeout-repeated` | task | reroute | escalate | — |
| `wrapper-rate-limited` | task | park-retry | park-retry | escalate |
| `provider-quota` | task | reroute | park-retry | escalate |
| `journal-state-unknown` | task | resume | escalate | — |
| `journal-append-failed` | task | resume | escalate | — |
| `runconfig-read-failed` | task | escalate | — | — |
| `registry-auth-missing` | task | escalate | — | — |
| `disk-space-low` | task | env-repair:gc-dry-then-tmp-clean | escalate | — |
| `plan-invalid` | run | escalate | — | — |
| `base-moved` | task | resume | escalate | — |
| `orphan-worktree` | task | resume (runner rung 1 cleans) | escalate | — |
| `journal-corrupt` (integrity/version-skew, see JOURNAL-V2) | run | escalate | — | — |
| `not-a-git-repo` | run | escalate | — | — |
| `fallback-exhausted` | task | escalate | — | — |
| `task-stalled` (new) | task | resume | redo-task | escalate |
| `agent-idle` (new) | task | resume | redo-task | escalate |
| `land-failed` (new) | run | LLM | escalate | — |
| `land-blocked-unreviewed` (new) | run | escalate | — | — |
| `protected-path-touched` (new) | task | escalate | — | — |
| `budget-exceeded` (task) | task | accept-quarantine-continue | — | — |
| `budget-exceeded` (run) | run | escalate (pause first, per V2) | — | — |
| `artifacts-missing` (new) | task | resume (ladder fixer can fix) | escalate | — |
| `adapter-contract-violation` (new) | adapter | reroute + degrade adapter health | escalate | — |
| `unknown` | task | LLM | escalate | — |

Rows marked `—` mean the breaker already tripped; reaching them is a bug — assert in tests.

**`protected-path-touched` division of labor:** the RUNNER quarantines the task in-run (V1 enforcement gate — prevent-band, never fixer-dispatched); the resolver's `escalate` row fires on the resulting exit-4 and means NOTIFY (decision.requested + handoff bundle) — it does not re-quarantine. Chaos C12 asserts both halves.

**Runner crash (no exit, dead pid):** not a failClass — always `resume` attempt 1-2, `escalate` ≥3. Verify pid death via pid + start-time (I5) before treating as crash.

**Flaky-gate rule (W8):** failure classified flaky (identical tree, prior identical failure then pass) → one `resume` that does NOT increment `attemptCount`. Second flake on same gate → `escalate` with kind `fix-flaky-test`.

## Deterministic repair registry (`env-repair` targets)

Named commands ONLY — resolver never composes shell. Registry in `src/resolver.js`, each entry `{id, cmd, timeoutMs, idempotent: true}`:

- `provision-deps`: `bash lib/provision-deps.sh <worktree>`
- `adapter-restart`: adapter's `healthCheck.cmd` then wrapper no-op ping
- `gc-dry-then-tmp-clean`: `runplan gc --dry-run` report + clean `/tmp/wt-*` orphans ONLY if liveness shows no owner

Non-idempotent command in registry = spec violation. Repair failure → journal `repair.failed`, fall through to next attempt column.

## LLM rung

Eligible only where table says `LLM`. One call, one retry on schema-invalid, then `escalate`. Fail-closed: LLM unreachable / invalid / low-confidence → `escalate`.

Prompt template (fill `{}`; keep structure):

```
You are the resolution policy for an unattended CI-like plan runner.
Task failed. Choose EXACTLY one action.

failClass: {failClass}
scope: {scope}
attempt: {attemptCount}
runner stderr (JSON, verbatim): {stderrJson}
journal tail (last 20 records for this task): {journalTail}
task log excerpt (last 100 lines): {logExcerpt}
lessons (OKF, matching failClass): {lessons}

Allowed actions: resume | redo-task | env-repair:<registry-id> | reroute | park-retry | accept-quarantine-continue | escalate
Rules: NEVER choose an action that weakens a gate. If uncertain, choose escalate.
```

Answer schema (reject anything else):

```json
{
  "type": "object",
  "required": ["action", "rationale", "confidence"],
  "properties": {
    "action": {"enum": ["resume","redo-task","env-repair:provision-deps","env-repair:adapter-restart","env-repair:gc-dry-then-tmp-clean","reroute","park-retry","accept-quarantine-continue","escalate"]},
    "rationale": {"type": "string", "maxLength": 300},
    "confidence": {"enum": ["high","medium","low"]}
  },
  "additionalProperties": false
}
```

`confidence: "low"` → treat as `escalate`. Model: cheapest enabled adapter model; cost journals into `task.usage` like any attempt.

## Breakers + persistence

- Per-`(taskId, failClass)` breaker: max 3 resolutions (table encodes it). Trip → quarantine (task scope) or escalate (run scope).
- Per-run global: `budget.maxResolutionsPerRun` (config default 10). Trip → pause run + escalate.
- Backoff before every `resume`: `min(2^attempt * 30s, 10min)` + jitter ±20%.
- State: NO in-memory state survives daemon restart. `attemptCount` recomputed from journal `resolve.decision` records. Every decision journals `resolve.decision {taskId?, failClass, attempt, action, source: "table"|"llm", rationale?}`; every completion journals `resolve.outcome {refDecisionSeq, action, success}` (this payload shape is the one true form — plan text citing `{action, success}` is superseded).

## Failure input contract

- Non-zero runner exit → resolver reads the LAST stderr line; if it parses as JSON `{failClass, scope, message, stack?}` use it directly. Runner does NOT emit this today — making the runner emit a final structured stderr JSON line on every fatal exit is a P2 work item landing WITH the resolver.
- Fallback (older runs / missing JSON line): feed raw stderr text to `classifyFailure` (`src/fail-taxonomy.js`) — identical classification path, lower fidelity detail. Resolver MUST handle both.

## Tests (MUST exist before resolver ships)

1. Table-driven: every row × attempt column → expected action (pure function test).
2. Breaker trip + backoff timing (fake clock).
3. LLM rung: valid answer honored; invalid/timeout/low-confidence → escalate; disabled LLM → table-only path byte-identical.
4. Crash-resume recomputes attemptCount from journal (kill daemon between decisions).
5. Chaos integration: see `spec/CHAOS-SUITE.md` scenarios C2, C3, C6.
