From 07c11c9522730689cdd1952c4a9a43bf92a114e8 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 23 Jul 2026 09:23:07 -0700 Subject: [PATCH 1/5] fix(durable-functions): route sync single-arg orchestrators to core-native (#321) A plain sync, single-argument, non-generator orchestrator (ctx) => value is now treated as core-native and receives the core OrchestrationContext directly, fixing #321. Detection no longer falls back to arity for plain sync functions. BREAKING: a classic v3 orchestrator written as a plain non-generator using context.df.* is no longer supported; use a function* generator (unaffected) or the core-native ctx.* API. Documented in the package README and CHANGELOG. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05 --- packages/azure-functions-durable/CHANGELOG.md | 10 +++++++++ packages/azure-functions-durable/README.md | 8 +++++++ .../src/orchestration-context.ts | 16 +++++++++----- .../test/unit/orchestration-context.spec.ts | 22 ++++++++++++++++--- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index 00858aea..0afddfca 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -2,4 +2,14 @@ ## TBD +- Change ([#321](https://github.com/microsoft/durabletask-js/issues/321)): a plain **synchronous, + single-argument, non-generator** orchestrator `(ctx) => value` is now always treated as a + **core-native** orchestrator and receives the core `OrchestrationContext` (`instanceId`, + `callActivity`, `newGuid`, …), fixing #321 where it previously received only the classic + `{ df, log }` context (core members read as `undefined` / threw `TypeError`). + - **BREAKING:** a *classic* v3 orchestrator written as a plain **non-generator** function using + `context.df.*` in this exact shape (sync, single-arg, non-generator) no longer receives the + classic context. Convert it to the standard classic **generator** form (`function*`, + unaffected) or to core-native (`ctx.*`). Sync generators, `async` orchestrators, async + generators, and 2-argument `(ctx, input)` handlers are unchanged. - Details to be finalized at release time. diff --git a/packages/azure-functions-durable/README.md b/packages/azure-functions-durable/README.md index 49adb637..d45ea14d 100644 --- a/packages/azure-functions-durable/README.md +++ b/packages/azure-functions-durable/README.md @@ -52,6 +52,14 @@ changed: (testing utilities), `ManagedIdentityTokenSource`, and the entity-lock types above. `TaskFailedError` is re-exported from the core SDK (aggregate failures surface as JS-native `AggregateError`); use the core `TestOrchestrationWorker` / `TestOrchestrationClient` for orchestration unit tests. +- **A plain non-generator classic orchestrator is no longer supported.** A classic v3 orchestrator + written as a *synchronous, single-argument, non-generator* function `(context) => context.df.*` + (one that never `yield`s) is now treated as a **core-native** orchestrator and receives the core + `OrchestrationContext`, which has no `.df`. This resolves + [#321](https://github.com/microsoft/durabletask-js/issues/321), where a core-native + `(ctx) => ctx.instanceId` was mis-routed to the classic context. Standard classic orchestrators — + sync **generators** (`function*`) using `context.df.*` — are unaffected; convert any non-generator + classic orchestrator to generator form, or to the core-native `ctx.*` API. ## Getting started diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index 14339102..e839d41d 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -218,8 +218,12 @@ export type ClassicOrchestrator = ( * generator to `yield` durable tasks), so `async` unambiguously means core-native. * - `function*` (classic v3 sync generator) is wrapped; the engine cannot drive a sync generator, so * the wrapper delegates to it via `yield*`. - * - A plain SYNC (non-generator) function is ambiguous, so fall back to arity: a lone `context` - * parameter is treated as classic, `(ctx, input)` as core-native. + * - A plain SYNC (non-generator) function is CORE-NATIVE regardless of arity and passes through + * unchanged, receiving the core {@link OrchestrationContext}. This fixes #321 (a native + * `(ctx) => ctx.instanceId` previously mis-routed by arity to the classic context). BREAKING: a + * classic v3 orchestrator written as a plain non-generator using `context.df.*` is no longer + * supported — convert it to a `function*` generator (the standard classic shape, unaffected) or + * to the core-native `ctx.*` API. */ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): TOrchestrator { if (typeof handler === "function" && isClassicOrchestrator(handler)) { @@ -274,9 +278,11 @@ function isClassicOrchestrator(handler: TOrchestrator | ClassicOrchestrator): bo if (kind === "GeneratorFunction") { return true; // classic v3: a sync generator the engine can't drive on its own. } - // Plain SYNC (non-generator) function: kind is ambiguous, so fall back to arity. A lone - // `context` parameter is the classic v3 shape; `(ctx, input)` is core-native. - return handler.length <= 1; + // Plain SYNC (non-generator) function is CORE-NATIVE regardless of arity: it passes through + // unchanged and receives the core OrchestrationContext. Only a sync generator (`function*`) is + // classic (handled above). A classic v3 orchestrator written as a plain non-generator using + // `context.df.*` is no longer supported (breaking) — see #321 and the package README/CHANGELOG. + return false; } /** diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts index e1d39137..bfc9e801 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -272,9 +272,9 @@ describe("wrapOrchestrator", () => { }); it("exposes a replay-safe logger as context.log/error on the classic context", async () => { - // A classic orchestrator may be a plain (non-generator) function that returns a value; the - // wrapper still invokes it with the full classic context, so log wiring is exercised here. - const classic = (context: ClassicOrchestrationContext): string => { + // A classic orchestrator is a sync generator (`function*`); the wrapper invokes it with the full + // classic context, so the replay-safe log wiring is exercised here. + const classic = function* (context: ClassicOrchestrationContext): Generator, string, unknown> { context.log("hi"); context.error("boom"); return "logged"; @@ -292,6 +292,22 @@ describe("wrapOrchestrator", () => { expect(replaySafeLogger.info).toHaveBeenCalledWith("hi"); expect(replaySafeLogger.error).toHaveBeenCalledWith("boom"); }); + + it("returns a plain sync single-argument core-native orchestrator unchanged (#321)", () => { + // #321: a plain sync, single-argument, non-generator `(ctx) => value` is core-native. It must + // pass through wrapOrchestrator UNCHANGED (identity) and receive the core OrchestrationContext. + // Previously arity-based detection mis-routed it to the classic `{ df, log }` context, so + // `ctx.instanceId` was undefined and core members like `ctx.newGuid()` threw. + const native = (ctx: OrchestrationContext): string => `native:${ctx.instanceId}`; + + const wrapped = wrapOrchestrator(native as unknown as TOrchestrator); + expect(wrapped).toBe(native as unknown as TOrchestrator); // identity passthrough, not wrapped + + const { ctx } = createFakeCoreContext(); + const out = (wrapped as unknown as (c: OrchestrationContext) => string)(ctx); + expect(out.startsWith("native:")).toBe(true); + expect(out).not.toContain("undefined"); + }); }); describe("wrapOrchestrator end-to-end (real core executor)", () => { From dc74edd849c59e5f4455f69e81d2b3e95e6c2544 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 23 Jul 2026 09:38:14 -0700 Subject: [PATCH 2/5] refactor(durable-functions): scope core-native routing to single-arg only (#321) Zero-arg plain sync orchestrators keep their prior classic classification; only the sync single-argument (ctx) => value shape is routed to core-native. Behavior change is now provably limited to the single-arg case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05 --- .../src/orchestration-context.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index e839d41d..195a93dc 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -218,12 +218,13 @@ export type ClassicOrchestrator = ( * generator to `yield` durable tasks), so `async` unambiguously means core-native. * - `function*` (classic v3 sync generator) is wrapped; the engine cannot drive a sync generator, so * the wrapper delegates to it via `yield*`. - * - A plain SYNC (non-generator) function is CORE-NATIVE regardless of arity and passes through - * unchanged, receiving the core {@link OrchestrationContext}. This fixes #321 (a native + * - A plain SYNC (non-generator) SINGLE-ARGUMENT function `(ctx) => value` is CORE-NATIVE and passes + * through unchanged, receiving the core {@link OrchestrationContext}. This fixes #321 (a native * `(ctx) => ctx.instanceId` previously mis-routed by arity to the classic context). BREAKING: a - * classic v3 orchestrator written as a plain non-generator using `context.df.*` is no longer - * supported — convert it to a `function*` generator (the standard classic shape, unaffected) or - * to the core-native `ctx.*` API. + * classic v3 orchestrator written as a plain single-arg non-generator using `context.df.*` is no + * longer supported — convert it to a `function*` generator (the standard classic shape, + * unaffected) or to the core-native `ctx.*` API. A zero-arg sync function keeps its prior classic + * classification, and `(ctx, input)` remains core-native as before. */ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): TOrchestrator { if (typeof handler === "function" && isClassicOrchestrator(handler)) { @@ -278,11 +279,13 @@ function isClassicOrchestrator(handler: TOrchestrator | ClassicOrchestrator): bo if (kind === "GeneratorFunction") { return true; // classic v3: a sync generator the engine can't drive on its own. } - // Plain SYNC (non-generator) function is CORE-NATIVE regardless of arity: it passes through - // unchanged and receives the core OrchestrationContext. Only a sync generator (`function*`) is - // classic (handled above). A classic v3 orchestrator written as a plain non-generator using + // Plain SYNC (non-generator) function: ONLY the single-argument shape `(ctx) => value` changes — + // it is now CORE-NATIVE (passes through unchanged, receiving the core OrchestrationContext), + // fixing #321. Every other arity keeps its prior classification: a zero-arg function stays classic, + // and `(ctx, input)` was already core-native. Only a sync generator (`function*`) is classic + // (handled above). A classic v3 orchestrator written as a plain single-arg non-generator using // `context.df.*` is no longer supported (breaking) — see #321 and the package README/CHANGELOG. - return false; + return handler.length === 0; } /** From 27c9355d659e8af58c1653ca229379c1d7ac8b51 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 23 Jul 2026 09:44:43 -0700 Subject: [PATCH 3/5] refactor(durable-functions): simplify classifier to return false for sync non-generators (#321) A classic v3 orchestrator must be a generator (it has to yield durable tasks), so no plain sync non-generator can be a legitimate classic orchestrator regardless of arity. Route all plain sync non-generators to core-native (return false) rather than special-casing zero-arg. The only real orchestrator shape whose behavior changes remains sync single-arg (ctx) => value; zero-arg is degenerate and behaves identically either way. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05 --- .../src/orchestration-context.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index 195a93dc..e839d41d 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -218,13 +218,12 @@ export type ClassicOrchestrator = ( * generator to `yield` durable tasks), so `async` unambiguously means core-native. * - `function*` (classic v3 sync generator) is wrapped; the engine cannot drive a sync generator, so * the wrapper delegates to it via `yield*`. - * - A plain SYNC (non-generator) SINGLE-ARGUMENT function `(ctx) => value` is CORE-NATIVE and passes - * through unchanged, receiving the core {@link OrchestrationContext}. This fixes #321 (a native + * - A plain SYNC (non-generator) function is CORE-NATIVE regardless of arity and passes through + * unchanged, receiving the core {@link OrchestrationContext}. This fixes #321 (a native * `(ctx) => ctx.instanceId` previously mis-routed by arity to the classic context). BREAKING: a - * classic v3 orchestrator written as a plain single-arg non-generator using `context.df.*` is no - * longer supported — convert it to a `function*` generator (the standard classic shape, - * unaffected) or to the core-native `ctx.*` API. A zero-arg sync function keeps its prior classic - * classification, and `(ctx, input)` remains core-native as before. + * classic v3 orchestrator written as a plain non-generator using `context.df.*` is no longer + * supported — convert it to a `function*` generator (the standard classic shape, unaffected) or + * to the core-native `ctx.*` API. */ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): TOrchestrator { if (typeof handler === "function" && isClassicOrchestrator(handler)) { @@ -279,13 +278,11 @@ function isClassicOrchestrator(handler: TOrchestrator | ClassicOrchestrator): bo if (kind === "GeneratorFunction") { return true; // classic v3: a sync generator the engine can't drive on its own. } - // Plain SYNC (non-generator) function: ONLY the single-argument shape `(ctx) => value` changes — - // it is now CORE-NATIVE (passes through unchanged, receiving the core OrchestrationContext), - // fixing #321. Every other arity keeps its prior classification: a zero-arg function stays classic, - // and `(ctx, input)` was already core-native. Only a sync generator (`function*`) is classic - // (handled above). A classic v3 orchestrator written as a plain single-arg non-generator using + // Plain SYNC (non-generator) function is CORE-NATIVE regardless of arity: it passes through + // unchanged and receives the core OrchestrationContext. Only a sync generator (`function*`) is + // classic (handled above). A classic v3 orchestrator written as a plain non-generator using // `context.df.*` is no longer supported (breaking) — see #321 and the package README/CHANGELOG. - return handler.length === 0; + return false; } /** From 7f35b25934106be095d00e3e1e59ada9b932ff47 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 23 Jul 2026 11:01:46 -0700 Subject: [PATCH 4/5] fix(durable-functions): satisfy require-yield lint on classic logger test (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classic replay-safe-logger test uses a `function*` that intentionally never yields — it logs and returns immediately to exercise the classic log wiring, and the test asserts completion on the first `.next()`. ESLint `require-yield` flags empty generators, so disable that rule on the line, matching existing repo usage (e.g. parent-orchestration-instance.spec.ts). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05 --- .../test/unit/orchestration-context.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts index bfc9e801..add2a1bf 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -274,6 +274,7 @@ describe("wrapOrchestrator", () => { it("exposes a replay-safe logger as context.log/error on the classic context", async () => { // A classic orchestrator is a sync generator (`function*`); the wrapper invokes it with the full // classic context, so the replay-safe log wiring is exercised here. + // eslint-disable-next-line require-yield const classic = function* (context: ClassicOrchestrationContext): Generator, string, unknown> { context.log("hi"); context.error("boom"); From c21e7076b5b8eec859844691ef446dd42449e644 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 23 Jul 2026 11:09:46 -0700 Subject: [PATCH 5/5] chore(durable-functions): drop premature CHANGELOG entry from #321 fix Restore packages/azure-functions-durable/CHANGELOG.md to its main-branch state. CHANGELOG entries are finalized at release time; the breaking-change note remains documented in the package README's "Migrating from durable-functions v3" section, which is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d86f5f69-4e1a-4c1d-b017-5e290c85cc05 --- packages/azure-functions-durable/CHANGELOG.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index 0afddfca..00858aea 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -2,14 +2,4 @@ ## TBD -- Change ([#321](https://github.com/microsoft/durabletask-js/issues/321)): a plain **synchronous, - single-argument, non-generator** orchestrator `(ctx) => value` is now always treated as a - **core-native** orchestrator and receives the core `OrchestrationContext` (`instanceId`, - `callActivity`, `newGuid`, …), fixing #321 where it previously received only the classic - `{ df, log }` context (core members read as `undefined` / threw `TypeError`). - - **BREAKING:** a *classic* v3 orchestrator written as a plain **non-generator** function using - `context.df.*` in this exact shape (sync, single-arg, non-generator) no longer receives the - classic context. Convert it to the standard classic **generator** form (`function*`, - unaffected) or to core-native (`ctx.*`). Sync generators, `async` orchestrators, async - generators, and 2-argument `(ctx, input)` handlers are unchanged. - Details to be finalized at release time.