@team-agent/installer 0.3.0 → 0.3.1

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.
@@ -18,7 +18,7 @@ use super::*;
18
18
  "golden new_owner includes os_user");
19
19
  }
20
20
 
21
- // D2 [BLOCK] — claim-path leader_receiver is golden's 14 keys in golden order; NO
21
+ // D2 [BLOCK] — claim-path leader_receiver is golden's 15 keys in golden order; NO
22
22
  // fingerprint/requested_provider/warning. Golden _receiver_from_claim_target (__init__.py:861-877).
23
23
  // Rust LeaderReceiver serializes all 17 (no skip_serializing_if) -> 3 always-null extras leak. RED.
24
24
  // (The POPULATED tmux values session_name/window_*/pane_* come from the caller-target scan — a
@@ -26,9 +26,12 @@ use super::*;
26
26
  // locked here are unchanged by that scan.)
27
27
  #[test]
28
28
  #[serial_test::serial(env)]
29
- fn d2_claim_leader_receiver_is_fourteen_golden_keys_in_order_no_extras() {
29
+ fn d2_claim_leader_receiver_is_fifteen_golden_keys_in_order_no_extras() {
30
30
  let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
31
- let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
31
+ let _e = EnvGuard::apply(&[
32
+ ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
33
+ ("TMUX", Some("/tmp/tmux-501/default,123,0")),
34
+ ]);
32
35
  let ws = p2_temp_ws("d2_recv_keys");
33
36
  let mut state = serde_json::json!({"session_name": "team-agent-x"});
34
37
  let r = claim_lease_no_incident(&ws, &mut state, None, &TeamKey::new("current"),
@@ -42,9 +45,9 @@ use super::*;
42
45
  let keys: Vec<&str> = recv.keys().map(String::as_str).collect();
43
46
  assert_eq!(keys, vec![
44
47
  "mode","status","provider","pane_id","session_name","window_index","window_name",
45
- "pane_index","pane_tty","pane_current_command","leader_session_uuid","owner_epoch",
46
- "attached_at","discovery",
47
- ], "golden _receiver_from_claim_target 14-key set + ORDER (__init__.py:861-877)");
48
+ "pane_index","pane_tty","pane_current_command","tmux_socket","leader_session_uuid",
49
+ "owner_epoch","attached_at","discovery",
50
+ ], "golden _receiver_from_claim_target 15-key set + ORDER (__init__.py:861-877 + BUG-4 socket-qualified receiver)");
48
51
  }
49
52
 
50
53
  // D2 seam — the caller-target SCAN that fills session_name/window_index/window_name/pane_index/
@@ -197,6 +197,7 @@ use super::*;
197
197
  pane_index: Some("2".into()),
198
198
  pane_tty: Some("/dev/ttys001".into()),
199
199
  pane_current_command: Some("claude".into()),
200
+ tmux_socket: None,
200
201
  fingerprint: Some("fp".into()),
201
202
  leader_session_uuid: Some(uuid("fp", "/ws", "u", "default")),
202
203
  owner_epoch: Some(OwnerEpoch(3)),
@@ -140,6 +140,163 @@ use super::*;
140
140
  assert!(r.reason.is_none(), "already-bound path carries no acquire reason");
141
141
  }
142
142
 
