@team-agent/installer 0.3.20 → 0.3.22

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 (30) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +9 -1
  4. package/crates/team-agent/src/cli/mod.rs +158 -2
  5. package/crates/team-agent/src/cli/status.rs +61 -0
  6. package/crates/team-agent/src/cli/status_port.rs +2 -2
  7. package/crates/team-agent/src/cli/tests/base.rs +26 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
  9. package/crates/team-agent/src/coordinator/runtime_detectors.rs +251 -2
  10. package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
  11. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  12. package/crates/team-agent/src/coordinator/tick.rs +452 -17
  13. package/crates/team-agent/src/lifecycle/display.rs +23 -302
  14. package/crates/team-agent/src/lifecycle/launch.rs +38 -0
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
  16. package/crates/team-agent/src/lifecycle/restart/common.rs +76 -0
  17. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
  18. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
  19. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
  20. package/crates/team-agent/src/messaging/delivery.rs +324 -95
  21. package/crates/team-agent/src/messaging/scheduler.rs +95 -73
  22. package/crates/team-agent/src/messaging/send.rs +10 -0
  23. package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
  24. package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
  25. package/crates/team-agent/src/session_capture.rs +261 -1
  26. package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
  27. package/crates/team-agent/src/tmux_backend.rs +138 -2
  28. package/crates/team-agent/src/transport/test_support.rs +25 -0
  29. package/crates/team-agent/src/transport.rs +11 -0
  30. package/package.json +4 -4
