From 6d17e190b198689529db377b8e8e95380e09b82b Mon Sep 17 00:00:00 2001 From: Miya Date: Tue, 25 Aug 2026 09:50:27 +0200 Subject: [PATCH 1/6] fix: verify recorded exit codes on every completion path Session-Id: 01a037bb-4e8c-7c20-8962-621515f5e335 Session-Id: 08f4e75c-099d-49ad-b98e-26a37e4cf2c9 --- .../src/__tests__/completion-pipeline.test.ts | 7 ++- .../src/__tests__/e2e-permissions.test.ts | 4 +- .../__tests__/fixtures/permission-test.yaml | 2 + .../core/src/__tests__/step-executor.test.ts | 41 +++++++++++++ .../core/src/__tests__/verification.test.ts | 22 ++++--- .../src/__tests__/workflow-runner.test.ts | 59 ++++++++++++++++++- packages/core/src/runner.ts | 16 +++-- packages/core/src/schema.ts | 2 +- packages/core/src/step-executor.ts | 24 ++++++-- packages/core/src/verification.ts | 16 +++-- 10 files changed, 166 insertions(+), 27 deletions(-) diff --git a/packages/core/src/__tests__/completion-pipeline.test.ts b/packages/core/src/__tests__/completion-pipeline.test.ts index 86df7fa..e2c103a 100644 --- a/packages/core/src/__tests__/completion-pipeline.test.ts +++ b/packages/core/src/__tests__/completion-pipeline.test.ts @@ -70,6 +70,7 @@ vi.mock('@relaycast/sdk', () => ({ let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>; let waitForIdleFn: (ms?: number) => Promise<'idle' | 'timeout' | 'exited'>; let mockSpawnOutputs: string[] = []; +let mockSpawnExitCodes: Array = []; vi.mock('node:child_process', async () => { const actual = await vi.importActual('node:child_process'); @@ -106,7 +107,7 @@ function makeMockHandle(name: string) { return { 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 })), waitForIdle: (ms?: number) => waitForIdleFn(ms).then((reason) => ({ reason })), @@ -348,6 +349,7 @@ describe('Completion Pipeline', () => { waitForExitFn = vi.fn().mockResolvedValue('exited'); waitForIdleFn = vi.fn().mockImplementation(() => never()); mockSpawnOutputs = []; + mockSpawnExitCodes = []; mockRelayInstance.spawnPty.mockImplementation(defaultSpawnPtyImplementation); eventListeners.clear(); db = makeDb(); @@ -1728,6 +1730,7 @@ describe('Completion Pipeline', () => { // Output has no STEP_COMPLETE, no OWNER_DECISION — just normal work output mockSpawnOutputs = ['Implemented the auth module. All tests pass.']; + mockSpawnExitCodes = [0]; const localDb = makeDb(); runner = new WorkflowRunner({ db: localDb, workspaceId: 'ws-test' }); @@ -1823,6 +1826,7 @@ describe('Completion Pipeline', () => { // Output contains positive conclusion words but no explicit marker mockSpawnOutputs = ['Feature implemented and verified. All artifacts are correct and complete.']; + mockSpawnExitCodes = [0]; const localDb = makeDb(); runner = new WorkflowRunner({ db: localDb, workspaceId: 'ws-test' }); @@ -1925,6 +1929,7 @@ describe('Completion Pipeline', () => { mockSpawnOutputs = [ 'Implemented the feature.\nOWNER_DECISION: INCOMPLETE_RETRY\nREASON: needs more tests\n', ]; + mockSpawnExitCodes = [0]; const localDb = makeDb(); runner = new WorkflowRunner({ db: localDb, workspaceId: 'ws-test' }); diff --git a/packages/core/src/__tests__/e2e-permissions.test.ts b/packages/core/src/__tests__/e2e-permissions.test.ts index 37ef793..dbb1dff 100644 --- a/packages/core/src/__tests__/e2e-permissions.test.ts +++ b/packages/core/src/__tests__/e2e-permissions.test.ts @@ -184,7 +184,9 @@ function makeMockHandle(name: string) { return { name, runtime: 'pty' as const, - exitCode: undefined as number | undefined, + // This fixture models agents that exit cleanly; exit_code verification now + // consumes the recorded code instead of treating process success as implicit. + exitCode: 0 as number | undefined, exitSignal: undefined as string | undefined, waitForExit: (ms?: number) => waitForExitFn(ms).then((reason) => ({ reason })), waitForIdle: (ms?: number) => waitForIdleFn(ms).then((reason) => ({ reason })), diff --git a/packages/core/src/__tests__/fixtures/permission-test.yaml b/packages/core/src/__tests__/fixtures/permission-test.yaml index a82ef62..6ddae0a 100644 --- a/packages/core/src/__tests__/fixtures/permission-test.yaml +++ b/packages/core/src/__tests__/fixtures/permission-test.yaml @@ -34,9 +34,11 @@ workflows: task: 'Verify you have read access. Check RELAY_AGENT_TOKEN is set.' verification: type: exit_code + value: '0' - name: write-step agent: writer dependsOn: [check-env] task: 'Verify you can write to src/tests/. Check RELAY_AGENT_TOKEN is set.' verification: type: exit_code + value: '0' diff --git a/packages/core/src/__tests__/step-executor.test.ts b/packages/core/src/__tests__/step-executor.test.ts index fe1d8d6..d7c2fa3 100644 --- a/packages/core/src/__tests__/step-executor.test.ts +++ b/packages/core/src/__tests__/step-executor.test.ts @@ -94,6 +94,47 @@ describe('StepExecutor — deterministic steps', () => { const result = await executor.executeOne(step, new Map()); expect(result.status).toBe('completed'); }); + + it('processSpawner path runs exit_code verification before accepting terminal success', async () => { + const executor = createExecutor({ + processSpawner: mockSpawner({ + spawnShell: vi.fn(async () => ({ output: 'claim already taken', exitCode: 78 })), + }), + }); + const step = makeStep({ + command: 'claim-work', + failOnError: false, + verification: { type: 'exit_code', value: '0' }, + ...({ terminalSuccessExitCodes: [78] } as Partial), + }); + + const result = await executor.executeOne(step, new Map()); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('recorded exit code "78" did not match "0"'); + }); + + it('injected executeStep path runs exit_code verification before accepting terminal success', async () => { + const executor = createExecutor({ + executeStep: vi.fn(async () => ({ + status: 'completed', + output: 'claim already taken', + exitCode: 78, + completionReason: 'completed_early_exit' as any, + })), + }); + const step = makeStep({ + command: 'claim-work', + verification: { type: 'exit_code', value: '0' }, + ...({ terminalSuccessExitCodes: [78] } as Partial), + }); + + const result = await executor.executeOne(step, new Map()); + + expect(result.status).toBe('failed'); + expect(result.completionReason).toBe('failed_verification'); + expect(result.error).toContain('recorded exit code "78" did not match "0"'); + }); }); // ── 2. Non-interactive agent step ──────────────────────────────────────────── diff --git a/packages/core/src/__tests__/verification.test.ts b/packages/core/src/__tests__/verification.test.ts index 935451a..2267a15 100644 --- a/packages/core/src/__tests__/verification.test.ts +++ b/packages/core/src/__tests__/verification.test.ts @@ -44,18 +44,25 @@ describe('verification logic', () => { vi.clearAllMocks(); }); - // 1. exit_code — pass on exit 0 (implicit success) + // 1. exit_code — compare the recorded exit against the expected value describe('exit_code', () => { - it('should pass when agent exited successfully (exit 0 implicit)', () => { - const result = run({ type: 'exit_code', value: '0' }, 'some output'); + it('should pass when the recorded exit code matches', () => { + const result = run({ type: 'exit_code', value: '0' }, 'some output', 'test-step', { + exitCode: 0, + }); expect(result.passed).toBe(true); expect(result.completionReason).toBe('completed_verified'); }); - it('should still pass for non-zero value (exit_code is implicitly satisfied)', () => { - // per existing logic, exit_code case is a no-op — always passes if we reach it - const result = run({ type: 'exit_code', value: '1' }, 'output'); - expect(result.passed).toBe(true); + it('should fail when the recorded exit code does not match', () => { + expect(() => + run({ type: 'exit_code', value: '0' }, 'output', 'test-step', { exitCode: 78 }) + ).toThrow('recorded exit code "78" did not match "0"'); + + const missing = run({ type: 'exit_code', value: '0' }, 'output', 'test-step', { + allowFailure: true, + }); + expect(missing.passed).toBe(false); }); }); @@ -227,6 +234,7 @@ describe('verification logic', () => { it('should log legacy marker message when completionMarkerFound is false', () => { const result = run({ type: 'exit_code', value: '0' }, 'output', 'my-step', { completionMarkerFound: false, + exitCode: 0, }); expect(result.passed).toBe(true); expect(noopSideEffects.log).toHaveBeenCalledWith( diff --git a/packages/core/src/__tests__/workflow-runner.test.ts b/packages/core/src/__tests__/workflow-runner.test.ts index 0c336da..23d597e 100644 --- a/packages/core/src/__tests__/workflow-runner.test.ts +++ b/packages/core/src/__tests__/workflow-runner.test.ts @@ -68,6 +68,7 @@ vi.mock('@relaycast/sdk', () => ({ let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>; let waitForIdleFn: (ms?: number) => Promise<'idle' | 'timeout' | 'exited'>; let mockSpawnOutputs: string[] = []; +let mockSpawnExitCodes: Array = []; const mockHarnessDriverSpawn = vi.fn(async () => mockRelayInstance); // Spawned-agent handle shaped like harness-driver's SpawnedAgentHandle, but @@ -76,7 +77,7 @@ function makeMockHandle(name: string) { return { 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 })), waitForIdle: (ms?: number) => waitForIdleFn(ms).then((reason) => ({ reason })), @@ -317,6 +318,7 @@ describe('WorkflowRunner', () => { waitForExitFn = vi.fn().mockResolvedValue('exited'); waitForIdleFn = vi.fn().mockImplementation(() => never()); mockSpawnOutputs = []; + mockSpawnExitCodes = []; mockHarnessDriverSpawn.mockImplementation(async () => mockRelayInstance); mockRelayInstance.spawnPty.mockImplementation(defaultSpawnPtyImplementation); eventListeners.clear(); @@ -1005,6 +1007,48 @@ agents: expect(run.status, run.error).toBe('completed'); }); + it('WorkflowRunner deterministic executor path rejects terminal success when exit_code verification disagrees', async () => { + const executeDeterministicStep = vi.fn(async () => ({ + output: 'claim already taken', + exitCode: 78, + })); + runner = new WorkflowRunner({ + db, + workspaceId: 'ws-test', + executor: { + executeAgentStep: vi.fn(async () => ''), + executeDeterministicStep, + }, + }); + + const run = await runner.execute( + makeConfig({ + errorHandling: { strategy: 'fail-fast' }, + agents: [], + workflows: [ + { + name: 'default', + steps: [ + { + name: 'claim-gate', + type: 'deterministic', + command: 'claim-work', + failOnError: false, + verification: { type: 'exit_code', value: '0' }, + terminalSuccessExitCodes: [78], + } as any, + ], + }, + ], + }), + 'default' + ); + + expect(executeDeterministicStep).toHaveBeenCalledTimes(1); + expect(run.status).toBe('failed'); + expect(run.error).toContain('recorded exit code "78" did not match "0"'); + }); + it('repairs a failed deterministic gate with a workflow agent before retrying', async () => { const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'relay-deterministic-repair-')); const stepDir = path.join(tmpDir, 'step-cwd'); @@ -1140,6 +1184,7 @@ agents: it('should apply verification fallback for self-owned interactive steps', async () => { mockSpawnOutputs = ['LEAD_DONE\n', 'REVIEW_DECISION: APPROVE\nREVIEW_REASON: verified\n']; + mockSpawnExitCodes = [0]; const run = await runner.execute( makeConfig({ @@ -1237,6 +1282,7 @@ agents: try { mockSpawnOutputs = ['LEAD_DONE\n']; + mockSpawnExitCodes = [0]; const run = await runner.execute( makeConfig({ @@ -1281,6 +1327,7 @@ agents: it('should pass canonical bypass args to interactive codex PTY spawns', async () => { mockSpawnOutputs = ['LEAD_DONE\n', 'REVIEW_DECISION: APPROVE\nREVIEW_REASON: verified\n']; + mockSpawnExitCodes = [0]; const run = await runner.execute( makeConfig({ @@ -1441,6 +1488,10 @@ agents: it('should not double release the worker when the owner fails after worker completion', async () => { const workerRelease = vi.fn().mockResolvedValue(undefined); const ownerRelease = vi.fn().mockResolvedValue(undefined); + let markWorkerReleased!: () => void; + const workerReleasedSignal = new Promise((resolve) => { + markWorkerReleased = resolve; + }); mockRelayInstance.spawnPty.mockImplementation( async ({ name, task }: { name: string; task?: string }) => { @@ -1461,7 +1512,10 @@ agents: // WorkflowAgentHandle destructures `reason`, so raw strings would // map to `undefined` and the timeout would go undetected. waitForExit: vi.fn().mockImplementation(async () => { - await Promise.resolve(); + // Model the test's stated ordering explicitly: the worker's + // completion promise settles before the owner timeout fires. + await workerReleasedSignal; + await new Promise((resolve) => setTimeout(resolve, 0)); return { reason: 'timeout' }; }), waitForIdle: vi.fn().mockResolvedValue({ reason: 'timeout' }), @@ -1476,6 +1530,7 @@ agents: exitSignal: undefined, waitForExit: vi.fn().mockImplementation(async () => { await workerRelease(); + markWorkerReleased(); return { reason: 'released' }; }), waitForIdle: vi.fn().mockImplementation(() => never()), diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index c077d01..90421aa 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -4669,7 +4669,9 @@ export class WorkflowRunner { { exitCode: executorResult.exitCode } ); const verificationResult = step.verification - ? this.runVerification(step.verification, output, step.name) + ? this.runVerification(step.verification, output, step.name, undefined, { + exitCode: executorResult.exitCode, + }) : undefined; return { output, @@ -4780,7 +4782,9 @@ export class WorkflowRunner { ); const verificationResult = step.verification - ? this.runVerification(step.verification, output, step.name) + ? this.runVerification(step.verification, output, step.name, undefined, { + exitCode: lastExitCode, + }) : undefined; lastCommandOutput = [commandStdout || output, commandStderr].filter(Boolean).join('\n'); @@ -5785,7 +5789,8 @@ export class WorkflowRunner { step.verification, specialistOutput, step.name, - promptTaskText + promptTaskText, + { exitCode: this.getStepCompletionEvidence(step.name)?.process.exitCode } ); completionReason = verificationResult.completionReason; } @@ -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 }; @@ -6959,7 +6965,7 @@ export class WorkflowRunner { specialistOutput, step.name, verificationTaskText, - { allowFailure: true } + { allowFailure: true, exitCode: evidence?.process.exitCode } ); if (!verificationResult.passed) return null; } @@ -7947,7 +7953,7 @@ export class WorkflowRunner { ptyOutput, step.name, preparedTask.promptTaskText, - { allowFailure: true } + { allowFailure: true, exitCode: agent?.exitCode } ); if (verificationResult.passed) { this.log(`[${step.name}] Agent timed out but verification passed — treating as complete`); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 9a62e48..5972c7c 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -477,7 +477,7 @@ export interface VerificationCheck { /** * Type-specific value: * - output_contains: token that must appear in the step's output - * - exit_code: expected exit code (currently informational) + * - exit_code: expected recorded process exit code * - file_exists: path that must exist (relative to cwd or absolute) * - custom: shell command to execute, or `regex:` against output * - pr_url: optional `/` qualifier to require the discovered diff --git a/packages/core/src/step-executor.ts b/packages/core/src/step-executor.ts index da254fe..403fe21 100644 --- a/packages/core/src/step-executor.ts +++ b/packages/core/src/step-executor.ts @@ -407,15 +407,24 @@ export class StepExecutor { error: state.row.error ?? result?.error, }; } + const output = result?.output ?? ''; + const status = result?.status ?? 'completed'; + const verificationResult = + status === 'completed' && step.verification + ? this.runVerification(step.verification, output, step.name, undefined, { + allowFailure: true, + exitCode: result?.exitCode, + }) + : undefined; return this.completeStep(step, state, { - status: result?.status ?? 'completed', - output: result?.output ?? '', + status: verificationResult?.passed === false ? 'failed' : status, + output, exitCode: result?.exitCode, exitSignal: result?.exitSignal, - completionReason: result?.completionReason, + completionReason: verificationResult?.completionReason ?? result?.completionReason, retries: result?.retries ?? state.row.retryCount, duration: result?.duration ?? 0, - error: result?.error, + error: verificationResult?.error ?? result?.error, }); } @@ -539,12 +548,19 @@ export class StepExecutor { }; } + const verificationResult = step.verification + ? this.runVerification(step.verification, output, step.name, undefined, { + exitCode: spawnResult.exitCode, + }) + : undefined; + return { status: 'completed' as const, output, exitCode: spawnResult.exitCode, exitSignal: spawnResult.exitSignal, retries: attempt, + completionReason: verificationResult?.completionReason, }; }, }); diff --git a/packages/core/src/verification.ts b/packages/core/src/verification.ts index de9e16d..d20696f 100644 --- a/packages/core/src/verification.ts +++ b/packages/core/src/verification.ts @@ -48,6 +48,8 @@ export interface VerificationOptions { allowFailure?: boolean; completionMarkerFound?: boolean; cwd?: string; + /** Exit code recorded for the process being verified. */ + exitCode?: number; } export class WorkflowCompletionError extends Error { @@ -124,8 +126,11 @@ export function runVerification( } case 'exit_code': - if (!checkExitCode(check.value)) { - return fail(`Verification failed for "${stepName}": exit code did not match "${check.value}"`); + if (!checkExitCode(check.value, options.exitCode)) { + return fail( + `Verification failed for "${stepName}": recorded exit code ` + + `"${options.exitCode ?? 'unavailable'}" did not match "${check.value}"` + ); } break; @@ -223,10 +228,9 @@ export function stripInjectedTaskEcho(output: string, injectedTaskText?: string) return output; } -export function checkExitCode(_expectedExitCode: string): boolean { - // Existing runner semantics treat process success as established before this - // 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; } export function checkOutputContains(output: string, token: string, injectedTaskText?: string): boolean { From 2283c608015de7a21da62c416869af175487c1c0 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 23:07:29 +0200 Subject: [PATCH 2/6] test(core): cover terminal-success exits Capture the issue #38 contract before implementation so the new opt-in behavior, the scheduler barrier, and unchanged failure semantics are proven independently. Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 08f4e75c-099d-49ad-b98e-26a37e4cf2c9 --- .../src/__tests__/terminal-success.test.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/core/src/__tests__/terminal-success.test.ts diff --git a/packages/core/src/__tests__/terminal-success.test.ts b/packages/core/src/__tests__/terminal-success.test.ts new file mode 100644 index 0000000..7fbe489 --- /dev/null +++ b/packages/core/src/__tests__/terminal-success.test.ts @@ -0,0 +1,162 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { WorkflowRunner, type WorkflowDb } from '../runner.js'; +import type { + RelayYamlConfig, + WorkflowRunRow, + WorkflowStep, + WorkflowStepRow, +} from '../types.js'; + +function makeDb(): WorkflowDb { + const runs = new Map(); + const steps = new Map(); + + return { + insertRun: vi.fn(async (run) => runs.set(run.id, { ...run })), + updateRun: vi.fn(async (id, patch) => { + const run = runs.get(id); + if (run) runs.set(id, { ...run, ...patch }); + }), + getRun: vi.fn(async (id) => { + const run = runs.get(id); + return run ? { ...run } : null; + }), + insertStep: vi.fn(async (step) => steps.set(step.id, { ...step })), + updateStep: vi.fn(async (id, patch) => { + const step = steps.get(id); + if (step) steps.set(id, { ...step, ...patch }); + }), + getStepsByRunId: vi.fn(async (runId) => + [...steps.values()].filter((step) => step.runId === runId).map((step) => ({ ...step })) + ), + }; +} + +function terminalStep(exitCode: number, configuredCodes: number[]): WorkflowStep { + return { + name: 'gate', + type: 'deterministic', + command: `exit ${exitCode}`, + terminalSuccessExitCodes: configuredCodes, + } as WorkflowStep; +} + +function configWithSteps(steps: WorkflowStep[]): RelayYamlConfig { + return { + version: '1', + name: 'terminal-success-test', + swarm: { pattern: 'dag' }, + agents: [], + workflows: [{ name: 'default', steps }], + errorHandling: { strategy: 'fail-fast' }, + trajectories: false, + }; +} + +describe('terminal-success deterministic exits', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('ends the run as completed_early and skips all not-started work', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-success-')); + tempDirs.push(cwd); + const db = makeDb(); + const runner = new WorkflowRunner({ db, cwd, workspaceId: 'ws-test' }); + const events: string[] = []; + runner.on((event) => events.push(event.type)); + + const run = await runner.execute( + configWithSteps([ + terminalStep(78, [78]), + { + name: 'ready-sibling', + type: 'deterministic', + command: 'touch ready-sibling-ran', + }, + { + name: 'downstream', + type: 'deterministic', + command: 'touch downstream-ran', + dependsOn: ['gate'], + }, + ]), + 'default' + ); + + expect(run.status).toBe('completed_early'); + expect(events).toContain('run:completed-early'); + expect(events).not.toContain('run:completed'); + expect(events).not.toContain('run:failed'); + expect(existsSync(path.join(cwd, 'ready-sibling-ran'))).toBe(false); + expect(existsSync(path.join(cwd, 'downstream-ran'))).toBe(false); + + const steps = await db.getStepsByRunId(run.id); + expect(steps.find((step) => step.stepName === 'gate')).toMatchObject({ + status: 'completed', + completionReason: 'completed_early_exit', + }); + expect(steps.find((step) => step.stepName === 'ready-sibling')?.status).toBe('skipped'); + expect(steps.find((step) => step.stepName === 'downstream')?.status).toBe('skipped'); + }); + + it('still fails for an unlisted non-zero exit code', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-failure-')); + tempDirs.push(cwd); + const db = makeDb(); + const runner = new WorkflowRunner({ db, cwd, workspaceId: 'ws-test' }); + + const run = await runner.execute( + configWithSteps([ + terminalStep(79, [78]), + { + name: 'downstream', + type: 'deterministic', + command: 'touch downstream-ran', + dependsOn: ['gate'], + }, + ]), + 'default' + ); + + expect(run.status).toBe('failed'); + expect(existsSync(path.join(cwd, 'downstream-ran'))).toBe(false); + const steps = await db.getStepsByRunId(run.id); + expect(steps.find((step) => step.stepName === 'gate')?.status).toBe('failed'); + expect(steps.find((step) => step.stepName === 'downstream')?.status).toBe('skipped'); + }); + + it('does not reinterpret exit 78 without the opt-in field', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-compat-')); + tempDirs.push(cwd); + const db = makeDb(); + const runner = new WorkflowRunner({ db, cwd, workspaceId: 'ws-test' }); + + const run = await runner.execute( + configWithSteps([ + { name: 'gate', type: 'deterministic', command: 'exit 78' }, + { + name: 'downstream', + type: 'deterministic', + command: 'touch downstream-ran', + dependsOn: ['gate'], + }, + ]), + 'default' + ); + + expect(run.status).toBe('failed'); + expect(existsSync(path.join(cwd, 'downstream-ran'))).toBe(false); + const steps = await db.getStepsByRunId(run.id); + expect(steps.find((step) => step.stepName === 'gate')?.status).toBe('failed'); + expect(steps.find((step) => step.stepName === 'downstream')?.status).toBe('skipped'); + }); +}); From d795f5d9de28f1555bedbc7a2dfd38eff7275e50 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 23:18:57 +0200 Subject: [PATCH 3/6] feat(core): add terminal-success workflow exits Scheduled workflows need an explicit no-op outcome that does not turn genuine failures into success. Add opt-in terminalSuccessExitCodes, scheduler-barrier handling, distinct completed_early reporting, and skipped remaining steps while preserving existing exit-code semantics. Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 08f4e75c-099d-49ad-b98e-26a37e4cf2c9 --- packages/cli/src/cli.ts | 8 ++ .../__tests__/builder-deterministic.test.ts | 2 + .../src/__tests__/channel-messenger.test.ts | 25 ++++ .../core/src/__tests__/step-executor.test.ts | 21 ++++ .../src/__tests__/swarm-coordinator.test.ts | 12 ++ .../src/__tests__/terminal-success.test.ts | 85 ++++++++++++- .../src/__tests__/yaml-validation.test.ts | 2 + packages/core/src/builder.ts | 5 + packages/core/src/channel-messenger.ts | 28 +++++ packages/core/src/cli.ts | 12 ++ packages/core/src/cloud-runner.ts | 1 + packages/core/src/coordinator.ts | 8 ++ packages/core/src/custom-steps.ts | 27 +++++ packages/core/src/default-logger.ts | 4 + packages/core/src/listr-renderer.ts | 6 + packages/core/src/run.ts | 2 +- packages/core/src/runner.ts | 114 ++++++++++++++++-- packages/core/src/schema.json | 22 ++++ packages/core/src/schema.ts | 5 + packages/core/src/step-executor.ts | 61 +++++++++- packages/core/src/types.ts | 4 + 21 files changed, 441 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 46287a0..8e95b67 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -84,6 +84,10 @@ program console.log('\nWorkflow resumed and completed successfully.'); return; } + if (result.status === 'completed_early') { + console.log('\nWorkflow resumed and completed early; remaining steps were skipped.'); + return; + } if (result.status === 'needs_human') { console.log(`\nWorkflow needs human input${result.error ? `: ${result.error}` : ''}`); return; @@ -115,6 +119,10 @@ program console.log('\nWorkflow completed successfully.'); return; } + if (result.status === 'completed_early') { + console.log('\nWorkflow completed early; remaining steps were skipped.'); + return; + } if (result.status === 'needs_human') { console.log(`\nWorkflow needs human input${result.error ? `: ${result.error}` : ''}`); return; diff --git a/packages/core/src/__tests__/builder-deterministic.test.ts b/packages/core/src/__tests__/builder-deterministic.test.ts index 74f119d..80caa21 100644 --- a/packages/core/src/__tests__/builder-deterministic.test.ts +++ b/packages/core/src/__tests__/builder-deterministic.test.ts @@ -46,6 +46,7 @@ describe('deterministic/worktree steps in builder', () => { command: 'npm test', captureOutput: true, failOnError: false, + terminalSuccessExitCodes: [78], dependsOn: ['build'], timeoutMs: 30000, }) @@ -55,6 +56,7 @@ describe('deterministic/worktree steps in builder', () => { const step = config.workflows![0].steps[0]; expect(step.captureOutput).toBe(true); expect(step.failOnError).toBe(false); + expect(step.terminalSuccessExitCodes).toEqual([78]); expect(step.dependsOn).toEqual(['build']); expect(step.timeoutMs).toBe(30000); }); diff --git a/packages/core/src/__tests__/channel-messenger.test.ts b/packages/core/src/__tests__/channel-messenger.test.ts index 0b39775..8d9f3ab 100644 --- a/packages/core/src/__tests__/channel-messenger.test.ts +++ b/packages/core/src/__tests__/channel-messenger.test.ts @@ -164,6 +164,31 @@ describe('ChannelMessenger', () => { }); }); + describe('postEarlyCompletionReport', () => { + it('keeps an early completion distinct from a normal completion', () => { + const postSpy = vi.fn(); + const messenger = new ChannelMessenger({ postFn: postSpy }); + const outcomes = [ + { name: 'gate', agent: 'deterministic', status: 'completed', attempts: 1 }, + { name: 'work', agent: 'worker', status: 'skipped', attempts: 0 }, + ]; + + messenger.postEarlyCompletionReport( + 'scheduled-workflow', + outcomes as any, + 'gate', + 'Nothing to do', + 0.9 + ); + + const text = postSpy.mock.calls[0][0]; + expect(text).toContain('Completed Early'); + expect(text).toContain('Terminal step: **gate**'); + expect(text).toContain('terminal-success exit'); + expect(text).toContain('work** — skipped'); + }); + }); + describe('postFailureReport', () => { it('formats a failure report with error details', () => { const postSpy = vi.fn(); diff --git a/packages/core/src/__tests__/step-executor.test.ts b/packages/core/src/__tests__/step-executor.test.ts index d7c2fa3..ff648d1 100644 --- a/packages/core/src/__tests__/step-executor.test.ts +++ b/packages/core/src/__tests__/step-executor.test.ts @@ -424,6 +424,27 @@ describe('ProcessSpawner — buildCommand', () => { // ── 9. executeAll — DAG orchestration ──────────────────────────────────────── describe('StepExecutor — executeAll', () => { + it('runs terminal-capable steps as barriers and skips remaining work on a listed exit', async () => { + const spawnShell = vi.fn(async (command: string) => + command === 'gate' ? { output: 'no work', exitCode: 78 } : { output: 'unexpected', exitCode: 0 } + ); + const executor = createExecutor({ processSpawner: mockSpawner({ spawnShell }) }); + const steps = [ + makeStep({ name: 'ready-sibling', command: 'sibling' }), + makeStep({ name: 'gate', command: 'gate', terminalSuccessExitCodes: [78] }), + ]; + + const results = await executor.executeAll(steps, new Map()); + + expect(spawnShell).toHaveBeenCalledTimes(1); + expect(spawnShell).toHaveBeenCalledWith('gate', expect.any(Object)); + expect(results.get('gate')).toMatchObject({ + status: 'completed', + completionReason: 'completed_early_exit', + }); + expect(results.get('ready-sibling')?.status).toBe('skipped'); + }); + it('executes steps in dependency order', async () => { const order: string[] = []; const executor = createExecutor({ diff --git a/packages/core/src/__tests__/swarm-coordinator.test.ts b/packages/core/src/__tests__/swarm-coordinator.test.ts index e5a7c6a..7f38adb 100644 --- a/packages/core/src/__tests__/swarm-coordinator.test.ts +++ b/packages/core/src/__tests__/swarm-coordinator.test.ts @@ -736,6 +736,18 @@ describe('SwarmCoordinator', () => { expect(spy).toHaveBeenCalledWith(run); }); + it('should transition a run to completed_early and emit the distinct event', async () => { + const run = makeRunRow({ status: 'completed_early' }); + vi.mocked(db.query).mockResolvedValueOnce({ rows: [run] }); + + const spy = vi.fn(); + coordinator.on('run:completed_early', spy); + + const result = await coordinator.completeRunEarly('run_test_1'); + expect(result.status).toBe('completed_early'); + expect(spy).toHaveBeenCalledWith(run); + }); + it('should throw when run not found', async () => { vi.mocked(db.query).mockResolvedValueOnce({ rows: [] }); await expect(coordinator.completeRun('nonexistent')).rejects.toThrow('not found'); diff --git a/packages/core/src/__tests__/terminal-success.test.ts b/packages/core/src/__tests__/terminal-success.test.ts index 7fbe489..7571972 100644 --- a/packages/core/src/__tests__/terminal-success.test.ts +++ b/packages/core/src/__tests__/terminal-success.test.ts @@ -42,7 +42,7 @@ function terminalStep(exitCode: number, configuredCodes: number[]): WorkflowStep type: 'deterministic', command: `exit ${exitCode}`, terminalSuccessExitCodes: configuredCodes, - } as WorkflowStep; + }; } function configWithSteps(steps: WorkflowStep[]): RelayYamlConfig { @@ -66,6 +66,19 @@ describe('terminal-success deterministic exits', () => { } }); + it.each([ + { codes: [] as number[], message: 'non-empty array' }, + { codes: [78, 78], message: 'must not contain duplicates' }, + { codes: [256], message: 'from 0 to 255' }, + ])('rejects invalid terminal-success exit code lists: $codes', async ({ codes, message }) => { + const db = makeDb(); + const runner = new WorkflowRunner({ db, workspaceId: 'ws-test' }); + + await expect( + runner.execute(configWithSteps([terminalStep(0, codes)]), 'default') + ).rejects.toThrow(message); + }); + it('ends the run as completed_early and skips all not-started work', async () => { const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-success-')); tempDirs.push(cwd); @@ -76,12 +89,12 @@ describe('terminal-success deterministic exits', () => { const run = await runner.execute( configWithSteps([ - terminalStep(78, [78]), { name: 'ready-sibling', type: 'deterministic', command: 'touch ready-sibling-ran', }, + terminalStep(78, [78]), { name: 'downstream', type: 'deterministic', @@ -134,6 +147,74 @@ describe('terminal-success deterministic exits', () => { expect(steps.find((step) => step.stepName === 'downstream')?.status).toBe('skipped'); }); + it('does not let terminal-success classification hide a verification failure', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-verification-')); + tempDirs.push(cwd); + const db = makeDb(); + const runner = new WorkflowRunner({ db, cwd, workspaceId: 'ws-test' }); + const gate = { + ...terminalStep(78, [78]), + command: 'printf no-work; exit 78', + verification: { type: 'output_contains', value: 'verified' } as const, + }; + + const run = await runner.execute(configWithSteps([gate]), 'default'); + + expect(run.status).toBe('failed'); + expect(run.error).toContain('output does not contain "verified"'); + const steps = await db.getStepsByRunId(run.id); + expect(steps[0]).toMatchObject({ + status: 'failed', + completionReason: 'failed_verification', + }); + }); + + it('continues normally when a terminal-capable gate exits with an unlisted success code', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-continue-')); + tempDirs.push(cwd); + const db = makeDb(); + const runner = new WorkflowRunner({ db, cwd, workspaceId: 'ws-test' }); + + const run = await runner.execute( + configWithSteps([ + terminalStep(0, [78]), + { + name: 'ready-sibling', + type: 'deterministic', + command: 'touch ready-sibling-ran', + }, + ]), + 'default' + ); + + expect(run.status).toBe('completed'); + expect(existsSync(path.join(cwd, 'ready-sibling-ran'))).toBe(true); + }); + + it('honors terminal-success exits returned by an injected executor', async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-executor-')); + tempDirs.push(cwd); + const db = makeDb(); + const executeDeterministicStep = vi.fn(async () => ({ output: 'nothing to do', exitCode: 78 })); + const runner = new WorkflowRunner({ + db, + cwd, + workspaceId: 'ws-test', + executor: { executeDeterministicStep }, + }); + + const run = await runner.execute( + configWithSteps([ + terminalStep(78, [78]), + { name: 'ready-sibling', type: 'deterministic', command: 'echo should-not-run' }, + ]), + 'default' + ); + + expect(run.status).toBe('completed_early'); + expect(executeDeterministicStep).toHaveBeenCalledTimes(1); + }); + it('does not reinterpret exit 78 without the opt-in field', async () => { const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-compat-')); tempDirs.push(cwd); diff --git a/packages/core/src/__tests__/yaml-validation.test.ts b/packages/core/src/__tests__/yaml-validation.test.ts index bfa2302..a602c89 100644 --- a/packages/core/src/__tests__/yaml-validation.test.ts +++ b/packages/core/src/__tests__/yaml-validation.test.ts @@ -576,6 +576,7 @@ describe('Custom Step Resolution', () => { ], command: 'docker build -t {{image}} -f {{dockerfile}} .', captureOutput: true, + terminalSuccessExitCodes: [78], }, ], [ @@ -597,6 +598,7 @@ describe('Custom Step Resolution', () => { expect(resolved.type).toBe('deterministic'); expect(resolved.command).toBe('docker build -t myapp:latest -f Dockerfile .'); expect(resolved.captureOutput).toBe(true); + expect(resolved.terminalSuccessExitCodes).toEqual([78]); }); it('should resolve custom step with all params', () => { diff --git a/packages/core/src/builder.ts b/packages/core/src/builder.ts index 51d7161..9318420 100644 --- a/packages/core/src/builder.ts +++ b/packages/core/src/builder.ts @@ -121,6 +121,8 @@ export interface DeterministicStepOptions { captureOutput?: boolean; /** Fail if command exit code is non-zero. Default: true. */ failOnError?: boolean; + /** Exit codes that end the workflow with the distinct completed_early status. */ + terminalSuccessExitCodes?: number[]; dependsOn?: string[]; verification?: VerificationCheck; timeoutMs?: number; @@ -423,6 +425,9 @@ export class WorkflowBuilder { if (options.cwd !== undefined) step.cwd = options.cwd; if (options.captureOutput !== undefined) step.captureOutput = options.captureOutput; if (options.failOnError !== undefined) step.failOnError = options.failOnError; + if (options.terminalSuccessExitCodes !== undefined) { + step.terminalSuccessExitCodes = [...options.terminalSuccessExitCodes]; + } if (options.dependsOn !== undefined) step.dependsOn = options.dependsOn; if (options.verification !== undefined) step.verification = options.verification; if (options.timeoutMs !== undefined) step.timeoutMs = options.timeoutMs; diff --git a/packages/core/src/channel-messenger.ts b/packages/core/src/channel-messenger.ts index 721ab8d..cfc0cae 100644 --- a/packages/core/src/channel-messenger.ts +++ b/packages/core/src/channel-messenger.ts @@ -359,6 +359,34 @@ export class ChannelMessenger { this.postFn?.(lines.join('\n')); } + postEarlyCompletionReport( + workflowName: string, + outcomes: StepOutcome[], + terminalStepName: string, + summary: string, + confidence: number + ): void { + const completed = outcomes.filter((outcome) => outcome.status === 'completed'); + const skipped = outcomes.filter((outcome) => outcome.status === 'skipped'); + + const lines: string[] = [ + `## Workflow **${workflowName}** — Completed Early`, + '', + summary, + `Terminal step: **${terminalStepName}**`, + `Confidence: ${Math.round(confidence * 100)}%`, + '', + '### Steps', + ...completed.map( + (outcome) => + `- **${outcome.name}** (${outcome.agent}) — passed${outcome.name === terminalStepName ? ' (terminal-success exit)' : ''}` + ), + ...skipped.map((outcome) => `- **${outcome.name}** — skipped`), + ]; + + this.postFn?.(lines.join('\n')); + } + postFailureReport(workflowName: string, outcomes: StepOutcome[], errorMsg: string): void { const completed = outcomes.filter((outcome) => outcome.status === 'completed'); const failed = outcomes.filter((outcome) => outcome.status === 'failed'); diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 17a5b2d..044a534 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -284,6 +284,12 @@ async function runWithListr( break; } + case 'run:completed-early': { + setHeader(chalk.cyan(`Workflow completed early at ${event.stepName}`)); + resolveWorkflow(); + break; + } + case 'run:failed': { setHeader(chalk.red(`Workflow failed: ${event.error}`)); rejectWorkflow(new Error(event.error ?? 'Workflow failed')); @@ -417,6 +423,9 @@ async function main(): Promise { if (result.status === 'completed') { console.log(chalk.green('\nWorkflow completed successfully.')); process.exit(0); + } else if (result.status === 'completed_early') { + console.log(chalk.cyan('\nWorkflow completed early; remaining steps were skipped.')); + process.exit(0); } else if (result.status === 'needs_human') { console.log(chalk.yellow(`\nWorkflow needs human input${result.error ? `: ${result.error}` : ''}`)); process.exit(0); @@ -478,6 +487,9 @@ async function main(): Promise { if (result.status === 'completed') { console.log(chalk.green('\nWorkflow completed successfully.')); process.exit(0); + } else if (result.status === 'completed_early') { + console.log(chalk.cyan('\nWorkflow completed early; remaining steps were skipped.')); + process.exit(0); } else if (result.status === 'needs_human') { console.log(chalk.yellow(`\nWorkflow needs human input${result.error ? `: ${result.error}` : ''}`)); process.exit(0); diff --git a/packages/core/src/cloud-runner.ts b/packages/core/src/cloud-runner.ts index 1a7cc6b..e5d3fd1 100644 --- a/packages/core/src/cloud-runner.ts +++ b/packages/core/src/cloud-runner.ts @@ -43,6 +43,7 @@ export async function runInCloud(config: RelayYamlConfig, options: CloudRunOptio if ( data.status === 'completed' || + data.status === 'completed_early' || data.status === 'failed' || data.status === 'cancelled' || data.status === 'needs_human' diff --git a/packages/core/src/coordinator.ts b/packages/core/src/coordinator.ts index 090536c..0f5f56c 100644 --- a/packages/core/src/coordinator.ts +++ b/packages/core/src/coordinator.ts @@ -170,6 +170,7 @@ export interface SwarmCoordinatorEvents { 'run:created': (run: WorkflowRunRow) => void; 'run:started': (run: WorkflowRunRow) => void; 'run:completed': (run: WorkflowRunRow) => void; + 'run:completed_early': (run: WorkflowRunRow) => void; 'run:failed': (run: WorkflowRunRow) => void; 'run:cancelled': (run: WorkflowRunRow) => void; 'run:needs_human': (run: WorkflowRunRow) => void; @@ -566,6 +567,13 @@ export class SwarmCoordinator extends EventEmitter { return this.transitionRun(runId, 'completed', undefined, stateSnapshot); } + async completeRunEarly( + runId: string, + stateSnapshot?: Record, + ): Promise { + return this.transitionRun(runId, 'completed_early', undefined, stateSnapshot); + } + async failRun(runId: string, error: string): Promise { return this.transitionRun(runId, 'failed', error); } diff --git a/packages/core/src/custom-steps.ts b/packages/core/src/custom-steps.ts index 698e230..ba1a2f2 100644 --- a/packages/core/src/custom-steps.ts +++ b/packages/core/src/custom-steps.ts @@ -184,6 +184,30 @@ function validateCustomStepDefinition( ); } + if (stepDef.terminalSuccessExitCodes !== undefined) { + if ( + stepType !== 'deterministic' || + !Array.isArray(stepDef.terminalSuccessExitCodes) || + stepDef.terminalSuccessExitCodes.length === 0 || + stepDef.terminalSuccessExitCodes.some( + (code) => !Number.isInteger(code) || (code as number) < 0 || (code as number) > 255 + ) + ) { + throw new CustomStepsParseError( + `Invalid terminalSuccessExitCodes for step "${name}"`, + 'terminalSuccessExitCodes must be a non-empty array of unique integer exit codes from 0 to 255 on a deterministic step', + filePath + ); + } + if (new Set(stepDef.terminalSuccessExitCodes).size !== stepDef.terminalSuccessExitCodes.length) { + throw new CustomStepsParseError( + `Invalid terminalSuccessExitCodes for step "${name}"`, + 'terminalSuccessExitCodes must not contain duplicate exit codes', + filePath + ); + } + } + if (stepType === 'worktree' && !hasBranch) { throw new CustomStepsParseError( `Worktree step "${name}" is missing "branch"`, @@ -415,6 +439,9 @@ export function resolveCustomStep( resolvedStep.command = interpolate(customDef.command); resolvedStep.failOnError = customDef.failOnError; resolvedStep.captureOutput = customDef.captureOutput; + resolvedStep.terminalSuccessExitCodes = customDef.terminalSuccessExitCodes + ? [...customDef.terminalSuccessExitCodes] + : undefined; } else if (stepType === 'worktree') { resolvedStep.branch = interpolate(customDef.branch); resolvedStep.baseBranch = interpolate(customDef.baseBranch); diff --git a/packages/core/src/default-logger.ts b/packages/core/src/default-logger.ts index fb60704..66a3d1b 100644 --- a/packages/core/src/default-logger.ts +++ b/packages/core/src/default-logger.ts @@ -26,6 +26,10 @@ export function createDefaultEventLogger(level: LogLevel = 'normal'): WorkflowEv console.log(chalk.green(`[workflow] completed`)); break; + case 'run:completed-early': + console.log(chalk.cyan(`[workflow] completed early at ${event.stepName}`)); + break; + case 'run:failed': console.log(chalk.red(`[workflow] FAILED: ${event.error}`)); break; diff --git a/packages/core/src/listr-renderer.ts b/packages/core/src/listr-renderer.ts index 3faabd3..6a44d10 100644 --- a/packages/core/src/listr-renderer.ts +++ b/packages/core/src/listr-renderer.ts @@ -235,6 +235,12 @@ export function createWorkflowRenderer(): WorkflowRenderer { break; } + case 'run:completed-early': { + setHeader(chalk.cyan(`Workflow completed early at ${event.stepName}`)); + resolveWorkflow(); + break; + } + case 'run:failed': { setHeader(chalk.red(`Workflow failed: ${event.error ?? 'unknown error'}`)); rejectWorkflow(new Error(event.error ?? 'Workflow failed')); diff --git a/packages/core/src/run.ts b/packages/core/src/run.ts index b01276e..71bdbd8 100644 --- a/packages/core/src/run.ts +++ b/packages/core/src/run.ts @@ -39,7 +39,7 @@ export interface RunWorkflowOptions { * import { runWorkflow } from "@relayflows/core"; * * const result = await runWorkflow("workflows/daytona-migration.yaml"); - * console.log(result.status); // "completed" | "failed" | "cancelled" | "needs_human" + * console.log(result.status); // "completed" | "completed_early" | "failed" | "cancelled" | "needs_human" * ``` */ export async function runWorkflow( diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index 90421aa..45525d1 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -353,6 +353,7 @@ interface CompletionDecisionResult { export type WorkflowEvent = | { type: 'run:started'; runId: string } | { type: 'run:completed'; runId: string } + | { type: 'run:completed-early'; runId: string; stepName: string } | { type: 'run:failed'; runId: string; error: string } | { type: 'run:needs-human'; runId: string; error: string; stepName: string } | { type: 'run:cancelled'; runId: string } @@ -3552,11 +3553,34 @@ export class WorkflowRunner { throw new Error(`${source}: each step must have a string "name" field`); } + if (s.terminalSuccessExitCodes !== undefined && s.type !== 'deterministic') { + throw new Error( + `${source}: terminalSuccessExitCodes is only valid on deterministic steps ("${s.name}")` + ); + } + // Deterministic steps require type and command if (s.type === 'deterministic') { if (typeof s.command !== 'string') { throw new Error(`${source}: deterministic step "${s.name}" must have a "command" field`); } + if (s.terminalSuccessExitCodes !== undefined) { + const codes = s.terminalSuccessExitCodes; + if ( + !Array.isArray(codes) || + codes.length === 0 || + codes.some((code) => !Number.isInteger(code) || (code as number) < 0 || (code as number) > 255) + ) { + throw new Error( + `${source}: deterministic step "${s.name}" terminalSuccessExitCodes must be a non-empty array of integer exit codes from 0 to 255` + ); + } + if (new Set(codes).size !== codes.length) { + throw new Error( + `${source}: deterministic step "${s.name}" terminalSuccessExitCodes must not contain duplicates` + ); + } + } } else if (s.type === 'worktree') { if (typeof s.branch !== 'string' || s.branch.trim().length === 0) { throw new Error(`${source}: worktree step "${s.name}" must have a "branch" string field`); @@ -3820,6 +3844,8 @@ export class WorkflowRunner { }, markDownstreamSkipped: async (failedStepName) => this.markDownstreamSkipped(failedStepName, workflow.steps, stepStates, runId), + markRemainingSkipped: async (terminalStepName) => + this.markRemainingStepsSkipped(terminalStepName, workflow.steps, stepStates, runId), buildCompletionMode: (stepName, completionReason) => completionReason ? this.buildStepCompletionDecision(stepName, completionReason)?.mode : undefined, }; @@ -4195,7 +4221,37 @@ export class WorkflowRunner { (s) => s.row.status === 'completed' || s.row.status === 'skipped' ); - if (allCompleted) { + const completedEarlyStep = [...stepStates.values()].find( + (state) => state.row.completionReason === 'completed_early_exit' + ); + const hasFailedStep = [...stepStates.values()].some((state) => state.row.status === 'failed'); + + if (completedEarlyStep && !hasFailedStep) { + const terminalStepName = completedEarlyStep.row.stepName; + this.log(`Workflow completed early at "${terminalStepName}"`); + await this.updateRunStatus(runId, 'completed_early'); + this.emit({ type: 'run:completed-early', runId, stepName: terminalStepName }); + + const outcomes = this.collectOutcomes(stepStates, workflow.steps); + const skippedCount = outcomes.filter((outcome) => outcome.status === 'skipped').length; + const summary = + `Workflow completed early at "${terminalStepName}"; ` + + `${skippedCount} not-started step${skippedCount === 1 ? ' was' : 's were'} skipped.`; + const confidence = this.trajectory.computeConfidence(outcomes); + await this.trajectory.complete(summary, confidence, { + learnings: this.trajectory.extractLearnings(outcomes), + challenges: this.trajectory.extractChallenges(outcomes), + }); + + this.channelMessenger.postEarlyCompletionReport( + workflow.name, + outcomes, + terminalStepName, + summary, + confidence + ); + this.logRunSummary(workflow.name, outcomes, runId, 'completed_early'); + } else if (allCompleted) { this.log('Workflow completed successfully'); await this.updateRunStatus(runId, 'completed'); this.emit({ type: 'run:completed', runId }); @@ -4649,8 +4705,9 @@ export class WorkflowRunner { lastExitCode = executorResult.exitCode; lastExitSignal = undefined; lastCommandOutput = executorResult.output; + const terminalSuccess = this.isTerminalSuccessExitCode(step, executorResult.exitCode); const failOnError = step.failOnError !== false; - if (failOnError && executorResult.exitCode !== 0) { + if (!terminalSuccess && failOnError && executorResult.exitCode !== 0) { this.log(`[${step.name}] Command failed (exit code ${executorResult.exitCode})`); if (executorResult.output) { this.log(`[${step.name}] Output:\n${executorResult.output}`); @@ -4675,7 +4732,9 @@ export class WorkflowRunner { : undefined; return { output, - completionReason: verificationResult?.completionReason, + completionReason: terminalSuccess + ? ('completed_early_exit' as const) + : verificationResult?.completionReason, }; } @@ -4744,8 +4803,9 @@ export class WorkflowRunner { lastExitSignal = signal ?? undefined; lastCommandOutput = [stdout, stderr].filter(Boolean).join('\n'); + const terminalSuccess = this.isTerminalSuccessExitCode(step, code ?? undefined); const failOnError = step.failOnError !== false; - if (failOnError && code !== 0 && code !== null) { + if (!terminalSuccess && failOnError && code !== 0 && code !== null) { this.log(`[${step.name}] Command failed (exit code ${code})`); if (stdout) { this.log(`[${step.name}] stdout:\n${stdout}`); @@ -4790,7 +4850,9 @@ export class WorkflowRunner { return { output, - completionReason: verificationResult?.completionReason, + completionReason: this.isTerminalSuccessExitCode(step, lastExitCode) + ? ('completed_early_exit' as const) + : verificationResult?.completionReason, }; }, toCompletionResult: ({ output, completionReason }, attempt) => ({ @@ -4830,6 +4892,10 @@ export class WorkflowRunner { } } + private isTerminalSuccessExitCode(step: WorkflowStep, exitCode: number | undefined): boolean { + return exitCode !== undefined && step.terminalSuccessExitCodes?.includes(exitCode) === true; + } + private resolveWorkflowRepairAgent( step: WorkflowStep, stepStates: Map, @@ -10833,7 +10899,13 @@ export class WorkflowRunner { status, updatedAt: new Date().toISOString(), }; - if (status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'needs_human') { + if ( + status === 'completed' || + status === 'completed_early' || + status === 'failed' || + status === 'cancelled' || + status === 'needs_human' + ) { patch.completedAt = new Date().toISOString(); } if (error) { @@ -10970,6 +11042,32 @@ export class WorkflowRunner { } } + private async markRemainingStepsSkipped( + terminalStepName: string, + allSteps: WorkflowStep[], + stepStates: Map, + runId: string + ): Promise { + for (const step of allSteps) { + const state = stepStates.get(step.name); + if (!state || state.row.status !== 'pending') continue; + + const completedAt = new Date().toISOString(); + state.row.status = 'skipped'; + state.row.completedAt = completedAt; + await this.db.updateStep(state.row.id, { + status: 'skipped', + completedAt, + updatedAt: completedAt, + }); + this.emit({ type: 'step:skipped', runId, stepName: step.name }); + const reason = `Workflow completed early at "${terminalStepName}"`; + this.postToChannel(`**[${step.name}]** Skipped — ${reason}`); + await this.trajectory?.stepSkipped(step, reason); + await this.trajectory?.decide(`Whether to skip ${step.name}`, 'skip', reason); + } + } + // ── startFrom dependency resolution ───────────────────────────────── /** @@ -11100,7 +11198,7 @@ export class WorkflowRunner { workflowName: string, outcomes: StepOutcome[], runId: string, - status: Extract = 'failed' + status: Extract = 'failed' ): void { const completed = outcomes.filter((o) => o.status === 'completed'); const failed = outcomes.filter((o) => o.status === 'failed'); @@ -11108,6 +11206,8 @@ export class WorkflowRunner { const statusLabel = status === 'completed' ? chalk.green('COMPLETED') + : status === 'completed_early' + ? chalk.cyan('COMPLETED EARLY') : status === 'needs_human' ? chalk.yellow('NEEDS HUMAN') : chalk.red('FAILED'); diff --git a/packages/core/src/schema.json b/packages/core/src/schema.json index 88a227c..50122b4 100644 --- a/packages/core/src/schema.json +++ b/packages/core/src/schema.json @@ -889,6 +889,17 @@ "default": true, "description": "Capture stdout as step output for downstream steps" }, + "terminalSuccessExitCodes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "description": "Explicit exit codes that end the workflow with completed_early and skip remaining work" + }, "workdir": { "type": "string", "description": "Sets this step's working directory to a named entry from the top-level paths array." @@ -1155,6 +1166,17 @@ "default": true, "description": "Capture stdout as step output" }, + "terminalSuccessExitCodes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "description": "Explicit exit codes that end the workflow with completed_early and skip remaining work" + }, "timeoutMs": { "type": "integer", "minimum": 0, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 5972c7c..6ae21eb 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -417,6 +417,11 @@ export interface WorkflowStep { failOnError?: boolean; /** Capture stdout as step output for downstream steps. Default: true. */ captureOutput?: boolean; + /** + * Explicit exit codes that end the workflow successfully without running + * remaining work. The run is reported as completed_early, not completed. + */ + terminalSuccessExitCodes?: number[]; // ── Integration step fields ──────────────────────────────────────────────── /** Integration name: 'github', 'linear', 'slack' (required for integration steps). */ diff --git a/packages/core/src/step-executor.ts b/packages/core/src/step-executor.ts index 403fe21..5c39020 100644 --- a/packages/core/src/step-executor.ts +++ b/packages/core/src/step-executor.ts @@ -86,6 +86,7 @@ export interface StepExecutorDeps { onBeginTrack?: (steps: WorkflowStep[]) => Promise | void; onConverge?: (steps: WorkflowStep[], outcomes: StepOutcome[]) => Promise | void; markDownstreamSkipped?: (failedStepName: string) => Promise; + markRemainingSkipped?: (terminalStepName: string) => Promise; buildCompletionMode?: ( stepName: string, completionReason?: WorkflowStepCompletionReason @@ -286,8 +287,15 @@ export class StepExecutor { this.deps.checkAborted?.(); await this.deps.waitIfPaused?.(); - const readySteps = this.findReady(steps, states); - if (readySteps.length === 0) break; + const allReadySteps = this.findReady(steps, states); + if (allReadySteps.length === 0) break; + + // A step that can terminate the workflow is a scheduling barrier. Run it + // alone so a root no-op/claim gate cannot race other ready work. + const terminalBarrier = allReadySteps.find( + (step) => step.type === 'deterministic' && (step.terminalSuccessExitCodes?.length ?? 0) > 0 + ); + const readySteps = terminalBarrier ? [terminalBarrier] : allReadySteps; const schedules = readySteps.map((step, index) => this.scheduleStep(step, { @@ -310,6 +318,7 @@ export class StepExecutor { ); const batchOutcomes: StepOutcome[] = []; + let completedEarlyAt: string | undefined; for (let index = 0; index < settled.length; index += 1) { const settledResult = settled[index]; @@ -341,6 +350,9 @@ export class StepExecutor { throw new Error(`Step "${step.name}" failed: ${result.error ?? 'unknown error'}`); } } + if (result.completionReason === 'completed_early_exit') { + completedEarlyAt = step.name; + } continue; } @@ -381,6 +393,15 @@ export class StepExecutor { if (readySteps.length > 1 && batchOutcomes.length > 0) { await this.deps.onConverge?.(readySteps, batchOutcomes); } + + if (completedEarlyAt) { + if (this.deps.markRemainingSkipped) { + await this.deps.markRemainingSkipped(completedEarlyAt); + } else { + await this.skipRemainingSteps(states, results); + } + break; + } } return results; @@ -527,8 +548,13 @@ export class StepExecutor { return spawner.spawnInteractive(agent, task, { cwd: this.deps.cwd, timeoutMs: step.timeoutMs }); }, toCompletionResult: (spawnResult, attempt) => { + const terminalSuccess = + step.type === 'deterministic' && + spawnResult.exitCode !== undefined && + step.terminalSuccessExitCodes?.includes(spawnResult.exitCode) === true; const failOnError = step.failOnError !== false; const failed = + !terminalSuccess && failOnError && ((spawnResult.exitCode ?? 0) !== 0 || (spawnResult.exitCode === undefined && spawnResult.exitSignal !== undefined)); @@ -560,12 +586,41 @@ export class StepExecutor { exitCode: spawnResult.exitCode, exitSignal: spawnResult.exitSignal, retries: attempt, - completionReason: verificationResult?.completionReason, + // Verification has already run and thrown on failure, so a terminal + // exit here has satisfied its contract. It then wins the + // classification: the run ended early, which is the more specific + // fact than "verified". + completionReason: terminalSuccess + ? 'completed_early_exit' + : verificationResult?.completionReason, }; }, }); } + private async skipRemainingSteps( + states: Map, + results: Map + ): Promise { + for (const [stepName, state] of states) { + if (state.row.status !== 'pending') continue; + const completedAt = new Date().toISOString(); + state.row.status = 'skipped'; + state.row.completedAt = completedAt; + await this.deps.persistStepRow?.(state.row.id, { + status: 'skipped', + completedAt, + updatedAt: completedAt, + }); + results.set(stepName, { + status: 'skipped', + output: '', + duration: 0, + retries: state.row.retryCount, + }); + } + } + private createEphemeralStates(steps: WorkflowStep[]): Map { return new Map(steps.map((step) => [step.name, this.createEphemeralState(step)])); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0879a8e..eacdcb6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -322,6 +322,8 @@ export interface CustomStepDefinition { failOnError?: boolean; /** Capture stdout as step output. Default: true. */ captureOutput?: boolean; + /** Exit codes that end the workflow with the distinct completed_early status. */ + terminalSuccessExitCodes?: number[]; /** Timeout in milliseconds. */ timeoutMs?: number; /** Human-readable description of this step. */ @@ -565,6 +567,7 @@ export type WorkflowRunStatus = | 'pending' | 'running' | 'completed' + | 'completed_early' | 'failed' | 'cancelled' | 'needs_human'; @@ -601,6 +604,7 @@ export type WorkflowStepCompletionReason = | 'completed_by_owner_decision' | 'completed_by_evidence' | 'completed_by_process_exit' + | 'completed_early_exit' | 'retry_requested_by_owner' | 'failed_verification' | 'failed_verification_with_diagnostic' From 0885365a6575e0a94e969b92662fd3ff1b78edb3 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 23:20:35 +0200 Subject: [PATCH 4/6] docs: document terminal-success exit semantics Explain the explicit opt-in surface, completed_early reporting, scheduler barrier, and the additive status compatibility impact for published-package consumers. Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 08f4e75c-099d-49ad-b98e-26a37e4cf2c9 --- README.md | 23 ++++++++++++++++++++++- docs/reference.mdx | 15 +++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 65c5f47..872fcda 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ const result = await workflow("ship-feature") }) .run(); -console.log(result.status); // "completed" | "failed" | "cancelled" | "needs_human" +console.log(result.status); // "completed" | "completed_early" | "failed" | "cancelled" | "needs_human" ``` ### Python @@ -453,6 +453,27 @@ steps: timeoutMs: 300000 # 5 minute timeout ``` +### Successful early termination + +A deterministic gate can explicitly declare exit codes that mean “there is no work to do.” A matching code ends the run with the distinct `completed_early` status and skips every step that has not started: + +```yaml +steps: + - name: claim-work + type: deterministic + command: node bin/claim-work.mjs + terminalSuccessExitCodes: [78] + + - name: process-claim + agent: worker + task: Process the claimed work + dependsOn: [claim-work] +``` + +Terminal-capable gates are scheduling barriers, so other ready work does not race the gate. The triggering step is `completed` with completion reason `completed_early_exit`; remaining steps are `skipped`, and the CLI exits 0 while clearly reporting **COMPLETED EARLY**. Verification still applies, so a verification failure remains a real failure. + +This behavior is opt-in. Without `terminalSuccessExitCodes`, exit 78 and every other non-zero exit retain their existing failure behavior. The new `completed_early` run status is an additive public API value: consumers with exhaustive status switches, strict validators, database constraints, or terminal-status polling must handle it separately from `completed`. + ### Workflow-Level The `onError` field on a workflow controls what happens when a step fails: diff --git a/docs/reference.mdx b/docs/reference.mdx index fbb929e..72f4e5c 100644 --- a/docs/reference.mdx +++ b/docs/reference.mdx @@ -155,6 +155,21 @@ workflows: - **Deterministic step**: shell command step with `type: deterministic` - **Worktree step**: git worktree management step with `type: worktree` +### Terminal-success deterministic steps + +Use `terminalSuccessExitCodes` when a deterministic gate can correctly decide that the run has no work to perform: + +```yaml +- name: claim-work + type: deterministic + command: node bin/claim-work.mjs + terminalSuccessExitCodes: [78] +``` + +A listed exit code completes the gate, skips all not-started steps, and ends the run as `completed_early`. The gate acts as a scheduling barrier so other ready steps do not race it. The CLI treats `completed_early` as a successful process outcome while preserving the distinct status in results and events. + +The option is explicit: unlisted codes and workflows without `terminalSuccessExitCodes` keep the existing failure behavior. Verification failures also continue to fail. Consumers that exhaustively handle run statuses must add `completed_early` as a distinct terminal status. + ## Completion Signals The runner can complete a step from several signals: From 30b2a0449fee27a39cb0b754d15d11029149a5d5 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 25 Aug 2026 08:50:58 +0200 Subject: [PATCH 5/6] test(core): lock the terminal-success / artifact-contract precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #39 was written before #41 landed. The two are textually disjoint but answer the same question — when is a step finished — so the interaction is asserted rather than assumed. Both directions now have a test: - a terminal exit CANNOT hide a failed verification (pre-existing test) - a passing verification does NOT downgrade a terminal exit back to a normal completion; the run still ends early (new) Co-Authored-By: Claude Opus 5 Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 568eca24-d8dd-4afd-810d-3d804a51d100 Session-Id: 08f4e75c-099d-49ad-b98e-26a37e4cf2c9 --- .../src/__tests__/terminal-success.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/core/src/__tests__/terminal-success.test.ts b/packages/core/src/__tests__/terminal-success.test.ts index 7571972..668ffb8 100644 --- a/packages/core/src/__tests__/terminal-success.test.ts +++ b/packages/core/src/__tests__/terminal-success.test.ts @@ -169,6 +169,38 @@ describe('terminal-success deterministic exits', () => { }); }); + it('records completed_early_exit even when verification also passes', async () => { + // Locks the precedence between #39 (terminal-success exits) and the + // artifact-contract judgement: a passing verification does NOT downgrade a + // terminal exit back to a normal completion, and the run still ends early. + // Deliberate, and asserted here so it cannot change silently. + const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-verified-')); + tempDirs.push(cwd); + const db = makeDb(); + const runner = new WorkflowRunner({ db, cwd, workspaceId: 'ws-test' }); + const gate = { + ...terminalStep(78, [78]), + command: 'printf verified; exit 78', + verification: { type: 'output_contains', value: 'verified' } as const, + }; + + const run = await runner.execute( + configWithSteps([ + gate, + { name: 'never-runs', type: 'deterministic', command: 'touch never-runs-ran' }, + ]), + 'default' + ); + + expect(run.status).toBe('completed_early'); + const steps = await db.getStepsByRunId(run.id); + expect(steps.find((s) => s.stepName === 'gate')).toMatchObject({ + status: 'completed', + completionReason: 'completed_early_exit', + }); + expect(existsSync(path.join(cwd, 'never-runs-ran'))).toBe(false); + }); + it('continues normally when a terminal-capable gate exits with an unlisted success code', async () => { const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-continue-')); tempDirs.push(cwd); From 3cb262636b6115d0161ad3497f3882651ef2e4fd Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 25 Aug 2026 12:15:53 +0200 Subject: [PATCH 6/6] fix(core): a resumed run that finished everything is not completed_early completionReason 'completed_early_exit' is stored on the terminal step and outlives the run it described. On resume the reset returns failed steps to pending; when they succeed, nothing is left skipped, yet the run still reported completed_early with '0 not-started steps were skipped'. Derive the claim from the current step states instead: no skipped step means no work was cut short. Found by coderabbitai on #39. Their conclusion was right; the mechanism they described (the reset returning skipped siblings to pending) was not - the reset only touches failed steps. The reachable path needs the failure and the gate in different scheduling waves, since a ready terminal gate is a barrier that runs alone. The regression test builds exactly that shape. Session-Id: 08f4e75c-099d-49ad-b98e-26a37e4cf2c9 Session-Id: 08f4e75c-099d-49ad-b98e-26a37e4cf2c9 --- .../src/__tests__/terminal-success.test.ts | 49 +++++++++++++++++++ packages/core/src/runner.ts | 13 ++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/terminal-success.test.ts b/packages/core/src/__tests__/terminal-success.test.ts index 668ffb8..5be961a 100644 --- a/packages/core/src/__tests__/terminal-success.test.ts +++ b/packages/core/src/__tests__/terminal-success.test.ts @@ -247,6 +247,55 @@ describe('terminal-success deterministic exits', () => { expect(executeDeterministicStep).toHaveBeenCalledTimes(1); }); + it('does not report completed_early on a resume where every step finished', async () => { + // Regression: `completionReason: 'completed_early_exit'` is stored on the + // terminal step and outlives the run it described. On resume the reset + // returns failed steps to pending; if they then succeed, nothing is left + // skipped and the run must NOT still claim it ended early. + // + // Reaching this needs the failure and the gate in different scheduling + // waves: a ready terminal gate is a barrier that runs alone (see + // step-executor `terminalBarrier`), so a gate ready in wave 1 would skip + // the flaky step instead of letting it fail. Gating it behind `opener` + // puts the failure in wave 1 and the early exit in wave 2. + const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-resume-')); + tempDirs.push(cwd); + const db = makeDb(); + const runner = new WorkflowRunner({ db, cwd, workspaceId: 'ws-test' }); + + const config: RelayYamlConfig = { + ...configWithSteps([ + { + name: 'flaky', + type: 'deterministic', + command: 'test -f retry-marker || { touch retry-marker; exit 1; }', + }, + { name: 'opener', type: 'deterministic', command: 'true' }, + { ...terminalStep(78, [78]), dependsOn: ['opener'] }, + ]), + errorHandling: { strategy: 'continue' }, + }; + + const first = await runner.execute(config, 'default'); + expect(first.status).toBe('failed'); + const firstSteps = await db.getStepsByRunId(first.id); + expect(firstSteps.find((s) => s.stepName === 'flaky')?.status).toBe('failed'); + expect(firstSteps.find((s) => s.stepName === 'gate')).toMatchObject({ + status: 'completed', + completionReason: 'completed_early_exit', + }); + + const resumed = await runner.resume(first.id, undefined, config); + + const resumedSteps = await db.getStepsByRunId(first.id); + expect(resumedSteps.every((s) => s.status === 'completed')).toBe(true); + expect(resumedSteps.find((s) => s.stepName === 'gate')?.completionReason).toBe( + 'completed_early_exit' + ); + // Nothing was skipped, so the run completed — it did not complete early. + expect(resumed.status).toBe('completed'); + }, 60000); + it('does not reinterpret exit 78 without the opt-in field', async () => { const cwd = mkdtempSync(path.join(os.tmpdir(), 'relayflows-terminal-compat-')); tempDirs.push(cwd); diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index 45525d1..c04ae7f 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -4225,8 +4225,17 @@ export class WorkflowRunner { (state) => state.row.completionReason === 'completed_early_exit' ); const hasFailedStep = [...stepStates.values()].some((state) => state.row.status === 'failed'); - - if (completedEarlyStep && !hasFailedStep) { + // "Completed early" is a claim about work that did NOT run, so it has to + // be derived from the current step states, not from a stored reason that + // outlives the condition it described. A resumed run is the case that + // separates them: the terminal step keeps completionReason + // 'completed_early_exit' forever, but the resume reset returns failed + // steps to pending and they can then all succeed, leaving nothing + // skipped. Reporting "completed early ... 0 steps were skipped" there + // contradicts itself. No skipped step means nothing was cut short. + const hasSkippedStep = [...stepStates.values()].some((state) => state.row.status === 'skipped'); + + if (completedEarlyStep && !hasFailedStep && hasSkippedStep) { const terminalStepName = completedEarlyStep.row.stepName; this.log(`Workflow completed early at "${terminalStepName}"`); await this.updateRunStatus(runId, 'completed_early');