fix(daemon): ask the tolerant question while the clock can still fund the answer

The launcher asked the cheap question first and the patient one last. That is
backwards. This path is only reached because a 3s health check timed out, so
every 4s probe re-asks on a stricter budget than the one that triaged the daemon
here — it can only ever agree. The one ask that could disagree ran last, by which
point the clock could fund its handshake but not its answer, and a daemon that
answered in 12s was read as dead and replaced along with its agents.

Three changes, one idea: never make an ask you cannot afford to hear out.

- The patient ask goes first, with every millisecond the answer does not need.
- Cheap retries follow it, and stop once the clock cannot fund both halves.
  Funded to knock but not to listen is not an ask.
- The adoption connect is capped. It is not a classification step — it acquires
  a lease preserveDaemon() re-establishes anyway — but on a daemon that accepts
  the socket and never completes hello it would spend the entire classification
  budget, leaving nothing for the probes that protect that daemon's sessions.

The test double now forwards the connect budget instead of dropping it, so what
the launcher was willing to wait for is observable at all.
This commit is contained in:
Neil
2026-08-12 13:45:17 -07:00
parent b4a4b13c5a
commit 7256ea2876
2 changed files with 28 additions and 29 deletions
+9 -7
View File
@@ -315,13 +315,15 @@ vi.mock('./daemon-health', () => ({
vi.mock('./client', () => ({
// Mirror ensureConnected onto the bounded variant so every existing double keeps its
// behaviour — including the ones whose whole point is that connecting throws.
// behaviour — including the ones whose whole point is that connecting throws. The budget is
// forwarded rather than dropped so a test can see what the launcher was willing to wait.
DaemonClient: function DaemonClientDouble(...args: unknown[]) {
const instance = (daemonClientMock as unknown as (...a: unknown[]) => Record<string, unknown>)(
...args
)
if (instance && typeof instance === 'object' && !('ensureConnectedWithin' in instance)) {
instance.ensureConnectedWithin = instance.ensureConnected
instance.ensureConnectedWithin = (budgetMs?: number) =>
(instance.ensureConnected as (ms?: number) => unknown)(budgetMs)
}
return instance
}
@@ -3195,11 +3197,11 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
'/fake/token'
)
expect(forkMock).toHaveBeenCalled()
// 1 adoption + 1 initial probe + the retries + 1 patient probe + 1 post-fork adoption.
// The patient probe is the point: retrying a four-second handshake never reaches a
// daemon that consistently needs longer, so the launcher spends what is left of the
// clock on a single tolerant ask before it is willing to replace anything.
expect(daemonClientMock).toHaveBeenCalledTimes(4 + WEDGED_DAEMON_GRACE_RETRIES)
// 1 adoption + 1 patient probe + the cheap retries + 1 post-fork adoption. The patient
// probe comes first and is the point: retrying a four-second handshake never reaches a
// daemon that consistently needs longer, and spending the clock on the cheap asks first
// left the tolerant one unable to fund an answer.
expect(daemonClientMock).toHaveBeenCalledTimes(3 + WEDGED_DAEMON_GRACE_RETRIES)
// Why: this replace path used to kill the daemon with no log, so a post-hoc
// reader could not tell it apart from an adoption; the verdict must be recorded.
expect(warnSpy).toHaveBeenCalledWith(