feat(tui): rescue lifecycle outbox and extract exec agent - #5645
Conversation
…iled events Add an opt-in, machine-readable lifecycle event outbox for supervisors and automation harnesses. Unset/empty config = feature OFF = behavior unchanged. Config ([lifecycle_outbox]): - path — JSONL outbox file (unset/empty disables the feature) - webhook_url — optional webhook endpoint; POSTs only when set - webhook_token — optional bearer token for webhook_url Writer (crates/hooks/src/lifecycle_outbox.rs): - One JSONL line per event in the existing RuntimeEventEnvelope shape (schema_version, seq, event, kind, thread_id, turn_id, item_id, timestamp, created_at, payload); append + flush per event. - seq monotonic per file; recovers from the last complete line on open via a bounded 64 KiB tail scan (torn trailing lines ignored). - Single non-blocking writer task: emit() enqueues; no tokio runtime available => drop with warning. - Payloads only from bounded, pre-redacted fields (headline ≤ 80, detail ≤ 120, preview ≤ 200 chars; control bytes stripped). Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
… site The consumer resolves the project from payload.workspace, so events lacking it were dropped fail-closed. Every emit site now carries the resolved workspace path — TUI turn_start, turn_end, session_end, turn_stalled, both subagent events (which additionally carry subagent alongside agent_id), and both exec turn_end sites (terminal receipt and channel-closed) — matching the session_start and exec turn_start sites that already had it. Tests: a hooks round-trip asserts workspace on every event type and subagent on the subagent events; the stall emit-site test asserts the workspace; the exec integration asserts payload.workspace equals the --workspace directory for turn_start and turn_end. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
Move the current-main exec pipeline into its prescribed module so the v0.9.12 lifecycle-outbox regression is independently reproducible and repairable on the release topology. Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Attribute headless lifecycle turn_start receipts to the already-resolved dispatch model. Keep the value bounded by the outbox detail limit, matching the original contract without re-reading ambient configuration. Signed-off-by: CodeWhale Bot <bot@codewhale.net>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @Hmbown's task in 7m 5s —— View job Claude finished reviewing PR #5645Todo list
SummaryThis is a well-structured rescue of the lifecycle outbox feature plus the exec-agent extraction. I verified the extraction is a faithful, near-verbatim move (diffed the pre-extraction Findings
Provider/model/route safety (EPIC #2608): Checked — Good things worth calling out:
|
| if exit_code != 1 { | ||
| eprintln!("Error: exec turn failed: {error}"); | ||
| let _ = io::stdout().flush(); | ||
| std::process::exit(exit_code); |
There was a problem hiding this comment.
Correctness: the lifecycle-outbox turn.failed event can be silently dropped on exactly the failure class it exists to report.
LifecycleOutbox::emit is fire-and-forget: it pushes onto an mpsc channel and lazily spawns a writer task that appends to disk / POSTs the webhook asynchronously (crates/hooks/src/lifecycle_outbox.rs:149-163, :191-228). It never blocks the caller and never waits for the write to land.
Just above this line, when the engine channel closes without a terminal receipt, a turn_end / turn.failed event is emitted (lines 1015-1030). If the failure category is network or timeout (exec_failure_exit_code, crates/tui/src/lib.rs:10713-10718), execution falls into this branch and calls std::process::exit(exit_code) — which terminates the process immediately with no destructors run at all, unlike a normal bail!/Ok(()) return that at least unwinds through the Runtime's Drop. The just-queued outbox line has no guaranteed opportunity to be written before the process dies.
This means the exact events a supervisor most wants (network/timeout failures, i.e. "retry this") are the ones most likely to never reach the outbox file or webhook.
Suggested fix: before this std::process::exit, synchronously drain the outbox (e.g. add a bounded LifecycleOutbox::flush()/shutdown() that closes the sender and awaits the writer task, or joins on the pending write) so the just-emitted event is durably written first.
| item_id: None, | ||
| payload: serde_json::json!({ | ||
| "mode": outbox_mode, | ||
| "model": outbox_model, |
There was a problem hiding this comment.
Minor consistency nit: this session_start payload inserts outbox_model (Option<String>) directly, without passing it through codewhale_hooks::bounded_text first. Every other free-form field written to the outbox in this PR does go through bounded_text — turn_start's model (a few lines below, and in exec_agent.rs), turn_stalled's message, the subagent prompt_preview/result_preview, and turn_end's error. That's also what the module doc for lifecycle_outbox.rs states as the contract: "Payloads are constructed by the emit sites from bounded, pre-redacted fields only."
Model names are normally short, so this is low-risk in practice, but a custom/self-hosted route can set an arbitrary model string, and nothing here enforces the same ceiling other emit sites rely on. Suggest: outbox_model.map(|m| codewhale_hooks::bounded_text(&m, codewhale_hooks::OUTBOX_DETAIL_MAX_CHARS)).
| //! | ||
| //! - `seq` is monotonic per outbox file. On the first write the writer | ||
| //! recovers the `seq` of the file's last complete line (bounded tail scan, | ||
| // so an outbox that grows unbounded is never re-read in full) and continues |
There was a problem hiding this comment.
Nit: this line uses a plain // instead of //!, so it drops out of the module doc comment block (rustdoc won't render it as part of the surrounding sentence). Cosmetic only — cargo fmt/clippy won't flag it.
| // so an outbox that grows unbounded is never re-read in full) and continues | |
| //! so an outbox that grows unbounded is never re-read in full) and continues |
Closes #5531
Advances #5586
Summary
This is the current-main rescue of M-Maciej's lifecycle outbox work from #5592, with the contributor-authored commits preserved verbatim at the base of the stack.
Authorship
The first two commits retain M-Maciej as the author with the canonical GitHub noreply address and original DCO sign-offs. The maintainer follow-ups are separate commits so the rescue history remains legible.
Verification
This PR does not enable the outbox by default and does not publish, deploy, or change credentials.