@team-agent/installer 0.3.24 → 0.3.26
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/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +193 -7
- package/crates/team-agent/src/cli/send.rs +106 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +7 -0
- package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
- package/crates/team-agent/src/coordinator/tick.rs +55 -7
- package/crates/team-agent/src/leader/lease.rs +57 -0
- package/crates/team-agent/src/leader/start.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch.rs +217 -19
- package/crates/team-agent/src/lifecycle/restart/agent.rs +116 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +66 -1
- package/crates/team-agent/src/lifecycle/restart.rs +19 -2
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +994 -0
- package/crates/team-agent/src/messaging/delivery.rs +91 -1
- package/crates/team-agent/src/messaging/helpers.rs +64 -24
- package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +158 -4
- package/crates/team-agent/src/tmux_backend/tests.rs +414 -2
- package/crates/team-agent/src/tmux_backend.rs +366 -2
- package/crates/team-agent/src/transport/test_support.rs +1 -0
- package/crates/team-agent/src/transport/tests/mod.rs +1 -0
- package/crates/team-agent/src/transport/tests/wire.rs +2 -1
- package/crates/team-agent/src/transport.rs +103 -1
- package/npm/install.mjs +312 -39
- package/package.json +4 -4
|
@@ -378,6 +378,12 @@ struct CleanShutdownTransport {
|
|
|
378
378
|
kill_server_called: Mutex<bool>,
|
|
379
379
|
probe_timeout_kind: Option<&'static str>,
|
|
380
380
|
targets_persist_after_kill: bool,
|
|
381
|
+
// E49 (0.3.24 P0, shutdown kills leader CLI): record per-pane / per-window /
|
|
382
|
+
// per-session kill targets so RED contracts can assert (a) the leader pane is
|
|
383
|
+
// never killed and (b) worker panes ARE killed via the new per-pane path.
|
|
384
|
+
killed_panes: Mutex<Vec<String>>,
|
|
385
|
+
killed_window_targets: Mutex<Vec<String>>,
|
|
386
|
+
killed_sessions: Mutex<Vec<String>>,
|
|
381
387
|
}
|
|
382
388
|
|
|
383
389
|
impl CleanShutdownTransport {
|
|
@@ -388,6 +394,9 @@ impl CleanShutdownTransport {
|
|
|
388
394
|
kill_server_called: Mutex::new(false),
|
|
389
395
|
probe_timeout_kind: None,
|
|
390
396
|
targets_persist_after_kill: false,
|
|
397
|
+
killed_panes: Mutex::new(Vec::new()),
|
|
398
|
+
killed_window_targets: Mutex::new(Vec::new()),
|
|
399
|
+
killed_sessions: Mutex::new(Vec::new()),
|
|
391
400
|
}
|
|
392
401
|
}
|
|
393
402
|
|
|
@@ -409,6 +418,21 @@ impl CleanShutdownTransport {
|
|
|
409
418
|
self.targets_persist_after_kill = true;
|
|
410
419
|
self
|
|
411
420
|
}
|
|
421
|
+
|
|
422
|
+
/// E49 (0.3.24 P0): observed per-pane kill targets, in call order.
|
|
423
|
+
fn killed_panes(&self) -> Vec<String> {
|
|
424
|
+
self.killed_panes.lock().unwrap().clone()
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/// E49 (0.3.24 P0): observed per-window kill targets (target stringified).
|
|
428
|
+
fn killed_window_targets(&self) -> Vec<String> {
|
|
429
|
+
self.killed_window_targets.lock().unwrap().clone()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/// E49 (0.3.24 P0): observed per-session kill targets, in call order.
|
|
433
|
+
fn killed_sessions_observed(&self) -> Vec<String> {
|
|
434
|
+
self.killed_sessions.lock().unwrap().clone()
|
|
435
|
+
}
|
|
412
436
|
}
|
|
413
437
|
|
|
414
438
|
impl Transport for CleanShutdownTransport {
|
|
@@ -499,10 +523,14 @@ impl Transport for CleanShutdownTransport {
|
|
|
499
523
|
Ok(SetEnvOutcome::Applied)
|
|
500
524
|
}
|
|
501
525
|
|
|
502
|
-
fn kill_session(&self,
|
|
526
|
+
fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
|
|
503
527
|
if let Some(probe) = self.probe_timeout_kind {
|
|
504
528
|
crate::os_probe::set_probe_timeout_for_test(probe, None, 900);
|
|
505
529
|
}
|
|
530
|
+
self.killed_sessions
|
|
531
|
+
.lock()
|
|
532
|
+
.unwrap()
|
|
533
|
+
.push(session.as_str().to_string());
|
|
506
534
|
*self.session_present.lock().unwrap() = false;
|
|
507
535
|
Ok(())
|
|
508
536
|
}
|
|
@@ -512,7 +540,19 @@ impl Transport for CleanShutdownTransport {
|
|
|
512
540
|
Ok(())
|
|
513
541
|
}
|
|
514
542
|
|
|
515
|
-
fn kill_window(&self,
|
|
543
|
+
fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
|
|
544
|
+
self.killed_window_targets
|
|
545
|
+
.lock()
|
|
546
|
+
.unwrap()
|
|
547
|
+
.push(format!("{target:?}"));
|
|
548
|
+
Ok(())
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
fn kill_pane(&self, pane: &PaneId) -> Result<(), TransportError> {
|
|
552
|
+
self.killed_panes
|
|
553
|
+
.lock()
|
|
554
|
+
.unwrap()
|
|
555
|
+
.push(pane.as_str().to_string());
|
|
516
556
|
Ok(())
|
|
517
557
|
}
|
|
518
558
|
|
|
@@ -730,24 +770,240 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
|
|
|
730
770
|
);
|
|
731
771
|
}
|
|
732
772
|
|
|
773
|
+
/// E49 (0.3.24 P0, shutdown kills leader CLI): managed-leader topology with
|
|
774
|
+
/// leader anchor pane + worker panes in the SAME tmux session.
|
|
775
|
+
///
|
|
776
|
+
/// Pre-fix (cli/mod.rs:629-638): `transport.kill_session(state.session_name)`
|
|
777
|
+
/// killed the session unconditionally — including the leader's own pane.
|
|
778
|
+
/// User truth: "执行命令绝不能关掉启动自己的那个 leader CLI". macmini repro:
|
|
779
|
+
/// run `team-agent quick-start ...`, then `team-agent shutdown` from the
|
|
780
|
+
/// same terminal → leader CLI dies.
|
|
781
|
+
///
|
|
782
|
+
/// Architect-approved fix:
|
|
783
|
+
/// * pre :629 guard: when state is managed (NOT external leader) and the
|
|
784
|
+
/// leader_receiver / team_owner anchor pane is in `state.session_name`,
|
|
785
|
+
/// do NOT call kill_session(state.session_name). Instead kill workers
|
|
786
|
+
/// per-pane via `kill_pane`, deriving pane_ids from state.agents +
|
|
787
|
+
/// teams[*].agents and EXCLUDING the leader anchor pane ids
|
|
788
|
+
/// (`collect_state_leader_anchor_pane_ids`).
|
|
789
|
+
/// * cli/mod.rs:384-435 managed_leader_socket_cleanup: spare any session
|
|
790
|
+
/// carrying a leader anchor; never kill_session it.
|
|
791
|
+
///
|
|
792
|
+
/// Pre-fix test asserted `killed_sessions=["team-current"]` — that was
|
|
793
|
+
/// LOCKING THE BUG (the architect explicitly named it). Post-fix:
|
|
794
|
+
/// * spared_sessions contains "team-current"
|
|
795
|
+
/// * killed_sessions does NOT contain "team-current"
|
|
796
|
+
/// * killed_panes contains worker panes %w1, %w2
|
|
797
|
+
/// * killed_panes does NOT contain %leader
|
|
798
|
+
/// * no kill_server call
|
|
733
799
|
#[test]
|
|
734
|
-
fn
|
|
735
|
-
let ws = tmp_shutdown_workspace("managed-leader-
|
|
800
|
+
fn e49_managed_leader_shutdown_spares_leader_session_and_kills_workers_per_pane() {
|
|
801
|
+
let ws = tmp_shutdown_workspace("e49-managed-leader-spares-session");
|
|
736
802
|
crate::state::persist::save_runtime_state(
|
|
737
803
|
&ws,
|
|
738
804
|
&json!({
|
|
739
805
|
"session_name": "team-current",
|
|
740
806
|
"is_external_leader": false,
|
|
741
|
-
"
|
|
807
|
+
"leader_receiver": {"pane_id": "%leader"},
|
|
808
|
+
"team_owner": {"pane_id": "%leader"},
|
|
809
|
+
"agents": {
|
|
810
|
+
"w1": {"status": "running", "provider": "codex", "window": "w1", "pane_id": "%w1"},
|
|
811
|
+
"w2": {"status": "running", "provider": "codex", "window": "w2", "pane_id": "%w2"}
|
|
812
|
+
}
|
|
813
|
+
}),
|
|
814
|
+
)
|
|
815
|
+
.unwrap();
|
|
816
|
+
let transport = CleanShutdownTransport::new()
|
|
817
|
+
.with_targets(vec![
|
|
818
|
+
// Leader anchor pane
|
|
819
|
+
PaneInfo {
|
|
820
|
+
pane_id: PaneId::new("%leader"),
|
|
821
|
+
session: SessionName::new("team-current"),
|
|
822
|
+
window_index: Some(0),
|
|
823
|
+
window_name: Some(WindowName::new("leader")),
|
|
824
|
+
pane_index: Some(0),
|
|
825
|
+
tty: None,
|
|
826
|
+
current_command: Some("codex".to_string()),
|
|
827
|
+
current_path: None,
|
|
828
|
+
active: true,
|
|
829
|
+
pane_pid: None,
|
|
830
|
+
leader_env: BTreeMap::new(),
|
|
831
|
+
},
|
|
832
|
+
// Worker pane 1
|
|
833
|
+
PaneInfo {
|
|
834
|
+
pane_id: PaneId::new("%w1"),
|
|
835
|
+
session: SessionName::new("team-current"),
|
|
836
|
+
window_index: Some(1),
|
|
837
|
+
window_name: Some(WindowName::new("w1")),
|
|
838
|
+
pane_index: Some(0),
|
|
839
|
+
tty: None,
|
|
840
|
+
current_command: Some("codex".to_string()),
|
|
841
|
+
current_path: None,
|
|
842
|
+
active: false,
|
|
843
|
+
pane_pid: None,
|
|
844
|
+
leader_env: BTreeMap::new(),
|
|
845
|
+
},
|
|
846
|
+
// Worker pane 2
|
|
847
|
+
PaneInfo {
|
|
848
|
+
pane_id: PaneId::new("%w2"),
|
|
849
|
+
session: SessionName::new("team-current"),
|
|
850
|
+
window_index: Some(2),
|
|
851
|
+
window_name: Some(WindowName::new("w2")),
|
|
852
|
+
pane_index: Some(0),
|
|
853
|
+
tty: None,
|
|
854
|
+
current_command: Some("codex".to_string()),
|
|
855
|
+
current_path: None,
|
|
856
|
+
active: false,
|
|
857
|
+
pane_pid: None,
|
|
858
|
+
leader_env: BTreeMap::new(),
|
|
859
|
+
},
|
|
860
|
+
])
|
|
861
|
+
.with_targets_persist_after_kill();
|
|
862
|
+
|
|
863
|
+
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
864
|
+
.expect("shutdown should complete");
|
|
865
|
+
|
|
866
|
+
let killed_sessions = transport.killed_sessions_observed();
|
|
867
|
+
assert!(
|
|
868
|
+
!killed_sessions.iter().any(|s| s == "team-current"),
|
|
869
|
+
"E49 (RED): managed-leader shutdown must NEVER kill_session(team-current) — \
|
|
870
|
+
that ends the leader's own pane (the user's CLI). Got transport \
|
|
871
|
+
killed_sessions={killed_sessions:?}"
|
|
872
|
+
);
|
|
873
|
+
let killed_panes = transport.killed_panes();
|
|
874
|
+
assert!(
|
|
875
|
+
!killed_panes.iter().any(|p| p == "%leader"),
|
|
876
|
+
"E49 (RED): leader anchor pane %leader must NEVER be killed. Got \
|
|
877
|
+
killed_panes={killed_panes:?}"
|
|
878
|
+
);
|
|
879
|
+
assert!(
|
|
880
|
+
killed_panes.iter().any(|p| p == "%w1"),
|
|
881
|
+
"E49: worker pane %w1 must be killed per-pane (workers cleared, leader spared). \
|
|
882
|
+
Got killed_panes={killed_panes:?}"
|
|
883
|
+
);
|
|
884
|
+
assert!(
|
|
885
|
+
killed_panes.iter().any(|p| p == "%w2"),
|
|
886
|
+
"E49: worker pane %w2 must be killed per-pane. Got killed_panes={killed_panes:?}"
|
|
887
|
+
);
|
|
888
|
+
assert!(
|
|
889
|
+
!transport.kill_server_called(),
|
|
890
|
+
"E49: managed topology must never kill the tmux server (would end leader pane)."
|
|
891
|
+
);
|
|
892
|
+
// Report-level assertions: the leader session must appear in spared_sessions,
|
|
893
|
+
// not killed_sessions.
|
|
894
|
+
let out_killed = out["killed_sessions"]
|
|
895
|
+
.as_array()
|
|
896
|
+
.cloned()
|
|
897
|
+
.unwrap_or_default();
|
|
898
|
+
let out_spared = out["spared_sessions"]
|
|
899
|
+
.as_array()
|
|
900
|
+
.cloned()
|
|
901
|
+
.unwrap_or_default();
|
|
902
|
+
assert!(
|
|
903
|
+
!out_killed
|
|
904
|
+
.iter()
|
|
905
|
+
.any(|v| v.as_str() == Some("team-current")),
|
|
906
|
+
"E49: report's killed_sessions must NOT contain the leader session. Got {out_killed:?}"
|
|
907
|
+
);
|
|
908
|
+
assert!(
|
|
909
|
+
out_spared
|
|
910
|
+
.iter()
|
|
911
|
+
.any(|v| v.as_str() == Some("team-current")),
|
|
912
|
+
"E49: report's spared_sessions MUST contain the leader session. Got {out_spared:?}"
|
|
913
|
+
);
|
|
914
|
+
assert_eq!(out["ok"], json!(true));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/// E49 regression guard: external-leader topology (is_external_leader=true)
|
|
918
|
+
/// must STILL kill_session unconditionally. In that topology the team session
|
|
919
|
+
/// is a disposable worker session and the leader pane lives elsewhere
|
|
920
|
+
/// (a separate terminal / different socket). Sparing the session here would
|
|
921
|
+
/// leak worker processes after shutdown.
|
|
922
|
+
#[test]
|
|
923
|
+
fn e49_external_leader_shutdown_still_kills_team_session_unconditionally() {
|
|
924
|
+
let ws = tmp_shutdown_workspace("e49-external-leader-still-kills");
|
|
925
|
+
crate::state::persist::save_runtime_state(
|
|
926
|
+
&ws,
|
|
927
|
+
&json!({
|
|
928
|
+
"session_name": "team-external",
|
|
929
|
+
"is_external_leader": true,
|
|
930
|
+
"leader_receiver": {"pane_id": "%leaderelsewhere"},
|
|
931
|
+
"agents": {
|
|
932
|
+
"w1": {"status": "running", "provider": "codex", "window": "w1", "pane_id": "%w1"}
|
|
933
|
+
}
|
|
934
|
+
}),
|
|
935
|
+
)
|
|
936
|
+
.unwrap();
|
|
937
|
+
let transport = CleanShutdownTransport::new()
|
|
938
|
+
.with_targets(vec![
|
|
939
|
+
// Worker pane in team session
|
|
940
|
+
PaneInfo {
|
|
941
|
+
pane_id: PaneId::new("%w1"),
|
|
942
|
+
session: SessionName::new("team-external"),
|
|
943
|
+
window_index: Some(0),
|
|
944
|
+
window_name: Some(WindowName::new("w1")),
|
|
945
|
+
pane_index: Some(0),
|
|
946
|
+
tty: None,
|
|
947
|
+
current_command: Some("codex".to_string()),
|
|
948
|
+
current_path: None,
|
|
949
|
+
active: true,
|
|
950
|
+
pane_pid: None,
|
|
951
|
+
leader_env: BTreeMap::new(),
|
|
952
|
+
},
|
|
953
|
+
// Leader pane is in a DIFFERENT session (external topology)
|
|
954
|
+
PaneInfo {
|
|
955
|
+
pane_id: PaneId::new("%leaderelsewhere"),
|
|
956
|
+
session: SessionName::new("user-shell"),
|
|
957
|
+
window_index: Some(0),
|
|
958
|
+
window_name: Some(WindowName::new("shell")),
|
|
959
|
+
pane_index: Some(0),
|
|
960
|
+
tty: None,
|
|
961
|
+
current_command: Some("zsh".to_string()),
|
|
962
|
+
current_path: None,
|
|
963
|
+
active: true,
|
|
964
|
+
pane_pid: None,
|
|
965
|
+
leader_env: BTreeMap::new(),
|
|
966
|
+
},
|
|
967
|
+
])
|
|
968
|
+
.with_targets_persist_after_kill();
|
|
969
|
+
|
|
970
|
+
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
971
|
+
.expect("shutdown should complete");
|
|
972
|
+
|
|
973
|
+
let killed_sessions = transport.killed_sessions_observed();
|
|
974
|
+
assert!(
|
|
975
|
+
killed_sessions.iter().any(|s| s == "team-external"),
|
|
976
|
+
"E49 regression guard: external-leader topology must STILL kill_session \
|
|
977
|
+
the team session — leader pane lives elsewhere so this is safe. Got \
|
|
978
|
+
transport killed_sessions={killed_sessions:?}"
|
|
979
|
+
);
|
|
980
|
+
assert_eq!(out["ok"], json!(true));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/// E49 regression guard: managed topology where the leader anchor pane is NOT
|
|
984
|
+
/// in `state.session_name` (e.g. a stale state from an aborted launch where
|
|
985
|
+
/// only worker panes exist in the session). The pre-fix behaviour (kill_session)
|
|
986
|
+
/// is preserved here — no leader pane is at risk.
|
|
987
|
+
#[test]
|
|
988
|
+
fn e49_managed_leader_without_anchor_in_session_still_kills_session() {
|
|
989
|
+
let ws = tmp_shutdown_workspace("e49-managed-no-anchor-in-session");
|
|
990
|
+
crate::state::persist::save_runtime_state(
|
|
991
|
+
&ws,
|
|
992
|
+
&json!({
|
|
993
|
+
"session_name": "team-orphan",
|
|
994
|
+
"is_external_leader": false,
|
|
995
|
+
"agents": {
|
|
996
|
+
"w1": {"status": "running", "provider": "codex", "window": "w1", "pane_id": "%w1"}
|
|
997
|
+
}
|
|
742
998
|
}),
|
|
743
999
|
)
|
|
744
1000
|
.unwrap();
|
|
745
1001
|
let transport = CleanShutdownTransport::new()
|
|
746
1002
|
.with_targets(vec![PaneInfo {
|
|
747
|
-
pane_id: PaneId::new("%
|
|
748
|
-
session: SessionName::new("team-
|
|
1003
|
+
pane_id: PaneId::new("%w1"),
|
|
1004
|
+
session: SessionName::new("team-orphan"),
|
|
749
1005
|
window_index: Some(0),
|
|
750
|
-
window_name: Some(WindowName::new("
|
|
1006
|
+
window_name: Some(WindowName::new("w1")),
|
|
751
1007
|
pane_index: Some(0),
|
|
752
1008
|
tty: None,
|
|
753
1009
|
current_command: Some("codex".to_string()),
|
|
@@ -761,13 +1017,14 @@ fn managed_leader_shutdown_never_kills_server_even_when_socket_looks_exclusive()
|
|
|
761
1017
|
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
762
1018
|
.expect("shutdown should complete");
|
|
763
1019
|
|
|
764
|
-
|
|
765
|
-
assert_eq!(out["killed_sessions"], json!(["team-current"]));
|
|
766
|
-
assert_eq!(out["spared_sessions"], json!([]));
|
|
1020
|
+
let killed_sessions = transport.killed_sessions_observed();
|
|
767
1021
|
assert!(
|
|
768
|
-
|
|
769
|
-
"
|
|
1022
|
+
killed_sessions.iter().any(|s| s == "team-orphan"),
|
|
1023
|
+
"E49 regression guard: when no leader anchor pane is in the session, \
|
|
1024
|
+
the pre-fix kill_session path is preserved (no leader is at risk). \
|
|
1025
|
+
Got transport killed_sessions={killed_sessions:?}"
|
|
770
1026
|
);
|
|
1027
|
+
assert_eq!(out["ok"], json!(true));
|
|
771
1028
|
}
|
|
772
1029
|
|
|
773
1030
|
#[test]
|
|
@@ -312,6 +312,13 @@ pub struct SendArgs {
|
|
|
312
312
|
/// When set, the store insert uses this id verbatim; a repeat with the same
|
|
313
313
|
/// id returns a `Duplicate` refusal instead of creating a second row.
|
|
314
314
|
pub message_id: Option<String>,
|
|
315
|
+
/// F1 (0.3.26): `--pane <pane_id>` — direct pane-id targeting. Mutually
|
|
316
|
+
/// exclusive with `target` / `targets`. When set, the message is injected
|
|
317
|
+
/// directly into the specified tmux pane via `transport.inject`, bypassing
|
|
318
|
+
/// the agent-name → pane-id resolution + team-membership check. This is
|
|
319
|
+
/// the **cross-team communication** primitive: the target pane does not
|
|
320
|
+
/// need to be in the sender's team.
|
|
321
|
+
pub pane: Option<String>,
|
|
315
322
|
}
|
|
316
323
|
|
|
317
324
|
/// E23 worker-side emergency fallback for `team_orchestrator.send_message`
|
|
@@ -33,6 +33,7 @@ impl Transport for CapturingTransport {
|
|
|
33
33
|
submit_verification: crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
34
34
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
35
35
|
attempts: 1,
|
|
36
|
+
submit_diagnostics: None,
|
|
36
37
|
})
|
|
37
38
|
}
|
|
38
39
|
fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
|
|
@@ -59,6 +59,7 @@ impl Transport for DeliveringTransport {
|
|
|
59
59
|
submit_verification: crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
60
60
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
61
61
|
attempts: 1,
|
|
62
|
+
submit_diagnostics: None,
|
|
62
63
|
})
|
|
63
64
|
}
|
|
64
65
|
fn send_keys(&self, t: &Target, k: &[Key]) -> Result<(), TransportError> {
|
|
@@ -35,6 +35,7 @@ impl Transport for CountingCaptureTransport {
|
|
|
35
35
|
submit_verification: crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
36
36
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
37
37
|
attempts: 1,
|
|
38
|
+
submit_diagnostics: None,
|
|
38
39
|
})
|
|
39
40
|
}
|
|
40
41
|
fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
|
|
@@ -544,13 +544,27 @@ impl Coordinator {
|
|
|
544
544
|
.get("last_output_at")
|
|
545
545
|
.and_then(Value::as_str)
|
|
546
546
|
.map(str::to_string);
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
)
|
|
547
|
+
// E47 (0.3.24 P0, idle/busy 假阳): consult the AUTHORITATIVE
|
|
548
|
+
// provider JSONL classifier BEFORE the TUI keyword scan. The
|
|
549
|
+
// scrollback Tail(40) keeps historical `• Working (...· esc to
|
|
550
|
+
// interrupt)` + spinners within the 40-line window long after a
|
|
551
|
+
// turn completed, so `classify_agent_activity`'s rfind-recency
|
|
552
|
+
// grep flips a truly-idle worker to Working/Stuck. The provider
|
|
553
|
+
// JSONL (codex task_complete / claude end_turn) is the single
|
|
554
|
+
// source of truth for "turn closed". Only fall back to TUI
|
|
555
|
+
// scanning when the JSONL is unreadable or no lifecycle fact has
|
|
556
|
+
// been written yet (TurnState::Unknown) — never silently fall
|
|
557
|
+
// through into the leaky grep on a CONFIRMED IDLE.
|
|
558
|
+
let activity_from_jsonl = jsonl_activity_for_agent(agent);
|
|
559
|
+
let activity = activity_from_jsonl.unwrap_or_else(|| {
|
|
560
|
+
crate::messaging::classify_agent_activity(
|
|
561
|
+
&snapshot,
|
|
562
|
+
&captured.text,
|
|
563
|
+
pane_in_mode,
|
|
564
|
+
current_command.as_deref(),
|
|
565
|
+
last_output_at_now.as_deref(),
|
|
566
|
+
)
|
|
567
|
+
});
|
|
554
568
|
remember_idle_capture_schedule(agent, &activity);
|
|
555
569
|
write_activity(agent, &activity, false);
|
|
556
570
|
let last_output_at = last_output_at_now;
|
|
@@ -2519,6 +2533,40 @@ fn agent_rollout_path(agent: &Value) -> Option<PathBuf> {
|
|
|
2519
2533
|
.map(PathBuf::from)
|
|
2520
2534
|
}
|
|
2521
2535
|
|
|
2536
|
+
/// E47 (0.3.24 P0, idle/busy 假阳): consult the authoritative provider JSONL
|
|
2537
|
+
/// classifier and map to neutral `AgentActivity`. Returns `None` when the
|
|
2538
|
+
/// classifier reports `TurnState::Unknown` (unreadable JSONL / no lifecycle
|
|
2539
|
+
/// fact yet) so the caller falls back to the TUI scan — this honours the
|
|
2540
|
+
/// IRON LAW (activity.rs:3 / bug-071/077/085): no-signal = Uncertain (not
|
|
2541
|
+
/// silently coerced to Idle); but here Unknown means "JSONL gave no signal",
|
|
2542
|
+
/// so we hand off to the TUI scanner which has its OWN no-signal → Uncertain
|
|
2543
|
+
/// path. Copilot/Gemini/Fake providers (which don't have JSONL — classify.rs
|
|
2544
|
+
/// returns Unknown for them) thus keep using TUI scanning unchanged.
|
|
2545
|
+
fn jsonl_activity_for_agent(agent: &Value) -> Option<crate::messaging::AgentActivity> {
|
|
2546
|
+
let rollout_path = agent_rollout_path(agent)?;
|
|
2547
|
+
let provider = agent
|
|
2548
|
+
.get("provider")
|
|
2549
|
+
.and_then(Value::as_str)
|
|
2550
|
+
.and_then(parse_provider)?;
|
|
2551
|
+
let log_text = std::fs::read_to_string(&rollout_path).ok()?;
|
|
2552
|
+
let process = explicit_process_liveness(agent).unwrap_or(ProcessLiveness::Unverifiable);
|
|
2553
|
+
let result = crate::provider::classify(provider, &log_text, process, 0.0).ok()?;
|
|
2554
|
+
use crate::messaging::{ActivityStatus, AgentActivity};
|
|
2555
|
+
use crate::provider::types::TurnState;
|
|
2556
|
+
let status = match result.state {
|
|
2557
|
+
TurnState::Idle => ActivityStatus::Idle,
|
|
2558
|
+
TurnState::IdleInterrupted => ActivityStatus::Idle,
|
|
2559
|
+
TurnState::Working => ActivityStatus::Working,
|
|
2560
|
+
TurnState::BlockedOnHuman | TurnState::Abnormal => ActivityStatus::Uncertain,
|
|
2561
|
+
TurnState::Unknown => return None,
|
|
2562
|
+
};
|
|
2563
|
+
Some(AgentActivity {
|
|
2564
|
+
status,
|
|
2565
|
+
confidence: 0.95,
|
|
2566
|
+
rationale: format!("provider_jsonl:{}", result.reason),
|
|
2567
|
+
})
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2522
2570
|
fn runtime_approval_target(
|
|
2523
2571
|
agent: &Value,
|
|
2524
2572
|
session_name: Option<&str>,
|
|
@@ -581,6 +581,31 @@ fn claim_lease_no_incident_with_target(
|
|
|
581
581
|
));
|
|
582
582
|
}
|
|
583
583
|
}
|
|
584
|
+
// E51 (0.3.26 P0, lease guard): refuse to write the leader binding when the
|
|
585
|
+
// caller pane is a REGISTERED WORKER pane. Without this, claim_leader from a
|
|
586
|
+
// worker's tmux pane overwrites leader_receiver.pane_id with the worker's
|
|
587
|
+
// pane → delivery routes worker messages to itself (loop) and the leader
|
|
588
|
+
// loses its handle (the macmini "hand-handle mapping 灾难" truth source).
|
|
589
|
+
if let Some(worker_id) = registered_worker_for_pane(state, caller_pane) {
|
|
590
|
+
emit_lease_refusal(
|
|
591
|
+
event_log,
|
|
592
|
+
LeaseReason::CallerNotLeaderShaped,
|
|
593
|
+
state,
|
|
594
|
+
bound_pane_id.as_deref(),
|
|
595
|
+
Some(caller_pane.as_str()),
|
|
596
|
+
team_id,
|
|
597
|
+
)?;
|
|
598
|
+
return Ok(refused(
|
|
599
|
+
LeaseReason::CallerNotLeaderShaped,
|
|
600
|
+
&format!(
|
|
601
|
+
"pane {} is registered as worker {worker_id}; \
|
|
602
|
+
run claim-leader from the leader's own pane, not a worker pane",
|
|
603
|
+
caller_pane.as_str()
|
|
604
|
+
),
|
|
605
|
+
None,
|
|
606
|
+
bound_pane_id.clone().map(PaneId::new),
|
|
607
|
+
));
|
|
608
|
+
}
|
|
584
609
|
let reason = if bound_pane_id.is_some() {
|
|
585
610
|
LeaseReason::PreviousOwnerPaneDead
|
|
586
611
|
} else {
|
|
@@ -974,6 +999,38 @@ fn make_owner(
|
|
|
974
999
|
}
|
|
975
1000
|
}
|
|
976
1001
|
|
|
1002
|
+
/// E51 (0.3.26 P0, lease guard): scan state.agents + teams[*].agents for a worker
|
|
1003
|
+
/// whose pane_id matches the caller. Returns the first matching agent_id, or None
|
|
1004
|
+
/// if no registered worker owns this pane. The check is O(N agents) — negligible
|
|
1005
|
+
/// on a team-agent team which rarely exceeds ~20 workers.
|
|
1006
|
+
fn registered_worker_for_pane(state: &Value, caller_pane: &PaneId) -> Option<String> {
|
|
1007
|
+
if let Some(agent_id) = scan_agents_for_pane(state.get("agents"), caller_pane) {
|
|
1008
|
+
return Some(agent_id);
|
|
1009
|
+
}
|
|
1010
|
+
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
1011
|
+
for (_, team_state) in teams {
|
|
1012
|
+
if let Some(agent_id) = scan_agents_for_pane(team_state.get("agents"), caller_pane) {
|
|
1013
|
+
return Some(agent_id);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
None
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
fn scan_agents_for_pane(agents: Option<&Value>, caller_pane: &PaneId) -> Option<String> {
|
|
1021
|
+
let map = agents?.as_object()?;
|
|
1022
|
+
for (agent_id, agent) in map {
|
|
1023
|
+
if agent
|
|
1024
|
+
.get("pane_id")
|
|
1025
|
+
.and_then(Value::as_str)
|
|
1026
|
+
.is_some_and(|pane| pane == caller_pane.as_str())
|
|
1027
|
+
{
|
|
1028
|
+
return Some(agent_id.clone());
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
None
|
|
1032
|
+
}
|
|
1033
|
+
|
|
977
1034
|
fn write_binding_to_state(
|
|
978
1035
|
state: &mut Value,
|
|
979
1036
|
receiver: &LeaderReceiver,
|