@team-agent/installer 0.5.78 → 0.5.81

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.
Files changed (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/models.rs +31 -0
  4. package/crates/team-agent/src/cli/status_port/brief.rs +40 -16
  5. package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
  6. package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
  7. package/crates/team-agent/src/cli/tests/status_brief.rs +199 -0
  8. package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
  9. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  10. package/crates/team-agent/src/coordinator/tests/s3_bounded.rs +239 -0
  11. package/crates/team-agent/src/coordinator/tick.rs +21 -13
  12. package/crates/team-agent/src/leader/start.rs +46 -74
  13. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +21 -5
  14. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
  15. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
  16. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +2 -11
  17. package/crates/team-agent/src/mcp_server/tools.rs +1 -3
  18. package/crates/team-agent/src/messaging/delivery.rs +154 -72
  19. package/crates/team-agent/src/messaging/results.rs +1 -2
  20. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
  21. package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
  22. package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
  23. package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
  24. package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
  25. package/crates/team-agent/src/state/mod.rs +1 -0
  26. package/crates/team-agent/src/state/persist.rs +47 -5
  27. package/crates/team-agent/src/state/projection.rs +31 -1
  28. package/crates/team-agent/src/state/repository/tests.rs +81 -0
  29. package/crates/team-agent/src/state/repository.rs +169 -1
  30. package/crates/team-agent/src/tmux_backend/tests.rs +28 -0
  31. package/crates/team-agent/src/tmux_backend.rs +31 -9
  32. package/crates/team-agent/src/transport.rs +6 -0
  33. package/package.json +4 -4
@@ -0,0 +1,239 @@
1
+ use super::*;
2
+ use serde_json::json;
3
+ use std::io::{BufRead, Write};
4
+ use std::path::{Path, PathBuf};
5
+ use std::process::{Child, Command, Stdio};
6
+ use crate::state::repository::{StateRepository, StateWriteIntent};
7
+
8
+ const WORKER: &str = "coordinator::tests::s3_bounded::s3_process_worker";
9
+
10
+ fn isolated(name: &str, check: impl FnOnce(&Path)) {
11
+ if std::env::var("TA_S3_CASE").as_deref() == Ok(name) {
12
+ check(&std::env::current_dir().unwrap());
13
+ return;
14
+ }
15
+ let dir = std::env::temp_dir().join(format!("ta-s3-{name}-{}", std::process::id()));
16
+ std::fs::create_dir_all(&dir).unwrap();
17
+ let output = Command::new(std::env::current_exe().unwrap())
18
+ .args(["--exact", &format!("coordinator::tests::s3_bounded::{name}"), "--nocapture"])
19
+ .env_clear().env("HOME", dir.join("home")).env("TA_S3_CASE", name)
20
+ .current_dir(&dir).output().unwrap();
21
+ print!("{}", String::from_utf8_lossy(&output.stdout));
22
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
23
+ std::fs::remove_dir_all(dir).unwrap();
24
+ }
25
+
26
+ fn disk(dir: &Path) -> Value {
27
+ serde_json::from_str(&std::fs::read_to_string(crate::state::persist::runtime_state_path(dir)).unwrap()).unwrap()
28
+ }
29
+
30
+ fn seed(dir: &Path, legacy: bool) {
31
+ std::fs::create_dir_all(dir).unwrap();
32
+ let team = |key: &str| {
33
+ let spec = dir.join(".team/runtime").join(key).join("team.spec.yaml");
34
+ std::fs::create_dir_all(spec.parent().unwrap()).unwrap();
35
+ std::fs::write(&spec, format!("name: {key}\nagents: []\ntasks: []\n")).unwrap();
36
+ json!({
37
+ "session_name": key, "team_dir": dir.join(".team").join(key), "spec_path": spec,
38
+ "agents": {"w1": {"agent_id": "w1", "provider": "fake", "status": "running",
39
+ "window": "w1", "pane_id": "%11", "spawn_epoch": 1,
40
+ "session_id": "fixture-session", "startup_prompts": "handled"}},
41
+ "tasks": [{"id": "original", "title": "Original task", "assignee": "w1", "status": "pending"}],
42
+ "notes": ["original note"]
43
+ })
44
+ };
45
+ let mut state = team("T1");
46
+ state["active_team_key"] = json!("T1");
47
+ if !legacy { state["teams"] = json!({"T1": state.clone(), "T2": team("T2")}); }
48
+ crate::state::persist::save_runtime_state(dir, &state).unwrap();
49
+ MessageStore::open(dir).unwrap();
50
+ std::fs::write(dir.join("result.json"), json!({
51
+ "schema_version": "result_envelope_v1", "result_id": "result-original",
52
+ "task_id": "original", "agent_id": "w1", "status": "success", "summary": "done",
53
+ "artifacts": [], "changes": [], "tests": [], "risks": [], "next_actions": []
54
+ }).to_string()).unwrap();
55
+ }
56
+
57
+ fn task(id: &str) -> Value { json!({"id": id, "title": id, "assignee": "w1"}) }
58
+
59
+ fn start_writer(dir: &Path, action: &str, team: Option<&str>, payload: Value, pause: bool) -> Child {
60
+ let mut child = Command::new(std::env::current_exe().unwrap())
61
+ .args(["--exact", WORKER, "--ignored", "--nocapture"])
62
+ .env_clear().env("HOME", dir.join("home"))
63
+ .env("TA_S3_ACTION", action).env("TA_S3_TEAM", team.unwrap_or(""))
64
+ .env("TA_S3_PAYLOAD", payload.to_string()).env("TA_S3_PAUSE", if pause { "1" } else { "0" })
65
+ .current_dir(dir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped())
66
+ .spawn().unwrap();
67
+ if pause {
68
+ let mut reader = std::io::BufReader::new(child.stdout.take().unwrap());
69
+ loop {
70
+ let mut line = String::new();
71
+ assert!(reader.read_line(&mut line).unwrap() > 0, "writer exited before barrier");
72
+ print!("{line}");
73
+ if line.contains("S3_READY") { break; }
74
+ }
75
+ child.stdout = Some(reader.into_inner());
76
+ }
77
+ child
78
+ }
79
+
80
+ fn finish(mut child: Child, paused: bool) {
81
+ if paused { child.stdin.take().unwrap().write_all(b"commit\n").unwrap(); }
82
+ let output = child.wait_with_output().unwrap();
83
+ print!("{}", String::from_utf8_lossy(&output.stdout));
84
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
85
+ }
86
+
87
+ fn write(dir: &Path, action: &str, team: Option<&str>, payload: Value) {
88
+ finish(start_writer(dir, action, team, payload, false), false);
89
+ }
90
+
91
+ #[test]
92
+ #[ignore = "hermetic subprocess entry; parent contracts invoke this exact test"]
93
+ fn s3_process_worker() {
94
+ let dir = std::env::current_dir().unwrap();
95
+ let action = std::env::var("TA_S3_ACTION").unwrap();
96
+ let team = std::env::var("TA_S3_TEAM").unwrap();
97
+ let team = (!team.is_empty()).then_some(team.as_str());
98
+ let payload: Value = serde_json::from_str(&std::env::var("TA_S3_PAYLOAD").unwrap()).unwrap();
99
+ if std::env::var("TA_S3_PAUSE").as_deref() == Ok("1") {
100
+ crate::state::repository::test_support::set_before_commit(|| {
101
+ println!("S3_READY");
102
+ std::io::stdout().flush().unwrap();
103
+ let mut line = String::new();
104
+ std::io::stdin().read_line(&mut line).unwrap();
105
+ assert_eq!(line.trim(), "commit");
106
+ });
107
+ }
108
+ let tools = crate::mcp_server::TeamOrchestratorTools::with_identity(
109
+ &dir, Some(crate::model::ids::AgentId::new("leader")), team.map(crate::model::ids::TeamKey::new),
110
+ );
111
+ match action.as_str() {
112
+ "assign" => { tools.assign_task(&payload, Some("bounded contract")).unwrap(); }
113
+ "note" => { println!("S3 note={:?}", tools.update_state(payload.as_str().unwrap()).unwrap()); }
114
+ "collect" => {
115
+ let output = crate::messaging::results::collect_for_team(&dir, Some(&dir.join("result.json")), false, team).unwrap();
116
+ assert_eq!(output["ok"], true);
117
+ println!("S3 collect={output}");
118
+ }
119
+ "sibling" => {
120
+ let mut state = StateRepository::new(&dir).load_workspace().unwrap();
121
+ state["teams"]["T3"] = json!({"session_name": "T3", "tasks": [task("third")], "notes": ["third"]});
122
+ StateRepository::new(&dir).save(StateWriteIntent::LaunchTeam { team_key: "T3" }, &state).unwrap();
123
+ }
124
+ "topology" => {
125
+ let mut state = StateRepository::new(&dir).load_team(Some("T1")).unwrap();
126
+ state["agents"]["w1"]["pane_id"] = json!("%22");
127
+ state["agents"]["w1"]["spawn_epoch"] = json!(2);
128
+ // A concurrent restart owns topology for this agent. StartAgent's
129
+ // add-only tail intentionally carries no existing-agent authority.
130
+ StateRepository::new(&dir).save(StateWriteIntent::RestartTeam {
131
+ team_key: "T1", topology_authority_agent_ids: &["w1"],
132
+ skip_capture_backfill_agent_ids: &[],
133
+ }, &state).unwrap();
134
+ assert_eq!(disk(&dir)["teams"]["T1"]["agents"]["w1"]["pane_id"], "%22");
135
+ }
136
+ _ => panic!("unexpected test action"),
137
+ }
138
+ println!("S3_COMMITTED {action}");
139
+ }
140
+
141
+ fn coordinator(dir: &Path, hook: SaveHook) -> Coordinator {
142
+ let transport = OfflineTransport::new().with_session_present(true)
143
+ .with_windows(vec![WindowName::new("live-peer")]);
144
+ Coordinator::for_test(WorkspacePath::new(dir.to_path_buf()),
145
+ Box::new(MockRegistry::new(&[], &[])), Box::new(transport), None, None)
146
+ .with_before_save_hook(hook)
147
+ }
148
+
149
+ #[test]
150
+ fn s3_tick_preserves_tasks_notes_and_siblings() {
151
+ isolated("s3_tick_preserves_tasks_notes_and_siblings", |dir| {
152
+ seed(dir, false);
153
+ let hook: SaveHook = Box::new(|ws, observed| {
154
+ assert_eq!(observed["agents"]["w1"]["worker_state"], "DEAD", "tick must produce a real observation delta");
155
+ write(ws.as_path(), "assign", Some("T1"), task("new-task"));
156
+ write(ws.as_path(), "note", Some("T1"), json!("concurrent note"));
157
+ write(ws.as_path(), "collect", Some("T1"), Value::Null);
158
+ write(ws.as_path(), "note", Some("T2"), json!("sibling note"));
159
+ write(ws.as_path(), "sibling", None, Value::Null);
160
+ let latest = disk(ws.as_path());
161
+ assert_eq!(latest["teams"]["T1"]["agents"]["w1"]["pane_id"], observed["agents"]["w1"]["pane_id"]);
162
+ println!("S3 before tick save topology unchanged; committed business={}", latest["teams"]);
163
+ Ok(())
164
+ });
165
+ let report = coordinator(dir, hook).tick().unwrap();
166
+ assert!(report.ok, "tick must save its observation: {report:?}");
167
+ let state = disk(dir);
168
+ let team = &state["teams"]["T1"];
169
+ assert_eq!(team["tasks"][0]["accepted_result_id"], "result-original");
170
+ assert!(team["tasks"].as_array().unwrap().iter().any(|row| row["id"] == "new-task"));
171
+ assert_eq!(team["notes"], json!(["original note", "concurrent note"]));
172
+ assert_eq!(team["agents"]["w1"]["worker_state"], "DEAD");
173
+ assert_eq!(state["teams"]["T2"]["notes"], json!(["original note", "sibling note"]));
174
+ assert_eq!(state["teams"]["T3"]["notes"], json!(["third"]));
175
+ assert_eq!(state["tasks"], team["tasks"]);
176
+ assert_eq!(StateRepository::new(dir).load_team(Some("T1")).unwrap()["tasks"], team["tasks"]);
177
+ println!("S3 after tick save={state}");
178
+ });
179
+ }
180
+
181
+ #[test]
182
+ fn s3_business_writers_commit_once_on_latest() {
183
+ isolated("s3_business_writers_commit_once_on_latest", |root| {
184
+ for (i, (left, right)) in [
185
+ (("assign", task("left")), ("assign", task("right"))),
186
+ (("note", json!("same note")), ("note", json!("same note"))),
187
+ (("note", json!("same note")), ("assign", task("right"))),
188
+ (("collect", Value::Null), ("assign", task("right"))),
189
+ (("collect", Value::Null), ("note", json!("same note"))),
190
+ ].into_iter().enumerate() {
191
+ let dir = root.join(i.to_string());
192
+ seed(&dir, false);
193
+ let waiting = start_writer(&dir, left.0, Some("T1"), left.1, true);
194
+ write(&dir, right.0, Some("T1"), right.1);
195
+ finish(waiting, true);
196
+ let state = disk(&dir);
197
+ let team = &state["teams"]["T1"];
198
+ if left.0 == "assign" { assert!(team["tasks"].as_array().unwrap().iter().any(|task| task["id"] == "left")); }
199
+ if right.0 == "assign" { assert!(team["tasks"].as_array().unwrap().iter().any(|task| task["id"] == "right")); }
200
+ if left.0 == "collect" { assert_eq!(team["tasks"][0]["accepted_result_id"], "result-original"); }
201
+ let notes = usize::from(left.0 == "note") + usize::from(right.0 == "note");
202
+ assert_eq!(team["notes"].as_array().unwrap().len(), 1 + notes);
203
+ if left.0 == "note" && right.0 == "assign" {
204
+ let markdown = std::fs::read_to_string(dir.join(".team/runtime/T1/team_state.md")).unwrap();
205
+ assert!(markdown.contains("right"), "note must render the committed task view: {markdown}");
206
+ }
207
+ if left.0 == "collect" {
208
+ write(&dir, "assign", Some("T1"), json!({"id": "original", "assignee": "w1", "title": "edited"}));
209
+ assert_eq!(disk(&dir)["teams"]["T1"]["tasks"][0]["accepted_result_id"], "result-original");
210
+ }
211
+ println!("S3 pair {i} canonical={team}");
212
+ }
213
+ });
214
+ }
215
+
216
+ #[test]
217
+ fn s3_legacy_note_and_collection_keep_independent_updates() {
218
+ isolated("s3_legacy_note_and_collection_keep_independent_updates", |dir| {
219
+ seed(dir, true);
220
+ let waiting = start_writer(dir, "collect", None, Value::Null, true);
221
+ write(dir, "note", None, json!("legacy note"));
222
+ finish(waiting, true);
223
+ let state = disk(dir);
224
+ assert_eq!(state["tasks"][0]["accepted_result_id"], "result-original");
225
+ assert_eq!(state["notes"], json!(["original note", "legacy note"]));
226
+ assert!(state.get("teams").is_none(), "legacy single Team shape remains valid");
227
+ });
228
+ }
229
+
230
+ #[test]
231
+ fn s3_observation_keeps_live_topology_conflict() {
232
+ isolated("s3_observation_keeps_live_topology_conflict", |dir| {
233
+ seed(dir, false);
234
+ let hook: SaveHook = Box::new(|ws, _| { write(ws.as_path(), "topology", Some("T1"), Value::Null); Ok(()) });
235
+ let report = coordinator(dir, hook).tick().unwrap();
236
+ assert_eq!(report.reason, Some(TickStopReason::PersistenceDegraded));
237
+ assert_eq!(disk(dir)["teams"]["T1"]["agents"]["w1"]["pane_id"], "%22");
238
+ });
239
+ }
@@ -193,6 +193,8 @@ pub struct Coordinator {
193
193
  /// bug-084 save 注入钩。`None` ⇔ 真实 `state::save_runtime_state`。
194
194
  #[allow(dead_code)]
195
195
  save_hook: Option<SaveHook>,
196
+ #[cfg(test)]
197
+ before_save_hook: Option<SaveHook>,
196
198
  /// tick 副作用 ORDER 探针。`None` ⇔ 不记录(生产)。
197
199
  #[allow(dead_code)]
198
200
  order_recorder: Option<OrderRecorder>,
@@ -219,6 +221,8 @@ impl Coordinator {
219
221
  transport,
220
222
  daemon_team_key: None,
221
223
  save_hook: None,
224
+ #[cfg(test)]
225
+ before_save_hook: None,
222
226
  order_recorder: None,
223
227
  }
224
228
  }
@@ -263,10 +267,18 @@ impl Coordinator {
263
267
  transport,
264
268
  daemon_team_key: None,
265
269
  save_hook,
270
+ before_save_hook: None,
266
271
  order_recorder,
267
272
  }
268
273
  }
