@team-agent/installer 0.5.94 → 0.5.98
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 +381 -214
- package/crates/team-agent/src/cli/diagnose.rs +40 -10
- package/crates/team-agent/src/cli/emit.rs +6 -26
- package/crates/team-agent/src/cli/mod.rs +114 -30
- package/crates/team-agent/src/cli/spec.rs +2 -5
- package/crates/team-agent/src/cli/tests/base.rs +20 -10
- package/crates/team-agent/src/cli/tests/divergence.rs +12 -21
- package/crates/team-agent/src/cli/tests/main_preserved.rs +6 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +20 -11
- package/crates/team-agent/src/cli/tests/mod.rs +0 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +43 -0
- package/crates/team-agent/src/cli/triage.rs +31 -31
- package/crates/team-agent/src/cli/types.rs +3 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +10 -4
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +128 -11
- package/package.json +4 -4
- package/skills/team-agent/references/team-agent-operator.md +20 -28
- package/crates/team-agent/src/cli/tests/compile.rs +0 -192
|
@@ -299,7 +299,7 @@ fn dispatch_routes_sessions_subcommand() {
|
|
|
299
299
|
// + NO session_name + NO agents -> issues=[] -> EXIT 0.
|
|
300
300
|
#[test]
|
|
301
301
|
#[serial(env)]
|
|
302
|
-
fn
|
|
302
|
+
fn dispatch_routes_diagnose_checks_runtime_beyond_healthy_leader() {
|
|
303
303
|
let _env = EnvUnsetGuard::unset(&[
|
|
304
304
|
"TMUX",
|
|
305
305
|
"TMUX_PANE",
|
|
@@ -332,17 +332,26 @@ fn dispatch_routes_diagnose_healthy_leader() {
|
|
|
332
332
|
pane_pid: None,
|
|
333
333
|
leader_env: Default::default(),
|
|
334
334
|
}]);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
335
|
+
crate::transport_factory::with_leader_endpoint_transport(endpoint, transport, || {
|
|
336
|
+
for command in ["doctor", "diagnose"] {
|
|
337
|
+
let code = run(
|
|
338
|
+
&cli_argv(&[command, "--workspace", &ws.to_string_lossy(), "--json"]),
|
|
339
|
+
&ws,
|
|
340
|
+
);
|
|
341
|
+
assert_eq!(code, ExitCode::Error, "healthy binding alone cannot hide missing coordinator");
|
|
342
|
+
}
|
|
343
|
+
let result = cmd_diagnose(&DiagnoseArgs {
|
|
344
|
+
workspace: ws.clone(), json: true, team: None,
|
|
345
|
+
}).expect("diagnostic report");
|
|
346
|
+
let CmdOutput::Json(report) = result.output else {
|
|
347
|
+
panic!("expected JSON diagnostic report");
|
|
348
|
+
};
|
|
349
|
+
assert_eq!(report["coordinator"]["ok"], false, "{report}");
|
|
350
|
+
let issues = report["issues"].to_string();
|
|
351
|
+
for binding_failure in ["leader_not_attached", "leader_receiver_unbound", "leader_workspace_mismatch"] {
|
|
352
|
+
assert!(!issues.contains(binding_failure), "healthy binding regressed: {report}");
|
|
353
|
+
}
|
|
340
354
|
});
|
|
341
|
-
assert_eq!(
|
|
342
|
-
code,
|
|
343
|
-
ExitCode::Ok,
|
|
344
|
-
"`diagnose` must be Ok when canonical owner/receiver, production registry, and live align; got {code:?}"
|
|
345
|
-
);
|
|
346
355
|
let _ = std::fs::remove_dir_all(&ws);
|
|
347
356
|
}
|
|
348
357
|
|
|
@@ -23,6 +23,49 @@ fn anchors(raw: &[&str]) -> BTreeSet<String> {
|
|
|
23
23
|
raw.iter().map(|s| s.to_string()).collect()
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
#[test]
|
|
27
|
+
fn leader_anchors_are_endpoint_scoped_and_unknown_anchors_stay_protected() {
|
|
28
|
+
use crate::cli::lifecycle_port::collect_state_leader_anchor_pane_ids as collect;
|
|
29
|
+
let state = json!({
|
|
30
|
+
"team_owner": {"pane_id": "%2"},
|
|
31
|
+
"leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host.sock"},
|
|
32
|
+
"teams": {
|
|
33
|
+
"local": {
|
|
34
|
+
"team_owner": {"pane_id": "%3", "tmux_socket": "/tmp/s4-worker.sock"},
|
|
35
|
+
"leader_receiver": {"pane_id": "%3"}
|
|
36
|
+
},
|
|
37
|
+
"legacy": {"team_owner": {"pane_id": "%4"}}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
assert_eq!(collect(&state, Some("/tmp/s4-worker.sock")), anchors(&["%3", "%4"]));
|
|
41
|
+
assert_eq!(collect(&state, Some("/tmp/s4-host.sock")), anchors(&["%2", "%4"]));
|
|
42
|
+
assert_eq!(collect(&state, None), anchors(&["%2", "%3", "%4"]));
|
|
43
|
+
|
|
44
|
+
// An unrelated receiver's socket is not evidence about the owner's pane.
|
|
45
|
+
let different_panes = json!({
|
|
46
|
+
"team_owner": {"pane_id": "%5"},
|
|
47
|
+
"leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host.sock"}
|
|
48
|
+
});
|
|
49
|
+
assert_eq!(collect(&different_panes, Some("/tmp/s4-worker.sock")), anchors(&["%5"]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[test]
|
|
53
|
+
#[cfg(unix)]
|
|
54
|
+
fn leader_anchor_endpoint_aliases_preserve_same_server_protection() {
|
|
55
|
+
use crate::cli::lifecycle_port::collect_state_leader_anchor_pane_ids as collect;
|
|
56
|
+
let ws = tmp_shutdown_workspace("anchor-endpoint-alias");
|
|
57
|
+
let socket = ws.join("server.sock");
|
|
58
|
+
let alias = ws.join("alias.sock");
|
|
59
|
+
std::fs::write(&socket, b"fixture").unwrap();
|
|
60
|
+
std::os::unix::fs::symlink(&socket, &alias).unwrap();
|
|
61
|
+
let state = json!({"leader_receiver": {"pane_id": "%2", "tmux_socket": alias}});
|
|
62
|
+
assert_eq!(collect(&state, socket.to_str()), anchors(&["%2"]));
|
|
63
|
+
let short_name = format!("s4-anchor-{}", std::process::id());
|
|
64
|
+
let full_path = crate::tmux_backend::socket_path_for_name(&short_name).unwrap();
|
|
65
|
+
let state = json!({"leader_receiver": {"pane_id": "%2", "tmux_socket": short_name}});
|
|
66
|
+
assert_eq!(collect(&state, full_path.to_str()), anchors(&["%2"]));
|
|
67
|
+
}
|
|
68
|
+
|
|
26
69
|
// RC-4: even an apparently exclusive socket uses per-session cleanup.
|
|
27
70
|
#[test]
|
|
28
71
|
fn rc4_exclusive_socket_is_fail_closed() {
|
|
@@ -35,43 +35,27 @@ pub(crate) fn render(command: &str, report: &Value) -> String {
|
|
|
35
35
|
// generic emitter redacts again for defense in depth.
|
|
36
36
|
let report = crate::redaction::redact_external_value(report);
|
|
37
37
|
let ok = report.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
|
38
|
-
let
|
|
38
|
+
let runtime = report
|
|
39
|
+
.get("runtime")
|
|
40
|
+
.and_then(|runtime| runtime.get("status"))
|
|
41
|
+
.and_then(Value::as_str);
|
|
42
|
+
let issues = array_items(report.get("issues"));
|
|
43
|
+
let repairs = array_items(report.get("suggested_repairs"));
|
|
44
|
+
let mut summary = format!(
|
|
39
45
|
"{command}: {}",
|
|
40
46
|
if ok { "ok" } else { "needs attention" }
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
for finding in array_items(
|
|
45
|
-
report
|
|
46
|
-
.get("secret_scan")
|
|
47
|
-
.and_then(|scan| scan.get("findings")),
|
|
48
|
-
) {
|
|
49
|
-
let Some(object) = finding.as_object() else {
|
|
50
|
-
continue;
|
|
51
|
-
};
|
|
52
|
-
let (Some(rule), Some(path), Some(line)) = (
|
|
53
|
-
object.get("rule").and_then(Value::as_str),
|
|
54
|
-
object.get("path").and_then(Value::as_str),
|
|
55
|
-
object.get("line").and_then(Value::as_u64),
|
|
56
|
-
) else {
|
|
57
|
-
continue;
|
|
58
|
-
};
|
|
59
|
-
lines.push(bounded(format!("warn: {rule} in {path}:{line}")));
|
|
60
|
-
}
|
|
47
|
+
);
|
|
48
|
+
if let Some(runtime) = runtime {
|
|
49
|
+
summary.push_str(&format!("; runtime={runtime}"));
|
|
61
50
|
}
|
|
62
|
-
|
|
63
|
-
|
|
51
|
+
summary.push_str(&format!("; issues={}; repairs={}", issues.len(), repairs.len()));
|
|
52
|
+
let mut lines = vec![bounded(summary)];
|
|
53
|
+
for issue in issues {
|
|
64
54
|
lines.push(bounded(format!("issue: {}", summarize(issue, false))));
|
|
65
55
|
}
|
|
66
|
-
for repair in
|
|
56
|
+
for repair in repairs {
|
|
67
57
|
lines.push(bounded(format!("repair: {}", summarize(repair, true))));
|
|
68
58
|
}
|
|
69
|
-
if let Some(error) = report.get("error").filter(|value| !value.is_null()) {
|
|
70
|
-
lines.push(bounded(format!("detail: {}", summarize(error, false))));
|
|
71
|
-
}
|
|
72
|
-
if lines.len() == 1 {
|
|
73
|
-
lines.push(bounded("detail: no issues detected".to_string()));
|
|
74
|
-
}
|
|
75
59
|
lines.join("\n")
|
|
76
60
|
}
|
|
77
61
|
|
|
@@ -95,6 +79,15 @@ fn summarize(value: &Value, repair: bool) -> String {
|
|
|
95
79
|
.as_slice()
|
|
96
80
|
};
|
|
97
81
|
let mut parts = Vec::new();
|
|
82
|
+
if let Some(path) = object.get("path").and_then(Value::as_str) {
|
|
83
|
+
let location = match object.get("line").and_then(Value::as_u64) {
|
|
84
|
+
Some(line) => format!("{path}:{line}"),
|
|
85
|
+
None => path.to_string(),
|
|
86
|
+
};
|
|
87
|
+
// Keep the target ahead of prose so bounded human lines retain
|
|
88
|
+
// the distinguishing location rather than only the issue id.
|
|
89
|
+
parts.push(format!("at={location}"));
|
|
90
|
+
}
|
|
98
91
|
for key in keys {
|
|
99
92
|
if let Some(value) = object.get(*key).and_then(Value::as_str) {
|
|
100
93
|
parts.push(format!("{key}={value}"));
|
|
@@ -166,6 +159,13 @@ mod tests {
|
|
|
166
159
|
fn renderer_projects_secret_findings_without_values() {
|
|
167
160
|
let report = json!({
|
|
168
161
|
"ok": false,
|
|
162
|
+
"issues": [{
|
|
163
|
+
"id": "secret_scan_finding",
|
|
164
|
+
"rule": "api_key_assignment",
|
|
165
|
+
"path": "leaky-role.md",
|
|
166
|
+
"line": 7,
|
|
167
|
+
}],
|
|
168
|
+
"suggested_repairs": [{"issue": "secret_scan_finding", "action": "remove secret finding at leaky-role.md:7"}],
|
|
169
169
|
"secret_scan": {
|
|
170
170
|
"findings": [{
|
|
171
171
|
"rule": "api_key_assignment",
|
|
@@ -176,7 +176,7 @@ mod tests {
|
|
|
176
176
|
}
|
|
177
177
|
});
|
|
178
178
|
let output = render("doctor", &report);
|
|
179
|
-
assert!(output.contains("
|
|
179
|
+
assert!(output.contains("issue: at=leaky-role.md:7; id=secret_scan_finding"));
|
|
180
180
|
assert!(!output.contains("match_excerpt"));
|
|
181
181
|
assert!(!output.contains("OPENAI_API_KEY=secret"));
|
|
182
182
|
}
|
|
@@ -321,14 +321,6 @@ pub struct InitArgs {
|
|
|
321
321
|
pub json: bool,
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
/// `compile`(`parser.py:125`)。
|
|
325
|
-
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
326
|
-
pub struct CompileArgs {
|
|
327
|
-
pub team: PathBuf,
|
|
328
|
-
pub out: PathBuf,
|
|
329
|
-
pub json: bool,
|
|
330
|
-
}
|
|
331
|
-
|
|
332
324
|
/// `send`(`parser.py:262`)。`target` xor `--to`(fanout);`message` 多 token join 空格。
|
|
333
325
|
#[derive(Debug, Clone, PartialEq)]
|
|
334
326
|
pub struct SendArgs {
|
|
@@ -697,7 +689,9 @@ pub struct WaitArgs {
|
|
|
697
689
|
pub json: bool,
|
|
698
690
|
}
|
|
699
691
|
|
|
700
|
-
/// `diagnose`
|
|
692
|
+
/// `diagnose` compatibility spelling for the canonical `doctor` health report.
|
|
693
|
+
/// The CLI normalizes it to `DoctorArgs`; this narrow type remains for direct
|
|
694
|
+
/// Rust callers that used the pre-unification API.
|
|
701
695
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
702
696
|
pub struct DiagnoseArgs {
|
|
703
697
|
pub workspace: PathBuf,
|
|
@@ -1041,6 +1041,32 @@ fn restart_with_selected_team_and_transport(
|
|
|
1041
1041
|
attach_commands,
|
|
1042
1042
|
});
|
|
1043
1043
|
}
|
|
1044
|
+
// Respawn changes the generation used by shutdown's ownership check, even
|
|
1045
|
+
// when build-before-destroy keeps the tmux session itself alive. Stamp only
|
|
1046
|
+
// the selected session, after its replacement state is durable.
|
|
1047
|
+
if let Some(generation) = state
|
|
1048
|
+
.get("generation")
|
|
1049
|
+
.and_then(serde_json::Value::as_str)
|
|
1050
|
+
.or_else(|| {
|
|
1051
|
+
state
|
|
1052
|
+
.get("agents")
|
|
1053
|
+
.and_then(serde_json::Value::as_object)
|
|
1054
|
+
.and_then(|agents| {
|
|
1055
|
+
agents.values().find_map(|agent| {
|
|
1056
|
+
agent.get("spawned_at").and_then(serde_json::Value::as_str)
|
|
1057
|
+
})
|
|
1058
|
+
})
|
|
1059
|
+
})
|
|
1060
|
+
{
|
|
1061
|
+
transport
|
|
1062
|
+
.set_session_owner_with_generation(
|
|
1063
|
+
&session_name,
|
|
1064
|
+
&selected.run_workspace,
|
|
1065
|
+
&selected.team_key,
|
|
1066
|
+
generation,
|
|
1067
|
+
)
|
|
1068
|
+
.map_err(|error| LifecycleError::Transport(error.to_string()))?;
|
|
1069
|
+
}
|
|
1044
1070
|
// RM-039-SESS-001 step 2 (architect verdict 2026-06-22): post-respawn
|
|
1045
1071
|
// resume backing validation.
|
|
1046
1072
|
//
|
|
@@ -168,8 +168,10 @@ fn launch_restart_start_add_and_fork_all_delegate_to_the_profile_launch_resolver
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
#[test]
|
|
171
|
+
#[serial_test::serial(env)]
|
|
171
172
|
fn diagnose_missing_profile_is_rc1_ok_false_and_zero_token() {
|
|
172
|
-
let
|
|
173
|
+
let env = hermetic_guard::HermeticTestEnv::enter("diagnose-missing-profile");
|
|
174
|
+
let ws = env.workspace("missing-profile");
|
|
173
175
|
let team = write_claude_profile_team(&ws, "diagteam", "clauder");
|
|
174
176
|
std::fs::remove_file(
|
|
175
177
|
ws.join(".team")
|
|
@@ -180,7 +182,7 @@ fn diagnose_missing_profile_is_rc1_ok_false_and_zero_token() {
|
|
|
180
182
|
.unwrap();
|
|
181
183
|
|
|
182
184
|
let output = Command::new(crate::lifecycle::tests::test_binary_path())
|
|
183
|
-
.args(["diagnose", "--workspace", ws.to_str().unwrap(), "--json"])
|
|
185
|
+
.args(["diagnose", team.to_str().unwrap(), "--workspace", ws.to_str().unwrap(), "--json"])
|
|
184
186
|
.current_dir(&team)
|
|
185
187
|
.output()
|
|
186
188
|
.expect("diagnose subprocess should run");
|
|
@@ -197,10 +199,14 @@ fn diagnose_missing_profile_is_rc1_ok_false_and_zero_token() {
|
|
|
197
199
|
json!(false),
|
|
198
200
|
"diagnose missing profile must emit ok:false JSON; stdout={stdout} stderr={stderr}"
|
|
199
201
|
);
|
|
202
|
+
assert_eq!(json["profile_smoke"]["ok"], false, "{json}");
|
|
200
203
|
assert!(
|
|
201
|
-
|
|
202
|
-
"diagnose missing profile must
|
|
204
|
+
json["profile_smoke"].to_string().contains("profile"),
|
|
205
|
+
"diagnose missing profile must identify the scoped configuration failure: {json}"
|
|
203
206
|
);
|
|
207
|
+
assert!(json["providers"].as_object().unwrap().values().all(|provider| {
|
|
208
|
+
provider["probe_status"] == "not_run"
|
|
209
|
+
}));
|
|
204
210
|
assert!(
|
|
205
211
|
!stdout.contains("tokens") && !stderr.contains("tokens"),
|
|
206
212
|
"diagnose dry-run must not consume provider tokens or report token usage; stdout={stdout} stderr={stderr}"
|
|
@@ -30,7 +30,7 @@ use team_agent::state::persist::{load_runtime_state, save_runtime_state};
|
|
|
30
30
|
use team_agent::transport::{
|
|
31
31
|
AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
|
|
32
32
|
InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, PaneLiveness, SessionName,
|
|
33
|
-
SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
|
|
33
|
+
SessionOwner, SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
|
|
34
34
|
TurnVerification, WindowName,
|
|
35
35
|
};
|
|
36
36
|
|
|
@@ -130,6 +130,95 @@ fn server_exited_during_replacement_spawn_does_not_cascade_session_disappeared_a
|
|
|
130
130
|
);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
#[test]
|
|
134
|
+
#[serial(env)]
|
|
135
|
+
fn scoped_shutdown_after_restart_refreshes_owner_and_preserves_sibling() {
|
|
136
|
+
let case = RestartCase::new("restart-shutdown-owner", worker_ids(2), ProviderShape::Fake);
|
|
137
|
+
let transport = BuildBeforeDestroyTransport::recording(worker_ids(2));
|
|
138
|
+
for pane in transport.state.lock().unwrap().panes.values_mut() {
|
|
139
|
+
pane.current_path = Some(case.workspace.clone());
|
|
140
|
+
pane.pane_pid = None;
|
|
141
|
+
}
|
|
142
|
+
let session = SessionName::new(TEAM_SESSION);
|
|
143
|
+
let sibling_session = SessionName::new("team-sibling");
|
|
144
|
+
let mut state = case.read_state();
|
|
145
|
+
// Host and worker tmux servers may allocate the same pane ID. The host
|
|
146
|
+
// receiver must not protect the replacement worker on this transport.
|
|
147
|
+
state["leader_receiver"] = json!({
|
|
148
|
+
"pane_id": "%new-1", "tmux_socket": "/tmp/s4-host-only.sock"
|
|
149
|
+
});
|
|
150
|
+
state["team_owner"] = json!({"pane_id": "%new-1"});
|
|
151
|
+
state["teams"][TEAM]["leader_receiver"] = state["leader_receiver"].clone();
|
|
152
|
+
state["teams"][TEAM]["team_owner"] = state["team_owner"].clone();
|
|
153
|
+
state["teams"]["sibling"] = json!({
|
|
154
|
+
"team_key": "sibling",
|
|
155
|
+
"session_name": sibling_session.as_str(),
|
|
156
|
+
"tmux_socket": TMUX_ENDPOINT,
|
|
157
|
+
"agents": {
|
|
158
|
+
"sibling-worker": {
|
|
159
|
+
"provider": "fake", "status": "running", "pane_id": "%sibling",
|
|
160
|
+
"spawned_at": "sibling-generation"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
save_runtime_state(&case.workspace, &state).unwrap();
|
|
165
|
+
let sibling_before = case.read_state()["teams"]["sibling"].clone();
|
|
166
|
+
let mut sibling_pane = pane_info(
|
|
167
|
+
sibling_session.clone(), WindowName::new("sibling-worker"), PaneId::new("%sibling"),
|
|
168
|
+
);
|
|
169
|
+
sibling_pane.current_path = Some(case.workspace.clone());
|
|
170
|
+
sibling_pane.pane_pid = None;
|
|
171
|
+
transport.state.lock().unwrap().panes.insert("%sibling".to_string(), sibling_pane.clone());
|
|
172
|
+
transport.set_session_owner_with_generation(
|
|
173
|
+
&session, &case.workspace, TEAM, "2026-07-14T00:00:00+00:00",
|
|
174
|
+
).unwrap();
|
|
175
|
+
transport.set_session_owner_with_generation(
|
|
176
|
+
&sibling_session, &case.workspace, "sibling", "sibling-generation",
|
|
177
|
+
).unwrap();
|
|
178
|
+
let sibling_owner = transport.session_owner(&sibling_session).unwrap();
|
|
179
|
+
|
|
180
|
+
let report = restart_with_transport(&case.workspace, true, Some(TEAM), &transport).unwrap();
|
|
181
|
+
assert!(matches!(report, RestartReport::Restarted { .. }), "{report:?}");
|
|
182
|
+
let state = case.read_state();
|
|
183
|
+
let expected_generation = state["teams"][TEAM]["agents"]["w1"]["spawned_at"].as_str().unwrap();
|
|
184
|
+
assert_ne!(expected_generation, "2026-07-14T00:00:00+00:00");
|
|
185
|
+
let owner = transport.session_owner(&session).unwrap().unwrap();
|
|
186
|
+
assert_eq!(owner.workspace, case.workspace.canonicalize().unwrap().to_string_lossy());
|
|
187
|
+
assert_eq!(owner.team, TEAM);
|
|
188
|
+
assert_eq!(owner.generation, expected_generation);
|
|
189
|
+
assert_no_original_session_kill(&transport.ops(), "restart retains the session");
|
|
190
|
+
|
|
191
|
+
let out = team_agent::cli::lifecycle_port::shutdown_with_transport(
|
|
192
|
+
&case.workspace, true, Some(TEAM), &transport,
|
|
193
|
+
).unwrap();
|
|
194
|
+
assert_eq!(out["ok"], true, "{out}");
|
|
195
|
+
assert_eq!(out["session_killed"], true, "{out}");
|
|
196
|
+
assert_eq!(out["spared_sessions"], json!([]), "{out}");
|
|
197
|
+
assert_eq!(out["coordinator_stop_reason"], "scoped_live_sibling_present", "{out}");
|
|
198
|
+
assert!(!transport.has_session(&session).unwrap());
|
|
199
|
+
assert!(transport.has_session(&sibling_session).unwrap());
|
|
200
|
+
assert_eq!(transport.session_owner(&sibling_session).unwrap(), sibling_owner);
|
|
201
|
+
assert_eq!(case.read_state()["teams"]["sibling"], sibling_before);
|
|
202
|
+
assert_eq!(transport.state.lock().unwrap().panes.get("%sibling"), Some(&sibling_pane));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#[test]
|
|
206
|
+
#[serial(env)]
|
|
207
|
+
fn failed_replacement_preserves_original_session_owner() {
|
|
208
|
+
let case = RestartCase::new("failed-restart-owner", worker_ids(1), ProviderShape::Fake);
|
|
209
|
+
let transport = BuildBeforeDestroyTransport::recording(worker_ids(1))
|
|
210
|
+
.with_spawn_failure("w1", "injected replacement spawn failure");
|
|
211
|
+
let session = SessionName::new(TEAM_SESSION);
|
|
212
|
+
transport.set_session_owner_with_generation(
|
|
213
|
+
&session, &case.workspace, TEAM, "2026-07-14T00:00:00+00:00",
|
|
214
|
+
).unwrap();
|
|
215
|
+
let owner_before = transport.session_owner(&session).unwrap();
|
|
216
|
+
let report = restart_with_transport(&case.workspace, true, Some(TEAM), &transport).unwrap();
|
|
217
|
+
assert_restart_did_not_succeed(&report);
|
|
218
|
+
assert_eq!(transport.session_owner(&session).unwrap(), owner_before);
|
|
219
|
+
assert_eq!(transport.ops().iter().filter(|op| op.starts_with("set_session_owner:")).count(), 1);
|
|
220
|
+
}
|
|
221
|
+
|
|
133
222
|
struct RestartCase {
|
|
134
223
|
env: hermetic_guard::HermeticTestEnv,
|
|
135
224
|
workspace: PathBuf,
|
|
@@ -354,6 +443,7 @@ struct TransportState {
|
|
|
354
443
|
session_present: bool,
|
|
355
444
|
ops: Vec<String>,
|
|
356
445
|
panes: BTreeMap<String, PaneInfo>,
|
|
446
|
+
owners: BTreeMap<String, SessionOwner>,
|
|
357
447
|
next_pane: usize,
|
|
358
448
|
spawns: usize,
|
|
359
449
|
}
|
|
@@ -416,6 +506,7 @@ impl BuildBeforeDestroyTransport {
|
|
|
416
506
|
kind: &'static str,
|
|
417
507
|
session: &SessionName,
|
|
418
508
|
window: &WindowName,
|
|
509
|
+
cwd: &Path,
|
|
419
510
|
) -> Result<SpawnResult, TransportError> {
|
|
420
511
|
let mut state = self.state.lock().unwrap();
|
|
421
512
|
state
|
|
@@ -434,10 +525,10 @@ impl BuildBeforeDestroyTransport {
|
|
|
434
525
|
let pane_id = PaneId::new(format!("%new-{}", state.next_pane));
|
|
435
526
|
state.next_pane += 1;
|
|
436
527
|
state.session_present = true;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
);
|
|
528
|
+
let mut pane = pane_info(session.clone(), window.clone(), pane_id.clone());
|
|
529
|
+
pane.current_path = Some(cwd.to_path_buf());
|
|
530
|
+
pane.pane_pid = None;
|
|
531
|
+
state.panes.insert(pane_id.as_str().to_string(), pane);
|
|
441
532
|
if self.server_exit_after_first_spawn && state.spawns == 1 {
|
|
442
533
|
state.session_present = false;
|
|
443
534
|
}
|
|
@@ -451,7 +542,12 @@ impl BuildBeforeDestroyTransport {
|
|
|
451
542
|
|
|
452
543
|
fn real_tmux_has_session(&self, session: &SessionName) -> Result<bool, TransportError> {
|
|
453
544
|
let Some(socket) = self.real_socket.as_ref() else {
|
|
454
|
-
|
|
545
|
+
let state = self.state.lock().unwrap();
|
|
546
|
+
return Ok(if session.as_str() == TEAM_SESSION {
|
|
547
|
+
state.session_present
|
|
548
|
+
} else {
|
|
549
|
+
state.panes.values().any(|pane| pane.session == *session)
|
|
550
|
+
});
|
|
455
551
|
};
|
|
456
552
|
Ok(Command::new("tmux")
|
|
457
553
|
.args([
|
|
@@ -535,10 +631,10 @@ impl Transport for BuildBeforeDestroyTransport {
|
|
|
535
631
|
session: &SessionName,
|
|
536
632
|
window: &WindowName,
|
|
537
633
|
_argv: &[String],
|
|
538
|
-
|
|
634
|
+
cwd: &Path,
|
|
539
635
|
_env: &BTreeMap<String, String>,
|
|
540
636
|
) -> Result<SpawnResult, TransportError> {
|
|
541
|
-
self.spawn("spawn_first", session, window)
|
|
637
|
+
self.spawn("spawn_first", session, window, cwd)
|
|
542
638
|
}
|
|
543
639
|
|
|
544
640
|
fn spawn_into(
|
|
@@ -546,10 +642,10 @@ impl Transport for BuildBeforeDestroyTransport {
|
|
|
546
642
|
session: &SessionName,
|
|
547
643
|
window: &WindowName,
|
|
548
644
|
_argv: &[String],
|
|
549
|
-
|
|
645
|
+
cwd: &Path,
|
|
550
646
|
_env: &BTreeMap<String, String>,
|
|
551
647
|
) -> Result<SpawnResult, TransportError> {
|
|
552
|
-
self.spawn("spawn_into", session, window)
|
|
648
|
+
self.spawn("spawn_into", session, window, cwd)
|
|
553
649
|
}
|
|
554
650
|
|
|
555
651
|
fn inject(
|
|
@@ -648,6 +744,27 @@ impl Transport for BuildBeforeDestroyTransport {
|
|
|
648
744
|
Ok(windows.into_iter().collect())
|
|
649
745
|
}
|
|
650
746
|
|
|
747
|
+
fn set_session_owner_with_generation(
|
|
748
|
+
&self,
|
|
749
|
+
session: &SessionName,
|
|
750
|
+
workspace: &Path,
|
|
751
|
+
team: &str,
|
|
752
|
+
generation: &str,
|
|
753
|
+
) -> Result<(), TransportError> {
|
|
754
|
+
let mut state = self.state.lock().unwrap();
|
|
755
|
+
state.ops.push(format!("set_session_owner:{}", session.as_str()));
|
|
756
|
+
state.owners.insert(session.as_str().to_string(), SessionOwner {
|
|
757
|
+
workspace: workspace.canonicalize().unwrap().to_string_lossy().into_owned(),
|
|
758
|
+
team: team.to_string(),
|
|
759
|
+
generation: generation.to_string(),
|
|
760
|
+
});
|
|
761
|
+
Ok(())
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
fn session_owner(&self, session: &SessionName) -> Result<Option<SessionOwner>, TransportError> {
|
|
765
|
+
Ok(self.state.lock().unwrap().owners.get(session.as_str()).cloned())
|
|
766
|
+
}
|
|
767
|
+
|
|
651
768
|
fn set_session_env(
|
|
652
769
|
&self,
|
|
653
770
|
_session: &SessionName,
|
|
@@ -665,7 +782,7 @@ impl Transport for BuildBeforeDestroyTransport {
|
|
|
665
782
|
.push(format!("kill_session:{}", session.as_str()));
|
|
666
783
|
if session.as_str() == TEAM_SESSION {
|
|
667
784
|
self.state.lock().unwrap().session_present = false;
|
|
668
|
-
self.state.lock().unwrap().panes.
|
|
785
|
+
self.state.lock().unwrap().panes.retain(|_, pane| pane.session != *session);
|
|
669
786
|
if let Some(socket) = self.real_socket.as_ref() {
|
|
670
787
|
let status = Command::new("tmux")
|
|
671
788
|
.args([
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.98",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.98",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.98",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.98"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|