Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 31 additions & 2 deletions crates/daemon/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ mod widgets;

#[cfg(test)]
use lifecycle::{
force_redraw_size_on_resume, install_session_identity_env, resume_redraw_ready,
should_resume_on_startup, start_params_for_create,
force_redraw_size_on_resume, install_session_identity_env, reattached_state,
resume_redraw_ready, should_resume_on_startup, start_params_for_create,
};

const BROADCAST_CAP: usize = 4096;
Expand Down Expand Up @@ -7418,6 +7418,35 @@ mod tests {
assert!(!should_resume_on_startup(SessionState::Done));
}

/// Reattaching to an adapter that outlived the daemon must not invent a
/// turn. The adapter is parked exactly where it was, and for a headless
/// session it will not speak again until one runs — so a false `Running`
/// there is unfalsifiable by anything except a client opening the session,
/// and meanwhile it spins a working glyph and banks fake compute time.
#[test]
fn reattach_leaves_an_idle_session_idle() {
assert_eq!(
reattached_state(SessionState::AwaitingInput),
SessionState::AwaitingInput
);
// Everything else: a live adapter means the session is live. Mid-turn
// stays mid-turn, a never-started session gets a non-terminal
// placeholder, and an `Errored` one must stop looking dead now that
// its adapter answered.
assert_eq!(
reattached_state(SessionState::Running),
SessionState::Running
);
assert_eq!(
reattached_state(SessionState::Pending),
SessionState::Running
);
assert_eq!(
reattached_state(SessionState::Errored),
SessionState::Running
);
}

