// tick-telemetry.ts — THE tick audit trail (AUTOPILOT-51). // // See docs/AUTOPILOT-51-tick-audit-gap.md for the investigation: the writer // (appendTelemetry, used by Autopilot.logEvent for started/complete/flip/ // sweep) was never broken — but `type: "tick"` was only ever logged from // Autopilot.sweep()'s decideTick() result, a path AUTOPILOT-9 made rare (it // moved auto-dispatch ahead of that call on every free-slot window). Every // tick the orchestrator actually receives (harness/recovery/review/ // decision-panel/config-change/shipping/main-write) is built directly in // framework/runner.ts (or, for the pi decision panel, hosts/pi-panel.ts) and // delivered straight to the host's sendMessage/promptAsync — never touching // appendTelemetry. // // Fix: instrument the delivery CHOKE POINT each host already has (pi: // deliverTick in hosts/pi-extension.ts; opencode: TickDelivery.deliver in // hosts/opencode-plugin.ts) with the SAME appendTelemetry writer — no second // logger. Both the runner and (on pi) the decision panel funnel through that // one function per host, so one call site captures every tick actually // delivered, from every source, INCLUDING the INFORMATIONAL ticks delivered // while autopilot is OFF (AUTOPILOT-120 — autopilotOn: false is the normal // shape of an informational delivery now, not a violation; the class gate in // framework/tick-class.ts keeps the automatic-action classes out). import { appendTelemetry } from "../config.ts"; /** Ticks are always framework-crafted as `[orch-tick: ] ...` (the * engine's dispatch/intake ticks, the runner's harness/recovery/review/ * decision/config-change/shipping/main-write ticks, and the panel's decision * ticks all follow this prefix). Falls back to "unknown" for anything that * doesn't — defensive, not expected to fire against a current call site. */ export function tickReasonFromMessage(message: string): string { const m = /^\[orch-tick:\s*([a-z0-9_-]+)/i.exec(message); return m ? m[1].toLowerCase() : "unknown"; } export interface TickDeliveryLogOptions { stateDir: string; message: string; /** Whether autopilot was ON for the delivering session at delivery time. * Since AUTOPILOT-120 `false` is the NORMAL shape for an INFORMATIONAL * tick (review/failure/budget/decision/harness/ship/main-write/config * deliver while OFF, by design); an AUTOMATIC-ACTION tick * (dispatch/intake/recover) delivering with `false` would be the finding — * the class gate in framework/tick-class.ts forbids it. */ autopilotOn: boolean; sessionId?: string; } export interface TickDropLogOptions { stateDir: string; /** The tick's reason tag (`[orch-tick: ]`). */ reason: string; /** The item key the dropped tick named, or null for a keyless nudge. */ key: string | null; /** How long the tick had been held since it was composed (ms). */ ageMs: number; /** The concrete premise that failed (the audit's why). */ why: string; } /** AUTOPILOT-87 — ONE appendTelemetry line when a tick is DROPPED at * delivery because the store moved on underneath it. The `ageMs` is the * enqueue→delivery hold time, so a burst of replays is visible as a cluster * of large-age drops. Type `tick.dropped-stale` (the historical kebab-case * types here are kebab too; the dotted form is the KEY's chosen tag). Never * throws. */ export function logTickDrop(opts: TickDropLogOptions): void { appendTelemetry( opts.stateDir, JSON.stringify({ t: new Date().toISOString(), type: "tick.dropped-stale", reason: opts.reason, key: opts.key, ageMs: Math.max(0, Math.round(opts.ageMs)), why: opts.why, }), ); } /** ONE appendTelemetry line per tick actually handed to a host's delivery * mechanism. Never throws (appendTelemetry itself is silent-on-failure). */ export function logTickDelivery(opts: TickDeliveryLogOptions): void { // type "tick-delivered", NOT "tick" — Autopilot.logEvent already writes // type:"tick" for the ENGINE's decideTick() decision (reason/source, no // autopilotOn field). This is a DIFFERENT event (an actual delivery // attempt, from ANY tick source) — reusing the name would silently // conflate two shapes and make older "tick" lines look like they carry // this record's fields. appendTelemetry( opts.stateDir, JSON.stringify({ t: new Date().toISOString(), type: "tick-delivered", reason: tickReasonFromMessage(opts.message), autopilotOn: opts.autopilotOn, ...(opts.sessionId ? { sessionId: opts.sessionId.slice(0, 8) } : {}), message: opts.message.slice(0, 300), }), ); }