@team-agent/installer 0.5.28 → 0.5.31

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.
@@ -440,21 +440,18 @@ pub fn claim_leader(
440
440
  .unwrap_or_default();
441
441
  let raw_state = crate::state::persist::load_runtime_state(workspace)?;
442
442
  let event_log = crate::event_log::EventLog::new(workspace);
443
- // Phase 1d Batch 6: factory tmux channel helpers for grep-visibility.
444
- // Tmux-only leader-claim path (caller pane enumeration).
445
- let mut targets = crate::transport_factory::tmux_workspace_transport(workspace)
446
- .list_targets()
447
- .unwrap_or_default();
448
- targets.extend(
449
- crate::transport_factory::tmux_default_transport()
450
- .list_targets()
451
- .unwrap_or_default(),
452
- );
453
- let caller_pane_info = targets
443
+ let targets = claim_leader_targets(workspace, &raw_state);
444
+ let caller_candidate = targets
454
445
  .iter()
455
- .find(|target| target.pane_id.as_str() == caller);
456
- let caller_target =
457
- caller_pane_info.and_then(|target| claim_target_from_pane_info(workspace, target));
446
+ .filter(|target| target.info.pane_id.as_str() == caller)
447
+ .min_by_key(|target| target.source.priority());
448
+ let caller_pane_info = caller_candidate.map(|target| &target.info);
449
+ let caller_target = caller_candidate.and_then(|target| {
450
+ claim_target_from_pane_info(workspace, &target.info).map(|mut claim_target| {
451
+ claim_target.endpoint = target.endpoint.clone();
452
+ claim_target
453
+ })
454
+ });
458
455
  let env_team = std::env::var("TEAM_AGENT_TEAM_ID")
459
456
  .ok()
460
457
  .filter(|team| !team.is_empty());
@@ -489,7 +486,7 @@ pub fn claim_leader(
489
486
  } else {
490
487
  raw_state
491
488
  };
492
- let liveness = AnyPaneLiveness::from_targets(&targets);
489
+ let liveness = AnyPaneLiveness::from_claim_targets(&targets);
493
490
  let result = claim_lease_no_incident_with_target(
494
491
  workspace,
495
492
  &mut state,
@@ -634,8 +631,23 @@ fn claim_lease_no_incident_with_target(
634
631
  let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
635
632
  if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
636
633
  let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
634
+ let observed_endpoint = caller_target.and_then(|target| target.endpoint.as_deref());
635
+ let convergence_candidate = observed_endpoint.or(current_endpoint.as_deref());
636
+ let candidate_source = if observed_endpoint.is_some() {
637
+ Some("observed_target_endpoint")
638
+ } else if current_endpoint.is_some() {
639
+ Some("fallback_tmux_env")
640
+ } else {
641
+ None
642
+ };
637
643
  let (mut topology_convergence, converged) =
638
- apply_endpoint_convergence(state, team_id, current_endpoint.as_deref(), pre_epoch);
644
+ apply_endpoint_convergence(
645
+ state,
646
+ team_id,
647
+ convergence_candidate,
648
+ candidate_source,
649
+ pre_epoch,
650
+ );
639
651
  if converged {
640
652
  write_claim_state(workspace, state, scoped_team, team)?;
641
653
  topology_convergence = verify_persisted_topology_convergence(
@@ -796,7 +808,8 @@ fn claim_lease_no_incident_with_target(
796
808
  }
797
809
  let next_epoch = OwnerEpoch(pre_epoch.0.saturating_add(1));
798
810
  let provider = caller_target.map_or_else(|| prior_provider(state), |target| target.provider);
799
- let receiver = make_receiver(
811
+ let observed_endpoint = caller_target.and_then(|target| target.endpoint.clone());
812
+ let mut receiver = make_receiver(
800
813
  provider,
801
814
  &non_empty_caller_pane,
802
815
  &identity.leader_session_uuid,
@@ -804,10 +817,26 @@ fn claim_lease_no_incident_with_target(
804
817
  Discovery::ClaimLeader,
805
818
  caller_target.and_then(|target| target.pane_info.clone()),
806
819
  );
820
+ if let Some(endpoint) = observed_endpoint.as_ref() {
821
+ receiver.tmux_socket = Some(endpoint.clone());
822
+ }
807
823
  let owner = make_owner(provider, &non_empty_caller_pane, &identity, next_epoch);
808
824
  write_binding_to_state(state, &receiver, &owner)?;
825
+ let candidate_source = if observed_endpoint.is_some() {
826
+ Some("observed_target_endpoint")
827
+ } else if receiver.tmux_socket.is_some() {
828
+ Some("fallback_tmux_env")
829
+ } else {
830
+ None
831
+ };
809
832
  let (mut topology_convergence, converged) =
810
- apply_endpoint_convergence(state, team_id, receiver.tmux_socket.as_deref(), next_epoch);
833
+ apply_endpoint_convergence(
834
+ state,
835
+ team_id,
836
+ receiver.tmux_socket.as_deref(),
837
+ candidate_source,
838
+ next_epoch,
839
+ );
811
840
  write_claim_state(workspace, state, scoped_team, team)?;
812
841
  if converged {
813
842
  topology_convergence = verify_persisted_topology_convergence(
@@ -884,13 +913,17 @@ fn apply_endpoint_convergence(
884
913
  state: &mut Value,
885
914
  team_id: &TeamKey,
886
915
  candidate_endpoint: Option<&str>,
916
+ candidate_source: Option<&'static str>,
887
917
  owner_epoch: OwnerEpoch,
888
918
  ) -> (Option<Value>, bool) {
889
- let Some(candidate_endpoint) = candidate_endpoint
919
+ let explicit_candidate = candidate_endpoint
890
920
  .filter(|endpoint| !endpoint.is_empty())
891
- .map(str::to_string)
892
- .or_else(|| state_tmux_socket_candidate(state, team_id.as_str()))
893
- else {
921
+ .map(str::to_string);
922
+ let (candidate_endpoint, candidate_source) = if let Some(endpoint) = explicit_candidate {
923
+ (endpoint, candidate_source.unwrap_or("candidate"))
924
+ } else if let Some(endpoint) = state_tmux_socket_candidate(state, team_id.as_str()) {
925
+ (endpoint, "fallback_state")
926
+ } else {
894
927
  return (None, false);
895
928
  };
896
929
  match crate::topology::endpoint_convergence_decision(
@@ -903,6 +936,7 @@ fn apply_endpoint_convergence(
903
936
  Some(json!({
904
937
  "status": "unknown",
905
938
  "new_tmux_endpoint": candidate_endpoint,
939
+ "candidate_source": candidate_source,
906
940
  "owner_epoch": owner_epoch.0,
907
941
  })),
908
942
  false,
@@ -910,14 +944,17 @@ fn apply_endpoint_convergence(
910
944
  crate::topology::EndpointConvergenceDecision::RefuseLiveOldEndpoint {
911
945
  old_endpoint,
912
946
  new_endpoint,
947
+ reason,
913
948
  } => (
914
949
  Some(json!({
915
950
  "status": "not_converged_old_endpoint_live",
916
951
  "old_tmux_endpoint": old_endpoint,
917
952
  "new_tmux_endpoint": new_endpoint,
953
+ "reason": reason,
954
+ "candidate_source": candidate_source,
918
955
  "owner_epoch": owner_epoch.0,
919
956
  "action": format!(
920
- "old tmux endpoint {old_endpoint} is still live; run team-agent diagnose --json and clean that endpoint before retrying restart"
957
+ "old tmux endpoint {old_endpoint} still has this team's session or pane tuple; run team-agent diagnose --json before retrying restart"
921
958
  ),
922
959
  })),
923
960
  false,
@@ -925,12 +962,14 @@ fn apply_endpoint_convergence(
925
962
  crate::topology::EndpointConvergenceDecision::Converge {
926
963
  old_endpoint,
927
964
  new_endpoint,
965
+ reason,
928
966
  } => {
929
967
  let metadata = json!({
930
968
  "status": "converged",
931
969
  "old_tmux_endpoint": old_endpoint,
932
970
  "new_tmux_endpoint": new_endpoint,
933
- "reason": "old_endpoint_dead",
971
+ "reason": reason,
972
+ "candidate_source": candidate_source,
934
973
  "owner_epoch": owner_epoch.0,
935
974
  });
936
975
  write_endpoint_fields(state, team_id.as_str(), &new_endpoint);
@@ -1175,6 +1214,94 @@ struct LeaderClaimTarget {
1175
1214
  leader_session_uuid: Option<crate::model::ids::LeaderSessionUuid>,
1176
1215
  team_id: Option<String>,
1177
1216
  pane_info: Option<PaneInfo>,
1217
+ endpoint: Option<String>,
1218
+ }
1219
+
1220
+ #[derive(Clone)]
1221
+ struct ClaimLeaderTargetCandidate {
1222
+ info: PaneInfo,
1223
+ endpoint: Option<String>,
1224
+ source: ClaimLeaderTargetSource,
1225
+ }
1226
+
1227
+ #[derive(Clone, Copy, PartialEq, Eq)]
1228
+ enum ClaimLeaderTargetSource {
1229
+ StateRecorded,
1230
+ Workspace,
1231
+ CurrentTmux,
1232
+ Default,
1233
+ }
1234
+
1235
+ impl ClaimLeaderTargetSource {
1236
+ fn priority(self) -> u8 {
1237
+ match self {
1238
+ Self::StateRecorded => 0,
1239
+ Self::Workspace => 1,
1240
+ Self::CurrentTmux => 2,
1241
+ Self::Default => 3,
1242
+ }
1243
+ }
1244
+ }
1245
+
1246
+ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTargetCandidate> {
1247
+ let mut targets = Vec::new();
1248
+ if let Some(endpoint) = crate::tmux_backend::socket_name_from_tmux_env() {
1249
+ let backend = tmux_backend_for_endpoint(&endpoint);
1250
+ let resolved_endpoint = backend.tmux_endpoint();
1251
+ targets.extend(
1252
+ backend
1253
+ .list_targets()
1254
+ .unwrap_or_default()
1255
+ .into_iter()
1256
+ .map(|info| ClaimLeaderTargetCandidate {
1257
+ info,
1258
+ endpoint: resolved_endpoint.clone(),
1259
+ source: ClaimLeaderTargetSource::CurrentTmux,
1260
+ }),
1261
+ );
1262
+ }
1263
+ for endpoint in state_recorded_tmux_endpoints(state) {
1264
+ let backend = tmux_backend_for_endpoint(&endpoint);
1265
+ let resolved_endpoint = backend.tmux_endpoint();
1266
+ targets.extend(
1267
+ backend
1268
+ .list_targets()
1269
+ .unwrap_or_default()
1270
+ .into_iter()
1271
+ .map(|info| ClaimLeaderTargetCandidate {
1272
+ info,
1273
+ endpoint: resolved_endpoint.clone(),
1274
+ source: ClaimLeaderTargetSource::StateRecorded,
1275
+ }),
1276
+ );
1277
+ }
1278
+ let workspace_backend = crate::transport_factory::tmux_workspace_transport(workspace);
1279
+ let workspace_endpoint = workspace_backend.tmux_endpoint();
1280
+ targets.extend(
1281
+ workspace_backend
1282
+ .list_targets()
1283
+ .unwrap_or_default()
1284
+ .into_iter()
1285
+ .map(|info| ClaimLeaderTargetCandidate {
1286
+ info,
1287
+ endpoint: workspace_endpoint.clone(),
1288
+ source: ClaimLeaderTargetSource::Workspace,
1289
+ }),
1290
+ );
1291
+ let default_backend = crate::transport_factory::tmux_default_transport();
1292
+ let default_endpoint = default_backend.tmux_endpoint();
1293
+ targets.extend(
1294
+ default_backend
1295
+ .list_targets()
1296
+ .unwrap_or_default()
1297
+ .into_iter()
1298
+ .map(|info| ClaimLeaderTargetCandidate {
1299
+ info,
1300
+ endpoint: default_endpoint.clone(),
1301
+ source: ClaimLeaderTargetSource::Default,
1302
+ }),
1303
+ );
1304
+ targets
1178
1305
  }
1179
1306
 
1180
1307
  fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<LeaderClaimTarget> {
@@ -1191,6 +1318,7 @@ fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<Le
1191
1318
  leader_session_uuid: target_leader_session_uuid(target),
1192
1319
  team_id: target.leader_env.get("TEAM_AGENT_TEAM_ID").filter(|raw| !raw.is_empty()).cloned(),
1193
1320
  pane_info: Some(target.clone()),
1321
+ endpoint: None,
1194
1322
  })
1195
1323
  }
1196
1324
 
@@ -1290,11 +1418,11 @@ struct AnyPaneLiveness {
1290
1418
  }
1291
1419
 
1292
1420
  impl AnyPaneLiveness {
1293
- fn from_targets(targets: &[PaneInfo]) -> Self {
1421
+ fn from_claim_targets(targets: &[ClaimLeaderTargetCandidate]) -> Self {
1294
1422
  Self {
1295
1423
  live_panes: targets
1296
1424
  .iter()
1297
- .map(|target| target.pane_id.as_str().to_string())
1425
+ .map(|target| target.info.pane_id.as_str().to_string())
1298
1426
  .collect(),
1299
1427
  }
1300
1428
  }
@@ -1677,10 +1805,16 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
1677
1805
  .map(|_| crate::state::projection::team_state_key(&existing));
1678
1806
  let existing_active_key = existing.get("active_team_key").and_then(Value::as_str);
1679
1807
  let updates_active_team = existing_active_key == Some(target_key);
1808
+ let writes_endpoint_convergence = state
1809
+ .get("topology_convergence")
1810
+ .and_then(|marker| marker.get("status"))
1811
+ .and_then(Value::as_str)
1812
+ == Some("converged");
1680
1813
  let mut merged = if existing_primary_key
1681
1814
  .as_deref()
1682
1815
  .is_none_or(|key| key == target_key)
1683
1816
  || updates_active_team
1817
+ || writes_endpoint_convergence
1684
1818
  {
1685
1819
  value_object(state)
1686
1820
  } else {
@@ -1697,7 +1831,12 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
1697
1831
  // / coordinator tick / promote sibling. `had_existing_teams` /
1698
1832
  // primary/active key computations are retained as dead writes only
1699
1833
  // via _ binding — they no longer gate any owner promote.
1700
- let _ = (had_existing_teams, existing_primary_key, existing_active_key);
1834
+ let _ = (
1835
+ had_existing_teams,
1836
+ existing_primary_key,
1837
+ existing_active_key,
1838
+ writes_endpoint_convergence,
1839
+ );
1701
1840
  merged.insert("teams".to_string(), Value::Object(teams));
1702
1841
  crate::state::persist::save_runtime_state(workspace, &Value::Object(merged))?;
1703
1842
  Ok(())
@@ -4130,7 +4130,12 @@ fn upsert_agent_state_from_role(
4130
4130
 
4131
4131
  /// E5 Bug1:把 add-agent 就地编译出的 agent 条目注入 base team spec(`agents` 列表 +
4132
4132
  /// `routing.rules` 加 `route-<id>`),复刻 [`compile_team`] 的路由规则形态。不落任何文件。
4133
- fn inject_agent_into_spec(
4133
+ ///
4134
+ /// 0.5.30 (`.team/artifacts/add-agent-restart-saveconflict-locate.md` §5.2):
4135
+ /// `pub(crate)` 让 restart/rebuild.rs::rebuild_runtime_spec_from_roles 复用
4136
+ /// 同一去重注入逻辑,把 add-agent 记录的 dynamic_role_file 合并回 restart
4137
+ /// 重建 spec,防止 live helper 被 prune 后触发 SaveConflict。行为不变。
4138
+ pub(crate) fn inject_agent_into_spec(
4134
4139
  spec: &mut Value,
4135
4140
  agent: Value,
4136
4141
  agent_id: &str,
@@ -433,7 +433,9 @@ fn restart_with_selected_team_and_transport(
433
433
  &decision.agent_id,
434
434
  &raw_agent,
435
435
  );
436
- if has_endpoint_convergence_marker(&state) && is_fake_model_harness_agent(&agent) {
436
+ if endpoint_convergence_fake_harness_enabled(&state)
437
+ && is_fake_model_harness_agent(&agent)
438
+ {
437
439
  write_fake_harness_spawn_argv_event(
438
440
  &selected.run_workspace,
439
441
  decision,
@@ -1242,7 +1244,7 @@ fn restart_worker_panes_addressable(
1242
1244
  if decisions.is_empty() {
1243
1245
  return true;
1244
1246
  }
1245
- if has_endpoint_convergence_marker(state)
1247
+ if endpoint_convergence_fake_harness_enabled(state)
1246
1248
  && decisions.iter().all(|decision| {
1247
1249
  state
1248
1250
  .get("agents")
@@ -2354,6 +2356,11 @@ fn rebuild_runtime_spec_from_roles(
2354
2356
  {
2355
2357
  crate::lifecycle::launch::override_spec_session_name(&mut spec, session_name);
2356
2358
  }
2359
+ // 0.5.30 (`.team/artifacts/add-agent-restart-saveconflict-locate.md` §4/§11):
2360
+ // 把 add-agent 记录的 dynamic_role_file 合并回 restart 重建 spec —— 单一真相 =
2361
+ // 静态 team_dir/agents/*.md + state 记录的 dynamic role source。缺文件 fail-closed
2362
+ // 三行式,不静默 prune 已 live 的 helper(persist SaveConflict 保护继续生效)。
2363
+ merge_state_dynamic_role_files(&mut spec, run_workspace, &team_dir, team_key, state)?;
2357
2364
  // 写 runtime spec(覆盖,原子 tmp+rename;Bug2)。
2358
2365
  let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
2359
2366
  crate::lifecycle::launch::write_spec_atomic(&spec_path, &spec)?;
@@ -2366,6 +2373,86 @@ fn rebuild_runtime_spec_from_roles(
2366
2373
  Ok(spec)
2367
2374
  }
2368
2375
 
2376
+ /// 0.5.30 (`add-agent-restart-saveconflict-locate.md` §4/§11): 把 add-agent
2377
+ /// 写入 `state.agents.<id>.dynamic_role_file` 的动态 role 文档合并回 restart
2378
+ /// 重建 spec。规则:
2379
+ /// - path 为空 / 缺失字段 → 跳过(纯静态 team_dir agent);
2380
+ /// - path 存在但文件 missing → fail-closed 三行式错误(不 prune live helper);
2381
+ /// - path 有效 → 编译成 CompiledRole,校验 compiled.id 等于 agent_id;
2382
+ /// - 复用 launch::inject_agent_into_spec 去重注入(名字已在 spec → 跳过)。
2383
+ fn merge_state_dynamic_role_files(
2384
+ spec: &mut YamlValue,
2385
+ run_workspace: &Path,
2386
+ team_dir: &Path,
2387
+ team_key: &str,
2388
+ state: &serde_json::Value,
2389
+ ) -> Result<(), LifecycleError> {
2390
+ let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
2391
+ return Ok(());
2392
+ };
2393
+ if agents.is_empty() {
2394
+ return Ok(());
2395
+ }
2396
+ let team_meta = crate::compiler::read_front_matter(&team_dir.join("TEAM.md"))
2397
+ .map(|(meta, _)| meta)
2398
+ .unwrap_or(YamlValue::Null);
2399
+ let workspace_s = spec
2400
+ .get("team")
2401
+ .and_then(|team| team.get("workspace"))
2402
+ .and_then(YamlValue::as_str)
2403
+ .unwrap_or_else(|| team_dir.to_str().unwrap_or_default())
2404
+ .to_string();
2405
+ let mut dynamic_ids: Vec<(String, String)> = agents
2406
+ .iter()
2407
+ .filter_map(|(agent_id, agent)| {
2408
+ agent
2409
+ .get("dynamic_role_file")
2410
+ .and_then(serde_json::Value::as_str)
2411
+ .filter(|s| !s.is_empty())
2412
+ .map(|raw| (agent_id.clone(), raw.to_string()))
2413
+ })
2414
+ .collect();
2415
+ dynamic_ids.sort_by(|(a, _), (b, _)| a.cmp(b));
2416
+ for (agent_id, raw_path) in dynamic_ids {
2417
+ let role_path = {
2418
+ let candidate = std::path::PathBuf::from(&raw_path);
2419
+ if candidate.is_absolute() {
2420
+ candidate
2421
+ } else {
2422
+ run_workspace.join(&candidate)
2423
+ }
2424
+ };
2425
+ if !role_path.exists() {
2426
+ // N38 三行式:error / action / log。不 prune live helper。
2427
+ return Err(LifecycleError::TeamSelect(format!(
2428
+ "cannot restart: dynamic role file missing for agent '{agent_id}' in team \
2429
+ '{team_key}': {}. \
2430
+ action: restore the dynamic role file at that path, or run team-agent \
2431
+ remove-agent {agent_id} --force to drop the dynamic worker before restart. \
2432
+ log: workspace={}",
2433
+ role_path.display(),
2434
+ run_workspace.display(),
2435
+ )));
2436
+ }
2437
+ let compiled = crate::compiler::compile_role_agent(&role_path, &team_meta, &workspace_s)
2438
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?;
2439
+ if compiled.id != agent_id {
2440
+ return Err(LifecycleError::Compile(format!(
2441
+ "dynamic role file for agent '{agent_id}' declares name '{}' at {}; \
2442
+ restart cannot rename a live worker. \
2443
+ action: fix the role file's front-matter name to match agent_id, or run \
2444
+ team-agent remove-agent {agent_id} --force before restart. \
2445
+ log: workspace={}",
2446
+ compiled.id,
2447
+ role_path.display(),
2448
+ run_workspace.display(),
2449
+ )));
2450
+ }
2451
+ crate::lifecycle::launch::inject_agent_into_spec(spec, compiled.agent, &compiled.id)?;
2452
+ }
2453
+ Ok(())
2454
+ }
2455
+
2369
2456
  fn load_endpoint_convergence_runtime_spec(
2370
2457
  run_workspace: &Path,
2371
2458
  team_key: &str,
@@ -2404,6 +2491,11 @@ fn has_endpoint_convergence_marker(state: &serde_json::Value) -> bool {
2404
2491
  == Some("converged")
2405
2492
  }
2406
2493
 
2494
+ fn endpoint_convergence_fake_harness_enabled(state: &serde_json::Value) -> bool {
2495
+ has_endpoint_convergence_marker(state)
2496
+ && std::env::var_os("TEAM_AGENT_TEST_ENDPOINT_CONVERGENCE_HARNESS_SPEC_FALLBACK").is_some()
2497
+ }
2498
+
2407
2499
  fn is_fake_model_harness_agent(agent: &serde_json::Value) -> bool {
2408
2500
  agent_provider(agent) == crate::model::enums::Provider::Fake
2409
2501
  && agent.get("model").and_then(serde_json::Value::as_str) == Some("fake")
@@ -26,6 +26,7 @@ pub mod ownership;
26
26
  pub mod paths;
27
27
  pub mod persist;
28
28
  pub mod projection;
29
+ pub mod repository;
29
30
  pub mod selector;
30
31
 
31
32
  use serde_json::Value;