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: 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 fe1d8d6..eb27e0e 100644 --- a/packages/core/src/__tests__/step-executor.test.ts +++ b/packages/core/src/__tests__/step-executor.test.ts @@ -383,6 +383,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 new file mode 100644 index 0000000..7571972 --- /dev/null +++ b/packages/core/src/__tests__/terminal-success.test.ts @@ -0,0 +1,243 @@ +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, + }; +} + +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.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); + 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([ + { + name: 'ready-sibling', + type: 'deterministic', + command: 'touch ready-sibling-ran', + }, + terminalStep(78, [78]), + { + 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 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); + 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'); + }); +}); 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 90346a6..eab08f5 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -342,6 +342,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 } @@ -3515,11 +3516,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`); @@ -3783,6 +3807,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, }; @@ -4158,7 +4184,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 }); @@ -4612,8 +4668,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}`); @@ -4636,7 +4693,9 @@ export class WorkflowRunner { : undefined; return { output, - completionReason: verificationResult?.completionReason, + completionReason: terminalSuccess + ? ('completed_early_exit' as const) + : verificationResult?.completionReason, }; } @@ -4705,8 +4764,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}`); @@ -4749,7 +4809,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) => ({ @@ -4789,6 +4851,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, @@ -10437,7 +10503,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) { @@ -10574,6 +10646,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 ───────────────────────────────── /** @@ -10704,7 +10802,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'); @@ -10712,6 +10810,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 129b489..044d76b 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -394,6 +394,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 da254fe..a26fb0b 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; @@ -518,8 +539,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)); @@ -545,11 +571,35 @@ export class StepExecutor { exitCode: spawnResult.exitCode, exitSignal: spawnResult.exitSignal, retries: attempt, + completionReason: terminalSuccess ? 'completed_early_exit' : undefined, }; }, }); } + 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'