Immich is the one app in the catalogue whose migrated pause threshold is longer than the global stop threshold, so once the pause tier is on it skips the paused state almost entirely: it runs for 6 h, pauses, and is stopped on the next control cycle ~10 s later.
The arithmetic
apps/immich/app_meta.json is still v: 1.0 and carries the pre-rework key:
"lifecycle": { "always_on": false, "idle_time_for_shutdown": 21600 }
migrate_1_2_to_1_3 maps that to idle_for_pause and deliberately leaves idle_for_stop unset so it falls back to the global default. The defaults in config.toml are:
default_idle_for_pause = 60
default_idle_for_stop = 10800 # 3 h
So for Immich t1 = 21600 (6 h) and t2 = 10800 (3 h) — t1 > t2, inverting the intended ordering. In _control_app_time:
if app.status == Status.RUNNING and idle >= t1: # 21600
await docker_pause_app(app.name)
elif app.status == Status.PAUSED and idle >= t2: # 10800
await docker_stop_app(app.name)
At the moment it pauses, idle is already ≥ 21600, which is comfortably past t2. The next cycle — refresh_interval = 10 — takes the PAUSED branch and stops it. Immich therefore never spends a useful interval paged-out-but-resumable, which is the state the whole tier exists to provide, and every return after a 6 h gap pays a full cold start instead of an unpause.
Immich is the only affected app: it is the only entry in the catalogue above 10800 (next highest is mirotalk at 4000).
Why the validator does not catch it
Lifecycle.validate_combinations compares the two only when both are explicitly set:
if (self.idle_for_pause is not None
and self.idle_for_stop is not None
and self.idle_for_pause >= self.idle_for_stop):
raise ValueError("idle_for_pause must be less than idle_for_stop")
The migration never sets idle_for_stop, so the pair that actually governs behaviour — an explicit idle_for_pause against the global default stop — is never compared. Any future app that sets a pause threshold above 10800 lands in the same hole silently.
Severity
Latent right now: pause_enabled defaults to false in both config.toml and settings.py, where it is described as the rollout kill-switch, with a per-shard PAUSE_ENABLED override in the backend shard model. This bites when the tier is switched on — and it bites the app that most wants the tier, since a photo library is exactly the "come back to it hours later" case.
Options
- Set
idle_for_stop explicitly on Immich (something above 21600) and bump the file to the current v. Narrowest fix.
- Lower Immich's
idle_for_pause. A photo library has no reason to hold RAM for 6 h when pausing is cheap and resumable — arguably the pre-rework 21600 was tuned for stopping and never re-examined for pausing, which is the real root cause.
- Extend the validator to compare an explicit
idle_for_pause against the effective idle_for_stop, defaults included, so the class of bug cannot recur. Worth doing alongside 1 or 2.
Found while checking the idle thresholds after an internal meeting, 2026-08-31.
Immich is the one app in the catalogue whose migrated pause threshold is longer than the global stop threshold, so once the pause tier is on it skips the paused state almost entirely: it runs for 6 h, pauses, and is stopped on the next control cycle ~10 s later.
The arithmetic
apps/immich/app_meta.jsonis stillv: 1.0and carries the pre-rework key:migrate_1_2_to_1_3maps that toidle_for_pauseand deliberately leavesidle_for_stopunset so it falls back to the global default. The defaults inconfig.tomlare:So for Immich
t1 = 21600(6 h) andt2 = 10800(3 h) —t1 > t2, inverting the intended ordering. In_control_app_time:At the moment it pauses,
idleis already ≥ 21600, which is comfortably pastt2. The next cycle —refresh_interval = 10— takes thePAUSEDbranch and stops it. Immich therefore never spends a useful interval paged-out-but-resumable, which is the state the whole tier exists to provide, and every return after a 6 h gap pays a full cold start instead of an unpause.Immich is the only affected app: it is the only entry in the catalogue above 10800 (next highest is mirotalk at 4000).
Why the validator does not catch it
Lifecycle.validate_combinationscompares the two only when both are explicitly set:The migration never sets
idle_for_stop, so the pair that actually governs behaviour — an explicitidle_for_pauseagainst the global default stop — is never compared. Any future app that sets a pause threshold above 10800 lands in the same hole silently.Severity
Latent right now:
pause_enableddefaults tofalsein bothconfig.tomlandsettings.py, where it is described as the rollout kill-switch, with a per-shardPAUSE_ENABLEDoverride in the backend shard model. This bites when the tier is switched on — and it bites the app that most wants the tier, since a photo library is exactly the "come back to it hours later" case.Options
idle_for_stopexplicitly on Immich (something above 21600) and bump the file to the currentv. Narrowest fix.idle_for_pause. A photo library has no reason to hold RAM for 6 h when pausing is cheap and resumable — arguably the pre-rework 21600 was tuned for stopping and never re-examined for pausing, which is the real root cause.idle_for_pauseagainst the effectiveidle_for_stop, defaults included, so the class of bug cannot recur. Worth doing alongside 1 or 2.Found while checking the idle thresholds after an internal meeting, 2026-08-31.