143
+ #[test]
144
+ #[serial_test::serial(env)]
145
+ fn claim_leader_persists_full_tmux_endpoint_when_tmux_tmpdir_differs_from_coordinator() {
146
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
147
+ let leader_socket = "/tmp/ta-leader-root/tmux-501/dl2f";
148
+ let _e = EnvGuard::apply(&[
149
+ ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
150
+ ("TMUX", Some("/tmp/ta-leader-root/tmux-501/dl2f,12345,0")),
151
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
152
+ ]);
153
+ let ws = p2_temp_ws("claim_full_endpoint");
154
+ let team_id = TeamKey::new("current");
155
+ let caller = PaneId::new("%5");
156
+ let mut state = serde_json::json!({"session_name": "team-agent-x"});
157
+ let event_log = crate::event_log::EventLog::new(&ws);
158
+ let live = seeded_liveness(&["%5"]);
159
+
160
+ let r = claim_lease_no_incident(
161
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
162
+ )
163
+ .unwrap();
164
+
165
+ assert!(r.ok);
166
+ assert_eq!(r.status, LeaseStatus::Claimed);
167
+ assert_eq!(
168
+ r.receiver.as_ref().and_then(|receiver| receiver.tmux_socket.as_deref()),
169
+ Some(leader_socket),
170
+ "claim-leader must persist the full $TMUX socket path, not only the -L short name"
171
+ );
172
+ let persisted: serde_json::Value = serde_json::from_str(
173
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
174
+ )
175
+ .unwrap();
176
+ assert_eq!(
177
+ persisted["leader_receiver"]["tmux_socket"],
178
+ serde_json::json!(leader_socket),
179
+ "state.json must carry enough endpoint information for later delivery to use tmux -S"
180
+ );
181
+ }
182
+
183
+ #[test]
184
+ #[serial_test::serial(env)]
185
+ fn claim_leader_already_bound_requires_same_delivery_endpoint_not_only_same_pane_id() {
186
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
187
+ let current_socket = "/tmp/ta-current-leader-root/tmux-501/dl2f";
188
+ let stale_socket = "/tmp/ta-stale-leader-root/tmux-501/dl2f";
189
+ let _e = EnvGuard::apply(&[
190
+ ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
191
+ ("TMUX", Some("/tmp/ta-current-leader-root/tmux-501/dl2f,12345,0")),
192
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
193
+ ]);
194
+ let ws = p2_temp_ws("claim_bound_endpoint");
195
+ let team_id = TeamKey::new("current");
196
+ let caller = PaneId::new("%5");
197
+ let mut state = serde_json::json!({
198
+ "session_name": "team-agent-x",
199
+ "team_owner": {
200
+ "pane_id":"%5",
201
+ "provider":"codex",
202
+ "machine_fingerprint":"fp",
203
+ "leader_session_uuid":"U",
204
+ "owner_epoch":1,
205
+ "claimed_at":"t",
206
+ "claimed_via":"claim-leader",
207
+ "tmux_socket": stale_socket
208
+ },
209
+ "leader_receiver": {
210
+ "pane_id":"%5",
211
+ "owner_epoch":1,
212
+ "leader_session_uuid":"U",
213
+ "tmux_socket": stale_socket
214
+ },
215
+ });
216
+ let event_log = crate::event_log::EventLog::new(&ws);
217
+ let live = seeded_liveness(&["%5"]);
218
+
219
+ let r = claim_lease_no_incident(
220
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
221
+ )
222
+ .unwrap();
223
+
224
+ assert_ne!(
225
+ r.status,
226
+ LeaseStatus::AlreadyBound,
227
+ "already_bound must verify the same delivery endpoint is reachable; matching bare pane \
228
+ ids are not sufficient because different tmux socket roots can both have %5"
229
+ );
230
+ assert_eq!(
231
+ r.receiver.as_ref().and_then(|receiver| receiver.tmux_socket.as_deref()),
232
+ Some(current_socket),
233
+ "claim should refresh the receiver to the caller's current full tmux endpoint"
234
+ );
235
+ }
236
+
237
+ #[test]
238
+ #[serial_test::serial(env)]
239
+ fn claim_leader_already_bound_normalizes_short_tmux_endpoint_to_full_path() {
240
+ let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
241
+ let current_socket = "/tmp/ta-current-leader-root/tmux-501/dl9aa40c88";
242
+ let short_socket = "dl9aa40c88";
243
+ let _e = EnvGuard::apply(&[
244
+ ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
245
+ ("TMUX", Some("/tmp/ta-current-leader-root/tmux-501/dl9aa40c88,12345,0")),
246
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
247
+ ]);
248
+ let ws = p2_temp_ws("claim_bound_short_endpoint");
249
+ let team_id = TeamKey::new("current");
250
+ let caller = PaneId::new("%5");
251
+ let mut state = serde_json::json!({
252
+ "session_name": "team-agent-x",
253
+ "team_owner": {
254
+ "pane_id":"%5",
255
+ "provider":"codex",
256
+ "machine_fingerprint":"fp",
257
+ "leader_session_uuid":"U",
258
+ "owner_epoch":1,
259
+ "claimed_at":"t",
260
+ "claimed_via":"claim-leader",
261
+ "tmux_socket": short_socket
262
+ },
263
+ "leader_receiver": {
264
+ "pane_id":"%5",
265
+ "owner_epoch":1,
266
+ "leader_session_uuid":"U",
267
+ "tmux_socket": short_socket
268
+ },
269
+ });
270
+ let event_log = crate::event_log::EventLog::new(&ws);
271
+ let live = seeded_liveness(&["%5"]);
272
+
273
+ let r = claim_lease_no_incident(
274
+ &ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
275
+ )
276
+ .unwrap();
277
+
278
+ assert_ne!(
279
+ r.status,
280
+ LeaseStatus::AlreadyBound,
281
+ "claim already_bound must not treat a stored short socket name as equivalent to the \
282
+ caller's full $TMUX endpoint by basename; it must refresh/normalize the receiver"
283
+ );
284
+ assert_eq!(
285
+ r.receiver.as_ref().and_then(|receiver| receiver.tmux_socket.as_deref()),
286
+ Some(current_socket),
287
+ "claim should rewrite any legacy short leader_receiver.tmux_socket to the full physical endpoint"
288
+ );
289
+ let persisted: serde_json::Value = serde_json::from_str(
290
+ &std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
291
+ )
292
+ .unwrap();
293
+ assert_eq!(
294
+ persisted["leader_receiver"]["tmux_socket"],
295
+ serde_json::json!(current_socket),
296
+ "state must not preserve a short endpoint after explicit claim; state={persisted}"
297
+ );
298
+ }
299
+
143
300
  // RED — NOT-IN-TMUX-PANE: empty caller pane → refused not_in_tmux_pane with
