@sublang/playbook 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,21 +15,26 @@
15
15
  // PlaybookRuntime imported and re-exported from
16
16
  // @sublang/playbook/runtime
17
17
  // (slc/link.md §Output, DR-004 Addendum A4)
18
+ // Runtime: the shared createXStatePlaybookRuntime factory from
19
+ // @sublang/playbook/xstate-runtime interprets the FSM
20
+ // (slc/link.md §Output, DR-019); this module carries only
21
+ // the CODE-specific spec — options validation, player
22
+ // binding, prompt composition, Boss-event classification,
23
+ // and Captain-pane status formatting.
18
24
 
19
- import PQueue from 'p-queue';
20
- import { createActor, fromPromise } from 'xstate';
21
- import type { InspectionEvent, SnapshotFrom } from 'xstate';
22
25
  import {
23
- assertPlaybookRuntimeSnapshot,
24
- combineAbortSignals,
25
- createNestedPlaybookBridge,
26
- detachPersistedMachineSnapshot,
26
+ createPlayerBridge,
27
+ createXStatePlaybookRuntime,
28
+ adjudicatePlayerOutput,
27
29
  normalizeError,
28
- normalizePlaybookSnapshot,
30
+ normalizeErrorCompact,
31
+ normalizeErrorFull,
32
+ parseJudgeJson,
29
33
  snapshotJsonValue,
30
- snapshotPlaybookSession,
31
- validatePlayerResult,
32
- waitForPlaybookQuiescence,
34
+ type PlaybookPlayerInput,
35
+ type RuntimeBoundaryCalls,
36
+ type ScheduledStatus,
37
+ type XStatePlaybookRuntimeSpec,
33
38
  } from '../../../src/xstate-runtime.js';
34
39
  import {
35
40
  codingMachine,
@@ -126,12 +131,6 @@ function snapshotCodePlaybookOptions(value: unknown): CodePlaybookOptions {
126
131
  return captured as unknown as CodePlaybookOptions;
127
132
  }
128
133
 
129
- const BOSS_REPLY_ERRORS = {
130
- missingQuestion: "needsBossReply outcome missing 'question' field",
131
- unregisteredState: (stateId: string) =>
132
- `state ${stateId} declared needsBossReply but is not registered as resumable`,
133
- } as const;
134
-
135
134
  // Required-payload fields whose value is the player's verbatim long-form
136
135
  // prose. The runtime carries `finalText.trim()` into these fields rather
137
136
  // than asking the judge to round-trip the text through JSON. Short
@@ -142,34 +141,6 @@ const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
142
141
  'challenges',
143
142
  ]);
144
143
 
145
- // Normalize an unknown error value to the compact `{ name, message }`
146
- // shape used by Captain-pane / status emissions. Returns `undefined`
147
- // for nullish input so callers can omit absent errors.
148
- function normalizeErrorCompact(
149
- err: unknown,
150
- ): { name: string; message: string } | undefined {
151
- if (err === undefined || err === null) return undefined;
152
- const normalized = normalizeError(err);
153
- return { name: normalized.name, message: normalized.message };
154
- }
155
-
156
- // Normalize an unknown error value to the full `{ name, message, stack }`
157
- // shape used by telemetry emissions. Returns `undefined` for nullish
158
- // input. `stack` is omitted when not available on the source value.
159
- function normalizeErrorFull(
160
- err: unknown,
161
- ): { name: string; message: string; stack?: string } | undefined {
162
- if (err === undefined || err === null) return undefined;
163
- return normalizeError(err);
164
- }
165
-
166
- function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
167
- return (
168
- signal.aborted &&
169
- (error === signal.reason || normalizeError(error).name === 'AbortError')
170
- );
171
- }
172
-
173
144
  // Normalize any `error` field inside a telemetry event so failed
174
145
  // transitions don't leak raw Error instances through the channel.
175
146
  function normalizeEventForTelemetry(event: unknown): unknown {
@@ -220,10 +191,6 @@ function normalizeEventValue(
220
191
  return snapshotJsonValue(normalized, path);
221
192
  }
222
193
 
223
- // Internal capabilities (DR-004 §10). Each ships with its final
224
- // signature; behavior lands in the per-capability task noted by the
225
- // TODO marker.
226
-
227
194
  // Player-prompt composer — DR-004 §6.
228
195
  // Substitutes the three placeholder tokens in `input.prompt` (literal
229
196
  // string replace, no escaping) and arranges labelled blocks around
@@ -312,89 +279,6 @@ function resolvePlayerId(input: PlayerInput): string {
312
279
  }
313
280
  }
314
281
 
