Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions docs/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/__tests__/builder-deterministic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe('deterministic/worktree steps in builder', () => {
command: 'npm test',
captureOutput: true,
failOnError: false,
terminalSuccessExitCodes: [78],
dependsOn: ['build'],
timeoutMs: 30000,
})
Expand All @@ -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);
});
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/__tests__/channel-messenger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/__tests__/step-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/__tests__/swarm-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading