@team-agent/installer 0.3.39 → 0.4.0

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 (61) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +7 -0
  4. package/crates/team-agent/src/cli/adapters.rs +8 -3
  5. package/crates/team-agent/src/cli/diagnose.rs +12 -3
  6. package/crates/team-agent/src/cli/emit.rs +1 -0
  7. package/crates/team-agent/src/cli/mod.rs +250 -9
  8. package/crates/team-agent/src/cli/send.rs +11 -2
  9. package/crates/team-agent/src/cli/status_port.rs +39 -4
  10. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
  11. package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
  12. package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
  13. package/crates/team-agent/src/cli/types.rs +5 -0
  14. package/crates/team-agent/src/coordinator/mod.rs +1 -0
  15. package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
  16. package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
  17. package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
  19. package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
  20. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
  21. package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
  22. package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
  23. package/crates/team-agent/src/coordinator/tick.rs +126 -30
  24. package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
  25. package/crates/team-agent/src/db/mod.rs +1 -0
  26. package/crates/team-agent/src/layout/mod.rs +7 -0
  27. package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
  28. package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
  29. package/crates/team-agent/src/leader/start.rs +27 -1
  30. package/crates/team-agent/src/leader/tests/identity.rs +50 -0
  31. package/crates/team-agent/src/lib.rs +6 -2
  32. package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
  33. package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
  34. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
  35. package/crates/team-agent/src/lifecycle/launch.rs +34 -0
  36. package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
  37. package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
  38. package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
  39. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
  40. package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
  41. package/crates/team-agent/src/lifecycle/restart.rs +6 -0
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
  43. package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
  44. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/types.rs +20 -1
  46. package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
  47. package/crates/team-agent/src/mcp_server/tools.rs +29 -4
  48. package/crates/team-agent/src/provider/adapter.rs +31 -1
  49. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
  50. package/crates/team-agent/src/provider/classify.rs +138 -0
  51. package/crates/team-agent/src/provider/command.rs +71 -0
  52. package/crates/team-agent/src/provider/mod.rs +3 -0
  53. package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
  54. package/crates/team-agent/src/provider/session/mod.rs +13 -0
  55. package/crates/team-agent/src/provider/session/resume.rs +417 -0
  56. package/crates/team-agent/src/provider/session_scan.rs +63 -0
  57. package/crates/team-agent/src/provider/types.rs +5 -0
  58. package/crates/team-agent/src/state/persist.rs +238 -0
  59. package/crates/team-agent/src/tmux_backend.rs +25 -0
  60. package/npm/install.mjs +27 -1
  61. package/package.json +4 -4
@@ -0,0 +1,51 @@
1
+ //! unit-8 (Stage 3) — `lifecycle::launch::spec_state` phase boundary.
2
+ //!
3
+ //! The dedicated home for spec/runtime-path resolution and state-tree
4
+ //! materialization phases of `quick_start`. Lives in the
5
+ //! `lifecycle/launch/` submodule so future commits can migrate the
6
+ //! existing inline phase fns (launch.rs:2781-2906 and 1680-1756 ranges)
7
+ //! here in small, reviewable pieces.
8
+ //!
9
+ //! Established phases (canonical names — keep stable for future
10
+ //! migration):
11
+ //!
12
+ //! * `resolve_spec_paths` — `.team/runtime/<team>/team.spec.yaml`
13
+ //! resolution + workspace canonicalization
14
+ //! * `materialize_state` — T1 layer state.json initialization including
15
+ //! agent capture fields and `spawn_cwd`
16
+ //!
17
+ //! This commit lands the boundary + a marker enum so unit-8's adoption
18
+ //! sites can reference the phases by name. The phase fns themselves
19
+ //! remain in launch.rs until the next batch of relocations.
20
+
21
+ /// Named launch phases under spec_state. Used in phase labels for logs
22
+ /// and (future) for the orchestrator's step dispatcher.
23
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
24
+ pub enum SpecStatePhase {
25
+ ResolveSpecPaths,
26
+ MaterializeState,
27
+ }
28
+
29
+ impl SpecStatePhase {
30
+ pub fn as_str(self) -> &'static str {
31
+ match self {
32
+ Self::ResolveSpecPaths => "launch.spec_state.resolve_spec_paths",
33
+ Self::MaterializeState => "launch.spec_state.materialize_state",
34
+ }
35
+ }
36
+ }
37
+
38
+ #[cfg(test)]
39
+ mod tests {
40
+ use super::*;
41
+
42
+ #[test]
43
+ fn phase_labels_are_dotted_paths_under_launch_spec_state() {
44
+ assert!(SpecStatePhase::ResolveSpecPaths
45
+ .as_str()
46
+ .starts_with("launch.spec_state."));
47
+ assert!(SpecStatePhase::MaterializeState
48
+ .as_str()
49
+ .starts_with("launch.spec_state."));
50
+ }
51
+ }
@@ -251,6 +251,11 @@ fn spawn_agents(
251
251
  model: command_model,
252
252
  tools: &resolved_tool_refs,
253
253
  profile_launch: Some(&profile_launch),
254
+ // Layer 1 self-healing (architect probe 2026-06-22): expose
255
+ // agent_id as a display-name hint so Claude / Copilot
256
+ // adapters can pass `--name <agent_id>`. Codex has no
257
+ // equivalent flag and ignores the hint.
258
+ agent_id_hint: Some(agent_id_raw),
254
259
  })
