Skip to content

Commit 15be40e

Browse files
committed
feat(workflow): add shared lifecycle supervisor
1 parent dfbf5a1 commit 15be40e

3 files changed

Lines changed: 278 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"dev": "node scripts/dev-server.mjs",
2929
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
3030
"start": "node dist/cli.js serve",
31-
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts",
31+
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts",
3232
"typecheck": "tsc -p tsconfig.json --noEmit"
3333
},
3434
"keywords": [],

src/workflow-lifecycle.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import assert from "node:assert/strict";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import {
6+
cancelWorkflowRun,
7+
type WorkflowLifecycleRuntime,
8+
} from "./workflow-lifecycle.js";
9+
import { WorkflowStore } from "./workflow-store.js";
10+
11+
const root = mkdtempSync(join(tmpdir(), "devspace-workflow-lifecycle-test-"));
12+
const store = new WorkflowStore(root);
13+
14+
try {
15+
{
16+
const run = createRunningRun(store, root, "cooperative", 101);
17+
const signals: NodeJS.Signals[] = [];
18+
let slept = false;
19+
const runtime: WorkflowLifecycleRuntime = {
20+
sleep: async () => {
21+
if (!slept) {
22+
slept = true;
23+
store.cancelRun(run.id, "worker observed cancellation");
24+
}
25+
},
26+
terminate: (_pid, signal) => signals.push(signal),
27+
};
28+
const cancelled = await cancelWorkflowRun(store, run.id, {
29+
graceMs: 100,
30+
pollMs: 1,
31+
runtime,
32+
});
33+
assert.equal(cancelled.status, "cancelled");
34+
assert.deepEqual(signals, []);
35+
}
36+
37+
{
38+
const run = createRunningRun(store, root, "hard", 202);
39+
const signals: NodeJS.Signals[] = [];
40+
const runtime: WorkflowLifecycleRuntime = {
41+
sleep: async () => {},
42+
terminate: (_pid, signal) => signals.push(signal),
43+
};
44+
const cancelled = await cancelWorkflowRun(store, run.id, {
45+
graceMs: 0,
46+
termWaitMs: 0,
47+
runtime,
48+
});
49+
assert.equal(cancelled.status, "cancelled");
50+
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
51+
assert.equal(store.listEvents(run.id).at(-1)?.type, "run_cancelled");
52+
}
53+
54+
{
55+
const run = store.createRun({
56+
name: "not-claimed",
57+
source: "inline",
58+
scriptPath: "inline",
59+
scriptHash: "h",
60+
workspaceRoot: root,
61+
});
62+
const signals: NodeJS.Signals[] = [];
63+
const cancelled = await cancelWorkflowRun(store, run.id, {
64+
graceMs: 0,
65+
termWaitMs: 0,
66+
runtime: {
67+
sleep: async () => {},
68+
terminate: (_pid, signal) => signals.push(signal),
69+
},
70+
});
71+
assert.equal(cancelled.status, "cancelled");
72+
assert.deepEqual(signals, []);
73+
}
74+
75+
{
76+
const run = createRunningRun(store, root, "already-done", 303);
77+
store.completeRun(run.id, { callCount: 0 });
78+
const signals: NodeJS.Signals[] = [];
79+
const completed = await cancelWorkflowRun(store, run.id, {
80+
runtime: {
81+
sleep: async () => {},
82+
terminate: (_pid, signal) => signals.push(signal),
83+
},
84+
});
85+
assert.equal(completed.status, "completed");
86+
assert.deepEqual(signals, []);
87+
}
88+
} finally {
89+
store.close();
90+
rmSync(root, { recursive: true, force: true });
91+
}
92+
93+
function createRunningRun(
94+
workflowStore: WorkflowStore,
95+
workspaceRoot: string,
96+
name: string,
97+
pid: number,
98+
) {
99+
const run = workflowStore.createRun({
100+
name,
101+
source: "inline",
102+
scriptPath: "inline",
103+
scriptHash: "h",
104+
workspaceRoot,
105+
});
106+
return workflowStore.claimRun(run.id, pid)!;
107+
}
108+
109+
console.log("workflow-lifecycle.test.ts: ok");

