@team-agent/installer 0.5.105 → 0.5.106
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/README.md +5 -3
- package/README.zh.md +4 -3
- package/crates/team-agent/src/cli/adapters.rs +15 -1
- package/crates/team-agent/src/cli/emit.rs +33 -13
- package/crates/team-agent/src/cli/mod.rs +148 -21
- package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
- package/crates/team-agent/src/cli/tests/run_delegation.rs +4 -2
- package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +22 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +43 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +71 -0
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +6 -2
- package/crates/team-agent/src/state/projection.rs +15 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +137 -0
- package/crates/team-agent/src/tmux_backend.rs +110 -0
- package/crates/team-agent/src/transport/test_support.rs +35 -0
- package/crates/team-agent/src/transport/tests/wire.rs +32 -3
- package/crates/team-agent/src/transport.rs +26 -12
- package/package.json +4 -4
- package/skills/team-agent/references/recovery-runbook.md +2 -2
|
@@ -509,6 +509,8 @@ fn restart_with_selected_team_and_transport(
|
|
|
509
509
|
let live_worker_session_deferred_teardown =
|
|
510
510
|
session_live_or_default(transport, &session_name, false)
|
|
511
511
|
&& !collect_live_agents_from_state(&state).is_empty();
|
|
512
|
+
let inherited_generation =
|
|
513
|
+
crate::state::projection::session_generation(&state).map(str::to_string);
|
|
512
514
|
let same_role_cohort_targets =
|
|
513
515
|
same_role_cohort_targets_from_decisions(&state, spec_workspace, &plan.decisions);
|
|
514
516
|
let pre_spawn_pane_ids = if live_worker_session_deferred_teardown {
|
|
@@ -998,6 +1000,25 @@ fn restart_with_selected_team_and_transport(
|
|
|
998
1000
|
}
|
|
999
1001
|
}
|
|
1000
1002
|
}
|
|
1003
|
+
if !fatal_resume_failure && !successful_agents.is_empty() {
|
|
1004
|
+
let generation = if live_worker_session_deferred_teardown {
|
|
1005
|
+
inherited_generation
|
|
1006
|
+
} else {
|
|
1007
|
+
// A recreated session gets the first successful NEW worker's birth,
|
|
1008
|
+
// not an arbitrary old stopped worker still present in the roster.
|
|
1009
|
+
successful_agents.first().and_then(|agent| {
|
|
1010
|
+
state
|
|
1011
|
+
.get("agents")?
|
|
1012
|
+
.get(agent.agent_id.as_str())?
|
|
1013
|
+
.get("spawned_at")?
|
|
1014
|
+
.as_str()
|
|
1015
|
+
.map(str::to_string)
|
|
1016
|
+
})
|
|
1017
|
+
};
|
|
1018
|
+
if let Some(generation) = generation {
|
|
1019
|
+
state["generation"] = serde_json::json!(generation);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1001
1022
|
save_restart_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
|
|
1002
1023
|
&selected.run_workspace,
|
|
1003
1024
|
&mut state,
|
|
@@ -1040,23 +1061,9 @@ fn restart_with_selected_team_and_transport(
|
|
|
1040
1061
|
attach_commands,
|
|
1041
1062
|
});
|
|
1042
1063
|
}
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
|
|
1046
|
-
if let Some(generation) = state
|
|
1047
|
-
.get("generation")
|
|
1048
|
-
.and_then(serde_json::Value::as_str)
|
|
1049
|
-
.or_else(|| {
|
|
1050
|
-
state
|
|
1051
|
-
.get("agents")
|
|
1052
|
-
.and_then(serde_json::Value::as_object)
|
|
1053
|
-
.and_then(|agents| {
|
|
1054
|
-
agents.values().find_map(|agent| {
|
|
1055
|
-
agent.get("spawned_at").and_then(serde_json::Value::as_str)
|
|
1056
|
-
})
|
|
1057
|
-
})
|
|
1058
|
-
})
|
|
1059
|
-
{
|
|
1064
|
+
// Stamp the physical session birth after durable state. Replacing workers
|
|
1065
|
+
// in a retained session does not create a new session generation.
|
|
1066
|
+
if let Some(generation) = crate::state::projection::session_generation(&state) {
|
|
1060
1067
|
transport
|
|
1061
1068
|
.set_session_owner_with_generation(
|
|
1062
1069
|
&session_name,
|
|
@@ -505,6 +505,7 @@ session="${TEAM_AGENT_E27_SESSION_NAME}"
|
|
|
505
505
|
pane="${TEAM_AGENT_E27_PANE_ID}"
|
|
506
506
|
killed_marker="${log}.killed"
|
|
507
507
|
spawned_marker="${log}.spawned"
|
|
508
|
+
anchor_marker="${log}.anchor"
|
|
508
509
|
track=0
|
|
509
510
|
case "$*" in
|
|
510
511
|
*"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
|
|
@@ -544,6 +545,23 @@ case "$*" in
|
|
|
544
545
|
;;
|
|
545
546
|
esac
|
|
546
547
|
case "$*" in
|
|
548
|
+
*"list-panes -s -t =$session -F"*)
|
|
549
|
+
if [ -f "$anchor_marker" ]; then
|
|
550
|
+
printf '%%9299\t[team-agent keepalive]\n'
|
|
551
|
+
fi
|
|
552
|
+
if [ ! -f "$killed_marker" ]; then
|
|
553
|
+
printf '%s\talpha\n' "$pane"
|
|
554
|
+
fi
|
|
555
|
+
if [ -f "$spawned_marker" ]; then
|
|
556
|
+
printf '%%9288\talpha\n'
|
|
557
|
+
fi
|
|
558
|
+
exit 0
|
|
559
|
+
;;
|
|
560
|
+
*"new-window "*"[team-agent keepalive]"*)
|
|
561
|
+
: > "$anchor_marker"
|
|
562
|
+
printf '%%9299\n'
|
|
563
|
+
exit 0
|
|
564
|
+
;;
|
|
547
565
|
*"new-window "*|*"new-session "*)
|
|
548
566
|
: > "$spawned_marker"
|
|
549
567
|
printf '%%9288\n'
|
|
@@ -651,6 +669,10 @@ fn e27_stop_agent_uses_attached_explicit_state_socket() {
|
|
|
651
669
|
"attached explicit-socket worker should be stopped"
|
|
652
670
|
);
|
|
653
671
|
assert_only_expected_socket_used(&shim.log, endpoint);
|
|
672
|
+
let raw = std::fs::read_to_string(&shim.log).unwrap();
|
|
673
|
+
assert!(raw.contains(&format!("list-panes -s -t ={session_name}")));
|
|
674
|
+
assert!(raw.contains("set-window-option -t %9299 remain-on-exit on"));
|
|
675
|
+
assert!(raw.contains("respawn-pane -k -t %9299 exit 0"));
|
|
654
676
|
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
655
677
|
assert_eq!(
|
|
656
678
|
state
|
|
@@ -73,6 +73,8 @@ pub(super) struct LaneTransport {
|
|
|
73
73
|
killed: LaneKills,
|
|
74
74
|
spawns: LaneSpawns,
|
|
75
75
|
spawn_observed_at: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
|
|
76
|
+
stop_preservation: std::sync::Mutex<Vec<(Vec<crate::transport::Target>, Vec<String>)>>,
|
|
77
|
+
fail_stop_preservation: bool,
|
|
76
78
|
}
|
|
77
79
|
impl LaneTransport {
|
|
78
80
|
pub(super) fn new(session: &str, windows: &[&str]) -> Self {
|
|
@@ -85,6 +87,8 @@ impl LaneTransport {
|
|
|
85
87
|
killed: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
86
88
|
spawns: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
87
89
|
spawn_observed_at: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
90
|
+
stop_preservation: std::sync::Mutex::new(Vec::new()),
|
|
91
|
+
fail_stop_preservation: false,
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
pub(super) fn killed(&self) -> Vec<String> {
|
|
@@ -297,6 +301,21 @@ impl crate::transport::Transport for LaneTransport {
|
|
|
297
301
|
) -> Result<crate::transport::SetEnvOutcome, crate::transport::TransportError> {
|
|
298
302
|
Ok(crate::transport::SetEnvOutcome::Applied)
|
|
299
303
|
}
|
|
304
|
+
fn preserve_session_before_stop(
|
|
305
|
+
&self,
|
|
306
|
+
session: &crate::transport::SessionName,
|
|
307
|
+
targets: &[crate::transport::Target],
|
|
308
|
+
) -> Result<(), crate::transport::TransportError> {
|
|
309
|
+
self.stop_preservation
|
|
310
|
+
.lock()
|
|
311
|
+
.unwrap()
|
|
312
|
+
.push((targets.to_vec(), self.killed()));
|
|
313
|
+
if self.fail_stop_preservation {
|
|
314
|
+
Err(crate::transport::TransportError::TargetNotFound { target: session.as_str().to_string() })
|
|
315
|
+
} else {
|
|
316
|
+
Ok(())
|
|
317
|
+
}
|
|
318
|
+
}
|
|
300
319
|
fn kill_session(
|
|
301
320
|
&self,
|
|
302
321
|
_s: &crate::transport::SessionName,
|
|
@@ -408,6 +427,30 @@ fn lanea_one_agent_ws(alpha_status: &str) -> PathBuf {
|
|
|
408
427
|
ws
|
|
409
428
|
}
|
|
410
429
|
|
|
430
|
+
#[test]
|
|
431
|
+
fn stop_session_preservation_runs_before_kill_and_failure_preserves_worker_state() {
|
|
432
|
+
for fail in [false, true] {
|
|
433
|
+
let ws = lanea_one_agent_ws("running");
|
|
434
|
+
let mut tx = LaneTransport::new("team-laneateam", &["alpha"]);
|
|
435
|
+
tx.fail_stop_preservation = fail;
|
|
436
|
+
let result = stop_agent_with_transport(&ws, &aid("alpha"), None, &tx);
|
|
437
|
+
let calls = tx.stop_preservation.lock().unwrap();
|
|
438
|
+
assert_eq!(calls.len(), 1);
|
|
439
|
+
assert_eq!(calls[0].1, Vec::<String>::new(), "preserve must precede all worker kills");
|
|
440
|
+
assert_eq!(calls[0].0, vec![crate::transport::Target::Pane(crate::transport::PaneId::new("%alpha"))]);
|
|
441
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
442
|
+
if fail {
|
|
443
|
+
assert!(result.is_err());
|
|
444
|
+
assert!(tx.killed().is_empty());
|
|
445
|
+
assert_eq!(state["agents"]["alpha"]["status"], "running");
|
|
446
|
+
} else {
|
|
447
|
+
assert!(result.unwrap().stopped);
|
|
448
|
+
assert_eq!(tx.killed(), vec!["%alpha".to_string()]);
|
|
449
|
+
assert_eq!(state["agents"]["alpha"]["status"], "stopped");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
411
454
|
fn selected_spec_path(ws: &std::path::Path) -> std::path::PathBuf {
|
|
412
455
|
crate::state::selector::resolve_active_team(
|
|
413
456
|
ws,
|
|
@@ -4,6 +4,7 @@ mod hermetic_guard;
|
|
|
4
4
|
use super::*;
|
|
5
5
|
use crate::tmux_backend::TmuxBackend;
|
|
6
6
|
use crate::transport::test_support::OfflineTransport;
|
|
7
|
+
use crate::transport::Transport;
|
|
7
8
|
use serde_json::json;
|
|
8
9
|
use serial_test::serial;
|
|
9
10
|
|
|
@@ -1231,6 +1232,64 @@ fn launch_with_transport_records_one_spawn_per_agent_carrying_build_command() {
|
|
|
1231
1232
|
"LaunchReport.started must list the compiled agent; got {:?}",
|
|
1232
1233
|
report.started
|
|
1233
1234
|
);
|
|
1235
|
+
let workspace = crate::model::paths::team_workspace(&team).unwrap();
|
|
1236
|
+
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
1237
|
+
let generation = report.started[0].spawned_at.as_str();
|
|
1238
|
+
assert_eq!(state["generation"], generation);
|
|
1239
|
+
let team_key = crate::state::projection::team_state_key(&state);
|
|
1240
|
+
assert_eq!(state["teams"][&team_key]["generation"], generation);
|
|
1241
|
+
let owner = transport.session_owner(&SessionName::new("team-quickteam")).unwrap().unwrap();
|
|
1242
|
+
assert_eq!(owner.generation, generation);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
#[test]
|
|
1246
|
+
#[serial(env)]
|
|
1247
|
+
fn start_agent_session_generation_inherits_birth_or_stamps_recreated_session() {
|
|
1248
|
+
for canonical_state in [false, true] {
|
|
1249
|
+
for existing_session in [true, false] {
|
|
1250
|
+
for explicit_generation in [true, false] {
|
|
1251
|
+
let ws = restart_ws_one_resumable_worker();
|
|
1252
|
+
let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1253
|
+
let birth = "2026-07-14T00:00:00+00:00";
|
|
1254
|
+
state["agents"]["alpha"]["status"] = json!("stopped");
|
|
1255
|
+
state["agents"]["alpha"]["spawned_at"] = json!(birth);
|
|
1256
|
+
if explicit_generation {
|
|
1257
|
+
state["generation"] = json!("explicit-session-birth");
|
|
1258
|
+
}
|
|
1259
|
+
let team_key = crate::state::projection::team_state_key(&state);
|
|
1260
|
+
if canonical_state {
|
|
1261
|
+
state["team_key"] = json!(team_key);
|
|
1262
|
+
state["active_team_key"] = json!(team_key);
|
|
1263
|
+
state["teams"] = json!({});
|
|
1264
|
+
state["teams"][&team_key] = crate::state::projection::compact_team_state(&state);
|
|
1265
|
+
}
|
|
1266
|
+
let inherited = if explicit_generation { "explicit-session-birth" } else { birth };
|
|
1267
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
1268
|
+
let transport = OfflineTransport::new()
|
|
1269
|
+
.with_session_present(existing_session)
|
|
1270
|
+
.with_session_owner(&ws, &team_key, inherited);
|
|
1271
|
+
let result = start_agent_with_transport(
|
|
1272
|
+
&ws, &aid("alpha"), false, false, false, None, &transport,
|
|
1273
|
+
).unwrap();
|
|
1274
|
+
assert!(matches!(result, StartAgentOutcome::Running { .. }), "{result:?}");
|
|
1275
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1276
|
+
let spawned_at = state["agents"]["alpha"]["spawned_at"].as_str().unwrap();
|
|
1277
|
+
assert_ne!(spawned_at, birth);
|
|
1278
|
+
let expected = if existing_session { inherited } else { spawned_at };
|
|
1279
|
+
assert_eq!(state["generation"], expected);
|
|
1280
|
+
if canonical_state {
|
|
1281
|
+
assert_eq!(state["teams"][&team_key]["generation"], expected);
|
|
1282
|
+
} else {
|
|
1283
|
+
// Legacy flat state stays flat; generation persistence does
|
|
1284
|
+
// not implicitly migrate it to a canonical teams table.
|
|
1285
|
+
assert!(state.get("teams").is_none());
|
|
1286
|
+
}
|
|
1287
|
+
let owner = transport.session_owner(&SessionName::new("team-restartone")).unwrap().unwrap();
|
|
1288
|
+
assert_eq!(owner.generation, expected);
|
|
1289
|
+
assert_eq!(transport.calls().contains(&"set_session_owner_with_generation"), !existing_session);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1234
1293
|
}
|
|
1235
1294
|
|
|
1236
1295
|
// ═════════════════════════════════════════════════════════════════════════
|
|
@@ -1704,6 +1763,9 @@ fn restart_ws_one_resumable_worker() -> PathBuf {
|
|
|
1704
1763
|
#[test]
|
|
1705
1764
|
fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
1706
1765
|
let ws = restart_ws_two_resumable_workers();
|
|
1766
|
+
let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1767
|
+
state["generation"] = json!("old-destroyed-session-birth");
|
|
1768
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
1707
1769
|
let transport = OfflineTransport::new();
|
|
1708
1770
|
|
|
1709
1771
|
let result = restart_with_transport(&ws, false, None, &transport);
|
|
@@ -1731,6 +1793,13 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
|
|
|
1731
1793
|
matches!(result, Ok(RestartReport::Restarted { coordinator_started: true, .. })),
|
|
1732
1794
|
"restart must reach RestartReport::Restarted with coordinator_started=true (AlreadyRunning, seeded); got {result:?}"
|
|
1733
1795
|
);
|
|
1796
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1797
|
+
let generation = state["agents"]["alpha"]["spawned_at"].as_str().unwrap();
|
|
1798
|
+
assert_ne!(generation, "old-destroyed-session-birth");
|
|
1799
|
+
assert_eq!(state["generation"], generation);
|
|
1800
|
+
assert!(state.get("teams").is_none(), "legacy flat state must remain flat");
|
|
1801
|
+
let owner = transport.session_owner(&SessionName::new("team-restartteam")).unwrap().unwrap();
|
|
1802
|
+
assert_eq!(owner.generation, generation);
|
|
1734
1803
|
let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
|
|
1735
1804
|
let completed = events
|
|
1736
1805
|
.iter()
|
|
@@ -3071,6 +3140,8 @@ fn quick_start_state_seeds_spec_path_workspace_leader() {
|
|
|
3071
3140
|
// `ResolvedTransport.source` through the launch call site.
|
|
3072
3141
|
"transport",
|
|
3073
3142
|
"active_team_key",
|
|
3143
|
+
// Persist physical session birth independently of worker respawns.
|
|
3144
|
+
"generation",
|
|
3074
3145
|
"teams",
|
|
3075
3146
|
],
|
|
3076
3147
|
"state.json top-level key order must match golden launch/core.py:62-71 \
|
|
@@ -180,8 +180,10 @@ fn scoped_shutdown_after_restart_refreshes_owner_and_preserves_sibling() {
|
|
|
180
180
|
let report = restart_with_transport(&case.workspace, true, Some(TEAM), &transport).unwrap();
|
|
181
181
|
assert!(matches!(report, RestartReport::Restarted { .. }), "{report:?}");
|
|
182
182
|
let state = case.read_state();
|
|
183
|
-
let expected_generation =
|
|
184
|
-
assert_ne!(
|
|
183
|
+
let expected_generation = "2026-07-14T00:00:00+00:00";
|
|
184
|
+
assert_ne!(state["teams"][TEAM]["agents"]["w1"]["spawned_at"], expected_generation);
|
|
185
|
+
assert_eq!(state["generation"], expected_generation);
|
|
186
|
+
assert_eq!(state["teams"][TEAM]["generation"], expected_generation);
|
|
185
187
|
let owner = transport.session_owner(&session).unwrap().unwrap();
|
|
186
188
|
assert_eq!(owner.workspace, case.workspace.canonicalize().unwrap().to_string_lossy());
|
|
187
189
|
assert_eq!(owner.team, TEAM);
|
|
@@ -213,8 +215,10 @@ fn failed_replacement_preserves_original_session_owner() {
|
|
|
213
215
|
&session, &case.workspace, TEAM, "2026-07-14T00:00:00+00:00",
|
|
214
216
|
).unwrap();
|
|
215
217
|
let owner_before = transport.session_owner(&session).unwrap();
|
|
218
|
+
let generation_before = case.read_state().get("generation").cloned();
|
|
216
219
|
let report = restart_with_transport(&case.workspace, true, Some(TEAM), &transport).unwrap();
|
|
217
220
|
assert_restart_did_not_succeed(&report);
|
|
221
|
+
assert_eq!(case.read_state().get("generation").cloned(), generation_before);
|
|
218
222
|
assert_eq!(transport.session_owner(&session).unwrap(), owner_before);
|
|
219
223
|
assert_eq!(transport.ops().iter().filter(|op| op.starts_with("set_session_owner:")).count(), 1);
|
|
220
224
|
}
|
|
@@ -26,6 +26,21 @@ use crate::state::persist::{
|
|
|
26
26
|
|
|
27
27
|
pub(crate) const CURRENT_TEAM_ALIAS: &str = "current";
|
|
28
28
|
|
|
29
|
+
/// Physical tmux session birth identity, independent of later worker spawns.
|
|
30
|
+
/// Legacy launches did not persist it: freeze their fallback BEFORE respawn.
|
|
31
|
+
pub fn session_generation(state: &Value) -> Option<&str> {
|
|
32
|
+
state.get("generation").and_then(Value::as_str).or_else(|| {
|
|
33
|
+
state
|
|
34
|
+
.get("agents")
|
|
35
|
+
.and_then(Value::as_object)
|
|
36
|
+
.and_then(|agents| {
|
|
37
|
+
agents.values().find_map(|agent| {
|
|
38
|
+
agent.get("spawned_at").and_then(Value::as_str)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
///
|
|
30
45
|
/// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
|
|
31
46
|
/// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
|
|
@@ -355,6 +355,21 @@ fn backend_with(default: MockResp, queued: Vec<MockResp>) -> (TmuxBackend, Recor
|
|
|
355
355
|
(TmuxBackend::with_runner(Box::new(runner)), recorded)
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
#[test]
|
|
359
|
+
fn pane_dead_observation_requires_native_boolean_receipt() {
|
|
360
|
+
for (text, expected) in [("1\n", Some(true)), ("0\n", Some(false)), ("", None), ("dead", None), ("2", None)] {
|
|
361
|
+
let (backend, recorded) = backend_with(MockResp::Out(ok(text)), Vec::new());
|
|
362
|
+
assert_eq!(backend.pane_is_dead(&PaneId::new("%47")).unwrap(), expected);
|
|
363
|
+
assert_eq!(recorded.lock().unwrap()[0], svec(&[
|
|
364
|
+
"tmux", "display-message", "-p", "-t", "%47", "#{pane_dead}",
|
|
365
|
+
]));
|
|
366
|
+
}
|
|
367
|
+
let (backend, _) = backend_with(MockResp::Out(fail(1, "pane unavailable")), Vec::new());
|
|
368
|
+
assert_eq!(backend.pane_is_dead(&PaneId::new("%47")).unwrap(), None);
|
|
369
|
+
let (backend, _) = backend_with(MockResp::Io(std::io::ErrorKind::TimedOut), Vec::new());
|
|
370
|
+
assert!(backend.pane_is_dead(&PaneId::new("%47")).is_err());
|
|
371
|
+
}
|
|
372
|
+
|
|
358
373
|
#[test]
|
|
359
374
|
fn provider_exit_receipt_accepts_only_shell_exit_status() {
|
|
360
375
|
for (text, expected) in [
|
|
@@ -2202,6 +2217,128 @@ fn kill_session_and_kill_window_argv() {
|
|
|
2202
2217
|
);
|
|
2203
2218
|
}
|
|
2204
2219
|
|
|
2220
|
+
#[test]
|
|
2221
|
+
fn stop_session_preservation_creates_processless_anchor_by_exact_pane_id() {
|
|
2222
|
+
let (be, rec) = backend_with(
|
|
2223
|
+
MockResp::Out(ok("")),
|
|
2224
|
+
vec![MockResp::Out(ok("%7\tworker\n")), MockResp::Out(ok("%8\n"))],
|
|
2225
|
+
);
|
|
2226
|
+
be.preserve_session_before_stop(
|
|
2227
|
+
&SessionName::new("sess"),
|
|
2228
|
+
&[Target::Pane(PaneId::new("%7"))],
|
|
2229
|
+
).unwrap();
|
|
2230
|
+
let calls = rec.lock().unwrap();
|
|
2231
|
+
assert_eq!(calls.len(), 4);
|
|
2232
|
+
assert_eq!(calls[0], svec(&[
|
|
2233
|
+
"tmux", "list-panes", "-s", "-t", "=sess", "-F", "#{pane_id}\t#{window_name}",
|
|
2234
|
+
]));
|
|
2235
|
+
assert_eq!(calls[1], tmux_spawn_argv(
|
|
2236
|
+
&SessionName::new("=sess"), &WindowName::new("[team-agent keepalive]"),
|
|
2237
|
+
"exec sleep 86400", false,
|
|
2238
|
+
));
|
|
2239
|
+
assert_eq!(calls[2], svec(&[
|
|
2240
|
+
"tmux", "set-window-option", "-t", "%8", "remain-on-exit", "on",
|
|
2241
|
+
]));
|
|
2242
|
+
assert_eq!(calls[3], svec(&["tmux", "respawn-pane", "-k", "-t", "%8", "exit 0"]));
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
#[test]
|
|
2246
|
+
fn stop_session_preservation_skips_surviving_workers_leaders_and_anchors() {
|
|
2247
|
+
for survivor in ["other-worker", "leader", "[team-agent keepalive]", "worker"] {
|
|
2248
|
+
let (be, rec) = backend_with(
|
|
2249
|
+
MockResp::Out(ok(&format!("%7\tworker\n%8\t{survivor}\n"))), vec![],
|
|
2250
|
+
);
|
|
2251
|
+
be.preserve_session_before_stop(
|
|
2252
|
+
&SessionName::new("sess"), &[Target::Pane(PaneId::new("%7"))],
|
|
2253
|
+
).unwrap();
|
|
2254
|
+
assert_eq!(rec.lock().unwrap().len(), 1, "survivor={survivor}");
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
#[test]
|
|
2259
|
+
fn stop_session_preservation_covers_duplicate_panes_and_legacy_window_targets() {
|
|
2260
|
+
for targets in [
|
|
2261
|
+
vec![Target::Pane(PaneId::new("%7")), Target::Pane(PaneId::new("%8"))],
|
|
2262
|
+
vec![Target::SessionWindow { session: SessionName::new("sess"), window: WindowName::new("worker") }],
|
|
2263
|
+
] {
|
|
2264
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), vec![
|
|
2265
|
+
MockResp::Out(ok("%7\tworker\n%8\tworker\n")), MockResp::Out(ok("%9\n")),
|
|
2266
|
+
]);
|
|
2267
|
+
be.preserve_session_before_stop(&SessionName::new("sess"), &targets).unwrap();
|
|
2268
|
+
assert_eq!(rec.lock().unwrap().len(), 4);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
#[test]
|
|
2273
|
+
fn stop_session_preservation_refuses_unknown_listing_and_rolls_back_failed_anchor() {
|
|
2274
|
+
for response in [fail(1, "listing failed"), ok(""), ok("malformed")] {
|
|
2275
|
+
let (be, rec) = backend_with(MockResp::Out(response), vec![]);
|
|
2276
|
+
assert!(be.preserve_session_before_stop(
|
|
2277
|
+
&SessionName::new("sess"), &[Target::Pane(PaneId::new("%7"))],
|
|
2278
|
+
).is_err());
|
|
2279
|
+
assert_eq!(rec.lock().unwrap().len(), 1);
|
|
2280
|
+
}
|
|
2281
|
+
for failed_step in ["new-window", "set-window-option", "respawn-pane"] {
|
|
2282
|
+
let mut queued = vec![MockResp::Out(ok("%7\tworker\n"))];
|
|
2283
|
+
if failed_step != "new-window" { queued.push(MockResp::Out(ok("%8\n"))); }
|
|
2284
|
+
if failed_step == "respawn-pane" { queued.push(MockResp::Out(ok(""))); }
|
|
2285
|
+
queued.push(MockResp::Out(fail(1, "anchor failed")));
|
|
2286
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), queued);
|
|
2287
|
+
assert!(be.preserve_session_before_stop(
|
|
2288
|
+
&SessionName::new("sess"), &[Target::Pane(PaneId::new("%7"))],
|
|
2289
|
+
).is_err());
|
|
2290
|
+
let calls = rec.lock().unwrap();
|
|
2291
|
+
assert!(!calls.iter().any(|argv| argv.get(1).is_some_and(|op| op == "kill-window")));
|
|
2292
|
+
if failed_step != "new-window" {
|
|
2293
|
+
assert_eq!(calls.last().unwrap(), &svec(&["tmux", "kill-pane", "-t", "%8"]));
|
|
2294
|
+
}
|
|
2295
|
+
assert!(!calls.iter().any(|argv| argv.get(1).is_some_and(|op| op == "kill-pane") && argv.contains(&"%7".to_string())));
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
#[test]
|
|
2300
|
+
#[ignore = "isolated native tmux: executed explicitly on the remote test host"]
|
|
2301
|
+
fn stop_session_preservation_native_tmux_stop_start_and_shutdown() {
|
|
2302
|
+
let tmp = std::env::temp_dir().join(format!(
|
|
2303
|
+
"ta-preserve-{}-{}", std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap(),
|
|
2304
|
+
));
|
|
2305
|
+
std::fs::create_dir(&tmp).unwrap();
|
|
2306
|
+
let endpoint = tmp.join("tmux.sock");
|
|
2307
|
+
let be = TmuxBackend::with_runner_for_tmux_endpoint(Box::new(RealCommandRunner), endpoint.to_str().unwrap());
|
|
2308
|
+
let session = SessionName::new("preserve-test");
|
|
2309
|
+
struct Cleanup<'a>(&'a TmuxBackend, &'a SessionName, &'a Path);
|
|
2310
|
+
impl Drop for Cleanup<'_> {
|
|
2311
|
+
fn drop(&mut self) {
|
|
2312
|
+
let _ = self.0.kill_session(self.1);
|
|
2313
|
+
let _ = std::fs::remove_dir_all(self.2);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
let _cleanup = Cleanup(&be, &session, &tmp);
|
|
2317
|
+
let argv = svec(&["sleep", "86400"]);
|
|
2318
|
+
let env = BTreeMap::new();
|
|
2319
|
+
let mut worker = be.spawn_first(&session, &WindowName::new("worker"), &argv, &tmp, &env).unwrap();
|
|
2320
|
+
for _ in 0..3 {
|
|
2321
|
+
be.preserve_session_before_stop(&session, &[Target::Pane(worker.pane_id.clone())]).unwrap();
|
|
2322
|
+
be.kill_pane(&worker.pane_id).unwrap();
|
|
2323
|
+
assert!(be.has_session(&session).unwrap());
|
|
2324
|
+
assert_eq!(be.has_pane(&worker.pane_id).unwrap(), Some(false));
|
|
2325
|
+
assert_eq!(be.list_windows(&session).unwrap(), vec![WindowName::new("[team-agent keepalive]")]);
|
|
2326
|
+
let anchor = be.list_targets().unwrap().into_iter().find(|pane| pane.session == session).unwrap();
|
|
2327
|
+
let deadline = Instant::now() + Duration::from_secs(2);
|
|
2328
|
+
loop {
|
|
2329
|
+
let dead = be.runner.run(&be.tmux_argv(&svec(&[
|
|
2330
|
+
"tmux", "display-message", "-p", "-t", anchor.pane_id.as_str(), "#{pane_dead}",
|
|
2331
|
+
]))).unwrap();
|
|
2332
|
+
if dead.success && dead.stdout.trim() == "1" { break; }
|
|
2333
|
+
assert!(Instant::now() < deadline, "keepalive must have no running process: {dead:?}");
|
|
2334
|
+
std::thread::sleep(Duration::from_millis(20));
|
|
2335
|
+
}
|
|
2336
|
+
worker = be.spawn_into(&session, &WindowName::new("worker"), &argv, &tmp, &env).unwrap();
|
|
2337
|
+
}
|
|
2338
|
+
be.kill_session(&session).unwrap();
|
|
2339
|
+
assert!(!be.has_session(&session).unwrap());
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2205
2342
|
// ── 8. ERROR MAPPING: non-zero tmux exit -> TransportError::Subprocess; runner io::Error -> Err ──
|
|
2206
2343
|
#[test]
|
|
2207
2344
|
fn error_paths_map_to_transport_error_not_panic() {
|
|
@@ -74,6 +74,7 @@ use crate::transport::{
|
|
|
74
74
|
};
|
|
75
75
|
|
|
76
76
|
pub const PANE_BINDING_NONCE_METADATA_KEY: &str = "TEAM_AGENT_PANE_BINDING_NONCE";
|
|
77
|
+
pub(crate) const KEEPALIVE_WINDOW_NAME: &str = "[team-agent keepalive]";
|
|
77
78
|
const TMUX_PANE_BINDING_NONCE_OPTION: &str = "@team_agent_pane_binding_nonce";
|
|
78
79
|
const TMUX_SESSION_OWNER_WORKSPACE_OPTION: &str = "@team_agent_owner_workspace";
|
|
79
80
|
const TMUX_SESSION_OWNER_TEAM_OPTION: &str = "@team_agent_owner_team";
|
|
@@ -3875,6 +3876,26 @@ impl Transport for TmuxBackend {
|
|
|
3875
3876
|
Ok(output.stdout.trim().parse::<u8>().ok())
|
|
3876
3877
|
}
|
|
3877
3878
|
|
|
3879
|
+
fn pane_is_dead(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
|
|
3880
|
+
let argv = self.tmux_argv(&[
|
|
3881
|
+
"tmux".to_string(),
|
|
3882
|
+
"display-message".to_string(),
|
|
3883
|
+
"-p".to_string(),
|
|
3884
|
+
"-t".to_string(),
|
|
3885
|
+
pane.as_str().to_string(),
|
|
3886
|
+
"#{pane_dead}".to_string(),
|
|
3887
|
+
]);
|
|
3888
|
+
let output = self.runner.run(&argv)?;
|
|
3889
|
+
if !output.success {
|
|
3890
|
+
return Ok(None);
|
|
3891
|
+
}
|
|
3892
|
+
Ok(match output.stdout.trim() {
|
|
3893
|
+
"1" => Some(true),
|
|
3894
|
+
"0" => Some(false),
|
|
3895
|
+
_ => None,
|
|
3896
|
+
})
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3878
3899
|
fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
|
|
3879
3900
|
let argv = self.tmux_argv(&[
|
|
3880
3901
|
"tmux".to_string(),
|
|
@@ -4087,6 +4108,95 @@ impl Transport for TmuxBackend {
|
|
|
4087
4108
|
self.run_ok(&argv)
|
|
4088
4109
|
}
|
|
4089
4110
|
|
|
4111
|
+
fn preserve_session_before_stop(
|
|
4112
|
+
&self,
|
|
4113
|
+
session: &SessionName,
|
|
4114
|
+
targets: &[Target],
|
|
4115
|
+
) -> Result<(), TransportError> {
|
|
4116
|
+
let list_argv = self.tmux_argv(&[
|
|
4117
|
+
"tmux".to_string(),
|
|
4118
|
+
"list-panes".to_string(),
|
|
4119
|
+
"-s".to_string(),
|
|
4120
|
+
"-t".to_string(),
|
|
4121
|
+
format!("={}", session.as_str()),
|
|
4122
|
+
"-F".to_string(),
|
|
4123
|
+
"#{pane_id}\t#{window_name}".to_string(),
|
|
4124
|
+
]);
|
|
4125
|
+
let output = self.runner.run(&list_argv)?;
|
|
4126
|
+
if !output.success {
|
|
4127
|
+
return Err(subprocess_error(list_argv, output));
|
|
4128
|
+
}
|
|
4129
|
+
for line in output.stdout.lines() {
|
|
4130
|
+
let Some((pane, window)) = line.split_once('\t') else {
|
|
4131
|
+
return Err(TransportError::Subprocess {
|
|
4132
|
+
argv: list_argv,
|
|
4133
|
+
code: output.code,
|
|
4134
|
+
stderr: "tmux returned an invalid session pane listing".to_string(),
|
|
4135
|
+
});
|
|
4136
|
+
};
|
|
4137
|
+
if !targets.iter().any(|target| match target {
|
|
4138
|
+
Target::Pane(id) => id.as_str() == pane,
|
|
4139
|
+
Target::SessionWindow {
|
|
4140
|
+
session: owner,
|
|
4141
|
+
window: name,
|
|
4142
|
+
} => owner == session && name.as_str() == window,
|
|
4143
|
+
}) {
|
|
4144
|
+
return Ok(());
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
// An empty listing cannot prove that this is the last worker.
|
|
4148
|
+
if output.stdout.trim().is_empty() {
|
|
4149
|
+
return Err(TransportError::TargetNotFound {
|
|
4150
|
+
target: session.as_str().to_string(),
|
|
4151
|
+
});
|
|
4152
|
+
}
|
|
4153
|
+
// Use a separate, non-worker window: remain-on-exit does not protect a
|
|
4154
|
+
// pane explicitly deleted by kill-pane. Configure by the new pane ID,
|
|
4155
|
+
// not a window name that could resolve to another same-named window.
|
|
4156
|
+
let spawn_argv = tmux_spawn_argv(
|
|
4157
|
+
&SessionName::new(format!("={}", session.as_str())),
|
|
4158
|
+
&WindowName::new(KEEPALIVE_WINDOW_NAME),
|
|
4159
|
+
"exec sleep 86400",
|
|
4160
|
+
false,
|
|
4161
|
+
);
|
|
4162
|
+
let spawned = self.run_spawn(&spawn_argv)?;
|
|
4163
|
+
let pane = spawned.stdout.trim();
|
|
4164
|
+
if !pane.starts_with('%')
|
|
4165
|
+
|| pane.len() == 1
|
|
4166
|
+
|| !pane[1..].chars().all(|ch| ch.is_ascii_digit())
|
|
4167
|
+
{
|
|
4168
|
+
return Err(TransportError::Subprocess {
|
|
4169
|
+
argv: self.tmux_argv(&spawn_argv),
|
|
4170
|
+
code: spawned.code,
|
|
4171
|
+
stderr: "tmux keepalive spawn returned no valid pane id".to_string(),
|
|
4172
|
+
});
|
|
4173
|
+
}
|
|
4174
|
+
let result = self
|
|
4175
|
+
.run_ok(&[
|
|
4176
|
+
"tmux".to_string(),
|
|
4177
|
+
"set-window-option".to_string(),
|
|
4178
|
+
"-t".to_string(),
|
|
4179
|
+
pane.to_string(),
|
|
4180
|
+
"remain-on-exit".to_string(),
|
|
4181
|
+
"on".to_string(),
|
|
4182
|
+
])
|
|
4183
|
+
.and_then(|()| {
|
|
4184
|
+
self.run_ok(&[
|
|
4185
|
+
"tmux".to_string(),
|
|
4186
|
+
"respawn-pane".to_string(),
|
|
4187
|
+
"-k".to_string(),
|
|
4188
|
+
"-t".to_string(),
|
|
4189
|
+
pane.to_string(),
|
|
4190
|
+
"exit 0".to_string(),
|
|
4191
|
+
])
|
|
4192
|
+
});
|
|
4193
|
+
if result.is_err() {
|
|
4194
|
+
// The worker has not been killed yet; remove only our new anchor.
|
|
4195
|
+
let _ = self.kill_pane(&PaneId::new(pane));
|
|
4196
|
+
}
|
|
4197
|
+
result
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4090
4200
|
fn kill_server(&self) -> Result<(), TransportError> {
|
|
4091
4201
|
TmuxBackend::kill_server(self);
|
|
4092
4202
|
Ok(())
|