Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a342000
docs: add implementation plan for codex-turn-thread-scope
danshapiro Aug 1, 2026
14baedb
docs(plan): harden codex-turn-thread-scope plan with load-bearing val…
danshapiro Aug 1, 2026
81c921c
docs(plan): fix fresheyes blocking issues in codex-turn-thread-scope …
danshapiro Aug 1, 2026
488d26a
docs(plan): fix fresheyes iteration-2 blocking issues in codex-turn-t…
danshapiro Aug 1, 2026
6380716
docs(plan): make Task 3 router seam test a non-vacuous verifier (fres…
danshapiro Aug 1, 2026
e039320
fix(activity): rollout turn_aborted clears codex phase without record…
danshapiro Aug 1, 2026
941ad58
fix(activity): thread-scope, status-guard, and turn-id-dedupe the cod…
danshapiro Aug 1, 2026
278c08d
test(ws): pin thread-scoped codex proxy routing at the hub and router…
danshapiro Aug 1, 2026
f740b17
feat(server): carry threadId/turnId/status on codex turn registry events
danshapiro Aug 1, 2026
d2341c9
fix(server): thread-scope and status-guard codex app-server turn events
danshapiro Aug 1, 2026
911fa4c
fix(server): reconcile turn_aborted clears codex phase without record…
danshapiro Aug 1, 2026
f7c0302
docs: add implementation plan for codex-attention-bell
danshapiro Aug 1, 2026
9e81890
docs(plan): harden attention-bell plan with load-bearing audit findings
danshapiro Aug 1, 2026
0d7efdc
docs(plan): move Task 7 approval-state clear before the phase match
danshapiro Aug 1, 2026
42289e4
feat(activity): failed codex turns record a completion and ring termi…
danshapiro Aug 1, 2026
eb5e14d
test(ws): make the queued-failed hub test actually drive a failed pro…
danshapiro Aug 1, 2026
a767d72
fix(activity): clear the in-flight codex proxy turn id at accepted co…
danshapiro Aug 1, 2026
97b7700
feat(activity): ring on non-human turn_aborted reasons via end-to-end…
danshapiro Aug 1, 2026
bcadfdb
feat(terminal): carry a spontaneous-vs-requested discriminator on exi…
danshapiro Aug 1, 2026
853e67b
feat(ws): ring terminal.idle on spontaneous process death while engaged
danshapiro Aug 1, 2026
111ebee
feat(codex): sniff app-server approval requests and match their respo…
danshapiro Aug 1, 2026
a4b481e
feat(activity): approval-request pauses ring once and resolve back to…
danshapiro Aug 1, 2026
d4a4a7f
feat(server): failed codex turns ring; gate the completed-at diagnost…
danshapiro Aug 1, 2026
a24b880
fix(server): clear the in-flight codex turn id at accepted completion
danshapiro Aug 1, 2026
461701e
feat(server): ring on non-human codex turn_aborted reasons (Node parity)
danshapiro Aug 1, 2026
2753958
feat(server): ring terminal.idle on spontaneous process death while e…
danshapiro Aug 1, 2026
0fffa68
feat(server): codex approval-request pauses ring once and resolve bac…
danshapiro Aug 1, 2026
bc37714
test(server): expect the spontaneous exit discriminator in sidecar re…
danshapiro Aug 1, 2026
b7ce877
docs: terminal.idle rings on all non-human stops (contract comment up…
danshapiro Aug 1, 2026
fce0867
docs(activity): correct idle.rs exit-death-bell attribution (hub emit…
danshapiro Aug 1, 2026
f75d750
test: verification sweep fixes for the attention-bell causes
danshapiro Aug 1, 2026
3176313
fix(activity): silence mid-pause turn-end echoes and unify proxy turn…
danshapiro Aug 1, 2026
7c67244
fix(server): phase-guard Node proxy turn completions during approval …
danshapiro Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
993 changes: 959 additions & 34 deletions crates/freshell-activity/src/codex.rs

Large diffs are not rendered by default.

75 changes: 73 additions & 2 deletions crates/freshell-activity/src/idle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
//! Pinned wire contract: `{ terminalId, at (server epoch ms), reason:
//! 'grace' | 'queue-empty' }`, emitted ONCE per busy→truly-idle transition.
//!
//! Semantics:
//! Semantics (terminal.idle is never emitted after a HUMAN-REQUESTED stop;
//! it IS emitted for failed turns, non-human abort reasons (forward-compatible
//! — none emitted at codex <= 0.147), spontaneous death while engaged, and
//! approval pauses; see shared/ws-protocol.ts terminal.idle doc):
//! * a turn boundary (the provider's positive turn end) ARMS a grace window
//! (default [`IDLE_GRACE_MS`] = 2000ms);
//! * new activity within the window EXTENDS it (amplifier: any events.jsonl
Expand All @@ -18,10 +21,37 @@
//! * a turn boundary while the tracker still reports busy/pending is a
//! QUEUED turn: it records queue evidence and never arms mid-turn;
//! * subagent/tool completions inside a running turn never reach this gate
//! (the trackers only report REAL turn boundaries).
//! (the trackers only report REAL turn boundaries);
//! * spontaneous death (exit removal while `is_engaged`): the gate itself
//! never emits for a removed terminal. The hub reads `is_engaged` BEFORE
//! removal and emits the exit-death bell directly. `is_engaged` deliberately
//! excludes the input-only Pending state because a human `/quit`/`/exit`
//! Enter from an idle pane is indistinguishable from a prompt submit
//! (ringing there would bell the canonical human quit).
//!
//! Zero-polling: pure deadlines + `next_deadline()`; the hub arms a single
//! one-shot timer. No pending windows ⇒ no timers.
//!
//! # Accepted Residuals
//!
//! The following edge cases are accepted design trade-offs (not deferrals):
//! 1. Mid-turn `/quit`/Ctrl+D: codex sends NO `Op::Interrupt` on Ctrl+D, and
//! the TUI's ~2s shutdown budget can exit before the abort evidence lands
//! — may ring on a human force-quit of a visibly-working pane. No in-band
//! discriminator exists; accepted.
//! 2. Out-of-band `kill -9`/SIGTERM of the CLI by the user: observationally
//! identical to a crash — rings; accepted.
//! 3. Claude/amplifier Enter-executed quits (`/exit`): input-driven Busy is
//! those trackers' ONLY turn evidence, so it stays death-bell engagement;
//! same residual family as (1); accepted.
//! 4. Node 120s busy-deadman swallow (audit A17): a recovery window longer
//! than `BUSY_DEADMAN_MS` demotes busy→unknown and `unknown` never arms the
//! death bell — a MISSED bell (never a false ring); accepted.
//! 5. A SENT approval request auto-resolved server-side slower than ~2s rings
//! once (decision 5); accepted.
//! 6. Node opencode death bells: deliberately excluded (noisy busy proxy) —
//! follow-up. Rust opencode: no hub tracker exists — N/A.
//! 7. Unmanaged/PTY-only codex has no approval signal — documented limitation.

use std::collections::HashMap;

Expand Down Expand Up @@ -143,6 +173,20 @@ impl IdleGate {
self.states.remove(terminal_id);
}

/// Engagement for the DEATH BELL (decision 3): true only for a CONFIRMED
/// busy phase or an armed grace window. The codex input-only Pending
/// submit gate is excluded — the Enter that executes a human /quit//exit
/// is indistinguishable from a prompt submit in the input lane
/// (signal.rs:36-38), so ringing on pending would bell the canonical
/// human quit. Read by the hub's exit arm BEFORE `note_exit` drops the
/// state: a spontaneous process death while engaged rings the bell.
pub fn is_engaged(&self, terminal_id: &str) -> bool {
self.states
.get(terminal_id)
.map(|s| (s.busy && !s.pending) || s.deadline.is_some())
.unwrap_or(false)
}

/// Emit every window whose deadline has lapsed (once each). A terminal
/// that re-entered busy never emits (defensive second gate).
pub fn expire(&mut self, at: i64) -> Vec<IdleEmission> {
Expand Down Expand Up @@ -400,6 +444,33 @@ mod tests {
);
}

#[test]
fn is_engaged_reflects_confirmed_busy_and_armed_deadlines_but_never_input_pending() {
let mut gate = IdleGate::with_grace_ms(2_000);
assert!(!gate.is_engaged("t1"), "unknown terminal is not engaged");
gate.note_phase("t1", IdleGatePhase::Pending);
assert!(
!gate.is_engaged("t1"),
"input-only pending is NOT death-bell engagement: the Enter that \
executes /quit looks like a prompt submit (signal.rs:36-38) and \
must not ring when the pty then exits (decision 3, audit A6)"
);
gate.note_phase("t1", IdleGatePhase::Busy);
assert!(gate.is_engaged("t1"), "confirmed busy is engaged");
gate.note_phase("t1", IdleGatePhase::Idle);
assert!(
!gate.is_engaged("t1"),
"idle with no pending window is not engaged"
);
gate.note_turn_boundary("t1", 10_000); // arms deadline
assert!(
gate.is_engaged("t1"),
"an armed grace window is engaged (a pending bell must survive death)"
);
gate.expire(20_000);
assert!(!gate.is_engaged("t1"), "after emission nothing is engaged");
}

#[test]
fn default_gate_uses_the_production_grace_window() {
// HubInner is #[derive(Default)] (freshell-ws activity.rs), so
Expand Down
4 changes: 4 additions & 0 deletions crates/freshell-activity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,8 @@ pub enum TrackerEffect<R> {
},
/// Amplifier only: force-read the events tail (missed-signal failsafe).
ForceRead { terminal_id: String, at: i64 },
/// Arms the truly-idle gate WITHOUT minting a turn completion or a
/// terminal.turn.complete frame. Used for attention causes that are not
/// turn ends (approval-request pauses).
AttentionBoundary { terminal_id: String, at: i64 },
}
Loading
Loading