@team-agent/installer 0.3.1 → 0.3.3

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 (79) hide show
  1. package/Cargo.lock +34 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -1
  4. package/crates/team-agent/src/cli/adapters.rs +234 -26
  5. package/crates/team-agent/src/cli/diagnose.rs +144 -10
  6. package/crates/team-agent/src/cli/emit.rs +289 -54
  7. package/crates/team-agent/src/cli/leader.rs +37 -8
  8. package/crates/team-agent/src/cli/mod.rs +1281 -196
  9. package/crates/team-agent/src/cli/status_port.rs +195 -46
  10. package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
  11. package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
  12. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
  13. package/crates/team-agent/src/cli/tests/run_delegation.rs +59 -3
  14. package/crates/team-agent/src/cli/types.rs +18 -0
  15. package/crates/team-agent/src/compiler.rs +15 -5
  16. package/crates/team-agent/src/coordinator/health.rs +95 -17
  17. package/crates/team-agent/src/coordinator/mod.rs +4 -0
  18. package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
  19. package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
  20. package/crates/team-agent/src/coordinator/tick.rs +222 -69
  21. package/crates/team-agent/src/coordinator/types.rs +15 -3
  22. package/crates/team-agent/src/db/schema.rs +37 -2
  23. package/crates/team-agent/src/diagnose/comms.rs +226 -0
  24. package/crates/team-agent/src/diagnose/mod.rs +45 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +658 -0
  26. package/crates/team-agent/src/fake_worker.rs +146 -3
  27. package/crates/team-agent/src/leader/start.rs +121 -23
  28. package/crates/team-agent/src/leader/types.rs +44 -1
  29. package/crates/team-agent/src/lib.rs +3 -0
  30. package/crates/team-agent/src/lifecycle/display.rs +645 -47
  31. package/crates/team-agent/src/lifecycle/launch.rs +1061 -146
  32. package/crates/team-agent/src/lifecycle/mod.rs +2 -0
  33. package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
  34. package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
  35. package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
  36. package/crates/team-agent/src/lifecycle/restart/common.rs +183 -24
  37. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +498 -22
  38. package/crates/team-agent/src/lifecycle/restart/remove.rs +27 -7
  39. package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
  40. package/crates/team-agent/src/lifecycle/restart.rs +24 -1
  41. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
  43. package/crates/team-agent/src/lifecycle/types.rs +19 -0
  44. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  45. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
  46. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
  47. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
  48. package/crates/team-agent/src/mcp_server/mod.rs +3 -74
  49. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
  51. package/crates/team-agent/src/mcp_server/tools.rs +312 -111
  52. package/crates/team-agent/src/mcp_server/types.rs +6 -4
  53. package/crates/team-agent/src/mcp_server/wire.rs +19 -7
  54. package/crates/team-agent/src/message_store.rs +21 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +470 -59
  56. package/crates/team-agent/src/messaging/mod.rs +9 -6
  57. package/crates/team-agent/src/messaging/results.rs +353 -63
  58. package/crates/team-agent/src/messaging/selftest.rs +199 -12
  59. package/crates/team-agent/src/messaging/send.rs +35 -3
  60. package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
  61. package/crates/team-agent/src/messaging/types.rs +11 -3
  62. package/crates/team-agent/src/os_probe.rs +119 -0
  63. package/crates/team-agent/src/packaging/migrate.rs +10 -2
  64. package/crates/team-agent/src/packaging/tests.rs +23 -0
  65. package/crates/team-agent/src/provider/adapter.rs +564 -63
  66. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
  67. package/crates/team-agent/src/provider/classify.rs +51 -4
  68. package/crates/team-agent/src/provider/helpers.rs +10 -1
  69. package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
  70. package/crates/team-agent/src/provider/types.rs +47 -0
  71. package/crates/team-agent/src/session_capture.rs +616 -0
  72. package/crates/team-agent/src/state/persist.rs +170 -1
  73. package/crates/team-agent/src/state/projection.rs +141 -8
  74. package/crates/team-agent/src/state/selector.rs +5 -2
  75. package/crates/team-agent/src/tmux_backend.rs +161 -64
  76. package/crates/team-agent/src/transport/test_support.rs +9 -0
  77. package/crates/team-agent/src/transport/tests/wire.rs +4 -0
  78. package/crates/team-agent/src/transport.rs +13 -2
  79. package/package.json +4 -4
