| 1 | # Changelog — lifecycle outbox (`[lifecycle_outbox]`) |
| 2 | |
| 3 | Changelog for the general lifecycle event outbox (target: upstream |
| 4 | PR). Feature-complete against the v0.9.9 baseline (`6f3850c3d`). |
| 5 | |
| 6 | ## Added |
| 7 | |
| 8 | - **Config**: new `[lifecycle_outbox]` table with three optional keys: |
| 9 | - `path` — JSONL outbox file. Unset/empty = feature **off**, behavior |
| 10 | unchanged (the whole feature is additive and opt-in). |
| 11 | - `webhook_url` — optional webhook endpoint; POSTs fire only when set. |
| 12 | - `webhook_token` — optional bearer token for `webhook_url`. |
| 13 | Documented in `docs/CONFIGURATION.md` and `config.example.toml`. The |
| 14 | documented example default is `~/.codewhale/notifications/outbox.jsonl`; |
| 15 | the config key drives the real path. |
| 16 | - **Writer** (`crates/hooks/src/lifecycle_outbox.rs`): appends one JSONL line |
| 17 | per event to the configured path — lazy parent dirs, append+flush, single |
| 18 | internal writer task serializing emits in order. Line shape is the existing |
| 19 | `RuntimeEventEnvelope` (`schema_version, seq, event, kind, thread_id, |
| 20 | turn_id, item_id, timestamp, created_at, payload`). `seq` is monotonic per |
| 21 | outbox file and recovers from the last complete line's `seq` on open |
| 22 | (bounded 64 KiB tail scan; a torn trailing line from a crash is ignored). |
| 23 | Payloads are constructed from bounded, pre-redacted fields only — never |
| 24 | raw tool args, environment, or transcript text — with free-form fields |
| 25 | capped at the notification limits (headline ≤ 80, detail ≤ 120, |
| 26 | preview ≤ 200 chars) and stripped of control bytes. |
| 27 | - **Webhook**: `WebhookHookSink` (previously dead code with no config |
| 28 | surface) now supports an optional bearer token and is wired to outbox |
| 29 | events when `webhook_url` is set — POST `{"at", "event"}`. Delivery is |
| 30 | best-effort: failures are logged and dropped, never retried into the |
| 31 | agent loop, and a failing webhook never blocks the local append. |
| 32 | |
| 33 | ## Events emitted |
| 34 | |
| 35 | | Event | Kind | Site | |
| 36 | |---|---|---| |
| 37 | | `turn_start` | `turn.started` | TUI `EngineEvent::TurnStarted`; headless `exec` at `Op::SendMessage` | |
| 38 | | `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | TUI `TurnComplete` processing; headless `exec` `TurnComplete` (kind projected from status) | |
| 39 | | `turn_stalled` | `turn.stalled` | `recover_stalled_runtime_turn` — the first scriptable stall signal | |
| 40 | | `subagent_spawn` | `subagent.spawned` | subagent observer site (fires even with no hooks configured) | |
| 41 | | `subagent_complete` | `subagent.completed` | subagent observer site (fires even with no hooks configured) | |
| 42 | | `session_start` | `session.started` | TUI session-start hook fire site | |
| 43 | | `session_end` | `session.ended` | TUI session-end hook fire site | |
| 44 | |
| 45 | Headless `codewhale exec` coverage: `turn_start` at message dispatch and |
| 46 | `turn_end` at the terminal `TurnComplete` — **and** at the "engine channel |
| 47 | closed before a terminal receipt" path, so every emitted `turn_start` has a |
| 48 | matching `turn_end` and a supervisor never sees an orphaned in-progress |
| 49 | turn. `exec` has no TurnStarted engine event, so `turn_id` is absent there; |
| 50 | `thread_id` is the resumed session id when `--continue` was used, empty for |
| 51 | fresh runs (the session id is only minted at persistence time). |
| 52 | |
| 53 | ## File contract |
| 54 | |
| 55 | - One JSON object per line; every line is a complete |
| 56 | `RuntimeEventEnvelope`; appended and flushed per event. |
| 57 | - `seq` counts up per file, starting at 1 for a new file, recovering from |
| 58 | the last complete line after a restart. |
| 59 | - Cross-process: appends use O_APPEND with the line + newline in a single |
| 60 | write, so two processes sharing one file can interleave *lines* but never |
| 61 | splice a line mid-record. Seq uniqueness is per process recovery, so |
| 62 | sharing one file across processes can repeat seq values — use one file |
| 63 | per process for strict uniqueness. |
| 64 | |
| 65 | ## Tests |
| 66 | |
| 67 | - `crates/hooks`: append/schema shape, seq recovery across reopen, missing/ |
| 68 | empty file, torn trailing line, emit ordering under the writer task, |
| 69 | disabled-outbox no-ops, `bounded_text` ceilings (incl. UTF-8 boundaries). |
| 70 | - `crates/config`: `[lifecycle_outbox]` off-by-default, webhook optional, |
| 71 | full-table parse. |
| 72 | - `crates/tui`: TUI config parse of the table; stall-recovery emit-site |
| 73 | tests (enabled outbox writes one `turn_stalled` line naming the wedged |
| 74 | turn; disabled outbox writes nothing and recovery behavior is unchanged). |
| 75 | |
| 76 | ## Not changed |
| 77 | |
| 78 | - With `[lifecycle_outbox]` unset, zero behavior change: the outbox handle |
| 79 | is a disabled no-op and no file or HTTP request is ever made. |
| 80 | - No new runtime dependencies (JSONL append uses tokio fs; webhook reuses |
| 81 | the existing `reqwest` client builder). |
| 82 | |
| 83 | ## Remaining / follow-ups |
| 84 | |
| 85 | - Session ids for fresh headless `exec` runs are empty on `turn_start` |
| 86 | (minted only when the run is persisted); a supervisor correlating runs |
| 87 | can key on the process + file. |
| 88 | - Webhook-only configuration (url without path) parses losslessly but does |
| 89 | not activate the outbox handle today — the file path is the feature gate. |
| 90 | Documented; can be lifted later if webhook-only delivery is wanted. |
| 91 |