@team-agent/installer 0.3.0 → 0.3.1
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/emit.rs +178 -51
- package/crates/team-agent/src/cli/mod.rs +83 -17
- package/crates/team-agent/src/coordinator/health.rs +121 -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 +300 -24
- 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 +204 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
- package/crates/team-agent/src/messaging/results.rs +18 -2
- package/crates/team-agent/src/messaging/send.rs +15 -19
- package/crates/team-agent/src/state/identity.rs +3 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
- package/crates/team-agent/src/tmux_backend.rs +58 -6
- package/npm/install.mjs +29 -7
- package/package.json +4 -4
|
@@ -18,7 +18,7 @@ use super::*;
|
|
|
18
18
|
"golden new_owner includes os_user");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
// D2 [BLOCK] — claim-path leader_receiver is golden's
|
|
21
|
+
// D2 [BLOCK] — claim-path leader_receiver is golden's 15 keys in golden order; NO
|
|
22
22
|
// fingerprint/requested_provider/warning. Golden _receiver_from_claim_target (__init__.py:861-877).
|
|
23
23
|
// Rust LeaderReceiver serializes all 17 (no skip_serializing_if) -> 3 always-null extras leak. RED.
|
|
24
24
|
// (The POPULATED tmux values session_name/window_*/pane_* come from the caller-target scan — a
|
|
@@ -26,9 +26,12 @@ use super::*;
|
|
|
26
26
|
// locked here are unchanged by that scan.)
|
|
27
27
|
#[test]
|
|
28
28
|
#[serial_test::serial(env)]
|
|
29
|
-
fn
|
|
29
|
+
fn d2_claim_leader_receiver_is_fifteen_golden_keys_in_order_no_extras() {
|
|
30
30
|
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
31
|
-
let _e = EnvGuard::apply(&[
|
|
31
|
+
let _e = EnvGuard::apply(&[
|
|
32
|
+
("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
|
|
33
|
+
("TMUX", Some("/tmp/tmux-501/default,123,0")),
|
|
34
|
+
]);
|
|
32
35
|
let ws = p2_temp_ws("d2_recv_keys");
|
|
33
36
|
let mut state = serde_json::json!({"session_name": "team-agent-x"});
|
|
34
37
|
let r = claim_lease_no_incident(&ws, &mut state, None, &TeamKey::new("current"),
|
|
@@ -42,9 +45,9 @@ use super::*;
|
|
|
42
45
|
let keys: Vec<&str> = recv.keys().map(String::as_str).collect();
|
|
43
46
|
assert_eq!(keys, vec![
|
|
44
47
|
"mode","status","provider","pane_id","session_name","window_index","window_name",
|
|
45
|
-
"pane_index","pane_tty","pane_current_command","
|
|
46
|
-
"attached_at","discovery",
|
|
47
|
-
], "golden _receiver_from_claim_target
|
|
48
|
+
"pane_index","pane_tty","pane_current_command","tmux_socket","leader_session_uuid",
|
|
49
|
+
"owner_epoch","attached_at","discovery",
|
|
50
|
+
], "golden _receiver_from_claim_target 15-key set + ORDER (__init__.py:861-877 + BUG-4 socket-qualified receiver)");
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
// D2 seam — the caller-target SCAN that fills session_name/window_index/window_name/pane_index/
|
|
@@ -197,6 +197,7 @@ use super::*;
|
|
|
197
197
|
pane_index: Some("2".into()),
|
|
198
198
|
pane_tty: Some("/dev/ttys001".into()),
|
|
199
199
|
pane_current_command: Some("claude".into()),
|
|
200
|
+
tmux_socket: None,
|
|
200
201
|
fingerprint: Some("fp".into()),
|
|
201
202
|
leader_session_uuid: Some(uuid("fp", "/ws", "u", "default")),
|
|
202
203
|
owner_epoch: Some(OwnerEpoch(3)),
|
|
@@ -140,6 +140,163 @@ use super::*;
|
|
|
140
140
|
assert!(r.reason.is_none(), "already-bound path carries no acquire reason");
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
#[test]
|
|
144
|
+
#[serial_test::serial(env)]
|
|
145
|
+
fn claim_leader_persists_full_tmux_endpoint_when_tmux_tmpdir_differs_from_coordinator() {
|
|
146
|
+
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
147
|
+
let leader_socket = "/tmp/ta-leader-root/tmux-501/dl2f";
|
|
148
|
+
let _e = EnvGuard::apply(&[
|
|
149
|
+
("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
|
|
150
|
+
("TMUX", Some("/tmp/ta-leader-root/tmux-501/dl2f,12345,0")),
|
|
151
|
+
("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
|
|
152
|
+
]);
|
|
153
|
+
let ws = p2_temp_ws("claim_full_endpoint");
|
|
154
|
+
let team_id = TeamKey::new("current");
|
|
155
|
+
let caller = PaneId::new("%5");
|
|
156
|
+
let mut state = serde_json::json!({"session_name": "team-agent-x"});
|
|
157
|
+
let event_log = crate::event_log::EventLog::new(&ws);
|
|
158
|
+
let live = seeded_liveness(&["%5"]);
|
|
159
|
+
|
|
160
|
+
let r = claim_lease_no_incident(
|
|
161
|
+
&ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
|
|
162
|
+
)
|
|
163
|
+
.unwrap();
|
|
164
|
+
|
|
165
|
+
assert!(r.ok);
|
|
166
|
+
assert_eq!(r.status, LeaseStatus::Claimed);
|
|
167
|
+
assert_eq!(
|
|
168
|
+
r.receiver.as_ref().and_then(|receiver| receiver.tmux_socket.as_deref()),
|
|
169
|
+
Some(leader_socket),
|
|
170
|
+
"claim-leader must persist the full $TMUX socket path, not only the -L short name"
|
|
171
|
+
);
|
|
172
|
+
let persisted: serde_json::Value = serde_json::from_str(
|
|
173
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
174
|
+
)
|
|
175
|
+
.unwrap();
|
|
176
|
+
assert_eq!(
|
|
177
|
+
persisted["leader_receiver"]["tmux_socket"],
|
|
178
|
+
serde_json::json!(leader_socket),
|
|
179
|
+
"state.json must carry enough endpoint information for later delivery to use tmux -S"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[test]
|
|
184
|
+
#[serial_test::serial(env)]
|
|
185
|
+
fn claim_leader_already_bound_requires_same_delivery_endpoint_not_only_same_pane_id() {
|
|
186
|
+
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
187
|
+
let current_socket = "/tmp/ta-current-leader-root/tmux-501/dl2f";
|
|
188
|
+
let stale_socket = "/tmp/ta-stale-leader-root/tmux-501/dl2f";
|
|
189
|
+
let _e = EnvGuard::apply(&[
|
|
190
|
+
("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
|
|
191
|
+
("TMUX", Some("/tmp/ta-current-leader-root/tmux-501/dl2f,12345,0")),
|
|
192
|
+
("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
|
|
193
|
+
]);
|
|
194
|
+
let ws = p2_temp_ws("claim_bound_endpoint");
|
|
195
|
+
let team_id = TeamKey::new("current");
|
|
196
|
+
let caller = PaneId::new("%5");
|
|
197
|
+
let mut state = serde_json::json!({
|
|
198
|
+
"session_name": "team-agent-x",
|
|
199
|
+
"team_owner": {
|
|
200
|
+
"pane_id":"%5",
|
|
201
|
+
"provider":"codex",
|
|
202
|
+
"machine_fingerprint":"fp",
|
|
203
|
+
"leader_session_uuid":"U",
|
|
204
|
+
"owner_epoch":1,
|
|
205
|
+
"claimed_at":"t",
|
|
206
|
+
"claimed_via":"claim-leader",
|
|
207
|
+
"tmux_socket": stale_socket
|
|
208
|
+
},
|
|
209
|
+
"leader_receiver": {
|
|
210
|
+
"pane_id":"%5",
|
|
211
|
+
"owner_epoch":1,
|
|
212
|
+
"leader_session_uuid":"U",
|
|
213
|
+
"tmux_socket": stale_socket
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
let event_log = crate::event_log::EventLog::new(&ws);
|
|
217
|
+
let live = seeded_liveness(&["%5"]);
|
|
218
|
+
|
|
219
|
+
let r = claim_lease_no_incident(
|
|
220
|
+
&ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
|
|
221
|
+
)
|
|
222
|
+
.unwrap();
|
|
223
|
+
|
|
224
|
+
assert_ne!(
|
|
225
|
+
r.status,
|
|
226
|
+
LeaseStatus::AlreadyBound,
|
|
227
|
+
"already_bound must verify the same delivery endpoint is reachable; matching bare pane \
|
|
228
|
+
ids are not sufficient because different tmux socket roots can both have %5"
|
|
229
|
+
);
|
|
230
|
+
assert_eq!(
|
|
231
|
+
r.receiver.as_ref().and_then(|receiver| receiver.tmux_socket.as_deref()),
|
|
232
|
+
Some(current_socket),
|
|
233
|
+
"claim should refresh the receiver to the caller's current full tmux endpoint"
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#[test]
|
|
238
|
+
#[serial_test::serial(env)]
|
|
239
|
+
fn claim_leader_already_bound_normalizes_short_tmux_endpoint_to_full_path() {
|
|
240
|
+
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
241
|
+
let current_socket = "/tmp/ta-current-leader-root/tmux-501/dl9aa40c88";
|
|
242
|
+
let short_socket = "dl9aa40c88";
|
|
243
|
+
let _e = EnvGuard::apply(&[
|
|
244
|
+
("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
|
|
245
|
+
("TMUX", Some("/tmp/ta-current-leader-root/tmux-501/dl9aa40c88,12345,0")),
|
|
246
|
+
("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
|
|
247
|
+
]);
|
|
248
|
+
let ws = p2_temp_ws("claim_bound_short_endpoint");
|
|
249
|
+
let team_id = TeamKey::new("current");
|
|
250
|
+
let caller = PaneId::new("%5");
|
|
251
|
+
let mut state = serde_json::json!({
|
|
252
|
+
"session_name": "team-agent-x",
|
|
253
|
+
"team_owner": {
|
|
254
|
+
"pane_id":"%5",
|
|
255
|
+
"provider":"codex",
|
|
256
|
+
"machine_fingerprint":"fp",
|
|
257
|
+
"leader_session_uuid":"U",
|
|
258
|
+
"owner_epoch":1,
|
|
259
|
+
"claimed_at":"t",
|
|
260
|
+
"claimed_via":"claim-leader",
|
|
261
|
+
"tmux_socket": short_socket
|
|
262
|
+
},
|
|
263
|
+
"leader_receiver": {
|
|
264
|
+
"pane_id":"%5",
|
|
265
|
+
"owner_epoch":1,
|
|
266
|
+
"leader_session_uuid":"U",
|
|
267
|
+
"tmux_socket": short_socket
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
let event_log = crate::event_log::EventLog::new(&ws);
|
|
271
|
+
let live = seeded_liveness(&["%5"]);
|
|
272
|
+
|
|
273
|
+
let r = claim_lease_no_incident(
|
|
274
|
+
&ws, &mut state, None, &team_id, &caller, false, &event_log, &live,
|
|
275
|
+
)
|
|
276
|
+
.unwrap();
|
|
277
|
+
|
|
278
|
+
assert_ne!(
|
|
279
|
+
r.status,
|
|
280
|
+
LeaseStatus::AlreadyBound,
|
|
281
|
+
"claim already_bound must not treat a stored short socket name as equivalent to the \
|
|
282
|
+
caller's full $TMUX endpoint by basename; it must refresh/normalize the receiver"
|
|
283
|
+
);
|
|
284
|
+
assert_eq!(
|
|
285
|
+
r.receiver.as_ref().and_then(|receiver| receiver.tmux_socket.as_deref()),
|
|
286
|
+
Some(current_socket),
|
|
287
|
+
"claim should rewrite any legacy short leader_receiver.tmux_socket to the full physical endpoint"
|
|
288
|
+
);
|
|
289
|
+
let persisted: serde_json::Value = serde_json::from_str(
|
|
290
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
291
|
+
)
|
|
292
|
+
.unwrap();
|
|
293
|
+
assert_eq!(
|
|
294
|
+
persisted["leader_receiver"]["tmux_socket"],
|
|
295
|
+
serde_json::json!(current_socket),
|
|
296
|
+
"state must not preserve a short endpoint after explicit claim; state={persisted}"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
143
300
|
// RED — NOT-IN-TMUX-PANE: empty caller pane → refused not_in_tmux_pane with
|
|
144
301
|
// the EXACT golden action string (differs from the current claim_leader stub
|
|
145
302
|
// string). golden /tmp/probe_claim.py _lease_refused("not_in_tmux_pane",...).
|
|
@@ -238,6 +238,8 @@ pub struct LeaderReceiver {
|
|
|
238
238
|
pub pane_index: Option<String>,
|
|
239
239
|
pub pane_tty: Option<String>,
|
|
240
240
|
pub pane_current_command: Option<String>,
|
|
241
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
242
|
+
pub tmux_socket: Option<String>,
|
|
241
243
|
/// `_target_fingerprint(pane_info)`。
|
|
242
244
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
243
245
|
pub fingerprint: Option<String>,
|
|
@@ -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};
|
|
@@ -82,7 +82,7 @@ pub fn launch_with_transport(
|
|
|
82
82
|
Vec::new()
|
|
83
83
|
} else {
|
|
84
84
|
let started = spawn_agents(spec_path, &spec, &session_name, &safety, transport)?;
|
|
85
|
-
persist_spawn_agent_state(spec_path, &spec)?;
|
|
85
|
+
persist_spawn_agent_state(spec_path, &spec, &session_name, transport, &started)?;
|
|
86
86
|
started
|
|
87
87
|
};
|
|
88
88
|
Ok(LaunchReport {
|
|
@@ -134,10 +134,14 @@ fn spawn_agents(
|
|
|
134
134
|
let role = agent.get("role").and_then(Value::as_str);
|
|
135
135
|
let tools = worker_tool_refs(agent_tool_strings(agent), safety);
|
|
136
136
|
let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
|
|
137
|
+
let mcp_team_id =
|
|
138
|
+
runtime_active_team_key_for_spawn(&workspace, spec_path, spec, session_name);
|
|
139
|
+
let process_team_id = process_team_id_for_spawn(&workspace, spec);
|
|
137
140
|
let mcp_config = adapter
|
|
138
141
|
.mcp_config(auth_mode)
|
|
139
142
|
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
140
|
-
let
|
|
143
|
+
let mcp_config = resolve_mcp_config(mcp_config, &workspace, agent_id_raw, &mcp_team_id);
|
|
144
|
+
let mcp_config_path = write_worker_mcp_config(&workspace, agent_id_raw, &mcp_config)?;
|
|
141
145
|
let mut argv = adapter
|
|
142
146
|
.build_command_with_tools(
|
|
143
147
|
auth_mode,
|
|
@@ -147,12 +151,18 @@ fn spawn_agents(
|
|
|
147
151
|
&tool_refs,
|
|
148
152
|
)
|
|
149
153
|
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
150
|
-
|
|
154
|
+
point_native_mcp_config_at_file(&mut argv, provider, &mcp_config_path);
|
|
155
|
+
fill_spawn_placeholders_full(
|
|
156
|
+
&mut argv,
|
|
157
|
+
&workspace,
|
|
158
|
+
agent_id_raw,
|
|
159
|
+
process_team_id.as_deref(),
|
|
160
|
+
);
|
|
151
161
|
let window = WindowName::new(agent_id_raw);
|
|
152
162
|
let env = inherited_env_with_team_overrides(
|
|
153
163
|
&workspace,
|
|
154
164
|
agent_id_raw,
|
|
155
|
-
|
|
165
|
+
process_team_id.as_deref(),
|
|
156
166
|
);
|
|
157
167
|
let spawn = if started.is_empty() {
|
|
158
168
|
transport.spawn_first(session_name, &window, &argv, team_dir, &env)
|
|
@@ -166,6 +176,9 @@ fn spawn_agents(
|
|
|
166
176
|
30,
|
|
167
177
|
0.5,
|
|
168
178
|
);
|
|
179
|
+
if matches!(transport.liveness(&spawn.pane_id), Ok(PaneLiveness::Dead)) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
169
182
|
started.push(StartedAgent {
|
|
170
183
|
agent_id,
|
|
171
184
|
start_mode: StartMode::Fresh,
|
|
@@ -180,7 +193,13 @@ fn spawn_agents(
|
|
|
180
193
|
Ok(started)
|
|
181
194
|
}
|
|
182
195
|
|
|
183
|
-
fn persist_spawn_agent_state(
|
|
196
|
+
fn persist_spawn_agent_state(
|
|
197
|
+
spec_path: &Path,
|
|
198
|
+
spec: &Value,
|
|
199
|
+
session_name: &SessionName,
|
|
200
|
+
transport: &dyn Transport,
|
|
201
|
+
started: &[StartedAgent],
|
|
202
|
+
) -> Result<(), LifecycleError> {
|
|
184
203
|
let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
|
|
185
204
|
let workspace = team_workspace(team_dir);
|
|
186
205
|
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
@@ -192,6 +211,26 @@ fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), Lifec
|
|
|
192
211
|
} else {
|
|
193
212
|
serde_json::json!({"agents": {}})
|
|
194
213
|
};
|
|
214
|
+
let team_id = explicit_active_team_key(&state)
|
|
215
|
+
.unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name));
|
|
216
|
+
let worker_tmux_socket = launched_worker_tmux_socket(transport, &workspace);
|
|
217
|
+
drop_worker_pane_seeded_owner(
|
|
218
|
+
&mut state,
|
|
219
|
+
&team_id,
|
|
220
|
+
started,
|
|
221
|
+
worker_tmux_socket.as_deref(),
|
|
222
|
+
);
|
|
223
|
+
// Only persist running state for agents whose spawn still has a live target.
|
|
224
|
+
let live_windows: BTreeSet<String> = transport
|
|
225
|
+
.list_windows(session_name)
|
|
226
|
+
.unwrap_or_default()
|
|
227
|
+
.into_iter()
|
|
228
|
+
.map(|w| w.as_str().to_string())
|
|
229
|
+
.collect();
|
|
230
|
+
let live_started_agents: BTreeSet<String> = started
|
|
231
|
+
.iter()
|
|
232
|
+
.map(|agent| agent.agent_id.as_str().to_string())
|
|
233
|
+
.collect();
|
|
195
234
|
let mut agents = serde_json::Map::new();
|
|
196
235
|
let spawned_at = spawn_timestamp();
|
|
197
236
|
for agent in spec_agent_values(spec) {
|
|
@@ -210,9 +249,25 @@ fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), Lifec
|
|
|
210
249
|
agents.insert(id.to_string(), serde_json::Value::Object(paused));
|
|
211
250
|
continue;
|
|
212
251
|
}
|
|
252
|
+
let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
|
|
253
|
+
if !live_started_agents.contains(id)
|
|
254
|
+
|| (!live_windows.is_empty() && !live_windows.contains(window))
|
|
255
|
+
{
|
|
256
|
+
let mut failed = serde_json::Map::new();
|
|
257
|
+
failed.insert("status".to_string(), serde_json::json!("spawn_failed"));
|
|
258
|
+
failed.insert("provider".to_string(), serde_json::json!(provider));
|
|
259
|
+
failed.insert("agent_id".to_string(), serde_json::json!(id));
|
|
260
|
+
failed.insert("window".to_string(), serde_json::json!(window));
|
|
261
|
+
failed.insert(
|
|
262
|
+
"reason".to_string(),
|
|
263
|
+
serde_json::json!("tmux window not present after spawn"),
|
|
264
|
+
);
|
|
265
|
+
agents.insert(id.to_string(), serde_json::Value::Object(failed));
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
213
268
|
agents.insert(
|
|
214
269
|
id.to_string(),
|
|
215
|
-
running_agent_state(agent, id, provider, &workspace, &spawned_at)?,
|
|
270
|
+
running_agent_state(agent, id, provider, &workspace, &spawned_at, &team_id)?,
|
|
216
271
|
);
|
|
217
272
|
}
|
|
218
273
|
if let Some(obj) = state.as_object_mut() {
|
|
@@ -287,6 +342,69 @@ fn drop_foreign_seeded_owner(existing: &serde_json::Value, launched_key: &str, l
|
|
|
287
342
|
}
|
|
288
343
|
}
|
|
289
344
|
|
|
345
|
+
fn drop_worker_pane_seeded_owner(
|
|
346
|
+
launched: &mut serde_json::Value,
|
|
347
|
+
launched_key: &str,
|
|
348
|
+
started: &[StartedAgent],
|
|
349
|
+
worker_tmux_socket: Option<&str>,
|
|
350
|
+
) {
|
|
351
|
+
let Some(pane) = launched
|
|
352
|
+
.get("team_owner")
|
|
353
|
+
.and_then(|owner| owner.get("pane_id"))
|
|
354
|
+
.and_then(serde_json::Value::as_str)
|
|
355
|
+
.filter(|pane| !pane.is_empty())
|
|
356
|
+
else {
|
|
357
|
+
return;
|
|
358
|
+
};
|
|
359
|
+
let leader_pane = std::env::var("TEAM_AGENT_LEADER_PANE_ID")
|
|
360
|
+
.ok()
|
|
361
|
+
.filter(|value| !value.is_empty());
|
|
362
|
+
let tmux_pane = std::env::var("TMUX_PANE")
|
|
363
|
+
.ok()
|
|
364
|
+
.filter(|value| !value.is_empty());
|
|
365
|
+
let has_leader_identity_env = leader_pane.is_some()
|
|
366
|
+
|| env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID")
|
|
367
|
+
|| env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE")
|
|
368
|
+
|| env_nonempty("TEAM_AGENT_LEADER_PROVIDER")
|
|
369
|
+
|| env_nonempty("TEAM_AGENT_ID")
|
|
370
|
+
|| env_nonempty("TEAM_AGENT_TEAM_ID");
|
|
371
|
+
let seeded_from_bare_tmux =
|
|
372
|
+
!has_leader_identity_env && tmux_pane.as_deref() == Some(pane);
|
|
373
|
+
let caller_tmux_socket = crate::tmux_backend::socket_name_from_tmux_env();
|
|
374
|
+
if seeded_from_bare_tmux
|
|
375
|
+
&& tmux_sockets_match_or_unknown(caller_tmux_socket.as_deref(), worker_tmux_socket)
|
|
376
|
+
&& started.iter().any(|agent| agent.target == pane)
|
|
377
|
+
{
|
|
378
|
+
seed_unbound_launched_owner(launched, launched_key);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
fn launched_worker_tmux_socket(
|
|
383
|
+
transport: &dyn Transport,
|
|
384
|
+
workspace: &Path,
|
|
385
|
+
) -> Option<String> {
|
|
386
|
+
if matches!(transport.kind(), crate::transport::BackendKind::Tmux) {
|
|
387
|
+
Some(crate::tmux_backend::socket_name_for_workspace(workspace))
|
|
388
|
+
} else {
|
|
389
|
+
None
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
fn tmux_sockets_match_or_unknown(
|
|
394
|
+
caller_socket: Option<&str>,
|
|
395
|
+
worker_socket: Option<&str>,
|
|
396
|
+
) -> bool {
|
|
397
|
+
match (caller_socket, worker_socket) {
|
|
398
|
+
(Some(caller), Some(worker)) => caller == worker,
|
|
399
|
+
(Some(_), None) => false,
|
|
400
|
+
(None, _) => true,
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
fn env_nonempty(key: &str) -> bool {
|
|
405
|
+
std::env::var(key).ok().is_some_and(|value| !value.is_empty())
|
|
406
|
+
}
|
|
407
|
+
|
|
290
408
|
fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
|
|
291
409
|
let provider = launched
|
|
292
410
|
.get("team_owner")
|
|
@@ -363,6 +481,7 @@ fn running_agent_state(
|
|
|
363
481
|
provider: Provider,
|
|
364
482
|
workspace: &Path,
|
|
365
483
|
spawned_at: &str,
|
|
484
|
+
team_id: &str,
|
|
366
485
|
) -> Result<serde_json::Value, LifecycleError> {
|
|
367
486
|
let model = agent.get("model").and_then(Value::as_str);
|
|
368
487
|
let auth_mode = agent
|
|
@@ -372,6 +491,11 @@ fn running_agent_state(
|
|
|
372
491
|
.unwrap_or(AuthMode::Subscription);
|
|
373
492
|
let profile = agent.get("profile").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null);
|
|
374
493
|
let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
|
|
494
|
+
let mcp_config = crate::provider::get_adapter(provider)
|
|
495
|
+
.mcp_config(auth_mode)
|
|
496
|
+
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
497
|
+
let mcp_config = resolve_mcp_config(mcp_config, workspace, id, team_id);
|
|
498
|
+
let mcp_config_path = write_worker_mcp_config(workspace, id, &mcp_config)?;
|
|
375
499
|
let mut state = serde_json::Map::new();
|
|
376
500
|
state.insert("status".to_string(), serde_json::json!("running"));
|
|
377
501
|
state.insert("provider".to_string(), serde_json::json!(provider));
|
|
@@ -382,13 +506,7 @@ fn running_agent_state(
|
|
|
382
506
|
state.insert("window".to_string(), serde_json::json!(window));
|
|
383
507
|
state.insert(
|
|
384
508
|
"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
|
-
),
|
|
509
|
+
serde_json::json!(mcp_config_path.to_string_lossy().to_string()),
|
|
392
510
|
);
|
|
393
511
|
state.insert(
|
|
394
512
|
"permissions".to_string(),
|
|
@@ -408,6 +526,80 @@ fn running_agent_state(
|
|
|
408
526
|
Ok(serde_json::Value::Object(state))
|
|
409
527
|
}
|
|
410
528
|
|
|
529
|
+
fn resolve_mcp_config(
|
|
530
|
+
config: crate::provider::McpConfig,
|
|
531
|
+
workspace: &Path,
|
|
532
|
+
agent_id: &str,
|
|
533
|
+
team_id: &str,
|
|
534
|
+
) -> crate::provider::McpConfig {
|
|
535
|
+
crate::provider::McpConfig {
|
|
536
|
+
raw: resolve_mcp_placeholders(config.raw, workspace, agent_id, team_id),
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
fn resolve_mcp_placeholders(
|
|
541
|
+
value: serde_json::Value,
|
|
542
|
+
workspace: &Path,
|
|
543
|
+
agent_id: &str,
|
|
544
|
+
team_id: &str,
|
|
545
|
+
) -> serde_json::Value {
|
|
546
|
+
match value {
|
|
547
|
+
serde_json::Value::String(s) => serde_json::Value::String(
|
|
548
|
+
s.replace("{workspace}", &workspace.to_string_lossy())
|
|
549
|
+
.replace("{agent_id}", agent_id)
|
|
550
|
+
.replace("{team_id}", team_id),
|
|
551
|
+
),
|
|
552
|
+
serde_json::Value::Array(items) => serde_json::Value::Array(
|
|
553
|
+
items
|
|
554
|
+
.into_iter()
|
|
555
|
+
.map(|item| resolve_mcp_placeholders(item, workspace, agent_id, team_id))
|
|
556
|
+
.collect(),
|
|
557
|
+
),
|
|
558
|
+
serde_json::Value::Object(map) => serde_json::Value::Object(
|
|
559
|
+
map.into_iter()
|
|
560
|
+
.map(|(key, value)| {
|
|
561
|
+
(
|
|
562
|
+
key,
|
|
563
|
+
resolve_mcp_placeholders(value, workspace, agent_id, team_id),
|
|
564
|
+
)
|
|
565
|
+
})
|
|
566
|
+
.collect(),
|
|
567
|
+
),
|
|
568
|
+
other => other,
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
fn write_worker_mcp_config(
|
|
573
|
+
workspace: &Path,
|
|
574
|
+
agent_id: &str,
|
|
575
|
+
config: &crate::provider::McpConfig,
|
|
576
|
+
) -> Result<PathBuf, LifecycleError> {
|
|
577
|
+
let path = workspace
|
|
578
|
+
.join(".team/runtime/mcp")
|
|
579
|
+
.join(format!("{agent_id}.json"));
|
|
580
|
+
if let Some(parent) = path.parent() {
|
|
581
|
+
std::fs::create_dir_all(parent)
|
|
582
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
|
|
583
|
+
}
|
|
584
|
+
let body = serde_json::to_string_pretty(&serde_json::json!({"mcpServers": config.raw}))
|
|
585
|
+
.map_err(|e| LifecycleError::StatePersist(format!("serialize mcp config: {e}")))?;
|
|
586
|
+
std::fs::write(&path, body)
|
|
587
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
|
|
588
|
+
Ok(path)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
fn point_native_mcp_config_at_file(argv: &mut [String], provider: Provider, path: &Path) {
|
|
592
|
+
if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
let Some(index) = argv.iter().position(|arg| arg == "--mcp-config") else {
|
|
596
|
+
return;
|
|
597
|
+
};
|
|
598
|
+
if let Some(value) = argv.get_mut(index.saturating_add(1)) {
|
|
599
|
+
*value = path.to_string_lossy().to_string();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
411
603
|
fn permissions_json(
|
|
412
604
|
agent: &Value,
|
|
413
605
|
id: &str,
|
|
@@ -565,6 +757,44 @@ fn spec_team_id(spec: &Value) -> Option<String> {
|
|
|
565
757
|
})
|
|
566
758
|
}
|
|
567
759
|
|
|
760
|
+
fn runtime_active_team_key_for_spawn(
|
|
761
|
+
workspace: &Path,
|
|
762
|
+
spec_path: &Path,
|
|
763
|
+
spec: &Value,
|
|
764
|
+
session_name: &SessionName,
|
|
765
|
+
) -> String {
|
|
766
|
+
load_runtime_state(workspace)
|
|
767
|
+
.ok()
|
|
768
|
+
.and_then(|state| explicit_active_team_key(&state))
|
|
769
|
+
.unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name))
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
fn process_team_id_for_spawn(workspace: &Path, spec: &Value) -> Option<String> {
|
|
773
|
+
load_runtime_state(workspace)
|
|
774
|
+
.ok()
|
|
775
|
+
.and_then(|state| explicit_active_team_key(&state))
|
|
776
|
+
.or_else(|| spec_team_id(spec))
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
fn explicit_active_team_key(state: &serde_json::Value) -> Option<String> {
|
|
780
|
+
state
|
|
781
|
+
.get("active_team_key")
|
|
782
|
+
.and_then(serde_json::Value::as_str)
|
|
783
|
+
.filter(|team| !team.is_empty() && *team != "current")
|
|
784
|
+
.map(str::to_string)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
fn runtime_team_key_for_spec(spec_path: &Path, spec: &Value, session_name: &SessionName) -> String {
|
|
788
|
+
let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
|
|
789
|
+
let state = serde_json::json!({
|
|
790
|
+
"team_dir": team_dir.to_string_lossy(),
|
|
791
|
+
"spec_path": spec_path.to_string_lossy(),
|
|
792
|
+
"session_name": session_name.as_str(),
|
|
793
|
+
"team": spec.get("team").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null),
|
|
794
|
+
});
|
|
795
|
+
crate::state::projection::team_state_key(&state)
|
|
796
|
+
}
|
|
797
|
+
|
|
568
798
|
fn transport_has_session(transport: &dyn Transport, session_name: &SessionName) -> bool {
|
|
569
799
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
570
800
|
transport.has_session(session_name)
|
|
@@ -684,15 +914,55 @@ pub fn quick_start_with_transport(
|
|
|
684
914
|
} else {
|
|
685
915
|
"coordinator not started"
|
|
686
916
|
};
|
|
917
|
+
// BUG-7: build an honest readiness verdict from the post-spawn runtime state.
|
|
918
|
+
// - If persist_spawn_agent_state (BUG-2 fix) marked any agent non-running, the
|
|
919
|
+
// team is observably Degraded.
|
|
920
|
+
// - Otherwise the framework cannot itself verify that the worker's MCP tool set
|
|
921
|
+
// loaded successfully (provider-side codex/claude schema rejections happen
|
|
922
|
+
// asynchronously after spawn), so the verdict is PendingToolLoad — never
|
|
923
|
+
// bare Ready.
|
|
924
|
+
let worker_readiness = quick_start_worker_readiness(&workspace);
|
|
687
925
|
Ok(QuickStartReport::Ready {
|
|
688
926
|
session_name,
|
|
689
927
|
launch: Box::new(launch),
|
|
690
928
|
next_actions: vec![format!(
|
|
691
929
|
"team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
|
|
692
930
|
)],
|
|
931
|
+
worker_readiness,
|
|
693
932
|
})
|
|
694
933
|
}
|
|
695
934
|
|
|
935
|
+
/// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
|
|
936
|
+
/// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
|
|
937
|
+
/// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
|
|
938
|
+
/// `PendingToolLoad` — never bare Ready. State read failure is treated as
|
|
939
|
+
/// PendingToolLoad rather than fabricated success.
|
|
940
|
+
fn quick_start_worker_readiness(workspace: &Path) -> QuickStartReadiness {
|
|
941
|
+
let Ok(state) = load_runtime_state(workspace) else {
|
|
942
|
+
return QuickStartReadiness::PendingToolLoad;
|
|
943
|
+
};
|
|
944
|
+
let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
|
|
945
|
+
return QuickStartReadiness::PendingToolLoad;
|
|
946
|
+
};
|
|
947
|
+
let mut unhealthy: Vec<String> = agents
|
|
948
|
+
.iter()
|
|
949
|
+
.filter_map(|(id, agent)| {
|
|
950
|
+
let status = agent.get("status").and_then(serde_json::Value::as_str);
|
|
951
|
+
match status {
|
|
952
|
+
Some("running") => None,
|
|
953
|
+
_ => Some(id.clone()),
|
|
954
|
+
}
|
|
955
|
+
})
|
|
956
|
+
.collect();
|
|
957
|
+
if unhealthy.is_empty() {
|
|
958
|
+
QuickStartReadiness::PendingToolLoad
|
|
959
|
+
} else {
|
|
960
|
+
unhealthy.sort();
|
|
961
|
+
unhealthy.dedup();
|
|
962
|
+
QuickStartReadiness::Degraded { unhealthy_agents: unhealthy }
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
696
966
|
/// `detect_inherited_dangerous_permissions`(`launch/config.py`):扫进程祖先链找
|
|
697
967
|
/// `--dangerously-*` flag,产出危险审批继承态。launch 在 inherited=false 且无 --yes 时拒。
|
|
698
968
|
pub fn detect_dangerous_approval() -> Result<DangerousApproval, LifecycleError> {
|
|
@@ -1571,10 +1841,7 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
|
|
|
1571
1841
|
} else {
|
|
1572
1842
|
caller.provider
|
|
1573
1843
|
};
|
|
1574
|
-
let pane_id =
|
|
1575
|
-
.ok()
|
|
1576
|
-
.filter(|pane| !pane.is_empty())
|
|
1577
|
-
.unwrap_or(caller.pane_id);
|
|
1844
|
+
let pane_id = caller.pane_id;
|
|
1578
1845
|
if pane_id.is_empty() {
|
|
1579
1846
|
return false;
|
|
1580
1847
|
}
|
|
@@ -1600,6 +1867,13 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
|
|
|
1600
1867
|
"owner_epoch": owner_epoch,
|
|
1601
1868
|
"discovery": "quick_start",
|
|
1602
1869
|
});
|
|
1870
|
+
let mut receiver = receiver;
|
|
1871
|
+
if let (Some(receiver), Some(socket)) = (
|
|
1872
|
+
receiver.as_object_mut(),
|
|
1873
|
+
crate::tmux_backend::socket_name_from_tmux_env(),
|
|
1874
|
+
) {
|
|
1875
|
+
receiver.insert("tmux_socket".to_string(), serde_json::json!(socket));
|
|
1876
|
+
}
|
|
1603
1877
|
if let Some(obj) = state.as_object_mut() {
|
|
1604
1878
|
obj.insert("leader_receiver".to_string(), receiver);
|
|
1605
1879
|
obj.insert("team_owner".to_string(), owner);
|
|
@@ -1807,10 +2081,12 @@ fn write_launch_permission_audit(
|
|
|
1807
2081
|
}
|
|
1808
2082
|
|
|
1809
2083
|
fn team_workspace(team_dir: &Path) -> PathBuf {
|
|
1810
|
-
team_dir
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
2084
|
+
crate::model::paths::team_workspace(team_dir).unwrap_or_else(|_| {
|
|
2085
|
+
team_dir
|
|
2086
|
+
.parent()
|
|
2087
|
+
.map(Path::to_path_buf)
|
|
2088
|
+
.unwrap_or_else(|| team_dir.to_path_buf())
|
|
2089
|
+
})
|
|
1814
2090
|
}
|
|
1815
2091
|
|
|
1816
2092
|
fn agent_id_exists_in_team_dir(team_dir: &Path, agent_id: &AgentId) -> bool {
|