@@ -150,6 +150,14 @@ pub(crate) fn write_team_state(
150
150
  lines.push(format!("- {id}: {summary}"));
151
151
  }
152
152
  }
153
+ if let Some(notes) = team_state_notes(state).filter(|notes| !notes.is_empty()) {
154
+ lines.push(String::new());
155
+ lines.push("## Notes".to_string());
156
+ lines.push(String::new());
157
+ for note in notes {
158
+ lines.push(format!("- {note}"));
159
+ }
160
+ }
153
161
  lines.push(String::new());
154
162
  lines.push("## Next Step".to_string());
155
163
  lines.push(String::new());
@@ -182,6 +190,17 @@ fn team_state_tasks(spec: &YamlValue, state: &serde_json::Value) -> Vec<TeamStat
182
190
  Vec::new()
183
191
  }
184
192
 
193
+ fn team_state_notes(state: &serde_json::Value) -> Option<Vec<String>> {
194
+ Some(
195
+ state
196
+ .get("notes")?
197
+ .as_array()?
198
+ .iter()
199
+ .filter_map(|note| note.as_str().filter(|text| !text.is_empty()).map(str::to_string))
200
+ .collect(),
201
+ )
202
+ }
203
+
185
204
  fn task_field_str(task: &TeamStateTask, key: &str) -> String {
186
205
  match task {
187
206
  TeamStateTask::Json(v) => v.get(key).and_then(|v| v.as_str()).unwrap_or("").to_string(),
@@ -33,8 +33,12 @@ mod team_state;
33
33
 
34
34
  pub use agent::{reset_agent, reset_agent_with_transport, start_agent, start_agent_with_transport, stop_agent, stop_agent_with_transport};
35
35
  pub(crate) use agent::start_agent_at_paths;
36
+ pub(crate) use common::refresh_missing_provider_sessions;
36
37
  pub use orchestrator::{halt_plan, plan_status};
37
- pub use rebuild::{restart, restart_candidates, restart_with_transport, select_restart_state};
38
+ pub use rebuild::{
39
+ restart, restart_candidates, restart_with_session_convergence_deadline, restart_with_transport,
40
+ select_restart_state,
41
+ };
38
42
  pub use remove::{remove_agent, remove_agent_with_transport};
39
43
  pub use selection::{classify_first_send_at, classify_restart_plan, decide_start_mode, python_type_name};
40
44
  pub(crate) use team_state::write_team_state;
@@ -45,6 +49,13 @@ pub(crate) fn lifecycle_run_workspace(workspace: &Path) -> Result<std::path::Pat
45
49
  }
46
50
 
47
51
  fn lifecycle_paths(workspace: &Path, team: Option<&str>) -> Result<LifecyclePaths, LifecycleError> {
52
+ if input_has_no_local_team_context(workspace) {
53
+ return Err(LifecycleError::TeamSelect(format!(
54
+ "active team spec not found: input_workspace={} expected_spec_path={}",
55
+ workspace.display(),
56
+ workspace.join("team.spec.yaml").display()
57
+ )));
58
+ }
48
59
  let selected = crate::state::selector::resolve_active_team(
49
60
  workspace,
50
61
  team,
@@ -60,6 +71,18 @@ fn lifecycle_paths(workspace: &Path, team: Option<&str>) -> Result<LifecyclePath
60
71
  })
61
72
  }
62
73
 
