@team-agent/installer 0.5.78 → 0.5.80
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/models.rs +31 -0
- package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
- package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
- package/crates/team-agent/src/leader/start.rs +46 -74
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +21 -5
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
- package/crates/team-agent/src/messaging/delivery.rs +154 -72
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
- package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
- package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
- package/crates/team-agent/src/state/mod.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +5 -3
- package/crates/team-agent/src/state/projection.rs +7 -1
- package/crates/team-agent/src/tmux_backend/tests.rs +28 -0
- package/crates/team-agent/src/tmux_backend.rs +31 -9
- package/crates/team-agent/src/transport.rs +6 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -248,6 +248,37 @@ mod tests {
|
|
|
248
248
|
None
|
|
249
249
|
);
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
#[cfg(unix)]
|
|
253
|
+
#[test]
|
|
254
|
+
fn search_survives_unrelated_spaced_model_ids() {
|
|
255
|
+
let catalog = include_str!("../lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt");
|
|
256
|
+
let path = fixture(&format!("printf '%s' '{catalog}'"));
|
|
257
|
+
let result = cmd_models_with(
|
|
258
|
+
&ModelsArgs {
|
|
259
|
+
provider: "pi".into(),
|
|
260
|
+
search: Some("gpt-5.6-luna".into()),
|
|
261
|
+
json: true,
|
|
262
|
+
},
|
|
263
|
+
&path,
|
|
264
|
+
Duration::from_secs(10),
|
|
265
|
+
MAX_CATALOG_BYTES,
|
|
266
|
+
)
|
|
267
|
+
.unwrap();
|
|
268
|
+
cleanup_fixture(&path);
|
|
269
|
+
assert_eq!(result.exit, ExitCode::Ok);
|
|
270
|
+
let CmdOutput::Human(text) = result.output else {
|
|
271
|
+
panic!("expected JSON projection")
|
|
272
|
+
};
|
|
273
|
+
let value: Value = serde_json::from_str(&text).unwrap();
|
|
274
|
+
assert_eq!(value["ok"], true);
|
|
275
|
+
assert_eq!(value["auth"], "ok");
|
|
276
|
+
let models = value["models"].as_array().unwrap();
|
|
277
|
+
assert_eq!(models.len(), 2);
|
|
278
|
+
assert_eq!(models[0]["role_model"], "openai-codex/gpt-5.6-luna");
|
|
279
|
+
assert_eq!(models[1]["role_model"], "team-agent/gpt-5.6-luna");
|
|
280
|
+
}
|
|
281
|
+
|
|
251
282
|
#[cfg(unix)]
|
|
252
283
|
fn fixture(body: &str) -> std::path::PathBuf {
|
|
253
284
|
use std::os::unix::fs::PermissionsExt;
|
|
@@ -58,7 +58,9 @@ pub(crate) fn compute_runtime_freshness(
|
|
|
58
58
|
for (agent_id, entry) in watch {
|
|
59
59
|
let exited =
|
|
60
60
|
entry.get("worker_provider_exited").and_then(Value::as_bool) == Some(true);
|
|
61
|
-
if exited
|
|
61
|
+
if exited
|
|
62
|
+
&& crate::state::abnormal_watch::matches_observation(state, agent_id, entry)
|
|
63
|
+
{
|
|
62
64
|
provider_exited_agents.insert(agent_id.clone());
|
|
63
65
|
}
|
|
64
66
|
}
|
|
@@ -28,7 +28,12 @@ fn health_fixture() -> crate::coordinator::HealthReport {
|
|
|
28
28
|
|
|
29
29
|
#[test]
|
|
30
30
|
fn runtime_freshness_provider_exit_requires_typed_positive_fact() {
|
|
31
|
-
let state = serde_json::json!({
|
|
31
|
+
let mut state = serde_json::json!({
|
|
32
|
+
"active_team_key": "status-team",
|
|
33
|
+
"agents": {
|
|
34
|
+
"pane_only": {"provider": "codex", "rollout_path": "/fixture/pane", "spawn_epoch": 1},
|
|
35
|
+
"typed_exit": {"provider": "codex", "rollout_path": "/fixture/typed", "spawn_epoch": 1}
|
|
36
|
+
},
|
|
32
37
|
"coordinator": {
|
|
33
38
|
"abnormal_exit_watch": {
|
|
34
39
|
"pane_only": {
|
|
@@ -43,6 +48,10 @@ fn runtime_freshness_provider_exit_requires_typed_positive_fact() {
|
|
|
43
48
|
}
|
|
44
49
|
});
|
|
45
50
|
|
|
51
|
+
for id in ["pane_only", "typed_exit"] {
|
|
52
|
+
state["coordinator"]["abnormal_exit_watch"][id]["watch_identity"] =
|
|
53
|
+
crate::state::abnormal_watch::observation_identity(&state, id).unwrap();
|
|
54
|
+
}
|
|
46
55
|
let freshness = compute_runtime_freshness(
|
|
47
56
|
Path::new("/nonexistent/status-port-typed-provider-exit-test"),
|
|
48
57
|
&state,
|
|
@@ -52,3 +61,30 @@ fn runtime_freshness_provider_exit_requires_typed_positive_fact() {
|
|
|
52
61
|
assert!(!freshness.provider_exited_agents.contains("pane_only"));
|
|
53
62
|
assert!(freshness.provider_exited_agents.contains("typed_exit"));
|
|
54
63
|
}
|
|
64
|
+
|
|
65
|
+
#[test]
|
|
66
|
+
fn runtime_freshness_rejects_foreign_legacy_and_unknown_watch_identity() {
|
|
67
|
+
let mut state = json!({
|
|
68
|
+
"active_team_key": "a",
|
|
69
|
+
"agents": {"w": {"provider": "codex", "rollout_path": "/fixture/w", "spawn_epoch": 1}},
|
|
70
|
+
"coordinator": {"abnormal_exit_watch": {"w": {"worker_provider_exited": true}}}
|
|
71
|
+
});
|
|
72
|
+
let path = Path::new("/nonexistent/status-watch-188");
|
|
73
|
+
assert!(compute_runtime_freshness(path, &state, &health_fixture())
|
|
74
|
+
.provider_exited_agents
|
|
75
|
+
.is_empty());
|
|
76
|
+
let identity = crate::state::abnormal_watch::observation_identity(&state, "w").unwrap();
|
|
77
|
+
state["coordinator"]["abnormal_exit_watch"]["w"]["watch_identity"] = identity;
|
|
78
|
+
for (pointer, value) in [
|
|
79
|
+
("/active_team_key", json!("b")),
|
|
80
|
+
("/agents/w/provider", json!("claude")),
|
|
81
|
+
("/agents/w/spawn_epoch", json!(2)),
|
|
82
|
+
("/agents/w/rollout_path", json!(null)),
|
|
83
|
+
] {
|
|
84
|
+
let mut changed = state.clone();
|
|
85
|
+
*changed.pointer_mut(pointer).unwrap() = value;
|
|
86
|
+
assert!(compute_runtime_freshness(path, &changed, &health_fixture())
|
|
87
|
+
.provider_exited_agents
|
|
88
|
+
.is_empty());
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -73,6 +73,8 @@ pub(crate) fn detect_abnormal_exits(
|
|
|
73
73
|
let team = crate::state::projection::team_state_key(&snapshot);
|
|
74
74
|
let session_name = snapshot.get("session_name").and_then(Value::as_str);
|
|
75
75
|
for agent in abnormal_watch_agents(&snapshot) {
|
|
76
|
+
// Reject untrusted history without skipping a fresh provider/path observation.
|
|
77
|
+
crate::state::abnormal_watch::begin_observation(state, &agent.agent_id);
|
|
76
78
|
// Pane/process liveness is independent of transcript content. A
|
|
77
79
|
// frozen rollout is common after pane death, so probe before the
|
|
78
80
|
// metadata dedupe gate; only the expensive tail scan stays deduped.
|
|
@@ -109,7 +111,7 @@ pub(crate) fn detect_abnormal_exits(
|
|
|
109
111
|
// re-read below.
|
|
110
112
|
if let (Some(mtime), Some(stored)) = (
|
|
111
113
|
mtime_ns,
|
|
112
|
-
abnormal_watch_stored_metadata(
|
|
114
|
+
abnormal_watch_stored_metadata(state, &agent.agent_id),
|
|
113
115
|
) {
|
|
114
116
|
if stored == (size, mtime) {
|
|
115
117
|
refresh_abnormal_watch_liveness(state, &agent.agent_id, &liveness);
|
|
@@ -149,7 +151,7 @@ pub(crate) fn detect_abnormal_exits(
|
|
|
149
151
|
.map(|fact| abnormal_error_observation_key(&agent, fact));
|
|
150
152
|
let error_observation_cohort = fact.as_ref().map(|_| abnormal_error_cohort_key(&agent));
|
|
151
153
|
let error_recency = abnormal_error_recency(
|
|
152
|
-
|
|
154
|
+
state,
|
|
153
155
|
&agent,
|
|
154
156
|
error_observation_key.as_deref(),
|
|
155
157
|
error_observation_cohort.as_deref(),
|
|
@@ -954,6 +956,9 @@ fn abnormal_watch_payload(
|
|
|
954
956
|
}
|
|
955
957
|
|
|
956
958
|
fn upsert_abnormal_watch(state: &mut Value, agent_id: &str, mut payload: Value) {
|
|
959
|
+
if let Some(identity) = crate::state::abnormal_watch::observation_identity(state, agent_id) {
|
|
960
|
+
payload["watch_identity"] = identity;
|
|
961
|
+
}
|
|
957
962
|
let preserved = [
|
|
958
963
|
"last_notified_key",
|
|
959
964
|
"last_notified_at",
|
|
@@ -1413,6 +1418,121 @@ mod tests {
|
|
|
1413
1418
|
crate::provider::get_adapter(provider)
|
|
1414
1419
|
}
|
|
1415
1420
|
}
|
|
1421
|
+
#[test]
|
|
1422
|
+
fn resource188_foreign_metadata_and_dedupe_cannot_skip_new_baseline() {
|
|
1423
|
+
let dir = temp_abnormal_dir("188-foreign-watch");
|
|
1424
|
+
let rollout = dir.join("rollout-w1.jsonl");
|
|
1425
|
+
let error = "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"old\",\"status\":\"failed\"}}}\n";
|
|
1426
|
+
std::fs::write(&rollout, error).unwrap();
|
|
1427
|
+
seed_abnormal_state(&dir, &rollout, "alive", 1);
|
|
1428
|
+
let mut state = crate::state::persist::load_runtime_state(&dir).unwrap();
|
|
1429
|
+
let metadata = std::fs::metadata(&rollout).unwrap();
|
|
1430
|
+
let mut foreign = crate::state::abnormal_watch::observation_identity(&state, "w1").unwrap();
|
|
1431
|
+
foreign["team_key"] = serde_json::json!("another-team");
|
|
1432
|
+
state["coordinator"] = serde_json::json!({"abnormal_exit_watch": {"w1": {
|
|
1433
|
+
"watch_identity": foreign,
|
|
1434
|
+
"size": metadata.len(), "mtime_ns": metadata_mtime_ns(&metadata),
|
|
1435
|
+
"last_notified_key": "foreign", "last_check_key": "foreign",
|
|
1436
|
+
"last_error_observation_key": "foreign", "last_error_observation_cohort": "foreign"
|
|
1437
|
+
}}});
|
|
1438
|
+
let log = EventLog::new(&dir);
|
|
1439
|
+
let transport = crate::transport::test_support::OfflineTransport::new();
|
|
1440
|
+
detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
|
|
1441
|
+
let baseline = state["coordinator"]["abnormal_exit_watch"]["w1"].clone();
|
|
1442
|
+
assert_eq!(baseline["error_recency"], "stale");
|
|
1443
|
+
assert!(baseline.get("last_notified_key").is_none());
|
|
1444
|
+
assert!(crate::state::abnormal_watch::matches_observation(
|
|
1445
|
+
&state, "w1", &baseline
|
|
1446
|
+
));
|
|
1447
|
+
let events = log.tail(0).unwrap();
|
|
1448
|
+
detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
|
|
1449
|
+
assert_eq!(
|
|
1450
|
+
log.tail(0).unwrap(),
|
|
1451
|
+
events,
|
|
1452
|
+
"unchanged baseline must not re-alarm"
|
|
1453
|
+
);
|
|
1454
|
+
assert!(find_event(&events, "worker.abnormal_exit").is_none());
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
#[test]
|
|
1458
|
+
fn resource188_legacy_missing_cohort_still_observes_fresh_errors_and_dedupes() {
|
|
1459
|
+
let dir = temp_abnormal_dir("188-legacy-cohort");
|
|
1460
|
+
let rollout = dir.join("rollout-w1.jsonl");
|
|
1461
|
+
let error = "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"old\",\"status\":\"failed\"}}}\n";
|
|
1462
|
+
std::fs::write(&rollout, error).unwrap();
|
|
1463
|
+
seed_abnormal_state(&dir, &rollout, "alive", 1);
|
|
1464
|
+
let mut state = crate::state::persist::load_runtime_state(&dir).unwrap();
|
|
1465
|
+
state["agents"]["w1"]
|
|
1466
|
+
.as_object_mut()
|
|
1467
|
+
.unwrap()
|
|
1468
|
+
.remove("spawn_epoch");
|
|
1469
|
+
let log = EventLog::new(&dir);
|
|
1470
|
+
let transport = crate::transport::test_support::OfflineTransport::new();
|
|
1471
|
+
detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
|
|
1472
|
+
assert_eq!(
|
|
1473
|
+
state["coordinator"]["abnormal_exit_watch"]["w1"]["error_recency"],
|
|
1474
|
+
"stale"
|
|
1475
|
+
);
|
|
1476
|
+
assert!(find_event(&log.tail(0).unwrap(), "worker.abnormal_exit").is_none());
|
|
1477
|
+
std::fs::OpenOptions::new()
|
|
1478
|
+
.append(true)
|
|
1479
|
+
.open(&rollout)
|
|
1480
|
+
.unwrap()
|
|
1481
|
+
.write_all(error.replace("old", "new-error").as_bytes())
|
|
1482
|
+
.unwrap();
|
|
1483
|
+
detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
|
|
1484
|
+
let events = log.tail(0).unwrap();
|
|
1485
|
+
assert_eq!(
|
|
1486
|
+
find_event(&events, "worker.abnormal_exit").unwrap()["error_recency"],
|
|
1487
|
+
"fresh"
|
|
1488
|
+
);
|
|
1489
|
+
let notified =
|
|
1490
|
+
state["coordinator"]["abnormal_exit_watch"]["w1"]["last_notified_key"].clone();
|
|
1491
|
+
assert!(notified.is_string());
|
|
1492
|
+
detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
|
|
1493
|
+
assert_eq!(log.tail(0).unwrap(), events);
|
|
1494
|
+
assert_eq!(
|
|
1495
|
+
state["coordinator"]["abnormal_exit_watch"]["w1"]["last_notified_key"],
|
|
1496
|
+
notified
|
|
1497
|
+
);
|
|
1498
|
+
state["agents"]["w1"]["spawn_epoch"] = serde_json::json!(2);
|
|
1499
|
+
detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
|
|
1500
|
+
let watch = &state["coordinator"]["abnormal_exit_watch"]["w1"];
|
|
1501
|
+
assert_eq!(
|
|
1502
|
+
watch["error_recency"], "stale",
|
|
1503
|
+
"a known generation must rebaseline unchanged bytes"
|
|
1504
|
+
);
|
|
1505
|
+
assert!(watch.get("last_notified_key").is_none());
|
|
1506
|
+
assert_eq!(
|
|
1507
|
+
log.tail(0)
|
|
1508
|
+
.unwrap()
|
|
1509
|
+
.iter()
|
|
1510
|
+
.filter(|event| event["event"] == "worker.abnormal_exit")
|
|
1511
|
+
.count(),
|
|
1512
|
+
1
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
#[test]
|
|
1517
|
+
fn resource188_unknown_team_still_records_fresh_liveness_without_reusing_cache() {
|
|
1518
|
+
let dir = temp_abnormal_dir("188-unknown-team");
|
|
1519
|
+
let rollout = dir.join("rollout-w1.jsonl");
|
|
1520
|
+
std::fs::write(&rollout, "").unwrap();
|
|
1521
|
+
seed_abnormal_state(&dir, &rollout, "dead", 1);
|
|
1522
|
+
let mut state = crate::state::persist::load_runtime_state(&dir).unwrap();
|
|
1523
|
+
state.as_object_mut().unwrap().remove("active_team_key");
|
|
1524
|
+
let log = EventLog::new(&dir);
|
|
1525
|
+
let transport = crate::transport::test_support::OfflineTransport::new();
|
|
1526
|
+
detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
|
|
1527
|
+
assert_eq!(
|
|
1528
|
+
state["coordinator"]["abnormal_exit_watch"]["w1"]["last_liveness"],
|
|
1529
|
+
"dead"
|
|
1530
|
+
);
|
|
1531
|
+
assert!(state["coordinator"]["abnormal_exit_watch"]["w1"]
|
|
1532
|
+
.get("watch_identity")
|
|
1533
|
+
.is_none());
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1416
1536
|
#[test]
|
|
1417
1537
|
fn abnormal_stale_error_baselines_then_fresh_alive_error_notifies() {
|
|
1418
1538
|
let dir = temp_abnormal_dir("alive-fresh");
|
|
@@ -1412,13 +1412,6 @@ extern "C" fn managed_term_handler(_: libc::c_int) {
|
|
|
1412
1412
|
}
|
|
1413
1413
|
|
|
1414
1414
|
fn managed_attach_should_teardown(watch: &ManagedAttachWatch<'_>) -> bool {
|
|
1415
|
-
let marker = crate::tmux_backend::leader_provider_exit_marker(watch.provider_label);
|
|
1416
|
-
let target = Target::Pane(watch.spawned.pane_id.clone());
|
|
1417
|
-
if let Ok(cap) = watch.transport.capture(&target, CaptureRange::Tail(80)) {
|
|
1418
|
-
if cap.text.contains(&marker) || cap.text.contains("[team-agent] Provider exited") {
|
|
1419
|
-
return true;
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
1415
|
matches!(
|
|
1423
1416
|
leader_provider_health(
|
|
1424
1417
|
watch.transport,
|
|
@@ -1674,65 +1667,21 @@ fn ensure_managed_provider_live_after_attach(
|
|
|
1674
1667
|
)))
|
|
1675
1668
|
}
|
|
1676
1669
|
|
|
1677
|
-
///
|
|
1678
|
-
///
|
|
1679
|
-
///
|
|
1680
|
-
/// remains at its inert tail. This function distinguishes
|
|
1681
|
-
/// `provider_alive` from `provider_exited` by:
|
|
1682
|
-
/// 1. Reading `pane_current_command` (tmux `#{pane_current_command}`).
|
|
1683
|
-
/// 2. If the current command matches the expected provider binary (or
|
|
1684
|
-
/// one of its known aliases), report `Alive`.
|
|
1685
|
-
/// 3. If the current command is a shell tail process AND the pane
|
|
1686
|
-
/// content contains the exit marker `[team-agent] <provider> exited`
|
|
1687
|
-
/// (emitted by `leader_shell_wrapper_command`), report
|
|
1688
|
-
/// `ProviderExited`.
|
|
1689
|
-
/// 4. Otherwise (Unknown shell, no marker), report `Alive` as the
|
|
1690
|
-
/// conservative default — avoid false-positive exit alarms.
|
|
1691
|
-
///
|
|
1692
|
-
/// Note: when the leader shell wrapper is used (CR C-2), a provider exit
|
|
1693
|
-
/// leaves the pane as an inert `/bin/sh -c` tail with the exit marker in
|
|
1694
|
-
/// scrollback. Pre-wrapper code that hit `exec claude` would have left the
|
|
1695
|
-
/// pane as `[exited]` and `liveness()` would have returned `Dead`. The new
|
|
1696
|
-
/// failure mode requires this richer health check to surface
|
|
1697
|
-
/// `leader_provider_exited` as a distinct status.
|
|
1670
|
+
/// Reconcile real pane liveness with the supervising wrapper's exit receipt.
|
|
1671
|
+
/// Restored provider output may contain any exit-marker text; it is not process
|
|
1672
|
+
/// state and must never authorize destructive managed-window cleanup.
|
|
1698
1673
|
pub fn leader_provider_health(
|
|
1699
1674
|
transport: &dyn Transport,
|
|
1700
1675
|
pane_id: &PaneId,
|
|
1701
|
-
|
|
1676
|
+
_expected_provider_label: &str,
|
|
1702
1677
|
) -> LeaderProviderHealth {
|
|
1703
|
-
|
|
1704
|
-
let liveness = transport.liveness(pane_id).ok();
|
|
1705
|
-
if matches!(liveness, Some(PaneLiveness::Dead)) {
|
|
1678
|
+
if matches!(transport.liveness(pane_id), Ok(PaneLiveness::Dead)) {
|
|
1706
1679
|
return LeaderProviderHealth::Unreachable;
|
|
1707
1680
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
.query(&target, PaneField::PaneCurrentCommand)
|
|
1711
|
-
.ok()
|
|
1712
|
-
.flatten()
|
|
1713
|
-
.map(|s| s.trim().to_lowercase())
|
|
1714
|
-
.unwrap_or_default();
|
|
1715
|
-
if !current_command.is_empty()
|
|
1716
|
-
&& (current_command == expected_provider_label
|
|
1717
|
-
|| current_command.contains(expected_provider_label))
|
|
1718
|
-
{
|
|
1719
|
-
return LeaderProviderHealth::Alive;
|
|
1681
|
+
if matches!(transport.provider_exit_status(pane_id), Ok(Some(_))) {
|
|
1682
|
+
return LeaderProviderHealth::ProviderExited;
|
|
1720
1683
|
}
|
|
1721
|
-
//
|
|
1722
|
-
let is_shell = is_interactive_shell_basename(¤t_command);
|
|
1723
|
-
if is_shell {
|
|
1724
|
-
// CR R6: marker text from single-source `leader_provider_exit_marker`
|
|
1725
|
-
// so the wrapper printf and the health-check substring cannot drift.
|
|
1726
|
-
let exit_marker_substr =
|
|
1727
|
-
crate::tmux_backend::leader_provider_exit_marker(expected_provider_label);
|
|
1728
|
-
if let Ok(cap) = transport.capture(&target, CaptureRange::Tail(200)) {
|
|
1729
|
-
if cap.text.contains(&exit_marker_substr) {
|
|
1730
|
-
return LeaderProviderHealth::ProviderExited;
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
// Conservative default — pane addressable, but couldn't positively
|
|
1735
|
-
// confirm provider exit. Treat as Alive.
|
|
1684
|
+
// Missing/unsupported receipts (including old wrappers) are not proof of exit.
|
|
1736
1685
|
LeaderProviderHealth::Alive
|
|
1737
1686
|
}
|
|
1738
1687
|
|
|
@@ -1750,7 +1699,6 @@ pub enum LeaderProviderHealth {
|
|
|
1750
1699
|
|
|
1751
1700
|
/// 0.4.x (CR R6 + R3): single-source shell-tail detection.
|
|
1752
1701
|
/// Used by:
|
|
1753
|
-
/// - `leader_provider_health` to decide "pane is at the shell tail"
|
|
1754
1702
|
/// - shutdown logic to recognise a leader pane in shell-tail mode
|
|
1755
1703
|
/// as still owned by the leader (not stray).
|
|
1756
1704
|
///
|
|
@@ -3001,6 +2949,7 @@ mod tests {
|
|
|
3001
2949
|
screens: Mutex<Vec<String>>,
|
|
3002
2950
|
sent: Mutex<Vec<(Target, Vec<Key>)>>,
|
|
3003
2951
|
liveness: PaneLiveness,
|
|
2952
|
+
exit_status: Option<u8>,
|
|
3004
2953
|
targets: Vec<PaneInfo>,
|
|
3005
2954
|
window_options: Mutex<Vec<(String, String)>>,
|
|
3006
2955
|
window_option_error: Mutex<Option<String>>,
|
|
@@ -3014,6 +2963,7 @@ mod tests {
|
|
|
3014
2963
|
screens: Mutex::new(screens),
|
|
3015
2964
|
sent: Mutex::new(Vec::new()),
|
|
3016
2965
|
liveness: PaneLiveness::Unknown,
|
|
2966
|
+
exit_status: None,
|
|
3017
2967
|
targets: Vec::new(),
|
|
3018
2968
|
window_options: Mutex::new(Vec::new()),
|
|
3019
2969
|
window_option_error: Mutex::new(None),
|
|
@@ -3027,6 +2977,7 @@ mod tests {
|
|
|
3027
2977
|
screens: Mutex::new(Vec::new()),
|
|
3028
2978
|
sent: Mutex::new(Vec::new()),
|
|
3029
2979
|
liveness,
|
|
2980
|
+
exit_status: None,
|
|
3030
2981
|
targets: Vec::new(),
|
|
3031
2982
|
window_options: Mutex::new(Vec::new()),
|
|
3032
2983
|
window_option_error: Mutex::new(None),
|
|
@@ -3040,6 +2991,7 @@ mod tests {
|
|
|
3040
2991
|
screens: Mutex::new(Vec::new()),
|
|
3041
2992
|
sent: Mutex::new(Vec::new()),
|
|
3042
2993
|
liveness,
|
|
2994
|
+
exit_status: None,
|
|
3043
2995
|
targets,
|
|
3044
2996
|
window_options: Mutex::new(Vec::new()),
|
|
3045
2997
|
window_option_error: Mutex::new(None),
|
|
@@ -3055,6 +3007,7 @@ mod tests {
|
|
|
3055
3007
|
screens: Mutex::new(Vec::new()),
|
|
3056
3008
|
sent: Mutex::new(Vec::new()),
|
|
3057
3009
|
liveness: PaneLiveness::Unknown,
|
|
3010
|
+
exit_status: None,
|
|
3058
3011
|
targets: Vec::new(),
|
|
3059
3012
|
window_options: Mutex::new(Vec::new()),
|
|
3060
3013
|
window_option_error: Mutex::new(Some(error.to_string())),
|
|
@@ -3186,6 +3139,10 @@ mod tests {
|
|
|
3186
3139
|
Ok(self.liveness)
|
|
3187
3140
|
}
|
|
3188
3141
|
|
|
3142
|
+
fn provider_exit_status(&self, _pane: &PaneId) -> Result<Option<u8>, TransportError> {
|
|
3143
|
+
Ok(self.exit_status)
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3189
3146
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
3190
3147
|
Ok(self.targets.clone())
|
|
3191
3148
|
}
|
|
@@ -3859,22 +3816,37 @@ mod tests {
|
|
|
3859
3816
|
}
|
|
3860
3817
|
|
|
3861
3818
|
#[test]
|
|
3862
|
-
fn
|
|
3819
|
+
fn managed_attach_teardown_requires_exit_receipt_not_replayed_markers() {
|
|
3863
3820
|
let spawned = managed_spawn_result();
|
|
3864
|
-
let transport = ScriptedTransport::new(vec![
|
|
3865
|
-
"[team-agent]
|
|
3821
|
+
let mut transport = ScriptedTransport::new(vec![
|
|
3822
|
+
"[team-agent] pi exited with 1\n[team-agent] Provider exited".to_string(),
|
|
3866
3823
|
]);
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3824
|
+
transport.liveness = PaneLiveness::Live;
|
|
3825
|
+
for exit_status in [None, Some(0), Some(17)] {
|
|
3826
|
+
transport.exit_status = exit_status;
|
|
3827
|
+
let watch = super::ManagedAttachWatch {
|
|
3828
|
+
transport: &transport,
|
|
3829
|
+
spawned: &spawned,
|
|
3830
|
+
session: &spawned.session,
|
|
3831
|
+
session_existed_before: false,
|
|
3832
|
+
workspace: Path::new("/tmp"),
|
|
3833
|
+
provider_label: "pi",
|
|
3834
|
+
};
|
|
3835
|
+
assert_eq!(
|
|
3836
|
+
super::managed_attach_should_teardown(&watch),
|
|
3837
|
+
exit_status.is_some()
|
|
3838
|
+
);
|
|
3839
|
+
}
|
|
3840
|
+
assert_eq!(
|
|
3841
|
+
transport.screens.lock().unwrap().len(),
|
|
3842
|
+
1,
|
|
3843
|
+
"health must not read history"
|
|
3844
|
+
);
|
|
3845
|
+
transport.liveness = PaneLiveness::Dead;
|
|
3846
|
+
transport.exit_status = None;
|
|
3847
|
+
assert_eq!(
|
|
3848
|
+
super::leader_provider_health(&transport, &spawned.pane_id, "pi"),
|
|
3849
|
+
super::LeaderProviderHealth::Unreachable
|
|
3878
3850
|
);
|
|
3879
3851
|
}
|
|
3880
3852
|
|
|
@@ -151,14 +151,30 @@ pub(crate) fn parse_pi_list_models_table(bytes: &[u8]) -> Result<Vec<String>, Pr
|
|
|
151
151
|
if line.trim().is_empty() {
|
|
152
152
|
continue;
|
|
153
153
|
}
|
|
154
|
-
let
|
|
155
|
-
|
|
156
|
-
return Err(ProviderError::Command(format!(
|
|
154
|
+
let malformed = || {
|
|
155
|
+
ProviderError::Command(format!(
|
|
157
156
|
"Pi model catalog row {} is malformed",
|
|
158
157
|
index + 2
|
|
159
|
-
))
|
|
158
|
+
))
|
|
159
|
+
};
|
|
160
|
+
let (provider, model) = line
|
|
161
|
+
.trim()
|
|
162
|
+
.split_once(char::is_whitespace)
|
|
163
|
+
.ok_or_else(malformed)?;
|
|
164
|
+
let mut model = model.trim();
|
|
165
|
+
// Pi prints the raw model id, which may contain spaces. Only the
|
|
166
|
+
// capability columns following it are single whitespace-free values.
|
|
167
|
+
for _ in 2..header_columns.len() {
|
|
168
|
+
model = model
|
|
169
|
+
.rsplit_once(char::is_whitespace)
|
|
170
|
+
.ok_or_else(malformed)?
|
|
171
|
+
.0
|
|
172
|
+
.trim_end();
|
|
173
|
+
}
|
|
174
|
+
if model.is_empty() {
|
|
175
|
+
return Err(malformed());
|
|
160
176
|
}
|
|
161
|
-
let exact = format!("{}/{}"
|
|
177
|
+
let exact = format!("{provider}/{model}");
|
|
162
178
|
if !seen.insert(exact.clone()) {
|
|
163
179
|
return Err(ProviderError::Command(format!(
|
|
164
180
|
"Pi model catalog contains duplicate exact id {exact:?}"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
provider model context max-out thinking images
|
|
2
|
+
cursor claude-opus-4-7@1m:fast 1M 16.4K yes yes
|
|
3
|
+
cursor claude-opus-4-7@1m:slow 1M 16.4K yes yes
|
|
4
|
+
openai-codex gpt-5.6-luna 272K 128K yes yes
|
|
5
|
+
team-agent Claude Opus 4.6 (Thinking) 200K 32.8K yes yes
|
|
6
|
+
team-agent Claude Sonnet 4.6 (Thinking) 200K 32.8K yes yes
|
|
7
|
+
team-agent Gemini 3.8 Flash 200K 32.8K yes yes
|
|
8
|
+
team-agent gpt-5.6-luna 200K 32.8K yes yes
|
|
9
|
+
team-agent qwen3.8-27b 262.1K 32.8K yes yes
|
|
@@ -8,6 +8,77 @@ use crate::model::enums::ProviderEffort;
|
|
|
8
8
|
use crate::provider::adapters::pi::{build_pi_command_argv, PiCommandRequest, PiSessionSelector};
|
|
9
9
|
|
|
10
10
|
const CATALOG: &[u8] = include_bytes!("fixtures/pi_list_models_g0.stdout.txt");
|
|
11
|
+
// Public pi --list-models rows captured for issue #186, preserving column widths.
|
|
12
|
+
const SPACED_IDS_CATALOG: &[u8] = include_bytes!("fixtures/pi_list_models_spaced_ids.stdout.txt");
|
|
13
|
+
|
|
14
|
+
#[test]
|
|
15
|
+
fn pi_catalog_preserves_spaced_ids_and_qualified_slug_variants() {
|
|
16
|
+
let models = parse_pi_list_models_table(SPACED_IDS_CATALOG).expect("public catalog rows");
|
|
17
|
+
assert_eq!(
|
|
18
|
+
models,
|
|
19
|
+
vec![
|
|
20
|
+
"cursor/claude-opus-4-7@1m:fast",
|
|
21
|
+
"cursor/claude-opus-4-7@1m:slow",
|
|
22
|
+
"openai-codex/gpt-5.6-luna",
|
|
23
|
+
"team-agent/Claude Opus 4.6 (Thinking)",
|
|
24
|
+
"team-agent/Claude Sonnet 4.6 (Thinking)",
|
|
25
|
+
"team-agent/Gemini 3.8 Flash",
|
|
26
|
+
"team-agent/gpt-5.6-luna",
|
|
27
|
+
"team-agent/qwen3.8-27b",
|
|
28
|
+
]
|
|
29
|
+
);
|
|
30
|
+
for exact in &models {
|
|
31
|
+
assert_eq!(select_exact_pi_model(&models, exact).unwrap(), *exact);
|
|
32
|
+
assert!(argv_has_pair(&command_for_model(exact), "--model", exact));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[test]
|
|
37
|
+
fn pi_catalog_still_rejects_duplicate_complete_spaced_ids() {
|
|
38
|
+
let text = std::str::from_utf8(SPACED_IDS_CATALOG).unwrap();
|
|
39
|
+
let repeated = text
|
|
40
|
+
.lines()
|
|
41
|
+
.find(|line| line.contains("Claude Opus 4.6 (Thinking)"))
|
|
42
|
+
.unwrap();
|
|
43
|
+
let duplicate = format!("{text}{repeated}\n");
|
|
44
|
+
let error = parse_pi_list_models_table(duplicate.as_bytes()).unwrap_err();
|
|
45
|
+
assert!(error
|
|
46
|
+
.to_string()
|
|
47
|
+
.contains("duplicate exact id \"team-agent/Claude Opus 4.6 (Thinking)\""));
|
|
48
|
+
let exact = "team-agent/Claude Opus 4.6 (Thinking)";
|
|
49
|
+
assert!(select_exact_pi_model(&[exact.into(), exact.into()], exact).is_err());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[test]
|
|
53
|
+
fn pi_catalog_preserves_internal_whitespace_with_compact_and_full_headers() {
|
|
54
|
+
for catalog in [
|
|
55
|
+
"provider model\r\nteam-agent\tClaude Opus 4.6 (Thinking) \r\n",
|
|
56
|
+
"provider model context max-out thinking images\r\nteam-agent\tClaude Opus 4.6 (Thinking)\t200K 32.8K\tyes yes \r\n",
|
|
57
|
+
] {
|
|
58
|
+
assert_eq!(
|
|
59
|
+
parse_pi_list_models_table(catalog.as_bytes()).unwrap(),
|
|
60
|
+
vec!["team-agent/Claude Opus 4.6 (Thinking)"]
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[test]
|
|
66
|
+
fn pi_catalog_rejects_rows_without_model_or_required_trailing_fields() {
|
|
67
|
+
for row in [
|
|
68
|
+
"team-agent",
|
|
69
|
+
"team-agent 200K 32.8K yes yes",
|
|
70
|
+
"team-agent gpt-5.6-luna 200K 32.8K yes",
|
|
71
|
+
] {
|
|
72
|
+
let catalog = format!("provider model context max-out thinking images\n{row}\n");
|
|
73
|
+
assert!(
|
|
74
|
+
parse_pi_list_models_table(catalog.as_bytes())
|
|
75
|
+
.unwrap_err()
|
|
76
|
+
.to_string()
|
|
77
|
+
.contains("row 2 is malformed"),
|
|
78
|
+
"missing model or capability fields must refuse: {row}"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
11
82
|
|
|
12
83
|
fn argv_has_pair(argv: &[String], flag: &str, value: &str) -> bool {
|
|
13
84
|
argv.windows(2)
|