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
267 changes: 267 additions & 0 deletions src/browser/features/Tools/WorkflowRunToolCall.timeout.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import type { Meta, StoryObj } from "@storybook/react-vite";

import { WorkflowRunToolCall } from "@/browser/features/Tools/WorkflowRunToolCall";
import { CHROMATIC_DISABLED, lightweightMeta } from "@/browser/stories/meta.js";
import type { WorkflowRunRecord } from "@/common/types/workflow";

const meta = {
...lightweightMeta,
title: "App/Chat/Tools/WorkflowRun/Timeouts",
component: WorkflowRunToolCall,
parameters: {
chromatic: CHROMATIC_DISABLED,
},
} satisfies Meta<typeof WorkflowRunToolCall>;

export default meta;

type Story = StoryObj<typeof meta>;

const timeoutRecoveredRun: WorkflowRunRecord = {
id: "wfr_story_timeout_recovered",
workspaceId: "workspace-1",
workflow: {
name: "timeout-demo",
description: "Timeout demo",
scope: "project",
sourcePath: "./workflows/timeout-demo.js",
requestedScriptPath: "./workflows/timeout-demo.js",
canonicalScriptPath: "./workflows/timeout-demo.js",
sourceKind: "workspace-file",
sourceHash: "sha256:timeout-story",
executable: true,
},
source: "export default function workflow() { return null; }",
sourceHash: "sha256:timeout-story",
args: { topic: "workflow timeout finalization" },
status: "completed",
createdAt: "2026-05-29T00:00:00.000Z",
updatedAt: "2026-05-29T00:00:05.000Z",
events: [
{ sequence: 1, type: "status", at: "2026-05-29T00:00:00.000Z", status: "running" },
{
sequence: 2,
type: "task",
at: "2026-05-29T00:00:01.000Z",
stepId: "slow-investigation",
taskId: "task_timeout",
status: "started",
title: "Slow investigation",
},
{
sequence: 3,
type: "timeout",
at: "2026-05-29T00:00:03.000Z",
stepId: "slow-investigation",
taskId: "task_timeout",
phase: "soft",
details: { softMs: 2000, graceMs: 2000 },
},
{
sequence: 4,
type: "task",
at: "2026-05-29T00:00:03.000Z",
stepId: "slow-investigation",
taskId: "task_timeout",
status: "finalizing",
title: "Slow investigation",
},
{
sequence: 5,
type: "timeout",
at: "2026-05-29T00:00:03.100Z",
stepId: "slow-investigation",
taskId: "task_timeout",
phase: "finalization_prompt_sent",
},
{
sequence: 6,
type: "timeout",
at: "2026-05-29T00:00:04.000Z",
stepId: "slow-investigation",
taskId: "task_timeout",
phase: "recovered",
details: { graceMs: 2000 },
},
{
sequence: 7,
type: "task",
at: "2026-05-29T00:00:04.000Z",
stepId: "slow-investigation",
taskId: "task_timeout",
status: "completed",
title: "Slow investigation",
},
{ sequence: 8, type: "status", at: "2026-05-29T00:00:05.000Z", status: "completed" },
],
steps: [],
};

export const TimeoutRecovered: Story = {
args: {
args: {
script_path: "./workflows/timeout-demo.js",
args: { topic: "workflow timeout finalization" },
run_in_background: false,
},
status: "completed",
result: {
status: "completed",
runId: "wfr_story_timeout_recovered",
result: { reportMarkdown: "Recovered during timeout finalization." },
run: timeoutRecoveredRun,
},
},
};

export const TimeoutFinalizing: Story = {
args: {
...TimeoutRecovered.args,
result: {
status: "running",
runId: "wfr_story_timeout_finalizing",
result: null,
run: {
...timeoutRecoveredRun,
id: "wfr_story_timeout_finalizing",
status: "running",
updatedAt: "2026-05-29T00:00:03.100Z",
events: timeoutRecoveredRun.events.slice(0, 5),
},
},
},
};

