@team-agent/installer 0.5.75 → 0.5.77
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/README.md +9 -0
- package/README.zh.md +8 -0
- package/crates/team-agent/src/cli/adapters.rs +23 -96
- package/crates/team-agent/src/cli/emit.rs +35 -7
- package/crates/team-agent/src/cli/mod.rs +63 -42
- package/crates/team-agent/src/cli/models.rs +138 -27
- package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
- package/crates/team-agent/src/cli/status_port.rs +8 -0
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
- package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
- package/crates/team-agent/src/leader/lease.rs +46 -8
- package/crates/team-agent/src/leader/types.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +81 -4
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +11 -4
- package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +24 -28
- package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
- package/crates/team-agent/src/lifecycle/types.rs +4 -0
- package/crates/team-agent/src/messaging/watchers.rs +48 -4
- package/crates/team-agent/src/state/paths.rs +17 -2
- package/crates/team-agent/src/state/projection.rs +18 -2
- package/crates/team-agent/src/state/selector.rs +23 -3
- package/crates/team-agent/src/test_support/models_timeout_fixture.rs +122 -0
- package/package.json +4 -4
|
@@ -345,7 +345,7 @@ fn status_compact_reports_identity_slot_health_on_grok_identity_slot_ok() {
|
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
#[test]
|
|
348
|
-
fn
|
|
348
|
+
fn cmd_status_human_is_seven_field_brief_without_diagnostics() {
|
|
349
349
|
let ws = seed_status_workspace();
|
|
350
350
|
let args = StatusArgs {
|
|
351
351
|
agent: None,
|
|
@@ -362,7 +362,13 @@ fn cmd_status_human_appends_harness_reminder() {
|
|
|
362
362
|
other => panic!("expected human status output, got {other:?}"),
|
|
363
363
|
};
|
|
364
364
|
|
|
365
|
-
assert!(
|
|
365
|
+
assert!(
|
|
366
|
+
text.contains(
|
|
367
|
+
"name: a1 provider: unknown runtime_status: unknown activity: unknown health: unknown session_name: null tmux_command: null"
|
|
368
|
+
),
|
|
369
|
+
"{text}"
|
|
370
|
+
);
|
|
371
|
+
assert!(!text.contains(crate::cli::STATUS_REMINDER));
|
|
366
372
|
let _ = std::fs::remove_dir_all(&ws);
|
|
367
373
|
}
|
|
368
374
|
|
|
@@ -32,6 +32,11 @@ pub(crate) struct AppliedLeaderGrant {
|
|
|
32
32
|
pub(crate) receiver: Value,
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
#[derive(Debug, Default)]
|
|
36
|
+
struct AttachRecovery {
|
|
37
|
+
blocked_counts: crate::message_store::BlockedLeaderRequeueCounts,
|
|
38
|
+
}
|
|
39
|
+
|
|
35
40
|
#[derive(Debug)]
|
|
36
41
|
pub(crate) struct AttachLeaderError {
|
|
37
42
|
source: LeaderError,
|
|
@@ -126,8 +131,11 @@ pub fn attach_leader(
|
|
|
126
131
|
if let Some(endpoint) = target.endpoint.as_deref() {
|
|
127
132
|
quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
|
|
128
133
|
}
|
|
129
|
-
let
|
|
134
|
+
let recovery =
|
|
130
135
|
requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
|
|
136
|
+
let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
|
|
137
|
+
let attach_window_failures =
|
|
138
|
+
attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
|
|
131
139
|
return Ok(LeaseResult {
|
|
132
140
|
ok: true,
|
|
133
141
|
status: LeaseStatus::Claimed,
|
|
@@ -141,6 +149,7 @@ pub fn attach_leader(
|
|
|
141
149
|
.map(str::to_string),
|
|
142
150
|
bound_pane_id: Some(pane_id),
|
|
143
151
|
topology_convergence: None,
|
|
152
|
+
attach_window_failures,
|
|
144
153
|
});
|
|
145
154
|
}
|
|
146
155
|
event_log.write(
|
|
@@ -165,7 +174,10 @@ pub fn attach_leader(
|
|
|
165
174
|
if let Some(endpoint) = target.endpoint.as_deref() {
|
|
166
175
|
quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
|
|
167
176
|
}
|
|
168
|
-
let
|
|
177
|
+
let recovery = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
|
|
178
|
+
let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
|
|
179
|
+
let attach_window_failures =
|
|
180
|
+
attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
|
|
169
181
|
return Ok(LeaseResult {
|
|
170
182
|
ok: true,
|
|
171
183
|
status: LeaseStatus::AlreadyBound,
|
|
@@ -176,6 +188,7 @@ pub fn attach_leader(
|
|
|
176
188
|
action: None,
|
|
177
189
|
bound_pane_id: Some(pane_id),
|
|
178
190
|
topology_convergence: None,
|
|
191
|
+
attach_window_failures,
|
|
179
192
|
});
|
|
180
193
|
}
|
|
181
194
|
let identity = leader_identity_context(workspace, None, Some(&state))?;
|
|
@@ -192,7 +205,10 @@ pub fn attach_leader(
|
|
|
192
205
|
if let Some(endpoint) = target.endpoint.as_deref() {
|
|
193
206
|
quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
|
|
194
207
|
}
|
|
195
|
-
let
|
|
208
|
+
let recovery = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
|
|
209
|
+
let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
|
|
210
|
+
let attach_window_failures =
|
|
211
|
+
attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
|
|
196
212
|
Ok(LeaseResult {
|
|
197
213
|
ok: true,
|
|
198
214
|
status: LeaseStatus::Claimed,
|
|
@@ -203,6 +219,7 @@ pub fn attach_leader(
|
|
|
203
219
|
action: None,
|
|
204
220
|
bound_pane_id: Some(pane_id),
|
|
205
221
|
topology_convergence: None,
|
|
222
|
+
attach_window_failures,
|
|
206
223
|
})
|
|
207
224
|
}
|
|
208
225
|
|
|
@@ -441,17 +458,34 @@ fn requeue_exhausted_watchers_after_attach(
|
|
|
441
458
|
state: &Value,
|
|
442
459
|
event_log: &crate::event_log::EventLog,
|
|
443
460
|
pane_id: &PaneId,
|
|
444
|
-
) -> Result<
|
|
461
|
+
) -> Result<AttachRecovery, LeaderError> {
|
|
445
462
|
let store = MessageStore::open(workspace)?;
|
|
446
463
|
let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
|
|
447
|
-
let notices =
|
|
448
|
-
|
|
449
|
-
|
|
464
|
+
let (notices, blocked_counts) =
|
|
465
|
+
crate::messaging::watchers::requeue_delivery_exhausted_watchers_with_counts(
|
|
466
|
+
workspace, &store, event_log, &team_id, pane_id,
|
|
467
|
+
)?;
|
|
450
468
|
event_log.write(
|
|
451
469
|
super::LeaderEvent::ReceiverRequeuedExhaustedWatchers.name(),
|
|
452
470
|
requeued_exhausted_watchers_event_payload(pane_id, &team_id, ¬ices),
|
|
453
471
|
)?;
|
|
454
|
-
Ok(
|
|
472
|
+
Ok(AttachRecovery { blocked_counts })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
fn attach_window_failure_value(
|
|
476
|
+
counts: crate::message_store::BlockedLeaderRequeueCounts,
|
|
477
|
+
team_id: &TeamKey,
|
|
478
|
+
pane_id: &PaneId,
|
|
479
|
+
) -> Option<Value> {
|
|
480
|
+
(counts.blocked_leader_unbound > 0).then(|| {
|
|
481
|
+
json!({
|
|
482
|
+
"team_id": team_id.as_str(),
|
|
483
|
+
"pane_id": pane_id.as_str(),
|
|
484
|
+
"reason": "leader_not_attached",
|
|
485
|
+
"count": counts.blocked_leader_unbound,
|
|
486
|
+
"status": "requeued_pending_physical_retry",
|
|
487
|
+
})
|
|
488
|
+
})
|
|
455
489
|
}
|
|
456
490
|
|
|
457
491
|
/// R8 D4 (decoupled for offline byte-lock — c-lite): build the `leader_receiver.requeued_exhausted_watchers`
|
|
@@ -949,6 +983,7 @@ fn claim_lease_no_incident_with_target(
|
|
|
949
983
|
action: None,
|
|
950
984
|
bound_pane_id: Some(caller_pane.clone()),
|
|
951
985
|
topology_convergence,
|
|
986
|
+
attach_window_failures: None,
|
|
952
987
|
});
|
|
953
988
|
}
|
|
954
989
|
let owner_live = bound_pane_id.as_deref().is_some_and(|pane| {
|
|
@@ -1194,6 +1229,7 @@ fn claim_lease_no_incident_with_target(
|
|
|
1194
1229
|
action: None,
|
|
1195
1230
|
bound_pane_id: Some(caller_pane.clone()),
|
|
1196
1231
|
topology_convergence,
|
|
1232
|
+
attach_window_failures: None,
|
|
1197
1233
|
})
|
|
1198
1234
|
}
|
|
1199
1235
|
|
|
@@ -1452,6 +1488,7 @@ fn convergence_persistence_refusal(
|
|
|
1452
1488
|
),
|
|
1453
1489
|
bound_pane_id,
|
|
1454
1490
|
topology_convergence,
|
|
1491
|
+
attach_window_failures: None,
|
|
1455
1492
|
}
|
|
1456
1493
|
}
|
|
1457
1494
|
|
|
@@ -1475,6 +1512,7 @@ fn refused(
|
|
|
1475
1512
|
},
|
|
1476
1513
|
bound_pane_id,
|
|
1477
1514
|
topology_convergence: None,
|
|
1515
|
+
attach_window_failures: None,
|
|
1478
1516
|
}
|
|
1479
1517
|
}
|
|
1480
1518
|
|
|
@@ -419,6 +419,10 @@ pub struct LeaseResult {
|
|
|
419
419
|
/// paths so existing lease JSON stays stable unless a convergence decision
|
|
420
420
|
/// was actually evaluated.
|
|
421
421
|
pub topology_convergence: Option<Value>,
|
|
422
|
+
/// Counts returned by the same attach operation when blocked leader
|
|
423
|
+
/// messages were requeued. Kept typed and team/pane-bound; omitted when
|
|
424
|
+
/// no attach-window debt was observed.
|
|
425
|
+
pub attach_window_failures: Option<Value>,
|
|
422
426
|
}
|
|
423
427
|
|
|
424
428
|
/// Family A 正源 owner 绑定结果(`bind_owner_from_caller_pane` 返回 dict 的 typed 版)。
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
use std::collections::BTreeSet;
|
|
17
17
|
use std::fs::OpenOptions;
|
|
18
18
|
use std::io::{Read, Write};
|
|
19
|
+
#[cfg(test)]
|
|
20
|
+
use std::cell::RefCell;
|
|
19
21
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
20
22
|
use std::path::{Path, PathBuf};
|
|
21
23
|
use std::process::{Command, Stdio};
|
|
@@ -588,6 +590,58 @@ fn executable_file_type(path: &Path) -> Result<PiExecutableFileType, ProviderErr
|
|
|
588
590
|
}
|
|
589
591
|
}
|
|
590
592
|
|
|
593
|
+
#[cfg(test)]
|
|
594
|
+
#[derive(Debug, Default)]
|
|
595
|
+
pub(crate) struct PiCatalogTestObservation {
|
|
596
|
+
pub(crate) spawn_count: usize,
|
|
597
|
+
pub(crate) argv: Vec<String>,
|
|
598
|
+
pub(crate) parent_pid: Option<u32>,
|
|
599
|
+
pub(crate) parent_exit_success: Option<bool>,
|
|
600
|
+
pub(crate) parent_exit_code: Option<i32>,
|
|
601
|
+
pub(crate) reader_timeout: bool,
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
#[cfg(test)]
|
|
605
|
+
struct PiCatalogTestContext {
|
|
606
|
+
receipt: PathBuf,
|
|
607
|
+
helper_mode: Option<String>,
|
|
608
|
+
observation: PiCatalogTestObservation,
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
#[cfg(test)]
|
|
612
|
+
thread_local! {
|
|
613
|
+
static PI_CATALOG_TEST_CONTEXT: RefCell<Option<PiCatalogTestContext>> = RefCell::new(None);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
#[cfg(test)]
|
|
617
|
+
pub(crate) fn with_pi_catalog_test_observation<T>(
|
|
618
|
+
receipt: &Path,
|
|
619
|
+
helper_mode: Option<&str>,
|
|
620
|
+
action: impl FnOnce() -> T,
|
|
621
|
+
) -> (T, PiCatalogTestObservation) {
|
|
622
|
+
PI_CATALOG_TEST_CONTEXT.with(|slot| {
|
|
623
|
+
assert!(slot
|
|
624
|
+
.replace(Some(PiCatalogTestContext {
|
|
625
|
+
receipt: receipt.to_path_buf(),
|
|
626
|
+
helper_mode: helper_mode.map(str::to_owned),
|
|
627
|
+
observation: PiCatalogTestObservation::default(),
|
|
628
|
+
}))
|
|
629
|
+
.is_none());
|
|
630
|
+
let result = action();
|
|
631
|
+
let context = slot.take().expect("Pi catalog test context");
|
|
632
|
+
(result, context.observation)
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
#[cfg(test)]
|
|
637
|
+
fn observe_pi_catalog_test(f: impl FnOnce(&mut PiCatalogTestContext)) {
|
|
638
|
+
PI_CATALOG_TEST_CONTEXT.with(|slot| {
|
|
639
|
+
if let Some(context) = slot.borrow_mut().as_mut() {
|
|
640
|
+
f(context);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
591
645
|
/// Discover candidates with one PATH-first catalog observation. This deliberately
|
|
592
646
|
/// does not resolve adapters or scan wrapper chains; callers use it only for
|
|
593
647
|
/// rejecting an unqualified role model before lifecycle mutation.
|
|
@@ -596,8 +650,18 @@ pub(crate) fn run_pi_catalog(
|
|
|
596
650
|
timeout: Duration,
|
|
597
651
|
max_bytes: u64,
|
|
598
652
|
) -> Result<Vec<u8>, String> {
|
|
599
|
-
let mut
|
|
600
|
-
|
|
653
|
+
let mut command = Command::new(executable);
|
|
654
|
+
command.arg("--list-models");
|
|
655
|
+
#[cfg(test)]
|
|
656
|
+
observe_pi_catalog_test(|context| {
|
|
657
|
+
context.observation.spawn_count += 1;
|
|
658
|
+
context.observation.argv.push("--list-models".to_string());
|
|
659
|
+
command.env("TEAM_AGENT_MODELS_TIMEOUT_RECEIPT", &context.receipt);
|
|
660
|
+
if let Some(mode) = context.helper_mode.as_deref() {
|
|
661
|
+
command.env("TEAM_AGENT_MODELS_TIMEOUT_MODE", mode);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
let mut child = command
|
|
601
665
|
.stdin(Stdio::null())
|
|
602
666
|
.stdout(Stdio::piped())
|
|
603
667
|
.stderr(Stdio::null())
|
|
@@ -614,6 +678,8 @@ pub(crate) fn run_pi_catalog(
|
|
|
614
678
|
let _ = error;
|
|
615
679
|
"Pi executable is unavailable on PATH".to_string()
|
|
616
680
|
})?;
|
|
681
|
+
#[cfg(test)]
|
|
682
|
+
observe_pi_catalog_test(|context| context.observation.parent_pid = Some(child.id()));
|
|
617
683
|
let stdout = child.stdout.take().ok_or_else(|| {
|
|
618
684
|
let _ = child.kill();
|
|
619
685
|
let _ = child.wait();
|
|
@@ -629,8 +695,19 @@ pub(crate) fn run_pi_catalog(
|
|
|
629
695
|
loop {
|
|
630
696
|
match child.try_wait() {
|
|
631
697
|
Ok(Some(status)) => {
|
|
632
|
-
|
|
633
|
-
|
|
698
|
+
#[cfg(test)]
|
|
699
|
+
observe_pi_catalog_test(|context| {
|
|
700
|
+
context.observation.parent_exit_success = Some(status.success());
|
|
701
|
+
context.observation.parent_exit_code = status.code();
|
|
702
|
+
});
|
|
703
|
+
let (result, bytes) = match receiver.recv_timeout(timeout.saturating_sub(start.elapsed())) {
|
|
704
|
+
Ok(value) => value,
|
|
705
|
+
Err(_) => {
|
|
706
|
+
#[cfg(test)]
|
|
707
|
+
observe_pi_catalog_test(|context| context.observation.reader_timeout = true);
|
|
708
|
+
return Err("Pi model catalog command timed out".to_string());
|
|
709
|
+
}
|
|
710
|
+
};
|
|
634
711
|
if !status.success() { return Err("Pi model catalog command failed".into()); }
|
|
635
712
|
result.map_err(|_| "Pi model catalog could not be read".to_string())?;
|
|
636
713
|
if bytes.len() as u64 > max_bytes { return Err("Pi model catalog exceeds the bounded output limit".into()); }
|
|
@@ -1303,7 +1303,7 @@ fn restart_with_selected_team_and_transport(
|
|
|
1303
1303
|
)?;
|
|
1304
1304
|
// 0.3.30 Bug 1: auto-attach on partial restart too — workers that did
|
|
1305
1305
|
// come up still need a leader_receiver pane to deliver report_result.
|
|
1306
|
-
try_autobind_leader_after_restart(
|
|
1306
|
+
let attach_window_failures = try_autobind_leader_after_restart(
|
|
1307
1307
|
&selected.run_workspace,
|
|
1308
1308
|
Some(selected.team_key.as_str()),
|
|
1309
1309
|
&state,
|
|
@@ -1316,6 +1316,7 @@ fn restart_with_selected_team_and_transport(
|
|
|
1316
1316
|
coordinator,
|
|
1317
1317
|
next_actions,
|
|
1318
1318
|
attach_commands,
|
|
1319
|
+
attach_window_failures,
|
|
1319
1320
|
});
|
|
1320
1321
|
}
|
|
1321
1322
|
phase_timer.emit(&selected.run_workspace, "restart.phase", "completed");
|
|
@@ -1331,7 +1332,8 @@ fn restart_with_selected_team_and_transport(
|
|
|
1331
1332
|
// invoked from a tmux pane should bind that pane as leader_receiver,
|
|
1332
1333
|
// restoring the worker→leader delivery path. Failure is non-fatal — the
|
|
1333
1334
|
// user can still run `team-agent attach-leader` manually.
|
|
1334
|
-
|
|
1335
|
+
let attach_window_failures =
|
|
1336
|
+
try_autobind_leader_after_restart(&selected.run_workspace, Some(&selected.team_key), &state);
|
|
1335
1337
|
if let Ok(probe) = crate::lifecycle::display::probe_display_capabilities(&selected.run_workspace)
|
|
1336
1338
|
{
|
|
1337
1339
|
let _ = crate::lifecycle::display::rebuild_adaptive_display_after_rebind(
|
|
@@ -1352,6 +1354,7 @@ fn restart_with_selected_team_and_transport(
|
|
|
1352
1354
|
coordinator,
|
|
1353
1355
|
next_actions,
|
|
1354
1356
|
attach_commands,
|
|
1357
|
+
attach_window_failures,
|
|
1355
1358
|
})
|
|
1356
1359
|
}
|
|
1357
1360
|
|
|
@@ -1825,9 +1828,9 @@ fn try_autobind_leader_after_restart(
|
|
|
1825
1828
|
workspace: &std::path::Path,
|
|
1826
1829
|
team: Option<&str>,
|
|
1827
1830
|
state: &serde_json::Value,
|
|
1828
|
-
) {
|
|
1831
|
+
) -> Option<serde_json::Value> {
|
|
1829
1832
|
if std::env::var_os("TMUX_PANE").is_none() {
|
|
1830
|
-
return;
|
|
1833
|
+
return None;
|
|
1831
1834
|
}
|
|
1832
1835
|
// Provider: prefer the existing team_owner.provider (if rebind),
|
|
1833
1836
|
// else leader_receiver.provider (stale, but still informative),
|
|
@@ -1854,6 +1857,7 @@ fn try_autobind_leader_after_restart(
|
|
|
1854
1857
|
let team_str = team;
|
|
1855
1858
|
match crate::leader::attach_leader(workspace, team_str, None, provider) {
|
|
1856
1859
|
Ok(result) if result.ok => {
|
|
1860
|
+
let debt = result.attach_window_failures.clone();
|
|
1857
1861
|
let _ = crate::leader::registry::register_binding_from_state_best_effort(
|
|
1858
1862
|
workspace,
|
|
1859
1863
|
team_str,
|
|
@@ -1864,18 +1868,21 @@ fn try_autobind_leader_after_restart(
|
|
|
1864
1868
|
result.bound_pane_id.as_ref().map(|p| p.as_str()),
|
|
1865
1869
|
team_str,
|
|
1866
1870
|
);
|
|
1871
|
+
debt
|
|
1867
1872
|
}
|
|
1868
1873
|
Ok(result) => {
|
|
1869
1874
|
eprintln!(
|
|
1870
1875
|
"team_agent::restart auto_attach_leader skipped reason={:?} team={:?}",
|
|
1871
1876
|
result.reason, team_str,
|
|
1872
1877
|
);
|
|
1878
|
+
None
|
|
1873
1879
|
}
|
|
1874
1880
|
Err(error) => {
|
|
1875
1881
|
eprintln!(
|
|
1876
1882
|
"team_agent::restart auto_attach_leader failed error={error} team={team_str:?} \
|
|
1877
1883
|
(run `team-agent attach-leader` from your tmux pane to bind manually)",
|
|
1878
1884
|
);
|
|
1885
|
+
None
|
|
1879
1886
|
}
|
|
1880
1887
|
}
|
|
1881
1888
|
}
|
|
@@ -18,6 +18,8 @@ mod hermetic_guard;
|
|
|
18
18
|
|
|
19
19
|
#[path = "../../../tests/support/composite_source.rs"]
|
|
20
20
|
mod composite_source;
|
|
21
|
+
#[path = "../../../tests/support/brief_probe.rs"]
|
|
22
|
+
mod brief_probe;
|
|
21
23
|
use std::path::{Path, PathBuf};
|
|
22
24
|
|
|
23
25
|
use serde_json::{json, Value};
|
|
@@ -97,6 +99,18 @@ fn stale_snapshot_cannot_flip_status_or_diagnose_ok_readiness() {
|
|
|
97
99
|
let case = B0HideOneCase::new("status-diagnose-hide-one");
|
|
98
100
|
let root = case.current_state();
|
|
99
101
|
save_runtime_state(&case.workspace, &root).expect("seed current root state");
|
|
102
|
+
let probe = brief_probe::install_trusted_nodeprobe(
|
|
103
|
+
&case.workspace.join("probe"),
|
|
104
|
+
NEW_ENDPOINT,
|
|
105
|
+
SESSION,
|
|
106
|
+
WORKER,
|
|
107
|
+
"%new",
|
|
108
|
+
"fake",
|
|
109
|
+
);
|
|
110
|
+
let _path = case.env.with_env(
|
|
111
|
+
"PATH",
|
|
112
|
+
&brief_probe::path_with_probe(&probe, std::env::var_os("PATH").as_deref()),
|
|
113
|
+
);
|
|
100
114
|
|
|
101
115
|
let status_before = case.run_json(&[
|
|
102
116
|
"status",
|
|
@@ -136,10 +150,20 @@ fn stale_snapshot_cannot_flip_status_or_diagnose_ok_readiness() {
|
|
|
136
150
|
"--json",
|
|
137
151
|
]);
|
|
138
152
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
153
|
+
let node = brief_node(&status_after).unwrap_or_else(|| {
|
|
154
|
+
panic!("F0-3 RED3: status must retain the worker in the seven-field projection; before={status_before} after={status_after}")
|
|
155
|
+
});
|
|
156
|
+
assert_eq!(node.get("name").and_then(Value::as_str), Some(WORKER));
|
|
157
|
+
assert_eq!(node.get("provider").and_then(Value::as_str), Some("fake"));
|
|
158
|
+
assert_eq!(node.get("runtime_status").and_then(Value::as_str), Some("running"));
|
|
159
|
+
assert_eq!(node.get("activity").and_then(Value::as_str), Some("idle"));
|
|
160
|
+
assert_eq!(node.get("health").and_then(Value::as_str), Some("normal"));
|
|
161
|
+
assert_eq!(node.get("session_name").and_then(Value::as_str), Some(SESSION));
|
|
162
|
+
assert!(
|
|
163
|
+
node.get("tmux_command")
|
|
164
|
+
.and_then(Value::as_str)
|
|
165
|
+
.is_some_and(|command| command.contains(NEW_ENDPOINT) && command.contains("team-b0-hideone:worker.%new")),
|
|
166
|
+
"F0-3 RED3: trusted root probe must produce a non-empty exact endpoint command; node={node}"
|
|
143
167
|
);
|
|
144
168
|
assert_eq!(
|
|
145
169
|
worker_status_tuple(&status_before),
|
|
@@ -333,18 +357,25 @@ impl B0HideOneCase {
|
|
|
333
357
|
}
|
|
334
358
|
}
|
|
335
359
|
|
|
360
|
+
fn brief_node(status: &Value) -> Option<&Value> {
|
|
361
|
+
status
|
|
362
|
+
.get("nodes")
|
|
363
|
+
.and_then(Value::as_array)
|
|
364
|
+
.and_then(|nodes| nodes.iter().find(|node| {
|
|
365
|
+
node.get("name").and_then(Value::as_str) == Some(WORKER)
|
|
366
|
+
}))
|
|
367
|
+
}
|
|
368
|
+
|
|
336
369
|
fn worker_status_tuple(status: &Value) -> Value {
|
|
337
|
-
let worker = status
|
|
338
|
-
.get("agents")
|
|
339
|
-
.and_then(Value::as_object)
|
|
340
|
-
.and_then(|agents| agents.get(WORKER))
|
|
341
|
-
.cloned()
|
|
342
|
-
.unwrap_or(Value::Null);
|
|
370
|
+
let worker = brief_node(status).cloned().unwrap_or(Value::Null);
|
|
343
371
|
json!({
|
|
344
|
-
"
|
|
345
|
-
"
|
|
346
|
-
"
|
|
347
|
-
"
|
|
372
|
+
"name": worker.get("name").cloned().unwrap_or(Value::Null),
|
|
373
|
+
"provider": worker.get("provider").cloned().unwrap_or(Value::Null),
|
|
374
|
+
"runtime_status": worker.get("runtime_status").cloned().unwrap_or(Value::Null),
|
|
375
|
+
"activity": worker.get("activity").cloned().unwrap_or(Value::Null),
|
|
376
|
+
"health": worker.get("health").cloned().unwrap_or(Value::Null),
|
|
377
|
+
"session_name": worker.get("session_name").cloned().unwrap_or(Value::Null),
|
|
378
|
+
"tmux_command": worker.get("tmux_command").cloned().unwrap_or(Value::Null),
|
|
348
379
|
})
|
|
349
380
|
}
|
|
350
381
|
|
|
@@ -234,6 +234,7 @@ fn clone_ok(case: &Case, source: &str, dest: &str) {
|
|
|
234
234
|
|
|
235
235
|
/// clone 后分身 role + spec tools 必须等于源席;三件套源不得被扩成六件。
|
|
236
236
|
#[test]
|
|
237
|
+
#[serial_test::serial(env)]
|
|
237
238
|
fn clone_agent_preserves_source_tools() {
|
|
238
239
|
let case = Case::start();
|
|
239
240
|
let qs = case.run(&[
|
|
@@ -425,7 +425,7 @@ check behind it = false green); it must reflect an actual doctor result"
|
|
|
425
425
|
/// Mutates the process-global PATH/HOME — must not race host_cotenant's ps/tmux
|
|
426
426
|
/// shims (P0 pid-probe isolation), hence `#[serial(pid_probe)]`.
|
|
427
427
|
#[test]
|
|
428
|
-
#[serial(pid_probe)]
|
|
428
|
+
#[serial(env, pid_probe)]
|
|
429
429
|
fn t3_claude_auth_hint_parse_failure_is_not_present() {
|
|
430
430
|
let home = tmp_ws("t3-auth-home");
|
|
431
431
|
let shim_dir = home.join("bin");
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
use super::launch_spawn::seed_healthy_coordinator;
|
|
2
2
|
use super::*;
|
|
3
3
|
use crate::cli::{
|
|
4
|
-
cmd_collect, cmd_send, cmd_status, lifecycle_port, CmdOutput, CmdResult,
|
|
5
|
-
StatusArgs,
|
|
4
|
+
cmd_collect, cmd_diagnose, cmd_send, cmd_status, lifecycle_port, CmdOutput, CmdResult,
|
|
5
|
+
CollectArgs, DiagnoseArgs, SendArgs, StatusArgs,
|
|
6
6
|
};
|
|
7
7
|
use crate::transport::test_support::OfflineTransport;
|
|
8
8
|
use crate::transport::WindowName;
|
|
@@ -176,29 +176,28 @@ fn phase_f_golden_events_state_status_zero_drift() {
|
|
|
176
176
|
#[test]
|
|
177
177
|
#[serial_test::serial(env)]
|
|
178
178
|
fn phase_golden_unbound_not_ready_requires_claim_leader() {
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
let
|
|
183
|
-
|
|
184
|
-
.get("not_ready")
|
|
185
|
-
.and_then(Value::as_object)
|
|
186
|
-
.unwrap_or_else(|| panic!("compact must carry not_ready; got {compact}"));
|
|
187
|
-
let reasons = not_ready
|
|
188
|
-
.get("reasons")
|
|
179
|
+
// Unbound recovery guidance belongs to the diagnose/quick-start refusal
|
|
180
|
+
// surface, not to the seven-field status brief.
|
|
181
|
+
let diagnose = run_diagnose_after_quick_start();
|
|
182
|
+
let issues = diagnose
|
|
183
|
+
.get("issues")
|
|
189
184
|
.and_then(Value::as_array)
|
|
190
185
|
.cloned()
|
|
191
186
|
.unwrap_or_default();
|
|
192
187
|
assert!(
|
|
193
|
-
|
|
194
|
-
.
|
|
195
|
-
|
|
196
|
-
|
|
188
|
+
issues.iter().any(|issue| {
|
|
189
|
+
issue.as_str() == Some("leader_not_attached")
|
|
190
|
+
|| issue.get("id").and_then(Value::as_str) == Some("leader_channel_unbound")
|
|
191
|
+
}),
|
|
192
|
+
"tombstone fixture must still diagnose leader_receiver_unbound; diagnose={diagnose}"
|
|
197
193
|
);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
194
|
+
let repairs = diagnose
|
|
195
|
+
.get("suggested_repairs")
|
|
196
|
+
.map(Value::to_string)
|
|
197
|
+
.unwrap_or_default();
|
|
198
|
+
assert!(
|
|
199
|
+
repairs.contains("claim-leader"),
|
|
200
|
+
"diagnose must retain claim-leader recovery guidance; diagnose={diagnose}"
|
|
202
201
|
);
|
|
203
202
|
}
|
|
204
203
|
|
|
@@ -474,7 +473,7 @@ fn strip_full_line_json_comments(text: &str) -> String {
|
|
|
474
473
|
.join("\n")
|
|
475
474
|
}
|
|
476
475
|
|
|
477
|
-
fn
|
|
476
|
+
fn run_diagnose_after_quick_start() -> Value {
|
|
478
477
|
let hermetic = HermeticTestEnv::enter("tombstone-claim-leader");
|
|
479
478
|
let _permission_mode = EnvVarGuard::set(ANCESTRY_ENV, "[]");
|
|
480
479
|
let team = two_worker_team_dir(&hermetic);
|
|
@@ -491,20 +490,17 @@ fn run_compact_status_after_quick_start() -> Value {
|
|
|
491
490
|
false,
|
|
492
491
|
)
|
|
493
492
|
.expect("tombstone fixture must quick-start");
|
|
494
|
-
let
|
|
495
|
-
agent: None,
|
|
493
|
+
let diagnose = cmd_diagnose(&DiagnoseArgs {
|
|
496
494
|
workspace,
|
|
497
|
-
detail: false,
|
|
498
|
-
summary: false,
|
|
499
495
|
json: true,
|
|
500
496
|
team: Some("teamdir".to_string()),
|
|
501
497
|
});
|
|
502
|
-
match cmd_value(
|
|
498
|
+
match cmd_value(diagnose) {
|
|
503
499
|
Value::Object(map) => map
|
|
504
500
|
.get("output")
|
|
505
501
|
.cloned()
|
|
506
|
-
.unwrap_or_else(|| panic!("
|
|
507
|
-
other => panic!("
|
|
502
|
+
.unwrap_or_else(|| panic!("diagnose missing output")),
|
|
503
|
+
other => panic!("diagnose must be a JSON object, got {other}"),
|
|
508
504
|
}
|
|
509
505
|
}
|
|
510
506
|
|
|
@@ -72,12 +72,19 @@ fn upgrade_0211_state_team_key_runtime_key_not_spec_name() {
|
|
|
72
72
|
},
|
|
73
73
|
Some(TEAM_KEY),
|
|
74
74
|
));
|
|
75
|
+
let node_names = status
|
|
76
|
+
.get("nodes")
|
|
77
|
+
.and_then(Value::as_array)
|
|
78
|
+
.expect("status brief must expose nodes")
|
|
79
|
+
.iter()
|
|
80
|
+
.filter_map(|node| node.get("name").and_then(Value::as_str))
|
|
81
|
+
.collect::<Vec<_>>();
|
|
75
82
|
assert!(
|
|
76
|
-
|
|
83
|
+
node_names.contains(&"upgrade_worker"),
|
|
77
84
|
"status --team upgrade-key must project the runtime-key team; status={status}"
|
|
78
85
|
);
|
|
79
86
|
assert!(
|
|
80
|
-
|
|
87
|
+
!node_names.contains(&"sibling_worker"),
|
|
81
88
|
"status --team upgrade-key must not collapse to sibling/spec-name state; status={status}"
|
|
82
89
|
);
|
|
83
90
|
}
|
|
@@ -216,15 +223,20 @@ fn upgrade_status_collect_scope_by_selected_team_key() {
|
|
|
216
223
|
},
|
|
217
224
|
Some(TEAM_KEY),
|
|
218
225
|
));
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
"status
|
|
226
|
+
let node_names = status
|
|
227
|
+
.get("nodes")
|
|
228
|
+
.and_then(Value::as_array)
|
|
229
|
+
.expect("status brief must expose nodes")
|
|
230
|
+
.iter()
|
|
231
|
+
.filter_map(|node| node.get("name").and_then(Value::as_str))
|
|
232
|
+
.collect::<Vec<_>>();
|
|
233
|
+
assert!(
|
|
234
|
+
node_names.contains(&"upgrade_worker"),
|
|
235
|
+
"status --team upgrade-key must retain the selected runtime-key node; status={status}"
|
|
223
236
|
);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
"status --team upgrade-key must count only upgrade-key messages; status={status}"
|
|
237
|
+
assert!(
|
|
238
|
+
!node_names.contains(&"sibling_worker"),
|
|
239
|
+
"status --team upgrade-key must not expose sibling rows; status={status}"
|
|
228
240
|
);
|
|
229
241
|
|
|
230
242
|
let collect = json_output(cmd_collect_for_team(
|
|
@@ -797,6 +797,8 @@ pub enum RestartReport {
|
|
|
797
797
|
coordinator: CoordinatorStartSummary,
|
|
798
798
|
next_actions: Vec<String>,
|
|
799
799
|
attach_commands: Vec<String>,
|
|
800
|
+
/// Debt returned by this restart's best-effort auto-attach, if any.
|
|
801
|
+
attach_window_failures: Option<serde_json::Value>,
|
|
800
802
|
},
|
|
801
803
|
/// At least one worker failed during live spawn, but other workers were isolated
|
|
802
804
|
/// and restarted. The CLI reports `status=partial` and exits non-zero.
|
|
@@ -811,6 +813,8 @@ pub enum RestartReport {
|
|
|
811
813
|
coordinator: CoordinatorStartSummary,
|
|
812
814
|
next_actions: Vec<String>,
|
|
813
815
|
attach_commands: Vec<String>,
|
|
816
|
+
/// Debt returned by this restart's best-effort auto-attach, if any.
|
|
817
|
+
attach_window_failures: Option<serde_json::Value>,
|
|
814
818
|
},
|
|
815
819
|
/// All workers failed during live spawn. No worker is reported as restarted.
|
|
816
820
|
Failed {
|