diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml index 5765c04..5f6fc07 100644 --- a/fleetd/fleetd.example.yaml +++ b/fleetd/fleetd.example.yaml @@ -110,6 +110,14 @@ bind: # notifications: # mode: disabled +# Idle-sleep guard: while at least one member is live, hold an OS-level assertion against idle +# sleep (macOS only — a `caffeinate -i` child; a no-op elsewhere or if caffeinate is missing), so +# an unattended host does not idle-sleep out from under a member's long turn. Unlike health/ +# configReload above, this is ON BY DEFAULT — omitting the block entirely leaves it enabled, the +# same as `enabled: true`. Uncomment only to turn it off: +# idleSleepGuard: +# enabled: false + # herdr Unix socket. Omit to use the client default # (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}). herdrSocket: ~/.config/herdr/herdr.sock diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 8550526..86370cf 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -55,6 +55,8 @@ import dev.ltms.fleet.member.MemberCredentialPolicyView; import dev.ltms.fleet.member.OpenCodeLauncher; import dev.ltms.fleet.placement.BackendOutagePolicy; import dev.ltms.fleet.placement.BackendQuarantine; +import dev.ltms.fleet.power.CaffeinateSleepAssertionMechanism; +import dev.ltms.fleet.power.IdleSleepGuard; import io.javalin.Javalin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -253,6 +255,26 @@ public final class Fleetd { System::nanoTime, contextCap, clearAfterTurn); liveCountRef.set(profileName -> liveSessionCount(sessions.roster(), profileName)); + // Idle-sleep guard: hold an OS-level assertion against idle sleep while at least one + // member is live, so an unattended host does not idle-sleep out from under a member's + // long turn (see FleetConfig.IdleSleepGuard / dev.ltms.fleet.power.IdleSleepGuard for the + // measurement that motivated this). Opt-out via idleSleepGuard.enabled: false; on by + // default. Hangs off SessionManager's own onAcquire/onRelease hooks (CB-520/CB-516, + // previously wired only to the reply inbox) and SessionManager#size() — the exact registry + // fleet_list's live/capacity numbers are themselves computed from — rather than tracking + // members a second way. No-op (never constructed) off macOS or when idleSleepGuard.enabled + // is explicitly false; the mechanism itself is additionally a no-op if 'caffeinate' cannot + // be started, so this can never fail a spawn, a release, or startup. + boolean idleSleepGuardEnabled = cfg.idleSleepGuard() == null || cfg.idleSleepGuard().isEnabled(); + final IdleSleepGuard idleSleepGuard; + if (idleSleepGuardEnabled) { + idleSleepGuard = new IdleSleepGuard(new CaffeinateSleepAssertionMechanism(), sessions::size); + sessions.onAcquire(_ -> idleSleepGuard.recheck()); + sessions.onRelease(_ -> idleSleepGuard.recheck()); + } else { + idleSleepGuard = null; + } + // CB-303 part 1: idle-ttl reaper — only when configured, defaults to disabled. final SessionReaper reaper; if (cfg.lifecycle() != null @@ -704,6 +726,11 @@ public final class Fleetd { if (configWatcher != null) configWatcher.stop(); // CB-559: stop polling the config file mcp.close(); if (reaper != null) reaper.stop(); + // Idle-sleep guard: release unconditionally, even though sessions.close() above already + // drained every session (and each release already drove the live count to 0, which + // releases the guard's assertion on its own) — this is the backstop for a drain that was + // itself interrupted or threw, so no caffeinate child ever outlives the daemon. + if (idleSleepGuard != null) idleSleepGuard.close(); // Release the broker connection last among message resources (no-op for the in-memory inbox). if (replyInbox instanceof AutoCloseable closeable) { try { diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java index 7c11ccf..aa5072a 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java @@ -37,6 +37,10 @@ import java.util.function.Supplier; * makes {@code fleet:} split rather than hot — see below. *
The denominator, measured on 2026-09-04 (fleetd #330; recounted for fleetd #333);
- * recounted again for fleetd #362. {@code FleetConfig} has 23 top-level record components:
- * 5 cold, 12 deferred, 3 split, 3 hot-excluded. Three of them are named nowhere in this file, and
- * the reason is the same for all
+ * recounted again for fleetd #362, and again after {@code idleSleepGuard:} was added.
+ * {@code FleetConfig} has 24 top-level record components: 5 cold, 13 deferred, 3 split, 3
+ * hot-excluded. Three of them are named nowhere in this file, and the reason is the same for all
* three: {@code placement}, {@code memberCredentials} and {@code memberLoginShell} are
* hot and correctly absent — all three are read live off {@code config.get()}
* (placement through the {@code CompositePeerLauncher} supplier the Hot bullet names;
@@ -215,7 +219,7 @@ public final class ConfigRef implements Supplier Unlike most opt-in blocks in this file, this one defaults to on: an unattended
+ * host idle-sleeping mid-turn is a correctness problem (a dropped AMQP link, a frozen member),
+ * not a convenience, so the safer default is armed. An operator who wants the previous
+ * behaviour (no assertion held, ever) sets {@code enabled: false} explicitly.
+ *
+ * @param enabled {@code false} turns the guard off; {@code null} (the block omitted
+ * entirely) or {@code true} leaves it on
+ */
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record IdleSleepGuard(Boolean enabled) {
+ public boolean isEnabled() {
+ return !Boolean.FALSE.equals(enabled);
+ }
+ }
+
/**
* The terminal → lead-name map seeded from the legacy singular {@code primary:} pin (CB-530).
*
@@ -1529,7 +1568,8 @@ public record FleetConfig(
"bind", "herdrSocket", "memberHerdrSocket", "profiles", "guard", "worktreeRoot",
"lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "fleet",
"leadHeartbeat", "health", "placement", "auth", "configReload", "quarantineCooldownSeconds",
- "memberCredentials", "coordinator", "worktreeGroup", "memberLoginShell", "memberSkills");
+ "memberCredentials", "coordinator", "worktreeGroup", "memberLoginShell", "memberSkills",
+ "idleSleepGuard");
/** Load and validate config from {@code path}. */
public static FleetConfig load(Path path) {
@@ -2209,9 +2249,15 @@ public record FleetConfig(
// memberSkills is left as-is (fleetd #362), like worktreeGroup/memberLoginShell: null/blank
// is "off", and there is no sane non-null default — the daemon may not even run from a
// checkout that ships its own .claude/skills/.
+ // idleSleepGuard is left as-is, like leadHeartbeat/configReload above, but for the opposite
+ // reason: it is on by default already (its own isEnabled() treats null the same as
+ // enabled: true — see its javadoc), so defaulting the block here would change nothing a
+ // reader observes and would only obscure that "block omitted" and "block present and
+ // enabled" are deliberately the same outcome.
return new FleetConfig(b, herdrSocket, memberHerdrSocket, profiles, g, worktreeRoot, l, timeout, pollMs,
broker, primary, f, leadHeartbeat, health, placementOrDefault, a, configReload,
- quarantineCooldown, mc, coordinator, worktreeGroup, memberLoginShell, memberSkills);
+ quarantineCooldown, mc, coordinator, worktreeGroup, memberLoginShell, memberSkills,
+ idleSleepGuard);
}
/**
diff --git a/fleetd/src/main/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanism.java b/fleetd/src/main/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanism.java
new file mode 100644
index 0000000..0c45b57
--- /dev/null
+++ b/fleetd/src/main/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanism.java
@@ -0,0 +1,93 @@
+package dev.ltms.fleet.power;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Holds macOS idle sleep off by keeping a {@code caffeinate -i} child process alive for the life
+ * of the returned {@link SleepAssertion}.
+ *
+ * {@code -i} asserts only against idle sleep — it does not stop the lid closing or an
+ * operator-requested sleep from taking effect. That is deliberate: this class exists to stop an
+ * unattended host from sleeping out from under a member's long turn, never to override the
+ * operator. {@code -s}/{@code -d} (which also block system/display sleep on demand) are
+ * intentionally not used here.
+ *
+ * {@link #acquire()} never throws. It returns {@code null} — a no-op — off macOS, and again if
+ * starting the {@code caffeinate} child fails for any reason (binary missing, process table full,
+ * …); either case is logged once at INFO, not on every occurrence, so a daemon that runs for
+ * weeks with the tool unavailable does not fill its log.
+ */
+public final class CaffeinateSleepAssertionMechanism implements SleepAssertionMechanism {
+
+ private static final Logger log = LoggerFactory.getLogger(CaffeinateSleepAssertionMechanism.class);
+
+ private final AtomicBoolean loggedOnce = new AtomicBoolean(false);
+
+ /** {@code true} when running on macOS, the only platform {@code caffeinate} ships on. */
+ public static boolean isSupportedPlatform() {
+ return isSupportedPlatform(System.getProperty("os.name"));
+ }
+
+ /** Package-visible so a test can drive the platform check without touching a real property. */
+ static boolean isSupportedPlatform(String osName) {
+ return osName != null && osName.toLowerCase(Locale.ROOT).contains("mac");
+ }
+
+ @Override
+ public SleepAssertion acquire() {
+ if (!isSupportedPlatform()) {
+ logOnce("not running on macOS (os.name={}); the idle-sleep guard is a no-op on this platform",
+ System.getProperty("os.name"));
+ return null;
+ }
+ try {
+ Process process = new ProcessBuilder("caffeinate", "-i")
+ .redirectOutput(ProcessBuilder.Redirect.DISCARD)
+ .redirectError(ProcessBuilder.Redirect.DISCARD)
+ .start();
+ return new CaffeinateAssertion(process);
+ } catch (IOException | RuntimeException e) {
+ logOnce("could not start 'caffeinate -i' ({}); the host may idle-sleep while members are live",
+ e.toString());
+ return null;
+ }
+ }
+
+ private void logOnce(String format, Object arg) {
+ if (loggedOnce.compareAndSet(false, true)) {
+ log.info("idle-sleep guard: " + format, arg);
+ }
+ }
+
+ /** Wraps the live {@code caffeinate} child; {@link #close} force-destroys it, idempotently. */
+ private static final class CaffeinateAssertion implements SleepAssertion {
+
+ private final Process process;
+
+ CaffeinateAssertion(Process process) {
+ this.process = process;
+ }
+
+ @Override
+ public void close() {
+ if (!process.isAlive()) {
+ return;
+ }
+ process.destroy();
+ try {
+ if (!process.waitFor(2, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ process.destroyForcibly();
+ }
+ }
+ }
+}
diff --git a/fleetd/src/main/java/dev/ltms/fleet/power/IdleSleepGuard.java b/fleetd/src/main/java/dev/ltms/fleet/power/IdleSleepGuard.java
new file mode 100644
index 0000000..8aad4df
--- /dev/null
+++ b/fleetd/src/main/java/dev/ltms/fleet/power/IdleSleepGuard.java
@@ -0,0 +1,105 @@
+package dev.ltms.fleet.power;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.function.IntSupplier;
+
+/**
+ * Holds an OS-level assertion against idle sleep for exactly as long as at least one fleet
+ * member is live.
+ *
+ * Why this exists: a fleetd host was measured idle-sleeping after as little
+ * as one minute of inactivity (its {@code pmset -g custom} reports {@code sleep 1} on battery).
+ * Overnight the daemon's AMQP link to the broker dropped 13 times, and cross-checking every drop
+ * minute against {@code pmset -g log} found a sleep or wake event in the same minute or the one
+ * before, every time. The AMQP churn is only the visible symptom — the real problem is that a
+ * member mid-turn freezes with the host, and a long turn with nobody typing is exactly the case
+ * that goes idle.
+ *
+ * How it tracks "live": this is driven by {@code SessionManager}'s existing
+ * {@code onAcquire}/{@code onRelease} lifecycle hooks (added for CB-520/CB-516, previously wired
+ * to nothing but the reply inbox) rather than a second member count kept in parallel. Wire it as:
+ * Failure posture: every method here is safe to call whether or not {@link
+ * SleepAssertionMechanism#acquire()} actually works. A mechanism that returns {@code null} (wrong
+ * platform, missing tool, spawn failure) simply means this guard never holds anything — it never
+ * throws and never blocks a spawn, a release, or shutdown.
+ */
+public final class IdleSleepGuard implements AutoCloseable {
+
+ private static final Logger log = LoggerFactory.getLogger(IdleSleepGuard.class);
+
+ private final SleepAssertionMechanism mechanism;
+ private final IntSupplier liveCount;
+ private final Object lock = new Object();
+ private SleepAssertion held;
+
+ public IdleSleepGuard(SleepAssertionMechanism mechanism, IntSupplier liveCount) {
+ this.mechanism = mechanism;
+ this.liveCount = liveCount;
+ }
+
+ /**
+ * Re-read the live count and acquire or release the held assertion to match: nothing held and
+ * at least one member live ⇒ acquire; something held and no member live ⇒ release. A steady
+ * count (still zero, still positive) is a no-op either way, so a single spawn or release only
+ * ever touches the OS on the crossing, not on every call.
+ */
+ public void recheck() {
+ synchronized (lock) {
+ int live = liveCount.getAsInt();
+ if (live > 0 && held == null) {
+ held = mechanism.acquire();
+ if (held != null) {
+ log.debug("idle-sleep guard armed: {} live member(s)", live);
+ }
+ } else if (live == 0 && held != null) {
+ releaseHeldLocked();
+ }
+ }
+ }
+
+ /** {@code true} while an assertion is actually held. Exposed for tests. */
+ boolean isHeld() {
+ synchronized (lock) {
+ return held != null;
+ }
+ }
+
+ /**
+ * Release whatever is held, if anything. Idempotent and safe to call at any time, including
+ * repeatedly — a daemon shutdown hook calls this unconditionally so no assertion (and no
+ * {@code caffeinate} child) survives the process, even if the drain that would otherwise have
+ * driven the live count to zero was itself interrupted or threw.
+ */
+ @Override
+ public void close() {
+ synchronized (lock) {
+ if (held != null) {
+ releaseHeldLocked();
+ }
+ }
+ }
+
+ /** Caller must hold {@link #lock}. */
+ private void releaseHeldLocked() {
+ try {
+ held.close();
+ } catch (RuntimeException e) {
+ log.warn("idle-sleep guard: failed to release its assertion cleanly: {}", e.toString());
+ } finally {
+ held = null;
+ }
+ }
+}
diff --git a/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertion.java b/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertion.java
new file mode 100644
index 0000000..4dbe4d9
--- /dev/null
+++ b/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertion.java
@@ -0,0 +1,11 @@
+package dev.ltms.fleet.power;
+
+/**
+ * A held OS-level assertion against idle sleep. {@link #close} must be idempotent — safe to call
+ * more than once — and must never throw, matching {@link IdleSleepGuard}'s "never break the
+ * fleet" contract.
+ */
+public interface SleepAssertion extends AutoCloseable {
+ @Override
+ void close();
+}
diff --git a/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertionMechanism.java b/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertionMechanism.java
new file mode 100644
index 0000000..ae58461
--- /dev/null
+++ b/fleetd/src/main/java/dev/ltms/fleet/power/SleepAssertionMechanism.java
@@ -0,0 +1,21 @@
+package dev.ltms.fleet.power;
+
+/**
+ * The OS mechanism {@link IdleSleepGuard} uses to hold and release an idle-sleep assertion. This
+ * is the seam a test exercises instead of the real effect (a live {@code caffeinate} child) — see
+ * {@code IdleSleepGuardTest}.
+ *
+ * Implementations must never throw. Every failure — wrong platform, missing tool, a spawn
+ * error — must show up as {@link #acquire()} returning {@code null}, so a caller can treat "no
+ * assertion held" and "the mechanism could not be used" identically and the fleet keeps running
+ * either way.
+ */
+public interface SleepAssertionMechanism {
+
+ /**
+ * Acquire a fresh assertion against idle sleep, or {@code null} when this mechanism is not
+ * usable right now (wrong platform, the tool is missing, the child process could not start).
+ * Never throws.
+ */
+ SleepAssertion acquire();
+}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java
index 8bf2cf1..f022306 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java
@@ -108,6 +108,7 @@ class ConfigRefTopLevelReportingCoverageTest {
v.put("worktreeGroup", "group-a");
v.put("memberLoginShell", null);
v.put("memberSkills", "/skills/a");
+ v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(true));
assertNamesMatchComponents(v);
return v;
}
@@ -149,6 +150,7 @@ class ConfigRefTopLevelReportingCoverageTest {
v.put("worktreeGroup", "group-b");
v.put("memberLoginShell", null);
v.put("memberSkills", "/skills/b");
+ v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(false));
assertNamesMatchComponents(v);
return v;
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
index df9331b..d8e6fc8 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
@@ -2629,4 +2629,58 @@ class FleetConfigTest {
"with no pool to choose from, every configured profile is a candidate and the "
+ "first one wins");
}
+
+ // ── idle-sleep guard: default-on config block ───────────────────────────────────────────────
+
+ @Test
+ void idleSleepGuardIsOnByDefaultWhenTheBlockIsEntirelyAbsent(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ host: 127.0.0.1
+ port: 8080
+ """);
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertNull(cfg.idleSleepGuard(), "an absent block parses to null, unlike most other blocks here");
+ // The block itself is absent, but the FEATURE stays on: Fleetd treats a null block the
+ // same as enabled: true (see FleetConfig.idleSleepGuard's javadoc) — this test only pins
+ // the parse result, the on-by-default behaviour is Fleetd's own null check.
+ }
+
+ @Test
+ void idleSleepGuardExplicitlyEnabledIsOn(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ idleSleepGuard:
+ enabled: true
+ """);
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertTrue(cfg.idleSleepGuard().isEnabled());
+ }
+
+ @Test
+ void idleSleepGuardExplicitlyDisabledIsOff(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ idleSleepGuard:
+ enabled: false
+ """);
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertFalse(cfg.idleSleepGuard().isEnabled());
+ }
+
+ @Test
+ void idleSleepGuardBlockPresentButEmptyDefaultsToEnabled(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ idleSleepGuard: {}
+ """);
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertTrue(cfg.idleSleepGuard().isEnabled(),
+ "unlike ConfigReload/Health, this block defaults to ON even when present but empty");
+ }
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java
index 5faefb4..de487fc 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigWithDefaultsPreservesEveryComponentTest.java
@@ -46,7 +46,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
* comments document that it only ever REPLACES a component when the incoming value is {@code null}
* (or blank, for {@code placement}) — {@code broker}/{@code primary}/{@code leadHeartbeat}/
* {@code configReload}/{@code coordinator}/{@code worktreeGroup}/{@code memberLoginShell}/
- * {@code memberSkills} are left as-is unconditionally, and {@code bind}/{@code guard}/{@code lifecycle}/{@code auth}/
+ * {@code memberSkills}/{@code idleSleepGuard} are left as-is unconditionally, and {@code bind}/{@code guard}/{@code lifecycle}/{@code auth}/
* {@code fleet}/{@code quarantineCooldownSeconds}/{@code memberCredentials}/{@code placement} are
* replaced only on null/blank input. A value that is never null or blank going in must therefore
* never change coming out, for every current component. No exclusion is needed today.
@@ -96,6 +96,7 @@ class FleetConfigWithDefaultsPreservesEveryComponentTest {
v.put("worktreeGroup", "group-guard");
v.put("memberLoginShell", "/bin/zsh");
v.put("memberSkills", "/skills/guard");
+ v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(true));
assertNamesMatchComponents(v);
return v;
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanismTest.java b/fleetd/src/test/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanismTest.java
new file mode 100644
index 0000000..3d1c70d
--- /dev/null
+++ b/fleetd/src/test/java/dev/ltms/fleet/power/CaffeinateSleepAssertionMechanismTest.java
@@ -0,0 +1,54 @@
+package dev.ltms.fleet.power;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Platform-detection unit tests for {@link CaffeinateSleepAssertionMechanism}.
+ *
+ * This deliberately never calls {@link CaffeinateSleepAssertionMechanism#acquire()} itself —
+ * doing so on a real macOS machine would actually start a live {@code caffeinate} child and hold
+ * a real idle-sleep assertion, which the ticket this class exists for explicitly forbids testing
+ * with. Instead this exercises the pure {@code isSupportedPlatform(String)} predicate that
+ * {@code acquire()} consults before ever touching {@link ProcessBuilder} — so it proves the
+ * platform check itself is correct on any CI OS, but it does not prove that a
+ * real {@code caffeinate -i} spawn succeeds or that its child is torn down correctly; that half is
+ * exercised indirectly by {@link IdleSleepGuardTest} against a {@link FakeSleepAssertionMechanism}
+ * instead, which is the seam invariant 2/3 in the ticket call for.
+ */
+class CaffeinateSleepAssertionMechanismTest {
+
+ @Test
+ void macOsNamesAreSupported() {
+ assertTrue(CaffeinateSleepAssertionMechanism.isSupportedPlatform("Mac OS X"));
+ assertTrue(CaffeinateSleepAssertionMechanism.isSupportedPlatform("macOS"));
+ assertTrue(CaffeinateSleepAssertionMechanism.isSupportedPlatform("MAC OS X"));
+ }
+
+ @Test
+ void nonMacNamesAreNotSupported() {
+ assertFalse(CaffeinateSleepAssertionMechanism.isSupportedPlatform("Linux"));
+ assertFalse(CaffeinateSleepAssertionMechanism.isSupportedPlatform("Windows 11"));
+ }
+
+ @Test
+ void nullOsNameIsNotSupported() {
+ assertFalse(CaffeinateSleepAssertionMechanism.isSupportedPlatform(null));
+ }
+
+ /**
+ * The overload {@code isSupportedPlatform()} (no args) reads the JVM's real {@code os.name} —
+ * proves the wiring is live, without asserting a specific answer (this suite itself must pass
+ * on both macOS and Linux CI).
+ */
+ @Test
+ void noArgOverloadReadsRealSystemProperty() {
+ boolean expected = CaffeinateSleepAssertionMechanism
+ .isSupportedPlatform(System.getProperty("os.name"));
+ boolean actual = CaffeinateSleepAssertionMechanism.isSupportedPlatform();
+ assertEquals(expected, actual);
+ }
+}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/power/FakeSleepAssertionMechanism.java b/fleetd/src/test/java/dev/ltms/fleet/power/FakeSleepAssertionMechanism.java
new file mode 100644
index 0000000..7b8e180
--- /dev/null
+++ b/fleetd/src/test/java/dev/ltms/fleet/power/FakeSleepAssertionMechanism.java
@@ -0,0 +1,56 @@
+package dev.ltms.fleet.power;
+
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Recording fake {@link SleepAssertionMechanism} — the seam behind the real OS effect (a live
+ * {@code caffeinate} child process). No test in this package ever spawns that real process; every
+ * assertion here is against this fake's own call log instead.
+ *
+ * Each acquired {@link FakeAssertion} records its own {@code close()} calls, and every
+ * acquired instance is kept in {@link #acquired} so a test can inspect all of them, including
+ * ones {@link IdleSleepGuard} has already released.
+ */
+final class FakeSleepAssertionMechanism implements SleepAssertionMechanism {
+
+ /** Every {@link FakeAssertion} this mechanism has ever handed out, in order. */
+ final CopyOnWriteArrayList{@code
+ * IdleSleepGuard guard = new IdleSleepGuard(mechanism, sessions::size);
+ * sessions.onAcquire(_ -> guard.recheck());
+ * sessions.onRelease(_ -> guard.recheck());
+ * }
+ * Every acquire/release event re-reads {@code SessionManager#size()} — the same registry {@code
+ * fleet_list}'s live/capacity numbers are themselves computed from — and only an actual 0→1 or
+ * 1→0 crossing touches the OS. A listener exception is already caught and logged by {@code
+ * SessionManager} itself (it must never let a listener failure block the acquire/release it is
+ * reacting to), so {@link #recheck()} does not need its own top-level try/catch to honor that.
+ *
+ *