export const TimeoutHard: Story = {
args: {
...TimeoutRecovered.args,
result: {
status: "failed",
runId: "wfr_story_timeout_hard",
result: null,
run: {
...timeoutRecoveredRun,
id: "wfr_story_timeout_hard",
status: "failed",
updatedAt: "2026-05-29T00:00:06.000Z",
events: [
...timeoutRecoveredRun.events.slice(0, 5),
{
sequence: 6,
type: "timeout",
at: "2026-05-29T00:00:05.000Z",
stepId: "slow-investigation",
taskId: "task_timeout",
phase: "hard",
details: {
error:
"Workflow agent step slow-investigation exceeded its soft timeout (2000ms) and did not produce a valid agent_report within the grace period (2000ms).",
},
},
{
sequence: 7,
type: "task",
at: "2026-05-29T00:00:05.000Z",
stepId: "slow-investigation",
taskId: "task_timeout",
status: "timed_out",
title: "Slow investigation",
},
{
sequence: 8,
type: "error",
at: "2026-05-29T00:00:05.100Z",
message:
"Workflow agent step slow-investigation exceeded its soft timeout (2000ms) and did not produce a valid agent_report within the grace period (2000ms).",
},
{ sequence: 9, type: "status", at: "2026-05-29T00:00:06.000Z", status: "failed" },
],
},
},
},
};