#[test]
fn clipboard_attachment_names_are_safe_and_typed() {
assert_eq!(
Expand Down
27 changes: 26 additions & 1 deletion crates/daemon/src/session/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,9 +591,10 @@ impl SessionManager {
*entry.adapter.lock().await = Some(adapter);
let snapshot = {
let mut s = entry.summary.write().await;
let resumed_state = reattached_state(s.state);
crate::session::set_state_tracked(
&mut s,
SessionState::Running,
resumed_state,
Utc::now().timestamp_millis(),
);
s.pending_input = false;
Expand Down Expand Up @@ -916,6 +917,30 @@ pub(super) fn should_resume_on_startup(state: SessionState) -> bool {
!matches!(state, SessionState::Done)
}

/// The lifecycle state a session takes when the daemon *reattaches* to an
/// adapter that outlived it, rather than spawning a replacement.
///
/// Nothing about that adapter changed across the restart: a session parked at
/// its harness's prompt is still parked there, and the adapter — already
/// blocked waiting for input — has no reason to emit another status. Claiming
/// `Running` there invents work that is not happening, and only the PTY
/// quiescence sweep can take it back. That sweep needs PTY output to measure
/// silence against, so a headless session (whose child emits none until a turn
/// runs) stays falsely `Running` until a client opens it and the hydration
/// resize forces a repaint. Meanwhile the false span inflates the session's
/// compute accounting and paints a working spinner over an idle session.
///
/// So an idle session stays idle. Every other state resumes as `Running`,
/// which is what a live adapter means for them: `Running` was mid-turn and
/// still is, `Pending` never reported anything, and `Errored` must stop
/// looking terminal now that its adapter answered (see the spawn path below).
pub(super) fn reattached_state(persisted: SessionState) -> SessionState {
match persisted {
SessionState::AwaitingInput => SessionState::AwaitingInput,
_ => SessionState::Running,
}
}

// Decide whether to schedule the bump+restore SIGWINCH cycle after a
// session.start succeeds on respawn. Returns the size to restore to
// (we always restore to the cached size, then bump by one column for
Expand Down
80 changes: 80 additions & 0 deletions crates/e2e/tests/restart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,86 @@ async fn restart_reloads_updated_binary() {
}
}

// ---------------------------------------------------------------------------
// 1b. Session state across the restart
// ---------------------------------------------------------------------------

/// A restart must not invent work. An adapter that outlives the daemon is
/// reattached — nothing about it changed, so a session parked at its harness's
/// prompt is still parked there and must come back `AwaitingInput`.
///
/// Reporting `Running` instead is not a cosmetic slip: only the PTY quiescence
/// sweep takes it back, and that sweep needs PTY output to measure silence
/// against. A session whose child stays silent until its next turn (every
/// headless one) therefore stays falsely `Running` until a client opens it and
/// the hydration resize forces a repaint — spinning a working glyph and
/// banking compute time for a session doing nothing.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restart_keeps_an_idle_session_idle() {
use construct_protocol::SessionState;

let d = Daemon::spawn().await.expect("spawn daemon");
let cwd = d.dir.path().to_string_lossy().to_string();
let id = d
.client
.create(CreateSessionParams {
harness: "shell".into(),
cwd,
prompt: None,
model: None,
title: Some("idle across restart".into()),
mode: None,
pty_size: None,
worktree: false,
env: std::collections::HashMap::new(),
args: Vec::new(),
kind: Default::default(),
parent_session_id: None,
group_id: None,
position_after_session_id: None,
forked_from: None,
})
.await
.expect("create shell session");

// Let the shell settle at its prompt: the adapter reports the idle via
// foreground-process-group detection, no client interaction needed.
let deadline = Instant::now() + Duration::from_secs(15);
loop {
let state = d.client.get(&id).await.expect("session detail").summary.state;
if state == SessionState::AwaitingInput {
break;
}
assert!(
Instant::now() < deadline,
"shell session never went idle before the restart (state {state:?})"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}

let _ = d.client.daemon_restart(None, false).await;
let client = d
.wait_until_back(Duration::from_secs(30))
.await
.expect("daemon did not come back");

// Sampled rather than checked once: the bug painted `Running` for the
// whole post-resume life of the session, so any sample catches it, and
// nothing here pokes the child into output that could legitimately move
// it off idle.
let watch_until = Instant::now() + Duration::from_secs(3);
while Instant::now() < watch_until {
let state = client.get(&id).await.expect("session detail").summary.state;
assert_eq!(
state,
SessionState::AwaitingInput,
"reattached idle session came back as {state:?}"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}

// ---------------------------------------------------------------------------
// 2. TUI auto-reconnect
// ---------------------------------------------------------------------------
Expand Down
31 changes: 31 additions & 0 deletions specs/0180-a-restart-does-not-invent-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 0180-a-restart-does-not-invent-work

Status: accepted
Date: 2026-08-02
Area: architecture
Scope: What lifecycle state a session comes back with when a daemon restart reattaches to an adapter that outlived the daemon.

## Decision

A daemon restart may not report a session as working unless it is.

When the daemon reattaches to an adapter process that survived the restart, the session keeps the lifecycle state it was persisted with. A session parked at its harness's prompt comes back idle. Only a session the daemon has to bring back — one whose adapter is gone and whose replacement child is booting — may be marked as running on the strength of the resume alone, and only because the resume genuinely started something.

## Reason

A surviving adapter did not change across the restart. It is blocked in the same place it was blocked before, and an adapter waiting for input has no reason to announce anything: its next status arrives when a turn ends, which is to say never, until someone starts one.

That makes an optimistic "running" unfalsifiable. The daemon's only correction for harnesses that do not self-report is the idle sweep over PTY silence, and silence is only measurable against output. A session whose child emits nothing until its next turn — every headless one — therefore stays wrongly running until a client happens to open it and the attach repaints its child. Until then the fleet paints a working indicator over an idle session, the operator cannot tell which of their sessions are actually busy, and the session banks compute time it never spent.

The cost of the honest default is bounded in the other direction: if a reattached session really was mid-turn, its state says so, because that is what was persisted.

## Consequences

- Resume must treat "the adapter is alive" and "the session is working" as different facts. Any future resume path that writes a state has to justify it from what the harness actually reported, not from the fact that a socket answered.
- A non-terminal placeholder on the boot path stays legitimate: a session that was never started, or that errored and is being retried, must stop looking dead the moment its adapter is back, and something really is starting.
- Compute accounting stays truthful across restarts, since busy spans are opened only by real turns. Restart no longer inflates a session's recorded compute time by its idle hours.
- Clients may keep treating a headless session's running state as "working right now" — that reading is only sound while the daemon refuses to assert it speculatively.

## Non-Goals

Does not change which sessions are resumed at startup, and does not add a status query to the harness protocol. The rule is about not overwriting known state, not about interrogating adapters for fresh state.
Loading