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
13 changes: 11 additions & 2 deletions VENDORED.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ never a convenience.
| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the usage forward (CL-5879), pack-acceptance fixes, adopted deploy front, wire-projection writer, event-collector serialization, or anchor ordering | sawyer | 2026-09-19 | `check:killdates` |
| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `onBodyFailure` trigger policy and its projection (CL-6326, CL-6324); retired when upstream absorbs the delta | sawyer | 2026-09-19 | `check:killdates` |
| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | Carries no delta of its own, but must bind against the vendored `@intx/workflow` (whose `onBodyFailure` field flows through the projection it hashes); retired with the workflow delta | sawyer | 2026-09-19 | `check:killdates` |
| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the empty-mail drop (CL-6164) or the action/loop runtime bind (CL-6325; its adapters live in `packages/workflow-host-actions` since CL-6435); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` |
| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the empty-mail drop (CL-6164), the action/loop runtime bind (CL-6325; its adapters live in `packages/workflow-host-actions` since CL-6435), or the body-spawn authorize/credential threading (CL-6448); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` |

The pinned commit `b5580a02` is upstream's `v0.3.0` release tag, 16 commits
past the previous pin `4ed8baf4`: a workflow-host supervisor
Expand Down Expand Up @@ -115,7 +115,16 @@ defaulting to the fail-closed empty registries; `buildRuntimeEnv` wires
`effects`, `invokeAction`, `loopFns`, and `runLoopIteration` into every
run's env and is exported so a host's runtime-env-level probe
(`apps/sidecar/test/action-runtime-env.test.ts`) can exercise the bind
without the full control-channel harness. `vendor/intx/workflow` (CL-6326, CL-6324) gives
without the full control-channel harness. `vendor/intx/workflow-host`
(CL-6448) also threads the parent child's credentials-backed authorize and
live `CredentialWiring` through the suspendable-child (onTrigger body) spawn
seam: `RunSuspendableChild`'s input and
`createInMemorySpawnSuspendableChild`'s opts gain optional
`authorize`/`credentialWiring` fields, and `run-child.ts` passes both when
building the body resolver, so a body agent's tool calls gate through the
same per-step grant snapshot a top-level step's do instead of the host's
throwing authorize stub. Upstream never runs tool-bearing body agents, so
the seam has no upstream analog yet. `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
Expand Down
13 changes: 12 additions & 1 deletion apps/sidecar/src/conversation-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,13 @@ export interface DurableConversationRegistryOpts {
export interface DurableConversationRegistry {
acquire(key: string): Promise<DurableConversationStore>;
get(key: string): DurableConversationStore;
/**
* The store for `key` if one has been acquired, else `undefined`.
* The body-turn mirror (CL-6448) runs in a `finally` that must not
* mask a build failure with `get`'s throw when the env builder never
* reached its acquire.
*/
peek(key: string): DurableConversationStore | undefined;
}

export function createDurableConversationRegistry(
Expand Down Expand Up @@ -703,6 +710,10 @@ export function createDurableConversationRegistry(
return promise;
}

function peek(key: string): DurableConversationStore | undefined {
return stores.get(key);
}

function get(key: string): DurableConversationStore {
const store = stores.get(key);
if (store === undefined) {
Expand All @@ -713,7 +724,7 @@ export function createDurableConversationRegistry(
return store;
}

return { acquire, get };
return { acquire, get, peek };
}

interface SnapshotMetadataValue {
Expand Down
36 changes: 29 additions & 7 deletions apps/sidecar/src/workflow-substrate-factory/child-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
createWorkflowRunBlobSubstrate,
createWorkflowRunRepoStore,
createInMemorySpawnChild,
type CredentialWiring,
type RunChildWorkflow,
type RunSuspendableChild,
type SourcesSnapshotRef,
Expand All @@ -57,16 +58,19 @@ import {
* from the body's on-disk `sources.json`, disjoint from the top-level's
* mutable source table so a top-level source rotation never leaks into a
* body. It also carries an `onEvent` funnel that attributes the body child's
* live inference events to the body run id on the hub timeline, and the
* child env's workflow-typed `authorize` so any tool gate a (guaranteed
* toolless) body agent would somehow reach fails loud through the child's
* throwing authorize stub.
* live inference events to the body run id on the hub timeline, the
* workflow-typed `authorize` the spawn seam threaded from the parent child
* (CL-6448: the credentials-backed authorize, so a body agent's tool calls
* gate through the same per-step grant snapshot a top-level step's do),
* and the parent's live `CredentialWiring` for tool bundles that declare a
* `credentials` capability.
*/
export type SidecarBodyStepInvoker = (
req: StepInvokeRequest,
authorize: WorkflowAuthorizeFn,
sourcesRef: SourcesSnapshotRef,
onEvent: (event: InferenceEvent) => void,
credentialWiring?: CredentialWiring,
) => Promise<StepInvokeResult>;

/**
Expand Down Expand Up @@ -320,7 +324,15 @@ export function createSidecarSpawnSuspendableChild(
const runChild = createSidecarRunChild(deps);

return async (
{ definition, childRunId, input, resumeFromEvents, signal },
{
definition,
childRunId,
input,
resumeFromEvents,
signal,
authorize: threadedAuthorize,
credentialWiring,
},
onEvent,
) => {
const {
Expand Down Expand Up @@ -361,9 +373,19 @@ export function createSidecarSpawnSuspendableChild(
),
};
const bodyInvokeStep = deps.bodyInvokeStep;
const authorize = baseEnv.authorize;
// CL-6448: prefer the parent child's credentials-backed authorize
// the spawn seam threaded in; a spawn that carried none keeps the
// fail-loud stub, so an unthreaded tool gate still surfaces
// precisely rather than silently authorizing.
const authorize = threadedAuthorize ?? baseEnv.authorize;
invokeStep = (req) =>
bodyInvokeStep(req, authorize, bodySourcesRef, onEvent);
bodyInvokeStep(
req,
authorize,
bodySourcesRef,
onEvent,
credentialWiring,
);
}

// FIFO the caller drains via `next()`: each entry is either an approval
Expand Down
78 changes: 39 additions & 39 deletions apps/sidecar/src/workflow-substrate-factory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,6 @@ export function createSidecarSubstrateFactory(
outboundMailBridge: env.outboundMailBridge,
cache: stepToolCache,
adapters: childAdapterRegistry,
toolless: false,
hubArtifactsUrl: deriveHubHttpUrl(validated.HUB_WS_URL),
sidecarToken: validated.SIDECAR_TOKEN,
definitionId: validated.WORKFLOW_DEFINITION_ID,
Expand Down Expand Up @@ -428,49 +427,50 @@ export function createSidecarSubstrateFactory(
new ChildStepNotImplementedError(req.agent.id, req.authzContext.stepId),
);

// onTrigger BODY step invoker. Unlike a childWorkflow child, an
// onTrigger section body IS staged: its definition and per-step
// inference sources land on disk beside each other at deploy, and its
// agents are guaranteed toolless (a tool-bearing body agent is rejected
// at deploy). So a body agent step runs for real through the same
// `createWorkflowStepInvoker` the top level uses -- built COLD per
// invocation (no warm registry: a body is a fresh run per section
// event, so no durableConversation, warmCache, or run-boundary mirror)
// and TOOLLESS (the build-env skips tool materialization, so a body
// stepId colliding with a parent step id can never read the parent's
// tools). The per-body `sourcesRef` is threaded in per spawn, disjoint
// from the top level's. `onEvent` is the per-run event funnel from the
// parent run's event channel, so a body agent's live inference events
// reach the hub stream (per-run attribution stays durable via
// onTrigger BODY step invoker (CL-6448). Unlike a childWorkflow child,
// an onTrigger section body IS staged: its definition and per-step
// inference sources land on disk beside each other at deploy. A body
// agent step runs for real through the same `createWorkflowStepInvoker`
// and the same `buildStepEnv` the top level uses -- so a warm-kept
// section deployment's body turns share the per-agent durable
// conversation store (each turn's agent loads every prior turn, keyed
// by the body's stable stepId across `turn__<n>` occurrences) and
// materialize the deployment's staged tool manifest (the head/step
// collapse reads the folded launch's own staged pins for a single-step
// deployment). The agent itself stays cold per occurrence; the mirror
// in the `finally` below is the body path's run-boundary durability
// flush, matching the warm top-level path's `onRunBoundary`. The
// per-body `sourcesRef` is threaded in per spawn, disjoint from the
// top level's. `onEvent` is the per-run event funnel from the parent
// run's event channel, so a body agent's live inference events reach
// the hub stream (per-run attribution stays durable via
// runs/<childRunId>/events/).
const coldBodyBuildStepEnv = createSidecarStepBuildEnv({
dataDir: validated.SIDECAR_DATA_DIR,
workflowRunRepoId,
signer: conversationSigner,
registries: parseToolRegistries(validated.SIDECAR_TOOL_REGISTRIES),
mailboxAddress: env.spawn.mailboxAddress,
stepCount: env.spawn.stepCount,
outboundMailBridge: env.outboundMailBridge,
cache: stepToolCache,
adapters: childAdapterRegistry,
toolless: true,
hubArtifactsUrl: deriveHubHttpUrl(validated.HUB_WS_URL),
sidecarToken: validated.SIDECAR_TOKEN,
definitionId: validated.WORKFLOW_DEFINITION_ID,
});
const bodyInvokeStep: SidecarBodyStepInvoker = (
const bodyInvokeStep: SidecarBodyStepInvoker = async (
req,
authorize,
sourcesRef,
onEvent,
) =>
createWorkflowStepInvoker({
workflowAuthorize: authorize,
buildEnv: (buildReq) => coldBodyBuildStepEnv(buildReq, sourcesRef),
agentFactory: stepAgentFactory,
sourcesRef,
onEvent,
})(req);
credentialWiring,
) => {
try {
return await createWorkflowStepInvoker({
workflowAuthorize: authorize,
buildEnv: (buildReq) =>
buildStepEnv(buildReq, sourcesRef, credentialWiring),
agentFactory: stepAgentFactory,
sourcesRef,
onEvent,
})(req);
} finally {
const bodyStepId = req.authzContext.stepId;
if (durableConversation !== undefined && bodyStepId !== undefined) {
// `peek`, not `get`: a build failure before the env's acquire
// must surface as itself, not as the registry's missing-store
// throw.
await durableConversation.peek(bodyStepId)?.mirrorToSubstrate();
}
}
};

// Adapt the workflow-runtime `StepInvoker` shape onto the host's
// `ChildStepInvoker` shape. The host's `onEvent` is the child's
Expand Down
67 changes: 65 additions & 2 deletions apps/sidecar/src/workflow-substrate-factory/step-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ function buildEnvDeps(dataDir: string) {
hubArtifactsUrl: "https://hub.example.com",
sidecarToken: "sc-token",
adapters: { resolve: () => undefined } as never,
toolless: true,
definitionId: "wfd_capability_owner",
};
}
Expand Down Expand Up @@ -96,7 +95,7 @@ test("the built step env carries the deploying definition's own definitionId and
);
});

test("a toolless body-step env still carries definitionId, so the binding is not tool-materialization-gated", async () => {
test("a body-step env with no staged deploy tree still carries definitionId, so the binding is not tool-materialization-gated", async () => {
const dataDir = makeTmpDataDir();
const buildEnv = createSidecarStepBuildEnv({
...buildEnvDeps(dataDir),
Expand All @@ -114,6 +113,70 @@ test("a toolless body-step env still carries definitionId, so the binding is not
);
});

// CL-6448: the body-turn history seam. A section body runs each message as
// its own child run (`turn__<n>`), so conversation continuity depends on
// the env builder resolving `storage` through the per-agent durable
// registry keyed by the STABLE stepId — never the per-run isogit store a
// changing runId would reset every turn.
test("with a durable-conversation registry, envs built for different runIds share one storage keyed by stepId", async () => {
const dataDir = makeTmpDataDir();
const acquired: string[] = [];
const sharedStorage = { marker: "durable-store" };
const registry = {
acquire: (key: string) => {
acquired.push(key);
return Promise.resolve({ storage: sharedStorage } as never);
},
get: () => {
throw new Error("unused");
},
peek: () => undefined,
};
const buildEnv = createSidecarStepBuildEnv({
...buildEnvDeps(dataDir),
durableConversation: registry as never,
});
const sourcesRef: SourcesSnapshotRef = {
current: {
step_1: [{ id: "src_1", provider: "anthropic", model: "claude" }],
},
} as unknown as SourcesSnapshotRef;

const turn1 = stepInvokeRequest();
turn1.authzContext.runId = "turn__0";
const turn2 = stepInvokeRequest();
turn2.authzContext.runId = "turn__1";

const env1 = await buildEnv(turn1, sourcesRef);
const env2 = await buildEnv(turn2, sourcesRef);

expect(acquired).toEqual(["step_1", "step_1"]);
expect(env1.storage).toBe(sharedStorage as never);
expect(env2.storage).toBe(env1.storage);
});

// CL-6448: without the registry, per-run isogit stores stay per-run — the
// multi-step cold path's behavior is unchanged.
test("without a durable-conversation registry, envs built for different runIds get distinct storage", async () => {
const dataDir = makeTmpDataDir();
const buildEnv = createSidecarStepBuildEnv(buildEnvDeps(dataDir));
const sourcesRef: SourcesSnapshotRef = {
current: {
step_1: [{ id: "src_1", provider: "anthropic", model: "claude" }],
},
} as unknown as SourcesSnapshotRef;

const turn1 = stepInvokeRequest();
turn1.authzContext.runId = "turn__0";
const turn2 = stepInvokeRequest();
turn2.authzContext.runId = "turn__1";

const env1 = await buildEnv(turn1, sourcesRef);
const env2 = await buildEnv(turn2, sourcesRef);

expect(env1.storage).not.toBe(env2.storage);
});

test("the built step env forwards the summarize-older-turns compactor (CL-6204) like the other env fields above", async () => {
const dataDir = makeTmpDataDir();
const buildEnv = createSidecarStepBuildEnv(buildEnvDeps(dataDir));
Expand Down
Loading
Loading