255
260
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
256
261
  if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
@@ -923,6 +928,26 @@ fn save_launched_team_state_for_key(
923
928
  .unwrap_or_else(|| crate::state::projection::team_state_key(launched));
924
929
  let mut launched = launched.clone();
925
930
  if let Some(obj) = launched.as_object_mut() {
931
+ // RM-039-STAT-001 second-round fix (architect verdict
932
+ // 2026-06-22): the canonical runtime team key MUST be written
933
+ // explicitly to `state.team_key`, not only inferred from
934
+ // `team_dir` by `team_state_key`'s cascade. The historical
935
+ // bug: when `team_dir = "./.team/current"` and the runtime team
936
+ // is `rm039-status-working-891`, `team_state_key` cascades to
937
+ // the team_dir basename (`current`), but `active_team_key` is
938
+ // the real team key. Coordinator tick uses `team_state_key`
939
+ // when saving the team-scoped state, so writes land on
940
+ // `teams.current` instead of `teams.<active_team_key>`. Status
941
+ // reads `teams[active_team_key]`, sees stale data.
942
+ //
943
+ // Writing `team_key=launched_key` here pins the first branch of
944
+ // `team_state_key` so the cascade returns the canonical runtime
945
+ // team key everywhere — coordinator tick, save_team_scoped_state,
946
+ // and status selector all agree.
947
+ obj.insert(
948
+ "team_key".to_string(),
949
+ serde_json::Value::String(launched_key.clone()),
950
+ );
926
951
  obj.insert(
927
952
  "active_team_key".to_string(),
928
953
  serde_json::Value::String(launched_key.clone()),
@@ -4030,6 +4055,7 @@ pub fn fork_agent_with_transport(
4030
4055
  model: command_model,
4031
4056
  tools: &resolved_tool_refs,
4032
4057
  profile_launch: Some(&profile_launch),
4058
+ agent_id_hint: Some(as_agent_id.as_str()),
4033
4059
  },
4034
4060
  )
4035
4061
  .map_err(|e| {
@@ -4927,3 +4953,11 @@ fn team_workspace(team_dir: &Path) -> PathBuf {
4927
4953
 
4928
4954
  mod plan;
4929
4955
  pub use plan::{handle_report_result, start_plan};
4956
+
4957
+ // unit-8 (Stage 3) phase boundary modules. Established here as named
4958
+ // homes for upcoming relocations from the launch.rs monolith. Each
4959
+ // child carries a `*Phase` enum naming the canonical phase labels used
4960
+ // in event logs and (future) the orchestrator's step dispatcher.
4961
+ pub mod readiness;
4962
+ pub mod spawn;
4963
+ pub mod spec_state;
@@ -190,6 +190,11 @@ pub(crate) fn start_agent_at_paths(
190
190
  .as_ref()
191
191
  .map(|placement| placement.layout_window.as_str().to_string())
192
192
  .unwrap_or_else(|| window.clone());
193
+ // Issue 2 (Round 3b gate review §6): pass the explicit team_key so the
194
+ // worker MCP env carries it through restart-agent path too. The
195
+ // `restart_projection_team_key` helper consolidates the same resolution
196
+ // used for save_restart_projected_state below.
197
+ let resolved_team_key = restart_projection_team_key(&state, team);
193
198
  let spawn = spawn_agent_window(
194
199
  workspace,
195
200
  &session_name,
@@ -201,6 +206,7 @@ pub(crate) fn start_agent_at_paths(
201
206
  Some(&safety),
202
207
  layout_placement.as_ref(),
203
208
  None,
209
+ Some(resolved_team_key.as_str()),
204
210
  )?;
205
211
  mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety)?;
206
212
  // **0.3.24 add-agent socket drift fix**: keep `state.tmux_endpoint` /
@@ -625,6 +631,16 @@ fn mark_agent_started(
625
631
  "spawn_cwd".to_string(),
626
632
  serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
627
633
  );
634
+ // Issue 2 (Round 3b gate review §6): persist the resolved owner_team_id
635
+ // so future restart/start cycles read it directly from the agent row.
636
+ if let Some(ref team_id) = spawn.owner_team_id {
637
+ if !team_id.is_empty() {
638
+ agent.insert(
639
+ "owner_team_id".to_string(),
640
+ serde_json::json!(team_id),
641
+ );
642
+ }
643
+ }
628
644
  crate::lifecycle::launch::persist_command_plan_state(
629
645
  agent,
630
646
  &spawn.plan,
@@ -889,6 +905,7 @@ fn write_start_agent_start_event(
889
905
  model: command_model,
890
906
  tools: &resolved_tool_refs,
891
907
  profile_launch: Some(&profile_launch),
908
+ agent_id_hint: Some(agent_id.as_str()),
892
909
  };
893
910
  let mut plan = match session_id {
894
911
  Some(session_id) => adapter
@@ -6,8 +6,15 @@ pub(super) struct SpawnedAgentWindow {
6
6
  pub profile_launch: crate::provider::ProviderProfileLaunch,
7
7
  pub layout_placement: Option<crate::lifecycle::launch::LayoutPlacement>,
8
8
  pub spawn_cwd: std::path::PathBuf,
9
+ /// Issue 2 (Round 3b gate review §6): the resolved `owner_team_id` used
10
+ /// for this spawn's MCP env / command. Callers (`mark_agent_respawned`,
11
+ /// `mark_agent_started`) must persist this back into the agent row so
12
+ /// future restarts read it directly (priority #2 in the resolution
13
+ /// cascade) instead of relying on top-level `active_team_key`.
14
+ pub owner_team_id: Option<String>,
9
15
  }
10
16
 
17
+ #[allow(clippy::too_many_arguments)]
11
18
  pub(super) fn spawn_agent_window(
12
19
  workspace: &Path,
13
20
  session_name: &SessionName,
@@ -19,6 +26,14 @@ pub(super) fn spawn_agent_window(
19
26
  safety: Option<&DangerousApproval>,
20
27
  layout_placement: Option<&crate::lifecycle::launch::LayoutPlacement>,
21
28
  spawn_cwd_override: Option<&Path>,
29
+ // Issue 2 (Round 3b gate review §6): explicit owner_team_id override.
30
+ // When `Some`, callers (restart/rebuild.rs, restart/agent.rs) thread the
31
+ // resolved `selected.team_key` through here so the worker's MCP env /
32
+ // command argv carries `TEAM_AGENT_OWNER_TEAM_ID=<selected team>` —
33
+ // even when the persisted agent row OR the top-level `active_team_key`
34
+ // is stale. When `None`, falls back to the legacy resolution
35
+ // (agent row → active_team_key) for back-compat with non-restart callers.
36
+ owner_team_id_override: Option<&str>,
22
37
  ) -> Result<SpawnedAgentWindow, LifecycleError> {
23
38
  let provider = agent_provider(agent);
24
39
  let auth_mode = agent_auth_mode(agent);
@@ -52,15 +67,26 @@ pub(super) fn spawn_agent_window(
52
67
  safety,
53
68
  )?;
54
69
  let resolved_tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
55
- // owner_team_id resolution: prefer the runtime-state row's `owner_team_id` (set by
56
- // launch/restart); fall back to the active team key for paths that don't write the
57
- // row first (e.g. add-agent calls spawn before upserting team metadata).
70
+ // owner_team_id resolution priority (Issue 2 fix):
71
+ // 1. caller's explicit override (restart paths pass `selected.team_key`)
72
+ // 2. agent row's persisted `owner_team_id` (set by prior launch/restart)
73
+ // 3. top-level `active_team_key` (legacy fallback for add-agent etc.)
74
+ // The override breaks the dependency on top-level state mutation: even if
75
+ // top-level `active_team_key` is stale (e.g. `ta-probe-ws`), a restart that
76
+ // resolved `selected.team_key=prerelease-040-round3b` propagates THAT team
77
+ // into the worker's MCP env.
58
78
  let state_for_team =
59
79
  crate::state::persist::load_runtime_state(workspace).unwrap_or(serde_json::json!({}));
60
- let team_id = agent
61
- .get("owner_team_id")
62
- .and_then(|v| v.as_str())
80
+ let team_id = owner_team_id_override
81
+ .filter(|s| !s.is_empty())
63
82
  .map(str::to_string)
83
+ .or_else(|| {
84
+ agent
85
+ .get("owner_team_id")
86
+ .and_then(|v| v.as_str())
87
+ .filter(|s| !s.is_empty())
88
+ .map(str::to_string)
89
+ })
64
90
  .or_else(|| {
65
91
  let key =
66
92
  crate::messaging::leader_receiver::active_team_key(workspace, &state_for_team);
@@ -95,6 +121,7 @@ pub(super) fn spawn_agent_window(
95
121
  model: command_model,
96
122
  tools: &resolved_tool_refs,
97
123
  profile_launch: Some(&profile_launch),
124
+ agent_id_hint: Some(agent_id.as_str()),
98
125
  };
99
126
  let mut plan = match resume_session_id {
100
127
  Some(session_id) => adapter
@@ -240,6 +267,7 @@ pub(super) fn spawn_agent_window(
240
267
  profile_launch,
241
268
  layout_placement: layout_placement.cloned(),
242
269
  spawn_cwd: spawn_cwd.to_path_buf(),
270
+ owner_team_id: team_id,
243
271
  })
244
272
  }
245
273
 
@@ -468,22 +496,95 @@ pub(super) fn resume_backing_exists_for_agent(
468
496
  session_id: &SessionId,
469
497
  rollout_path: Option<&RolloutPath>,
470
498
  ) -> bool {
471
- match provider {
499
+ resume_backing_probe_for_agent(
500
+ workspace,
501
+ agent_id,
502
+ agent,
503
+ provider,
504
+ session_id,
505
+ rollout_path,
506
+ )
507
+ .exists
508
+ }
509
+
510
+ /// Layer 2 self-healing (leader follow-up 2026-06-22): result of probing
511
+ /// the provider backing store for a resumable session. `checked_paths`
512
+ /// reports every path the runtime probed so the operator can see WHICH
513
+ /// places we looked — surfaced into the
514
+ /// `ResumeRefusalReason::SessionBackingStoreMissing.checked_paths`
515
+ /// field, the CLI JSON `unresumable[].checked_paths` array, and the
516
+ /// `restart.resume_decision` event payload.
517
+ #[derive(Debug, Clone, PartialEq, Eq)]
518
+ pub(super) struct BackingProbeResult {
519
+ pub exists: bool,
520
+ pub checked_paths: Vec<PathBuf>,
521
+ }
522
+
523
+ pub(super) fn resume_backing_probe_for_agent(
524
+ workspace: &Path,
525
+ agent_id: &AgentId,
526
+ agent: &serde_json::Value,
527
+ provider: Provider,
528
+ session_id: &SessionId,
529
+ rollout_path: Option<&RolloutPath>,
530
+ ) -> BackingProbeResult {
531
+ let mut checked_paths: Vec<PathBuf> = Vec::new();
532
+
533
+ // Always record the persisted rollout_path even when it does not
534
+ // exist — that "we looked here" tells the operator that state has a
535
+ // pointer but the file is gone.
536
+ if let Some(path) = rollout_path.map(RolloutPath::as_path) {
537
+ checked_paths.push(path.to_path_buf());
538
+ }
539
+
540
+ let exists = match provider {
472
541
  provider if !provider_supports_resume(provider) => {
473
542
  let _ = (workspace, agent_id, agent, session_id, rollout_path);
474
543
  false
475
544
  }
476
545
  Provider::Codex => {
477
- rollout_path_exists(rollout_path)
478
- || codex_session_transcript_exists(agent, session_id.as_str(), rollout_path)
546
+ let rollout_ok = rollout_path_exists(rollout_path);
547
+ let scan_roots = codex_session_transcript_scan_roots(agent, rollout_path);
548
+ for root in &scan_roots {
549
+ checked_paths.push(root.clone());
550
+ }
551
+ rollout_ok
552
+ || codex_session_transcript_exists_with_roots(
553
+ session_id.as_str(),
554
+ &scan_roots,
555
+ )
479
556
  }
480
557
  Provider::Claude | Provider::ClaudeCode => {
481
- rollout_path_exists(rollout_path)
558
+ let rollout_ok = rollout_path_exists(rollout_path);
559
+ let projects_root = claude_projects_root_for_agent(agent);
560
+ if let Some(root) = projects_root.as_ref() {
561
+ checked_paths.push(root.clone());
562
+ }
563
+ let event_log_path = workspace.join(".team/logs/events.jsonl");
564
+ checked_paths.push(event_log_path);
565
+ rollout_ok
482
566
  || event_log_transcript_exists(workspace, agent_id.as_str(), session_id.as_str())
483
- || claude_project_transcript_exists(agent, session_id.as_str())
567
+ || projects_root.is_some_and(|root| {
568
+ claude_project_transcript_exists_under(&root, session_id.as_str())
569
+ })
570
+ }
571
+ Provider::Copilot => {
572
+ if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
573
+ checked_paths.push(home.join(".copilot/session-store.db"));
574
+ }
575
+ copilot_session_store_has_session(session_id.as_str())
484
576
  }
485
- Provider::Copilot => copilot_session_store_has_session(session_id.as_str()),
486
577
  Provider::GeminiCli | Provider::Fake => false,
578
+ };
579
+
580
+ // Deduplicate while preserving order (HashSet would lose deterministic
581
+ // ordering needed for stable JSON/event output).
582
+ let mut seen: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
583
+ checked_paths.retain(|p| seen.insert(p.clone()));
584
+
585
+ BackingProbeResult {
586
+ exists,
587
+ checked_paths,
487
588
  }
488
589
  }
489
590
 
@@ -535,25 +636,32 @@ fn event_transcript_path(event: &serde_json::Value) -> Option<PathBuf> {
535
636
  /// so we avoid recomputing the project-dir slug, which is brittle for non-ASCII
536
637
  /// workspace paths).
537
638
  fn claude_project_transcript_exists(agent: &serde_json::Value, session_id: &str) -> bool {
538
- if session_id.is_empty() {
639
+ let Some(root) = claude_projects_root_for_agent(agent) else {
539
640
  return false;
540
- }
541
- let projects_root = agent
641
+ };
642
+ claude_project_transcript_exists_under(&root, session_id)
643
+ }
644
+
645
+ fn claude_projects_root_for_agent(agent: &serde_json::Value) -> Option<PathBuf> {
646
+ agent
542
647
  .get("claude_projects_root")
543
648
  .and_then(serde_json::Value::as_str)
544
649
  .filter(|value| !value.is_empty())
545
650
  .map(PathBuf::from)
546
651
  .or_else(|| {
547
652
  std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude").join("projects"))
548
- });
549
- let Some(projects_root) = projects_root else {
653
+ })
654
+ }
655
+
656
+ fn claude_project_transcript_exists_under(projects_root: &Path, session_id: &str) -> bool {
657
+ if session_id.is_empty() {
550
658
  return false;
551
- };
659
+ }
552
660
  if !projects_root.is_dir() {
553
661
  return false;
554
662
  }
555
663
  let transcript_name = format!("{session_id}.jsonl");
556
- let Ok(project_dirs) = std::fs::read_dir(&projects_root) else {
664
+ let Ok(project_dirs) = std::fs::read_dir(projects_root) else {
557
665
  return false;
558
666
  };
559
667
  project_dirs
@@ -567,10 +675,15 @@ fn codex_session_transcript_exists(
567
675
  session_id: &str,
568
676
  rollout_path: Option<&RolloutPath>,
569
677
  ) -> bool {
570
- if session_id.is_empty() {
571
- return false;
572
- }
573
- let mut roots = Vec::new();
678
+ let roots = codex_session_transcript_scan_roots(agent, rollout_path);
679
+ codex_session_transcript_exists_with_roots(session_id, &roots)
680
+ }
681
+
682
+ fn codex_session_transcript_scan_roots(
683
+ agent: &serde_json::Value,
684
+ rollout_path: Option<&RolloutPath>,
685
+ ) -> Vec<PathBuf> {
686
+ let mut roots: Vec<PathBuf> = Vec::new();
574
687
  if let Some(parent) = rollout_path
575
688
  .map(RolloutPath::as_path)
576
689
  .and_then(Path::parent)
@@ -596,6 +709,13 @@ fn codex_session_transcript_exists(
596
709
  }
597
710
  roots.sort();
598
711
  roots.dedup();
712
+ roots
713
+ }
714
+
715
+ fn codex_session_transcript_exists_with_roots(session_id: &str, roots: &[PathBuf]) -> bool {
716
+ if session_id.is_empty() {
717
+ return false;
718
+ }
599
719
  roots
600
720
  .iter()
601
721
  .any(|root| session_transcript_exists_under(root, session_id, 4))
@@ -0,0 +1,87 @@
1
+ //! unit-3 (Stage 1) — restart preflight session-identity guard.
2
+ //!
3
+ //! Single check that runs BEFORE the worker-session teardown in
4
+ //! `rebuild::restart_inner` (and the parallel check in
5
+ //! `common::session_name_present` callers). The guard's purpose is to
6
+ //! REFUSE the kill when the value in `state.session_name` is actually a
7
+ //! leader launcher session name (the 0.3.39 leader-mis-kill bug shape).
8
+ //!
9
+ //! Typed identity comes from unit-1's `RuntimeSessions::from_state`. This
10
+ //! preflight is the first adoption site of that typed reader inside the
11
+ //! restart code path.
12
+
13
+ use crate::layout::{RuntimeSessionAnomaly, RuntimeSessions};
14
+
15
+ /// Outcome of the preflight session-identity check.
16
+ #[derive(Debug, Clone, PartialEq, Eq)]
17
+ pub enum SessionPreflight {
18
+ /// State's session-name fields are well-formed. The caller may proceed
19
+ /// with the worker-session teardown.
20
+ Ok,
21
+ /// `state.session_name` matches the leader launcher prefix. Refuse the
22
+ /// kill — proceeding would tear down the leader pane (E49 / 0.3.39).
23
+ WorkerSessionIsLeaderSession {
24
+ session_name: String,
25
+ reason: String,
26
+ },
27
+ }
28
+
29
+ impl SessionPreflight {
30
+ /// True when the preflight passed and the caller may proceed.
31
+ pub fn is_ok(&self) -> bool {
32
+ matches!(self, SessionPreflight::Ok)
33
+ }
34
+ }
35
+
36
+ /// Inspect `state.json` and decide whether the upcoming worker-session
37
+ /// kill is safe. Pure: no I/O, no mutation.
38
+ pub fn check_session_preflight(state: &serde_json::Value) -> SessionPreflight {
39
+ let sessions = RuntimeSessions::from_state(state);
40
+ if let Some(anomaly) = sessions
41
+ .anomalies
42
+ .iter()
43
+ .find(|a| matches!(a, RuntimeSessionAnomaly::WorkerSessionNameIsLeaderPrefixed { .. }))
44
+ {
45
+ if let RuntimeSessionAnomaly::WorkerSessionNameIsLeaderPrefixed { value } = anomaly {
46
+ return SessionPreflight::WorkerSessionIsLeaderSession {
47
+ session_name: value.clone(),
48
+ reason: "worker_session_is_leader_session".to_string(),
49
+ };
50
+ }
51
+ }
52
+ SessionPreflight::Ok
53
+ }
54
+
55
+ #[cfg(test)]
56
+ mod tests {
57
+ use super::*;
58
+ use serde_json::json;
59
+
60
+ #[test]
61
+ fn preflight_passes_clean_state() {
62
+ let s = json!({ "session_name": "team-foo" });
63
+ assert!(check_session_preflight(&s).is_ok());
64
+ }
65
+
66
+ #[test]
67
+ fn preflight_passes_empty_state() {
68
+ assert!(check_session_preflight(&json!({})).is_ok());
69
+ }
70
+
71
+ #[test]
72
+ fn preflight_refuses_leader_prefixed_worker_session_name() {
73
+ let s = json!({ "session_name": "team-agent-leader-claude-abc" });
74
+ let r = check_session_preflight(&s);
75
+ assert!(!r.is_ok());
76
+ match r {
77
+ SessionPreflight::WorkerSessionIsLeaderSession {
78
+ session_name,
79
+ reason,
80
+ } => {
81
+ assert_eq!(session_name, "team-agent-leader-claude-abc");
82
+ assert_eq!(reason, "worker_session_is_leader_session");
83
+ }
84
+ _ => panic!("expected WorkerSessionIsLeaderSession refusal"),
85
+ }
86
+ }
87
+ }