@team-agent/installer 0.5.39 → 0.5.41

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.39"
578
+ version = "0.5.41"
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.39"
12
+ version = "0.5.41"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -198,9 +198,86 @@ pub(crate) fn diagnose_runtime_for_workspace(
198
198
  let (mut issues, mut repairs) = diagnose_runtime(state, backend);
199
199
  append_legacy_snapshot_issue(workspace, state, &mut issues);
200
200
  append_coordinator_health_issue(workspace, state, &mut issues, &mut repairs);
201
+ append_runtime_bindings_stale_after_boot_issue(workspace, state, &mut issues, &mut repairs);
201
202
  (issues, repairs)
202
203
  }
203
204
 
205
+ /// 0.5.41 Slice 1 (fault-invisibility-locate.md §5/§6.2): read the
206
+ /// coordinator heartbeat sidecar for the last-observed host boot
207
+ /// identity and compare it against the current host boot. If they
208
+ /// differ, the pane/pid/session bindings in state predate the current
209
+ /// boot — surface `runtime_bindings_stale_after_boot` with a reused
210
+ /// `team-agent restart` hint. Read-only: no mutation. When the
211
+ /// heartbeat is missing OR the current host boot cannot be probed,
212
+ /// stay silent (per §8 risk note — do not guess).
213
+ fn append_runtime_bindings_stale_after_boot_issue(
214
+ workspace: &std::path::Path,
215
+ state: &Value,
216
+ issues: &mut Value,
217
+ repairs: &mut Value,
218
+ ) {
219
+ let workspace_path = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
220
+ let Some(heartbeat) = crate::coordinator::read_coordinator_heartbeat(&workspace_path) else {
221
+ return;
222
+ };
223
+ let Some(recorded_host_boot) = heartbeat
224
+ .get("host_boot_id")
225
+ .and_then(Value::as_str)
226
+ .filter(|s| !s.is_empty() && *s != "unknown")
227
+ else {
228
+ return;
229
+ };
230
+ let Some(current_host_boot) = crate::coordinator::probe_host_boot_id() else {
231
+ return;
232
+ };
233
+ if current_host_boot == recorded_host_boot {
234
+ return;
235
+ }
236
+ // Only surface when state actually has pane/session bindings that
237
+ // matter — otherwise there's nothing to be stale about.
238
+ let has_bindings = state
239
+ .get("agents")
240
+ .and_then(Value::as_object)
241
+ .is_some_and(|agents| {
242
+ agents.values().any(|agent| {
243
+ agent
244
+ .get("pane_id")
245
+ .and_then(Value::as_str)
246
+ .is_some_and(|s| !s.is_empty())
247
+ })
248
+ });
249
+ if !has_bindings {
250
+ return;
251
+ }
252
+ let session_name = state
253
+ .get("session_name")
254
+ .and_then(Value::as_str)
255
+ .unwrap_or("unknown");
256
+ if let Some(items) = issues.as_array_mut() {
257
+ items.push(json!({
258
+ "id": "runtime_bindings_stale_after_boot",
259
+ "session_name": session_name,
260
+ "recorded_host_boot_id": recorded_host_boot,
261
+ "current_host_boot_id": current_host_boot,
262
+ "details": "cached pane/pid/session bindings predate the current host boot; runtime facts are stale",
263
+ }));
264
+ }
265
+ if let Some(items) = repairs.as_array_mut() {
266
+ items.push(json!({
267
+ "issue": "runtime_bindings_stale_after_boot",
268
+ "action_required": true,
269
+ "advisory": false,
270
+ "hint_action": "team-agent restart",
271
+ "action": format!(
272
+ "runtime bindings for `{session_name}` predate the current host boot \
273
+ (recorded {recorded_host_boot}, current {current_host_boot}); rerun \
274
+ `team-agent restart` to rebuild the pane/pid/session tuple"
275
+ ),
276
+ "dedupe_key": "runtime_bindings_stale_after_boot",
277
+ }));
278
+ }
279
+ }
280
+
204
281
  fn append_legacy_snapshot_issue(workspace: &std::path::Path, state: &Value, issues: &mut Value) {
205
282
  let Ok(Some(details)) = crate::leader::detect_dual_state_divergence(workspace, state) else {
206
283
  return;
@@ -3339,6 +3339,30 @@ pub mod lifecycle_port {
3339
3339
  "invalid": invalid.iter().map(|w| w.worker_id.as_str()).collect::<Vec<_>>(),
3340
3340
  "reminder": crate::cli::QUICK_START_REMINDER,
3341
3341
  }),
3342
+ crate::lifecycle::RestartReport::RefusedBuildBeforeDestroyRequired {
3343
+ session_name,
3344
+ live_agents,
3345
+ error,
3346
+ } => {
3347
+ let repair_team = team
3348
+ .filter(|team| !team.is_empty())
3349
+ .unwrap_or(session_name.as_str());
3350
+ let shutdown_scoped =
3351
+ format!("team-agent shutdown --team {repair_team}");
3352
+ json!({
3353
+ "ok": false,
3354
+ "status": "refused_build_before_destroy",
3355
+ "reason": "build_before_destroy_required",
3356
+ "session_name": session_name,
3357
+ "live_agents": live_agents,
3358
+ "error": error,
3359
+ "next_actions": [
3360
+ shutdown_scoped,
3361
+ "team-agent restart",
3362
+ ],
3363
+ "reminder": crate::cli::QUICK_START_REMINDER,
3364
+ })
3365
+ }
3342
3366
  crate::lifecycle::RestartReport::RefusedDirtyTopology {
3343
3367
  session_name,
3344
3368
  reason,
@@ -36,12 +36,19 @@ use rusqlite::params;
36
36
  let undelivered_backlog = count_undelivered_backlog(&conn, owner_team_id)?;
37
37
  let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
38
38
  let tmux_present = tmux_session_present(workspace, state, session_name.as_str());
39
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): resolve
40
+ // RuntimeFreshness once and thread it through the runtime block
41
+ // and per-agent enrichment. This is the single-source read that
42
+ // makes host-boot / coordinator / provider-exit staleness win
43
+ // over cached state.agents and DB agent_health.
44
+ let freshness = compute_runtime_freshness(workspace, state, &health);
39
45
  let runtime_block = build_runtime_status_block(
40
46
  coordinator_running,
41
47
  undelivered_backlog,
42
48
  !tmux_present,
49
+ &freshness,
43
50
  );
44
- let agents = enrich_agents(state.get("agents"), tmux_present);
51
+ let agents = enrich_agents(state.get("agents"), tmux_present, &freshness);
45
52
  let tasks = state
46
53
  .get("tasks")
47
54
  .cloned()
@@ -438,7 +445,88 @@ use rusqlite::params;
438
445
  .to_string()
439
446
  }
440
447
 
