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
2 changes: 1 addition & 1 deletion vendor/interchange.manifest.json

Large diffs are not rendered by default.

13 changes: 10 additions & 3 deletions vendor/interchange/packages/workflow/src/runtime/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1772,9 +1772,15 @@ async function runStep(
};
bridgeAbort(abort, onOuter);
let timer: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
if (step.timeout !== undefined) {
timer = setTimeout(() => {
stepAbort.abort();
timedOut = true;
stepAbort.abort(
new Error(
`step ${step.id} exceeded its ${String(step.timeout)} ms timeout`,
),
);
}, step.timeout);
}

Expand Down Expand Up @@ -1964,6 +1970,7 @@ async function runStep(
return output;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
const error = timedOut ? { message, code: "step-timeout" } : { message };
const exhausted = attempt >= maxAttempts;
let after = await reloadState(env, runId);
// Cancellation wins over step-level failure: if the run is
Expand Down Expand Up @@ -2043,7 +2050,7 @@ async function runStep(
at: env.clock().toISOString(),
stepId: step.id,
attempt,
error: { message },
error,
retriesExhausted: true,
routedTo: step.onFailure,
};
Expand All @@ -2057,7 +2064,7 @@ async function runStep(
at: env.clock().toISOString(),
stepId: step.id,
attempt,
error: { message },
error,
retriesExhausted: exhausted,
};
after = await commit(env, runId, failed);
Expand Down
7 changes: 7 additions & 0 deletions workflows/reviewer/src/workflow.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { AgentDefinition, BaseEnv } from "@intx/agent/authoring";
import { defineWorkflow, step, type Selector } from "@intx/workflow/definition";

// Matches the writer's bound until reviewer runs are measured.
// A host that raises the output cap raises this with it.
const STEP_TIMEOUT_MS = 600_000;

// One bounded review. The document and instructions arrive in the first message.
export function createReviewerWorkflow<Env extends BaseEnv>(options: {
readonly id: string;
readonly address: string;
readonly agent: AgentDefinition<Env>;
readonly inference: Selector;
readonly stepTimeoutMs?: number;
readonly credentialBindings?: Parameters<
typeof defineWorkflow
>[0]["credentialBindings"];
Expand All @@ -30,6 +35,8 @@ export function createReviewerWorkflow<Env extends BaseEnv>(options: {
input: { from: "trigger.payload" },
inference: options.inference,
drainBehavior: "wait",
// No retry: a second attempt would redo work the first may already have mailed.
timeout: options.stepTimeoutMs ?? STEP_TIMEOUT_MS,
triggers: 1,
}),
},
Expand Down
98 changes: 98 additions & 0 deletions workflows/writer/src/workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { expect, jest, test } from "bun:test";
import { createDefaultDirectorRegistry } from "@intx/agent";
import {
createInMemoryBlobSubstrate,
createInMemoryRepoStore,
createInMemoryScheduler,
createNoopDrainController,
runtimeRun,
type StepInvoker,
type WorkflowRuntimeEnv,
} from "@intx/workflow";
import { createObservedSignalChannel } from "@intx/workflow/testing";
import { createWriterAgent } from "./agent.ts";
import { createWriterWorkflow } from "./workflow.ts";

const workflow = createWriterWorkflow({
id: "writer",
address: "writer",
agent: createWriterAgent({
id: "writer",
inference: { provider: "provider", model: "model" },
tools: [],
capabilities: [],
}),
inference: { literal: {} },
});

// A model that never answers: the step ends only when the runtime aborts it, and rejects with the
// abort reason as the real step invoker does.
const stalled: StepInvoker = (request) =>
new Promise((_, reject) => {
request.signal.addEventListener("abort", () => {
reject(request.signal.reason);
});
});

test("a stalled write fails once on its timeout and fails the run", async () => {
const runId = "run-writer";
const repoStore = createInMemoryRepoStore();
const clock = () => new Date();
const env: WorkflowRuntimeEnv = {
repoStore,
scheduler: createInMemoryScheduler({ repoStore, clock }),
signalChannel: createObservedSignalChannel(),
blobs: createInMemoryBlobSubstrate(),
directors: createDefaultDirectorRegistry(),
authorize: async () => ({
effect: "allow",
matchingGrants: [],
resolvedBy: null,
}),
invokeStep: stalled,
spawnChild: () => Promise.reject(new Error("no_children")),
clock,
newId: (prefix) => `${prefix}-${crypto.randomUUID()}`,
drain: createNoopDrainController(workflow),
hasUpstreamSignalResolver: true,
};
jest.useFakeTimers();
let settled = false;
try {
const { complete } = runtimeRun(workflow, env, {
runId,
triggerPayload: { body: "Write the constraints." },
});
async function settle() {
try {
await complete;
} finally {
settled = true;
}
}
const settling = settle();
// Let the run reach its stalled step between clock moves, until the step timeout has passed.
for (let minute = 0; minute < 120 && !settled; minute += 1) {
await new Promise<void>((resolve) => queueMicrotask(resolve));
jest.advanceTimersByTime(60_000);
}
expect(settled).toBe(true);
await settling;
} finally {
jest.useRealTimers();
}

const events = await repoStore.read(runId);
const failed = events.filter((event) => event.kind === "StepFailed");
expect(failed).toHaveLength(1);
expect(failed[0]).toMatchObject({
stepId: "write",
attempt: 1,
retriesExhausted: true,
error: {
code: "step-timeout",
message: expect.stringContaining("step write"),
},
});
expect(events.at(-1)?.kind).toBe("RunFailed");
});
7 changes: 7 additions & 0 deletions workflows/writer/src/workflow.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { AgentDefinition, BaseEnv } from "@intx/agent/authoring";
import { defineWorkflow, step, type Selector } from "@intx/workflow/definition";

// The longest writer step in stored runs took 276 s; ten minutes leaves room for a slower model.
// A host that raises the output cap raises this with it.
const STEP_TIMEOUT_MS = 600_000;

// One bounded write. The task, its documents, and the artifact to write arrive in the first message.
export function createWriterWorkflow<Env extends BaseEnv>(options: {
readonly id: string;
readonly address: string;
readonly agent: AgentDefinition<Env>;
readonly inference: Selector;
readonly stepTimeoutMs?: number;
readonly credentialBindings?: Parameters<
typeof defineWorkflow
>[0]["credentialBindings"];
Expand All @@ -30,6 +35,8 @@ export function createWriterWorkflow<Env extends BaseEnv>(options: {
input: { from: "trigger.payload" },
inference: options.inference,
drainBehavior: "wait",
// No retry: a second attempt would redo work the first may already have filed.
timeout: options.stepTimeoutMs ?? STEP_TIMEOUT_MS,
triggers: 1,
}),
},
Expand Down