@team-agent/installer 0.5.121 → 0.5.122

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
@@ -727,7 +727,7 @@ dependencies = [
727
727
 
728
728
  [[package]]
729
729
  name = "team-agent"
730
- version = "0.5.121"
730
+ version = "0.5.122"
731
731
  dependencies = [
732
732
  "anyhow",
733
733
  "chrono",
package/Cargo.toml CHANGED
@@ -11,7 +11,7 @@ exclude = ["crates/team-agent-contract"]
11
11
 
12
12
  [workspace.package]
13
13
  edition = "2021"
14
- version = "0.5.121"
14
+ version = "0.5.122"
15
15
  license = "AGPL-3.0"
16
16
  rust-version = "1.95"
17
17
 
@@ -15,6 +15,7 @@ use super::*;
15
15
  /// agent. `has_session`→true (gate passes); `inject`→Ok (delivery may run); the rest are Ok defaults.
16
16
  struct CapturingTransport {
17
17
  scrollback: String,
18
+ targets: Vec<PaneInfo>,
18
19
  }
19
20
  impl Transport for CapturingTransport {
20
21
  fn kind(&self) -> BackendKind {
@@ -73,7 +74,7 @@ impl Transport for CapturingTransport {
73
74
  Ok(PaneLiveness::Live)
74
75
  }
75
76
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
76
- Ok(Vec::new())
77
+ Ok(self.targets.clone())
77
78
  }
78
79
  fn has_session(&self, _s: &SessionName) -> Result<bool, TransportError> {
79
80
  Ok(true)
@@ -106,6 +107,14 @@ impl Transport for CapturingTransport {
106
107
  fn seeded_health_coord(
107
108
  agents: serde_json::Value,
108
109
  scrollback: &str,
110
+ ) -> (Coordinator, std::path::PathBuf) {
111
+ seeded_health_coord_with_readiness(agents, scrollback, false)
112
+ }
113
+
114
+ fn seeded_health_coord_with_readiness(
115
+ agents: serde_json::Value,
116
+ scrollback: &str,
117
+ ready: bool,
109
118
  ) -> (Coordinator, std::path::PathBuf) {
110
119
  use std::sync::atomic::{AtomicU64, Ordering};
111
120
  static N: AtomicU64 = AtomicU64::new(0);
@@ -115,11 +124,18 @@ fn seeded_health_coord(
115
124
  N.fetch_add(1, Ordering::Relaxed)
116
125
  ));
117
126
  std::fs::create_dir_all(&dir).unwrap();
118
- crate::state::persist::save_runtime_state(
119
- &dir,
120
- &serde_json::json!({ "session_name": "team-health", "agents": agents }),
121
- )
122
- .unwrap();
127
+ let mut state = serde_json::json!({ "session_name": "team-health", "agents": agents });
128
+ #[cfg(unix)]
129
+ let targets = if ready {
130
+ vec![crate::mcp_server::readiness::test_ready_worker(
131
+ &dir, &mut state, "current", "w1", "%1",
132
+ )]
133
+ } else {
134
+ Vec::new()
135
+ };
136
+ #[cfg(not(unix))]
137
+ let targets = { let _ = ready; Vec::new() };
138
+ crate::state::persist::save_runtime_state(&dir, &state).unwrap();
123
139
  let ws = WorkspacePath::new(dir.clone());
124
140
  let reg: Box<dyn ProviderRegistry> = Box::new(MockRegistry::new(&[], &[]));
125
141
  let coord = Coordinator::for_test(
@@ -127,6 +143,7 @@ fn seeded_health_coord(
127
143
  reg,
128
144
  Box::new(CapturingTransport {
129
145
  scrollback: scrollback.to_string(),
146
+ targets,
130
147
  }),
131
148
  None,
132
149
  None,
@@ -260,10 +277,12 @@ fn spine2_capture_missing_captures_session_id_for_missing_agent() {
260
277
  // activity/agent_health WORKING remains diagnostic turn state.
261
278
  #[test]
262
279
  fn spine2_sync_health_working_status_delivers_same_tick() {
263
- let (coord, dir) = seeded_health_coord(one_agent("codex"), "Working (5s · esc to interrupt)");
280
+ let (coord, dir) = seeded_health_coord_with_readiness(
281
+ one_agent("codex"), "Working (5s · esc to interrupt)", true,
282
+ );
264
283
  let store = MessageStore::open(&dir).unwrap();
265
284
  let mid = store
266
- .create_message(Some("t"), "leader", "w1", "hi", None, true, None)
285
+ .create_message(Some("t"), "leader", "w1", "hi", None, true, Some("current"))
267
286
  .unwrap();
268
287
  let conn = crate::db::schema::open_db(store.db_path()).unwrap();
269
288
  conn.execute(
@@ -1410,6 +1410,9 @@ pub(super) fn mark_agent_started(
1410
1410
  "spawn_cwd".to_string(),
1411
1411
  serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
1412
1412
  );
1413
+ if let Some(path) = &spawn.mcp_config_path {
1414
+ agent.insert("mcp_config".to_string(), serde_json::json!(path));
1415
+ }
1413
1416
  // 0.4.6 Stage 1+2: ensure spawn_epoch is present after every start.
1414
1417
  // reset_agent_at_paths bumps it before start; non-reset starts
1415
1418
  // initialise to 1 (or preserve existing) so capture/event paths can
@@ -45,6 +45,8 @@ pub(super) struct SpawnedAgentWindow {
45
45
  pub profile_launch: crate::provider::ProviderProfileLaunch,
46
46
  pub layout_placement: Option<crate::lifecycle::launch::LayoutPlacement>,
47
47
  pub spawn_cwd: std::path::PathBuf,
48
+ /// Actual configuration generated for this spawn, not inferred from old state.
49
+ pub mcp_config_path: Option<std::path::PathBuf>,
48
50
  /// Issue 2 (Round 3b gate review §6): the resolved `owner_team_id` used
49
51
  /// for this spawn's MCP env / command. Callers (`mark_agent_respawned`,
50
52
  /// `mark_agent_started`) must persist this back into the agent row so
@@ -701,6 +703,7 @@ pub(super) fn spawn_agent_window(
701
703
  profile_launch,
702
704
  layout_placement: layout_placement.cloned(),
703
705
  spawn_cwd: native_spawn_cwd,
706
+ mcp_config_path,
704
707
  owner_team_id: team_id,
705
708
  })
706
709
  }
@@ -2480,6 +2480,9 @@ fn mark_agent_respawned(
2480
2480
  "spawn_cwd".to_string(),
2481
2481
  serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
2482
2482
  );
2483
+ if let Some(path) = &spawn.mcp_config_path {
2484
+ agent.insert("mcp_config".to_string(), serde_json::json!(path));
2485
+ }
2483
2486
  // 0.5.38 (`.team/artifacts/startup-latency-locate.md` §7): every
2484
2487
  // multi-worker restart respawn is a fresh process cohort. The
2485
2488
  // pre-0.5.38 code only wrote `spawned_at` here and left `spawn_epoch`
@@ -2968,6 +2971,7 @@ impl Clone for ParallelSpawnResult {
2968
2971
  profile_launch: self.spawn.profile_launch.clone(),
2969
2972
  layout_placement: self.spawn.layout_placement.clone(),
2970
2973
  spawn_cwd: self.spawn.spawn_cwd.clone(),
2974
+ mcp_config_path: self.spawn.mcp_config_path.clone(),
2971
2975
  owner_team_id: self.spawn.owner_team_id.clone(),
2972
2976
  },
2973
2977
  spawn_start: self.spawn_start,
@@ -4232,6 +4236,7 @@ tasks:
4232
4236
  profile_launch: crate::provider::ProviderProfileLaunch::default(),
4233
4237
  layout_placement: None,
4234
4238
  spawn_cwd: std::path::PathBuf::from("/tmp/team-epoch"),
4239
+ mcp_config_path: None,
4235
4240
  owner_team_id: None,
4236
4241
  };
4237
4242
  mark_agent_respawned(
@@ -59,6 +59,14 @@ fn b4_monitor_step_failure_does_not_block_deliver_pending() {
59
59
  let _g = EnvGuard::set(&[(ANCESTRY_KEY, NEUTRAL_ANCESTRY)]);
60
60
  let ws = tmp_ws("b4-monitor");
61
61
  seed_copilot_team_state(&ws, "cp1", true);
62
+ let mut state = team_agent::state::persist::load_runtime_state(&ws).unwrap();
63
+ #[cfg(unix)]
64
+ let targets = vec![crate::mcp_server::readiness::test_ready_worker(
65
+ &ws, &mut state, "team-b4", "cp1", "%41",
66
+ )];
67
+ #[cfg(not(unix))]
68
+ let targets = Vec::new();
69
+ save_runtime_state(&ws, &state).unwrap();
62
70
  let store = MessageStore::open(&ws).unwrap();
63
71
  store
64
72
  .create_message(
@@ -68,14 +76,17 @@ fn b4_monitor_step_failure_does_not_block_deliver_pending() {
68
76
  "do the thing",
69
77
  None,
70
78
  false,
71
- None,
79
+ Some("team-b4"),
72
80
  )
73
81
  .unwrap();
74
82
 
75
83
  let coord = Coordinator::new(
76
84
  WorkspacePath::new(ws.clone()),
77
85
  Box::new(RealRegistry),
78
- Box::new(MonitorFailTransport::new()),
86
+ Box::new(MonitorFailTransport {
87
+ targets,
88
+ ..MonitorFailTransport::new()
89
+ }),
79
90
  );
80
91
  let report = coord.tick();
81
92
 
@@ -603,11 +614,13 @@ fn attach_socket_path(command: &str) -> Option<PathBuf> {
603
614
  /// (B-4). Sessions/windows present so the tick proceeds to the monitor steps.
604
615
  struct MonitorFailTransport {
605
616
  session: Mutex<bool>,
617
+ targets: Vec<PaneInfo>,
606
618
  }
607
619
  impl MonitorFailTransport {
608
620
  fn new() -> Self {
609
621
  Self {
610
622
  session: Mutex::new(true),
623
+ targets: Vec::new(),
611
624
  }
612
625
  }
613
626
  }
@@ -688,7 +701,7 @@ impl Transport for MonitorFailTransport {
688
701
  }
689
702
  }
690
703
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
691
- Ok(Vec::new())
704
+ Ok(self.targets.clone())
692
705
  }
693
706
  fn list_windows(&self, _s: &SessionName) -> Result<Vec<WindowName>, TransportError> {
694
707
  Ok(vec![WindowName::new("cp1")])
@@ -36,6 +36,112 @@ use team_agent::lifecycle::{
36
36
  };
37
37
  use team_agent::transport::test_support::OfflineTransport;
38
38
 
39
+ #[test]
40
+ #[serial(env)]
41
+ fn issue345_dynamic_cursor_start_persists_its_generated_mcp_path() {
42
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("cursor-dynamic-mcp");
43
+ let (ws, _home, _guard, team) = seed_cursor("dynamic-mcp");
44
+ std::fs::write(
45
+ team.join("agents/cursor_writer.md"),
46
+ "---\nprovider: fake\ndangerously_skip_permissions: true\n---\nControl seat.\n",
47
+ )
48
+ .unwrap();
49
+ let transport = OfflineTransport::new();
50
+ quick_start_with_transport_in_workspace(&ws, &team, None, true, Some("cursortm"), &transport)
51
+ .unwrap();
52
+ let role = team.join("cursor_dynamic.md");
53
+ std::fs::write(&role, "---\nprovider: cursor_agent\nmodel: grok-4.7-high\nauth_mode: subscription\ndangerously_skip_permissions: true\n---\nDynamic Cursor seat.\n").unwrap();
54
+ crate::lifecycle::add_agent_with_transport(
55
+ &team,
56
+ &AgentId::new("cursor_dynamic"),
57
+ &role,
58
+ false,
59
+ Some("cursortm"),
60
+ &transport,
61
+ )
62
+ .unwrap();
63
+ let state = load_runtime_state(&ws).unwrap();
64
+ let expected = ws.join(".team/runtime/mcp/cursor_dynamic.json");
65
+ assert!(
66
+ expected.is_file(),
67
+ "positive control: spawn generated the real config"
68
+ );
69
+ assert_eq!(
70
+ state["agents"]["cursor_dynamic"]["mcp_config"].as_str(),
71
+ expected.to_str()
72
+ );
73
+ assert_eq!(
74
+ state["teams"]["cursortm"]["agents"]["cursor_dynamic"]["mcp_config"].as_str(),
75
+ expected.to_str()
76
+ );
77
+ let config: serde_json::Value =
78
+ serde_json::from_slice(&std::fs::read(&expected).unwrap()).unwrap();
79
+ assert_eq!(
80
+ config["mcpServers"]["team_orchestrator"]["env"]["TEAM_AGENT_ID"],
81
+ "cursor_dynamic"
82
+ );
83
+ assert_eq!(
84
+ config["mcpServers"]["team_orchestrator"]["env"]["TEAM_AGENT_OWNER_TEAM_ID"],
85
+ "cursortm"
86
+ );
87
+ }
88
+
89
+ #[test]
90
+ #[serial(env)]
91
+ fn issue345_cursor_wakeup_restores_missing_derived_mcp_path() {
92
+ let _hermetic = hermetic_guard::HermeticTestEnv::enter_with_leader("cursor-wakeup-mcp");
93
+ let (ws, _home, _guard, team) = seed_cursor("wakeup-mcp");
94
+ let transport = OfflineTransport::new();
95
+ quick_start_with_transport_in_workspace(&ws, &team, None, true, Some("cursortm"), &transport)
96
+ .unwrap();
97
+ crate::lifecycle::restart::stop_agent_with_transport(
98
+ &ws,
99
+ &AgentId::new("cursor_writer"),
100
+ Some("cursortm"),
101
+ &transport,
102
+ )
103
+ .unwrap();
104
+ let mut state = load_runtime_state(&ws).unwrap();
105
+ state["agents"]["cursor_writer"]
106
+ .as_object_mut()
107
+ .unwrap()
108
+ .remove("mcp_config");
109
+ state["teams"]["cursortm"]["agents"]["cursor_writer"]
110
+ .as_object_mut()
111
+ .unwrap()
112
+ .remove("mcp_config");
113
+ // Plant an actual legacy row; ordinary state saves intentionally backfill fields.
114
+ std::fs::write(
115
+ crate::state::persist::runtime_state_path(&ws),
116
+ serde_json::to_vec(&state).unwrap(),
117
+ )
118
+ .unwrap();
119
+ assert!(load_runtime_state(&ws).unwrap()["agents"]["cursor_writer"]
120
+ .get("mcp_config")
121
+ .is_none());
122
+ crate::lifecycle::restart::start_agent_with_transport(
123
+ &ws,
124
+ &AgentId::new("cursor_writer"),
125
+ false,
126
+ false,
127
+ true,
128
+ Some("cursortm"),
129
+ &transport,
130
+ )
131
+ .unwrap();
132
+ let after = load_runtime_state(&ws).unwrap();
133
+ let expected = ws.join(".team/runtime/mcp/cursor_writer.json");
134
+ assert!(expected.is_file());
135
+ assert_eq!(
136
+ after["agents"]["cursor_writer"]["mcp_config"].as_str(),
137
+ expected.to_str()
138
+ );
139
+ assert_eq!(
140
+ after["teams"]["cursortm"]["agents"]["cursor_writer"]["mcp_config"].as_str(),
141
+ expected.to_str()
142
+ );
143
+ }
144
+
39
145
  #[test]
40
146
  #[serial(env)]
41
147
  fn cursor_fresh_spawn_does_not_invent_session_id() {
@@ -109,7 +215,23 @@ fn cursor_restart_uses_resume_when_session_captured_and_archive_present() {
109
215
  primary.to_string_lossy().as_ref()
110
216
  );
111
217
  patch_cursor_writer(&mut state, sid, &dir, &primary);
112
- save_runtime_state(&ws, &state).expect("save captured");
218
+ state["agents"]["cursor_writer"]
219
+ .as_object_mut()
220
+ .unwrap()
221
+ .remove("mcp_config");
222
+ state["teams"]["cursortm"]["agents"]["cursor_writer"]
223
+ .as_object_mut()
224
+ .unwrap()
225
+ .remove("mcp_config");
226
+ // Preserve the real captured SID while removing only the derived config path.
227
+ std::fs::write(
228
+ crate::state::persist::runtime_state_path(&ws),
229
+ serde_json::to_vec(&state).unwrap(),
230
+ )
231
+ .unwrap();
232
+ assert!(load_runtime_state(&ws).unwrap()["agents"]["cursor_writer"]
233
+ .get("mcp_config")
234
+ .is_none());
113
235
 
114
236
  seed_healthy_coordinator(&ws);
115
237
 
@@ -135,6 +257,15 @@ fn cursor_restart_uses_resume_when_session_captured_and_archive_present() {
135
257
  primary.to_string_lossy().as_ref()
136
258
  );
137
259
  assert_eq!(after["agents"]["cursor_writer"]["session_id"], sid);
260
+ let expected_config = ws.join(".team/runtime/mcp/cursor_writer.json");
261
+ assert_eq!(
262
+ after["agents"]["cursor_writer"]["mcp_config"].as_str(),
263
+ expected_config.to_str()
264
+ );
265
+ assert_eq!(
266
+ after["teams"]["cursortm"]["agents"]["cursor_writer"]["mcp_config"].as_str(),
267
+ expected_config.to_str()
268
+ );
138
269
  assert_eq!(
139
270
  cursor_mcp_project_dir(&ws, "cursor_writer").unwrap(),
140
271
  primary_before
@@ -651,10 +651,10 @@ fn contract_b_delivery_gate_trusts_startup_handled_state_over_pane_scrollback()
651
651
  "SUB step2 probe: handled startup trust must deliver despite scrollback residue.",
652
652
  None,
653
653
  true,
654
- None,
654
+ Some("subprep-scrollback"),
655
655
  )
656
656
  .unwrap();
657
- let handled_state = json!({
657
+ let mut handled_state = json!({
658
658
  "session_name": "team-ta-subroot-SUB-PROBE-2-send-turn-idle",
659
659
  "agents": {
660
660
  "w1": {
@@ -670,9 +670,15 @@ fn contract_b_delivery_gate_trusts_startup_handled_state_over_pane_scrollback()
670
670
  }
671
671
  }
672
672
  });
673
+ #[cfg(unix)]
674
+ let pane = crate::mcp_server::readiness::test_ready_worker(
675
+ &handled_ws, &mut handled_state, "subprep-scrollback", "w1", "%7",
676
+ );
673
677
  team_agent::state::persist::save_runtime_state(&handled_ws, &handled_state).unwrap();
674
678
  let handled_transport =
675
679
  RecordingTransport::new(vec![SUBROOT_FULL_TRUST_PANE_AFTER_QUICKSTART.to_string()]);
680
+ #[cfg(unix)]
681
+ { handled_transport.state.lock().unwrap().carrier = pane.pane_pid; }
676
682
  let handled_log = EventLog::new(&handled_ws);
677
683
  let handled = deliver_pending_message(
678
684
  &handled_ws,
@@ -273,9 +273,6 @@ pub(crate) fn preflight(
273
273
  let Some(agent) = state.get("agents").and_then(|agents| agents.get(agent_id)) else {
274
274
  return Ok(None);
275
275
  };
276
- let Some(config) = agent.get("mcp_config").and_then(Value::as_str) else {
277
- return Ok(None);
278
- };
279
276
  let provider = agent
280
277
  .get("provider")
281
278
  .and_then(Value::as_str)
@@ -284,6 +281,11 @@ pub(crate) fn preflight(
284
281
  if !crate::provider::get_adapter(provider).requires_worker_mcp_handshake() {
285
282
  return Ok(None);
286
283
  }
284
+ let config = agent
285
+ .get("mcp_config")
286
+ .and_then(Value::as_str)
287
+ .filter(|path| !path.trim().is_empty())
288
+ .ok_or("mcp_handshake_config_missing")?;
287
289
  let owner = owner
288
290
  .filter(|s| !s.is_empty())
289
291
  .ok_or("mcp_handshake_scope_missing")?;
@@ -427,6 +429,67 @@ pub(crate) fn test_carrier_pid() -> Option<u32> {
427
429
  crate::platform::process::current_parent_pid()
428
430
  }
429
431
 
432
+ /// Bind a controlled live test helper through the real receipt publisher. This
433
+ /// does not bypass preflight or fabricate a process/image/ancestry receipt.
434
+ #[cfg(test)]
435
+ pub(crate) fn test_ready_worker(
436
+ workspace: &Path,
437
+ state: &mut Value,
438
+ owner: &str,
439
+ agent: &str,
440
+ pane: &str,
441
+ ) -> PaneInfo {
442
+ let workspace = workspace.canonicalize().unwrap();
443
+ let carrier = test_carrier_pid().unwrap();
444
+ let config = workspace
445
+ .join(".team/runtime/mcp")
446
+ .join(format!("{agent}.json"));
447
+ std::fs::create_dir_all(config.parent().unwrap()).unwrap();
448
+ std::fs::write(&config, serde_json::to_vec(&serde_json::json!({
449
+ "mcpServers": {"team_orchestrator": {
450
+ "command": std::env::current_exe().unwrap().canonicalize().unwrap(),
451
+ "env": {"TEAM_AGENT_WORKSPACE": workspace, "TEAM_AGENT_ID": agent, "TEAM_AGENT_OWNER_TEAM_ID": owner}
452
+ }}
453
+ })).unwrap()).unwrap();
454
+ state["team_key"] = serde_json::json!(owner);
455
+ state["active_team_key"] = serde_json::json!(owner);
456
+ let row = &mut state["agents"][agent];
457
+ row["mcp_config"] = serde_json::json!(config);
458
+ row["pane_id"] = serde_json::json!(pane);
459
+ row["pane_pid"] = serde_json::json!(carrier);
460
+ row["spawned_at"] =
461
+ serde_json::json!((Utc::now() - chrono::Duration::seconds(10)).to_rfc3339());
462
+ let window = row["window"].as_str().unwrap_or(agent).to_string();
463
+ let session = state["session_name"].as_str().unwrap().to_string();
464
+ if state["teams"][owner].is_object() {
465
+ state["teams"][owner]["agents"][agent] = state["agents"][agent].clone();
466
+ }
467
+ publish(&workspace, owner, agent, Utc::now()).unwrap();
468
+ let target = PaneInfo {
469
+ pane_id: crate::transport::PaneId::new(pane),
470
+ session: crate::transport::SessionName::new(session),
471
+ window_index: None,
472
+ window_name: Some(crate::transport::WindowName::new(window)),
473
+ pane_index: None,
474
+ tty: None,
475
+ current_command: None,
476
+ current_path: Some(workspace.clone()),
477
+ active: true,
478
+ pane_pid: Some(carrier),
479
+ leader_env: Default::default(),
480
+ };
481
+ let admission = preflight(&workspace, Some(owner), agent, state)
482
+ .unwrap()
483
+ .unwrap();
484
+ revalidate(
485
+ &admission,
486
+ &Target::Pane(target.pane_id.clone()),
487
+ std::slice::from_ref(&target),
488
+ )
489
+ .unwrap();
490
+ target
491
+ }
492
+
430
493
  #[cfg(test)]
431
494
  #[path = "readiness_tests.rs"]
432
495
  mod tests;
@@ -94,6 +94,29 @@ impl Drop for Case {
94
94
  }
95
95
  }
96
96
 
97
+ #[test]
98
+ fn issue345_missing_mcp_config_does_not_disable_eager_admission() {
99
+ for provider in ["cursor_agent", "codex", "claude"] {
100
+ let mut case = Case::new();
101
+ case.state["agents"]["worker"]["provider"] = json!(provider);
102
+ case.state["agents"]["worker"]
103
+ .as_object_mut()
104
+ .unwrap()
105
+ .remove("mcp_config");
106
+ assert!(
107
+ case.admission().is_err(),
108
+ "missing config cannot disable {provider} admission"
109
+ );
110
+ for value in [Value::Null, json!(false), json!(""), json!(" ")] {
111
+ case.state["agents"]["worker"]["mcp_config"] = value;
112
+ assert!(
113
+ case.admission().is_err(),
114
+ "invalid config cannot disable {provider} admission"
115
+ );
116
+ }
117
+ }
118
+ }
119
+
97
120
  #[test]
98
121
  fn missing_handshake_defers_then_a_live_scoped_receipt_admits() {
99
122
  let case = Case::new();
@@ -238,5 +261,12 @@ fn fake_and_pi_keep_their_existing_non_eager_or_runtime_registry_admission() {
238
261
  assert!(preflight(&case.ws, Some("owner"), "worker", &state)
239
262
  .unwrap()
240
263
  .is_none());
264
+ state["agents"]["worker"]
265
+ .as_object_mut()
266
+ .unwrap()
267
+ .remove("mcp_config");
268
+ assert!(preflight(&case.ws, Some("owner"), "worker", &state)
269
+ .unwrap()
270
+ .is_none());
241
271
  }
242
272
  }
@@ -122,9 +122,20 @@ fn successful_transport_report_does_not_override_unconsumed_composer() {
122
122
  "[Pasted text #1 +25 lines]",
123
123
  "foreign draft",
124
124
  ] {
125
- let f = Fixture::new("cursor_agent");
125
+ let mut f = Fixture::new("cursor_agent");
126
+ #[cfg(unix)]
127
+ let panes = vec![crate::mcp_server::readiness::test_ready_worker(
128
+ &f.workspace,
129
+ &mut f.state,
130
+ "team",
131
+ "worker",
132
+ "%1",
133
+ )];
134
+ #[cfg(not(unix))]
135
+ let panes = f.transport().list_targets().unwrap();
126
136
  let t = f
127
137
  .transport()
138
+ .with_targets(panes)
128
139
  .with_native_input_observation(frame(composer, ""));
129
140
  let out = f.deliver(&t);
130
141
  assert!(!out.ok, "composer={composer}");
@@ -66,6 +66,30 @@ fn eager_mcp_waits_without_spending_input_attempts_then_delivers_the_same_messag
66
66
  assert_eq!(transport.inject_count(), 1, "same queued row proceeds after the real receipt");
67
67
  }
68
68
 
69
+ #[test]
70
+ #[cfg(unix)]
71
+ fn issue345_missing_eager_config_defers_without_input_or_attempts() {
72
+ let mut case = WorkerCase::new("missing-mcp-config", "cursor_agent");
73
+ let carrier = crate::mcp_server::readiness::test_carrier_pid().unwrap();
74
+ case.state["team_key"] = serde_json::json!("dup-team");
75
+ case.state["agents"]["w1"]["pane_pid"] = serde_json::json!(carrier);
76
+ case.state["agents"]["w1"]["spawned_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
77
+ case.state["teams"] = serde_json::json!({"dup-team": case.state.clone()});
78
+ crate::state::persist::save_runtime_state(&case.workspace, &case.state).unwrap();
79
+ let transport = RecordingTransport { carrier: Some(carrier), ..RecordingTransport::new("") };
80
+ let message_id = case.store.create_message(None, "leader", "w1", "missing config canary", None, false, Some("dup-team")).unwrap();
81
+ for _ in 0..3 {
82
+ let outcome = deliver_pending_message(&case.workspace, &case.store, &transport,
83
+ &message_id, &case.event_log, &case.state).unwrap();
84
+ assert!(!outcome.ok);
85
+ assert!(outcome.verification.as_deref().unwrap().starts_with("mcp_handshake_"));
86
+ assert_eq!(transport.inject_count(), 0, "missing metadata must never reach input");
87
+ }
88
+ let connection = rusqlite::Connection::open(case.store.db_path()).unwrap();
89
+ let attempts: i64 = connection.query_row("select delivery_attempts from messages where message_id=?1", [&message_id], |row| row.get(0)).unwrap();
90
+ assert_eq!(attempts, 0, "metadata wait is not an input attempt");
91
+ }
92
+
69
93
  #[test]
70
94
  fn error_retry_claim_is_single_winner_across_two_delivers() {
71
95
  let case = WorkerCase::new("claim-gate", "fake");
@@ -141,10 +165,30 @@ fn observer_state_save_failure_after_enter_does_not_return_err() {
141
165
 
142
166
  #[test]
143
167
  fn pane_token_refuses_second_inject_for_claude_grok_cursor() {
144
- for provider in ["claude", "grok", "cursor"] {
145
- let case = WorkerCase::new(&format!("token-{provider}"), provider);
146
- let transport = RecordingTransport::new("");
147
- let message_id = case.seed_leader_to_worker(&format!("{provider} canary"));
168
+ for provider in ["claude", "grok", "cursor_agent"] {
169
+ let mut case = WorkerCase::new(&format!("token-{provider}"), provider);
170
+ #[cfg(unix)]
171
+ let carrier = crate::mcp_server::readiness::test_carrier_pid().unwrap();
172
+ #[cfg(not(unix))]
173
+ let carrier = std::process::id();
174
+ let config = case.workspace.join(".team/runtime/mcp/w1.json");
175
+ std::fs::create_dir_all(config.parent().unwrap()).unwrap();
176
+ std::fs::write(&config, serde_json::to_vec(&serde_json::json!({
177
+ "mcpServers": {"team_orchestrator": {
178
+ "command": std::env::current_exe().unwrap().canonicalize().unwrap(),
179
+ "env": {"TEAM_AGENT_WORKSPACE": case.workspace, "TEAM_AGENT_ID": "w1", "TEAM_AGENT_OWNER_TEAM_ID": "dup-team"}
180
+ }}
181
+ })).unwrap()).unwrap();
182
+ case.state["team_key"] = serde_json::json!("dup-team");
183
+ case.state["agents"]["w1"]["mcp_config"] = serde_json::json!(config);
184
+ case.state["agents"]["w1"]["pane_pid"] = serde_json::json!(carrier);
185
+ case.state["agents"]["w1"]["spawned_at"] = serde_json::json!((chrono::Utc::now() - chrono::Duration::seconds(10)).to_rfc3339());
186
+ case.state["teams"] = serde_json::json!({"dup-team": case.state.clone()});
187
+ crate::state::persist::save_runtime_state(&case.workspace, &case.state).unwrap();
188
+ #[cfg(unix)]
189
+ crate::mcp_server::readiness::publish(&case.workspace, "dup-team", "w1", chrono::Utc::now()).unwrap();
190
+ let transport = RecordingTransport { carrier: Some(carrier), ..RecordingTransport::new("") };
191
+ let message_id = case.store.create_message(None, "leader", "w1", &format!("{provider} canary"), None, false, Some("dup-team")).unwrap();
148
192
  let first = deliver_pending_message(
149
193
  &case.workspace,
150
194
  &case.store,
@@ -169,7 +169,10 @@ fn p2_scheduler_unknown_kind_surfaces_error() {
169
169
 
170
170
  /// A delivery transport whose `inject` RECORDS-and-succeeds (the real deliver loop legitimately
171
171
  /// injects; the §84-guard NoopTransport panics). Only `inject` is reached by deliver_pending_message.
172
- struct DeliverOkTransport;
172
+ #[derive(Default)]
173
+ struct DeliverOkTransport {
174
+ targets: Vec<PaneInfo>,
175
+ }
173
176
  impl Transport for DeliverOkTransport {
174
177
  fn kind(&self) -> BackendKind {
175
178
  BackendKind::Tmux
@@ -232,7 +235,7 @@ impl Transport for DeliverOkTransport {
232
235
  Ok(PaneLiveness::Unknown)
233
236
  }
234
237
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
235
- Ok(Vec::new())
238
+ Ok(self.targets.clone())
236
239
  }
237
240
  fn has_session(&self, _s: &SessionName) -> Result<bool, TransportError> {
238
241
  Ok(true)
@@ -333,9 +336,9 @@ fn spine_delivery_skips_queued_statuses() {
333
336
  .unwrap();
334
337
  set_message_status(&store, &qidle, "queued_until_idle");
335
338
  set_message_status(&store, &qstart, "queued_until_start");
336
- let state = serde_json::json!({"agents": {"w1": {}}});
339
+ let state = serde_json::json!({"agents": {"w1": {"provider": "fake"}}});
337
340
 
338
- let _ = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
341
+ let _ = deliver_pending_messages(&ws, &state, &DeliverOkTransport::default(), &log).unwrap();
339
342
 
340
343
  assert_eq!(
341
344
  message_status_of(&store, &qidle),
@@ -359,9 +362,9 @@ fn spine_delivery_busy_recipient_is_injected_without_idle_deferral() {
359
362
  let mid = store
360
363
  .create_message(Some("t"), "leader", "w1", "hi", None, true, None)
361
364
  .unwrap();
362
- let state = serde_json::json!({"agents": {"w1": {"status": "busy"}}});
365
+ let state = serde_json::json!({"agents": {"w1": {"provider": "fake", "status": "busy"}}});
363
366
 
364
- let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
367
+ let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport::default(), &log).unwrap();
365
368
 
366
369
  assert!(delivered.contains(&mid));
367
370
  assert_eq!(message_status_of(&store, &mid), "delivered");
@@ -380,11 +383,39 @@ fn spine_delivery_pi_cursor_uses_v113_direct_input_without_idle_scope() {
380
383
  let store = store_for(&ws);
381
384
  let log = EventLog::new(&ws);
382
385
  let mid = store
383
- .create_message(Some("t"), "leader", "w1", "hi", None, true, None)
386
+ .create_message(Some("t"), "leader", "w1", "hi", None, true, Some("spine-team"))
384
387
  .unwrap();
385
- let state = serde_json::json!({"agents": {"w1": {"provider": provider, "status": "running"}}});
386
- let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
387
- assert!(delivered.contains(&mid), "{provider}");
388
+ #[cfg(unix)]
389
+ let carrier = crate::mcp_server::readiness::test_carrier_pid().unwrap();
390
+ #[cfg(not(unix))]
391
+ let carrier = std::process::id();
392
+ let config = ws.join(".team/runtime/mcp/w1.json");
393
+ std::fs::create_dir_all(config.parent().unwrap()).unwrap();
394
+ std::fs::write(&config, serde_json::to_vec(&serde_json::json!({
395
+ "mcpServers": {"team_orchestrator": {
396
+ "command": std::env::current_exe().unwrap().canonicalize().unwrap(),
397
+ "env": {"TEAM_AGENT_WORKSPACE": ws, "TEAM_AGENT_ID": "w1", "TEAM_AGENT_OWNER_TEAM_ID": "spine-team"}
398
+ }}
399
+ })).unwrap()).unwrap();
400
+ let mut state = serde_json::json!({"team_key": "spine-team", "active_team_key": "spine-team", "session_name": "spine", "agents": {"w1": {
401
+ "provider": provider, "status": "running", "window": "w1", "mcp_config": config,
402
+ "spawned_at": (chrono::Utc::now() - chrono::Duration::seconds(10)).to_rfc3339(),
403
+ "pane_id": "%spine", "pane_pid": carrier
404
+ }}});
405
+ state["teams"] = serde_json::json!({"spine-team": state.clone()});
406
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
407
+ #[cfg(unix)]
408
+ if provider == "cursor_agent" {
409
+ crate::mcp_server::readiness::publish(&ws, "spine-team", "w1", chrono::Utc::now()).unwrap();
410
+ }
411
+ let transport = DeliverOkTransport { targets: vec![PaneInfo {
412
+ pane_id: PaneId::new("%spine"), session: SessionName::new("spine"),
413
+ window_index: None, window_name: Some(WindowName::new("w1")), pane_index: None, tty: None,
414
+ current_command: None, current_path: None, active: true,
415
+ pane_pid: Some(carrier), leader_env: BTreeMap::new(),
416
+ }] };
417
+ let delivered = deliver_pending_messages(&ws, &state, &transport, &log).unwrap();
418
+ assert!(delivered.contains(&mid), "{provider}: {:?}", read_event_log(&ws));
388
419
  assert_eq!(message_status_of(&store, &mid), "delivered");
389
420
  }
390
421
  }
@@ -405,9 +436,9 @@ fn contract_alive_worker_with_working_health_is_deliverable_not_deferred() {
405
436
  .create_message(Some("t"), "leader", "w1", "hi", None, true, None)
406
437
  .unwrap();
407
438
  seed_agent_health(&store, "w1", "WORKING"); // turn-level state — must NOT gate delivery
408
- let state = serde_json::json!({"agents": {"w1": {"status": "running"}}}); // lifecycle: alive
439
+ let state = serde_json::json!({"agents": {"w1": {"provider": "fake", "status": "running"}}}); // lifecycle: alive
409
440
 
410
- let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
441
+ let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport::default(), &log).unwrap();
411
442
 
412
443
  let events = read_event_log(&ws);
413
444
  let deferred: Vec<_> = events
@@ -530,7 +561,7 @@ fn spine_delivery_injects_resolvable_target_not_bare_agent_pane() {
530
561
  // in-team, non-busy agent -> deliver proceeds; the session-qualified resolution uses session_name + window.
531
562
  let state = serde_json::json!({
532
563
  "session_name": "team-x",
533
- "agents": {"w1": {"status": "idle", "window": "w1"}}
564
+ "agents": {"w1": {"provider": "fake", "status": "idle", "window": "w1"}}
534
565
  });
535
566
  let transport = OfflineTransport::new();
536
567
 
@@ -585,7 +616,7 @@ fn spine_delivery_injects_rendered_protocol_block_not_raw_content() {
585
616
  .unwrap();
586
617
  let state = serde_json::json!({
587
618
  "session_name": "team-x",
588
- "agents": {"w1": {"status": "idle", "window": "w1"}}
619
+ "agents": {"w1": {"provider": "fake", "status": "idle", "window": "w1"}}
589
620
  });
590
621
  let transport = OfflineTransport::new();
591
622
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.121",
3
+ "version": "0.5.122",
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.5.121",
24
- "@team-agent/cli-darwin-x64": "0.5.121",
25
- "@team-agent/cli-linux-x64": "0.5.121"
23
+ "@team-agent/cli-darwin-arm64": "0.5.122",
24
+ "@team-agent/cli-darwin-x64": "0.5.122",
25
+ "@team-agent/cli-linux-x64": "0.5.122"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",