# AUTOPILOT-50 — the "stuck" review tick fires without checking liveness

## The symptom

`reviewTick(now, "stuck")` (`src/core.ts`) is the periodic timer/activate
safety net for items sitting in `ai-review` or `human-review`: "the
orchestrator has no other way to know a reviewer finished" (its own doc
comment). It fires the same procedure text —

> `[orch-tick: review] AI-review in flight: <keys> — check each: if its
> reviewer finished, read the verdict … if the reviewer is still running,
> leave it (its completion will notify); if it died, re-dispatch. …`

— **unconditionally**, the moment `store.items` has ANY item with
`status === "ai-review"`. It never looks at whether a reviewer is actually
running. Observed 5+ times in one session, including once with autopilot OFF
(the tick had been generated earlier and only delivered late — see "gating"
below). Every one of those fires spent an orchestrator turn re-confirming
something the queue already knew: the reviewer was still alive and its own
completion event would notify normally.

## Contrast: runs that DID notify vs runs that die silently

The dead-run family of fixes (AUTOPILOT-47, and the WORKTREE-PRESERVATION-2
line of work) drew exactly this line for the `active` lane:

- **Notifies correctly**: an `active` item whose worker finishes normally —
  the async-complete event fires, `handleAsyncComplete` flips the item and
  ticks. No sweep involvement needed; the event IS the notification.
- **Dies silently**: an `active` item whose worker crashed/was killed OUTSIDE
  the normal completion path — the completion event is lost, and the item
  sits `active` forever with nothing to say so. `deadRunReconcile`
  (AUTOPILOT-47) closes this gap by reading the run's OWN `status.json`
  (`runStateEvidence` in `src/framework/run-liveness.ts`) and treating a
  parsed **terminal** phase as proof of death — while an **undeterminable**
  read (no backend, no run dir, unreadable status, unknown phase) is treated
  as absence of evidence and never flips anything (AUTOPILOT-20's fail-open
  boundary, deliberately NOT reversed for the sweep).

`reviewTick`'s `"stuck"` branch never got this treatment. It has the SAME two
classes of item mixed into one bucket (`status === "ai-review"`):

- items whose reviewer is genuinely still running (the completion event
  WILL fire normally — no action needed, matching the "notifies correctly"
  case above), and
- items whose reviewer already died, or was never dispatched at all (the
  completion event is lost or will never come — matching the "dies silently"
  case, and structurally identical to what `deadRunReconcile` fixed for
  `active` items).

The tick's own wording already _describes_ this split ("if the reviewer is
still running, leave it… if it died, re-dispatch") but never CHECKS which is
true — every aiReview key is always listed as needing "check each," even the
ones that are provably fine.

## Why this reads as "ticks are not gated correctly"

Because the check is purely on queue STATUS, not on run liveness, the tick
fires on every timer/activate sweep as long as any item sits in ai-review —
independent of whether anything changed, and independent of whether the
run behind it needs attention. Combined with the deferred-delivery path
(`framework/runner.ts`'s `deferred` queue + `flushDeferred`/`flushHarness`,
which intentionally deliver held ticks at the next settle/timer REGARDLESS of
the current `enabled()` state, so a queued notice is never silently lost) a
review tick generated moments before autopilot went OFF can still be
delivered afterward. The `"stuck"` tick's content itself was already stale by
construction — it never encoded whether anything needed a look — so a
liveness-blind message showing up post-OFF is the same root defect wearing a
timing coincidence, not a second bug: fix the content check and the
after-OFF delivery of a truly-nothing-to-do tick becomes moot because the
tick is never generated with nothing to do in the first place. (The
deferred-delivery mechanism's OWN unconditional-after-OFF delivery is
deliberate design — see `refreshTickFacts`'s doc comment: "review ticks pass
through untouched" — and is intentionally NOT touched by this fix; only
"review" tick's own construction is.)

## The fix

Extend AUTOPILOT-47's terminal-status distinction (`runStateEvidence`) into
`reviewTick`'s `"stuck"` branch instead of adding new polling:

- the caller (`framework/runner.ts`, the same timer/activate site that
  already calls `reviewTick(undefined, "stuck")`) now passes a
  `runEvidence` callback built from `runStateEvidence(opts.backend, runId)`
  — the SAME function `deadRunReconcile` already uses, at the SAME trigger
  points that already run (no new schedule, no new polling loop);
- `reviewTick` filters `ai-review` items down to the ones that are NOT
  provably `in-flight`: no `reviewerRunId` yet (never dispatched), a
  `terminal` run (the reviewer's own completion event was lost — the exact
  dead-run shape), or `undeterminable` (AUTOPILOT-20's fail-open boundary:
  never treated as proof anything is fine OR dead — it stays IN the nudge,
  which is the safe/cheap direction: an extra check costs a turn, a missed
  one costs silence);
- when nothing survives that filter AND no `human-review` item is pending,
  the tick returns `null` — **silence instead of an empty procedure**;
- no `runEvidence` supplied (the `"reviewer-completed"` caller, or a
  `"stuck"` caller that cannot resolve run dirs) keeps the OLD unfiltered
  behaviour — this is a strict narrowing of noise, never a new false
  silence for a caller that opts out of the check.

See `src/core.ts` (`reviewTick`), `src/framework/runner.ts` (the call site),
and `test/autopilot.test.ts` for the covering tests.
