@team-agent/installer 0.5.25 → 0.5.27
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 +26 -24
- package/crates/team-agent/src/cli/mod.rs +73 -14
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +123 -0
- package/crates/team-agent/src/lifecycle/launch.rs +30 -2
- package/crates/team-agent/src/state/persist.rs +69 -19
- package/crates/team-agent/src/state/projection.rs +107 -15
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -203,7 +203,13 @@ const DISPATCH_COMMANDS: &[&str] = &[
|
|
|
203
203
|
"coordinator",
|
|
204
204
|
];
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.6):
|
|
207
|
+
// `purge-agent` was previously listed in help but had no dispatch arm,
|
|
208
|
+
// so it read as a supported recovery command while actually failing with
|
|
209
|
+
// "invalid choice". The dispatch registration remains out of scope for
|
|
210
|
+
// 0.5.26 (destructive semantics deserve their own CR); keep the help
|
|
211
|
+
// consistent with the dispatch table so it is no longer advertised.
|
|
212
|
+
const SPEC_ONLY_HELP_COMMANDS: &[&str] = &["start"];
|
|
207
213
|
// Command grammar, not provider identity parsing: these are top-level CLI
|
|
208
214
|
// passthrough verbs for starting a leader under a provider executable.
|
|
209
215
|
const LEADER_PASSTHROUGH_COMMANDS: &[&str] = &["codex", "claude", "copilot"];
|
|
@@ -272,7 +278,7 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
272
278
|
Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
|
|
273
279
|
Some("fork-agent") => "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
|
|
274
280
|
Some("remove-agent") => "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]".to_string(),
|
|
275
|
-
|
|
281
|
+
// 0.5.26 (§7.6): removed from help; dispatch was never wired.
|
|
276
282
|
Some("stuck-list") => "usage: team-agent stuck-list [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
277
283
|
Some("stuck-cancel") => "usage: team-agent stuck-cancel AGENT [--workspace WORKSPACE] [--alert-type stuck|idle_fallback|cross_worker_deadlock|all] [--json]".to_string(),
|
|
278
284
|
Some("acknowledge-idle") => "usage: team-agent acknowledge-idle [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
@@ -875,7 +881,8 @@ fn quick_start_args(args: &[String], cwd: &Path) -> Result<QuickStartArgs, CliEr
|
|
|
875
881
|
return Err(CliError::Usage(
|
|
876
882
|
"quick-start no longer accepts --fresh. Reset semantics moved to \
|
|
877
883
|
`team-agent restart --allow-fresh`, which requires explicit user \
|
|
878
|
-
confirmation."
|
|
884
|
+
confirmation."
|
|
885
|
+
.to_string(),
|
|
879
886
|
));
|
|
880
887
|
}
|
|
881
888
|
let parsed = parse_args(args);
|
|
@@ -1307,11 +1314,7 @@ fn stuck_list_args(args: &[String], cwd: &Path) -> StuckListArgs {
|
|
|
1307
1314
|
fn stuck_cancel_args(args: &[String], cwd: &Path) -> Result<StuckCancelArgs, CliError> {
|
|
1308
1315
|
let parsed = parse_args(args);
|
|
1309
1316
|
let workspace = workspace(&parsed, cwd);
|
|
1310
|
-
refuse_if_multi_alive_team_missing_scope(
|
|
1311
|
-
"stuck-cancel",
|
|
1312
|
-
&workspace,
|
|
1313
|
-
parsed.team.as_deref(),
|
|
1314
|
-
)?;
|
|
1317
|
+
refuse_if_multi_alive_team_missing_scope("stuck-cancel", &workspace, parsed.team.as_deref())?;
|
|
1315
1318
|
Ok(StuckCancelArgs {
|
|
1316
1319
|
agent: required_pos(&parsed, 0, "agent")?,
|
|
1317
1320
|
workspace,
|
|
@@ -1827,10 +1830,20 @@ mod tests {
|
|
|
1827
1830
|
("sessions", &["--workspace", "--team", "--json"][..]),
|
|
1828
1831
|
(
|
|
1829
1832
|
"repair-state",
|
|
1830
|
-
&[
|
|
1833
|
+
&[
|
|
1834
|
+
"--task",
|
|
1835
|
+
"--status",
|
|
1836
|
+
"--assignee",
|
|
1837
|
+
"--workspace",
|
|
1838
|
+
"--team",
|
|
1839
|
+
"--json",
|
|
1840
|
+
][..],
|
|
1831
1841
|
),
|
|
1832
1842
|
("diagnose", &["--workspace", "--team", "--json"][..]),
|
|
1833
|
-
(
|
|
1843
|
+
(
|
|
1844
|
+
"wait-ready",
|
|
1845
|
+
&["--workspace", "--team", "--timeout", "--json"][..],
|
|
1846
|
+
),
|
|
1834
1847
|
(
|
|
1835
1848
|
"peek",
|
|
1836
1849
|
&[
|
|
@@ -2059,11 +2072,7 @@ mod tests {
|
|
|
2059
2072
|
fn stuck_cancel_args_builder_refuses_on_multi_alive_team() {
|
|
2060
2073
|
let ws = tmp_workspace();
|
|
2061
2074
|
seed_two_alive_teams_in(&ws);
|
|
2062
|
-
let argv = cli_argv(&[
|
|
2063
|
-
"worker_a",
|
|
2064
|
-
"--workspace",
|
|
2065
|
-
&ws.to_string_lossy(),
|
|
2066
|
-
]);
|
|
2075
|
+
let argv = cli_argv(&["worker_a", "--workspace", &ws.to_string_lossy()]);
|
|
2067
2076
|
let err = stuck_cancel_args(&argv, &ws).expect_err("must refuse");
|
|
2068
2077
|
assert!(
|
|
2069
2078
|
err.to_string().contains("multiple alive teams"),
|
|
@@ -2156,11 +2165,7 @@ mod tests {
|
|
|
2156
2165
|
// not advertised or carried in QuickStartArgs, but scripts that
|
|
2157
2166
|
// still pass it get a clear redirect to restart --allow-fresh.
|
|
2158
2167
|
let ws = tmp_workspace();
|
|
2159
|
-
let argv = cli_argv(&[
|
|
2160
|
-
"--workspace",
|
|
2161
|
-
&ws.to_string_lossy(),
|
|
2162
|
-
"--fresh",
|
|
2163
|
-
]);
|
|
2168
|
+
let argv = cli_argv(&["--workspace", &ws.to_string_lossy(), "--fresh"]);
|
|
2164
2169
|
let err = quick_start_args(&argv, &ws).expect_err("must refuse --fresh");
|
|
2165
2170
|
let message = err.to_string();
|
|
2166
2171
|
assert!(
|
|
@@ -2177,10 +2182,7 @@ mod tests {
|
|
|
2177
2182
|
fn quick_start_without_fresh_flag_still_builds_args() {
|
|
2178
2183
|
// Without --fresh, args build normally (the initial-creation path).
|
|
2179
2184
|
let ws = tmp_workspace();
|
|
2180
|
-
let argv = cli_argv(&[
|
|
2181
|
-
"--workspace",
|
|
2182
|
-
&ws.to_string_lossy(),
|
|
2183
|
-
]);
|
|
2185
|
+
let argv = cli_argv(&["--workspace", &ws.to_string_lossy()]);
|
|
2184
2186
|
let args = quick_start_args(&argv, &ws).expect("must build");
|
|
2185
2187
|
assert_eq!(args.workspace, ws);
|
|
2186
2188
|
// No `fresh` field anymore — the struct must compile and round-trip
|
|
@@ -823,8 +823,10 @@ pub mod lifecycle_port {
|
|
|
823
823
|
let mut coordinator_pid_for_report = None;
|
|
824
824
|
let coordinator_stop_reason =
|
|
825
825
|
coordinator_stop_reason_for_shutdown(&run_workspace, team, &state)?;
|
|
826
|
-
let should_stop_coordinator =
|
|
827
|
-
|
|
826
|
+
let should_stop_coordinator = matches!(
|
|
827
|
+
coordinator_stop_reason,
|
|
828
|
+
"bare_shutdown" | "scoped_last_live_team"
|
|
829
|
+
);
|
|
828
830
|
let stopped = if should_stop_coordinator {
|
|
829
831
|
let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
|
|
830
832
|
let coordinator_pid_before_stop = crate::coordinator::coordinator_health(&wp).pid;
|
|
@@ -867,14 +869,29 @@ pub mod lifecycle_port {
|
|
|
867
869
|
&& session_residuals.is_empty()
|
|
868
870
|
&& process_residuals.is_empty()
|
|
869
871
|
&& owned_file_residuals.is_empty();
|
|
872
|
+
// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.2):
|
|
873
|
+
// shutdown 只把 agent.status 打成 "stopped" 会让 team 自身留在
|
|
874
|
+
// `status:null` 的"看起来还活着"形态,进而卡活队写入的 SaveConflict。
|
|
875
|
+
// 只有 session_killed=true(无 kill error / 无 session/process/file
|
|
876
|
+
// residual / 无 verification degrade)才把匹配 team 的 status 打成
|
|
877
|
+
// "shutdown";degraded/false-green shutdown 保留 null,由 diagnose
|
|
878
|
+
// 走 dirty state 排查通道。
|
|
870
879
|
mark_agents_stopped(&mut state);
|
|
880
|
+
let team_shutdown_status = "shutdown";
|
|
871
881
|
deadline.check("save_state")?;
|
|
872
882
|
if team.is_some() {
|
|
883
|
+
if session_killed && !verification_degraded {
|
|
884
|
+
mark_active_team_shutdown(&mut state, team_shutdown_status);
|
|
885
|
+
}
|
|
873
886
|
crate::state::projection::save_team_scoped_state(&run_workspace, &state)?;
|
|
874
887
|
promote_live_sibling_after_scoped_shutdown(&run_workspace, &state)?;
|
|
875
888
|
} else {
|
|
876
|
-
let _changed_keys =
|
|
877
|
-
|
|
889
|
+
let _changed_keys = mark_matching_session_teams_stopped(
|
|
890
|
+
&mut state,
|
|
891
|
+
session_name.as_ref(),
|
|
892
|
+
session_killed && !verification_degraded,
|
|
893
|
+
team_shutdown_status,
|
|
894
|
+
);
|
|
878
895
|
crate::state::persist::save_runtime_state(&run_workspace, &state)?;
|
|
879
896
|
}
|
|
880
897
|
let coordinator_status = if coordinator_timeout {
|
|
@@ -1004,7 +1021,8 @@ pub mod lifecycle_port {
|
|
|
1004
1021
|
// Failed/degraded shutdowns leave the entry STALE so operators can
|
|
1005
1022
|
// decide.
|
|
1006
1023
|
if ok && !verification_degraded && !probe_degraded {
|
|
1007
|
-
let _ =
|
|
1024
|
+
let _ =
|
|
1025
|
+
crate::cli::leader_port::unregister_after_shutdown_success(&run_workspace, team);
|
|
1008
1026
|
}
|
|
1009
1027
|
Ok(response)
|
|
1010
1028
|
}
|
|
@@ -2334,7 +2352,8 @@ pub mod lifecycle_port {
|
|
|
2334
2352
|
let agent_id = crate::model::ids::AgentId::new(agent);
|
|
2335
2353
|
match crate::lifecycle::remove_agent_flag_requirements(workspace, &agent_id, team) {
|
|
2336
2354
|
Ok(requirements) => {
|
|
2337
|
-
if !remove_agent_missing_flags(from_spec, confirm, force, &requirements).is_empty()
|
|
2355
|
+
if !remove_agent_missing_flags(from_spec, confirm, force, &requirements).is_empty()
|
|
2356
|
+
{
|
|
2338
2357
|
return Ok(remove_agent_flag_refusal(
|
|
2339
2358
|
workspace,
|
|
2340
2359
|
agent,
|
|
@@ -3413,6 +3432,8 @@ pub mod lifecycle_port {
|
|
|
3413
3432
|
fn mark_matching_session_teams_stopped(
|
|
3414
3433
|
state: &mut Value,
|
|
3415
3434
|
session_name: Option<&crate::transport::SessionName>,
|
|
3435
|
+
stamp_team_shutdown: bool,
|
|
3436
|
+
team_shutdown_status: &str,
|
|
3416
3437
|
) -> Vec<String> {
|
|
3417
3438
|
let Some(session_name) = session_name.map(crate::transport::SessionName::as_str) else {
|
|
3418
3439
|
return Vec::new();
|
|
@@ -3428,12 +3449,49 @@ pub mod lifecycle_port {
|
|
|
3428
3449
|
.is_some_and(|session| session == session_name);
|
|
3429
3450
|
if matches {
|
|
3430
3451
|
mark_agents_stopped(team);
|
|
3452
|
+
// 0.5.26 (§7.2): 只在 session_killed && !verification_degraded
|
|
3453
|
+
// 时把 team 自身标成 shutdown,degraded 保留 null 让 diagnose
|
|
3454
|
+
// 走 dirty state。
|
|
3455
|
+
if stamp_team_shutdown {
|
|
3456
|
+
if let Some(team_obj) = team.as_object_mut() {
|
|
3457
|
+
team_obj.insert("status".to_string(), json!(team_shutdown_status));
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3431
3460
|
out.push(key.clone());
|
|
3432
3461
|
}
|
|
3433
3462
|
}
|
|
3434
3463
|
out
|
|
3435
3464
|
}
|
|
3436
3465
|
|
|
3466
|
+
/// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.2) /
|
|
3467
|
+
/// 0.5.27 (`.team/artifacts/0526-supermarket-shutdown-status-real-fail-locate.md` §5.1):
|
|
3468
|
+
/// scoped shutdown 只有一队,`state.active_team_key` 指向被关的那队;
|
|
3469
|
+
/// 只在 session_killed && !verification_degraded 时被调用。
|
|
3470
|
+
///
|
|
3471
|
+
/// 同时 stamp 顶层 `state["status"]` 与 `teams[active_key]["status"]`:
|
|
3472
|
+
/// scoped 关队走 `save_team_scoped_state`,反写盘的源是 `compact(top-level)`
|
|
3473
|
+
/// (见 `state/persist.rs::save_team_scoped_state` +
|
|
3474
|
+
/// `projection::compact_team_state`),只标 nested 会被顶层视图 clobber 丢盘。
|
|
3475
|
+
/// 保留 nested 写入以维持同 tick 内的 in-memory 一致(其他 read-back 立即看到)。
|
|
3476
|
+
fn mark_active_team_shutdown(state: &mut Value, status: &str) {
|
|
3477
|
+
let Some(active_key) = state
|
|
3478
|
+
.get("active_team_key")
|
|
3479
|
+
.and_then(Value::as_str)
|
|
3480
|
+
.map(str::to_string)
|
|
3481
|
+
else {
|
|
3482
|
+
return;
|
|
3483
|
+
};
|
|
3484
|
+
if let Some(obj) = state.as_object_mut() {
|
|
3485
|
+
obj.insert("status".to_string(), json!(status));
|
|
3486
|
+
}
|
|
3487
|
+
let Some(teams) = state.get_mut("teams").and_then(Value::as_object_mut) else {
|
|
3488
|
+
return;
|
|
3489
|
+
};
|
|
3490
|
+
if let Some(team_obj) = teams.get_mut(&active_key).and_then(Value::as_object_mut) {
|
|
3491
|
+
team_obj.insert("status".to_string(), json!(status));
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3437
3495
|
fn promote_live_sibling_after_scoped_shutdown(
|
|
3438
3496
|
workspace: &Path,
|
|
3439
3497
|
stopped_state: &Value,
|
|
@@ -4120,9 +4178,7 @@ pub mod leader_port {
|
|
|
4120
4178
|
response: &mut Value,
|
|
4121
4179
|
) {
|
|
4122
4180
|
let Some(outcome) = crate::leader::registry::register_binding_from_state_best_effort(
|
|
4123
|
-
workspace,
|
|
4124
|
-
team,
|
|
4125
|
-
source,
|
|
4181
|
+
workspace, team, source,
|
|
4126
4182
|
) else {
|
|
4127
4183
|
return;
|
|
4128
4184
|
};
|
|
@@ -4173,11 +4229,14 @@ pub mod leader_port {
|
|
|
4173
4229
|
workspace: &Path,
|
|
4174
4230
|
team: Option<&str>,
|
|
4175
4231
|
) -> Option<PathBuf> {
|
|
4176
|
-
let team_key = team
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4232
|
+
let team_key = team
|
|
4233
|
+
.filter(|t| !t.is_empty())
|
|
4234
|
+
.map(str::to_string)
|
|
4235
|
+
.or_else(|| {
|
|
4236
|
+
crate::state::persist::load_runtime_state(workspace)
|
|
4237
|
+
.ok()
|
|
4238
|
+
.map(|s| crate::state::projection::team_state_key(&s))
|
|
4239
|
+
})?;
|
|
4181
4240
|
let path = crate::leader::registry::unregister_entry(workspace, &team_key)?;
|
|
4182
4241
|
let event_log = crate::event_log::EventLog::new(workspace);
|
|
4183
4242
|
let _ = event_log.write(
|
|
@@ -331,6 +331,129 @@ fn scoped_shutdown_keeps_owned_endpoint_when_sibling_session_remains() {
|
|
|
331
331
|
);
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
#[test]
|
|
335
|
+
fn scoped_clean_shutdown_persists_team_shutdown_status_after_disk_roundtrip() {
|
|
336
|
+
let ws = tmp_shutdown_workspace("scoped-clean-status-roundtrip");
|
|
337
|
+
crate::state::persist::save_runtime_state(
|
|
338
|
+
&ws,
|
|
339
|
+
&json!({
|
|
340
|
+
"schema_version": 1,
|
|
341
|
+
"active_team_key": "current",
|
|
342
|
+
"team_key": "current",
|
|
343
|
+
"session_name": "team-current",
|
|
344
|
+
"team_dir": ws.display().to_string(),
|
|
345
|
+
"workspace": ws.display().to_string(),
|
|
346
|
+
"agents": {
|
|
347
|
+
"adminweb": {
|
|
348
|
+
"agent_id": "adminweb",
|
|
349
|
+
"status": "running",
|
|
350
|
+
"provider": "fake",
|
|
351
|
+
"window": "adminweb"
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
"teams": {
|
|
355
|
+
"current": {
|
|
356
|
+
"team_key": "current",
|
|
357
|
+
"session_name": "team-current",
|
|
358
|
+
"team_dir": ws.display().to_string(),
|
|
359
|
+
"workspace": ws.display().to_string(),
|
|
360
|
+
"agents": {
|
|
361
|
+
"adminweb": {
|
|
362
|
+
"agent_id": "adminweb",
|
|
363
|
+
"status": "running",
|
|
364
|
+
"provider": "fake",
|
|
365
|
+
"window": "adminweb"
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}),
|
|
371
|
+
)
|
|
372
|
+
.unwrap();
|
|
373
|
+
|
|
374
|
+
let out = crate::cli::lifecycle_port::shutdown_with_transport(
|
|
375
|
+
&ws,
|
|
376
|
+
true,
|
|
377
|
+
Some("current"),
|
|
378
|
+
&CleanShutdownTransport::new(),
|
|
379
|
+
)
|
|
380
|
+
.expect("shutdown should complete");
|
|
381
|
+
|
|
382
|
+
assert_eq!(out["ok"], json!(true), "shutdown report: {out}");
|
|
383
|
+
assert_eq!(out["session_killed"], json!(true), "shutdown report: {out}");
|
|
384
|
+
assert_eq!(out["verification_degraded"], json!(false), "shutdown report: {out}");
|
|
385
|
+
let saved = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
386
|
+
assert_eq!(
|
|
387
|
+
saved.pointer("/teams/current/status").and_then(serde_json::Value::as_str),
|
|
388
|
+
Some("shutdown"),
|
|
389
|
+
"0.5.27 RED: clean scoped shutdown returned session_killed=true and not degraded, \
|
|
390
|
+
so disk state must persist teams.current.status=shutdown. The compact projected \
|
|
391
|
+
save path must not drop the tombstone; saved={saved}"
|
|
392
|
+
);
|
|
393
|
+
assert_eq!(
|
|
394
|
+
saved
|
|
395
|
+
.pointer("/teams/current/agents/adminweb/status")
|
|
396
|
+
.and_then(serde_json::Value::as_str),
|
|
397
|
+
Some("stopped"),
|
|
398
|
+
"0.5.27 RED: clean shutdown must also persist stopped agents under the retained team; saved={saved}"
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#[test]
|
|
403
|
+
fn scoped_degraded_shutdown_does_not_persist_team_shutdown_status() {
|
|
404
|
+
let ws = tmp_shutdown_workspace("scoped-degraded-no-status");
|
|
405
|
+
crate::state::persist::save_runtime_state(
|
|
406
|
+
&ws,
|
|
407
|
+
&json!({
|
|
408
|
+
"schema_version": 1,
|
|
409
|
+
"active_team_key": "current",
|
|
410
|
+
"team_key": "current",
|
|
411
|
+
"session_name": "team-current",
|
|
412
|
+
"agents": {
|
|
413
|
+
"adminweb": {
|
|
414
|
+
"agent_id": "adminweb",
|
|
415
|
+
"status": "running",
|
|
416
|
+
"provider": "fake",
|
|
417
|
+
"window": "adminweb"
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
"teams": {
|
|
421
|
+
"current": {
|
|
422
|
+
"team_key": "current",
|
|
423
|
+
"session_name": "team-current",
|
|
424
|
+
"agents": {
|
|
425
|
+
"adminweb": {
|
|
426
|
+
"agent_id": "adminweb",
|
|
427
|
+
"status": "running",
|
|
428
|
+
"provider": "fake",
|
|
429
|
+
"window": "adminweb"
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}),
|
|
435
|
+
)
|
|
436
|
+
.unwrap();
|
|
437
|
+
|
|
438
|
+
let out = crate::cli::lifecycle_port::shutdown_with_transport(
|
|
439
|
+
&ws,
|
|
440
|
+
true,
|
|
441
|
+
Some("current"),
|
|
442
|
+
&CleanShutdownTransport::new().with_probe_timeout("ps_table"),
|
|
443
|
+
)
|
|
444
|
+
.expect("shutdown should complete");
|
|
445
|
+
|
|
446
|
+
assert_eq!(out["session_killed"], json!(true), "shutdown report: {out}");
|
|
447
|
+
assert_eq!(out["verification_degraded"], json!(true), "shutdown report: {out}");
|
|
448
|
+
let saved = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
449
|
+
assert_ne!(
|
|
450
|
+
saved.pointer("/teams/current/status").and_then(serde_json::Value::as_str),
|
|
451
|
+
Some("shutdown"),
|
|
452
|
+
"degraded shutdown must remain dirty/diagnostic and must not stamp \
|
|
453
|
+
teams.current.status=shutdown; saved={saved}"
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
334
457
|
#[test]
|
|
335
458
|
fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
|
|
336
459
|
let ws = tmp_shutdown_workspace("owned-loop-no-growth");
|
|
@@ -3994,8 +3994,19 @@ fn rollback_add_agent_atomic(
|
|
|
3994
3994
|
} else {
|
|
3995
3995
|
let _ = std::fs::remove_file(spec_path);
|
|
3996
3996
|
}
|
|
3997
|
+
// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.4):
|
|
3998
|
+
// rollback must tombstone the newly-added agent so the persist merge
|
|
3999
|
+
// does not re-attach a `roster_stub` from the latest on disk. Without
|
|
4000
|
+
// the tombstone the half-added `agents.standards` / `teams.<key>.agents.standards`
|
|
4001
|
+
// survives the restore-from-pre_state pass and the retry sees
|
|
4002
|
+
// "agent id already exists".
|
|
4003
|
+
let deleted = [agent_id.as_str()];
|
|
3997
4004
|
if let Some(state) = pre_runtime_state {
|
|
3998
|
-
let _ = crate::state::persist::
|
|
4005
|
+
let _ = crate::state::persist::save_runtime_state_with_deleted_agents(
|
|
4006
|
+
run_workspace,
|
|
4007
|
+
state,
|
|
4008
|
+
&deleted,
|
|
4009
|
+
);
|
|
3999
4010
|
} else {
|
|
4000
4011
|
// No prior runtime state — drop just the agent we added (load → strip → save).
|
|
4001
4012
|
if let Ok(mut state) = crate::state::persist::load_runtime_state(run_workspace) {
|
|
@@ -4005,7 +4016,24 @@ fn rollback_add_agent_atomic(
|
|
|
4005
4016
|
{
|
|
4006
4017
|
agents.remove(agent_id.as_str());
|
|
4007
4018
|
}
|
|
4008
|
-
let
|
|
4019
|
+
if let Some(teams) = state
|
|
4020
|
+
.get_mut("teams")
|
|
4021
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
4022
|
+
{
|
|
4023
|
+
for team in teams.values_mut() {
|
|
4024
|
+
if let Some(agents) = team
|
|
4025
|
+
.get_mut("agents")
|
|
4026
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
4027
|
+
{
|
|
4028
|
+
agents.remove(agent_id.as_str());
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
let _ = crate::state::persist::save_runtime_state_with_deleted_agents(
|
|
4033
|
+
run_workspace,
|
|
4034
|
+
&state,
|
|
4035
|
+
&deleted,
|
|
4036
|
+
);
|
|
4009
4037
|
}
|
|
4010
4038
|
}
|
|
4011
4039
|
}
|
|
@@ -451,6 +451,10 @@ fn apply_persist_merge_contract(
|
|
|
451
451
|
let skip_top_level_capture_backfill =
|
|
452
452
|
should_skip_capture_backfill(top_level_team.as_deref(), skip_capture_backfill_team_key);
|
|
453
453
|
if projection_matches {
|
|
454
|
+
// 0.5.26 (§7.3): top-level projection uses the incoming root state's
|
|
455
|
+
// implicit "alive" shape (top-level agents are always the active team).
|
|
456
|
+
// Passing `None` for team lifecycle keeps historical behavior when the
|
|
457
|
+
// active team is not lifecycle-marked.
|
|
454
458
|
merge_agent_projection(
|
|
455
459
|
"agents",
|
|
456
460
|
incoming.get_mut("agents"),
|
|
@@ -459,6 +463,7 @@ fn apply_persist_merge_contract(
|
|
|
459
463
|
skip_top_level_capture_backfill,
|
|
460
464
|
skip_capture_backfill_agent_ids,
|
|
461
465
|
topology_update_agent_ids,
|
|
466
|
+
None,
|
|
462
467
|
)?;
|
|
463
468
|
// Stage 3c (identity-boundary unified plan, architect direction
|
|
464
469
|
// 2026-06-23): top-level owner copy-back removed. Pre-3c this
|
|
@@ -495,6 +500,19 @@ fn apply_persist_merge_contract(
|
|
|
495
500
|
continue;
|
|
496
501
|
};
|
|
497
502
|
let projection = format!("teams.{team}.agents");
|
|
503
|
+
// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.3):
|
|
504
|
+
// decide the containing team's aliveness from BOTH sides — a shutdown
|
|
505
|
+
// that stamped the incoming as non-alive would otherwise leak the
|
|
506
|
+
// stale latest into the conflict path. `false` on either side kills
|
|
507
|
+
// the live-topology protection so a dead sibling's stale pane can't
|
|
508
|
+
// block a live sibling's write.
|
|
509
|
+
let team_alive = Some(
|
|
510
|
+
crate::state::projection::team_is_alive_candidate(latest_entry)
|
|
511
|
+
&& incoming_entry
|
|
512
|
+
.as_object()
|
|
513
|
+
.map(|_| crate::state::projection::team_is_alive_candidate(incoming_entry))
|
|
514
|
+
.unwrap_or(true),
|
|
515
|
+
);
|
|
498
516
|
merge_agent_projection(
|
|
499
517
|
&projection,
|
|
500
518
|
incoming_entry.get_mut("agents"),
|
|
@@ -503,6 +521,7 @@ fn apply_persist_merge_contract(
|
|
|
503
521
|
should_skip_capture_backfill(Some(team), skip_capture_backfill_team_key),
|
|
504
522
|
skip_capture_backfill_agent_ids,
|
|
505
523
|
topology_update_agent_ids,
|
|
524
|
+
team_alive,
|
|
506
525
|
)?;
|
|
507
526
|
preserve_latest_ownership_fields(incoming_entry, latest_entry);
|
|
508
527
|
preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
|
|
@@ -528,11 +547,7 @@ fn apply_persist_merge_contract(
|
|
|
528
547
|
fn preserve_transport_shim(incoming: &mut Value, latest: &Value) {
|
|
529
548
|
// The shim block only matters when `latest` has one AND
|
|
530
549
|
// `incoming` doesn't (or `incoming.transport` doesn't exist).
|
|
531
|
-
let Some(latest_shim) = latest
|
|
532
|
-
.get("transport")
|
|
533
|
-
.and_then(|t| t.get("shim"))
|
|
534
|
-
.cloned()
|
|
535
|
-
else {
|
|
550
|
+
let Some(latest_shim) = latest.get("transport").and_then(|t| t.get("shim")).cloned() else {
|
|
536
551
|
return;
|
|
537
552
|
};
|
|
538
553
|
let Some(incoming_obj) = incoming.as_object_mut() else {
|
|
@@ -604,21 +619,20 @@ fn latest_has_preferable_endpoint_convergence(incoming: &Value, latest: &Value)
|
|
|
604
619
|
{
|
|
605
620
|
return false;
|
|
606
621
|
}
|
|
607
|
-
let latest_epoch =
|
|
622
|
+
let latest_epoch =
|
|
623
|
+
endpoint_convergence_epoch(latest).unwrap_or_else(|| ownership_epoch(latest));
|
|
608
624
|
let incoming_epoch =
|
|
609
625
|
endpoint_convergence_epoch(incoming).unwrap_or_else(|| ownership_epoch(incoming));
|
|
610
626
|
if latest_epoch < incoming_epoch {
|
|
611
627
|
return false;
|
|
612
628
|
}
|
|
613
|
-
!incoming
|
|
614
|
-
.get("
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
.is_some_and(|epoch| epoch >= latest_epoch)
|
|
621
|
-
})
|
|
629
|
+
!incoming.get("topology_convergence").is_some_and(|marker| {
|
|
630
|
+
marker.get("status").and_then(Value::as_str) == Some("converged")
|
|
631
|
+
&& marker
|
|
632
|
+
.get("owner_epoch")
|
|
633
|
+
.and_then(Value::as_u64)
|
|
634
|
+
.is_some_and(|epoch| epoch >= latest_epoch)
|
|
635
|
+
})
|
|
622
636
|
}
|
|
623
637
|
|
|
624
638
|
fn endpoint_convergence_epoch(state: &Value) -> Option<u64> {
|
|
@@ -666,6 +680,7 @@ fn ownership_attached(state: &Value) -> bool {
|
|
|
666
680
|
})
|
|
667
681
|
}
|
|
668
682
|
|
|
683
|
+
#[allow(clippy::too_many_arguments)]
|
|
669
684
|
fn merge_agent_projection(
|
|
670
685
|
projection: &str,
|
|
671
686
|
incoming_agents: Option<&mut Value>,
|
|
@@ -674,6 +689,14 @@ fn merge_agent_projection(
|
|
|
674
689
|
skip_capture_backfill: bool,
|
|
675
690
|
skip_capture_backfill_agent_ids: &BTreeSet<String>,
|
|
676
691
|
topology_update_agent_ids: &BTreeSet<String>,
|
|
692
|
+
// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.3):
|
|
693
|
+
// whether the containing team is a live protection target.
|
|
694
|
+
// - `None`: top-level projection — historical behavior, protect on truthy topology.
|
|
695
|
+
// - `Some(true)`: per-team merge for a canonical-alive team — protect.
|
|
696
|
+
// - `Some(false)`: per-team merge for a shutdown/legacy-stopped team —
|
|
697
|
+
// the merge must NOT protect stale topology so a live sibling's write
|
|
698
|
+
// can go through.
|
|
699
|
+
team_alive: Option<bool>,
|
|
677
700
|
) -> Result<(), StateError> {
|
|
678
701
|
let Some(incoming_agents) = incoming_agents else {
|
|
679
702
|
return Ok(());
|
|
@@ -695,7 +718,9 @@ fn merge_agent_projection(
|
|
|
695
718
|
if topology_update_agent_ids.contains(agent_id) {
|
|
696
719
|
continue;
|
|
697
720
|
}
|
|
698
|
-
if !tombstoned_for_projection
|
|
721
|
+
if !tombstoned_for_projection
|
|
722
|
+
&& latest_has_protected_live_topology(latest_agent, team_alive)
|
|
723
|
+
{
|
|
699
724
|
return Err(save_conflict(
|
|
700
725
|
projection,
|
|
701
726
|
agent_id,
|
|
@@ -708,7 +733,7 @@ fn merge_agent_projection(
|
|
|
708
733
|
}
|
|
709
734
|
serde_json::map::Entry::Occupied(mut existing) => {
|
|
710
735
|
if !tombstoned_for_projection && !topology_update_agent_ids.contains(agent_id) {
|
|
711
|
-
let fields = topology_conflict_fields(existing.get(), latest_agent);
|
|
736
|
+
let fields = topology_conflict_fields(existing.get(), latest_agent, team_alive);
|
|
712
737
|
if !fields.is_empty() {
|
|
713
738
|
return Err(save_conflict(projection, agent_id, fields));
|
|
714
739
|
}
|
|
@@ -729,7 +754,23 @@ fn save_conflict(projection: &str, agent_id: &str, fields: Vec<&'static str>) ->
|
|
|
729
754
|
))
|
|
730
755
|
}
|
|
731
756
|
|
|
732
|
-
|
|
757
|
+
/// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.3):
|
|
758
|
+
/// 谓词收敛。原 `latest_has_live_topology` 只看 topology 字段真值,现改为:
|
|
759
|
+
/// - 若上层告知 `team_alive=Some(false)` → 死队,永不 protect;
|
|
760
|
+
/// - 若 latest 的 agent.status 是终态(stopped/dead/…)→ 永不 protect;
|
|
761
|
+
/// - 否则维持原判据(topology 字段真值 → protect)。
|
|
762
|
+
///
|
|
763
|
+
/// 保守设计:top-level 传 `None` 时行为与 0.5.25 前完全一致(仅看 topology
|
|
764
|
+
/// 真值),避免影响 F0-1/F0-2/E6 已通契约。
|
|
765
|
+
fn latest_has_protected_live_topology(agent: &Value, team_alive: Option<bool>) -> bool {
|
|
766
|
+
if team_alive == Some(false) {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
if let Some(status) = agent.get("status").and_then(Value::as_str) {
|
|
770
|
+
if crate::state::projection::agent_status_is_terminal(status) {
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
733
774
|
LIVE_TOPOLOGY_FIELDS
|
|
734
775
|
.iter()
|
|
735
776
|
.any(|field| agent.get(field).is_some_and(json_truthy))
|
|
@@ -743,7 +784,16 @@ fn live_topology_fields(agent: &Value) -> Vec<&'static str> {
|
|
|
743
784
|
.collect()
|
|
744
785
|
}
|
|
745
786
|
|
|
746
|
-
fn topology_conflict_fields(
|
|
787
|
+
fn topology_conflict_fields(
|
|
788
|
+
incoming_agent: &Value,
|
|
789
|
+
latest_agent: &Value,
|
|
790
|
+
team_alive: Option<bool>,
|
|
791
|
+
) -> Vec<&'static str> {
|
|
792
|
+
// 0.5.26 (§7.3): 若被守卫的 team 已死 / agent 处终态,不再报 topology
|
|
793
|
+
// 冲突,允许活队 sibling write 通过。
|
|
794
|
+
if !latest_has_protected_live_topology(latest_agent, team_alive) {
|
|
795
|
+
return Vec::new();
|
|
796
|
+
}
|
|
747
797
|
let latest_live = live_topology_fields(latest_agent);
|
|
748
798
|
if latest_live.is_empty() {
|
|
749
799
|
return Vec::new();
|
|
@@ -237,8 +237,15 @@ pub fn merge_workspace_team_state(existing: &Value, launched: &Value) -> Value {
|
|
|
237
237
|
Value::Object(merged)
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
/// `team_state_candidates`(`state.py:131`):唯一候选源 = `state.teams` 中
|
|
241
|
-
/// (大小写不敏感;缺 status/空 视为 alive
|
|
240
|
+
/// `team_state_candidates`(`state.py:131`):唯一候选源 = `state.teams` 中
|
|
241
|
+
/// `status=="alive"`(大小写不敏感;缺 status/空 视为 alive,但 0.5.26 起
|
|
242
|
+
/// legacy shutdown 残留「无 status + 全体 agents 处于终态」不再算 alive)。
|
|
243
|
+
/// 保留 teams 插入序。
|
|
244
|
+
///
|
|
245
|
+
/// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.1):
|
|
246
|
+
/// shutdown 只标 agent stopped 不动 team status,retained state 让 selector
|
|
247
|
+
/// 把死队当活队,进而卡活队写入的 SaveConflict。谓词收敛到
|
|
248
|
+
/// [`team_is_alive_candidate`],这里只做遍历。
|
|
242
249
|
pub fn team_state_candidates(state: &Value) -> Map<String, Value> {
|
|
243
250
|
let mut out = Map::new();
|
|
244
251
|
let teams = match state.get("teams") {
|
|
@@ -246,19 +253,7 @@ pub fn team_state_candidates(state: &Value) -> Map<String, Value> {
|
|
|
246
253
|
_ => return out,
|
|
247
254
|
};
|
|
248
255
|
for (key, value) in teams {
|
|
249
|
-
if !value
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
if value.get("archived_at").is_some_and(|v| !v.is_null()) {
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
// `str(value.get("status") or "alive").lower()` —— None/空串 → "alive"。
|
|
256
|
-
let status = value
|
|
257
|
-
.get("status")
|
|
258
|
-
.and_then(Value::as_str)
|
|
259
|
-
.filter(|s| !s.is_empty())
|
|
260
|
-
.unwrap_or("alive");
|
|
261
|
-
if !status.eq_ignore_ascii_case("alive") {
|
|
256
|
+
if !team_is_alive_candidate(value) {
|
|
262
257
|
continue;
|
|
263
258
|
}
|
|
264
259
|
out.insert(key.clone(), value.clone());
|
|
@@ -266,6 +261,69 @@ pub fn team_state_candidates(state: &Value) -> Map<String, Value> {
|
|
|
266
261
|
out
|
|
267
262
|
}
|
|
268
263
|
|
|
264
|
+
/// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.1): 唯一 alive
|
|
265
|
+
/// 谓词。规则:
|
|
266
|
+
/// - 非 object → 排除;
|
|
267
|
+
/// - `archived_at` 非空 → 排除;
|
|
268
|
+
/// - 显式 `status` → 只有 case-insensitive `alive` 才算活;
|
|
269
|
+
/// - `status` 缺失/空 + `agents` 有条目且全体处于终态 → legacy shutdown
|
|
270
|
+
/// residue,排除;
|
|
271
|
+
/// - `status` 缺失/空 + 无 agent 或至少一个非终态 agent → 保持兼容 alive。
|
|
272
|
+
pub(crate) fn team_is_alive_candidate(team: &Value) -> bool {
|
|
273
|
+
if !team.is_object() {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
if team.get("archived_at").is_some_and(|v| !v.is_null()) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
let status_raw = team
|
|
280
|
+
.get("status")
|
|
281
|
+
.and_then(Value::as_str)
|
|
282
|
+
.filter(|s| !s.is_empty());
|
|
283
|
+
if let Some(status) = status_raw {
|
|
284
|
+
return status.eq_ignore_ascii_case("alive");
|
|
285
|
+
}
|
|
286
|
+
if all_present_agents_terminal(team) && !team_has_live_leader_binding(team) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
true
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// 0.5.26: legacy team 缺 `status` 时,若挂着 `leader_receiver`/`team_owner`
|
|
293
|
+
/// 视为仍活的绑定面(0515 endpoint-convergence 家族:worker stopped 但 receiver
|
|
294
|
+
/// attached,restart 预检要能看到 leader_receiver 以派 `leader_receiver_socket_mismatch`)。
|
|
295
|
+
fn team_has_live_leader_binding(team: &Value) -> bool {
|
|
296
|
+
let is_non_null_object = |key: &str| {
|
|
297
|
+
team.get(key)
|
|
298
|
+
.filter(|v| !v.is_null())
|
|
299
|
+
.and_then(Value::as_object)
|
|
300
|
+
.is_some_and(|m| !m.is_empty())
|
|
301
|
+
};
|
|
302
|
+
is_non_null_object("leader_receiver") || is_non_null_object("team_owner")
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fn all_present_agents_terminal(team: &Value) -> bool {
|
|
306
|
+
let Some(agents) = team.get("agents").and_then(Value::as_object) else {
|
|
307
|
+
return false;
|
|
308
|
+
};
|
|
309
|
+
if agents.is_empty() {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
agents.values().all(|agent| {
|
|
313
|
+
agent
|
|
314
|
+
.get("status")
|
|
315
|
+
.and_then(Value::as_str)
|
|
316
|
+
.is_some_and(agent_status_is_terminal)
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
pub(crate) fn agent_status_is_terminal(status: &str) -> bool {
|
|
321
|
+
matches!(
|
|
322
|
+
status.to_ascii_lowercase().as_str(),
|
|
323
|
+
"stopped" | "removed" | "dead" | "terminated" | "failed"
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
269
327
|
/// `format_team_candidates`(`state.py:148`):候选摘要串(key 排序;agents 排序逗号连,空→`-`)。
|
|
270
328
|
pub fn format_team_candidates(team_states: &Map<String, Value>) -> String {
|
|
271
329
|
if team_states.is_empty() {
|
|
@@ -432,6 +490,19 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
|
|
|
432
490
|
if alive.contains_key(team) {
|
|
433
491
|
return Ok(project_top_level_view(&state, team));
|
|
434
492
|
}
|
|
493
|
+
// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.1):
|
|
494
|
+
// 显式 `--team X` 命中 `state.teams` 里的键(即使被标 shutdown/终态)
|
|
495
|
+
// 仍应投影出来,支持「shutdown 后再 restart --team X」多队场景
|
|
496
|
+
// (E2E-REST-014 家族)。走 project_top_level_view 保证顶层 agents
|
|
497
|
+
// 与 teams[key].agents 收敛(E2E-REST-002 依赖 projection clobber
|
|
498
|
+
// 顶层注入的 session_id)。
|
|
499
|
+
if state
|
|
500
|
+
.get("teams")
|
|
501
|
+
.and_then(Value::as_object)
|
|
502
|
+
.is_some_and(|teams| teams.contains_key(team))
|
|
503
|
+
{
|
|
504
|
+
return Ok(project_top_level_view(&state, team));
|
|
505
|
+
}
|
|
435
506
|
let matches: Vec<&String> = alive
|
|
436
507
|
.iter()
|
|
437
508
|
.filter(|(key, value)| team_selector_matches(team, key, value))
|
|
@@ -466,6 +537,19 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
|
|
|
466
537
|
return Ok(project_top_level_view(&state, &only));
|
|
467
538
|
}
|
|
468
539
|
if alive.is_empty() {
|
|
540
|
+
// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.1):
|
|
541
|
+
// 全体被标 shutdown(bare restart 场景),`active_team_key` 若命中
|
|
542
|
+
// `state.teams` 则仍投影,让 `project_top_level_view` 用 nested
|
|
543
|
+
// `teams[key].agents` 覆盖历史顶层注入(E2E-REST-002 前置)。
|
|
544
|
+
if let Some(active_key) = state.get("active_team_key").and_then(Value::as_str) {
|
|
545
|
+
if state
|
|
546
|
+
.get("teams")
|
|
547
|
+
.and_then(Value::as_object)
|
|
548
|
+
.is_some_and(|teams| teams.contains_key(active_key))
|
|
549
|
+
{
|
|
550
|
+
return Ok(project_top_level_view(&state, active_key));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
469
553
|
return Ok(state);
|
|
470
554
|
}
|
|
471
555
|
Err(StateError::TeamSelect(
|
|
@@ -897,11 +981,19 @@ mod tests {
|
|
|
897
981
|
|
|
898
982
|
#[test]
|
|
899
983
|
fn candidates_alive_filter_and_order() {
|
|
984
|
+
// 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.1):
|
|
985
|
+
// 空 bootstrap team(无 agents)在 status 缺失时保 alive 兼容;
|
|
986
|
+
// 但「status 缺失 + 全体 agent stopped」是 legacy shutdown residue,
|
|
987
|
+
// 必须被剔除,防止 shutdown --keep-logs 的死队卡活队写入。
|
|
900
988
|
let s = json!({"teams": {
|
|
901
989
|
"alive1": {"status": "alive", "session_name": "sa"},
|
|
902
990
|
"dead1": {"status": "shutdown"},
|
|
903
991
|
"nostatus": {"session_name": "sn"},
|
|
904
992
|
"ALIVE_CASE": {"status": "ALIVE"},
|
|
993
|
+
"legacy_shutdown_all_stopped": {
|
|
994
|
+
"session_name": "team-supermarket-suite",
|
|
995
|
+
"agents": {"adminweb": {"status": "stopped"}}
|
|
996
|
+
},
|
|
905
997
|
}});
|
|
906
998
|
let cands = team_state_candidates(&s);
|
|
907
999
|
let keys: Vec<&String> = cands.keys().collect();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.27",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.27",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.27",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.27"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|