Skip to content

feat(tui): rescue lifecycle outbox and extract exec agent - #5645

Merged
Hmbown merged 4 commits into
mainfrom
codex/fix-lifecycle-outbox-20260827
Aug 27, 2026
Merged

feat(tui): rescue lifecycle outbox and extract exec agent#5645
Hmbown merged 4 commits into
mainfrom
codex/fix-lifecycle-outbox-20260827

Conversation

@Hmbown

@Hmbown Hmbown commented Aug 27, 2026

Copy link
Copy Markdown
Owner

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.

  • adds the opt-in JSONL and webhook lifecycle outbox for session, turn, stall, and subagent events
  • carries workspace identity on every event and subagent identity where applicable
  • keeps payloads bounded and pre-redacted, with atomic sequence recovery and bounded webhook fan-out
  • extracts exec-agent assembly from the TUI mega-file as the compatible first Decompose the mega files: lib.rs (18.7k), config.rs (12.3k), client.rs (11.1k), runtime_threads.rs (9.3k) #5586 decomposition slice
  • repairs a current-main integration defect so turn-start receipts retain the effective model instead of serializing it as null

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

  • reproduced the effective-model failure before the four-line repair and verified it passes afterward
  • lifecycle module tests: 3 passed
  • hooks lifecycle tests: 13 passed
  • formatting and diff checks passed
  • all-target TUI check passed
  • all-target TUI clippy passed with warnings denied

This PR does not enable the outbox by default and does not publish, deploy, or change credentials.

M-Maciej and others added 4 commits August 27, 2026 00:47
…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>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 7m 5s —— View job


Claude finished reviewing PR #5645

Todo list

  • Gather context (diff, CLAUDE.md/AGENTS.md, changed files)
  • Understand request (code review requested by PR author)
  • Review correctness bugs / panics / error handling
  • Review provider/model/route safety invariant
  • Review reuse & simplification opportunities
  • Review test coverage
  • Review security concerns
  • Post final review

Summary

This 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 run_exec_agent/exec_max_steps block against the new exec_agent.rs directly — the only differences are the module doc comment, pub(crate) visibility, and the intentional model field addition from the follow-up fix commit). Left 3 inline comments; details below.

Findings

  1. Correctness (the one worth fixing before merge)crates/tui/src/exec_agent.rs:1052 — on a network/timeout exec failure, the code calls std::process::exit(exit_code), which skips all destructors. LifecycleOutbox::emit (crates/hooks/src/lifecycle_outbox.rs) is fire-and-forget — it queues onto a channel and a spawned writer task appends the line asynchronously. The turn.failed event emitted a few lines above (line 1015) has no guaranteed chance to be written before the hard exit. This means the exact failures a supervisor watching the outbox most wants to see (retryable infra errors) are the ones most likely to be silently lost. Suggest draining/flushing the outbox synchronously before this exit call.

  2. Minor consistencycrates/tui/src/tui/ui/event_loop.rs:553 — the new session_start outbox payload writes model without passing it through bounded_text, unlike every other free-form field in this PR (turn_start's model, turn_stalled's message, subagent previews, turn_end's error). Breaks the module's own documented "bounded, pre-redacted fields only" contract; low real-world risk since model names are normally short, but a one-line fix for consistency.

  3. Nitcrates/hooks/src/lifecycle_outbox.rs:22 — a doc-comment line uses // instead of //!, so it drops out of the rendered module doc. Cosmetic only.

Provider/model/route safety (EPIC #2608): Checked — effective_model in exec_agent.rs comes from route.model (the already-resolved dispatch route), never inferred from a provider-prefixed string or freeform text. The ca90090 fix correctly matches the existing TUI contract (turn_start carries model, turn_end doesn't — verified against event_loop.rs's pre-existing TUI emit sites). No issues here.

Good things worth calling out:

  • The rescued commits keep original authorship/DCO intact, with maintainer follow-ups as separate commits — good provenance hygiene.
  • lifecycle_outbox.rs's bounded-tail-scan seq recovery is well-documented and has solid edge-case test coverage (missing/empty file, torn trailing line from a crash, cross-process continuation).
  • crates/tui/tests/integration/lifecycle_outbox_exec.rs is a genuine end-to-end test: spawns the real codewhale-tui binary against a mocked OpenAI-compatible SSE endpoint and asserts on the actual JSONL output, including the model field the ca90090 commit fixes and seq recovery across two real processes.
  • Config threading ([lifecycle_outbox]config::mergeApp/exec_agent) is consistent and the feature is correctly opt-in (disabled outbox is a true no-op, confirmed by tests).

if exit_code != 1 {
eprintln!("Error: exec turn failed: {error}");
let _ = io::stdout().flush();
std::process::exit(exit_code);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix this →

item_id: None,
payload: serde_json::json!({
"mode": outbox_mode,
"model": outbox_model,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_textturn_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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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

@Hmbown
Hmbown merged commit 0bc319b into main Aug 27, 2026
31 checks passed
@Hmbown
Hmbown deleted the codex/fix-lifecycle-outbox-20260827 branch August 27, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: local lifecycle event outbox (JSONL + webhook) with turn_stalled / turn_failed events

2 participants