src/workflow-lifecycle.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import type { ServerConfig } from "./config.js";
2+
import { terminateProcessTree } from "./process-platform.js";
3+
import {
4+
createWorkflowStore,
5+
type WorkflowStore,
6+
} from "./workflow-store.js";
7+
import {
8+
WORKFLOW_CANCEL_HARD_MS,
9+
WORKFLOW_HEARTBEAT_MS,
10+
type WorkflowRunRecord,
11+
} from "./workflow-types.js";
12+
13+
const DEFAULT_TERM_WAIT_MS = 1_000;
14+
const DEFAULT_POLL_MS = 100;
15+
const DEFAULT_REAPER_INTERVAL_MS = WORKFLOW_HEARTBEAT_MS * 2;
16+
const DEFAULT_STALE_AFTER_MS = WORKFLOW_HEARTBEAT_MS * 3;
17+
18+
const ACTIVE_STATUSES = new Set(["starting", "running"]);
19+
20+
export interface WorkflowLifecycleRuntime {
21+
sleep(ms: number): Promise<void>;
22+
terminate(pid: number, signal: NodeJS.Signals): void;
23+
}
24+
25+
const defaultRuntime: WorkflowLifecycleRuntime = {
26+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
27+
terminate: (pid, signal) => {
28+
terminateProcessTree(
29+
{
30+
pid,
31+
kill: (requestedSignal = signal) => {
32+
try {
33+
process.kill(pid, requestedSignal);
34+
return true;
35+
} catch (error) {
36+
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
37+
throw error;
38+
}
39+
},
40+
},
41+
signal,
42+
true,
43+
);
44+
},
45+
};
46+
47+
export interface CancelWorkflowRunOptions {
48+
graceMs?: number;
49+
termWaitMs?: number;
50+
pollMs?: number;
51+
runtime?: WorkflowLifecycleRuntime;
52+
}
53+
54+
/**
55+
* Shared CLI/MCP cancellation path: cooperative flag, grace period, process
56+
* tree termination, then an atomic terminal fallback in the journal.
57+
*/
58+
export async function cancelWorkflowRun(
59+
store: WorkflowStore,
60+
runId: string,
61+
options: CancelWorkflowRunOptions = {},
62+
): Promise<WorkflowRunRecord> {
63+
const requested = store.requestCancelResult(runId);
64+
if (requested.isErr()) throw requested.error;
65+
if (!isActive(requested.value)) return requested.value;
66+
67+
const runtime = options.runtime ?? defaultRuntime;
68+
const graceMs = Math.max(0, options.graceMs ?? WORKFLOW_CANCEL_HARD_MS);
69+
const termWaitMs = Math.max(0, options.termWaitMs ?? DEFAULT_TERM_WAIT_MS);
70+
const pollMs = Math.max(1, options.pollMs ?? DEFAULT_POLL_MS);
71+
72+
const cooperative = await waitForTerminal(store, runId, graceMs, pollMs, runtime);
73+
if (cooperative && !isActive(cooperative)) return cooperative;
74+
75+
let current = store.getRun(runId);
76+
if (!current) throw new Error(`Unknown workflow run: ${runId}`);
77+
if (!isActive(current)) return current;
78+
79+
if (current.pid) {
80+
safelyTerminate(runtime, current.pid, "SIGTERM");
81+
const afterTerm = await waitForTerminal(store, runId, termWaitMs, pollMs, runtime);
82+
if (afterTerm && !isActive(afterTerm)) return afterTerm;
83+
84+
current = store.getRun(runId) ?? current;
85+
if (isActive(current) && current.pid) {
86+
safelyTerminate(runtime, current.pid, "SIGKILL");
87+
}
88+
}
89+
90+
const cancelled = store.cancelRunResult(runId, "cancelled by workflow supervisor");
91+
if (cancelled.isErr()) throw cancelled.error;
92+
return cancelled.value;
93+
}
94+
95+
export function reapStaleWorkflows(
96+
store: WorkflowStore,
97+
staleAfterMs = DEFAULT_STALE_AFTER_MS,
98+
): WorkflowRunRecord[] {
99+
return store.reapStale(staleAfterMs);
100+
}
101+
102+
export interface WorkflowReaperHandle {
103+
close(): void;
104+
}
105+
106+
export function startWorkflowReaper(
107+
config: ServerConfig,
108+
options: {
109+
intervalMs?: number;
110+
staleAfterMs?: number;
111+
onError?: (error: unknown) => void;
112+
} = {},
113+
): WorkflowReaperHandle {
114+
const intervalMs = Math.max(1, options.intervalMs ?? DEFAULT_REAPER_INTERVAL_MS);
115+
const staleAfterMs = Math.max(1, options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS);
116+
117+
const tick = (): void => {
118+
const store = createWorkflowStore(config);
119+
try {
120+
reapStaleWorkflows(store, staleAfterMs);
121+
} catch (error) {
122+
options.onError?.(error);
123+
} finally {
124+
store.close();
125+
}
126+
};
127+
128+
tick();
129+
const timer = setInterval(tick, intervalMs);
130+
timer.unref();
131+
return {
132+
close(): void {
133+
clearInterval(timer);
134+
},
135+
};
136+
}
137+
138+
async function waitForTerminal(
139+
store: WorkflowStore,
140+
runId: string,
141+
waitMs: number,
142+
pollMs: number,
143+
runtime: WorkflowLifecycleRuntime,
144+
): Promise<WorkflowRunRecord | undefined> {
145+
const deadline = Date.now() + waitMs;
146+
let current = store.getRun(runId);
147+
while (current && isActive(current) && Date.now() < deadline) {
148+
await runtime.sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
149+
current = store.getRun(runId);
150+
}
151+
return current;
152+
}
153+
154+
function safelyTerminate(
155+
runtime: WorkflowLifecycleRuntime,
156+
pid: number,
157+
signal: NodeJS.Signals,
158+
): void {
159+
try {
160+
runtime.terminate(pid, signal);
161+
} catch (error) {
162+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
163+
}
164+
}
165+
166+
function isActive(run: WorkflowRunRecord): boolean {
167+
return ACTIVE_STATUSES.has(run.status);
168+
}

0 commit comments

Comments
 (0)