144
301
  // the EXACT golden action string (differs from the current claim_leader stub
145
302
  // string). golden /tmp/probe_claim.py _lease_refused("not_in_tmux_pane",...).
@@ -238,6 +238,8 @@ pub struct LeaderReceiver {
238
238
  pub pane_index: Option<String>,
239
239
  pub pane_tty: Option<String>,
240
240
  pub pane_current_command: Option<String>,
241
+ #[serde(skip_serializing_if = "Option::is_none")]
242
+ pub tmux_socket: Option<String>,
241
243
  /// `_target_fingerprint(pane_info)`。
242
244
  #[serde(skip_serializing_if = "Option::is_none")]
243
245
  pub fingerprint: Option<String>,
@@ -1,12 +1,12 @@
1
1
  //! lifecycle::launch —— 冷启 / quick-start / 危险审批探测 + add/fork / plan 起步与推进。
2
2
 
3
- use std::collections::BTreeMap;
3
+ use std::collections::{BTreeMap, BTreeSet};
4
4
  use std::path::{Path, PathBuf};
5
5
  use std::process::Command;
6
6
 
7
- use crate::model::permissions::{self, AgentPermissionInput};
7
+ use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider};
8
8
  use crate::model::ids::AgentId;
9
- use crate::model::enums::{AuthMode, DisplayBackend, Provider};
9
+ use crate::model::permissions::{self, AgentPermissionInput};
10
10
  use crate::model::yaml::{self, Value};
11
11
  use crate::state::persist::{load_runtime_state, save_runtime_state};
12
12
  use crate::transport::{SessionName, Target, Transport, WindowName};
@@ -82,7 +82,7 @@ pub fn launch_with_transport(
82
82
  Vec::new()
83
83
  } else {
84
84
  let started = spawn_agents(spec_path, &spec, &session_name, &safety, transport)?;
85
- persist_spawn_agent_state(spec_path, &spec)?;
85
+ persist_spawn_agent_state(spec_path, &spec, &session_name, transport, &started)?;
86
86
  started
87
87
  };