@@ -105,6 +105,7 @@ pub fn rebuild_adaptive_display_after_rebind(
105
105
  struct DisplayTarget {
106
106
  agent_id: String,
107
107
  window: Option<WindowName>,
108
+ pane_id: Option<PaneId>,
108
109
  role: Option<String>,
109
110
  }
110
111
 
@@ -128,6 +129,11 @@ fn worker_display_targets(
128
129
  targets.push(DisplayTarget {
129
130
  agent_id: agent_id.clone(),
130
131
  window: Some(WindowName::new(window)),
132
+ pane_id: agent
133
+ .get("pane_id")
134
+ .and_then(serde_json::Value::as_str)
135
+ .filter(|pane| !pane.is_empty())
136
+ .map(PaneId::new),
131
137
  role: agent
132
138
  .get("role")
133
139
  .and_then(serde_json::Value::as_str)
@@ -190,81 +196,26 @@ fn open_adaptive_worker_displays(
190
196
  probe: &DisplayProbe,
191
197
  ) -> Result<BTreeMap<String, WorkerDisplay>, LifecycleError> {
192
198
  let targets = worker_display_targets(workspace, session_name)?;
193
- if targets.is_empty() {
194
- return Ok(BTreeMap::new());
195
- }
196
- if probe.adaptive_status != DisplayStatus::Opened {
197
- return Ok(targets
198
- .into_iter()
199
- .map(|target| {
200
- (
201
- target.agent_id,
202
- WorkerDisplay::Blocked {
203
- reason: probe
204
- .reason
205
- .unwrap_or(AdaptiveBlockReason::AggregatorRebuildFailed),
206
- },
207
- )
208
- })
209
- .collect());
210
- }
211
- let Some(leader_session) = probe.leader_session.as_ref() else {
212
- return Ok(blocked_displays(
213
- targets,
214
- AdaptiveBlockReason::LeaderNotInTmux,
215
- ));
216
- };
217
- let mut linked_jobs = Vec::new();
218
- for target in &targets {
219
- match create_linked_worker_session(session_name, target) {
220
- Ok(linked_session) => linked_jobs.push((target.clone(), linked_session)),
221
- Err(reason) => {
222
- kill_linked_sessions(linked_jobs.iter().map(|(_, linked)| linked.as_str()));
223
- return Ok(blocked_displays(targets, reason));
224
- }
225
- }
226
- }
227
- close_adaptive_windows(leader_session.as_str(), session_name.as_str());
228
- let panes = match prepare_tmux_attached_panes(
229
- leader_session.as_str(),
230
- session_name.as_str(),
231
- &linked_jobs,
232
- ) {
233
- Ok(panes) => panes,
234
- Err(reason) => {
235
- close_adaptive_windows(leader_session.as_str(), session_name.as_str());
236
- kill_linked_sessions(linked_jobs.iter().map(|(_, linked)| linked.as_str()));
237
- return Ok(blocked_displays(
238
- linked_jobs.into_iter().map(|(target, _)| target).collect(),
239
- reason,
240
- ));
241
- }
242
- };
243
- Ok(linked_jobs
199
+ Ok(targets
244
200
  .into_iter()
245
- .filter_map(|(target, linked_session)| {
246
- let pane = panes.get(&target.agent_id)?;
247
- let pane_title = display_pane_title(&target);
248
- Some((
249
- target.agent_id,
201
+ .map(|target| {
202
+ let agent_id = target.agent_id.clone();
203
+ (
204
+ agent_id.clone(),
250
205
  WorkerDisplay::Adaptive {
251
- status: DisplayStatus::Opened,
252
- window: Some(WindowName::new(pane.window_name.clone())),
253
- workspace_window: Some(WindowName::new(pane.window_name.clone())),
254
- pane_id: Some(PaneId::new(pane.pane_id.clone())),
255
- pane_title: Some(pane_title),
256
- target: Some(format!("{}:{}", session_name.as_str(), pane.agent_id)),
257
- target_worker_session: Some(format!(
258
- "{}:{}",
259
- session_name.as_str(),
260
- pane.agent_id
261
- )),
262
- linked_session: Some(linked_session),
263
- leader_session: Some(leader_session.clone()),
264
- display_session: Some(leader_session.clone()),
265
- fallback: Some("tmux_headless".to_string()),
206
+ status: probe.adaptive_status,
207
+ window: target.window.clone(),
208
+ workspace_window: target.window.clone(),
209
+ pane_id: target.pane_id.clone(),
210
+ pane_title: Some(agent_id),
211
+ target: target.pane_id.as_ref().map(|pane| pane.as_str().to_string()),
212
+ target_worker_session: Some(session_name.as_str().to_string()),
213
+ linked_session: None,
214
+ leader_session: Some(session_name.clone()),
215
+ display_session: Some(session_name.clone()),
216
+ fallback: None,
266
217
  },
267
- ))
218
+ )
268
219
  })
269
220
  .collect())
270
221
  }
@@ -407,13 +358,6 @@ struct TmuxOutput {
407
358
  stderr: String,
408
359
  }
409
360
 
410
- #[derive(Debug, Clone)]
411
- struct PaneRecord {
412
- agent_id: String,
413
- pane_id: String,
414
- window_name: String,
415
- }
416
-
417
361
  fn in_wsl() -> bool {
418
362
  std::env::var("WSL_DISTRO_NAME").is_ok_and(|value| !value.is_empty())
419
363
  || std::env::var("WSL_INTEROP").is_ok_and(|value| !value.is_empty())
@@ -508,161 +452,6 @@ fn parse_tmux_info(stdout: &str) -> Option<LeaderTmuxInfo> {
508
452
  }
509
453
  }
510
454
 
511
- fn create_linked_worker_session(
512
- session_name: &SessionName,
513
- target: &DisplayTarget,
514
- ) -> Result<String, AdaptiveBlockReason> {
515
- let linked_session = adaptive_linked_session_name(session_name.as_str(), &target.agent_id);
516
- let worker_window = target
517
- .window
518
- .as_ref()
519
- .map(WindowName::as_str)
520
- .unwrap_or(target.agent_id.as_str());
521
- let _ = run_tmux(&["kill-session", "-t", linked_session.as_str()]);
522
- run_tmux(&[
523
- "new-session",
524
- "-d",
525
- "-t",
526
- session_name.as_str(),
527
- "-s",
528
- linked_session.as_str(),
529
- ])
530
- .map_err(|_| AdaptiveBlockReason::WorkerSessionMissing)
531
- .or_else(|reason| {
532
- if running_inside_tmux() {
533
- Err(reason)
534
- } else {
535
- Ok(TmuxOutput {
536
- ok: true,
537
- stdout: String::new(),
538
- stderr: String::new(),
539
- })
540
- }
541
- })?;
542
- if run_tmux(&[
543
- "select-window",
544
- "-t",
545
- &format!("{linked_session}:{worker_window}"),
546
- ])
547
- .is_err()
548
- {
549
- if !running_inside_tmux() {
550
- return Ok(linked_session);
551
- }
552
- let _ = run_tmux(&["kill-session", "-t", linked_session.as_str()]);
553
- return Err(AdaptiveBlockReason::WorkerSessionMissing);
554
- }
555
- Ok(linked_session)
556
- }
557
-
558
- fn prepare_tmux_attached_panes(
559
- leader_session: &str,
560
- session_name: &str,
561
- linked_jobs: &[(DisplayTarget, String)],
562
- ) -> Result<BTreeMap<String, PaneRecord>, AdaptiveBlockReason> {
563
- let mut panes = BTreeMap::new();
564
- for (window_index, window_jobs) in linked_jobs.chunks(3).enumerate() {
565
- let window_name = adaptive_window_name(session_name, window_index);
566
- let (first_target, first_linked_session) = &window_jobs[0];
567
- let first_pane = run_tmux(&[
568
- "new-window",
569
- "-t",
570
- leader_session,
571
- "-n",
572
- window_name.as_str(),
573
- "-P",
574
- "-F",
575
- "#{pane_id}",
576
- &tmux_attach_pane_command(first_linked_session),
577
- ]);
578
- let first_pane_id = match first_pane {
579
- Ok(output) => tmux_stdout_last_line(&output.stdout)
580
- .unwrap_or_else(|| format!("%ta{window_index}0")),
581
- Err(_) if !running_inside_tmux() => format!("%ta{window_index}0"),
582
- Err(_) => return Err(AdaptiveBlockReason::WindowCreateFailed),
583
- };
584
- if running_inside_tmux() {
585
- set_display_pane_title(&first_pane_id, first_target)?;
586
- } else {
587
- let _ = set_display_pane_title(&first_pane_id, first_target);
588
- }
589
- panes.insert(
590
- first_target.agent_id.clone(),
591
- PaneRecord {
592
- agent_id: first_target.agent_id.clone(),
593
- pane_id: first_pane_id,
594
- window_name: window_name.clone(),
595
- },
596
- );
597
- let remain = run_tmux(&[
598
- "set-window-option",
599
- "-t",
600
- &format!("{leader_session}:{window_name}"),
601
- "remain-on-exit",
602
- "on",
603
- ]);
604
- if running_inside_tmux() && remain.is_err() {
605
- return Err(AdaptiveBlockReason::AggregatorRebuildFailed);
606
- }
607
- for (pane_index, (target, linked_session)) in window_jobs.iter().enumerate().skip(1) {
608
- let split = run_tmux(&[
609
- "split-window",
610
- "-t",
611
- &format!("{leader_session}:{window_name}"),
612
- "-h",
613
- "-P",
614
- "-F",
615
- "#{pane_id}",
616
- &tmux_attach_pane_command(linked_session),
617
- ]);
618
- let pane_id = match split {
619
- Ok(output) => tmux_stdout_last_line(&output.stdout)
620
- .unwrap_or_else(|| format!("%ta{window_index}{pane_index}")),
621
- Err(_) if !running_inside_tmux() => format!("%ta{window_index}{pane_index}"),
622
- Err(_) => return Err(AdaptiveBlockReason::SplitFailed),
623
- };
624
- if running_inside_tmux() {
625
- set_display_pane_title(&pane_id, target)?;
626
- } else {
627
- let _ = set_display_pane_title(&pane_id, target);
628
- }
629
- panes.insert(
630
- target.agent_id.clone(),
631
- PaneRecord {
632
- agent_id: target.agent_id.clone(),
633
- pane_id,
634
- window_name: window_name.clone(),
635
- },
636
- );
637
- }
638
- let layout = run_tmux(&[
639
- "select-layout",
640
- "-t",
641
- &format!("{leader_session}:{window_name}"),
642
- "even-horizontal",
643
- ]);
644
- if running_inside_tmux() && layout.is_err() {
645
- return Err(AdaptiveBlockReason::AggregatorRebuildFailed);
646
- }
647
- }
648
- Ok(panes)
649
- }
650
-
651
- fn set_display_pane_title(
652
- pane_id: &str,
653
- target: &DisplayTarget,
654
- ) -> Result<(), AdaptiveBlockReason> {
655
- run_tmux(&[
656
- "select-pane",
657
- "-t",
658
- pane_id,
659
- "-T",
660
- &display_pane_title(target),
661
- ])
662
- .map(|_| ())
663
- .map_err(|_| AdaptiveBlockReason::AggregatorRebuildFailed)
664
- }
665
-
666
455
  fn close_adaptive_windows(leader_session: &str, session_name: &str) -> Vec<String> {
667
456
  let prefix = format!("team-agent:{session_name}:overview");
668
457
  let Ok(output) = run_tmux(&["list-windows", "-t", leader_session, "-F", "#{window_name}"])
@@ -684,17 +473,6 @@ fn close_adaptive_windows(leader_session: &str, session_name: &str) -> Vec<Strin
684
473
  .collect()
685
474
  }
686
475
 
687
- fn kill_linked_sessions<'a>(sessions: impl IntoIterator<Item = &'a str>) -> Vec<String> {
688
- sessions
689
- .into_iter()
690
- .filter_map(|session| {
691
- run_tmux(&["kill-session", "-t", session])
692
- .ok()
693
- .map(|_| session.to_string())
694
- })
695
- .collect()
696
- }
697
-
698
476
  fn kill_adaptive_window(target: &str) -> bool {
699
477
  run_tmux(&["kill-window", "-t", target]).is_ok()
700
478
  }
@@ -718,13 +496,6 @@ fn adaptive_leader_session(state: &serde_json::Value) -> Option<String> {
718
496
  .map(str::to_string)
719
497
  }
720
498
 
721
- fn adaptive_linked_session_name(session_name: &str, agent_id: &str) -> String {
722
- let digest = crate::leader::sha1_hex_prefix(format!("{session_name}:{agent_id}").as_bytes(), 8);
723
- let safe_session = sanitize_tmux_name(session_name, 80, "team");
724
- let safe_agent = sanitize_tmux_name(agent_id, 40, "agent");
725
- format!("{safe_session}__display__{safe_agent}__{digest}")
726
- }
727
-
728
499
  fn adaptive_window_name(session_name: &str, index: usize) -> String {
729
500
  if index == 0 {
730
501
  format!("team-agent:{session_name}:overview")
@@ -738,13 +509,6 @@ fn adaptive_window_is_tagged(session_name: &str, window: &str) -> bool {
738
509
  window == prefix || window.starts_with(&format!("{prefix}-"))
739
510
  }
740
511
 
741
- fn tmux_attach_pane_command(linked_session: &str) -> String {
742
- format!(
743
- "TMUX= tmux attach-session -t {}",
744
- shell_quote(linked_session)
745
- )
746
- }
747
-
748
512
  fn display_pane_title(target: &DisplayTarget) -> String {
749
513
  format!(
750
514
  "team-agent:{}:{}",
@@ -753,39 +517,6 @@ fn display_pane_title(target: &DisplayTarget) -> String {
753
517
  )
754
518
  }
755
519
 
756
- fn sanitize_tmux_name(raw: &str, max_len: usize, fallback: &str) -> String {
757
- let sanitized = raw
758
- .chars()
759
- .map(|c| {
760
- if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
761
- c
762
- } else {
763
- '_'
764
- }
765
- })
766
- .collect::<String>();
767
- let trimmed = sanitized
768
- .chars()
769
- .take(max_len)
770
- .collect::<String>()
771
- .trim_matches(&['.', '_', '-'][..])
772
- .to_string();
773
- if trimmed.is_empty() {
774
- fallback.to_string()
775
- } else {
776
- trimmed
777
- }
778
- }
779
-
780
- fn tmux_stdout_last_line(stdout: &str) -> Option<String> {
781
- stdout
782
- .lines()
783
- .rev()
784
- .map(str::trim)
785
- .find(|line| !line.is_empty())
786
- .map(str::to_string)
787
- }
788
-
789
520
  fn string_field(display: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
790
521
  display
791
522
  .get(key)
@@ -814,13 +545,3 @@ fn run_tmux(args: &[&str]) -> Result<TmuxOutput, LifecycleError> {
814
545
  )))
815
546
  }
816
547
  }
817
-
818
- fn shell_quote(value: &str) -> String {
819
- if value
820
- .chars()
821
- .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':'))
822
- {
823
- return value.to_string();
824
- }
825
- format!("'{}'", value.replace('\'', "'\\''"))
826
- }
@@ -438,6 +438,16 @@ fn spawn_agents(
438
438
  if matches!(transport.liveness(&spawn.pane_id), Ok(PaneLiveness::Dead)) {
439
439
  continue;
440
440
  }
441
+ if placement.is_some() {
442
+ configure_adaptive_pane_title(
443
+ workspace,
444
+ transport,
445
+ session_name,
446
+ &window,
447
+ &spawn.pane_id,
448
+ agent_id_raw,
449
+ );
450
+ }
441
451
  let display = if placement.is_some() {
442
452
  WorkerDisplay::Adaptive {
443
453
  status: DisplayStatus::Opened,
@@ -2654,6 +2664,34 @@ pub(crate) fn selected_tmux_socket_source(
2654
2664
  }
2655
2665
  }
2656
2666
 
2667
+ pub(crate) fn configure_adaptive_pane_title(
2668
+ workspace: &Path,
2669
+ transport: &dyn Transport,
2670
+ session_name: &SessionName,
2671
+ window: &WindowName,
2672
+ pane: &PaneId,
2673
+ agent_id: &str,
2674
+ ) {
2675
+ if let Err(error) =
2676
+ transport.configure_adaptive_pane_title(session_name, window, pane, agent_id)
2677
+ {
2678
+ let message = format!("adaptive layout pane title failed for {agent_id}: {error}");
2679
+ eprintln!("Warning: {message}");
2680
+ if let Err(event_error) = crate::event_log::EventLog::new(workspace).write(
2681
+ "adaptive_layout.pane_title_failed",
2682
+ serde_json::json!({
2683
+ "agent_id": agent_id,
2684
+ "session": session_name.as_str(),
2685
+ "window": window.as_str(),
2686
+ "pane_id": pane.as_str(),
2687
+ "warning": message,
2688
+ }),
2689
+ ) {
2690
+ eprintln!("Warning: adaptive_layout.pane_title_failed event write failed: {event_error}");
2691
+ }
2692
+ }
2693
+ }
2694
+
2657
2695
  fn explicit_quick_start_workspace(workspace: &Path) -> PathBuf {
2658
2696
  std::fs::canonicalize(workspace).unwrap_or_else(|_| {
2659
2697
  if workspace.is_absolute() {
@@ -97,8 +97,8 @@ pub(crate) fn start_agent_at_paths(
97
97
  };
98
98
  if !force && agent_live {
99
99
  mark_agent_running_noop(&mut state, agent_id, &session_name, &window)?;
100
- crate::state::projection::save_team_scoped_state(workspace, &state)
101
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
100
+ let team_key = restart_projection_team_key(&state, team);
101
+ save_restart_projected_state(workspace, &mut state, &team_key)?;
102
102
  if let Ok(spec) = load_team_spec(spec_workspace) {
103
103
  write_team_state(spec_workspace, &spec, &state)?;
104
104
  }
@@ -184,9 +184,9 @@ pub(crate) fn start_agent_at_paths(
184
184
  layout_placement.as_ref(),
185
185
  None,
186
186
  )?;
187
- mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, &safety)?;
188
- crate::state::projection::save_team_scoped_state(workspace, &state)
189
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
187
+ mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety)?;
188
+ let team_key = restart_projection_team_key(&state, team);
189
+ save_restart_projected_state(workspace, &mut state, &team_key)?;
190
190
  write_start_agent_start_event(
191
191
  workspace,
192
192
  agent_id,
@@ -410,6 +410,7 @@ fn mark_agent_started(
410
410
  agent_id: &AgentId,
411
411
  window: &str,
412
412
  spawn: &SpawnedAgentWindow,
413
+ transport: &dyn crate::transport::Transport,
413
414
  safety: &DangerousApproval,
414
415
  ) -> Result<(), LifecycleError> {
415
416
  let Some(agent) = state
@@ -430,9 +431,23 @@ fn mark_agent_started(
430
431
  "pane_id".to_string(),
431
432
  serde_json::json!(spawn.spawn.pane_id.as_str()),
432
433
  );
433
- if let Some(pane_pid) = spawn.spawn.child_pid {
434
+ let pane_pid = spawn.spawn.child_pid.or_else(|| {
435
+ transport
436
+ .list_targets()
437
+ .unwrap_or_default()
438
+ .into_iter()
439
+ .find(|pane| pane.pane_id == spawn.spawn.pane_id)
440
+ .and_then(|pane| pane.pane_pid)
441
+ });
442
+ if let Some(pane_pid) = pane_pid {
434
443
  agent.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
444
+ } else {
445
+ agent.remove("pane_pid");
435
446
  }
447
+ agent.insert(
448
+ "spawned_at".to_string(),
449
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
450
+ );
436
451
  crate::lifecycle::launch::persist_command_plan_state(
437
452
  agent,
438
453
  &spawn.plan,
@@ -467,6 +482,10 @@ fn mark_agent_started(
467
482
  }),
468
483
  );
469
484
  }
485
+ agent.remove("startup_prompts");
486
+ agent.remove("startup_prompt_status");
487
+ agent.remove("startup_prompt_probe_epoch");
488
+ agent.remove("startup_prompt_probe_disabled_at");
470
489
  Ok(())
471
490
  }
472
491
 
@@ -551,6 +570,8 @@ fn reset_agent_at_paths(
551
570
  let mut state = resolve_team_scoped_state_or_refuse(workspace, team)?;
552
571
  let spec = load_team_spec(spec_workspace)?;
553
572
  discard_agent_session_fields(&mut state, agent_id)?;
573
+ let team_key = restart_projection_team_key(&state, team);
574
+ sync_restart_team_projections(&mut state, &team_key);
554
575
  // golden operations.py (reset): save_team_scoped_state on the team projection — same multi-team
555
576
  // preservation as stop, not a raw save_runtime_state.
556
577
  crate::state::projection::save_team_scoped_state_with_tombstoned_agents(
@@ -187,6 +187,16 @@ pub(super) fn spawn_agent_window(
187
187
  )
188
188
  };
189
189
  let spawn = result.map_err(|e| LifecycleError::Transport(e.to_string()))?;
190
+ if layout_placement.is_some() {
191
+ crate::lifecycle::launch::configure_adaptive_pane_title(
192
+ workspace,
193
+ transport,
194
+ session_name,
195
+ &window,
196
+ &spawn.pane_id,
197
+ agent_id.as_str(),
198
+ );
199
+ }
190
200
  let _ = adapter.handle_startup_prompts(
191
201
  transport,
192
202
  &crate::transport::Target::Pane(spawn.pane_id.clone()),
@@ -247,6 +257,72 @@ pub(super) fn persist_effective_approval_policy_for_restart(
247
257
  crate::lifecycle::launch::persist_effective_approval_policy(agent, safety);
248
258
  }
249
259
 
260
+ pub(super) fn save_restart_projected_state(
261
+ workspace: &Path,
262
+ state: &mut serde_json::Value,
263
+ team_key: &str,
264
+ ) -> Result<(), LifecycleError> {
265
+ sync_restart_team_projections(state, team_key);
266
+ crate::state::projection::save_team_scoped_state(workspace, state)
267
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
268
+ }
269
+
270
+ pub(super) fn restart_projection_team_key(
271
+ state: &serde_json::Value,
272
+ team: Option<&str>,
273
+ ) -> String {
274
+ team.filter(|key| !key.is_empty())
275
+ .map(str::to_string)
276
+ .or_else(|| {
277
+ state
278
+ .get("active_team_key")
279
+ .and_then(serde_json::Value::as_str)
280
+ .filter(|key| !key.is_empty())
281
+ .map(str::to_string)
282
+ })
283
+ .unwrap_or_else(|| crate::state::projection::team_state_key(state))
284
+ }
285
+
286
+ pub(super) fn sync_restart_team_projections(state: &mut serde_json::Value, team_key: &str) {
287
+ let Some(teams) = state.get("teams").and_then(serde_json::Value::as_object) else {
288
+ return;
289
+ };
290
+ if teams.is_empty() {
291
+ return;
292
+ }
293
+ let compact = crate::state::projection::compact_team_state(state);
294
+ let active_key = state
295
+ .get("active_team_key")
296
+ .and_then(serde_json::Value::as_str)
297
+ .filter(|key| !key.is_empty())
298
+ .map(str::to_string);
299
+ let derived_key = crate::state::projection::team_state_key(state);
300
+ let Some(teams) = state
301
+ .get_mut("teams")
302
+ .and_then(serde_json::Value::as_object_mut)
303
+ else {
304
+ return;
305
+ };
306
+ let mut keys = Vec::new();
307
+ if !team_key.is_empty() {
308
+ keys.push(team_key.to_string());
309
+ }
310
+ if let Some(active_key) = active_key {
311
+ keys.push(active_key);
312
+ }
313
+ if !derived_key.is_empty() {
314
+ keys.push(derived_key);
315
+ }
316
+ if teams.contains_key("current") {
317
+ keys.push("current".to_string());
318
+ }
319
+ keys.sort();
320
+ keys.dedup();
321
+ for key in keys {
322
+ teams.insert(key, compact.clone());
323
+ }
324
+ }
325
+
250
326
  pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
251
327
  state
252
328
  .get("session_name")