@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.
- package/Cargo.lock +34 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +234 -26
- package/crates/team-agent/src/cli/diagnose.rs +144 -10
- package/crates/team-agent/src/cli/emit.rs +289 -54
- package/crates/team-agent/src/cli/leader.rs +37 -8
- package/crates/team-agent/src/cli/mod.rs +1281 -196
- package/crates/team-agent/src/cli/status_port.rs +195 -46
- package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
- package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +59 -3
- package/crates/team-agent/src/cli/types.rs +18 -0
- package/crates/team-agent/src/compiler.rs +15 -5
- package/crates/team-agent/src/coordinator/health.rs +95 -17
- package/crates/team-agent/src/coordinator/mod.rs +4 -0
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
- package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
- package/crates/team-agent/src/coordinator/tick.rs +222 -69
- package/crates/team-agent/src/coordinator/types.rs +15 -3
- package/crates/team-agent/src/db/schema.rs +37 -2
- package/crates/team-agent/src/diagnose/comms.rs +226 -0
- package/crates/team-agent/src/diagnose/mod.rs +45 -0
- package/crates/team-agent/src/diagnose/orphans.rs +658 -0
- package/crates/team-agent/src/fake_worker.rs +146 -3
- package/crates/team-agent/src/leader/start.rs +121 -23
- package/crates/team-agent/src/leader/types.rs +44 -1
- package/crates/team-agent/src/lib.rs +3 -0
- package/crates/team-agent/src/lifecycle/display.rs +645 -47
- package/crates/team-agent/src/lifecycle/launch.rs +1061 -146
- package/crates/team-agent/src/lifecycle/mod.rs +2 -0
- package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
- package/crates/team-agent/src/lifecycle/restart/common.rs +183 -24
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +498 -22
- package/crates/team-agent/src/lifecycle/restart/remove.rs +27 -7
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
- package/crates/team-agent/src/lifecycle/restart.rs +24 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
- package/crates/team-agent/src/lifecycle/types.rs +19 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
- package/crates/team-agent/src/mcp_server/mod.rs +3 -74
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
- package/crates/team-agent/src/mcp_server/tools.rs +312 -111
- package/crates/team-agent/src/mcp_server/types.rs +6 -4
- package/crates/team-agent/src/mcp_server/wire.rs +19 -7
- package/crates/team-agent/src/message_store.rs +21 -4
- package/crates/team-agent/src/messaging/delivery.rs +470 -59
- package/crates/team-agent/src/messaging/mod.rs +9 -6
- package/crates/team-agent/src/messaging/results.rs +353 -63
- package/crates/team-agent/src/messaging/selftest.rs +199 -12
- package/crates/team-agent/src/messaging/send.rs +35 -3
- package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
- package/crates/team-agent/src/messaging/types.rs +11 -3
- package/crates/team-agent/src/os_probe.rs +119 -0
- package/crates/team-agent/src/packaging/migrate.rs +10 -2
- package/crates/team-agent/src/packaging/tests.rs +23 -0
- package/crates/team-agent/src/provider/adapter.rs +564 -63
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
- package/crates/team-agent/src/provider/classify.rs +51 -4
- package/crates/team-agent/src/provider/helpers.rs +10 -1
- package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
- package/crates/team-agent/src/provider/types.rs +47 -0
- package/crates/team-agent/src/session_capture.rs +616 -0
- package/crates/team-agent/src/state/persist.rs +170 -1
- package/crates/team-agent/src/state/projection.rs +141 -8
- package/crates/team-agent/src/state/selector.rs +5 -2
- package/crates/team-agent/src/tmux_backend.rs +161 -64
- package/crates/team-agent/src/transport/test_support.rs +9 -0
- package/crates/team-agent/src/transport/tests/wire.rs +4 -0
- package/crates/team-agent/src/transport.rs +13 -2
- 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::{
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
653
|
-
//
|
|
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!(
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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!(
|
|
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};
|