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}