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:
archipelago 2026-07-09 02:59:19 -04:00
parent 891cbba469
commit dd3afbbac2

View File

@ -1184,6 +1184,30 @@ fn merge_preserving_transitional(
{ {
fresh.state.clone() 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 // Removing with a live running container is stale: uninstall either
// failed or Archipelago restarted before the spawned task could revert // failed or Archipelago restarted before the spawned task could revert
// state. Let the scanner recover the UI immediately instead of // 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")); 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] #[test]
fn merges_fresh_observability_fields() { fn merges_fresh_observability_fields() {
// Non-state observability fields (health, exit_code, installed) // Non-state observability fields (health, exit_code, installed)