88
88
  Ok(LaunchReport {
@@ -134,10 +134,14 @@ fn spawn_agents(
134
134
  let role = agent.get("role").and_then(Value::as_str);
135
135
  let tools = worker_tool_refs(agent_tool_strings(agent), safety);
136
136
  let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
137
+ let mcp_team_id =
138
+ runtime_active_team_key_for_spawn(&workspace, spec_path, spec, session_name);
139
+ let process_team_id = process_team_id_for_spawn(&workspace, spec);
137
140
  let mcp_config = adapter
138
141
  .mcp_config(auth_mode)
139
142
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
140
- let team_id = spec_team_id(spec);
143
+ let mcp_config = resolve_mcp_config(mcp_config, &workspace, agent_id_raw, &mcp_team_id);
144
+ let mcp_config_path = write_worker_mcp_config(&workspace, agent_id_raw, &mcp_config)?;
141
145
  let mut argv = adapter
142
146
  .build_command_with_tools(
143
147
  auth_mode,
@@ -147,12 +151,18 @@ fn spawn_agents(
147
151
  &tool_refs,
148
152
  )
149
153
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
150
- fill_spawn_placeholders_full(&mut argv, &workspace, agent_id_raw, team_id.as_deref());
154
+ point_native_mcp_config_at_file(&mut argv, provider, &mcp_config_path);
155
+ fill_spawn_placeholders_full(
156
+ &mut argv,
157
+ &workspace,
158
+ agent_id_raw,
159
+ process_team_id.as_deref(),
160
+ );
151
161
  let window = WindowName::new(agent_id_raw);
152
162
  let env = inherited_env_with_team_overrides(
153
163
  &workspace,
154
164
  agent_id_raw,
155
- team_id.as_deref(),
165
+ process_team_id.as_deref(),
156
166
  );
157
167
  let spawn = if started.is_empty() {
158
168
  transport.spawn_first(session_name, &window, &argv, team_dir, &env)
@@ -166,6 +176,9 @@ fn spawn_agents(
166
176
  30,
167
177
  0.5,
168
178
  );
179
+ if matches!(transport.liveness(&spawn.pane_id), Ok(PaneLiveness::Dead)) {
180
+ continue;
181
+ }
169
182
  started.push(StartedAgent {
170
183
  agent_id,
171
184
  start_mode: StartMode::Fresh,
@@ -180,7 +193,13 @@ fn spawn_agents(
180
193
  Ok(started)
181
194
  }
182
195
 
183
- fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), LifecycleError> {
196
+ fn persist_spawn_agent_state(
197
+ spec_path: &Path,
198
+ spec: &Value,
199
+ session_name: &SessionName,
200
+ transport: &dyn Transport,
201
+ started: &[StartedAgent],
202
+ ) -> Result<(), LifecycleError> {
184
203
  let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
185
204
  let workspace = team_workspace(team_dir);
186
205
  let state_path = crate::state::persist::runtime_state_path(&workspace);
@@ -192,6 +211,26 @@ fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), Lifec
192
211
  } else {
193
212
  serde_json::json!({"agents": {}})
194
213
  };
214
+ let team_id = explicit_active_team_key(&state)
215
+ .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name));
216
+ let worker_tmux_socket = launched_worker_tmux_socket(transport, &workspace);
217
+ drop_worker_pane_seeded_owner(
218
+ &mut state,
219
+ &team_id,
220
+ started,
221
+ worker_tmux_socket.as_deref(),
222
+ );
223
+ // Only persist running state for agents whose spawn still has a live target.
224
+ let live_windows: BTreeSet<String> = transport
225
+ .list_windows(session_name)
226
+ .unwrap_or_default()
227
+ .into_iter()
228
+ .map(|w| w.as_str().to_string())
229
+ .collect();
230
+ let live_started_agents: BTreeSet<String> = started
231
+ .iter()
232
+ .map(|agent| agent.agent_id.as_str().to_string())
233
+ .collect();
195
234
  let mut agents = serde_json::Map::new();
196
235
  let spawned_at = spawn_timestamp();
197
236
  for agent in spec_agent_values(spec) {
@@ -210,9 +249,25 @@ fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), Lifec
210
249
  agents.insert(id.to_string(), serde_json::Value::Object(paused));
211
250
  continue;
212
251
  }
252
+ let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
253
+ if !live_started_agents.contains(id)
254
+ || (!live_windows.is_empty() && !live_windows.contains(window))
255
+ {
256
+ let mut failed = serde_json::Map::new();
257
+ failed.insert("status".to_string(), serde_json::json!("spawn_failed"));
258
+ failed.insert("provider".to_string(), serde_json::json!(provider));
259
+ failed.insert("agent_id".to_string(), serde_json::json!(id));
260
+ failed.insert("window".to_string(), serde_json::json!(window));
261
+ failed.insert(
262
+ "reason".to_string(),
263
+ serde_json::json!("tmux window not present after spawn"),
264
+ );
265
+ agents.insert(id.to_string(), serde_json::Value::Object(failed));
266
+ continue;
267
+ }
213
268
  agents.insert(
214
269
  id.to_string(),
215
- running_agent_state(agent, id, provider, &workspace, &spawned_at)?,
270
+ running_agent_state(agent, id, provider, &workspace, &spawned_at, &team_id)?,
216
271
  );
217
272
  }
218
273
  if let Some(obj) = state.as_object_mut() {
@@ -287,6 +342,69 @@ fn drop_foreign_seeded_owner(existing: &serde_json::Value, launched_key: &str, l
287
342
  }
288
343
  }
289
344
 
345
+ fn drop_worker_pane_seeded_owner(
346
+ launched: &mut serde_json::Value,
347
+ launched_key: &str,
348
+ started: &[StartedAgent],
349
+ worker_tmux_socket: Option<&str>,
350
+ ) {
351
+ let Some(pane) = launched
352
+ .get("team_owner")
353
+ .and_then(|owner| owner.get("pane_id"))
354
+ .and_then(serde_json::Value::as_str)
355
+ .filter(|pane| !pane.is_empty())
356
+ else {
357
+ return;
358
+ };
359
+ let leader_pane = std::env::var("TEAM_AGENT_LEADER_PANE_ID")
360
+ .ok()
361
+ .filter(|value| !value.is_empty());
362
+ let tmux_pane = std::env::var("TMUX_PANE")
363
+ .ok()
364
+ .filter(|value| !value.is_empty());
365
+ let has_leader_identity_env = leader_pane.is_some()
366
+ || env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID")
367
+ || env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE")
368
+ || env_nonempty("TEAM_AGENT_LEADER_PROVIDER")
369
+ || env_nonempty("TEAM_AGENT_ID")
370
+ || env_nonempty("TEAM_AGENT_TEAM_ID");
371
+ let seeded_from_bare_tmux =
372
+ !has_leader_identity_env && tmux_pane.as_deref() == Some(pane);
373
+ let caller_tmux_socket = crate::tmux_backend::socket_name_from_tmux_env();
374
+ if seeded_from_bare_tmux
375
+ && tmux_sockets_match_or_unknown(caller_tmux_socket.as_deref(), worker_tmux_socket)
376
+ && started.iter().any(|agent| agent.target == pane)
377
+ {
378
+ seed_unbound_launched_owner(launched, launched_key);
379
+ }
380
+ }
381
+
382
+ fn launched_worker_tmux_socket(
383
+ transport: &dyn Transport,
384
+ workspace: &Path,
385
+ ) -> Option<String> {
386
+ if matches!(transport.kind(), crate::transport::BackendKind::Tmux) {
387
+ Some(crate::tmux_backend::socket_name_for_workspace(workspace))
388
+ } else {
389
+ None
390
+ }
391
+ }
392
+
393
+ fn tmux_sockets_match_or_unknown(
394
+ caller_socket: Option<&str>,
395
+ worker_socket: Option<&str>,
396
+ ) -> bool {
397
+ match (caller_socket, worker_socket) {
398
+ (Some(caller), Some(worker)) => caller == worker,
399
+ (Some(_), None) => false,
400
+ (None, _) => true,
401
+ }
402
+ }
403
+
404
+ fn env_nonempty(key: &str) -> bool {
405
+ std::env::var(key).ok().is_some_and(|value| !value.is_empty())
406
+ }
407
+
290
408
  fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
291
409
  let provider = launched
292
410
  .get("team_owner")
@@ -363,6 +481,7 @@ fn running_agent_state(
363
481
  provider: Provider,
364
482
  workspace: &Path,
365
483
  spawned_at: &str,
484
+ team_id: &str,
366
485
  ) -> Result<serde_json::Value, LifecycleError> {
367
486
  let model = agent.get("model").and_then(Value::as_str);
368
487
  let auth_mode = agent
@@ -372,6 +491,11 @@ fn running_agent_state(
372
491
  .unwrap_or(AuthMode::Subscription);
373
492
  let profile = agent.get("profile").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null);
374
493
  let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
494
+ let mcp_config = crate::provider::get_adapter(provider)
495
+ .mcp_config(auth_mode)
496
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?;
497
+ let mcp_config = resolve_mcp_config(mcp_config, workspace, id, team_id);
498
+ let mcp_config_path = write_worker_mcp_config(workspace, id, &mcp_config)?;
375
499
  let mut state = serde_json::Map::new();
376
500
  state.insert("status".to_string(), serde_json::json!("running"));
377
501
  state.insert("provider".to_string(), serde_json::json!(provider));
@@ -382,13 +506,7 @@ fn running_agent_state(
382
506
  state.insert("window".to_string(), serde_json::json!(window));
383
507
  state.insert(
384
508
  "mcp_config".to_string(),
385
- serde_json::json!(
386
- workspace
387
- .join(".team/runtime/mcp")
388
- .join(format!("{id}.json"))
389
- .to_string_lossy()
390
- .to_string()
391
- ),
509
+ serde_json::json!(mcp_config_path.to_string_lossy().to_string()),
392
510
  );
393
511
  state.insert(
394
512
  "permissions".to_string(),
@@ -408,6 +526,80 @@ fn running_agent_state(
408
526
  Ok(serde_json::Value::Object(state))
409
527
  }
410
528
 
529
+ fn resolve_mcp_config(
530
+ config: crate::provider::McpConfig,
531
+ workspace: &Path,
532
+ agent_id: &str,
533
+ team_id: &str,
534
+ ) -> crate::provider::McpConfig {
535
+ crate::provider::McpConfig {
536
+ raw: resolve_mcp_placeholders(config.raw, workspace, agent_id, team_id),
537
+ }
538
+ }
539
+
540
+ fn resolve_mcp_placeholders(
541
+ value: serde_json::Value,
542
+ workspace: &Path,
543
+ agent_id: &str,
544
+ team_id: &str,
545
+ ) -> serde_json::Value {
546
+ match value {
547
+ serde_json::Value::String(s) => serde_json::Value::String(
548
+ s.replace("{workspace}", &workspace.to_string_lossy())
549
+ .replace("{agent_id}", agent_id)
550
+ .replace("{team_id}", team_id),
551
+ ),
552
+ serde_json::Value::Array(items) => serde_json::Value::Array(
553
+ items
554
+ .into_iter()
555
+ .map(|item| resolve_mcp_placeholders(item, workspace, agent_id, team_id))
556
+ .collect(),
557
+ ),
558
+ serde_json::Value::Object(map) => serde_json::Value::Object(
559
+ map.into_iter()
560
+ .map(|(key, value)| {
561
+ (
562
+ key,
563
+ resolve_mcp_placeholders(value, workspace, agent_id, team_id),
564
+ )
565
+ })
566
+ .collect(),
567
+ ),
568
+ other => other,
569
+ }
570
+ }
571
+
572
+ fn write_worker_mcp_config(
573
+ workspace: &Path,
574
+ agent_id: &str,
575
+ config: &crate::provider::McpConfig,
576
+ ) -> Result<PathBuf, LifecycleError> {
577
+ let path = workspace
578
+ .join(".team/runtime/mcp")
579
+ .join(format!("{agent_id}.json"));
580
+ if let Some(parent) = path.parent() {
581
+ std::fs::create_dir_all(parent)
582
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
583
+ }
584
+ let body = serde_json::to_string_pretty(&serde_json::json!({"mcpServers": config.raw}))
585
+ .map_err(|e| LifecycleError::StatePersist(format!("serialize mcp config: {e}")))?;
586
+ std::fs::write(&path, body)
587
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
588
+ Ok(path)
589
+ }
590
+
591
+ fn point_native_mcp_config_at_file(argv: &mut [String], provider: Provider, path: &Path) {
592
+ if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
593
+ return;
594
+ }
595
+ let Some(index) = argv.iter().position(|arg| arg == "--mcp-config") else {
596
+ return;
597
+ };
598
+ if let Some(value) = argv.get_mut(index.saturating_add(1)) {
599
+ *value = path.to_string_lossy().to_string();
600
+ }
601
+ }
602
+
411
603
  fn permissions_json(
412
604
  agent: &Value,
413
605
  id: &str,
@@ -565,6 +757,44 @@ fn spec_team_id(spec: &Value) -> Option<String> {
565
757
  })
566
758
  }
567
759
 
760
+ fn runtime_active_team_key_for_spawn(
761
+ workspace: &Path,
762
+ spec_path: &Path,
763
+ spec: &Value,
764
+ session_name: &SessionName,
765
+ ) -> String {
766
+ load_runtime_state(workspace)
767
+ .ok()
768
+ .and_then(|state| explicit_active_team_key(&state))
769
+ .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name))
770
+ }
771
+
772
+ fn process_team_id_for_spawn(workspace: &Path, spec: &Value) -> Option<String> {
773
+ load_runtime_state(workspace)
774
+ .ok()
775
+ .and_then(|state| explicit_active_team_key(&state))
776
+ .or_else(|| spec_team_id(spec))
777
+ }
778
+
779
+ fn explicit_active_team_key(state: &serde_json::Value) -> Option<String> {
780
+ state
781
+ .get("active_team_key")
782
+ .and_then(serde_json::Value::as_str)
783
+ .filter(|team| !team.is_empty() && *team != "current")
784
+ .map(str::to_string)
785
+ }
786
+
787
+ fn runtime_team_key_for_spec(spec_path: &Path, spec: &Value, session_name: &SessionName) -> String {
788
+ let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
789
+ let state = serde_json::json!({
790
+ "team_dir": team_dir.to_string_lossy(),
791
+ "spec_path": spec_path.to_string_lossy(),
792
+ "session_name": session_name.as_str(),
793
+ "team": spec.get("team").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null),
794
+ });
795
+ crate::state::projection::team_state_key(&state)
796
+ }
797
+
568
798
  fn transport_has_session(transport: &dyn Transport, session_name: &SessionName) -> bool {
569
799
  match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
570
800
  transport.has_session(session_name)
@@ -684,15 +914,55 @@ pub fn quick_start_with_transport(
684
914
  } else {
685
915
  "coordinator not started"
686
916
  };
917
+ // BUG-7: build an honest readiness verdict from the post-spawn runtime state.
918
+ // - If persist_spawn_agent_state (BUG-2 fix) marked any agent non-running, the
919
+ // team is observably Degraded.
920
+ // - Otherwise the framework cannot itself verify that the worker's MCP tool set
921
+ // loaded successfully (provider-side codex/claude schema rejections happen
922
+ // asynchronously after spawn), so the verdict is PendingToolLoad — never
923
+ // bare Ready.
924
+ let worker_readiness = quick_start_worker_readiness(&workspace);
687
925
  Ok(QuickStartReport::Ready {
688
926
  session_name,
689
927
  launch: Box::new(launch),
690
928
  next_actions: vec![format!(
691
929
  "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
692
930
  )],
931
+ worker_readiness,
693
932
  })
694
933
  }
