Skip to content

Commit c32b094

Browse files
committed
fix(workflow): honor nested workflow context
1 parent ddb0f44 commit c32b094

3 files changed

Lines changed: 44 additions & 71 deletions

File tree

src/workflow-api.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ export interface WorkflowApiDeps {
172172
nestDepth: number;
173173
}) => Promise<unknown>;
174174
nestDepth?: number;
175+
runtime?: WorkflowApiRuntime;
175176
}
176177

177178
export interface WorkflowApi extends WorkflowSandboxApi {
@@ -242,6 +243,18 @@ export class WorkflowSemaphore {
242243
}
243244
}
244245

246+
export interface WorkflowApiRuntime {
247+
semaphore: WorkflowSemaphore;
248+
callIndex: number;
249+
}
250+
251+
export function createWorkflowApiRuntime(concurrency: number): WorkflowApiRuntime {
252+
return {
253+
semaphore: new WorkflowSemaphore(Math.max(1, concurrency)),
254+
callIndex: 0,
255+
};
256+
}
257+
245258
// ---------------------------------------------------------------------------
246259
// API factory
247260
// ---------------------------------------------------------------------------
@@ -250,8 +263,8 @@ const phaseAls = new AsyncLocalStorage<string>();
250263

251264
export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
252265
const nestDepth = deps.nestDepth ?? 0;
253-
const semaphore = new WorkflowSemaphore(Math.max(1, deps.concurrency));
254-
let callIndex = 0;
266+
const runtime = deps.runtime ?? createWorkflowApiRuntime(deps.concurrency);
267+
const semaphore = runtime.semaphore;
255268

