@team-agent/installer 0.5.24 → 0.5.26

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.24"
578
+ version = "0.5.26"
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.24"
12
+ version = "0.5.26"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -171,10 +171,23 @@ pub(crate) fn diagnose_runtime_for_workspace(
171
171
  backend: &dyn Transport,
172
172
  ) -> (Value, Value) {
173
173
  let (mut issues, mut repairs) = diagnose_runtime(state, backend);
174
+ append_legacy_snapshot_issue(workspace, state, &mut issues);
174
175
  append_coordinator_health_issue(workspace, state, &mut issues, &mut repairs);
175
176
  (issues, repairs)
176
177
  }
177
178
 
179
+ fn append_legacy_snapshot_issue(workspace: &std::path::Path, state: &Value, issues: &mut Value) {
180
+ let Ok(Some(details)) = crate::leader::detect_dual_state_divergence(workspace, state) else {
181
+ return;
182
+ };
183
+ if let Some(items) = issues.as_array_mut() {
184
+ items.push(json!({
185
+ "id": "legacy_snapshot_stale",
186
+ "details": details,
187
+ }));
188
+ }
189
+ }
190
+
178
191
  fn append_coordinator_health_issue(
179
192
  workspace: &std::path::Path,
180
193
  state: &Value,
@@ -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,39 @@ 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
+ /// scoped shutdown 只有一队,`state.active_team_key` 指向被关的那队。
3468
+ /// 只在 session_killed && !verification_degraded 时被调用。
3469
+ fn mark_active_team_shutdown(state: &mut Value, status: &str) {
3470
+ let Some(active_key) = state
3471
+ .get("active_team_key")
3472
+ .and_then(Value::as_str)
3473
+ .map(str::to_string)
3474
+ else {
3475
+ return;
3476
+ };
3477
+ let Some(teams) = state.get_mut("teams").and_then(Value::as_object_mut) else {
3478
+ return;
3479
+ };
3480
+ if let Some(team_obj) = teams.get_mut(&active_key).and_then(Value::as_object_mut) {
3481
+ team_obj.insert("status".to_string(), json!(status));
3482
+ }
3483
+ }
3484
+
3437
3485
  fn promote_live_sibling_after_scoped_shutdown(
3438
3486
  workspace: &Path,
3439
3487
  stopped_state: &Value,
@@ -4120,9 +4168,7 @@ pub mod leader_port {
4120
4168
  response: &mut Value,
4121
4169
  ) {
4122
4170
  let Some(outcome) = crate::leader::registry::register_binding_from_state_best_effort(
4123
- workspace,
4124
- team,
4125
- source,
4171
+ workspace, team, source,
4126
4172
  ) else {
4127
4173
  return;
4128
4174
  };
@@ -4173,11 +4219,14 @@ pub mod leader_port {
4173
4219
  workspace: &Path,
4174
4220
  team: Option<&str>,
4175
4221
  ) -> 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
- })?;
4222
+ let team_key = team
4223
+ .filter(|t| !t.is_empty())
4224
+ .map(str::to_string)
4225
+ .or_else(|| {
4226
+ crate::state::persist::load_runtime_state(workspace)
4227
+ .ok()
4228
+ .map(|s| crate::state::projection::team_state_key(&s))
4229
+ })?;
4181
4230
  let path = crate::leader::registry::unregister_entry(workspace, &team_key)?;
4182
4231
  let event_log = crate::event_log::EventLog::new(workspace);
4183
4232
  let _ = event_log.write(
@@ -1780,10 +1780,13 @@ pub fn detect_dual_state_divergence( // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diag
1780
1780
  .or_else(|| get_path_u64(state, &["leader_receiver", "owner_epoch"]));
1781
1781
  let team_epoch = get_path_u64(&snap, &["team_owner", "owner_epoch"])
1782
1782
  .or_else(|| get_path_u64(&snap, &["leader_receiver", "owner_epoch"]));
1783
+ let workspace_agent_bindings = agent_binding_summary(state);
1784
+ let team_agent_bindings = agent_binding_summary(&snap);
1783
1785
  let diverged = workspace_owner_pane != team_owner_pane
1784
1786
  || workspace_owner_uuid != team_owner_uuid
1785
1787
  || workspace_receiver_pane != team_receiver_pane
1786
- || workspace_epoch != team_epoch;
1788
+ || workspace_epoch != team_epoch
1789
+ || workspace_agent_bindings != team_agent_bindings;
1787
1790
  if !diverged {
1788
1791
  return Ok(None);
1789
1792
  }
@@ -1796,10 +1799,38 @@ pub fn detect_dual_state_divergence( // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diag
1796
1799
  "team_receiver_pane": team_receiver_pane,
1797
1800
  "workspace_owner_epoch": workspace_epoch,
1798
1801
  "team_owner_epoch": team_epoch,
1802
+ "workspace_agent_bindings": workspace_agent_bindings,
1803
+ "team_agent_bindings": team_agent_bindings,
1799
1804
  "_legacy_snapshot_stale": true,
1800
1805
  })))
