@team-agent/installer 0.3.15 → 0.3.16
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/leader.rs +2 -0
- package/crates/team-agent/src/cli/mod.rs +198 -30
- package/crates/team-agent/src/cli/status.rs +5 -1
- package/crates/team-agent/src/cli/status_port.rs +24 -0
- package/crates/team-agent/src/cli/tests/base.rs +21 -3
- package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +127 -1
- package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
- package/crates/team-agent/src/cli/types.rs +2 -0
- package/crates/team-agent/src/leader/start.rs +272 -8
- package/crates/team-agent/src/leader/tests/basics.rs +1 -0
- package/crates/team-agent/src/leader/tests/identity.rs +121 -25
- package/crates/team-agent/src/leader/types.rs +9 -0
- package/crates/team-agent/src/lifecycle/launch.rs +72 -2
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +7 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +63 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -29,6 +29,8 @@ pub fn leader_launcher_args(values: &[String]) -> Result<LeaderLauncherArgs, Cli
|
|
|
29
29
|
out.attach_existing = true;
|
|
30
30
|
} else if token == "--confirm" {
|
|
31
31
|
out.confirm_attach = true;
|
|
32
|
+
} else if token == "--external-leader" {
|
|
33
|
+
out.external_leader = true;
|
|
32
34
|
} else if token == "--attach-session" {
|
|
33
35
|
let Some(value) = values.get(idx + 1) else {
|
|
34
36
|
return Err(CliError::Runtime(
|
|
@@ -148,6 +148,7 @@ pub mod lifecycle_port {
|
|
|
148
148
|
attach.attach_existing,
|
|
149
149
|
attach.confirm_attach,
|
|
150
150
|
attach_session.as_ref(),
|
|
151
|
+
attach.external_leader,
|
|
151
152
|
)
|
|
152
153
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
153
154
|
let outcome = crate::leader::start::execute_leader_plan(&plan, cwd)
|
|
@@ -157,10 +158,21 @@ pub mod lifecycle_port {
|
|
|
157
158
|
crate::leader::LeaderLaunchStatus::Detached => true,
|
|
158
159
|
crate::leader::LeaderLaunchStatus::NotStarted => false,
|
|
159
160
|
};
|
|
161
|
+
let leader_attach_command = if plan.is_external_leader {
|
|
162
|
+
None
|
|
163
|
+
} else {
|
|
164
|
+
plan.session_name.as_ref().and_then(|session| {
|
|
165
|
+
crate::tmux_backend::attach_command_for_workspace(cwd, session, "leader")
|
|
166
|
+
})
|
|
167
|
+
};
|
|
160
168
|
Ok(json!({
|
|
161
169
|
"ok": ok,
|
|
162
170
|
"provider": provider,
|
|
163
171
|
"mode": plan.mode,
|
|
172
|
+
"leader_topology": if plan.is_external_leader { "external" } else { "managed" },
|
|
173
|
+
"is_external_leader": plan.is_external_leader,
|
|
174
|
+
"leader_window": plan.leader_window.as_ref().map(|window| window.as_str().to_string()),
|
|
175
|
+
"leader_attach_command": leader_attach_command,
|
|
164
176
|
"status": outcome.status,
|
|
165
177
|
"exit_code": outcome.exit_code,
|
|
166
178
|
"reason": outcome.reason,
|
|
@@ -282,6 +294,9 @@ pub mod lifecycle_port {
|
|
|
282
294
|
// no independent ps/tmux re-derivation (N39).
|
|
283
295
|
let pane_targets = transport.list_targets().unwrap_or_default();
|
|
284
296
|
let sessions = socket_session_names_from_targets(&pane_targets);
|
|
297
|
+
if !state_uses_external_leader(state) {
|
|
298
|
+
return managed_leader_socket_cleanup(transport, state, &sessions, event_log);
|
|
299
|
+
}
|
|
285
300
|
let anchor_sessions = anchor_sessions_from_state(state, &pane_targets, event_log);
|
|
286
301
|
match sessions_to_kill(&sessions, &anchor_sessions) {
|
|
287
302
|
KillDecision::KillServerExclusive => {
|
|
@@ -347,6 +362,66 @@ pub mod lifecycle_port {
|
|
|
347
362
|
}
|
|
348
363
|
}
|
|
349
364
|
|
|
365
|
+
fn state_uses_external_leader(state: &Value) -> bool {
|
|
366
|
+
state
|
|
367
|
+
.get("is_external_leader")
|
|
368
|
+
.and_then(Value::as_bool)
|
|
369
|
+
.unwrap_or(true)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
fn managed_leader_socket_cleanup(
|
|
373
|
+
transport: &dyn crate::transport::Transport,
|
|
374
|
+
state: &Value,
|
|
375
|
+
sessions: &[crate::transport::SessionName],
|
|
376
|
+
event_log: &crate::event_log::EventLog,
|
|
377
|
+
) -> ShutdownSocketCleanup {
|
|
378
|
+
let target = state
|
|
379
|
+
.get("session_name")
|
|
380
|
+
.and_then(Value::as_str)
|
|
381
|
+
.filter(|session| !session.is_empty())
|
|
382
|
+
.map(crate::transport::SessionName::new);
|
|
383
|
+
let mut to_kill = Vec::new();
|
|
384
|
+
if let Some(target) = target {
|
|
385
|
+
if sessions.is_empty()
|
|
386
|
+
|| sessions
|
|
387
|
+
.iter()
|
|
388
|
+
.any(|session| session.as_str() == target.as_str())
|
|
389
|
+
{
|
|
390
|
+
to_kill.push(target);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
let spared = sessions
|
|
394
|
+
.iter()
|
|
395
|
+
.filter(|session| {
|
|
396
|
+
!to_kill
|
|
397
|
+
.iter()
|
|
398
|
+
.any(|target| target.as_str() == session.as_str())
|
|
399
|
+
})
|
|
400
|
+
.cloned()
|
|
401
|
+
.collect::<Vec<_>>();
|
|
402
|
+
let _ = event_log.write(
|
|
403
|
+
"shutdown.kill_server_skipped_managed_leader",
|
|
404
|
+
json!({
|
|
405
|
+
"reason": "managed_leader_topology",
|
|
406
|
+
"spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
407
|
+
"killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
let mut error = None;
|
|
411
|
+
for session in &to_kill {
|
|
412
|
+
if let Err(err) = transport.kill_session(session) {
|
|
413
|
+
if !tmux_absent_error(&err.to_string()) {
|
|
414
|
+
error.get_or_insert_with(|| err.to_string());
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
ShutdownSocketCleanup {
|
|
419
|
+
killed_sessions: to_kill,
|
|
420
|
+
spared_sessions: spared,
|
|
421
|
+
error,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
350
425
|
pub fn shutdown_with_transport(
|
|
351
426
|
workspace: &Path,
|
|
352
427
|
keep_logs: bool,
|
|
@@ -483,8 +558,12 @@ pub mod lifecycle_port {
|
|
|
483
558
|
);
|
|
484
559
|
deadline.check("stop_coordinator")?;
|
|
485
560
|
let mut coordinator_timeout = false;
|
|
561
|
+
let mut coordinator_post_stop = CoordinatorStopObservation::NotNeeded;
|
|
562
|
+
let mut coordinator_pid_for_report = None;
|
|
486
563
|
let stopped = if team.is_none() {
|
|
487
564
|
let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
|
|
565
|
+
let coordinator_pid_before_stop = crate::coordinator::coordinator_health(&wp).pid;
|
|
566
|
+
coordinator_pid_for_report = coordinator_pid_before_stop.map(|pid| pid.get());
|
|
488
567
|
match stop_coordinator_bounded(wp, std::time::Duration::from_millis(900)) {
|
|
489
568
|
Some(Ok(report)) => Some(report),
|
|
490
569
|
Some(Err(error)) => {
|
|
@@ -493,12 +572,19 @@ pub mod lifecycle_port {
|
|
|
493
572
|
}
|
|
494
573
|
None => {
|
|
495
574
|
coordinator_timeout = true;
|
|
575
|
+
let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
|
|
576
|
+
coordinator_post_stop =
|
|
577
|
+
coordinator_post_stop_observation(&wp, coordinator_pid_before_stop);
|
|
496
578
|
None
|
|
497
579
|
}
|
|
498
580
|
}
|
|
499
581
|
} else {
|
|
500
582
|
None
|
|
501
583
|
};
|
|
584
|
+
if let Some(stopped) = stopped.as_ref().filter(|stopped| !stopped.ok) {
|
|
585
|
+
let wp = crate::coordinator::WorkspacePath::new(run_workspace.clone());
|
|
586
|
+
coordinator_post_stop = coordinator_post_stop_observation(&wp, stopped.pid);
|
|
587
|
+
}
|
|
502
588
|
let probe_timeout = crate::os_probe::probe_timeout();
|
|
503
589
|
let probe_timeout_kind = probe_timeout.as_ref().map(|timeout| timeout.probe);
|
|
504
590
|
// swallow batch 1: a failed ps probe degrades cleanup truthfully — the
|
|
@@ -536,32 +622,19 @@ pub mod lifecycle_port {
|
|
|
536
622
|
let coordinator_pid = stopped
|
|
537
623
|
.as_ref()
|
|
538
624
|
.and_then(|stopped| stopped.pid.map(|p| p.get()));
|
|
539
|
-
let
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
"partial"
|
|
553
|
-
} else if kill_error.is_some() {
|
|
554
|
-
"failed"
|
|
555
|
-
} else {
|
|
556
|
-
"partial"
|
|
557
|
-
};
|
|
558
|
-
let phase = if coordinator_timeout {
|
|
559
|
-
Some("stop_coordinator")
|
|
560
|
-
} else if cleanup_truth_degraded {
|
|
561
|
-
Some("os_probe")
|
|
562
|
-
} else {
|
|
563
|
-
None
|
|
564
|
-
};
|
|
625
|
+
let coordinator_pid = coordinator_pid.or(coordinator_pid_for_report);
|
|
626
|
+
let outcome = classify_shutdown_outcome(ShutdownOutcomeInput {
|
|
627
|
+
kill_error: kill_error.is_some(),
|
|
628
|
+
session_residuals: !session_residuals.is_empty(),
|
|
629
|
+
process_residuals: !process_residuals.is_empty(),
|
|
630
|
+
cleanup_truth_degraded,
|
|
631
|
+
coordinator_timeout,
|
|
632
|
+
coordinator_stop_ok: stopped.as_ref().map(|stopped| stopped.ok),
|
|
633
|
+
coordinator_post_stop,
|
|
634
|
+
});
|
|
635
|
+
let ok = outcome.ok;
|
|
636
|
+
let status = outcome.status;
|
|
637
|
+
let phase = outcome.phase;
|
|
565
638
|
let probe_timeout_value = probe_timeout.as_ref().map(|timeout| {
|
|
566
639
|
json!({
|
|
567
640
|
"probe": timeout.probe,
|
|
@@ -618,14 +691,110 @@ pub mod lifecycle_port {
|
|
|
618
691
|
/// worker thread — on a timely result it joins immediately; on timeout it gives the
|
|
619
692
|
/// thread one short grace join window instead of dropping it detached (repeated
|
|
620
693
|
/// shutdowns no longer accumulate leaked threads racing the same workspace).
|
|
694
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
695
|
+
pub(crate) enum CoordinatorStopObservation {
|
|
696
|
+
NotNeeded,
|
|
697
|
+
Gone,
|
|
698
|
+
Running,
|
|
699
|
+
Unknown,
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
pub(crate) struct ShutdownOutcome {
|
|
703
|
+
pub(crate) ok: bool,
|
|
704
|
+
pub(crate) status: &'static str,
|
|
705
|
+
pub(crate) phase: Option<&'static str>,
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
pub(crate) struct ShutdownOutcomeInput {
|
|
709
|
+
pub(crate) kill_error: bool,
|
|
710
|
+
pub(crate) session_residuals: bool,
|
|
711
|
+
pub(crate) process_residuals: bool,
|
|
712
|
+
pub(crate) cleanup_truth_degraded: bool,
|
|
713
|
+
pub(crate) coordinator_timeout: bool,
|
|
714
|
+
pub(crate) coordinator_stop_ok: Option<bool>,
|
|
715
|
+
pub(crate) coordinator_post_stop: CoordinatorStopObservation,
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
pub(crate) fn classify_shutdown_outcome(input: ShutdownOutcomeInput) -> ShutdownOutcome {
|
|
719
|
+
let coordinator_clean = match input.coordinator_post_stop {
|
|
720
|
+
CoordinatorStopObservation::Gone => true,
|
|
721
|
+
CoordinatorStopObservation::Running | CoordinatorStopObservation::Unknown => false,
|
|
722
|
+
CoordinatorStopObservation::NotNeeded => {
|
|
723
|
+
!input.coordinator_timeout && input.coordinator_stop_ok.unwrap_or(true)
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
let ok = coordinator_clean
|
|
727
|
+
&& !input.kill_error
|
|
728
|
+
&& !input.session_residuals
|
|
729
|
+
&& !input.process_residuals
|
|
730
|
+
&& !input.cleanup_truth_degraded;
|
|
731
|
+
if ok {
|
|
732
|
+
return ShutdownOutcome {
|
|
733
|
+
ok,
|
|
734
|
+
status: "ok",
|
|
735
|
+
phase: None,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
let (status, phase) = if input.coordinator_timeout && !coordinator_clean {
|
|
739
|
+
("timeout", Some("stop_coordinator"))
|
|
740
|
+
} else if input.cleanup_truth_degraded {
|
|
741
|
+
("partial", Some("os_probe"))
|
|
742
|
+
} else if input.kill_error {
|
|
743
|
+
("failed", None)
|
|
744
|
+
} else {
|
|
745
|
+
("partial", None)
|
|
746
|
+
};
|
|
747
|
+
ShutdownOutcome { ok, status, phase }
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
fn coordinator_post_stop_observation(
|
|
751
|
+
workspace: &crate::coordinator::WorkspacePath,
|
|
752
|
+
pid: Option<crate::coordinator::Pid>,
|
|
753
|
+
) -> CoordinatorStopObservation {
|
|
754
|
+
if let Some(pid) = pid {
|
|
755
|
+
match crate::coordinator::pid_is_running(pid) {
|
|
756
|
+
Ok(true) => return CoordinatorStopObservation::Running,
|
|
757
|
+
Ok(false) => return CoordinatorStopObservation::Gone,
|
|
758
|
+
Err(_) => {}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
let health = crate::coordinator::coordinator_health(workspace);
|
|
762
|
+
match health.status {
|
|
763
|
+
crate::coordinator::CoordinatorHealthStatus::Running => {
|
|
764
|
+
CoordinatorStopObservation::Running
|
|
765
|
+
}
|
|
766
|
+
crate::coordinator::CoordinatorHealthStatus::Missing
|
|
767
|
+
| crate::coordinator::CoordinatorHealthStatus::InvalidPid
|
|
768
|
+
| crate::coordinator::CoordinatorHealthStatus::Stale => {
|
|
769
|
+
CoordinatorStopObservation::Gone
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
621
774
|
fn stop_coordinator_bounded(
|
|
622
775
|
workspace: crate::coordinator::WorkspacePath,
|
|
623
776
|
timeout: std::time::Duration,
|
|
624
777
|
) -> Option<Result<crate::coordinator::types::StopReport, String>> {
|
|
778
|
+
stop_coordinator_bounded_with(workspace, timeout, |workspace| {
|
|
779
|
+
crate::coordinator::stop_coordinator(workspace).map_err(|error| error.to_string())
|
|
780
|
+
})
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
pub(crate) fn stop_coordinator_bounded_with<F>(
|
|
784
|
+
workspace: crate::coordinator::WorkspacePath,
|
|
785
|
+
timeout: std::time::Duration,
|
|
786
|
+
stop: F,
|
|
787
|
+
) -> Option<Result<crate::coordinator::types::StopReport, String>>
|
|
788
|
+
where
|
|
789
|
+
F: FnOnce(
|
|
790
|
+
&crate::coordinator::WorkspacePath,
|
|
791
|
+
) -> Result<crate::coordinator::types::StopReport, String>
|
|
792
|
+
+ Send
|
|
793
|
+
+ 'static,
|
|
794
|
+
{
|
|
625
795
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
626
796
|
let handle = std::thread::spawn(move || {
|
|
627
|
-
let result =
|
|
628
|
-
crate::coordinator::stop_coordinator(&workspace).map_err(|error| error.to_string());
|
|
797
|
+
let result = stop(&workspace);
|
|
629
798
|
let _ = tx.send(result);
|
|
630
799
|
});
|
|
631
800
|
let outcome = rx.recv_timeout(timeout).ok();
|
|
@@ -640,8 +809,7 @@ pub mod lifecycle_port {
|
|
|
640
809
|
match rx.recv_timeout(std::time::Duration::from_millis(250)) {
|
|
641
810
|
Ok(late) => {
|
|
642
811
|
let _ = handle.join();
|
|
643
|
-
|
|
644
|
-
None
|
|
812
|
+
Some(late)
|
|
645
813
|
}
|
|
646
814
|
Err(_) => {
|
|
647
815
|
if handle.is_finished() {
|
|
@@ -104,6 +104,10 @@ pub fn format_status_summary(data: &Value) -> String {
|
|
|
104
104
|
],
|
|
105
105
|
"-",
|
|
106
106
|
);
|
|
107
|
+
let topology = non_empty_str(
|
|
108
|
+
data.get("leader_topology").and_then(Value::as_str),
|
|
109
|
+
"external",
|
|
110
|
+
);
|
|
107
111
|
let agents = data.get("agents").unwrap_or(&Value::Null);
|
|
108
112
|
let health = data.get("agent_health").unwrap_or(&Value::Null);
|
|
109
113
|
let counts = agent_summary_counts(agents, health);
|
|
@@ -137,7 +141,7 @@ pub fn format_status_summary(data: &Value) -> String {
|
|
|
137
141
|
.map(format_latest_result)
|
|
138
142
|
.unwrap_or_else(|| "none".to_string());
|
|
139
143
|
format!(
|
|
140
|
-
"coordinator: {coordinator} schema_ok={schema_ok} tmux={tmux}\nreceiver: {pane} cmd={cmd}\n{agent_line}\nqueued: {queued} mailbox messages awaiting delivery\nlatest result: {latest}"
|
|
144
|
+
"coordinator: {coordinator} schema_ok={schema_ok} tmux={tmux}\nreceiver: {pane} cmd={cmd} topology={topology}\n{agent_line}\nqueued: {queued} mailbox messages awaiting delivery\nlatest result: {latest}"
|
|
141
145
|
)
|
|
142
146
|
}
|
|
143
147
|
|
|
@@ -48,6 +48,22 @@ use rusqlite::params;
|
|
|
48
48
|
.cloned()
|
|
49
49
|
.unwrap_or_else(|| json!({}));
|
|
50
50
|
let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
|
|
51
|
+
let is_external_leader = state
|
|
52
|
+
.get("is_external_leader")
|
|
53
|
+
.and_then(Value::as_bool)
|
|
54
|
+
.unwrap_or(true);
|
|
55
|
+
let leader_topology = if is_external_leader { "external" } else { "managed" };
|
|
56
|
+
let leader_attach_command = if is_external_leader {
|
|
57
|
+
None
|
|
58
|
+
} else {
|
|
59
|
+
session_name.as_str().and_then(|session| {
|
|
60
|
+
crate::tmux_backend::attach_command_for_workspace(
|
|
61
|
+
workspace,
|
|
62
|
+
&crate::transport::SessionName::new(session.to_string()),
|
|
63
|
+
"leader",
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
};
|
|
51
67
|
let tmux_present = tmux_session_present(workspace, session_name.as_str());
|
|
52
68
|
let mut readiness_state = state.clone();
|
|
53
69
|
if let Some(obj) = readiness_state.as_object_mut() {
|
|
@@ -58,6 +74,10 @@ use rusqlite::params;
|
|
|
58
74
|
"ok": true,
|
|
59
75
|
"team": state.pointer("/leader/id").cloned().unwrap_or_else(|| json!("leader")),
|
|
60
76
|
"session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
|
|
77
|
+
"leader_topology": leader_topology,
|
|
78
|
+
"is_external_leader": is_external_leader,
|
|
79
|
+
"leader_attach_command": leader_attach_command,
|
|
80
|
+
"leader_client": state.get("leader_client").cloned().unwrap_or(Value::Null),
|
|
61
81
|
"tmux_session_present": tmux_present,
|
|
62
82
|
"all_spawned": readiness.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
|
|
63
83
|
"all_attached_receiver": readiness.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
|
|
@@ -553,6 +573,10 @@ use rusqlite::params;
|
|
|
553
573
|
json!({
|
|
554
574
|
"team": full.get("team").cloned().unwrap_or(Value::Null),
|
|
555
575
|
"session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
|
|
576
|
+
"leader_topology": full.get("leader_topology").cloned().unwrap_or_else(|| json!("external")),
|
|
577
|
+
"is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(true)),
|
|
578
|
+
"leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
|
|
579
|
+
"leader_client": full.get("leader_client").cloned().unwrap_or(Value::Null),
|
|
556
580
|
"tmux_session_present": full.get("tmux_session_present").cloned().unwrap_or(Value::Bool(false)),
|
|
557
581
|
"all_spawned": full.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
|
|
558
582
|
"all_attached_receiver": full.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
|
|
@@ -52,6 +52,7 @@ use super::*;
|
|
|
52
52
|
assert!(!got.attach_existing);
|
|
53
53
|
assert!(!got.confirm_attach);
|
|
54
54
|
assert_eq!(got.attach_session, None);
|
|
55
|
+
assert!(!got.external_leader);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
#[test]
|
|
@@ -72,6 +73,23 @@ use super::*;
|
|
|
72
73
|
assert!(!got.confirm_attach);
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
#[test]
|
|
77
|
+
fn leader_launcher_args_external_leader_opt_out() {
|
|
78
|
+
let got = leader_launcher_args(&[
|
|
79
|
+
"--external-leader".into(),
|
|
80
|
+
"--".into(),
|
|
81
|
+
"--model".into(),
|
|
82
|
+
"opus".into(),
|
|
83
|
+
])
|
|
84
|
+
.unwrap();
|
|
85
|
+
assert!(got.external_leader);
|
|
86
|
+
assert!(!got.attach_existing);
|
|
87
|
+
assert_eq!(
|
|
88
|
+
got.provider_args,
|
|
89
|
+
vec!["--".to_string(), "--model".to_string(), "opus".to_string()]
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
#[test]
|
|
76
94
|
fn leader_launcher_args_attach_session_spaced() {
|
|
77
95
|
// golden: ["--attach-session","mysess"] -> attach_session="mysess"
|
|
@@ -272,7 +290,7 @@ use super::*;
|
|
|
272
290
|
fn format_status_summary_full_byte_lock() {
|
|
273
291
|
// golden:
|
|
274
292
|
// coordinator: running schema_ok=True tmux=True
|
|
275
|
-
// receiver: %3 cmd=codex
|
|
293
|
+
// receiver: %3 cmd=codex topology=external
|
|
276
294
|
// agents: 2 — running=1 busy=1 idle=0 stopped=0 failed=0 unknown=0
|
|
277
295
|
// queued: 2 mailbox messages awaiting delivery
|
|
278
296
|
// latest result: a1 -> did the thing @ -
|
|
@@ -287,7 +305,7 @@ use super::*;
|
|
|
287
305
|
});
|
|
288
306
|
let got = format_status_summary(&data);
|
|
289
307
|
let expected = "coordinator: running schema_ok=true tmux=true\n\
|
|
290
|
-
receiver: %3 cmd=codex\n\
|
|
308
|
+
receiver: %3 cmd=codex topology=external\n\
|
|
291
309
|
agents: 2 — running=1 busy=1 idle=0 stopped=0 failed=0 unknown=0\n\
|
|
292
310
|
queued: 2 mailbox messages awaiting delivery\n\
|
|
293
311
|
latest result: a1 -> did the thing @ -";
|
|
@@ -299,7 +317,7 @@ latest result: a1 -> did the thing @ -";
|
|
|
299
317
|
// golden empty data: stopped/false/false, dashes, 0 counts, none latest.
|
|
300
318
|
let got = format_status_summary(&json!({}));
|
|
301
319
|
let expected = "coordinator: stopped schema_ok=false tmux=false\n\
|
|
302
|
-
receiver: - cmd
|
|
320
|
+
receiver: - cmd=- topology=external\n\
|
|
303
321
|
agents: 0 — running=0 busy=0 idle=0 stopped=0 failed=0 unknown=0\n\
|
|
304
322
|
queued: 0 mailbox messages awaiting delivery\n\
|
|
305
323
|
latest result: none";
|
|
@@ -148,35 +148,35 @@ use super::*;
|
|
|
148
148
|
|
|
149
149
|
#[test]
|
|
150
150
|
fn red_format_status_summary_empty_pane_id_is_dash() {
|
|
151
|
-
// golden: pane_id='' -> 'receiver: - cmd=x'
|
|
151
|
+
// golden: pane_id='' -> 'receiver: - cmd=x topology=external'
|
|
152
152
|
let line = format_status_summary(&json!({
|
|
153
153
|
"leader_receiver": {"pane_id": "", "pane_current_command": "x"}
|
|
154
154
|
}));
|
|
155
155
|
assert_eq!(
|
|
156
156
|
line.lines().nth(1).unwrap(),
|
|
157
|
-
"receiver: - cmd=x",
|
|
157
|
+
"receiver: - cmd=x topology=external",
|
|
158
158
|
"empty-string pane_id MUST fall back to '-' (golden `pane_id or '-'`)"
|
|
159
159
|
);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
#[test]
|
|
163
163
|
fn red_format_status_summary_cmd_falls_back_to_current_command() {
|
|
164
|
-
// golden: missing pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude'
|
|
164
|
+
// golden: missing pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude topology=external'
|
|
165
165
|
let line_missing = format_status_summary(&json!({
|
|
166
166
|
"leader_receiver": {"pane_id": "%3", "current_command": "claude"}
|
|
167
167
|
}));
|
|
168
168
|
assert_eq!(
|
|
169
169
|
line_missing.lines().nth(1).unwrap(),
|
|
170
|
-
"receiver: %3 cmd=claude",
|
|
170
|
+
"receiver: %3 cmd=claude topology=external",
|
|
171
171
|
"cmd MUST fall back to current_command when pane_current_command is absent (golden line 285)"
|
|
172
172
|
);
|
|
173
|
-
// golden: empty pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude'
|
|
173
|
+
// golden: empty pane_current_command + current_command='claude' -> 'receiver: %3 cmd=claude topology=external'
|
|
174
174
|
let line_empty = format_status_summary(&json!({
|
|
175
175
|
"leader_receiver": {"pane_id": "%3", "pane_current_command": "", "current_command": "claude"}
|
|
176
176
|
}));
|
|
177
177
|
assert_eq!(
|
|
178
178
|
line_empty.lines().nth(1).unwrap(),
|
|
179
|
-
"receiver: %3 cmd=claude",
|
|
179
|
+
"receiver: %3 cmd=claude topology=external",
|
|
180
180
|
"empty pane_current_command MUST fall through to current_command (golden falsy `or`)"
|
|
181
181
|
);
|
|
182
182
|
}
|
|
@@ -161,6 +161,7 @@ struct CleanShutdownTransport {
|
|
|
161
161
|
targets: Vec<PaneInfo>,
|
|
162
162
|
kill_server_called: Mutex<bool>,
|
|
163
163
|
probe_timeout_kind: Option<&'static str>,
|
|
164
|
+
targets_persist_after_kill: bool,
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
impl CleanShutdownTransport {
|
|
@@ -170,6 +171,7 @@ impl CleanShutdownTransport {
|
|
|
170
171
|
targets: Vec::new(),
|
|
171
172
|
kill_server_called: Mutex::new(false),
|
|
172
173
|
probe_timeout_kind: None,
|
|
174
|
+
targets_persist_after_kill: false,
|
|
173
175
|
}
|
|
174
176
|
}
|
|
175
177
|
|
|
@@ -186,6 +188,11 @@ impl CleanShutdownTransport {
|
|
|
186
188
|
self.probe_timeout_kind = Some(probe);
|
|
187
189
|
self
|
|
188
190
|
}
|
|
191
|
+
|
|
192
|
+
fn with_targets_persist_after_kill(mut self) -> Self {
|
|
193
|
+
self.targets_persist_after_kill = true;
|
|
194
|
+
self
|
|
195
|
+
}
|
|
189
196
|
}
|
|
190
197
|
|
|
191
198
|
impl Transport for CleanShutdownTransport {
|
|
@@ -249,7 +256,7 @@ impl Transport for CleanShutdownTransport {
|
|
|
249
256
|
}
|
|
250
257
|
|
|
251
258
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
252
|
-
if *self.session_present.lock().unwrap() {
|
|
259
|
+
if *self.session_present.lock().unwrap() || self.targets_persist_after_kill {
|
|
253
260
|
Ok(self.targets.clone())
|
|
254
261
|
} else {
|
|
255
262
|
Ok(Vec::new())
|
|
@@ -375,6 +382,85 @@ fn ps_table_timeout_still_degrades_shutdown_truth() {
|
|
|
375
382
|
assert_eq!(out["probe_timeout_kind"], json!("ps_table"));
|
|
376
383
|
}
|
|
377
384
|
|
|
385
|
+
#[test]
|
|
386
|
+
fn bounded_coordinator_stop_returns_grace_window_late_success() {
|
|
387
|
+
let ws = tmp_shutdown_workspace("late-coordinator-stop-success");
|
|
388
|
+
let report = crate::cli::lifecycle_port::stop_coordinator_bounded_with(
|
|
389
|
+
crate::coordinator::WorkspacePath::new(ws),
|
|
390
|
+
std::time::Duration::from_millis(5),
|
|
391
|
+
|_workspace| {
|
|
392
|
+
std::thread::sleep(std::time::Duration::from_millis(25));
|
|
393
|
+
Ok(crate::coordinator::StopReport {
|
|
394
|
+
ok: true,
|
|
395
|
+
status: crate::coordinator::StopOutcome::Stopped,
|
|
396
|
+
pid: Some(crate::coordinator::Pid::new(12345)),
|
|
397
|
+
})
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
.expect("late result inside grace window must be returned")
|
|
401
|
+
.expect("stop result should be ok");
|
|
402
|
+
|
|
403
|
+
assert!(report.ok, "late success must not be discarded as timeout");
|
|
404
|
+
assert_eq!(report.status, crate::coordinator::StopOutcome::Stopped);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#[test]
|
|
408
|
+
fn shutdown_outcome_late_or_postcheck_gone_is_ok_with_lsof_diagnostic() {
|
|
409
|
+
let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
|
|
410
|
+
crate::cli::lifecycle_port::ShutdownOutcomeInput {
|
|
411
|
+
kill_error: false,
|
|
412
|
+
session_residuals: false,
|
|
413
|
+
process_residuals: false,
|
|
414
|
+
cleanup_truth_degraded: false,
|
|
415
|
+
coordinator_timeout: true,
|
|
416
|
+
coordinator_stop_ok: None,
|
|
417
|
+
coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Gone,
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
assert!(out.ok);
|
|
422
|
+
assert_eq!(out.status, "ok");
|
|
423
|
+
assert_eq!(out.phase, None);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#[test]
|
|
427
|
+
fn shutdown_outcome_coordinator_timeout_still_running_is_timeout() {
|
|
428
|
+
let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
|
|
429
|
+
crate::cli::lifecycle_port::ShutdownOutcomeInput {
|
|
430
|
+
kill_error: false,
|
|
431
|
+
session_residuals: false,
|
|
432
|
+
process_residuals: false,
|
|
433
|
+
cleanup_truth_degraded: false,
|
|
434
|
+
coordinator_timeout: true,
|
|
435
|
+
coordinator_stop_ok: None,
|
|
436
|
+
coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Running,
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
assert!(!out.ok);
|
|
441
|
+
assert_eq!(out.status, "timeout");
|
|
442
|
+
assert_eq!(out.phase, Some("stop_coordinator"));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#[test]
|
|
446
|
+
fn shutdown_outcome_ps_table_degraded_still_partial_after_coordinator_gone() {
|
|
447
|
+
let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
|
|
448
|
+
crate::cli::lifecycle_port::ShutdownOutcomeInput {
|
|
449
|
+
kill_error: false,
|
|
450
|
+
session_residuals: false,
|
|
451
|
+
process_residuals: false,
|
|
452
|
+
cleanup_truth_degraded: true,
|
|
453
|
+
coordinator_timeout: true,
|
|
454
|
+
coordinator_stop_ok: None,
|
|
455
|
+
coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Gone,
|
|
456
|
+
},
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
assert!(!out.ok);
|
|
460
|
+
assert_eq!(out.status, "partial");
|
|
461
|
+
assert_eq!(out.phase, Some("os_probe"));
|
|
462
|
+
}
|
|
463
|
+
|
|
378
464
|
#[test]
|
|
379
465
|
fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive() {
|
|
380
466
|
let ws = tmp_shutdown_workspace("leader-env-socket-no-kill-server");
|
|
@@ -417,3 +503,43 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
|
|
|
417
503
|
"leader-env/shared socket shutdown must kill sessions individually, never kill-server"
|
|
418
504
|
);
|
|
419
505
|
}
|
|
506
|
+
|
|
507
|
+
#[test]
|
|
508
|
+
fn managed_leader_shutdown_never_kills_server_even_when_socket_looks_exclusive() {
|
|
509
|
+
let ws = tmp_shutdown_workspace("managed-leader-no-kill-server");
|
|
510
|
+
crate::state::persist::save_runtime_state(
|
|
511
|
+
&ws,
|
|
512
|
+
&json!({
|
|
513
|
+
"session_name": "team-current",
|
|
514
|
+
"is_external_leader": false,
|
|
515
|
+
"agents": {}
|
|
516
|
+
}),
|
|
517
|
+
)
|
|
518
|
+
.unwrap();
|
|
519
|
+
let transport = CleanShutdownTransport::new()
|
|
520
|
+
.with_targets(vec![PaneInfo {
|
|
521
|
+
pane_id: PaneId::new("%1"),
|
|
522
|
+
session: SessionName::new("team-current"),
|
|
523
|
+
window_index: Some(0),
|
|
524
|
+
window_name: Some(WindowName::new("leader")),
|
|
525
|
+
pane_index: Some(0),
|
|
526
|
+
tty: None,
|
|
527
|
+
current_command: Some("codex".to_string()),
|
|
528
|
+
current_path: None,
|
|
529
|
+
active: true,
|
|
530
|
+
pane_pid: None,
|
|
531
|
+
leader_env: BTreeMap::new(),
|
|
532
|
+
}])
|
|
533
|
+
.with_targets_persist_after_kill();
|
|
534
|
+
|
|
535
|
+
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
536
|
+
.expect("shutdown should complete");
|
|
537
|
+
|
|
538
|
+
assert_eq!(out["ok"], json!(true));
|
|
539
|
+
assert_eq!(out["killed_sessions"], json!(["team-current"]));
|
|
540
|
+
assert_eq!(out["spared_sessions"], json!([]));
|
|
541
|
+
assert!(
|
|
542
|
+
!transport.kill_server_called(),
|
|
543
|
+
"managed topology must clear the team session/window without kill-server"
|
|
544
|
+
);
|
|
545
|
+
}
|