Skip to content

Commit 669c990

Browse files
committed
fix(workflows): end stalled writer and reviewer steps after 30 minutes
1 parent dcb3702 commit 669c990

3 files changed

Lines changed: 105 additions & 0 deletions

File tree

‎workflows/reviewer/src/workflow.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { AgentDefinition, BaseEnv } from "@intx/agent/authoring";
22
import { defineWorkflow, step, type Selector } from "@intx/workflow/definition";
33

4+
// No reviewer runs are measured yet, so this reuses the writer's bound: a slow model writing a full
5+
// 32,768-token reply. A stalled step fails instead of holding its run open. No retry: a retried
6+
// attempt would redo work the first may already have mailed.
7+
const STEP_TIMEOUT_MS = 1_800_000;
8+
49
// One bounded review. The document and instructions arrive in the first message.
510
export function createReviewerWorkflow<Env extends BaseEnv>(options: {
611
readonly id: string;
@@ -30,6 +35,7 @@ export function createReviewerWorkflow<Env extends BaseEnv>(options: {
3035
input: { from: "trigger.payload" },
3136
inference: options.inference,
3237
drainBehavior: "wait",
38+
timeout: STEP_TIMEOUT_MS,
3339
triggers: 1,
3440
}),
3541
},
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { expect, jest, test } from "bun:test";
2+
import { createDefaultDirectorRegistry } from "@intx/agent";
3+
import {
4+
createInMemoryBlobSubstrate,
5+
createInMemoryRepoStore,
6+
createInMemoryScheduler,
7+
createNoopDrainController,
8+
runtimeRun,
9+
type StepInvoker,
10+
type WorkflowRuntimeEnv,
11+
} from "@intx/workflow";
12+
import { createObservedSignalChannel } from "@intx/workflow/testing";
13+
import { createWriterAgent } from "./agent.ts";
14+
import { createWriterWorkflow } from "./workflow.ts";
15+
16+
const workflow = createWriterWorkflow({
17+
id: "writer",
18+
address: "writer",
19+
agent: createWriterAgent({
20+
id: "writer",
21+
inference: { provider: "provider", model: "model" },
22+
tools: [],
23+
capabilities: [],
24+
}),
25+
inference: { literal: {} },
26+
});
27+
28+
// A model that never answers: the step ends only when the runtime aborts it, and rejects with the
29+
// abort reason as the real step invoker does.
30+
const stalled: StepInvoker = (request) =>
31+
new Promise((_, reject) => {
32+
request.signal.addEventListener("abort", () => {
33+
reject(request.signal.reason);
34+
});
35+
});
36+
37+
test("a stalled write fails once on its timeout and fails the run", async () => {
38+
const runId = "run-writer";
39+
const repoStore = createInMemoryRepoStore();
40+
const clock = () => new Date();
41+
const env: WorkflowRuntimeEnv = {
42+
repoStore,
43+
scheduler: createInMemoryScheduler({ repoStore, clock }),
44+
signalChannel: createObservedSignalChannel(),
45+
blobs: createInMemoryBlobSubstrate(),
46+
directors: createDefaultDirectorRegistry(),
47+
authorize: async () => ({
48+
effect: "allow",
49+
matchingGrants: [],
50+
resolvedBy: null,
51+
}),
52+
invokeStep: stalled,
53+
spawnChild: () => Promise.reject(new Error("no_children")),
54+
clock,
55+
newId: (prefix) => `${prefix}-${crypto.randomUUID()}`,
56+
drain: createNoopDrainController(workflow),
57+
hasUpstreamSignalResolver: true,
58+
};
59+
jest.useFakeTimers();
60+
let settled = false;
61+
try {
62+
const complete = runtimeRun(workflow, env, {
63+
runId,
64+
triggerPayload: { body: "Write the constraints." },
65+
}).complete.finally(() => {
66+
settled = true;
67+
});
68+
// Let the run reach its stalled step between clock moves, until the step timeout has passed.
69+
for (let minute = 0; minute < 120 && !settled; minute += 1) {
70+
await new Promise<void>((resolve) => queueMicrotask(resolve));
71+
jest.advanceTimersByTime(60_000);
72+
}
73+
expect(settled).toBe(true);
74+
await complete;
75+
} finally {
76+
jest.useRealTimers();
77+
}
78+
79+
const events = await repoStore.read(runId);
80+
const failed = events.filter((event) => event.kind === "StepFailed");
81+
expect(failed).toHaveLength(1);
82+
expect(failed[0]).toMatchObject({
83+
stepId: "write",
84+
attempt: 1,
85+
retriesExhausted: true,
86+
error: {
87+
message: expect.stringMatching(
88+
/^step write exceeded its \d+ ms timeout$/,
89+
),
90+
},
91+
});
92+
expect(events.at(-1)?.kind).toBe("RunFailed");
93+
});

‎workflows/writer/src/workflow.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { AgentDefinition, BaseEnv } from "@intx/agent/authoring";
22
import { defineWorkflow, step, type Selector } from "@intx/workflow/definition";
33

4+
// The longest writer step in stored runs took 276 s, but a slow model writing a full 32,768-token
5+
// reply can take far longer. A stalled step fails instead of holding its run open. No retry: a
6+
// retried attempt would redo work the first may already have filed.
7+
const STEP_TIMEOUT_MS = 1_800_000;
8+
49
// One bounded write. The task, its documents, and the artifact to write arrive in the first message.
510
export function createWriterWorkflow<Env extends BaseEnv>(options: {
611
readonly id: string;
@@ -30,6 +35,7 @@ export function createWriterWorkflow<Env extends BaseEnv>(options: {
3035
input: { from: "trigger.payload" },
3136
inference: options.inference,
3237
drainBehavior: "wait",
38+
timeout: STEP_TIMEOUT_MS,
3339
triggers: 1,
3440
}),
3541
},

0 commit comments

Comments
 (0)