@team-agent/installer 0.4.11 → 0.5.0

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 (72) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +62 -4
  6. package/crates/team-agent/src/compiler.rs +11 -20
  7. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  8. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  9. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  10. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  11. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  12. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  13. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  14. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  15. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  16. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  17. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  18. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  19. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  20. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  21. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  22. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  23. package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
  24. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  26. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  27. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  28. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
  29. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  30. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  31. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
  32. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  33. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +648 -0
  34. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  35. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  36. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  37. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  38. package/crates/team-agent/src/messaging/activity.rs +43 -18
  39. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  40. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  41. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  42. package/crates/team-agent/src/messaging/results.rs +34 -9
  43. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  44. package/crates/team-agent/src/model/spec.rs +10 -6
  45. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  46. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  47. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  48. package/crates/team-agent/src/provider/classify.rs +23 -48
  49. package/crates/team-agent/src/provider/command.rs +16 -7
  50. package/crates/team-agent/src/provider/faults.rs +93 -53
  51. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  52. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  53. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  54. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  55. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  56. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  57. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  58. package/crates/team-agent/src/provider/types.rs +33 -1
  59. package/crates/team-agent/src/provider/wire.rs +171 -1
  60. package/crates/team-agent/src/state/identity.rs +242 -57
  61. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  62. package/crates/team-agent/src/state/mod.rs +5 -1
  63. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  64. package/crates/team-agent/src/state/ownership.rs +12 -11
  65. package/crates/team-agent/src/state/paths.rs +13 -6
  66. package/crates/team-agent/src/state/persist.rs +671 -128
  67. package/crates/team-agent/src/state/projection.rs +240 -49
  68. package/crates/team-agent/src/state/selector.rs +11 -5
  69. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  70. package/crates/team-agent/src/tmux_backend.rs +42 -5
  71. package/crates/team-agent/src/transport/test_support.rs +55 -6
  72. package/package.json +4 -4
