@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 CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.25"
578
+ version = "0.5.27"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.25"
12
+ version = "0.5.27"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -203,7 +203,13 @@ const DISPATCH_COMMANDS: &[&str] = &[
203
203
  "coordinator",
204
204
  ];
205
205
 
206
- const SPEC_ONLY_HELP_COMMANDS: &[&str] = &["start", "purge-agent"];
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
- Some("purge-agent") => "usage: team-agent purge-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--json]".to_string(),
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.".to_string(),
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
- &["--task", "--status", "--assignee", "--workspace", "--team", "--json"][..],
1833
+ &[
1834
+ "--task",
1835
+ "--status",
1836
+ "--assignee",
1837
+ "--workspace",
1838
+ "--team",
1839
+ "--json",
1840
+ ][..],
1831
1841
  ),
1832
1842
  ("diagnose", &["--workspace", "--team", "--json"][..]),
1833
- ("wait-ready", &["--workspace", "--team", "--timeout", "--json"][..]),
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
- matches!(coordinator_stop_reason, "bare_shutdown" | "scoped_last_live_team");
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
- mark_matching_session_teams_stopped(&mut state, session_name.as_ref());
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 _ = crate::cli::leader_port::unregister_after_shutdown_success(&run_workspace, team);
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.filter(|t| !t.is_empty()).map(str::to_string).or_else(|| {
4177
- crate::state::persist::load_runtime_state(workspace)
4178
- .ok()
4179
- .map(|s| crate::state::projection::team_state_key(&s))
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::save_runtime_state(run_workspace, state);
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 _ = crate::state::persist::save_runtime_state(run_workspace, &state);
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 = endpoint_convergence_epoch(latest).unwrap_or_else(|| ownership_epoch(latest));
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("topology_convergence")
615
- .is_some_and(|marker| {
616
- marker.get("status").and_then(Value::as_str) == Some("converged")
617
- && marker
618
- .get("owner_epoch")
619
- .and_then(Value::as_u64)
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 && latest_has_live_topology(latest_agent) {
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
- fn latest_has_live_topology(agent: &Value) -> bool {
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(incoming_agent: &Value, latest_agent: &Value) -> Vec<&'static str> {
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` 中 `status=="alive"`
241
- /// (大小写不敏感;缺 status/空 视为 alive)。保留 teams 插入序。
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.is_object() {
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.25",
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.25",
24
- "@team-agent/cli-darwin-x64": "0.5.25",
25
- "@team-agent/cli-linux-x64": "0.5.25"
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",