74
+ pub(crate) fn input_has_no_local_team_context(workspace: &Path) -> bool {
75
+ !workspace.join("team.spec.yaml").exists()
76
+ && !workspace.join(".team").exists()
77
+ && !crate::state::persist::runtime_state_path(workspace).exists()
78
+ && workspace.file_name().and_then(|s| s.to_str()) != Some(".team")
79
+ && workspace
80
+ .parent()
81
+ .and_then(|p| p.file_name())
82
+ .and_then(|s| s.to_str())
83
+ != Some(".team")
84
+ }
85
+
63
86
  fn selected_state_spec_workspace(state: &serde_json::Value) -> Option<std::path::PathBuf> {
64
87
  state
65
88
  .get("spec_path")
@@ -496,10 +496,10 @@ fn lanea_fork_window_already_exists_guard_before_spec_mutation() {
496
496
 
497
497
  // ── FORK (fork-gate-error-text) [RED] + (fork-incomplete-rollback, adapter arm) — golden gate text + spec rollback
498
498
  // Golden operations.py:329-330 raises f"{provider} does not support native session fork" when the native
499
- // fork gate fails (auth_mode==compatible_api). Rust relies on adapter.fork() -> CapabilityUnsupported
499
+ // fork gate fails (auth_mode==compatible_api). Rust relies on adapter.fork_plan() -> CapabilityUnsupported
500
500
  // ("Codex:fork") (adapter.rs:310) -> a different observable. AND golden wraps the post-spec-write steps
501
501
  // in try/except restoring the spec on ANY failure (operations.py:384-394); Rust writes the spec
502
- // (launch.rs:443) then errors at adapter.fork (458-460) WITHOUT restoring it. RED on both: the message
502
+ // (launch.rs:443) then errors at adapter.fork_plan (458-460) WITHOUT restoring it. RED on both: the message
503
503
  // text AND the spec must be rolled back to not contain the fork agent.
504
504
  #[test]
505
505
  fn lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm() {
@@ -516,7 +516,7 @@ fn lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm() {
516
516
  assert!(
517
517
  !spec_text.contains("newfork"),
518
518
  "golden operations.py:384-394: on the gate failure the spec must be ROLLED BACK; Rust writes the spec \
519
- then errors at adapter.fork without restoring it, leaving the fork agent 'newfork' in the spec"
519
+ then errors at adapter.fork_plan without restoring it, leaving the fork agent 'newfork' in the spec"
520
520
  );
521
521
  }
522
522
 
@@ -568,9 +568,9 @@ fn lanea_remove_rollback_restores_agent_health() {
568
568
  // (4) restores prior state. Rust only restores the spec on the spawn_into arm (launch.rs:481); the
569
569
  // save_runtime_state (486-487) and start_coordinator (488-493) failure arms leave the spec mutated, the
570
570
  // already-spawned window un-killed, and the state un-rolled-back; install_mcp/cleanup_mcp are absent.
571
- // The adapter.fork arm IS covered HARD above (lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm).
571
+ // The adapter.fork_plan arm IS covered HARD above (lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm).
572
572
  // The post-SPAWN arms need a failure-injection seam after spawn_into (codex+subscription forks past
573
- // adapter.fork, so the spawn succeeds and there is no in-process way to fail save/coordinator cleanly).
573
+ // adapter.fork_plan, so the spawn succeeds and there is no in-process way to fail save/coordinator cleanly).
574
574
  // PORTER: a Drop guard armed after the spec write, disarmed on success — kills the window, restores spec
575
575
  // + state, runs cleanup_mcp on every post-write error arm.
576
576
  #[test]
@@ -649,8 +649,9 @@ pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
649
649
  }
650
650
 
651
651
  // 2 [P0] — restart_with_transport must drive the REAL Route-B resume spawn: one spawn per resumable
652
- // worker (first=spawn_first, rest=spawn_into), each carrying the provider build_command, + coordinator
653
- // started. Today the stub returns RequirementUnmet with ZERO spawns -> RED at recorded.len().
652
+ // worker. The first resumed worker recreates the session with spawn_first; later workers may use
653
+ // spawn_into only after a live-session check proves that recreated session still exists. Each spawn
654
+ // carries the provider build_command, and the coordinator is started.
654
655
  #[test]
655
656
  fn restart_with_transport_spawns_resumable_workers_not_stub() {
656
657
  let ws = restart_ws_two_resumable_workers();
@@ -665,10 +666,13 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
665
666
  "restart must spawn ONE worker per resumable agent (alpha, bravo); the rt-host-a stub returns \
666
667
  RequirementUnmet with ZERO spawns; got {recorded:?}"
667
668
  );
668
- assert_eq!(recorded[0].0, "spawn_first", "the first resumed worker uses new-session (spawn_first); got {recorded:?}");
669
- assert!(
670
- recorded.iter().any(|(kind, _)| kind == "spawn_into"),
671
- "subsequent resumed workers use new-window (spawn_into); got {recorded:?}"
669
+ assert_eq!(
670
+ recorded[0].0, "spawn_first",
671
+ "with has-session=false before alpha, restart must recreate the session with spawn_first; got {recorded:?}"
672
+ );
673
+ assert_eq!(
674
+ recorded[1].0, "spawn_into",
675
+ "with has-session=true after alpha spawn, restart may add bravo with spawn_into; this is liveness-based, not index-based; got {recorded:?}"
672
676
  );
673
677
  assert!(
674
678
  recorded.iter().all(|(_, argv)| argv.iter().any(|a| a == "codex")),
@@ -678,6 +682,25 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
678
682
  matches!(result, Ok(RestartReport::Restarted { coordinator_started: true, .. })),
679
683
  "restart must reach RestartReport::Restarted with coordinator_started=true (AlreadyRunning, seeded); got {result:?}"
680
684
  );
685
+
686
+ let ws = restart_ws_two_resumable_workers();
687
+ let dead_after_first = OfflineTransport::new().with_session_absent_after_spawn_first();
688
+ let result = restart_with_transport(&ws, false, None, &dead_after_first);
689
+ let recorded = dead_after_first.spawn_records();
690
+ assert_eq!(
691
+ recorded.len(),
692
+ 1,
693
+ "if the session disappears after alpha, restart must stop before spawning bravo; got result={result:?} recorded={recorded:?}"
694
+ );
695
+ assert!(
696
+ recorded.iter().all(|(kind, _)| kind != "spawn_into"),
697
+ "restart must not call spawn_into/new-window for bravo against a dead server; result={result:?} recorded={recorded:?}"
698
+ );
699
+ let error = format!("{result:?}");
700
+ assert!(
701
+ error.contains("session_disappeared_after_spawn") || error.contains("provider_resume_exited"),
702
+ "restart must report the first resumed agent/session disappearance explicitly, not continue to a later no-server failure; result={result:?}"
703
+ );
681
704
  }
682
705
 
683
706
  // 3 [P0] — start_agent_with_transport on a non-paused agent with a session_id must spawn EXACTLY ONE
@@ -899,6 +922,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
899
922
  "window",
900
923
  "mcp_config",
901
924
  "permissions",
925
+ "effective_approval_policy",
902
926
  "session_id",
903
927
  "rollout_path",
904
928
  "captured_at",
@@ -906,6 +930,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
906
930
  "attribution_confidence",
907
931
  "spawn_cwd",
908
932
  "spawned_at",
933
+ "pane_id",
909
934
  ],
910
935
  "running agent state key order must match golden launch/core.py:238-255; raw={raw}"
911
936
  );
@@ -935,8 +960,13 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
935
960
  assert!(agent["captured_at"].is_null());
936
961
  assert!(agent["captured_via"].is_null());
937
962
  assert!(agent["attribution_confidence"].is_null());
938
- assert_eq!(agent["spawn_cwd"], json!(workspace.to_string_lossy()));
963
+ assert_eq!(agent["spawn_cwd"], json!(team.to_string_lossy()));
939
964
  assert_eq!(agent["spawned_at"], json!(FIXED_SPAWNED_AT));
965
+ assert_eq!(
966
+ agent["pane_id"],
967
+ json!("%0"),
968
+ "#252 RC2: launch must persist the real pane_id returned by the transport, not drop it when pane_pid is unavailable"
969
+ );
940
970
  }
