@team-agent/installer 0.3.12 → 0.3.14
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 +23 -0
- package/crates/team-agent/src/cli/leader.rs +2 -5
- package/crates/team-agent/src/cli/mod.rs +258 -79
- package/crates/team-agent/src/cli/tests/compile.rs +69 -0
- package/crates/team-agent/src/cli/tests/main_preserved.rs +68 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +160 -2
- package/crates/team-agent/src/compiler.rs +30 -0
- package/crates/team-agent/src/coordinator/tick.rs +16 -83
- package/crates/team-agent/src/leader/lease.rs +4 -20
- package/crates/team-agent/src/leader/mod.rs +3 -1
- package/crates/team-agent/src/leader/owner_bind.rs +46 -48
- package/crates/team-agent/src/leader/provider_attribution.rs +214 -0
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -3
- package/crates/team-agent/src/leader/rediscover.rs +34 -17
- package/crates/team-agent/src/leader/start.rs +279 -4
- package/crates/team-agent/src/lifecycle/launch.rs +279 -24
- package/crates/team-agent/src/lifecycle/restart/common.rs +50 -40
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +343 -49
- package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
- package/crates/team-agent/src/lifecycle/tests/core.rs +181 -45
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +390 -61
- package/crates/team-agent/src/lifecycle/types.rs +28 -0
- package/crates/team-agent/src/messaging/delivery.rs +5 -1
- package/crates/team-agent/src/messaging/helpers.rs +1 -1
- package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
- package/crates/team-agent/src/provider/adapter.rs +6 -3
- package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
- package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
- package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
- package/crates/team-agent/src/tmux_backend.rs +5 -0
- package/crates/team-agent/src/transport/test_support.rs +22 -7
- package/crates/team-agent/src/transport.rs +4 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -166,6 +166,7 @@ fn quickstart_human(value: &Value) -> String {
|
|
|
166
166
|
pub fn cmd_compile(args: &CompileArgs) -> Result<CmdResult, CliError> {
|
|
167
167
|
let spec = crate::compiler::compile_team(&args.team)
|
|
168
168
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
169
|
+
warn_ignored_owner_team_id(&args.team);
|
|
169
170
|
std::fs::write(&args.out, crate::model::yaml::dumps(&spec))?;
|
|
170
171
|
Ok(CmdResult::from_json(
|
|
171
172
|
json!({
|
|
@@ -178,6 +179,28 @@ pub fn cmd_compile(args: &CompileArgs) -> Result<CmdResult, CliError> {
|
|
|
178
179
|
))
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
fn warn_ignored_owner_team_id(team_dir: &std::path::Path) {
|
|
183
|
+
let Ok(Some(ignored)) = crate::compiler::ignored_owner_team_id_from_team_md(team_dir) else {
|
|
184
|
+
return;
|
|
185
|
+
};
|
|
186
|
+
let workspace = crate::model::paths::team_workspace(team_dir)
|
|
187
|
+
.unwrap_or_else(|_| team_dir.parent().unwrap_or(team_dir).to_path_buf());
|
|
188
|
+
eprintln!("Warning: ignored TEAM.md {}={}", ignored.field, ignored.value);
|
|
189
|
+
eprintln!("Reason: owner identity is the canonical runtime team key, not TEAM.md front matter");
|
|
190
|
+
eprintln!("Action: remove {} from TEAM.md", ignored.field);
|
|
191
|
+
let fields = json!({
|
|
192
|
+
"field": ignored.field,
|
|
193
|
+
"source": team_dir.join("TEAM.md").to_string_lossy().to_string(),
|
|
194
|
+
"value": ignored.value,
|
|
195
|
+
"warning": "ignored user-set owner_team_id",
|
|
196
|
+
"reason": "owner identity is derived from the canonical runtime team key",
|
|
197
|
+
"action": "remove owner_team_id from TEAM.md",
|
|
198
|
+
});
|
|
199
|
+
if let Err(err) = crate::event_log::EventLog::new(&workspace).write("spec.field_ignored", fields) {
|
|
200
|
+
eprintln!("Warning: spec.field_ignored event write failed: {err}");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
181
204
|
/// `cmd_status`(`commands.py:90`)。三态:`--summary`(xor json,xor agent)→五行文本;
|
|
182
205
|
/// `--json`→`status_port::status(compact=!detail)`;else→`status_port::format_status(agent)`。
|
|
183
206
|
pub fn cmd_status(args: &StatusArgs) -> Result<CmdResult, CliError> {
|
|
@@ -88,11 +88,8 @@ pub fn cmd_leader_passthrough(
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
pub(crate) fn leader_passthrough_provider(command: &str) -> crate::model::enums::Provider {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"copilot" => crate::model::enums::Provider::Copilot,
|
|
94
|
-
_ => crate::model::enums::Provider::ClaudeCode,
|
|
95
|
-
}
|
|
91
|
+
crate::leader::attribute_command_provider(command)
|
|
92
|
+
.unwrap_or(crate::model::enums::Provider::ClaudeCode)
|
|
96
93
|
}
|
|
97
94
|
|
|
98
95
|
// =============================================================================
|
|
@@ -168,48 +168,12 @@ pub mod lifecycle_port {
|
|
|
168
168
|
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
169
169
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
170
170
|
let state = shutdown_state_for_team(&run_ws, team)?;
|
|
171
|
-
let state_for_kill = state.clone();
|
|
172
171
|
let transport = if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
|
|
173
172
|
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
|
|
174
173
|
} else {
|
|
175
174
|
shutdown_workspace_transport(&run_ws)
|
|
176
175
|
};
|
|
177
|
-
|
|
178
|
-
shutdown_with_transport_and_state(workspace, keep_logs, team, &transport, Some(state));
|
|
179
|
-
if team.is_none() {
|
|
180
|
-
// E12 (P0): the leader terminal lives on this socket by design. A bare shutdown must
|
|
181
|
-
// NOT `kill-server` it away. spare = state-anchor sessions ∪ `team-agent-leader-*`
|
|
182
|
-
// prefix sessions (union; cr E12 ①). kill_server only when the socket is exclusively
|
|
183
|
-
// ours (no spare + no foreign session); shared socket → kill our sessions individually
|
|
184
|
-
// (cr E12 ②). All spare derivation comes from ONE snapshot (list_targets + the state
|
|
185
|
-
// already loaded) — no independent ps/tmux re-derivation (N39).
|
|
186
|
-
let transport_dyn: &dyn crate::transport::Transport = &transport;
|
|
187
|
-
let pane_targets = transport_dyn.list_targets().unwrap_or_default();
|
|
188
|
-
let sessions = socket_session_names(transport_dyn);
|
|
189
|
-
let event_log = crate::event_log::EventLog::new(&run_ws);
|
|
190
|
-
let anchor_sessions =
|
|
191
|
-
anchor_sessions_from_state(&state_for_kill, &pane_targets, &event_log);
|
|
192
|
-
let decision = sessions_to_kill(&sessions, &anchor_sessions);
|
|
193
|
-
match decision {
|
|
194
|
-
KillDecision::KillServerExclusive => transport.kill_server(),
|
|
195
|
-
KillDecision::KillIndividually { to_kill, spared } => {
|
|
196
|
-
if !spared.is_empty() || to_kill.len() != sessions.len() {
|
|
197
|
-
// shared socket / leader spared → never whole-server teardown.
|
|
198
|
-
let _ = event_log.write(
|
|
199
|
-
"shutdown.kill_server_skipped_shared_socket",
|
|
200
|
-
json!({
|
|
201
|
-
"spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
202
|
-
"killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
203
|
-
}),
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
for session in &to_kill {
|
|
207
|
-
let _ = transport_dyn.kill_session(session);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
result
|
|
176
|
+
shutdown_with_transport_and_state(workspace, keep_logs, team, &transport, Some(state))
|
|
213
177
|
}
|
|
214
178
|
|
|
215
179
|
/// E12 ①:从 state 锚 pane_id(leader_receiver/team_owner,top+teams)映射到其所在 session
|
|
@@ -235,15 +199,13 @@ pub mod lifecycle_port {
|
|
|
235
199
|
.collect()
|
|
236
200
|
}
|
|
237
201
|
|
|
238
|
-
fn
|
|
239
|
-
|
|
202
|
+
fn socket_session_names_from_targets(
|
|
203
|
+
pane_targets: &[crate::transport::PaneInfo],
|
|
240
204
|
) -> Vec<crate::transport::SessionName> {
|
|
241
205
|
let mut seen = std::collections::BTreeSet::new();
|
|
242
|
-
|
|
243
|
-
.
|
|
244
|
-
.
|
|
245
|
-
.into_iter()
|
|
246
|
-
.map(|pane| pane.session)
|
|
206
|
+
pane_targets
|
|
207
|
+
.iter()
|
|
208
|
+
.map(|pane| pane.session.clone())
|
|
247
209
|
.filter(|session| seen.insert(session.as_str().to_string()))
|
|
248
210
|
.collect()
|
|
249
211
|
}
|
|
@@ -281,6 +243,76 @@ pub mod lifecycle_port {
|
|
|
281
243
|
}
|
|
282
244
|
}
|
|
283
245
|
|
|
246
|
+
#[derive(Debug, Default)]
|
|
247
|
+
struct ShutdownSocketCleanup {
|
|
248
|
+
killed_sessions: Vec<crate::transport::SessionName>,
|
|
249
|
+
spared_sessions: Vec<crate::transport::SessionName>,
|
|
250
|
+
error: Option<String>,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fn push_unique_session(
|
|
254
|
+
sessions: &mut Vec<crate::transport::SessionName>,
|
|
255
|
+
session: crate::transport::SessionName,
|
|
256
|
+
) {
|
|
257
|
+
if !sessions
|
|
258
|
+
.iter()
|
|
259
|
+
.any(|existing| existing.as_str() == session.as_str())
|
|
260
|
+
{
|
|
261
|
+
sessions.push(session);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fn bare_shutdown_socket_cleanup(
|
|
266
|
+
transport: &dyn crate::transport::Transport,
|
|
267
|
+
state: &Value,
|
|
268
|
+
event_log: &crate::event_log::EventLog,
|
|
269
|
+
) -> ShutdownSocketCleanup {
|
|
270
|
+
// E12 (P0): the leader terminal lives on this socket by design. A bare shutdown must
|
|
271
|
+
// NOT `kill-server` it away. spare = state-anchor sessions ∪ `team-agent-leader-*`
|
|
272
|
+
// prefix sessions (union; cr E12 ①). kill_server only when the socket is exclusively
|
|
273
|
+
// ours (no spare + no foreign session); shared socket → kill our sessions individually
|
|
274
|
+
// (cr E12 ②). All spare derivation comes from ONE snapshot (list_targets + state) —
|
|
275
|
+
// no independent ps/tmux re-derivation (N39).
|
|
276
|
+
let pane_targets = transport.list_targets().unwrap_or_default();
|
|
277
|
+
let sessions = socket_session_names_from_targets(&pane_targets);
|
|
278
|
+
let anchor_sessions = anchor_sessions_from_state(state, &pane_targets, event_log);
|
|
279
|
+
match sessions_to_kill(&sessions, &anchor_sessions) {
|
|
280
|
+
KillDecision::KillServerExclusive => {
|
|
281
|
+
let error = transport.kill_server().err().map(|error| error.to_string());
|
|
282
|
+
ShutdownSocketCleanup {
|
|
283
|
+
killed_sessions: sessions,
|
|
284
|
+
spared_sessions: Vec::new(),
|
|
285
|
+
error,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
KillDecision::KillIndividually { to_kill, spared } => {
|
|
289
|
+
if !spared.is_empty() || to_kill.len() != sessions.len() {
|
|
290
|
+
// shared socket / leader spared → never whole-server teardown.
|
|
291
|
+
let _ = event_log.write(
|
|
292
|
+
"shutdown.kill_server_skipped_shared_socket",
|
|
293
|
+
json!({
|
|
294
|
+
"spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
295
|
+
"killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
let mut error = None;
|
|
300
|
+
for session in &to_kill {
|
|
301
|
+
if let Err(err) = transport.kill_session(session) {
|
|
302
|
+
if !tmux_absent_error(&err.to_string()) {
|
|
303
|
+
error.get_or_insert_with(|| err.to_string());
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
ShutdownSocketCleanup {
|
|
308
|
+
killed_sessions: to_kill,
|
|
309
|
+
spared_sessions: spared,
|
|
310
|
+
error,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
284
316
|
pub fn shutdown_with_transport(
|
|
285
317
|
workspace: &Path,
|
|
286
318
|
keep_logs: bool,
|
|
@@ -358,8 +390,11 @@ pub mod lifecycle_port {
|
|
|
358
390
|
reap_process_tree(&root_pids, &protected, &entry_table);
|
|
359
391
|
reap_process_groups(&root_pgids, &protected);
|
|
360
392
|
let mut kill_error: Option<String> = None;
|
|
393
|
+
let mut killed_sessions = Vec::new();
|
|
394
|
+
let mut spared_sessions = Vec::new();
|
|
361
395
|
deadline.check("kill_session")?;
|
|
362
396
|
if let Some(session) = session_name.as_ref() {
|
|
397
|
+
push_unique_session(&mut killed_sessions, session.clone());
|
|
363
398
|
if let Err(error) = transport.kill_session(session) {
|
|
364
399
|
if !tmux_absent_error(&error.to_string()) {
|
|
365
400
|
kill_error = Some(error.to_string());
|
|
@@ -376,21 +411,28 @@ pub mod lifecycle_port {
|
|
|
376
411
|
reap_scope,
|
|
377
412
|
&mut probe_degraded,
|
|
378
413
|
);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
let
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
session
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if let Some(error) = error {
|
|
414
|
+
if team.is_none() {
|
|
415
|
+
deadline.check("shared_socket_cleanup")?;
|
|
416
|
+
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
417
|
+
let cleanup = bare_shutdown_socket_cleanup(transport, &state, &event_log);
|
|
418
|
+
for session in cleanup.killed_sessions {
|
|
419
|
+
push_unique_session(&mut killed_sessions, session);
|
|
420
|
+
}
|
|
421
|
+
spared_sessions = cleanup.spared_sessions;
|
|
422
|
+
if let Some(error) = cleanup.error {
|
|
388
423
|
kill_error.get_or_insert(error);
|
|
389
424
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
425
|
+
}
|
|
426
|
+
deadline.check("session_residuals")?;
|
|
427
|
+
let (session_residuals, session_residual_error) = session_residuals_after_reap_many(
|
|
428
|
+
transport,
|
|
429
|
+
&run_workspace,
|
|
430
|
+
&killed_sessions,
|
|
431
|
+
!captured_missing_sessions,
|
|
432
|
+
);
|
|
433
|
+
if let Some(error) = session_residual_error {
|
|
434
|
+
kill_error.get_or_insert(error);
|
|
435
|
+
}
|
|
394
436
|
deadline.check("process_residuals")?;
|
|
395
437
|
// C-①: the post-verify gets ONE fresh verification snapshot (reaps changed
|
|
396
438
|
// the world; #248 post-verify facts must be current, not the entry view).
|
|
@@ -427,7 +469,7 @@ pub mod lifecycle_port {
|
|
|
427
469
|
// swallow batch 1: a failed ps probe degrades verification truthfully — the
|
|
428
470
|
// empty table must never read as a clean "no residual processes".
|
|
429
471
|
let verification_degraded = probe_timeout.is_some() || probe_degraded;
|
|
430
|
-
let session_killed =
|
|
472
|
+
let session_killed = !killed_sessions.is_empty()
|
|
431
473
|
&& kill_error.is_none()
|
|
432
474
|
&& session_residuals.is_empty()
|
|
433
475
|
&& process_residuals.is_empty();
|
|
@@ -452,7 +494,9 @@ pub mod lifecycle_port {
|
|
|
452
494
|
let coordinator_pid = stopped
|
|
453
495
|
.as_ref()
|
|
454
496
|
.and_then(|stopped| stopped.pid.map(|p| p.get()));
|
|
455
|
-
let
|
|
497
|
+
let coordinator_clean =
|
|
498
|
+
!coordinator_timeout && stopped.as_ref().map(|stopped| stopped.ok).unwrap_or(true);
|
|
499
|
+
let ok = coordinator_clean
|
|
456
500
|
&& kill_error.is_none()
|
|
457
501
|
&& session_residuals.is_empty()
|
|
458
502
|
&& process_residuals.is_empty()
|
|
@@ -492,6 +536,8 @@ pub mod lifecycle_port {
|
|
|
492
536
|
"team": team,
|
|
493
537
|
"session_name": session_name.as_ref().map(|s| s.as_str().to_string()),
|
|
494
538
|
"session_killed": session_killed,
|
|
539
|
+
"killed_sessions": killed_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
540
|
+
"spared_sessions": spared_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
495
541
|
"coordinator_status": coordinator_status,
|
|
496
542
|
"status": status,
|
|
497
543
|
"phase": phase,
|
|
@@ -513,6 +559,8 @@ pub mod lifecycle_port {
|
|
|
513
559
|
"team": team,
|
|
514
560
|
"session_name": session_name.map(|s| s.as_str().to_string()),
|
|
515
561
|
"session_killed": session_killed,
|
|
562
|
+
"killed_sessions": killed_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
563
|
+
"spared_sessions": spared_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
516
564
|
"residuals": {
|
|
517
565
|
"sessions": session_residuals,
|
|
518
566
|
"processes": process_residuals,
|
|
@@ -671,6 +719,33 @@ pub mod lifecycle_port {
|
|
|
671
719
|
(sessions, error)
|
|
672
720
|
}
|
|
673
721
|
|
|
722
|
+
fn session_residuals_after_reap_many(
|
|
723
|
+
transport: &dyn crate::transport::Transport,
|
|
724
|
+
workspace: &Path,
|
|
725
|
+
sessions: &[crate::transport::SessionName],
|
|
726
|
+
check_primary_transport: bool,
|
|
727
|
+
) -> (Vec<String>, Option<String>) {
|
|
728
|
+
let mut residuals = Vec::new();
|
|
729
|
+
let mut error = None;
|
|
730
|
+
for session in sessions {
|
|
731
|
+
let (session_residuals, session_error) = session_residuals_after_reap(
|
|
732
|
+
transport,
|
|
733
|
+
workspace,
|
|
734
|
+
session,
|
|
735
|
+
check_primary_transport,
|
|
736
|
+
);
|
|
737
|
+
for residual in session_residuals {
|
|
738
|
+
if !residuals.iter().any(|seen| seen == &residual) {
|
|
739
|
+
residuals.push(residual);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if let Some(session_error) = session_error {
|
|
743
|
+
error.get_or_insert(session_error);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
(residuals, error)
|
|
747
|
+
}
|
|
748
|
+
|
|
674
749
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
675
750
|
enum ShutdownReapScope {
|
|
676
751
|
Workspace,
|
|
@@ -719,11 +794,7 @@ pub mod lifecycle_port {
|
|
|
719
794
|
/// pids; Gap 37 escalation order TERM -> grace -> KILL preserved), then a single
|
|
720
795
|
/// bounded wait for the whole union. kill/wait sets derive from the SAME snapshot
|
|
721
796
|
/// as the protected set (N39).
|
|
722
|
-
fn reap_process_tree(
|
|
723
|
-
root_pids: &[u32],
|
|
724
|
-
protected: &ShutdownProtection,
|
|
725
|
-
table: &[ProcessInfo],
|
|
726
|
-
) {
|
|
797
|
+
fn reap_process_tree(root_pids: &[u32], protected: &ShutdownProtection, table: &[ProcessInfo]) {
|
|
727
798
|
let mut pids = Vec::new();
|
|
728
799
|
let mut seen = std::collections::BTreeSet::new();
|
|
729
800
|
for root in root_pids {
|
|
@@ -785,12 +856,21 @@ pub mod lifecycle_port {
|
|
|
785
856
|
let mut protected = shutdown_protection_set(&round_table);
|
|
786
857
|
extend_protection_with_leader_panes(&mut protected, transport, state, &round_table);
|
|
787
858
|
let residuals = matched_processes(
|
|
788
|
-
workspace,
|
|
859
|
+
workspace,
|
|
860
|
+
state,
|
|
861
|
+
root_pids,
|
|
862
|
+
root_pgids,
|
|
863
|
+
&protected,
|
|
864
|
+
scope,
|
|
865
|
+
&round_table,
|
|
789
866
|
);
|
|
790
867
|
if residuals.is_empty() {
|
|
791
868
|
return;
|
|
792
869
|
}
|
|
793
|
-
let residual_pids = residuals
|
|
870
|
+
let residual_pids = residuals
|
|
871
|
+
.iter()
|
|
872
|
+
.map(|process| process.pid)
|
|
873
|
+
.collect::<Vec<_>>();
|
|
794
874
|
reap_process_tree(&residual_pids, &protected, &round_table);
|
|
795
875
|
let pgids = residuals
|
|
796
876
|
.iter()
|
|
@@ -904,7 +984,9 @@ pub mod lifecycle_port {
|
|
|
904
984
|
/// - E4b team-in-team:子 team state 的 team_owner.pane_id 指父 team worker pane;
|
|
905
985
|
/// 父 team state 的 teams.<child>.team_owner.pane_id 同义(若有该字段)
|
|
906
986
|
/// → 任一 team 的 shutdown 都不杀任何 team 的 leader 锚 pane
|
|
907
|
-
pub fn collect_state_leader_anchor_pane_ids(
|
|
987
|
+
pub fn collect_state_leader_anchor_pane_ids(
|
|
988
|
+
state: &Value,
|
|
989
|
+
) -> std::collections::BTreeSet<String> {
|
|
908
990
|
let mut out = std::collections::BTreeSet::new();
|
|
909
991
|
push_anchor_pane_id(state, &mut out);
|
|
910
992
|
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
@@ -1040,7 +1122,10 @@ pub mod lifecycle_port {
|
|
|
1040
1122
|
for socket_endpoint in collect_state_recorded_tmux_sockets(state) {
|
|
1041
1123
|
let cross_backend =
|
|
1042
1124
|
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket_endpoint);
|
|
1043
|
-
let cross_panes =
|
|
1125
|
+
let cross_panes =
|
|
1126
|
+
<crate::tmux_backend::TmuxBackend as crate::transport::Transport>::list_targets(
|
|
1127
|
+
&cross_backend,
|
|
1128
|
+
)
|
|
1044
1129
|
.unwrap_or_default();
|
|
1045
1130
|
leader_pane_pids.extend(
|
|
1046
1131
|
cross_panes
|
|
@@ -1165,8 +1250,9 @@ pub mod lifecycle_port {
|
|
|
1165
1250
|
scope: ShutdownReapScope,
|
|
1166
1251
|
table: &[ProcessInfo],
|
|
1167
1252
|
) -> Vec<Value> {
|
|
1168
|
-
let mut residuals =
|
|
1169
|
-
|
|
1253
|
+
let mut residuals = matched_processes(
|
|
1254
|
+
workspace, state, root_pids, root_pgids, protected, scope, table,
|
|
1255
|
+
);
|
|
1170
1256
|
let mut seen = residuals
|
|
1171
1257
|
.iter()
|
|
1172
1258
|
.map(|process| process.pid)
|
|
@@ -1484,9 +1570,28 @@ pub mod lifecycle_port {
|
|
|
1484
1570
|
}
|
|
1485
1571
|
let agent_id = crate::model::ids::AgentId::new(agent);
|
|
1486
1572
|
match crate::lifecycle::remove_agent(workspace, &agent_id, from_spec, force, team) {
|
|
1487
|
-
Ok(report) => {
|
|
1488
|
-
|
|
1489
|
-
|
|
1573
|
+
Ok(report @ crate::lifecycle::RemoveAgentOutcome::Removed { .. }) => Ok(json!({
|
|
1574
|
+
"ok": true,
|
|
1575
|
+
"agent_id": agent,
|
|
1576
|
+
"status": "removed",
|
|
1577
|
+
"report": format!("{report:?}"),
|
|
1578
|
+
})),
|
|
1579
|
+
Ok(crate::lifecycle::RemoveAgentOutcome::RefusedFromSpecConfirm { .. }) => Ok(json!({
|
|
1580
|
+
"ok": false,
|
|
1581
|
+
"agent_id": agent,
|
|
1582
|
+
"status": "refused",
|
|
1583
|
+
"reason": "from_spec_confirm_required",
|
|
1584
|
+
"error": "remove-agent requires --from-spec --confirm for spec-defined agents",
|
|
1585
|
+
"action": "rerun with --from-spec --confirm, or omit --from-spec only for dynamic agents",
|
|
1586
|
+
})),
|
|
1587
|
+
Ok(crate::lifecycle::RemoveAgentOutcome::RefusedForceRequired { .. }) => Ok(json!({
|
|
1588
|
+
"ok": false,
|
|
1589
|
+
"agent_id": agent,
|
|
1590
|
+
"status": "refused",
|
|
1591
|
+
"reason": "force_required",
|
|
1592
|
+
"error": "agent is running; remove-agent requires --force",
|
|
1593
|
+
"action": "rerun with --force to stop and remove the running agent",
|
|
1594
|
+
})),
|
|
1490
1595
|
Err(e) => Ok(error_value(e)),
|
|
1491
1596
|
}
|
|
1492
1597
|
}
|
|
@@ -1870,7 +1975,7 @@ pub mod lifecycle_port {
|
|
|
1870
1975
|
state_path: Some(PathBuf::from("/tmp/state.json")),
|
|
1871
1976
|
next_actions: vec!["restart".to_string()],
|
|
1872
1977
|
attach_commands: vec![
|
|
1873
|
-
"tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker".to_string()
|
|
1978
|
+
"tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker".to_string()
|
|
1874
1979
|
],
|
|
1875
1980
|
});
|
|
1876
1981
|
assert_eq!(
|
|
@@ -1913,6 +2018,71 @@ pub mod lifecycle_port {
|
|
|
1913
2018
|
"next_actions": next_actions,
|
|
1914
2019
|
"attach_commands": attach_commands,
|
|
1915
2020
|
}),
|
|
2021
|
+
crate::lifecycle::RestartReport::Partial {
|
|
2022
|
+
session_name,
|
|
2023
|
+
agents,
|
|
2024
|
+
failed_agents,
|
|
2025
|
+
coordinator_started,
|
|
2026
|
+
next_actions,
|
|
2027
|
+
attach_commands,
|
|
2028
|
+
} => json!({
|
|
2029
|
+
"ok": false,
|
|
2030
|
+
"status": "partial",
|
|
2031
|
+
"reason": "restart_agent_failed",
|
|
2032
|
+
"session_name": session_name.as_str(),
|
|
2033
|
+
"agents": agents.iter().map(|a| a.agent_id.as_str()).collect::<Vec<_>>(),
|
|
2034
|
+
"failed_agents": failed_agents.iter().map(|failure| json!({
|
|
2035
|
+
"agent_id": failure.agent_id.as_str(),
|
|
2036
|
+
"restart_mode": failure.restart_mode,
|
|
2037
|
+
"decision": failure.decision,
|
|
2038
|
+
"session_id": failure.session_id.as_ref().map(|session| session.as_str()),
|
|
2039
|
+
"phase": failure.phase,
|
|
2040
|
+
"error": failure.error,
|
|
2041
|
+
"action": format!(
|
|
2042
|
+
"inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
|
|
2043
|
+
failure.agent_id,
|
|
2044
|
+
failure.agent_id
|
|
2045
|
+
),
|
|
2046
|
+
"log": format!(
|
|
2047
|
+
".team/logs/coordinator.log and .team/runtime/state.json agent={}",
|
|
2048
|
+
failure.agent_id
|
|
2049
|
+
),
|
|
2050
|
+
})).collect::<Vec<_>>(),
|
|
2051
|
+
"coordinator_started": coordinator_started,
|
|
2052
|
+
"next_actions": next_actions,
|
|
2053
|
+
"attach_commands": attach_commands,
|
|
2054
|
+
}),
|
|
2055
|
+
crate::lifecycle::RestartReport::Failed {
|
|
2056
|
+
session_name,
|
|
2057
|
+
failed_agents,
|
|
2058
|
+
next_actions,
|
|
2059
|
+
attach_commands,
|
|
2060
|
+
} => json!({
|
|
2061
|
+
"ok": false,
|
|
2062
|
+
"status": "failed",
|
|
2063
|
+
"reason": "restart_all_agents_failed",
|
|
2064
|
+
"session_name": session_name.as_str(),
|
|
2065
|
+
"agents": [],
|
|
2066
|
+
"failed_agents": failed_agents.iter().map(|failure| json!({
|
|
2067
|
+
"agent_id": failure.agent_id.as_str(),
|
|
2068
|
+
"restart_mode": failure.restart_mode,
|
|
2069
|
+
"decision": failure.decision,
|
|
2070
|
+
"session_id": failure.session_id.as_ref().map(|session| session.as_str()),
|
|
2071
|
+
"phase": failure.phase,
|
|
2072
|
+
"error": failure.error,
|
|
2073
|
+
"action": format!(
|
|
2074
|
+
"inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
|
|
2075
|
+
failure.agent_id,
|
|
2076
|
+
failure.agent_id
|
|
2077
|
+
),
|
|
2078
|
+
"log": format!(
|
|
2079
|
+
".team/logs/coordinator.log and .team/runtime/state.json agent={}",
|
|
2080
|
+
failure.agent_id
|
|
2081
|
+
),
|
|
2082
|
+
})).collect::<Vec<_>>(),
|
|
2083
|
+
"next_actions": next_actions,
|
|
2084
|
+
"attach_commands": attach_commands,
|
|
2085
|
+
}),
|
|
1916
2086
|
crate::lifecycle::RestartReport::RefusedResumeAtomicity {
|
|
1917
2087
|
unresumable,
|
|
1918
2088
|
allow_fresh,
|
|
@@ -2071,7 +2241,10 @@ pub mod lifecycle_port {
|
|
|
2071
2241
|
let msg = "agent start requirement unmet: agent foo not found";
|
|
2072
2242
|
let na = error_next_action(msg).expect("not-found must carry next_action");
|
|
2073
2243
|
assert!(na.contains("add-agent"), "must steer to add-agent: {na}");
|
|
2074
|
-
assert!(
|
|
2244
|
+
assert!(
|
|
2245
|
+
na.contains("--role-file"),
|
|
2246
|
+
"must show the role-file flag: {na}"
|
|
2247
|
+
);
|
|
2075
2248
|
}
|
|
2076
2249
|
|
|
2077
2250
|
#[test]
|
|
@@ -2090,7 +2263,10 @@ pub mod lifecycle_port {
|
|
|
2090
2263
|
|
|
2091
2264
|
#[test]
|
|
2092
2265
|
fn unrelated_error_has_no_next_action() {
|
|
2093
|
-
assert_eq!(
|
|
2266
|
+
assert_eq!(
|
|
2267
|
+
error_next_action("state persistence failed: disk full"),
|
|
2268
|
+
None
|
|
2269
|
+
);
|
|
2094
2270
|
}
|
|
2095
2271
|
|
|
2096
2272
|
#[test]
|
|
@@ -2101,7 +2277,10 @@ pub mod lifecycle_port {
|
|
|
2101
2277
|
let v = error_value(err);
|
|
2102
2278
|
assert_eq!(v["ok"], serde_json::json!(false));
|
|
2103
2279
|
assert!(
|
|
2104
|
-
v["next_action"]
|
|
2280
|
+
v["next_action"]
|
|
2281
|
+
.as_str()
|
|
2282
|
+
.unwrap_or("")
|
|
2283
|
+
.contains("add-agent"),
|
|
2105
2284
|
"error_value must attach the add-agent guidance: {v}"
|
|
2106
2285
|
);
|
|
2107
2286
|
}
|
|
@@ -2156,8 +2335,8 @@ pub mod diagnose_port {
|
|
|
2156
2335
|
.unwrap_or(true);
|
|
2157
2336
|
// legacy 降级面(legacy_team_invalid)不下拉整体 ok —— 用户没显式让我们
|
|
2158
2337
|
// 体检这个 team,失败是降级诊断信息,不是 install 自检失败。
|
|
2159
|
-
let legacy_only_failure =
|
|
2160
|
-
|
|
2338
|
+
let legacy_only_failure = !profile_smoke_ok
|
|
2339
|
+
&& profile_smoke_value.get("status").and_then(Value::as_str)
|
|
2161
2340
|
== Some("legacy_team_invalid");
|
|
2162
2341
|
let effective_smoke_ok = profile_smoke_ok || legacy_only_failure;
|
|
2163
2342
|
let ok = workspace_valid && (team_context || workspace_has_entries) && effective_smoke_ok;
|
|
@@ -16,6 +16,16 @@ fn compile_team_dir(tag: &str) -> std::path::PathBuf {
|
|
|
16
16
|
team
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
fn compile_team_dir_with_owner_team_id(tag: &str) -> std::path::PathBuf {
|
|
20
|
+
let team = compile_team_dir(tag);
|
|
21
|
+
std::fs::write(
|
|
22
|
+
team.join("TEAM.md"),
|
|
23
|
+
"---\nname: compileteam\nobjective: Compile probe.\nprovider: fake\nowner_team_id: user-set-owner\n---\n\nCompile team.\n",
|
|
24
|
+
)
|
|
25
|
+
.unwrap();
|
|
26
|
+
team
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
#[test]
|
|
20
30
|
fn cmd_compile_json_and_human_match_golden_shape_and_writes_out() {
|
|
21
31
|
let team = compile_team_dir("compile-ok");
|
|
@@ -47,6 +57,65 @@ fn cmd_compile_json_and_human_match_golden_shape_and_writes_out() {
|
|
|
47
57
|
assert_eq!(human_text, expected_human, "golden human output preserves cmd_compile insertion order");
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
#[test]
|
|
61
|
+
fn cmd_compile_ignores_user_owner_team_id_and_emits_warning_event() {
|
|
62
|
+
let team = compile_team_dir_with_owner_team_id("compile-ignored-owner");
|
|
63
|
+
let workspace = team.parent().unwrap().to_path_buf();
|
|
64
|
+
let out = workspace.join("ignored-owner-out.yaml");
|
|
65
|
+
let args = CompileArgs { team: team.clone(), out: out.clone(), json: true };
|
|
66
|
+
|
|
67
|
+
let result = cmd_compile(&args).expect("compile");
|
|
68
|
+
assert_eq!(result.exit, ExitCode::Ok);
|
|
69
|
+
let compiled = std::fs::read_to_string(&out).unwrap();
|
|
70
|
+
assert!(
|
|
71
|
+
!compiled.contains("owner_team_id"),
|
|
72
|
+
"user-set owner_team_id must not be compiled into team.spec.yaml"
|
|
73
|
+
);
|
|
74
|
+
let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
|
|
75
|
+
let event = events
|
|
76
|
+
.iter()
|
|
77
|
+
.find(|event| {
|
|
78
|
+
event
|
|
79
|
+
.get("event")
|
|
80
|
+
.and_then(serde_json::Value::as_str)
|
|
81
|
+
== Some("spec.field_ignored")
|
|
82
|
+
})
|
|
83
|
+
.expect("owner_team_id warning event");
|
|
84
|
+
assert_eq!(
|
|
85
|
+
event.get("field").and_then(serde_json::Value::as_str),
|
|
86
|
+
Some("owner_team_id")
|
|
87
|
+
);
|
|
88
|
+
assert_eq!(
|
|
89
|
+
event.get("value").and_then(serde_json::Value::as_str),
|
|
90
|
+
Some("user-set-owner")
|
|
91
|
+
);
|
|
92
|
+
assert_eq!(
|
|
93
|
+
event.get("action").and_then(serde_json::Value::as_str),
|
|
94
|
+
Some("remove owner_team_id from TEAM.md")
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#[test]
|
|
99
|
+
fn cmd_compile_without_owner_team_id_emits_no_ignored_field_event() {
|
|
100
|
+
let team = compile_team_dir("compile-no-ignored-owner");
|
|
101
|
+
let workspace = team.parent().unwrap().to_path_buf();
|
|
102
|
+
let out = workspace.join("no-ignored-owner-out.yaml");
|
|
103
|
+
let args = CompileArgs { team: team.clone(), out, json: true };
|
|
104
|
+
|
|
105
|
+
let result = cmd_compile(&args).expect("compile");
|
|
106
|
+
assert_eq!(result.exit, ExitCode::Ok);
|
|
107
|
+
let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
|
|
108
|
+
assert!(
|
|
109
|
+
events.iter().all(|event| {
|
|
110
|
+
event
|
|
111
|
+
.get("event")
|
|
112
|
+
.and_then(serde_json::Value::as_str)
|
|
113
|
+
!= Some("spec.field_ignored")
|
|
114
|
+
}),
|
|
115
|
+
"TEAM.md without owner_team_id must not emit ignored-field warning"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
50
119
|
#[test]
|
|
51
120
|
fn run_dispatches_compile_and_error_path_exits_error() {
|
|
52
121
|
let team = compile_team_dir("compile-dispatch");
|