Skip to content
Merged
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
47 changes: 45 additions & 2 deletions packages/cli/src/commands/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2364,6 +2364,7 @@ describe('workflowRunCommand — detach', () => {

const execBefore = (executeWorkflow as ReturnType<typeof mock>).mock.calls.length;
const spawnSpy = spyOn(Bun, 'spawn').mockReturnValue({
pid: 12345,
unref: mock(() => undefined),
} as unknown as ReturnType<typeof Bun.spawn>);
const savedArgv = process.argv;
Expand All @@ -2372,18 +2373,26 @@ describe('workflowRunCommand — detach', () => {
// Capture call data BEFORE mockRestore() — restoring a spy clears its recorded calls.
let spawnCallCount = 0;
let spawnCmd: string[] = [];
let spawnOptions: { cwd: string; cmd: string[] } | undefined;
let spawnOptions:
| { cwd: string; cmd: string[]; detached?: boolean; windowsHide?: boolean }
| undefined;
try {
await workflowRunCommand('/test/path', 'assist', 'hello', { detach: true });
spawnCallCount = spawnSpy.mock.calls.length;
spawnOptions = spawnSpy.mock.calls[0]?.[0] as { cwd: string; cmd: string[] } | undefined;
spawnOptions = spawnSpy.mock.calls[0]?.[0] as
| { cwd: string; cmd: string[]; detached?: boolean; windowsHide?: boolean }
| undefined;
spawnCmd = (spawnOptions?.cmd ?? []).slice();
} finally {
process.argv = savedArgv;
spawnSpy.mockRestore();
}

expect(spawnCallCount).toBe(1);
// The actual Windows fix: the child must be spawned into its own process
// group, or the launching shell's teardown kills it (~1s in).
expect(spawnOptions?.detached).toBe(true);
expect(spawnOptions?.windowsHide).toBe(true);
expect(spawnCmd).not.toContain('--detach');
expect(spawnCmd).toContain('--branch');
expect(spawnCmd).toContain('--conversation-id');
Expand Down Expand Up @@ -2421,6 +2430,7 @@ describe('workflowRunCommand — detach', () => {
});

const spawnSpy = spyOn(Bun, 'spawn').mockReturnValue({
pid: 12345,
unref: mock(() => undefined),
} as unknown as ReturnType<typeof Bun.spawn>);
const savedArgv = process.argv;
Expand Down Expand Up @@ -2452,6 +2462,7 @@ describe('workflowRunCommand — detach', () => {
throw new Error('no home in test');
});
const spawnSpy = spyOn(Bun, 'spawn').mockReturnValue({
pid: 12345,
unref: mock(() => undefined),
} as unknown as ReturnType<typeof Bun.spawn>);
const savedArgv = process.argv;
Expand All @@ -2477,6 +2488,38 @@ describe('workflowRunCommand — detach', () => {
expect(parsed).toMatchObject({ ok: true, action: 'run', detached: true, workflow: 'assist' });
expect(typeof parsed.conversationId).toBe('string');
});

it('throws (no false success ack) when the detached child fails to spawn', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
const paths = await import('@archon/paths');
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
errors: [],
});
(paths.getArchonHome as ReturnType<typeof mock>).mockImplementationOnce(() => {
throw new Error('no home in test');
});
// Node's spawn does not throw synchronously on a bad executable — the only
// synchronous failure signal is an undefined pid.
const spawnSpy = spyOn(Bun, 'spawn').mockReturnValue({
pid: undefined,
unref: mock(() => undefined),
} as unknown as ReturnType<typeof Bun.spawn>);
const savedArgv = process.argv;
process.argv = ['bun', '/abs/cli.ts', 'workflow', 'run', 'assist', 'hello', '--detach'];

try {
await expect(
workflowRunCommand('/test/path', 'assist', 'hello', { detach: true })
).rejects.toThrow(/Failed to start detached workflow child/);
} finally {
process.argv = savedArgv;
spawnSpy.mockRestore();
}
// The success ack must never have been printed.
const logged = consoleSpy.mock.calls.map(call => String(call[0]));
expect(logged).not.toContain("Started 'assist' in the background.");
});
});

