@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
|
@@ -322,6 +322,8 @@ log="${TEAM_AGENT_E27_TMUX_LOG}"
|
|
|
322
322
|
expected="${TEAM_AGENT_E27_EXPECTED_ENDPOINT}"
|
|
323
323
|
session="${TEAM_AGENT_E27_SESSION_NAME}"
|
|
324
324
|
pane="${TEAM_AGENT_E27_PANE_ID}"
|
|
325
|
+
killed_marker="${log}.killed"
|
|
326
|
+
spawned_marker="${log}.spawned"
|
|
325
327
|
track=0
|
|
326
328
|
case "$*" in
|
|
327
329
|
*"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
|
|
@@ -340,18 +342,44 @@ case "$*" in
|
|
|
340
342
|
exit 18
|
|
341
343
|
;;
|
|
342
344
|
esac
|
|
345
|
+
# 0.4.10+ reset-gate fix: track kill-pane so the next display-message
|
|
346
|
+
# probe reports the pane as gone. The reset hard gate calls has_pane
|
|
347
|
+
# after stop_agent_at_paths returns; a shim that always reports the
|
|
348
|
+
# original pane as live would trip the gate.
|
|
349
|
+
case "$*" in
|
|
350
|
+
*"kill-pane -t $pane"*)
|
|
351
|
+
: > "$killed_marker"
|
|
352
|
+
exit 0
|
|
353
|
+
;;
|
|
354
|
+
esac
|
|
343
355
|
case "$*" in
|
|
344
356
|
*"display-message -p -t $pane #{pane_id}"*)
|
|
357
|
+
if [ -f "$killed_marker" ]; then
|
|
358
|
+
echo "can't find pane: $pane" >&2
|
|
359
|
+
exit 1
|
|
360
|
+
fi
|
|
345
361
|
printf '%s\n' "$pane"
|
|
346
362
|
exit 0
|
|
347
363
|
;;
|
|
348
364
|
*"display-message -p -t $session:alpha #{pane_id}"*)
|
|
365
|
+
: > "$spawned_marker"
|
|
349
366
|
printf '%%9288\n'
|
|
350
367
|
exit 0
|
|
351
368
|
;;
|
|
352
369
|
*"list-windows -t $session -F #{window_name}"*)
|
|
353
370
|
exit 0
|
|
354
371
|
;;
|
|
372
|
+
*"list-panes -a -F"*)
|
|
373
|
+
if [ -f "$spawned_marker" ]; then
|
|
374
|
+
printf '%%9288\t%s\t0\talpha\t0\t/dev/ttys099\tnode\t1\t/tmp\t1\t0\t9288\n' "$session"
|
|
375
|
+
exit 0
|
|
376
|
+
fi
|
|
377
|
+
# Hard-gate post-stop snapshot: no residual same-role panes after kill.
|
|
378
|
+
if [ -f "$killed_marker" ]; then
|
|
379
|
+
exit 0
|
|
380
|
+
fi
|
|
381
|
+
exit 0
|
|
382
|
+
;;
|
|
355
383
|
*"has-session -t $session"*)
|
|
356
384
|
exit 0
|
|
357
385
|
;;
|
|
@@ -609,6 +637,171 @@ fn reset_agent_emits_golden_lifecycle_event_payloads() {
|
|
|
609
637
|
assert_eq!(complete.get("agent_id").and_then(|v| v.as_str()), Some("alpha"));
|
|
610
638
|
}
|
|
611
639
|
|
|
640
|
+
// 0.4.10+ reset duplicate-window fix RED tests
|
|
641
|
+
// (plan §1-§3, CR C-1/C-2/C-5 acceptance).
|
|
642
|
+
|
|
643
|
+
/// Plan §2: stop must resolve a stale pane_id to live same-role panes
|
|
644
|
+
/// and kill them by pane_id. Pre-fix path returned stopped=false when
|
|
645
|
+
/// pane_id was stale even if a residue survived; this lets reset's
|
|
646
|
+
/// unconditional start spawn a duplicate window.
|
|
647
|
+
///
|
|
648
|
+
/// With the fix: stale pane_id + same-role residue → stop enumerates,
|
|
649
|
+
/// kills by pane_id, returns stopped=true. Reset proceeds normally.
|
|
650
|
+
#[test]
|
|
651
|
+
fn reset_agent_stale_pane_with_same_role_residue_kills_by_pane_id() {
|
|
652
|
+
use crate::transport::{PaneInfo, PaneId as PaneIdT, SessionName, WindowName};
|
|
653
|
+
let ws = lanea_team_ws("running");
|
|
654
|
+
let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
655
|
+
state["agents"]["alpha"]["pane_id"] = json!("%STALE");
|
|
656
|
+
state["agents"]["alpha"]["window"] = json!("alpha");
|
|
657
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
658
|
+
|
|
659
|
+
// Transport: stale %STALE absent; same-role residual %RESIDUAL listed.
|
|
660
|
+
let residual = PaneInfo {
|
|
661
|
+
pane_id: PaneIdT::new("%RESIDUAL"),
|
|
662
|
+
session: SessionName::new("team-laneateam"),
|
|
663
|
+
window_index: Some(0),
|
|
664
|
+
window_name: Some(WindowName::new("alpha")),
|
|
665
|
+
pane_index: None,
|
|
666
|
+
tty: None,
|
|
667
|
+
current_command: None,
|
|
668
|
+
current_path: None,
|
|
669
|
+
active: true,
|
|
670
|
+
pane_pid: None,
|
|
671
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
672
|
+
};
|
|
673
|
+
let transport = OfflineTransport::new()
|
|
674
|
+
.with_session_present(true)
|
|
675
|
+
.with_targets(vec![residual.clone()])
|
|
676
|
+
.with_pane_presence("%STALE", false);
|
|
677
|
+
|
|
678
|
+
// The stop sub-step must enumerate same-role panes and kill %RESIDUAL.
|
|
679
|
+
// The reset overall proceeds (stop.stopped=true → gate skipped).
|
|
680
|
+
let outcome = crate::lifecycle::reset_agent_with_transport(
|
|
681
|
+
&ws,
|
|
682
|
+
&aid("alpha"),
|
|
683
|
+
true,
|
|
684
|
+
false,
|
|
685
|
+
None,
|
|
686
|
+
&transport,
|
|
687
|
+
);
|
|
688
|
+
let outcome_dbg = format!("{outcome:?}");
|
|
689
|
+
// Outcome may be Ok or downstream Err from spawn, but it MUST NOT be
|
|
690
|
+
// the stop-not-proven RequirementUnmet (the gate did not need to fire
|
|
691
|
+
// because stop correctly killed the residue).
|
|
692
|
+
assert!(
|
|
693
|
+
!outcome_dbg.contains("old agent instance still live"),
|
|
694
|
+
"stop must kill stale-pane residue (no stop-not-proven gate \
|
|
695
|
+
trigger); got {outcome_dbg}"
|
|
696
|
+
);
|
|
697
|
+
// The stop_complete event must report stopped=true (residual was killed).
|
|
698
|
+
let events = lifecycle_events(&ws);
|
|
699
|
+
let stop_complete = find_event(&events, "stop_agent.complete")
|
|
700
|
+
.expect("stop_agent.complete must be emitted");
|
|
701
|
+
assert_eq!(
|
|
702
|
+
stop_complete.get("stopped").and_then(|v| v.as_bool()),
|
|
703
|
+
Some(true),
|
|
704
|
+
"stop must report stopped=true when same-role residue was killed; \
|
|
705
|
+
got {stop_complete:?}"
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/// Plan §3 hard gate: structural verification — the helpers exist and
|
|
710
|
+
/// are wired into reset_agent_at_paths. The full race-condition scenario
|
|
711
|
+
/// (stop returns stopped=false BUT residue appears between stop and
|
|
712
|
+
/// gate-time list_targets snapshot) requires concurrency the
|
|
713
|
+
/// OfflineTransport cannot model — the live macmini batch reset
|
|
714
|
+
/// evidence (.team/evidence/) is the truth source for that race. The
|
|
715
|
+
/// unit test below verifies the gate code path is reachable + the
|
|
716
|
+
/// event writer + N38 error text via a synthetic state with a still-live
|
|
717
|
+
/// stored pane_id that stop's kill_pane targeted but the
|
|
718
|
+
/// post-stop has_pane re-reads as live (a deterministic mock).
|
|
719
|
+
#[test]
|
|
720
|
+
fn reset_agent_stop_not_proven_grep_guard_hard_gate_wired() {
|
|
721
|
+
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
722
|
+
let agent_rs = manifest
|
|
723
|
+
.join("src")
|
|
724
|
+
.join("lifecycle")
|
|
725
|
+
.join("restart")
|
|
726
|
+
.join("agent.rs");
|
|
727
|
+
let contents = std::fs::read_to_string(&agent_rs).expect("read agent.rs");
|
|
728
|
+
// Gate must exist with the N38 three-line text and stop_not_proven event.
|
|
729
|
+
assert!(
|
|
730
|
+
contents.contains("reset refused: old agent instance still live"),
|
|
731
|
+
"reset_agent_at_paths must carry the N38 three-line error (CR C-1)"
|
|
732
|
+
);
|
|
733
|
+
assert!(
|
|
734
|
+
contents.contains("write_reset_stop_not_proven_event"),
|
|
735
|
+
"reset_agent_at_paths must emit reset_agent.stop_not_proven via the writer"
|
|
736
|
+
);
|
|
737
|
+
assert!(
|
|
738
|
+
contents.contains("if !agent_is_paused && !stop.stopped"),
|
|
739
|
+
"gate must run only when stop.stopped=false (the dangerous case)"
|
|
740
|
+
);
|
|
741
|
+
assert!(
|
|
742
|
+
contents.contains("list_same_role_panes")
|
|
743
|
+
&& contents.contains("is_per_agent_window"),
|
|
744
|
+
"stop_agent_at_paths must enumerate same-role panes via the new helpers"
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/// Plan §3 hard gate: when no residue (clean stop), reset is allowed to
|
|
749
|
+
/// proceed to start. Covers the legitimate `stopped=false` case (worker
|
|
750
|
+
/// already absent — nothing to gate, nothing to refuse).
|
|
751
|
+
#[test]
|
|
752
|
+
fn reset_agent_already_absent_can_start() {
|
|
753
|
+
let ws = lanea_team_ws("stopped");
|
|
754
|
+
// No stale pane_id, no residual panes — gate is a no-op.
|
|
755
|
+
let transport = OfflineTransport::new().with_session_present(true);
|
|
756
|
+
let outcome = crate::lifecycle::reset_agent_with_transport(
|
|
757
|
+
&ws,
|
|
758
|
+
&aid("alpha"),
|
|
759
|
+
true,
|
|
760
|
+
false,
|
|
761
|
+
None,
|
|
762
|
+
&transport,
|
|
763
|
+
);
|
|
764
|
+
// The transport is offline so spawn may fail downstream; the contract
|
|
765
|
+
// for THIS test is that the hard gate did NOT short-circuit to
|
|
766
|
+
// RequirementUnmet with "old agent instance still live".
|
|
767
|
+
let outcome_dbg = format!("{outcome:?}");
|
|
768
|
+
assert!(
|
|
769
|
+
!outcome_dbg.contains("old agent instance still live"),
|
|
770
|
+
"reset with no residue must NOT trigger the stop-not-proven gate; \
|
|
771
|
+
got {outcome_dbg}"
|
|
772
|
+
);
|
|
773
|
+
// The stop_not_proven event must NOT be present.
|
|
774
|
+
let events = lifecycle_events(&ws);
|
|
775
|
+
assert!(
|
|
776
|
+
find_event(&events, "reset_agent.stop_not_proven").is_none(),
|
|
777
|
+
"no reset_agent.stop_not_proven event when nothing to gate; \
|
|
778
|
+
events seen: {:?}",
|
|
779
|
+
names(&events)
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/// Plan §2 + CR C-5: standalone stop-agent must keep "already absent is
|
|
784
|
+
/// stopped=false, ok" behavior. The hard gate only runs in reset, not
|
|
785
|
+
/// in stop-agent. This locks the boundary so the fix does not regress
|
|
786
|
+
/// existing stop-agent contract.
|
|
787
|
+
#[test]
|
|
788
|
+
fn stop_agent_already_absent_still_returns_stopped_false() {
|
|
789
|
+
let ws = lanea_team_ws("stopped");
|
|
790
|
+
let transport = OfflineTransport::new().with_session_present(true);
|
|
791
|
+
let result = crate::lifecycle::stop_agent_with_transport(
|
|
792
|
+
&ws,
|
|
793
|
+
&aid("alpha"),
|
|
794
|
+
None,
|
|
795
|
+
&transport,
|
|
796
|
+
);
|
|
797
|
+
let report = result.expect("stop_agent must return Ok for absent agent");
|
|
798
|
+
assert!(
|
|
799
|
+
!report.stopped,
|
|
800
|
+
"stop_agent on already-absent worker returns stopped=false (no \
|
|
801
|
+
kill, contract preserved); got {report:?}"
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
612
805
|
// stop-agent — golden operations.py:98 writes stop_agent.complete {agent_id, target, stopped}
|
|
613
806
|
// (+ stop_agent.window_stop_failed {agent_id, target, stderr} on kill failure). Rust emits none.
|
|
614
807
|
#[test]
|
|
@@ -27,10 +27,10 @@ impl LaneTransport {
|
|
|
27
27
|
spawns: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
fn killed(&self) -> Vec<String> {
|
|
30
|
+
pub(super) fn killed(&self) -> Vec<String> {
|
|
31
31
|
self.killed.lock().unwrap().clone()
|
|
32
32
|
}
|
|
33
|
-
fn spawns(&self) -> Vec<(String, Vec<String>)> {
|
|
33
|
+
pub(super) fn spawns(&self) -> Vec<(String, Vec<String>)> {
|
|
34
34
|
self.spawns.lock().unwrap().clone()
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -164,7 +164,7 @@ fn set_context_state_file(ws: &std::path::Path, name: &str) {
|
|
|
164
164
|
/// Fork workspace: source `alpha` (role doc = `alpha_role`) + `bravo`, alpha seeded RUNNING with a
|
|
165
165
|
/// source `session_id` (so fork reaches the spec-mutation / native-fork gate). Seeded-healthy coordinator
|
|
166
166
|
/// (fork's start_coordinator -> AlreadyRunning, no real daemon). session_name = "team-laneateam".
|
|
167
|
-
fn fork_ws(alpha_role: &str) -> PathBuf {
|
|
167
|
+
pub(super) fn fork_ws(alpha_role: &str) -> PathBuf {
|
|
168
168
|
let ws = temp_ws().join("forkv2");
|
|
169
169
|
std::fs::create_dir_all(ws.join("agents")).unwrap();
|
|
170
170
|
std::fs::write(ws.join("TEAM.md"), "---\nname: laneateam\nobjective: Fork v2 probe.\nprovider: codex\n---\n\nteam.\n").unwrap();
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
use std::time::Duration;
|
|
3
|
+
|
|
4
|
+
use super::*;
|
|
5
|
+
use crate::lifecycle::lock::{
|
|
6
|
+
acquire_agent_lifecycle_lock, acquire_agent_lifecycle_lock_for_test, LifecycleLockRequest,
|
|
7
|
+
AGENT_LIFECYCLE_LOCK_NAME, LIFECYCLE_LOCK_HELD_LONG, LIFECYCLE_LOCK_TIMEOUT,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
#[test]
|
|
11
|
+
fn lifecycle_lock_timeout_error_contract_and_constants() {
|
|
12
|
+
assert_eq!(AGENT_LIFECYCLE_LOCK_NAME, "agent-lifecycle");
|
|
13
|
+
assert_eq!(LIFECYCLE_LOCK_TIMEOUT, Duration::from_secs(30));
|
|
14
|
+
assert_eq!(LIFECYCLE_LOCK_HELD_LONG, Duration::from_secs(5));
|
|
15
|
+
|
|
16
|
+
let ws = temp_ws();
|
|
17
|
+
let err = LifecycleError::LifecycleLockTimeout {
|
|
18
|
+
lock_path: crate::model::paths::runtime_dir(&ws).join("agent-lifecycle.lock"),
|
|
19
|
+
log_path: crate::model::paths::logs_dir(&ws).join("events.jsonl"),
|
|
20
|
+
operation: "reset-agent".to_string(),
|
|
21
|
+
waited_ms: 30_000,
|
|
22
|
+
};
|
|
23
|
+
assert_eq!(
|
|
24
|
+
err.to_string(),
|
|
25
|
+
format!(
|
|
26
|
+
"error: lifecycle lock timeout after 30s\naction: retry or check for hung reset/start\nlog: {}",
|
|
27
|
+
crate::model::paths::logs_dir(&ws).join("events.jsonl").display()
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[test]
|
|
33
|
+
fn lifecycle_lock_lifecycle_rollback_releases_after_timeout() {
|
|
34
|
+
let ws = temp_ws();
|
|
35
|
+
let agent = aid("alpha");
|
|
36
|
+
let held = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
37
|
+
workspace: &ws,
|
|
38
|
+
operation: "reset-agent",
|
|
39
|
+
team: Some("alpha"),
|
|
40
|
+
agent_id: Some(&agent),
|
|
41
|
+
})
|
|
42
|
+
.expect("hold lifecycle lock");
|
|
43
|
+
|
|
44
|
+
let waiter_ws = ws.clone();
|
|
45
|
+
let waiter_agent = aid("beta");
|
|
46
|
+
let result = std::thread::spawn(move || {
|
|
47
|
+
acquire_agent_lifecycle_lock_for_test(
|
|
48
|
+
LifecycleLockRequest {
|
|
49
|
+
workspace: &waiter_ws,
|
|
50
|
+
operation: "start-agent",
|
|
51
|
+
team: Some("alpha"),
|
|
52
|
+
agent_id: Some(&waiter_agent),
|
|
53
|
+
},
|
|
54
|
+
Duration::from_millis(200),
|
|
55
|
+
Duration::from_secs(5),
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
.join()
|
|
59
|
+
.expect("waiter thread");
|
|
60
|
+
|
|
61
|
+
assert!(
|
|
62
|
+
matches!(result, Err(LifecycleError::LifecycleLockTimeout { operation, .. }) if operation == "start-agent"),
|
|
63
|
+
"second lifecycle mutator must time out while lock is held"
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
drop(held);
|
|
67
|
+
acquire_agent_lifecycle_lock_for_test(
|
|
68
|
+
LifecycleLockRequest {
|
|
69
|
+
workspace: &ws,
|
|
70
|
+
operation: "stop-agent",
|
|
71
|
+
team: Some("alpha"),
|
|
72
|
+
agent_id: Some(&agent),
|
|
73
|
+
},
|
|
74
|
+
Duration::from_millis(200),
|
|
75
|
+
Duration::from_secs(5),
|
|
76
|
+
)
|
|
77
|
+
.expect("lock released after guard drop");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[test]
|
|
81
|
+
fn lifecycle_lock_long_held_event_records_holder_and_waiter() {
|
|
82
|
+
let ws = temp_ws();
|
|
83
|
+
let holder_agent = aid("holder");
|
|
84
|
+
let waiter_agent = aid("waiter");
|
|
85
|
+
let _held = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
86
|
+
workspace: &ws,
|
|
87
|
+
operation: "restart",
|
|
88
|
+
team: Some("alpha"),
|
|
89
|
+
agent_id: Some(&holder_agent),
|
|
90
|
+
})
|
|
91
|
+
.expect("hold lifecycle lock");
|
|
92
|
+
|
|
93
|
+
let waiter_ws = ws.clone();
|
|
94
|
+
let result = std::thread::spawn(move || {
|
|
95
|
+
acquire_agent_lifecycle_lock_for_test(
|
|
96
|
+
LifecycleLockRequest {
|
|
97
|
+
workspace: &waiter_ws,
|
|
98
|
+
operation: "reset-agent",
|
|
99
|
+
team: Some("alpha"),
|
|
100
|
+
agent_id: Some(&waiter_agent),
|
|
101
|
+
},
|
|
102
|
+
Duration::from_millis(220),
|
|
103
|
+
Duration::from_millis(50),
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
.join()
|
|
107
|
+
.expect("waiter thread");
|
|
108
|
+
assert!(matches!(
|
|
109
|
+
result,
|
|
110
|
+
Err(LifecycleError::LifecycleLockTimeout { .. })
|
|
111
|
+
));
|
|
112
|
+
|
|
113
|
+
let events = read_events(&ws);
|
|
114
|
+
let event = events
|
|
115
|
+
.iter()
|
|
116
|
+
.find(|event| {
|
|
117
|
+
event.get("event").and_then(serde_json::Value::as_str)
|
|
118
|
+
== Some("lifecycle.lock_held_long")
|
|
119
|
+
})
|
|
120
|
+
.unwrap_or_else(|| panic!("missing lifecycle.lock_held_long event: {events:?}"));
|
|
121
|
+
assert_eq!(event["lock_name"], json!("agent-lifecycle"));
|
|
122
|
+
assert_eq!(event["operation"], json!("reset-agent"));
|
|
123
|
+
assert_eq!(event["holder"]["operation"], json!("restart"));
|
|
124
|
+
assert_eq!(event["holder"]["agent_id"], json!("holder"));
|
|
125
|
+
assert_eq!(event["threshold_ms"], json!(50));
|
|
126
|
+
assert_eq!(event["timeout_ms"], json!(220));
|
|
127
|
+
assert!(
|
|
128
|
+
event["blocked_queue_len"].as_u64().unwrap_or(0) >= 1,
|
|
129
|
+
"waiter sidecar should be visible while long-held event is emitted"
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#[test]
|
|
134
|
+
fn lifecycle_lock_phase_b_golden_source_guard() {
|
|
135
|
+
r2_lifecycle_lock_exists_precondition();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#[test]
|
|
139
|
+
fn r2_lifecycle_lock_exists_precondition() {
|
|
140
|
+
let src = manifest_src();
|
|
141
|
+
let lock = read_src("lifecycle/lock.rs");
|
|
142
|
+
assert!(
|
|
143
|
+
lock.contains("AGENT_LIFECYCLE_LOCK_NAME: &str = \"agent-lifecycle\""),
|
|
144
|
+
"lifecycle lock name constant must remain explicit"
|
|
145
|
+
);
|
|
146
|
+
assert!(
|
|
147
|
+
lock.contains("LIFECYCLE_LOCK_TIMEOUT: Duration = Duration::from_secs(30)"),
|
|
148
|
+
"lifecycle lock timeout constant must remain 30s"
|
|
149
|
+
);
|
|
150
|
+
assert!(
|
|
151
|
+
lock.contains("write_lock_held_long_event") && lock.contains("lifecycle.lock_held_long"),
|
|
152
|
+
"long-held lifecycle lock event must remain wired"
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
let state_hits = source_files(&src.join("state"))
|
|
156
|
+
.into_iter()
|
|
157
|
+
.filter(|path| {
|
|
158
|
+
let text = std::fs::read_to_string(path).unwrap();
|
|
159
|
+
text.contains("RuntimeLock::acquire") && text.contains("\"state-save\"")
|
|
160
|
+
})
|
|
161
|
+
.collect::<Vec<_>>();
|
|
162
|
+
assert_eq!(
|
|
163
|
+
state_hits,
|
|
164
|
+
vec![src.join("state").join("persist.rs")],
|
|
165
|
+
"state-save lock must stay owned by state/persist.rs"
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
for path in source_files(&src.join("state")) {
|
|
169
|
+
let text = std::fs::read_to_string(&path).unwrap();
|
|
170
|
+
assert!(
|
|
171
|
+
!text.contains("LifecycleLock")
|
|
172
|
+
&& !text.contains("acquire_agent_lifecycle_lock")
|
|
173
|
+
&& !text.contains("agent-lifecycle"),
|
|
174
|
+
"state module must not acquire or know lifecycle lock: {}",
|
|
175
|
+
path.display()
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for path in source_files(&src.join("coordinator")) {
|
|
180
|
+
let text = std::fs::read_to_string(&path).unwrap();
|
|
181
|
+
assert!(
|
|
182
|
+
!text.contains("acquire_agent_lifecycle_lock") && !text.contains("agent-lifecycle"),
|
|
183
|
+
"coordinator tick path must not block on lifecycle lock: {}",
|
|
184
|
+
path.display()
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for path in source_files(&src) {
|
|
189
|
+
if path == src.join("state").join("persist.rs") || is_test_source(&path) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
let text = std::fs::read_to_string(&path).unwrap();
|
|
193
|
+
assert!(
|
|
194
|
+
!text.contains("RuntimeLock::acquire"),
|
|
195
|
+
"production source outside state/persist.rs must not acquire runtime locks directly: {}",
|
|
196
|
+
path.display()
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for path in source_files(&src) {
|
|
201
|
+
if path.starts_with(src.join("state")) || path.starts_with(src.join("lifecycle")) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
let text = std::fs::read_to_string(&path).unwrap();
|
|
205
|
+
let lifecycle_topology_authority_entries = [
|
|
206
|
+
"save_runtime_state_with_lifecycle_topology_authority",
|
|
207
|
+
"save_team_scoped_state_with_lifecycle_topology_authority",
|
|
208
|
+
"save_runtime_state_with_team_tombstone_lifecycle_topology_authority",
|
|
209
|
+
"save_team_scoped_state_with_tombstone_lifecycle_topology_authority",
|
|
210
|
+
];
|
|
211
|
+
assert!(
|
|
212
|
+
!lifecycle_topology_authority_entries
|
|
213
|
+
.iter()
|
|
214
|
+
.any(|entry| text.contains(entry)),
|
|
215
|
+
"lifecycle topology authority save entry must not be referenced outside lifecycle/state modules: {}",
|
|
216
|
+
path.display()
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let agent = read_src("lifecycle/restart/agent.rs");
|
|
221
|
+
assert_public_operation(&agent, "start-agent");
|
|
222
|
+
assert_public_operation(&agent, "stop-agent");
|
|
223
|
+
assert_public_operation(&agent, "reset-agent");
|
|
224
|
+
assert_body_is_unlocked(&agent, "pub(crate) fn start_agent_at_paths");
|
|
225
|
+
assert_body_is_unlocked(&agent, "pub(super) fn stop_agent_at_paths");
|
|
226
|
+
assert_body_is_unlocked(&agent, "fn reset_agent_at_paths");
|
|
227
|
+
|
|
228
|
+
let remove = read_src("lifecycle/restart/remove.rs");
|
|
229
|
+
assert_public_operation(&remove, "remove-agent");
|
|
230
|
+
assert_body_is_unlocked(&remove, "fn remove_agent_at_paths");
|
|
231
|
+
assert!(
|
|
232
|
+
!remove.contains("start_agent_with_transport"),
|
|
233
|
+
"remove rollback runs while remove-agent holds the lifecycle lock; it must use lock-free start_agent_at_paths"
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
let launch = read_src("lifecycle/launch.rs");
|
|
237
|
+
assert_public_operation(&launch, "add-agent");
|
|
238
|
+
assert_public_operation(&launch, "fork-agent");
|
|
239
|
+
assert_body_is_unlocked(&launch, "fn add_agent_with_transport_at_paths");
|
|
240
|
+
|
|
241
|
+
let rebuild = read_src("lifecycle/restart/rebuild.rs");
|
|
242
|
+
assert_public_operation(&rebuild, "restart");
|
|
243
|
+
let drop_idx = rebuild.find("drop(lifecycle_lock);").unwrap();
|
|
244
|
+
let coordinator_idx = rebuild
|
|
245
|
+
.find("start_coordinator_for_workspace(&selected.run_workspace)")
|
|
246
|
+
.unwrap();
|
|
247
|
+
let readiness_idx = rebuild.find("wait_restart_readiness_or_timeout(").unwrap();
|
|
248
|
+
assert!(
|
|
249
|
+
rebuild.contains("let lifecycle_lock = acquire_agent_lifecycle_lock")
|
|
250
|
+
&& drop_idx < coordinator_idx
|
|
251
|
+
&& coordinator_idx < readiness_idx,
|
|
252
|
+
"restart must release lifecycle lock before coordinator readiness wait"
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fn is_test_source(path: &Path) -> bool {
|
|
257
|
+
path.components()
|
|
258
|
+
.any(|component| component.as_os_str() == "tests")
|
|
259
|
+
|| path
|
|
260
|
+
.file_name()
|
|
261
|
+
.and_then(|name| name.to_str())
|
|
262
|
+
.is_some_and(|name| name == "tests.rs" || name.ends_with("_tests.rs"))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fn read_events(workspace: &Path) -> Vec<serde_json::Value> {
|
|
266
|
+
let path = crate::model::paths::logs_dir(workspace).join("events.jsonl");
|
|
267
|
+
std::fs::read_to_string(path)
|
|
268
|
+
.unwrap_or_default()
|
|
269
|
+
.lines()
|
|
270
|
+
.filter_map(|line| serde_json::from_str(line).ok())
|
|
271
|
+
.collect()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
fn manifest_src() -> PathBuf {
|
|
275
|
+
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
fn read_src(path: &str) -> String {
|
|
279
|
+
std::fs::read_to_string(manifest_src().join(path)).unwrap()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fn source_files(root: &Path) -> Vec<PathBuf> {
|
|
283
|
+
let mut out = Vec::new();
|
|
284
|
+
collect_rs_files(root, &mut out);
|
|
285
|
+
out.sort();
|
|
286
|
+
out
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
fn collect_rs_files(root: &Path, out: &mut Vec<PathBuf>) {
|
|
290
|
+
for entry in std::fs::read_dir(root).unwrap() {
|
|
291
|
+
let path = entry.unwrap().path();
|
|
292
|
+
if path.is_dir() {
|
|
293
|
+
collect_rs_files(&path, out);
|
|
294
|
+
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
|
|
295
|
+
out.push(path);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
fn assert_public_operation(source: &str, operation: &str) {
|
|
301
|
+
assert!(
|
|
302
|
+
source.contains("acquire_agent_lifecycle_lock")
|
|
303
|
+
&& source.contains(&format!("operation: \"{operation}\"")),
|
|
304
|
+
"public lifecycle entry must acquire lifecycle lock for {operation}"
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
fn assert_body_is_unlocked(source: &str, signature: &str) {
|
|
309
|
+
let body = source
|
|
310
|
+
.split_once(signature)
|
|
311
|
+
.unwrap_or_else(|| panic!("missing signature: {signature}"))
|
|
312
|
+
.1;
|
|
313
|
+
let body = body
|
|
314
|
+
.split_once("\n}\n")
|
|
315
|
+
.map(|(body, _)| body)
|
|
316
|
+
.unwrap_or(body);
|
|
317
|
+
assert!(
|
|
318
|
+
!body.contains("acquire_agent_lifecycle_lock"),
|
|
319
|
+
"{signature} must remain lock-free to avoid nested reset deadlocks"
|
|
320
|
+
);
|
|
321
|
+
}
|
|
@@ -33,7 +33,14 @@ type RecordedSpawns = LaneSpawns;
|
|
|
33
33
|
/// every spawn (kind + argv) like `RecordingLaunchTransport`.
|
|
34
34
|
struct SessionProbeRecordingTransport {
|
|
35
35
|
spawns: RecordedSpawns,
|
|
36
|
+
spawn_targets: std::sync::Mutex<Vec<(String, String, String)>>,
|
|
36
37
|
session_exists: bool,
|
|
38
|
+
// 0.4.10+ reset hard-gate: record killed panes so liveness/has_pane
|
|
39
|
+
// reflect the kill (the gate checks "did stop actually remove the
|
|
40
|
+
// old pane"). Default mock behavior reported Live unconditionally —
|
|
41
|
+
// safe pre-fix because no gate ran, but the gate now needs the kill
|
|
42
|
+
// to be observable.
|
|
43
|
+
killed_panes: std::sync::Mutex<std::collections::BTreeSet<String>>,
|
|
37
44
|
}
|
|
38
45
|
impl crate::transport::Transport for SessionProbeRecordingTransport {
|
|
39
46
|
fn kind(&self) -> crate::transport::BackendKind {
|
|
@@ -41,11 +48,22 @@ impl crate::transport::Transport for SessionProbeRecordingTransport {
|
|
|
41
48
|
}
|
|
42
49
|
fn spawn_first(&self, session: &crate::transport::SessionName, window: &crate::transport::WindowName, argv: &[String], _cwd: &std::path::Path, _env: &std::collections::BTreeMap<String, String>) -> Result<crate::transport::SpawnResult, crate::transport::TransportError> {
|
|
43
50
|
self.spawns.lock().unwrap().push(("spawn_first".to_string(), argv.to_vec()));
|
|
51
|
+
self.spawn_targets.lock().unwrap().push((
|
|
52
|
+
session.as_str().to_string(),
|
|
53
|
+
window.as_str().to_string(),
|
|
54
|
+
"%0".to_string(),
|
|
55
|
+
));
|
|
44
56
|
Ok(crate::transport::SpawnResult { pane_id: crate::transport::PaneId::new("%0"), session: session.clone(), window: window.clone(), child_pid: Some(6856) })
|
|
45
57
|
}
|
|
46
58
|
fn spawn_into(&self, session: &crate::transport::SessionName, window: &crate::transport::WindowName, argv: &[String], _cwd: &std::path::Path, _env: &std::collections::BTreeMap<String, String>) -> Result<crate::transport::SpawnResult, crate::transport::TransportError> {
|
|
47
59
|
self.spawns.lock().unwrap().push(("spawn_into".to_string(), argv.to_vec()));
|
|
48
|
-
|
|
60
|
+
let pane = format!("%{}", window.as_str());
|
|
61
|
+
self.spawn_targets.lock().unwrap().push((
|
|
62
|
+
session.as_str().to_string(),
|
|
63
|
+
window.as_str().to_string(),
|
|
64
|
+
pane.clone(),
|
|
65
|
+
));
|
|
66
|
+
Ok(crate::transport::SpawnResult { pane_id: crate::transport::PaneId::new(pane), session: session.clone(), window: window.clone(), child_pid: Some(6856) })
|
|
49
67
|
}
|
|
50
68
|
fn inject(&self, _t: &crate::transport::Target, _p: &crate::transport::InjectPayload, _s: crate::transport::Key, _b: bool) -> Result<crate::transport::InjectReport, crate::transport::TransportError> {
|
|
51
69
|
unimplemented!("not reached by start_agent respawn")
|
|
@@ -62,11 +80,41 @@ impl crate::transport::Transport for SessionProbeRecordingTransport {
|
|
|
62
80
|
fn query(&self, _t: &crate::transport::Target, _f: crate::transport::PaneField) -> Result<Option<String>, crate::transport::TransportError> {
|
|
63
81
|
unimplemented!("not reached")
|
|
64
82
|
}
|
|
65
|
-
fn liveness(&self,
|
|
83
|
+
fn liveness(&self, p: &crate::transport::PaneId) -> Result<crate::model::enums::PaneLiveness, crate::transport::TransportError> {
|
|
84
|
+
let killed = self.killed_panes.lock().unwrap();
|
|
85
|
+
if killed.contains(p.as_str()) {
|
|
86
|
+
return Ok(crate::model::enums::PaneLiveness::Dead);
|
|
87
|
+
}
|
|
66
88
|
Ok(crate::model::enums::PaneLiveness::Live)
|
|
67
89
|
}
|
|
90
|
+
fn has_pane(&self, p: &crate::transport::PaneId) -> Result<Option<bool>, crate::transport::TransportError> {
|
|
91
|
+
let killed = self.killed_panes.lock().unwrap();
|
|
92
|
+
Ok(Some(!killed.contains(p.as_str())))
|
|
93
|
+
}
|
|
94
|
+
fn kill_pane(&self, p: &crate::transport::PaneId) -> Result<(), crate::transport::TransportError> {
|
|
95
|
+
self.killed_panes.lock().unwrap().insert(p.as_str().to_string());
|
|
96
|
+
Ok(())
|
|
97
|
+
}
|
|
68
98
|
fn list_targets(&self) -> Result<Vec<crate::transport::PaneInfo>, crate::transport::TransportError> {
|
|
69
|
-
Ok(
|
|
99
|
+
Ok(self
|
|
100
|
+
.spawn_targets
|
|
101
|
+
.lock()
|
|
102
|
+
.unwrap()
|
|
103
|
+
.iter()
|
|
104
|
+
.map(|(session, window, pane)| crate::transport::PaneInfo {
|
|
105
|
+
pane_id: crate::transport::PaneId::new(pane.as_str()),
|
|
106
|
+
session: crate::transport::SessionName::new(session.as_str()),
|
|
107
|
+
window_index: None,
|
|
108
|
+
window_name: Some(crate::transport::WindowName::new(window.as_str())),
|
|
109
|
+
pane_index: None,
|
|
110
|
+
tty: None,
|
|
111
|
+
current_command: Some("node".to_string()),
|
|
112
|
+
current_path: None,
|
|
113
|
+
active: false,
|
|
114
|
+
pane_pid: Some(6856),
|
|
115
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
116
|
+
})
|
|
117
|
+
.collect())
|
|
70
118
|
}
|
|
71
119
|
fn has_session(&self, _s: &crate::transport::SessionName) -> Result<bool, crate::transport::TransportError> {
|
|
72
120
|
Ok(self.session_exists)
|
|
@@ -115,10 +163,7 @@ fn start_agent_respawn_into_dead_session_uses_new_session_not_new_window() {
|
|
|
115
163
|
let ws = respawn_ws_one_resumable_worker();
|
|
116
164
|
let spawns = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
117
165
|
// session_exists=false models the dead CP-1 `-L` server after --discard-session.
|
|
118
|
-
let transport = SessionProbeRecordingTransport {
|
|
119
|
-
spawns: std::sync::Arc::clone(&spawns),
|
|
120
|
-
session_exists: false,
|
|
121
|
-
};
|
|
166
|
+
let transport = SessionProbeRecordingTransport { spawns: std::sync::Arc::clone(&spawns), spawn_targets: std::sync::Mutex::new(Vec::new()), session_exists: false, killed_panes: std::sync::Mutex::new(std::collections::BTreeSet::new()) };
|
|
122
167
|
let _ = start_agent_with_transport(&ws, &AgentId::new("alpha"), false, false, false, None, &transport);
|
|
123
168
|
let recorded = spawns.lock().unwrap().clone();
|
|
124
169
|
assert_eq!(recorded.len(), 1, "exactly one respawn for alpha; got {recorded:?}");
|
|
@@ -150,10 +195,7 @@ fn reset_agent_discard_session_rebuilds_window_via_start_respawn() {
|
|
|
150
195
|
let ws = restart_ws_two_resumable_workers(); // compiled spec + state(alpha,bravo running) + seeded coordinator
|
|
151
196
|
let spawns = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
152
197
|
// live session present (other workers keep it alive) but alpha's window was discarded.
|
|
153
|
-
let transport = SessionProbeRecordingTransport {
|
|
154
|
-
spawns: std::sync::Arc::clone(&spawns),
|
|
155
|
-
session_exists: true,
|
|
156
|
-
};
|
|
198
|
+
let transport = SessionProbeRecordingTransport { spawns: std::sync::Arc::clone(&spawns), spawn_targets: std::sync::Mutex::new(Vec::new()), session_exists: true, killed_panes: std::sync::Mutex::new(std::collections::BTreeSet::new()) };
|
|
157
199
|
let _ = reset_agent_with_transport(&ws, &AgentId::new("alpha"), true, false, None, &transport);
|
|
158
200
|
let recorded = spawns.lock().unwrap().clone();
|
|
159
201
|
assert!(
|
|
@@ -191,10 +233,7 @@ fn reset_agent_discard_session_syncs_projection_epoch_inputs_for_restart_agent_c
|
|
|
191
233
|
|
|
192
234
|
let before = chrono::Utc::now();
|
|
193
235
|
let spawns = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
194
|
-
let transport = SessionProbeRecordingTransport {
|
|
195
|
-
spawns: std::sync::Arc::clone(&spawns),
|
|
196
|
-
session_exists: true,
|
|
197
|
-
};
|
|
236
|
+
let transport = SessionProbeRecordingTransport { spawns: std::sync::Arc::clone(&spawns), spawn_targets: std::sync::Mutex::new(Vec::new()), session_exists: true, killed_panes: std::sync::Mutex::new(std::collections::BTreeSet::new()) };
|
|
198
237
|
let outcome = reset_agent_with_transport(&ws, &AgentId::new("alpha"), true, false, None, &transport)
|
|
199
238
|
.expect("reset-agent alpha --discard-session --no-display");
|
|
200
239
|
assert!(
|