Skip to content

Heat: end the session after 15 minutes of serious heat, and sleep a closed lid - #81

Draft
aldikosh23 wants to merge 12 commits into
krishhgg:mainfrom
aldikosh23:feat/sustained-heat-end
Draft

aldikosh23 wants to merge 12 commits into
krishhgg:mainfrom
aldikosh23:feat/sustained-heat-end

Conversation

@aldikosh23

@aldikosh23 aldikosh23 commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Draft: this builds on #61 and #80 and contains their commits until they land. Its own changes are the last two commits, e5cd136 (the end) and 92c900e (the stretch timed on the continuous clock).

Problem

critical is rare on Apple silicon, which throttles before it gets there. A Mac that cannot shed its heat, in a bag for example (#55, #52), can stay at serious for hours with Low Power Mode on, and nothing ends the session.

An end alone would make that worse: it switches Low Power Mode off and unfreezes the apps, and macOS does not ask for a closed lid's sleep again (#61), while the computer-use agent's UserIsActive every ~40 s keeps idle sleep away. That is why this waits for #61.

Fix

  • A serious stretch that lasts seriousThermalLimit ends the session with a new reason, thermalSustained: "Thermal state stayed serious for 15 minutes. Sleep is back to normal." It is in Lid: a timer, heat or battery end with the lid closed puts the Mac to sleep #61's sleepsAClosedLid, so with the lid closed the Mac is put to sleep, as after a critical end.
  • The default is 15 minutes, also for a config.json without the key, so it applies to existing installs. Settings has "End after serious heat for" in 5-minute steps from Off to 60; a hand-edited value outside that range is clamped at load, logged and written back, like the floors. The limit is one of Config.floorInputs, so a change in Settings applies at once through Heat: keep Low Power Mode on until nominal, and show it in the menu #80's floorInputsChanged.
  • The stretch is timed from the first floor run that reads it, on the continuous clock like the wait below, so a step of the wall clock (an NTP correction, a time set by hand) does not count, and any reading below serious breaks it. ProcessInfo sends no event while the state stays serious, so the driver reports after each run when the stretch reaches the limit (recheck), and AppServices runs the floors once more at that moment. Once the limit is reached no new wait is owed: an end that the lock or a running sudo pmset refuses is retried by SessionManager, not every second.

The end goes through the existing journaled SessionManager.end, so the journal, the backstop and the scripts do not change.

Tests

  • FloorRulesTests: the end at the limit, with the limit at 0 and with the thermal rules off, and its order against the battery floor, critical and an unreadable battery. Driver cases through the recheck hook: the end at the limit with the journal clean and the notification text, a break that starts the stretch again, the settings changing under a running stretch, an end refused for the lock that owes no new wait, and the text when the limit was turned off before a retry.
  • FloorRuleDriverTests, clock: the wall clock jumps 20 minutes ahead while 5 minutes pass, and the session stays with 10 minutes to go.
  • ClosedLidSleepTests: sustained serious heat with the lid closed ends the session and asks for sleep once, with sleep restored and the lock free.
  • ConfigTests: decoding without the key, the clamp at load, the limit text.
  • Full swift test at 92c900e, on main db9085e with Lid: a timer, heat or battery end with the lid closed puts the Mac to sleep #61 and Heat: keep Low Power Mode on until nominal, and show it in the menu #80, on macOS 27.2 (MacBook Pro M5, Xcode 27.0), with tmux installed: 1,270 tests, 0 skipped. The only failures are the 4 locale assertions listed in Heat: keep Low Power Mode on until nominal, and show it in the menu #80, which fail the same way on unmodified main on this Mac. swift build -c release -Xswiftc -warnings-as-errors passes.

Not verified

  • Nothing here ran on hardware: I did not heat a Mac. There is a new item in "Real-Mac checks before a release" in docs/releasing.md.
  • 15 minutes is a judgment, not a measurement: long enough for a heavy build on a desk, short enough to stop heat soaking a closed Mac. A MacBook Air under a long heavy load may reach it on a desk too.

shikhar1729 and others added 4 commits October 10, 2026 15:36
macOS asks for a closed lid's sleep once, at the close, and disablesleep 1
refuses it. disablesleep 0 does not make it ask again, so a Mac ended for
heat or a dying battery kept running in the bag until the lid opened.

Claude-Session: https://claude.ai/code/session_01Jco1ESE6NxipTXFsZE31rZ
After a failed disablesleep 0, macOS refuses the request, and the recovery
agent that later restores sleep does not ask. A refused request now asks
again every 30 s while the lid stays closed and no session starts.

Also tests an end that cannot remove session.json and a quit during an end.

Claude-Session: https://claude.ai/code/session_01Jco1ESE6NxipTXFsZE31rZ
main's README is now short (krishhgg#65) and its lid-close detail lives in
docs/lid-close.md, so this PR's sentence about a battery or heat end with
the lid closed moves there. The README's battery and heat paragraph gets
one short line saying the Mac goes to sleep.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Fixes the three review findings on the retry. The sleep is now owed from
the floor end, not tied to one end's reason, so a quit that replaces the
pending end no longer drops it. It is asked only with no session, no
lifecycle operation queued or running, and no live sudo pmset, so it never
lands inside a queued start. A start clears it only once it holds the lock,
so a start refused for a busy lock keeps it.

Claude-Session: https://claude.ai/code/session_01Jco1ESE6NxipTXFsZE31rZ
@aldikosh23
aldikosh23 force-pushed the feat/sustained-heat-end branch from caae952 to 2a1b26c Compare October 11, 2026 19:55

@paoValle paoValle left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the new commit only (2a1b26c), since the draft carries #61's and #80's commits until they land. Source review only — no Xcode on this machine, so I did not run swift test.

What I checked in the new content:

  • the ordering in evaluate: critical still wins, and the sustained end sits above the unreadable-battery and low-power branches, so a Mac at serious heat does not first switch Low Power Mode on and then end;
  • HeatLog now stores the start of the stretch and clears it on any reading below serious, and secondsUntilSeriousThermalEnd() returns nil once the limit is reached, so a refused end is not re-scheduled every second — the retry belongs to SessionManager, which matches the comment;
  • recheck is rebuilt on every run, so a Settings change during a stretch (the limit itself is in floorInputs) re-times the wait, and stop() cancels it;
  • normalizeSeriousThermalLimit(), the write-back and the clamp mirror normalizeFloors, and a config without the key gets the default;
  • sleepsAClosedLid includes the new reason, so the lid-closed path goes to sleep like a critical end;
  • the notification text handles the limit being turned off between a refused end and its retry.

One question inline (the clock the stretch is measured with). Separately, for the owner rather than for the code: the 15-minute default applies to configs written by earlier builds, so a session on a hot Mac now ends by itself where it did not before, and the end notification is the only place the user sees why — intended, or worth a one-line notice for existing installs on first launch after the upgrade?

Comment thread Sources/Insomnia/Core/FloorRules.swift Outdated
private final class HeatLog {
var hot = false
/// When the current serious stretch was first read.
var seriousSince: Date?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stretch is measured with Date while the wait that triggers the re-read is a Task.sleep, which runs on the continuous clock. The two disagree when the wall clock steps: an NTP correction or a manual time change of, say, +20 minutes between two runs makes seriousFor jump past the limit, and the session ends on a clock change rather than on 15 minutes of heat; a step backwards holds the session longer. RecoveryLock already measures its timeout with ContinuousClock for this reason, and the clock is injected here anyway, so switching seriousSince/seriousFor to ContinuousClock.Instant (or ProcessInfo.systemUptime) keeps the tests as they are and makes both halves of the timer agree.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Fixed in 92c900e: HeatLog records ContinuousClock.Instant, as RecoveryLock times its timeout, so the stretch and the wait that runs the floors at the limit are on the same clock. The tests now move a FakeContinuousClock apart from the wall clock the manager reads, and a new case jumps the wall clock 20 minutes ahead while 5 minutes pass: the session stays, with 10 minutes to go.

Low Power Mode turned on for a serious thermal state went off at fair, the
first step down. That brought back the load that made the Mac serious, so
the mode went on and off again as the Mac heated up, with a notification
each time. The thermal cause now holds from serious until nominal;
FloorRuleDriver keeps that history for the session, in memory.

The notification after the cooldown said "Back above the floor.", which is
about the battery. It now says "Thermal state is back to nominal.", or
"Thermal rules are off." when Settings turned them off, and the one at the
start says the mode stays on until then.

A battery floor or the thermal rules changed in Settings during a session
applied only at the next battery or thermal event. They now run the floors
at once, as the lid option already did.
The machine line adds "Thermal: serious" (or fair, critical), read again
when the menu opens rather than through the monitor, whose copy moves only
with the notification and is not kept current outside a session. The
actions line says "Low Power Mode set by Insomnia" while the journal has
lowPowerSetByUs, so the reason the Mac is slower is in the menu.
The Settings view asked for a floor run with an onChange per setting, a
path no test could reach. SessionManager.config now calls
floorInputsChanged when a battery floor, the thermal rules or the lid
option changes (Config.floorInputs), and AppServices points it at
reevaluateFloors(). The view's onChange calls, the lid option's included,
are gone, so there is one path for every floor input.

Tests: a floor input change asks for one run and any other change none,
and during a session each change applies through that hook with no
battery, thermal or lid event, in the power calls, the journal and the
session end.
A Mac that stays at fair after serious, under a long build say, keeps
Low Power Mode and the held display sample until the session ends. Say so
next to the hold, with why a release on a timer is not used: it would
bring the cycling back at a slower pace. The menu shows the mode and the
state meanwhile, and turning the thermal rules off releases it.
…losed lid

Apple silicon throttles before it reaches critical, so a Mac that cannot
shed its heat, in a bag say, can stay serious for hours with Low Power
Mode on, and the session never ends. A serious stretch that lasts
seriousThermalLimit now ends it with the new reason thermalSustained,
which is one of the floor-rule ends that put a closed Mac to sleep (krishhgg#61):
without that sleep the end would switch Low Power Mode off and unfreeze
the apps of a Mac still running in the bag.

The limit is 15 minutes by default, also for a config.json without the
key, and Settings has "End after serious heat for" in 5-minute steps from
Off to 60; a hand-edited value outside that range is clamped at load. The
stretch is timed from the first floor run that reads it, and any reading
below serious breaks it. The thermal state sends no event while it stays
serious, so the driver reports after each run when the stretch reaches the
limit and AppServices runs the floors once more then. Once it has, no new
wait is owed: a refused end is retried by the manager, not every second.
The stretch was measured with Date while the wait that runs the floors at
the limit sleeps on the continuous clock. A step of the wall clock, an
NTP correction or a time set by hand, then moved the stretch: 20 minutes
ahead ended the session on a clock change, a step back held it longer.
HeatLog now records ContinuousClock.Instant, as RecoveryLock times its
timeout, and the tests move a FakeContinuousClock apart from the wall
clock, with a case where the wall clock jumps and the session stays.
@aldikosh23
aldikosh23 force-pushed the feat/sustained-heat-end branch from 2a1b26c to 92c900e Compare October 12, 2026 13:39
@aldikosh23

Copy link
Copy Markdown
Contributor Author

On the default for existing installs, which @paoValle raised for the owner: it is intended. Serious heat for 15 minutes is a reason to stop holding sleep on any install, and the end notification names the reason ("Thermal state stayed serious for 15 minutes."), with Off in Settings. A one-time notice at the first launch after the upgrade, like the lid-close update's, would be easy to add if you want it; I left it out to keep this small.

@paoValle

Copy link
Copy Markdown

Thanks — both of my points are settled then.

On the default: the end notification naming the reason, plus Off in Settings, is enough for me. The one-time first-launch notice is a product call for @krishhgg, and I would not widen this PR for it.

The clock point is addressed on this head as well: FloorRules.swift now carries an injectable clock: () -> ContinuousClock.Instant and measures the serious stretch with it, so a wall-clock step can no longer end the session early or hold it — the same convention RecoveryLock already uses here. Nothing further from me.

(I read the source, not a build: no Xcode on this machine, so swift test still has not run — and since this is still a draft I have left it as a comment rather than an approval.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants