Skip to main content

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}