lifecycle outbox - part b - #5592
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>
A TUI turn killed mid-flight by a disconnected engine (stream idle/error, crash) never receives a TurnComplete, so its turn_start stayed orphaned in the outbox — the TUI had no analogue of the exec channel-closed guarantee. recover_engine_event_disconnect now captures the in-progress turn identity before the state reset and emits the folded turn_end (kind turn.failed, status failed, wall-clock duration, bounded error, workspace) for exactly the state turn_start is emitted for; a disconnect with no in-progress turn fabricates nothing. Tests cover both branches. 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>
… turn_end CONFIGURATION.md documents the routing fields (workspace on every payload, subagent on subagent events), the new TUI failure-path folded turn_end, and tightens the webhook wording to bounded retries inside the sink, failures logged and dropped, never fed back into the agent loop. The lane changelog marks the two closed gaps (routing fields, TUI orphan turn_end) done and keeps the genuinely remaining follow-ups. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
codewhale doctor now reports the resolved outbox state — off (default) when [lifecycle_outbox].path is unset/empty, on with the sink path otherwise — matching the truth-and-resilience theme of the other posture rows. Tested for both states. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
- docs/rfcs/1365-lifecycle-outbox.md: converts the build log into the review artifact modeled on 1364-hooks-lifecycle.md (problem, scope table, design/contract, structure, limitations, test plan, review checkpoints). Issue number provisional until the upstream issue is filed. - docs/changelog-lifecycle-outbox.md: status line pointing at the RFC. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
Goal-continuation turns are engine-originated: the synthetic ContinueGoal token never passes through the UI's user-message dispatch, so the emit sites hooked there produced no outbox turn pair for them. Move the TUI's turn-boundary emits into the engine. The interactive TUI wires its [lifecycle_outbox] handle at engine spawn (spawn_tui_engine, including every engine-replacement site), and handle_send_message emits turn_start right after TurnStarted and turn_end at the single terminal outcome, with the same envelope shape (workspace + thread/turn ids) and the same seq discipline (same writer). Exec and hosted engines keep their existing emit sites and pass no handle, so nothing changes there. Side effects, both emit-only fixes: the engine-side turn_end is projected exhaustively from the terminal status (no more undocumented turn.ended fallback), and completion events without a preceding turn (compaction/ purge, bang commands) no longer mint a phantom turn_end under a stale turn id. Regression: goal_continuation_turn_emits_turn_start_and_turn_end_pair_to_the_outbox runs a real user turn plus its synthetic continuation against the engine harness and asserts both turn pairs land in the outbox file with distinct, correlated turn ids. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
The in-file round-trip test still constructed next_seq/recovered after the atomic seq change landed; drop the removed fields. cargo test -p codewhale-hooks 22/22. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
was already removed when turn-boundary outbox events moved engine-side
(08e684550): turn_start after TurnStarted and turn_end only at the single
terminal outcome of handle_send_message, so compaction/purge completions
with no preceding TurnStarted can no longer mint a turn_end under a stale
turn id. Re-adding a TUI-side emit here would double every turn_end.
Add the regression test the audit asked for at the interactive TUI's
engine wiring: a real user turn produces exactly one turn_start/turn_end
pair (one turn_end, status completed), and a cancel-before-start
compaction TurnComplete { Interrupted } with no in-progress turn writes
nothing — the outbox still holds exactly the one pair, no turn.interrupted
line, no duplicate.
Gate: ZIG=/opt/zig-0.15.2/zig cargo test -p codewhale-tui --lib event_loop
→ 5 passed, 0 failed.
Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
- expand ~/env vars in [lifecycle_outbox].path at both TUI construction sites (tui/app/init.rs and lib.rs exec) via config::expand_path, so the documented ~/.codewhale/... example lands under $HOME instead of a literal ~ directory. Regression test builds the App with a tilde path and asserts the line lands in the expanded home and no literal ~ directory appears. - webhook delivery no longer blocks the local append path. The writer drain loop hands each POST to a detached task bounded by a WEBHOOK_MAX_IN_FLIGHT (4) semaphore; a full backlog drops the newest delivery rather than queueing unbounded. Regression tests: a slow endpoint cannot delay local appends; a full backlog drops webhook deliveries but never the local append. - bounded_text strips full ANSI escape sequences (CSI, OSC, DCS/SOS/PM/APC, two-char escapes) instead of only the ESC byte; leaks like '[31m' no longer survive. - module header reworded to the RFC-mandated phrasing: bounded retries inside the sink; failures logged and dropped, never fed back into the agent loop. - CONFIGURATION.md documents the exhaustive turn_end kind projection (no turn.ended fallback kind); config.example.toml comments match. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
…on + signal flush The shared lifecycle outbox is written by many concurrent session processes. Two failure modes remain from that ownership gap: - A session killed mid-turn (SIGKILL, closed pane) dies between its turn_start and turn_end appends and can run no code, leaving the turn_start unpaired in the file (verifier turn-pairing/G1 FAIL). - A SIGTERM/SIGHUP/SIGINT exit previously restored the terminal and exited without closing the open turn. The session now owns its turn events end to end: - LifecycleOutbox::reconcile_interrupted_turns(thread_id, reason) scans the outbox under the cross-process exclusive lock for this thread's turn_start lines lacking a matching turn_end and appends one synthetic turn_end each (kind turn.interrupted, payload status=interrupted + reconciled=true + reason, inheriting the start's workspace). One lock acquisition across scan+appends keeps the reconciliation idempotent across concurrent sessions. The open turn is derived from file truth, never in-memory state, so no duplicate turn_end can be fabricated. - The TUI reconciles at boot (before the first emit) and registers its outbox identity for the terminating-signal cleanup task, which runs the same reconciliation for SIGTERM/SIGINT/SIGHUP before exiting. Both paths wait (bounded) for the process's own queued events to drain first, so a still-queued turn_start is visible to the scan. - LifecycleOutbox::emit_blocking adds the synchronous, runtime-free append primitive for shutdown paths and deterministic fixture writers. - New unit tests: reconciliation pairing/idempotence/torn-tail/foreign thread untouched, blocking emit, signal flush pairing + no-op; the existing N-writer monotonic-seq test still covers locked appends. - New example interleaved_outbox_fixture generates a cross-process interleaved fixture; the verifier script reports PASS for turn pairing, and B2 on it (the WP gate). - RFC 1365 documents the session-ownership contract and the SIGKILL / boot-reconciliation / graceful-shutdown relationship. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
…the provisional number Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
…-writers poll emit() enqueues without awaiting, so on a busy runner the first poll can race ahead of the first append (which creates the outbox file). The lenient reader treated the missing file as a panic; on the Windows CI job the race lost and the test failed with NotFound. A missing file is now an empty poll — the writers' appends are asserted by the final line count, so a genuinely broken writer still fails the test, just with a proper assertion instead of an early read panic. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
Regenerated crates/tui/CHANGELOG.md via scripts/sync-changelog.sh. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
docs/changelog-lifecycle-outbox.md is the ecosystem-internal build log the RFC was mined from; upstream-facing notes live in the RFC itself and in the root CHANGELOG.md. The RFC no longer references it. Signed-off-by: M-Maciej <130112810+M-Maciej@users.noreply.github.com>
|
Thanks @M-Maciej for taking the time to contribute. This repository is observing a maintainer-managed PR intake gate in dry-run mode, so this pull request is staying open. This note helps maintainers prepare the allowlist before any enforcement is considered. Please read |
Hmbown
left a comment
There was a problem hiding this comment.
Thanks for splitting this into its own reviewable PR and for preserving DCO across all 16 commits. I reviewed the complete diff at exact head f005243e5fff5ac934c1073e5b2e68f54bfd5a39. The integration conflicts are only the two expected Unreleased changelog sections, and the code merge with #5591 is clean. I am requesting changes for three contract blockers:
-
Boot reconciliation cannot find the process it is supposed to repair. The TUI passes the current hook-session ID into reconciliation (event_loop.rs L509-L514), but
HookExecutor::newmints a new randomsess_*ID on every launch (executor.rs L1556-L1570). That ID cannot match the killed prior process's records. The RFC acknowledges this (RFC L366-L372), while the PR, issue, and changelog state that next-boot pairing works. Please use a stable cross-process outbox identity and add a real two-process kill/relaunch regression. -
A torn tail is not repaired; the next append corrupts and then wedges the outbox. Recovery selects the previous complete record's sequence (lifecycle_outbox.rs L762-L808), but the append path opens with
O_APPENDand writes at the original EOF without truncating the torn bytes or inserting a separator (L466-L485). The partial JSON and new envelope become one invalid line; the following recovery then fails parsing it. Current tests assert only the recovered sequence or reconciliation count. Please repair/truncate the torn suffix under the lock and add: seed torn tail -> append -> parse every line -> reopen -> append again. -
Fresh
execstart/end records are not correlatable, and killedexecruns are never reconciled. The start uses an emptythread_idand noturn_id(lib.rs L11953-L11958); the engine publishes its new session ID before terminal completion (engine.rs L4691-L4693), so the end uses a different, non-emptythread_id, still with noturn_id(lib.rs L12362-L12367). In the supported shared/interleaved file, there is no process key that can safely pair these records.execalso never registers the signal context (outbox_signal.rs L24-L27) and has no boot reconciliation, so a killed run leaves a permanent orphan. Please mint one stable run/thread + turn identity before dispatch and add shared-file concurrency plus killed/restartedexecregressions.
Secondary durability risks to address while repairing the contract:
emithas no completion receipt or shutdown/join path. Normal terminal events can remain queued when the runtime exits, and several stream-output?paths return afterturn_startbut before eitherturn_endsite. Add a bounded, deterministic exit flush for TUI andexec.- Signal cleanup calls blocking full-file reconciliation under an unbounded file-lock wait (lib.rs L703-L735, lifecycle_outbox.rs L543-L614). The one
signals.wait()future has already been consumed, so a second signal cannot re-enter theCLEANED_UPbranch as the comment claims. Bound the whole signal flush and test lock contention/second-signal behavior. - The documented 64 KiB recovery invariant is not enforced for a serialized envelope; for example, TUI
session_startinserts the model string withoutbounded_text. Enforce a maximum record size or bound every producer field, and serialize the two process-global signal tests so they cannot race.
Please preserve M-Maciej's authorship and Signed-off-by trailers throughout the refresh. Once these regressions are in place, the main/changelog refresh should remain mechanical and I can re-review the exact new head.
This review was written and posted by ChatGPT 5.6 Sol on the maintainer-approved v0.9.12 release lane; it is not a personal comment authored by Hunter.
|
Review from the 0.9.12 integration lane — not taken yet, deliberately: The design is strong and I have no code objections from reading the description and the design points: file-lock + tail-recovery for multi-writer monotonic seq, boot reconciliation derived from the file rather than memory, goal-continuation turns emitting proper pairs, bounded/redacted payloads, and a real exec integration harness. The merge-order note (standalone off Why it's not folded into |
Resolves the only conflict: main's `## [Unreleased]` grew a `### Added` list while this branch appended its lifecycle-outbox bullet at the same insertion point. Kept both — main's list, with this PR's bullet appended — and regenerated crates/tui/CHANGELOG.md via scripts/sync-changelog.sh. No source files conflicted; every .rs change auto-merged.
|
Hi @M-Maciej — maintainer housekeeping on this PR, no code of yours was touched. What I found. Two separate things were keeping this un-judgeable:
I also prepended Heads-up on what CI will report.
Both are fixed by #5610, which is waiting to land. Anything else that goes red is worth your attention. Sorry this sat for so long without a real signal. Same treatment applied to #5593 and #5594. |
|
Merged onto the v0.9.12 integration branch in 397b9cb (with follow-ups 6b60646); re-verified in-tree: [lifecycle_outbox] config in codewhale-config, crates/tui/tests/integration/lifecycle_outbox_exec.rs, docs/rfcs/1365-lifecycle-outbox.md, and docs/CONFIGURATION.md rows. Closing as landed — thanks @M-Maciej! |
Opt-in `[lifecycle_outbox]` JSONL/webhook stream (off by default) for TUI and `codewhale exec`, with monotonic seq, workspace routing, boot reconciliation, and signal flush. Authored by @M-Maciej; taken into the 0.9.12 integration branch with its authorship preserved. Adapted onto the #5586 extracted modules rather than restoring the monolithic lib.rs/config.rs: - doctor posture row lives in doctor_cli.rs - exec turn-boundary emits live in exec_agent.rs - terminating-signal flush lives in cli_args.rs - config merge of the new table lives in config/merge.rs Evidence: rustfmt --edition 2024 cargo test -p codewhale-hooks --locked --offline -> 31 passed cargo test -p codewhale-config --lib lifecycle_outbox -> 2 passed cargo test -p codewhale-tui --lib --locked --offline -- lifecycle_outbox doctor_reports_lifecycle_outbox goal_continuation_turn_emits_turn_start_and_turn_end_pair_to_the_outbox tui_config_parses_lifecycle_outbox_table stalled_turn_emits_turn_stalled_outbox_event stalled_turn_without_outbox_config_writes_nothing compaction_turn_complete_without_in_progress_turn_writes_no_phantom_turn_end outbox_signal -> 9 passed Co-Authored-By: M-Maciej <130112810+M-Maciej@users.noreply.github.com> Co-Authored-By: Grok 4.6 <noreply@anthropic.com>
/#5594 Replay tonight's M-Maciej PRs onto the integration commits that landed while the take was in flight (FEAT-019 /loop handler, image attach, computer-use fallbacks). No further adaptation. Co-Authored-By: Grok 4.6 <noreply@anthropic.com>
Take: - #5592 lifecycle outbox (`[lifecycle_outbox]`, opt-in JSONL + webhook) - #5593 /relaunch (save like /exit, Unix self-exec resume) - #5594 per-session control socket (`[control_socket]`, Unix JSON-RPC) Authorship of the original commits is preserved. Adapted onto the #5586 extracted modules (doctor_cli, exec_agent, cli_args, config/merge) and existing /loop locale keys rather than clobbering them. Did not merge origin/main. Co-Authored-By: M-Maciej <130112810+M-Maciej@users.noreply.github.com> Co-Authored-By: Grok 4.6 <noreply@anthropic.com> Entire-Checkpoint: 01M0W25Y7BH0J5342G4WRNQZJT
#5592 wires spawn_tui_engine through Engine::new so the lifecycle outbox handle can be attached. The #5566 max_steps import of spawn_engine is unused on that path; keep DEFAULT_MAX_STEPS / DEFAULT_MAX_WALL_TIME for frame.rs (`use super::*`). Evidence: rustfmt --edition 2024 Co-Authored-By: Grok 4.6 <noreply@anthropic.com>
Closes #5531
Summary
Opt-in
[lifecycle_outbox]config table. When set, codewhale appends oneJSONL line per lifecycle event to the configured file, for interactive TUI
sessions and headless
codewhale execruns, with no per-hook shellcommands:
Events (one JSONL line per event;
kindis the envelope's dotted kind):turn_start,turn_end,turn_stalled,subagent_spawn,subagent_complete,session_start,session_end.Design points:
seqis monotonic in file order. Every append re-locks a<path>.locksidecar and re-recovers the tail, so multiple sessions sharing one outbox
file produce unique, increasing seqs; the sidecar is the only new on-disk
artifact. A torn trailing line from a crash is ignored (bounded 64 KiB tail
scan; recovery reads the last complete line).
(SIGKILL, closed pane) dies between
turn_startandturn_end. Catchablesignals flush a synthetic
turn_end(status: "interrupted",payload.reconciled: true) for every open turn; SIGKILL runs no code, soboot reconciliation on the next session start pairs anything the killed
process left behind. Both paths derive the open turn from the file, never
in-memory state, and run under the exclusive lock, so two reconcilers never
double-append.
workspace(the resolved workspace path) so aconsumer can route each event to its project; subagent events additionally
carry
subagentalongsideagent_id.spawn_tui_enginewiring,handle_send_message), so goal-continuation turns — engine-originated,never passing through the UI user-message dispatch — emit the same
turn_start/turn_endpair as user turns, with distinct, correlated turnids. Exec and hosted engines keep their existing emit sites; a
completion without a preceding turn no longer mints a phantom
turn_endunder a stale turn id.
and exponential back-off inside the sink; at most
WEBHOOK_MAX_IN_FLIGHTdeliveries run concurrently and a full backlog drops the newest delivery.
detail ≤ 120, preview ≤ 200 chars; control bytes stripped).
codewhale doctorposture row reports the resolved state and sink path.Design record:
docs/rfcs/1365-lifecycle-outbox.md(modeled on1364-hooks-lifecycle.md). Both new config tables are documented indocs/CONFIGURATION.md.Merge-order note
Merge order: after the cadence fix (a). This branch is standalone — cut
from current
main, passing all gates on its own (13,403 / 0); thegoal-continuation turn emits touch the same engine dispatch files as the
cadence fix, different lines, so a trivial rebase is expected after (a)
lands — happy to re-push on request.
Testing
<!-- Exact gate commands (including the clippy allow list) are in
CONTRIBUTING.md → "Pre-push verification". -->
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-features --locked(warning-free under the CI allow list)cargo test --workspace --all-features --lockedResults: clippy clean on stable 1.98.0 under the CONTRIBUTING.md allow
list; full suite 13,403 passed / 0 failed (one pre-existing
environmental failure skipped locally:
diagnostic_dispatch_read_onlyfails identically on clean
mainon hosts with a system-wide rustup shim;it is unaffected by this branch and passes on CI). Focused:
codewhale-hooks31/31; outbox exec integration harness 3/3 (realexecsubprocess against a stubbed OpenAI-compatible endpoint); hooks round-trip
asserting
workspaceon every event type andsubagenton the subagentevents; reconciliation tests (signal flush + boot pairing, no
double-append).
Checklist
docs/rfcs/1365-lifecycle-outbox.md,docs/CONFIGURATION.md, Unreleased changelog entryemit behavior exercised on live sessions during development ( before split, not after split )
every commit is authored under
130112810+M-Maciej@users.noreply.github.comSigned-off-by(DCO)