1801
1806
  }
1802
1807
 
1808
+ fn agent_binding_summary(state: &Value) -> Value {
1809
+ let mut out = serde_json::Map::new();
1810
+ let Some(agents) = state.get("agents").and_then(Value::as_object) else {
1811
+ return Value::Object(out);
1812
+ };
1813
+ for (agent_id, agent) in agents {
1814
+ let mut binding = serde_json::Map::new();
1815
+ for key in [
1816
+ "pane_id",
1817
+ "pane_pid",
1818
+ "tmux_endpoint",
1819
+ "tmux_socket",
1820
+ "window",
1821
+ "window_name",
1822
+ ] {
1823
+ if let Some(value) = agent.get(key) {
1824
+ binding.insert(key.to_string(), value.clone());
1825
+ }
1826
+ }
1827
+ if !binding.is_empty() {
1828
+ out.insert(agent_id.clone(), Value::Object(binding));
1829
+ }
1830
+ }
1831
+ Value::Object(out)
1832
+ }
1833
+
1803
1834
  fn readable_team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf { // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic-only path resolver.
1804
1835
  let safe_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name); // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: reuses helpers safe legacy path.
1805
1836
  if safe_path.exists() {
@@ -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
  }
@@ -238,6 +238,9 @@ pub fn compact_tool_result(result: &Value) -> ToolResult {
238
238
  "durably_stored",
239
239
  "result_id",
240
240
  "task_id",
241
+ "attributed_message_id",
242
+ "attribution_scope",
243
+ "task_id_source",
241
244
  "agent_id",
242
245
  "new_agent_id",
243
246
  "source_agent_id",
@@ -20,10 +20,10 @@ use crate::state::persist::{
20
20
  use crate::messaging::{self, MessageTarget, SendOptions};
21
21
 
22
22
  use super::helpers::{
23
- delivery_outcome_value, direct_message_attribution_for, ensure_object, enum_value,
24
- insert_array, is_worker_recipient, json_dumps_default, latest_task_for_assignee,
25
- non_empty_string, normalized_envelope_value, object_fields, requires_ack_for_target,
26
- tool_runtime_error, DirectMessageAttribution,
23
+ current_reportable_message_for, delivery_outcome_value, direct_message_attribution_for,
24
+ ensure_object, enum_value, insert_array, is_worker_recipient, json_dumps_default,
25
+ latest_task_for_assignee, non_empty_string, normalized_envelope_value, object_fields,
26
+ requires_ack_for_target, tool_runtime_error, DirectMessageAttribution,
27
27
  };
28
28
  use super::normalize::{
29
29
  compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
@@ -341,45 +341,76 @@ impl TeamOrchestratorTools {
341
341
  // only when no newer failed/refused/blocked direct turn blocks fallback
342
342
  // 5. "manual" — truly uncorrelated; collect still rejects
343
343
  let owner_team_id_str = self.owner_team_id.as_ref().map(|t| t.as_str().to_string());
344
+ let mut attributed_message_id = None;
345
+ let mut attribution_scope = None;
346
+ let mut task_id_source = None;
344
347
  let resolved = if let Some(task_id) = task_id {
345
348
  task_id.to_string()
346
349
  } else if let Some(agent) = self.agent_id.as_ref() {
347
- match direct_message_attribution_for(
350
+ if let Some(message_id) = current_reportable_message_for(
348
351
  &self.workspace,
349
352
  agent.as_str(),
350
353
  owner_team_id_str.as_deref(),
351
354
  ) {
352
- DirectMessageAttribution::Reportable(message_id) => message_id,
353
- DirectMessageAttribution::BlockedByNewer {
354
- message_id,
355
- status,
356
- error,
357
- } => {
358
- push_report_warning(
359
- obj,
360
- serde_json::json!({
361
- "code": "result_attribution_blocked_by_newer_direct_message",
362
- "field": "task_id",
363
- "severity": "warning",
364
- "advisory": true,
365
- "message_id": message_id,
366
- "message_status": status,
367
- "message_error": error,
368
- }),
369
- );
370
- "manual".to_string()
371
- }
372
- DirectMessageAttribution::None => latest_task_for_assignee(
355
+ attributed_message_id = Some(message_id.clone());
356
+ attribution_scope = Some("message");
357
+ task_id_source = Some("current_turn_message");
358
+ message_id
359
+ } else {
360
+ match direct_message_attribution_for(
373
361
  &self.workspace,
374
362
  agent.as_str(),
375
363
  owner_team_id_str.as_deref(),
376
- )
377
- .unwrap_or_else(|| "manual".to_string()),
364
+ ) {
365
+ DirectMessageAttribution::Reportable(message_id) => {
366
+ attributed_message_id = Some(message_id.clone());
367
+ attribution_scope = Some("message");
368
+ task_id_source = Some("direct_message");
369
+ message_id
370
+ }
371
+ DirectMessageAttribution::BlockedByNewer {
372
+ message_id,
373
+ status,
374
+ error,
375
+ } => {
376
+ push_report_warning(
377
+ obj,
378
+ serde_json::json!({
379
+ "code": "result_attribution_blocked_by_newer_direct_message",
380
+ "field": "task_id",
381
+ "severity": "warning",
382
+ "advisory": true,
383
+ "message_id": message_id,
384
+ "message_status": status,
385
+ "message_error": error,
386
+ }),
387
+ );
388
+ "manual".to_string()
389
+ }
390
+ DirectMessageAttribution::None => latest_task_for_assignee(
391
+ &self.workspace,
392
+ agent.as_str(),
393
+ owner_team_id_str.as_deref(),
394
+ )
395
+ .unwrap_or_else(|| "manual".to_string()),
396
+ }
378
397
  }
379
398
  } else {
380
399
  "manual".to_string()
381
400
  };
382
401
  obj.insert("task_id".to_string(), Value::String(resolved));
402
+ if let Some(message_id) = attributed_message_id {
403
+ obj.entry("attributed_message_id")
404
+ .or_insert(Value::String(message_id));
405
+ }
406
+ if let Some(scope) = attribution_scope {
407
+ obj.entry("attribution_scope")
408
+ .or_insert(Value::String(scope.to_string()));
409
+ }
410
+ if let Some(source) = task_id_source {
411
+ obj.entry("task_id_source")
412
+ .or_insert(Value::String(source.to_string()));
413
+ }
383
414
  }
384
415
  if !obj.contains_key("agent_id") {
385
416
  let resolved = agent_id
@@ -419,6 +450,7 @@ impl TeamOrchestratorTools {
419
450
  let normalized = normalize_report_envelope(&base);
420
451
  let warnings = report_result_integrity_warnings(&base, &normalized);
421
452
  let mut env_value = normalized_envelope_value(&normalized);
453
+ copy_report_attribution_fields(&base, &mut env_value);
422
454
  if !warnings.is_empty() {
423
455
  if let Some(obj) = env_value.as_object_mut() {
424
456
  obj.insert("warnings".to_string(), Value::Array(warnings));
@@ -985,6 +1017,24 @@ fn merge_object_fields(existing: &mut Value, incoming: &Value) {
985
1017
  }
986
1018
  }
987
1019
 
1020
+ fn copy_report_attribution_fields(source: &Value, target: &mut Value) {
1021
+ let Some(src) = source.as_object() else {
1022
+ return;
1023
+ };
1024
+ let Some(dst) = target.as_object_mut() else {
1025
+ return;
1026
+ };
1027
+ for key in [
1028
+ "attributed_message_id",
1029
+ "attribution_scope",
1030
+ "task_id_source",
1031
+ ] {
1032
+ if let Some(value) = src.get(key) {
1033
+ dst.entry(key.to_string()).or_insert(value.clone());
1034
+ }
1035
+ }
1036
+ }
1037
+
988
1038
  fn push_report_warning(obj: &mut serde_json::Map<String, Value>, warning: Value) {
989
1039
  let Some(code) = warning.get("code").and_then(Value::as_str) else {
990
1040
  return;
@@ -697,6 +697,7 @@ fn report_result_for_owner_team_inner(
697
697
  "task_id".to_string(),
698
698
  serde_json::Value::String(task_id.to_string()),
699
699
  );
700
+ copy_report_attribution_fields(envelope, &mut out);
700
701
  out.insert(
701
702
  "agent_id".to_string(),
702
703
  serde_json::Value::String(agent_id.to_string()),
@@ -927,6 +928,7 @@ fn report_result_for_owner_team_inner(
927
928
  "task_id".to_string(),
928
929
  serde_json::Value::String(task_id.to_string()),
929
930
  );
931
+ copy_report_attribution_fields(envelope, &mut out);
930
932
  out.insert(
931
933
  "agent_id".to_string(),
932
934
  serde_json::Value::String(agent_id.to_string()),
@@ -968,6 +970,21 @@ fn report_result_for_owner_team_inner(
968
970
  Ok(serde_json::Value::Object(out))
969
971
  }
970
972
 
973
+ fn copy_report_attribution_fields(
974
+ envelope: &serde_json::Value,
975
+ out: &mut serde_json::Map<String, serde_json::Value>,
976
+ ) {
977
+ for key in [
978
+ "attributed_message_id",
979
+ "attribution_scope",
980
+ "task_id_source",
981
+ ] {
982
+ if let Some(value) = envelope.get(key) {
983
+ out.insert(key.to_string(), value.clone());
984
+ }
985
+ }
986
+ }
987
+
971
988
  fn fallback_primary_error_text(cli_primary_error: Option<&str>, observed: String) -> String {
972
989
  match cli_primary_error.filter(|error| !error.trim().is_empty()) {
973
990
  Some(error) => format!("{error}; {observed}"),
@@ -5,7 +5,7 @@
5
5
  //! paths or behaviour. It introduces a shared vocabulary that Stage 2 (owner
6
6
  //! repository), Stage 5 (per-team runtime state), and Stage 6 (per-team
7
7
  //! coordinator) will all consume, so that no later stage has to hand-build
8
- //! `.team/runtime/<team_key>/...` paths or guess the canonical team_key from
8
+ //! `.team/runtime/teams/<team_key>/...` paths or guess the canonical team_key from
9
9
  //! a display name.
10
10
  //!
11
11
  //! Canonical rules:
@@ -15,10 +15,10 @@
15
15
  //! - For the foundation slice, `TeamScope::new(team_key)` accepts whatever
16
16
  //! the caller already resolved (state/selector + state/persist already do
17
17
  //! this work). Slug/hash promotion to global uniqueness lands in Stage 5.
18
- //! - `TeamRuntimePaths` is the SINGLE place where the
19
- //! `.team/runtime/<team_key>/` layout is constructed. Anywhere downstream
20
- //! code today hand-joins `runtime_dir(ws).join(team_key).join(...)` it
21
- //! should migrate to call a method on `TeamRuntimePaths` in a later stage.
18
+ //! - `TeamRuntimePaths` is the SINGLE place where the future B3
19
+ //! `.team/runtime/teams/<team_key>/` layout is constructed. Anywhere
20
+ //! downstream code today hand-joins runtime team paths should migrate to
21
+ //! call a method on `TeamRuntimePaths` in a later stage.
22
22
  //!
23
23
  //! Single-team behaviour: unchanged. The foundation does not move data, does
24
24
  //! not introduce new files, and is invoked by zero existing call sites yet.
@@ -72,15 +72,15 @@ impl TeamScope {
72
72
  }
73
73
  }
74
74
 
75
- /// Single source of `.team/runtime/<team_key>/...` path construction.
75
+ /// Single source of future B3 `.team/runtime/teams/<team_key>/...` path construction.
76
76
  ///
77
77
  /// Stage 2 (owner repository), Stage 5 (per-team state), Stage 6 (per-team
78
78
  /// coordinator), and Stage 7 (per-team tmux socket) will all migrate from
79
79
  /// hand-built paths to `TeamRuntimePaths`. Today nothing reads from these
80
80
  /// methods yet — the foundation just owns the layout decision so when later
81
- /// stages start writing `.team/runtime/<team_key>/state.json` (Stage 5) or
82
- /// `.team/runtime/<team_key>/coordinator.pid` (Stage 6), there is exactly
83
- /// one place to change the layout.
81
+ /// stages start writing `.team/runtime/teams/<team_key>/state.json` (Stage 5)
82
+ /// or `.team/runtime/teams/<team_key>/coordinator.pid` (Stage 6), there is
83
+ /// exactly one place to change the layout.
84
84
  #[derive(Debug, Clone)]
85
85
  pub struct TeamRuntimePaths {
86
86
  workspace: PathBuf,
@@ -110,34 +110,36 @@ impl TeamRuntimePaths {
110
110
  &self.team_key
111
111
  }
112
112
 
113
- /// `.team/runtime/<team_key>/` — the team's runtime directory. Today this
114
- /// already exists (runtime_spec lives under it). Stage 5 will start
115
- /// writing `state.json` here too.
113
+ /// `.team/runtime/teams/<team_key>/` — the team's future B3 runtime
114
+ /// directory. It is not used as product authority until the B3 migration;
115
+ /// current callers must keep using `runtime_state_path`.
116
116
  pub fn team_dir(&self) -> PathBuf {
117
- runtime_dir(&self.workspace).join(&self.team_key)
117
+ runtime_dir(&self.workspace)
118
+ .join("teams")
119
+ .join(&self.team_key)
118
120
  }
119
121
 
120
- /// `.team/runtime/<team_key>/team.spec.yaml` runtime spec. Mirrors the
121
- /// existing `runtime_spec_path` helper; provided here so downstream
122
- /// callers don't need to import two layout APIs.
122
+ /// Transitional runtime spec path. Mirrors the existing
123
+ /// `runtime_spec_path` helper while specs remain at
124
+ /// `.team/runtime/<team_key>/team.spec.yaml`.
123
125
  pub fn spec_path(&self) -> PathBuf {
124
126
  runtime_spec_path(&self.workspace, &self.team_key)
125
127
  }
126
128
 
127
- /// `.team/runtime/<team_key>/state.json` — the canonical per-team state
128
- /// path that Stage 5 will start writing. Not used by Stage 0 product
129
- /// code; callers must continue using `runtime_state_path` until Stage 5
129
+ /// `.team/runtime/teams/<team_key>/state.json` — the canonical per-team
130
+ /// state path that B3 will start writing. Not used by current product
131
+ /// code; callers must continue using `runtime_state_path` until B3
130
132
  /// migrates the truth source.
131
133
  pub fn state_path(&self) -> PathBuf {
132
134
  self.team_dir().join("state.json")
133
135
  }
134
136
 
135
- /// `.team/runtime/<team_key>/coordinator.pid` — Stage 6 sidecar location.
137
+ /// `.team/runtime/teams/<team_key>/coordinator.pid` — future sidecar location.
136
138
  pub fn coordinator_pid_path(&self) -> PathBuf {
137
139
  self.team_dir().join("coordinator.pid")
138
140
  }
139
141
 
140
- /// `.team/runtime/<team_key>/coordinator.log` — Stage 6 sidecar location.
142
+ /// `.team/runtime/teams/<team_key>/coordinator.log` — future sidecar location.
141
143
  pub fn coordinator_log_path(&self) -> PathBuf {
142
144
  self.team_dir().join("coordinator.log")
143
145
  }
@@ -240,23 +242,20 @@ mod tests {
240
242
  }
241
243
 
242
244
  #[test]
243
- fn team_runtime_paths_layout_matches_existing_runtime_dir_layout() {
245
+ fn team_runtime_paths_layout_preannounces_b3_state_dir() {
244
246
  let paths = TeamRuntimePaths::new(PathBuf::from("/ws/proj"), "alpha");
245
- assert_eq!(
246
- paths.team_dir(),
247
- PathBuf::from("/ws/proj/.team/runtime/alpha")
248
- );
247
+ let b3_team_dir = PathBuf::from("/ws/proj/.team/runtime")
248
+ .join("teams")
249
+ .join("alpha");
250
+ assert_eq!(paths.team_dir(), b3_team_dir);
249
251
  assert_eq!(
250
252
  paths.spec_path(),
251
253
  PathBuf::from("/ws/proj/.team/runtime/alpha/team.spec.yaml")
252
254
  );
253
- assert_eq!(
254
- paths.state_path(),
255
- PathBuf::from("/ws/proj/.team/runtime/alpha/state.json")
256
- );
255
+ assert_eq!(paths.state_path(), b3_team_dir.join("state.json"));
257
256
  assert_eq!(
258
257
  paths.coordinator_pid_path(),
259
- PathBuf::from("/ws/proj/.team/runtime/alpha/coordinator.pid")
258
+ b3_team_dir.join("coordinator.pid")
260
259
  );
261
260
  }
262
261
 
@@ -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.24",
3
+ "version": "0.5.26",
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",
24
- "@team-agent/cli-darwin-x64": "0.5.24",
25
- "@team-agent/cli-linux-x64": "0.5.24"
23
+ "@team-agent/cli-darwin-arm64": "0.5.26",
24
+ "@team-agent/cli-darwin-x64": "0.5.26",
25
+ "@team-agent/cli-linux-x64": "0.5.26"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",