Skip to content

engine: reschedule node-scoped actions to the owning provider, not the broker#262

Merged
willwashburn merged 3 commits into
mainfrom
reschedule-node-provider
Jul 11, 2026
Merged

engine: reschedule node-scoped actions to the owning provider, not the broker#262
willwashburn merged 3 commits into
mainfrom
reschedule-node-provider

Conversation

@willwashburn

@willwashburn willwashburn commented Jul 10, 2026

Copy link
Copy Markdown
Member

What this is

When a node hosting an invoked node-scoped action dies, the crash-recovery reschedule dispatches the action to the provider that owns it on the chosen fallback node, instead of the node's default (broker) provider.

Why

rescheduleNodeInvocation's generic-placement branch called dispatchNodeInvocation with no providerName. On a multi-provider node that routes via sendToNodedefaultProviderName, which returns the default (broker) provider. The broker rejects a node-scoped action with handler_unavailable, so the invocation loops between nodes and never completes.

Concretely: a node-scoped work action invoked on node-a; node-a is lost; the engine reschedules work to node-b, which hosts both a broker and a fleet provider — the reschedule went to node-b's broker and was rejected.

The initial-invoke path (invokeNodeAction/invokeAction) and the agent-target reschedule branch already resolve their provider; only the generic-placement branch omitted it.

Change

Resolve the chosen node's action row with fetchNodeAction and pass its handlerProvider to dispatchNodeInvocation. When there is no node action row (spawn/capacity or a legacy single-socket node), the provider falls back to undefined → the node default, preserving current behavior.

Test

rescheduleNodeProvider.test.ts: work owned by a fleet provider on node-a; node-b is multi-provider (broker + fleet owning work); invoke on node-a, lose node-a, reschedule — asserts the dispatch reaches node-b's fleet provider and not its broker. Verified to fail without the fix.

Refs #250.

🤖 Generated with Claude Code

Review in cubic

…e broker

rescheduleNodeInvocation's generic-placement dispatch (action.ts) called
dispatchNodeInvocation with no providerName, so on a multi-provider fallback
node it fell back to the default provider (the broker) via sendToNode ->
defaultProviderName. The broker rejects a node-scoped action with
handler_unavailable, and the invocation loops between nodes without completing.

Resolve the chosen node's action row (fetchNodeAction) and dispatch to its
handlerProvider, mirroring the initial-invoke path. When there is no node action
row (spawn/capacity, legacy single-socket nodes) the provider falls back to the
node default as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Node-scoped action rescheduling now resolves the action-owning provider on fallback nodes, applies provider liveness and queue policies, retries alternate placements when providers are unavailable, and records action ownership during dispatch. Conformance tests cover routing, queueing, failures, and capacity release.

Changes

Provider-aware rescheduling

Layer / File(s) Summary
Define provider attachment and liveness checks
packages/engine/src/ports/realtime.ts, packages/engine/src/adapters/node/realtime.ts, packages/engine/src/engine/action.ts
Adds provider attachment detection and gates queue draining on connectivity, attachment, and handlers_live state.
Dispatch through available owning providers
packages/engine/src/engine/action.ts
Propagates actionId, queues unavailable providers when allowed, and fails invocations when unavailable providers cannot queue.
Route and validate rescheduled actions
packages/engine/src/engine/action.ts, packages/engine/src/__tests__/conformance/rescheduleNodeProvider.test.ts, packages/engine/CHANGELOG.md
Rescheduling retries alternate nodes after provider rejection and tests owning-provider routing, queueing, liveness, capacity release, and failure states.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Rescheduler
  participant FallbackNode
  participant FleetProvider
  participant Database
  Rescheduler->>FallbackNode: Resolve the action-owning provider
  FallbackNode-->>Rescheduler: Return handlerProvider
  Rescheduler->>FleetProvider: Dispatch action.invoke
  FleetProvider->>Database: Persist invocation status
  Database-->>Rescheduler: Return dispatch state
Loading

Possibly related PRs

Poem

A rabbit hops where lost tasks flee,
Finds the provider meant to be.
Queues wait until handlers wake,
Alternate paths the journey make.
No broker loop—success to take! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: rescheduling node-scoped actions to the owning provider instead of the broker.
Description check ✅ Passed The description accurately explains the bug, fix, and test coverage, and is clearly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch reschedule-node-provider

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.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request fixes an issue where rescheduling a node-scoped action onto a fallback node incorrectly targeted the node's default (broker) provider instead of the provider that owns the action. This is resolved by fetching the action-owning provider on the chosen node and passing it to dispatchNodeInvocation. A conformance test was also added to verify this behavior. There are no review comments, so no further feedback is provided.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3a96be6935

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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

@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 3 files

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

Re-trigger cubic

Comment thread packages/engine/src/engine/action.ts Outdated
@willwashburn willwashburn merged commit ed8ec24 into main Jul 11, 2026
5 checks passed
@willwashburn willwashburn deleted the reschedule-node-provider branch July 11, 2026 15:26

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/engine/src/engine/action.ts (1)

921-939: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Clear actionId on fallback dispatches. dispatchNodeAttempt preserves the previous actionId when opts.actionId is omitted, and rescheduleNodeInvocation can hit that path when a retry lands on a node that has the capability but no node-scoped action row. That leaves actionInvocations.actionId pointing at the old node, so drainNodeInvocations joins the wrong handler and can skip the invocation on the node it was actually dispatched to.

