@team-agent/installer 0.4.11 → 0.5.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.
Files changed (80) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +98 -5
  6. package/crates/team-agent/src/cli/mod.rs +2 -0
  7. package/crates/team-agent/src/cli/named_address.rs +864 -0
  8. package/crates/team-agent/src/cli/send.rs +104 -0
  9. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
  12. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  13. package/crates/team-agent/src/cli/types.rs +4 -0
  14. package/crates/team-agent/src/compiler.rs +11 -20
  15. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  16. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  17. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  19. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  20. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  21. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  22. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  23. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  24. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  25. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  26. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  27. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  28. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  29. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  30. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  31. package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
  32. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  33. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  34. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  35. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  36. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  38. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  41. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
  42. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  43. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  44. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  45. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  46. package/crates/team-agent/src/messaging/activity.rs +43 -18
  47. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  48. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  49. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  50. package/crates/team-agent/src/messaging/results.rs +34 -9
  51. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  52. package/crates/team-agent/src/model/spec.rs +10 -6
  53. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  54. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  55. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  56. package/crates/team-agent/src/provider/classify.rs +23 -48
  57. package/crates/team-agent/src/provider/command.rs +16 -7
  58. package/crates/team-agent/src/provider/faults.rs +93 -53
  59. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  60. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  61. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  62. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  63. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  64. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  65. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  66. package/crates/team-agent/src/provider/types.rs +33 -1
  67. package/crates/team-agent/src/provider/wire.rs +171 -1
  68. package/crates/team-agent/src/state/identity.rs +242 -57
  69. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  70. package/crates/team-agent/src/state/mod.rs +5 -1
  71. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  72. package/crates/team-agent/src/state/ownership.rs +12 -11
  73. package/crates/team-agent/src/state/paths.rs +13 -6
  74. package/crates/team-agent/src/state/persist.rs +671 -128
  75. package/crates/team-agent/src/state/projection.rs +240 -49
  76. package/crates/team-agent/src/state/selector.rs +11 -5
  77. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  78. package/crates/team-agent/src/tmux_backend.rs +42 -5
  79. package/crates/team-agent/src/transport/test_support.rs +55 -6
  80. package/package.json +4 -4
