diff --git a/crates/runner-app/src/session/manager/lifecycle.rs b/crates/runner-app/src/session/manager/lifecycle.rs index 5b712de..78cca39 100644 --- a/crates/runner-app/src/session/manager/lifecycle.rs +++ b/crates/runner-app/src/session/manager/lifecycle.rs @@ -66,6 +66,8 @@ impl SessionManager { Some(mut h) => { state.activity = None; state.suppress_local_input_busy = false; + state.local_input_pending = false; + state.last_local_input_at = None; state.mission_status_sink = None; state.completion_armed = false; state.pending_resize = None; @@ -290,6 +292,8 @@ impl SessionManager { state.handle = None; state.activity = None; state.suppress_local_input_busy = false; + state.local_input_pending = false; + state.last_local_input_at = None; state.mission_status_sink = None; state.completion_armed = false; } diff --git a/crates/runner-app/src/session/manager/mod.rs b/crates/runner-app/src/session/manager/mod.rs index 6539694..3528528 100644 --- a/crates/runner-app/src/session/manager/mod.rs +++ b/crates/runner-app/src/session/manager/mod.rs @@ -556,6 +556,8 @@ struct SessionState { handle: Option, activity: Option, suppress_local_input_busy: bool, + local_input_pending: bool, + last_local_input_at: Option, mission_status_sink: Option, completion_armed: bool, output_buffer: VecDeque, @@ -589,6 +591,8 @@ impl SessionState { self.handle.is_none() && self.activity.is_none() && !self.suppress_local_input_busy + && !self.local_input_pending + && self.last_local_input_at.is_none() && self.mission_status_sink.is_none() && !self.completion_armed && self.output_buffer.is_empty() @@ -784,6 +788,8 @@ impl SessionManager { ) { let state = self.session_state_or_insert(session_id); let mut state = state.lock().unwrap(); + state.local_input_pending = false; + state.last_local_input_at = None; state.handle = Some(handle); state.mission_status_sink = mission_status_sink; state.killed = false; diff --git a/crates/runner-app/src/session/manager/output.rs b/crates/runner-app/src/session/manager/output.rs index 8c900e5..81457c4 100644 --- a/crates/runner-app/src/session/manager/output.rs +++ b/crates/runner-app/src/session/manager/output.rs @@ -4,6 +4,60 @@ pub(super) const PURGE_RESUME_RESET: &[u8] = b"\x1bc"; pub(super) const KEEP_RESUME_SEAM: &[u8] = b"\x1b[0m\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\r\n"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum LocalInputClass { + SetPending, + ClearPending, + ActivityOnly, +} + +pub(super) fn classify_local_input(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + if bytes == b"\r" || bytes == b"\x03" { + return Some(LocalInputClass::ClearPending); + } + if bytes == b"\x16" || bytes.starts_with(b"\x1b[200~") { + return Some(LocalInputClass::SetPending); + } + if bytes.starts_with(b"\x1b") { + return Some(LocalInputClass::ActivityOnly); + } + if bytes + .iter() + .any(|byte| matches!(byte, 0x20..=0x7e | 0x80..=0xff)) + { + Some(LocalInputClass::SetPending) + } else { + Some(LocalInputClass::ActivityOnly) + } +} + +pub(super) fn update_local_input_state( + state: &mut SessionState, + input_class: Option, + now: Instant, +) -> bool { + match input_class { + Some(LocalInputClass::SetPending) => { + state.local_input_pending = true; + state.last_local_input_at = Some(now); + false + } + Some(LocalInputClass::ClearPending) => { + state.local_input_pending = false; + state.last_local_input_at = None; + true + } + Some(LocalInputClass::ActivityOnly) => { + state.last_local_input_at = Some(now); + false + } + None => false, + } +} + impl SessionManager { /// Forwarder thread shared by `spawn`, `spawn_direct`, and `resume`. /// Drains the runtime's `OutputStream` into `session/output` @@ -216,12 +270,15 @@ impl SessionManager { ) -> Result<()> { let rt_session = self.live_runtime_session(session_id)?; let submitted = bytes == b"\r"; + let input_class = classify_local_input(bytes); let session = self.session_state(session_id); let (transition, mission_status_sink, mission_scoped) = if let Some(session) = session.as_ref() { let mut session = session.lock().unwrap(); let previous_activity = session.activity; let previous_suppression = session.suppress_local_input_busy; + let previous_input_pending = session.local_input_pending; + let previous_input_at = session.last_local_input_at; let mission_status_sink = session.mission_status_sink.clone(); let mission_scoped = session .handle @@ -245,9 +302,15 @@ impl SessionManager { } None }; + // The deferred router slice will consume the clear transition and + // recent-input timestamp when it ports delivery gating. + let _input_cleared = + update_local_input_state(&mut session, input_class, Instant::now()); if let Err(error) = self.write_stdin_bytes(&rt_session, bytes) { session.activity = previous_activity; session.suppress_local_input_busy = previous_suppression; + session.local_input_pending = previous_input_pending; + session.last_local_input_at = previous_input_at; return Err(error); } if submitted { diff --git a/crates/runner-app/src/session/manager/tests.rs b/crates/runner-app/src/session/manager/tests.rs index 1c4356c..e8b3a39 100644 --- a/crates/runner-app/src/session/manager/tests.rs +++ b/crates/runner-app/src/session/manager/tests.rs @@ -1103,6 +1103,66 @@ fn inject_stdin_on_unknown_session_errors_cleanly() { assert!(format!("{err}").contains("session not found")); } +#[test] +fn local_input_byte_classes_drive_pending_state() { + use super::output::{classify_local_input, update_local_input_state, LocalInputClass}; + + assert_eq!( + classify_local_input(b"x"), + Some(LocalInputClass::SetPending) + ); + assert_eq!( + classify_local_input("界".as_bytes()), + Some(LocalInputClass::SetPending) + ); + for protocol in [ + b"\x1b[A".as_slice(), + b"\x1b]10;rgb:dcdc/dcdc/e0e0\x1b\\", + b"\x1b]11;rgb:1515/1616/1b1b\x1b\\", + ] { + assert_eq!( + classify_local_input(protocol), + Some(LocalInputClass::ActivityOnly), + "terminal protocol traffic must not mark local input pending" + ); + } + assert_eq!( + classify_local_input(b"\x1b[200~pasted text\x1b[201~"), + Some(LocalInputClass::SetPending) + ); + assert_eq!( + classify_local_input(b"\x16"), + Some(LocalInputClass::SetPending) + ); + assert_eq!( + classify_local_input(b"\r"), + Some(LocalInputClass::ClearPending) + ); + assert_eq!( + classify_local_input(b"\x03"), + Some(LocalInputClass::ClearPending) + ); + + let now = Instant::now(); + let mut state = SessionState::default(); + update_local_input_state(&mut state, classify_local_input(b"draft"), now); + assert!(state.local_input_pending); + assert_eq!(state.last_local_input_at, Some(now)); + + update_local_input_state(&mut state, classify_local_input(b"\r"), now); + assert!(!state.local_input_pending); + assert!(state.last_local_input_at.is_none()); + + update_local_input_state(&mut state, classify_local_input(b"\x1b[D"), now); + assert!(!state.local_input_pending); + assert_eq!(state.last_local_input_at, Some(now)); + + state.local_input_pending = true; + update_local_input_state(&mut state, classify_local_input(b"\x03"), now); + assert!(!state.local_input_pending); + assert!(state.last_local_input_at.is_none()); +} + // `await_pty_output` was deleted in the Step 9 cutover. Tests // that previously observed echoed bytes from /bin/cat through // a portable-pty master now assert on FakeRuntime's captured @@ -2115,6 +2175,14 @@ fn direct_chat_typing_stays_idle_until_submit() { mgr.inject_direct_stdin(&spawned.id, b"x", cap.as_ref()) .unwrap(); + assert!( + mgr.session_state(&spawned.id) + .unwrap() + .lock() + .unwrap() + .local_input_pending, + "printable terminal input must latch pending input", + ); assert!( !mgr.take_completion_armed(std::slice::from_ref(&spawned.id)), "typing without submit must not arm completion", @@ -2132,6 +2200,12 @@ fn direct_chat_typing_stays_idle_until_submit() { mgr.inject_direct_stdin(&spawned.id, b"\r", cap.as_ref()) .unwrap(); + { + let state = mgr.session_state(&spawned.id).unwrap(); + let state = state.lock().unwrap(); + assert!(!state.local_input_pending, "Enter must clear the latch"); + assert!(state.last_local_input_at.is_none()); + } let submitted = wait_for_session_status_event(&cap, &spawned.id, SessionActivityState::Busy); assert_eq!(submitted.source, "input-submit"); assert_eq!( diff --git a/crates/runner-native/src/bootstrap.rs b/crates/runner-native/src/bootstrap.rs index 9d4e79d..37be842 100644 --- a/crates/runner-native/src/bootstrap.rs +++ b/crates/runner-native/src/bootstrap.rs @@ -1,8 +1,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use anyhow::{Context as _, Result}; -use runner_app::{db, event_bus, events, mcp, session, shell_path, windows, AppCore}; +use anyhow::{bail, Context as _, Result}; +use runner_app::{db, event_bus, events, mcp, repo, session, shell_path, windows, AppCore}; pub const APP_IDENTIFIER: &str = "com.wycstudios.runner"; @@ -76,24 +76,20 @@ pub fn boot_core(paths: &NativePaths) -> Result { Ok(core) } -pub fn stop_running_direct_sessions(core: &AppCore) -> Result<()> { +pub fn stop_running_sessions_on_quit(core: &AppCore) -> Result<()> { let ids = { - let conn = core.db.get().context("get database connection")?; - let mut stmt = conn.prepare( - "SELECT id FROM sessions - WHERE status = 'running' AND mission_id IS NULL", - )?; - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - let mut ids = Vec::new(); - for row in rows { - ids.push(row?); - } - ids + let mut conn = core.db.get().context("get database connection")?; + repo::session::mark_running_for_resume_on_launch(&mut conn) + .context("stamp sessions for resume on launch")? }; + let mut failures = Vec::new(); for id in ids { - core.sessions - .kill(&id) - .with_context(|| format!("stop direct session {id}"))?; + if let Err(error) = core.sessions.kill(&id) { + failures.push(format!("{id}: {error}")); + } + } + if !failures.is_empty() { + bail!("failed to stop sessions on quit: {}", failures.join("; ")); } Ok(()) } @@ -118,4 +114,59 @@ mod tests { assert!(debug.app_data_dir.ends_with("com.wycstudios.runner-dev")); assert!(debug.log_dir.ends_with("com.wycstudios.runner-dev")); } + + #[test] + fn quit_preparation_stamps_running_sessions_and_requeues_claims() { + let temp = tempfile::tempdir().unwrap(); + let pool = Arc::new(db::open_pool(&temp.path().join("runner.db")).unwrap()); + { + let conn = pool.get().unwrap(); + conn.execute( + "INSERT INTO runners + (id, handle, display_name, runtime, command, + args_json, created_at, updated_at) + VALUES + ('r1', 'alpha', 'Alpha', 'shell', '/bin/cat', + '[]', '2026-08-18T00:00:00Z', '2026-08-18T00:00:00Z')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO sessions + (id, runner_id, status, started_at, resume_on_launch) + VALUES + ('s1', 'r1', 'running', '2026-08-18T00:00:00Z', 0), + ('claimed', 'r1', 'stopped', '2026-08-18T00:00:01Z', 2)", + [], + ) + .unwrap(); + } + let runtime: Arc = + Arc::new(session::pty_runtime::PtyRuntime::new()); + let core = AppCore { + db: Arc::clone(&pool), + app_data_dir: PathBuf::new(), + sessions: session::SessionManager::new(shell_path::LoginShellEnv::default(), runtime), + buses: event_bus::BusRegistry::new(), + routers: runner_app::router::RouterRegistry::new(), + mcp: Arc::new(mcp::McpHandle::new()), + windows: Arc::new(windows::WindowRegistry::new()), + events: events::EventChannel::new(), + app_version: "0.0.0-test".into(), + }; + + stop_running_sessions_on_quit(&core).unwrap(); + + let conn = pool.get().unwrap(); + for id in ["s1", "claimed"] { + let stamp: i64 = conn + .query_row( + "SELECT resume_on_launch FROM sessions WHERE id = ?1", + [id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stamp, 1, "{id} must be pending for the next launch"); + } + } } diff --git a/crates/runner-native/src/main.rs b/crates/runner-native/src/main.rs index 8a3b807..9158ada 100644 --- a/crates/runner-native/src/main.rs +++ b/crates/runner-native/src/main.rs @@ -16,7 +16,7 @@ use runner_app::model::{Runner, SessionStatus}; use runner_app::ops::session::DirectSessionEntry; use runner_app::AppCore; use runner_native::bootstrap::{ - boot_core, native_paths, stop_running_direct_sessions, NativePaths, + boot_core, native_paths, stop_running_sessions_on_quit, NativePaths, }; use runner_native::pane_layout::{ PaneLayout, PaneLeaf, PaneNode, PresetKind, SplitOrientation, TabSet, @@ -215,13 +215,17 @@ fn run() -> Result<()> { Application::new().run(move |cx: &mut App| { let quit_core = core.clone(); cx.on_action(move |_: &Quit, cx| { - let _ = stop_running_direct_sessions(&quit_core); + if let Err(error) = stop_running_sessions_on_quit(&quit_core) { + eprintln!("Runner Native quit session teardown failed: {error:#}"); + } cx.quit(); }); let close_core = core.clone(); cx.on_window_closed(move |cx| { - let _ = stop_running_direct_sessions(&close_core); if cx.windows().is_empty() { + if let Err(error) = stop_running_sessions_on_quit(&close_core) { + eprintln!("Runner Native quit session teardown failed: {error:#}"); + } cx.quit(); } }) @@ -255,7 +259,7 @@ fn run() -> Result<()> { cx.activate(true); }); - stop_running_direct_sessions(&shutdown_core)?; + stop_running_sessions_on_quit(&shutdown_core)?; Ok(()) } diff --git a/docs/impls/gpui-rewrite/impl_log.md b/docs/impls/gpui-rewrite/impl_log.md index 041d3c2..9cd71dc 100644 --- a/docs/impls/gpui-rewrite/impl_log.md +++ b/docs/impls/gpui-rewrite/impl_log.md @@ -4,9 +4,9 @@ Progress record for the whole gpui-rewrite program ([README](README.md)), from t ## Current state (update with each entry) -- **Branch**: `gpui-nightly` (M0+M1 merged via PR #407, M2 via PR #408, both 2026-08-17; M3.1 via PR #409, M3.2 via PR #410, M3.3 via the `feat/0046-m3-resize-storms` PR, all 2026-08-18). -- **Done**: 0046 M0 (gpui-ce swap), M1 (repo-and-below at `main` parity, node tree adopted, protocol crates wholesale; human-verified 2026-08-17), M2 (terminal split + shell modularization), M3.1 (session reaping), M3.2 (resume seams + geometry), M3.3 (resize-storm coalescing + owner-pane gating), terminal-pane IME (M4 pull-forward; Pinyin human-verified 2026-08-18), direct-chat composer removal (parity restore, smoke-tested 2026-08-18). -- **Next**: M3.4 (input latch + native quit stamping) closes the session-hardening slice; then the crate renames as one mechanical commit (plan decision 7: `runner-app` → `runner-backend`, `runner-native` → `runner-app`); later M3 slices continue in the 2026-08-18 breakdown order, followed by M4 (UI parity) → M5 (sweep + watermark) per the merged [program plan](plan.md). Parity references are `main`'s React frontend (`src/`) and the `design/*.pen` files via the pencil MCP. +- **Branch**: `gpui-nightly` (M0+M1 merged via PR #407, M2 via PR #408, both 2026-08-17; M3.1 via PR #409, M3.2 via PR #410, M3.3 via PR #411, M3.4 via the `feat/0046-m3-input-latch` PR, all 2026-08-18). Session-hardening slice complete. +- **Done**: 0046 M0 (gpui-ce swap), M1 (repo-and-below at `main` parity, node tree adopted, protocol crates wholesale; human-verified 2026-08-17), M2 (terminal split + shell modularization), M3.1–M3.4 (session-hardening slice: reaping, resume seams + geometry, resize storms + owner gating, input latch + quit stamping), terminal-pane IME (M4 pull-forward; Pinyin human-verified 2026-08-18), direct-chat composer removal (parity restore, smoke-tested 2026-08-18). +- **Next**: crate renames as one mechanical commit (plan decision 7: `runner-app` → `runner-backend`, `runner-native` → `runner-app`); then M3.5 (runtimes slice: discovery, Qoder/TRAE, model catalog, model/effort overrides, codex trust preseed) → start-chat modal + pane controls (M4 pull-forward: `main`'s `StartChatModal` + pane close button replacing the sidebar-swap flow; needs M3.5's backend) → remaining M3 slices → M4 (UI parity) → nightly channel (plan decision 10) → M5 (sweep + watermark). Parity references are `main`'s React frontend (`src/`) and the `design/*.pen` files via the pencil MCP. - **Parity watermark**: `origin/main` fully ported for repo-and-below as of `1b7ee92` (v0.5.2 line, 2026-08-17); feature-logic lag is M3 scope. ## 2026-07-18 — Phase 1 kickoff @@ -174,3 +174,10 @@ M3's slices (0046 §Sequencing) run as serial codex-peer missions, one task at a - With terminal-pane IME verified, the bottom composer under direct-chat terminals is gone: running chats render the terminal as the only input, at parity with `main`'s `RunnerChat` (the composer was this line's IME workaround, never a `main` surface). The terminal grid gains the reclaimed height; the stopped/crashed Resume bar stays. - `composer.rs` deleted rather than parked — dead code fails the clippy gate, and the mission-feed channel composer (0042, later M3 slice) will build on `terminal_ime` + pulse's `text_input` instead. `text_util` and its tests stay (the terminal IME consumes them); `theme::selection` dropped as dead. - Gates: runner-native build + tests green, clippy `-D warnings` clean, fmt clean. Human smoke test passed (typing incl. Pinyin straight into the terminal, shortcuts intact, Resume bar present). + +## 2026-08-18 — M3.4: conservative input latch + native quit stamping + +- Ported the session-manager half of `f926581` (impl 0041's conservative end state): printable/UTF-8 input and bracketed/image paste latch pending input; Enter and Ctrl-C clear it; all escape-prefixed terminal protocol traffic is activity-only; failed writes roll the latch back; respawn, kill, and exit clear it. The source classifier test ports with OSC 10/11 coverage. +- Deferred the router and frontend halves precisely: this branch's router predates `828c9fc`'s reservation/outbox/listener contract, so the latch is tracked but routing does not consume it until the router/inbox slice; pulling that contract now would drag the whole group. `InboxBlockedPill` and `MissionWorkspace` have no native mission surface yet and remain M4 scope; the pill design is the `InboxBlockedPill` component in `design/runner-mission.pen`. +- Ported `c5b1ce4` at the native seam: every real quit path atomically calls `mark_running_for_resume_on_launch` before stopping the returned live sessions, including the final event-loop fallback, and stop failures are aggregated after every marked id is attempted. This activates `cb32720`'s feasible quit-side claim handling: an in-flight launch claim is requeued before teardown. Claim consumption/finalization remains deferred because native has no `take_resume_on_launch` queue, `SessionManager::resume_on_launch` path, setting, or launch UI; manual resume must not finish a launch claim. Until a consumer lands, quit stamps accumulate as an undrained `PENDING` backlog, so the launch-queue slice must explicitly drain and discard the pre-existing backlog on first boot or impose a cutoff before enabling auto-resume. +- Gates: `make verify` green (workspace check, 440 `runner-app` tests, all native/helper tests, clippy `-D warnings`, fmt-check); the 10-test terminal fixture corpus stays green. The sandboxed run hit the expected temporary Unix-socket `EPERM`; the identical permitted run passed. diff --git a/docs/impls/gpui-rewrite/plan.md b/docs/impls/gpui-rewrite/plan.md index bf39b55..a7e8a52 100644 --- a/docs/impls/gpui-rewrite/plan.md +++ b/docs/impls/gpui-rewrite/plan.md @@ -66,6 +66,7 @@ Build: the pioneer line has Rust-only `fmt`, `clippy`, `test`, and `run` Make ta 8. **Pulse is the window-level UI reference** (see §UI reference below). 9. **UI parity is its own milestone (M4)**, after the M3 feature slices — the catchup plan's backend-first slices deliberately defer surface/style parity; M4 restores the full Phase 4 surface breadth as an explicitly planned stage. +10. **Nightly channel before cutover.** After the crate renames, the native app ships as a separate nightly build: own bundle id (`com.wycstudios.runner.nightly`), own Sparkle feed, own GitHub release tags never referenced from the Tauri updater's manifest — so no Tauri user can be updated into it, and the production bundle id + minisign keypair stay reserved for the cutover bridge. It shares the production `runner.db` (guaranteed by decision 2) under a run-one-app-at-a-time discipline — two live instances would fight over the MCP socket, session PIDs, and the startup orphan sweep. Gate: the first nightly ships when M4 (UI parity) is complete — the daily-drive bar is the full product UI including missions; before that the native app cannot run Jason's real workflow. Packaging/notarization/Sparkle infrastructure is prepared earlier in parallel (validated with throwaway internal builds) so the nightly can ship the day M4 closes. Rollout: Jason daily-drives it for ~a month, with bug fixes and remaining work landing as Sparkle nightly updates — this starts the Phase 6 daily-drive clock, proves the updater repeatedly, and surfaces real bugs early; colleagues opt in during the month at their choice (the mission feed exists by then). This pulls Phase 5's packaging/notarization/Sparkle work forward in nightly form. ## Ground rules