441
- fn enrich_agents(agents: Option<&Value>, tmux_session_present: bool) -> Value {
448
+ /// 0.5.41 Slice 3 (fault-invisibility-locate.md §5): single-source
449
+ /// runtime freshness projection consumed by status/diagnose. Read-
450
+ /// only over existing sources (`coordinator_health`, heartbeat
451
+ /// sidecar, watch state); computes NO transport calls of its own.
452
+ #[derive(Default, Clone)]
453
+ pub(crate) struct RuntimeFreshness {
454
+ pub coordinator_service_available: bool,
455
+ pub host_boot_stale: bool,
456
+ pub host_boot_recorded: Option<String>,
457
+ pub host_boot_current: Option<String>,
458
+ pub provider_exited_agents: std::collections::BTreeSet<String>,
459
+ }
460
+
461
+ impl RuntimeFreshness {
462
+ fn host_boot_stale_reason(&self) -> Option<&'static str> {
463
+ self.host_boot_stale.then_some("host_boot_mismatch")
464
+ }
465
+ }
466
+
467
+ pub(crate) fn compute_runtime_freshness(
468
+ workspace: &Path,
469
+ state: &Value,
470
+ health: &crate::coordinator::HealthReport,
471
+ ) -> RuntimeFreshness {
472
+ let workspace_path = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
473
+ let heartbeat = crate::coordinator::read_coordinator_heartbeat(&workspace_path);
474
+ let host_boot_recorded = heartbeat
475
+ .as_ref()
476
+ .and_then(|hb| hb.get("host_boot_id"))
477
+ .and_then(Value::as_str)
478
+ .filter(|s| !s.is_empty() && *s != "unknown")
479
+ .map(str::to_string);
480
+ let host_boot_current = crate::coordinator::probe_host_boot_id();
481
+ let host_boot_stale = match (host_boot_recorded.as_deref(), host_boot_current.as_deref()) {
482
+ (Some(recorded), Some(current)) => recorded != current,
483
+ _ => false,
484
+ };
485
+ // Collect worker-provider-exited agents from the coordinator
486
+ // abnormal_exit_watch payload (0.5.41 Slice 4 writes
487
+ // `worker_provider_exited` / `provider_process_dead=true`
488
+ // there). Read from top-level `coordinator.abnormal_exit_watch`
489
+ // OR the team-scoped mirror `teams.<key>.coordinator...`.
490
+ let mut provider_exited_agents = std::collections::BTreeSet::new();
491
+ for path in [
492
+ "/coordinator/abnormal_exit_watch",
493
+ &format!(
494
+ "/teams/{}/coordinator/abnormal_exit_watch",
495
+ state
496
+ .get("active_team_key")
497
+ .and_then(Value::as_str)
498
+ .unwrap_or("")
499
+ ),
500
+ ] {
501
+ if let Some(watch) = state.pointer(path).and_then(Value::as_object) {
502
+ for (agent_id, entry) in watch {
503
+ let exited = entry
504
+ .get("worker_provider_exited")
505
+ .and_then(Value::as_bool)
506
+ == Some(true)
507
+ || entry.get("provider_process_dead").and_then(Value::as_bool)
508
+ == Some(true)
509
+ || entry.get("provider_exit_marker").is_some();
510
+ if exited {
511
+ provider_exited_agents.insert(agent_id.clone());
512
+ }
513
+ }
514
+ }
515
+ }
516
+ RuntimeFreshness {
517
+ coordinator_service_available: health.service_available,
518
+ host_boot_stale,
519
+ host_boot_recorded,
520
+ host_boot_current,
521
+ provider_exited_agents,
522
+ }
523
+ }
524
+
525
+ fn enrich_agents(
526
+ agents: Option<&Value>,
527
+ tmux_session_present: bool,
528
+ freshness: &RuntimeFreshness,
529
+ ) -> Value {
442
530
  let Some(Value::Object(input)) = agents else {
443
531
  return json!({});
444
532
  };
@@ -451,15 +539,74 @@ use rusqlite::params;
451
539
  "interacted".to_string(),
452
540
  Value::String(interacted_marker(obj.get("first_send_at"))),
453
541
  );
454
- if let Some(reason) =
455
- stale_reason_for_agent(&Value::Object(obj.clone()), tmux_session_present)
542
+ // 0.5.41 Slice 3: order stale sources most-authoritative-first.
543
+ // Host boot mismatch wins because it invalidates all cached
544
+ // pane/pid/session facts. Provider-exit marker wins over
545
+ // pane liveness because the wrapper leaves an interactive
546
+ // shell live. Coordinator unavailability without stronger
547
+ // live provider proof means DB agent_health rows are stale.
548
+ // Tmux session missing keeps its existing legacy path so
549
+ // pre-0.5.41 tests remain byte-identical when no new
550
+ // signal fires.
551
+ let has_pane_binding = agent_has_pane_fact(&Value::Object(obj.clone()));
552
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5 point 3
553
+ // + §9 RED4 live-provider guard): the wrapper-era pane
554
+ // liveness cannot prove provider liveness — but a state
555
+ // `pane_current_command` that MATCHES the agent's provider
556
+ // IS positive proof (the abnormal.rs classifier writes it
557
+ // when the pane's foreground command is the provider CLI).
558
+ // When that positive proof is present, no stale downgrade
559
+ // fires here so the agent renders as working.
560
+ let provider_command_positive_proof = provider_current_command_matches(obj);
561
+ // 0.5.41 Slice 3 (0.5.35 R4 regression guard): when the
562
+ // runtime classifier has already written canonical
563
+ // `worker_state=UNKNOWN` / `activity.status=uncertain`,
564
+ // that is the authoritative honest observation — do NOT
565
+ // reclassify it as `coordinator_unavailable` stale (which
566
+ // would land it in the Stopped bucket instead of Unknown).
567
+ // Host-boot mismatch and provider-exited marker are
568
+ // stronger, more specific signals and still win.
569
+ let canonical_unknown = agent_canonical_worker_state_is_unknown(obj);
570
+ let new_reason = if freshness.host_boot_stale && has_pane_binding {
571
+ freshness.host_boot_stale_reason()
572
+ } else if freshness.provider_exited_agents.contains(agent_id) {
573
+ Some("worker_provider_exited")
574
+ } else if !freshness.coordinator_service_available
575
+ && has_pane_binding
576
+ && !provider_command_positive_proof
577
+ && !canonical_unknown
456
578
  {
579
+ Some("coordinator_unavailable")
580
+ } else {
581
+ None
582
+ };
583
+ let legacy_reason = if provider_command_positive_proof {
584
+ None
585
+ } else {
586
+ stale_reason_for_agent(
587
+ &Value::Object(obj.clone()),
588
+ tmux_session_present,
589
+ )
590
+ };
591
+ let reason = new_reason.or(legacy_reason);
592
+ if let Some(reason) = reason {
457
593
  enriched.insert("stale".to_string(), Value::Bool(true));
458
594
  enriched.insert(
459
595
  "stale_reason".to_string(),
460
596
  Value::String(reason.to_string()),
461
597
  );
462
- if !tmux_session_present {
598
+ // Downgrade cached BUSY/working when the stale source is
599
+ // one of the new authoritative signals OR the pre-existing
600
+ // session-missing signal. Legacy code only downgraded on
601
+ // !tmux_session_present; that let host_boot / provider-
602
+ // exit / coord-unavailable stale rows keep raw=running.
603
+ let is_new_signal = matches!(
604
+ reason,
605
+ "host_boot_mismatch"
606
+ | "worker_provider_exited"
607
+ | "coordinator_unavailable"
608
+ );
609
+ if !tmux_session_present || is_new_signal {
463
610
  downgrade_stale_agent(&mut enriched);
464
611
  }
465
612
  }
@@ -504,6 +651,49 @@ use rusqlite::params;
504
651
  }
