Skip to main content

agentmux_srv/backend/blockcontroller/persistent/
stderr_reader.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The persistent CLI's stderr reader task, started by `spawn_process`
5//! (`spawn.rs`): logs every line and reacts to a stale `--resume`.
6
7use super::*;
8
9/// What the stderr reader task captures from `spawn_process`.
10pub(super) struct StderrReaderCtx {
11    pub(super) stderr_pipe: tokio::process::ChildStderr,
12    pub(super) block_id_stderr: String,
13    pub(super) inner_stderr: Arc<Mutex<PersistentInner>>,
14    pub(super) mstore_stderr: Option<Arc<Store>>,
15    pub(super) event_bus_stderr: Option<Arc<EventBus>>,
16    pub(super) attempted_resume_sid: Option<String>,
17    pub(super) my_generation_stderr: u64,
18}
19
20impl PersistentSubprocessController {
21    /// Body of the stderr reader task; `spawn_process` keeps its JoinHandle
22    /// for the process waiter.
23    pub(super) async fn run_stderr_reader(ctx: StderrReaderCtx) {
24        let StderrReaderCtx {
25            stderr_pipe,
26            block_id_stderr,
27            inner_stderr,
28            mstore_stderr,
29            event_bus_stderr,
30            attempted_resume_sid,
31            my_generation_stderr,
32        } = ctx;
33        let mut reader = BufReader::new(stderr_pipe).lines();
34        while let Ok(Some(line)) = reader.next_line().await {
35            tracing::warn!(
36                block_id = %block_id_stderr,
37                line = %line,
38                "persistent stderr"
39            );
40            // Claude Code's own message when `--resume <sid>` targets a
41            // conversation its current CLAUDE_CONFIG_DIR can't see — e.g.
42            // after a relogin/reseed moves the agent onto a different
43            // config dir than the one the session was recorded under. Left
44            // uncleared, EVERY future respawn (one per message, since a
45            // dead persistent process auto-restarts on next send) keeps
46            // retrying the same unreachable --resume and immediately
47            // exits again — a permanent "Agent encountered an error" with
48            // no path to recovery. Clear it so the next respawn starts a
49            // fresh conversation instead.
50            if line.contains("No conversation found with session ID") {
51                if let Some(ref bad_sid) = attempted_resume_sid {
52                    // See PersistentInner::poison_resume — also guards
53                    // against the stdout reader's own capture (`stdout_reader.rs`)
54                    // re-adopting this same dead id if it wins the race.
55                    inner_stderr.lock().unwrap().poison_resume(bad_sid, my_generation_stderr);
56                    tracing::warn!(
57                        block_id = %block_id_stderr,
58                        session_id = %bad_sid,
59                        "stale --resume session id unreachable under the current config dir — \
60                         clearing so the next message starts a fresh conversation"
61                    );
62                    core::persist_session_id(&block_id_stderr, "", &mstore_stderr, &event_bus_stderr);
63                    // Surface this to the user — previously silent
64                    // (only the warn! above). See
65                    // SPEC_PANE_CLOSE_REOPEN_CONTINUITY_GUARANTEE_2026_07_27.md
66                    // §4.2: a resumed conversation silently starting
67                    // fresh, with no indication anything happened, is
68                    // exactly the failure mode this flag exists to close.
69                    if let Some(ref store) = mstore_stderr {
70                        crate::backend::blockcontroller::session_recovery::mark_resume_failed(
71                            store,
72                            &event_bus_stderr,
73                            &block_id_stderr,
74                        );
75                    }
76                }
77            }
78        }
79    }
80}