Skip to content

Commit e0b078a

Browse files
committed
fix(workflow): supervise cancellation and stale workers
1 parent 5528337 commit e0b078a

5 files changed

Lines changed: 47 additions & 50 deletions

File tree

src/server.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { createWorkspaceStore } from "./workspace-store.js";
4848
import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
4949
import { summarizeLocalAgentProfile } from "./local-agent-profiles.js";
5050
import { registerWorkflowTools } from "./workflow-tools.js";
51+
import { startWorkflowReaper } from "./workflow-lifecycle.js";
5152
import { createWorkflowStore } from "./workflow-store.js";
5253
import { loadActiveWorkflowSummaries } from "./workflow-ui.js";
5354
import {
@@ -1659,6 +1660,15 @@ export function createServer(config = loadConfig()): RunningServer {
16591660
const localAgentProviders = config.subagents
16601661
? getLocalAgentProviderAvailabilitySnapshot()
16611662
: [];
1663+
const workflowReaper = config.subagents
1664+
? startWorkflowReaper(config, {
1665+
onError: (error) => {
1666+
logEvent(config.logging, "warn", "workflow_reaper_failed", {
1667+
error: error instanceof Error ? error.message : String(error),
1668+
});
1669+
},
1670+
})
1671+
: undefined;
16621672

16631673
const logSessionCloseResults = (
16641674
reason: "idle_timeout" | "server_shutdown",
@@ -1846,6 +1856,7 @@ export function createServer(config = loadConfig()): RunningServer {
18461856
close: () => {
18471857
closePromise ??= (async () => {
18481858
clearInterval(sessionCleanupTimer);
1859+
workflowReaper?.close();
18491860
const results = await transports.closeAll();
18501861
logSessionCloseResults("server_shutdown", results);
18511862
processSessions.shutdown();

src/workflow-cli.ts

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ import {
2121
resolveWorkflowScriptFromPathOrNameResult,
2222
} from "./workflow-files.js";
2323
import { createWorkflowReplay } from "./workflow-replay.js";
24+
import {
25+
cancelWorkflowRun,
26+
reapStaleWorkflows,
27+
} from "./workflow-lifecycle.js";
2428
import { parseWorkflowScript } from "./workflow-script.js";
2529
import { createWorkflowStore, type WorkflowStore } from "./workflow-store.js";
2630
import {
27-
WORKFLOW_CANCEL_HARD_MS,
2831
WORKFLOW_HEARTBEAT_MS,
2932
WORKFLOW_LIMITS,
3033
resolveWorkflowConcurrency,
@@ -234,6 +237,7 @@ async function runWorkflowStatus(args: string[], config: ServerConfig): Promise<
234237

235238
const store = createWorkflowStore(config);
236239
try {
240+
reapStaleWorkflows(store);
237241
const runResult = store.getRunResult(runId);
238242
if (runResult.isErr()) throw runResult.error;
239243
const run = runResult.value;
@@ -256,36 +260,8 @@ async function runWorkflowCancel(args: string[], config: ServerConfig): Promise<
256260
if (!runId) throw new Error("Usage: devspace workflow cancel <runId>");
257261
const store = createWorkflowStore(config);
258262
try {
259-
const requested = store.requestCancelResult(runId);
260-
if (requested.isErr()) throw requested.error;
261-
const run = requested.value;
262-
console.log(formatRunLine(run));
263-
if (run.pid && (run.status === "running" || run.status === "starting")) {
264-
try {
265-
process.kill(run.pid, "SIGTERM");
266-
} catch {
267-
// already dead
268-
}
269-
await sleep(WORKFLOW_CANCEL_HARD_MS);
270-
const again = store.getRun(runId);
271-
if (again && (again.status === "running" || again.status === "starting") && again.pid) {
272-
try {
273-
process.kill(-again.pid, "SIGKILL");
274-
} catch {
275-
try {
276-
process.kill(again.pid, "SIGKILL");
277-
} catch {
278-
// gone
279-
}
280-
}
281-
const latest = store.getRun(runId);
282-
if (latest && (latest.status === "running" || latest.status === "starting")) {
283-
const cancelled = store.cancelRunResult(runId, "cancelled (hard kill)");
284-
if (cancelled.isErr()) throw cancelled.error;
285-
}
286-
}
287-
}
288-
console.log(formatRunLine(store.getRun(runId)!));
263+
reapStaleWorkflows(store);
264+
console.log(formatRunLine(await cancelWorkflowRun(store, runId)));
289265
} finally {
290266
store.close();
291267
}
@@ -294,6 +270,7 @@ async function runWorkflowCancel(args: string[], config: ServerConfig): Promise<
294270
async function runWorkflowList(config: ServerConfig): Promise<void> {
295271
const store = createWorkflowStore(config);
296272
try {
273+
reapStaleWorkflows(store);
297274
const runs = store.listRuns(50);
298275
if (runs.length === 0) {
299276
console.log("No workflow runs.");

src/workflow-store.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,25 @@ try {
193193
assert.equal(store.getRun(run3.id)?.errorKind, "heartbeat");
194194
assert.equal(store.listEvents(run3.id).at(-1)?.type, "run_failed");
195195

196+
const runStarting = store.createRun({
197+
name: "never-started",
198+
source: "inline",
199+
scriptPath: join(root, "never.js"),
200+
scriptHash: "never",
201+
workspaceRoot: join(root, "project"),
202+
});
203+
const staleStartingDb = openDatabase(root);
204+
try {
205+
staleStartingDb.sqlite
206+
.prepare(`update workflow_runs set updated_at = ? where id = ?`)
207+
.run(new Date(Date.now() - 120_000).toISOString(), runStarting.id);
208+
} finally {
209+
staleStartingDb.close();
210+
}
211+
const reapedStarting = store.reapStale(60_000);
212+
assert.ok(reapedStarting.some((entry) => entry.id === runStarting.id));
213+
assert.equal(store.getRun(runStarting.id)?.status, "failed");
214+
196215
const run4 = store.createRun({
197216
name: "seq",
198217
source: "inline",

src/workflow-store.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -749,27 +749,26 @@ export class WorkflowStore {
749749
}
750750

751751
/**
752-
* Mark running runs with a dead worker as failed.
753-
* staleBeforeMs: heartbeat older than this AND pid not alive.
752+
* Mark abandoned starting runs and running runs with a dead worker as failed.
753+
* staleBeforeMs: start/update or heartbeat older than this and no live pid.
754754
*/
755755
reapStale(staleBeforeMs = 60_000, nowMs = Date.now()): WorkflowRunRecord[] {
756756
const cutoff = new Date(nowMs - staleBeforeMs).toISOString();
757757
const candidates = this.database.sqlite
758758
.prepare(
759759
`select * from workflow_runs
760-
where status = 'running'
761-
and heartbeat_at is not null
762-
and heartbeat_at < ?`,
760+
where (status = 'running' and heartbeat_at is not null and heartbeat_at < ?)
761+
or (status = 'starting' and updated_at < ?)`,
763762
)
764-
.all(cutoff) as WorkflowRunRow[];
763+
.all(cutoff, cutoff) as WorkflowRunRow[];
765764

766765
const reaped: WorkflowRunRecord[] = [];
767766
for (const row of candidates) {
768767
if (row.pid !== null && isPidAlive(row.pid)) continue;
769768
const latest = this.getRun(row.id);
770-
if (!latest || latest.status !== "running") continue;
769+
if (!latest || (latest.status !== "running" && latest.status !== "starting")) continue;
771770
const failed = this.failRun(row.id, {
772-
error: "worker heartbeat lost",
771+
error: latest.status === "starting" ? "workflow worker failed to start" : "worker heartbeat lost",
773772
errorKind: "heartbeat",
774773
});
775774
if (failed.status === "failed" && failed.errorKind === "heartbeat") {

src/workflow-tools.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "./workflow-types.js";
2222
import { resolveWorkspaceHead } from "./workflow-worktrees.js";
2323
import { spawnWorkflowWorkerFromCli } from "./workflow-cli.js";
24+
import { cancelWorkflowRun } from "./workflow-lifecycle.js";
2425
import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availability.js";
2526
import {
2627
isLocalAgentProvider,
@@ -268,17 +269,7 @@ export function registerWorkflowTools(
268269
async ({ runId }) => {
269270
const store = createWorkflowStore(config);
270271
try {
271-
const requested = store.requestCancelResult(runId);
272-
if (requested.isErr()) throw requested.error;
273-
const run = requested.value;
274-
if (run.pid && (run.status === "running" || run.status === "starting")) {
275-
try {
276-
process.kill(run.pid, "SIGTERM");
277-
} catch {
278-
// already gone
279-
}
280-
}
281-
const latest = store.getRun(runId)!;
272+
const latest = await cancelWorkflowRun(store, runId);
282273
return {
283274
content: [{ type: "text" as const, text: JSON.stringify({ runId, status: latest.status }) }],
284275
structuredContent: { runId, status: latest.status },

0 commit comments

Comments
 (0)