695
934
 
935
+ /// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
936
+ /// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
937
+ /// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
938
+ /// `PendingToolLoad` — never bare Ready. State read failure is treated as
939
+ /// PendingToolLoad rather than fabricated success.
940
+ fn quick_start_worker_readiness(workspace: &Path) -> QuickStartReadiness {
941
+ let Ok(state) = load_runtime_state(workspace) else {
942
+ return QuickStartReadiness::PendingToolLoad;
943
+ };
944
+ let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
945
+ return QuickStartReadiness::PendingToolLoad;
946
+ };
947
+ let mut unhealthy: Vec<String> = agents
948
+ .iter()
949
+ .filter_map(|(id, agent)| {
950
+ let status = agent.get("status").and_then(serde_json::Value::as_str);
951
+ match status {
952
+ Some("running") => None,
953
+ _ => Some(id.clone()),
954
+ }
955
+ })
956
+ .collect();
957
+ if unhealthy.is_empty() {
958
+ QuickStartReadiness::PendingToolLoad
959
+ } else {
960
+ unhealthy.sort();
961
+ unhealthy.dedup();
962
+ QuickStartReadiness::Degraded { unhealthy_agents: unhealthy }
963
+ }
964
+ }
965
+
696
966
  /// `detect_inherited_dangerous_permissions`(`launch/config.py`):扫进程祖先链找
