@team-agent/installer 0.5.40 → 0.5.42

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.40"
578
+ version = "0.5.42"
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.40"
12
+ version = "0.5.42"
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;
@@ -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(),
@@ -424,7 +424,25 @@ impl Coordinator {
424
424
  let saved = match &self.save_hook {
425
425
  Some(hook) => hook(&self.workspace, &state),
426
426
  None => {
427
- crate::state::projection::save_team_scoped_state(self.workspace.as_path(), &state)
427
+ // 0.5.42 S1b (s1b-writer-cluster-locate.md §3.2 /
428
+ // §4.4): the daemon tick's single terminal save
429
+ // routes through `StateRepository` with the
430
+ // `CoordinatorTick` intent. Repository dispatch is
431
+ // byte-identical to the old `save_team_scoped_state`
432
+ // helper (see `state/repository.rs:CoordinatorTick =>
433
+ // helper_write_team_scoped`), so persist/merge/lock/
434
+ // atomic-rename semantics and degraded-mapping stay
435
+ // unchanged. No cached repository, no extra load, no
436
+ // retry — the caller still owns the `Value` and
437
+ // failure still returns `TickReport{PersistenceDegraded}`
438
+ // via the `saved.is_err()` branch below.
439
+ let team_key = crate::state::projection::team_state_key(&state);
440
+ crate::state::repository::StateRepository::new(self.workspace.as_path()).save(
441
+ crate::state::repository::StateWriteIntent::CoordinatorTick {
442
+ team_key: team_key.as_str(),
443
+ },
444
+ &state,
445
+ )
428
446
  }
429
447
  };
430
448
  if saved.is_err() {
@@ -1494,10 +1512,18 @@ pub(crate) fn write_coordinator_heartbeat(
1494
1512
  .map(ToString::to_string)
1495
1513
  })
1496
1514
  .unwrap_or_else(|| format!("coord_unknown_{}", pid.get()));
1515
+ // 0.5.41 Slice 1 (fault-invisibility-locate.md §5/§6.1): stamp the
1516
+ // host boot identity onto every heartbeat write so downstream
1517
+ // status/diagnose can detect "all pane/pid/session bindings predate
1518
+ // the current host boot" without inventing a new sidecar. Existing
1519
+ // `boot_id` stays as the daemon-generation id.
1520
+ let host_boot_id = probe_host_boot_id().unwrap_or_else(|| "unknown".to_string());
1497
1521
  let mut next = serde_json::json!({
1498
1522
  "coordinator_tick_iteration_count": next_count,
1499
1523
  "pid": pid.get(),
1500
1524
  "boot_id": boot_id,
1525
+ "host_boot_id": host_boot_id,
1526
+ "host_boot_time": now.clone(),
1501
1527
  "binary_path": identity.binary_path,
1502
1528
  "binary_version": identity.binary_version,
1503
1529
  "last_phase": last_phase,
@@ -1523,6 +1549,57 @@ fn coordinator_heartbeat_path(workspace: &WorkspacePath) -> PathBuf {
1523
1549
  crate::model::paths::runtime_dir(workspace.as_path()).join("coordinator_tick.json")
1524
1550
  }
1525
1551
 
1552
+ /// 0.5.41 Slice 1 (fault-invisibility-locate.md §6.1): single-helper
1553
+ /// probe for the current host boot identity. Test overrides via
1554
+ /// `TEAM_AGENT_TEST_HOST_BOOT_ID` (deterministic RED path). On real
1555
+ /// hosts, reads platform files: Linux `/proc/sys/kernel/random/boot_id`
1556
+ /// (128-bit UUID stable across the boot), macOS `sysctl kern.boottime`
1557
+ /// falls back to a formatted timestamp. Returns None when the platform
1558
+ /// signal cannot be read — the caller MUST NOT guess; downstream
1559
+ /// treats a missing/mismatched host boot separately from a matched one.
1560
+ pub(crate) fn probe_host_boot_id() -> Option<String> {
1561
+ if let Ok(override_id) = std::env::var("TEAM_AGENT_TEST_HOST_BOOT_ID") {
1562
+ if !override_id.is_empty() {
1563
+ return Some(override_id);
1564
+ }
1565
+ }
1566
+ // Linux: stable UUID for the current boot.
1567
+ if let Ok(raw) = std::fs::read_to_string("/proc/sys/kernel/random/boot_id") {
1568
+ let trimmed = raw.trim();
1569
+ if !trimmed.is_empty() {
1570
+ return Some(format!("linux-{trimmed}"));
1571
+ }
1572
+ }
1573
+ // macOS/BSD: kern.boottime via sysctl. Cheap and root-free.
1574
+ #[cfg(target_os = "macos")]
1575
+ {
1576
+ if let Ok(output) = std::process::Command::new("sysctl")
1577
+ .args(["-n", "kern.boottime"])
1578
+ .output()
1579
+ {
1580
+ if output.status.success() {
1581
+ let text = String::from_utf8_lossy(&output.stdout);
1582
+ let trimmed = text.trim();
1583
+ if !trimmed.is_empty() {
1584
+ return Some(format!("macos-{trimmed}"));
1585
+ }
1586
+ }
1587
+ }
1588
+ }
1589
+ None
1590
+ }
1591
+
1592
+ /// 0.5.41 Slice 1: expose the last-written heartbeat sidecar so
1593
+ /// `cli::diagnose` / `cli::status_port` can consume it read-only. Any
1594
+ /// error (missing file, malformed JSON) returns None — callers treat
1595
+ /// that as "unknown, fall back to existing facts" per locate §8 risk
1596
+ /// note, they must never guess a stale/fresh verdict from absence.
1597
+ pub fn read_coordinator_heartbeat(workspace: &WorkspacePath) -> Option<Value> {
1598
+ let path = coordinator_heartbeat_path(workspace);
1599
+ let text = std::fs::read_to_string(path).ok()?;
1600
+ serde_json::from_str(&text).ok()
1601
+ }
1602
+
1526
1603
  fn read_coordinator_heartbeat_value(path: &Path) -> Value {
1527
1604
  std::fs::read_to_string(path)
1528
1605
  .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 / 失败原因。
@@ -1750,7 +1750,25 @@ fn state_owner(state: &Value) -> Option<TeamOwner> {
1750
1750
  /// name for 0.5.x call-site stability. The B0 legacy snapshot is
1751
1751
  /// diagnostic-only via `lifecycle::save_team_runtime_snapshot`.
1752
1752
  pub fn write_lease_dual_state(workspace: &Path, state: &Value) -> Result<(), LeaderError> {
1753
- crate::state::persist::save_runtime_state(workspace, state)?;
1753
+ // 0.5.42 S1b (s1b-writer-cluster-locate.md §4.4): terminal root
1754
+ // save routes through `StateRepository` with the `ClaimLeader`
1755
+ // intent. Dispatch is byte-identical to the old
1756
+ // `save_runtime_state` helper (see
1757
+ // `state/repository.rs:ClaimLeader => helper_write_root`), so the
1758
+ // full lease writer cluster (attach_leader / attach_app_server /
1759
+ // attach_leader_to_state / claim_lease_no_incident_with_target /
1760
+ // rediscover) keeps its persist/merge/CAS/readback semantics.
1761
+ // Public signature/error mapping preserved for caller stability.
1762
+ // Repository does NOT compute owner_epoch, re-run convergence,
1763
+ // emit ownership events, or acquire a second lease lock — the
1764
+ // caller still owns all of that.
1765
+ let team_key = canonical_owner_write_key(state);
1766
+ crate::state::repository::StateRepository::new(workspace).save(
1767
+ crate::state::repository::StateWriteIntent::ClaimLeader {
1768
+ team_key: team_key.as_str(),
1769
+ },
1770
+ state,
1771
+ )?;
1754
1772
  Ok(())
1755
1773
  }
1756
1774
 
@@ -1838,7 +1856,16 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
1838
1856
  writes_endpoint_convergence,
1839
1857
  );
1840
1858
  merged.insert("teams".to_string(), Value::Object(teams));
1841
- crate::state::persist::save_runtime_state(workspace, &Value::Object(merged))?;
1859
+ // 0.5.42 S1b (s1b-writer-cluster-locate.md §4.4): only the
1860
+ // terminal save routes through `StateRepository`; the entire
1861
+ // load/merge/compact block above stays in place — repository is a
1862
+ // writer facade, not a projection assembler.
1863
+ crate::state::repository::StateRepository::new(workspace).save(
1864
+ crate::state::repository::StateWriteIntent::ClaimLeader {
1865
+ team_key: target_key,
1866
+ },
1867
+ &Value::Object(merged),
1868
+ )?;
1842
1869
  Ok(())
1843
1870
  }
1844
1871
 
@@ -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
 
@@ -1,17 +1,32 @@
1
- //! S1a StateRepository facade.
1
+ //! StateRepository facade.
2
2
  //!
3
- //! Ref: `.team/artifacts/s1a-state-repository-design.md`
3
+ //! Ref:
4
+ //! - `.team/artifacts/s1a-state-repository-design.md` (skeleton)
5
+ //! - `.team/artifacts/s1b-writer-cluster-locate.md` (0.5.42 first
6
+ //! writer-cluster migration: `CoordinatorTick` + `ClaimLeader`)
4
7
  //!
5
- //! S1a is a **skeleton + governance** slice. `StateRepository` is a thin write
6
- //! facade that dispatches every write to the existing helper family without
7
- //! changing helper behavior, merge policy, or on-disk layout. The value it adds
8
- //! is that every write now carries a `StateWriteIntent`, so S1b can migrate
9
- //! writer clusters one semantic at a time.
8
+ //! `StateRepository` is a thin write facade that dispatches every write
9
+ //! to the existing helper family without changing helper behavior,
10
+ //! merge policy, or on-disk layout. The value it adds is that every
11
+ //! write now carries a `StateWriteIntent`, so writer clusters migrate
12
+ //! one semantic at a time.
10
13
  //!
11
- //! Non-goals in S1a (see design section 11): no schema rewrite, no path
12
- //! layout change, no writer migration, no helper deletion. Every existing
13
- //! caller keeps calling the raw helpers behind the S1a allowlist; only new
14
- //! direct callsites are governed.
14
+ //! 0.5.42 status: first real consumers are the daemon `Coordinator::
15
+ //! tick` production save (`CoordinatorTick`) and the lease/claim
16
+ //! writer cluster's two terminal saves (`ClaimLeader`)
17
+ //! `write_lease_dual_state` + `save_claim_team_scoped_state`.
18
+ //! Post-S1b, the direct-save allowlist drops from 73 to 70; the three
19
+ //! migrated sites have no allowlist row and every future direct save
20
+ //! must either enter through `StateRepository` or claim an explicit
21
+ //! new allowlist row (fail-closed).
22
+ //!
23
+ //! Non-goals kept from S1a (design §11) + reasserted by
24
+ //! s1b-writer-cluster-locate.md §7: no schema rewrite, no path layout
25
+ //! change, no helper deletion, no epoch computation in the repository,
26
+ //! no retry / reload / event emission, no reinterpretation of
27
+ //! `owner_epoch` / `topology_convergence` / readback proof — the
28
+ //! caller still owns the whole `Value`, and repository dispatches to
29
+ //! the same existing persist helper the direct edge used to hit.
15
30
 
16
31
  #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
17
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.40",
3
+ "version": "0.5.42",
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.40",
24
- "@team-agent/cli-darwin-x64": "0.5.40",
25
- "@team-agent/cli-linux-x64": "0.5.40"
23
+ "@team-agent/cli-darwin-arm64": "0.5.42",
24
+ "@team-agent/cli-darwin-x64": "0.5.42",
25
+ "@team-agent/cli-linux-x64": "0.5.42"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",