@team-agent/installer 0.4.10 → 0.5.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 (74) 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 +62 -4
  6. package/crates/team-agent/src/compiler.rs +11 -20
  7. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  8. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  9. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  10. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  11. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  12. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  13. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  14. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  15. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  16. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  17. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  18. package/crates/team-agent/src/leader/start.rs +169 -25
  19. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  20. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  21. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  22. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  23. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  24. package/crates/team-agent/src/lifecycle/restart/agent.rs +436 -121
  25. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  26. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  27. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  28. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  29. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +193 -0
  30. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  31. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  32. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +54 -15
  33. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  34. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +648 -0
  35. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  36. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  37. package/crates/team-agent/src/lifecycle/worker_command_context.rs +24 -34
  38. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  39. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  40. package/crates/team-agent/src/messaging/activity.rs +43 -18
  41. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  42. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  43. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  44. package/crates/team-agent/src/messaging/results.rs +34 -9
  45. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  46. package/crates/team-agent/src/model/spec.rs +10 -6
  47. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  48. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  49. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  50. package/crates/team-agent/src/provider/classify.rs +23 -48
  51. package/crates/team-agent/src/provider/command.rs +16 -7
  52. package/crates/team-agent/src/provider/faults.rs +93 -53
  53. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  54. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  55. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  56. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  57. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  58. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  59. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  60. package/crates/team-agent/src/provider/types.rs +33 -1
  61. package/crates/team-agent/src/provider/wire.rs +171 -1
  62. package/crates/team-agent/src/state/identity.rs +242 -57
  63. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  64. package/crates/team-agent/src/state/mod.rs +5 -1
  65. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  66. package/crates/team-agent/src/state/ownership.rs +12 -11
  67. package/crates/team-agent/src/state/paths.rs +13 -6
  68. package/crates/team-agent/src/state/persist.rs +671 -128
  69. package/crates/team-agent/src/state/projection.rs +240 -49
  70. package/crates/team-agent/src/state/selector.rs +11 -5
  71. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  72. package/crates/team-agent/src/tmux_backend.rs +42 -5
  73. package/crates/team-agent/src/transport/test_support.rs +55 -6
  74. package/package.json +4 -4
@@ -503,20 +503,43 @@ fn is_provider_or_mcp_workspace_command(command: &str) -> bool {
503
503
  }
504
504
 
