@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/diagnose.rs +77 -0
- package/crates/team-agent/src/cli/status_port.rs +235 -11
- package/crates/team-agent/src/cli/tests/lane_c.rs +9 -2
- package/crates/team-agent/src/coordinator/health.rs +7 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +157 -20
- package/crates/team-agent/src/coordinator/tick.rs +78 -1
- package/crates/team-agent/src/coordinator/types.rs +9 -0
- package/crates/team-agent/src/leader/lease.rs +29 -2
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/state/repository.rs +26 -11
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
"
|
|
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!("
|
|
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
|
-
"
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//!
|
|
1
|
+
//! StateRepository facade.
|
|
2
2
|
//!
|
|
3
|
-
//! Ref:
|
|
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
|
-
//!
|
|
6
|
-
//!
|
|
7
|
-
//!
|
|
8
|
-
//!
|
|
9
|
-
//!
|
|
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
|
-
//!
|
|
12
|
-
//!
|
|
13
|
-
//!
|
|
14
|
-
//!
|
|
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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|