From 2566da15ebc728a60611078dfd4abd749320b8d5 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:42:34 +0000 Subject: [PATCH 1/2] feat(workflow): add opt-in settled results --- src/workflow-api.ts | 53 ++++++++++++++++++++++++++++++++++ src/workflow-contracts.ts | 14 +++++++++ src/workflow-engine.test.ts | 55 ++++++++++++++++++++++++++++++++++++ src/workflow-engine.ts | 1 + src/workflow-sandbox.test.ts | 10 +++++++ src/workflow-sandbox.ts | 3 ++ src/workflow-script.test.ts | 10 +++++++ src/workflow-script.ts | 2 +- src/workflow-tools.ts | 1 + src/workflow-types.ts | 3 ++ 10 files changed, 151 insertions(+), 1 deletion(-) diff --git a/src/workflow-api.ts b/src/workflow-api.ts index 8ae13c92..3ba87099 100644 --- a/src/workflow-api.ts +++ b/src/workflow-api.ts @@ -17,6 +17,10 @@ import { type WorkflowMeta, } from "./workflow-types.js"; import { agentOptsSchema } from "./workflow-contracts.js"; +import { + isWorkflowOperationError, + serializeWorkflowError, +} from "./workflow-errors.js"; // --------------------------------------------------------------------------- // Host deps (injected by engine; fakes OK in tests) @@ -574,6 +578,18 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { ); }; + const settle = async (...args: unknown[]): Promise => { + const task = args[0]; + if (typeof task !== "function") { + throw new WorkflowEngineError("internal", "settle(task) requires a function"); + } + try { + return { ok: true, value: await (task as () => unknown | Promise)() }; + } catch (error) { + return { ok: false, error: normalizeSettledError(error) }; + } + }; + const phase = (...args: unknown[]): void => { const title = args[0]; if (typeof title !== "string" || !title.trim()) { @@ -628,6 +644,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { agent: agent as WorkflowSandboxApi["agent"], parallel: parallel as WorkflowSandboxApi["parallel"], pipeline: pipeline as WorkflowSandboxApi["pipeline"], + settle: settle as WorkflowSandboxApi["settle"], phase: phase as WorkflowSandboxApi["phase"], log: log as WorkflowSandboxApi["log"], args: deps.args, @@ -639,6 +656,42 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi { }; } +function normalizeSettledError(error: unknown): { + kind: import("./workflow-types.js").WorkflowErrorKind; + message: string; + retryable: boolean; +} { + if (error instanceof WorkflowEngineError) { + return { + kind: error.kind, + message: error.message, + retryable: + error.kind === "provider_unavailable" || + error.kind === "provider_disabled" || + error.kind === "no_provider", + }; + } + if (isWorkflowOperationError(error)) { + const serialized = serializeWorkflowError(error); + return { + kind: serialized.kind, + message: serialized.message, + retryable: serialized.retryable, + }; + } + if (error && typeof error === "object" && "name" in error) { + const name = String((error as { name: unknown }).name); + if (name === "AbortError") { + return { kind: "cancelled", message: "Workflow cancelled", retryable: false }; + } + } + return { + kind: "internal", + message: error instanceof Error ? error.message : String(error), + retryable: false, + }; +} + function formatReplayMiss(miss: WorkflowReplayMiss): string { return miss.reason === "identity_changed" && miss.changedFields?.length ? `${miss.reason}:${miss.changedFields.join(",")}` diff --git a/src/workflow-contracts.ts b/src/workflow-contracts.ts index 1944b1c5..2590c7f7 100644 --- a/src/workflow-contracts.ts +++ b/src/workflow-contracts.ts @@ -82,6 +82,20 @@ export interface WorkflowAgent { export type WorkflowTask = () => T | Promise; +export interface WorkflowSettledError { + kind: WorkflowErrorKind; + message: string; + retryable: boolean; +} + +export type WorkflowSettledResult = + | { ok: true; value: T } + | { ok: false; error: WorkflowSettledError }; + +export interface WorkflowSettle { + (task: WorkflowTask): Promise>>; +} + export interface WorkflowParallel { ( tasks: T, diff --git a/src/workflow-engine.test.ts b/src/workflow-engine.test.ts index 5c3438b3..3eff18f0 100644 --- a/src/workflow-engine.test.ts +++ b/src/workflow-engine.test.ts @@ -77,6 +77,61 @@ import { createStubBudget } from "./workflow-types.js"; await rm(dir, { recursive: true, force: true }); } +// --------------------------------------------------------------------------- +// settle() preserves a typed failure as data without changing parallel defaults +// --------------------------------------------------------------------------- +{ + const dir = await mkdtemp(join(tmpdir(), "wf-settle-")); + const store = new WorkflowStore(dir); + const run = store.createRun({ + name: "settle", + source: "inline", + scriptPath: "inline", + scriptHash: "h", + workspaceRoot: dir, + }); + const api = createWorkflowApi({ + runId: run.id, + journal: store, + meta: { name: "settle", description: "d" }, + args: undefined, + concurrency: 2, + signal: new AbortController().signal, + workspaceRoot: dir, + enabledProviders: ["codex"], + runProvider: async (input) => { + if (input.prompt === "fail") { + throw new WorkflowEngineError("provider_unavailable", "provider missing"); + } + return { finalResponse: `ok:${input.prompt}` }; + }, + }); + + const ok = await api.settle(() => api.agent("a")); + assert.deepEqual(ok, { ok: true, value: "ok:a" }); + + const failed = await api.settle(() => api.agent("fail")); + assert.deepEqual(failed, { + ok: false, + error: { + kind: "provider_unavailable", + message: "provider missing", + retryable: true, + }, + }); + assert.equal(store.getAgentCall(run.id, 1)?.status, "failed"); + + const rows = await api.parallel([ + () => api.settle(() => api.agent("fail")), + () => api.settle(() => api.agent("b")), + ]); + assert.equal(rows[0]?.ok, false); + assert.deepEqual(rows[1], { ok: true, value: "ok:b" }); + + store.close(); + await rm(dir, { recursive: true, force: true }); +} + // --------------------------------------------------------------------------- // pipeline — no barrier across items (item B can finish stage2 before A stage1 ends) // --------------------------------------------------------------------------- diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts index 1611e7ca..72918039 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -160,6 +160,7 @@ async function executeNestedOnApi(input: { agent: input.parentApi.agent, parallel: input.parentApi.parallel, pipeline: input.parentApi.pipeline, + settle: input.parentApi.settle, phase: input.parentApi.phase, log: input.parentApi.log, args: input.args, diff --git a/src/workflow-sandbox.test.ts b/src/workflow-sandbox.test.ts index 7805aa75..b919b342 100644 --- a/src/workflow-sandbox.test.ts +++ b/src/workflow-sandbox.test.ts @@ -12,6 +12,16 @@ function api(meta: WorkflowMeta, logs?: string[]): WorkflowSandboxApi { agent: async () => "", parallel: async () => [], pipeline: async () => [], + settle: async (task: () => T | Promise) => { + try { + return { ok: true, value: await task() }; + } catch (error) { + return { + ok: false, + error: { kind: "internal", message: String(error), retryable: false }, + }; + } + }, phase: () => {}, log: (msg: unknown) => { logs?.push(String(msg)); diff --git a/src/workflow-sandbox.ts b/src/workflow-sandbox.ts index 656e128c..856ff593 100644 --- a/src/workflow-sandbox.ts +++ b/src/workflow-sandbox.ts @@ -8,6 +8,7 @@ import type { WorkflowNested, WorkflowParallel, WorkflowPipeline, + WorkflowSettle, } from "./workflow-types.js"; export class WorkflowDeterminismError extends Error { @@ -21,6 +22,7 @@ export interface WorkflowSandboxApi { agent: WorkflowAgent; parallel: WorkflowParallel; pipeline: WorkflowPipeline; + settle: WorkflowSettle; phase: (title: string) => void; log: (...args: unknown[]) => unknown; args: JsonValue | undefined; @@ -71,6 +73,7 @@ export async function runWorkflowSandbox( agent: api.agent, parallel: api.parallel, pipeline: api.pipeline, + settle: api.settle, phase: api.phase, log: api.log, args: api.args, diff --git a/src/workflow-script.test.ts b/src/workflow-script.test.ts index d64c5cfc..214c5d58 100644 --- a/src/workflow-script.test.ts +++ b/src/workflow-script.test.ts @@ -87,6 +87,16 @@ async function runBody(source: string): Promise { return Promise.all(thunks.map((t) => t().catch(() => null))); }, pipeline: async (...args: unknown[]) => args[0], + settle: async (task) => { + try { + return { ok: true, value: await task() }; + } catch (error) { + return { + ok: false, + error: { kind: "internal", message: String(error), retryable: false }, + }; + } + }, phase: () => {}, log: (msg: unknown) => { logs.push(String(msg)); diff --git a/src/workflow-script.ts b/src/workflow-script.ts index cf949568..b4cf4537 100644 --- a/src/workflow-script.ts +++ b/src/workflow-script.ts @@ -58,7 +58,7 @@ export function parseWorkflowScript( // Inject host APIs as params. `meta` stays as the script's own `const meta` // (would TDZ/redeclare if also injected). `console` lives on the sandbox globals. - const wrapped = `(async ({ agent, parallel, pipeline, phase, log, args, budget, workflow }) => {\n${body}\n})`; + const wrapped = `(async ({ agent, parallel, pipeline, settle, phase, log, args, budget, workflow }) => {\n${body}\n})`; let script: vm.Script; try { script = new vm.Script(wrapped, { diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 5f8c2c6c..68ac13a0 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -41,6 +41,7 @@ Workflow scripts (JS only): agent(prompt, { label?, phase?, schema?, model?, effort?, provider?, isolation?: 'worktree' }) parallel(thunks) → Array // barrier; throw → null pipeline(items, ...stages) // no cross-item barrier + settle(() => operation) → { ok, value } | { ok, error } phase(title); log(msg); args workflow(name | { scriptPath }, args?) // nest depth 1 Bans: Date.now(), Math.random(), new Date() without args. diff --git a/src/workflow-types.ts b/src/workflow-types.ts index ad050e1c..bbbd2b42 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -40,6 +40,9 @@ export type { WorkflowPipeline, WorkflowRunSource, WorkflowRunStatus, + WorkflowSettle, + WorkflowSettledError, + WorkflowSettledResult, WorkflowTask, } from "./workflow-contracts.js"; From a8db86fd51be7c61a9543a60d3cbe4f97c72e18d Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:42:34 +0000 Subject: [PATCH 2/2] docs(workflow): teach settled failure handling --- skills/dynamic-workflows/SKILL.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index 87322cb8..f744b5d7 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -58,12 +58,39 @@ return { summary, findings } | `agent(prompt, opts?)` | Throws on failure. `opts`: `label`, `phase`, `schema`, `model`, `effort`, `provider`, `isolation: 'worktree'` | | `parallel(thunks)` | Barrier; throw → `null` slot | | `pipeline(items, ...stages)` | Per-item chains; no cross-item barrier | +| `settle(() => operation)` | DevSpace extension: convert a thrown operation into `{ ok, value }` or `{ ok, error: { kind, message, retryable } }` | | `phase(title)` / `log(msg)` | Progress; journaled | | `args` | Run input (object preferred) | | `workflow(name\|{scriptPath}, args?)` | Nested, depth 1, shared call index | **No `writeMode`.** Teach read-only vs write in the prompt. Use `isolation: 'worktree'` when parallel mutators would conflict (git required). +### Failure-aware orchestration + +Default behavior stays Claude-compatible: direct `agent()` failures throw, while +`parallel()` and `pipeline()` map a failed branch/item to `null`. Use `settle()` +only when the script must distinguish failure kinds or implement fallback: + +```js +const primary = await settle(() => + agent('Read-only security review', { provider: 'claude' }), +) + +const review = primary.ok + ? primary + : primary.error.kind === 'provider_unavailable' + ? await settle(() => + agent('Read-only security review', { provider: 'codex' }), + ) + : primary + +return review +``` + +Inside `parallel()`, wrap each branch with `settle()` to preserve failures as +data instead of `null`. Failed settled outcomes are journaled failures and are +not replayed as successful cached agent results. + ### Determinism bans `Date.now()`, `Math.random()`, and `new Date()` without args throw. Pass timestamps via `args` if needed.