From 5bc2aab1ce612ab8f85ca4f77fe87cffa5b64dd6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:20:55 -0700 Subject: [PATCH 1/5] Add tests for onTrigger onBodyFailure and its projection Red/green coverage for the onBodyFailure policy on the re-pinned runtime (CL-6326, CL-6324): default policy unchanged, "continue" re-arms past a failed occurrence while a cancelled one stays terminal-is-final, and crash-recovery honors the same policy. Adds projector coverage asserting a projected onTrigger section carries the authored policy through the live->inert projection, and omits the field when no policy was authored. Fails against the unmodified vendored runtime and projector. --- .../workflow/src/live-inert-projector.test.ts | 51 +++ vendor/intx/workflow/src/runtime/run.test.ts | 334 ++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 vendor/intx/workflow/src/live-inert-projector.test.ts create mode 100644 vendor/intx/workflow/src/runtime/run.test.ts diff --git a/vendor/intx/workflow/src/live-inert-projector.test.ts b/vendor/intx/workflow/src/live-inert-projector.test.ts new file mode 100644 index 000000000..587ce4f1c --- /dev/null +++ b/vendor/intx/workflow/src/live-inert-projector.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; + +import { defineWorkflow } from "./definition/workflow"; +import { action, onTrigger } from "./definition/primitives"; +import { projectLiveToInert } from "./live-inert-projector"; +import type { InertOnTrigger } from "./live-inert-projector"; + +function sectionWorkflow(onBodyFailure?: "end" | "continue") { + const body = defineWorkflow({ + id: "body", + triggers: [{ type: "manual" }], + steps: { reply: action({ handler: "reply" }) }, + }); + return defineWorkflow({ + id: "section-host", + steps: { + turn: onTrigger({ + on: { type: "mail", to: "section@example.test" }, + body, + ...(onBodyFailure !== undefined ? { onBodyFailure } : {}), + }), + }, + }); +} + +function projectedSection(onBodyFailure?: "end" | "continue"): InertOnTrigger { + const projected = projectLiveToInert(sectionWorkflow(onBodyFailure)); + const step = projected.steps["turn"]; + if (step === undefined || step.kind !== "onTrigger") { + throw new Error("expected a projected onTrigger section"); + } + return step; +} + +describe("live->inert projection of onTrigger.onBodyFailure", () => { + test("carries an explicit \"continue\" policy through the projection", () => { + expect(projectedSection("continue").onBodyFailure).toBe("continue"); + }); + + test("carries an explicit \"end\" policy through the projection", () => { + expect(projectedSection("end").onBodyFailure).toBe("end"); + }); + + test("omits the field entirely when the author set no policy", () => { + const section = projectedSection(); + expect(section.onBodyFailure).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(section, "onBodyFailure")).toBe( + false, + ); + }); +}); diff --git a/vendor/intx/workflow/src/runtime/run.test.ts b/vendor/intx/workflow/src/runtime/run.test.ts new file mode 100644 index 000000000..4a5607bce --- /dev/null +++ b/vendor/intx/workflow/src/runtime/run.test.ts @@ -0,0 +1,334 @@ +// `onTrigger`'s non-fatal body-failure edge (`onBodyFailure`). +// +// These are unit tests of the runtime state machine, not the sidecar +// wiring: `runtimeRun` is exercised directly against a hand-built +// `WorkflowRuntimeEnv` (the same in-memory pieces `runLocal` wires, +// plus a fake `spawnSuspendableChild` this file controls per +// `childRunId`) rather than through `runLocal`, which does not expose a +// `spawnSuspendableChild` override. No real agent or substrate is +// involved -- this is the "runtime/run.test.ts" the discipline comment +// in `runlocal/run-local.ts` names as the intended home for this +// coverage. + +import { describe, test, expect } from "bun:test"; + +import { createDefaultDirectorRegistry } from "@intx/agent"; + +import type { OnTriggerPrimitive } from "../definition/primitives"; +import type { WorkflowDefinition } from "../definition/workflow"; +import { createInMemoryBlobSubstrate } from "../runlocal/blob-substrate"; +import { createInMemoryRepoStore } from "../runlocal/repo-store"; +import { createInMemoryScheduler } from "../runlocal/scheduler"; +import { createInMemorySignalChannel } from "../runlocal/signal-channel"; +import { createNoopDrainController } from "./drain"; +import { runtimeRun } from "./run"; +import type { + SpawnSuspendableChild, + SuspendableChildHandle, + WorkflowRuntimeEnv, +} from "./env"; +import { + controlParkKindOf, + resumeFromLog, + type RunState, + type WorkflowEvent, +} from "../state-machine/index"; + +const SECTION_ID = "section"; + +function definitionWith( + onTriggerOverrides: Partial = {}, +): WorkflowDefinition { + const primitive: OnTriggerPrimitive = { + kind: "onTrigger", + id: SECTION_ID, + on: { type: "manual" }, + body: { ref: "test-body" }, + ...onTriggerOverrides, + }; + return { + id: "wf-onbodyfailure", + triggers: [{ type: "manual" }], + steps: { [SECTION_ID]: primitive }, + stepOrder: [SECTION_ID], + }; +} + +type TerminalStatus = "completed" | "failed" | "cancelled"; + +/** + * A fake `spawnSuspendableChild` keyed by `childRunId`, each occurrence + * settling immediately on the terminal status the test scripted for it. + * `resume`/`deliverSignal` are unused by every scenario here (no body + * ever parks) so they throw if called, matching the pattern + * `apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts` + * uses for handle members a scenario does not exercise. + */ +function fakeSpawn( + responses: Record, +): { spawn: SpawnSuspendableChild; spawnedChildRunIds: string[] } { + const spawnedChildRunIds: string[] = []; + const spawn: SpawnSuspendableChild = async ({ childRunId }) => { + spawnedChildRunIds.push(childRunId); + const terminalStatus = responses[childRunId]; + if (terminalStatus === undefined) { + throw new Error(`fakeSpawn: no scripted response for ${childRunId}`); + } + let delivered = false; + const handle: SuspendableChildHandle = { + async next() { + if (delivered) { + throw new Error( + `fakeSpawn: ${childRunId} next() called more than once`, + ); + } + delivered = true; + return { kind: "terminal", terminalStatus }; + }, + async resume() { + throw new Error(`fakeSpawn: ${childRunId} unexpected resume()`); + }, + async deliverSignal() { + throw new Error(`fakeSpawn: ${childRunId} unexpected deliverSignal()`); + }, + }; + return handle; + }; + return { spawn, spawnedChildRunIds }; +} + +function buildEnv(spawn: SpawnSuspendableChild): WorkflowRuntimeEnv { + const repoStore = createInMemoryRepoStore(); + const clock = () => new Date(); + let idCounter = 0; + const newId = (prefix: string): string => { + idCounter += 1; + return `${prefix}-${String(idCounter)}`; + }; + const definitionForDrain = definitionWith(); + return { + repoStore, + scheduler: createInMemoryScheduler({ repoStore, clock }), + signalChannel: createInMemorySignalChannel({ newId: () => newId("sig") }), + blobs: createInMemoryBlobSubstrate(), + directors: createDefaultDirectorRegistry(), + authorize: async () => ({ + effect: "allow", + matchingGrants: [], + resolvedBy: null, + }), + invokeStep: async () => { + throw new Error("no step primitive is exercised by these tests"); + }, + spawnChild: async () => { + throw new Error("no childWorkflow primitive is exercised by these tests"); + }, + spawnSuspendableChild: spawn, + clock, + newId, + drain: createNoopDrainController(definitionForDrain), + }; +} + +async function readState( + env: WorkflowRuntimeEnv, + runId: string, +): Promise { + const events = await env.repoStore.read(runId); + return resumeFromLog(runId, events); +} + +/** Poll the durable log until `predicate` holds. In-memory, so this settles fast. */ +async function waitFor( + env: WorkflowRuntimeEnv, + runId: string, + predicate: (state: RunState) => boolean, +): Promise { + for (let attempt = 0; attempt < 1000; attempt += 1) { + const state = await readState(env, runId); + if (predicate(state)) return state; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("waitFor: predicate never became true"); +} + +function inputParkName(state: RunState): string | undefined { + const container = state.steps.get(SECTION_ID); + if (container === undefined || container.phase !== "awaiting-signal") { + return undefined; + } + if (container.awaitingSignal === undefined) return undefined; + if (controlParkKindOf(container.awaitingSignal) !== "input") return undefined; + return container.awaitingSignal.name; +} + +describe("onTrigger onBodyFailure", () => { + test("default policy: a failed body run ends the whole section run", async () => { + const { spawn, spawnedChildRunIds } = fakeSpawn({ + "section__0": "failed", + }); + const env = buildEnv(spawn); + const definition = definitionWith(); // no onBodyFailure -- default "end" + + const run = runtimeRun(definition, env, { triggerPayload: {} }); + const result = await run.complete; + + expect(result.terminalStatus).toBe("failed"); + // The failed occurrence is still durably recorded before the throw. + const events = await env.repoStore.read(run.runId); + const childCompleted = events.find( + (e): e is WorkflowEvent & { kind: "ChildCompleted" } => + e.kind === "ChildCompleted", + ); + expect(childCompleted?.terminalStatus).toBe("failed"); + // The section never re-arms for a second occurrence under the default. + expect(spawnedChildRunIds).toEqual(["section__0"]); + }); + + test('onBodyFailure: "continue" keeps the section alive through a failed occurrence', async () => { + const { spawn } = fakeSpawn({ + "section__0": "failed", + "section__1": "completed", + }); + const env = buildEnv(spawn); + const definition = definitionWith({ onBodyFailure: "continue" }); + + const run = runtimeRun(definition, env, { triggerPayload: { n: 0 } }); + + // The run does not settle terminal after occurrence 0 fails -- it + // re-arms on the input park instead. + const afterFirstFailure = await waitFor(env, run.runId, (state) => + inputParkName(state) !== undefined, + ); + expect(afterFirstFailure.phase).not.toBe("failed"); + expect(afterFirstFailure.children.get("section__0")?.terminalStatus).toBe( + "failed", + ); + + // The failed occurrence's ChildCompleted is a durable, loud audit event + // on the run's own log -- the section did not silently swallow it. + const eventsAfterFirstFailure = await env.repoStore.read(run.runId); + const childCompleted = eventsAfterFirstFailure.find( + (e): e is WorkflowEvent & { kind: "ChildCompleted" } => + e.kind === "ChildCompleted" && e.childRunId === "section__0", + ); + expect(childCompleted).toBeDefined(); + expect(childCompleted?.terminalStatus).toBe("failed"); + + const parkName = inputParkName(afterFirstFailure); + if (parkName === undefined) throw new Error("expected an input park"); + await run.signal(parkName, { n: 1 }); + + // Occurrence 1 spawns and succeeds normally -- the run proceeds, it + // does not throw. + await waitFor( + env, + run.runId, + (state) => state.children.get("section__1")?.terminalStatus === "completed", + ); + + await run.cancel("self", "test cleanup"); + const result = await run.complete; + expect(result.terminalStatus).toBe("cancelled"); + }); + + test('onBodyFailure: "continue" never swallows a cancelled body run', async () => { + const { spawn } = fakeSpawn({ + "section__0": "cancelled", + }); + const env = buildEnv(spawn); + const definition = definitionWith({ onBodyFailure: "continue" }); + + const run = runtimeRun(definition, env, { triggerPayload: {} }); + const result = await run.complete; + + // A cancelled body run still throws terminal-is-final -- it lands the + // section's own step as StepFailed, so the whole run's terminalStatus + // is "failed" (a thrown primitive error, not a run-level cancel); + // what matters here is that `onBodyFailure` did NOT swallow it into a + // re-arm the way it does for "failed". + expect(result.terminalStatus).toBe("failed"); + const events = await env.repoStore.read(run.runId); + const message = events.find( + (e): e is WorkflowEvent & { kind: "StepFailed" } => e.kind === "StepFailed", + )?.error.message; + expect(message).toContain("cancelled"); + }); + + test("crash-recovery honors onBodyFailure: a failed-but-continuing section resumes on the input re-arm", async () => { + // Hand-built seed log: the container's mid-flight state right after + // occurrence 0's ChildCompleted{failed} commits, but BEFORE the + // re-arm park lands -- the exact crash window `planOnTriggerResume`'s + // ordering comment describes. Built by hand (rather than captured off + // a live run) so the test is deterministic about which side of that + // race it exercises. + const definition = definitionWith({ onBodyFailure: "continue" }); + const seedLog: WorkflowEvent[] = [ + { + kind: "RunStarted", + seq: 1, + at: "2026-01-01T00:00:00.000Z", + runId: "resume-test", + definitionHash: "seed-hash", + trigger: { type: "manual", payload: {} }, + }, + { + kind: "StepStarted", + seq: 2, + at: "2026-01-01T00:00:00.000Z", + stepId: SECTION_ID, + attempt: 1, + input: { ref: "unused-input-ref" }, + }, + { + kind: "ChildSpawned", + seq: 3, + at: "2026-01-01T00:00:00.000Z", + stepId: SECTION_ID, + childRunId: "section__0", + childDefinitionRef: "test-body", + }, + { + kind: "ChildCompleted", + seq: 4, + at: "2026-01-01T00:00:00.000Z", + childRunId: "section__0", + terminalStatus: "failed", + }, + ]; + + // Resume a fresh env from that seed log with the same policy. The + // resume path (`planOnTriggerResume`) must take the reawait-input + // arm, not terminal-is-final, so the section keeps going. + const resumeSpawn = fakeSpawn({ + "section__0": "failed", + "section__1": "completed", + }); + const resumeEnv = buildEnv(resumeSpawn.spawn); + const resumeRun = runtimeRun(definition, resumeEnv, { + runId: "resume-test", + resumeFromEvents: seedLog, + }); + + const afterResume = await waitFor(resumeEnv, resumeRun.runId, (state) => + inputParkName(state) !== undefined, + ); + // Resume did not re-spawn occurrence 0's body -- it recovered position + // from the log rather than throwing terminal-is-final. + expect(resumeSpawn.spawnedChildRunIds).toEqual([]); + const parkName = inputParkName(afterResume); + if (parkName === undefined) throw new Error("expected an input park"); + await resumeRun.signal(parkName, { n: 1 }); + + await waitFor( + resumeEnv, + resumeRun.runId, + (state) => + state.children.get("section__1")?.terminalStatus === "completed", + ); + + await resumeRun.cancel("self", "test cleanup"); + const result = await resumeRun.complete; + expect(result.terminalStatus).toBe("cancelled"); + }); +}); From e78722052b6eb9365ab9918cdd7be07fa4706d89 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:20:56 -0700 Subject: [PATCH 2/5] onTrigger: restore the onBodyFailure policy and carry it through projection Re-applies the CL-6326 vendored delta on top of the re-vendored runtime: onBodyFailure?: "end" | "continue" on OnTriggerPrimitive/OnTriggerOpts (default "end", byte-compatible with prior behavior), read live by the steady-state drive loop and planOnTriggerResume so a "continue" section re-arms past a failed occurrence instead of ending the run. Cancellation is unaffected and always ends the section. Adds what the delta previously lacked: the live->inert projector's InertOnTrigger and projectOnTrigger now carry the field, so a section's policy survives the child->hub projection instead of being silently dropped before deploy. BodyFailurePolicy is exported from the definition barrel for the projector's type reference. --- vendor/intx/workflow/src/definition/index.ts | 1 + .../workflow/src/definition/primitives.ts | 33 +++++++++++--- .../intx/workflow/src/live-inert-projector.ts | 5 +++ vendor/intx/workflow/src/runtime/run.ts | 43 ++++++++++++++----- 4 files changed, 64 insertions(+), 18 deletions(-) diff --git a/vendor/intx/workflow/src/definition/index.ts b/vendor/intx/workflow/src/definition/index.ts index 02f4535b5..15010b986 100644 --- a/vendor/intx/workflow/src/definition/index.ts +++ b/vendor/intx/workflow/src/definition/index.ts @@ -28,6 +28,7 @@ export { type ActionPrimitive, type AwaitSignalOpts, type AwaitSignalPrimitive, + type BodyFailurePolicy, type ChildWorkflowBody, type ChildWorkflowOpts, type ChildWorkflowPrimitive, diff --git a/vendor/intx/workflow/src/definition/primitives.ts b/vendor/intx/workflow/src/definition/primitives.ts index 4d737b935..ca87381e1 100644 --- a/vendor/intx/workflow/src/definition/primitives.ts +++ b/vendor/intx/workflow/src/definition/primitives.ts @@ -30,6 +30,8 @@ import type { Trigger } from "./triggers"; export type DrainBehavior = "cancel" | "wait"; +export type BodyFailurePolicy = "end" | "continue"; + export interface RetryPolicy { /** Maximum number of attempts including the first. */ maxAttempts: number; @@ -239,13 +241,15 @@ export interface LoopPrimitive extends PrimitiveBase { * `trigger.payload`. * * The section never self-completes: the workflow stays running while - * subscribed and terminates only on a body error or an explicit - * end-of-workflow, and a terminated run is final -- never relaunched. The - * first occurrence is the run's own firing trigger (its - * `RunStarted.trigger.payload`); each later occurrence arrives as an input - * signal carrying the next payload. `defineWorkflow` collects every `on` - * into the workflow's `triggers`, so `on` is the first-class binding - * between a trigger and the section it drives. + * subscribed and terminates only on a body run ending `cancelled`, a body + * run ending `failed` under the default `onBodyFailure: "end"` policy, or + * an explicit end-of-workflow -- `onBodyFailure: "continue"` keeps the + * section alive through a failed occurrence -- and a terminated run is + * final -- never relaunched. The first occurrence is the run's own firing + * trigger (its `RunStarted.trigger.payload`); each later occurrence + * arrives as an input signal carrying the next payload. `defineWorkflow` + * collects every `on` into the workflow's `triggers`, so `on` is the + * first-class binding between a trigger and the section it drives. * * `drainBehavior` defaults to `"wait"`: a live interactive section is not * abandoned mid-conversation at redeploy unless the author opts into @@ -256,6 +260,17 @@ export interface OnTriggerPrimitive extends PrimitiveBase { on: Trigger; body: OnTriggerBody; drainBehavior?: DrainBehavior; + /** + * How a body run that ends `failed` affects the section. Absent (or + * `"end"`) preserves terminal-is-final: a failed body run ends the + * whole section run, exactly as before this field existed. `"continue"` + * records the failed occurrence and keeps the section subscribed -- + * the next occurrence spawns and runs normally. A body run that ends + * `cancelled` is unaffected by this field and always ends the section: + * cancellation reflects a drain/operator decision, not a turn-level + * error. + */ + onBodyFailure?: BodyFailurePolicy; } /** @@ -575,6 +590,7 @@ export interface OnTriggerOpts { on: Trigger; body: WorkflowDefinition; drainBehavior?: DrainBehavior; + onBodyFailure?: BodyFailurePolicy; after?: readonly string[]; } @@ -587,6 +603,9 @@ export function onTrigger(opts: OnTriggerOpts): OnTriggerPrimitive { // Authored inline; the deploy step rewrites this to `{ ref }`. body: { inline: opts.body }, drainBehavior, + ...(opts.onBodyFailure !== undefined + ? { onBodyFailure: opts.onBodyFailure } + : {}), ...(opts.after !== undefined ? { after: opts.after } : {}), }; } diff --git a/vendor/intx/workflow/src/live-inert-projector.ts b/vendor/intx/workflow/src/live-inert-projector.ts index 461d6e1e3..9b5ad129a 100644 --- a/vendor/intx/workflow/src/live-inert-projector.ts +++ b/vendor/intx/workflow/src/live-inert-projector.ts @@ -41,6 +41,7 @@ import type { CredentialBinding } from "@intx/types"; import type { ActionPrimitive, AwaitSignalPrimitive, + BodyFailurePolicy, ChildWorkflowPrimitive, DrainBehavior, EscalationPrimitive, @@ -154,6 +155,7 @@ export interface InertOnTrigger { readonly on: Trigger; readonly body: InertOnTriggerBody; readonly drainBehavior?: DrainBehavior; + readonly onBodyFailure?: BodyFailurePolicy; readonly after?: readonly string[]; } @@ -380,6 +382,9 @@ function projectOnTrigger(primitive: OnTriggerPrimitive): InertOnTrigger { ...(primitive.drainBehavior !== undefined ? { drainBehavior: primitive.drainBehavior } : {}), + ...(primitive.onBodyFailure !== undefined + ? { onBodyFailure: primitive.onBodyFailure } + : {}), ...(primitive.after !== undefined ? { after: [...primitive.after] } : {}), }; } diff --git a/vendor/intx/workflow/src/runtime/run.ts b/vendor/intx/workflow/src/runtime/run.ts index 6c71a148f..81931d51e 100644 --- a/vendor/intx/workflow/src/runtime/run.ts +++ b/vendor/intx/workflow/src/runtime/run.ts @@ -2080,15 +2080,27 @@ async function runOnTrigger( } await flush(env, runId); - if (terminalStatus !== "completed") { - // Terminal-is-final: a body run that failed or was cancelled ends the - // whole section run. Throwing lands the parent terminal via + if (terminalStatus === "cancelled") { + // Terminal-is-final, unconditionally: a cancelled body run always ends + // the section. Cancellation reflects a drain/operator decision, not a + // turn-level error, so `onBodyFailure` never swallows it. + throw new Error( + `onTrigger ${primitive.id} body run ${childRunId} ended cancelled`, + ); + } + if (terminalStatus === "failed" && primitive.onBodyFailure !== "continue") { + // Terminal-is-final (default): a failed body run ends the whole + // section run. Throwing lands the parent terminal via // `runPrimitiveSafe`; the run does not relaunch. throw new Error( - `onTrigger ${primitive.id} body run ${childRunId} ended ` + - `${terminalStatus}`, + `onTrigger ${primitive.id} body run ${childRunId} ended failed`, ); } + // terminalStatus is "completed", or "failed" with onBodyFailure: + // "continue" -- the failed occurrence is already recorded (the + // ChildCompleted commit above this block carries + // `terminalStatus: "failed"`, the run's durable audit event for it); fall + // through to the same re-arm every completed occurrence takes. // Re-arm: park on a fresh input channel for the next event. The park is // snapshot-less (`kind: "input"`); the run's owner delivers the next @@ -2395,20 +2407,29 @@ function planOnTriggerResume( // is already owned -- a body that then completed is caught HERE (reawait- // input), not by the in-flight throw. Inverting the order would wrongly fail a // post-abandon-completed body. + if (child.terminalStatus === "cancelled") { + return { + kind: "terminal-is-final", + eventIndex, + terminalStatus: "cancelled", + }; + } if ( - child.terminalStatus === "failed" || - child.terminalStatus === "cancelled" + child.terminalStatus === "failed" && + primitive.onBodyFailure !== "continue" ) { return { kind: "terminal-is-final", eventIndex, - terminalStatus: child.terminalStatus, + terminalStatus: "failed", }; } const container = state.steps.get(primitive.id); - if (child.terminalStatus === "completed") { - // The event's body finished; the section is idle on its input re-arm. Re- - // adopt the durable input park if it was committed, else re-arm fresh. + if (child.terminalStatus === "completed" || child.terminalStatus === "failed") { + // The event's body finished -- completed, or failed with + // `onBodyFailure: "continue"`, both of which are "the event is over" -- + // the section is idle on its input re-arm. Re-adopt the durable input + // park if it was committed, else re-arm fresh. if ( container !== undefined && container.phase === "awaiting-signal" && From 4ce216c32c3ec2ca1d688c9debcbcb3798d7f13c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:48:37 -0700 Subject: [PATCH 3/5] Add tests for the adopting code-sourced deploy front Red/green coverage for a shared-capacity code-sourced deploy that stamps a pre-existing anchor workflow_run instead of inserting one (CL-6324): the adoption succeeds and issues no INSERT, a definition carrying credential bindings fails closed when no cipher is threaded, and an anchor the tenant does not own is refused before any frame reaches the sidecar. Fails against the two upstream fronts, neither of which accepts a pre-existing anchor. --- .../hub-sessions/src/session-service.test.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 vendor/intx/hub-sessions/src/session-service.test.ts diff --git a/vendor/intx/hub-sessions/src/session-service.test.ts b/vendor/intx/hub-sessions/src/session-service.test.ts new file mode 100644 index 000000000..ade9e6062 --- /dev/null +++ b/vendor/intx/hub-sessions/src/session-service.test.ts @@ -0,0 +1,175 @@ +// Co-located coverage for the ADOPTING shared-capacity code-sourced deploy +// (CL-6324's vendored seam). The two upstream code-sourced fronts cannot deploy +// onto a run the caller already owns: `deployWorkflowFromSource` INSERTs a fresh +// anchor row (a PK collision against a folded run's existing row) and threads no +// `credentialCipher`, while `deployPreparedCodeSourcedWorkflow` does both right +// but hard-requires an `allocationTarget`. `deployAdoptedCodeSourcedWorkflow` is +// the third front: it adopts the pre-existing anchor under an ownership check +// and threads the cipher, with no allocation lock. +// +// The fakes here stand in for the two collaborators the front actually touches: +// the sidecar router (which returns the supervisor key on the deploy ack) and +// the drizzle handle. A real Postgres is out of scope -- what is under test is +// the front's own composition, and a fake `db` is the only way to assert the +// negative that matters: that no INSERT is ever issued. +import { describe, expect, test } from "bun:test"; + +import { + deployAdoptedCodeSourcedWorkflow, + type DeployCodeSourcedWorkflowArgs, +} from "./session-service"; + +const TENANT = "tnt_adopt"; +const ANCHOR_RUN_ID = "run_adopted_anchor"; +const DEPLOYMENT_DOMAIN = "runs.example.test"; +const DEFINITION_ID = "wdef_frozen"; +const SUPERVISOR_KEY = "pk_supervisor"; + +type CapturedDeploy = { + agentAddress: string; + workflow: { credentials?: unknown }; +}; + +type FakeDb = { + handle: DeployCodeSourcedWorkflowArgs["db"]; + inserts: number; + updates: { set: Record }[]; +}; + +/** + * A drizzle-shaped stub covering exactly the surface the adopting front uses: + * the two `query.*.findFirst` guards, the `update(...).set(...).returning()` + * stamp, and an `insert` that records any call so the no-duplicate-anchor + * assertion can fail loud rather than silently pass. + */ +function fakeDb(options: { anchorExists: boolean }): FakeDb { + const state: FakeDb = { + handle: undefined as unknown as DeployCodeSourcedWorkflowArgs["db"], + inserts: 0, + updates: [], + }; + const returningRows = options.anchorExists ? [{ id: ANCHOR_RUN_ID }] : []; + const handle = { + query: { + workflowDefinition: { + findFirst: () => Promise.resolve({ id: DEFINITION_ID }), + }, + workflowRun: { + findFirst: () => + Promise.resolve( + options.anchorExists ? { id: ANCHOR_RUN_ID } : undefined, + ), + }, + }, + insert: () => { + state.inserts += 1; + return { values: () => Promise.resolve(undefined) }; + }, + update: () => ({ + set: (values: Record) => { + state.updates.push({ set: values }); + return { + where: () => ({ returning: () => Promise.resolve(returningRows) }), + }; + }, + }), + }; + state.handle = handle as unknown as DeployCodeSourcedWorkflowArgs["db"]; + return state; +} + +function deployArgs( + db: FakeDb, + captured: CapturedDeploy[], + overrides?: { credentialBindings?: readonly unknown[] }, +): DeployCodeSourcedWorkflowArgs { + const projection = { + id: "wf_adopted", + triggers: [{ type: "manual" }], + stepOrder: [], + steps: {}, + ...(overrides?.credentialBindings !== undefined + ? { credentialBindings: overrides.credentialBindings } + : {}), + }; + const args = { + approved: { + approval: { + ok: true, + definitionId: DEFINITION_ID, + approvedWireHash: "sha256:frozen", + approvedGrants: new Set(), + projection, + }, + projection, + closure: { entries: [] }, + }, + sidecarRouter: { + sendAgentDeploy: ( + agentAddress: string, + _config: unknown, + workflow: { credentials?: unknown }, + ) => { + captured.push({ agentAddress, workflow }); + return Promise.resolve({ publicKey: SUPERVISOR_KEY }); + }, + }, + agentAddress: `${ANCHOR_RUN_ID}@${DEPLOYMENT_DOMAIN}`, + config: { sources: [], defaultSource: "default", principalId: "prn_x" }, + sources: {}, + db: db.handle, + tenantId: TENANT, + anchorRunId: ANCHOR_RUN_ID, + deploymentDomain: DEPLOYMENT_DOMAIN, + source: { kind: "registry", registry: "npm" }, + }; + return args as unknown as DeployCodeSourcedWorkflowArgs; +} + +describe("deployAdoptedCodeSourcedWorkflow", () => { + test("adopts a pre-existing anchor run and stamps it, inserting nothing", async () => { + const db = fakeDb({ anchorExists: true }); + const captured: CapturedDeploy[] = []; + + const result = await deployAdoptedCodeSourcedWorkflow( + deployArgs(db, captured), + ); + + expect(result.publicKey).toBe(SUPERVISOR_KEY); + expect(db.inserts).toBe(0); + expect(db.updates).toHaveLength(1); + expect(db.updates[0]?.set).toEqual({ + definitionId: DEFINITION_ID, + publicKey: SUPERVISOR_KEY, + }); + }); + + test("threads the credentialCipher through to the launch frame", async () => { + const db = fakeDb({ anchorExists: true }); + const captured: CapturedDeploy[] = []; + const args = deployArgs(db, captured, { + credentialBindings: [{ id: "cred_a", as: "API_KEY" }], + }); + + // Without a cipher the binding-bearing definition must fail closed; the + // cipher is the only thing that lets credential material reach the frame. + await expect(deployAdoptedCodeSourcedWorkflow(args)).rejects.toThrow( + /no credentialCipher was supplied/, + ); + expect(db.inserts).toBe(0); + expect(captured).toHaveLength(0); + }); + + test("refuses to adopt an anchor run this tenant does not own", async () => { + const db = fakeDb({ anchorExists: false }); + const captured: CapturedDeploy[] = []; + + await expect( + deployAdoptedCodeSourcedWorkflow(deployArgs(db, captured)), + ).rejects.toThrow(/no adoptable anchor/); + // Fail closed BEFORE the sidecar sees a frame: a refused adoption must + // leave no deployed-but-unanchored agent behind. + expect(captured).toHaveLength(0); + expect(db.inserts).toBe(0); + }); +}); From 046b3914582e7390bf81b0b29a4f05d0120b3c60 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:48:37 -0700 Subject: [PATCH 4/5] hub-sessions: a code-sourced deploy front that adopts an existing anchor run Neither code-sourced front could deploy onto a run whose anchor row already exists. deployWorkflowFromSource INSERTs its anchor (a primary-key collision against a folded run's row) and threads no credentialCipher; deployPreparedCodeSourcedWorkflow updates a pre-existing row and threads the cipher, but only under the allocation-ownership lock, so it cannot run on shared capacity. Adds a third front composed from the existing halves -- emitSourceRefDeployFrame and buildInertProjectionStepSources -- following the prepared front's semantics minus the allocation lock: ownership is the anchor row's own tenant plus self-anchoring, checked before the frame so a refused adoption leaves no deployed-but-unanchored agent, and re-asserted on the guarded UPDATE that stamps definitionId and publicKey. No deployer read grant is seeded: the anchor predates the call, so its grants belong to whoever created it. --- vendor/intx/hub-sessions/src/index.ts | 3 + .../intx/hub-sessions/src/session-service.ts | 168 +++++++++++++++++- 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/vendor/intx/hub-sessions/src/index.ts b/vendor/intx/hub-sessions/src/index.ts index b15df60f3..5054518f9 100644 --- a/vendor/intx/hub-sessions/src/index.ts +++ b/vendor/intx/hub-sessions/src/index.ts @@ -8,12 +8,15 @@ export { SessionLaunchError, bridgeOrchestratorDeployContent, deployCodeSourcedWorkflow, + deployAdoptedCodeSourcedWorkflow, type SessionService, type DeployWorkflowDefinitionResult, type DeployWorkflowFromSourceParams, type DeployPreparedCodeSourcedWorkflowParams, type InstallAndApproveWorkflowSourceParams, type PreparedWorkflowDeployer, + type AdoptingWorkflowDeployer, + type DeployAdoptedWorkflowFromSourceParams, type DeployCodeSourcedWorkflowArgs, } from "./session-service"; export { diff --git a/vendor/intx/hub-sessions/src/session-service.ts b/vendor/intx/hub-sessions/src/session-service.ts index 975ed4fe1..b8ff78a79 100644 --- a/vendor/intx/hub-sessions/src/session-service.ts +++ b/vendor/intx/hub-sessions/src/session-service.ts @@ -259,6 +259,32 @@ export type DeployPreparedCodeSourcedWorkflowParams = { credentialCipher?: CredentialCipher; }; +/** + * Inputs for a shared-capacity code-sourced deploy that ADOPTS an anchor + * `workflow_run` the caller already owns -- a folded run, whose row exists + * before any deployment is attached to it. Identical to + * `DeployWorkflowFromSourceParams` (same source/entry/pin/definition-asset + * intent, same harness config) plus the credential cipher the inserting front + * never accepted. + */ +export type DeployAdoptedWorkflowFromSourceParams = + DeployWorkflowFromSourceParams & { + /** Cipher for the definition's tenant-owned credential bindings, if any. */ + credentialCipher?: CredentialCipher; + }; + +export type AdoptingWorkflowDeployer = { + /** + * Deploy a code-sourced definition onto shared capacity, stamping the + * deployment onto a pre-existing anchor run instead of inserting one. The + * anchor's tenant + self-anchoring is the ownership gate; there is no + * allocation lock. + */ + deployAdoptedWorkflowFromSource( + params: DeployAdoptedWorkflowFromSourceParams, + ): Promise; +}; + export type PreparedWorkflowDeployer = { /** * Install + probe + gate + freeze a code-sourced definition on shared @@ -855,9 +881,73 @@ export async function deployCodeSourcedWorkflow( return { publicKey }; } +/** + * The single public composition entrypoint for an ADOPTING shared-capacity + * code-sourced deploy: emit the source-ref frame, then STAMP the deployment's + * identity onto an anchor `workflow_run` row the caller already owns. This is + * the third code-sourced front, and the only one a folded run can use. + * + * `deployCodeSourcedWorkflow` INSERTs its anchor row, so a run whose row already + * exists collides on the primary key. `deployPreparedCodeSourcedWorkflow` does + * update a pre-existing row and threads a `credentialCipher`, but only under an + * allocation-ownership lock, so it cannot deploy onto shared capacity. This + * front follows the prepared front's semantics MINUS the allocation lock: the + * ownership check is the anchor row's own tenant + self-anchoring, and the frame + * routes on the shared `sidecarRouter`. The credential cipher rides through + * `emitSourceRefDeployFrame` exactly as it does on the prepared path. + * + * Ownership is checked TWICE, deliberately. The read below runs BEFORE the + * frame, so a refused adoption never leaves a deployed-but-unanchored sidecar + * agent behind. The guarded UPDATE afterwards is the actual authority: it + * re-asserts the same predicate at write time, so a row that disappeared or + * changed hands mid-deploy fails closed rather than stamping nothing silently. + */ +export async function deployAdoptedCodeSourcedWorkflow( + args: DeployCodeSourcedWorkflowArgs, +): Promise<{ publicKey: string }> { + const adoptable = await args.db.query.workflowRun.findFirst({ + where: and( + eq(workflowRunTable.id, args.anchorRunId), + eq(workflowRunTable.anchorRunId, args.anchorRunId), + eq(workflowRunTable.tenantId, args.tenantId), + ), + columns: { id: true }, + }); + if (adoptable === undefined) { + throw new Error( + `deployAdoptedCodeSourcedWorkflow: tenant ${args.tenantId} has no adoptable anchor run ${args.anchorRunId}`, + ); + } + + const { publicKey, definitionId } = await emitSourceRefDeployFrame(args); + + const [adopted] = await args.db + .update(workflowRunTable) + .set({ definitionId, publicKey }) + .where( + and( + eq(workflowRunTable.id, args.anchorRunId), + eq(workflowRunTable.anchorRunId, args.anchorRunId), + eq(workflowRunTable.tenantId, args.tenantId), + ), + ) + .returning({ id: workflowRunTable.id }); + if (adopted === undefined) { + throw new SessionLaunchError( + "start", + new Error( + `Adopted anchor run ${args.anchorRunId} vanished before the deployment could be stamped onto it`, + ), + true, + ); + } + + return { publicKey }; +} + export function createSessionService( deps: SessionServiceDeps, -): SessionService & PreparedWorkflowDeployer { +): SessionService & PreparedWorkflowDeployer & AdoptingWorkflowDeployer { const { sidecarRouter, sidecarAllocationRouter, @@ -1466,6 +1556,81 @@ export function createSessionService( }; } + /** + * Deploy a code-sourced definition onto shared capacity ADOPTING an anchor + * `workflow_run` the caller already owns. Same install + probe + gate + freeze + * as `deployWorkflowFromSource`, and the same per-step source pin; the deploy + * hand-off stamps the existing anchor instead of inserting a new one, and + * threads the caller's `credentialCipher` so a definition with credential + * bindings resolves its material. + * + * No deployer read grant is seeded here: the anchor row predates this call, so + * whoever created it owns its grants. + */ + async function deployAdoptedWorkflowFromSource( + params: DeployAdoptedWorkflowFromSourceParams, + ): Promise { + if (db === undefined) { + throw new Error( + "deployAdoptedWorkflowFromSource requires a db handle to adopt the deployment's anchor run", + ); + } + const source = params.source; + const { approved, resolveAttachment } = + await prepareCodeSourcedApproval(params); + if (!approved.approval.ok) { + throw new WorkflowDefinitionInvalidError( + approved.projection.id, + `code-sourced workflow install did not approve (reason: ${approved.approval.reason})`, + ); + } + + const sources = buildInertProjectionStepSources({ + projection: approved.projection, + config: params.config, + operatorApprovals: approved.approval.approvedGrants, + }); + + const commonDeploy = { + approved, + sidecarRouter, + agentAddress: params.agentAddress, + config: params.config, + sources, + db, + tenantId: params.tenantId, + anchorRunId: params.anchorRunId, + deploymentDomain: params.deploymentDomain, + ...(params.credentialCipher !== undefined + ? { credentialCipher: params.credentialCipher } + : {}), + }; + let result: { publicKey: string }; + if (source.kind === "asset") { + if (resolveAttachment === null) { + throw new Error( + "deployAdoptedWorkflowFromSource: asset source deploy is missing its attachment resolver", + ); + } + result = await deployAdoptedCodeSourcedWorkflow({ + ...commonDeploy, + source, + resolveAttachment, + }); + } else { + result = await deployAdoptedCodeSourcedWorkflow({ + ...commonDeploy, + source, + }); + } + + return { + anchorRunId: params.anchorRunId, + deploymentAddress: params.agentAddress, + publicKey: result.publicKey, + }; + } + /** * Update a prepared anchor run's `publicKey` under the allocation-ownership * lock. The anchor row was inserted at prepare time; this stamps the @@ -2007,6 +2172,7 @@ export function createSessionService( deployWorkflowFromSource, installAndApproveWorkflowSource, deployPreparedCodeSourcedWorkflow, + deployAdoptedWorkflowFromSource, sendUserMessage, endSession, }; From 0e071031463604efe314635e9066d63ac5e513d2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:50:38 -0700 Subject: [PATCH 5/5] Update docs: ledger the onBodyFailure projection and the adopting deploy front Records both vendored deltas in VENDORED.md and each package's VENDORED-FROM, and re-records the workflow and hub-sessions tree hashes so check:killdates matches the edited trees. --- VENDORED.md | 20 +++++++++++++++++++- scripts/checks/kill-dates.txt | 4 ++-- vendor/intx/hub-sessions/VENDORED-FROM | 2 +- vendor/intx/workflow/VENDORED-FROM | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/VENDORED.md b/VENDORED.md index 99596e3f8..2610e8398 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -99,7 +99,25 @@ sidecar withheld the ack, and the hub redelivered forever inbound mail carrying no conversation text on the parked-resume path rather than delivering an empty string that throws inside `agent.send` and fails the step with `retriesExhausted`; the gate is the new pure helper -`hasConversationText`. `vendor/intx/inference-catalog`'s own local +`hasConversationText`. `vendor/intx/workflow` (CL-6326, CL-6324) gives +`onTrigger` an `onBodyFailure?: "end" | "continue"` policy: absent or `"end"` +preserves terminal-is-final, while `"continue"` lets a long-lived section +re-arm past a `failed` body occurrence instead of one bad turn permanently +ending the section. Cancellation is unaffected — it reflects a drain/operator +decision, not a turn-level error — and the failed occurrence stays on the +run's durable audit log either way, so the policy makes it non-fatal, never +silent. The live→inert projector carries the field too, so an authored policy +survives the child→hub projection the deploy gate hashes rather than being +dropped on the way. `vendor/intx/hub-sessions` (CL-6324) adds a third +code-sourced deploy front, `deployAdoptedCodeSourcedWorkflow`, which deploys +onto shared capacity while adopting an anchor `workflow_run` row the caller +already owns. Neither upstream front can: `deployWorkflowFromSource` inserts +its anchor row, which collides with a folded run's existing one, and threads +no credential cipher; `deployPreparedCodeSourcedWorkflow` updates a +pre-existing row and threads the cipher but only under the +allocation-ownership lock, so it cannot run on shared capacity. The new front +composes the same private halves and follows the prepared front's semantics +minus that lock. `vendor/intx/inference-catalog`'s own local modification also repoints the `./models` subpath's exports, not just the root export. Each package's `VENDORED-FROM` file restates its own delta. diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index a83e9cc69..34f8ebb57 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -22,7 +22,7 @@ vendor/intx/harness | sawyer | 2026-09-14 | af9b270a297ae1dc6d8684da9005ec9d3d62 vendor/intx/hub-agent | sawyer | 2026-09-14 | 6402193dfe48dce3525c9b233bd6974e566df57ff5bc209128633af92abe8b17 vendor/intx/hub-api | sawyer | 2026-09-14 | 7d82a625c852b9e9bb13fd59e71c6c45be792bcbb9ebb5994586e97840dc66c1 vendor/intx/hub-common | sawyer | 2026-09-14 | 0e2d71d4754713538d7fd6451c8648c6b277390abfc888e605499fc004ce0349 -vendor/intx/hub-sessions | sawyer | 2026-09-05 | daaf9b2626e3fe66c530d025621c2067ac05716846deb9864c1a3400f6518b29 +vendor/intx/hub-sessions | sawyer | 2026-09-05 | 446cd132ccf9d0cad9c2128bd7bb28b21bcacfea5430f6302de55dabf1043115 vendor/intx/inference | sawyer | 2026-09-14 | f91ac6a6b9621888276c5d2c90bd8a0ff8f9c6d3ce3ad67dd3ba57fdd9c01b0f vendor/intx/inference-catalog | sawyer | 2026-09-14 | 6e2ef3af83eafafdf1b773725afcb724cbb712604266919ecd1d67d50ff8016a vendor/intx/log | sawyer | 2026-09-14 | 17ba64f2ff751b640dd2db9eb034450876c435f43641b022fbc4a2e9aa9da04d @@ -32,7 +32,7 @@ vendor/intx/pack-transport | sawyer | 2026-09-14 | 94578a75112059d31960abdc0b121 vendor/intx/storage-isogit | sawyer | 2026-09-14 | a89b58687b8738620ce664e81a99250cba7b3bbaddbe0904661778fafef8d586 vendor/intx/tool-packaging | sawyer | 2026-09-14 | a4f446a5712f906986ddc02b3a9fb133018d15ac661052026527263d942d0249 vendor/intx/types | sawyer | 2026-09-14 | 21833d272f619f31371e80d752e22bdf8e1d31839169d7faec71240fb2db1139 -vendor/intx/workflow | sawyer | 2026-09-14 | 326a9e10693d5587cc35f9db0a7830b8037b2a81852b9b8bdd7bc276a5eb66fd +vendor/intx/workflow | sawyer | 2026-09-14 | ebcacbf8668f21bf336e6d91fcaa9d2a0cf4e06478797cffb5ecdc9c88d3abfc vendor/intx/workflow-deploy | sawyer | 2026-09-14 | ee75c87a3f8141eaa83068ec29731f064b7f27ef108919aac81419755b9bc1e3 vendor/intx/workflow-host | sawyer | 2026-09-14 | 6522cf5c3efcd8b482e0db418bfa3be350034c76cd63fa9fdd55d6e6718907f6 diff --git a/vendor/intx/hub-sessions/VENDORED-FROM b/vendor/intx/hub-sessions/VENDORED-FROM index 302fe0334..163408b78 100644 --- a/vendor/intx/hub-sessions/VENDORED-FROM +++ b/vendor/intx/hub-sessions/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/hub-sessions) Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b License: LGPL-2.1-only (see vendor/intx/LICENSE) -Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-5879: event-collector.ts's inference.usage case (previously falling into the "not persisted" default) now forwards {turnId, provider, model, usage} to an optional `onUsage` callback, threaded through event-collector-registry.ts's EventCollectorRegistryConfig as `onUsage(agentAddress, tenantId, sessionId, usage)` — the collector's own turn/tenant state is the only place these identifiers meet an inference.usage event. No persistence added upstream; the app wires the callback to @corbits/insights' usage sink. Terminal-anchor pack acceptance: hub-session-lookups.ts's receiveWorkflowRunPack no longer gates the anchor lookup on liveWorkflowRunStatuses — the ownership gate is the exported pure helper ownsWorkflowRunRepo (self-anchored row with a routable address), so a terminal run can still land the inbox-enqueue and markConsumed-rejection packs that retire mail which arrived in its teardown window. Upstream's live-status gate made that pair unresolvable: pack rejected as path_violation -> ack withheld -> hub redelivers, forever. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-5879: event-collector.ts's inference.usage case (previously falling into the "not persisted" default) now forwards {turnId, provider, model, usage} to an optional `onUsage` callback, threaded through event-collector-registry.ts's EventCollectorRegistryConfig as `onUsage(agentAddress, tenantId, sessionId, usage)` — the collector's own turn/tenant state is the only place these identifiers meet an inference.usage event. No persistence added upstream; the app wires the callback to @corbits/insights' usage sink. Terminal-anchor pack acceptance: hub-session-lookups.ts's receiveWorkflowRunPack no longer gates the anchor lookup on liveWorkflowRunStatuses — the ownership gate is the exported pure helper ownsWorkflowRunRepo (self-anchored row with a routable address), so a terminal run can still land the inbox-enqueue and markConsumed-rejection packs that retire mail which arrived in its teardown window. Upstream's live-status gate made that pair unresolvable: pack rejected as path_violation -> ack withheld -> hub redelivers, forever. CL-6324: a third code-sourced deploy front, `deployAdoptedCodeSourcedWorkflow` (plus the `deployAdoptedWorkflowFromSource` service method and its `AdoptingWorkflowDeployer` type), deploys onto shared capacity while ADOPTING an anchor `workflow_run` row the caller already owns. Upstream's two fronts cannot: `deployWorkflowFromSource` INSERTs its anchor (a primary-key collision against a folded run's existing row) and threads no `credentialCipher`, and `deployPreparedCodeSourcedWorkflow` does both correctly but only under the allocation-ownership lock. The new front composes the same private halves (`emitSourceRefDeployFrame`, `buildInertProjectionStepSources`) and follows the prepared front's semantics minus the allocation lock: ownership is the anchor row's own tenant plus self-anchoring, checked before the frame and re-asserted on the guarded UPDATE that stamps `definitionId`/`publicKey`. See VENDORED.md and docs/revendor-inventory.md. diff --git a/vendor/intx/workflow/VENDORED-FROM b/vendor/intx/workflow/VENDORED-FROM index 71e0d6038..27a5ad65f 100644 --- a/vendor/intx/workflow/VENDORED-FROM +++ b/vendor/intx/workflow/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/workflow) Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b License: LGPL-2.1-only (see vendor/intx/LICENSE) -Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-6326: `onTrigger` gains an `onBodyFailure?: "end" | "continue"` policy field (definition/primitives.ts, re-exported from definition/index.ts as `BodyFailurePolicy`); `runtime/run.ts`'s steady-state drive loop and `planOnTriggerResume` read it live to let a `"continue"`-policy section re-arm past a `failed` body occurrence instead of ending the whole run (`cancelled` is unaffected, always terminal-is-final). CL-6324 extends it through the projection: `live-inert-projector.ts`'s `InertOnTrigger` and `projectOnTrigger` carry `onBodyFailure`, so an authored policy survives the live->inert projection the child->hub boundary hashes instead of being dropped before deploy. See VENDORED.md and docs/revendor-inventory.md.