505
652
  }
506
653
 
654
+ /// 0.5.41 Slice 3 (0.5.35 R4 regression guard): true when the agent
655
+ /// row carries the canonical `worker_state=UNKNOWN` OR
656
+ /// `activity.status=uncertain` observation the runtime classifier
657
+ /// writes. Used to skip the coordinator-unavailable stale mark
658
+ /// (see `enrich_agents`) so the pre-existing R4 rendering (UNKNOWN
659
+ /// beats WORKING) is preserved.
660
+ fn agent_canonical_worker_state_is_unknown(agent: &serde_json::Map<String, Value>) -> bool {
661
+ let worker_state_unknown = agent
662
+ .get("worker_state")
663
+ .and_then(Value::as_str)
664
+ .is_some_and(|value| value.eq_ignore_ascii_case("UNKNOWN"));
665
+ let activity_uncertain = agent
666
+ .get("activity")
667
+ .and_then(|v| v.get("status"))
668
+ .and_then(Value::as_str)
669
+ .is_some_and(|value| value.eq_ignore_ascii_case("uncertain"));
670
+ worker_state_unknown || activity_uncertain
671
+ }
672
+
673
+ /// 0.5.41 Slice 3 (fault-invisibility-locate.md §9 RED4 live-provider
674
+ /// guard): true when the agent row carries a `pane_current_command`
675
+ /// that matches the agent's provider CLI. This is positive proof
676
+ /// the provider is the pane's foreground process — the abnormal.rs
677
+ /// classifier writes this field after the marker/current-command
678
+ /// check clears. When true, stale-downgrade paths in
679
+ /// `enrich_agents` skip so the row keeps its BUSY/working state.
680
+ fn provider_current_command_matches(agent: &serde_json::Map<String, Value>) -> bool {
681
+ let Some(command) = agent
682
+ .get("pane_current_command")
683
+ .and_then(Value::as_str)
684
+ .filter(|s| !s.is_empty())
685
+ else {
686
+ return false;
687
+ };
688
+ let Some(provider_wire) = agent.get("provider").and_then(Value::as_str) else {
689
+ return false;
690
+ };
691
+ let Some(provider) = crate::provider::wire::parse_provider(provider_wire) else {
692
+ return false;
693
+ };
694
+ crate::leader::command_matches_provider(provider, command)
695
+ }
696
+
507
697
  fn agent_has_pane_fact(agent: &Value) -> bool {
508
698
  ["pane_id", "window", "window_name"].iter().any(|key| {
509
699
  agent
@@ -1120,6 +1310,7 @@ use rusqlite::params;
1120
1310
  coordinator_running: bool,
1121
1311
  undelivered: i64,
1122
1312
  tmux_session_missing: bool,
1313
+ freshness: &RuntimeFreshness,
1123
1314
  ) -> Value {
1124
1315
  let mut runtime = serde_json::Map::new();
1125
1316
  runtime.insert(
@@ -1127,11 +1318,33 @@ use rusqlite::params;
1127
1318
  json!({"ok": coordinator_running}),
1128
1319
  );
1129
1320
  runtime.insert("undelivered".to_string(), json!(undelivered));
1321
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): expose
1322
+ // typed issues on the runtime block so `status --json --detail`
1323
+ // can be scanned for `runtime_bindings_stale_after_boot` the
1324
+ // same way `diagnose` surfaces it. Hint precedence: session-
1325
+ // missing > host-boot-stale > coordinator-not-running-with-
1326
+ // backlog — most-actionable-first.
1327
+ let mut issues: Vec<Value> = Vec::new();
1328
+ if freshness.host_boot_stale {
1329
+ issues.push(json!({
1330
+ "id": "runtime_bindings_stale_after_boot",
1331
+ "recorded_host_boot_id": freshness.host_boot_recorded,
1332
+ "current_host_boot_id": freshness.host_boot_current,
1333
+ }));
1334
+ }
1335
+ if !issues.is_empty() {
1336
+ runtime.insert("issues".to_string(), Value::Array(issues));
1337
+ }
1130
1338
  if tmux_session_missing {
1131
1339
  runtime.insert(
1132
1340
  "hint".to_string(),
1133
1341
  json!("tmux session missing — run team-agent restart"),
1134
1342
  );
1343
+ } else if freshness.host_boot_stale {
1344
+ runtime.insert(
1345
+ "hint".to_string(),
1346
+ json!("runtime_bindings_stale_after_boot — run team-agent restart"),
1347
+ );
1135
1348
  } else if !coordinator_running && undelivered > 0 {
1136
1349
  runtime.insert(
1137
1350
  "hint".to_string(),
@@ -1146,7 +1359,13 @@ use rusqlite::params;
1146
1359
  /// Whether the coordinator HealthReport reflects a running tick loop. Used by the
1147
1360
  /// runtime block + the hint gate.
1148
1361
  fn coordinator_status_running(health: &crate::coordinator::HealthReport) -> bool {
1149
- matches!(health.status, crate::coordinator::CoordinatorHealthStatus::Running)
1362
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): runtime
1363
+ // service predicate is `service_available`, not any-`Running` pid.
1364
+ // A stale-pid daemon is CoordinatorHealthStatus::Stale (or Running
1365
+ // with metadata_ok=false); a service-compatible newer daemon is
1366
+ // Running + service_available=true — send/diagnose already use
1367
+ // this same truth source, and status must agree.
1368
+ health.service_available
1150
1369
  }
1151
1370
 
1152
1371
  /// Count of messages currently sitting in delivery-able backlog
@@ -1199,16 +1418,21 @@ use rusqlite::params;
1199
1418
  "schema": {
1200
1419
  "message_store_schema_version": health.schema.schema_version,
1201
1420
  },
1421
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3):
1422
+ // `service_available` is now always exposed alongside `ok`
1423
+ // and `status` so status/diagnose consumers can share one
1424
+ // service-availability truth without keying on the legacy
1425
+ // `expose_binary_drift` conditional (that path only fired
1426
+ // for the newer-daemon-preserved shape). `binary_identity_
1427
+ // relation` moves to always-on for the same reason — RED3
1428
+ // scans the summary for `daemon_newer_than_caller`.
1429
+ "service_available": health.service_available,
1430
+ "binary_identity_relation": binary_identity_relation,
1202
1431
  });
1203
1432
  if expose_binary_drift {
1204
1433
  if let Some(obj) = value.as_object_mut() {
1205
1434
  obj.insert("wire_metadata_ok".to_string(), Value::Bool(true));
1206
1435
  obj.insert("binary_identity_ok".to_string(), Value::Bool(false));
1207
- obj.insert(
1208
- "binary_identity_relation".to_string(),
1209
- Value::String(binary_identity_relation.to_string()),
1210
- );
1211
- obj.insert("service_available".to_string(), Value::Bool(true));
1212
1436
  }
1213
1437
  }
1214
1438
  value
@@ -78,9 +78,16 @@ use super::*;
78
78
  "binary_version",
79
79
  "schema_ok",
