Skip to content

engine: log rejected node-control messages server-side#259

Merged
willwashburn merged 4 commits into
mainfrom
node-control-loud-rejects
Jul 11, 2026
Merged

engine: log rejected node-control messages server-side#259
willwashburn merged 4 commits into
mainfrom
node-control-loud-rejects

Conversation

@willwashburn

@willwashburn willwashburn commented Jul 10, 2026

Copy link
Copy Markdown
Member

What this is

The node-control handler logs a rejected node.register / node.heartbeat (and any other node-control message) at warn level with workspace, node, provider, message type, and error code, before sending the client the error frame.

Why

The handler's catch previously sent the rejection only as a WebSocket error frame and logged nothing. A node whose register/heartbeat is rejected — e.g. node_name_conflict when two nodes register under the same name, or the underlying SQLITE_CONSTRAINT_UNIQUE on nodes.(workspace_id, name) — then runs half-registered (dead heartbeats, absent from the roster) with no server-side signal. Diagnosing the self-host two-node boot failure required adding instrumentation just to see the rejection.

Test

nodeProviders.test.ts asserts a rejected registration is logged with its code and node/workspace context, in addition to the on-socket error reply.

Refs #250.

🤖 Generated with Claude Code

Review in cubic

…n-socket

The node-control handler's catch sent the rejection as a WS `error` frame but
logged nothing, so a rejected `node.register`/`node.heartbeat` (e.g.
`node_name_conflict` when two nodes share a name, or a UNIQUE violation) left a
node running half-registered with no server-side signal — it took manual
instrumentation to see. Log the rejection at warn with workspace, node,
provider, message type, and code before sending the error frame.

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

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6a0040f8-4600-410e-87bc-61d90ce4ea28

📥 Commits

Reviewing files that changed from the base of the PR and between 0a7bdf1 and d050447.

📒 Files selected for processing (5)
  • packages/engine/CHANGELOG.md
  • packages/engine/src/__tests__/conformance/nodeProviders.test.ts
  • packages/engine/src/__tests__/conformance/triggerDispatchLog.test.ts
  • packages/engine/src/engine/node.ts
  • packages/engine/src/engine/trigger.ts

📝 Walkthrough

Walkthrough

The engine now logs rejected node-control messages and message-trigger dispatch failures at warn level with structured context. Conformance tests verify both logging paths, while existing socket error responses and non-throwing trigger behavior remain unchanged.

Changes

Engine observability

Layer / File(s) Summary
Node-control rejection logging
packages/engine/src/engine/node.ts, packages/engine/src/__tests__/conformance/nodeProviders.test.ts
Rejected node messages now log type, code, workspace/node identifiers, provider, and error details; duplicate provider registration tests verify the warning and socket error.
Trigger dispatch failure logging
packages/engine/src/engine/trigger.ts, packages/engine/src/__tests__/conformance/triggerDispatchLog.test.ts, packages/engine/CHANGELOG.md
Failed action invocations now emit structured warnings while remaining swallowed, with coverage for missing actions and corresponding changelog entries.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Poem

A rabbit heard warnings hop through the hall,
For nodes that refuse and triggers that fall.
With workspace clues tucked neatly inside,
The logs now speak where errors once hid.
Thump-thump—tests cheer from burrow to wall!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: logging rejected node-control messages on the server side.
Description check ✅ Passed The description accurately describes the node-control logging change and the added test.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 node-control-loud-rejects

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 adds server-side warning logs for rejected node control messages (such as node.register or node.heartbeat) to ensure these rejections are visible server-side rather than only on the client socket. It also includes a new conformance test and updates the changelog. The reviewer suggested distinguishing between client-side errors and unexpected server-side errors, recommending that server-side faults be logged as errors with stack traces instead of warnings to avoid obscuring critical system health issues.

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.

