Skip to content

fix(engine): bound never-dispatched pending invocations by absolute age - #358

Merged
kjgbot merged 3 commits into
mainfrom
fix/never-dispatched-pending-age-bound
Aug 25, 2026
Merged

fix(engine): bound never-dispatched pending invocations by absolute age#358
kjgbot merged 3 commits into
mainfrom
fix/never-dispatched-pending-age-bound

Conversation

@kjgbot

@kjgbot kjgbot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #357.

The 0032 handler-unreachable TTL fails an open invocation only after a dispatched handler connection is observed unreachable for the TTL. A pending row that was never dispatched (dispatch_attempts = 0) has no handler to observe, so the clock never starts and the row can sit pending indefinitely. The relaycast-cloud D1 measurement in #357 shows 7,527 such rows, oldest 2026-06-26.

The failure is not inert. When a matching node eventually returns and the queue drains, week-old spawn briefs come back to life as fresh agents. In the incident that motivated this fix, one landed as chief on a second node and — because agent rows carry one token_hash per name — evicted the live resident's token, so every DM to chief routed to the impostor while the resident sat silently deaf.

The fix: sweepTimedOutInvocations now runs a second pass that fails

status='pending' AND dispatch_attempts=0 AND created_at older than PENDING_INVOCATION_MAX_AGE_MS

with the distinguishing error never_dispatched_expired.

Choice of N

PENDING_INVOCATION_MAX_AGE_MS = 72 hours. Justification:

  • Handler-unreachable TTL is 120s; the age bound needs to be much longer so it never races the existing guard.
  • Legitimate node outages: minutes to hours, occasionally a long weekend.
  • 72h covers a Friday→Monday weekend outage with a full day of buffer.
  • Far shorter than the observed multi-month backlog (60 days) that motivated this.

Exposed as pendingInvocationMaxAgeMs on SweepTimedOutInvocationsOptions so operators can tune per environment.

What is NOT changed

  • The 0032 handler-unreachable TTL guard still runs first with the same semantics. The new bound covers a shape that guard cannot see (no dispatched connection to observe), and its query (dispatch_attempts = 0) never touches a row the existing guard would touch (which requires dispatch_attempts > 0).
  • No schema changes. The fix is pure engine logic over existing columns.

Test plan

  • must-fire: an old, never-dispatched pending row IS failed with never_dispatched_expired.
  • must-not-fire: a recent pending row is untouched.
  • must-not-fire: a dispatched-then-unreachable row inside the 0032 TTL is untouched (dispatch_attempts > 0 excludes it from the new query).
  • control: reset the same row to the never-dispatched shape and the age bound DOES fail it — proves the must-not-fire is guarded by the query filter, not by accident.
  • Both must-not-fire cases were verified to turn red when their guarded condition was removed from the sweep query (dispatch_attempts=0 removed → dispatched-then-unreachable turns red; createdAt<cutoff removed → recent-untouched turns red). Guards restored before push.
  • npx turbo test lint build --filter=@relaycast/engine --force — all 684 tests green.

Notes for reviewer

🤖 Generated with Claude Code

The 0032 handler-unreachable TTL fails an open invocation only after a
DISPATCHED handler connection is observed unreachable for the TTL. A
`pending` row that was never dispatched to any node
(`dispatch_attempts = 0`) has no handler to observe, so the clock never
starts and it can sit pending indefinitely — a 7,527-row backlog on the
`relaycast-cloud` D1 (oldest 2026-06-26) is the direct measurement.

The failure mode is not inert. When a matching node eventually returns
and the queue drains, week-old spawn briefs come back to life as fresh
agents. One landed as `chief` on a second node, and because agent rows
carry one `token_hash` per name it evicted the live resident's token —
every DM to `chief` routed to the impostor while the resident sat
silently deaf.

`sweepTimedOutInvocations` now runs a second pass that fails
`status='pending' AND dispatch_attempts=0 AND created_at older than
PENDING_INVOCATION_MAX_AGE_MS (default 72h)` with the distinguishing
error `never_dispatched_expired`. 72h rides out a weekend-scale node
outage with a day of buffer, and is far shorter than the observed
multi-month backlog. The existing handler-unreachable TTL runs first
and is unchanged; the new bound only touches the shape that guard
cannot see.

