From 4db3307e7b003e054fe2edd3f5b3968b92565888 Mon Sep 17 00:00:00 2001 From: Edward Irby Date: Wed, 23 Sep 2026 22:06:11 -0700 Subject: [PATCH] feat: wire sessionId alongside instanceId on the trace wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine self-mints `instanceId = ueid('bp_')` and stamps it on every trace. With an ACP ingress landing later, the host owns session identity policy (mints on `session/new`, passes a client's old id on `session/load`), so the engine must ACCEPT a host-supplied session id — it never mints one and never returns ids. No rename: the two ids are separate axes and both live on the wire. - `TraceBase` gains `sessionId: string` alongside `instanceId`; a new exported `TraceBaseSchema` is the one home for the trace wire's common shape, so per-kind validators derive from it instead of hand-mirroring. - `behavioral()` gains `{ sessionId?: string }`; the frozen return object is unchanged. Session id is computed once at factory time (`options?.sessionId ?? instanceId`) and stamped on every trace next to `instanceId`; `resumePendingThreadsForSelectedEvent` stamps it on interrupt traces too. - The frontier faculty speaks the same wire with no drift: replay/explore/ verify inputs accept `sessionId` (AJV `nullable`, defaulting to the `instanceId`), and every synthetic trace it builds (frontier, selection, deadlock) carries both ids. - `serve.ts`/`trace-consumer.ts` are pass-through only — no construction sites existed in production code; only spec fixtures gained the field. - `skills/behavioral/references/behavioral.md` factory signature updated. Validation: `bun --bun tsc --noEmit` plus the behavioral, frontier faculty, and cli serve/trace-consumer suites (211 specs). --- skills/behavioral/references/behavioral.md | 6 +- src/behavioral/behavioral.ts | 23 +++++- src/behavioral/behavioral.types.ts | 25 +++++++ src/behavioral/behavioral.utils.ts | 3 + src/behavioral/tests/schemas.spec.ts | 34 ++++++--- src/behavioral/tests/session-id.spec.ts | 37 ++++++++++ src/cli/tests/serve.spec.ts | 1 + src/cli/tests/trace-consumer.spec.ts | 1 + src/faculties/frontier/faculty.ts | 73 ++++++++++++++++++-- src/faculties/frontier/tests/faculty.spec.ts | 41 ++++++++++- 10 files changed, 226 insertions(+), 18 deletions(-) create mode 100644 src/behavioral/tests/session-id.spec.ts diff --git a/skills/behavioral/references/behavioral.md b/skills/behavioral/references/behavioral.md index 9c6157ac0..63bf08e50 100644 --- a/skills/behavioral/references/behavioral.md +++ b/skills/behavioral/references/behavioral.md @@ -34,9 +34,13 @@ import type { `useAddHandler`, no `sendTrace`, no generic type parameter: ```ts -const { useAddThread, trigger, useTrace } = behavioral({ instanceId?: string }) +const { useAddThread, trigger, useTrace } = behavioral({ sessionId?: string }) ``` +The optional `sessionId` is host-supplied session identity stamped on every +trace alongside the self-minted `instanceId` (an ACP ingress host owns session +identity policy); absent it defaults to the `instanceId`. + Threads are JSON objects: `{ label: string, rules: Idioms[], once?: true }`. Each idiom is one sync point with `request` (propose an event), `waitFor` (block until an event), `block` (forbid an event), `interrupt` (terminate the diff --git a/src/behavioral/behavioral.ts b/src/behavioral/behavioral.ts index 59782adf5..cbebba82c 100644 --- a/src/behavioral/behavioral.ts +++ b/src/behavioral/behavioral.ts @@ -87,6 +87,12 @@ const createSubject = (): SendTrace => { * and threads to run. If no events can be selected (either because all requests are blocked * or there are no requests), the program will pause until an event is admitted via `trigger`. * + * @param options - Optional factory options. + * @param options.sessionId - Host-supplied session identity stamped on every + * trace alongside `instanceId`. The engine never mints or returns session + * ids — an ingress host (e.g. the ACP host) owns session identity policy. + * Defaults to the self-minted `instanceId` when omitted. + * * **Channel invariant:** a selected event carries `ingress: true` iff it was admitted * externally through `trigger`; everything internal (dispatch-bridge results, transform * targets, `threads.registered`) arrives as a thread request added through `useAddThread`. @@ -97,8 +103,10 @@ const createSubject = (): SendTrace => { * absent = either). `trigger` is therefore external admission plus one super-step, * nothing else. */ -export const behavioral = () => { +export const behavioral = (options?: { sessionId?: string }) => { const instanceId = ueid('bp_') + /** @internal Host session identity — accepted at factory time, never minted. */ + const sessionId = options?.sessionId ?? instanceId /** * @internal * Set of threads that have yielded and are waiting for event selection. @@ -133,6 +141,7 @@ export const behavioral = () => { step: stepId, ingress, instanceId, + sessionId, }) advanceRunningToPending(running, pending) selectNextEvent() @@ -159,6 +168,7 @@ export const behavioral = () => { timestamp: Date.now(), step, instanceId, + sessionId, threads: [...pending].map(({ generator: _, ...rest }) => rest), }) @@ -168,6 +178,7 @@ export const behavioral = () => { timestamp: Date.now(), step, instanceId, + sessionId, ...frontier, }) @@ -185,6 +196,7 @@ export const behavioral = () => { timestamp: Date.now(), step, instanceId, + sessionId, }) } if (frontier.status === FRONTIER_STATUS.idle) { @@ -193,6 +205,7 @@ export const behavioral = () => { timestamp: Date.now(), step, instanceId, + sessionId, }) } } @@ -216,6 +229,7 @@ export const behavioral = () => { kind: TRACE_MESSAGE_KINDS.thread_added, timestamp: Date.now(), instanceId, + sessionId, thread: args, }) } catch (err) { @@ -223,6 +237,7 @@ export const behavioral = () => { kind: TRACE_MESSAGE_KINDS.add_thread_error, timestamp: Date.now(), instanceId, + sessionId, error: [err instanceof Error ? err.message : String(err)], space, }) @@ -232,6 +247,7 @@ export const behavioral = () => { kind: TRACE_MESSAGE_KINDS.add_thread_error, timestamp: Date.now(), instanceId, + sessionId, error: validateThread.errors ?? [], ...(typeof attemptedSpace === 'string' && { space: attemptedSpace }), }) @@ -258,6 +274,7 @@ export const behavioral = () => { pending, sendTrace, instanceId, + sessionId, step: stepId, }) if (transformers.length) { @@ -266,6 +283,7 @@ export const behavioral = () => { timestamp: Date.now(), step: stepId, instanceId, + sessionId, transformers, }) for (const { query, target, thread, space } of transformers) { @@ -284,6 +302,7 @@ export const behavioral = () => { timestamp: Date.now(), step: stepId, instanceId, + sessionId, transformer: { query, target, thread, space }, reason: result.reason, ...(result.stderr !== undefined && { stderr: result.stderr }), @@ -297,6 +316,7 @@ export const behavioral = () => { timestamp: Date.now(), step: stepId, instanceId, + sessionId, selected: selectedEvent, }) /** @@ -326,6 +346,7 @@ export const behavioral = () => { kind: TRACE_MESSAGE_KINDS.trigger_error, timestamp: Date.now(), instanceId, + sessionId, error: validateBPEvent.errors ?? [], ...(typeof attemptedSpace === 'string' ? { space: attemptedSpace } : {}), }) diff --git a/src/behavioral/behavioral.types.ts b/src/behavioral/behavioral.types.ts index 216d751d9..e14159011 100644 --- a/src/behavioral/behavioral.types.ts +++ b/src/behavioral/behavioral.types.ts @@ -319,14 +319,39 @@ export type Threads = Thread[] * `TRACE_MESSAGE_KINDS` so narrowing by `kind` remains unambiguous in the * unified `Trace | T` stream. * + * The two id axes are separate and both live on the wire: `instanceId` is the + * per-process identity the engine self-mints; `sessionId` is the host's + * session identity (an ACP/ingress host mints and loads sessions), defaulted + * to the `instanceId` when no host supplies one. The engine accepts a session + * id at factory time — it never mints one and never returns ids. + * + * @see {@link TraceBaseSchema} for the runtime (JSON-schema) mirror * @see {@link Trace} for the engine's closed trace union */ type TraceBase = { kind: string timestamp: number instanceId: string + sessionId: string } +/** + * Wire schema for the fields every trace carries — the runtime mirror of + * {@link TraceBase}. The one home for the trace wire's common shape: per-kind + * trace validators derive from this (spread the properties, extend `required`) + * instead of hand-mirroring the fields. + */ +export const TraceBaseSchema = { + type: 'object', + properties: { + kind: { type: 'string' }, + timestamp: { type: 'number' }, + instanceId: { type: 'string' }, + sessionId: { type: 'string' }, + }, + required: ['kind', 'timestamp', 'instanceId', 'sessionId'], +} as const + // --------------------------------------------------------------------------- // Trace kinds // --------------------------------------------------------------------------- diff --git a/src/behavioral/behavioral.utils.ts b/src/behavioral/behavioral.utils.ts index 728817f7c..731287845 100644 --- a/src/behavioral/behavioral.utils.ts +++ b/src/behavioral/behavioral.utils.ts @@ -119,6 +119,7 @@ export const resumePendingThreadsForSelectedEvent = ({ selectedEvent, sendTrace, instanceId, + sessionId, step, }: { running: Set @@ -126,6 +127,7 @@ export const resumePendingThreadsForSelectedEvent = ({ selectedEvent: CandidateBid sendTrace?: SendTrace instanceId: string + sessionId: string step: number }) => { const transformers: Transformer[] = [] @@ -147,6 +149,7 @@ export const resumePendingThreadsForSelectedEvent = ({ timestamp: Date.now(), step, instanceId, + sessionId, selected: selectedEvent, threadLabel: label, }) diff --git a/src/behavioral/tests/schemas.spec.ts b/src/behavioral/tests/schemas.spec.ts index 3d2e9b8f8..60aa25418 100644 --- a/src/behavioral/tests/schemas.spec.ts +++ b/src/behavioral/tests/schemas.spec.ts @@ -1,17 +1,20 @@ import { describe, expect, test } from 'bun:test' -import { ajv, validateBPEvent, validateThread, validateTransformEvaluation } from '../behavioral.types.ts' +import { + ajv, + TraceBaseSchema, + validateBPEvent, + validateThread, + validateTransformEvaluation, +} from '../behavioral.types.ts' +// Derived from TraceBaseSchema — the one home for the trace wire's common +// shape — extended per-kind with the discriminating `kind` and `step`. const compileTraceValidator = (kind: string) => ajv.compile({ - type: 'object', - properties: { - kind: { const: kind }, - timestamp: { type: 'number' }, - instanceId: { type: 'string' }, - step: { type: 'integer' }, - }, - required: ['kind', 'timestamp', 'instanceId', 'step'], + ...TraceBaseSchema, + properties: { ...TraceBaseSchema.properties, kind: { const: kind }, step: { type: 'integer' } }, + required: [...TraceBaseSchema.required, 'step'], }) describe('behavioral schemas', () => { @@ -65,6 +68,7 @@ describe('behavioral schemas', () => { kind: 'selection', timestamp: 3, instanceId: 'bp_test', + sessionId: 'sess_test', step: 3, selected: { type: 'event', detail: { value: 1 } }, } @@ -73,6 +77,18 @@ describe('behavioral schemas', () => { expect(narrowed.selected.type).toBe('event') }) + test('Trace validators reject missing sessionId', () => { + expect( + compileTraceValidator('selection')({ + kind: 'selection', + timestamp: 0, + instanceId: 'bp_test', + step: 0, + selected: { type: 'event' }, + }), + ).toBe(false) + }) + test('Trace validators reject unknown kinds and missing step', () => { expect(compileTraceValidator('selection')({ kind: 'worker', response: { id: 'worker-1' }, step: 0 })).toBe(false) expect(compileTraceValidator('deadlock')({ kind: 'deadlock', timestamp: 0, instanceId: 'bp_test' })).toBe(false) diff --git a/src/behavioral/tests/session-id.spec.ts b/src/behavioral/tests/session-id.spec.ts new file mode 100644 index 000000000..97f5e39fb --- /dev/null +++ b/src/behavioral/tests/session-id.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' + +import { behavioral } from '../behavioral.ts' +import type { Trace } from '../behavioral.types.ts' + +const runProgram = (options?: Parameters[0]): { traces: Trace[]; instanceId: string } => { + const traces: Trace[] = [] + const program = behavioral(options) + program.useTrace((trace) => { + traces.push(trace) + }) + program.addThread({ label: 'greeter', once: true, rules: [{ request: { type: 'hello' } }] }) + program.trigger({ type: 'wake' }) + const first = traces[0] as Trace | undefined + return { traces, instanceId: first?.instanceId ?? '' } +} + +describe('session id wiring', () => { + test('a host-supplied sessionId is stamped on every trace alongside instanceId', () => { + const { traces, instanceId } = runProgram({ sessionId: 'sess_host_1' }) + expect(traces.length).toBeGreaterThan(0) + for (const trace of traces) { + expect(trace.sessionId).toBe('sess_host_1') + expect(trace.instanceId).toBe(instanceId) + expect(trace.instanceId).not.toBe('sess_host_1') + } + }) + + test('without a host session id, every trace defaults sessionId to the instanceId', () => { + const { traces, instanceId } = runProgram() + expect(instanceId).not.toBe('') + expect(traces.length).toBeGreaterThan(0) + for (const trace of traces) { + expect(trace.sessionId).toBe(instanceId) + } + }) +}) diff --git a/src/cli/tests/serve.spec.ts b/src/cli/tests/serve.spec.ts index 3c674bbb2..ebe649d35 100644 --- a/src/cli/tests/serve.spec.ts +++ b/src/cli/tests/serve.spec.ts @@ -36,6 +36,7 @@ const selectionOf = (selected: { type: string; detail?: JsonObject; space?: stri kind: TRACE_MESSAGE_KINDS.selection, timestamp: 0, instanceId: 'i', + sessionId: 'i', step: 1, selected: { priority: 0, ...selected }, }) diff --git a/src/cli/tests/trace-consumer.spec.ts b/src/cli/tests/trace-consumer.spec.ts index c2ee55fcf..28e645765 100644 --- a/src/cli/tests/trace-consumer.spec.ts +++ b/src/cli/tests/trace-consumer.spec.ts @@ -10,6 +10,7 @@ const selection = (detail: JsonObject, space?: string): SelectionTrace => ({ kind: TRACE_MESSAGE_KINDS.selection, timestamp: 0, instanceId: 'i', + sessionId: 'i', step: 1, selected: { priority: 0, type: 'shell_request', detail, ...(space === undefined ? {} : { space }) }, }) diff --git a/src/faculties/frontier/faculty.ts b/src/faculties/frontier/faculty.ts index 052c2b479..173bb4329 100644 --- a/src/faculties/frontier/faculty.ts +++ b/src/faculties/frontier/faculty.ts @@ -67,14 +67,17 @@ const createFrontierTrace = ({ frontier, step, instanceId, + sessionId, }: { frontier: Frontier step: number instanceId: string + sessionId: string }): FrontierTrace => ({ kind: 'frontier', timestamp: Date.now(), instanceId, + sessionId, step, status: frontier.status, candidates: frontier.candidates.map((candidate) => ({ @@ -97,22 +100,34 @@ const createSelectionTrace = ({ selected, step, instanceId, + sessionId, }: { selected: CandidateBid step: number instanceId: string + sessionId: string }): SelectionTrace => ({ kind: TRACE_MESSAGE_KINDS.selection, timestamp: Date.now(), instanceId, + sessionId, step, selected, }) -const createDeadlockTrace = ({ step, instanceId }: { step: number; instanceId: string }): Trace => ({ +const createDeadlockTrace = ({ + step, + instanceId, + sessionId, +}: { + step: number + instanceId: string + sessionId: string +}): Trace => ({ kind: TRACE_MESSAGE_KINDS.deadlock, timestamp: Date.now(), instanceId, + sessionId, step, }) @@ -221,6 +236,8 @@ type DeadlockFinding = { * @param args.space - Optional space stamp applied to all thread rules. * @param args.instanceId - Instance id stamped on synthetic interrupt/transform * traces emitted during resumption. Defaults to a minted `ueid('bp_')`. + * @param args.sessionId - Host session id stamped on the same traces alongside + * `instanceId`. Defaults to the `instanceId` — the faculty never mints one. * @returns The replay result containing the pending set and final frontier. * * @throws If a selection event is not enabled at its replay step. @@ -232,12 +249,15 @@ const replayToFrontierRaw = ({ messages = [], space, instanceId = ueid('bp_'), + sessionId, }: { threads: Thread[] messages?: Trace[] space?: string instanceId?: string + sessionId?: string }): ReplayToFrontierResult => { + const resolvedSessionId = sessionId ?? instanceId const pending = new Set() const running = new Set() @@ -286,6 +306,7 @@ const replayToFrontierRaw = ({ pending, selectedEvent: matched, instanceId, + sessionId: resolvedSessionId, step, }) advanceRunningToPending(resumed, pending) @@ -340,11 +361,13 @@ const getRequestSuccessors = ({ selectionPolicy, step, instanceId, + sessionId, }: { frontier: Frontier selectionPolicy: 'all-enabled' | 'scheduler' step: number instanceId: string + sessionId: string }) => { if (frontier.status !== FRONTIER_STATUS.ready) { return [] @@ -355,7 +378,7 @@ const getRequestSuccessors = ({ ? [...frontier.enabled].sort((left, right) => left.priority - right.priority).slice(0, 1) : frontier.enabled - return enabled.map((candidate) => createSelectionTrace({ selected: candidate, step, instanceId })) + return enabled.map((candidate) => createSelectionTrace({ selected: candidate, step, instanceId, sessionId })) } const getTriggerSuccessors = ({ @@ -366,6 +389,7 @@ const getTriggerSuccessors = ({ triggers, space, instanceId, + sessionId, }: { pending: Set messages: Trace[] @@ -374,6 +398,7 @@ const getTriggerSuccessors = ({ triggers: BPEvent[] space?: string instanceId: string + sessionId: string }) => { const successors: SelectionTrace[] = [] @@ -399,6 +424,7 @@ const getTriggerSuccessors = ({ const selection = createSelectionTrace({ step, instanceId, + sessionId, selected: { priority: 0, type: trigger.type, @@ -414,6 +440,7 @@ const getTriggerSuccessors = ({ messages: [...messages, selection], space, instanceId, + sessionId, }) successors.push(selection) } catch { @@ -710,6 +737,8 @@ type ExploreFrontiersArgs = { space?: string /** Instance id stamped on synthetic traces. Defaults to a minted `ueid('bp_')` — pass the analyzed kernel's id to make joins natural. */ instanceId?: string + /** Host session id stamped on synthetic traces alongside `instanceId`. Defaults to the `instanceId` — the faculty never mints one. */ + sessionId?: string } /** @@ -755,11 +784,14 @@ const exploreFrontiersRaw = ({ maxDepth, space, instanceId = ueid('bp_'), + sessionId, }: ExploreFrontiersArgs): ExploreFrontiersResult => { if (strategy !== 'bfs' && strategy !== 'dfs') { throw new Error(`Unsupported frontier exploration strategy "${String(strategy)}".`) } + const resolvedSessionId = sessionId ?? instanceId + const pending: WorkItem[] = [{ messages }] const visited = new Set() const stateGraph = new Map() @@ -774,6 +806,7 @@ const exploreFrontiersRaw = ({ messages: current.messages, space, instanceId, + sessionId: resolvedSessionId, }) const stateKey = frontierStateKey({ pending: currentPending }) @@ -796,7 +829,7 @@ const exploreFrontiersRaw = ({ successors: [], }) - const frontierTrace = createFrontierTrace({ frontier, step, instanceId }) + const frontierTrace = createFrontierTrace({ frontier, step, instanceId, sessionId: resolvedSessionId }) traces.push({ messages: [...current.messages, frontierTrace], @@ -807,6 +840,7 @@ const exploreFrontiersRaw = ({ selectionPolicy, step, instanceId, + sessionId: resolvedSessionId, }) const triggerSuccessors = getTriggerSuccessors({ pending: currentPending, @@ -816,13 +850,18 @@ const exploreFrontiersRaw = ({ triggers, space, instanceId, + sessionId: resolvedSessionId, }) const successors = [...requestSuccessors, ...triggerSuccessors] if (frontier.status === FRONTIER_STATUS.deadlock && triggerSuccessors.length === 0) { findings.push({ code: 'deadlock', - messages: [...current.messages, frontierTrace, createDeadlockTrace({ step, instanceId })], + messages: [ + ...current.messages, + frontierTrace, + createDeadlockTrace({ step, instanceId, sessionId: resolvedSessionId }), + ], }) } @@ -958,6 +997,7 @@ export type FrontierReplayInput = { messages?: SelectionTrace[] space?: string instanceId?: string + sessionId?: string } export type FrontierReplayOutput = { @@ -979,6 +1019,11 @@ export const FrontierReplayInputSchema = { nullable: true, description: 'instance id stamped on synthetic traces; defaults to a minted ueid("bp_")', }, + sessionId: { + type: 'string', + nullable: true, + description: 'host session id stamped on synthetic traces; defaults to the instanceId', + }, }, required: ['threads'], additionalProperties: false, @@ -1033,6 +1078,7 @@ export type FrontierExploreInput = { maxDepth: number space?: string instanceId?: string + sessionId?: string } export type FrontierExploreOutput = { @@ -1082,6 +1128,11 @@ export const FrontierExploreInputSchema = { nullable: true, description: 'instance id stamped on synthetic traces; defaults to a minted ueid("bp_")', }, + sessionId: { + type: 'string', + nullable: true, + description: 'host session id stamped on synthetic traces; defaults to the instanceId', + }, }, required: ['threads', 'maxDepth'], additionalProperties: false, @@ -1110,6 +1161,7 @@ export type FrontierVerifyInput = { progress?: string[] space?: string instanceId?: string + sessionId?: string } export type FrontierVerifyOutput = { @@ -1166,6 +1218,11 @@ export const FrontierVerifyInputSchema = { nullable: true, description: 'instance id stamped on synthetic traces; defaults to a minted ueid("bp_")', }, + sessionId: { + type: 'string', + nullable: true, + description: 'host session id stamped on synthetic traces; defaults to the instanceId', + }, }, required: ['threads', 'maxDepth'], additionalProperties: false, @@ -1218,9 +1275,9 @@ const OP_RUNNERS: Record = { replay: { validate: validateReplayInput, errors: () => ajv.errorsText(validateReplayInput.errors), - run: ({ threads, messages, space, instanceId }: FrontierReplayInput): FrontierReplayOutput => { + run: ({ threads, messages, space, instanceId, sessionId }: FrontierReplayInput): FrontierReplayOutput => { try { - const { pending, frontier } = replayToFrontierRaw({ threads, messages, space, instanceId }) + const { pending, frontier } = replayToFrontierRaw({ threads, messages, space, instanceId, sessionId }) return { frontier, stateKey: frontierStateKey({ pending }), pendingCount: pending.size } } catch (err) { return { @@ -1245,6 +1302,7 @@ const OP_RUNNERS: Record = { maxDepth, space, instanceId, + sessionId, }: FrontierExploreInput): FrontierExploreOutput => { try { const { traces, findings, report, stateGraph } = exploreFrontiersRaw({ @@ -1256,6 +1314,7 @@ const OP_RUNNERS: Record = { maxDepth, space, instanceId, + sessionId, }) return { traces, findings, report, stateGraph: serializeStateGraph(stateGraph) } } catch (err) { @@ -1290,6 +1349,7 @@ const OP_RUNNERS: Record = { progress, space, instanceId, + sessionId, }: FrontierVerifyInput): FrontierVerifyOutput => { try { const { status, findings, report, livelocks } = verifyFrontiersRaw({ @@ -1302,6 +1362,7 @@ const OP_RUNNERS: Record = { progress, space, instanceId, + sessionId, }) return { status, findings, report, livelocks } } catch (err) { diff --git a/src/faculties/frontier/tests/faculty.spec.ts b/src/faculties/frontier/tests/faculty.spec.ts index 28bdff7e3..9133cff82 100644 --- a/src/faculties/frontier/tests/faculty.spec.ts +++ b/src/faculties/frontier/tests/faculty.spec.ts @@ -65,7 +65,9 @@ type ReplayResult = { message?: string } type ExploreResult = { - traces: Array<{ messages: Array<{ kind: string; selected?: { type: string } }> }> + traces: Array<{ + messages: Array<{ kind: string; selected?: { type: string }; instanceId?: string; sessionId?: string }> + }> findings: Array<{ code: string }> report: { visitedCount: number; findingCount: number; truncated: boolean } stateGraph: Record }> @@ -309,6 +311,43 @@ describe('explore', () => { } }) + test('stamps the host sessionId on every synthetic trace, defaulting to the instanceId', async () => { + const frontier = spawnFrontierWorker() + try { + // Host-supplied: both id axes ride the trace wire — instanceId per-process, + // sessionId the host's session identity. + frontier.call('e1', 'explore', { + threads, + strategy: 'bfs', + maxDepth: 3, + instanceId: 'test', + sessionId: 'sess_host', + }) + const { result } = await frontier.resultFor('e1') + const explore = result as ExploreResult + expect(explore.isError).toBeFalsy() + expect(explore.traces.length).toBeGreaterThan(0) + for (const record of explore.traces) { + for (const message of record.messages) { + expect(message.sessionId).toBe('sess_host') + expect(message.instanceId).toBe('test') + } + } + + // Absent sessionId defaults to the instanceId — same treatment as the + // engine, no drift between the faculty's schemas and TraceBase. + frontier.call('e2', 'explore', { threads, strategy: 'bfs', maxDepth: 3, instanceId: 'test' }) + const defaulted = await frontier.resultFor('e2') + for (const record of (defaulted.result as ExploreResult).traces) { + for (const message of record.messages) { + expect(message.sessionId).toBe('test') + } + } + } finally { + frontier.terminate() + } + }) + test('finds deadlock', async () => { const frontier = spawnFrontierWorker() try {