@team-agent/installer 0.4.10 → 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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +1 -0
- package/crates/team-agent/src/cli/diagnose.rs +2 -15
- package/crates/team-agent/src/cli/emit.rs +62 -4
- package/crates/team-agent/src/compiler.rs +11 -20
- package/crates/team-agent/src/coordinator/orphan.rs +2 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
- package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
- package/crates/team-agent/src/coordinator/tick.rs +31 -932
- package/crates/team-agent/src/diagnose/orphans.rs +66 -8
- package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
- package/crates/team-agent/src/leader/rediscover.rs +1 -11
- package/crates/team-agent/src/leader/start.rs +169 -25
- package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
- package/crates/team-agent/src/lifecycle/launch.rs +135 -58
- package/crates/team-agent/src/lifecycle/lock.rs +302 -0
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +436 -121
- package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
- package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +193 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +54 -15
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +648 -0
- package/crates/team-agent/src/lifecycle/tests.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +24 -34
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
- package/crates/team-agent/src/mcp_server/tools.rs +157 -68
- package/crates/team-agent/src/messaging/activity.rs +43 -18
- package/crates/team-agent/src/messaging/delivery.rs +161 -97
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
- package/crates/team-agent/src/messaging/results.rs +34 -9
- package/crates/team-agent/src/messaging/scheduler.rs +52 -9
- package/crates/team-agent/src/model/spec.rs +10 -6
- package/crates/team-agent/src/provider/adapter.rs +10 -1038
- package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
- package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
- package/crates/team-agent/src/provider/classify.rs +23 -48
- package/crates/team-agent/src/provider/command.rs +16 -7
- package/crates/team-agent/src/provider/faults.rs +93 -53
- package/crates/team-agent/src/provider/session/capture.rs +4 -15
- package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
- package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
- package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
- package/crates/team-agent/src/provider/session_scan.rs +65 -27
- package/crates/team-agent/src/provider/tests/faults.rs +80 -0
- package/crates/team-agent/src/provider/types.rs +33 -1
- package/crates/team-agent/src/provider/wire.rs +171 -1
- package/crates/team-agent/src/state/identity.rs +242 -57
- package/crates/team-agent/src/state/identity_keys.rs +9 -12
- package/crates/team-agent/src/state/mod.rs +5 -1
- package/crates/team-agent/src/state/owner_gate.rs +59 -15
- package/crates/team-agent/src/state/ownership.rs +12 -11
- package/crates/team-agent/src/state/paths.rs +13 -6
- package/crates/team-agent/src/state/persist.rs +671 -128
- package/crates/team-agent/src/state/projection.rs +240 -49
- package/crates/team-agent/src/state/selector.rs +11 -5
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
- package/crates/team-agent/src/tmux_backend.rs +42 -5
- package/crates/team-agent/src/transport/test_support.rs +55 -6
- 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) =
|
|
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
|
-
|
|
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:
|
|
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(
|
|
2785
|
-
|
|
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
|
|
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
|
|