diff --git a/VENDORED.md b/VENDORED.md index f0b01ddf3..609db403d 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -22,23 +22,23 @@ never a convenience. ## Ledger -| Vendored path | What was copied | Upstream repo @ commit | Why not a published package | Owner | Kill date | Kill-date test | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- | ----------------- | -| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: six modules byte-identical (`default-harness.ts`, `signing-keypair.ts`, `source-asset-delivery.ts`, `workflow-closure-apply.ts`, `workflow-probe-handler.ts`, `workflow-run-pack-restore.ts`), three near-verbatim (`atomic-write.ts` differs only by the repo-wide logger namespace), the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `step-agent-tools.ts`, `workflow-host-wiring/`, `workflow-substrate-factory/`, …) substantially rewritten, plus workbench-only modules. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the operator-configurable doom-loop threshold (`afd0c82b`, `c421c092`) the re-vendored `workflow-host` configures; one local delta (CL-7190): `ToolBundle.beforeToolExtension` (`tool.ts`) and its composition into `ResolvedTools.beforeToolExtensions` (`agent.ts`), so a tool package can contribute its own suspend-capable extension without the reactor or director special-casing it by name; retired by the next `@intx/agent` publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324) or the `workflow_definition.origin` column separating a definition from the per-run record of one folded run's deploy (CL-6452), shipped as migrations `0086`/`0087` behind upstream's `0085_add_approval_run_idx`, plus `0088` rewriting the retired `onBodyFailure: "continue"` literal to upstream's `"tolerate"` in stored wire projections; retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the connector reply drain (`driveConnectorReplies`, `ConnectorReplyDrain`, `AgentEventStream`; `11590e66`) the sidecar's warm mail loop drives; no local delta; retired by the next `@intx/harness` publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the `agentDir` path export (`927556de`) the sidecar's deploy-tree lookup uses, and its own `@intx/mail-memory`/`@intx/harness` pins must resolve the vendored copies; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the null-principal `resolveApproval` for policy-resolved decisions (CL-6345) or the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the pack-acceptance fixes (`ownsWorkflowRunRepo`, `anchorAddressForPackSource`, `decideTerminalRunFlip`), the adopted deploy front + `sourceRef` (CL-6324), the wire-projection writer (CL-6324), malformed tool-call-name sanitization (CL-6478), the sealed-run terminal-status backfill (CL-6595), the CL-7190 `registerSignalCorrelation` approval-only guard (fails loud on a future `SignalKind` this RPC has no persistence for, rather than silently mis-persisting one), or the CL-7191 `UserMessageParams.correlationId` plumbing into `sendUserMessage`'s `headers.interchangeCorrelationId` (a plain chat message can now resolve a parked `message_response` gate); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates doom-loop detection (`8da4c827`, `afd0c82b`, `c421c092`); local deltas: `providers/google-genai-files.ts` builds its upload body as `new Uint8Array(bytes)` because TS 6's lib.dom `BodyInit` rejects `Uint8Array` (upstream compiles ESNext-only under TS 5.9); and CL-7190's `message_response` resume branch in `reactor.ts`'s `resumePendingOperation`/`timeoutMessageFor`, plus its `reactor.test.ts`/`testing/fakes.ts` regression harness (this package previously had zero tests); retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the `@intx/mailbox` extraction (`af03bb90`), on-demand body reads (`54f7c239`) and `expunge` returning the swept uids (`bcabb1f8`) that the re-vendored `workflow-host` binds against; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/mailbox` | `@intx/mailbox` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | Never published: a new package at the target pin (`af03bb90`) that `workflow-host`'s substrate mailbox store and supervisor-backed transport import; no local delta; retired by its first publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/mime` | `@intx/mime` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the non-RFC message-id guard `isMessageId` (`d97e1832`), the full `References` chain (`65c6fe70`) and the lossless `decodeMail` decoder (`3b6d06b2`) that `mailbox`/`mail-memory` at the same pin import; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the type surface the re-vendored trees compile against: `expunge` returning `expungedUids` (`bcabb1f8`), plain-string `PackRejectReason` (`7b42f405`), the run authorization/approvals REST types (`71ad6c08`), the decoded-mail `Mail`/`MailPartReader` model (`3b6d06b2`) and the `interchange.actions`/`loops` package-json refs (`3bd5b837`, `1ea2f39b`); one local delta (CL-7190): `"message_response"` added to `signals.ts`'s `signalKinds`, alongside `signalKindToGateType`; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | No local delta: npm 0.3.0 predates the `onBodyFailure: "tolerate"` section policy (`b977ade6`) that `@corbits/agent-runtime` authors and the action/loop primitives (`3bd5b837`, `1ea2f39b`) the re-vendored `workflow-host` runs; retired by the next `@intx/workflow` publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | No local delta: npm 0.3.0 predates `inertLoopBody` and the loop-body source pin (`1ea2f39b`) that the re-vendored `hub-sessions` imports; retired by the next `@intx/workflow-deploy` publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the body-spawn authorize/credential/mail-part-reader threading and grants head-collapse (CL-6448) that let the fork run tool-bearing onTrigger bodies; retired when upstream absorbs the delta | sawyer | 2026-10-26 | `check:killdates` | +| Vendored path | What was copied | Upstream repo @ commit | Why not a published package | Owner | Kill date | Kill-date test | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- | ----------------- | +| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: six modules byte-identical (`default-harness.ts`, `signing-keypair.ts`, `source-asset-delivery.ts`, `workflow-closure-apply.ts`, `workflow-probe-handler.ts`, `workflow-run-pack-restore.ts`), three near-verbatim (`atomic-write.ts` differs only by the repo-wide logger namespace), the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `step-agent-tools.ts`, `workflow-host-wiring/`, `workflow-substrate-factory/`, …) substantially rewritten, plus workbench-only modules. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the operator-configurable doom-loop threshold (`afd0c82b`, `c421c092`) the re-vendored `workflow-host` configures; one local delta (CL-7190): `ToolBundle.beforeToolExtension` (`tool.ts`) and its composition into `ResolvedTools.beforeToolExtensions` (`agent.ts`), so a tool package can contribute its own suspend-capable extension without the reactor or director special-casing it by name; retired by the next `@intx/agent` publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324) or the `workflow_definition.origin` column separating a definition from the per-run record of one folded run's deploy (CL-6452), shipped as migrations `0086`/`0087` behind upstream's `0085_add_approval_run_idx`, plus `0088` rewriting the retired `onBodyFailure: "continue"` literal to upstream's `"tolerate"` in stored wire projections; retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the connector reply drain (`driveConnectorReplies`, `ConnectorReplyDrain`, `AgentEventStream`; `11590e66`) the sidecar's warm mail loop drives; no local delta; retired by the next `@intx/harness` publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the `agentDir` path export (`927556de`) the sidecar's deploy-tree lookup uses, and its own `@intx/mail-memory`/`@intx/harness` pins must resolve the vendored copies; one local delta (CL-6396): `SessionEventSink` / `publishWorkflowInferenceEvent` stamp optional `childRunId` on `agent.event`; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the null-principal `resolveApproval` for policy-resolved decisions (CL-6345) or the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the pack-acceptance fixes (`ownsWorkflowRunRepo`, `anchorAddressForPackSource`, `decideTerminalRunFlip`), the adopted deploy front + `sourceRef` (CL-6324), the wire-projection writer (CL-6324), malformed tool-call-name sanitization (CL-6478), the sealed-run terminal-status backfill (CL-6595), the CL-7190 `registerSignalCorrelation` approval-only guard (fails loud on a future `SignalKind` this RPC has no persistence for, rather than silently mis-persisting one), or the CL-7191 `UserMessageParams.correlationId` plumbing into `sendUserMessage`'s `headers.interchangeCorrelationId` (a plain chat message can now resolve a parked `message_response` gate), or the CL-6396 `childRunId` forward on `agent.event`; retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates doom-loop detection (`8da4c827`, `afd0c82b`, `c421c092`); local deltas: `providers/google-genai-files.ts` builds its upload body as `new Uint8Array(bytes)` because TS 6's lib.dom `BodyInit` rejects `Uint8Array` (upstream compiles ESNext-only under TS 5.9); and CL-7190's `message_response` resume branch in `reactor.ts`'s `resumePendingOperation`/`timeoutMessageFor`, plus its `reactor.test.ts`/`testing/fakes.ts` regression harness (this package previously had zero tests); retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the `@intx/mailbox` extraction (`af03bb90`), on-demand body reads (`54f7c239`) and `expunge` returning the swept uids (`bcabb1f8`) that the re-vendored `workflow-host` binds against; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/mailbox` | `@intx/mailbox` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | Never published: a new package at the target pin (`af03bb90`) that `workflow-host`'s substrate mailbox store and supervisor-backed transport import; no local delta; retired by its first publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/mime` | `@intx/mime` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the non-RFC message-id guard `isMessageId` (`d97e1832`), the full `References` chain (`65c6fe70`) and the lossless `decodeMail` decoder (`3b6d06b2`) that `mailbox`/`mail-memory` at the same pin import; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the type surface the re-vendored trees compile against: `expunge` returning `expungedUids` (`bcabb1f8`), plain-string `PackRejectReason` (`7b42f405`), the run authorization/approvals REST types (`71ad6c08`), the decoded-mail `Mail`/`MailPartReader` model (`3b6d06b2`) and the `interchange.actions`/`loops` package-json refs (`3bd5b837`, `1ea2f39b`); one local delta (CL-7190): `"message_response"` added to `signals.ts`'s `signalKinds`, alongside `signalKindToGateType`; another (CL-6396): optional `childRunId` on `AgentEventFrame`; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | No local delta: npm 0.3.0 predates the `onBodyFailure: "tolerate"` section policy (`b977ade6`) that `@corbits/agent-runtime` authors and the action/loop primitives (`3bd5b837`, `1ea2f39b`) the re-vendored `workflow-host` runs; retired by the next `@intx/workflow` publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | No local delta: npm 0.3.0 predates `inertLoopBody` and the loop-body source pin (`1ea2f39b`) that the re-vendored `hub-sessions` imports; retired by the next `@intx/workflow-deploy` publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the body-spawn authorize/credential/mail-part-reader threading and grants head-collapse (CL-6448) that let the fork run tool-bearing onTrigger bodies, or the CL-6396 event-channel `childRunId` stamp so overlapping turns HMAC-bind the occurrence they belong to; retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | The pin is `a8bc06ae` (upstream `origin/main`, 2026-08-27), 72 commits past the `v0.3.0` release tag `b5580a02`. npm is still `0.3.0`, so every tree a diff --git a/apps/sidecar/src/workflow-host-wiring/event-channel-hmac.test.ts b/apps/sidecar/src/workflow-host-wiring/event-channel-hmac.test.ts new file mode 100644 index 000000000..b5759268f --- /dev/null +++ b/apps/sidecar/src/workflow-host-wiring/event-channel-hmac.test.ts @@ -0,0 +1,112 @@ +// CL-6396: a body child's stamped `childRunId` is inside the HMAC-signed +// envelope, not a sidecar-side annotation after verify. This roundtrip +// is the production event-channel path (`encodeEnvelope` → HMAC → +// `receiveEventChannel` → `ReceivedEvent`), so a missing field cannot +// hide behind an in-process object that never went over the wire. +import { expect, test } from "bun:test"; +import { + createEventChannelSender, + decodeEnvelope, + encodeEnvelope, + generateChannelId, + generateHmacKey, + receiveEventChannel, + type EventPayload, + type FrameReader, + type FrameWriter, +} from "@intx/workflow-host"; + +const EVENT = { + type: "inference.start" as const, + seq: 1, + data: { model: "stub" }, +}; + +function createMemoryPipe(): { + writer: FrameWriter; + reader: FrameReader; + close: () => void; +} { + const chunks: Uint8Array[] = []; + let closed = false; + let wake: (() => void) | null = null; + function notify() { + const waiting = wake; + wake = null; + waiting?.(); + } + return { + writer: { + write(bytes) { + chunks.push(bytes); + notify(); + }, + }, + reader: { + async *read() { + for (;;) { + while (chunks.length > 0) { + const next = chunks.shift(); + if (next !== undefined) yield next; + } + if (closed) return; + await new Promise((resolve) => { + wake = resolve; + }); + } + }, + }, + close() { + closed = true; + notify(); + }, + }; +} + +test("stamped childRunId survives encodeEnvelope, HMAC verify, and ReceivedEvent", async () => { + const hmacKey = generateHmacKey(); + const channelId = generateChannelId(); + const envelopeBytes = encodeEnvelope({ + seq: 1, + channelId, + payload: EVENT, + childRunId: "turn__0", + }); + expect(decodeEnvelope(envelopeBytes).childRunId).toBe("turn__0"); + + const pipe = createMemoryPipe(); + const sender = createEventChannelSender({ + hmacKey, + channelId, + writer: pipe.writer, + }); + const received: { event: EventPayload; childRunId?: string }[] = []; + const crashes: string[] = []; + const consume = (async () => { + for await (const frame of receiveEventChannel({ + hmacKey, + channelId, + reader: pipe.reader, + onCrash: (reason) => crashes.push(reason), + })) { + received.push(frame); + } + })(); + + await sender.send(EVENT, "turn__0"); + const deadline = Date.now() + 2_000; + while (received.length === 0) { + if (crashes.length > 0) { + throw new Error(`event channel crashed: ${crashes.join("; ")}`); + } + if (Date.now() > deadline) { + throw new Error("timed out waiting for HMAC-verified event"); + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + pipe.close(); + await consume; + + expect(crashes).toEqual([]); + expect(received).toEqual([{ event: EVENT, childRunId: "turn__0" }]); +}); diff --git a/apps/sidecar/src/workflow-host-wiring/index.ts b/apps/sidecar/src/workflow-host-wiring/index.ts index 242db0269..a362fb3d3 100644 --- a/apps/sidecar/src/workflow-host-wiring/index.ts +++ b/apps/sidecar/src/workflow-host-wiring/index.ts @@ -444,6 +444,7 @@ export function createSidecarDeployRouter(deps: { agentAddress: string, event: InferenceEvent, sessionId: string | undefined, + childRunId?: string, ) => void; /** * Optional override for the multi-step branch's per-step mail-address @@ -617,6 +618,7 @@ export function createSidecarDeployRouter(deps: { _address: string, _event: InferenceEvent, _sessionId: string | undefined, + _childRunId?: string, ): void => { /* no-op default: tests and production-without-a-publisher deployments do not consume events. */ @@ -1081,7 +1083,7 @@ export function createSidecarDeployRouter(deps: { stepOrder, definitionHash, warmKeep, - onInferenceEvent: (event) => { + onInferenceEvent: (event, childRunId) => { // The event arrives HMAC-verified over the child's event channel. // Re-narrow it to the hub's `InferenceEvent` union; a parse // failure means upstream corruption, so drop it loudly rather @@ -1091,7 +1093,12 @@ export function createSidecarDeployRouter(deps: { logger.warn`dropping workflow inference event for ${spec.agentAddress}: ${validated.summary}`; return; } - publishInferenceEvent(spec.agentAddress, validated, spec.sessionId); + publishInferenceEvent( + spec.agentAddress, + validated, + spec.sessionId, + childRunId, + ); }, }; diff --git a/packages/chat/src/agent-turns.test.ts b/packages/chat/src/agent-turns.test.ts index 2adf0be95..2e0112d9e 100644 --- a/packages/chat/src/agent-turns.test.ts +++ b/packages/chat/src/agent-turns.test.ts @@ -111,6 +111,31 @@ describe("createInMemoryAgentTurnStore", () => { expect(listed.map((turn) => turn.id)).toEqual([second.id, first.id]); }); + // CL-6396: two simultaneously-running rows for the same (workbench, agent) + // are no longer a coin flip when the caller names the occurrence. The + // newest-occurrence pick remains the documented fallback for a lookup + // that omits childRunId (an old sidecar's agent.event frames). + test("findRunningTurn names a specific occurrence when childRunId is given", async () => { + const store = createInMemoryAgentTurnStore(); + const first = await store.startTurn(BASE); + const second = await store.startTurn(BASE); + expect(first.childRunId).toBe("turn__0"); + expect(second.childRunId).toBe("turn__1"); + expect(first.status).toBe("running"); + expect(second.status).toBe("running"); + + expect((await store.findRunningTurn(BASE))?.id).toBe(second.id); + expect( + (await store.findRunningTurn({ ...BASE, childRunId: "turn__0" }))?.id, + ).toBe(first.id); + expect( + (await store.findRunningTurn({ ...BASE, childRunId: "turn__1" }))?.id, + ).toBe(second.id); + expect( + await store.findRunningTurn({ ...BASE, childRunId: "turn__9" }), + ).toBeUndefined(); + }); + // CL-7200: listing used to sort by startedAt string alone, while // findRunningTurn sorted by occurrence. Two turns sharing a timestamp // (same-millisecond ISO strings compare equal) then disagreed — listing diff --git a/packages/chat/src/agent-turns.ts b/packages/chat/src/agent-turns.ts index 74be717d9..bee52b4c5 100644 --- a/packages/chat/src/agent-turns.ts +++ b/packages/chat/src/agent-turns.ts @@ -79,26 +79,34 @@ export interface AgentTurnStore { */ finishTurn(input: FinishAgentTurnInput): Promise; /** - * The newest turn still `running` for (workbench, agent) — what the - * reply path stamps onto the message it posts. The sidecar's - * `agent.event` frames carry the agent's address and nothing finer, - * so this is how a reply finds the occurrence that produced it. - * Unambiguous only because `waitUntilFree` (below) is what - * `dispatchTurn` (`./workbench-service.ts`) now calls before ever - * opening a second occurrence for the same (workbench, agent) — the - * one-in-flight-turn-per-workbench claim (`./turn-queue.ts`) is a - * different, coarser guarantee (it only spans the fast dispatch - * handoff, not the agent's actual reply) and on its own was NOT - * enough: two messages sent to the same agent a few seconds apart - * could each win that claim in turn and open a second `running` row - * while the first agent turn was still generating (CL-6670), making - * this method's "newest" pick a coin flip between the two turns' - * real replies. + * The newest turn still `running` for (workbench, agent), or — when + * `childRunId` is present — that exact occurrence. The reply path stamps + * the row it finds onto the message it posts. An old sidecar's + * `agent.event` frames omit `childRunId`; those callers keep the + * newest-occurrence pick, which is the one documented fallback. + * + * `waitUntilFree` (below) is what `dispatchTurn` (`./workbench-service.ts`) + * calls before opening a second occurrence for the same (workbench, + * agent) — the one-in-flight-turn-per-workbench claim (`./turn-queue.ts`) + * is a different, coarser guarantee (it only spans the fast dispatch + * handoff, not the agent's actual reply) and on its own was NOT enough: + * two messages sent to the same agent a few seconds apart could each win + * that claim in turn and open a second `running` row while the first + * agent turn was still generating (CL-6670), making the newest pick a + * coin flip between the two turns' real replies. */ findRunningTurn(input: { readonly tenantId: string; readonly workbenchId: string; readonly agentAddress: string; + /** + * When present, this is an exact occurrence lookup — the row whose + * `childRunId` matches, still `running`. An old sidecar's `agent.event` + * frames omit this (CL-6396); those callers keep the newest-occurrence + * pick below, which is the one documented fallback, not a second path + * that posts a late reply. + */ + readonly childRunId?: string; }): Promise; /** * Every `running` turn for a workbench, across every agent — the @@ -301,6 +309,7 @@ export function createInMemoryAgentTurnStore( readonly tenantId: string; readonly workbenchId: string; readonly agentAddress: string; + readonly childRunId?: string; }): AgentTurn | undefined { expireStaleTurns(); return [...rows.values()] @@ -309,7 +318,9 @@ export function createInMemoryAgentTurnStore( turn.tenantId === input.tenantId && turn.workbenchId === input.workbenchId && turn.agentAddress === input.agentAddress && - turn.status === "running", + turn.status === "running" && + (input.childRunId === undefined || + turn.childRunId === input.childRunId), ) .sort(compareTurnsNewestFirst)[0]; } @@ -505,6 +516,7 @@ export function createDrizzleAgentTurnStore< readonly tenantId: string; readonly workbenchId: string; readonly agentAddress: string; + readonly childRunId?: string; }): Promise { await expireStaleTurns(input); const [row] = await db @@ -516,6 +528,9 @@ export function createDrizzleAgentTurnStore< eq(agentTurns.workbenchId, input.workbenchId), eq(agentTurns.agentAddress, input.agentAddress), eq(agentTurns.status, "running"), + ...(input.childRunId !== undefined + ? [eq(agentTurns.childRunId, input.childRunId)] + : []), ), ) .orderBy(desc(agentTurns.startedAt), desc(agentTurns.occurrence)) diff --git a/packages/chat/src/chat-orchestrator.ts b/packages/chat/src/chat-orchestrator.ts index 7b056301f..ad5bdecfd 100644 --- a/packages/chat/src/chat-orchestrator.ts +++ b/packages/chat/src/chat-orchestrator.ts @@ -96,8 +96,10 @@ export type ChatOrchestratorDeps = { * The turn projection (CL-6329). A room agent runs as an `onTrigger` * section, so the reply this orchestrator posts belongs to one * occurrence — its own child run — and the projection is what names - * that child: the sidecar's `agent.event` frames carry the agent's - * address and nothing finer. Omitted, replies carry no run id at all + * that child. `agent.event` frames carry an optional `childRunId` + * (`turn__`) so a reply can name the occurrence that produced it; + * an old sidecar omits that field and this module falls back to the + * newest running turn. Omitted store, replies carry no run id at all * rather than a guessed one; there is no second way to name a run. */ agentTurns?: Pick< @@ -214,11 +216,17 @@ function gateBlockedCorrelationId(event: unknown): string | undefined { * `agent.event` frame carries a `sessionId` regardless of which inner * event type it wraps (`vendor/intx/hub-sessions/src/ws/sidecar-events.ts`'s * `SidecarEventMap["agent.event"]`), scoped to the one sidecar connection - * that turn's occurrence runs on — the correlator this module's in-flight - * turn state (below) keys on instead of the address alone. + * that turn's occurrence runs on. When the frame also names an occurrence + * (`childRunId`), that is the correlator — two overlapping turns share a + * session and must not share this key. An old sidecar omits `childRunId` + * and today's session id remains the key. */ -function turnKeyFor(agentAddress: string, sessionId: string): string { - return `${agentAddress}:${sessionId}`; +function turnKeyFor( + agentAddress: string, + sessionId: string, + childRunId?: string, +): string { + return `${agentAddress}:${childRunId ?? sessionId}`; } /** @@ -575,6 +583,7 @@ async function postReply( parts: readonly Part[], outcome: TurnOutcome, event?: unknown, + childRunId?: string, ): Promise { const resolved = await resolveMemberWorkbenches(deps, agentAddress); if (resolved === undefined) return; @@ -585,6 +594,7 @@ async function postReply( tenantId: resolved.tenantId, workbenchId, agentAddress: resolved.roomAddress, + ...(childRunId !== undefined ? { childRunId } : {}), }); members.push({ workbenchId, @@ -647,6 +657,7 @@ async function postReply( tenantId: resolved.tenantId, workbenchId, agentAddress: resolved.roomAddress, + ...(childRunId !== undefined ? { childRunId } : {}), }); const posted = await postRoomMessage(deps, { tenantId: resolved.tenantId, @@ -1108,12 +1119,13 @@ export function createChatOrchestrator( // Every turn with a `connector.reply` pending delivery — added the // moment reply content is seen, cleared the moment that same turn's own // `message.run.ended` bracket closes (see below). Keyed by `turnKeyFor` - // (agent address + sidecar session id), never by address alone: one + // (agent address + occurrence run id when the sidecar supplied one, + // otherwise the sidecar session id), never by address alone: one // address can be mid-turn in several benches at once // (`resolveMemberWorkbenches` returns workbench ids plural by design), - // and each such turn runs its own sidecar connection with its own - // `sessionId` — the correlator every `agent.event` frame carries - // regardless of which inner event type it wraps. + // two overlapping turns on one sidecar share a `sessionId`, and each + // `agent.event` frame carries that session id regardless of which + // inner event type it wraps. const repliedTurns = new Set(); // Process-lifetime idempotency guard for the turn-drop notice below, @@ -1142,14 +1154,14 @@ export function createChatOrchestrator( const unsubscribe = deps.events.on( "agent.event", - ({ agentAddress, sessionId, event }) => { + ({ agentAddress, sessionId, event, childRunId }) => { // Any event at all counts as activity, not just `connector.reply` // below — an agent mid-inference must never be undeployed out // from under itself by the idle sweep just because it hasn't // replied yet. deps.recordActivity?.(agentAddress); - const turnKey = turnKeyFor(agentAddress, sessionId); + const turnKey = turnKeyFor(agentAddress, sessionId, childRunId); if (messageRunStarted(event)) { notifiedDropTurns.delete(turnKey); @@ -1196,6 +1208,7 @@ export function createChatOrchestrator( status: "completed", }, event, + childRunId, ).catch((cause: unknown) => { log.error`chat orchestrator: failed to post ${agentAddress}'s reply: ${ cause instanceof Error ? cause.message : String(cause) @@ -1253,6 +1266,7 @@ export function createChatOrchestrator( [{ kind: "text", text: noticeContent }], { status: "failed", error: errorMessage }, event, + childRunId, ).catch((cause: unknown) => { log.error`chat orchestrator: failed to post ${agentAddress}'s turn-drop notice: ${ cause instanceof Error ? cause.message : String(cause) diff --git a/packages/chat/test/agent-turns.drizzle.test.ts b/packages/chat/test/agent-turns.drizzle.test.ts index c23543667..c14eb490d 100644 --- a/packages/chat/test/agent-turns.drizzle.test.ts +++ b/packages/chat/test/agent-turns.drizzle.test.ts @@ -199,4 +199,40 @@ describeIfDb("createDrizzleAgentTurnStore", () => { await sql.end(); } }); + + // CL-6396: the in-memory store already names a specific occurrence when + // `childRunId` is given. This is that same lookup against the production + // Drizzle store — `eq(agentTurns.childRunId)` must pick the named row, + // never the newest-occurrence fallback, while two rows are still running. + test("findRunningTurn names a specific occurrence when childRunId is given", async () => { + const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); + try { + const store = createDrizzleAgentTurnStore(drizzle(sql)); + const input = { + tenantId: TENANT, + workbenchId: "run_child_run_id", + agentAddress: AGENT, + requestMessageIds: ["msg_lookup"], + }; + const first = await store.startTurn(input); + const second = await store.startTurn(input); + expect(first.childRunId).toBe("turn__0"); + expect(second.childRunId).toBe("turn__1"); + expect(first.status).toBe("running"); + expect(second.status).toBe("running"); + + expect((await store.findRunningTurn(input))?.id).toBe(second.id); + expect( + (await store.findRunningTurn({ ...input, childRunId: "turn__0" }))?.id, + ).toBe(first.id); + expect( + (await store.findRunningTurn({ ...input, childRunId: "turn__1" }))?.id, + ).toBe(second.id); + expect( + await store.findRunningTurn({ ...input, childRunId: "turn__9" }), + ).toBeUndefined(); + } finally { + await sql.end(); + } + }); }); diff --git a/packages/chat/test/chat-orchestrator.test.ts b/packages/chat/test/chat-orchestrator.test.ts index fa40d7178..d96108b18 100644 --- a/packages/chat/test/chat-orchestrator.test.ts +++ b/packages/chat/test/chat-orchestrator.test.ts @@ -732,6 +732,348 @@ describe("createChatOrchestrator", () => { orchestrator.dispose(); }); + // CL-6396: two overlapping running rows for the same (workbench, agent) + // used to make `findRunningTurn`'s newest-occurrence pick a coin flip. + // A reply that names turn__0 must close that row, never the later one. + test("a reply named for turn__0 does not close an overlapping turn__1", async () => { + const room = fakeRoom(); + const agentTurns = createInMemoryAgentTurnStore(); + const agentAddress = "ins_echo1@ten1.workbench.test"; + const first = await agentTurns.startTurn({ + tenantId: "ten_1", + workbenchId: "ins_workbench1", + agentAddress, + requestMessageIds: ["msg_0"], + }); + const second = await agentTurns.startTurn({ + tenantId: "ten_1", + workbenchId: "ins_workbench1", + agentAddress, + requestMessageIds: ["msg_1"], + }); + expect(first.childRunId).toBe("turn__0"); + expect(second.childRunId).toBe("turn__1"); + const events = createSidecarEmitter(); + const orchestrator = createChatOrchestrator({ + db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, + store: { + listWorkbenchSettings: async () => [ + workbenchRow("ins_workbench1", [agentAddress]), + ], + }, + roomMessages: room.roomMessages, + publish: room.publish, + platform: fakeMail().platform, + events, + agentTurns, + claims: fakeClaims(), + approvals: { findByCorrelationId: async () => null }, + }); + + events.emit("agent.event", { + agentAddress, + sessionId: "ses_1", + childRunId: "turn__0", + event: { type: "connector.reply", data: { content: "first reply" } }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(room.posted).toHaveLength(1); + expect(room.posted[0]).toMatchObject({ + runId: "turn__0", + parts: [{ kind: "text", text: "first reply" }], + }); + expect( + (await agentTurns.getTurn({ tenantId: "ten_1", turnId: first.id })) + ?.status, + ).toBe("completed"); + expect( + (await agentTurns.getTurn({ tenantId: "ten_1", turnId: second.id })) + ?.status, + ).toBe("running"); + + orchestrator.dispose(); + }); + + // CL-6396: a failed-turn notice is the same `postReply` path as a + // successful `connector.reply`. Naming turn__0 must close that row + // failed, leave the overlapping turn__1 running, and stamp the notice + // with turn__0 — never the newest-occurrence pick. + test("a failed-turn notice named for turn__0 does not close an overlapping turn__1", async () => { + const room = fakeRoom(); + const agentTurns = createInMemoryAgentTurnStore(); + const agentAddress = "ins_echo1@ten1.workbench.test"; + const first = await agentTurns.startTurn({ + tenantId: "ten_1", + workbenchId: "ins_workbench1", + agentAddress, + requestMessageIds: ["msg_0"], + }); + const second = await agentTurns.startTurn({ + tenantId: "ten_1", + workbenchId: "ins_workbench1", + agentAddress, + requestMessageIds: ["msg_1"], + }); + expect(first.childRunId).toBe("turn__0"); + expect(second.childRunId).toBe("turn__1"); + const events = createSidecarEmitter(); + const orchestrator = createChatOrchestrator({ + db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, + store: { + listWorkbenchSettings: async () => [ + workbenchRow("ins_workbench1", [agentAddress]), + ], + }, + roomMessages: room.roomMessages, + publish: room.publish, + platform: fakeMail().platform, + events, + agentTurns, + claims: fakeClaims(), + approvals: { findByCorrelationId: async () => null }, + }); + + events.emit("agent.event", { + agentAddress, + sessionId: "ses_1", + childRunId: "turn__0", + event: { + type: "message.run.ended", + data: { status: "failed", error: { message: "provider timed out" } }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(room.posted).toHaveLength(1); + expect(room.posted[0]).toMatchObject({ + runId: "turn__0", + parts: [{ kind: "text", text: "provider timed out" }], + }); + expect( + (await agentTurns.getTurn({ tenantId: "ten_1", turnId: first.id })) + ?.status, + ).toBe("failed"); + expect( + (await agentTurns.getTurn({ tenantId: "ten_1", turnId: second.id })) + ?.status, + ).toBe("running"); + + orchestrator.dispose(); + }); + + // CL-6396: an old sidecar's agent.event frames omit childRunId. The one + // documented fallback is newest-occurrence — the later overlapping row + // receives the reply, not a spray and not a late-reply invent. + test("a reply with no childRunId still finishes the newest overlapping turn", async () => { + const room = fakeRoom(); + const agentTurns = createInMemoryAgentTurnStore(); + const agentAddress = "ins_echo1@ten1.workbench.test"; + const first = await agentTurns.startTurn({ + tenantId: "ten_1", + workbenchId: "ins_workbench1", + agentAddress, + requestMessageIds: ["msg_0"], + }); + const second = await agentTurns.startTurn({ + tenantId: "ten_1", + workbenchId: "ins_workbench1", + agentAddress, + requestMessageIds: ["msg_1"], + }); + const events = createSidecarEmitter(); + const orchestrator = createChatOrchestrator({ + db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, + store: { + listWorkbenchSettings: async () => [ + workbenchRow("ins_workbench1", [agentAddress]), + ], + }, + roomMessages: room.roomMessages, + publish: room.publish, + platform: fakeMail().platform, + events, + agentTurns, + claims: fakeClaims(), + approvals: { findByCorrelationId: async () => null }, + }); + + events.emit("agent.event", { + agentAddress, + sessionId: "ses_1", + event: { type: "connector.reply", data: { content: "legacy reply" } }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(room.posted).toHaveLength(1); + expect(room.posted[0]?.runId).toBe("turn__1"); + expect( + (await agentTurns.getTurn({ tenantId: "ten_1", turnId: first.id })) + ?.status, + ).toBe("running"); + expect( + (await agentTurns.getTurn({ tenantId: "ten_1", turnId: second.id })) + ?.status, + ).toBe("completed"); + + orchestrator.dispose(); + }); + + // CL-6396: two turns on one sidecar session must not share the + // reply-parts accumulator. Turn 0's late `message.run.ended` used to + // `take()` the session-keyed bucket after turn 1 had already started + // accumulating, swallowing turn 1's structured parts. + test("sequential turns on one sessionId with distinct childRunIds do not steal reply-parts", async () => { + const room = fakeRoom(); + const agentTurns = createInMemoryAgentTurnStore(); + const agentAddress = "ins_echo1@ten1.workbench.test"; + const first = await agentTurns.startTurn({ + tenantId: "ten_1", + workbenchId: "ins_workbench1", + agentAddress, + requestMessageIds: ["msg_0"], + }); + const events = createSidecarEmitter(); + const orchestrator = createChatOrchestrator({ + db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, + store: { + listWorkbenchSettings: async () => [ + workbenchRow("ins_workbench1", [agentAddress]), + ], + }, + roomMessages: room.roomMessages, + publish: room.publish, + platform: fakeMail().platform, + events, + agentTurns, + claims: fakeClaims(), + approvals: { findByCorrelationId: async () => null }, + }); + + events.emit("agent.event", { + agentAddress, + sessionId: "ses_shared", + childRunId: "turn__0", + event: { + type: "inference.done", + data: { turn: { content: [{ type: "text", text: "first thought" }] } }, + }, + }); + events.emit("agent.event", { + agentAddress, + sessionId: "ses_shared", + childRunId: "turn__0", + event: { + type: "connector.reply", + data: { content: "first thought" }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + (await agentTurns.getTurn({ tenantId: "ten_1", turnId: first.id })) + ?.status, + ).toBe("completed"); + + const second = await agentTurns.startTurn({ + tenantId: "ten_1", + workbenchId: "ins_workbench1", + agentAddress, + requestMessageIds: ["msg_1"], + }); + expect(second.childRunId).toBe("turn__1"); + + events.emit("agent.event", { + agentAddress, + sessionId: "ses_shared", + childRunId: "turn__1", + event: { + type: "inference.done", + seq: 1, + data: { + turn: { + content: [ + { type: "text", text: "Let me check that." }, + { + type: "tool_call", + id: "call_1", + name: "web_search", + arguments: { query: "second thought" }, + }, + ], + }, + }, + }, + }); + events.emit("agent.event", { + agentAddress, + sessionId: "ses_shared", + childRunId: "turn__1", + event: { + type: "tool.done", + seq: 2, + data: { + result: { callId: "call_1", content: "3 results found" }, + }, + }, + }); + events.emit("agent.event", { + agentAddress, + sessionId: "ses_shared", + childRunId: "turn__1", + event: { + type: "inference.done", + seq: 3, + data: { + turn: { content: [{ type: "text", text: "second thought" }] }, + }, + }, + }); + // Late bracket-close for the first turn, still on the shared session. + events.emit("agent.event", { + agentAddress, + sessionId: "ses_shared", + childRunId: "turn__0", + event: { + type: "message.run.ended", + data: { status: "completed", messageRunId: "mr_0", messageId: "msg_0" }, + }, + }); + events.emit("agent.event", { + agentAddress, + sessionId: "ses_shared", + childRunId: "turn__1", + event: { + type: "connector.reply", + data: { content: "second thought" }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(room.posted).toHaveLength(2); + expect(room.posted[0]?.parts).toEqual([ + { kind: "text", text: "first thought" }, + ]); + expect(room.posted[1]?.parts).toEqual([ + { kind: "text", text: "Let me check that." }, + { + kind: "tool-trace", + name: "web_search", + input: { query: "second thought" }, + status: "success", + output: "3 results found", + }, + { kind: "text", text: "second thought" }, + ]); + expect(room.posted[1]?.runId).toBe("turn__1"); + expect( + (await agentTurns.getTurn({ tenantId: "ten_1", turnId: second.id })) + ?.status, + ).toBe("completed"); + + orchestrator.dispose(); + }); + // CL-6378: a turn's `inference.done` events already split the model's // output into prose and tool calls (see `event-collector.ts`'s // `handleInferenceDone`), and `tool.done` resolves each call's diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index c184aeeee..3e0675e69 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -17,17 +17,17 @@ apps/sidecar | sawyer | 2026-10-26 vendor/intx/agent | sawyer | 2026-10-26 | f29297427016f6cb0c10ba49a846e2e445bdd38b6aabfc47854a1cc85ce8e002 vendor/intx/db | sawyer | 2026-10-26 | e1a41e59050ca32d6b590b33f559221584b3e78c71d4b61b3b3d10df82a50096 vendor/intx/harness | sawyer | 2026-10-26 | 5daababe006d9cf8e678c0eed22c2daa99cc519f35ed7a32ec2b977da9f2fb71 -vendor/intx/hub-agent | sawyer | 2026-10-26 | 617f93b05da6a02415ea3b319526137b56a1d5cc3689ca4d33ab324d8f5807d3 +vendor/intx/hub-agent | sawyer | 2026-10-26 | f0f01b50973355d435f2f4543760190a0f87b419d5a2fc47aab64c8845ee05be vendor/intx/hub-api | sawyer | 2026-10-26 | 10fd46c7bf0b058618a8124f6f43d7e779fd5861ff0146fe8d7c2886b836c40e -vendor/intx/hub-sessions | sawyer | 2026-10-26 | c884af15f80f228acb95b95d415a035bc8d6bae579d199c00fb98a511ba42219 +vendor/intx/hub-sessions | sawyer | 2026-10-26 | dbe424a39424d9848742903e6dd8aef5129b6355a4ece31af71bcabf07b24225 vendor/intx/inference | sawyer | 2026-10-26 | 3754de556eac6387a427454d57a22f46ad42dc22096c9c76f993cea74f97f50a vendor/intx/mail-memory | sawyer | 2026-10-26 | 99e15f6b256f36562dcf4b0cbd84e412b1a8985f00019e3e1748ecd18332b74d vendor/intx/mailbox | sawyer | 2026-10-26 | 9647f7c0cda5a9fce7d687b2901a92769f18a442dcd4d1ffe8334eeb71b7dbb0 vendor/intx/mime | sawyer | 2026-10-26 | ef4ad0c981b976bdfd88afd3c170d09fc534f04a263648eb9e1b440e9e6b5a07 -vendor/intx/types | sawyer | 2026-10-26 | 38b6e88e7b1710e865b783f9d8b842fe3603eb54b4d9f8f945ba64f5e654d708 +vendor/intx/types | sawyer | 2026-10-26 | f6d90f1dbbc604d1ead7727d46b8405f91929538fc0f08c730ab2d0da901f54b vendor/intx/workflow | sawyer | 2026-10-26 | 0d8b76651b9890ee39d271bf5d14e25a2a6496821d2a99d0712db0d75c736193 vendor/intx/workflow-deploy | sawyer | 2026-10-26 | dd144cb4ca55373597a681d08b84fffc5084ec1fc7150ad25963d8e8273c49d9 -vendor/intx/workflow-host | sawyer | 2026-10-26 | 85eb308faadb576db975050545f9d7306c3c1a05bbbc16648782691c62ac8798 +vendor/intx/workflow-host | sawyer | 2026-10-26 | d94bacae4d56a0dc55a686e5d2ed0533489e7bac603104aaa345b1a368d4614c packages/folded-runs | sawyer | 2026-11-01 diff --git a/vendor/intx/hub-agent/VENDORED-FROM b/vendor/intx/hub-agent/VENDORED-FROM index 9be44faa5..c023d0da6 100644 --- a/vendor/intx/hub-agent/VENDORED-FROM +++ b/vendor/intx/hub-agent/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/hub-agent) Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) 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. No source delta. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-6396: SessionEventSink and createSidecarOrchestrator's dispatchEvent/publishWorkflowInferenceEvent take an optional childRunId, and createHubLink's sendEvent stamps it on agent.event so overlapping turns close the right occurrence row. diff --git a/vendor/intx/hub-agent/src/sidecar-orchestrator.ts b/vendor/intx/hub-agent/src/sidecar-orchestrator.ts index d968fa056..236e73b2c 100644 --- a/vendor/intx/hub-agent/src/sidecar-orchestrator.ts +++ b/vendor/intx/hub-agent/src/sidecar-orchestrator.ts @@ -75,6 +75,7 @@ export type CreateDeployRouter = (deps: { agentAddress: string, event: InferenceEvent, sessionId: string | undefined, + childRunId?: string, ) => void; /** * Control-plane suspension sink the multi-step branch routes a @@ -254,6 +255,7 @@ export function createSidecarOrchestrator( agentAddress: string, sessionId: string, event: InferenceEvent, + childRunId?: string, ) => void = () => { /* replaced after HubLink construction */ }; @@ -286,7 +288,7 @@ export function createSidecarOrchestrator( // is observed. A sessionless event is dropped rather than guessed // onto an arbitrary session -- the hub timeline is session-keyed and // a forged session id would mis-route the event. - publishWorkflowInferenceEvent: (agentAddress, event, sessionId) => { + publishWorkflowInferenceEvent: (agentAddress, event, sessionId, childRunId) => { if (sessionId === undefined) { log.warn( "Dropping workflow inference event for {agentAddress}: deploy carried no sessionId", @@ -294,7 +296,7 @@ export function createSidecarOrchestrator( ); return; } - dispatchEvent(agentAddress, sessionId, event); + dispatchEvent(agentAddress, sessionId, event, childRunId); }, // Route a supervisor's suspension registration up the hub-link so the // hub co-writes the parked run's routing + approval rows. diff --git a/vendor/intx/hub-agent/src/ws/hub-link.ts b/vendor/intx/hub-agent/src/ws/hub-link.ts index def792e1d..81ff76730 100644 --- a/vendor/intx/hub-agent/src/ws/hub-link.ts +++ b/vendor/intx/hub-agent/src/ws/hub-link.ts @@ -59,6 +59,7 @@ export type SessionEventSink = ( agentAddress: string, sessionId: string, event: InferenceEvent, + childRunId?: string, ) => void; const logger = getLogger(["interchange", "hub-agent", "ws"]); @@ -1757,12 +1758,18 @@ export function createHubLink(config: HubLinkConfig): HubLink { } } - const sendEvent: SessionEventSink = (agentAddress, sessionId, event) => { + const sendEvent: SessionEventSink = ( + agentAddress, + sessionId, + event, + childRunId, + ) => { send({ type: "agent.event", agentAddress, sessionId, event, + ...(childRunId !== undefined ? { childRunId } : {}), }); }; diff --git a/vendor/intx/hub-sessions/VENDORED-FROM b/vendor/intx/hub-sessions/VENDORED-FROM index d36678b34..ac2f80fd9 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: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) 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. Terminal-anchor pack acceptance: hub-session-lookups.ts's receiveWorkflowRunPack gates the anchor lookup on the exported pure helper ownsWorkflowRunRepo (self-anchored row with a routable address, no liveness requirement) instead of upstream's `status in (deployed, running)`, so a terminal run can still land the inbox-enqueue and markConsumed-rejection packs that retire mail arriving in its teardown window; CL-6361 widens the same lookup to peel a per-step pack source address back to its base run's anchor via anchorAddressForPackSource; CL-6379 classifies an accepted pack's newly-terminal runs through decideTerminalRunFlip so a section occurrence's repo-local child run (turn__) is skipped quietly. CL-6324: a third code-sourced deploy front, deployAdoptedCodeSourcedWorkflow (plus the deployAdoptedWorkflowFromSource service method and its AdoptingWorkflowDeployer / DeployAdoptedWorkflowFromSourceParams types), deploys onto shared capacity while ADOPTING an anchor workflow_run row the caller already owns; it composes upstream's emitSourceRefDeployFrame and a guarded UPDATE. DeployWorkflowFromSourceParams / DeployPreparedCodeSourcedWorkflowParams gain an optional sourceRef threaded through bindAssetAttachmentResolver / bindSourceAttachmentResolver so a per-run source tree on refs/heads/runs/ packs the pinned commit. CL-6324: workflow-probe-gate.ts's PersistFrozenApprovalFn carries the inert projection and createDbFrozenApprovalWriter stamps workflow_definition_version.wire_projection in the same transaction as approved_wire_hash. CL-6478: sanitize-tool-name.ts + event-collector.ts's tool_call case persist only a tool-call name encodeToolName can re-invert (anything else collapses to MALFORMED_TOOL_NAME), so one bad name fails its turn instead of wedging the room; @intx/inference is a dependency for this. CL-6595: workflow-run-kind.ts's validatePush also reports a run sealed from birth (combined events.jsonl with no per-event blobs) as newly terminal, and the new readCommittedWorkflowRunTerminalStatus export backs a same-push markTerminal backfill in hub-session-lookups.ts; both classify through upstream's classifyTerminalEvent. Retired at this pin: the per-event inference.usage forward (upstream fab86ca9 emits TurnUsage once per turn), the collector-level event serialization (upstream a1d419c3 serializes at the registry) and the anchor-before-frame ordering with DeployFrameNotSentError (upstream a203a057 + 9e11829f, isDeployFrameFailure). CL-7190: registerSignalCorrelation (hub-session-lookups.ts) now throws for any signal kind other than "approval", failing loud instead of silently mis-persisting a future SignalKind it has no co-write for. CL-7191: UserMessageParams gains an optional correlationId (session-service.ts) stamped as sendUserMessage's headers.interchangeCorrelationId, so a plain chat message can resolve a parked message_response gate. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. Terminal-anchor pack acceptance: hub-session-lookups.ts's receiveWorkflowRunPack gates the anchor lookup on the exported pure helper ownsWorkflowRunRepo (self-anchored row with a routable address, no liveness requirement) instead of upstream's `status in (deployed, running)`, so a terminal run can still land the inbox-enqueue and markConsumed-rejection packs that retire mail arriving in its teardown window; CL-6361 widens the same lookup to peel a per-step pack source address back to its base run's anchor via anchorAddressForPackSource; CL-6379 classifies an accepted pack's newly-terminal runs through decideTerminalRunFlip so a section occurrence's repo-local child run (turn__) is skipped quietly. CL-6324: a third code-sourced deploy front, deployAdoptedCodeSourcedWorkflow (plus the deployAdoptedWorkflowFromSource service method and its AdoptingWorkflowDeployer / DeployAdoptedWorkflowFromSourceParams types), deploys onto shared capacity while ADOPTING an anchor workflow_run row the caller already owns; it composes upstream's emitSourceRefDeployFrame and a guarded UPDATE. DeployWorkflowFromSourceParams / DeployPreparedCodeSourcedWorkflowParams gain an optional sourceRef threaded through bindAssetAttachmentResolver / bindSourceAttachmentResolver so a per-run source tree on refs/heads/runs/ packs the pinned commit. CL-6324: workflow-probe-gate.ts's PersistFrozenApprovalFn carries the inert projection and createDbFrozenApprovalWriter stamps workflow_definition_version.wire_projection in the same transaction as approved_wire_hash. CL-6478: sanitize-tool-name.ts + event-collector.ts's tool_call case persist only a tool-call name encodeToolName can re-invert (anything else collapses to MALFORMED_TOOL_NAME), so one bad name fails its turn instead of wedging the room; @intx/inference is a dependency for this. CL-6595: workflow-run-kind.ts's validatePush also reports a run sealed from birth (combined events.jsonl with no per-event blobs) as newly terminal, and the new readCommittedWorkflowRunTerminalStatus export backs a same-push markTerminal backfill in hub-session-lookups.ts; both classify through upstream's classifyTerminalEvent. Retired at this pin: the per-event inference.usage forward (upstream fab86ca9 emits TurnUsage once per turn), the collector-level event serialization (upstream a1d419c3 serializes at the registry) and the anchor-before-frame ordering with DeployFrameNotSentError (upstream a203a057 + 9e11829f, isDeployFrameFailure). CL-7190: registerSignalCorrelation (hub-session-lookups.ts) now throws for any signal kind other than "approval", failing loud instead of silently mis-persisting a future SignalKind it has no co-write for. CL-7191: UserMessageParams gains an optional correlationId (session-service.ts) stamped as sendUserMessage's headers.interchangeCorrelationId, so a plain chat message can resolve a parked message_response gate. CL-6396: sidecar-events.ts's agent.event map and sidecar-handler.ts's agent.event dispatch forward an optional childRunId from the sidecar frame so the hub can close the matching occurrence row. diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-events.ts b/vendor/intx/hub-sessions/src/ws/sidecar-events.ts index 08ea169d0..87cd5e814 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-events.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-events.ts @@ -82,6 +82,8 @@ export type SidecarEventMap = { agentAddress: string; sessionId: string; event: unknown; + /** Occurrence child run id (`turn__`) when the sidecar supplied it. */ + childRunId?: string; }; /** Notification. Emitted once when a sidecar's connection closes, diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts index 6e89ae956..52a849cd1 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts @@ -1132,6 +1132,9 @@ export function createSidecarRouter( agentAddress: frame.agentAddress, sessionId: frame.sessionId, event: frame.event, + ...(frame.childRunId !== undefined + ? { childRunId: frame.childRunId } + : {}), }); dispatchToSubscribers(frame.agentAddress, frame.event); return; diff --git a/vendor/intx/types/VENDORED-FROM b/vendor/intx/types/VENDORED-FROM index f9ad8bee3..2bf2e87a9 100644 --- a/vendor/intx/types/VENDORED-FROM +++ b/vendor/intx/types/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/types) Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) 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-7190: "message_response" added to signals.ts's signalKinds, alongside signalKindToGateType. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-7190: "message_response" added to signals.ts's signalKinds, alongside signalKindToGateType. CL-6396: AgentEventFrame gains an optional childRunId so a sidecar can name the occurrence (turn__) the event belongs to. diff --git a/vendor/intx/types/src/sidecar.ts b/vendor/intx/types/src/sidecar.ts index ca693e021..d0c89794e 100644 --- a/vendor/intx/types/src/sidecar.ts +++ b/vendor/intx/types/src/sidecar.ts @@ -117,6 +117,7 @@ export const AgentEventFrame = type({ agentAddress: "string", sessionId: "string", event: InferenceEvent, + "childRunId?": "string", }); export type AgentEventFrame = typeof AgentEventFrame.infer; diff --git a/vendor/intx/workflow-host/VENDORED-FROM b/vendor/intx/workflow-host/VENDORED-FROM index a14b96fb0..92edf7fe1 100644 --- a/vendor/intx/workflow-host/VENDORED-FROM +++ b/vendor/intx/workflow-host/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/workflow-host) Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) 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-6448: the suspendable-child (onTrigger body) spawn seam threads the parent child's credentials-backed authorize, live CredentialWiring and MailPartReader: RunSuspendableChild's input and createInMemorySpawnSuspendableChild's opts gain the three optional fields (adapters/spawn-child.ts), and child/run-child.ts hoists `authorize` above the body resolver and passes all three when building it, so a tool-bearing body agent gates its tool calls through the same per-step grant snapshot a top-level step does, resolves credentials, and reads an attachments-only inbound mail's parts instead of throwing. Upstream runs bodies toolless and has no analog. child/run-child.ts also resolves a step's grants entry through findStepGrantsEntry (head collapse: a body step whose own id is absent from a single-step deployment's snapshot resolves to the sole entry). Retired at this pin: the empty-mail drop (CL-6164; upstream 81ef5ad9 decodes mail and omits empty content) and the action/loop runtime bind (CL-6325; upstream 3bd5b837/1ea2f39b load closure exports natively, and no workbench workflow authors an action step). +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-6448: the suspendable-child (onTrigger body) spawn seam threads the parent child's credentials-backed authorize, live CredentialWiring and MailPartReader: RunSuspendableChild's input and createInMemorySpawnSuspendableChild's opts gain the three optional fields (adapters/spawn-child.ts), and child/run-child.ts hoists `authorize` above the body resolver and passes all three when building it, so a tool-bearing body agent gates its tool calls through the same per-step grant snapshot a top-level step does, resolves credentials, and reads an attachments-only inbound mail's parts instead of throwing. Upstream runs bodies toolless and has no analog. child/run-child.ts also resolves a step's grants entry through findStepGrantsEntry (head collapse: a body step whose own id is absent from a single-step deployment's snapshot resolves to the sole entry). Retired at this pin: the empty-mail drop (CL-6164; upstream 81ef5ad9 decodes mail and omits empty content) and the action/loop runtime bind (CL-6325; upstream 3bd5b837/1ea2f39b load closure exports natively, and no workbench workflow authors an action step). CL-6396: the event-channel envelope, sender, and receiver carry an optional childRunId; run-child stamps spawnInput.childRunId onto body-child events so overlapping turns HMAC-bind the occurrence they belong to. diff --git a/vendor/intx/workflow-host/src/child/run-child.ts b/vendor/intx/workflow-host/src/child/run-child.ts index e9a2b7db8..a59fd891c 100644 --- a/vendor/intx/workflow-host/src/child/run-child.ts +++ b/vendor/intx/workflow-host/src/child/run-child.ts @@ -901,8 +901,8 @@ export async function runWorkflowChild( warmCache, sourcesRef, credentialWiring, - onEvent: (event) => { - void eventSender.send(event).catch((cause) => { + onEvent: (event, childRunId) => { + void eventSender.send(event, childRunId).catch((cause) => { logger.error`event-channel send failed during resume run ${run.runId}: ${String(cause)}`; }); }, @@ -1171,8 +1171,8 @@ async function handleControlPayload( warmCache: ctx.warmCache, sourcesRef: ctx.sourcesRef, credentialWiring: ctx.credentialWiring, - onEvent: (event) => { - void ctx.eventSender.send(event).catch((cause) => { + onEvent: (event, childRunId) => { + void ctx.eventSender.send(event, childRunId).catch((cause) => { logger.error`event-channel send failed during run ${payload.data.runId}: ${String(cause)}`; }); }, @@ -1622,7 +1622,7 @@ function buildRuntimeEnv(args: { warmCache: WarmAgentCache | undefined; sourcesRef: SourcesSnapshotRef; credentialWiring: CredentialWiring; - onEvent: (event: EventPayload) => void; + onEvent: (event: EventPayload, childRunId?: string) => void; upstreamSender: ControlChannelSender; }): WorkflowRuntimeEnv { const signalChannel = createWorkflowHostSignalChannel({ @@ -1681,7 +1681,10 @@ function buildRuntimeEnv(args: { const spawnSuspendableChild: SpawnSuspendableChild | undefined = hostSuspendable === undefined ? undefined - : (spawnInput) => hostSuspendable(spawnInput, args.onEvent); + : (spawnInput) => + hostSuspendable(spawnInput, (event) => { + args.onEvent(event, spawnInput.childRunId); + }); const env: WorkflowRuntimeEnv = { repoStore: args.runtimeRepoStore, scheduler: args.bindings.scheduler, diff --git a/vendor/intx/workflow-host/src/ipc/envelope.ts b/vendor/intx/workflow-host/src/ipc/envelope.ts index ea931c983..4555a9fd9 100644 --- a/vendor/intx/workflow-host/src/ipc/envelope.ts +++ b/vendor/intx/workflow-host/src/ipc/envelope.ts @@ -16,7 +16,7 @@ // signer signs and the verifier verifies. // // Canonical JSON: keys appear in fixed insertion order (seq, -// channelId, payload). The payload's internal structure is preserved +// channelId, payload, then childRunId when the event channel set it). The payload's internal structure is preserved // as the sender produced it; no recursive canonicalization is // performed because the verifier never compares two structurally // different serializations of the same logical value. Senders and @@ -35,6 +35,7 @@ export const FrameEnvelope = type({ seq: "number", channelId: "string", payload: "unknown", + "childRunId?": "string", }); export type FrameEnvelope = typeof FrameEnvelope.infer; @@ -69,11 +70,19 @@ export type MacedEnvelope = typeof MacedEnvelope.infer; * fixed-shape object. */ export function encodeEnvelope(envelope: FrameEnvelope): Uint8Array { - const ordered = { + const ordered: { + seq: number; + channelId: string; + payload: unknown; + childRunId?: string; + } = { seq: envelope.seq, channelId: envelope.channelId, payload: envelope.payload, }; + if (envelope.childRunId !== undefined) { + ordered.childRunId = envelope.childRunId; + } return new TextEncoder().encode(JSON.stringify(ordered)); } diff --git a/vendor/intx/workflow-host/src/ipc/event-channel.ts b/vendor/intx/workflow-host/src/ipc/event-channel.ts index 835b0c7d2..30588f22e 100644 --- a/vendor/intx/workflow-host/src/ipc/event-channel.ts +++ b/vendor/intx/workflow-host/src/ipc/event-channel.ts @@ -46,6 +46,16 @@ import { signHmac, verifyHmac } from "./crypto"; export const EventPayload = InferenceEvent; export type EventPayload = typeof EventPayload.infer; +/** + * One verified event-channel frame. `childRunId` is the occurrence the + * body child stamped (`turn__`); omitted for a top-level step event + * that is not an occurrence, and for a child that never stamped one. + */ +export type ReceivedEvent = { + readonly event: EventPayload; + readonly childRunId?: string; +}; + export interface FrameWriter { write(bytes: Uint8Array): Promise | void; } @@ -61,7 +71,7 @@ export interface EventChannelSenderOpts { } export interface EventChannelSender { - send(payload: EventPayload): Promise; + send(payload: EventPayload, childRunId?: string): Promise; readonly seq: number; } @@ -88,7 +98,7 @@ export function createEventChannelSender( get seq() { return seq; }, - send(payload: EventPayload): Promise { + send(payload: EventPayload, childRunId?: string): Promise { const previous = tail; let release: () => void = () => undefined; tail = new Promise((resolve) => { @@ -102,6 +112,7 @@ export function createEventChannelSender( seq, channelId: opts.channelId, payload, + ...(childRunId !== undefined ? { childRunId } : {}), }; const envelopeBytes = encodeEnvelope(envelope); const tag = await signHmac(envelopeBytes, opts.hmacKey); @@ -144,18 +155,19 @@ export interface EventChannelReceiverOpts { /** * Construct the supervisor-side event-channel receiver. Yields one - * verified, in-order `EventPayload` per call. Any frame that fails + * verified, in-order event per call, carrying the occurrence + * `childRunId` when the sender stamped it. Any frame that fails * HMAC verification, carries a non-current channelId, arrives out * of order, or arrives faster than the consumer drains -- triggers * `onCrash` and ends the iterator. */ export async function* receiveEventChannel( opts: EventChannelReceiverOpts, -): AsyncGenerator { +): AsyncGenerator { const limit = opts.bufferLimit ?? DEFAULT_EVENT_BUFFER_LIMIT; let highestSeq = 0; - const buffer: EventPayload[] = []; + const buffer: ReceivedEvent[] = []; let crashed = false; let producerDone = false; let waiter: (() => void) | null = null; @@ -311,7 +323,12 @@ export async function* receiveEventChannel( wake(); return true; } - buffer.push(payload); + buffer.push({ + event: payload, + ...(maced.envelope.childRunId !== undefined + ? { childRunId: maced.envelope.childRunId } + : {}), + }); wake(); return false; } diff --git a/vendor/intx/workflow-host/src/ipc/index.ts b/vendor/intx/workflow-host/src/ipc/index.ts index ab849e6b0..8944f716c 100644 --- a/vendor/intx/workflow-host/src/ipc/index.ts +++ b/vendor/intx/workflow-host/src/ipc/index.ts @@ -163,6 +163,7 @@ export { type EventChannelReceiverOpts, type FrameReader, type FrameWriter, + type ReceivedEvent, } from "./event-channel"; export { diff --git a/vendor/intx/workflow-host/src/supervisor/recycle.ts b/vendor/intx/workflow-host/src/supervisor/recycle.ts index 86544fd48..ce3b45f5a 100644 --- a/vendor/intx/workflow-host/src/supervisor/recycle.ts +++ b/vendor/intx/workflow-host/src/supervisor/recycle.ts @@ -103,6 +103,7 @@ import { type ControlChannelSender, type ControlPayload, type EventPayload, + type ReceivedEvent, } from "../ipc/index"; import { @@ -194,7 +195,7 @@ export interface RecycleContext { */ readonly warmKeep: boolean; /** Forward target for InferenceEvents the new child publishes. */ - readonly onInferenceEvent: (event: EventPayload) => void; + readonly onInferenceEvent: (event: EventPayload, childRunId?: string) => void; /** Live child wiring on entry; replaced before return. */ readonly current: ChildWiring; /** Supervisor-side drain primitive; sends the existing drain mail. */ @@ -583,11 +584,11 @@ async function waitForReady( } async function pumpEvents( - iter: AsyncGenerator, - onInferenceEvent: (event: EventPayload) => void, + iter: AsyncGenerator, + onInferenceEvent: (event: EventPayload, childRunId?: string) => void, ): Promise { - for await (const event of iter) { - onInferenceEvent(event); + for await (const received of iter) { + onInferenceEvent(received.event, received.childRunId); } } diff --git a/vendor/intx/workflow-host/src/supervisor/supervisor.ts b/vendor/intx/workflow-host/src/supervisor/supervisor.ts index fe52871a9..ad1723bb0 100644 --- a/vendor/intx/workflow-host/src/supervisor/supervisor.ts +++ b/vendor/intx/workflow-host/src/supervisor/supervisor.ts @@ -92,6 +92,7 @@ import { type ControlPayload, type EventPayload, type OutboundMessagePayload, + type ReceivedEvent, } from "../ipc/index"; import { @@ -356,7 +357,7 @@ export type SpawnOpts = { * the child publishes. Mirrors the existing `agent.event` event * sink the host exposes; the supervisor is the in-host translator. */ - onInferenceEvent: (event: EventPayload) => void; + onInferenceEvent: (event: EventPayload, childRunId?: string) => void; }; export type SpawnResult = { @@ -2143,7 +2144,7 @@ export function createWorkflowSupervisor( hmacKey: Uint8Array; ipcKeypair: { privateKey: Uint8Array; publicKey: Uint8Array }; handle: SubprocessHandle; - onInferenceEvent: (event: EventPayload) => void; + onInferenceEvent: (event: EventPayload, childRunId?: string) => void; }): Promise<{ wiring: ChildWiring; readyPromise: Promise<{ childPid: number }>; @@ -4254,7 +4255,7 @@ type ActiveState = { controlSender: ControlChannelSender; channelId: string; eventPump: Promise; - onInferenceEvent: (event: EventPayload) => void; + onInferenceEvent: (event: EventPayload, childRunId?: string) => void; mailUnsubscribe: (() => void) | null; credentialsSnapshot: CredentialsSnapshot | null; /** @@ -4325,7 +4326,7 @@ type SpawnContext = { definitionHash: string; /** Warm-keep flag carried on respawn env (unchanged across recycle). */ warmKeep: boolean; - onInferenceEvent: (event: EventPayload) => void; + onInferenceEvent: (event: EventPayload, childRunId?: string) => void; spawnedAt: number; }; @@ -4375,11 +4376,11 @@ function defaultNow(): number { * to the supervisor's shutdown path. */ async function pumpEvents( - iter: AsyncGenerator, - onInferenceEvent: (event: EventPayload) => void, + iter: AsyncGenerator, + onInferenceEvent: (event: EventPayload, childRunId?: string) => void, ): Promise { - for await (const event of iter) { - onInferenceEvent(event); + for await (const received of iter) { + onInferenceEvent(received.event, received.childRunId); } }