@team-agent/installer 0.3.0 → 0.3.2
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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +38 -7
- package/crates/team-agent/src/cli/emit.rs +182 -54
- package/crates/team-agent/src/cli/mod.rs +703 -35
- package/crates/team-agent/src/cli/status_port.rs +170 -44
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/health.rs +130 -0
- package/crates/team-agent/src/leader/lease.rs +23 -2
- package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
- package/crates/team-agent/src/leader/rediscover.rs +2 -0
- package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
- package/crates/team-agent/src/leader/tests/idle.rs +1 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
- package/crates/team-agent/src/leader/types.rs +2 -0
- package/crates/team-agent/src/lifecycle/launch.rs +554 -65
- package/crates/team-agent/src/lifecycle/restart/common.rs +65 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +57 -15
- package/crates/team-agent/src/lifecycle/restart/remove.rs +5 -1
- package/crates/team-agent/src/lifecycle/restart.rs +20 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
- package/crates/team-agent/src/lifecycle/types.rs +25 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
- package/crates/team-agent/src/mcp_server/wire.rs +81 -1
- package/crates/team-agent/src/messaging/delivery.rs +574 -12
- package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
- package/crates/team-agent/src/messaging/mod.rs +1 -1
- package/crates/team-agent/src/messaging/results.rs +218 -49
- package/crates/team-agent/src/messaging/send.rs +15 -19
- package/crates/team-agent/src/provider/adapter.rs +95 -10
- package/crates/team-agent/src/provider/helpers.rs +10 -1
- package/crates/team-agent/src/state/identity.rs +3 -0
- package/crates/team-agent/src/state/persist.rs +113 -1
- package/crates/team-agent/src/state/projection.rs +127 -3
- package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
- package/crates/team-agent/src/tmux_backend.rs +124 -12
- package/npm/install.mjs +29 -7
- package/package.json +4 -4
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
//! lifecycle::launch —— 冷启 / quick-start / 危险审批探测 + add/fork / plan 起步与推进。
|
|
2
2
|
|
|
3
|
-
use std::collections::BTreeMap;
|
|
3
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
4
4
|
use std::path::{Path, PathBuf};
|
|
5
5
|
use std::process::Command;
|
|
6
6
|
|
|
7
|
-
use crate::model::
|
|
7
|
+
use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider};
|
|
8
8
|
use crate::model::ids::AgentId;
|
|
9
|
-
use crate::model::
|
|
9
|
+
use crate::model::permissions::{self, AgentPermissionInput};
|
|
10
10
|
use crate::model::yaml::{self, Value};
|
|
11
11
|
use crate::state::persist::{load_runtime_state, save_runtime_state};
|
|
12
12
|
use crate::transport::{SessionName, Target, Transport, WindowName};
|
|
@@ -44,6 +44,26 @@ pub fn launch_with_transport(
|
|
|
44
44
|
auto_approve: bool,
|
|
45
45
|
skip_profile_smoke: bool,
|
|
46
46
|
transport: &dyn Transport,
|
|
47
|
+
) -> Result<LaunchReport, LifecycleError> {
|
|
48
|
+
let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
|
|
49
|
+
let workspace = team_workspace(team_dir);
|
|
50
|
+
launch_with_transport_in_workspace(
|
|
51
|
+
&workspace,
|
|
52
|
+
spec_path,
|
|
53
|
+
dry_run,
|
|
54
|
+
auto_approve,
|
|
55
|
+
skip_profile_smoke,
|
|
56
|
+
transport,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pub fn launch_with_transport_in_workspace(
|
|
61
|
+
workspace: &Path,
|
|
62
|
+
spec_path: &Path,
|
|
63
|
+
dry_run: bool,
|
|
64
|
+
auto_approve: bool,
|
|
65
|
+
skip_profile_smoke: bool,
|
|
66
|
+
transport: &dyn Transport,
|
|
47
67
|
) -> Result<LaunchReport, LifecycleError> {
|
|
48
68
|
let _ = skip_profile_smoke;
|
|
49
69
|
if !spec_path.exists() {
|
|
@@ -76,13 +96,13 @@ pub fn launch_with_transport(
|
|
|
76
96
|
raw: serde_json::json!({"source": "compiled_spec"}),
|
|
77
97
|
})
|
|
78
98
|
.collect::<Vec<_>>();
|
|
79
|
-
write_launch_permission_audit(
|
|
99
|
+
write_launch_permission_audit(workspace, &safety)?;
|
|
80
100
|
let routes = spec_routes(&spec);
|
|
81
101
|
let started = if dry_run {
|
|
82
102
|
Vec::new()
|
|
83
103
|
} else {
|
|
84
|
-
let started = spawn_agents(spec_path, &spec, &session_name, &safety, transport)?;
|
|
85
|
-
persist_spawn_agent_state(spec_path, &spec)?;
|
|
104
|
+
let started = spawn_agents(workspace, spec_path, &spec, &session_name, &safety, transport)?;
|
|
105
|
+
persist_spawn_agent_state(workspace, spec_path, &spec, &session_name, transport, &started)?;
|
|
86
106
|
started
|
|
87
107
|
};
|
|
88
108
|
Ok(LaunchReport {
|
|
@@ -97,6 +117,7 @@ pub fn launch_with_transport(
|
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
fn spawn_agents(
|
|
120
|
+
workspace: &Path,
|
|
100
121
|
spec_path: &Path,
|
|
101
122
|
spec: &Value,
|
|
102
123
|
session_name: &SessionName,
|
|
@@ -104,7 +125,6 @@ fn spawn_agents(
|
|
|
104
125
|
transport: &dyn Transport,
|
|
105
126
|
) -> Result<Vec<StartedAgent>, LifecycleError> {
|
|
106
127
|
let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
|
|
107
|
-
let workspace = team_workspace(team_dir);
|
|
108
128
|
let mut started = Vec::new();
|
|
109
129
|
for agent in spec_agent_values(spec) {
|
|
110
130
|
let Some(agent_id_raw) = agent.get("id").and_then(Value::as_str) else {
|
|
@@ -134,10 +154,13 @@ fn spawn_agents(
|
|
|
134
154
|
let role = agent.get("role").and_then(Value::as_str);
|
|
135
155
|
let tools = worker_tool_refs(agent_tool_strings(agent), safety);
|
|
136
156
|
let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
|
|
157
|
+
let mcp_team_id =
|
|
158
|
+
runtime_active_team_key_for_spawn(workspace, spec_path, spec, session_name);
|
|
137
159
|
let mcp_config = adapter
|
|
138
160
|
.mcp_config(auth_mode)
|
|
139
161
|
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
140
|
-
let
|
|
162
|
+
let mcp_config = resolve_mcp_config(mcp_config, workspace, agent_id_raw, &mcp_team_id);
|
|
163
|
+
let mcp_config_path = write_worker_mcp_config(workspace, agent_id_raw, &mcp_config)?;
|
|
141
164
|
let mut argv = adapter
|
|
142
165
|
.build_command_with_tools(
|
|
143
166
|
auth_mode,
|
|
@@ -147,12 +170,18 @@ fn spawn_agents(
|
|
|
147
170
|
&tool_refs,
|
|
148
171
|
)
|
|
149
172
|
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
150
|
-
|
|
173
|
+
point_native_mcp_config_at_file(&mut argv, provider, &mcp_config_path);
|
|
174
|
+
fill_spawn_placeholders_full(
|
|
175
|
+
&mut argv,
|
|
176
|
+
workspace,
|
|
177
|
+
agent_id_raw,
|
|
178
|
+
Some(&mcp_team_id),
|
|
179
|
+
);
|
|
151
180
|
let window = WindowName::new(agent_id_raw);
|
|
152
181
|
let env = inherited_env_with_team_overrides(
|
|
153
|
-
|
|
182
|
+
workspace,
|
|
154
183
|
agent_id_raw,
|
|
155
|
-
|
|
184
|
+
Some(&mcp_team_id),
|
|
156
185
|
);
|
|
157
186
|
let spawn = if started.is_empty() {
|
|
158
187
|
transport.spawn_first(session_name, &window, &argv, team_dir, &env)
|
|
@@ -166,6 +195,9 @@ fn spawn_agents(
|
|
|
166
195
|
30,
|
|
167
196
|
0.5,
|
|
168
197
|
);
|
|
198
|
+
if matches!(transport.liveness(&spawn.pane_id), Ok(PaneLiveness::Dead)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
169
201
|
started.push(StartedAgent {
|
|
170
202
|
agent_id,
|
|
171
203
|
start_mode: StartMode::Fresh,
|
|
@@ -180,10 +212,15 @@ fn spawn_agents(
|
|
|
180
212
|
Ok(started)
|
|
181
213
|
}
|
|
182
214
|
|
|
183
|
-
fn persist_spawn_agent_state(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
215
|
+
fn persist_spawn_agent_state(
|
|
216
|
+
workspace: &Path,
|
|
217
|
+
spec_path: &Path,
|
|
218
|
+
spec: &Value,
|
|
219
|
+
session_name: &SessionName,
|
|
220
|
+
transport: &dyn Transport,
|
|
221
|
+
started: &[StartedAgent],
|
|
222
|
+
) -> Result<(), LifecycleError> {
|
|
223
|
+
let state_path = crate::state::persist::runtime_state_path(workspace);
|
|
187
224
|
let mut state = if state_path.exists() {
|
|
188
225
|
let text = std::fs::read_to_string(&state_path)
|
|
189
226
|
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", state_path.display())))?;
|
|
@@ -192,6 +229,27 @@ fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), Lifec
|
|
|
192
229
|
} else {
|
|
193
230
|
serde_json::json!({"agents": {}})
|
|
194
231
|
};
|
|
232
|
+
let team_id = explicit_active_team_key(&state)
|
|
233
|
+
.unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name));
|
|
234
|
+
let worker_tmux_socket = launched_worker_tmux_socket(transport, workspace);
|
|
235
|
+
drop_worker_pane_seeded_owner(
|
|
236
|
+
&mut state,
|
|
237
|
+
&team_id,
|
|
238
|
+
started,
|
|
239
|
+
worker_tmux_socket.as_deref(),
|
|
240
|
+
);
|
|
241
|
+
// Only persist running state for agents whose spawn still has a live target.
|
|
242
|
+
let live_windows: BTreeSet<String> = transport
|
|
243
|
+
.list_windows(session_name)
|
|
244
|
+
.unwrap_or_default()
|
|
245
|
+
.into_iter()
|
|
246
|
+
.map(|w| w.as_str().to_string())
|
|
247
|
+
.collect();
|
|
248
|
+
let live_started_agents: BTreeSet<String> = started
|
|
249
|
+
.iter()
|
|
250
|
+
.map(|agent| agent.agent_id.as_str().to_string())
|
|
251
|
+
.collect();
|
|
252
|
+
let pane_pids_by_agent = pane_pids_by_started_agent(transport, started);
|
|
195
253
|
let mut agents = serde_json::Map::new();
|
|
196
254
|
let spawned_at = spawn_timestamp();
|
|
197
255
|
for agent in spec_agent_values(spec) {
|
|
@@ -210,9 +268,26 @@ fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), Lifec
|
|
|
210
268
|
agents.insert(id.to_string(), serde_json::Value::Object(paused));
|
|
211
269
|
continue;
|
|
212
270
|
}
|
|
271
|
+
let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
|
|
272
|
+
if !live_started_agents.contains(id)
|
|
273
|
+
|| (!live_windows.is_empty() && !live_windows.contains(window))
|
|
274
|
+
{
|
|
275
|
+
let mut failed = serde_json::Map::new();
|
|
276
|
+
failed.insert("status".to_string(), serde_json::json!("spawn_failed"));
|
|
277
|
+
failed.insert("provider".to_string(), serde_json::json!(provider));
|
|
278
|
+
failed.insert("agent_id".to_string(), serde_json::json!(id));
|
|
279
|
+
failed.insert("window".to_string(), serde_json::json!(window));
|
|
280
|
+
failed.insert(
|
|
281
|
+
"reason".to_string(),
|
|
282
|
+
serde_json::json!("tmux window not present after spawn"),
|
|
283
|
+
);
|
|
284
|
+
agents.insert(id.to_string(), serde_json::Value::Object(failed));
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
let pane_pid = pane_pids_by_agent.get(id).copied();
|
|
213
288
|
agents.insert(
|
|
214
289
|
id.to_string(),
|
|
215
|
-
running_agent_state(agent, id, provider,
|
|
290
|
+
running_agent_state(agent, id, provider, workspace, &spawned_at, &team_id, pane_pid)?,
|
|
216
291
|
);
|
|
217
292
|
}
|
|
218
293
|
if let Some(obj) = state.as_object_mut() {
|
|
@@ -222,21 +297,108 @@ fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), Lifec
|
|
|
222
297
|
obj.insert("agents".to_string(), serde_json::Value::Object(agents));
|
|
223
298
|
state = serde_json::Value::Object(obj);
|
|
224
299
|
}
|
|
225
|
-
|
|
300
|
+
save_launched_team_state_for_key(workspace, &state, Some(&team_id))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fn pane_pids_by_started_agent(
|
|
304
|
+
transport: &dyn Transport,
|
|
305
|
+
started: &[StartedAgent],
|
|
306
|
+
) -> BTreeMap<String, u32> {
|
|
307
|
+
let panes = transport.list_targets().unwrap_or_default();
|
|
308
|
+
started
|
|
309
|
+
.iter()
|
|
310
|
+
.filter_map(|agent| {
|
|
311
|
+
panes
|
|
312
|
+
.iter()
|
|
313
|
+
.find(|pane| pane.pane_id.as_str() == agent.target)
|
|
314
|
+
.and_then(|pane| pane.pane_pid)
|
|
315
|
+
.map(|pid| (agent.agent_id.as_str().to_string(), pid))
|
|
316
|
+
})
|
|
317
|
+
.collect()
|
|
226
318
|
}
|
|
227
319
|
|
|
228
320
|
fn save_launched_team_state(workspace: &Path, launched: &serde_json::Value) -> Result<(), LifecycleError> {
|
|
321
|
+
save_launched_team_state_for_key(workspace, launched, None)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
fn save_launched_team_state_for_key(
|
|
325
|
+
workspace: &Path,
|
|
326
|
+
launched: &serde_json::Value,
|
|
327
|
+
team_key: Option<&str>,
|
|
328
|
+
) -> Result<(), LifecycleError> {
|
|
229
329
|
let existing = load_runtime_state(workspace).unwrap_or_else(|_| serde_json::json!({}));
|
|
230
|
-
let launched_key =
|
|
330
|
+
let launched_key = team_key
|
|
331
|
+
.filter(|key| !key.is_empty())
|
|
332
|
+
.map(str::to_string)
|
|
333
|
+
.unwrap_or_else(|| crate::state::projection::team_state_key(launched));
|
|
231
334
|
let mut launched = launched.clone();
|
|
335
|
+
if let Some(obj) = launched.as_object_mut() {
|
|
336
|
+
obj.insert(
|
|
337
|
+
"active_team_key".to_string(),
|
|
338
|
+
serde_json::Value::String(launched_key.clone()),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
232
341
|
promote_launched_binding_from_team_entry(&mut launched, &launched_key);
|
|
233
342
|
drop_foreign_seeded_owner(&existing, &launched_key, &mut launched);
|
|
234
|
-
let merged =
|
|
343
|
+
let merged = if team_key.is_some() {
|
|
344
|
+
merge_workspace_team_state_with_key(&existing, &launched, &launched_key)
|
|
345
|
+
} else {
|
|
346
|
+
crate::state::projection::merge_workspace_team_state(&existing, &launched)
|
|
347
|
+
};
|
|
235
348
|
let mut projected = crate::state::projection::project_top_level_view(&merged, &launched_key);
|
|
236
349
|
drop_unbound_top_level_owner(&mut projected);
|
|
237
350
|
save_runtime_state(workspace, &projected).map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
238
351
|
}
|
|
239
352
|
|
|
353
|
+
fn merge_workspace_team_state_with_key(
|
|
354
|
+
existing: &serde_json::Value,
|
|
355
|
+
launched: &serde_json::Value,
|
|
356
|
+
launched_key: &str,
|
|
357
|
+
) -> serde_json::Value {
|
|
358
|
+
let mut launched_obj = launched.as_object().cloned().unwrap_or_default();
|
|
359
|
+
let mut teams = launched
|
|
360
|
+
.get("teams")
|
|
361
|
+
.and_then(serde_json::Value::as_object)
|
|
362
|
+
.cloned()
|
|
363
|
+
.unwrap_or_default();
|
|
364
|
+
let launched_entry = crate::state::projection::compact_team_state(launched);
|
|
365
|
+
if !existing
|
|
366
|
+
.get("session_name")
|
|
367
|
+
.and_then(serde_json::Value::as_str)
|
|
368
|
+
.is_some_and(|session| !session.is_empty())
|
|
369
|
+
{
|
|
370
|
+
teams.insert(launched_key.to_string(), launched_entry);
|
|
371
|
+
launched_obj.insert("teams".to_string(), serde_json::Value::Object(teams));
|
|
372
|
+
return serde_json::Value::Object(launched_obj);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let existing_key = explicit_active_team_key(existing)
|
|
376
|
+
.unwrap_or_else(|| crate::state::projection::team_state_key(existing));
|
|
377
|
+
if existing_key == launched_key {
|
|
378
|
+
let mut teams = existing
|
|
379
|
+
.get("teams")
|
|
380
|
+
.and_then(serde_json::Value::as_object)
|
|
381
|
+
.cloned()
|
|
382
|
+
.unwrap_or_default();
|
|
383
|
+
teams.insert(launched_key.to_string(), launched_entry);
|
|
384
|
+
launched_obj.insert("teams".to_string(), serde_json::Value::Object(teams));
|
|
385
|
+
return serde_json::Value::Object(launched_obj);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let mut merged = existing.as_object().cloned().unwrap_or_default();
|
|
389
|
+
let mut teams = merged
|
|
390
|
+
.get("teams")
|
|
391
|
+
.and_then(serde_json::Value::as_object)
|
|
392
|
+
.cloned()
|
|
393
|
+
.unwrap_or_default();
|
|
394
|
+
teams
|
|
395
|
+
.entry(existing_key)
|
|
396
|
+
.or_insert_with(|| crate::state::projection::compact_team_state(existing));
|
|
397
|
+
teams.insert(launched_key.to_string(), launched_entry);
|
|
398
|
+
merged.insert("teams".to_string(), serde_json::Value::Object(teams));
|
|
399
|
+
serde_json::Value::Object(merged)
|
|
400
|
+
}
|
|
401
|
+
|
|
240
402
|
fn promote_launched_binding_from_team_entry(launched: &mut serde_json::Value, launched_key: &str) {
|
|
241
403
|
let entry = launched
|
|
242
404
|
.get("teams")
|
|
@@ -287,6 +449,69 @@ fn drop_foreign_seeded_owner(existing: &serde_json::Value, launched_key: &str, l
|
|
|
287
449
|
}
|
|
288
450
|
}
|
|
289
451
|
|
|
452
|
+
fn drop_worker_pane_seeded_owner(
|
|
453
|
+
launched: &mut serde_json::Value,
|
|
454
|
+
launched_key: &str,
|
|
455
|
+
started: &[StartedAgent],
|
|
456
|
+
worker_tmux_socket: Option<&str>,
|
|
457
|
+
) {
|
|
458
|
+
let Some(pane) = launched
|
|
459
|
+
.get("team_owner")
|
|
460
|
+
.and_then(|owner| owner.get("pane_id"))
|
|
461
|
+
.and_then(serde_json::Value::as_str)
|
|
462
|
+
.filter(|pane| !pane.is_empty())
|
|
463
|
+
else {
|
|
464
|
+
return;
|
|
465
|
+
};
|
|
466
|
+
let leader_pane = std::env::var("TEAM_AGENT_LEADER_PANE_ID")
|
|
467
|
+
.ok()
|
|
468
|
+
.filter(|value| !value.is_empty());
|
|
469
|
+
let tmux_pane = std::env::var("TMUX_PANE")
|
|
470
|
+
.ok()
|
|
471
|
+
.filter(|value| !value.is_empty());
|
|
472
|
+
let has_leader_identity_env = leader_pane.is_some()
|
|
473
|
+
|| env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID")
|
|
474
|
+
|| env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE")
|
|
475
|
+
|| env_nonempty("TEAM_AGENT_LEADER_PROVIDER")
|
|
476
|
+
|| env_nonempty("TEAM_AGENT_ID")
|
|
477
|
+
|| env_nonempty("TEAM_AGENT_TEAM_ID");
|
|
478
|
+
let seeded_from_bare_tmux =
|
|
479
|
+
!has_leader_identity_env && tmux_pane.as_deref() == Some(pane);
|
|
480
|
+
let caller_tmux_socket = crate::tmux_backend::socket_name_from_tmux_env();
|
|
481
|
+
if seeded_from_bare_tmux
|
|
482
|
+
&& tmux_sockets_match_or_unknown(caller_tmux_socket.as_deref(), worker_tmux_socket)
|
|
483
|
+
&& started.iter().any(|agent| agent.target == pane)
|
|
484
|
+
{
|
|
485
|
+
seed_unbound_launched_owner(launched, launched_key);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
fn launched_worker_tmux_socket(
|
|
490
|
+
transport: &dyn Transport,
|
|
491
|
+
workspace: &Path,
|
|
492
|
+
) -> Option<String> {
|
|
493
|
+
if matches!(transport.kind(), crate::transport::BackendKind::Tmux) {
|
|
494
|
+
Some(crate::tmux_backend::socket_name_for_workspace(workspace))
|
|
495
|
+
} else {
|
|
496
|
+
None
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
fn tmux_sockets_match_or_unknown(
|
|
501
|
+
caller_socket: Option<&str>,
|
|
502
|
+
worker_socket: Option<&str>,
|
|
503
|
+
) -> bool {
|
|
504
|
+
match (caller_socket, worker_socket) {
|
|
505
|
+
(Some(caller), Some(worker)) => caller == worker,
|
|
506
|
+
(Some(_), None) => false,
|
|
507
|
+
(None, _) => true,
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
fn env_nonempty(key: &str) -> bool {
|
|
512
|
+
std::env::var(key).ok().is_some_and(|value| !value.is_empty())
|
|
513
|
+
}
|
|
514
|
+
|
|
290
515
|
fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
|
|
291
516
|
let provider = launched
|
|
292
517
|
.get("team_owner")
|
|
@@ -330,6 +555,7 @@ fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &
|
|
|
330
555
|
"status": "attached",
|
|
331
556
|
"provider": provider,
|
|
332
557
|
"pane_id": "__team_agent_unbound__",
|
|
558
|
+
"pane": "__team_agent_unbound__",
|
|
333
559
|
"leader_session_uuid": uuid.as_str(),
|
|
334
560
|
"owner_epoch": owner_epoch,
|
|
335
561
|
"discovery": "quick_start",
|
|
@@ -363,6 +589,8 @@ fn running_agent_state(
|
|
|
363
589
|
provider: Provider,
|
|
364
590
|
workspace: &Path,
|
|
365
591
|
spawned_at: &str,
|
|
592
|
+
team_id: &str,
|
|
593
|
+
pane_pid: Option<u32>,
|
|
366
594
|
) -> Result<serde_json::Value, LifecycleError> {
|
|
367
595
|
let model = agent.get("model").and_then(Value::as_str);
|
|
368
596
|
let auth_mode = agent
|
|
@@ -372,6 +600,11 @@ fn running_agent_state(
|
|
|
372
600
|
.unwrap_or(AuthMode::Subscription);
|
|
373
601
|
let profile = agent.get("profile").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null);
|
|
374
602
|
let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
|
|
603
|
+
let mcp_config = crate::provider::get_adapter(provider)
|
|
604
|
+
.mcp_config(auth_mode)
|
|
605
|
+
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
606
|
+
let mcp_config = resolve_mcp_config(mcp_config, workspace, id, team_id);
|
|
607
|
+
let mcp_config_path = write_worker_mcp_config(workspace, id, &mcp_config)?;
|
|
375
608
|
let mut state = serde_json::Map::new();
|
|
376
609
|
state.insert("status".to_string(), serde_json::json!("running"));
|
|
377
610
|
state.insert("provider".to_string(), serde_json::json!(provider));
|
|
@@ -382,13 +615,7 @@ fn running_agent_state(
|
|
|
382
615
|
state.insert("window".to_string(), serde_json::json!(window));
|
|
383
616
|
state.insert(
|
|
384
617
|
"mcp_config".to_string(),
|
|
385
|
-
serde_json::json!(
|
|
386
|
-
workspace
|
|
387
|
-
.join(".team/runtime/mcp")
|
|
388
|
-
.join(format!("{id}.json"))
|
|
389
|
-
.to_string_lossy()
|
|
390
|
-
.to_string()
|
|
391
|
-
),
|
|
618
|
+
serde_json::json!(mcp_config_path.to_string_lossy().to_string()),
|
|
392
619
|
);
|
|
393
620
|
state.insert(
|
|
394
621
|
"permissions".to_string(),
|
|
@@ -405,9 +632,86 @@ fn running_agent_state(
|
|
|
405
632
|
serde_json::json!(workspace.to_string_lossy().to_string()),
|
|
406
633
|
);
|
|
407
634
|
state.insert("spawned_at".to_string(), serde_json::json!(spawned_at));
|
|
635
|
+
if let Some(pane_pid) = pane_pid {
|
|
636
|
+
state.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
|
|
637
|
+
}
|
|
408
638
|
Ok(serde_json::Value::Object(state))
|
|
409
639
|
}
|
|
410
640
|
|
|
641
|
+
fn resolve_mcp_config(
|
|
642
|
+
config: crate::provider::McpConfig,
|
|
643
|
+
workspace: &Path,
|
|
644
|
+
agent_id: &str,
|
|
645
|
+
team_id: &str,
|
|
646
|
+
) -> crate::provider::McpConfig {
|
|
647
|
+
crate::provider::McpConfig {
|
|
648
|
+
raw: resolve_mcp_placeholders(config.raw, workspace, agent_id, team_id),
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
fn resolve_mcp_placeholders(
|
|
653
|
+
value: serde_json::Value,
|
|
654
|
+
workspace: &Path,
|
|
655
|
+
agent_id: &str,
|
|
656
|
+
team_id: &str,
|
|
657
|
+
) -> serde_json::Value {
|
|
658
|
+
match value {
|
|
659
|
+
serde_json::Value::String(s) => serde_json::Value::String(
|
|
660
|
+
s.replace("{workspace}", &workspace.to_string_lossy())
|
|
661
|
+
.replace("{agent_id}", agent_id)
|
|
662
|
+
.replace("{team_id}", team_id),
|
|
663
|
+
),
|
|
664
|
+
serde_json::Value::Array(items) => serde_json::Value::Array(
|
|
665
|
+
items
|
|
666
|
+
.into_iter()
|
|
667
|
+
.map(|item| resolve_mcp_placeholders(item, workspace, agent_id, team_id))
|
|
668
|
+
.collect(),
|
|
669
|
+
),
|
|
670
|
+
serde_json::Value::Object(map) => serde_json::Value::Object(
|
|
671
|
+
map.into_iter()
|
|
672
|
+
.map(|(key, value)| {
|
|
673
|
+
(
|
|
674
|
+
key,
|
|
675
|
+
resolve_mcp_placeholders(value, workspace, agent_id, team_id),
|
|
676
|
+
)
|
|
677
|
+
})
|
|
678
|
+
.collect(),
|
|
679
|
+
),
|
|
680
|
+
other => other,
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
fn write_worker_mcp_config(
|
|
685
|
+
workspace: &Path,
|
|
686
|
+
agent_id: &str,
|
|
687
|
+
config: &crate::provider::McpConfig,
|
|
688
|
+
) -> Result<PathBuf, LifecycleError> {
|
|
689
|
+
let path = workspace
|
|
690
|
+
.join(".team/runtime/mcp")
|
|
691
|
+
.join(format!("{agent_id}.json"));
|
|
692
|
+
if let Some(parent) = path.parent() {
|
|
693
|
+
std::fs::create_dir_all(parent)
|
|
694
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
|
|
695
|
+
}
|
|
696
|
+
let body = serde_json::to_string_pretty(&serde_json::json!({"mcpServers": config.raw}))
|
|
697
|
+
.map_err(|e| LifecycleError::StatePersist(format!("serialize mcp config: {e}")))?;
|
|
698
|
+
std::fs::write(&path, body)
|
|
699
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
|
|
700
|
+
Ok(path)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
fn point_native_mcp_config_at_file(argv: &mut [String], provider: Provider, path: &Path) {
|
|
704
|
+
if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
let Some(index) = argv.iter().position(|arg| arg == "--mcp-config") else {
|
|
708
|
+
return;
|
|
709
|
+
};
|
|
710
|
+
if let Some(value) = argv.get_mut(index.saturating_add(1)) {
|
|
711
|
+
*value = path.to_string_lossy().to_string();
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
411
715
|
fn permissions_json(
|
|
412
716
|
agent: &Value,
|
|
413
717
|
id: &str,
|
|
@@ -565,6 +869,37 @@ fn spec_team_id(spec: &Value) -> Option<String> {
|
|
|
565
869
|
})
|
|
566
870
|
}
|
|
567
871
|
|
|
872
|
+
fn runtime_active_team_key_for_spawn(
|
|
873
|
+
workspace: &Path,
|
|
874
|
+
spec_path: &Path,
|
|
875
|
+
spec: &Value,
|
|
876
|
+
session_name: &SessionName,
|
|
877
|
+
) -> String {
|
|
878
|
+
load_runtime_state(workspace)
|
|
879
|
+
.ok()
|
|
880
|
+
.and_then(|state| explicit_active_team_key(&state))
|
|
881
|
+
.unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name))
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
fn explicit_active_team_key(state: &serde_json::Value) -> Option<String> {
|
|
885
|
+
state
|
|
886
|
+
.get("active_team_key")
|
|
887
|
+
.and_then(serde_json::Value::as_str)
|
|
888
|
+
.filter(|team| !team.is_empty() && *team != "current")
|
|
889
|
+
.map(str::to_string)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
fn runtime_team_key_for_spec(spec_path: &Path, spec: &Value, session_name: &SessionName) -> String {
|
|
893
|
+
let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
|
|
894
|
+
let state = serde_json::json!({
|
|
895
|
+
"team_dir": team_dir.to_string_lossy(),
|
|
896
|
+
"spec_path": spec_path.to_string_lossy(),
|
|
897
|
+
"session_name": session_name.as_str(),
|
|
898
|
+
"team": spec.get("team").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null),
|
|
899
|
+
});
|
|
900
|
+
crate::state::projection::team_state_key(&state)
|
|
901
|
+
}
|
|
902
|
+
|
|
568
903
|
fn transport_has_session(transport: &dyn Transport, session_name: &SessionName) -> bool {
|
|
569
904
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
570
905
|
transport.has_session(session_name)
|
|
@@ -594,6 +929,43 @@ fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
|
|
|
594
929
|
}
|
|
595
930
|
}
|
|
596
931
|
|
|
932
|
+
fn quick_start_requested_team_key<'a>(team_id: Option<&'a str>, name: Option<&'a str>) -> Option<&'a str> {
|
|
933
|
+
team_id.or(name).filter(|team| !team.is_empty())
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
fn runtime_state_has_quick_start_team(state: &serde_json::Value, team: &str) -> bool {
|
|
937
|
+
explicit_active_team_key(state).as_deref() == Some(team)
|
|
938
|
+
|| state
|
|
939
|
+
.get("teams")
|
|
940
|
+
.and_then(serde_json::Value::as_object)
|
|
941
|
+
.is_some_and(|teams| {
|
|
942
|
+
teams.contains_key(team)
|
|
943
|
+
|| teams
|
|
944
|
+
.values()
|
|
945
|
+
.any(|entry| json_team_identity_matches(entry, team))
|
|
946
|
+
})
|
|
947
|
+
|| crate::state::projection::team_state_key(state) == team
|
|
948
|
+
|| json_team_identity_matches(state, team)
|
|
949
|
+
|| state
|
|
950
|
+
.get("session_name")
|
|
951
|
+
.and_then(serde_json::Value::as_str)
|
|
952
|
+
.is_some_and(|session| {
|
|
953
|
+
session == team || session.strip_prefix("team-") == Some(team)
|
|
954
|
+
})
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
fn json_team_identity_matches(state: &serde_json::Value, team: &str) -> bool {
|
|
958
|
+
state
|
|
959
|
+
.get("team")
|
|
960
|
+
.and_then(|value| value.get("id").or_else(|| value.get("name")))
|
|
961
|
+
.and_then(serde_json::Value::as_str)
|
|
962
|
+
.is_some_and(|value| value == team)
|
|
963
|
+
|| state
|
|
964
|
+
.get("name")
|
|
965
|
+
.and_then(serde_json::Value::as_str)
|
|
966
|
+
.is_some_and(|value| value == team)
|
|
967
|
+
}
|
|
968
|
+
|
|
597
969
|
/// `quick_start(agents_dir, name, yes, fresh, team_id)`(`diagnose/quick_start.py:18`)。
|
|
598
970
|
/// 面向用户的零配置入口:编译 team_dir → `launch` → autobind leader receiver → 起
|
|
599
971
|
/// coordinator → `wait_ready` 轮询就绪。归入 lifecycle module(不与 diagnose 混)。
|
|
@@ -604,14 +976,29 @@ pub fn quick_start(
|
|
|
604
976
|
fresh: bool,
|
|
605
977
|
team_id: Option<&str>,
|
|
606
978
|
) -> Result<QuickStartReport, LifecycleError> {
|
|
607
|
-
|
|
979
|
+
let workspace = team_workspace(agents_dir);
|
|
980
|
+
quick_start_in_workspace(&workspace, agents_dir, name, yes, fresh, team_id)
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
pub fn quick_start_in_workspace(
|
|
984
|
+
workspace: &Path,
|
|
985
|
+
agents_dir: &Path,
|
|
986
|
+
name: Option<&str>,
|
|
987
|
+
yes: bool,
|
|
988
|
+
fresh: bool,
|
|
989
|
+
team_id: Option<&str>,
|
|
990
|
+
) -> Result<QuickStartReport, LifecycleError> {
|
|
991
|
+
let workspace = crate::model::paths::canonical_run_workspace(workspace)
|
|
992
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
993
|
+
quick_start_with_transport_in_workspace(
|
|
994
|
+
&workspace,
|
|
608
995
|
agents_dir,
|
|
609
996
|
name,
|
|
610
997
|
yes,
|
|
611
998
|
fresh,
|
|
612
999
|
team_id,
|
|
613
|
-
// CP-1: per-team socket bound to the run workspace
|
|
614
|
-
&crate::tmux_backend::TmuxBackend::for_workspace(&
|
|
1000
|
+
// CP-1: per-team socket bound to the selected run workspace.
|
|
1001
|
+
&crate::tmux_backend::TmuxBackend::for_workspace(&workspace),
|
|
615
1002
|
)
|
|
616
1003
|
}
|
|
617
1004
|
|
|
@@ -624,6 +1011,19 @@ pub fn quick_start_with_transport(
|
|
|
624
1011
|
fresh: bool,
|
|
625
1012
|
team_id: Option<&str>,
|
|
626
1013
|
transport: &dyn Transport,
|
|
1014
|
+
) -> Result<QuickStartReport, LifecycleError> {
|
|
1015
|
+
let workspace = team_workspace(agents_dir);
|
|
1016
|
+
quick_start_with_transport_in_workspace(&workspace, agents_dir, name, yes, fresh, team_id, transport)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
pub fn quick_start_with_transport_in_workspace(
|
|
1020
|
+
workspace: &Path,
|
|
1021
|
+
agents_dir: &Path,
|
|
1022
|
+
name: Option<&str>,
|
|
1023
|
+
yes: bool,
|
|
1024
|
+
fresh: bool,
|
|
1025
|
+
team_id: Option<&str>,
|
|
1026
|
+
transport: &dyn Transport,
|
|
627
1027
|
) -> Result<QuickStartReport, LifecycleError> {
|
|
628
1028
|
if !agents_dir.exists() {
|
|
629
1029
|
return Err(LifecycleError::Compile(format!(
|
|
@@ -631,34 +1031,43 @@ pub fn quick_start_with_transport(
|
|
|
631
1031
|
agents_dir.display()
|
|
632
1032
|
)));
|
|
633
1033
|
}
|
|
634
|
-
let workspace =
|
|
1034
|
+
let workspace = workspace.to_path_buf();
|
|
1035
|
+
let mut spec = crate::compiler::compile_team(agents_dir)
|
|
1036
|
+
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
1037
|
+
let requested_team = quick_start_requested_team_key(team_id, name)
|
|
1038
|
+
.map(str::to_string)
|
|
1039
|
+
.or_else(|| spec_team_id(&spec));
|
|
1040
|
+
let explicit_team_key = quick_start_requested_team_key(team_id, name).map(str::to_string);
|
|
635
1041
|
if !fresh {
|
|
636
1042
|
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
637
1043
|
if state_path.exists() {
|
|
638
1044
|
let state = crate::state::persist::load_runtime_state(&workspace)
|
|
639
1045
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
1046
|
+
if requested_team
|
|
1047
|
+
.as_deref()
|
|
1048
|
+
.is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
|
|
1049
|
+
{
|
|
1050
|
+
return Ok(QuickStartReport::ExistingRuntime {
|
|
1051
|
+
team: requested_team.clone(),
|
|
1052
|
+
session_name: state
|
|
1053
|
+
.get("session_name")
|
|
1054
|
+
.and_then(serde_json::Value::as_str)
|
|
1055
|
+
.filter(|s| !s.is_empty())
|
|
1056
|
+
.map(SessionName::new),
|
|
1057
|
+
state_path: Some(state_path),
|
|
1058
|
+
next_actions: vec![
|
|
1059
|
+
"run restart to resume the existing team or pass --fresh to replace it".to_string(),
|
|
1060
|
+
],
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
652
1063
|
}
|
|
653
1064
|
}
|
|
654
|
-
let mut spec = crate::compiler::compile_team(agents_dir)
|
|
655
|
-
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
656
1065
|
// CR-040/042: repeated quick-start from one template with distinct --team-id/--name
|
|
657
1066
|
// must NOT collide on the template-derived tmux session. Override the compiled
|
|
658
1067
|
// spec's runtime.session_name with one derived from the REQUESTED team identity
|
|
659
1068
|
// so launch_with_transport (which reads runtime.session_name) spawns into an
|
|
660
1069
|
// isolated session per requested team.
|
|
661
|
-
if let Some(requested) =
|
|
1070
|
+
if let Some(requested) = requested_team.as_deref() {
|
|
662
1071
|
override_spec_session_name(&mut spec, &format!("team-{requested}"));
|
|
663
1072
|
}
|
|
664
1073
|
let spec_path = agents_dir.join("team.spec.yaml");
|
|
@@ -670,11 +1079,13 @@ pub fn quick_start_with_transport(
|
|
|
670
1079
|
let session_name = spec_session_name(&spec);
|
|
671
1080
|
let resolved_spec_path = std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
|
|
672
1081
|
let state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
|
|
673
|
-
|
|
1082
|
+
let state_team_key = explicit_team_key
|
|
1083
|
+
.unwrap_or_else(|| runtime_team_key_for_spec(&resolved_spec_path, &spec, &session_name));
|
|
1084
|
+
save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key))?;
|
|
674
1085
|
// FIX (rt-host-a real-machine finding): dry_run=false so launch_with_transport calls spawn_agents
|
|
675
1086
|
// and really creates the tmux session + worker windows (was hardcoded true → never spawned, which
|
|
676
1087
|
// also starved the coordinator: no session → first tick TmuxSessionMissing → run_daemon loop exits).
|
|
677
|
-
let launch =
|
|
1088
|
+
let launch = launch_with_transport_in_workspace(&workspace, &spec_path, false, yes, true, transport)?;
|
|
678
1089
|
let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
|
|
679
1090
|
let coordinator_started = crate::coordinator::start_coordinator(&coordinator_workspace)
|
|
680
1091
|
.map(|report| report.ok)
|
|
@@ -684,15 +1095,55 @@ pub fn quick_start_with_transport(
|
|
|
684
1095
|
} else {
|
|
685
1096
|
"coordinator not started"
|
|
686
1097
|
};
|
|
1098
|
+
// BUG-7: build an honest readiness verdict from the post-spawn runtime state.
|
|
1099
|
+
// - If persist_spawn_agent_state (BUG-2 fix) marked any agent non-running, the
|
|
1100
|
+
// team is observably Degraded.
|
|
1101
|
+
// - Otherwise the framework cannot itself verify that the worker's MCP tool set
|
|
1102
|
+
// loaded successfully (provider-side codex/claude schema rejections happen
|
|
1103
|
+
// asynchronously after spawn), so the verdict is PendingToolLoad — never
|
|
1104
|
+
// bare Ready.
|
|
1105
|
+
let worker_readiness = quick_start_worker_readiness(&workspace);
|
|
687
1106
|
Ok(QuickStartReport::Ready {
|
|
688
1107
|
session_name,
|
|
689
1108
|
launch: Box::new(launch),
|
|
690
1109
|
next_actions: vec![format!(
|
|
691
1110
|
"team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
|
|
692
1111
|
)],
|
|
1112
|
+
worker_readiness,
|
|
693
1113
|
})
|
|
694
1114
|
}
|
|
695
1115
|
|
|
1116
|
+
/// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
|
|
1117
|
+
/// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
|
|
1118
|
+
/// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
|
|
1119
|
+
/// `PendingToolLoad` — never bare Ready. State read failure is treated as
|
|
1120
|
+
/// PendingToolLoad rather than fabricated success.
|
|
1121
|
+
fn quick_start_worker_readiness(workspace: &Path) -> QuickStartReadiness {
|
|
1122
|
+
let Ok(state) = load_runtime_state(workspace) else {
|
|
1123
|
+
return QuickStartReadiness::PendingToolLoad;
|
|
1124
|
+
};
|
|
1125
|
+
let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
|
|
1126
|
+
return QuickStartReadiness::PendingToolLoad;
|
|
1127
|
+
};
|
|
1128
|
+
let mut unhealthy: Vec<String> = agents
|
|
1129
|
+
.iter()
|
|
1130
|
+
.filter_map(|(id, agent)| {
|
|
1131
|
+
let status = agent.get("status").and_then(serde_json::Value::as_str);
|
|
1132
|
+
match status {
|
|
1133
|
+
Some("running") => None,
|
|
1134
|
+
_ => Some(id.clone()),
|
|
1135
|
+
}
|
|
1136
|
+
})
|
|
1137
|
+
.collect();
|
|
1138
|
+
if unhealthy.is_empty() {
|
|
1139
|
+
QuickStartReadiness::PendingToolLoad
|
|
1140
|
+
} else {
|
|
1141
|
+
unhealthy.sort();
|
|
1142
|
+
unhealthy.dedup();
|
|
1143
|
+
QuickStartReadiness::Degraded { unhealthy_agents: unhealthy }
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
696
1147
|
/// `detect_inherited_dangerous_permissions`(`launch/config.py`):扫进程祖先链找
|
|
697
1148
|
/// `--dangerously-*` flag,产出危险审批继承态。launch 在 inherited=false 且无 --yes 时拒。
|
|
698
1149
|
pub fn detect_dangerous_approval() -> Result<DangerousApproval, LifecycleError> {
|
|
@@ -908,7 +1359,8 @@ pub fn add_agent(
|
|
|
908
1359
|
let team_dir = selected
|
|
909
1360
|
.spec_workspace
|
|
910
1361
|
.ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
|
|
911
|
-
|
|
1362
|
+
add_agent_with_transport_at_paths(
|
|
1363
|
+
&selected.run_workspace,
|
|
912
1364
|
&team_dir,
|
|
913
1365
|
agent_id,
|
|
914
1366
|
role_file_path,
|
|
@@ -930,11 +1382,31 @@ pub fn add_agent_with_transport(
|
|
|
930
1382
|
) -> Result<AddAgentReport, LifecycleError> {
|
|
931
1383
|
let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
|
|
932
1384
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
1385
|
+
add_agent_with_transport_at_paths(
|
|
1386
|
+
&run_workspace,
|
|
1387
|
+
workspace,
|
|
1388
|
+
agent_id,
|
|
1389
|
+
role_file_path,
|
|
1390
|
+
open_display,
|
|
1391
|
+
team,
|
|
1392
|
+
transport,
|
|
1393
|
+
)
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
fn add_agent_with_transport_at_paths(
|
|
1397
|
+
run_workspace: &Path,
|
|
1398
|
+
team_dir: &Path,
|
|
1399
|
+
agent_id: &AgentId,
|
|
1400
|
+
role_file_path: &Path,
|
|
1401
|
+
open_display: bool,
|
|
1402
|
+
team: Option<&str>,
|
|
1403
|
+
transport: &dyn Transport,
|
|
1404
|
+
) -> Result<AddAgentReport, LifecycleError> {
|
|
933
1405
|
let owner_state = if team.is_some() {
|
|
934
|
-
crate::state::projection::select_runtime_state(
|
|
1406
|
+
crate::state::projection::select_runtime_state(run_workspace, team)
|
|
935
1407
|
.map_err(|e| LifecycleError::TeamSelect(e.to_string()))?
|
|
936
1408
|
} else {
|
|
937
|
-
load_runtime_state(
|
|
1409
|
+
load_runtime_state(run_workspace).map_err(|e| LifecycleError::StatePersist(e.to_string()))?
|
|
938
1410
|
};
|
|
939
1411
|
ensure_owner_allowed_for_state(&owner_state, Some(agent_id))?;
|
|
940
1412
|
if !role_file_path.exists() {
|
|
@@ -943,7 +1415,6 @@ pub fn add_agent_with_transport(
|
|
|
943
1415
|
role_file_path.display()
|
|
944
1416
|
)));
|
|
945
1417
|
}
|
|
946
|
-
let team_dir = workspace;
|
|
947
1418
|
if agent_id_exists_in_team_dir(team_dir, agent_id) {
|
|
948
1419
|
return Err(LifecycleError::RequirementUnmet(format!(
|
|
949
1420
|
"agent id already exists: {agent_id}"
|
|
@@ -958,10 +1429,9 @@ pub fn add_agent_with_transport(
|
|
|
958
1429
|
})?;
|
|
959
1430
|
let (meta, _) = crate::compiler::read_front_matter(&dynamic_role_file)
|
|
960
1431
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
961
|
-
|
|
962
|
-
upsert_agent_state_from_role(&run_ws, agent_id, &meta, &dynamic_role_file)?;
|
|
1432
|
+
upsert_agent_state_from_role(run_workspace, team, agent_id, &meta, &dynamic_role_file)?;
|
|
963
1433
|
let started = crate::lifecycle::restart::start_agent_at_paths(
|
|
964
|
-
|
|
1434
|
+
run_workspace,
|
|
965
1435
|
team_dir,
|
|
966
1436
|
agent_id,
|
|
967
1437
|
false,
|
|
@@ -990,12 +1460,18 @@ pub fn add_agent_with_transport(
|
|
|
990
1460
|
|
|
991
1461
|
fn upsert_agent_state_from_role(
|
|
992
1462
|
workspace: &Path,
|
|
1463
|
+
team: Option<&str>,
|
|
993
1464
|
agent_id: &AgentId,
|
|
994
1465
|
meta: &Value,
|
|
995
1466
|
dynamic_role_file: &Path,
|
|
996
1467
|
) -> Result<(), LifecycleError> {
|
|
997
|
-
let mut state =
|
|
998
|
-
|
|
1468
|
+
let mut state = if team.is_some() {
|
|
1469
|
+
crate::state::projection::select_runtime_state(workspace, team)
|
|
1470
|
+
.map_err(|e| LifecycleError::TeamSelect(e.to_string()))?
|
|
1471
|
+
} else {
|
|
1472
|
+
crate::state::persist::load_runtime_state(workspace)
|
|
1473
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?
|
|
1474
|
+
};
|
|
999
1475
|
if !state.is_object() {
|
|
1000
1476
|
state = serde_json::json!({});
|
|
1001
1477
|
}
|
|
@@ -1040,7 +1516,13 @@ fn upsert_agent_state_from_role(
|
|
|
1040
1516
|
}
|
|
1041
1517
|
}
|
|
1042
1518
|
agent_map.insert(agent_id.as_str().to_string(), entry);
|
|
1043
|
-
|
|
1519
|
+
if team.is_some() {
|
|
1520
|
+
let team_key = explicit_active_team_key(&state)
|
|
1521
|
+
.or_else(|| team.filter(|key| !key.is_empty()).map(str::to_string));
|
|
1522
|
+
save_launched_team_state_for_key(workspace, &state, team_key.as_deref())
|
|
1523
|
+
} else {
|
|
1524
|
+
save_runtime_state(workspace, &state).map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
1525
|
+
}
|
|
1044
1526
|
}
|
|
1045
1527
|
|
|
1046
1528
|
fn materialize_added_role_file(
|
|
@@ -1571,10 +2053,7 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
|
|
|
1571
2053
|
} else {
|
|
1572
2054
|
caller.provider
|
|
1573
2055
|
};
|
|
1574
|
-
let pane_id =
|
|
1575
|
-
.ok()
|
|
1576
|
-
.filter(|pane| !pane.is_empty())
|
|
1577
|
-
.unwrap_or(caller.pane_id);
|
|
2056
|
+
let pane_id = caller.pane_id;
|
|
1578
2057
|
if pane_id.is_empty() {
|
|
1579
2058
|
return false;
|
|
1580
2059
|
}
|
|
@@ -1596,10 +2075,18 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
|
|
|
1596
2075
|
"status": "attached",
|
|
1597
2076
|
"provider": provider,
|
|
1598
2077
|
"pane_id": owner.get("pane_id").cloned().unwrap_or(serde_json::Value::Null),
|
|
2078
|
+
"pane": owner.get("pane_id").cloned().unwrap_or(serde_json::Value::Null),
|
|
1599
2079
|
"leader_session_uuid": owner.get("leader_session_uuid").cloned().unwrap_or(serde_json::Value::Null),
|
|
1600
2080
|
"owner_epoch": owner_epoch,
|
|
1601
2081
|
"discovery": "quick_start",
|
|
1602
2082
|
});
|
|
2083
|
+
let mut receiver = receiver;
|
|
2084
|
+
if let (Some(receiver), Some(socket)) = (
|
|
2085
|
+
receiver.as_object_mut(),
|
|
2086
|
+
crate::tmux_backend::socket_name_from_tmux_env(),
|
|
2087
|
+
) {
|
|
2088
|
+
receiver.insert("tmux_socket".to_string(), serde_json::json!(socket));
|
|
2089
|
+
}
|
|
1603
2090
|
if let Some(obj) = state.as_object_mut() {
|
|
1604
2091
|
obj.insert("leader_receiver".to_string(), receiver);
|
|
1605
2092
|
obj.insert("team_owner".to_string(), owner);
|
|
@@ -1807,10 +2294,12 @@ fn write_launch_permission_audit(
|
|
|
1807
2294
|
}
|
|
1808
2295
|
|
|
1809
2296
|
fn team_workspace(team_dir: &Path) -> PathBuf {
|
|
1810
|
-
team_dir
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
2297
|
+
crate::model::paths::team_workspace(team_dir).unwrap_or_else(|_| {
|
|
2298
|
+
team_dir
|
|
2299
|
+
.parent()
|
|
2300
|
+
.map(Path::to_path_buf)
|
|
2301
|
+
.unwrap_or_else(|| team_dir.to_path_buf())
|
|
2302
|
+
})
|
|
1814
2303
|
}
|
|
1815
2304
|
|
|
1816
2305
|
fn agent_id_exists_in_team_dir(team_dir: &Path, agent_id: &AgentId) -> bool {
|