@team-agent/installer 0.3.2 → 0.3.4
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 +196 -19
- package/crates/team-agent/src/cli/diagnose.rs +145 -11
- package/crates/team-agent/src/cli/emit.rs +287 -53
- package/crates/team-agent/src/cli/leader.rs +37 -8
- package/crates/team-agent/src/cli/mod.rs +807 -316
- package/crates/team-agent/src/cli/status_port.rs +25 -2
- 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 +57 -3
- package/crates/team-agent/src/cli/types.rs +17 -0
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +16 -6
- package/crates/team-agent/src/coordinator/health.rs +89 -20
- 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/tests/watch.rs +4 -2
- 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 +648 -50
- package/crates/team-agent/src/lifecycle/launch.rs +1048 -264
- package/crates/team-agent/src/lifecycle/mod.rs +3 -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 +113 -26
- package/crates/team-agent/src/lifecycle/restart/common.rs +189 -102
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +465 -25
- package/crates/team-agent/src/lifecycle/restart/remove.rs +22 -6
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
- package/crates/team-agent/src/lifecycle/restart.rs +4 -1
- package/crates/team-agent/src/lifecycle/tests/core.rs +4 -4
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +39 -9
- package/crates/team-agent/src/lifecycle/types.rs +23 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +326 -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 +87 -37
- package/crates/team-agent/src/messaging/mod.rs +9 -6
- package/crates/team-agent/src/messaging/results.rs +153 -16
- 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 +483 -67
- 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/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 +57 -0
- package/crates/team-agent/src/state/projection.rs +32 -23
- package/crates/team-agent/src/state/selector.rs +5 -2
- package/crates/team-agent/src/tmux_backend.rs +151 -60
- 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
|
@@ -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};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
|
|
5
|
+
use crate::model::ids::TeamKey;
|
|
6
|
+
|
|
7
|
+
use super::super::helpers::{ensure_object, object_fields, tool_runtime_error};
|
|
8
|
+
use super::super::{ToolOk, ToolResult};
|
|
9
|
+
|
|
10
|
+
pub(crate) fn update_state(
|
|
11
|
+
workspace: &Path,
|
|
12
|
+
owner_team: Option<&TeamKey>,
|
|
13
|
+
note: &str,
|
|
14
|
+
) -> ToolResult {
|
|
15
|
+
let selected = match crate::state::selector::resolve_active_team(
|
|
16
|
+
workspace,
|
|
17
|
+
owner_team.map(TeamKey::as_str),
|
|
18
|
+
crate::state::selector::SelectorMode::RequireSpec,
|
|
19
|
+
) {
|
|
20
|
+
Ok(selected) => selected,
|
|
21
|
+
Err(err) if is_missing_active_spec(&err) => {
|
|
22
|
+
return update_state_without_spec(workspace, owner_team, note);
|
|
23
|
+
}
|
|
24
|
+
Err(err) => return Err(tool_runtime_error(err)),
|
|
25
|
+
};
|
|
26
|
+
let mut state = selected.state;
|
|
27
|
+
ensure_object(&mut state);
|
|
28
|
+
append_note(&mut state, note);
|
|
29
|
+
crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
|
|
30
|
+
.map_err(tool_runtime_error)?;
|
|
31
|
+
let spec_path = selected
|
|
32
|
+
.spec_path
|
|
33
|
+
.ok_or_else(|| tool_runtime_error("active team spec not found for update_state"))?;
|
|
34
|
+
let spec_workspace = spec_path.parent().ok_or_else(|| {
|
|
35
|
+
tool_runtime_error(format!("active team spec has no parent: {}", spec_path.display()))
|
|
36
|
+
})?;
|
|
37
|
+
let spec_text = std::fs::read_to_string(&spec_path).map_err(tool_runtime_error)?;
|
|
38
|
+
let spec = crate::model::yaml::loads(&spec_text).map_err(tool_runtime_error)?;
|
|
39
|
+
let path = crate::lifecycle::restart::write_team_state(spec_workspace, &spec, &state)
|
|
40
|
+
.map_err(tool_runtime_error)?;
|
|
41
|
+
Ok(update_state_ok(path))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn update_state_without_spec(
|
|
45
|
+
workspace: &Path,
|
|
46
|
+
owner_team: Option<&TeamKey>,
|
|
47
|
+
note: &str,
|
|
48
|
+
) -> ToolResult {
|
|
49
|
+
let selected = crate::state::selector::resolve_active_team(
|
|
50
|
+
workspace,
|
|
51
|
+
owner_team.map(TeamKey::as_str),
|
|
52
|
+
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
53
|
+
)
|
|
54
|
+
.map_err(tool_runtime_error)?;
|
|
55
|
+
let mut state = selected.state;
|
|
56
|
+
ensure_object(&mut state);
|
|
57
|
+
seed_legacy_team_key(&mut state, &selected.run_workspace, &selected.team_key);
|
|
58
|
+
append_note(&mut state, note);
|
|
59
|
+
crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
|
|
60
|
+
.map_err(tool_runtime_error)?;
|
|
61
|
+
let path = crate::lifecycle::restart::write_team_state(
|
|
62
|
+
&selected.run_workspace,
|
|
63
|
+
&crate::model::yaml::Value::Null,
|
|
64
|
+
&state,
|
|
65
|
+
)
|
|
66
|
+
.map_err(tool_runtime_error)?;
|
|
67
|
+
Ok(update_state_ok(path))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fn append_note(state: &mut Value, note: &str) {
|
|
71
|
+
if let Some(obj) = state.as_object_mut() {
|
|
72
|
+
let notes = obj
|
|
73
|
+
.entry("notes".to_string())
|
|
74
|
+
.or_insert_with(|| Value::Array(Vec::new()));
|
|
75
|
+
if !notes.is_array() {
|
|
76
|
+
*notes = Value::Array(Vec::new());
|
|
77
|
+
}
|
|
78
|
+
if let Some(items) = notes.as_array_mut() {
|
|
79
|
+
items.push(Value::String(note.to_string()));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn seed_legacy_team_key(state: &mut Value, run_workspace: &Path, team_key: &str) {
|
|
85
|
+
if state.get("team_dir").and_then(Value::as_str).is_some()
|
|
86
|
+
|| state.get("spec_path").and_then(Value::as_str).is_some()
|
|
87
|
+
|| state.get("session_name").and_then(Value::as_str).is_some()
|
|
88
|
+
{
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if let Some(obj) = state.as_object_mut() {
|
|
92
|
+
obj.insert(
|
|
93
|
+
"team_dir".to_string(),
|
|
94
|
+
Value::String(
|
|
95
|
+
run_workspace
|
|
96
|
+
.join(".team")
|
|
97
|
+
.join(team_key)
|
|
98
|
+
.to_string_lossy()
|
|
99
|
+
.to_string(),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn update_state_ok(path: std::path::PathBuf) -> ToolOk {
|
|
106
|
+
let mut fields = serde_json::Map::new();
|
|
107
|
+
fields.insert("ok".to_string(), Value::Bool(true));
|
|
108
|
+
fields.insert(
|
|
109
|
+
"state_file".to_string(),
|
|
110
|
+
Value::String(path.to_string_lossy().to_string()),
|
|
111
|
+
);
|
|
112
|
+
ToolOk { fields }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn is_missing_active_spec(err: &crate::state::StateError) -> bool {
|
|
116
|
+
matches!(
|
|
117
|
+
err,
|
|
118
|
+
crate::state::StateError::TeamSelect(message)
|
|
119
|
+
if message.starts_with("active team spec not found:")
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pub(crate) fn get_team_status(
|
|
124
|
+
workspace: &Path,
|
|
125
|
+
owner_team: Option<&TeamKey>,
|
|
126
|
+
) -> ToolResult {
|
|
127
|
+
let selected = crate::state::selector::resolve_active_team(
|
|
128
|
+
workspace,
|
|
129
|
+
owner_team.map(TeamKey::as_str),
|
|
130
|
+
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
131
|
+
)
|
|
132
|
+
.map_err(tool_runtime_error)?;
|
|
133
|
+
let status = crate::cli::status_port::status_scoped(
|
|
134
|
+
&selected.run_workspace,
|
|
135
|
+
&selected.state,
|
|
136
|
+
Some(selected.team_key.as_str()),
|
|
137
|
+
true,
|
|
138
|
+
false,
|
|
139
|
+
)
|
|
140
|
+
.map_err(tool_runtime_error)?;
|
|
141
|
+
let mut fields = object_fields(status);
|
|
142
|
+
fields
|
|
143
|
+
.entry("teams".to_string())
|
|
144
|
+
.or_insert_with(|| selected_team_only(&selected.state, &selected.team_key));
|
|
145
|
+
Ok(ToolOk { fields })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fn selected_team_only(state: &Value, team_key: &str) -> Value {
|
|
149
|
+
let mut teams = serde_json::Map::new();
|
|
150
|
+
if let Some(team) = state
|
|
151
|
+
.get("teams")
|
|
152
|
+
.and_then(Value::as_object)
|
|
153
|
+
.and_then(|all| all.get(team_key))
|
|
154
|
+
{
|
|
155
|
+
teams.insert(team_key.to_string(), team.clone());
|
|
156
|
+
}
|
|
157
|
+
Value::Object(teams)
|
|
158
|
+
}
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
//! 铁律 (card §11, Rust 绝不重蹈 Python 坑):
|
|
32
32
|
//! - **scope 锚 env, 禁候选扫描** (C13-C17/bug-064/082): sender identity =
|
|
33
33
|
//! spawn-time `TEAM_AGENT_ID`; scope = `TEAM_AGENT_OWNER_TEAM_ID`. `to="*"`
|
|
34
|
-
//! defaults to the sender team;
|
|
35
|
-
//!
|
|
34
|
+
//! defaults to the sender team; worker-origin RPC arguments cannot widen
|
|
35
|
+
//! that scope. A peer not in scope → typed [`ToolError`] with
|
|
36
36
|
//! [`ToolErrorReason::PeerNotInScope`] — never leak other-team peer names.
|
|
37
37
|
//! - **错误信封冗余键** (server.py:98-106): `reason == error_code` and
|
|
38
38
|
//! `message == error` are byte-stable downstream contracts — preserved verbatim
|
|
@@ -83,6 +83,7 @@ use crate::state::persist::{load_runtime_state, save_runtime_state};
|
|
|
83
83
|
use crate::messaging::{self, DeliveryOutcome, MessageTarget, SendOptions};
|
|
84
84
|
|
|
85
85
|
pub mod helpers;
|
|
86
|
+
pub(crate) mod lifecycle_tools;
|
|
86
87
|
pub mod normalize;
|
|
87
88
|
pub mod tools;
|
|
88
89
|
pub mod types;
|
|
@@ -107,77 +108,5 @@ pub(crate) use normalize::{
|
|
|
107
108
|
};
|
|
108
109
|
pub(crate) use wire::dispatch_tool;
|
|
109
110
|
|
|
110
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
111
|
-
// CROSS-DEP PLACEHOLDERS — step 13 lifecycle / team_state surface not yet in tree.
|
|
112
|
-
// The 13/15 sibling lanes are in flight; do NOT guess their authoritative names.
|
|
113
|
-
// Leader reconciles these at integration.
|
|
114
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
115
|
-
|
|
116
|
-
/// **PLACEHOLDER** — step 13 lifecycle `runtime.{stop,reset,add,fork}_agent`. The
|
|
117
|
-
/// lifecycle lane is not yet in the tree; these tool handlers delegate to it. Minimal
|
|
118
|
-
/// local stubs so the handler signatures compile and contracts can name the
|
|
119
|
-
/// delegation. Leader swaps for the authoritative step-13 surface at integration.
|
|
120
|
-
pub mod lifecycle_placeholder {
|
|
121
|
-
use super::*;
|
|
122
|
-
|
|
123
|
-
/// `runtime.stop_agent(workspace, agent_id)` (step 13).
|
|
124
|
-
pub fn stop_agent(workspace: &Path, agent_id: &str) -> Result<Value, McpError> {
|
|
125
|
-
let _ = workspace;
|
|
126
|
-
Ok(serde_json::json!({"ok": true, "status": "stopped", "agent_id": agent_id}))
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/// `runtime.reset_agent(workspace, agent_id, discard_session)` (step 13).
|
|
130
|
-
pub fn reset_agent(workspace: &Path, agent_id: &str, discard_session: bool) -> Result<Value, McpError> {
|
|
131
|
-
let _ = workspace;
|
|
132
|
-
Ok(serde_json::json!({"ok": true, "status": "reset", "agent_id": agent_id, "discard_session": discard_session}))
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/// `runtime.add_agent(workspace, new_agent_id, role_file_path)` (step 13).
|
|
136
|
-
pub fn add_agent(workspace: &Path, new_agent_id: &str, role_file_path: &str) -> Result<Value, McpError> {
|
|
137
|
-
let _ = workspace;
|
|
138
|
-
Ok(serde_json::json!({"ok": true, "status": "added", "agent_id": new_agent_id, "role_file_path": role_file_path}))
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/// `runtime.fork_agent(workspace, source_agent_id, as_agent_id, label)` (step 13).
|
|
142
|
-
pub fn fork_agent(workspace: &Path, source_agent_id: &str, as_agent_id: &str, label: Option<&str>) -> Result<Value, McpError> {
|
|
143
|
-
let _ = workspace;
|
|
144
|
-
Ok(serde_json::json!({"ok": true, "status": "forked", "source_agent_id": source_agent_id, "agent_id": as_agent_id, "label": label}))
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/// `runtime.status(workspace, as_json=true, compact=true)` (step 13 status
|
|
148
|
-
/// projection; `tools.py:328`).
|
|
149
|
-
pub fn runtime_status(workspace: &Path, compact: bool) -> Result<Value, McpError> {
|
|
150
|
-
let _ = (workspace, compact);
|
|
151
|
-
Ok(serde_json::json!({"ok": true, "status": "ok"}))
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/// `state.write_team_state(workspace, spec, state)` (step 5/13 team_state.md
|
|
155
|
-
/// rewrite; `tools.py:324`). Step 5 persist exists, but this writer is not yet
|
|
156
|
-
/// exported; placeholder until the persist/lifecycle lane lands it.
|
|
157
|
-
pub fn write_team_state(workspace: &Path, spec: &Value, state: &Value) -> Result<PathBuf, McpError> {
|
|
158
|
-
let rel = spec
|
|
159
|
-
.get("context")
|
|
160
|
-
.and_then(|v| v.get("state_file"))
|
|
161
|
-
.and_then(Value::as_str)
|
|
162
|
-
.unwrap_or("team_state.md");
|
|
163
|
-
let path = workspace.join(rel);
|
|
164
|
-
if let Some(parent) = path.parent() {
|
|
165
|
-
std::fs::create_dir_all(parent)?;
|
|
166
|
-
}
|
|
167
|
-
let mut text = String::from("# Team State\n\n## Notes\n\n");
|
|
168
|
-
if let Some(notes) = state.get("notes").and_then(Value::as_array) {
|
|
169
|
-
for note in notes {
|
|
170
|
-
if let Some(note) = note.as_str() {
|
|
171
|
-
text.push_str("- ");
|
|
172
|
-
text.push_str(note);
|
|
173
|
-
text.push('\n');
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
std::fs::write(&path, text)?;
|
|
178
|
-
Ok(path)
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
111
|
#[cfg(test)]
|
|
183
112
|
mod tests;
|
|
@@ -185,5 +185,5 @@
|
|
|
185
185
|
assert_eq!(refused["scope"], json!("team"));
|
|
186
186
|
assert_eq!(refused["sender_team_id"], json!("teamA"));
|
|
187
187
|
assert_eq!(refused["hint"],
|
|
188
|
-
json!("the requested peer is not part of your team
|
|
188
|
+
json!("the requested peer is not part of your team; worker-origin MCP cannot widen team scope."));
|
|
189
189
|
}
|
|
@@ -154,22 +154,23 @@
|
|
|
154
154
|
assert_eq!(env.get("reason"), Some(&json!("peer_not_in_scope")));
|
|
155
155
|
assert_eq!(
|
|
156
156
|
env.get("hint"),
|
|
157
|
-
Some(&json!("the requested peer is not part of your team
|
|
157
|
+
Some(&json!("the requested peer is not part of your team; worker-origin MCP cannot widen team scope."))
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
#[test]
|
|
162
|
-
fn
|
|
162
|
+
fn refuse_cross_team_peer_rejects_workspace_scope_override_for_worker() {
|
|
163
163
|
let tools = TeamOrchestratorTools::with_identity(
|
|
164
164
|
Path::new("/tmp/ws"),
|
|
165
165
|
Some(AgentId::new("worker-1")),
|
|
166
166
|
Some(TeamKey::new("teamA")),
|
|
167
167
|
);
|
|
168
|
-
// scope="workspace"
|
|
169
|
-
|
|
168
|
+
// scope="workspace" is not worker consent to cross team boundaries.
|
|
169
|
+
let te = tools.refuse_cross_team_peer(
|
|
170
170
|
&MessageTarget::Single("other-team-bob".to_string()),
|
|
171
171
|
Some(Scope::Workspace),
|
|
172
|
-
).
|
|
172
|
+
).expect("workspace scope override must still be refused for worker-origin MCP");
|
|
173
|
+
assert_eq!(te.reason, ToolErrorReason::McpScopeRefused);
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
#[test]
|