@@ -118,7 +118,9 @@ pub(super) fn spawn_agent_window(
118
118
  // persisted agent JSON (state's agent["effort"] field, set by launch).
119
119
  let restart_effort = crate::lifecycle::launch::provider_effort_for_spawn_json(agent, provider);
120
120
  if let Some(event_value) = crate::lifecycle::launch::provider_effort_event_if_dropped_json(
121
- agent, provider, agent_id.as_str(),
121
+ agent,
122
+ provider,
123
+ agent_id.as_str(),
122
124
  ) {
123
125
  let _ = crate::event_log::EventLog::new(workspace)
124
126
  .write("provider.effort_unsupported", event_value);
@@ -200,10 +202,8 @@ pub(super) fn spawn_agent_window(
200
202
  .and_then(|i| plan.argv.get(i + 1))
201
203
  .cloned();
202
204
  let env_overlay_keys: Vec<&String> = env.keys().collect();
203
- let tmux_start_mode_pre_spawn = predict_tmux_start_mode(
204
- layout_placement,
205
- into_existing_session,
206
- );
205
+ let tmux_start_mode_pre_spawn =
206
+ predict_tmux_start_mode(layout_placement, into_existing_session);
207
207
  let spawn_epoch = state_spawn_epoch_for_agent(workspace, agent_id);
208
208
  let event_log = crate::event_log::EventLog::new(workspace);
209
209
  let _ = event_log.write(
@@ -311,12 +311,23 @@ pub(super) fn spawn_agent_window(
311
311
  agent_id.as_str(),
312
312
  );
313
313
  }
314
- let _ = adapter.handle_startup_prompts(
314
+ let startup_prompt = adapter.handle_startup_prompts_outcome(
315
315
  transport,
316
316
  &crate::transport::Target::Pane(spawn.pane_id.clone()),
317
317
  30,
318
318
  0.5,
319
319
  );
320
+ if let Some(error) = startup_prompt.capture_error.as_deref() {
321
+ if is_structural_startup_prompt_error(error) {
322
+ return Err(LifecycleError::Transport(format!(
323
+ "startup prompt structural failure for {}:{} pane {}: {}",
324
+ session_name.as_str(),
325
+ window.as_str(),
326
+ spawn.pane_id.as_str(),
327
+ error
328
+ )));
329
+ }
330
+ }
320
331
  Ok(SpawnedAgentWindow {
321
332
  spawn,
322
333
  plan,
@@ -327,6 +338,86 @@ pub(super) fn spawn_agent_window(
327
338
  })
328
339
  }
329
340
 
341
+ fn is_structural_startup_prompt_error(error: &str) -> bool {
342
+ let lower = error.to_ascii_lowercase();
343
+ [
344
+ "can't find window",
345
+ "can't find pane",
346
+ "cannot find window",
347
+ "cannot find pane",
348
+ "target not found",
349
+ "no such pane",
350
+ "window disappeared",
351
+ "pane not owned",
352
+ "not owned by requested",
353
+ ]
354
+ .iter()
355
+ .any(|needle| lower.contains(needle))
356
+ }
357
+
358
+ pub(super) fn rehydrate_agent_command_context_from_spec(
359
+ spec_workspace: &Path,
360
+ agent_id: &AgentId,
361
+ agent: &serde_json::Value,
362
+ ) -> serde_json::Value {
363
+ let Ok(spec) = load_team_spec(spec_workspace) else {
364
+ return agent.clone();
365
+ };
366
+ let Some(spec_agent) = find_spec_agent(&spec, agent_id) else {
367
+ return agent.clone();
368
+ };
369
+ merge_command_context_fields(agent, spec_agent)
370
+ }
371
+
372
+ fn merge_command_context_fields(
373
+ agent: &serde_json::Value,
374
+ spec_agent: &YamlValue,
375
+ ) -> serde_json::Value {
376
+ let mut merged = agent.clone();
377
+ let Some(obj) = merged.as_object_mut() else {
378
+ return merged;
379
+ };
380
+ for field in [
381
+ "role",
382
+ "tools",
383
+ "system_prompt",
384
+ "output_contract",
385
+ "provider",
386
+ "model",
387
+ "auth_mode",
388
+ "effort",
389
+ "profile",
390
+ "permission_mode",
391
+ ] {
392
+ if let Some(value) = spec_agent.get(field).and_then(yaml_value_to_json) {
393
+ obj.insert(field.to_string(), value);
394
+ }
395
+ }
396
+ merged
397
+ }
398
+
399
+ fn yaml_value_to_json(value: &YamlValue) -> Option<serde_json::Value> {
400
+ match value {
401
+ YamlValue::Null => Some(serde_json::Value::Null),
402
+ YamlValue::Bool(value) => Some(serde_json::json!(value)),
403
+ YamlValue::Int(value) => Some(serde_json::json!(value)),
404
+ YamlValue::Float(value) => Some(serde_json::json!(value)),
405
+ YamlValue::Str(value) => Some(serde_json::json!(value)),
406
+ YamlValue::List(items) => Some(serde_json::Value::Array(
407
+ items.iter().filter_map(yaml_value_to_json).collect(),
408
+ )),
409
+ YamlValue::Map(items) => {
410
+ let mut map = serde_json::Map::new();
411
+ for (key, value) in items {
412
+ if let Some(value) = yaml_value_to_json(value) {
413
+ map.insert(key.clone(), value);
414
+ }
415
+ }
416
+ Some(serde_json::Value::Object(map))
417
+ }
418
+ }
419
+ }
420
+
330
421
  /// E43 Fix C helper (0.3.24 bug#3): probe live tmux for a window's existence
331
422
  /// before issuing `split-window -t :<window>`. Uses `list_windows` first
332
423
  /// (cheaper, authoritative when present); falls back to `list_targets` so
@@ -419,16 +510,18 @@ pub(super) fn save_restart_projected_state(
419
510
  workspace: &Path,
420
511
  state: &mut serde_json::Value,
421
512
  team_key: &str,
513
+ topology_authority_agent_ids: &[&str],
422
514
  ) -> Result<(), LifecycleError> {
423
515
  sync_restart_team_projections(state, team_key);
424
- crate::state::projection::save_team_scoped_state(workspace, state)
425
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))
516
+ crate::state::projection::save_team_scoped_state_with_lifecycle_topology_authority(
517
+ workspace,
518
+ state,
519
+ topology_authority_agent_ids,
520
+ )
521
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
426
522
  }
427
523
 
428
- pub(super) fn restart_projection_team_key(
429
- state: &serde_json::Value,
430
- team: Option<&str>,
431
- ) -> String {
524
+ pub(super) fn restart_projection_team_key(state: &serde_json::Value, team: Option<&str>) -> String {
432
525
  team.filter(|key| !key.is_empty())
433
526
  .map(str::to_string)
434
527
  .or_else(|| {
@@ -605,10 +698,7 @@ pub(super) fn resume_backing_probe_for_agent(
605
698
  checked_paths.push(root.clone());
606
699
  }
607
700
  rollout_ok
608
- || codex_session_transcript_exists_with_roots(
609
- session_id.as_str(),
610
- &scan_roots,
611
- )
701
+ || codex_session_transcript_exists_with_roots(session_id.as_str(), &scan_roots)
612
702
  }
613
703
  Provider::Claude | Provider::ClaudeCode => {
614
704
  let rollout_ok = rollout_path_exists(rollout_path);
@@ -705,7 +795,8 @@ fn claude_projects_root_for_agent(agent: &serde_json::Value) -> Option<PathBuf>
705
795
  .filter(|value| !value.is_empty())
706
796
  .map(PathBuf::from)
707
797
  .or_else(|| {
708
- std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude").join("projects"))
798
+ std::env::var_os("HOME")
799
+ .map(|home| PathBuf::from(home).join(".claude").join("projects"))
709
800
  })
710
801
  }
711
802
 
@@ -1,6 +1,7 @@
1
1
  use super::common::*;
2
2
  use super::selection::classify_restart_plan_with_resume_validation;
3
3
  use super::*;
4
+ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
4
5
 
5
6
  // ── lifecycle::restart —— 整队 Route B resume-or-fresh 重建 ──────────────────
6
7
 
@@ -128,6 +129,12 @@ pub fn restart_with_transport_with_session_convergence_deadline(
128
129
  expected.display()
129
130
  )));
130
131
  }
132
+ let lifecycle_lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
133
+ workspace: &selected.run_workspace,
134
+ operation: "restart",
135
+ team: Some(selected.team_key.as_str()),
136
+ agent_id: None,
137
+ })?;
131
138
  let mut state = selected.state;