@@ -107,10 +107,7 @@ pub enum TickError {
107
107
  /// returns `None` and the tick falls back to the raw state — preserving
108
108
  /// behavior for legacy single-team workspaces and tests that don't seed
109
109
  /// `teams.<key>`. Sibling teams under `state.teams.*` are NOT touched.
110
- fn coordinator_team_scoped_state(
111
- workspace: &std::path::Path,
112
- raw_state: &Value,
113
- ) -> Option<Value> {
110
+ fn coordinator_team_scoped_state(workspace: &std::path::Path, raw_state: &Value) -> Option<Value> {
114
111
  let active = raw_state
115
112
  .get("active_team_key")
116
113
  .and_then(Value::as_str)
@@ -334,7 +331,13 @@ impl Coordinator {
334
331
  BTreeMap::new()
335
332
  }
336
333
  };
337
- if let Err(error) = self.detect_abnormal_exits(&mut state, &event_log, &pane_snapshot) {
334
+ if let Err(error) = crate::coordinator::steps::abnormal::detect_abnormal_exits(
335
+ self.workspace.as_path(),
336
+ self.transport.as_ref(),
337
+ &mut state,
338
+ &event_log,
339
+ &pane_snapshot,
340
+ ) {
338
341
  let _ = event_log.write(
339
342
  "coordinator.tick.detect_abnormal_failed",
340
343
  serde_json::json!({"error": error.to_string()}),
@@ -481,9 +484,7 @@ impl Coordinator {
481
484
  // can replay durable session truth even if state was lost. Architect
482
485
  // §4 fix #5.
483
486
  for agent_id in &report.assigned {
484
- let agent = state
485
- .get("agents")
486
- .and_then(|a| a.get(agent_id.as_str()));
487
+ let agent = state.get("agents").and_then(|a| a.get(agent_id.as_str()));
487
488
  if let Some(agent) = agent {
488
489
  event_log.write(
489
490
  "session.captured",
@@ -515,11 +516,13 @@ impl Coordinator {
515
516
  let agent = state
516
517
  .get("agents")
517
518
  .and_then(|a| a.get(missing.agent_id.as_str()));
518
- let pane_id = agent
519
- .and_then(|a| a.get("pane_id"))
519
+ let pane_id = agent.and_then(|a| a.get("pane_id")).and_then(Value::as_str);
520
+ let pane_pid = agent
521
+ .and_then(|a| a.get("pane_pid"))
522
+ .and_then(Value::as_u64);
523
+ let provider = agent
524
+ .and_then(|a| a.get("provider"))
520
525
  .and_then(Value::as_str);
521
- let pane_pid = agent.and_then(|a| a.get("pane_pid")).and_then(Value::as_u64);
522
- let provider = agent.and_then(|a| a.get("provider")).and_then(Value::as_str);
523
526
  let session_id_in_argv = agent
524
527
  .and_then(|a| a.get("session_id_in_argv"))
525
528
  .and_then(Value::as_str);
@@ -802,7 +805,8 @@ impl Coordinator {
802
805
  .map(str::to_string),
803
806
  last_output_at,
804
807
  rollout_path,
805
- process_liveness: explicit_process_liveness(agent),
808
+ process_liveness:
809
+ crate::coordinator::steps::abnormal::explicit_process_liveness(agent),
806
810
  },
807
811
  );
808
812
  }
@@ -845,182 +849,6 @@ impl Coordinator {
845
849
  })
846
850
  }
847
851
 
848
- /// #236 `worker.abnormal_exit` watcher.
849
- ///
850
- /// Notify only when both signals are true: the provider process is dead AND the
851
- /// latest transcript/rollout JSONL record is an explicit provider error. Dead-only
852
- /// and error-only observations are written as check/suppressed audit events with
853
- /// `notification=false`; they never call the N32 leader funnel. This path is
854
- /// intentionally separate from the generic transcript-only abnormal fact track.
855
- fn detect_abnormal_exits(
856
- &self,
857
- state: &mut Value,
858
- event_log: &EventLog,
859
- targets: &[crate::transport::PaneInfo],
860
- ) -> Result<(), TickError> {
861
- let snapshot = state.clone();
862
- let team = crate::state::projection::team_state_key(&snapshot);
863
- let session_name = snapshot.get("session_name").and_then(Value::as_str);
864
- for agent in abnormal_watch_agents(&snapshot) {
865
- let rollout_path =
866
- resolve_agent_rollout_path(self.workspace.as_path(), &agent.rollout_path);
867
- let metadata = match std::fs::metadata(&rollout_path) {
868
- Ok(metadata) => metadata,
869
- Err(error) => {
870
- upsert_abnormal_watch(
871
- state,
872
- &agent.agent_id,
873
- abnormal_watch_payload(
874
- &agent,
875
- None,
876
- None,
877
- "unverifiable",
878
- None,
879
- Some(error.to_string()),
880
- ),
881
- );
882
- continue;
883
- }
884
- };
885
- let size = metadata.len();
886
- let mtime_ns = metadata_mtime_ns(&metadata);
887
- // P1 (C-P1-2/3): (size, mtime_ns) pair gate — an unchanged transcript is not
888
- // read at all (live sample: 332MB whole-file read per agent per 2s tick).
889
- // ANY field change (including a size shrink / truncate) falls through to the
890
- // re-read below.
891
- if let (Some(mtime), Some(stored)) = (
892
- mtime_ns,
893
- abnormal_watch_stored_metadata(&snapshot, &agent.agent_id),
894
- ) {
895
- if stored == (size, mtime) {
896
- continue;
897
- }
898
- }
899
- // P1 (C-P1-1): bounded tail read — the abnormal decision only consumes the
900
- // LATEST transcript record; window matches Python `_TAIL_BYTES` (131072,
901
- // idle_takeover_wiring.py:13), never less.
902
- let text = match read_tail_text(&rollout_path, ABNORMAL_TAIL_BYTES) {
903
- Ok(text) => text,
904
- Err(error) => {
905
- upsert_abnormal_watch(
906
- state,
907
- &agent.agent_id,
908
- abnormal_watch_payload(
909
- &agent,
910
- Some(size),
911
- mtime_ns,
912
- "unverifiable",
913
- None,
914
- Some(error.to_string()),
915
- ),
916
- );
917
- continue;
918
- }
919
- };
920
- let liveness =
921
- agent_process_liveness(&agent, session_name, targets, self.transport.as_ref());
922
- let fact = crate::provider::latest_explicit_error_fact(agent.provider, &text);
923
- let decision = abnormal_exit_decision(liveness.state, fact.as_ref());
924
- let check_key = abnormal_check_key(&agent, &liveness, fact.as_ref(), size);
925
- upsert_abnormal_watch(
926
- state,
927
- &agent.agent_id,
928
- abnormal_watch_payload(
929
- &agent,
930
- Some(size),
931
- mtime_ns,
932
- process_liveness_wire(liveness.state),
933
- fact.as_ref().map(|f| f.signature.as_str()),
934
- None,
935
- ),
936
- );
937
- if abnormal_last_check_key(state, &agent.agent_id).as_deref()
938
- != Some(check_key.as_str())
939
- {
940
- write_abnormal_check(
941
- event_log,
942
- &team,
943
- &agent,
944
- &liveness,
945
- fact.as_ref(),
946
- decision,
947
- size,
948
- mtime_ns,
949
- )?;
950
- mark_abnormal_checked(state, &agent.agent_id, &check_key);
951
- }
952
- let fact = match (decision, fact) {
953
- (AbnormalExitDecision::Notify, Some(fact)) => fact,
954
- (AbnormalExitDecision::Suppress(reason), _) => {
955
- let suppress_key = abnormal_suppression_key(&agent, &liveness, reason, size);
956
- if abnormal_last_suppressed_key(state, &agent.agent_id).as_deref()
957
- != Some(suppress_key.as_str())
958
- {
959
- write_abnormal_suppressed(event_log, &team, &agent, &liveness, reason)?;
960
- mark_abnormal_suppressed(state, &agent.agent_id, &suppress_key);
961
- }
962
- continue;
963
- }
964
- (AbnormalExitDecision::NoSignal, _) => continue,
965
- (AbnormalExitDecision::Notify, None) => continue,
966
- };
967
- let dedupe_key = abnormal_dedupe_key(&agent, &fact, size);
968
- if abnormal_last_notified_key(state, &agent.agent_id).as_deref()
969
- == Some(dedupe_key.as_str())
970
- {
971
- continue;
972
- }
973
- let content = format_abnormal_exit_message(&team, &agent, &fact, &liveness, size);
974
- let outcome = crate::messaging::send_to_leader_receiver(
975
- self.workspace.as_path(),
976
- state,
977
- "leader",
978
- &content,
979
- None,
980
- &agent.agent_id,
981
- false,
982
- Some(&dedupe_key),
983
- event_log,
984
- )?;
985
- let notification_status = if outcome.ok {
986
- "queued"
987
- } else if matches!(outcome.status, crate::messaging::DeliveryStatus::Blocked) {
988
- "rebind_required"
989
- } else {
990
- "refused"
991
- };
992
- event_log.write(
993
- "worker.abnormal_exit",
994
- serde_json::json!({
995
- "team_id": team.as_str(),
996
- "agent_id": agent.agent_id.as_str(),
997
- "provider": provider_wire(agent.provider),
998
- "path": agent.rollout_path_display.as_str(),
999
- "dead_process": true,
1000
- "process_dead": true,
1001
- "provider_process_dead": true,
1002
- "latest_error": true,
1003
- "latest_explicit_error": true,
1004
- "dead_process_and_latest_error": true,
1005
- "dead_process_and_latest_explicit_error": true,
1006
- "process_dead_and_latest_explicit_error": true,
1007
- "provider_process_dead_and_latest_explicit_error": true,
1008
- "signature": fact.signature.as_str(),
1009
- "turn_id": fact.turn_id.as_ref().map(|id| id.as_str()),
1010
- "size": size,
1011
- "mtime_ns": mtime_ns,
1012
- "process_liveness": process_liveness_wire(liveness.state),
1013
- "pid_status": liveness.detail.as_str(),
1014
- "notification_message_id": outcome.message_id,
1015
- "notification_status": notification_status,
1016
- "notification_channel": outcome.channel,
1017
- }),
1018
- )?;
1019
- mark_abnormal_notified(state, &agent.agent_id, &dedupe_key);
1020
- }
1021
- Ok(())
1022
- }
1023
-
1024
852
  fn handle_startup_prompts(
1025
853
  &self,
1026
854
  state: &mut Value,
@@ -1656,743 +1484,6 @@ fn turn_state_wire(state: TurnState) -> &'static str {
1656
1484
  }
1657
1485
  }
1658
1486
 
1659
- use crate::provider::wire::provider_wire;
1660
-
1661
- #[derive(Debug, Clone)]
1662
- struct AbnormalWatchAgent {
1663
- agent_id: String,
1664
- provider: crate::model::enums::Provider,
1665
- rollout_path: PathBuf,
1666
- rollout_path_display: String,
1667
- status: Option<String>,
1668
- process_liveness: Option<ProcessLiveness>,
1669
- window: Option<String>,
1670
- pane_id: Option<String>,
1671
- pid: Option<Pid>,
1672
- current_command: Option<String>,
1673
- }
1674
-
1675
- #[derive(Debug, Clone, PartialEq, Eq)]
1676
- struct ProcessCheck {
1677
- state: ProcessLiveness,
1678
- detail: String,
1679
- }
1680
-
1681
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1682
- enum AbnormalExitDecision {
1683
- Notify,
1684
- Suppress(&'static str),
1685
- NoSignal,
1686
- }
1687
-
1688
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1689
- struct AbnormalExitGate {
1690
- provider_process_dead: bool,
1691
- latest_explicit_error: bool,
1692
- }
1693
-
1694
- impl AbnormalExitGate {
1695
- fn new(process_liveness: ProcessLiveness, latest_explicit_error: bool) -> Self {
1696
- Self {
1697
- provider_process_dead: process_liveness == ProcessLiveness::Dead,
1698
- latest_explicit_error,
1699
- }
1700
- }
1701
-
1702
- fn should_notify_worker_abnormal_exit(self) -> bool {
1703
- should_notify_worker_abnormal_exit(self.provider_process_dead, self.latest_explicit_error)
1704
- }
1705
-
1706
- fn suppressed_reason(self) -> Option<&'static str> {
1707
- match (self.provider_process_dead, self.latest_explicit_error) {
1708
- (true, false) => Some("dead_only"),
1709
- (false, true) => Some("error_only"),
1710
- _ => None,
1711
- }
1712
- }
1713
- }
1714
-
1715
- fn abnormal_exit_decision(
1716
- process_liveness: ProcessLiveness,
1717
- latest_explicit_error: Option<&crate::provider::FaultFact>,
1718
- ) -> AbnormalExitDecision {
1719
- let gate = AbnormalExitGate::new(process_liveness, latest_explicit_error.is_some());
1720
- if gate.should_notify_worker_abnormal_exit() {
1721
- return AbnormalExitDecision::Notify;
1722
- }
1723
- match gate.suppressed_reason() {
1724
- Some(reason) => AbnormalExitDecision::Suppress(reason),
1725
- None => AbnormalExitDecision::NoSignal,
1726
- }
1727
- }
1728
-
1729
- fn should_notify_worker_abnormal_exit(
1730
- provider_process_dead: bool,
1731
- latest_explicit_error: bool,
1732
- ) -> bool {
1733
- provider_process_dead && latest_explicit_error
1734
- }
1735
-
1736
- fn resolve_agent_rollout_path(workspace: &Path, path: &Path) -> PathBuf {
1737
- if path.is_absolute() {
1738
- path.to_path_buf()
1739
- } else {
1740
- workspace.join(path)
1741
- }
1742
- }
1743
-
1744
- fn abnormal_watch_agents(state: &Value) -> Vec<AbnormalWatchAgent> {
1745
- let Some(agents) = state.get("agents").and_then(Value::as_object) else {
1746
- return Vec::new();
1747
- };
1748
- agents
1749
- .iter()
1750
- .filter_map(|(agent_id, agent)| {
1751
- if matches!(agent.get("status").and_then(Value::as_str), Some("paused")) {
1752
- return None;
1753
- }
1754
- let provider = agent
1755
- .get("provider")
1756
- .and_then(Value::as_str)
1757
- .and_then(parse_provider)?;
1758
- let rollout_path_display = ["rollout_path", "transcript_path", "session_log_path"]
1759
- .into_iter()
1760
- .find_map(|key| agent.get(key).and_then(Value::as_str))
1761
- .filter(|path| !path.is_empty())?
1762
- .to_string();
1763
- Some(AbnormalWatchAgent {
1764
- agent_id: agent_id.clone(),
1765
- provider,
1766
- rollout_path: PathBuf::from(&rollout_path_display),
1767
- rollout_path_display,
1768
- status: agent
1769
- .get("status")
1770
- .and_then(Value::as_str)
1771
- .map(str::to_string),
1772
- process_liveness: explicit_process_liveness(agent),
1773
- window: agent
1774
- .get("window")
1775
- .and_then(Value::as_str)
1776
- .map(str::to_string),
1777
- pane_id: agent
1778
- .get("pane_id")
1779
- .and_then(Value::as_str)
1780
- .map(str::to_string),
1781
- pid: agent_pid(agent),
1782
- current_command: agent
1783
- .get("pane_current_command")
1784
- .or_else(|| agent.get("current_command"))
1785
- .and_then(Value::as_str)
1786
- .map(str::to_string),
1787
- })
1788
- })
1789
- .collect()
1790
- }
1791
-
1792
- fn agent_pid(agent: &Value) -> Option<Pid> {
1793
- ["provider_pid", "process_id", "pid", "child_pid", "pane_pid"]
1794
- .into_iter()
1795
- .find_map(|key| json_u32(agent.get(key)).map(Pid::new))
1796
- }
1797
-
1798
- fn explicit_process_liveness(agent: &Value) -> Option<ProcessLiveness> {
1799
- if let Some(process) = agent
1800
- .get("provider_process")
1801
- .or_else(|| agent.get("process"))
1802
- {
1803
- if let Some(liveness) = explicit_process_liveness(process) {
1804
- return Some(liveness);
1805
- }
1806
- }
1807
- for key in [
1808
- "provider_process_liveness",
1809
- "process_liveness",
1810
- "pane_liveness",
1811
- ] {
1812
- match agent.get(key).and_then(Value::as_str) {
1813
- Some("dead") => return Some(ProcessLiveness::Dead),
1814
- Some("alive" | "live") => return Some(ProcessLiveness::Alive),
1815
- Some("unverifiable" | "unknown") => return Some(ProcessLiveness::Unverifiable),
1816
- _ => {}
1817
- }
1818
- }
1819
- for key in [
1820
- "provider_process_alive",
1821
- "process_alive",
1822
- "provider_alive",
1823
- "alive",
1824
- ] {
1825
- if let Some(alive) = agent.get(key).and_then(Value::as_bool) {
1826
- return Some(if alive {
1827
- ProcessLiveness::Alive
1828
- } else {
1829
- ProcessLiveness::Dead
1830
- });
1831
- }
1832
- }
1833
- for key in [
1834
- "provider_process_dead",
1835
- "process_dead",
1836
- "provider_dead",
1837
- "dead",
1838
- ] {
1839
- if let Some(dead) = agent.get(key).and_then(Value::as_bool) {
1840
- return Some(if dead {
1841
- ProcessLiveness::Dead
1842
- } else {
1843
- ProcessLiveness::Alive
1844
- });
1845
- }
1846
- }
1847
- for key in ["status", "state", "liveness"] {
1848
- match agent.get(key).and_then(Value::as_str) {
1849
- Some("dead" | "exited" | "terminated" | "crashed" | "missing") => {
1850
- return Some(ProcessLiveness::Dead);
1851
- }
1852
- Some("alive" | "live" | "running") => return Some(ProcessLiveness::Alive),
1853
- Some("unverifiable" | "unknown") => return Some(ProcessLiveness::Unverifiable),
1854
- _ => {}
1855
- }
1856
- }
1857
- None
1858
- }
1859
-
1860
- fn json_u32(value: Option<&Value>) -> Option<u32> {
1861
- value
1862
- .and_then(|v| {
1863
- v.as_u64()
1864
- .or_else(|| v.as_i64().and_then(|n| u64::try_from(n).ok()))
1865
- })
1866
- .and_then(|n| u32::try_from(n).ok())
1867
- }
1868
-
1869
- fn agent_process_liveness(
1870
- agent: &AbnormalWatchAgent,
1871
- session_name: Option<&str>,
1872
- targets: &[crate::transport::PaneInfo],
1873
- transport: &dyn crate::transport::Transport,
1874
- ) -> ProcessCheck {
1875
- if let Some(pid) = agent.pid {
1876
- return pid_process_check("pid", pid);
1877
- }
1878
- if let Some(liveness) = agent.process_liveness {
1879
- return process_check(
1880
- liveness,
1881
- format!("explicit:{}", process_liveness_wire(liveness)),
1882
- );
1883
- }
1884
- if agent.status.as_deref().is_some_and(|status| {
1885
- matches!(
1886
- status,
1887
- "stopped" | "missing" | "error" | "dead" | "exited" | "terminated" | "crashed"
1888
- )
1889
- }) {
1890
- return process_check(
1891
- ProcessLiveness::Dead,
1892
- format!("status:{}", agent.status.as_deref().unwrap_or("unknown")),
1893
- );
1894
- }
1895
- if let Some(command) = agent.current_command.as_deref() {
1896
- return command_process_check(agent.provider, command);
1897
- }
1898
- if let Some(target) = matching_agent_target(agent, session_name, targets) {
1899
- if let Some(command) = target.current_command.as_deref() {
1900
- return pane_command_process_check(agent.provider, target, command);
1901
- }
1902
- if let Some(pid) = target.pane_pid.map(Pid::new) {
1903
- return pid_process_check("pane_pid", pid);
1904
- }
1905
- return process_check(
1906
- ProcessLiveness::Unverifiable,
1907
- "pane_present_pid_unknown".to_string(),
1908
- );
1909
- }
1910
- if let Some(pane_id) = agent.pane_id.as_deref() {
1911
- let pane = crate::transport::PaneId::new(pane_id);
1912
- return match transport.liveness(&pane) {
1913
- Ok(crate::transport::PaneLiveness::Dead) => {
1914
- process_check(ProcessLiveness::Dead, format!("pane_dead:{pane_id}"))
1915
- }
1916
- Ok(crate::transport::PaneLiveness::Live) => process_check(
1917
- ProcessLiveness::Unverifiable,
1918
- format!("pane_live_pid_unknown:{pane_id}"),
1919
- ),
1920
- Ok(crate::transport::PaneLiveness::Unknown) => process_check(
1921
- ProcessLiveness::Unverifiable,
1922
- format!("pane_unknown:{pane_id}"),
1923
- ),
1924
- Err(error) => process_check(
1925
- ProcessLiveness::Unverifiable,
1926
- format!("pane_unverifiable:{pane_id}:{error}"),
1927
- ),
1928
- };
1929
- }
1930
- let (Some(session), Some(window)) = (session_name, agent.window.as_deref()) else {
1931
- return process_check(
1932
- ProcessLiveness::Unverifiable,
1933
- "missing_session_or_window".to_string(),
1934
- );
1935
- };
1936
- let session = crate::transport::SessionName::new(session);
1937
- match transport.list_windows(&session) {
1938
- Ok(windows) if windows.iter().any(|known| known.as_str() == window) => process_check(
1939
- ProcessLiveness::Unverifiable,
1940
- "window_present_pid_unknown".to_string(),
1941
- ),
1942
- Ok(_) => process_check(ProcessLiveness::Dead, format!("window_missing:{window}")),
1943
- Err(error) => process_check(
1944
- ProcessLiveness::Unverifiable,
1945
- format!("window_unverifiable:{window}:{error}"),
1946
- ),
1947
- }
1948
- }
1949
-
1950
- fn matching_agent_target<'a>(
1951
- agent: &AbnormalWatchAgent,
1952
- session_name: Option<&str>,
1953
- targets: &'a [crate::transport::PaneInfo],
1954
- ) -> Option<&'a crate::transport::PaneInfo> {
1955
- if let Some(pane_id) = agent.pane_id.as_deref() {
1956
- if let Some(target) = targets
1957
- .iter()
1958
- .find(|target| target.pane_id.as_str() == pane_id)
1959
- {
1960
- return Some(target);
1961
- }
1962
- }
1963
- let (Some(session), Some(window)) = (session_name, agent.window.as_deref()) else {
1964
- return None;
1965
- };
1966
- targets.iter().find(|target| {
1967
- target.session.as_str() == session
1968
- && target
1969
- .window_name
1970
- .as_ref()
1971
- .is_some_and(|known| known.as_str() == window)
1972
- })
1973
- }
1974
-
1975
- fn pid_process_check(label: &str, pid: Pid) -> ProcessCheck {
1976
- match pid_is_running(pid) {
1977
- Ok(true) => process_check(ProcessLiveness::Alive, format!("{label}_running:{pid}")),
1978
- Ok(false) => process_check(ProcessLiveness::Dead, format!("{label}_not_running:{pid}")),
1979
- Err(error) => process_check(
1980
- ProcessLiveness::Unverifiable,
1981
- format!("{label}_unverifiable:{pid}:{error}"),
1982
- ),
1983
- }
1984
- }
1985
-
1986
- fn command_process_check(provider: crate::model::enums::Provider, command: &str) -> ProcessCheck {
1987
- if crate::leader::command_matches_provider(provider, command) {
1988
- process_check(ProcessLiveness::Alive, format!("current_command:{command}"))
1989
- } else {
1990
- process_check(
1991
- ProcessLiveness::Dead,
1992
- format!("provider_not_foreground:{command}"),
1993
- )
1994
- }
1995
- }
1996
-
1997
- fn pane_command_process_check(
1998
- provider: crate::model::enums::Provider,
1999
- pane: &crate::transport::PaneInfo,
2000
- command: &str,
2001
- ) -> ProcessCheck {
2002
- if crate::leader::attribute_pane_provider(pane)
2003
- .is_some_and(|candidate| crate::leader::provider_matches(candidate, provider))
2004
- {
2005
- process_check(ProcessLiveness::Alive, format!("current_command:{command}"))
2006
- } else {
2007
- process_check(
2008
- ProcessLiveness::Dead,
2009
- format!("provider_not_foreground:{command}"),
2010
- )
2011
- }
2012
- }
2013
-
2014
- fn process_check(state: ProcessLiveness, detail: String) -> ProcessCheck {
2015
- ProcessCheck { state, detail }
2016
- }
2017
-
2018
- fn process_liveness_wire(state: ProcessLiveness) -> &'static str {
2019
- match state {
2020
- ProcessLiveness::Alive => "alive",
2021
- ProcessLiveness::Dead => "dead",
2022
- ProcessLiveness::Unverifiable => "unverifiable",
2023
- }
2024
- }
2025
-
2026
- fn metadata_mtime_ns(metadata: &std::fs::Metadata) -> Option<u64> {
2027
- let duration = metadata
2028
- .modified()
2029
- .ok()?
2030
- .duration_since(std::time::UNIX_EPOCH)
2031
- .ok()?;
2032
- Some(
2033
- duration
2034
- .as_secs()
2035
- .saturating_mul(1_000_000_000)
2036
- .saturating_add(u64::from(duration.subsec_nanos())),
2037
- )
2038
- }
2039
-
2040
- fn abnormal_watch_payload(
2041
- agent: &AbnormalWatchAgent,
2042
- size: Option<u64>,
2043
- mtime_ns: Option<u64>,
2044
- liveness: &str,
2045
- signature: Option<&str>,
2046
- error: Option<String>,
2047
- ) -> Value {
2048
- let dead_process = liveness == "dead";
2049
- let latest_explicit_error = signature.is_some();
2050
- let notify = dead_process && latest_explicit_error;
2051
- let suppressed_reason = match (dead_process, latest_explicit_error) {
2052
- (true, false) => Some("dead_only"),
2053
- (false, true) => Some("error_only"),
2054
- _ => None,
2055
- };
2056
- serde_json::json!({
2057
- "path": agent.rollout_path_display.as_str(),
2058
- "provider": provider_wire(agent.provider),
2059
- "mtime_ns": mtime_ns,
2060
- "size": size,
2061
- "last_offset": size,
2062
- "last_signature": signature,
2063
- "last_liveness": liveness,
2064
- "dead_process": dead_process,
2065
- "process_dead": dead_process,
2066
- "provider_process_dead": dead_process,
2067
- "latest_error": latest_explicit_error,
2068
- "latest_explicit_error": latest_explicit_error,
2069
- "dead_process_and_latest_error": notify,
2070
- "dead_process_and_latest_explicit_error": notify,
2071
- "process_dead_and_latest_explicit_error": notify,
2072
- "provider_process_dead_and_latest_explicit_error": notify,
2073
- "suppressed_reason": suppressed_reason,
2074
- "notification": notify,
2075
- "last_error": error,
2076
- "last_checked_at": chrono::Utc::now().to_rfc3339(),
2077
- })
2078
- }
2079
-
2080
- fn upsert_abnormal_watch(state: &mut Value, agent_id: &str, mut payload: Value) {
2081
- let preserved = [
2082
- "last_notified_key",
2083
- "last_notified_at",
2084
- "last_suppressed_key",
2085
- "last_suppressed_at",
2086
- "last_check_key",
2087
- "last_check_at",
2088
- ]
2089
- .into_iter()
2090
- .filter_map(|key| abnormal_watch_field(state, agent_id, key).map(|value| (key, value)))
2091
- .collect::<Vec<_>>();
2092
- if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
2093
- if let Some(payload_obj) = payload.as_object_mut() {
2094
- for (key, value) in preserved {
2095
- payload_obj.insert(key.to_string(), value);
2096
- }
2097
- }
2098
- watch.insert(agent_id.to_string(), payload);
2099
- }
2100
- }
2101
-
2102
- fn coordinator_child_object<'a>(
2103
- state: &'a mut Value,
2104
- key: &str,
2105
- ) -> Option<&'a mut serde_json::Map<String, Value>> {
2106
- if !state.is_object() {
2107
- *state = serde_json::json!({});
2108
- }
2109
- let state_obj = state.as_object_mut()?;
2110
- let coordinator = state_obj
2111
- .entry("coordinator".to_string())
2112
- .or_insert_with(|| serde_json::json!({}));
2113
- if !coordinator.is_object() {
2114
- *coordinator = serde_json::json!({});
2115
- }
2116
- let coord_obj = coordinator.as_object_mut()?;
2117
- let child = coord_obj
2118
- .entry(key.to_string())
2119
- .or_insert_with(|| serde_json::json!({}));
2120
- if !child.is_object() {
2121
- *child = serde_json::json!({});
2122
- }
2123
- child.as_object_mut()
2124
- }
2125
-
2126
- fn abnormal_last_notified_key(state: &Value, agent_id: &str) -> Option<String> {
2127
- abnormal_watch_str(state, agent_id, "last_notified_key")
2128
- }
2129
-
2130
- fn abnormal_last_suppressed_key(state: &Value, agent_id: &str) -> Option<String> {
2131
- abnormal_watch_str(state, agent_id, "last_suppressed_key")
2132
- }
2133
-
2134
- fn abnormal_last_check_key(state: &Value, agent_id: &str) -> Option<String> {
2135
- abnormal_watch_str(state, agent_id, "last_check_key")
2136
- }
2137
-
2138
- /// P1: Python `_TAIL_BYTES` parity (idle_takeover_wiring.py:13) — RS must not read less.
2139
- const ABNORMAL_TAIL_BYTES: u64 = 131_072;
2140
-
2141
- /// P1: bounded tail read; a partial first line is harmless (the consumer only parses
2142
- /// the latest complete JSONL record) and lossy UTF-8 keeps a mid-codepoint seek safe.
2143
- fn read_tail_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
2144
- use std::io::{Read, Seek, SeekFrom};
2145
- let mut file = std::fs::File::open(path)?;
2146
- let len = file.metadata()?.len();
2147
- if len > max_bytes {
2148
- file.seek(SeekFrom::Start(len - max_bytes))?;
2149
- }
2150
- let mut bytes = Vec::new();
2151
- file.read_to_end(&mut bytes)?;
2152
- Ok(String::from_utf8_lossy(&bytes).into_owned())
2153
- }
2154
-
2155
- /// P1: the previous tick's `(size, mtime_ns)` pair from the abnormal watch payload.
2156
- fn abnormal_watch_stored_metadata(state: &Value, agent_id: &str) -> Option<(u64, u64)> {
2157
- let watch = state
2158
- .get("coordinator")?
2159
- .get("abnormal_exit_watch")?
2160
- .get(agent_id)?;
2161
- Some((
2162
- watch.get("size")?.as_u64()?,
2163
- watch.get("mtime_ns")?.as_u64()?,
2164
- ))
2165
- }
2166
-
2167
- fn abnormal_watch_str(state: &Value, agent_id: &str, field: &str) -> Option<String> {
2168
- state
2169
- .get("coordinator")
2170
- .and_then(|v| v.get("abnormal_exit_watch"))
2171
- .and_then(|v| v.get(agent_id))
2172
- .and_then(|v| v.get(field))
2173
- .and_then(Value::as_str)
2174
- .map(str::to_string)
2175
- }
2176
-
2177
- fn abnormal_watch_field(state: &Value, agent_id: &str, field: &str) -> Option<Value> {
2178
- state
2179
- .get("coordinator")
2180
- .and_then(|v| v.get("abnormal_exit_watch"))
2181
- .and_then(|v| v.get(agent_id))
2182
- .and_then(|v| v.get(field))
2183
- .cloned()
2184
- }
2185
-
2186
- fn mark_abnormal_notified(state: &mut Value, agent_id: &str, key: &str) {
2187
- if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
2188
- let entry = watch
2189
- .entry(agent_id.to_string())
2190
- .or_insert_with(|| serde_json::json!({}));
2191
- if !entry.is_object() {
2192
- *entry = serde_json::json!({});
2193
- }
2194
- if let Some(obj) = entry.as_object_mut() {
2195
- obj.insert("last_notified_key".to_string(), serde_json::json!(key));
2196
- obj.insert(
2197
- "last_notified_at".to_string(),
2198
- serde_json::json!(chrono::Utc::now().to_rfc3339()),
2199
- );
2200
- }
2201
- }
2202
- }
2203
-
2204
- fn mark_abnormal_suppressed(state: &mut Value, agent_id: &str, key: &str) {
2205
- if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
2206
- let entry = watch
2207
- .entry(agent_id.to_string())
2208
- .or_insert_with(|| serde_json::json!({}));
2209
- if !entry.is_object() {
2210
- *entry = serde_json::json!({});
2211
- }
2212
- if let Some(obj) = entry.as_object_mut() {
2213
- obj.insert("last_suppressed_key".to_string(), serde_json::json!(key));
2214
- obj.insert(
2215
- "last_suppressed_at".to_string(),
2216
- serde_json::json!(chrono::Utc::now().to_rfc3339()),
2217
- );
2218
- }
2219
- }
2220
- }
2221
-
2222
- fn mark_abnormal_checked(state: &mut Value, agent_id: &str, key: &str) {
2223
- if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
2224
- let entry = watch
2225
- .entry(agent_id.to_string())
2226
- .or_insert_with(|| serde_json::json!({}));
2227
- if !entry.is_object() {
2228
- *entry = serde_json::json!({});
2229
- }
2230
- if let Some(obj) = entry.as_object_mut() {
2231
- obj.insert("last_check_key".to_string(), serde_json::json!(key));
2232
- obj.insert(
2233
- "last_check_at".to_string(),
2234
- serde_json::json!(chrono::Utc::now().to_rfc3339()),
2235
- );
2236
- }
2237
- }
2238
- }
2239
-
2240
- fn write_abnormal_check(
2241
- event_log: &EventLog,
2242
- team: &str,
2243
- agent: &AbnormalWatchAgent,
2244
- liveness: &ProcessCheck,
2245
- fact: Option<&crate::provider::FaultFact>,
2246
- decision: AbnormalExitDecision,
2247
- size: u64,
2248
- mtime_ns: Option<u64>,
2249
- ) -> Result<(), TickError> {
2250
- let dead_process = liveness.state == ProcessLiveness::Dead;
2251
- let latest_explicit_error = fact.is_some();
2252
- event_log.write(
2253
- "worker.abnormal_exit.check",
2254
- serde_json::json!({
2255
- "team_id": team,
2256
- "agent_id": agent.agent_id.as_str(),
2257
- "provider": provider_wire(agent.provider),
2258
- "path": agent.rollout_path_display.as_str(),
2259
- "size": size,
2260
- "last_offset": size,
2261
- "mtime_ns": mtime_ns,
2262
- "dead_process": dead_process,
2263
- "process_dead": dead_process,
2264
- "provider_process_dead": dead_process,
2265
- "latest_error": latest_explicit_error,
2266
- "latest_explicit_error": latest_explicit_error,
2267
- "dead_process_and_latest_error": dead_process && latest_explicit_error,
2268
- "dead_process_and_latest_explicit_error": dead_process && latest_explicit_error,
2269
- "process_dead_and_latest_explicit_error": dead_process && latest_explicit_error,
2270
- "provider_process_dead_and_latest_explicit_error": dead_process && latest_explicit_error,
2271
- "notification": matches!(decision, AbnormalExitDecision::Notify),
2272
- "suppressed_reason": match decision {
2273
- AbnormalExitDecision::Suppress(reason) => Some(reason),
2274
- AbnormalExitDecision::Notify | AbnormalExitDecision::NoSignal => None,
2275
- },
2276
- "signature": fact.map(|fact| fact.signature.as_str()),
2277
- "turn_id": fact.and_then(|fact| fact.turn_id.as_ref().map(|id| id.as_str())),
2278
- "process_liveness": process_liveness_wire(liveness.state),
2279
- "pid_status": liveness.detail.as_str(),
2280
- }),
2281
- )?;
2282
- Ok(())
2283
- }
2284
-
2285
- fn write_abnormal_suppressed(
2286
- event_log: &EventLog,
2287
- team: &str,
2288
- agent: &AbnormalWatchAgent,
2289
- liveness: &ProcessCheck,
2290
- reason: &str,
2291
- ) -> Result<(), TickError> {
2292
- event_log.write(
2293
- "abnormal_exit.single_signal_suppressed",
2294
- serde_json::json!({
2295
- "team_id": team,
2296
- "agent_id": agent.agent_id.as_str(),
2297
- "provider": provider_wire(agent.provider),
2298
- "path": agent.rollout_path_display.as_str(),
2299
- "reason": reason,
2300
- "notification": false,
2301
- "dead_process": liveness.state == ProcessLiveness::Dead,
2302
- "process_dead": liveness.state == ProcessLiveness::Dead,
2303
- "provider_process_dead": liveness.state == ProcessLiveness::Dead,
2304
- "latest_error": reason == "error_only",
2305
- "latest_explicit_error": reason == "error_only",
2306
- "dead_process_and_latest_error": false,
2307
- "dead_process_and_latest_explicit_error": false,
2308
- "process_dead_and_latest_explicit_error": false,
2309
- "provider_process_dead_and_latest_explicit_error": false,
2310
- "process_liveness": process_liveness_wire(liveness.state),
2311
- "pid_status": liveness.detail.as_str(),
2312
- }),
2313
- )?;
2314
- Ok(())
2315
- }
2316
-
2317
- fn abnormal_dedupe_key(
2318
- agent: &AbnormalWatchAgent,
2319
- fact: &crate::provider::FaultFact,
2320
- size: u64,
2321
- ) -> String {
2322
- let bucket = fact
2323
- .turn_id
2324
- .as_ref()
2325
- .map(|id| id.as_str().to_string())
2326
- .unwrap_or_else(|| size.to_string());
2327
- format!(
2328
- "worker.abnormal_exit:{}:{}:{}:{}",
2329
- agent.agent_id,
2330
- agent.rollout_path_display,
2331
- fact.signature.as_str(),
2332
- bucket
2333
- )
2334
- }
2335
-
2336
- fn abnormal_suppression_key(
2337
- agent: &AbnormalWatchAgent,
2338
- liveness: &ProcessCheck,
2339
- reason: &str,
2340
- size: u64,
2341
- ) -> String {
2342
- format!(
2343
- "abnormal_exit.single_signal_suppressed:{}:{}:{}:{}:{}",
2344
- agent.agent_id,
2345
- agent.rollout_path_display,
2346
- reason,
2347
- process_liveness_wire(liveness.state),
2348
- size
2349
- )
2350
- }
2351
-
2352
- fn abnormal_check_key(
2353
- agent: &AbnormalWatchAgent,
2354
- liveness: &ProcessCheck,
2355
- fact: Option<&crate::provider::FaultFact>,
2356
- size: u64,
2357
- ) -> String {
2358
- format!(
2359
- "worker.abnormal_exit.check:{}:{}:{}:{}:{}",
2360
- agent.agent_id,
2361
- agent.rollout_path_display,
2362
- process_liveness_wire(liveness.state),
2363
- fact.map(|fact| fact.signature.as_str()).unwrap_or("-"),
2364
- size
2365
- )
2366
- }
2367
-
2368
- fn format_abnormal_exit_message(
2369
- team: &str,
2370
- agent: &AbnormalWatchAgent,
2371
- fact: &crate::provider::FaultFact,
2372
- liveness: &ProcessCheck,
2373
- size: u64,
2374
- ) -> String {
2375
- let turn_id = fact.turn_id.as_ref().map(|id| id.as_str()).unwrap_or("-");
2376
- format!(
2377
- "Team Agent detected a provider abnormal exit.\n\n\
2378
- event: worker.abnormal_exit\n\
2379
- team: {team}\n\
2380
- node: {node}\n\
2381
- provider: {provider}\n\
2382
- signature: {signature}\n\
2383
- turn_id: {turn_id}\n\
2384
- transcript: {path}\n\
2385
- last_offset: {size}\n\
2386
- pid_status: {pid_status}\n\n\
2387
- No automatic restart was performed.",
2388
- node = agent.agent_id.as_str(),
2389
- provider = provider_wire(agent.provider),
2390
- signature = fact.signature.as_str(),
2391
- path = agent.rollout_path_display.as_str(),
2392
- pid_status = liveness.detail.as_str(),
2393
- )
2394
- }
2395
-
2396
1487
  fn monotonic_seconds() -> f64 {
2397
1488
  match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
2398
1489
  Ok(duration) => duration.as_secs_f64(),
@@ -2768,7 +1859,7 @@ fn jsonl_activity_for_agent(agent: &Value) -> Option<crate::messaging::AgentActi
2768
1859
  // still forces re-read because size is part of the cache key.
2769
1860
  let metadata = std::fs::metadata(&rollout_path).ok()?;
2770
1861
  let size = metadata.len();
2771
- let mtime_ns = metadata_mtime_ns(&metadata)?;
1862
+ let mtime_ns = crate::coordinator::steps::abnormal::metadata_mtime_ns(&metadata)?;
2772
1863
  if let Ok(cache) = jsonl_activity_cache().lock() {
2773
1864
  if let Some(entry) = cache.get(&rollout_path) {
2774
1865
  if entry.size == size && entry.mtime_ns == mtime_ns {
@@ -2781,8 +1872,13 @@ fn jsonl_activity_for_agent(agent: &Value) -> Option<crate::messaging::AgentActi
2781
1872
  // latest lifecycle records to determine idle/working state; the
2782
1873
  // abnormal-exit path uses the same 128KiB tail and is sufficient for
2783
1874
  // claude / codex / copilot lifecycle markers.
2784
- let log_text = read_tail_text(&rollout_path, JSONL_ACTIVITY_TAIL_BYTES).ok()?;
2785
- let process = explicit_process_liveness(agent).unwrap_or(ProcessLiveness::Unverifiable);
1875
+ let log_text = crate::coordinator::steps::abnormal::read_tail_text(
1876
+ &rollout_path,
1877
+ JSONL_ACTIVITY_TAIL_BYTES,
1878
+ )
1879
+ .ok()?;
1880
+ let process = crate::coordinator::steps::abnormal::explicit_process_liveness(agent)
1881
+ .unwrap_or(ProcessLiveness::Unverifiable);
2786
1882
  let activity = crate::provider::classify(provider, &log_text, process, 0.0)
2787
1883
  .ok()
2788
1884
  .and_then(|result| {
@@ -3106,8 +2202,7 @@ fn write_activity(
3106
2202
  // 0.4.x Phase 1: resolve the 5-state worker runtime state alongside the
3107
2203
  // legacy `activity` write. CR R3: activity field is preserved as the
3108
2204
  // deprecated surface; worker_state is the new canonical surface.
3109
- let worker_state =
3110
- resolve_worker_runtime_state_with_fg_pgrp(agent, Some(activity));
2205
+ let worker_state = resolve_worker_runtime_state_with_fg_pgrp(agent, Some(activity));
3111
2206
  let Some(agent_obj) = agent.as_object_mut() else {
3112
2207
  return previous_last_output;
3113
2208
  };
@@ -3177,7 +2272,10 @@ pub(crate) fn resolve_worker_runtime_state_with_fg_pgrp(
3177
2272
  }
3178
2273
 
3179
2274
  // §4.3 Busy via fg-pgrp probe (when pane_pid is available).
3180
- let pane_pid = agent.get("pane_pid").and_then(Value::as_u64).map(|p| p as u32);
2275
+ let pane_pid = agent
2276
+ .get("pane_pid")
2277
+ .and_then(Value::as_u64)
2278
+ .map(|p| p as u32);
3181
2279
  if let Some(pid) = pane_pid {
3182
2280
  match crate::os_probe::pane_foreground_and_root_pgrp(pid) {
3183
2281
  Ok(Some((tpgid, pgid))) => {
@@ -3345,6 +2443,7 @@ fn notify_session_missing(
3345
2443
  #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
3346
2444
  mod u1_tests {
3347
2445
  use super::*;
2446
+ use std::io::Write as _;
3348
2447
 
3349
2448
  struct CaptureFailureRegistry;
3350
2449