256269
const agent = async (prompt: unknown, opts: unknown = {}): Promise<unknown> => {
257270
if (typeof prompt !== "string" || !prompt.trim()) {
@@ -272,8 +285,8 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
272285
const phase = agentOpts.phase ?? phaseAls.getStore();
273286
const isolation: AgentIsolationMode =
274287
agentOpts.isolation === "worktree" ? "worktree" : "shared";
275-
const index = callIndex;
276-
callIndex += 1;
288+
const index = runtime.callIndex;
289+
runtime.callIndex += 1;
277290

278291
const cacheKeyInput = buildAgentCacheKeyInput({
279292
prompt,
@@ -664,7 +677,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
664677
budget: createStubBudget(),
665678
workflow: workflow as WorkflowSandboxApi["workflow"],
666679
meta: deps.meta,
667-
getCallCount: () => callIndex,
680+
getCallCount: () => runtime.callIndex,
668681
getNestDepth: () => nestDepth,
669682
};
670683
}

src/workflow-engine.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -522,25 +522,27 @@ import type { LocalAgentProfile } from "./local-agent-profiles.js";
522522
await writeFile(
523523
childPath,
524524
`
525-
export const meta = { name: 'child', description: 'nested' }
525+
export const meta = { name: 'child', description: 'nested', defaultProvider: 'claude' }
526526
return await agent('nested-prompt')
527527
`,
528528
);
529529

530530
const prompts: string[] = [];
531+
const providers: string[] = [];
531532
const { result, callCount } = await executeWorkflow({
532533
source: `
533-
export const meta = { name: 'parent', description: 'p' }
534+
export const meta = { name: 'parent', description: 'p', defaultProvider: 'codex' }
534535
const a = await agent('parent-prompt')
535536
const nested = await workflow({ scriptPath: ${JSON.stringify(childPath)} })
536537
return { a, nested }
537538
`,
538539
runId: run.id,
539540
journal: store,
540541
workspaceRoot: dir,
541-
enabledProviders: ["codex"],
542+
enabledProviders: ["codex", "claude"],
542543
runProvider: async (input) => {
543544
prompts.push(input.prompt);
545+
providers.push(input.provider);
544546
return { finalResponse: `R:${input.prompt}` };
545547
},
546548
resolveNestedSource: async (ref) => {
@@ -558,6 +560,7 @@ return { a, nested }
558560
});
559561
assert.equal(callCount, 2);
560562
assert.deepEqual(prompts, ["parent-prompt", "nested-prompt"]);
563+
assert.deepEqual(providers, ["codex", "claude"]);
561564

562565
// depth 2 must fail
563566
await assert.rejects(

src/workflow-engine.ts

Lines changed: 20 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import { parseWorkflowScript, type ParsedWorkflowScript } from "./workflow-scrip
88
import { runWorkflowSandbox } from "./workflow-sandbox.js";
99
import {
1010
createWorkflowApi,
11+
createWorkflowApiRuntime,
1112
type CreateAgentWorktree,
1213
type WorkflowApi,
14+
type WorkflowApiRuntime,
1315
type WorkflowJournal,
1416
type WorkflowReplay,
1517
type WorkflowRunProvider,
@@ -46,6 +48,8 @@ export interface ExecuteWorkflowOptions {
4648
resolveNestedSource?: (nameOrRef: string | { scriptPath: string }) => string | Promise<string>;
4749
nestDepth?: number;
4850
timeoutMs?: number;
51+
/** Shared call counter/semaphore for nested workflow execution. */
52+
runtime?: WorkflowApiRuntime;
4953
/** Optional hooks after API construction (tests). */
5054
onApi?: (api: WorkflowApi) => void;
5155
}
@@ -73,6 +77,7 @@ export async function executeWorkflow(
7377
resolveWorkflowConcurrency(parsed.meta.concurrency, availableParallelism());
7478

7579
const resolveNestedSource = options.resolveNestedSource;
80+
const runtime = options.runtime ?? createWorkflowApiRuntime(concurrency);
7681

7782
// Shared callIndex/semaphore for nested scripts via parent API path.
7883
const api = createWorkflowApi({
@@ -89,17 +94,25 @@ export async function executeWorkflow(
8994
runProvider: options.runProvider,
9095
createWorktree: options.createWorktree,
9196
replay: options.replay,
97+
runtime,
9298
nestDepth,
9399
resolveNestedSource,
94100
executeNested: resolveNestedSource
95101
? async (input) =>
96-
executeNestedOnApi({
97-
parentOptions: options,
98-
parentApi: api,
99-
source: input.source,
100-
args: input.args,
101-
nestDepth: input.nestDepth,
102-
})
102+
(
103+
await executeWorkflow({
104+
...options,
105+
parsed: undefined,
106+
source: input.source,
107+
filename: "workflow:nested",
108+
args: input.args,
109+
signal,
110+
concurrency,
111+
runtime,
112+
nestDepth: input.nestDepth,
113+
onApi: undefined,
114+
})
115+
).result
103116
: undefined,
104117
});
105118
options.onApi?.(api);
@@ -136,62 +149,6 @@ export async function executeWorkflow(
136149
}
137150
}
138151

139-
/**
140-
* Nested script execution reusing parent's agent() call counter + semaphore
141-
* by constructing a child API that shares internal state via re-entry.
142-
*
143-
* Implementation: run child sandbox with a new API that has nestDepth+1 but
144-
* delegates agent/parallel/pipeline to the parent API (same callIndex).
145-
*/
146-
async function executeNestedOnApi(input: {
147-
parentOptions: ExecuteWorkflowOptions;
148-
parentApi: WorkflowApi;
149-
source: string;
150-
args: JsonValue | undefined;
151-
nestDepth: number;
152-
}): Promise<unknown> {
153-
if (input.nestDepth > WORKFLOW_MAX_NEST_DEPTH_LOCAL) {
154-
throw new WorkflowEngineError(
155-
"nest_depth",
156-
`workflow() nesting limited to ${WORKFLOW_MAX_NEST_DEPTH_LOCAL} level`,
157-
);
158-
}
159-
const parsed = parseWorkflowScript(input.source, {
160-
filename: "workflow:nested",
161-
});
162-
163-
// Child surface: reuse parent agent/parallel/pipeline/phase/log/budget/workflow
164-
// so callIndex + semaphore stay shared. Override args + meta for the child body.
165-
const childApi: WorkflowApi = {
166-
agent: input.parentApi.agent,
167-
parallel: input.parentApi.parallel,
168-
pipeline: input.parentApi.pipeline,
169-
phase: input.parentApi.phase,
170-
log: input.parentApi.log,
171-
args: input.args,
172-
budget: input.parentApi.budget,
173-
// Child workflow() must see nestDepth via a wrapper that throws at depth>1.
174-
workflow: async (...args: unknown[]) => {
175-
throw new WorkflowEngineError(
176-
"nest_depth",
177-
`workflow() nesting limited to ${WORKFLOW_MAX_NEST_DEPTH_LOCAL} level`,
178-
);
179-
},
180-
meta: parsed.meta,
181-
getCallCount: () => input.parentApi.getCallCount(),
182-
getNestDepth: () => input.nestDepth,
183-
};
184-
185-
return runWorkflowSandbox({
186-
parsed,
187-
api: childApi,
188-
timeoutMs: input.parentOptions.timeoutMs ?? WORKFLOW_HOST_TIMEOUT_MS,
189-
signal: input.parentOptions.signal,
190-
});
191-
}
192-
193-
const WORKFLOW_MAX_NEST_DEPTH_LOCAL = 1;
194-
195152
export function mapEngineErrorKind(error: unknown): WorkflowErrorKind {
196153
if (error instanceof WorkflowEngineError) {
197154
return error.kind;

0 commit comments

Comments
 (0)