Skip to content

Fix exit-code verification across completion paths - #44

Merged
kjgbot merged 1 commit into
mainfrom
fix/verification-exit-code-paths-0825
Aug 25, 2026
Merged

Fix exit-code verification across completion paths#44
kjgbot merged 1 commit into
mainfrom
fix/verification-exit-code-paths-0825

Conversation

@miyaontherelay

Copy link
Copy Markdown
Contributor

Summary

  • compare exit_code verification against the recorded process exit code instead of passing unconditionally
  • carry the recorded code through WorkflowRunner deterministic execution, StepExecutor process spawning, injected execution, and agent completion evidence
  • make existing clean-exit mocks explicit and stabilize the owner-timeout test ordering it claims to exercise

Execution path coverage

  • WorkflowRunner injected deterministic executor: WorkflowRunner deterministic executor path rejects terminal success when exit_code verification disagrees
  • exported StepExecutor plus processSpawner: processSpawner path runs exit_code verification before accepting terminal success
  • exported StepExecutor plus injected deps.executeStep: injected executeStep path runs exit_code verification before accepting terminal success
  • verification unit behavior covers matching, mismatching, and unavailable recorded exit codes

A green suite proves the paths that have tests, and nothing else.

Compatibility with #39

This branch is based on the CI gate branch and does not modify the in-flight PR #39 branch. When #39 adds terminalSuccessExitCodes, an exit such as 78 may be terminal for process control, but it cannot satisfy verification expecting 0. The three named regressions include that 78-versus-0 composition shape and are intended to remain valid when the branches meet.

Verification

  • npm test: exit 0; 951 passed, 0 failed
  • npm run typecheck: exit 0
  • focused verification, StepExecutor, WorkflowRunner, and completion-pipeline run: exit 0; 219 passed, 0 failed
  • focused permissions integration: exit 0; 2 passed, 0 failed

Base is ci/pr-test-gate-0825 so pull-request CI covers this stack. Retarget to main after PR-A merges.

Session-Id: 01a037bb-4e8c-7c20-8962-621515f5e335
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: babb4186-525c-47a0-a6c2-857a58ba1474

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@cubic-dev-ai cubic-dev-ai 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.

6 issues found across 10 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/core/src/verification.ts">

<violation number="1" location="packages/core/src/verification.ts:232">
P2: When an `exit_code` value is empty or whitespace, `Number` converts it to `0`, so malformed configuration passes for clean exits. Reject blank values before numeric conversion.</violation>
</file>

<file name="packages/core/src/step-executor.ts">

<violation number="1" location="packages/core/src/step-executor.ts:551">
P2: When exit-code verification fails in the process-spawner path, `monitorStep` falls back to a generic failure and drops the spawn output and recorded `exitCode`. Preserve the last spawn result and `failed_verification` reason in the failure result so failure callbacks and persisted evidence retain the process result.</violation>

<violation number="2" location="packages/core/src/step-executor.ts:551">
P2: When a verifier returns `{ passed: false }` without throwing, the process-spawner path still accepts terminal success. Derive the status and error from `verificationResult`, as the injected-execution branch already does.</violation>
</file>

<file name="packages/core/src/runner.ts">

<violation number="1" location="packages/core/src/runner.ts:5793">
P2: API-backed agent steps with `verification: { type: 'exit_code', value: '0' }` always fail because the branch records code 0 only on `spawnResult`, not completion evidence. Pass `lastExitCode` here, which is assigned from that result before this verification runs.</violation>

<violation number="2" location="packages/core/src/runner.ts:6660">
P1: For supervised steps, `exit_code` verification can validate the owner’s exit instead of the specialist whose output it checks. Keep worker and owner exit codes separate, then pass the specialist/worker code to this verification.</violation>
</file>

<file name="packages/core/src/__tests__/completion-pipeline.test.ts">

<violation number="1" location="packages/core/src/__tests__/completion-pipeline.test.ts:110">
P3: mockSpawnExitCodes only feeds the pty handle (makeMockHandle), but the node:child_process `spawn` mock still emits `close` with a hardcoded 0 and never consumes from mockSpawnExitCodes. Any test in this file that drives exit_code verification through the child-spawn path would silently report 0 regardless of the configured code, and because WorkflowAgentHandle.exitCode reads `inner.exitCode`, the affected path depends on which mock the spawn goes through. Either wire the spawn mock to mockSpawnExitCodes or leave a comment stating that exit codes are only controllable for the pty path so future tests don't rely on a mechanism that doesn't apply.</violation>
</file>

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

Re-trigger cubic