315
- type JudgePurpose = 'boss-input-classification' | 'player-output-adjudication';
316
-
317
- interface RuntimeBoundaryCalls {
318
- callPlayer(
319
- input: PlayerInput,
320
- playerId: string,
321
- prompt: string,
322
- signal: AbortSignal,
323
- ): Promise<PlayerResult>;
324
- callJudge(
325
- purpose: JudgePurpose,
326
- stateId: string | undefined,
327
- prompt: string,
328
- signal: AbortSignal,
329
- ): Promise<string>;
330
- }
331
-
332
- // LLM judge — DR-004 §4. Builds a prompt that lists each declared
333
- // outcome verbatim, asks ports.callJudge for a JSON
334
- // `{ guard, …payloadFields }` response, and returns the parsed
335
- // object once the chosen guard is one of the input.result keys.
336
- // Adjudicator failures (malformed JSON, missing/unknown guard) are
337
- // control-plane errors and propagate via throw per slc/link.md.
338
- async function adjudicate(
339
- input: PlayerInput,
340
- finalText: string,
341
- ports: PlaybookPorts,
342
- signal: AbortSignal,
343
- boundary?: RuntimeBoundaryCalls,
344
- ): Promise<PlayerOutput> {
345
- const prompt = buildJudgePrompt(input, finalText);
346
- const raw = boundary
347
- ? await boundary.callJudge(
348
- 'player-output-adjudication',
349
- input.stateId,
350
- prompt,
351
- signal,
352
- )
353
- : await ports.callJudge(prompt, signal);
354
- const parsed = parseJudgeJson(raw);
355
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
356
- throw new Error('adjudicate: judge response is not a JSON object');
357
- }
358
- const obj = parsed as Record<string, unknown>;
359
- const guard = obj.guard;
360
- if (typeof guard !== 'string') {
361
- throw new Error('adjudicate: judge response missing string "guard" field');
362
- }
363
- if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
364
- throw new Error(
365
- `adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(
366
- input.result,
367
- ).join(', ')}`,
368
- );
369
- }
370
- // Per slc/link.md, a missing payload field the state's `result`
371
- // description requires is a control-plane error. The FSM names
372
- // required fields with the literal phrase
373
- // Output shall include `<fieldName>: <...>`
374
- // so we extract those tokens and require each to be a string in
375
- // the judge response — except for VERBATIM_PAYLOAD_FIELDS
376
- // (`reviews`, `challenges`), where the runtime substitutes
377
- // `finalText.trim()` so the long-form prose is not round-tripped
378
- // through judge JSON. Short extracted fields like `question` and
379
- // `taskDescription` keep the existing extract-and-validate path.
380
- const verbatim = finalText.trim();
381
- for (const field of extractRequiredFields(input.result[guard])) {
382
- if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
383
- obj[field] = verbatim;
384
- continue;
385
- }
386
- if (typeof obj[field] !== 'string') {
387
- if (guard === 'needsBossReply' && field === 'question') {
388
- throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
389
- }
390
- throw new Error(
391
- `adjudicate: judge response missing required field "${field}" for guard "${guard}"`,
392
- );
393
- }
394
- }
395
- return obj as PlayerOutput;
396
- }
397
-
398
282
  function extractRequiredFields(description: string): string[] {
399
283
  const fields: string[] = [];
400
284
  const re = /Output shall include `([A-Za-z_][A-Za-z0-9_]*):/g;
@@ -427,137 +311,38 @@ function buildJudgePrompt(input: PlayerInput, finalText: string): string {
427
311
  return lines.join('\n');
428
312
  }
429
313
 
430
- // Judge replies are meant to be a single JSON object, but LLMs
431
- // routinely wrap them in prose ("Here is the result: …"), Markdown
432
- // code fences, or trailing commentary, and occasionally emit a
433
- // trailing comma or truncate the tail (a dropped closing brace, an
434
- // unterminated string). parseJudgeJson is deliberately lenient: it
435
- // first tries a strict parse of the (optionally fenced) body, then
436
- // scans every `{`/`[` as a possible start in document order and
437
- // returns the first recoverable object. At each start it prefers a
438
- // strict balanced span and falls back to a repaired (trailing-comma /
439
- // truncation) span, so a damaged object earlier in the prose is not
440
- // overridden by a cleaner one later. Scanning every start (not just
441
- // the first bracket) keeps a bracketed fragment in surrounding prose —
442
- // e.g. an aside like `see [1]` or `{n/a}` before the real object —
443
- // from masking a later, genuinely valid object. Both callers
444
- // (classification and adjudication) expect an object, so plain objects
445
- // win over arrays/scalars; the first value of any shape is remembered
446
- // so a legitimately array/scalar reply still surfaces to the caller's
447
- // own object check. Only a reply from which no JSON value can be
448
- // recovered is treated as malformed and throws, preserving the
449
- // control-plane error contract (PBRT-7, PBRT-10).
450
- function parseJudgeJson(raw: string): unknown {
451
- const fenced = stripCodeFence(raw.trim());
452
- // Fast path: a well-formed (optionally fenced) JSON body.
453
- try {
454
- return JSON.parse(fenced);
455
- } catch {
456
- // Fall through to lenient extraction + repair.
457
- }
458
- const starts: number[] = [];
459
- for (let i = 0; i < fenced.length; i++) {
460
- const ch = fenced[i];
461
- if (ch === '{' || ch === '[') starts.push(i);
462
- }
463
- // Walk starts in document order. At each start prefer a strict
464
- // balanced span (most trustworthy) and fall back to a repaired one
465
- // for a trailing-comma / truncated tail, so the earliest intended
466
- // object wins even when it needs repair. Return the first plain
467
- // object; remember the first value of any shape so a legitimately
468
- // array/scalar reply still surfaces to the caller's own object check.
469
- let firstValue: { value: unknown } | undefined;
470
- for (const start of starts) {
471
- let parsedHere: { value: unknown } | undefined;
472
- for (const repair of [false, true]) {
473
- const candidate = extractJsonValue(fenced, start, repair);
474
- if (candidate === undefined) continue;
475
- try {
476
- parsedHere = { value: JSON.parse(candidate) };
477
- } catch {
478
- continue; // not parseable this way — try repair, then next start
479
- }
480
- break; // prefer the strict span at this start over its repair
481
- }
482
- if (parsedHere === undefined) continue;
483
- if (isPlainObject(parsedHere.value)) return parsedHere.value;
484
- if (firstValue === undefined) firstValue = parsedHere;
485
- }
486
- if (firstValue !== undefined) return firstValue.value;
487
- throw new Error('adjudicate: judge response is not valid JSON');
488
- }
489
-
490
- function isPlainObject(value: unknown): boolean {
491
- return typeof value === 'object' && value !== null && !Array.isArray(value);
492
- }
493
-
494
- // Strip a single Markdown code fence that wraps the whole string.
495
- function stripCodeFence(text: string): string {
496
- const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
497
- return fence ? fence[1].trim() : text;
498
- }
499
-
500
- // Scan from `start` (a `{`/`[` index), tracking string and
501
- // bracket-nesting state, and emit the balanced JSON value rooted
502
- // there. Anything after the top-level value closes is ignored (so a
503
- // trailing code fence or commentary does not matter). With
504
- // `repair === false` the span is returned only if it actually closes,
505
- // and trailing commas are left intact — so the caller can prefer a
506
- // cleanly-balanced span before attempting repair; if input ends
507
- // before the value closes, undefined is returned. With
508
- // `repair === true` common damage is fixed: a trailing comma before a
509
- // close is removed, an unterminated string is closed, and any
510
- // brackets still open at end-of-input are closed in order.
511
- function extractJsonValue(
512
- text: string,
513
- start: number,
514
- repair: boolean,
515
- ): string | undefined {
516
- const stack: string[] = [];
517
- let out = '';
518
- let inString = false;
519
- let escaped = false;
520
- for (let i = start; i < text.length; i++) {
521
- const ch = text[i];
522
- if (inString) {
523
- out += ch;
524
- if (escaped) escaped = false;
525
- else if (ch === '\\') escaped = true;
526
- else if (ch === '"') inString = false;
527
- continue;
528
- }
529
- if (ch === '"') {
530
- inString = true;
531
- out += ch;
532
- continue;
533
- }
534
- if (ch === '{' || ch === '[') {
535
- stack.push(ch === '{' ? '}' : ']');
536
- out += ch;
537
- continue;
538
- }
539
- if (ch === '}' || ch === ']') {
540
- if (repair) out = dropTrailingComma(out);
541
- out += ch;
542
- stack.pop();
543
- if (stack.length === 0) return out; // top-level value complete
544
- continue;
545
- }
546
- out += ch;
547
- }
548
- // End of input before the top-level value closed.
549
- if (!repair) return undefined; // strict pass: no balanced span here
550
- if (inString) out += '"';
551
- out = dropTrailingComma(out);
552
- while (stack.length > 0) out += stack.pop();
553
- return out;
554
- }
314
+ // CODE-specific adjudication strategy: the CODE judge prompt above, the
315
+ // DR-004 `Output shall include` required-field extraction, and the
316
+ // verbatim long-form payload fields.
317
+ const CODE_ADJUDICATION = {
318
+ buildJudgePrompt: (input: PlaybookPlayerInput, finalText: string) =>
319
+ buildJudgePrompt(input as unknown as PlayerInput, finalText),
320
+ extractRequiredFields,
321
+ verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
322
+ };
555
323
 
556
- // Remove a trailing comma (and any whitespace after it) at the end of
557
- // the accumulated output, so `{"a":1,}` / `[1,2,]` and truncated
558
- // `{"a":1,` repair to valid JSON.
559
- function dropTrailingComma(out: string): string {
560
- return out.replace(/,(\s*)$/, '$1');
324
+ // LLM judge DR-004 §4. Delegates to the shared adjudicator with the
325
+ // CODE strategy: it lists each declared outcome verbatim, asks
326
+ // ports.callJudge for a JSON `{ guard, …payloadFields }` response, and
327
+ // returns the parsed object once the chosen guard is one of the
328
+ // input.result keys. Adjudicator failures (malformed JSON,
329
+ // missing/unknown guard) are control-plane errors and propagate via
330
+ // throw per slc/link.md.
331
+ async function adjudicate(
332
+ input: PlayerInput,
333
+ finalText: string,
334
+ ports: PlaybookPorts,
335
+ signal: AbortSignal,
336
+ boundary?: RuntimeBoundaryCalls,
337
+ ): Promise<PlayerOutput> {
338
+ return (await adjudicatePlayerOutput(
339
+ CODE_ADJUDICATION,
340
+ input,
341
+ finalText,
342
+ ports,
343
+ signal,
344
+ boundary,
345
+ )) as unknown as PlayerOutput;
561
346
  }
562
347
 
563
348
  // Boss-event classifier — DR-004 §3.
@@ -811,11 +596,12 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
811
596
  }
812
597
 
813
598
  // Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
814
- // codingMachine invokes from every player-invoking state. Per turn:
815
- // resolve playerId, compose the player prompt, await
816
- // ports.callPlayer, adjudicate the finalText. PlayerResult status of
817
- // 'aborted' or 'error' throws so XState routes via onError → #failed
818
- // (the single fail-stop sink for both Captain errors and player
599
+ // codingMachine invokes from every player-invoking state, built by the
600
+ // shared createPlayerBridge with the CODE binding, composer, and
601
+ // adjudication strategy. Per turn: resolve playerId, compose the player
602
+ // prompt, await ports.callPlayer, adjudicate the finalText. PlayerResult
603
+ // status of 'aborted' or 'error' throws so XState routes via onError →
604
+ // #failed (the single fail-stop sink for both Captain errors and player
819
605
  // failures). Captain remains the orchestrator and adjudicator; it is not
820
606
  // encoded as the delegated FSM actor.
821
607
  //
@@ -830,40 +616,20 @@ function captainBridge(
830
616
  boundary?: RuntimeBoundaryCalls,
831
617
  onControlPlaneError?: (error: unknown) => void,
832
618
  ) {
833
- return fromPromise<PlayerOutput, PlayerInput>(async ({ input, signal }) => {
834
- const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
835
- const playerId = resolvePlayerId(input);
836
- const prompt = composePlayerPrompt(input);
837
- const result = boundary
838
- ? await boundary.callPlayer(input, playerId, prompt, activeSignal)
839
- : await ports.callPlayer(playerId, prompt, activeSignal, {
840
- resume: false,
841
- });
842
- if (result.status !== 'ok') {
843
- throw new Error(
844
- result.error ?? `captainBridge: callPlayer status "${result.status}"`,
845
- );
846
- }
847
- if (result.finalText === undefined) {
848
- throw new Error(
849
- 'captainBridge: callPlayer returned status=ok with no finalText',
850
- );
851
- }
852
- try {
853
- const output = await adjudicate(
854
- input,
855
- result.finalText,
856
- ports,
857
- activeSignal,
858
- boundary,
859
- );
860
- validateBossReplyOutput(input, output);
861
- return output;
862
- } catch (error) {
863
- onControlPlaneError?.(error);
864
- throw error;
865
- }
866
- });
619
+ return createPlayerBridge(
620
+ {
621
+ resolvePlayerId: (input) =>
622
+ resolvePlayerId(input as unknown as PlayerInput),
623
+ composePlayerPrompt: (input) =>
624
+ composePlayerPrompt(input as unknown as PlayerInput),
625
+ adjudication: CODE_ADJUDICATION,
626
+ resumableStateIds: registeredResumableStateIds,
627
+ },
628
+ ports,
629
+ getActiveSignal,
630
+ boundary,
631
+ onControlPlaneError,
632
+ );
867
633
  }
868
634
 
869
635
  // Captain pane display — PBRT-3 / PBRT-14.
@@ -937,20 +703,6 @@ const registeredResumableStateIds: ReadonlySet<string> = new Set(
937
703
  ),
938
704
  );
939
705
 
940
- function validateBossReplyOutput(
941
- input: PlayerInput,
942
- output: PlayerOutput,
943
- ): void {
944
- if (output.guard !== 'needsBossReply') return;
945
- if (typeof output.question !== 'string') {
946
- throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
947
- }
948
- const stateId = input.stateId;
949
- if (!registeredResumableStateIds.has(stateId)) {
950
- throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId));
951
- }
952
- }
953
-
954
706
  const QUIESCENT_STATES: ReadonlySet<string> = new Set([
955
707
  'ready',
956
708
  'awaitBossReply',
@@ -1100,36 +852,47 @@ function stateTelemetryPayload(
1100
852
  return payload;
1101
853
  }
1102
854
 
1103
- function structuredStateTelemetryPayload(
1104
- previousState: PlaybookState | undefined,
855
+ // Captain-pane status lines for a root transition (PBRT-3 / PBRT-14):
856
+ // the `→ guard` outcome line for the settling transition, then either
857
+ // the awaitBossReply question + rider-less marker pair or the state's
858
+ // entry line (with `lastError` data on `failed`).
859
+ function statusesForState(
1105
860
  state: PlaybookState,
1106
- event: unknown,
1107
861
  context: Record<string, unknown>,
1108
- ): JsonValue {
1109
- const payload: Record<string, unknown> = {
1110
- from: previousState?.value ?? null,
1111
- to: state.value,
1112
- event: normalizeEventForTelemetry(event) ?? null,
1113
- previousState: previousState ?? null,
1114
- state,
1115
- };
1116
- if (state.stateId === 'awaitBossReply') {
1117
- const pendingBossQuestion = pendingBossQuestionFromContext(context);
1118
- if (pendingBossQuestion !== undefined) {
1119
- payload.pendingBossQuestion = pendingBossQuestion;
1120
- }
862
+ event: unknown,
863
+ ): ScheduledStatus[] {
864
+ const to = state.stateId;
865
+ if (to === undefined || !CAPTAIN_PANE_STATES.has(to)) return [];
866
+ const statuses: ScheduledStatus[] = [];
867
+ const transitionLine = formatTransition(event);
868
+ if (transitionLine !== undefined) {
869
+ statuses.push({ message: transitionLine });
1121
870
  }
1122
- if (state.stateId === 'failed') {
1123
- const lastError = normalizeErrorFull(context.lastError);
1124
- if (lastError !== undefined) payload.lastError = lastError;
871
+ if (to === 'awaitBossReply') {
872
+ statuses.push(
873
+ { message: formatAwaitBossReplyQuestion(context) },
874
+ { message: formatAwaitBossReplyMarker(context) },
875
+ );
876
+ } else {
877
+ const entryLine = formatStateEntry(to);
878
+ if (entryLine !== undefined) {
879
+ const lastError =
880
+ to === 'failed' ? normalizeErrorCompact(context.lastError) : undefined;
881
+ statuses.push({
882
+ message: entryLine,
883
+ ...(lastError === undefined
884
+ ? {}
885
+ : {
886
+ data: snapshotJsonValue({ lastError }, 'failed status data'),
887
+ }),
888
+ });
889
+ }
1125
890
  }
1126
- return snapshotJsonValue(payload, 'FSM telemetry payload');
891
+ return statuses;
1127
892
  }
1128
893
 
1129
894
  // Internal export surface for tests. Not part of the stable public API;
1130
- // the leading underscore signals "subject to change." Each member is
1131
- // referenced here so `noUnusedLocals` stays clean while later tasks
1132
- // wire the factory body to use them.
895
+ // the leading underscore signals "subject to change."
1133
896
  export const _internal = {
1134
897
  composePlayerPrompt,
1135
898
  resolvePlayerId,
@@ -1151,1214 +914,31 @@ export const _internal = {
1151
914
  VERBATIM_PAYLOAD_FIELDS,
1152
915
  };
1153
916
 
1154
- type BossSettlementOutcome =
1155
- | 'no-action'
1156
- | 'quiescent'
1157
- | 'failed'
1158
- | 'terminal'
1159
- | 'aborted'
1160
- | 'suspended';
1161
-
1162
- interface TracePosition {
1163
- turnId?: number;
1164
- callId?: string;
1165
- }
1166
-
1167
- export default function createPlaybookRuntime(
1168
- options: CodePlaybookOptions,
1169
- ): PlaybookRuntime {
1170
- const boundOptions = snapshotCodePlaybookOptions(options);
1171
- let actor: ReturnType<typeof createActor> | undefined;
1172
- let session: PlaybookSession | undefined;
1173
- let initialized = false;
1174
- let initInFlight: Promise<void> | undefined;
1175
- let disposalPromise: Promise<void> | undefined;
1176
- let disposed = false;
1177
- let savedPorts: PlaybookPorts | undefined;
1178
- let runtimePorts: PlaybookPorts | undefined;
1179
- // The Boss's per-turn AbortSignal, surfaced to captainBridge so
1180
- // ports.callPlayer / callJudge see the right cancellation source.
1181
- // null between turns; set by handleBossInput.
1182
- let activeSignal: AbortSignal | undefined;
1183
- let activeTurnId: number | undefined;
1184
- let controlPlaneError: unknown;
1185
- // Previous root-machine state for the inspect-driven telemetry /
1186
- // status emitter. undefined before the first inspect firing.
1187
- let priorState: PlaybookState | undefined;
1188
- let suppressInspectionEmissions = false;
1189
-
1190
- let traceSequence = 0;
1191
- let turnSequence = 0;
1192
- let judgeCallSequence = 0;
1193
- let playerCallSequence = 0;
1194
- let playbookCallSequence = 0;
1195
- const playerResumeTokens = new Map<string, string>();
1196
- const activePlayerIds = new Set<string>();
1197
- const playbookCallTurnIds = new Map<string, number | undefined>();
1198
- const judgeQueue = new PQueue({ concurrency: 1 });
1199
- const emissionQueue = new PQueue({ concurrency: 1 });
1200
- const activeEmissionCalls = new Set<Promise<void>>();
1201
-
1202
- // All trace, state-telemetry, and status work shares this one queue.
1203
- // Inspection callbacks enqueue a complete ordered batch synchronously;
1204
- // imperative boundaries await their queued work directly.
1205
- let emissionFailure: unknown;
1206
-
1207
- function enqueueEmission(fn: () => Promise<void>): Promise<void> {
1208
- const queued = emissionQueue.add(fn).then(() => undefined);
1209
- activeEmissionCalls.add(queued);
1210
- void queued.then(
1211
- () => activeEmissionCalls.delete(queued),
1212
- (error: unknown) => {
1213
- activeEmissionCalls.delete(queued);
1214
- emissionFailure ??= error;
1215
- },
1216
- );
1217
- return queued;
1218
- }
1219
-
1220
- async function drainEmissions(): Promise<void> {
1221
- while (true) {
1222
- const active = [...activeEmissionCalls];
1223
- if (active.length > 0) await Promise.allSettled(active);
1224
- await emissionQueue.onIdle();
1225
- if (
1226
- activeEmissionCalls.size === 0 &&
1227
- emissionQueue.size === 0 &&
1228
- emissionQueue.pending === 0
1229
- ) {
1230
- break;
1231
- }
1232
- }
1233
- if (emissionFailure !== undefined) {
1234
- const error = emissionFailure;
1235
- emissionFailure = undefined;
1236
- throw error;
1237
- }
1238
- }
1239
-
1240
- function requireSession(): PlaybookSession {
1241
- if (!session) {
1242
- throw new Error('createPlaybookRuntime: init must be called first');
1243
- }
1244
- return session;
1245
- }
1246
-
1247
- function requireHostPorts(): PlaybookPorts {
1248
- if (!savedPorts) {
1249
- throw new Error('createPlaybookRuntime: init must be called first');
1250
- }
1251
- return savedPorts;
1252
- }
1253
-
1254
- function createTraceEvent(
1255
- type: PlaybookTraceType,
1256
- payload: unknown,
1257
- position: TracePosition = {},
1258
- ): PlaybookTraceEvent {
1259
- const currentSession = requireSession();
1260
- const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
1261
- return {
1262
- schemaVersion: 2,
1263
- sessionId: currentSession.sessionId,
1264
- playbookId: currentSession.playbookId,
1265
- rootSessionId: currentSession.rootSessionId,
1266
- ...(currentSession.parentSessionId !== undefined
1267
- ? { parentSessionId: currentSession.parentSessionId }
1268
- : {}),
1269
- ...(currentSession.parentCallId !== undefined
1270
- ? { parentCallId: currentSession.parentCallId }
1271
- : {}),
1272
- depth: currentSession.depth,
1273
- sequence: ++traceSequence,
1274
- timestamp: Date.now(),
1275
- type,
1276
- ...(position.turnId !== undefined ? { turnId: position.turnId } : {}),
1277
- ...(position.callId !== undefined ? { callId: position.callId } : {}),
1278
- payload: safePayload,
1279
- };
1280
- }
1281
-
1282
- function emitTrace(
1283
- type: PlaybookTraceType,
1284
- payload: unknown,
1285
- position: TracePosition = {},
1286
- ): Promise<void> {
1287
- const currentSession = requireSession();
1288
- const event = createTraceEvent(type, payload, position);
1289
- return enqueueEmission(() =>
1290
- currentSession.ports.emitTelemetry({
1291
- topic: 'playbook.trace',
1292
- payload: event,
1293
- }),
1294
- );
1295
- }
1296
-
1297
- function stateIdentity(stateId: string | undefined): { stateId?: string } {
1298
- return stateId === undefined ? {} : { stateId };
1299
- }
1300
-
1301
- function currentState(): PlaybookState {
1302
- if (!actor) {
1303
- throw new Error('createPlaybookRuntime: actor is not initialized');
1304
- }
1305
- return normalizePlaybookSnapshot(actor.getSnapshot(), {
1306
- pendingCall: nestedBridge.getPendingCall(),
1307
- });
1308
- }
1309
-
1310
- function stateTracePayload(state = currentState()): Record<string, unknown> {
1311
- return {
1312
- state,
1313
- ...stateIdentity(state.stateId),
1314
- };
1315
- }
1316
-
1317
- function createRuntimePorts(hostPorts: PlaybookPorts): PlaybookPorts {
1318
- return {
1319
- callPlayer: (playerId, prompt, signal, callOptions) =>
1320
- hostPorts.callPlayer(playerId, prompt, signal, callOptions),
1321
- callCaptain: (prompt, signal, callOptions) =>
1322
- hostPorts.callCaptain(prompt, signal, callOptions),
1323
- callJudge: (prompt, signal) => hostPorts.callJudge(prompt, signal),
1324
- callPlaybook: (request, signal) =>
1325
- hostPorts.callPlaybook(request, signal),
1326
- emitStatus: (message, data) => {
1327
- const descriptor = actor ? currentState() : undefined;
1328
- const safeData =
1329
- data === undefined
1330
- ? undefined
1331
- : snapshotJsonValue(data, 'status data');
1332
- const trace = createTraceEvent(
1333
- 'status.emitted',
1334
- {
1335
- message,
1336
- ...(safeData !== undefined ? { data: safeData } : {}),
1337
- ...(descriptor !== undefined
1338
- ? {
1339
- state: descriptor,
1340
- ...stateIdentity(descriptor.stateId),
1341
- }
1342
- : {}),
1343
- },
1344
- activeTurnId !== undefined ? { turnId: activeTurnId } : {},
1345
- );
1346
- return enqueueEmission(async () => {
1347
- await hostPorts.emitTelemetry({
1348
- topic: 'playbook.trace',
1349
- payload: trace,
1350
- });
1351
- await hostPorts.emitStatus(message, safeData);
1352
- });
1353
- },
1354
- emitTelemetry: (event) => {
1355
- if (typeof event.topic !== 'string' || event.topic.length === 0) {
1356
- throw new TypeError('telemetry topic must be a non-empty string');
1357
- }
1358
- const payload = snapshotJsonValue(event.payload, 'telemetry payload');
1359
- return enqueueEmission(() =>
1360
- hostPorts.emitTelemetry({ topic: event.topic, payload }),
1361
- );
1362
- },
1363
- };
1364
- }
1365
-
1366
- async function emitCallStarted(
1367
- startedType: 'player.call.started' | 'judge.call.started',
1368
- finishedType: 'player.call.finished' | 'judge.call.finished',
1369
- identity: Record<string, unknown>,
1370
- position: TracePosition,
1371
- ): Promise<void> {
1372
- try {
1373
- await emitTrace(startedType, identity, position);
1374
- } catch (error) {
1375
- controlPlaneError ??= error;
1376
- try {
1377
- await emitTrace(
1378
- finishedType,
1379
- { ...identity, status: 'error', error: normalizeError(error) },
1380
- position,
1381
- );
1382
- } catch {
1383
- // Preserve the start failure after one best-effort finish attempt.
1384
- }
1385
- throw error;
1386
- }
1387
- }
1388
-
1389
- const boundary: RuntimeBoundaryCalls = {
1390
- async callPlayer(input, playerId, prompt, signal): Promise<PlayerResult> {
1391
- // State-entry telemetry/status must precede the call they describe.
1392
- await drainEmissions();
1393
- const turnId = activeTurnId;
1394
- const callId = `player-${++playerCallSequence}`;
1395
- const stateId = input.stateId;
1396
- const resume = playerResumeTokens.get(playerId) ?? false;
1397
- const identity = {
1398
- purpose: 'captain' as const,
1399
- ...stateIdentity(stateId),
1400
- sourceItem: input.sourceItem,
1401
- playerId,
1402
- resume,
1403
- };
1404
-
1405
- if (activePlayerIds.has(playerId)) {
1406
- const error = new Error(
1407
- `simultaneous calls to resolved player ${playerId} are not allowed`,
1408
- );
1409
- await emitCallStarted(
1410
- 'player.call.started',
1411
- 'player.call.finished',
1412
- { ...identity, prompt },
1413
- {
1414
- ...(turnId !== undefined ? { turnId } : {}),
1415
- callId,
1416
- },
1417
- );
1418
- await emitTrace(
1419
- 'player.call.finished',
1420
- { ...identity, status: 'error', error: normalizeError(error) },
1421
- {
1422
- ...(turnId !== undefined ? { turnId } : {}),
1423
- callId,
1424
- },
1425
- );
1426
- throw error;
1427
- }
1428
- activePlayerIds.add(playerId);
1429
-
1430
- try {
1431
- await emitTrace(
1432
- 'player.call.started',
1433
- { ...identity, prompt },
1434
- {
1435
- ...(turnId !== undefined ? { turnId } : {}),
1436
- callId,
1437
- },
1438
- );
1439
-
1440
- let rawResult: unknown;
1441
- try {
1442
- rawResult = await requireHostPorts().callPlayer(
1443
- playerId,
1444
- prompt,
1445
- signal,
1446
- { resume },
1447
- );
1448
- // A host promise is not required to honor cancellation. Do not let
1449
- // a late result mutate continuity or publish a successful finish.
1450
- signal.throwIfAborted();
1451
- } catch (error) {
1452
- if (!signal.aborted) controlPlaneError ??= error;
1453
- try {
1454
- await emitTrace(
1455
- 'player.call.finished',
1456
- {
1457
- ...identity,
1458
- status: signal.aborted ? 'aborted' : 'error',
1459
- error: normalizeError(error),
1460
- },
1461
- {
1462
- ...(turnId !== undefined ? { turnId } : {}),
1463
- callId,
1464
- },
1465
- );
1466
- } catch {
1467
- // The original non-abort port rejection remains authoritative.
1468
- }
1469
- // A thrown port call carries no authoritative result, so the
1470
- // prior token remains available for a later explicit resume.
1471
- throw error;
1472
- }
1473
-
1474
- let result: PlayerResult;
1475
- try {
1476
- result = validatePlayerResult(rawResult);
1477
- } catch (error) {
1478
- if (!signal.aborted) controlPlaneError ??= error;
1479
- try {
1480
- await emitTrace(
1481
- 'player.call.finished',
1482
- { ...identity, status: 'error', error: normalizeError(error) },
1483
- {
1484
- ...(turnId !== undefined ? { turnId } : {}),
1485
- callId,
1486
- },
1487
- );
1488
- } catch {
1489
- // The malformed host result remains authoritative.
1490
- }
1491
- throw error;
1492
- }
1493
-
1494
- if (
1495
- typeof result.resumeToken === 'string' &&
1496
- result.resumeToken.trim().length > 0
1497
- ) {
1498
- playerResumeTokens.set(playerId, result.resumeToken);
1499
- } else {
1500
- playerResumeTokens.delete(playerId);
1501
- }
1502
-
1503
- await emitTrace(
1504
- 'player.call.finished',
1505
- {
1506
- ...identity,
1507
- status: result.status,
1508
- ...(result.finalText !== undefined
1509
- ? { finalText: result.finalText }
1510
- : {}),
1511
- ...(result.error !== undefined
1512
- ? { error: normalizeError(result.error) }
1513
- : {}),
1514
- ...(result.resumeToken !== undefined
1515
- ? { resumeToken: result.resumeToken }
1516
- : {}),
1517
- },
1518
- {
1519
- ...(turnId !== undefined ? { turnId } : {}),
1520
- callId,
1521
- },
1522
- );
1523
- return result;
1524
- } finally {
1525
- activePlayerIds.delete(playerId);
1526
- }
1527
- },
1528
-
1529
- async callJudge(purpose, stateId, prompt, signal): Promise<string> {
1530
- return judgeQueue.add(async () => {
1531
- signal.throwIfAborted();
1532
- // A transition/status queued synchronously by XState must reach
1533
- // the host before the judge call that follows it.
1534
- await drainEmissions();
1535
- signal.throwIfAborted();
1536
- const turnId = activeTurnId;
1537
- const callId = `judge-${++judgeCallSequence}`;
1538
- const identity = { purpose, ...stateIdentity(stateId) };
1539
-
1540
- await emitCallStarted(
1541
- 'judge.call.started',
1542
- 'judge.call.finished',
1543
- { ...identity, prompt },
1544
- {
1545
- ...(turnId !== undefined ? { turnId } : {}),
1546
- callId,
1547
- },
1548
- );
1549
- let reply: unknown;
1550
- try {
1551
- reply = await requireHostPorts().callJudge(prompt, signal);
1552
- signal.throwIfAborted();
1553
- } catch (error) {
1554
- if (!isAbortFailure(error, signal)) {
1555
- controlPlaneError ??= error;
1556
- }
1557
- await emitTrace(
1558
- 'judge.call.finished',
1559
- {
1560
- ...identity,
1561
- status: signal.aborted ? 'aborted' : 'error',
1562
- error: normalizeError(error),
1563
- },
1564
- {
1565
- ...(turnId !== undefined ? { turnId } : {}),
1566
- callId,
1567
- },
1568
- );
1569
- throw error;
1570
- }
1571
- if (typeof reply !== 'string') {
1572
- const error = new TypeError('judge reply must be a string');
1573
- controlPlaneError ??= error;
1574
- await emitTrace(
1575
- 'judge.call.finished',
1576
- { ...identity, status: 'error', error: normalizeError(error) },
1577
- {
1578
- ...(turnId !== undefined ? { turnId } : {}),
1579
- callId,
1580
- },
1581
- );
1582
- throw error;
1583
- }
1584
- // Keep the success finish outside the port-call catch. If a
1585
- // telemetry sink records this boundary and then rejects, that sink
1586
- // failure must not synthesize a second, contradictory finish.
1587
- await emitTrace(
1588
- 'judge.call.finished',
1589
- { ...identity, status: 'ok', reply },
1590
- {
1591
- ...(turnId !== undefined ? { turnId } : {}),
1592
- callId,
1593
- },
1594
- );
1595
- return reply;
1596
- });
1597
- },
1598
- };
1599
-
1600
- const nestedBridge = createNestedPlaybookBridge({
1601
- nextCallId: () => `playbook-${++playbookCallSequence}`,
1602
- getBoundarySignal: () => activeSignal,
1603
- callPlaybook: (request, signal) =>
1604
- requireHostPorts().callPlaybook(request, signal),
1605
- emitStarted: async (event) => {
1606
- playbookCallTurnIds.set(event.callId, activeTurnId);
1607
- await emitTrace(
1608
- 'playbook.call.started',
1609
- {
1610
- stateId: event.stateId,
1611
- playbookId: event.playbookId,
1612
- text: event.text,
1613
- },
1614
- {
1615
- ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
1616
- callId: event.callId,
1617
- },
1618
- );
1619
- },
1620
- emitFinished: async (event) => {
1621
- const turnId = playbookCallTurnIds.get(event.callId);
1622
- try {
1623
- await emitTrace(
1624
- 'playbook.call.finished',
1625
- {
1626
- stateId: event.stateId,
1627
- playbookId: event.playbookId,
1628
- text: event.text,
1629
- result: event.result,
1630
- },
1631
- {
1632
- ...(turnId !== undefined ? { turnId } : {}),
1633
- callId: event.callId,
1634
- },
1635
- );
1636
- } finally {
1637
- playbookCallTurnIds.delete(event.callId);
1638
- }
1639
- },
1640
- drain: drainEmissions,
1641
- bindResumeSignal: (signal) => {
1642
- activeSignal = signal;
1643
- },
1644
- onControlPlaneError: (error) => {
1645
- if (!activeSignal?.aborted) controlPlaneError ??= error;
1646
- },
1647
- onBackgroundError: (error) => {
1648
- emissionFailure ??= error;
1649
- },
1650
- });
1651
-
1652
- function tracePositionForActiveTurn(): TracePosition {
1653
- return activeTurnId === undefined ? {} : { turnId: activeTurnId };
1654
- }
1655
-
1656
- interface ScheduledStatus {
1657
- message: string;
1658
- data?: JsonValue;
1659
- }
1660
-
1661
- function enqueueTransitionEmission(
1662
- payload: JsonValue,
1663
- state: PlaybookState,
1664
- statuses: readonly ScheduledStatus[],
1665
- position: TracePosition,
1666
- ): void {
1667
- const currentSession = requireSession();
1668
- const transitionTrace = createTraceEvent(
1669
- 'fsm.transition',
1670
- payload,
1671
- position,
1672
- );
1673
- const statusEmissions = statuses.map(({ message, data }) => ({
1674
- message,
1675
- data,
1676
- trace: createTraceEvent(
1677
- 'status.emitted',
1678
- {
1679
- message,
1680
- ...(data === undefined ? {} : { data }),
1681
- state,
1682
- ...stateIdentity(state.stateId),
1683
- },
1684
- position,
1685
- ),
1686
- }));
1687
- void enqueueEmission(async () => {
1688
- await currentSession.ports.emitTelemetry({
1689
- topic: 'playbook.trace',
1690
- payload: transitionTrace,
1691
- });
1692
- await currentSession.ports.emitTelemetry({
1693
- topic: 'playbook.fsm.state',
1694
- payload,
1695
- });
1696
- for (const status of statusEmissions) {
1697
- await currentSession.ports.emitTelemetry({
1698
- topic: 'playbook.trace',
1699
- payload: status.trace,
1700
- });
1701
- await currentSession.ports.emitStatus(status.message, status.data);
1702
- }
1703
- }).catch(() => undefined);
1704
- }
1705
-
1706
- function latchInspectionError(error: unknown): void {
1707
- if (activeSignal !== undefined) controlPlaneError ??= error;
1708
- else emissionFailure ??= error;
1709
- }
1710
-
1711
- function buildActor(
1712
- ports: PlaybookPorts,
1713
- machineSnapshot?: JsonValue,
1714
- ): ReturnType<typeof createActor> {
1715
- priorState = undefined;
1716
- let builtActor: ReturnType<typeof createActor>;
1717
- builtActor = createActor(
1718
- codingMachine.provide({
1719
- actors: {
1720
- player: captainBridge(
1721
- ports,
1722
- () => activeSignal,
1723
- boundary,
1724
- (error) => {
1725
- if (!activeSignal?.aborted) controlPlaneError ??= error;
1726
- },
1727
- ),
1728
- },
1729
- }),
1730
- {
1731
- input: boundOptions,
1732
- // DR-014 §1: a restore rehydrates the persisted machine snapshot;
1733
- // XState derives context/value from it and ignores `input` then.
1734
- ...(machineSnapshot === undefined
1735
- ? {}
1736
- : {
1737
- snapshot: machineSnapshot as unknown as SnapshotFrom<
1738
- typeof codingMachine
1739
- >,
1740
- }),
1741
- inspect: (inspectionEvent: InspectionEvent) => {
1742
- if (inspectionEvent.type !== '@xstate.snapshot') return;
1743
- if (inspectionEvent.actorRef !== builtActor) return;
1744
- if (suppressInspectionEmissions) return;
1745
- try {
1746
- const snap = inspectionEvent.snapshot as SnapshotFrom<
1747
- typeof codingMachine
1748
- >;
1749
- const state = normalizePlaybookSnapshot(snap);
1750
- const to = state.stateId;
1751
- if (to === undefined) {
1752
- throw new Error(
1753
- 'CODE root snapshot must expose exactly one playbook state id',
1754
- );
1755
- }
1756
- const previousState = priorState;
1757
- const context = snap.context as Record<string, unknown>;
1758
- const payload = structuredStateTelemetryPayload(
1759
- previousState,
1760
- state,
1761
- inspectionEvent.event,
1762
- context,
1763
- );
1764
- const statuses: ScheduledStatus[] = [];
1765
- if (CAPTAIN_PANE_STATES.has(to)) {
1766
- const transitionLine = formatTransition(inspectionEvent.event);
1767
- if (transitionLine !== undefined) {
1768
- statuses.push({ message: transitionLine });
1769
- }
1770
- if (to === 'awaitBossReply') {
1771
- statuses.push(
1772
- { message: formatAwaitBossReplyQuestion(context) },
1773
- { message: formatAwaitBossReplyMarker(context) },
1774
- );
1775
- } else {
1776
- const entryLine = formatStateEntry(to);
1777
- if (entryLine !== undefined) {
1778
- const lastError =
1779
- to === 'failed'
1780
- ? normalizeErrorCompact(
1781
- (snap.context as { lastError?: unknown }).lastError,
1782
- )
1783
- : undefined;
1784
- statuses.push({
1785
- message: entryLine,
1786
- ...(lastError === undefined
1787
- ? {}
1788
- : {
1789
- data: snapshotJsonValue(
1790
- { lastError },
1791
- 'failed status data',
1792
- ),
1793
- }),
1794
- });
1795
- }
1796
- }
1797
- }
1798
- enqueueTransitionEmission(
1799
- payload,
1800
- state,
1801
- statuses,
1802
- tracePositionForActiveTurn(),
1803
- );
1804
- priorState = state;
1805
- } catch (error) {
1806
- latchInspectionError(error);
1807
- }
1808
- },
1809
- },
1810
- );
1811
- return builtActor;
1812
- }
1813
-
1814
- function runResultFor(
1815
- outcome: BossSettlementOutcome,
1816
- error?: unknown,
1817
- ): PlaybookRunResult {
1818
- const state = currentState();
1819
- if (outcome === 'quiescent' || outcome === 'no-action') {
1820
- return { outcome, state };
1821
- }
1822
- if (outcome === 'suspended') {
1823
- const pendingCall = nestedBridge.getPendingCall();
1824
- if (!pendingCall) {
1825
- throw new Error('suspended runtime has no pending playbook call');
1826
- }
1827
- return { outcome, state, pendingCall };
1828
- }
1829
- if (outcome === 'terminal') {
1830
- const output = (actor?.getSnapshot() as { output?: unknown } | undefined)
1831
- ?.output;
1832
- if (output !== undefined) {
1833
- return {
1834
- outcome,
1835
- state,
1836
- output: snapshotJsonValue(output, 'terminal playbook output'),
1837
- };
1838
- }
1839
- return { outcome, state };
1840
- }
1841
- const failure =
1842
- error ??
1843
- (outcome === 'failed'
1844
- ? (actor?.getSnapshot() as { context?: { lastError?: unknown } })
1845
- ?.context?.lastError
1846
- : outcome === 'aborted'
1847
- ? activeSignal?.reason
1848
- : undefined);
1849
- return {
1850
- outcome,
1851
- state,
1852
- ...(failure !== undefined ? { error: normalizeError(failure) } : {}),
1853
- };
1854
- }
1855
-
1856
- function settledOutcome(signal: AbortSignal): BossSettlementOutcome {
1857
- if (nestedBridge.getPendingCall()) return 'suspended';
1858
- if (signal.aborted) return 'aborted';
1859
- const state = currentState();
1860
- if (state.status === 'error') {
1861
- const actorError = (
1862
- actor?.getSnapshot() as { error?: unknown } | undefined
1863
- )?.error;
1864
- throw actorError ?? new Error('CODE actor entered error status');
1865
- }
1866
- if (state.status === 'done') return 'terminal';
1867
- if (state.stateId === 'failed') return 'failed';
1868
- return 'quiescent';
1869
- }
1870
-
1871
- function settlementTracePayload(
1872
- result: PlaybookRunResult,
1873
- ): Record<string, unknown> {
1874
- return {
1875
- ...result,
1876
- ...stateIdentity(result.state.stateId),
1877
- };
1878
- }
1879
-
1880
- // Shared failed-start cleanup for init and restore: stop the actor,
1881
- // abort/drain nested and host work, optionally emit one best-effort
1882
- // session.disposed boundary, and unbind every closure field so dispose
1883
- // stays callable. The caller rethrows its original failure. A restore
1884
- // failure skips the disposal trace — the parked session was never
1885
- // re-bound in this process, so its persisted snapshot stays
1886
- // authoritative (DR-014 §2).
1887
- async function cleanupFailedStart(
1888
- cause: unknown,
1889
- options: { emitDisposal: boolean },
1890
- ): Promise<void> {
1891
- let finalState: PlaybookState | undefined;
1892
- if (options.emitDisposal && actor) {
1893
- try {
1894
- finalState = currentState();
1895
- } catch {
1896
- // A state that cannot even normalize has no disposal descriptor.
1897
- }
1898
- }
1899
- suppressInspectionEmissions = true;
1900
- try {
1901
- actor?.stop();
1902
- } catch {
1903
- // Preserve the original startup failure.
1904
- }
1905
- try {
1906
- await nestedBridge.abortPending(cause);
1907
- } catch {
1908
- // Preserve the original startup failure.
1909
- }
1910
- try {
1911
- await judgeQueue.onIdle();
1912
- await drainEmissions();
1913
- } catch {
1914
- // Preserve the original startup failure.
1915
- }
1916
- if (options.emitDisposal) {
1917
- try {
1918
- await emitTrace(
1919
- 'session.disposed',
1920
- finalState === undefined
1921
- ? {}
1922
- : {
1923
- state: finalState,
1924
- ...stateIdentity(finalState.stateId),
1925
- },
1926
- );
1927
- await drainEmissions();
1928
- } catch {
1929
- // The session-start error remains authoritative.
1930
- }
1931
- }
1932
- playerResumeTokens.clear();
1933
- activePlayerIds.clear();
1934
- playbookCallTurnIds.clear();
1935
- activeEmissionCalls.clear();
1936
- emissionQueue.clear();
1937
- judgeQueue.clear();
1938
- actor = undefined;
1939
- session = undefined;
1940
- savedPorts = undefined;
1941
- runtimePorts = undefined;
1942
- activeSignal = undefined;
1943
- activeTurnId = undefined;
1944
- controlPlaneError = undefined;
1945
- emissionFailure = undefined;
1946
- priorState = undefined;
1947
- suppressInspectionEmissions = false;
1948
- initialized = false;
1949
- traceSequence = 0;
1950
- turnSequence = 0;
1951
- judgeCallSequence = 0;
1952
- playerCallSequence = 0;
1953
- playbookCallSequence = 0;
1954
- }
1955
-
1956
- const runtime = {
1957
- async init(nextSession: PlaybookSession): Promise<void> {
1958
- if (initialized || disposed || disposalPromise !== undefined) {
1959
- throw new Error('createPlaybookRuntime.init: already initialized');
1960
- }
1961
- const boundSession = snapshotPlaybookSession(nextSession);
1962
- initialized = true;
1963
- let finishInitialization!: () => void;
1964
- const initialization = new Promise<void>((resolve) => {
1965
- finishInitialization = resolve;
1966
- });
1967
- initInFlight = initialization;
1968
- const initTask = (async () => {
1969
- session = boundSession;
1970
- savedPorts = boundSession.ports;
1971
- runtimePorts = createRuntimePorts(boundSession.ports);
1972
- suppressInspectionEmissions = false;
1973
- actor = buildActor(runtimePorts);
1974
- await emitTrace('session.started', stateTracePayload());
1975
- actor.start();
1976
- await drainEmissions();
1977
- })();
1978
- try {
1979
- await initTask;
1980
- } catch (error) {
1981
- await cleanupFailedStart(error, { emitDisposal: true });
1982
- throw error;
1983
- } finally {
1984
- finishInitialization();
1985
- if (initInFlight === initialization) initInFlight = undefined;
1986
- }
1987
- },
1988
-
1989
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1990
- // Defined only at a safe capture point — initialized, not disposing
1991
- // or disposed, no active public boundary, no pending nested call,
1992
- // and the actor quiescent with status `active`.
1993
- exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
1994
- if (!actor || !session || disposed || disposalPromise !== undefined) {
1995
- return undefined;
1996
- }
1997
- if (activeSignal !== undefined) return undefined;
1998
- if (nestedBridge.getPendingCall()) return undefined;
1999
- const state = currentState();
2000
- if (state.status !== 'active' || !state.quiescent) return undefined;
2001
- const machine = detachPersistedMachineSnapshot(
2002
- actor.getPersistedSnapshot(),
2003
- );
2004
- const context = (actor.getSnapshot() as { context?: unknown })
2005
- .context as Record<string, unknown>;
2006
- const pending = pendingBossQuestionFromContext(context ?? {});
2007
- return {
2008
- schemaVersion: 1,
2009
- playbookId: session.playbookId,
2010
- machine,
2011
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
2012
- sequences: {
2013
- trace: traceSequence,
2014
- turn: turnSequence,
2015
- judgeCall: judgeCallSequence,
2016
- playerCall: playerCallSequence,
2017
- playbookCall: playbookCallSequence,
2018
- },
2019
- state,
2020
- pendingBossQuestions:
2021
- pending === undefined
2022
- ? []
2023
- : [
2024
- {
2025
- questionId: pending.questionId,
2026
- player: pending.player,
2027
- question: pending.question,
2028
- sourceItem: pending.sourceItem,
2029
- },
2030
- ],
2031
- };
2032
- },
2033
-
2034
- // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
2035
- // exported snapshot under the same immutable session identity.
2036
- // Emits no `session.started`, transition trace, or human status —
2037
- // the session already started; the next public boundary continues
2038
- // the contiguous trace sequence.
2039
- async restore(
2040
- nextSession: PlaybookSession,
2041
- snapshot: PlaybookRuntimeSnapshot,
2042
- ): Promise<void> {
2043
- if (initialized || disposed || disposalPromise !== undefined) {
2044
- throw new Error('createPlaybookRuntime.restore: already initialized');
2045
- }
2046
- const boundSession = snapshotPlaybookSession(nextSession);
2047
- const boundSnapshot = assertPlaybookRuntimeSnapshot(
2048
- snapshot,
2049
- boundSession.playbookId,
2050
- );
2051
- initialized = true;
2052
- let finishInitialization!: () => void;
2053
- const initialization = new Promise<void>((resolve) => {
2054
- finishInitialization = resolve;
2055
- });
2056
- initInFlight = initialization;
2057
- const initTask = (async () => {
2058
- session = boundSession;
2059
- savedPorts = boundSession.ports;
2060
- runtimePorts = createRuntimePorts(boundSession.ports);
2061
- traceSequence = boundSnapshot.sequences.trace;
2062
- turnSequence = boundSnapshot.sequences.turn;
2063
- judgeCallSequence = boundSnapshot.sequences.judgeCall;
2064
- playerCallSequence = boundSnapshot.sequences.playerCall;
2065
- playbookCallSequence = boundSnapshot.sequences.playbookCall;
2066
- playerResumeTokens.clear();
2067
- for (const [playerId, token] of Object.entries(
2068
- boundSnapshot.playerResumeTokens,
2069
- )) {
2070
- playerResumeTokens.set(playerId, token);
2071
- }
2072
- suppressInspectionEmissions = true;
2073
- actor = buildActor(runtimePorts, boundSnapshot.machine);
2074
- actor.start();
2075
- const restoredState = currentState();
2076
- if (restoredState.status !== 'active') {
2077
- throw new Error(
2078
- `createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`,
2079
- );
2080
- }
2081
- suppressInspectionEmissions = false;
2082
- priorState = restoredState;
2083
- await drainEmissions();
2084
- })();
2085
- try {
2086
- await initTask;
2087
- } catch (error) {
2088
- await cleanupFailedStart(error, { emitDisposal: false });
2089
- throw error;
2090
- } finally {
2091
- finishInitialization();
2092
- if (initInFlight === initialization) initInFlight = undefined;
2093
- }
2094
- },
2095
-
2096
- async handleBossInput({
2097
- text,
2098
- signal,
2099
- }: {
2100
- text: string;
2101
- signal: AbortSignal;
2102
- }): Promise<PlaybookRunResult> {
2103
- if (!actor || !savedPorts) {
2104
- throw new Error(
2105
- 'createPlaybookRuntime.handleBossInput: init must be called first',
2106
- );
2107
- }
2108
- if (disposed || disposalPromise !== undefined) {
2109
- throw new Error(
2110
- 'createPlaybookRuntime.handleBossInput: runtime is disposing or disposed',
2111
- );
2112
- }
2113
- if (activeSignal !== undefined) {
2114
- throw new Error(
2115
- 'createPlaybookRuntime.handleBossInput: another runtime turn is active',
2116
- );
2117
- }
2118
- const turnId = ++turnSequence;
2119
- activeTurnId = turnId;
2120
- activeSignal = signal;
2121
- controlPlaneError = undefined;
2122
- let result: PlaybookRunResult | undefined;
2123
- let operationError: unknown;
2124
- try {
2125
- await emitTrace('boss.input.received', { text }, { turnId });
2126
- // 1. Classify non-empty text into an FSM event through the judge.
2127
- const event = await classifyBossText(
2128
- text,
2129
- runtimePorts!,
2130
- signal,
2131
- actor.getSnapshot(),
2132
- boundary,
2133
- );
2134
- // Empty input, no-action classifier output, or invalid classifier
2135
- // output — nothing to send.
2136
- if (event === undefined) {
2137
- result = runResultFor('no-action');
2138
- } else {
2139
- // 2. Captain-pane classification line (PBRT-14): the bare
2140
- // FSM event type, emitted before the FSM advances.
2141
- await runtimePorts!.emitStatus(formatClassification(event.type));
2142
- // 3. A final actor cannot accept new events; reconstruct only after
2143
- // classification produced a real event.
2144
- if (actor.getSnapshot().status === 'done') {
2145
- actor.stop();
2146
- actor = buildActor(runtimePorts!);
2147
- actor.start();
2148
- }
2149
- actor.send(event);
2150
- await waitForPlaybookQuiescence(actor, {
2151
- pendingCalls: nestedBridge,
2152
- });
2153
- if (controlPlaneError !== undefined) throw controlPlaneError;
2154
- result = runResultFor(settledOutcome(signal));
2155
- }
2156
- } catch (error) {
2157
- operationError = error;
2158
- }
2159
-
2160
- let drainError: unknown;
2161
- try {
2162
- await drainEmissions();
2163
- } catch (error) {
2164
- drainError = error;
2165
- }
2166
- const latchedControlError = controlPlaneError;
2167
- const primaryError = latchedControlError ?? drainError ?? operationError;
2168
- const abortError =
2169
- latchedControlError === undefined &&
2170
- drainError === undefined &&
2171
- operationError !== undefined &&
2172
- isAbortFailure(operationError, signal);
2173
- const settlementResult =
2174
- primaryError === undefined
2175
- ? (result ?? runResultFor('no-action'))
2176
- : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
2177
-
2178
- let settlementEmissionError: unknown;
2179
- try {
2180
- await emitTrace(
2181
- 'boss.input.settled',
2182
- settlementTracePayload(settlementResult),
2183
- { turnId },
2184
- );
2185
- } catch (error) {
2186
- settlementEmissionError = error;
2187
- }
2188
- try {
2189
- await drainEmissions();
2190
- } catch (error) {
2191
- settlementEmissionError ??= error;
2192
- }
2193
- const failure =
2194
- controlPlaneError ??
2195
- latchedControlError ??
2196
- drainError ??
2197
- (abortError
2198
- ? (settlementEmissionError ?? operationError)
2199
- : (operationError ?? settlementEmissionError));
2200
- activeSignal = undefined;
2201
- activeTurnId = undefined;
2202
- controlPlaneError = undefined;
2203
-
2204
- if (
2205
- failure !== undefined &&
2206
- !(abortError && settlementEmissionError === undefined)
2207
- ) {
2208
- throw failure;
2209
- }
2210
- return settlementResult;
2211
- },
2212
-
2213
- async resumePlaybookCall(input: {
2214
- callId: string;
2215
- result: PlaybookCallResult;
2216
- signal: AbortSignal;
2217
- }): Promise<PlaybookRunResult> {
2218
- if (!actor || !savedPorts) {
2219
- throw new Error(
2220
- 'createPlaybookRuntime.resumePlaybookCall: init must be called first',
2221
- );
2222
- }
2223
- if (disposed || disposalPromise !== undefined) {
2224
- throw new Error(
2225
- 'createPlaybookRuntime.resumePlaybookCall: runtime is disposing or disposed',
2226
- );
2227
- }
2228
- if (activeSignal !== undefined) {
2229
- throw new Error(
2230
- 'createPlaybookRuntime.resumePlaybookCall: another runtime turn is active',
2231
- );
2232
- }
2233
- activeTurnId = playbookCallTurnIds.get(input.callId);
2234
- activeSignal = input.signal;
2235
- controlPlaneError = undefined;
2236
- let result: PlaybookRunResult | undefined;
2237
- let operationError: unknown;
2238
- try {
2239
- await nestedBridge.resume(input);
2240
- } catch (error) {
2241
- operationError = error;
2242
- }
2243
- try {
2244
- await waitForPlaybookQuiescence(actor, {
2245
- pendingCalls: nestedBridge,
2246
- });
2247
- result = runResultFor(settledOutcome(input.signal));
2248
- } catch (error) {
2249
- operationError ??= error;
2250
- }
2251
- let drainError: unknown;
2252
- try {
2253
- await drainEmissions();
2254
- } catch (error) {
2255
- drainError = error;
2256
- }
2257
- const failure = controlPlaneError ?? drainError ?? operationError;
2258
- activeSignal = undefined;
2259
- activeTurnId = undefined;
2260
- controlPlaneError = undefined;
2261
- if (failure !== undefined) throw failure;
2262
- if (result === undefined) {
2263
- throw new Error('playbook resume produced no runtime result');
2264
- }
2265
- return result;
2266
- },
917
+ // The CODE-specific spec handed to the shared runtime factory
918
+ // (slc/link.md §Output, DR-019). The generic machinery — actor wiring,
919
+ // boundary tracing, Boss-turn lifecycle, nested-playbook bridge, and the
920
+ // DR-014 parked-session snapshot capability — lives in
921
+ // @sublang/playbook/xstate-runtime; this spec carries only what is
922
+ // CODE-specific.
923
+ const runtimeSpec: XStatePlaybookRuntimeSpec<CodePlaybookOptions> = {
924
+ label: 'CODE',
925
+ snapshotOptions: snapshotCodePlaybookOptions,
926
+ resolvePlayerId: (input) => resolvePlayerId(input as unknown as PlayerInput),
927
+ composePlayerPrompt: (input) =>
928
+ composePlayerPrompt(input as unknown as PlayerInput),
929
+ buildJudgePrompt: CODE_ADJUDICATION.buildJudgePrompt,
930
+ extractRequiredFields,
931
+ verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
932
+ resumableStateIds: registeredResumableStateIds,
933
+ classifyBossText: (text, ports, signal, snapshotOrState, boundary) =>
934
+ classifyBossText(text, ports, signal, snapshotOrState, boundary),
935
+ classificationStatus: (event) => formatClassification(event.type),
936
+ statusesForState,
937
+ normalizeTransitionEvent: (event) =>
938
+ normalizeEventForTelemetry(event) as JsonValue | undefined,
939
+ };
2267
940
 
2268
- dispose(): Promise<void> {
2269
- if (disposalPromise !== undefined) return disposalPromise;
2270
- if (disposed) return Promise.resolve();
2271
- if (activeSignal !== undefined) {
2272
- return Promise.reject(
2273
- new Error(
2274
- 'createPlaybookRuntime.dispose: cannot dispose during an active runtime boundary',
2275
- ),
2276
- );
2277
- }
2278
- const task = (async (): Promise<void> => {
2279
- const failures: unknown[] = [];
2280
- try {
2281
- if (initInFlight !== undefined) {
2282
- try {
2283
- await initInFlight;
2284
- } catch {
2285
- // Dispose still releases whatever an unsuccessful init bound.
2286
- }
2287
- }
2288
- const finalState = actor ? currentState() : undefined;
2289
- // Stop the root before settling a suspended child. Its rejection
2290
- // must not re-enter CODE and start fresh work during disposal.
2291
- if (actor) actor.stop();
2292
- try {
2293
- await nestedBridge.dispose();
2294
- } catch (error) {
2295
- failures.push(error);
2296
- }
2297
- try {
2298
- await drainEmissions();
2299
- } catch (error) {
2300
- failures.push(error);
2301
- }
2302
- if (session !== undefined) {
2303
- try {
2304
- await emitTrace(
2305
- 'session.disposed',
2306
- finalState === undefined
2307
- ? {}
2308
- : {
2309
- state: finalState,
2310
- ...stateIdentity(finalState.stateId),
2311
- },
2312
- );
2313
- await drainEmissions();
2314
- } catch (error) {
2315
- failures.push(error);
2316
- }
2317
- }
2318
- } finally {
2319
- playerResumeTokens.clear();
2320
- activePlayerIds.clear();
2321
- playbookCallTurnIds.clear();
2322
- activeEmissionCalls.clear();
2323
- emissionQueue.clear();
2324
- judgeQueue.clear();
2325
- actor = undefined;
2326
- activeSignal = undefined;
2327
- activeTurnId = undefined;
2328
- controlPlaneError = undefined;
2329
- emissionFailure = undefined;
2330
- savedPorts = undefined;
2331
- runtimePorts = undefined;
2332
- session = undefined;
2333
- disposed = true;
2334
- }
2335
- if (failures.length === 1) throw failures[0];
2336
- if (failures.length > 1) {
2337
- throw new AggregateError(
2338
- failures,
2339
- 'playbook runtime disposal failed',
2340
- );
2341
- }
2342
- })();
2343
- disposalPromise = task;
2344
- return task;
2345
- },
941
+ const createPlaybookRuntime: PlaybookRuntimeFactory<CodePlaybookOptions> =
942
+ createXStatePlaybookRuntime(codingMachine, runtimeSpec);
2346
943
 
2347
- // @internal — test-only escape hatch for inspecting the
2348
- // underlying actor's snapshot. Most state assertions are now
2349
- // expressible via the recorded emitStatus / emitTelemetry
2350
- // calls (DR-004 §9); the hatch stays for the few cases where
2351
- // direct context inspection is clearer (e.g., the dispose
2352
- // teardown test).
2353
- _getActor() {
2354
- return actor;
2355
- },
2356
- _getBoundary() {
2357
- return boundary;
2358
- },
2359
- _getNestedBridge() {
2360
- return nestedBridge;
2361
- },
2362
- };
2363
- return runtime as PlaybookRuntime;
2364
- }
944
+ export default createPlaybookRuntime;