describe('buildDetachedRunCmd', () => {
Expand Down
35 changes: 32 additions & 3 deletions packages/cli/src/commands/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from '@archon/paths';
import { join } from 'node:path';
import { mkdirSync, openSync, closeSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { createWorkflowDeps } from '@archon/core/workflows/store-adapter';
import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery';
import { resolveWorkflowName } from '@archon/workflows/router';
Expand Down Expand Up @@ -199,12 +200,34 @@ function spawnDetachedWorkflowRun(
}

try {
const child = Bun.spawn({
cmd,
// Node's spawn with `detached: true` puts the child in its own process
// group so it survives the parent's exit. Bun.spawn + unref() does NOT
// detach on Windows — the child was killed ~1s in (at worktree_creating)
// when the launching shell/console tore down. `detached: true` is the
// standard fix, also used by setup.ts's trySpawn(); if a kill-on-close Job
// Object wrapper ever defeats it, a `start /b` breakaway fallback is the
// next step. `windowsHide` keeps the child headless.
const child = spawn(cmd[0], cmd.slice(1), {
cwd,
env: process.env,
stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
detached: true,
windowsHide: true,
});
// Unlike Bun.spawn, Node's spawn does NOT throw synchronously on a bad
// executable or cwd — the failure arrives as an async 'error' event, which
// would crash the CLI as an uncaught exception without this listener.
child.on('error', (error: Error) => {
getLog().error(
{ err: error, execPath: cmd[0], conversationId },
'cli.detached_run_spawn_failed'
);
});
// pid is set synchronously iff the OS-level spawn succeeded (same check as
// setup.ts's trySpawn) — fail fast instead of acking a run that never started.
if (child.pid === undefined) {
throw new Error(`Failed to start detached workflow child (executable: ${cmd[0]})`);
}
child.unref();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} finally {
// The child inherits its own dup of the log fd; close the parent's copy so a
Expand Down Expand Up @@ -779,7 +802,13 @@ export async function workflowRunCommand(
} else {
console.log(`Started '${workflow.name}' in the background.`);
console.log('Track it with: archon workflow runs');
if (logPath) console.log(`Child output: ${logPath}`);
if (logPath) {
console.log(`Child output: ${logPath}`);
} else {
// Log file couldn't be opened — the child runs with its output discarded,
// so if it dies before creating a run record there will be no trail.
console.warn('Warning: could not open a log file — child output will not be captured.');
}
}
return;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/docs-web/src/content/docs/deployment/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ Do not kill `claude.exe` processes — those are active Claude Code sessions.

See also: [Port Conflicts](/reference/troubleshooting/#port-conflicts) in the troubleshooting guide.

## Sleep During Workflow Runs (Native Windows Only)

While a workflow run is active on native Windows, Archon holds the system awake (via `SetThreadExecutionState`) so Modern Standby cannot freeze the executor mid-run — the display can still turn off, only system sleep is inhibited. This is automatic and best-effort: it releases as soon as the last active run finishes, requires no configuration, and if the OS call is unavailable Archon simply runs without it.

## Tips

- **VS Code Integration**: Install the "Remote - WSL" extension to edit WSL2 files from VS Code
Expand Down
60 changes: 59 additions & 1 deletion packages/workflows/src/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Covers concurrent-run guards, model/provider resolution, and resume logic
* that the inner dag-executor.test.ts cannot reach.
*/
import { describe, it, expect, mock, beforeEach } from 'bun:test';
import { describe, it, expect, mock, beforeEach, spyOn } from 'bun:test';
import { join } from 'path';

// --- Mock logger ---
Expand Down Expand Up @@ -73,6 +73,7 @@ registerBuiltinProviders();

// --- Import after mocks ---
import { executeWorkflow, hydrateResumableRun, resolveProjectPaths } from './executor';
import { keepAwake } from './utils/keep-awake';
import type { WorkflowDeps, IWorkflowPlatform, WorkflowConfig } from './deps';
import type { IWorkflowStore } from './store';
import type { WorkflowDefinition, WorkflowRun } from './schemas';
Expand Down Expand Up @@ -194,6 +195,8 @@ describe('executeWorkflow', () => {
);
expect(result.success).toBe(false);
expect(result.error).toContain('Database error');
// Blocked before the execution window — keep-awake must never have fired.
expect(keepAwake.activeCount()).toBe(0);
});

it('blocks workflow when another is actively running', async () => {
Expand All @@ -219,6 +222,61 @@ describe('executeWorkflow', () => {
expect(result.error).toContain('already active');
});

// -----------------------------------------------------------------------
// Keep-awake pairing (acquire before the run's try, release in its finally)
// -----------------------------------------------------------------------

// Safe to spy on the real singleton: off-Windows its native fn is
// undefined, so acquire/release only touch the refcount.
it('acquires and releases keep-awake exactly once on a successful run', async () => {
const acquireSpy = spyOn(keepAwake, 'acquire');
const releaseSpy = spyOn(keepAwake, 'release');
try {
const result = await executeWorkflow(
makeDeps(),
makePlatform(),
'conv-1',
'/tmp',
makeWorkflow(),
'test message',
'db-conv-1'
);
expect(result.workflowRunId).toBe('run-123');
expect(acquireSpy).toHaveBeenCalledTimes(1);
expect(releaseSpy).toHaveBeenCalledTimes(1);
expect(keepAwake.activeCount()).toBe(0);
} finally {
acquireSpy.mockRestore();
releaseSpy.mockRestore();
}
});

it('still releases keep-awake when the DAG throws an unhandled error', async () => {
mockExecuteDagWorkflow.mockImplementationOnce(async () => {
throw new Error('DAG exploded');
});
const acquireSpy = spyOn(keepAwake, 'acquire');
const releaseSpy = spyOn(keepAwake, 'release');
try {
const result = await executeWorkflow(
makeDeps(),
makePlatform(),
'conv-1',
'/tmp',
makeWorkflow(),
'test message',
'db-conv-1'
);
expect(result.success).toBe(false);
expect(acquireSpy).toHaveBeenCalledTimes(1);
expect(releaseSpy).toHaveBeenCalledTimes(1);
expect(keepAwake.activeCount()).toBe(0);
} finally {
acquireSpy.mockRestore();
releaseSpy.mockRestore();
}
});

it('passes self-id and started_at to the lock query so self is excluded', async () => {
// The guard runs AFTER workflowRun is finalized so we always have
// a self-ID. Without these args, the dispatch's own row would match
Expand Down
14 changes: 13 additions & 1 deletion packages/workflows/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { isLoopNode, isApprovalNode, isScriptNode, isBashNode } from './schemas'
import { executeDagWorkflow } from './dag-executor';
import { logWorkflowStart, logWorkflowError } from './logger';
import { formatDuration, parseDbTimestamp } from './utils/duration';
import { keepAwake } from './utils/keep-awake';
import { getWorkflowEventEmitter } from './event-emitter';
import { isRegisteredProvider, getRegisteredProviders } from '@archon/providers';
import {
Expand Down Expand Up @@ -723,7 +724,14 @@ export async function executeWorkflow(
const userProviderEnv = await resolveUserProviderEnvForWorkflow(deps, userId, artifactsDir);
config.envVars = { ...config.envVars, ...userProviderEnv };

// Wrap execution in try-catch to ensure workflow is marked as failed on any error
// Wrap execution in try-catch to ensure workflow is marked as failed on any error.
//
// Hold a Windows keep-awake request for the executing window (see
// utils/keep-awake.ts for the Modern Standby / mid-run-death rationale and
// best-effort semantics). Placed HERE, not at function top, so the
// early-return validation paths above never leak an unpaired acquire; the
// matching release is the first statement of this try's finally.
keepAwake.acquire();
try {
getLog().info(
{
Expand Down Expand Up @@ -989,6 +997,10 @@ export async function executeWorkflow(
// Return failure result instead of re-throwing
return { success: false, workflowRunId: workflowRun.id, error: err.message };
} finally {
// Release the keep-awake request FIRST — before the backstop DB calls that
// may throw — so it always pairs with the acquire above this try, on every
// exit path (success, thrown error, or backstop failure).
keepAwake.release();
// Defensive backstop: if the workflow run is still 'running' after all
// normal and exceptional code paths, flip it to 'failed' to prevent zombie
// accumulation. Guards against any future code path that exits without
Expand Down
Loading
Loading