Comment on lines 1534 to +1548
const error = err as Error & { code?: string };
const code = error.code ?? 'node_control_failed';
// The rejection reaches the client only as the error frame below. Log it so
// it is not invisible server-side: a rejected node.register/node.heartbeat
// (e.g. node_name_conflict, a UNIQUE violation) otherwise leaves a node
// running half-registered with no signal to operators. Warn, not error —
// these are client/protocol rejections, not server faults.
console.warn('[node.control] rejected message', {
type: message.type,
code,
workspaceId: args.workspaceId,
nodeId: args.nodeId,
provider: frameProviderName,
message: error.message,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The catch block catches all errors thrown during message processing, including unexpected database connection failures, internal runtime errors (e.g., TypeError), or other server-side faults (5xx errors).

Logging actual server faults as client-side warnings (console.warn) with a generic [node.control] rejected message prefix can hide critical system health issues from log aggregators and monitoring tools that alert on console.error.

We should distinguish between client-side errors (status code < 500) and unexpected server-side errors (status code >= 500 or undefined), logging the latter as console.error with the stack trace for better observability.

    const error = err as Error & { code?: string; statusCode?: number };
    const code = error.code ?? 'node_control_failed';
    const isClientError = error.statusCode !== undefined && error.statusCode < 500;
    if (isClientError) {
      // The rejection reaches the client only as the error frame below. Log it so
      // it is not invisible server-side: a rejected node.register/node.heartbeat
      // (e.g. node_name_conflict, a UNIQUE violation) otherwise leaves a node
      // running half-registered with no signal to operators. Warn, not error —
      // these are client/protocol rejections, not server faults.
      console.warn('[node.control] rejected message', {
        type: message.type,
        code,
        workspaceId: args.workspaceId,
        nodeId: args.nodeId,
        provider: frameProviderName,
        message: error.message,
      });
    } else {
      console.error('[node.control] internal error processing message', {
        type: message.type,
        code,
        workspaceId: args.workspaceId,
        nodeId: args.nodeId,
        provider: frameProviderName,
        message: error.message,
        stack: error.stack,
      });
    }

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

2 issues found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/engine/src/__tests__/conformance/nodeProviders.test.ts">

<violation number="1" location="packages/engine/src/__tests__/conformance/nodeProviders.test.ts:109">
P3: The new log payload includes `provider` (the provider name from the frame or registry binding), but the test assertion for the log call doesn't verify it. Consider adding `provider: expect.any(String)` (or the expected value `'alpha'`) to `expect.objectContaining(...)` so a regression that drops the provider field is caught during test runs.</violation>
</file>

<file name="packages/engine/src/engine/node.ts">

<violation number="1" location="packages/engine/src/engine/node.ts:1541">
P2: This catch block handles all errors uniformly — both expected client/protocol rejections (e.g. `node_name_conflict`) and unexpected server-side faults (e.g. database connection failures, `TypeError`). Logging everything at `console.warn` without a stack trace can hide critical system errors from monitoring tools that alert on `console.error`.

Consider distinguishing between expected rejections (errors with a known `code` or a `statusCode < 500`) and unexpected failures, logging the latter at `console.error` with `error.stack` for better observability.</violation>
</file>

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

Re-trigger cubic

// (e.g. node_name_conflict, a UNIQUE violation) otherwise leaves a node
// running half-registered with no signal to operators. Warn, not error —
// these are client/protocol rejections, not server faults.
console.warn('[node.control] rejected message', {

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.

P2: This catch block handles all errors uniformly — both expected client/protocol rejections (e.g. node_name_conflict) and unexpected server-side faults (e.g. database connection failures, TypeError). Logging everything at console.warn without a stack trace can hide critical system errors from monitoring tools that alert on console.error.

Consider distinguishing between expected rejections (errors with a known code or a statusCode < 500) and unexpected failures, logging the latter at console.error with error.stack for better observability.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/engine/node.ts, line 1541:

<comment>This catch block handles all errors uniformly — both expected client/protocol rejections (e.g. `node_name_conflict`) and unexpected server-side faults (e.g. database connection failures, `TypeError`). Logging everything at `console.warn` without a stack trace can hide critical system errors from monitoring tools that alert on `console.error`.

Consider distinguishing between expected rejections (errors with a known `code` or a `statusCode < 500`) and unexpected failures, logging the latter at `console.error` with `error.stack` for better observability.</comment>

<file context>
@@ -1532,12 +1532,26 @@ export async function handleNodeControlMessage(args: HandleNodeControlMessageArg
+    // (e.g. node_name_conflict, a UNIQUE violation) otherwise leaves a node
+    // running half-registered with no signal to operators. Warn, not error —
+    // these are client/protocol rejections, not server faults.
+    console.warn('[node.control] rejected message', {
+      type: message.type,
+      code,
</file context>

await dup.handle.handleMessage(registerFrame('node_a', 'alpha', { name: 'py', instance_id: 'i2' }, [{ name: 'run-etl', kind: 'action' }]));

expect(dup.sock.ofType('error').at(-1)).toMatchObject({ code: 'provider_instance_conflict' });
expect(warn).toHaveBeenCalledWith('[node.control] rejected message', expect.objectContaining({

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.

P3: The new log payload includes provider (the provider name from the frame or registry binding), but the test assertion for the log call doesn't verify it. Consider adding provider: expect.any(String) (or the expected value 'alpha') to expect.objectContaining(...) so a regression that drops the provider field is caught during test runs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/__tests__/conformance/nodeProviders.test.ts, line 109:

<comment>The new log payload includes `provider` (the provider name from the frame or registry binding), but the test assertion for the log call doesn't verify it. Consider adding `provider: expect.any(String)` (or the expected value `'alpha'`) to `expect.objectContaining(...)` so a regression that drops the provider field is caught during test runs.</comment>

<file context>
@@ -91,6 +91,32 @@ describe('node providers', () => {
+      await dup.handle.handleMessage(registerFrame('node_a', 'alpha', { name: 'py', instance_id: 'i2' }, [{ name: 'run-etl', kind: 'action' }]));
+
+      expect(dup.sock.ofType('error').at(-1)).toMatchObject({ code: 'provider_instance_conflict' });
+      expect(warn).toHaveBeenCalledWith('[node.control] rejected message', expect.objectContaining({
+        type: 'node.register',
+        code: 'provider_instance_conflict',
</file context>

willwashburn and others added 2 commits July 10, 2026 15:23
fireMessageTriggers dispatched best-effort and swallowed every failure, so a
trigger whose action is missing, has no handler, or targets an offline node
died silently — the message posts, nothing fires, and there is no server-side
signal. Log the failure at warn with trigger id, action name, workspace, and
error code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <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.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/engine/src/engine/trigger.ts">

<violation number="1" location="packages/engine/src/engine/trigger.ts:206">
P3: The `catch (err)` block uses a raw type assertion (`err as Error & { code?: string }`) instead of the project's `asCodedError()` utility from `httpError.ts`. If a thrown value is not an `Error` instance (e.g. a string or plain object), `error.message` would be `undefined` in the log output. The codebase already has `asCodedError()` to handle this robustly — it wraps non-Error thrown values while preserving their own properties, matching the same pattern the engine uses for route-handler error replies.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// — but swallowing the failure silently hides a broken trigger (its
// action is missing, has no handler, or the handling node is offline).
// Log it with trigger/action context so a dead trigger is diagnosable.
const error = err as Error & { code?: string };

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.

P3: The catch (err) block uses a raw type assertion (err as Error & { code?: string }) instead of the project's asCodedError() utility from httpError.ts. If a thrown value is not an Error instance (e.g. a string or plain object), error.message would be undefined in the log output. The codebase already has asCodedError() to handle this robustly — it wraps non-Error thrown values while preserving their own properties, matching the same pattern the engine uses for route-handler error replies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/engine/trigger.ts, line 206:

<comment>The `catch (err)` block uses a raw type assertion (`err as Error & { code?: string }`) instead of the project's `asCodedError()` utility from `httpError.ts`. If a thrown value is not an `Error` instance (e.g. a string or plain object), `error.message` would be `undefined` in the log output. The codebase already has `asCodedError()` to handle this robustly — it wraps non-Error thrown values while preserving their own properties, matching the same pattern the engine uses for route-handler error replies.</comment>

<file context>
@@ -198,8 +198,19 @@ export async function fireMessageTriggers(args: {
+      // — but swallowing the failure silently hides a broken trigger (its
+      // action is missing, has no handler, or the handling node is offline).
+      // Log it with trigger/action context so a dead trigger is diagnosable.
+      const error = err as Error & { code?: string };
+      console.warn('[trigger] action dispatch failed', {
+        triggerId: row.id,
</file context>

@willwashburn willwashburn merged commit cabea01 into main Jul 11, 2026
5 checks passed
@willwashburn willwashburn deleted the node-control-loud-rejects branch July 11, 2026 12:45
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