941
971
 
942
972
  // Stage B2 — golden launch/core.py:171-173 writes paused workers as exactly
@@ -220,9 +220,15 @@ pub enum WorkerDisplay {
220
220
  Adaptive {
221
221
  status: DisplayStatus,
222
222
  window: Option<WindowName>,
223
+ workspace_window: Option<WindowName>,
223
224
  pane_id: Option<PaneId>,
225
+ pane_title: Option<String>,
224
226
  target: Option<String>,
227
+ target_worker_session: Option<String>,
228
+ linked_session: Option<String>,
225
229
  leader_session: Option<SessionName>,
230
+ display_session: Option<SessionName>,
231
+ fallback: Option<String>,
226
232
  },
227
233
  GhosttyWindow {
228
234
  status: DisplayStatus,
@@ -415,6 +421,7 @@ pub struct LaunchReport {
415
421
  pub safety: DangerousApproval,
416
422
  /// leader receiver(attach 成功时;经 step10 leader::attach_leader_to_state)。
417
423
  pub leader_receiver_attached: bool,
424
+ pub session_capture_incomplete_agents: Vec<String>,
418
425
  }
419
426
 
420
427
  /// 单个已起 worker(`launch` 的 `agents[]` / `started`)。
@@ -425,6 +432,10 @@ pub struct StartedAgent {
425
432
  pub target: String,
426
433
  pub session_id: Option<SessionId>,
427
434
  pub rollout_path: Option<RolloutPath>,
435
+ pub pending_session_id: Option<SessionId>,
436
+ pub claude_config_dir: Option<PathBuf>,
437
+ pub provider_projects_root: Option<PathBuf>,
438
+ pub managed_mcp_config: bool,
428
439
  pub display: WorkerDisplay,
429
440
  }
430
441
 
@@ -610,6 +621,14 @@ pub enum RestartReport {
610
621
  allow_fresh: bool,
611
622
  error: String,
612
623
  },
624
+ /// session capture did not converge before destructive restart. No teardown/spawn occurred.
625
+ RefusedResumeNotReady {
626
+ missing: Vec<AgentId>,
627
+ allow_fresh: bool,
628
+ deadline: std::time::Duration,
629
+ elapsed: std::time::Duration,
630
+ error: String,
631
+ },
613
632
  /// first_send_at 损坏(`reason=invalid_first_send_at`):决策前 hard refuse。
614
633
  RefusedInvalidFirstSendAt {
615
634
  invalid: Vec<CorruptFirstSendAt>,
@@ -60,6 +60,7 @@ pub(crate) fn tool_error_reason_wire(reason: ToolErrorReason) -> &'static str {
60
60
  ToolErrorReason::InvalidToolArguments => "invalid_tool_arguments",
61
61
  ToolErrorReason::InternalRuntimeError => "internal_runtime_error",
62
62
  ToolErrorReason::PeerNotInScope => "peer_not_in_scope",
63
+ ToolErrorReason::McpScopeRefused => "mcp.scope_refused",
63
64
  }
64
65
  }
65
66
 
@@ -0,0 +1,341 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use serde_json::Value;
4
+
5
+ use crate::lifecycle::{ResetAgentOutcome, ResetRefusal};
6
+ use crate::model::yaml::{self, Value as YamlValue};
7
+ use crate::model::ids::{AgentId, TeamKey};
8
+
9
+ use super::super::helpers::{enum_value, object_fields, tool_runtime_error};
10
+ use super::super::{ToolOk, ToolResult};
11
+
12
+ pub(crate) fn stop_agent(
13
+ workspace: &Path,
14
+ owner_team: Option<&TeamKey>,
15
+ agent_id: &str,
16
+ ) -> ToolResult {
17
+ let lifecycle_workspace = lifecycle_workspace(workspace, owner_team, true)?;
18
+ let report = crate::lifecycle::stop_agent(
19
+ &lifecycle_workspace,
20
+ &AgentId::new(agent_id),
21
+ owner_team.map(TeamKey::as_str),
22
+ )
23
+ .map_err(tool_runtime_error)?;
24
+ Ok(ToolOk {
25
+ fields: object_fields(serde_json::json!({
26
+ "ok": true,
27
+ "agent_id": report.agent_id.as_str(),
28
+ "status": "stopped",
29
+ "stopped": report.stopped,
30
+ "target": report.target,
31
+ "display_closed": report.display_closed,
32
+ "state_file": report.state_file.to_string_lossy().to_string(),
33
+ })),
34
+ })
35
+ }
36
+
37
+ pub(crate) fn reset_agent(
38
+ workspace: &Path,
39
+ owner_team: Option<&TeamKey>,
40
+ agent_id: &str,
41
+ discard_session: bool,
42
+ ) -> ToolResult {
43
+ if !discard_session {
44
+ return Ok(ToolOk {
45
+ fields: object_fields(serde_json::json!({
46
+ "ok": false,
47
+ "agent_id": agent_id,
48
+ "status": "refused",
49
+ "reason": "discard_session_required",
50
+ })),
51
+ });
52
+ }
53
+ let lifecycle_workspace = lifecycle_workspace(workspace, owner_team, true)?;
54
+ match crate::lifecycle::reset_agent(
55
+ &lifecycle_workspace,
56
+ &AgentId::new(agent_id),
57
+ discard_session,
58
+ false,
59
+ owner_team.map(TeamKey::as_str),
60
+ )
61
+ .map_err(tool_runtime_error)?
62
+ {
63
+ ResetAgentOutcome::Reset { env, start_mode } => Ok(ToolOk {
64
+ fields: object_fields(serde_json::json!({
65
+ "ok": true,
66
+ "agent_id": env.agent_id.as_str(),
67
+ "status": "reset",
68
+ "state_file": env.state_file.to_string_lossy().to_string(),
69
+ "coordinator_started": env.coordinator_started,
70
+ "start_mode": enum_value(start_mode),
71
+ })),
72
+ }),
73
+ ResetAgentOutcome::Refused { reason } => Ok(ToolOk {
74
+ fields: object_fields(serde_json::json!({
75
+ "ok": false,
76
+ "agent_id": agent_id,
77
+ "status": "refused",
78
+ "reason": reset_refusal_reason(reason),
79
+ })),
80
+ }),
81
+ }
82
+ }
83
+
84
+ pub(crate) fn fork_agent(
85
+ workspace: &Path,
86
+ owner_team: Option<&TeamKey>,
87
+ source_agent_id: &str,
88
+ as_agent_id: &str,
89
+ label: Option<&str>,
90
+ ) -> ToolResult {
91
+ let _ = label;
92
+ let lifecycle_workspace = lifecycle_workspace(workspace, owner_team, false)?;
93
+ let report = crate::lifecycle::launch::fork_agent(
94
+ &lifecycle_workspace,
95
+ &AgentId::new(source_agent_id),
96
+ &AgentId::new(as_agent_id),
97
+ false,
98
+ owner_team.map(TeamKey::as_str),
99
+ )
100
+ .map_err(tool_runtime_error)?;
101
+ Ok(ToolOk {
102
+ fields: object_fields(serde_json::json!({
103
+ "ok": true,
104
+ "status": "forked",
105
+ "source_agent_id": report.source_agent_id.as_str(),
106
+ "agent_id": report.new_agent_id.as_str(),
107
+ "new_agent_id": report.new_agent_id.as_str(),
108
+ "state_file": report.env.state_file.to_string_lossy().to_string(),
109
+ "coordinator_started": report.env.coordinator_started,
110
+ "session_id": report.session_id.as_ref().map(|session| session.as_str()),
111
+ })),
112
+ })
113
+ }
114
+
115
+ fn reset_refusal_reason(reason: ResetRefusal) -> Value {
116
+ match reason {
117
+ ResetRefusal::DiscardSessionRequired => Value::String("discard_session_required".to_string()),
118
+ }
119
+ }
120
+
121
+ fn lifecycle_workspace(
122
+ workspace: &Path,
123
+ owner_team: Option<&TeamKey>,
124
+ prepare_state: bool,
125
+ ) -> Result<PathBuf, super::super::ToolError> {
126
+ let team = owner_team.map(TeamKey::as_str);
127
+ if let Ok(raw_state) = load_local_runtime_state(workspace) {
128
+ if let Some(path) = state_spec_workspace(&raw_state, team) {
129
+ return Ok(path);
130
+ }
131
+ if raw_state.get("agents").is_some() || raw_state.get("teams").is_some() {
132
+ return materialize_mcp_lifecycle_spec(workspace, raw_state, team, prepare_state);
133
+ }
134
+ }
135
+ if let Ok(selected) = crate::state::selector::resolve_active_team(
136
+ workspace,
137
+ team,
138
+ crate::state::selector::SelectorMode::RequireSpec,
139
+ ) {
140
+ if let Some(path) = selected.spec_workspace {
141
+ return Ok(path);
142
+ }
143
+ }
144
+ let state = crate::state::projection::select_runtime_state(workspace, team)
145
+ .map_err(tool_runtime_error)?;
146
+ if let Some(path) = state_spec_workspace(&state, team) {
147
+ return Ok(path);
148
+ }
149
+ Ok(workspace.to_path_buf())
150
+ }
151
+
152
+ fn state_spec_workspace(state: &Value, team: Option<&str>) -> Option<PathBuf> {
153
+ if let Some(team) = team {
154
+ if let Some(entry) = state
155
+ .get("teams")
156
+ .and_then(Value::as_object)
157
+ .and_then(|teams| teams.get(team))
158
+ {
159
+ if let Some(path) = state_spec_workspace_from_entry(entry) {
160
+ return Some(path);
161
+ }
162
+ }
163
+ }
164
+ state_spec_workspace_from_entry(state).or_else(|| {
165
+ let teams = state.get("teams").and_then(Value::as_object)?;
166
+ if teams.len() == 1 {
167
+ teams.values().next().and_then(state_spec_workspace_from_entry)
168
+ } else {
169
+ None
170
+ }
171
+ })
172
+ }
173
+
174
+ fn state_spec_workspace_from_entry(state: &Value) -> Option<PathBuf> {
175
+ state
176
+ .get("spec_path")
177
+ .and_then(Value::as_str)
178
+ .filter(|s| !s.is_empty())
179
+ .and_then(|s| Path::new(s).parent().map(Path::to_path_buf))
180
+ .filter(|p| p.join("team.spec.yaml").exists())
181
+ .or_else(|| {
182
+ state
183
+ .get("team_dir")
184
+ .and_then(Value::as_str)
185
+ .filter(|s| !s.is_empty())
186
+ .map(PathBuf::from)
187
+ .filter(|p| p.join("team.spec.yaml").exists())
188
+ })
189
+ }
190
+
191
+ fn load_local_runtime_state(workspace: &Path) -> Result<Value, super::super::ToolError> {
192
+ let path = crate::state::persist::runtime_state_path(workspace);
193
+ let text = std::fs::read_to_string(&path).map_err(|e| {
194
+ tool_runtime_error(format!("read local runtime state {}: {e}", path.display()))
195
+ })?;
196
+ serde_json::from_str(&text).map_err(|e| {
197
+ tool_runtime_error(format!("parse local runtime state {}: {e}", path.display()))
198
+ })
199
+ }
200
+
201
+ fn materialize_mcp_lifecycle_spec(
202
+ workspace: &Path,
203
+ mut state: Value,
204
+ team: Option<&str>,
205
+ prepare_state: bool,
206
+ ) -> Result<PathBuf, super::super::ToolError> {
207
+ let team_name = team
208
+ .filter(|s| !s.is_empty())
209
+ .or_else(|| state.get("active_team_key").and_then(Value::as_str))
210
+ .unwrap_or("team");
211
+ let team_name = team_name.to_string();
212
+ let Some(agents) = selected_agents(&state, team) else {
213
+ return Ok(workspace.to_path_buf());
214
+ };
215
+ let mut agent_items = Vec::new();
216
+ let top_agents = state.get("agents").and_then(Value::as_object);
217
+ for (agent_id, agent_state) in agents {
218
+ let provider = agent_state
219
+ .get("provider")
220
+ .and_then(Value::as_str)
221
+ .or_else(|| {
222
+ top_agents
223
+ .and_then(|all| all.get(agent_id))
224
+ .and_then(|agent| agent.get("provider"))
225
+ .and_then(Value::as_str)
226
+ })
227
+ .unwrap_or("fake");
228
+ agent_items.push(YamlValue::Map(vec![
229
+ ("id".to_string(), YamlValue::Str(agent_id.clone())),
230
+ ("provider".to_string(), YamlValue::Str(provider.to_string())),
231
+ ("role".to_string(), YamlValue::Str("Worker".to_string())),
232
+ ]));
233
+ }
234
+ if agent_items.is_empty() {
235
+ return Ok(workspace.to_path_buf());
236
+ }
237
+ let spec = YamlValue::Map(vec![
238
+ (
239
+ "team".to_string(),
240
+ YamlValue::Map(vec![
241
+ ("name".to_string(), YamlValue::Str(team_name.clone())),
242
+ (
243
+ "objective".to_string(),
244
+ YamlValue::Str("MCP lifecycle state-backed team".to_string()),
245
+ ),
246
+ ]),
247
+ ),
248
+ (
249
+ "leader".to_string(),
250
+ YamlValue::Map(vec![("provider".to_string(), YamlValue::Str("codex".to_string()))]),
251
+ ),
252
+ ("agents".to_string(), YamlValue::List(agent_items)),
253
+ ]);
254
+ let spec_workspace = workspace.join(".team").join(&team_name);
255
+ std::fs::create_dir_all(&spec_workspace).map_err(|e| {
256
+ tool_runtime_error(format!("create MCP lifecycle spec dir {}: {e}", spec_workspace.display()))
257
+ })?;
258
+ let spec_path = spec_workspace.join("team.spec.yaml");
259
+ std::fs::write(&spec_path, yaml::dumps(&spec)).map_err(|e| {
260
+ tool_runtime_error(format!("write MCP lifecycle spec {}: {e}", spec_path.display()))
261
+ })?;
262
+ if prepare_state {
263
+ prepare_selected_team_state(workspace, &mut state, &team_name, &spec_workspace, &spec_path)?;
264
+ }
265
+ Ok(spec_workspace)
266
+ }
267
+
268
+ fn selected_agents<'a>(
269
+ state: &'a Value,
270
+ team: Option<&str>,
271
+ ) -> Option<&'a serde_json::Map<String, Value>> {
272
+ team
273
+ .and_then(|team| {
274
+ state
275
+ .get("teams")
276
+ .and_then(Value::as_object)
277
+ .and_then(|teams| teams.get(team))
278
+ .and_then(|entry| entry.get("agents"))
279
+ .and_then(Value::as_object)
280
+ })
281
+ .or_else(|| state.get("agents").and_then(Value::as_object))
282
+ }
283
+
284
+ fn prepare_selected_team_state(
285
+ workspace: &Path,
286
+ state: &mut Value,
287
+ team: &str,
288
+ spec_workspace: &Path,
289
+ spec_path: &Path,
290
+ ) -> Result<(), super::super::ToolError> {
291
+ let Some(root) = state.as_object_mut() else {
292
+ return Ok(());
293
+ };
294
+ let top_session_name = root.get("session_name").cloned();
295
+ let top_leader_receiver = root.get("leader_receiver").cloned();
296
+ let top_agents = root.get("agents").and_then(Value::as_object).cloned();
297
+ let teams = root
298
+ .entry("teams".to_string())
299
+ .or_insert_with(|| Value::Object(serde_json::Map::new()));
300
+ let Some(teams) = teams.as_object_mut() else {
301
+ return Ok(());
302
+ };
303
+ let entry = teams
304
+ .entry(team.to_string())
305
+ .or_insert_with(|| Value::Object(serde_json::Map::new()));
306
+ let Some(entry) = entry.as_object_mut() else {
307
+ return Ok(());
308
+ };
309
+ if let Some(value) = top_session_name {
310
+ entry.entry("session_name".to_string()).or_insert(value);
311
+ }
312
+ if let Some(value) = top_leader_receiver {
313
+ entry.entry("leader_receiver".to_string()).or_insert(value);
314
+ }
315
+ entry.insert(
316
+ "team_dir".to_string(),
317
+ Value::String(spec_workspace.to_string_lossy().to_string()),
318
+ );
319
+ entry.insert(
320
+ "spec_path".to_string(),
321
+ Value::String(spec_path.to_string_lossy().to_string()),
322
+ );
323
+ if let Some(top_agents) = top_agents {
324
+ if let Some(team_agents) = entry.get_mut("agents").and_then(Value::as_object_mut) {
325
+ for (agent_id, top_agent) in top_agents {
326
+ let Some(team_agent) = team_agents.get_mut(&agent_id).and_then(Value::as_object_mut) else {
327
+ continue;
328
+ };
329
+ let Some(top_agent) = top_agent.as_object() else {
330
+ continue;
331
+ };
332
+ for (key, value) in top_agent {
333
+ team_agent.entry(key.clone()).or_insert_with(|| value.clone());
334
+ }
335
+ }
336
+ }
337
+ }
338
+ crate::state::persist::save_runtime_state(workspace, state).map_err(|e| {
339
+ tool_runtime_error(format!("save MCP lifecycle scoped state {}: {e}", workspace.display()))
340
+ })
341
+ }
@@ -0,0 +1,10 @@
1
+ //! MCP lifecycle tool facades.
2
+ //!
3
+ //! S0 keeps the old placeholder behavior behind stable module boundaries so
4
+ //! follow-up lanes can implement real lifecycle logic without touching tools.rs.
5
+
6
+ mod agent_ops;
7
+ mod state_status;
8
+
9
+ pub(crate) use agent_ops::{fork_agent, reset_agent, stop_agent};
10
+ pub(crate) use state_status::{get_team_status, update_state};