132
139
  crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
133
140
  // E5 task#3 / RC-A6a + E4(leader 裁定:每次 restart 都从角色定义重建 runtime spec,覆盖):
@@ -187,8 +194,15 @@ pub fn restart_with_transport_with_session_convergence_deadline(
187
194
  if convergence.converged && convergence.changed {
188
195
  save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
189
196
  }
190
- if repair_resume_sessions_from_event_log(&selected.run_workspace, &mut state)? {
191
- save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
197
+ let repaired_agent_ids =
198
+ repair_resume_sessions_from_event_log(&selected.run_workspace, &mut state)?;
199
+ if !repaired_agent_ids.is_empty() {
200
+ save_restart_session_repairs(
201
+ &selected.run_workspace,
202
+ &mut state,
203
+ &selected.team_key,
204
+ &repaired_agent_ids,
205
+ )?;
192
206
  let missing_after_repair = restart_required_missing_session_agent_ids(&state);
193
207
  convergence.changed = true;
194
208
  convergence.converged = missing_after_repair.is_empty();
@@ -295,7 +309,17 @@ pub fn restart_with_transport_with_session_convergence_deadline(
295
309
  .map_err(|e| LifecycleError::Transport(e.to_string()))?;
296
310
  mark_leader_receiver_rebind_required(&mut state, &session_name);
297
311
  mark_restart_targets_stopped_after_teardown(&mut state, &plan.decisions);
298
- save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
312
+ let topology_authority_agent_ids = plan
313
+ .decisions
314
+ .iter()
315
+ .map(|decision| decision.agent_id.as_str().to_string())
316
+ .collect::<Vec<_>>();
317
+ save_restart_state_with_lifecycle_topology_authority(
318
+ &selected.run_workspace,
319
+ &mut state,
320
+ &selected.team_key,
321
+ &topology_authority_agent_ids,
322
+ )?;
299
323
  }
300
324
  let mut successful_agents: Vec<RestartedAgent> = Vec::new();
301
325
  let mut failed_agents: Vec<RestartFailedAgent> = Vec::new();
@@ -308,7 +332,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
308
332
  if fatal_resume_failure {
309
333
  continue;
310
334
  }
311
- let Some(agent) = state
335
+ let Some(raw_agent) = state
312
336
  .get("agents")
313
337
  .and_then(|v| v.get(decision.agent_id.as_str()))
314
338
  .cloned()
@@ -324,6 +348,11 @@ pub fn restart_with_transport_with_session_convergence_deadline(
324
348
  failed_agents.push(restart_failed_agent(decision, "spawn", error));
325
349
  continue;
326
350
  };
351
+ let agent = rehydrate_agent_command_context_from_spec(
352
+ spec_workspace,
353
+ &decision.agent_id,
354
+ &raw_agent,
355
+ );
327
356
  let session_id = if matches!(decision.restart_mode, StartMode::Resumed) {
328
357
  decision.session_id.as_ref()
329
358
  } else {
@@ -429,7 +458,21 @@ pub fn restart_with_transport_with_session_convergence_deadline(
429
458
  }
430
459
  }
431
460
  // END_B5_RESTART_ISOLATION_LOOP
432
- save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
461
+ let mut topology_authority_agent_ids = successful_agents
462
+ .iter()
463
+ .map(|agent| agent.agent_id.as_str().to_string())
464
+ .collect::<Vec<_>>();
465
+ topology_authority_agent_ids.extend(
466
+ failed_agents
467
+ .iter()
468
+ .map(|agent| agent.agent_id.as_str().to_string()),
469
+ );
470
+ save_restart_state_with_lifecycle_topology_authority(
471
+ &selected.run_workspace,
472
+ &mut state,
473
+ &selected.team_key,
474
+ &topology_authority_agent_ids,
475
+ )?;
433
476
  if fatal_resume_failure {
434
477
  let attach_commands = Vec::new();
435
478
  let next_actions = restart_failure_next_actions(&failed_agents);
@@ -496,7 +539,10 @@ pub fn restart_with_transport_with_session_convergence_deadline(
496
539
  let convergence_deadline = session_convergence_deadline(session_converge_deadline_ms);
497
540
  let convergence_poll = session_convergence_poll_interval();
498
541
  for decision in successful_agents.drain(..) {
499
- if !matches!(decision.restart_mode, crate::lifecycle::types::StartMode::Resumed) {
542
+ if !matches!(
543
+ decision.restart_mode,
544
+ crate::lifecycle::types::StartMode::Resumed
545
+ ) {
500
546
  survivors.push(decision);
501
547
  continue;
502
548
  }
@@ -610,18 +656,23 @@ pub fn restart_with_transport_with_session_convergence_deadline(
610
656
  let phase = "resume_postflight";
611
657
  let error = "session_backing_store_missing_after_restart".to_string();
612
658
  mark_agent_restart_failed(&mut state, &decision, &error);
613
- let _ = write_restart_agent_failed_event(
614
- &selected.run_workspace,
615
- &decision,
616
- phase,
617
- &error,
618
- );
659
+ let _ =
660
+ write_restart_agent_failed_event(&selected.run_workspace, &decision, phase, &error);
619
661
  postflight_failed.push(restart_failed_agent(&decision, phase, error));
620
662
  }
621
663
  successful_agents = survivors;
622
664
  failed_agents.extend(postflight_failed);
623
665
  if !failed_agents.is_empty() {
624
- save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
666
+ let topology_authority_agent_ids = failed_agents
667
+ .iter()
668
+ .map(|agent| agent.agent_id.as_str().to_string())
669
+ .collect::<Vec<_>>();
670
+ save_restart_state_with_lifecycle_topology_authority(
671
+ &selected.run_workspace,
672
+ &mut state,
673
+ &selected.team_key,
674
+ &topology_authority_agent_ids,
675
+ )?;
625
676
  }
626
677
  }
627
678
  if successful_agents.is_empty() && !failed_agents.is_empty() {
@@ -641,6 +692,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
641
692
  attach_commands,
642
693
  });
643
694
  }
695
+ drop(lifecycle_lock);
644
696
  let coordinator_started = start_coordinator_for_workspace(&selected.run_workspace)?;
645
697
  wait_restart_readiness_or_timeout(
646
698
  &selected.run_workspace,
@@ -691,11 +743,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
691
743
  // invoked from a tmux pane should bind that pane as leader_receiver,
692
744
  // restoring the worker→leader delivery path. Failure is non-fatal — the
693
745
  // user can still run `team-agent attach-leader` manually.
694
- try_autobind_leader_after_restart(
695
- &selected.run_workspace,
696
- Some(&selected.team_key),
697
- &state,
698
- );
746
+ try_autobind_leader_after_restart(&selected.run_workspace, Some(&selected.team_key), &state);
699
747
  // 0.3.28 Step 1: topology invariant guard (warn-only). Same pattern as
700
748
  // `lifecycle::launch::launch_with_transport_in_workspace` — logs to stderr,
701
749
  // never panics. Hard error path is deferred to Step 10.
@@ -713,13 +761,13 @@ pub fn restart_with_transport_with_session_convergence_deadline(
713
761
  fn repair_resume_sessions_from_event_log(
714
762
  workspace: &Path,
715
763
  state: &mut serde_json::Value,
716
- ) -> Result<bool, LifecycleError> {
764
+ ) -> Result<Vec<String>, LifecycleError> {
717
765
  let agent_ids = state
718
766
  .get("agents")
719
767
  .and_then(serde_json::Value::as_object)
720
768
  .map(|agents| agents.keys().cloned().collect::<Vec<_>>())
721
769
  .unwrap_or_default();
722
- let mut changed = false;
770
+ let mut repaired_agent_ids = Vec::new();
723
771
  for agent_id in agent_ids {
724
772
  let previous = state
725
773
  .get("agents")
@@ -824,9 +872,9 @@ fn repair_resume_sessions_from_event_log(
824
872
  }),
825
873
  )
826
874
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
827
- changed = true;
875
+ repaired_agent_ids.push(agent_id);
828
876
  }
829
- Ok(changed)
877
+ Ok(repaired_agent_ids)
830
878
  }
831
879
 
832
880
  fn claimed_session_ids_except(
@@ -1189,9 +1237,7 @@ fn try_autobind_leader_after_restart(
1189
1237
  // the same attach target). Wire-format `parse_provider` keeps
1190
1238
  // them distinct everywhere else.
1191
1239
  crate::provider::wire::parse_provider(s).map(|p| match p {
1192
- crate::model::enums::Provider::Claude => {
1193
- crate::model::enums::Provider::ClaudeCode
1194
- }
1240
+ crate::model::enums::Provider::Claude => crate::model::enums::Provider::ClaudeCode,
1195
1241
  other => other,
1196
1242
  })
1197
1243
  })
@@ -1272,7 +1318,92 @@ fn save_restart_state(
1272
1318
  state: &mut serde_json::Value,
1273
1319
  team_key: &str,
1274
1320
  ) -> Result<(), LifecycleError> {
1275
- save_restart_projected_state(workspace, state, team_key)
1321
+ save_restart_projected_state(workspace, state, team_key, &[])
1322
+ }
1323
+
1324
+ fn save_restart_state_with_lifecycle_topology_authority(
1325
+ workspace: &Path,
1326
+ state: &mut serde_json::Value,
1327
+ team_key: &str,
1328
+ agent_ids: &[String],
1329
+ ) -> Result<(), LifecycleError> {
1330
+ let agent_ids = agent_ids.iter().map(String::as_str).collect::<Vec<_>>();
1331
+ save_restart_projected_state(workspace, state, team_key, &agent_ids)
1332
+ }
1333
+
1334
+ fn save_restart_session_repairs(
1335
+ workspace: &Path,
1336
+ state: &mut serde_json::Value,
1337
+ team_key: &str,
1338
+ agent_ids: &[String],
1339
+ ) -> Result<(), LifecycleError> {
1340
+ let repairs = collect_session_repair_fields(state, agent_ids);
1341
+ sync_restart_team_projections(state, team_key);
1342
+ match crate::state::projection::save_team_scoped_state(workspace, state) {
1343
+ Ok(()) => Ok(()),
1344
+ Err(crate::state::StateError::SaveConflict(_)) => {
1345
+ let mut latest =
1346
+ crate::state::projection::select_runtime_state(workspace, Some(team_key))
1347
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1348
+ for (agent_id, repair) in &repairs {
1349
+ apply_session_repair_fields(&mut latest, agent_id, repair);
1350
+ }
1351
+ sync_restart_team_projections(&mut latest, team_key);
1352
+ crate::state::projection::save_team_scoped_state(workspace, &latest)
1353
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1354
+ *state = latest;
1355
+ Ok(())
1356
+ }
1357
+ Err(error) => Err(LifecycleError::StatePersist(error.to_string())),
1358
+ }
1359
+ }
1360
+
1361
+ fn collect_session_repair_fields(
1362
+ state: &serde_json::Value,
1363
+ agent_ids: &[String],
1364
+ ) -> Vec<(String, serde_json::Value)> {
1365
+ agent_ids
1366
+ .iter()
1367
+ .filter_map(|agent_id| {
1368
+ state
1369
+ .get("agents")
1370
+ .and_then(|agents| agents.get(agent_id))
1371
+ .cloned()
1372
+ .map(|agent| (agent_id.clone(), agent))
1373
+ })
1374
+ .collect()
1375
+ }
1376
+
1377
+ fn apply_session_repair_fields(
1378
+ state: &mut serde_json::Value,
1379
+ agent_id: &str,
1380
+ repair: &serde_json::Value,
1381
+ ) {
1382
+ let Some(target) = state
1383
+ .get_mut("agents")
1384
+ .and_then(serde_json::Value::as_object_mut)
1385
+ .and_then(|agents| agents.get_mut(agent_id))
1386
+ .and_then(serde_json::Value::as_object_mut)
1387
+ else {
1388
+ return;
1389
+ };
1390
+ let Some(repair) = repair.as_object() else {
1391
+ return;
1392
+ };
1393
+ for field in [
1394
+ "session_id",
1395
+ "rollout_path",
1396
+ "captured_at",
1397
+ "captured_via",
1398
+ "attribution_confidence",
1399
+ ] {
1400
+ if let Some(value) = repair.get(field) {
1401
+ target.insert(field.to_string(), value.clone());
1402
+ } else {
1403
+ target.remove(field);
1404
+ }
1405
+ }
1406
+ target.remove("attribution_ambiguous");
1276
1407
  }
1277
1408
 
1278
1409
  fn mark_agent_respawned(
@@ -1330,10 +1461,7 @@ fn mark_agent_respawned(
1330
1461
  // active_team_key (priority 3).
1331
1462
  if let Some(ref team_id) = spawn.owner_team_id {
1332
1463
  if !team_id.is_empty() {
1333
- agent.insert(
1334
- "owner_team_id".to_string(),
1335
- serde_json::json!(team_id),
1336
- );
1464
+ agent.insert("owner_team_id".to_string(), serde_json::json!(team_id));
1337
1465
  }
1338
1466
  }
1339
1467
  // Bug 2 (0.3.32): clear stale `attribution_ambiguous` whenever a new
@@ -1638,8 +1766,13 @@ fn write_restart_resume_decision_events(
1638
1766
  // Layer 2 (leader directive 2026-06-22): every restart.resume_decision
1639
1767
  // event for a Refuse decision must carry the structured refusal_reason
1640
1768
  // wire string so operators can see WHY without crawling state.json.
1641
- let refusal_index: std::collections::BTreeMap<&str, &crate::lifecycle::types::UnresumableWorker> =
1642
- unresumable.iter().map(|u| (u.agent_id.as_str(), u)).collect();
1769
+ let refusal_index: std::collections::BTreeMap<
1770
+ &str,
1771
+ &crate::lifecycle::types::UnresumableWorker,
1772
+ > = unresumable
1773
+ .iter()
1774
+ .map(|u| (u.agent_id.as_str(), u))
1775
+ .collect();
1643
1776
  for decision in decisions {
1644
1777
  let agent = state
1645
1778
  .get("agents")
@@ -1,7 +1,8 @@
1
1
  use super::*;
2
- use super::agent::{resolve_team_scoped_state_or_refuse, start_agent_with_transport, stop_agent_at_paths};
2
+ use super::agent::{resolve_team_scoped_state_or_refuse, start_agent_at_paths, stop_agent_at_paths};
3
3
  use super::common::*;
4
4
  use super::team_state::write_team_state;
5
+ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
5
6
 
6
7
  /// `remove_agent(workspace, agent_id, from_spec, force, team)`(`lifecycle/agents.py:22`)。
7
8
  /// 从 spec/state/team_state/role-file/agent_health 原子摘除;`_RemoveRollback` 字节级快照
@@ -14,6 +15,12 @@ pub fn remove_agent(
14
15
  team: Option<&str>,
15
16
  ) -> Result<RemoveAgentOutcome, LifecycleError> {
16
17
  let paths = lifecycle_paths(workspace, team)?;
18
+ let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
19
+ workspace: &paths.run_workspace,
20
+ operation: "remove-agent",
21
+ team,
22
+ agent_id: Some(agent_id),
23
+ })?;
17
24
  let transport =
18
25
  lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
19
26
  remove_agent_at_paths(
@@ -36,6 +43,12 @@ pub fn remove_agent_with_transport(
36
43
  transport: &dyn crate::transport::Transport,
37
44
  ) -> Result<RemoveAgentOutcome, LifecycleError> {
38
45
  let paths = lifecycle_paths(workspace, team)?;
46
+ let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
47
+ workspace: &paths.run_workspace,
48
+ operation: "remove-agent",
49
+ team,
50
+ agent_id: Some(agent_id),
51
+ })?;
39
52
  remove_agent_at_paths(
40
53
  &paths.run_workspace,
41
54
  &paths.spec_workspace,
@@ -787,8 +800,9 @@ impl RemoveRollback {
787
800
  }
788
801
 
789
802
  if self.restore_running && errors.is_empty() {
790
- if let Err(e) = start_agent_with_transport(
803
+ if let Err(e) = start_agent_at_paths(
791
804
  workspace,
805
+ spec_workspace,
792
806
  &self.agent_id,
793
807
  true,
794
808
  false,
@@ -1,5 +1,6 @@
1
1
  use super::common::*;
2
2
  use super::*;
3
+ use crate::provider::wire::{parse_canonical_provider, requires_resume_backing};
3
4
 
4
5
  /// bug-085 四象限 `start_mode` 决策(`start.py:179-188` + `_resume_rollout_missing` `start.py:66-69`),
5
6
  /// **从 start_agent 的整条 lock+spawn 路径里分离出的纯函数**(gate gap:porter 需要单元级 RED
@@ -31,7 +32,7 @@ pub fn decide_start_mode(
31
32
  }
32
33
 
33
34
  pub(crate) fn resumable_provider_requires_backing(provider: &str) -> bool {
34
- matches!(provider, "codex" | "claude" | "claude_code" | "copilot")
35
+ parse_canonical_provider(provider).is_some_and(requires_resume_backing)
35
36
  }
36
37
 
37
38
  /// `first_send_at` 严格分类(`_classify_first_send_at`,`orchestration.py:399`)。
@@ -323,6 +323,7 @@ expected="${TEAM_AGENT_E27_EXPECTED_ENDPOINT}"
323
323
  session="${TEAM_AGENT_E27_SESSION_NAME}"
324
324
  pane="${TEAM_AGENT_E27_PANE_ID}"
325
325
  killed_marker="${log}.killed"
326
+ spawned_marker="${log}.spawned"
326
327
  track=0
327
328
  case "$*" in
328
329
  *"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
@@ -361,6 +362,7 @@ case "$*" in
361
362
  exit 0
362
363
  ;;
363
364
  *"display-message -p -t $session:alpha #{pane_id}"*)
365
+ : > "$spawned_marker"
364
366
  printf '%%9288\n'
365
367
  exit 0
366
368
  ;;
@@ -368,6 +370,10 @@ case "$*" in
368
370
  exit 0
369
371
  ;;
370
372
  *"list-panes -a -F"*)
373
+ if [ -f "$spawned_marker" ]; then
374
+ printf '%%9288\t%s\t0\talpha\t0\t/dev/ttys099\tnode\t1\t/tmp\t1\t0\t9288\n' "$session"
375
+ exit 0
376
+ fi
371
377
  # Hard-gate post-stop snapshot: no residual same-role panes after kill.
372
378
  if [ -f "$killed_marker" ]; then
373
379
  exit 0
@@ -27,10 +27,10 @@ impl LaneTransport {
27
27
  spawns: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
28
28
  }
29
29
  }
30
- fn killed(&self) -> Vec<String> {
30
+ pub(super) fn killed(&self) -> Vec<String> {
31
31
  self.killed.lock().unwrap().clone()
32
32
  }
33
- fn spawns(&self) -> Vec<(String, Vec<String>)> {
33
+ pub(super) fn spawns(&self) -> Vec<(String, Vec<String>)> {
34
34
  self.spawns.lock().unwrap().clone()
35
35
  }
36
36
  }
@@ -164,7 +164,7 @@ fn set_context_state_file(ws: &std::path::Path, name: &str) {
164
164
  /// Fork workspace: source `alpha` (role doc = `alpha_role`) + `bravo`, alpha seeded RUNNING with a
165
165
  /// source `session_id` (so fork reaches the spec-mutation / native-fork gate). Seeded-healthy coordinator
166
166
  /// (fork's start_coordinator -> AlreadyRunning, no real daemon). session_name = "team-laneateam".
167
- fn fork_ws(alpha_role: &str) -> PathBuf {
167
+ pub(super) fn fork_ws(alpha_role: &str) -> PathBuf {
168
168
  let ws = temp_ws().join("forkv2");
169
169
  std::fs::create_dir_all(ws.join("agents")).unwrap();
170
170
  std::fs::write(ws.join("TEAM.md"), "---\nname: laneateam\nobjective: Fork v2 probe.\nprovider: codex\n---\n\nteam.\n").unwrap();