# AUTOPILOT-51 — why `autopilot.jsonl` has never contained a single tick record

## The working writer (nobody built a second one)

`appendTelemetry` (`src/config.ts:52`) is a fully functional, already-used JSONL
writer. `Autopilot.logEvent` (`src/core.ts:911`) calls it through `cfg.log`,
and it correctly produces `started`/`complete`/`flip`/`sweep`/`unmatched-completion`
lines today — `docs/AUTOPILOT-9-verification.md` quotes real
`~/.local/state/orchestrator/personal/autopilot.jsonl` lines for exactly these
types, including **`tick dispatch settled` / `tick dispatch timer`** entries
from that (older) tree.

The pi host wires `cfg.log` correctly:

```ts
// src/hosts/pi-extension.ts:106
log: (line) => {
  if (isAutopilotOn(stateDir, sessionId)) appendTelemetry(stateDir, line);
},
```

So the writer is not broken and is not missing. **The gap is that almost none
of the ticks a running autopilot actually delivers ever reach it.**

## Where a "tick" record can be written today — and where it can't

`Autopilot.logEvent("tick", …)` has exactly two call sites, both inside
`Autopilot.sweep()` (`src/core.ts:479` and `:491`): the engine's own
`decideTick()` result (reason `dispatch` / `intake`) and the budget-warning
tick. That is the *only* code path that logs a `type: "tick"` line.

Everything else the orchestrator actually sees as `[orch-tick: …]` messages is
built directly in `src/framework/runner.ts` and delivered via
`router.send(...)` / `sendTick` / `sendTickText` / `harnessTick` —
**none of which ever call `appendTelemetry` or go anywhere near
`Autopilot.logEvent`**:

- harness ticks (auto-dispatch/auto-review/auto-redispatch summaries)
- recovery ticks (`[orch-tick: recover] …`)
- the fleet-degraded / fleet-recovered narration ticks
- review-stuck ticks (`autopilot.reviewTick`)
- decision-panel move ticks (`decisionTick`, for zombie/dead-run flips)
- config-change ticks (`configChangeTick`)
- main-immutability-violation ticks
- shipping-lane ticks (`runShippingPass`)
- sweep-failure ticks

Two events (`fleet-rpc`, `sweep-failure`) *do* call `appendTelemetry` directly
(`runner.ts:470/478/618`) — proving the runner already knows how to reach the
writer — but nobody wired the same one-line call at the actual tick-delivery
sites.

## Why this only became total silence recently

Before the AUTOPILOT-9 fix, `runner.sweep()` ran `autoDispatchEligible` only
on `worker-done`; `settled`/`timer` sweeps fell through to
`autopilot.sweep()` unchanged, so `decideTick()` routinely saw a free slot +
ready items and returned `reason: "dispatch"` — a **logged** tick (the
historical `tick dispatch settled`/`tick dispatch timer` lines quoted above).

AUTOPILOT-9 made `autoDispatchEligible` run on *every* free-slot window,
**before** `autopilot.sweep()` is called (`runner.ts`, the `A —` block, ahead
of `const result = autopilot.sweep(...)`). By the time `decideTick()` runs,
auto-dispatch has usually already consumed the free-slot/ready-item condition,
so the one code path that ever wrote a `tick` record now fires only for the
residual manual cases (high-risk / incomplete scope, or a genuinely low
intake buffer) — rare in a healthy fleet. Meanwhile the ticks that now carry
the actual operational signal (harness/recovery/review/shipping/etc.) were
never wired to the writer at all. Net effect: `autopilot.jsonl` went from
"ticks logged often" to "ticks logged effectively never," with no code change
that looks like a regression in isolation — the dispatch responsibility moved
out from under the one instrumented path.

## NEW EVIDENCE: a tick fired while autopilot was OFF — same mechanism

`autopilotModeMessage("off", …)` stated the OFF contract in plain terms: *"no
auto flips, no verdict routing, no auto-dispatch/review, **no ticks**."*
`framework/runner.ts` honoured this everywhere it drove a tick: `sweep()` and
`onCompletion()` both opened with `if (!enabled()) return;`, and `enabled()` is
`isAutopilotOn(...)`.

> SUPERSEDED (KEY: AUTOPILOT-120): that blanket gate is gone. The OFF switch now
> stops only the PROACTIVE lane and delivers the INFORMATIONAL ticks; the OFF
> message no longer claims "no ticks". See
> `docs/AUTOPILOT-120-off-tick-scope.md`.

But the pi TUI decision panel does not go through the runner at all. Its
apply handler:

```ts
// src/hosts/pi-panel.ts:186-193
private apply(action: PanelActionId, payload?: {...}): void {
  const item = this.items[this.sel];
  if (!item) return;
  const r = applyPanelDecision(this.opts.stateDir, item.key, action, payload);
  // DECISION TICK first: every applied status move rides the custom-role
  // channel (same as ticks) BEFORE the panel refreshes/closes ...
  if (r.ok && r.tick) this.opts.deliver?.(r.tick);
  ...
```

calls `this.opts.deliver` — which is wired to the **same** `deliverTick`
(`pi.sendMessage({ customType: TICK_TYPE, ... })`, `pi-extension.ts:627`) the
runner uses — **unconditionally**, with no `isAutopilotOn` check. A manual
panel action (approve/dispatch/etc.) fires a real tick over the
`orchestrator-autopilot` channel even in an OFF session, violating the
documented contract.

This is **the same missing-instrumentation mechanism**, not a coincidence:
because no telemetry write exists at the tick *delivery* layer (only inside
`Autopilot.sweep()`'s rare `decideTick` path), this OFF-contract violation
also leaves **zero trace** in `autopilot.jsonl` — it is invisible in the audit
trail exactly like every other tick that goes missing. Instrumenting the
delivery choke point (below) fixes both: it gives `autopilot.jsonl` real tick
records, and it makes an OFF-delivered tick auditable (`autopilotOn: false`)
instead of silently vanishing.

## Fix direction (no second logger)

Instrument the **delivery choke point** each host already has — `deliverTick`
in `src/hosts/pi-extension.ts` and `TickDelivery.deliver` in
`src/hosts/opencode-plugin.ts` — with the *existing* `appendTelemetry` writer.
Both the runner (`createFrameworkRunner({ deliver: deliverTick })`) and the
decision panel (`registerDecisionPanel(pi, { deliver: deliverTick })`) already
funnel through this single function per host, so one call site per host
captures every tick that is actually sent to the orchestrator, from every
source, including the panel's OFF-contract violation.

Implemented as `src/framework/tick-telemetry.ts` (`logTickDelivery`), wired
into both choke points. It writes `type: "tick-delivered"`, deliberately
**not** `"tick"` — `Autopilot.logEvent` already owns that name for the
engine's `decideTick()` decision (`{reason, source}`, no `autopilotOn`); reusing
it would conflate two different shapes under one type string. Each
`tick-delivered` line carries `reason` (parsed from the `[orch-tick: reason]`
prefix), `autopilotOn` (the gate state at delivery — since AUTOPILOT-120 `false`
is the NORMAL shape for an INFORMATIONAL tick, so it is only a finding for an
AUTOMATIC-ACTION tick), and the message text — so both original symptoms now
have a fix: real tick records finally land in `autopilot.jsonl`, and an
OFF-delivered tick is audited instead of vanishing.

## Follow-up: the OFF-delivered tick itself (AUTOPILOT-67)

> SUPERSEDED (KEY: AUTOPILOT-120): gating the panel's tick WHOLESALE while OFF
> was replaced by the PROACTIVE/INFORMATIONAL class split. A `decision` tick is
> INFORMATIONAL, so the panel now DELIVERS it while OFF and writes its
> `tick-delivered` line with `autopilotOn: false` — the OFF case produces BOTH a
> live tick AND a telemetry record, exactly reversing the paragraph below. See
> `docs/AUTOPILOT-120-off-tick-scope.md` and `src/framework/tick-class.ts`.

This fix made the pi-panel bypass *auditable* (`autopilotOn: false` in the
`tick-delivered` line) — it deliberately did not change whether the panel
actually delivers a tick while OFF (out of scope here: the choke point is
instrumentation, not gating). AUTOPILOT-67 closes that remaining gap: the
panel's `apply()` (`hosts/pi-panel.ts`) now takes an `enabled` callback
(`isAutopilotOn(stateDir, sessionId)`, wired from `pi-extension.ts` the same
way `createFrameworkRunner`'s `enabled` is) and gates the `deliver` call on
it — the decision itself (a manual human action) still always applies to the
store; only the tick notice on the orchestrator-autopilot channel was
suppressed while OFF. Because `deliverTick` was skipped entirely in that case,
no `tick-delivered` line was written either — from AUTOPILOT-67 until
AUTOPILOT-120 the OFF case produced neither a live tick nor a telemetry
record. (AUTOPILOT-120 supersedes that: the class split above restores BOTH for
the informational classes.)

## The DEFERRED tick: SILENT, and never a drop (KEY: AUTOPILOT-122)

### The decision

A tick the host knows it cannot inject **right now** — the orchestrator is
mid-turn, auto-compaction is running, or a USER prompt is in pi core's pre-run
window — is **SILENT**. It is a handled deferral, not an alarm: no
`Extension "<runtime>" error:` line, no notify, no chat entry, nothing the
operator has to interpret. What the operator gets instead is the *audited*
signal described above — the `tick-delivered` line is written when the tick is
finally handed to the session, so a deferred tick appears **late**, and a tick
that was never delivered leaves no line at all (that absence is the inspectable
difference between "deferred" and "dropped").

### The contract (stated HERE, once)

1. `deliverTick` (`src/hosts/pi-extension.ts`) is the ONE tick delivery surface.
   Both the runner (`createFrameworkRunner({deliver})`) and the decision panel
   (`registerDecisionPanel({deliver})`) funnel through it, so this contract is
   enforced for every tick from every source.
2. On a deferral the surface **issues no `sendMessage` at all** — it does not
   trigger a turn and does not queue one. It hands the message to
   `runner.deferTick(message)` (the shared deferral, the same one
   `deferUserMessage` uses) and returns a rejected promise, which is the
   re-hold path `deliverWatchingAsync` already implements.
3. The gate is the host's LIVE state: **mid-turn, compacting, or a user prompt
   inside pi core's pre-run window** (that last one is the part-2 window below).
   The runner consults the SAME state through `host.busy()`
   (`src/hosts/pi-extension.ts` — `agentBusy || userPromptInFlight()`), so a
   runner tick is held by the router and never reaches `deliverTick`; the panel
   calls `deliverTick` directly and is held there.
4. **Never a drop.** The hold has no attempt cap: it is flushed at the next
   `onSettled()`/`onTimer()`, and the flush re-applies the LIVE gate
   (interactive/loaded/busy/compacting — including the user-prompt window), the
   AUTOPILOT-87 premise rule, and the AUTOPILOT-120 OFF class rule. The flush
   deliberately does **NOT** re-apply the COOLDOWN: `flushDeferred` passes
   `bypassCooldown: true` (`src/framework/runner.ts`, deliberate — AUTOPILOT-87:
   a held tick is a state change, not a re-firable nudge). The panel path needs
   item 2's `deferTick` in particular: the panel calls this surface directly
   and only best-effort catches the rejection, so without the hold its decision
   tick would be swallowed instead of re-delivered.
   *Boundary:* the runner's bounded retry (`RETRY_MAX_ATTEMPTS`) still governs a
   delivery the host **accepted** and that failed asynchronously. The
   pre-check's hold is the unbounded one; it is the case that must not lose a
   tick.
5. **`tick-delivered` means hand-off, not attempt.** A held tick is logged when
   it is finally delivered, never when it is held — so the log stays a truthful
   record of what the orchestrator was actually told.

### Why silent: the old "handled path" did not exist (measured on pi 0.85.1)

The old comment on `deliverTick` claimed "a SYNC throw propagates → the router
maps it to `deferred` + the runner holds it". That path **does not exist** in
the installed runtime:

- `pi.sendMessage` is bound as
  `this.sendCustomMessage(message, options).catch(err => runner.emitError({ extensionPath: "<runtime>", event: "send_message", error }))`
  (`dist/core/agent-session.js:2011-2017`). It **never throws** and returns
  `void`; the extension never sees the promise. The refusal is printed as
  `Extension "<runtime>" error: Agent is already processing a prompt. Use
  steer() or followUp() to queue messages, or wait for completion.`
- `sendCustomMessage` (`:1099`) honours `deliverAs` ONLY in its
  `this.isStreaming` branch (`:1112-1118`). On the other branch,
  `triggerTurn: true` runs `await this._runAgentPrompt(appMessage)`
  (`:1120-1121`) — a **new turn**.
- `AgentSession.isStreaming` is `_isAgentRunActive` (`:616`), which core sets
  only inside `_runAgentPrompt` (`:773`) — i.e. after the pre-run awaits of a
  user's own `prompt()`.
- `Agent.prompt()` refuses whoever claims second:
  `if (this.activeRun) throw new Error("Agent is already processing a prompt. …")`
  (`@earendil-works/pi-agent-core/dist/agent.js:227-228`), and the refused
  message is recorded **nowhere** — no queueing path is used for it.

So when the idle-reporting window was hit, the result was not "handled +
re-deferred": `deliverTick` issued a triggered send, core reported the refusal
as an alarm-shaped extension error, and the surface never saw it. The fix is to
not provoke the construct at all: while the host cannot safely inject, the
surface does not `triggerTurn`.

> SUPERSEDED in part (KEY: AUTOPILOT-125): the surface does not `triggerTurn`
> AT ALL now, injected or not — see "The NON-TRIGGERING tick" below. The
> deferral described here is still in force on the pre-check.

### The user-input loss (AUTOPILOT-122 part 2)

The same construct costs the user a message, and it is the SAME call path — so
it is closed by the SAME gate.

**Mechanism** (derived from the installed pi 0.85.1, then pinned against the
real `pi-agent-core`). `AgentSession.prompt()` (`agent-session.js:821`) awaits a
long PRE-RUN section before any run exists: the extension-command probe, the
compaction-abort check, `emitInput` (`:843`), template expansion, the streaming
probe, the model/auth check (`:877-882`), `_checkCompaction` (`:895`) and
`emitBeforeAgentStart` (`:915`). `isStreaming` is FALSE for that whole section —
`_isAgentRunActive` is set only at `:773`, inside `_runAgentPrompt`, which the
prompt reaches at `:949`. In that window a tick's
`{triggerTurn: true, deliverAs: "followUp"}` takes the `:1120-1121` branch,
claims the agent via `_runAgentPrompt` → `Agent.prompt`, and the user's prompt
— arriving at `_runAgentPrompt` later — is refused at `agent.js:227-228` and
recorded nowhere. Its only trace is the interactive loop's
`showError(errorMessage)` (`dist/modes/interactive/interactive-mode.js:836-842`),
after the editor has already released the text.

**Who was at fault.** pi-core behaviour, provoked by the harness. The
harness's option combination is the provocation — `triggerTurn: true` on a
session core reports idle, while `deliverAs: "followUp"` protects only the case
core calls streaming. The **host fix is non-provocation, and it must cover the
whole window**: the `agentBusy` gate ALONE is false here (no run has started),
so it is not sufficient. The host now also marks the window from the two events
core emits inside it — `pi.on("input", …)` (`:843`) and
`pi.on("before_agent_start", …)` (`:915`) — and defers while it is open (the
flag is folded into `host.busy()`, so the router holds a runner tick, and
`deliverTick` holds a panel tick). `agent_start` and `agent_settled` close it.
A prompt that FAILS in the pre-run reaches neither, so the window is
age-bounded (`USER_PROMPT_PENDING_MS`) rather than allowed to starve ticks
forever. The tick always rides `runner.deferTick`, so it is HELD and
re-delivered, never dropped.

**Reported to pi-core (not patched here):** `AgentSession.sendCustomMessage`
with `triggerTurn` should not start a turn while `AgentSession.prompt` is
already in flight. Core reports `isStreaming`/`isIdle` as idle throughout the
pre-run section, so a second turn can claim the agent and destroy the first
prompt's message; either `isIdle`/`isStreaming` should reflect a prompt that has
begun but not yet claimed a run, or `sendCustomMessage` should queue
(`followUp`) instead of triggering a turn in that state.

**Evidence, reproducible:**

- `test/hosts/pi-core-triggered-turn.test.ts` — against the REAL
  `@earendil-works/pi-agent-core`: (a) tick first → the user's `prompt()` is
  refused with the exact runtime message, is not queued, and is not in the
  transcript; (b) user first → the tick is the refused one (the alarm above);
  (c) `followUp` IS accepted on an active run, so only the `triggerTurn` path
  can be refused.
- `test/hosts/pi-extension.test.ts` (`AUTOPILOT-122`) — the host contract: a
  tick attempted while a user prompt is in the pre-run window issues NO
  `sendMessage` (the user's message is not stolen), is re-deferred, and is
  delivered at the settle; the decision itself applied immediately.
- `test/framework/runner.test.ts` (`AUTOPILOT-122`) — `deferTick` holds a
  mid-turn tick and flushes it at the settle, and a deferred AUTOMATIC-ACTION
  tick is still dropped while OFF.

**Not closed here:** the exact live interleaving that opened the window in the
reported session was not reproduced end-to-end (it needs a live TUI plus a tick
landing inside core's pre-run awaits). What would settle it: a session log in
which a user message never reaches the transcript adjacent to a tick's
triggered turn, or an instrumented run with a tick forced into that await.

## The NON-TRIGGERING tick (KEY: AUTOPILOT-125)

### The change

`deliverTick` (`src/hosts/pi-extension.ts`) no longer sends `triggerTurn: true`.
The ONE tick delivery surface now calls
`pi.sendMessage({customType, content, display}, {deliverAs: "followUp"})` — a
NON-TRIGGERING custom message. Measured against the installed pi 0.85.1,
`AgentSession.sendCustomMessage` (`agent-session.js:1099`) then takes its
`_appendCustomMessage` branch (`:1130-1131`): the message is written to the
transcript and **no turn is started**. This is ADR-0007's C-4 ("No push
turns") applied to the tick channel: a tick reports, it does not push.

**Chosen shape: non-triggering append — not `deliverAs: "nextTurn"`, not
TUI-only.** `nextTurn` would park the tick in `_pendingNextTurnMessages` and
inject it only as context for the session's next *user* prompt — a turn that may
never come, and one the tick is not recorded in until then. The plain
non-triggering append is strictly stronger for the guarantee below: the tick is
in the transcript (and the session log) immediately, so it is part of whatever
turn comes next, regardless of what starts it. TUI-only was rejected because it
would drop the tick from the transcript and the orchestrator's context entirely
— the tick IS the orchestrator's channel; it just must not fire a turn.

### What a tick guarantees now — and what it no longer does

- **Guaranteed:** a delivered tick is **RECORDED** — appended to the session
  transcript (`_appendCustomMessage`, and the session log) and therefore part of
  the session's next turn's context. `tick-delivered` (AUTOPILOT-51) still logs
  every hand-off, so the audit trail is unchanged.
- **No longer:** a tick **does not START a turn**. It does not wake an idle
  orchestrator, and it can no longer CLAIM the agent — which is the point: a
  message that cannot claim the agent can neither be refused nor make the user's
  just-submitted prompt the refused one. AUTOPILOT-122's residual window (a tick
  landing before core's `input` event, between an interactive submit and
  `prompt()` reaching `emitInput`) is now **UNREACHABLE**, not merely handled.

### The trade-off, stated

The wake loss is real and accepted. A tick sitting in the transcript reaches the
orchestrator only when the session takes its NEXT turn; if the session sits
idle, the tick is written to the log but never read. That is acceptable because
the lanes that must make progress do not depend on a tick waking the
orchestrator — the automatic lanes dispatch, review and re-dispatch on their own
in `src/framework/runner.ts` against the store, not inside the orchestrator's
turn. Human-facing attention that genuinely requires a wake goes through the
L-12 notifier, not through a tick: the tick is the orchestrator's channel and
must not push a model turn.

**No tick class is exempt.** Every tick source (runner, engine, harness,
recovery, review, panel decision, narration) funnels through `deliverTick`, so
there is one rule and no exception. If a future case genuinely needs to START a
turn, that is a new, explicit channel (an operator escalation), not a
re-enabled `triggerTurn` on the tick path — re-enabling it would restore the
user-input-loss window wholesale.

### What is still true from AUTOPILOT-122

The busy/compacting/user-prompt PRE-CHECK is kept: a tick that cannot be
injected right now still rides `runner.deferTick` (held, re-delivered at the
next settle, never dropped), still writes no `tick-delivered` line until it is
actually handed off, and the OFF class rule still applies at the flush. With the
trigger gone the pre-check is cheap insurance (it holds a tick out of a mid-turn
transcript), not the defence — there is no claim left to refuse.

### Evidence

- `test/hosts/pi-tick-non-triggering.test.ts` — SOURCE scan: NO `triggerTurn`
  in non-comment code of `src/` (with a detector-fires test, and a check that
  the ONE tick surface still sends, so the green is not vacuous). A future edit
  cannot silently reintroduce the claim.
- `test/hosts/pi-extension.test.ts` (`AUTOPILOT-125`) — the host contract:
  (i) a tick in the RESIDUAL window (core's pre-run open, the host's `input`
  event not yet fired) is delivered NON-triggering, RECORDED, and core's
  pre-run slot is untouched (the user's prompt survives); a tick in the
  previously-throwing busy-not-streaming window no longer provokes the refusal
  at all (no path sets `triggerTurn`).
- `test/hosts/pi-core-triggered-turn.test.ts` (`AUTOPILOT-125`) — against the
  REAL `@earendil-works/pi-agent-core`: a non-triggering delivery claims no run,
  the user's `prompt()` is ACCEPTED, and the recorded tick is in that turn's
  context. AUTOPILOT-122's refusal tests stay — they pin WHY the tick path must
  not trigger.