505
505
  fn parse_workspace_arg(command: &str) -> Option<PathBuf> {
506
- let mut parts = command.split_whitespace().peekable();
507
- while let Some(part) = parts.next() {
508
- if let Some(value) = part.strip_prefix("--workspace=") {
509
- if !value.is_empty() {
510
- return Some(PathBuf::from(value));
511
- }
506
+ for (start, _) in command.match_indices("--workspace") {
507
+ let boundary_before = start == 0
508
+ || command
509
+ .get(..start)
510
+ .and_then(|prefix| prefix.chars().next_back())
511
+ .is_some_and(char::is_whitespace);
512
+ if !boundary_before {
513
+ continue;
514
+ }
515
+ let after = &command[start + "--workspace".len()..];
516
+ if let Some(value) = after.strip_prefix('=') {
517
+ return parse_workspace_value(value);
512
518
  }
513
- if part == "--workspace" {
514
- return parts.peek().map(PathBuf::from);
519
+ if after.chars().next().is_some_and(char::is_whitespace) {
520
+ return parse_workspace_value(after);
515
521
  }
516
522
  }
517
523
  None
518
524
  }
519
525
 
526
+ fn parse_workspace_value(value: &str) -> Option<PathBuf> {
527
+ let value = value.trim_start();
528
+ if value.is_empty() {
529
+ return None;
530
+ }
531
+ let value = value
532
+ .split_once(" --")
533
+ .map(|(workspace, _)| workspace)
534
+ .unwrap_or(value)
535
+ .trim_end();
536
+ if value.is_empty() {
537
+ None
538
+ } else {
539
+ Some(PathBuf::from(value))
540
+ }
541
+ }
542
+
520
543
  fn classify_workspace_orphan(workspace: &Path, pid: Pid) -> Option<OrphanReason> {
521
544
  if !workspace.is_absolute() {
522
545
  return None;
@@ -656,3 +679,38 @@ fn reason_key(reason: &OrphanReason) -> &'static str {
656
679
  OrphanReason::PidNotRunning => "pid_not_running",
657
680
  }
658
681
  }
682
+
683
+ #[cfg(test)]
684
+ mod tests {
685
+ use super::*;
686
+
687
+ #[test]
688
+ fn parse_workspace_arg_keeps_trailing_path_with_spaces() {
689
+ let command = "/tmp/team-agent coordinator --workspace /Volumes/projects/My Agent";
690
+
691
+ assert_eq!(
692
+ parse_workspace_arg(command),
693
+ Some(PathBuf::from("/Volumes/projects/My Agent"))
694
+ );
695
+ }
696
+
697
+ #[test]
698
+ fn parse_workspace_arg_stops_at_next_flag() {
699
+ let command = "/tmp/team-agent coordinator --workspace /tmp/My Agent --once";
700
+
701
+ assert_eq!(
702
+ parse_workspace_arg(command),
703
+ Some(PathBuf::from("/tmp/My Agent"))
704
+ );
705
+ }
706
+
707
+ #[test]
708
+ fn parse_workspace_arg_supports_equals_form_with_spaces() {
709
+ let command = "/tmp/team-agent coordinator --workspace=/tmp/My Agent --once";
710
+
711
+ assert_eq!(
712
+ parse_workspace_arg(command),
713
+ Some(PathBuf::from("/tmp/My Agent"))
714
+ );
715
+ }
716
+ }
@@ -1,14 +1,12 @@
1
- //! 0.3.28 layout step 2 — small provider-wire helper for layout-layer window naming.
1
+ //! 0.3.28 layout step 2 — small window grammar helper for layout-layer naming.
2
2
  //!
3
- //! Mirrors `leader::helpers::provider_wire` so the layout module does not
4
- //! depend on the leader module's private helpers. Single source of truth
5
- //! is `provider_command_name` from `crate::provider::adapter` (the
6
- //! authoritative provider→string mapping).
3
+ //! Command/UI grammar, not provider identity parsing: leader window names
4
+ //! intentionally collapse `claude_code` to `claude` and use executable-style
5
+ //! labels such as `gemini`.
7
6
 
8
7
  use crate::model::enums::Provider;
9
8
 
10
- /// Window name used inside the leader session, derived from the provider's
11
- /// wire identifier (e.g. `claude`, `codex`, `copilot`, `fake`). Mirrors
9
+ /// Window name used inside the leader session. Mirrors
12
10
  /// Python's `leader_window_name = provider` (see
13
11
  /// `runtime/0.2.11/src/team_agent/leader/__init__.py:114-131`).
14
12
  pub fn provider_window_name(provider: Provider) -> &'static str {
@@ -8,7 +8,9 @@ use crate::transport::PaneInfo;
8
8
 
9
9
  use super::helpers::{parse_provider, provider_wire};
10
10
 
11
- const PROVIDER_COMMANDS: &[(&str, Provider)] = &[
11
+ // Command grammar, not provider identity parsing: these are process argv/current-command
12
+ // needles observed in tmux panes.
13
+ const COMMAND_GRAMMAR_PROVIDER_COMMANDS: &[(&str, Provider)] = &[
12
14
  ("claude", Provider::ClaudeCode),
13
15
  ("claude.exe", Provider::ClaudeCode),
14
16
  ("codex", Provider::Codex),
@@ -37,7 +39,7 @@ fn attribute_pane_provider_with_process(
37
39
  }
38
40
 
39
41
  pub(crate) fn attribute_command_provider(command: &str) -> Option<Provider> {
40
- provider_from_text(command)
42
+ provider_from_command_text(command)
41
43
  }
42
44
 
43
45
  pub(crate) fn command_matches_provider(provider: Provider, command: &str) -> bool {
@@ -58,7 +60,7 @@ fn provider_from_env(env: &BTreeMap<String, String>) -> Option<Provider> {
58
60
  fn provider_from_pid_argv(pid: u32) -> Option<Provider> {
59
61
  process_command_line(pid)
60
62
  .as_deref()
61
- .and_then(provider_from_text)
63
+ .and_then(provider_from_command_text)
62
64
  }
63
65
 
64
66
  fn provider_from_pid_env(pid: u32) -> Option<Provider> {
@@ -67,9 +69,9 @@ fn provider_from_pid_env(pid: u32) -> Option<Provider> {
67
69
  .and_then(provider_from_env_text)
68
70
  }
69
71
 
70
- fn provider_from_text(text: &str) -> Option<Provider> {
72
+ fn provider_from_command_text(text: &str) -> Option<Provider> {
71
73
  let lower = text.to_ascii_lowercase();
72
- PROVIDER_COMMANDS
74
+ COMMAND_GRAMMAR_PROVIDER_COMMANDS
73
75
  .iter()
74
76
  .find_map(|(needle, provider)| lower.contains(needle).then_some(*provider))
75
77
  }
@@ -7,6 +7,7 @@ use serde_json::{json, Value};
7
7
 
8
8
  use crate::model::ids::{LeaderSessionUuid, OwnerEpoch};
9
9
  use crate::provider::Provider;
10
+ use crate::provider::wire::provider_wire;
10
11
  use crate::transport::{PaneId, PaneInfo, SessionName, Transport, WindowName};
11
12
 
12
13
  use super::helpers::{get_path_str, now_ts, parse_provider, prefix, resolve_workspace_for_hash, sha1_hex_prefix};
@@ -902,17 +903,6 @@ fn uuid_prefix(uuid: Option<&LeaderSessionUuid>) -> String {
902
903
  uuid.map_or_else(String::new, |value| prefix(value.as_str(), 8).to_string())
903
904
  }
904
905
 
905
- fn provider_wire(provider: Provider) -> &'static str {
906
- match provider {
907
- Provider::Claude => "claude",
908
- Provider::ClaudeCode => "claude_code",
909
- Provider::Codex => "codex",
910
- Provider::Copilot => "copilot",
911
- Provider::GeminiCli => "gemini_cli",
912
- Provider::Fake => "fake",
913
- }
914
- }
915
-
916
906
  fn event_provider(identity: &OwnerIdentity) -> &'static str {
917
907
  provider_wire(identity.provider.unwrap_or(Provider::Codex))
918
908
  }
@@ -61,12 +61,20 @@ pub fn leader_start_plan(
61
61
  // `managed_team_session_name(identity) = team-<team_id>` which is the
62
62
  // worker session — that co-location is the structural root of
63
63
  // E49/E51/E53/E57-3/E60.
64
+ // 0.4.10+ mirror-session fix v2 (option B): managed-mode session name
65
+ // gets a per-launch nonce so each `team-agent <provider>` entry in the
66
+ // same workspace creates its OWN tmux session — matching the user
67
+ // expectation that `tmux new-session + claude` is independent every
68
+ // time. Pre-fix the managed path used the workspace-keyed name (same
69
+ // session for every entry) and silently attached the second client →
70
+ // UI mirror. The external/attach paths keep the stable workspace-keyed
71
+ // name (per-launch nonce would break `--attach-session` reattach).
64
72
  let session_name = if external_path {
65
73
  attach_session
66
74
  .cloned()
67
75
  .or_else(|| Some(leader_session_name(provider, workspace)))
68
76
  } else {
69
- Some(leader_session_name(provider, workspace))
77
+ Some(managed_leader_session_name(provider, workspace))
70
78
  };
71
79
  let in_tmux = std::env::var_os("TMUX").is_some();
72
80
  if !in_tmux {
@@ -266,6 +274,42 @@ pub fn leader_session_name(provider: Provider, workspace: &Path) -> SessionName
266
274
  ))
267
275
  }
268
276
 
277
+ /// 0.4.10+ mirror-session fix v2: managed-mode session name with a
278
+ /// per-launch nonce.
279
+ ///
280
+ /// Format: `team-agent-leader-<provider>-<folder>-<hash>-<nonce>`.
281
+ /// `nonce` = `<pid_hex>-<epoch_nanos_hex>`. The pid distinguishes
282
+ /// concurrent launches; the epoch_nanos distinguishes sequential ones.
283
+ ///
284
+ /// Each `team-agent <provider>` entry in the managed (non-tmux) path gets
285
+ /// its OWN session, matching the user expectation that `tmux new-session
286
+ /// + claude` is independent every time. The workspace-keyed prefix is
287
+ /// preserved so existing leader-session-anchored protections
288
+ /// (`LEADER_SESSION_PREFIX` matchers in shutdown/cli/mod.rs) still
289
+ /// classify these sessions as leader sessions.
290
+ ///
291
+ /// External / attach paths keep the stable `leader_session_name` —
292
+ /// per-launch nonce would break `--attach-session <name>` reattach
293
+ /// semantics.
294
+ pub fn managed_leader_session_name(provider: Provider, workspace: &Path) -> SessionName {
295
+ let resolved = resolve_workspace_for_hash(workspace);
296
+ let folder_raw = resolved
297
+ .file_name()
298
+ .and_then(|n| n.to_str())
299
+ .unwrap_or("workspace");
300
+ let folder = sanitize_session_folder(folder_raw);
301
+ let hash = sha1_hex_prefix(resolved.to_string_lossy().as_bytes(), 8);
302
+ let pid = std::process::id();
303
+ let epoch_nanos = std::time::SystemTime::now()
304
+ .duration_since(std::time::UNIX_EPOCH)
305
+ .map(|d| d.as_nanos())
306
+ .unwrap_or(0);
307
+ SessionName::new(format!(
308
+ "{LEADER_SESSION_PREFIX}{}-{folder}-{hash}-{pid:x}-{epoch_nanos:x}",
309
+ provider_wire(provider)
310
+ ))
311
+ }
312
+
269
313
  fn start_argv(
270
314
  mode: LeaderStartMode,
271
315
  provider: Provider,
@@ -423,31 +467,14 @@ fn ensure_managed_leader_pane(
423
467
  plan: &LeaderStartPlan,
424
468
  workspace: &Path,
425
469
  ) -> Result<SpawnResult, LeaderError> {
470
+ // 0.4.10+ mirror-session fix v2 (option B): each managed launch gets
471
+ // its OWN session name (via `managed_leader_session_name`'s nonce).
472
+ // The historical "session already exists → reuse pane" branch is
473
+ // structurally unreachable now (the per-launch session never
474
+ // preexists). If a caller bypasses the nonce path and reuses a name,
475
+ // tmux's own duplicate-session error will surface — better than
476
+ // silently attaching a second client.
426
477
  if transport.has_session(session).unwrap_or(false) {
427
- if transport
428
- .list_windows(session)
429
- .unwrap_or_default()
430
- .iter()
431
- .any(|existing| existing.as_str() == window.as_str())
432
- {
433
- if let Some(existing) = transport
434
- .list_targets()
435
- .unwrap_or_default()
436
- .into_iter()
437
- .find(|pane| {
438
- pane.session.as_str() == session.as_str()
439
- && pane.window_name.as_ref().map(WindowName::as_str)
440
- == Some(window.as_str())
441
- })
442
- {
443
- return Ok(SpawnResult {
444
- pane_id: existing.pane_id,
445
- session: session.clone(),
446
- window: window.clone(),
447
- child_pid: existing.pane_pid,
448
- });
449
- }
450
- }
451
478
  // 0.4.x (CR C-1 + C-2): leader env_unset reuses the worker
452
479
  // provider_env_unsets (single source of truth) + spawn through the
453
480
  // leader shell wrapper so provider exit returns to a shell, not
@@ -1586,4 +1613,121 @@ mod tests {
1586
1613
  assert_eq!(sent[0].0, Target::Pane(PaneId::new("%0")));
1587
1614
  assert_eq!(sent[0].1, vec![Key::Enter]);
1588
1615
  }
1616
+
1617
+ // ═══════════════════════════════════════════════════════════════════
1618
+ // 0.4.10+ mirror-session fix v2 (option B: independent session per launch).
1619
+ //
1620
+ // managed_leader_session_name appends a per-launch nonce so each
1621
+ // `team-agent <provider>` entry in the same workspace creates its own
1622
+ // tmux session, matching `tmux new-session + claude` semantics.
1623
+ // ═══════════════════════════════════════════════════════════════════
1624
+
1625
+ #[test]
1626
+ fn managed_leader_session_name_carries_leader_prefix() {
1627
+ // Protection invariant: every session name returned by the
1628
+ // managed path must still match LEADER_SESSION_PREFIX so the
1629
+ // shutdown / cli/mod.rs prefix matchers still classify it as a
1630
+ // leader session.
1631
+ let workspace = std::env::temp_dir().join(format!(
1632
+ "ta_rs_mgr_prefix_{}",
1633
+ std::process::id()
1634
+ ));
1635
+ let _ = std::fs::create_dir_all(&workspace);
1636
+ let name = super::managed_leader_session_name(Provider::ClaudeCode, &workspace);
1637
+ assert!(
1638
+ name.as_str().starts_with(super::super::LEADER_SESSION_PREFIX),
1639
+ "managed session name must carry LEADER_SESSION_PREFIX (`{}`); \
1640
+ got `{}`",
1641
+ super::super::LEADER_SESSION_PREFIX,
1642
+ name.as_str()
1643
+ );
1644
+ assert!(
1645
+ name.as_str().contains("claude_code"),
1646
+ "managed session name must include provider wire; got `{}`",
1647
+ name.as_str()
1648
+ );
1649
+ let _ = std::fs::remove_dir_all(&workspace);
1650
+ }
1651
+
1652
+ #[test]
1653
+ fn managed_leader_session_name_is_unique_per_call() {
1654
+ // Two consecutive calls must return DIFFERENT names — the
1655
+ // per-launch nonce defeats the mirror-session bug.
1656
+ let workspace = std::env::temp_dir().join(format!(
1657
+ "ta_rs_mgr_unique_{}",
1658
+ std::process::id()
1659
+ ));
1660
+ let _ = std::fs::create_dir_all(&workspace);
1661
+ let a = super::managed_leader_session_name(Provider::ClaudeCode, &workspace);
1662
+ // Sleep > 1ns to ensure epoch_nanos advances even on coarse clocks.
1663
+ std::thread::sleep(std::time::Duration::from_millis(1));
1664
+ let b = super::managed_leader_session_name(Provider::ClaudeCode, &workspace);
1665
+ assert_ne!(
1666
+ a.as_str(),
1667
+ b.as_str(),
1668
+ "two managed launches in the same workspace must get \
1669
+ DIFFERENT session names (mirror-session fix v2); got \
1670
+ `{}` vs `{}`",
1671
+ a.as_str(),
1672
+ b.as_str()
1673
+ );
1674
+ let _ = std::fs::remove_dir_all(&workspace);
1675
+ }
1676
+
1677
+ #[test]
1678
+ fn leader_session_name_is_stable_across_calls() {
1679
+ // External / attach paths must keep the stable workspace-keyed
1680
+ // name — `--attach-session <name>` reattach would break if the
1681
+ // name varied per call.
1682
+ let workspace = std::env::temp_dir().join(format!(
1683
+ "ta_rs_lsn_stable_{}",
1684
+ std::process::id()
1685
+ ));
1686
+ let _ = std::fs::create_dir_all(&workspace);
1687
+ let a = super::leader_session_name(Provider::ClaudeCode, &workspace);
1688
+ let b = super::leader_session_name(Provider::ClaudeCode, &workspace);
1689
+ assert_eq!(
1690
+ a.as_str(),
1691
+ b.as_str(),
1692
+ "leader_session_name must be deterministic per workspace \
1693
+ (external/attach paths depend on stability); got `{}` vs `{}`",
1694
+ a.as_str(),
1695
+ b.as_str()
1696
+ );
1697
+ let _ = std::fs::remove_dir_all(&workspace);
1698
+ }
1699
+
1700
+ /// CR C-5 source grep guard: managed mode passes the nonce session
1701
+ /// name through `leader_start_plan` so the per-launch independence
1702
+ /// reaches `ensure_managed_leader_pane`. External/attach paths must
1703
+ /// keep `leader_session_name`.
1704
+ #[test]
1705
+ fn managed_path_uses_nonce_session_name_grep_guard() {
1706
+ let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1707
+ let start_rs = manifest.join("src").join("leader").join("start.rs");
1708
+ let contents =
1709
+ std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
1710
+ // leader_start_plan must dispatch to managed_leader_session_name
1711
+ // on the managed path.
1712
+ let start = contents
1713
+ .find("pub fn leader_start_plan(")
1714
+ .expect("leader_start_plan must exist");
1715
+ let end = contents[start + 1..]
1716
+ .find("\npub fn ")
1717
+ .or_else(|| contents[start + 1..].find("\nfn "))
1718
+ .map(|p| start + 1 + p)
1719
+ .unwrap_or(contents.len());
1720
+ let body = &contents[start..end];
1721
+ assert!(
1722
+ body.contains("managed_leader_session_name(provider, workspace)"),
1723
+ "leader_start_plan must call managed_leader_session_name on \
1724
+ the managed path (mirror-session fix v2); body excerpt: {body}"
1725
+ );
1726
+ // The external_path branch must still use the stable name.
1727
+ assert!(
1728
+ body.contains("leader_session_name(provider, workspace)"),
1729
+ "leader_start_plan must keep leader_session_name on the \
1730
+ external/attach paths; body excerpt: {body}"
1731
+ );
1732
+ }
1589
1733
  }
@@ -15,9 +15,8 @@ pub fn start_plan(
15
15
  plan_path.display()
16
16
  )));
17
17
  }
18
- let text = std::fs::read_to_string(plan_path).map_err(|e| {
19
- LifecycleError::InvalidPlan(format!("{}: {e}", plan_path.display()))
20
- })?;
18
+ let text = std::fs::read_to_string(plan_path)
19
+ .map_err(|e| LifecycleError::InvalidPlan(format!("{}: {e}", plan_path.display())))?;
21
20
  let plan = yaml::loads(&text).map_err(|e| LifecycleError::InvalidPlan(e.to_string()))?;
22
21
  let plan_id = plan_id_from_plan(plan_path, &plan)?;
23
22
  let state_path = plan_state_path(workspace, &plan_id);
@@ -66,7 +65,8 @@ pub fn handle_report_result(
66
65
  return Ok(PlanProgress::NoMatch);
67
66
  };
68
67
  for entry in entries {
69
- let entry = entry.map_err(|e| LifecycleError::InvalidPlan(format!("read plan dir: {e}")))?;
68
+ let entry =
69
+ entry.map_err(|e| LifecycleError::InvalidPlan(format!("read plan dir: {e}")))?;
70
70
  let path = entry.path();
71
71
  if path.extension().and_then(|s| s.to_str()) != Some("json") {
72
72
  continue;
@@ -112,10 +112,19 @@ fn plan_stages(plan: &Value) -> Result<Vec<PlanStage>, LifecycleError> {
112
112
  .transpose()?;
113
113
  stages.push(PlanStage {
114
114
  id,
115
- assignee: raw.get("assignee").and_then(Value::as_str).map(AgentId::new),
116
- prompt: raw.get("prompt").and_then(Value::as_str).map(str::to_string),
115
+ assignee: raw
116
+ .get("assignee")
117
+ .and_then(Value::as_str)
118
+ .map(AgentId::new),
119
+ prompt: raw
120
+ .get("prompt")
121
+ .and_then(Value::as_str)
122
+ .map(str::to_string),
117
123
  on_result: condition,
118
- status: raw.get("status").and_then(Value::as_str).map(str::to_string),
124
+ status: raw
125
+ .get("status")
126
+ .and_then(Value::as_str)
127
+ .map(str::to_string),
119
128
  });
120
129
  }
121
130
  Ok(stages)
@@ -222,6 +231,8 @@ fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
222
231
  serde_json::Value::String(s) => Some(s.clone()),
223
232
  serde_json::Value::Bool(b) => Some(b.to_string()),
224
233
  serde_json::Value::Number(n) => Some(n.to_string()),
225
- serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => None,
234
+ serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
235
+ None
236
+ }
226
237
  }
227
238
  }