Proposed fix
     .set({
       ...stateFields,
       dispatchedNodeId: nodeId,
       spawnReservedAt: opts.reservationHeld ? new Date() : null,
-      ...(opts.actionId ? { actionId: opts.actionId } : {}),
+      actionId: opts.actionId ?? null,
       ...attemptFields,
     })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/action.ts` around lines 921 - 939, Update
dispatchNodeAttempt so fallback dispatches clear actionInvocations.actionId when
opts.actionId is omitted, rather than preserving the previous node’s action ID;
retain the provided action ID when opts.actionId exists. Ensure
rescheduleNodeInvocation paths without a node-scoped action row leave the
invocation associated with the newly dispatched node.
🧹 Nitpick comments (1)
packages/engine/src/engine/action.ts (1)

1151-1157: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoidable per-row provider lookup in the drain loop.

getProvider is now queried once per pending invocation row inside the for (const row of rows) loop. Multiple queued invocations commonly share the same (nodeId, providerName), so this is an N+1 query pattern on the reconnect-drain hot path. Consider caching the provider row per providerName for the duration of the function call.

♻️ Proposed refactor
+  const providerCache = new Map<string, ProviderRow | null>();
   for (const row of rows) {
     ...
     if (!registry.isProviderConnected(workspaceId, nodeId, providerName)) continue;
-    const provider = await getProvider(db, workspaceId, nodeId, providerName);
+    let provider = providerCache.get(providerName);
+    if (provider === undefined) {
+      provider = await getProvider(db, workspaceId, nodeId, providerName);
+      providerCache.set(providerName, provider);
+    }
     const explicitlyAttached = registry.isProviderAttached?.(workspaceId, nodeId, providerName) ?? true;
     if (provider && explicitlyAttached && !isProviderLive(provider)) continue;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/action.ts` around lines 1151 - 1157, Cache
provider lookups within the reconnect-drain function so repeated rows sharing a
providerName reuse the same getProvider result instead of issuing one query per
row. Update the loop around registry.isProviderConnected and isProviderLive to
read from this per-call providerName cache while preserving the existing
connection, attachment, and liveness checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/engine/src/engine/action.ts`:
- Around line 1320-1328: Update the queued placement branch in the dispatch flow
to capture the result returned by dispatchNodeAttempt and return its accepted
status instead of unconditionally returning true. Preserve the existing dispatch
arguments and ensure race-lost guarded updates report false to callers such as
rescheduleInvocationsForLostNode.

---

Outside diff comments:
In `@packages/engine/src/engine/action.ts`:
- Around line 921-939: Update dispatchNodeAttempt so fallback dispatches clear
actionInvocations.actionId when opts.actionId is omitted, rather than preserving
the previous node’s action ID; retain the provided action ID when opts.actionId
exists. Ensure rescheduleNodeInvocation paths without a node-scoped action row
leave the invocation associated with the newly dispatched node.

---

Nitpick comments:
In `@packages/engine/src/engine/action.ts`:
- Around line 1151-1157: Cache provider lookups within the reconnect-drain
function so repeated rows sharing a providerName reuse the same getProvider
result instead of issuing one query per row. Update the loop around
registry.isProviderConnected and isProviderLive to read from this per-call
providerName cache while preserving the existing connection, attachment, and
liveness checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 68d25611-54d1-4a16-81ad-311547708719

📥 Commits

Reviewing files that changed from the base of the PR and between 3a96be6 and 95621dd.

📒 Files selected for processing (5)
  • packages/engine/CHANGELOG.md
  • packages/engine/src/__tests__/conformance/rescheduleNodeProvider.test.ts
  • packages/engine/src/adapters/node/realtime.ts
  • packages/engine/src/engine/action.ts
  • packages/engine/src/ports/realtime.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/engine/CHANGELOG.md

Comment on lines +1320 to +1328
if (placement.queued) {
await dispatchNodeAttempt(db, invocation.workspaceId, invocation.id, placement.node.id, {
pending: true,
retryAfterAt: opts.retryAfterAt ?? null,
reservationHeld: false,
actionId: target?.id,
});
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Discarded dispatch result: queued fallback always reports success.

dispatchNodeAttempt's return value (whether the guarded DB update actually applied) is discarded here, and the branch unconditionally return trues — unlike the sibling branches at Lines 1315 and 1340, which correctly propagate dispatched.accepted. If the guarded update loses the race (invocation left OPEN_INVOCATION_STATUSES concurrently, e.g. completed/cancelled), this branch still reports the reschedule as successful to callers like rescheduleInvocationsForLostNode, which increments its rescheduled counter on a no-op.

🛠️ Proposed fix
         if (placement.queued) {
-          await dispatchNodeAttempt(db, invocation.workspaceId, invocation.id, placement.node.id, {
+          const accepted = await dispatchNodeAttempt(db, invocation.workspaceId, invocation.id, placement.node.id, {
             pending: true,
             retryAfterAt: opts.retryAfterAt ?? null,
             reservationHeld: false,
             actionId: target?.id,
           });
-          return true;
+          return accepted;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (placement.queued) {
await dispatchNodeAttempt(db, invocation.workspaceId, invocation.id, placement.node.id, {
pending: true,
retryAfterAt: opts.retryAfterAt ?? null,
reservationHeld: false,
actionId: target?.id,
});
return true;
}
if (placement.queued) {
const accepted = await dispatchNodeAttempt(db, invocation.workspaceId, invocation.id, placement.node.id, {
pending: true,
retryAfterAt: opts.retryAfterAt ?? null,
reservationHeld: false,
actionId: target?.id,
});
return accepted;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/action.ts` around lines 1320 - 1328, Update the
queued placement branch in the dispatch flow to capture the result returned by
dispatchNodeAttempt and return its accepted status instead of unconditionally
returning true. Preserve the existing dispatch arguments and ensure race-lost
guarded updates report false to callers such as
rescheduleInvocationsForLostNode.

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