agentmux_srv/backend/blockcontroller/subprocess/session.rs
1// Copyright 2025, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Session-id capture and hydration state machine for [`SubprocessController`].
5//!
6//! Two independent sources can set `inner.session_id`:
7//! - `hydrate_session_id_from_config`: best-effort seed from the picker
8//! "My Agents" reattach path (config-supplied, may be stale).
9//! - `record_captured_session_id[_inner]`: authoritative capture from the
10//! CLI's own stdout init event — always wins, and always overwrites a
11//! prior hydrated value.
12
13use std::sync::Mutex;
14
15use super::{SubprocessController, SubprocessControllerInner};
16
17impl SubprocessController {
18 /// Record an authoritative session id captured from the CLI's
19 /// stdout init/`thread.started` event. The CLI is the source of
20 /// truth for which session is live, so this ALWAYS overwrites
21 /// any prior value of `inner.session_id` — including values
22 /// previously hydrated from config on a picker reattach (which
23 /// may be stale by the time the CLI speaks).
24 ///
25 /// Free-function form (taking `&Arc<Mutex<…Inner>>` instead of
26 /// `&self`) so the spawn_turn stdout-reader tokio task can call
27 /// it without holding an `Arc<Self>` reference. The
28 /// `&SubprocessController` method below just delegates.
29 ///
30 /// Returns `true` when the value changed (caller should
31 /// broadcast the meta update + persist to block meta). Returns
32 /// `false` when the new id matches the current one — common
33 /// when the CLI emits the same `session_id` on every NDJSON
34 /// frame within a single turn.
35 ///
36 /// `pub(super)` (not `pub(crate)`): only called from the
37 /// `subprocess` module tree (`mod.rs`/`host_spawn`/`container_spawn`/
38 /// `tests`) — matching the module-private `SubprocessControllerInner`
39 /// parameter's own reachability avoids a `private_interfaces` lint.
40 pub(super) fn record_captured_session_id_inner(
41 inner: &Mutex<SubprocessControllerInner>,
42 sid: &str,
43 ) -> bool {
44 if sid.is_empty() {
45 return false;
46 }
47 let mut guard = inner.lock().unwrap();
48 let differs = guard.session_id.as_deref() != Some(sid);
49 if differs {
50 guard.session_id = Some(sid.to_string());
51 }
52 differs
53 }
54
55 /// `&self` convenience wrapper around
56 /// `record_captured_session_id_inner` — used by tests that
57 /// already hold a `SubprocessController`.
58 #[cfg(test)]
59 pub(crate) fn record_captured_session_id(&self, sid: &str) -> bool {
60 Self::record_captured_session_id_inner(&self.inner, sid)
61 }
62
63 /// Hydrate `inner.session_id` from a config-supplied id when the
64 /// controller hasn't seen a value yet.
65 ///
66 /// Picker reattach path: a fresh `SubprocessController` is
67 /// registered for the new block, so its `inner.session_id` is
68 /// `None`. The frontend persisted the prior block's session id
69 /// into `agent:sessionid` meta, the websocket / app_api caller
70 /// read it into `SubprocessSpawnConfig::session_id`, and this
71 /// method copies it to inner so the spawn_turn args-builder
72 /// appends `--resume <sid>` on the FIRST turn.
73 ///
74 /// **Hydration is best-effort, not authoritative.** If
75 /// `inner.session_id` is already `Some` we no-op (don't overwrite
76 /// a value already in place — could be a captured-from-stdout
77 /// id from an earlier turn, or a prior hydration on the same
78 /// reattach). Critically, the **CLI's stdout-emitted session id
79 /// is authoritative** and overwrites any prior value at capture
80 /// time (see the stdout-reader block in `spawn_turn`). So if the
81 /// hydrated value is stale, the FIRST turn passes the stale id
82 /// via `--resume` (likely accepted as a no-op or rejected with a
83 /// "no such session" error from the CLI), the CLI then emits its
84 /// own session id in the init event, and `inner.session_id` is
85 /// overwritten with that authoritative value for subsequent
86 /// turns. Without the capture overwrite, a stale hydrated id
87 /// would be re-used forever — that was the bug codex flagged on
88 /// PR #1018 first cut.
89 ///
90 /// Empty `&str` is treated as "no value" so the caller can use
91 /// it unconditionally without filtering.
92 pub(crate) fn hydrate_session_id_from_config(&self, config_sid: Option<&str>) {
93 let Some(sid) = config_sid.filter(|s| !s.is_empty()) else {
94 return;
95 };
96 let mut inner = self.inner.lock().unwrap();
97 if inner.session_id.is_some() {
98 return;
99 }
100 tracing::info!(
101 block_id = %self.block_id,
102 session_id = %sid,
103 "hydrated session_id from config (picker reattach)"
104 );
105 inner.session_id = Some(sid.to_string());
106 }
107}