? this.runVerification(step.verification, specialistOutput, step.name, verificationTaskText, {
allowFailure: true,
completionMarkerFound: hasMarker,
exitCode: this.getStepCompletionEvidence(step.name)?.process.exitCode,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: For supervised steps, exit_code verification can validate the owner’s exit instead of the specialist whose output it checks. Keep worker and owner exit codes separate, then pass the specialist/worker code to this verification.

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

<comment>For supervised steps, `exit_code` verification can validate the owner’s exit instead of the specialist whose output it checks. Keep worker and owner exit codes separate, then pass the specialist/worker code to this verification.</comment>

<file context>
@@ -6652,6 +6657,7 @@ export class WorkflowRunner {
       ? this.runVerification(step.verification, specialistOutput, step.name, verificationTaskText, {
           allowFailure: true,
           completionMarkerFound: hasMarker,
+          exitCode: this.getStepCompletionEvidence(step.name)?.process.exitCode,
         })
       : { passed: false };
</file context>

// verification hook runs, so this check is currently an unconditional pass.
return true;
export function checkExitCode(expectedExitCode: string, actualExitCode?: number): boolean {
const expected = Number(expectedExitCode);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When an exit_code value is empty or whitespace, Number converts it to 0, so malformed configuration passes for clean exits. Reject blank values before numeric conversion.

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

<comment>When an `exit_code` value is empty or whitespace, `Number` converts it to `0`, so malformed configuration passes for clean exits. Reject blank values before numeric conversion.</comment>

<file context>
@@ -223,10 +228,9 @@ export function stripInjectedTaskEcho(output: string, injectedTaskText?: string)
-  // verification hook runs, so this check is currently an unconditional pass.
-  return true;
+export function checkExitCode(expectedExitCode: string, actualExitCode?: number): boolean {
+  const expected = Number(expectedExitCode);
+  return Number.isInteger(expected) && actualExitCode !== undefined && actualExitCode === expected;
 }
</file context>
Suggested change
const expected = Number(expectedExitCode);
const normalizedExpected = String(expectedExitCode).trim();
const expected = normalizedExpected === '' ? Number.NaN : Number(normalizedExpected);

};
}

const verificationResult = step.verification

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When exit-code verification fails in the process-spawner path, monitorStep falls back to a generic failure and drops the spawn output and recorded exitCode. Preserve the last spawn result and failed_verification reason in the failure result so failure callbacks and persisted evidence retain the process result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/step-executor.ts, line 551:

<comment>When exit-code verification fails in the process-spawner path, `monitorStep` falls back to a generic failure and drops the spawn output and recorded `exitCode`. Preserve the last spawn result and `failed_verification` reason in the failure result so failure callbacks and persisted evidence retain the process result.</comment>

<file context>
@@ -539,12 +548,19 @@ export class StepExecutor<TState extends StateLike = StateLike> {
           };
         }
 
+        const verificationResult = step.verification
+          ? this.runVerification(step.verification, output, step.name, undefined, {
+              exitCode: spawnResult.exitCode,
</file context>

};
}

const verificationResult = step.verification

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When a verifier returns { passed: false } without throwing, the process-spawner path still accepts terminal success. Derive the status and error from verificationResult, as the injected-execution branch already does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/step-executor.ts, line 551:

<comment>When a verifier returns `{ passed: false }` without throwing, the process-spawner path still accepts terminal success. Derive the status and error from `verificationResult`, as the injected-execution branch already does.</comment>

<file context>
@@ -539,12 +548,19 @@ export class StepExecutor<TState extends StateLike = StateLike> {
           };
         }
 
