Summary
loopany up can return success without starting a daemon when no verified local daemon exists but the server still reports the machine online from a recent heartbeat. The same stale presence can make the forced replacement path report readiness before the newly spawned process has produced a poll.
Local process liveness and server presence are different signals. A recent server heartbeat is useful connection state, but it should not substitute for proof that this host currently has a live daemon.
Steps to reproduce
- Connect a daemon and let it poll successfully.
- Terminate the local daemon while its last heartbeat remains inside the server's 30-second online TTL, leaving no verified live PID.
- Immediately run
loopany up.
- Observe that it prints
daemon already running for this machine, returns exit code 0, and does not spawn a daemon.
The behavior is also deterministic through the existing injected seams: call runEnsure() with localPid: () => undefined, fetchStatus: async () => ({ online: true }), and a counting spawnDaemon. On @crewlet/loopany 0.16.0, the command returns 0 with a spawn count of 0.
For the forced replacement variant, call runEnsure([], deps, { force: true }) with the same unchanged online response. It spawns once, then immediately accepts that response as readiness even though it can predate the replacement.
Expected behavior
When no verified local daemon exists, loopany up starts exactly one daemon and does not treat the server's recent presence record as local liveness. After spawning, success waits for evidence attributable to the replacement rather than accepting an unchanged stale heartbeat.
Actual behavior
runEnsure() short-circuits on before?.online when there is no verified local PID. Its post-spawn readiness loop also accepts any st?.online. /api/machine/status deliberately reports online while the stored heartbeat is younger than ONLINE_TTL_MS, so both checks can observe the old process's heartbeat.
Affected area
packages/daemon/src/ensure.ts
packages/daemon/src/control.ts
packages/daemon/src/ensure.test.ts
packages/daemon/src/update.test.ts
packages/server/src/gateway/index.ts machine status response
Runtime or environment
- Reproduced with
@crewlet/loopany 0.16.0.
- Confirmed against commit
752755d52cb6d6b90fcd1e7fba3de7ceabeee9fb.
- The timing window is platform-independent.
Evidence
packages/daemon/README.md defines up as idempotently ensuring that a daemon is running and says it spawns one when none is running.
packages/daemon/src/ensure.ts returns success from server status alone when no local PID is verified, then uses the same online flag as post-spawn readiness.
packages/server/src/gateway/index.ts already returns lastSeen, but the daemon's MachineStatus currently retains only online and name.
- The review of PR #47 identified the no-daemon/stale-online window as an unresolved edge in the update flow.
- An injected regression reproduction returns success with zero spawns when the local PID is absent and server presence is still online.
Impact
After an unexpected daemon exit, the documented recovery command can claim success while the machine remains unable to execute scheduled work. Retrying after the heartbeat TTL expires is a workaround, but the first successful-looking recovery attempt is misleading.
Additional context
A regression test should start with no verified local PID and an already-online server response. It is green when exactly one daemon is spawned and readiness remains pending until fresh replacement evidence arrives. The implementation can use a fresh lastSeen, verified child/PID identity, a launch nonce, or another equivalent signal while preserving the one-daemon invariant.
Blocked by
None — can start immediately.
Summary
loopany upcan return success without starting a daemon when no verified local daemon exists but the server still reports the machine online from a recent heartbeat. The same stale presence can make the forced replacement path report readiness before the newly spawned process has produced a poll.Local process liveness and server presence are different signals. A recent server heartbeat is useful connection state, but it should not substitute for proof that this host currently has a live daemon.
Steps to reproduce
loopany up.daemon already running for this machine, returns exit code 0, and does not spawn a daemon.The behavior is also deterministic through the existing injected seams: call
runEnsure()withlocalPid: () => undefined,fetchStatus: async () => ({ online: true }), and a countingspawnDaemon. On@crewlet/loopany0.16.0, the command returns 0 with a spawn count of 0.For the forced replacement variant, call
runEnsure([], deps, { force: true })with the same unchanged online response. It spawns once, then immediately accepts that response as readiness even though it can predate the replacement.Expected behavior
When no verified local daemon exists,
loopany upstarts exactly one daemon and does not treat the server's recent presence record as local liveness. After spawning, success waits for evidence attributable to the replacement rather than accepting an unchanged stale heartbeat.Actual behavior
runEnsure()short-circuits onbefore?.onlinewhen there is no verified local PID. Its post-spawn readiness loop also accepts anyst?.online./api/machine/statusdeliberately reports online while the stored heartbeat is younger thanONLINE_TTL_MS, so both checks can observe the old process's heartbeat.Affected area
packages/daemon/src/ensure.tspackages/daemon/src/control.tspackages/daemon/src/ensure.test.tspackages/daemon/src/update.test.tspackages/server/src/gateway/index.tsmachine status responseRuntime or environment
@crewlet/loopany0.16.0.752755d52cb6d6b90fcd1e7fba3de7ceabeee9fb.Evidence
packages/daemon/README.mddefinesupas idempotently ensuring that a daemon is running and says it spawns one when none is running.packages/daemon/src/ensure.tsreturns success from server status alone when no local PID is verified, then uses the same online flag as post-spawn readiness.packages/server/src/gateway/index.tsalready returnslastSeen, but the daemon'sMachineStatuscurrently retains onlyonlineandname.Impact
After an unexpected daemon exit, the documented recovery command can claim success while the machine remains unable to execute scheduled work. Retrying after the heartbeat TTL expires is a workaround, but the first successful-looking recovery attempt is misleading.
Additional context
A regression test should start with no verified local PID and an already-online server response. It is green when exactly one daemon is spawned and readiness remains pending until fresh replacement evidence arrives. The implementation can use a fresh
lastSeen, verified child/PID identity, a launch nonce, or another equivalent signal while preserving the one-daemon invariant.Blocked by
None — can start immediately.