fix(lifecycle): let the scanner resolve visibly-completed stop/start transitions
One legacy app per gate run stuck at 'stopping'/'starting' past the test window while its container was already settled (vaultwarden:stop run C, jellyfin:stop run D, uptime-kuma:start run E — .228, 2026-07-09). The package scanner already sees the truth every 60s but merge_preserving_transitional refused to report it until the RPC worker wrote the final state, and the workers can legitimately trail the container by minutes: - stop workers queue behind the orchestrator per-app lock, which the reconcile host-port repair path holds through multi-minute stability waits (repair_manifest_host_ports_after_stability: 5s + 5s probe + restart + 60-420s port wait + 15-90s stable-running); - start workers hold Starting through the full readiness wait — host_port_wait_timeout_secs is 420s for uptime-kuma (HTTP probe), longer than any UI/test patience — and the Starting stuck-timeout is the 20-minute INSTALLING one. New merge rules, both truth-driven by podman's live view: - (Stopping, Stopped) + user-stop marker → Stopped: the scanner only normalizes exited→Stopped for user-stopped apps, so this is exactly "the user asked for a stop and the container has exited". - (Starting, Running) → Running: the start visibly succeeded; live health readings are merged separately and keep reporting readiness. Restarting is deliberately NOT resolved by a Running scan — mid-restart readings are the pre-stop container. Tests: merge_tests 13/13 (4 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
891cbba469
commit
dd3afbbac2
@ -1184,6 +1184,30 @@ fn merge_preserving_transitional(
|
||||
{
|
||||
fresh.state.clone()
|
||||
}
|
||||
// A user-initiated stop whose container podman now reports settled
|
||||
// (the scanner maps exited+user-stopped → Stopped) has visibly
|
||||
// completed — report it. The stop worker still owns cleanup, but it
|
||||
// can trail the actual container exit by minutes when it is queued
|
||||
// behind the orchestrator's per-app lock (reconcile host-port repair
|
||||
// holds it through multi-minute stability waits). Holding the card
|
||||
// (and the lifecycle gate) in "Stopping" that whole time reports a
|
||||
// completed stop as stuck (vaultwarden/jellyfin, gate runs C/D,
|
||||
// .228 2026-07-09).
|
||||
(crate::data_model::PackageState::Stopping, crate::data_model::PackageState::Stopped)
|
||||
if user_stop_requested =>
|
||||
{
|
||||
fresh.state.clone()
|
||||
}
|
||||
// Same reasoning for start: once podman reports Running, the start
|
||||
// has visibly succeeded. The start worker keeps Starting through its
|
||||
// full readiness wait (host-port probe budgets reach 420s for
|
||||
// uptime-kuma — longer than any UI/test patience) even though the
|
||||
// container is up and its live health is already shown separately
|
||||
// (uptime-kuma, gate run E). Restarting is deliberately NOT mapped:
|
||||
// mid-restart Running readings are the pre-stop container.
|
||||
(crate::data_model::PackageState::Starting, crate::data_model::PackageState::Running) => {
|
||||
fresh.state.clone()
|
||||
}
|
||||
// Removing with a live running container is stale: uninstall either
|
||||
// failed or Archipelago restarted before the spawned task could revert
|
||||
// state. Let the scanner recover the UI immediately instead of
|
||||
@ -1708,6 +1732,49 @@ mod merge_tests {
|
||||
assert_eq!(merged.health.as_deref(), Some("healthy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_stop_resolves_when_container_has_exited() {
|
||||
// The container exited and the scanner already normalized
|
||||
// exited+user-stopped to Stopped — the stop visibly completed, even
|
||||
// if the stop worker is still queued behind the per-app lock.
|
||||
let existing = make_entry(PackageState::Stopping, Some("unknown"));
|
||||
let fresh = make_entry(PackageState::Stopped, None);
|
||||
let merged = merge_preserving_transitional(&existing, &fresh, true);
|
||||
assert_eq!(merged.state, PackageState::Stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_user_stopping_with_exited_container_stays_owned() {
|
||||
// No user-stop marker → this Stopping belongs to some other flow;
|
||||
// don't resolve it from a scan.
|
||||
let existing = make_entry(PackageState::Stopping, Some("unknown"));
|
||||
let fresh = make_entry(PackageState::Stopped, None);
|
||||
let merged = merge_preserving_transitional(&existing, &fresh, false);
|
||||
assert_eq!(merged.state, PackageState::Stopping);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_resolves_when_container_is_running() {
|
||||
// Start worker may still be inside its readiness wait (up to 420s for
|
||||
// uptime-kuma) — but podman reporting Running means the start visibly
|
||||
// succeeded; live health is merged separately.
|
||||
let existing = make_entry(PackageState::Starting, Some("starting"));
|
||||
let fresh = make_entry(PackageState::Running, Some("healthy"));
|
||||
let merged = merge_preserving_transitional(&existing, &fresh, false);
|
||||
assert_eq!(merged.state, PackageState::Running);
|
||||
assert_eq!(merged.health.as_deref(), Some("healthy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarting_is_not_resolved_by_running_scan() {
|
||||
// Mid-restart the pre-stop container still reads Running — the
|
||||
// restart worker owns this state until it finishes.
|
||||
let existing = make_entry(PackageState::Restarting, Some("healthy"));
|
||||
let fresh = make_entry(PackageState::Running, Some("healthy"));
|
||||
let merged = merge_preserving_transitional(&existing, &fresh, false);
|
||||
assert_eq!(merged.state, PackageState::Restarting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_fresh_observability_fields() {
|
||||
// Non-state observability fields (health, exit_code, installed)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user