697
967
  /// `--dangerously-*` flag,产出危险审批继承态。launch 在 inherited=false 且无 --yes 时拒。
698
968
  pub fn detect_dangerous_approval() -> Result<DangerousApproval, LifecycleError> {
@@ -1571,10 +1841,7 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
1571
1841
  } else {
1572
1842
  caller.provider
1573
1843
  };
1574
- let pane_id = std::env::var("TMUX_PANE")
1575
- .ok()
1576
- .filter(|pane| !pane.is_empty())
1577
- .unwrap_or(caller.pane_id);
1844
+ let pane_id = caller.pane_id;
1578
1845
  if pane_id.is_empty() {
1579
1846
  return false;
1580
1847
  }
@@ -1600,6 +1867,13 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
1600
1867
  "owner_epoch": owner_epoch,
1601
1868
  "discovery": "quick_start",
1602
1869
  });
1870
+ let mut receiver = receiver;
1871
+ if let (Some(receiver), Some(socket)) = (
1872
+ receiver.as_object_mut(),
1873
+ crate::tmux_backend::socket_name_from_tmux_env(),
1874
+ ) {
1875
+ receiver.insert("tmux_socket".to_string(), serde_json::json!(socket));
1876
+ }
1603
1877
  if let Some(obj) = state.as_object_mut() {
1604
1878
  obj.insert("leader_receiver".to_string(), receiver);
1605
1879
  obj.insert("team_owner".to_string(), owner);
@@ -1807,10 +2081,12 @@ fn write_launch_permission_audit(
1807
2081
  }
1808
2082
 
1809
2083
  fn team_workspace(team_dir: &Path) -> PathBuf {
1810
- team_dir
1811
- .parent()
1812
- .map(Path::to_path_buf)
1813
- .unwrap_or_else(|| team_dir.to_path_buf())
2084
+ crate::model::paths::team_workspace(team_dir).unwrap_or_else(|_| {
2085
+ team_dir
2086
+ .parent()
2087
+ .map(Path::to_path_buf)
2088
+ .unwrap_or_else(|| team_dir.to_path_buf())
2089
+ })
1814
2090
  }
1815
2091
 
1816
2092
  fn agent_id_exists_in_team_dir(team_dir: &Path, agent_id: &AgentId) -> bool {