export const TimeoutParallel: Story = {
args: {
...TimeoutRecovered.args,
result: {
status: "completed",
runId: "wfr_story_timeout_parallel",
result: {
reportMarkdown: "Parallel workflow completed after one lane recovered during grace.",
},
run: {
...timeoutRecoveredRun,
id: "wfr_story_timeout_parallel",
status: "completed",
updatedAt: "2026-05-29T00:00:06.000Z",
events: [
{ sequence: 1, type: "status", at: "2026-05-29T00:00:00.000Z", status: "running" },
{
sequence: 2,
type: "task",
at: "2026-05-29T00:00:01.000Z",
stepId: "fast-lane",
taskId: "task_fast",
status: "completed",
title: "Fast lane",
},
{
sequence: 3,
type: "task",
at: "2026-05-29T00:00:01.000Z",
stepId: "slow-lane",
taskId: "task_slow",
status: "started",
title: "Slow lane",
},
{
sequence: 4,
type: "timeout",
at: "2026-05-29T00:00:03.000Z",
stepId: "slow-lane",
taskId: "task_slow",
phase: "soft",
details: { softMs: 2000, graceMs: 2000 },
},
{
sequence: 5,
type: "task",
at: "2026-05-29T00:00:03.000Z",
stepId: "slow-lane",
taskId: "task_slow",
status: "finalizing",
title: "Slow lane",
},
{
sequence: 6,
type: "timeout",
at: "2026-05-29T00:00:03.100Z",
stepId: "slow-lane",
taskId: "task_slow",
phase: "finalization_prompt_sent",
},
{
sequence: 7,
type: "timeout",
at: "2026-05-29T00:00:04.000Z",
stepId: "slow-lane",
taskId: "task_slow",
phase: "recovered",
details: { graceMs: 2000 },
},
{
sequence: 8,
type: "task",
at: "2026-05-29T00:00:04.000Z",
stepId: "slow-lane",
taskId: "task_slow",
status: "completed",
title: "Slow lane",
},
{ sequence: 9, type: "status", at: "2026-05-29T00:00:06.000Z", status: "completed" },
],
},
},
},
};
17 changes: 17 additions & 0 deletions src/browser/features/Tools/WorkflowRunToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,18 @@ function getWorkflowEventLabel(event: WorkflowRunEvent): string {
// Prefer the human-readable sub-agent title (matches the spawned
// workspace title); fall back to stepId for legacy events without one.
return `${event.title ?? event.stepId} / ${event.taskId} / ${event.status}`;
case "timeout":
switch (event.phase) {
case "soft":
return `${event.stepId} / ${event.taskId} / Soft timeout reached; requesting final report`;
case "finalization_prompt_sent":
return `${event.stepId} / ${event.taskId} / Final report requested`;
case "recovered":
return `${event.stepId} / ${event.taskId} / Recovered during grace period`;
case "hard":
return `${event.stepId} / ${event.taskId} / Hard timeout; child terminated`;
}
return `${event.stepId} / ${event.taskId} / timeout`;
case "workflow":
return `${event.stepId} / ${event.name} / ${event.runId} / ${event.status}`;
case "patch":
Expand Down Expand Up @@ -387,6 +399,8 @@ function getWorkflowEventDetail(event: WorkflowRunEvent): unknown {
return event.details;
case "patch":
return event.details;
case "timeout":
return event.details;
case "action":
return event.details;
case "task":
Expand Down Expand Up @@ -421,6 +435,9 @@ function getEventTone(event: WorkflowRunEvent): "normal" | "success" | "warning"
}
return event.status === "failed" ? "warning" : "normal";
}
if (event.type === "timeout") {
return event.phase === "recovered" ? "success" : "warning";
}
if (event.type === "workflow") {
return event.status === "completed"
? "success"
Expand Down
10 changes: 9 additions & 1 deletion src/browser/hooks/useCoderWorkspace.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { act, cleanup, renderHook } from "@testing-library/react";
import { GlobalWindow } from "happy-dom";
import * as APIModule from "@/browser/contexts/API";
import {
buildAutoSelectedTemplateConfig,
useCoderWorkspace,
Expand All @@ -9,6 +10,8 @@ import {
import type { CoderInfo, CoderTemplate } from "@/common/orpc/schemas/coder";
import type { CoderWorkspaceConfig } from "@/common/types/runtime";

const actualAPIModule = { ...APIModule };

const makeTemplate = (name: string, org = "default-org"): CoderTemplate => ({
name,
displayName: name,
Expand All @@ -34,13 +37,18 @@ const apiMock = {
};

void mock.module("@/browser/contexts/API", () => ({
...actualAPIModule,
useAPI: () => ({
api: apiMock,
status: "connected" as const,
error: null,
}),
}));

afterAll(async () => {
await mock.module("@/browser/contexts/API", () => actualAPIModule);
});

function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
Expand Down
22 changes: 22 additions & 0 deletions src/common/orpc/schemas/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@ export const WorkflowRunEventSchema = z.discriminatedUnion("type", [
// Optional so legacy persisted events without it still parse.
title: z.string().min(1).optional(),
}),
z.object({
sequence: z.number().int().positive(),
type: z.literal("timeout"),
at: IsoDateTimeSchema,
stepId: z.string().min(1),
taskId: z.string().min(1),
phase: z.enum(["soft", "finalization_prompt_sent", "recovered", "hard"]),
details: JsonValueSchema.optional(),
}),
z.object({
sequence: z.number().int().positive(),
type: z.literal("workflow"),
Expand Down Expand Up @@ -195,6 +204,18 @@ export const WorkflowEventSequenceSchema = z

export const WorkflowStepStatusSchema = z.enum(["started", "completed", "failed", "interrupted"]);

export const WorkflowStepTimeoutMetadataSchema = z
.object({
executionStartedAt: IsoDateTimeSchema.optional(),
softDeadlineAt: IsoDateTimeSchema.optional(),
hardDeadlineAt: IsoDateTimeSchema.optional(),
softTimedOutAt: IsoDateTimeSchema.optional(),
finalizationToken: z.string().min(1).optional(),
finalizationPromptSentAt: IsoDateTimeSchema.optional(),
hardTimedOutAt: IsoDateTimeSchema.optional(),
})
.strict();

export const WorkflowStepRecordSchema = z.object({
stepId: z.string().min(1),
inputHash: z.string().min(1),
Expand All @@ -203,6 +224,7 @@ export const WorkflowStepRecordSchema = z.object({
startedAt: IsoDateTimeSchema,
completedAt: IsoDateTimeSchema.optional(),
result: StructuredTaskOutputSchema.optional(),
timeout: WorkflowStepTimeoutMetadataSchema.optional(),
error: z.string().min(1).optional(),
});

Expand Down
4 changes: 4 additions & 0 deletions src/common/schemas/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ export const WorkspaceConfigSchema = z.object({
taskLaunchError: z.string().optional().meta({
description: "Startup failure recorded before an agent task could begin streaming.",
}),
taskTimeoutFinalizationTokens: z.array(z.string().min(1)).optional().meta({
description:
"Idempotency tokens for workflow timeout finalization prompts already sent to this task.",
}),
taskRecoveryAttempts: z.number().int().nonnegative().optional().meta({
description:
"Completion-tool recovery prompts sent to this agent task since it last completed successfully. Persisted (not in-memory) so crash/restart recovery loops stay bounded; cleared on a successful report, on plan-to-exec handoff, and on user-initiated resume.",
Expand Down
Loading
Loading