1use super::*;
10use crate::backend::continuity_segments as segs;
11
12pub(super) type SegmentRef = Option<(String, String)>;
14
15fn spawn_config_dir(provider: &str, env: &std::collections::HashMap<String, String>) -> Option<String> {
20 if let Some(p) = crate::backend::providers::get_provider(provider) {
21 if let Some(v) = env.get(p.auth_config_dir_env_var) {
22 return Some(v.clone());
23 }
24 }
25 crate::backend::providers::all_providers().find_map(|p| env.get(p.auth_config_dir_env_var).cloned())
26}
27
28use agentmux_common::time::now_ms;
29
30fn zone_size(zone: &str) -> Option<i64> {
33 let gfs = crate::backend::agent_session::global_transcript_store()?;
34 gfs.line_state(zone, crate::backend::agent_session::OUTPUT_FILE).ok()?.map(|s| s.size)
35}
36
37impl PersistentSubprocessController {
38 pub(super) fn lease_then_gate(
53 &self,
54 config: &PersistentSpawnConfig,
55 gfs: Option<&FileStore>,
56 history_session: Option<String>,
57 ) -> Result<Option<Arc<crate::backend::agent_admission::HeldAgentLease>>, String> {
58 let candidate = if config.resume_flag.is_empty() {
59 None
60 } else {
61 self.inner.lock().unwrap().session_id.clone().or_else(|| history_session.clone())
62 };
63 let lease = match self.acquire_agent_lease(config, candidate.as_deref()) {
64 Ok(lease) => lease,
65 Err(e) => {
72 let uid = config.env_vars.get("AGENTMUX_AGENT_UID").map(|u| u.trim()).filter(|u| !u.is_empty());
73 if let Some(other) = uid.and_then(|uid| self.agent_held_by_other_pane(uid)) {
74 return Err(super::held_elsewhere_error(&other, false));
75 }
76 if let Some((other, closing)) = candidate.as_deref().and_then(|sid| self.session_held_elsewhere(sid)) {
77 return Err(super::held_elsewhere_error(&other, closing));
78 }
79 return Err(e);
80 }
81 };
82 self.apply_resume_gate_with(config, gfs, history_session);
83 Ok(lease)
84 }
85
86 pub(super) fn apply_resume_gate_with(
105 &self,
106 config: &PersistentSpawnConfig,
107 gfs: Option<&FileStore>,
108 history_session: Option<String>,
109 ) {
110 if config.resume_flag.is_empty() {
111 return;
112 }
113 let (held, poisoned) = {
114 let inner = self.inner.lock().unwrap();
115 (inner.session_id.clone(), inner.resume_poisoned.clone())
116 };
117 let relocate_only = held.is_none();
118 let Some(candidate) = held.or(history_session).filter(|c| poisoned.as_deref() != Some(c.as_str())) else {
119 return;
120 };
121 let Some(config_dir) = config.env_vars.get("CLAUDE_CONFIG_DIR") else { return };
125 let provider = self
128 .mstore
129 .as_deref()
130 .and_then(|s| s.get::<crate::backend::obj::Block>(&self.block_id).ok().flatten())
131 .map(|b| crate::backend::obj::meta_get_string(&b.meta, "agentProvider", ""))
132 .unwrap_or_default();
133 if !matches!(provider.as_str(), "" | "claude" | "claude-code") {
134 return;
135 }
136 let (Some(gfs), Some(uid)) = (gfs, config.env_vars.get("AGENTMUX_AGENT_UID").filter(|u| !u.trim().is_empty()))
137 else {
138 return;
139 };
140 let uid = uid.trim();
141 let cwd = config.working_dir.as_str();
142 let swept = crate::backend::continuity_relocate::sweep(config_dir, cwd, uid);
147 if swept > 0 {
148 tracing::info!(target: "continuity", block_id = %self.block_id, swept, "resume gate: removed relocated copies a dead spawn left");
149 }
150 let head = segs::chain_head(gfs, uid).map(segs::with_legacy_identity);
151 let fork_source = if head.is_none() { self.mstore.as_deref().and_then(|s| segs::fork_source_head(s, gfs, uid)) } else { None };
153 let identity = crate::identity::account_email::identity_key_from_oauth_dir("claude", config_dir);
154 let input = segs::GateInput {
155 candidate: &candidate,
156 head: head.as_ref(),
157 identity: identity.as_deref(),
158 poisoned: poisoned.as_deref(),
159 config_dir,
160 cwd,
161 fork_source: fork_source.as_ref(),
162 };
163 let decision = segs::resume_gate(
164 &input,
165 |sid| provider_file_size(config_dir, cwd, sid),
166 crate::backend::continuity_relocate::can_relocate,
167 );
168 let place = |from_config_dir: &str, from_cwd: &str, head: &str| {
170 crate::backend::continuity_relocate::source_file(from_config_dir, from_cwd, head)
171 .ok_or_else(|| "source gone".to_string())
172 .and_then(|src| crate::backend::continuity_relocate::relocate(&src, config_dir, cwd, head, uid))
173 };
174 let refuse = |head: &str, why: &str| {
175 tracing::info!(
176 target: "continuity",
177 block_id = %self.block_id,
178 candidate = %candidate,
179 head = %head,
180 why,
181 "resume gate: the conversation is in a session this spawn can't resume; starting fresh with the record"
182 );
183 (None, None, false)
184 };
185 if relocate_only && !matches!(decision, segs::ResumeGate::Relocate { .. } | segs::ResumeGate::ForkFrom { .. }) {
186 return;
187 }
188 let (next, fork_copy, fork) = match decision {
189 segs::ResumeGate::Allow => return,
190 segs::ResumeGate::Fork { head } => {
191 tracing::info!(
192 target: "continuity",
193 block_id = %self.block_id,
194 candidate = %candidate,
195 head = %head,
196 "resume gate: the session grew after AgentMux last saw it (continued outside AgentMux); forking it"
197 );
198 (Some(head), None, true)
199 }
200 segs::ResumeGate::Redirect { head } => {
201 tracing::info!(
202 target: "continuity",
203 block_id = %self.block_id,
204 candidate = %candidate,
205 head = %head,
206 "resume gate: the conversation moved on; resuming the chain head instead"
207 );
208 (Some(head), None, false)
209 }
210 segs::ResumeGate::Refuse { head } => refuse(&head, "out of reach, or another identity's"),
211 segs::ResumeGate::Relocate { head, from_config_dir } => {
212 match place(&from_config_dir, cwd, &head) {
213 Ok(copy) => {
214 tracing::info!(
215 target: "continuity",
216 block_id = %self.block_id,
217 candidate = %candidate,
218 head = %head,
219 from = %from_config_dir,
220 "resume gate: the conversation is under another login of the same identity; relocated it to fork from"
221 );
222 (Some(head), Some(copy), true)
223 }
224 Err(e) => refuse(&head, &format!("relocation failed: {e}")),
225 }
226 }
227 segs::ResumeGate::ForkFrom { head, from_config_dir, from_cwd } => match place(&from_config_dir, &from_cwd, &head) {
228 Ok(copy) => {
229 tracing::info!(
230 target: "continuity",
231 block_id = %self.block_id,
232 head = %head,
233 from = %from_config_dir,
234 "resume gate: a fork's first spawn; copied the conversation it was forked from to fork from"
235 );
236 (Some(head), Some(copy), true)
237 }
238 Err(e) => refuse(&head, &format!("copying the conversation it was forked from failed: {e}")),
239 },
240 };
241 {
242 let mut inner = self.inner.lock().unwrap();
243 let judged = if relocate_only { None } else { Some(candidate.as_str()) };
246 if inner.session_id.as_deref() != judged {
247 drop(inner);
248 if let Some(copy) = fork_copy {
249 crate::backend::continuity_relocate::remove(©);
250 }
251 return;
252 }
253 inner.session_id = next.clone();
254 inner.fork_copy = fork_copy;
255 inner.fork_next = fork;
256 }
257 core::persist_session_id(&self.block_id, next.as_deref().unwrap_or(""), &self.mstore, &self.event_bus);
258 }
259
260 pub(super) fn reconcile_memory_before_spawn(&self, config: &PersistentSpawnConfig) {
266 let (Some(gfs), Some(mstore)) = (crate::backend::agent_session::global_transcript_store(), self.mstore.as_deref()) else {
267 return;
268 };
269 let Some(uid) = config.env_vars.get("AGENTMUX_AGENT_UID").filter(|u| !u.is_empty()) else {
270 return;
271 };
272 let meta = mstore
273 .get::<crate::backend::obj::Block>(&self.block_id)
274 .ok()
275 .flatten()
276 .map(|b| b.meta)
277 .unwrap_or_default();
278 if crate::backend::obj::meta_get_string(&meta, "agent:memoryrecord", "") == "off" {
279 return;
280 }
281 let live_elsewhere = super::super::get_all_controllers().into_iter().any(|(block_id, ctrl)| {
285 block_id != self.block_id
286 && ctrl.as_any().downcast_ref::<PersistentSubprocessController>().is_some_and(|other| {
287 other.stable_agent_uid.lock().unwrap().as_deref() == Some(uid.as_str())
288 && other.inner.lock().unwrap().current_pid.is_some()
289 })
290 });
291 if live_elsewhere {
292 tracing::info!(block_id = %self.block_id, uid, "memory reconcile skipped: the agent is live in another pane");
293 return;
294 }
295 let provider = crate::backend::obj::meta_get_string(&meta, "agentProvider", "");
296 let reconcile = || {
297 crate::backend::memory_reconcile::reconcile_before_spawn(
298 gfs,
299 mstore,
300 &crate::backend::memory_reconcile::SpawnMemory {
301 uid,
302 provider: &provider,
303 config_dir: config.env_vars.get("CLAUDE_CONFIG_DIR").map(String::as_str),
304 cwd: &config.working_dir,
305 overridden: crate::backend::memory_reconcile::spawn_overrides_memory_dir(&config.env_vars, &config.cli_args),
306 history_stores: None,
307 },
308 crate::backend::memory_reconcile::RECONCILE_BUDGET,
309 )
310 };
311 let on_multi_thread = tokio::runtime::Handle::try_current()
316 .is_ok_and(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread);
317 let report = if on_multi_thread { tokio::task::block_in_place(reconcile) } else { reconcile() };
318 if report.deferred {
319 tracing::info!(block_id = %self.block_id, uid, "memory reconcile deferred: over budget");
320 } else if report != Default::default() {
321 tracing::info!(block_id = %self.block_id, uid, ?report, "memory reconciled before spawn");
322 }
323 }
324
325 #[allow(clippy::too_many_arguments)]
329 pub(super) fn record_segment_start(
330 &self,
331 agent_uid: &str,
332 config: &PersistentSpawnConfig,
333 attempted_resume_sid: Option<&str>,
334 forked_from: Option<&str>,
335 relocated: bool,
336 carries_packet: bool,
337 lease_epoch: Option<u64>,
338 ) -> SegmentRef {
339 let gfs = crate::backend::agent_session::global_transcript_store()?;
340 let start = self.segment_start(agent_uid, config, attempted_resume_sid, forked_from, relocated, carries_packet, lease_epoch);
341 let rung = start.continuity_rung;
342 match segs::record_start(gfs, start) {
343 Ok(id) => {
344 tracing::info!(block_id = %self.block_id, segment_id = %id, rung = ?rung, "continuity: segment started");
345 Some((agent_uid.to_string(), id))
346 }
347 Err(e) => {
348 tracing::warn!(block_id = %self.block_id, error = %e, "continuity: segment start not recorded");
349 None
350 }
351 }
352 }
353
354 #[allow(clippy::too_many_arguments)]
360 pub(super) fn segment_start(
361 &self,
362 agent_uid: &str,
363 config: &PersistentSpawnConfig,
364 attempted_resume_sid: Option<&str>,
365 forked_from: Option<&str>,
366 relocated: bool,
367 carries_packet: bool,
368 lease_epoch: Option<u64>,
369 ) -> segs::Start {
370 let meta = self
371 .mstore
372 .as_deref()
373 .and_then(|s| s.get::<crate::backend::obj::Block>(&self.block_id).ok().flatten())
374 .map(|b| b.meta)
375 .unwrap_or_default();
376 let definition_id = Some(crate::backend::obj::meta_get_string(&meta, "agentId", "")).filter(|d| !d.is_empty());
377 let provider = crate::backend::obj::meta_get_string(&meta, "agentProvider", "");
378 let zone = crate::backend::agent_session::agent_zone_for_block_meta(&meta);
379 let config_dir = spawn_config_dir(&provider, &config.env_vars);
380 let identity_key = config_dir
381 .as_deref()
382 .and_then(|dir| crate::identity::account_email::identity_key_from_oauth_dir(&provider, dir));
383 segs::Start {
384 segment_id: String::new(),
385 agent_uid: agent_uid.to_string(),
386 definition_id,
387 config_dir,
388 identity_key,
389 forked_from: forked_from.map(str::to_string),
390 provider,
391 provider_session_id: attempted_resume_sid.filter(|_| !relocated).map(str::to_string),
392 cwd: config.working_dir.clone(),
393 channel: crate::backend::reactive::registry::local_channel_id(),
394 agentmux_version: crate::backend::base::get_version().to_string(),
395 block_id: self.block_id.clone(),
396 byte_start: zone.as_deref().and_then(zone_size),
397 zone,
398 started_at_ms: now_ms(),
399 continuity_rung: segs::rung_for_spawn(attempted_resume_sid.is_some(), carries_packet),
400 predecessor_segment_id: None,
401 lease_epoch,
402 }
403 }
404}
405
406pub(super) fn record_segment_session(segment: &SegmentRef, provider_session_id: &str) {
408 let (Some((uid, id)), Some(gfs)) = (segment, crate::backend::agent_session::global_transcript_store()) else {
409 return;
410 };
411 if let Err(e) = segs::record_session(gfs, uid, id, provider_session_id, now_ms()) {
412 tracing::warn!(segment_id = %id, error = %e, "continuity: segment session not recorded");
413 }
414}
415
416fn provider_file_size(config_dir: &str, cwd: &str, sid: &str) -> Option<u64> {
420 let path = crate::backend::session_backfill::session_file_path(config_dir, cwd, sid)?;
421 std::fs::metadata(path).ok().filter(|m| m.is_file()).map(|m| m.len())
422}
423
424fn provider_bytes_for_segment(gfs: &FileStore, uid: &str, id: &str) -> Option<i64> {
427 let segment = segs::segments(gfs, uid).into_iter().find(|s| s.start.segment_id == id)?;
428 let start = segment.start;
429 if !matches!(start.provider.as_str(), "claude" | "claude-code") {
430 return None;
431 }
432 let size = provider_file_size(start.config_dir.as_deref()?, &start.cwd, start.provider_session_id.as_deref()?)?;
433 i64::try_from(size).ok()
434}
435
436pub(super) fn record_segment_end(segment: &SegmentRef, reason: segs::EndReason, zone: Option<&str>) {
437 let (Some((uid, id)), Some(gfs)) = (segment, crate::backend::agent_session::global_transcript_store()) else {
438 return;
439 };
440 let provider_bytes = provider_bytes_for_segment(gfs, uid, id);
441 if let Err(e) = segs::record_end_with_provider_bytes(gfs, uid, id, reason, zone.and_then(zone_size), provider_bytes, now_ms()) {
442 tracing::warn!(segment_id = %id, error = %e, "continuity: segment end not recorded");
443 } else {
444 tracing::info!(segment_id = %id, reason = ?reason, "continuity: segment ended");
445 }
446}
447
448#[cfg(test)]
449mod config_dir_tests {
450 use super::spawn_config_dir;
451
452 fn env(pairs: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
453 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
454 }
455
456 #[test]
457 fn each_provider_records_its_own_config_dir() {
458 assert_eq!(spawn_config_dir("claude", &env(&[("CLAUDE_CONFIG_DIR", "/c")])).as_deref(), Some("/c"));
459 assert_eq!(spawn_config_dir("gemini", &env(&[("GEMINI_CLI_HOME", "/g")])).as_deref(), Some("/g"));
460 assert_eq!(spawn_config_dir("qwen", &env(&[("QWEN_HOME", "/q")])).as_deref(), Some("/q"));
461 assert_eq!(spawn_config_dir("kimi", &env(&[("KIMI_SHARE_DIR", "/k")])).as_deref(), Some("/k"));
462 }
463
464 #[test]
465 fn the_providers_own_variable_wins_over_another_in_the_env() {
466 let e = env(&[("CLAUDE_CONFIG_DIR", "/c"), ("CODEX_HOME", "/x")]);
467 assert_eq!(spawn_config_dir("codex", &e).as_deref(), Some("/x"));
468 assert_eq!(spawn_config_dir("", &env(&[])), None);
469 }
470}
471
472pub(super) fn forked_outcome(
476 outcome: persistent_resume::SessionOutcome,
477 forked_from: Option<&str>,
478 attempted_sid: &str,
479 actual_sid: Option<&str>,
480) -> persistent_resume::SessionOutcome {
481 let forked = forked_from == Some(attempted_sid) && actual_sid.is_some_and(|a| a != attempted_sid);
482 if forked {
483 persistent_resume::SessionOutcome::Resumed
484 } else {
485 outcome
486 }
487}
488
489pub(super) fn kept_relocated_id(
498 adopted: bool,
499 confirmed: bool,
500 holds_current: bool,
501 has_copy: bool,
502 forked_from: Option<&str>,
503 captured: &str,
504) -> bool {
505 !adopted && confirmed && holds_current && has_copy && forked_from == Some(captured)
506}
507
508pub(super) fn settle_relocated_copy(
515 block_id: &str,
516 copy: &crate::backend::continuity_relocate::Relocated,
517 forked_from: Option<&str>,
518 captured: &str,
519) -> bool {
520 if forked_from == Some(captured) {
521 let owned = crate::backend::continuity_relocate::unmark(copy);
522 if owned {
523 tracing::warn!(
524 target: "continuity",
525 block_id,
526 session_id = captured,
527 "relocated resume did not fork; keeping the copy as the live session"
528 );
529 }
530 owned
531 } else {
532 crate::backend::continuity_relocate::remove(copy)
533 }
534}