Skip to content

Commit 3744c42

Browse files
authored
fix: report dead-task respawns as restarts (#71)
* fix: report dead-task respawns as restarts * fix(reporting): preserve restart taxonomy on current main
1 parent c66c0c9 commit 3744c42

4 files changed

Lines changed: 94 additions & 17 deletions

File tree

src/eval_run.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1192,7 +1192,10 @@ fn run_eval_inner(spec: &Spec, eval: &Eval, spec_dir: &Path, catalog: &Path, hos
11921192
),
11931193
};
11941194
if !report.launched.is_empty() {
1195-
eval_log!("== supervise: respawned {:?} from spec ==", report.launched);
1195+
eval_log!("== supervise: launched {:?} from spec ==", report.launched);
1196+
}
1197+
if !report.restarted.is_empty() {
1198+
eval_log!("== supervise: restarted {:?} from spec ==", report.restarted);
11961199
}
11971200
}
11981201
};

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1959,6 +1959,7 @@ fn up(
19591959

19601960
fn print_report(report: &UpReport) {
19611961
report_line("launched", &report.launched);
1962+
report_line("restarted", &report.restarted);
19621963
report_line("torn down", &report.torn_down);
19631964
report_line("gc", &report.gc);
19641965
report_line("held", &report.held);

src/run.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -779,11 +779,15 @@ pub struct UpReport {
779779
/// The pass could not obtain an authoritative session snapshot, so it deliberately performed no
780780
/// reconciliation. Long-running supervisors retry; a one-shot caller must exit unsuccessfully.
781781
pub skipped: bool,
782-
/// pty ids spawned this pass.
782+
/// Task IDs that st2 started without a restart reap in this pass.
783783
pub launched: Vec<String>,
784+
/// Task IDs that st2 restarted successfully in this pass. st2 reaped a dead active record
785+
/// before it spawned the replacement. These IDs are not first launches or final garbage
786+
/// collection.
787+
pub restarted: Vec<String>,
784788
/// pty ids torn down (retired agents) this pass.
785789
pub torn_down: Vec<String>,
786-
/// pty ids garbage-collected (dead, non-`keep`) this pass.
790+
/// Task IDs in final garbage collection. st2 did not spawn replacements.
787791
pub gc: Vec<String>,
788792
/// pty ids whose GC/relaunch was DEFERRED this pass by the liveness debounce — a task that read
789793
/// not-alive but was alive within the grace window, i.e. a transient `pty list` flicker under load,
@@ -811,6 +815,7 @@ impl UpReport {
811815
fn absorb(&mut self, mut other: UpReport) {
812816
self.skipped |= other.skipped;
813817
self.launched.append(&mut other.launched);
818+
self.restarted.append(&mut other.restarted);
814819
self.torn_down.append(&mut other.torn_down);
815820
self.gc.append(&mut other.gc);
816821
self.deferred.append(&mut other.deferred);
@@ -828,6 +833,7 @@ impl UpReport {
828833
pub fn is_noteworthy(&self) -> bool {
829834
self.skipped
830835
|| !self.launched.is_empty()
836+
|| !self.restarted.is_empty()
831837
|| !self.torn_down.is_empty()
832838
|| !self.gc.is_empty()
833839
|| !self.flapping.is_empty()
@@ -888,11 +894,12 @@ pub fn execute(
888894
crate::flapping::RestartDecision::Delaying
889895
| crate::flapping::RestartDecision::RateLimited => continue,
890896
}
891-
// Reap the corpse first (a dead session blocks respawn), preserving any backend-owned
892-
// bounded diagnostics, then respawn.
893-
if gc_set.contains(target.pty_id.as_str()) {
897+
// Reap the dead record before st2 starts a replacement. A dead record blocks the
898+
// replacement. The backend preserves its bounded diagnostics.
899+
let restarting = gc_set.contains(target.pty_id.as_str());
900+
if restarting {
894901
match runner.reap_for_restart(&target.pty_id) {
895-
Ok(()) => report.gc.push(target.pty_id.clone()),
902+
Ok(()) => {}
896903
Err(e) => {
897904
report
898905
.errors
@@ -904,7 +911,11 @@ pub fn execute(
904911
match runner.spawn(target, spec_dir) {
905912
Ok(()) => {
906913
cap.record(&target.pty_id, now);
907-
report.launched.push(target.pty_id.clone());
914+
if restarting {
915+
report.restarted.push(target.pty_id.clone());
916+
} else {
917+
report.launched.push(target.pty_id.clone());
918+
}
908919
}
909920
Err(e) => report.errors.push(format!("spawn {}: {e}", target.pty_id)),
910921
}

tests/run.rs

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,17 +103,23 @@ fn selected_catalog_two_agent_kdl_recording_runner_matrix() {
103103
assert_eq!(runner.spawned.borrow().as_slice(), ["host.owner.work"]);
104104
assert!(runner.reaped.borrow().is_empty());
105105
assert_eq!(report.launched, ["host.owner.work"]);
106+
assert!(report.restarted.is_empty());
107+
assert!(report.gc.is_empty());
106108
}
107109
Actual::Live => {
108110
assert!(runner.spawned.borrow().is_empty());
109111
assert!(runner.reaped.borrow().is_empty());
110112
assert_eq!(report.adopted, ["owner"]);
113+
assert!(report.launched.is_empty());
114+
assert!(report.restarted.is_empty());
115+
assert!(report.gc.is_empty());
111116
}
112117
Actual::Dead => {
113118
assert_eq!(runner.reaped.borrow().as_slice(), ["host.owner.work"]);
114119
assert_eq!(runner.spawned.borrow().as_slice(), ["host.owner.work"]);
115-
assert_eq!(report.gc, ["host.owner.work"]);
116-
assert_eq!(report.launched, ["host.owner.work"]);
120+
assert!(report.launched.is_empty());
121+
assert_eq!(report.restarted, ["host.owner.work"]);
122+
assert!(report.gc.is_empty());
117123
}
118124
}
119125
assert!(
@@ -349,7 +355,7 @@ fn selected_one_shot_live_adopts_without_actions() {
349355
}
350356

351357
#[test]
352-
fn selected_one_shot_dead_reaps_and_relaunches_only_selected() {
358+
fn selected_one_shot_reports_a_dead_task_only_as_restarted() {
353359
let runner = FakeRunner {
354360
sessions: vec![
355361
dead("host.agent.work"),
@@ -375,8 +381,9 @@ fn selected_one_shot_dead_reaps_and_relaunches_only_selected() {
375381
assert_eq!(runner.spawned.borrow().as_slice(), ["host.agent.work"]);
376382
assert!(runner.killed.borrow().is_empty());
377383
assert!(runner.removed.borrow().is_empty());
378-
assert_eq!(report.gc, ["host.agent.work"]);
379-
assert_eq!(report.launched, ["host.agent.work"]);
384+
assert!(report.launched.is_empty());
385+
assert_eq!(report.restarted, ["host.agent.work"]);
386+
assert!(report.gc.is_empty());
380387
}
381388

382389
#[test]
@@ -444,6 +451,7 @@ struct FakeRunner {
444451
killed: RefCell<Vec<String>>,
445452
reaped: RefCell<Vec<String>>,
446453
removed: RefCell<Vec<String>>,
454+
ops: RefCell<Vec<String>>,
447455
}
448456

449457
impl Runner for FakeRunner {
@@ -458,6 +466,9 @@ impl Runner for FakeRunner {
458466
if self.fail_spawn.as_deref() == Some(target.pty_id.as_str()) {
459467
anyhow::bail!("simulated spawn failure");
460468
}
469+
self.ops
470+
.borrow_mut()
471+
.push(format!("spawn:{}", target.pty_id));
461472
self.spawned.borrow_mut().push(target.pty_id.clone());
462473
self.spawn_dirs
463474
.borrow_mut()
@@ -469,6 +480,7 @@ impl Runner for FakeRunner {
469480
Ok(())
470481
}
471482
fn reap_for_restart(&self, pty_id: &str) -> anyhow::Result<()> {
483+
self.ops.borrow_mut().push(format!("reap:{pty_id}"));
472484
self.reaped.borrow_mut().push(pty_id.to_string());
473485
if self.fail_reap.as_deref() == Some(pty_id) {
474486
anyhow::bail!("reap broke");
@@ -525,6 +537,8 @@ fn up_once_launches_all_tasks_of_a_fresh_agent() {
525537
let mut launched = report.launched.clone();
526538
launched.sort();
527539
assert_eq!(launched, vec!["hetz.demo-claude", "hetz.demo.ding"]);
540+
assert!(report.restarted.is_empty());
541+
assert!(report.gc.is_empty());
528542
assert!(report.errors.is_empty());
529543
let dirs = runner.spawn_dirs.borrow();
530544
assert!(dirs.iter().all(|(_, d)| d.ends_with("agents/hetz/demo")));
@@ -614,7 +628,7 @@ fn up_once_collects_spawn_errors_without_aborting() {
614628
}
615629

616630
#[test]
617-
fn up_once_reaps_dead_nonkeep_then_respawns() {
631+
fn up_once_reports_a_successful_replacement_only_as_restarted() {
618632
let tmp = tempfile::tempdir().unwrap();
619633
write(tmp.path(), "agents/hetz/demo/agent.toml", AGENT);
620634
let runner = FakeRunner {
@@ -627,9 +641,26 @@ fn up_once_reaps_dead_nonkeep_then_respawns() {
627641
assert_eq!(reaped, vec!["hetz.demo-claude", "hetz.demo.ding"]);
628642
assert!(
629643
runner.removed.borrow().is_empty(),
630-
"a crash restart is not final retirement cleanup"
644+
"a restart must not remove final retirement state"
645+
);
646+
assert!(report.launched.is_empty());
647+
let mut restarted = report.restarted.clone();
648+
restarted.sort();
649+
assert_eq!(restarted, vec!["hetz.demo-claude", "hetz.demo.ding"]);
650+
assert!(
651+
report.gc.is_empty(),
652+
"a successful restart must not be reported as final garbage collection"
653+
);
654+
assert_eq!(
655+
runner.ops.borrow().as_slice(),
656+
[
657+
"reap:hetz.demo-claude",
658+
"spawn:hetz.demo-claude",
659+
"reap:hetz.demo.ding",
660+
"spawn:hetz.demo.ding",
661+
],
662+
"st2 must reap each dead record before it starts the replacement"
631663
);
632-
assert_eq!(report.launched.len(), 2);
633664
}
634665

635666
#[test]
@@ -644,7 +675,9 @@ fn up_once_does_not_restart_a_task_when_diagnostic_reap_fails() {
644675

645676
let report = up_once(tmp.path(), "hetz", &runner).unwrap();
646677

647-
assert_eq!(report.launched, vec!["hetz.demo.ding"]);
678+
assert!(report.launched.is_empty());
679+
assert_eq!(report.restarted, vec!["hetz.demo.ding"]);
680+
assert!(report.gc.is_empty());
648681
assert_eq!(runner.spawned.borrow().as_slice(), ["hetz.demo.ding"]);
649682
assert!(
650683
report
@@ -654,6 +687,34 @@ fn up_once_does_not_restart_a_task_when_diagnostic_reap_fails() {
654687
);
655688
}
656689

690+
#[test]
691+
fn up_once_does_not_report_failed_replacement_as_restarted() {
692+
let tmp = tempfile::tempdir().unwrap();
693+
write(tmp.path(), "agents/hetz/demo/agent.toml", AGENT);
694+
let runner = FakeRunner {
695+
sessions: vec![dead("hetz.demo-claude"), live("hetz.demo.ding")],
696+
fail_spawn: Some("hetz.demo-claude".into()),
697+
..Default::default()
698+
};
699+
700+
let report = up_once(tmp.path(), "hetz", &runner).unwrap();
701+
702+
assert_eq!(
703+
runner.reaped.borrow().as_slice(),
704+
["hetz.demo-claude"],
705+
"st2 must reap the stale record before it starts a replacement"
706+
);
707+
assert!(report.launched.is_empty());
708+
assert!(report.restarted.is_empty());
709+
assert!(report.gc.is_empty());
710+
assert!(
711+
report
712+
.errors
713+
.iter()
714+
.any(|error| error == "spawn hetz.demo-claude: simulated spawn failure")
715+
);
716+
}
717+
657718
#[test]
658719
fn up_once_finally_removes_dead_retired_tasks_without_restarting_them() {
659720
let tmp = tempfile::tempdir().unwrap();
@@ -675,6 +736,7 @@ fn up_once_finally_removes_dead_retired_tasks_without_restarting_them() {
675736
assert_eq!(removed, vec!["hetz.demo-claude", "hetz.demo.ding"]);
676737
assert!(runner.reaped.borrow().is_empty());
677738
assert!(report.launched.is_empty());
739+
assert!(report.restarted.is_empty());
678740
assert_eq!(report.gc.len(), 2);
679741
}
680742

0 commit comments

Comments
 (0)