80
80
  "schema_error",
81
- "schema"
81
+ "schema",
82
+ // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3):
83
+ // service_available and binary_identity_relation are
84
+ // now always emitted (not only when expose_binary_drift
85
+ // fires) so status/diagnose share one service-
86
+ // availability truth.
87
+ "service_available",
88
+ "binary_identity_relation",
82
89
  ],
83
- "golden coordinator_health insertion order with 0.5.18 identity fields; got {order:?}"
90
+ "golden coordinator_health insertion order with 0.5.41 fault-invisibility fields; got {order:?}"
84
91
  );
85
92
  assert!(
86
93
  coord.get("schema_version").is_none(),
@@ -99,6 +99,7 @@ pub fn start_coordinator_with_team(
99
99
  binary_path: Some(identity.binary_path),
100
100
  binary_version: Some(identity.binary_version),
101
101
  rotation_reason: None,
102
+ binary_identity_relation: health.binary_identity_relation,
102
103
  log: Some(coordinator_log_path(workspace)),
103
104
  schema_error: None,
104
105
  action: None,
@@ -113,6 +114,7 @@ pub fn start_coordinator_with_team(
113
114
  binary_path: Some(identity.binary_path),
114
115
  binary_version: Some(identity.binary_version),
115
116
  rotation_reason: health.metadata_mismatch_reason,
117
+ binary_identity_relation: health.binary_identity_relation,
116
118
  log: None,
117
119
  schema_error: health.schema.error,
118
120
  action: health.schema.action,
@@ -127,6 +129,7 @@ pub fn start_coordinator_with_team(
127
129
  binary_path: Some(identity.binary_path),
128
130
  binary_version: Some(identity.binary_version),
129
131
  rotation_reason: health.metadata_mismatch_reason,
132
+ binary_identity_relation: health.binary_identity_relation,
130
133
  log: None,
131
134
  schema_error: None,
132
135
  action: Some(
@@ -169,6 +172,7 @@ pub fn start_coordinator_with_team(
169
172
  binary_path: Some(identity.binary_path),
170
173
  binary_version: Some(identity.binary_version),
171
174
  rotation_reason: Some("daemon_newer_than_caller".to_string()),
175
+ binary_identity_relation: health.binary_identity_relation,
172
176
  log: Some(coordinator_log_path(workspace)),
173
177
  schema_error: None,
174
178
  action: None,
@@ -208,6 +212,7 @@ pub fn start_coordinator_with_team(
208
212
  binary_path: Some(identity.binary_path),
209
213
  binary_version: Some(identity.binary_version),
210
214
  rotation_reason,
215
+ binary_identity_relation: health.binary_identity_relation,
211
216
  log: None,
212
217
  schema_error: None,
213
218
  action: Some("refusing to rotate coordinator metadata that points at the caller process".to_string()),
@@ -224,6 +229,7 @@ pub fn start_coordinator_with_team(
224
229
  binary_path: Some(identity.binary_path),
225
230
  binary_version: Some(identity.binary_version),
226
231
  rotation_reason,
232
+ binary_identity_relation: health.binary_identity_relation,
227
233
  log: None,
228
234
  schema_error: None,
229
235
  action: None,
@@ -271,6 +277,7 @@ pub fn start_coordinator_with_team(
271
277
  binary_path: Some(identity.binary_path),
272
278
  binary_version: Some(identity.binary_version),
273
279
  rotation_reason,
280
+ binary_identity_relation: health.binary_identity_relation,
274
281
  log: Some(log_path),
275
282
  schema_error: None,
276
283
  action: None,
@@ -43,7 +43,10 @@ pub(crate) fn detect_abnormal_exits(
43
43
  &agent,
44
44
  None,
45
45
  None,
46
- ProcessLiveness::Unverifiable,
46
+ process_check(
47
+ ProcessLiveness::Unverifiable,
48
+ "rollout_metadata_unavailable".to_string(),
49
+ ),
47
50
  None,
48
51
  ErrorRecency::None,
49
52
  None,
@@ -81,7 +84,10 @@ pub(crate) fn detect_abnormal_exits(
81
84
  &agent,
82
85
  Some(size),
83
86
  mtime_ns,
84
- ProcessLiveness::Unverifiable,
87
+ process_check(
88
+ ProcessLiveness::Unverifiable,
89
+ "rollout_read_failed".to_string(),
90
+ ),
85
91
  None,
86
92
  ErrorRecency::None,
87
93
  None,
@@ -119,7 +125,7 @@ pub(crate) fn detect_abnormal_exits(
119
125
  &agent,
120
126
  Some(size),
121
127
  mtime_ns,
122
- liveness.state,
128
+ liveness.clone(),
123
129
  fact.as_ref().map(|f| f.signature.as_str()),
124
130
  error_recency,
125
131
  error_observation_key.as_deref(),
@@ -430,7 +436,16 @@ fn abnormal_watch_agents(state: &Value) -> Vec<AbnormalWatchAgent> {
430
436
  }
431
437
 
432
438
  fn agent_pid(agent: &Value) -> Option<Pid> {
433
- ["provider_pid", "process_id", "pid", "child_pid", "pane_pid"]
439
+ // 0.5.41 Slice 4 (fault-invisibility-locate.md §5 point 3 / §6.4,
440
+ // 0.5.39 CR D-m): after the 0.5.39 worker shell wrapper landed,
441
+ // `pane_pid` is the long-lived shell — NOT the provider. Treating
442
+ // it as `provider pid` and probing it via `kill(0)` reports "alive"
443
+ // for a pane whose provider CLI already exited, which is exactly
444
+ // the false-BUSY that suppressed `worker_provider_exited`. Only
445
+ // pids we know are the provider itself (`provider_pid` /
446
+ // `process_id` / `pid` written by explicit spawn accounting, or
447
+ // `child_pid` from the transport's SpawnResult) count here.
448
+ ["provider_pid", "process_id", "pid", "child_pid"]
434
449
  .into_iter()
435
450
  .find_map(|key| json_u32(agent.get(key)).map(Pid::new))
436
451
  }
@@ -512,9 +527,52 @@ fn agent_process_liveness(
512
527
  targets: &[crate::transport::PaneInfo],
513
528
  transport: &dyn crate::transport::Transport,
514
529
  ) -> ProcessCheck {
530
+ // 0.5.41 Slice 4 (fault-invisibility-locate.md §5 point 3 / §6.4,
531
+ // 0.5.39 CR D-m): after the worker wrapper landed, `pane_pid` may
532
+ // be the long-lived shell that survived provider exit. Reordered
533
+ // to prefer POSITIVE provider evidence in this order:
534
+ // 1. Explicit provider pid (agent.pid) — set by the transport's
535
+ // SpawnResult.child_pid or explicit provider_pid state field.
536
+ // 2. Explicit liveness field.
537
+ // 3. Explicit dead-status field.
538
+ // 4. Explicit provider current-command (state.pane_current_command).
539
+ // 5. Live-pane current-command matching provider = alive.
540
+ // 6. Live-pane current-command shell + capture-tail worker exit
541
+ // marker = DEAD (positive proof provider exited).
542
+ // 7. Live pane, no positive evidence = Unverifiable (NOT alive,
543
+ // NOT working). Legacy code returned pid_process_check(pane_pid)
544
+ // here — that was the D-m false BUSY.
545
+ // 8. Pane / window liveness falls back on Unverifiable.
515
546
  if let Some(pid) = agent.pid {
516
547
  return pid_process_check("pid", pid);
517
548
  }
549
+ // 0.5.41 Slice 4 (fault-invisibility-locate.md §5 point 3 / §6.4):
550
+ // BEFORE consulting the loose `explicit_process_liveness` path
551
+ // (which happily maps state.status=`running` to Alive), probe the
552
+ // pane for POSITIVE provider evidence via current-command + worker
553
+ // exit marker. Under the 0.5.39 wrapper, `state.status=running` is
554
+ // administrative spawn accounting, not runtime truth — a provider
555
+ // may have exited into the shell fallback while state still reads
556
+ // running. The marker probe positively proves that case; when it
557
+ // fires we return Dead here instead of the false Alive further
558
+ // down.
559
+ if let Some(target) = matching_agent_target(agent, session_name, targets) {
560
+ if let Some(command) = target.current_command.as_deref() {
561
+ return pane_command_process_check_with_marker(agent, transport, target, command);
562
+ }
563
+ }
564
+ if let Some(command) = agent.current_command.as_deref() {
565
+ return command_process_check_with_marker(agent, transport, command);
566
+ }
567
+ if let Some(pane_id) = agent.pane_id.as_deref() {
568
+ // Even without pane current_command / matching target, try the
569
+ // marker probe directly — the wrapper's printf leaves the marker
570
+ // in the pane's capture tail whether or not the transport
571
+ // reports pane_current_command.
572
+ if let Some(check) = worker_provider_exit_marker_check(agent, transport) {
573
+ return check;
574
+ }
575
+ }
518
576
  if let Some(liveness) = agent.process_liveness {
519
577
  return process_check(
520
578
  liveness,
@@ -532,19 +590,15 @@ fn agent_process_liveness(
532
590
  format!("status:{}", agent.status.as_deref().unwrap_or("unknown")),
533
591
  );
534
592
  }
535
- if let Some(command) = agent.current_command.as_deref() {
536
- return command_process_check(agent.provider, command);
537
- }
538
593
  if let Some(target) = matching_agent_target(agent, session_name, targets) {
539
- if let Some(command) = target.current_command.as_deref() {
540
- return pane_command_process_check(agent.provider, target, command);
541
- }
542
- if let Some(pid) = target.pane_pid.map(Pid::new) {
543
- return pid_process_check("pane_pid", pid);
544
- }
594
+ // Reached only when the target has no current_command AND no
595
+ // marker was found earlier. Legacy code returned
596
+ // `pid_process_check("pane_pid", pane_pid)` here — that's the
597
+ // wrapper shell under 0.5.39. Now Unverifiable (not-working).
598
+ let _ = target;
545
599
  return process_check(
546
600
  ProcessLiveness::Unverifiable,
547
- "pane_present_pid_unknown".to_string(),
601
+ "pane_present_provider_evidence_unknown".to_string(),
548
602
  );
549
603
  }
550
604
  if let Some(pane_id) = agent.pane_id.as_deref() {
@@ -555,7 +609,7 @@ fn agent_process_liveness(
555
609
  }
556
610
  Ok(crate::transport::PaneLiveness::Live) => process_check(
557
611
  ProcessLiveness::Unverifiable,
558
- format!("pane_live_pid_unknown:{pane_id}"),
612
+ format!("pane_live_provider_evidence_unknown:{pane_id}"),
559
613
  ),
560
614
  Ok(crate::transport::PaneLiveness::Unknown) => process_check(
561
615
  ProcessLiveness::Unverifiable,
@@ -577,7 +631,7 @@ fn agent_process_liveness(
577
631
  match transport.list_windows(&session) {
578
632
  Ok(windows) if windows.iter().any(|known| known.as_str() == window) => process_check(
579
633
  ProcessLiveness::Unverifiable,
580
- "window_present_pid_unknown".to_string(),
634
+ "window_present_provider_evidence_unknown".to_string(),
581
635
  ),
582
636
  Ok(_) => process_check(ProcessLiveness::Dead, format!("window_missing:{window}")),
583
637
  Err(error) => process_check(
@@ -651,6 +705,79 @@ fn pane_command_process_check(
651
705
  }
652
706
  }
653
707
 
708
+ /// 0.5.41 Slice 4 (fault-invisibility-locate.md §5 point 3 / §6.4):
709
+ /// after the 0.5.39 worker wrapper landed, a pane whose current command
710
+ /// is a shell + capture-tail contains the worker exit marker is POSITIVE
711
+ /// proof the provider exited. Falls back to plain current-command check
712
+ /// when a pane_id is not available for capture (state-only path).
713
+ fn command_process_check_with_marker(
714
+ agent: &AbnormalWatchAgent,
715
+ transport: &dyn crate::transport::Transport,
716
+ command: &str,
717
+ ) -> ProcessCheck {
718
+ if crate::leader::command_matches_provider(agent.provider, command) {
719
+ return process_check(ProcessLiveness::Alive, format!("current_command:{command}"));
720
+ }
721
+ if let Some(check) = worker_provider_exit_marker_check(agent, transport) {
722
+ return check;
723
+ }
724
+ process_check(
725
+ ProcessLiveness::Dead,
726
+ format!("provider_not_foreground:{command}"),
727
+ )
728
+ }
729
+
730
+ fn pane_command_process_check_with_marker(
731
+ agent: &AbnormalWatchAgent,
732
+ transport: &dyn crate::transport::Transport,
733
+ pane: &crate::transport::PaneInfo,
734
+ command: &str,
735
+ ) -> ProcessCheck {
736
+ if crate::leader::attribute_pane_provider(pane)
737
+ .is_some_and(|candidate| crate::leader::provider_matches(candidate, agent.provider))
738
+ {
739
+ return process_check(ProcessLiveness::Alive, format!("current_command:{command}"));
740
+ }
741
+ if let Some(check) = worker_provider_exit_marker_check(agent, transport) {
742
+ return check;
743
+ }
744
+ process_check(
745
+ ProcessLiveness::Dead,
746
+ format!("provider_not_foreground:{command}"),
747
+ )
748
+ }
749
+
750
+ /// 0.5.41 Slice 4: probe the pane's capture-tail for the single-source
751
+ /// worker exit marker (`tmux_backend::worker_provider_exit_marker`).
752
+ /// Returns `Some(Dead)` when the marker is found (positive proof
753
+ /// provider exited), or `None` when we can't run the capture (no
754
+ /// pane_id in agent), so callers keep their default decision. Absence
755
+ /// of marker in a valid capture is intentionally NOT `alive` (locate
756
+ /// §8 risk: capture tail can scroll past the marker) — callers still
757
+ /// return `Dead` for "provider not foreground" because current command
758
+ /// already disproved provider liveness at that call site.
759
+ fn worker_provider_exit_marker_check(
760
+ agent: &AbnormalWatchAgent,
761
+ transport: &dyn crate::transport::Transport,
762
+ ) -> Option<ProcessCheck> {
763
+ let pane_id_str = agent.pane_id.as_deref()?;
764
+ let pane = crate::transport::PaneId::new(pane_id_str);
765
+ let target = crate::transport::Target::Pane(pane);
766
+ let provider_label = crate::provider::wire::command_name(agent.provider);
767
+ let marker = crate::tmux_backend::worker_provider_exit_marker(provider_label);
768
+ let cap = transport
769
+ .capture(&target, crate::transport::CaptureRange::Tail(200))
770
+ .ok()?;
771
+ if cap.text.contains(&marker) {
772
+ Some(process_check(
773
+ ProcessLiveness::Dead,
774
+ format!("worker_provider_exited:{pane_id_str}"),
775
+ ))
776
+ } else {
777
+ None
778
+ }
779
+ }
780
+
654
781
  fn process_check(state: ProcessLiveness, detail: String) -> ProcessCheck {
655
782
  ProcessCheck { state, detail }
656
783
  }
@@ -681,18 +808,26 @@ fn abnormal_watch_payload(
681
808
  agent: &AbnormalWatchAgent,
682
809
  size: Option<u64>,
683
810
  mtime_ns: Option<u64>,
684
- liveness: ProcessLiveness,
811
+ liveness: ProcessCheck,
685
812
  signature: Option<&str>,
686
813
  error_recency: ErrorRecency,
687
814
  error_observation_key: Option<&str>,
688
815
  error: Option<String>,
689
816
  ) -> Value {
690
- let liveness_wire = process_liveness_wire(liveness);
691
- let dead_process = liveness == ProcessLiveness::Dead;
817
+ let liveness_wire = process_liveness_wire(liveness.state);
818
+ let dead_process = liveness.state == ProcessLiveness::Dead;
692
819
  let latest_explicit_error = signature.is_some();
693
- let gate = AbnormalExitGate::new(liveness, latest_explicit_error, error_recency);
820
+ let gate = AbnormalExitGate::new(liveness.state, latest_explicit_error, error_recency);
694
821
  let notify = gate.should_notify_worker_abnormal_exit();
695
822
  let suppressed_reason = gate.suppressed_reason();
823
+ // 0.5.41 Slice 4 (fault-invisibility-locate.md §6.4): typed
824
+ // classification for status/render — `worker_provider_exited` is
825
+ // true only when Dead was proven via the capture-tail worker exit
826
+ // marker (detail prefix `worker_provider_exited:`). status_port's
827
+ // RuntimeFreshness collector reads this field to downgrade the
828
+ // corresponding agent row.
829
+ let worker_provider_exited =
830
+ dead_process && liveness.detail.starts_with("worker_provider_exited:");
696
831
  serde_json::json!({
697
832
  "path": agent.rollout_path_display.as_str(),
698
833
  "provider": provider_wire(agent.provider),
@@ -701,9 +836,11 @@ fn abnormal_watch_payload(
701
836
  "last_offset": size,
702
837
  "last_signature": signature,
703
838
  "last_liveness": liveness_wire,
839
+ "last_liveness_detail": liveness.detail,
704
840
  "dead_process": dead_process,
705
841
  "process_dead": dead_process,
706
842
  "provider_process_dead": dead_process,
843
+ "worker_provider_exited": worker_provider_exited,
707
844
  "latest_error": latest_explicit_error,
708
845
  "latest_explicit_error": latest_explicit_error,
709
846
  "error_recency": error_recency.as_str(),
@@ -1494,10 +1494,18 @@ pub(crate) fn write_coordinator_heartbeat(
1494
1494
  .map(ToString::to_string)
1495
1495
  })
1496
1496
  .unwrap_or_else(|| format!("coord_unknown_{}", pid.get()));
1497
+ // 0.5.41 Slice 1 (fault-invisibility-locate.md §5/§6.1): stamp the
1498
+ // host boot identity onto every heartbeat write so downstream
1499
+ // status/diagnose can detect "all pane/pid/session bindings predate
1500
+ // the current host boot" without inventing a new sidecar. Existing
1501
+ // `boot_id` stays as the daemon-generation id.
1502
+ let host_boot_id = probe_host_boot_id().unwrap_or_else(|| "unknown".to_string());
1497
1503
  let mut next = serde_json::json!({
1498
1504
  "coordinator_tick_iteration_count": next_count,
1499
1505
  "pid": pid.get(),
1500
1506
  "boot_id": boot_id,
1507
+ "host_boot_id": host_boot_id,
1508
+ "host_boot_time": now.clone(),
1501
1509
  "binary_path": identity.binary_path,
1502
1510
  "binary_version": identity.binary_version,
1503
1511
  "last_phase": last_phase,
@@ -1523,6 +1531,57 @@ fn coordinator_heartbeat_path(workspace: &WorkspacePath) -> PathBuf {
1523
1531
  crate::model::paths::runtime_dir(workspace.as_path()).join("coordinator_tick.json")
1524
1532
  }
1525
1533
 
1534
+ /// 0.5.41 Slice 1 (fault-invisibility-locate.md §6.1): single-helper
1535
+ /// probe for the current host boot identity. Test overrides via
1536
+ /// `TEAM_AGENT_TEST_HOST_BOOT_ID` (deterministic RED path). On real
1537
+ /// hosts, reads platform files: Linux `/proc/sys/kernel/random/boot_id`
1538
+ /// (128-bit UUID stable across the boot), macOS `sysctl kern.boottime`
1539
+ /// falls back to a formatted timestamp. Returns None when the platform
1540
+ /// signal cannot be read — the caller MUST NOT guess; downstream
1541
+ /// treats a missing/mismatched host boot separately from a matched one.
1542
+ pub(crate) fn probe_host_boot_id() -> Option<String> {
1543
+ if let Ok(override_id) = std::env::var("TEAM_AGENT_TEST_HOST_BOOT_ID") {
1544
+ if !override_id.is_empty() {
1545
+ return Some(override_id);
1546
+ }
1547
+ }
1548
+ // Linux: stable UUID for the current boot.
1549
+ if let Ok(raw) = std::fs::read_to_string("/proc/sys/kernel/random/boot_id") {
1550
+ let trimmed = raw.trim();
1551
+ if !trimmed.is_empty() {
1552
+ return Some(format!("linux-{trimmed}"));
1553
+ }
1554
+ }
1555
+ // macOS/BSD: kern.boottime via sysctl. Cheap and root-free.
1556
+ #[cfg(target_os = "macos")]
1557
+ {
1558
+ if let Ok(output) = std::process::Command::new("sysctl")
1559
+ .args(["-n", "kern.boottime"])
1560
+ .output()
1561
+ {
1562
+ if output.status.success() {
1563
+ let text = String::from_utf8_lossy(&output.stdout);
1564
+ let trimmed = text.trim();
1565
+ if !trimmed.is_empty() {
1566
+ return Some(format!("macos-{trimmed}"));
1567
+ }
1568
+ }
1569
+ }
1570
+ }
1571
+ None
1572
+ }
1573
+
1574
+ /// 0.5.41 Slice 1: expose the last-written heartbeat sidecar so
1575
+ /// `cli::diagnose` / `cli::status_port` can consume it read-only. Any
1576
+ /// error (missing file, malformed JSON) returns None — callers treat
1577
+ /// that as "unknown, fall back to existing facts" per locate §8 risk
1578
+ /// note, they must never guess a stale/fresh verdict from absence.
1579
+ pub fn read_coordinator_heartbeat(workspace: &WorkspacePath) -> Option<Value> {
1580
+ let path = coordinator_heartbeat_path(workspace);
1581
+ let text = std::fs::read_to_string(path).ok()?;
1582
+ serde_json::from_str(&text).ok()
1583
+ }
1584
+
1526
1585
  fn read_coordinator_heartbeat_value(path: &Path) -> Value {
1527
1586
  std::fs::read_to_string(path)
1528
1587
  .ok()
@@ -330,6 +330,15 @@ pub struct StartReport {
330
330
  pub binary_path: Option<String>,
331
331
  pub binary_version: Option<String>,
332
332
  pub rotation_reason: Option<String>,
333
+ /// 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§9 RED3): typed
334
+ /// binary-identity relation between the caller and the daemon that
335
+ /// was observed. Populated on every path (unknown when the health
336
+ /// probe returned no metadata). Downstream `coordinator_start_
337
+ /// summary_value` exposes this so restart/start summaries no longer
338
+ /// flatten `daemon_newer_than_caller` into a plain `already_running`
339
+ /// (RED3: caller-older-than-daemon must remain distinguishable
340
+ /// from a same-binary fresh daemon).
341
+ pub binary_identity_relation: CoordinatorBinaryIdentityRelation,
333
342
  /// coordinator.log 路径(成功路径,`lifecycle.py:54/121`)。
334
343
  pub log: Option<PathBuf>,
335
344
  /// schema_incompatible 时的修复 hint / 失败原因。
@@ -410,22 +410,27 @@ fn restart_with_selected_team_and_transport(
410
410
  }
411
411
  }
412
412
  let session_name = state_session_name(&state);
413
- if session_live_or_default(transport, &session_name, false) {
414
- // 0.3.28 Step 5 (warn-only): per architecture, restart should REFUSE
415
- // when the worker session is live (Python `restart/orchestration.py:79-85`
416
- // raises `_tmux_session_conflict_error`). Pre-0.3.28 Rust kills the
417
- // worker session here which under the old co-located topology also
418
- // killed the leader pane (the E57-1 cascade contribution from the
419
- // layout layer).
420
- //
421
- // After Step 2 the leader lives in a DIFFERENT session
422
- // (`team-agent-leader-...`), so killing the worker session no longer
423
- // tears down the leader. That makes this kill structurally safe, but
424
- // it still loses provider session state in the worker panes.
425
- //
426
- // Full "refuse" semantics will land once Steps 6+7 expose the
427
- // recovery path so users have a clean alternative. For now we emit
428
- // the warn-only event so operators see the drift in event logs.
413
+ // 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3, first-version
414
+ // build-before-destroy). Pre-0.5.40 code UNCONDITIONALLY killed the
415
+ // live worker session here, then ran the spawn loop against a fresh
416
+ // session which loses provider session state (Case B) and, under
417
+ // upstream tmux 3.6a private-server bugs, can cascade into whole-
418
+ // server death (Case A). Locate §7 Slice 3 requires: "restart must
419
+ // build/prove the replacement before retiring a currently live
420
+ // worker session." First-version narrow shape: defer the kill until
421
+ // AFTER the spawn loop proves the replacement is minimally viable,
422
+ // and refuse the kill on failure so the original session/state stay
423
+ // authoritative. Discriminator = `collect_live_agents_from_state`
424
+ // (state marks agents running with real pane_ids). When state has
425
+ // no authoritative live agents, the previous behavior (pre-spawn
426
+ // teardown of a stale empty session) is preserved so tests that
427
+ // seed a fake-live session without live agent rows still work.
428
+ let live_worker_session_deferred_teardown =
429
+ session_live_or_default(transport, &session_name, false)
430
+ && !collect_live_agents_from_state(&state).is_empty();
431
+ if session_live_or_default(transport, &session_name, false)
432
+ && !live_worker_session_deferred_teardown
433
+ {
429
434
  eprintln!(
430
435
  "team_agent::layout restart_precondition_warning worker_session=`{}` action=killing \
431
436
  (post-Step-7 will refuse and direct user to recover; safe today because Step 2 \
@@ -451,6 +456,20 @@ fn restart_with_selected_team_and_transport(
451
456
  }
452
457
  phase_timer.emit(&selected.run_workspace, "restart.phase", "teardown");
453
458
  phase_timer.emit(&selected.run_workspace, "restart.phase", "spawn_all");
459
+ // 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3): when the
460
+ // pre-spawn teardown was DEFERRED (live worker session with
461
+ // authoritative running agents), snapshot the pre-spawn agent rows
462
+ // so we can restore them if the replacement build fails. This
463
+ // enforces the "old session/state stay authoritative on failure"
464
+ // invariant without threading a buffer through every state
465
+ // mutation. On success (all spawns viable) the mutations already
466
+ // written win; on failure the snapshot restores the original rows.
467
+ let build_before_destroy_agents_snapshot: Option<serde_json::Value> =
468
+ if live_worker_session_deferred_teardown {
469
+ state.get("agents").cloned()
470
+ } else {
471
+ None
472
+ };
454
473
  let mut successful_agents: Vec<RestartedAgent> = Vec::new();
455
474
  let mut failed_agents: Vec<RestartFailedAgent> = Vec::new();
456
475
  let mut fatal_resume_failure = false;
@@ -599,6 +618,37 @@ fn restart_with_selected_team_and_transport(
599
618
  };
600
619
  let mut session_live = session_live_or_default(transport, &session_name, false);
601
620
  if !session_live {
621
+ // 0.5.40 Slice 3: when we deferred the pre-spawn teardown
622
+ // because the original session was live with authoritative
623
+ // running agents, a mid-flight session disappearance
624
+ // classifies as tmux_server_crashed (0539 §11.1 B) — the
625
+ // whole server died out from under us. Do NOT cascade-pop
626
+ // previously-successful agents into `session_disappeared_after_spawn`:
627
+ // that path (a) rewrites state.agents.<popped>.status to
628
+ // restart_failed (losing the authoritative old row R3
629
+ // requires stays untouched), and (b) emits the exact
630
+ // cascade error string R3 forbids. Instead, refuse the
631
+ // build-before-destroy cohort: leave `successful_agents`
632
+ // alone (their live spawn already happened but the server
633
+ // died before we could prove them viable), stop the loop,
634
+ // and let the terminal Failed report attribute the failure
635
+ // to tmux_server_crashed via `restart_failure_phase`. The
636
+ // legacy path still runs when the pre-spawn teardown was
637
+ // taken (empty session case), because
638
+ // `live_worker_session_deferred_teardown` is false there.
639
+ if live_worker_session_deferred_teardown {
640
+ fatal_resume_failure = true;
641
+ let error = format!(
642
+ "tmux_server_crashed: session {} disappeared during replacement build; refusing to touch original agent state",
643
+ session_name.as_str()
644
+ );
645
+ failed_agents.push(restart_failed_agent(
646
+ decision,
647
+ "tmux_server_crashed",
648
+ error,
649
+ ));
650
+ continue;
651
+ }
602
652
  if let Some(previous) = successful_agents.pop() {
603
653
  let error = format!(
604
654
  "session_disappeared_after_spawn: provider_resume_exited for {}; session {} disappeared before spawning {}",
@@ -750,6 +800,38 @@ fn restart_with_selected_team_and_transport(
750
800
  })
751
801
  .map(|agent| agent.agent_id.as_str().to_string())
752
802
  .collect::<Vec<_>>();
803
+ // 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3): restore the
804
+ // pre-spawn agents snapshot when we deferred the teardown AND the
805
+ // replacement build failed. This is the "old session/state stay
806
+ // authoritative on failure" invariant made concrete — the mid-loop
807
+ // mark_agent_respawned/mark_agent_restart_failed calls already
808
+ // mutated `state.agents` in memory; restoring the snapshot rewinds
809
+ // those mutations so `save_restart_state_...` below persists rows
810
+ // byte-identical to what was loaded. Only rewind when
811
+ // `fatal_resume_failure` (whole cohort refused) OR when NO agent
812
+ // survived (`successful_agents.is_empty()` with failures) — on
813
+ // partial success the surviving replacements are legitimately new
814
+ // and the pre-restart rows are stale.
815
+ if let Some(snapshot) = build_before_destroy_agents_snapshot.as_ref() {
816
+ let replacement_failed =
817
+ fatal_resume_failure || (!failed_agents.is_empty() && successful_agents.is_empty());
818
+ if replacement_failed {
819
+ if let Some(state_obj) = state.as_object_mut() {
820
+ state_obj.insert("agents".to_string(), snapshot.clone());
821
+ if let Some(teams) = state_obj
822
+ .get_mut("teams")
823
+ .and_then(serde_json::Value::as_object_mut)
824
+ {
825
+ if let Some(team_entry) = teams
826
+ .get_mut(selected.team_key.as_str())
827
+ .and_then(serde_json::Value::as_object_mut)
828
+ {
829
+ team_entry.insert("agents".to_string(), snapshot.clone());
830
+ }
831
+ }
832
+ }
833
+ }
834
+ }
753
835
  save_restart_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
754
836
  &selected.run_workspace,
755
837
  &mut state,
@@ -3073,6 +3155,32 @@ fn load_endpoint_convergence_runtime_spec(
3073
3155
  Ok(Some(spec))
3074
3156
  }
3075
3157
 
3158
+ /// 0.5.40 Slice 3: collect the set of agent ids that state marks as
3159
+ /// running with a real pane_id. "Running with pane_id" is the
3160
+ /// discriminator between "state has an authoritative live team the
3161
+ /// user would lose on teardown" and "state is fresh / all stopped and
3162
+ /// the live session is just leftover empty topology". Sorted for
3163
+ /// deterministic report output.
3164
+ fn collect_live_agents_from_state(state: &serde_json::Value) -> Vec<String> {
3165
+ let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
3166
+ return Vec::new();
3167
+ };
3168
+ let mut live: Vec<String> = agents
3169
+ .iter()
3170
+ .filter_map(|(agent_id, agent)| {
3171
+ let status = agent.get("status").and_then(serde_json::Value::as_str)?;
3172
+ let pane_id = agent
3173
+ .get("pane_id")
3174
+ .and_then(serde_json::Value::as_str)
3175
+ .filter(|s| !s.is_empty())?;
3176
+ let _ = pane_id;
3177
+ (status == "running").then(|| agent_id.clone())
3178
+ })
3179
+ .collect();
3180
+ live.sort();
3181
+ live
3182
+ }
3183
+
3076
3184
  fn has_endpoint_convergence_marker(state: &serde_json::Value) -> bool {
3077
3185
  state
3078
3186
  .get("topology_convergence")
@@ -91,6 +91,12 @@ pub struct CoordinatorStartSummary {
91
91
  pub binary_path: Option<String>,
92
92
  pub binary_version: Option<String>,
93
93
  pub rotation_reason: Option<String>,
94
+ /// 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§9 RED3): typed
95
+ /// binary-identity relation between caller and daemon that was
96
+ /// observed. Carried through from `StartReport` so restart/start
97
+ /// summaries keep `daemon_newer_than_caller` distinguishable from
98
+ /// a same-binary fresh daemon (both surface as `already_running`).
99
+ pub binary_identity_relation: String,
94
100
  }
95
101
 
96
102
  impl CoordinatorStartSummary {
@@ -102,6 +108,7 @@ impl CoordinatorStartSummary {
102
108
  binary_path: report.binary_path.clone(),
103
109
  binary_version: report.binary_version.clone(),
104
110
  rotation_reason: report.rotation_reason.clone(),
111
+ binary_identity_relation: report.binary_identity_relation.as_str().to_string(),
105
112
  }
106
113
  }
107
114
  }
@@ -126,6 +133,7 @@ pub fn coordinator_start_summary_value(summary: &CoordinatorStartSummary) -> ser
126
133
  "binary_path": summary.binary_path,
127
134
  "binary_version": summary.binary_version,
128
135
  "rotation_reason": summary.rotation_reason,
136
+ "binary_identity_relation": summary.binary_identity_relation,
129
137
  })
130
138
  }
131
139
 
@@ -745,6 +753,27 @@ pub enum RestartReport {
745
753
  allow_fresh: bool,
746
754
  error: String,
747
755
  },
756
+ /// 0.5.40 Slice 3 (tmux-server-death-locate §7 Slice 3, first-version
757
+ /// all-or-refuse): the previous worker session is still live AND state
758
+ /// carries running agents with real pane_ids. Restart today can only
759
+ /// proceed by killing the live session first — which loses provider
760
+ /// session state in the worker panes and, worse, can cascade into
761
+ /// server death under Case B. Until the build-before-destroy path
762
+ /// (later car: temporary session + minimal-viability proof + swap)
763
+ /// lands, restart refuses this shape *before* touching tmux or state.
764
+ /// **nothing created or killed; state.agents rows are byte-identical
765
+ /// to what was seeded before the call.**
766
+ RefusedBuildBeforeDestroyRequired {
767
+ /// Live worker session name from state that would need destructive
768
+ /// rebuild.
769
+ session_name: String,
770
+ /// Snapshot of agent ids currently running with real pane_ids —
771
+ /// this is the "authoritative old state" the refusal preserves.
772
+ live_agents: Vec<String>,
773
+ /// Human-readable error pointing the user at the canonical recovery
774
+ /// path (bare shutdown then restart).
775
+ error: String,
776
+ },
748
777
  /// unit-3 (Stage 1): session-identity preflight refused — `state.session_name`
749
778
  /// is a leader launcher session (`team-agent-leader-*`). Proceeding would
750
779
  /// tear down the leader pane (E49 / 0.3.39). **nothing created or killed**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.39",
3
+ "version": "0.5.41",
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.39",
24
- "@team-agent/cli-darwin-x64": "0.5.39",
25
- "@team-agent/cli-linux-x64": "0.5.39"
23
+ "@team-agent/cli-darwin-arm64": "0.5.41",
24
+ "@team-agent/cli-darwin-x64": "0.5.41",
25
+ "@team-agent/cli-linux-x64": "0.5.41"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",