Skip to content

agent-lifecycle: keep wake dedup alive until the real wake settles - #491

Merged
TheGreatAxios merged 2 commits into
mainfrom
cl-7217-agent-lifecycle-wake-timeout
Aug 30, 2026
Merged

TheGreatAxios merged 2 commits into
mainfrom
cl-7217-agent-lifecycle-wake-timeout

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

createAgentLifecycle's ensureAwake coalesces concurrent callers onto one in-flight wake per address via a pendingWakes map, bounded by a per-caller timeout so a wake the host never acks fails loud instead of hanging forever (CL-6643).

The bug: pendingWakes.delete(address) was chained off the timeout-bound promise, so it fired the instant a caller's own timeout rejected — not when the underlying wake(address) call itself actually settled. A later caller for the same address then saw no in-flight entry and dispatched a second, concurrent wake() call, racing two redeploys against each other on the host.

Fix

Splits the untimed "raw" wake(address) promise (stored in pendingWakes, cleared only when it truly settles) from a fresh per-caller timeout race every ensureAwake caller — the initiator or a later joiner — gets independently. wake() now runs at most once per address for as long as one is genuinely in flight, no matter how many callers' individual timeouts fire on top of it.

Deliberate trade-off (reviewed with Greybeard before implementation): an address whose underlying wake() never settles at all stays permanently deduped — no caller can trigger a fresh attempt until process restart. This is accepted as a smaller failure than the concurrent-wake race it closes, and is proven explicitly by a test asserting no second dispatch across three separate calls against a wake that never settles.

Stack

Bottom of a two-PR stack. CL-7214 (packages/chat's wake-coalescing bypass) is stacked on top of this branch, since it routes through the primitive this PR fixes.

Review

  • Approach reviewed with Greybeard before implementation (confirmed the fix and its trade-off).
  • Diff reviewed with Critique after implementation: no blocking findings.

Closes CL-7217.

ensureAwake's pendingWakes coalescing map cleared its entry the moment
a caller's own timeout fired, not when the underlying wake() call
actually settled -- so a caller arriving after a timeout could
dispatch a second, concurrent wake for an address whose real wake was
still running. Rewrites the existing "never settles" test to assert
the corrected behavior (no second dispatch, even across a third call)
and adds a case where the underlying wake resolves after the first
caller's timeout, proving a later caller joins it instead of starting
its own.
pendingWakes.delete was chained off the timeout-bound promise, so it
fired as soon as a caller's own timeout rejected -- even though the
underlying wake(address) call kept running past it. A later caller
then saw no in-flight entry and dispatched a second, concurrent wake
for the same address, racing two redeploys against each other.

Splits the untimed raw wake promise (stored in pendingWakes, cleared
only when it truly settles) from a fresh per-caller timeout race every
ensureAwake caller gets independently. wake() now runs at most once
per address for as long as one is genuinely in flight, regardless of
how many callers' timeouts fire on top of it.

Trade-off, accepted deliberately: an address whose wake never settles
at all stays permanently deduped until process restart, rather than
retried by every later caller -- a silently wedged address is a
smaller failure than concurrently redeploying it forever.
@TheGreatAxios
TheGreatAxios merged commit fc778aa into main Aug 30, 2026
5 checks passed
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.

1 participant