Skip to content

Commit c9a06cf

Browse files
authored
fix(reliability): apply the relay-field policy to the packaged driver too (#4710)
Ring 2 has never passed. It was added on 2026-07-30 (0f68fe6, #4606), after that day's last green release run, and every release since — five nightlies plus v0.7.0-rc.33 — has failed on all three OS legs with `streamShape golden mismatch (11); diverges from kernel`. Nothing was wrong with the packaged backend. `ws-server.ts` already had `stripRelayOnlyFields`: the WS relay backfills run identity onto frames the kernel actor emits bare, so the driver cancels that enrichment before recording and the two surfaces stay comparable. `protocol/messages.ts` specifies this — job_id is "Stamped downstream by the relay ... not by the kernel actor", and generation_complete is emitted by the actor as "a BARE event" with job_id and index "stamped DOWNSTREAM by the relay". `packaged.ts` is the same kind of relay and never got the rule. It records raw frames, so Ring 2 diffed a relay stream against a bare oracle and failed on exactly the fields the protocol says only the relay carries. A rule only one of two relay surfaces applies is not a policy, it is a discrepancy, so the sets move to `relay-fields.ts` and both drivers import them. The packaged driver also drops `sdk_execution_target` — sent once per socket at upgrade time by the production Fastify plugin, before any workflow exists, so the oracle has nothing to compare it against. Deliberately not changed: the journey manifest still declares only ["kernel", "ws-server"]. `PackagedDriver` has no `supports()`, so listing "packaged" would make a bare `nodetool reliability run` fail whenever no bundle is staged (compare.ts records an unstaged run as ok: false). Refs run 30927453380
1 parent 1de83a6 commit c9a06cf

4 files changed

Lines changed: 265 additions & 48 deletions

File tree

reliability/harness/src/drivers/packaged.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { packWebSocketMessage, unpackWebSocketMessage } from "@nodetool-ai/webso
2626
import type { Journey, JourneyInteraction } from "../core/journey.js";
2727
import { makeFrame, type RunFrame, type RunRecord } from "../core/record.js";
2828
import { AnchorWaiter } from "./anchors.js";
29+
import { isConnectionControlMessage, stripRelayOnlyFields } from "./relay-fields.js";
2930
import { findRepoRoot } from "./repo-root.js";
3031
import type { RunDriver } from "./types.js";
3132

@@ -235,9 +236,16 @@ export class PackagedDriver implements RunDriver {
235236
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws`);
236237

237238
ws.on("message", (data: Buffer, isBinary: boolean) => {
238-
const message = (
239+
const raw = (
239240
isBinary ? unpackWebSocketMessage(data) : JSON.parse(data.toString("utf8"))
240241
) as Record<string, unknown>;
242+
// The packaged server is a relay, exactly like `ws-server.ts`'s surface:
243+
// it boots the real `server.mjs`, whose Fastify plugin announces the
244+
// connection and whose `UnifiedWebSocketRunner` backfills run identity
245+
// onto frames the kernel oracle emits bare. Both are cancelled here so
246+
// the packaged stream is comparable to the oracle frame-for-frame.
247+
if (isConnectionControlMessage(raw)) return;
248+
const message = stripRelayOnlyFields(raw);
241249
waiter.notify(message);
242250
if (typeof message["job_id"] === "string") {
243251
jobId = message["job_id"] as string;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Relay-only field policy, shared by every driver whose surface speaks the
3+
* WS wire protocol (`ws-server.ts`, `packaged.ts`).
4+
*
5+
* Both of those surfaces run the same relay — `UnifiedWebSocketRunner` — so
6+
* both receive the same downstream enrichment the kernel oracle's raw stream
7+
* never carries. `packages/protocol/src/messages.ts` specifies this: `job_id`
8+
* is "Stamped downstream by the relay (the unified websocket runner and the
9+
* browser runner), not by the kernel actor, so it is optional on the wire",
10+
* and `generation_complete` is emitted by the actor as "a BARE event" with
11+
* `job_id` and `index` "stamped DOWNSTREAM by the relay".
12+
*
13+
* Lives here rather than in each driver because a rule that only one relay
14+
* surface applies is not a policy, it is a discrepancy — `packaged.ts` not
15+
* having it is exactly what made the Ring 2 release gate unpassable.
16+
*/
17+
18+
/**
19+
* Message `type`s the relay backfills `job_id` onto uniformly (its "outbound
20+
* spread", `unified-websocket-runner.ts`) that the kernel's own emissions for
21+
* these types (`runner.ts`/`actor.ts`) never carry at all. Scoped per-type
22+
* (not a blanket drop) because `job_update` DOES set both, and `edge_update`
23+
* DOES set `job_id`, on the raw kernel stream too — dropping either there
24+
* would hide a real regression instead of a harmless relay addition. Kept as
25+
* a driver-local rule (not a `normalize.ts` field list) because
26+
* `normalize.ts`'s generic per-key visitor has other callers — including its
27+
* own unit tests — that legitimately expect `job_id`/`workflow_id` preserved
28+
* on a `node_update`. The relay drivers are the only place that actually
29+
* knows which of these are relay-only enrichment on their wire protocol.
30+
*/
31+
export const RELAY_ONLY_JOB_ID_TYPES: ReadonlySet<string> = new Set([
32+
"node_update",
33+
"generation_complete",
34+
"output_update",
35+
// `llm_call` (task D1, journey 8 — the first journey to run a real LLM
36+
// node): the relay backfills `job_id`/`workflow_id` here the same way it
37+
// does for `node_update`; the kernel's own `llm_call` emission
38+
// (`BaseProvider`'s tracing helper) never carries either.
39+
"llm_call"
40+
]);
41+
42+
/** As {@link RELAY_ONLY_JOB_ID_TYPES}, for `workflow_id`. Includes
43+
* `edge_update` — the kernel stamps `job_id` there but never `workflow_id`. */
44+
export const RELAY_ONLY_WORKFLOW_ID_TYPES: ReadonlySet<string> = new Set([
45+
"node_update",
46+
"generation_complete",
47+
"output_update",
48+
"edge_update",
49+
"llm_call"
50+
]);
51+
52+
/**
53+
* Message `type`s that exist only as connection-scoped transport control and
54+
* are never part of a run's stream. `sdk_execution_target` is sent once per
55+
* socket, at upgrade time and BEFORE any workflow is submitted, by the
56+
* production Fastify plugin when an SDK live-runner registry is wired
57+
* (`packages/websocket/src/plugins/websocket.ts`). The kernel oracle has no
58+
* connection to announce, so there is nothing to compare it against.
59+
*/
60+
export const CONNECTION_CONTROL_MESSAGE_TYPES: ReadonlySet<string> = new Set([
61+
"sdk_execution_target"
62+
]);
63+
64+
/** True when `message` is transport control rather than a run frame — callers
65+
* drop these before recording (see {@link CONNECTION_CONTROL_MESSAGE_TYPES}). */
66+
export function isConnectionControlMessage(message: Record<string, unknown>): boolean {
67+
return CONNECTION_CONTROL_MESSAGE_TYPES.has(String(message["type"]));
68+
}
69+
70+
/** Strips fields the relay adds that the kernel driver's raw stream never has,
71+
* so the two are comparable frame-for-frame (see
72+
* {@link RELAY_ONLY_JOB_ID_TYPES}/{@link RELAY_ONLY_WORKFLOW_ID_TYPES}).
73+
* `generation_complete.index` is the same kind of addition — an
74+
* arrival-order stamp (`unified-websocket-runner.ts`'s
75+
* `generationIndexByNode`) the kernel's own `generation_complete` emission
76+
* never sets. */
77+
export function stripRelayOnlyFields(
78+
message: Record<string, unknown>
79+
): Record<string, unknown> {
80+
const type = String(message["type"]);
81+
const rest = { ...message };
82+
if (RELAY_ONLY_JOB_ID_TYPES.has(type)) delete rest["job_id"];
83+
if (RELAY_ONLY_WORKFLOW_ID_TYPES.has(type)) delete rest["workflow_id"];
84+
if (type === "generation_complete") delete rest["index"];
85+
return rest;
86+
}

reliability/harness/src/drivers/ws-server.ts

Lines changed: 1 addition & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { AnchorWaiter } from "./anchors.js";
3636
import { registerFixtureNodes } from "./fixture-nodes.js";
3737
import { journeyPythonBridgeFactory } from "./python-bridge.js";
38+
import { stripRelayOnlyFields } from "./relay-fields.js";
3839
import type { RunDriver } from "./types.js";
3940
import { maybeFrontWithProxy, type WsProxyFrontEnd } from "../faults/ws-proxy.js";
4041

@@ -45,53 +46,6 @@ const TERMINAL_JOB_STATUSES = new Set([
4546
"suspended"
4647
]);
4748

48-
/**
49-
* Message `type`s the ws-server relay backfills `job_id`/`workflow_id` onto
50-
* uniformly (its "outbound spread") that the kernel's own emissions for
51-
* these types (`runner.ts`/`actor.ts`) never carry at all. Scoped per-type
52-
* (not a blanket drop) because `job_update` DOES set both, and `edge_update`
53-
* DOES set `job_id`, on the raw kernel stream too — dropping either there
54-
* would hide a real regression instead of a harmless relay addition. Kept as
55-
* a driver-local rule (not a `normalize.ts` field list) because
56-
* `normalize.ts`'s generic per-key visitor has other callers — including its
57-
* own unit tests — that legitimately expect `job_id`/`workflow_id`
58-
* preserved on a `node_update`. This driver is the one place that actually
59-
* knows which of these are relay-only enrichment on ITS wire protocol.
60-
*/
61-
const RELAY_ONLY_JOB_ID_TYPES = new Set([
62-
"node_update",
63-
"generation_complete",
64-
"output_update",
65-
// `llm_call` (task D1, journey 8 — the first journey to run a real LLM
66-
// node): the relay backfills `job_id`/`workflow_id` here the same way it
67-
// does for `node_update`; the kernel's own `llm_call` emission
68-
// (`BaseProvider`'s tracing helper) never carries either.
69-
"llm_call"
70-
]);
71-
const RELAY_ONLY_WORKFLOW_ID_TYPES = new Set([
72-
"node_update",
73-
"generation_complete",
74-
"output_update",
75-
"edge_update",
76-
"llm_call"
77-
]);
78-
79-
/** Strips fields this driver's relay adds that the kernel driver's raw
80-
* stream never has, so the two are comparable frame-for-frame (see
81-
* {@link RELAY_ONLY_JOB_ID_TYPES}/{@link RELAY_ONLY_WORKFLOW_ID_TYPES}).
82-
* `generation_complete.index` is the same kind of addition — an
83-
* arrival-order stamp (`unified-websocket-runner.ts`'s
84-
* `generationIndexByNode`) the kernel's own `generation_complete` emission
85-
* never sets. */
86-
function stripRelayOnlyFields(message: Record<string, unknown>): Record<string, unknown> {
87-
const type = String(message["type"]);
88-
const rest = { ...message };
89-
if (RELAY_ONLY_JOB_ID_TYPES.has(type)) delete rest["job_id"];
90-
if (RELAY_ONLY_WORKFLOW_ID_TYPES.has(type)) delete rest["workflow_id"];
91-
if (type === "generation_complete") delete rest["index"];
92-
return rest;
93-
}
94-
9549
/** How long to let the server's post-disconnect cleanup settle before
9650
* recording the slot counters as final. Cleanup is asynchronous (the runner
9751
* finishes streaming, then frees the slot), so a snapshot taken the instant
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
isConnectionControlMessage,
4+
stripRelayOnlyFields
5+
} from "../src/drivers/relay-fields.js";
6+
7+
describe("stripRelayOnlyFields", () => {
8+
it("drops job_id on the types the relay backfills it onto", () => {
9+
for (const type of ["node_update", "generation_complete", "output_update", "llm_call"]) {
10+
expect(stripRelayOnlyFields({ type, job_id: "j-1" })).toEqual({ type });
11+
}
12+
});
13+
14+
it("keeps job_id on job_update and edge_update — the kernel stamps those itself", () => {
15+
// Dropping these would hide a real regression (the kernel silently
16+
// ceasing to stamp run identity) instead of a harmless relay addition.
17+
expect(stripRelayOnlyFields({ type: "job_update", job_id: "j-1" })).toEqual({
18+
type: "job_update",
19+
job_id: "j-1"
20+
});
21+
expect(stripRelayOnlyFields({ type: "edge_update", job_id: "j-1" })).toEqual({
22+
type: "edge_update",
23+
job_id: "j-1"
24+
});
25+
});
26+
27+
it("drops workflow_id on relay-enriched types, including edge_update", () => {
28+
for (const type of [
29+
"node_update",
30+
"generation_complete",
31+
"output_update",
32+
"edge_update",
33+
"llm_call"
34+
]) {
35+
expect(stripRelayOnlyFields({ type, workflow_id: "w-1" })).toEqual({ type });
36+
}
37+
});
38+
39+
it("keeps workflow_id on job_update", () => {
40+
expect(stripRelayOnlyFields({ type: "job_update", workflow_id: "w-1" })).toEqual({
41+
type: "job_update",
42+
workflow_id: "w-1"
43+
});
44+
});
45+
46+
it("drops generation_complete.index — an arrival-order stamp the kernel never sets", () => {
47+
expect(stripRelayOnlyFields({ type: "generation_complete", index: 0 })).toEqual({
48+
type: "generation_complete"
49+
});
50+
});
51+
52+
it("does not mutate its argument", () => {
53+
const message = { type: "node_update", job_id: "j-1" };
54+
stripRelayOnlyFields(message);
55+
expect(message).toEqual({ type: "node_update", job_id: "j-1" });
56+
});
57+
});
58+
59+
describe("isConnectionControlMessage", () => {
60+
it("recognizes sdk_execution_target", () => {
61+
expect(isConnectionControlMessage({ type: "sdk_execution_target", runner_id: "r-1" })).toBe(
62+
true
63+
);
64+
});
65+
66+
it("leaves run frames alone", () => {
67+
for (const type of ["job_update", "node_update", "edge_update", "output_update"]) {
68+
expect(isConnectionControlMessage({ type })).toBe(false);
69+
}
70+
});
71+
});
72+
73+
/**
74+
* Regression guard for the Ring 2 release gate (run 30927453380, tag
75+
* v0.7.0-rc.33): the packaged surface is a relay just like ws-server, but
76+
* `packaged.ts` never applied the relay-field policy, so every packaged
77+
* release run diffed relay-stamped frames against the bare kernel oracle and
78+
* failed. These are the exact frames from that failure's log.
79+
*/
80+
describe("the frames that failed Ring 2 on v0.7.0-rc.33", () => {
81+
it("reduces each one to the kernel's bare shape", () => {
82+
expect(
83+
stripRelayOnlyFields({
84+
type: "edge_update",
85+
counter: 1,
86+
edge_id: "e-0",
87+
job_id: "j-0",
88+
status: "active",
89+
workflow_id: "w-0"
90+
})
91+
).toEqual({ type: "edge_update", counter: 1, edge_id: "e-0", job_id: "j-0", status: "active" });
92+
93+
expect(
94+
stripRelayOnlyFields({
95+
type: "node_update",
96+
error: null,
97+
job_id: "j-0",
98+
node_id: "n-1",
99+
node_name: "out1",
100+
node_type: "nodetool.output.Output",
101+
properties: { name: "result" },
102+
provider_cost: null,
103+
result: null,
104+
status: "running"
105+
})
106+
).toEqual({
107+
type: "node_update",
108+
error: null,
109+
node_id: "n-1",
110+
node_name: "out1",
111+
node_type: "nodetool.output.Output",
112+
properties: { name: "result" },
113+
provider_cost: null,
114+
result: null,
115+
status: "running"
116+
});
117+
118+
expect(
119+
stripRelayOnlyFields({
120+
type: "output_update",
121+
disposition: "append",
122+
job_id: "j-0",
123+
metadata: {},
124+
node_id: "n-1",
125+
node_name: "out1",
126+
output_name: "result",
127+
output_type: "any",
128+
value: "HELLO RELIABILITY",
129+
workflow_id: "w-0"
130+
})
131+
).toEqual({
132+
type: "output_update",
133+
disposition: "append",
134+
metadata: {},
135+
node_id: "n-1",
136+
node_name: "out1",
137+
output_name: "result",
138+
output_type: "any",
139+
value: "HELLO RELIABILITY"
140+
});
141+
142+
expect(
143+
stripRelayOnlyFields({
144+
type: "generation_complete",
145+
index: 0,
146+
job_id: "j-0",
147+
node_id: "n-0",
148+
node_name: "upper1",
149+
node_type: "nodetool.text.ToUppercase",
150+
outputs: { output: "HELLO RELIABILITY" },
151+
properties: { text: "hello reliability" }
152+
})
153+
).toEqual({
154+
type: "generation_complete",
155+
node_id: "n-0",
156+
node_name: "upper1",
157+
node_type: "nodetool.text.ToUppercase",
158+
outputs: { output: "HELLO RELIABILITY" },
159+
properties: { text: "hello reliability" }
160+
});
161+
162+
expect(
163+
isConnectionControlMessage({
164+
type: "sdk_execution_target",
165+
runner_id: "e81fd54b-6544-4de9-a37c-18b6d843d440"
166+
})
167+
).toBe(true);
168+
});
169+
});

0 commit comments

Comments
 (0)