@team-agent/installer 0.3.20 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.20"
569
+ version = "0.3.21"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.20"
12
+ version = "0.3.21"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -116,6 +116,25 @@ fn detect_session_drift(
116
116
  .filter(|s| !s.trim().is_empty())?;
117
117
  let actual = extract_thread_id_from_scrollback(&fact.scrollback_tail)?;
118
118
  if actual.eq_ignore_ascii_case(stored) {
119
+ if agent_has_session_drift(state, &fact.agent_id)
120
+ || fact
121
+ .agent_state_snapshot
122
+ .get("status")
123
+ .and_then(Value::as_str)
124
+ == Some("session_drift")
125
+ {
126
+ clear_agent_session_drift(state, &fact.agent_id);
127
+ let _ = event_log.write(
128
+ "coordinator.session_drift_cleared",
129
+ json!({
130
+ "agent_id": fact.agent_id.as_str(),
131
+ "stored_session_id": stored,
132
+ "actual_thread_id": actual,
133
+ "status": "running",
134
+ "provider": "codex",
135
+ }),
136
+ );
137
+ }
119
138
  return None;
120
139
  }
121
140
  if fact
@@ -278,7 +297,9 @@ fn extract_thread_id_from_scrollback(scrollback: &str) -> Option<String> {
278
297
  while let Some(pos) = lower.get(offset..).and_then(|tail| tail.find(needle)) {
279
298
  let start = offset + pos + needle.len();
280
299
  if let Some(token) = first_token(scrollback.get(start..).unwrap_or_default()) {
281
- found = Some(token.to_ascii_lowercase());
300
+ if is_uuid_token(&token) {
301
+ found = Some(token.to_ascii_lowercase());
302
+ }
282
303
  }
283
304
  offset = start;
284
305
  }
@@ -297,11 +318,33 @@ fn first_token(text: &str) -> Option<String> {
297
318
  .unwrap_or(trimmed);
298
319
  let token: String = trimmed
299
320
  .chars()
300
- .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
321
+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
301
322
  .collect();
302
323
  (!token.is_empty()).then_some(token)
303
324
  }
304
325
 
326
+ fn is_uuid_token(token: &str) -> bool {
327
+ let bytes = token.as_bytes();
328
+ if bytes.len() != 36 {
329
+ return false;
330
+ }
331
+ for (idx, byte) in bytes.iter().enumerate() {
332
+ match idx {
333
+ 8 | 13 | 18 | 23 => {
334
+ if *byte != b'-' {
335
+ return false;
336
+ }
337
+ }
338
+ _ => {
339
+ if !byte.is_ascii_hexdigit() {
340
+ return false;
341
+ }
342
+ }
343
+ }
344
+ }
345
+ true
346
+ }
347
+
305
348
  fn mark_agent_session_drift(
306
349
  state: &mut Value,
307
350
  agent_id: &AgentId,
@@ -341,6 +384,49 @@ fn mark_agent_session_drift(
341
384
  }
342
385
  }
343
386
 
387
+ fn clear_agent_session_drift(state: &mut Value, agent_id: &AgentId) {
388
+ if let Some(agent) = agent_object_mut(state, agent_id) {
389
+ agent.insert("status".to_string(), Value::String("running".to_string()));
390
+ agent.remove("session_drift");
391
+ }
392
+ if let Some(teams) = state.get_mut("teams").and_then(Value::as_object_mut) {
393
+ for team in teams.values_mut() {
394
+ if let Some(agent) = team
395
+ .get_mut("agents")
396
+ .and_then(Value::as_object_mut)
397
+ .and_then(|agents| agents.get_mut(agent_id.as_str()))
398
+ .and_then(Value::as_object_mut)
399
+ {
400
+ agent.insert("status".to_string(), Value::String("running".to_string()));
401
+ agent.remove("session_drift");
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ fn agent_has_session_drift(state: &Value, agent_id: &AgentId) -> bool {
408
+ state
409
+ .get("agents")
410
+ .and_then(Value::as_object)
411
+ .and_then(|agents| agents.get(agent_id.as_str()))
412
+ .and_then(|agent| agent.get("status"))
413
+ .and_then(Value::as_str)
414
+ == Some("session_drift")
415
+ || state
416
+ .get("teams")
417
+ .and_then(Value::as_object)
418
+ .is_some_and(|teams| {
419
+ teams.values().any(|team| {
420
+ team.get("agents")
421
+ .and_then(Value::as_object)
422
+ .and_then(|agents| agents.get(agent_id.as_str()))
423
+ .and_then(|agent| agent.get("status"))
424
+ .and_then(Value::as_str)
425
+ == Some("session_drift")
426
+ })
427
+ })
428
+ }
429
+
344
430
  fn match_api_error(scrollback: &str) -> Option<(String, String)> {
345
431
  let lines: Vec<String> = scrollback
346
432
  .lines()
@@ -510,3 +596,166 @@ fn tail_chars(text: &str, max_chars: usize) -> String {
510
596
  let start = chars.len().saturating_sub(max_chars);
511
597
  chars[start..].iter().collect()
512
598
  }
599
+
600
+ #[cfg(test)]
601
+ mod tests {
602
+ use super::*;
603
+ use std::collections::BTreeMap;
604
+ use std::path::PathBuf;
605
+ use std::sync::atomic::{AtomicU64, Ordering};
606
+
607
+ use crate::model::ids::{AgentId, TeamKey};
608
+
609
+ const STORED: &str = "11111111-1111-4111-8111-111111111111";
610
+ const OTHER: &str = "22222222-2222-4222-8222-222222222222";
611
+
612
+ #[test]
613
+ fn session_drift_ignores_plain_text_after_marker_words() {
614
+ let workspace = temp_workspace("plain-text");
615
+ let mut state = state_with_agent("running", None);
616
+ let captures = captures("w1", STORED, "Need resume confirmation before changing anything.\nSeveral threads mention evidence.\n");
617
+
618
+ let observed = observe_runtime(&workspace, &mut state, captures, None);
619
+
620
+ assert!(
621
+ observed.session_drift.is_empty(),
622
+ "plain prose must not be treated as a runtime id observation: {observed:?}"
623
+ );
624
+ assert_eq!(
625
+ state.pointer("/agents/w1/status").and_then(Value::as_str),
626
+ Some("running")
627
+ );
628
+ assert!(state.pointer("/agents/w1/session_drift").is_none());
629
+ let _ = std::fs::remove_dir_all(workspace);
630
+ }
631
+
632
+ #[test]
633
+ fn session_drift_marks_real_uuid_mismatch() {
634
+ let workspace = temp_workspace("uuid-mismatch");
635
+ let mut state = state_with_agent("running", None);
636
+ let captures = captures(
637
+ "w1",
638
+ STORED,
639
+ &format!("Codex resumed. Switched to thread {OTHER}\n"),
640
+ );
641
+
642
+ let observed = observe_runtime(&workspace, &mut state, captures, None);
643
+
644
+ assert_eq!(observed.session_drift.len(), 1, "{observed:?}");
645
+ assert_eq!(
646
+ state.pointer("/agents/w1/status").and_then(Value::as_str),
647
+ Some("session_drift")
648
+ );
649
+ assert_eq!(
650
+ state
651
+ .pointer("/agents/w1/session_drift/actual_thread_id")
652
+ .and_then(Value::as_str),
653
+ Some(OTHER)
654
+ );
655
+ let _ = std::fs::remove_dir_all(workspace);
656
+ }
657
+
658
+ #[test]
659
+ fn session_drift_clears_existing_marker_when_uuid_matches() {
660
+ let workspace = temp_workspace("uuid-match-clear");
661
+ let mut state = state_with_agent(
662
+ "session_drift",
663
+ Some(json!({
664
+ "stored_session_id": STORED,
665
+ "actual_thread_id": OTHER,
666
+ "detected_at": "2026-06-13T00:00:00Z",
667
+ "remediation": "old"
668
+ })),
669
+ );
670
+ let captures = captures("w1", STORED, &format!("resume {STORED}\n"));
671
+
672
+ let observed = observe_runtime(&workspace, &mut state, captures, None);
673
+
674
+ assert!(
675
+ observed.session_drift.is_empty(),
676
+ "matching observation clears state instead of reporting a new drift: {observed:?}"
677
+ );
678
+ assert_eq!(
679
+ state.pointer("/agents/w1/status").and_then(Value::as_str),
680
+ Some("running")
681
+ );
682
+ assert!(state.pointer("/agents/w1/session_drift").is_none());
683
+ assert_eq!(
684
+ state
685
+ .pointer("/teams/current/agents/w1/status")
686
+ .and_then(Value::as_str),
687
+ Some("running")
688
+ );
689
+ assert!(state
690
+ .pointer("/teams/current/agents/w1/session_drift")
691
+ .is_none());
692
+ let _ = std::fs::remove_dir_all(workspace);
693
+ }
694
+
695
+ fn captures(
696
+ agent_id: &str,
697
+ stored_session_id: &str,
698
+ scrollback_tail: &str,
699
+ ) -> BTreeMap<AgentId, CapturedRuntimeFact> {
700
+ let agent_id = AgentId::new(agent_id);
701
+ let snapshot = json!({
702
+ "provider": "codex",
703
+ "status": "running",
704
+ "session_id": stored_session_id,
705
+ });
706
+ BTreeMap::from([(
707
+ agent_id.clone(),
708
+ CapturedRuntimeFact {
709
+ team_key: Some(TeamKey::new("current")),
710
+ agent_id,
711
+ provider: Some(Provider::Codex),
712
+ session_name: None,
713
+ window: None,
714
+ pane_id: None,
715
+ scrollback_tail: scrollback_tail.to_string(),
716
+ pane_info: None,
717
+ agent_state_snapshot: snapshot,
718
+ stored_session_id: Some(stored_session_id.to_string()),
719
+ last_output_at: None,
720
+ rollout_path: None,
721
+ process_liveness: None,
722
+ },
723
+ )])
724
+ }
725
+
726
+ fn state_with_agent(status: &str, session_drift: Option<Value>) -> Value {
727
+ let mut agent = json!({
728
+ "provider": "codex",
729
+ "status": status,
730
+ "session_id": STORED,
731
+ });
732
+ if let Some(drift) = session_drift {
733
+ agent["session_drift"] = drift;
734
+ }
735
+ json!({
736
+ "active_team_key": "current",
737
+ "agents": {
738
+ "w1": agent.clone()
739
+ },
740
+ "teams": {
741
+ "current": {
742
+ "agents": {
743
+ "w1": agent
744
+ }
745
+ }
746
+ }
747
+ })
748
+ }
749
+
750
+ fn temp_workspace(tag: &str) -> PathBuf {
751
+ static COUNTER: AtomicU64 = AtomicU64::new(0);
752
+ let dir = std::env::temp_dir().join(format!(
753
+ "ta-rs-e28-{tag}-{}-{}",
754
+ std::process::id(),
755
+ COUNTER.fetch_add(1, Ordering::Relaxed)
756
+ ));
757
+ let _ = std::fs::remove_dir_all(&dir);
758
+ std::fs::create_dir_all(&dir).unwrap();
759
+ std::fs::canonicalize(dir).unwrap()
760
+ }
761
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.20",
3
+ "version": "0.3.21",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.3.20",
24
- "@team-agent/cli-darwin-x64": "0.3.20",
25
- "@team-agent/cli-linux-x64": "0.3.20"
23
+ "@team-agent/cli-darwin-arm64": "0.3.21",
24
+ "@team-agent/cli-darwin-x64": "0.3.21",
25
+ "@team-agent/cli-linux-x64": "0.3.21"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",