Tests:
- must-fire: an old, never-dispatched pending row IS failed with
  `never_dispatched_expired`.
- must-not-fire: a recent pending row is untouched.
- must-not-fire: a dispatched-then-unreachable row inside the 0032 TTL
  is untouched (has `dispatch_attempts > 0`).
- control: reset the same row to the never-dispatched shape and the age
  bound DOES fail it, proving the must-not-fire is guarded by the query
  filter rather than by accident.

Both must-not-fire cases were verified to turn red when their guarded
condition was removed from the sweep query.

Fixes #357

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Session-Id: 10847086-04c3-455f-9fba-c975ab69ab62

Session-Id: 10847086-04c3-455f-9fba-c975ab69ab62

Session-Id: 10847086-04c3-455f-9fba-c975ab69ab62
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e6a1d9fb-2c6f-4ae7-8002-c595137d4284


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread packages/engine/src/engine/action.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 5 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/engine/src/engine/action.ts Outdated
Comment thread packages/engine/src/engine/action.ts Outdated
…weep

Two review findings on the age-bound sweep:

1) D1 100-bound-parameter cap: failNeverDispatchedExpiredInvocations was
   passing every stale id for a workspace into one inArray, so any workspace
   with >~100 aged rows threw "too many bound parameters" that the per-
   workspace catch swallowed — leaving the exact backlog the fix targets
   unable to drain. Chunk by D1_SAFE_IN_QUERY_CHUNK_SIZE with a per-chunk
   try/catch and per-chunk completion emission.

2) SELECT/UPDATE race: a row selected as (pending, dispatch_attempts=0,
   past cutoff) could be concurrently dispatched before failOpenInvocationRows
   ran; that UPDATE only re-checks status IN OPEN_INVOCATION_STATUSES, so it
   would fail a row that had just been dispatched. Introduce
   failNeverDispatchedInvocationRows whose UPDATE re-checks status='pending',
   dispatch_attempts=0, and created_at<=cutoff atomically, so a concurrently
   dispatched row is skipped. Never-dispatched rows have no spawn reservation
   to release, so the held-rows lookup is unnecessary here.

Test: pendingInvocationAgeBound now includes an isolation case for a pending
row with dispatch_attempts>0 past the cutoff — must NOT be failed with
never_dispatched_expired. Proves the dispatch_attempts=0 filter is load-
bearing on its own (the existing must-not-fire uses a dispatched row, so it
alone couldn't isolate the two filters).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/engine/src/engine/action.ts
Merge #355 (`fix: make action invocation retries idempotent`) into this
branch to clear the CONFLICTING/DIRTY state left by its landing on main.
Only textual conflicts (both changelogs, both entries under `[Unreleased -
Patch] > Fixed`); no logical conflict — #355's dispatch reordering and this
branch's sweep both coexist.

Also narrow the cubic-flagged race on the age-bound sweep. The dispatch
flow's send→record ordering (send frame, then `dispatchNodeAttempt`
increments `dispatchAttempts`) is load-bearing for #355's "wait for durable
dispatch outcome" idempotent-replay semantics — a claim that materializes
before the send would let a replay observe a success that hasn't
happened. Reordering to claim-then-send breaks the keyed idempotency
suite. Instead, sleep `NEVER_DISPATCHED_SWEEP_GRACE_MS` (default 5s)
between the sweep's candidate SELECT and its atomic UPDATE, so any
concurrent dispatcher's `dispatchAttempts` UPDATE lands before ours fires.
Combined with the existing `dispatch_attempts = 0` re-check in the UPDATE
WHERE, an in-flight dispatch is reliably invisible to the sweep instead of
racing with it. Only sleep when candidates were found. Override with
`neverDispatchedSweepGraceMs: 0` for tests.

A fully race-free fix requires reordering dispatch to claim-then-send with
revert-on-send-failure — that touches #355's atomic-outcome contract and
is out of this PR's scope; tracked as a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@kjgbot
kjgbot merged commit 8e2aba2 into main Aug 25, 2026
8 checks passed
@kjgbot
kjgbot deleted the fix/never-dispatched-pending-age-bound branch August 25, 2026 20:31
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.

A never-dispatched invocation has no path to failure: 7,527 pending spawns, oldest 2026-06-26

1 participant