269
274
 
275
+ /// A test barrier before the real repository save; never replaces the save.
276
+ #[cfg(test)]
277
+ pub(crate) fn with_before_save_hook(mut self, hook: SaveHook) -> Self {
278
+ self.before_save_hook = Some(hook);
279
+ self
280
+ }
281
+
270
282
  // ── tick 编排(lifecycle.py:250-385)──────────────────────────────────────
271
283
 
272
284
  /// 单次 tick(`coordinator_tick`,`lifecycle.py:250`)。固定顺序串 step 8-11 原子:
@@ -308,6 +320,7 @@ impl Coordinator {
308
320
  self.daemon_team_key.as_deref(),
309
321
  )
310
322
  .unwrap_or(raw_state);
323
+ let before_observation = state.clone();
311
324
  let store = crate::message_store::MessageStore::open(self.workspace.as_path())?;
312
325
  let event_log = EventLog::new(self.workspace.as_path());
313
326
  increment_coordinator_tick_iteration_count(&self.workspace);
@@ -487,26 +500,21 @@ impl Coordinator {
487
500
  };
488
501
 
489
502
  self.record_step(TickStepGroup::Persist, "atomic_save");
503
+ #[cfg(test)]
504
+ if let Some(hook) = &self.before_save_hook {
505
+ hook(&self.workspace, &state)?;
506
+ }
490
507
  let saved = match &self.save_hook {
491
508
  Some(hook) => hook(&self.workspace, &state),
492
509
  None => {
493
- // 0.5.42 S1b (s1b-writer-cluster-locate.md §3.2 /
494
- // §4.4): the daemon tick's single terminal save
495
- // routes through `StateRepository` with the
496
- // `CoordinatorTick` intent. Repository dispatch is
497
- // byte-identical to the old `save_team_scoped_state`
498
- // helper (see `state/repository.rs:CoordinatorTick =>
499
- // helper_write_team_scoped`), so persist/merge/lock/
500
- // atomic-rename semantics and degraded-mapping stay
501
- // unchanged. No cached repository, no extra load, no
502
- // retry — the caller still owns the `Value` and
503
- // failure still returns `TickReport{PersistenceDegraded}`
504
- // via the `saved.is_err()` branch below.
510
+ // Keep the sampled topology guard, but commit only this tick's
511
+ // observations onto latest so task/note writers cannot be lost.
505
512
  let team_key = crate::state::projection::team_state_key(&state);
506
- crate::state::repository::StateRepository::new(self.workspace.as_path()).save(
513
+ crate::state::repository::StateRepository::new(self.workspace.as_path()).commit_observations(
507
514
  crate::state::repository::StateWriteIntent::CoordinatorTick {
508
515
  team_key: team_key.as_str(),
509
516
  },
517
+ &before_observation,
510
518
  &state,
511
519
  )
512
520
  }
@@ -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
- /// 0.4.x (CR C-3 P0): leader provider health reconciliation. The default
1678
- /// `liveness()` check only proves the pane is ADDRESSABLE via tmux — it
1679
- /// returns `Live` even when the provider has exited and the wrapper shell
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
- expected_provider_label: &str,
1676
+ _expected_provider_label: &str,
1702
1677
  ) -> LeaderProviderHealth {
1703
- use crate::transport::{CaptureRange, PaneField, Target};
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
- let target = Target::Pane(pane_id.clone());
1709
- let current_command = transport
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
- // Current command is NOT the provider — likely fell back to shell.
1722
- let is_shell = is_interactive_shell_basename(&current_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 managed_attach_teardown_detects_provider_exit_marker() {
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] claude exited with 1\n[team-agent] Provider exited; this pane no longer accepts input.".to_string(),
3821
+ let mut transport = ScriptedTransport::new(vec![
3822
+ "[team-agent] pi exited with 1\n[team-agent] Provider exited".to_string(),
3866
3823
  ]);
3867
- let watch = super::ManagedAttachWatch {
3868
- transport: &transport,
3869
- spawned: &spawned,
3870
- session: &spawned.session,
3871
- session_existed_before: false,
3872
- workspace: Path::new("/tmp"),
3873
- provider_label: "claude",
3874
- };
3875
- assert!(
3876
- super::managed_attach_should_teardown(&watch),
3877
- "exit marker in capture must trigger teardown"
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 columns = line.split_whitespace().collect::<Vec<_>>();
155
- if columns.len() < 2 || columns[0].is_empty() || columns[1].is_empty() {
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!("{}/{}", columns[0], columns[1]);
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)