+        const verificationResult = step.verification
+          ? this.runVerification(step.verification, output, step.name, undefined, {
+              exitCode: spawnResult.exitCode,
</file context>

step.name,
promptTaskText
promptTaskText,
{ exitCode: this.getStepCompletionEvidence(step.name)?.process.exitCode }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: API-backed agent steps with verification: { type: 'exit_code', value: '0' } always fail because the branch records code 0 only on spawnResult, not completion evidence. Pass lastExitCode here, which is assigned from that result before this verification runs.

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

<comment>API-backed agent steps with `verification: { type: 'exit_code', value: '0' }` always fail because the branch records code 0 only on `spawnResult`, not completion evidence. Pass `lastExitCode` here, which is assigned from that result before this verification runs.</comment>

<file context>
@@ -5785,7 +5789,8 @@ export class WorkflowRunner {
             step.name,
-            promptTaskText
+            promptTaskText,
+            { exitCode: this.getStepCompletionEvidence(step.name)?.process.exitCode }
           );
           completionReason = verificationResult.completionReason;
</file context>
Suggested change
{ exitCode: this.getStepCompletionEvidence(step.name)?.process.exitCode }
{ exitCode: lastExitCode }

name,
runtime: 'pty' as const,
exitCode: undefined as number | undefined,
exitCode: mockSpawnExitCodes.shift(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: mockSpawnExitCodes only feeds the pty handle (makeMockHandle), but the node:child_process spawn mock still emits close with a hardcoded 0 and never consumes from mockSpawnExitCodes. Any test in this file that drives exit_code verification through the child-spawn path would silently report 0 regardless of the configured code, and because WorkflowAgentHandle.exitCode reads inner.exitCode, the affected path depends on which mock the spawn goes through. Either wire the spawn mock to mockSpawnExitCodes or leave a comment stating that exit codes are only controllable for the pty path so future tests don't rely on a mechanism that doesn't apply.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/__tests__/completion-pipeline.test.ts, line 110:

<comment>mockSpawnExitCodes only feeds the pty handle (makeMockHandle), but the node:child_process `spawn` mock still emits `close` with a hardcoded 0 and never consumes from mockSpawnExitCodes. Any test in this file that drives exit_code verification through the child-spawn path would silently report 0 regardless of the configured code, and because WorkflowAgentHandle.exitCode reads `inner.exitCode`, the affected path depends on which mock the spawn goes through. Either wire the spawn mock to mockSpawnExitCodes or leave a comment stating that exit codes are only controllable for the pty path so future tests don't rely on a mechanism that doesn't apply.</comment>

<file context>
@@ -106,7 +107,7 @@ function makeMockHandle(name: string) {
     name,
     runtime: 'pty' as const,
-    exitCode: undefined as number | undefined,
+    exitCode: mockSpawnExitCodes.shift(),
     exitSignal: undefined as string | undefined,
     waitForExit: (ms?: number) => waitForExitFn(ms).then((reason) => ({ reason })),
</file context>

@khaliqgant

Copy link
Copy Markdown
Member

Better factoring than mine — one behaviour question before merge

I was fixing this same defect inside #39 when this appeared. This PR wins and I have backed mine out: #39 no longer touches verification.ts and is now rebased onto this branch. Separating the latent checkExitCode bug from the feature that exposed it is the correct split — I had bundled a broadly-scoped correctness fix inside a narrowly-scoped feature review, which is worse. This also covers agent completion evidence, which I missed.

One question, and it is the reason I am not just approving

checkExitCode here fails closed when no exit code was recorded:

export function checkExitCode(expectedExitCode: string, actualExitCode?: number): boolean {
  const expected = Number(expectedExitCode);
  return Number.isInteger(expected) && actualExitCode !== undefined && actualExitCode === expected;
}

The old stub returned true unconditionally. So any caller running exit_code verification on a path that cannot observe a code flips from always passing to always failing.

Concretely: a user with verification: { type: 'exit_code', value: '0' } on an agent step. "Exit code" is not really a concept for an interactive PTY agent, the check was vacuous, and their workflow passed. After this it hard-fails.

I traced the call sites on this branch — 6 of 7 in runner.ts thread the code, and the 7th (line 3744) is the verificationRunner pass-through into StepExecutor, which forwards whatever options it is given. So whether an unthreaded path remains depends on StepExecutor's own call sites, which this PR does modify.

Three things would close it:

  1. Confirm no reachable path runs exit_code verification without a recorded code — and say which paths you enumerated, since "CI is green" only proves the paths that have tests. That is the lesson from feat(core): add explicit terminal-success exits #39: I checked one path, found the guarantee held, and reported it as a property of the system.
  2. If such a path does remain, decide deliberately between failing closed and preserving the old pass, rather than letting it fall out of the undefined case.
  3. Either way this belongs in the release notes as a behaviour change, not a bug fix — a design partner is live on relayflows today, and this is exactly the class of change that moves under someone silently.

Failing closed is probably right. I just want it to be a decision with the blast radius named, not a default.

Merge order

#42#44#39. #39 is rebased onto this branch, suite green at 964, typecheck exit 0.

@kjgbot
kjgbot deleted the branch main August 25, 2026 12:42
@kjgbot kjgbot closed this Aug 25, 2026
@kjgbot kjgbot reopened this Aug 25, 2026
@kjgbot
kjgbot changed the base branch from ci/pr-test-gate-0825 to main August 25, 2026 12:43
@kjgbot
kjgbot merged commit 7709b37 into main Aug 25, 2026
4 checks passed
@kjgbot
kjgbot deleted the fix/verification-exit-code-paths-0825 branch August 25, 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.

3 participants