@sublang/playbook 1.0.0 → 1.3.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,9 +15,13 @@
15
15
  // PlaybookRuntime imported and re-exported from
16
16
  // @sublang/playbook/runtime
17
17
  // (slc/link.md §Output, DR-004 Addendum A4)
18
- import PQueue from 'p-queue';
19
- import { createActor, fromPromise } from 'xstate';
20
- import { assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
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.
24
+ import { createPlayerBridge, createXStatePlaybookRuntime, adjudicatePlayerOutput, normalizeError, normalizeErrorCompact, normalizeErrorFull, parseJudgeJson, snapshotJsonValue, } from '../../../src/xstate-runtime.js';
21
25
  import { codingMachine, } from './code.fsm.js';
22
26
  import { enumerateAwaitBossReply, enumerateCaptainStates, enumerateRootEvents, } from './code.fsm.introspect.js';
23
27
  function snapshotCodePlaybookOptions(value) {
@@ -45,10 +49,6 @@ function snapshotCodePlaybookOptions(value) {
45
49
  }
46
50
  return captured;
47
51
  }
48
- const BOSS_REPLY_ERRORS = {
49
- missingQuestion: "needsBossReply outcome missing 'question' field",
50
- unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
51
- };
52
52
  // Required-payload fields whose value is the player's verbatim long-form
53
53
  // prose. The runtime carries `finalText.trim()` into these fields rather
54
54
  // than asking the judge to round-trip the text through JSON. Short
@@ -58,27 +58,6 @@ const VERBATIM_PAYLOAD_FIELDS = new Set([
58
58
  'reviews',
59
59
  'challenges',
60
60
  ]);
61
- // Normalize an unknown error value to the compact `{ name, message }`
62
- // shape used by Captain-pane / status emissions. Returns `undefined`
63
- // for nullish input so callers can omit absent errors.
64
- function normalizeErrorCompact(err) {
65
- if (err === undefined || err === null)
66
- return undefined;
67
- const normalized = normalizeError(err);
68
- return { name: normalized.name, message: normalized.message };
69
- }
70
- // Normalize an unknown error value to the full `{ name, message, stack }`
71
- // shape used by telemetry emissions. Returns `undefined` for nullish
72
- // input. `stack` is omitted when not available on the source value.
73
- function normalizeErrorFull(err) {
74
- if (err === undefined || err === null)
75
- return undefined;
76
- return normalizeError(err);
77
- }
78
- function isAbortFailure(error, signal) {
79
- return (signal.aborted &&
80
- (error === signal.reason || normalizeError(error).name === 'AbortError'));
81
- }
82
61
  // Normalize any `error` field inside a telemetry event so failed
83
62
  // transitions don't leak raw Error instances through the channel.
84
63
  function normalizeEventForTelemetry(event) {
@@ -120,9 +99,6 @@ function normalizeEventValue(value, path, ancestors) {
120
99
  }
121
100
  return snapshotJsonValue(normalized, path);
122
101
  }
123
- // Internal capabilities (DR-004 §10). Each ships with its final
124
- // signature; behavior lands in the per-capability task noted by the
125
- // TODO marker.
126
102
  // Player-prompt composer — DR-004 §6.
127
103
  // Substitutes the three placeholder tokens in `input.prompt` (literal
128
104
  // string replace, no escaping) and arranges labelled blocks around
@@ -203,54 +179,6 @@ function resolvePlayerId(input) {
203
179
  }
204
180
  }
205
181
  }
206
- // LLM judge — DR-004 §4. Builds a prompt that lists each declared
207
- // outcome verbatim, asks ports.callJudge for a JSON
208
- // `{ guard, …payloadFields }` response, and returns the parsed
209
- // object once the chosen guard is one of the input.result keys.
210
- // Adjudicator failures (malformed JSON, missing/unknown guard) are
211
- // control-plane errors and propagate via throw per slc/link.md.
212
- async function adjudicate(input, finalText, ports, signal, boundary) {
213
- const prompt = buildJudgePrompt(input, finalText);
214
- const raw = boundary
215
- ? await boundary.callJudge('player-output-adjudication', input.stateId, prompt, signal)
216
- : await ports.callJudge(prompt, signal);
217
- const parsed = parseJudgeJson(raw);
218
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
219
- throw new Error('adjudicate: judge response is not a JSON object');
220
- }
221
- const obj = parsed;
222
- const guard = obj.guard;
223
- if (typeof guard !== 'string') {
224
- throw new Error('adjudicate: judge response missing string "guard" field');
225
- }
226
- if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
227
- throw new Error(`adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(input.result).join(', ')}`);
228
- }
229
- // Per slc/link.md, a missing payload field the state's `result`
230
- // description requires is a control-plane error. The FSM names
231
- // required fields with the literal phrase
232
- // Output shall include `<fieldName>: <...>`
233
- // so we extract those tokens and require each to be a string in
234
- // the judge response — except for VERBATIM_PAYLOAD_FIELDS
235
- // (`reviews`, `challenges`), where the runtime substitutes
236
- // `finalText.trim()` so the long-form prose is not round-tripped
237
- // through judge JSON. Short extracted fields like `question` and
238
- // `taskDescription` keep the existing extract-and-validate path.
239
- const verbatim = finalText.trim();
240
- for (const field of extractRequiredFields(input.result[guard])) {
241
- if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
242
- obj[field] = verbatim;
243
- continue;
244
- }
245
- if (typeof obj[field] !== 'string') {
246
- if (guard === 'needsBossReply' && field === 'question') {
247
- throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
248
- }
249
- throw new Error(`adjudicate: judge response missing required field "${field}" for guard "${guard}"`);
250
- }
251
- }
252
- return obj;
253
- }
254
182
  function extractRequiredFields(description) {
255
183
  const fields = [];
256
184
  const re = /Output shall include `([A-Za-z_][A-Za-z0-9_]*):/g;
@@ -279,145 +207,23 @@ function buildJudgePrompt(input, finalText) {
279
207
  }
280
208
  return lines.join('\n');
281
209
  }
282
- // Judge replies are meant to be a single JSON object, but LLMs
283
- // routinely wrap them in prose ("Here is the result: …"), Markdown
284
- // code fences, or trailing commentary, and occasionally emit a
285
- // trailing comma or truncate the tail (a dropped closing brace, an
286
- // unterminated string). parseJudgeJson is deliberately lenient: it
287
- // first tries a strict parse of the (optionally fenced) body, then
288
- // scans every `{`/`[` as a possible start in document order and
289
- // returns the first recoverable object. At each start it prefers a
290
- // strict balanced span and falls back to a repaired (trailing-comma /
291
- // truncation) span, so a damaged object earlier in the prose is not
292
- // overridden by a cleaner one later. Scanning every start (not just
293
- // the first bracket) keeps a bracketed fragment in surrounding prose
294
- // e.g. an aside like `see [1]` or `{n/a}` before the real object —
295
- // from masking a later, genuinely valid object. Both callers
296
- // (classification and adjudication) expect an object, so plain objects
297
- // win over arrays/scalars; the first value of any shape is remembered
298
- // so a legitimately array/scalar reply still surfaces to the caller's
299
- // own object check. Only a reply from which no JSON value can be
300
- // recovered is treated as malformed and throws, preserving the
301
- // control-plane error contract (PBRT-7, PBRT-10).
302
- function parseJudgeJson(raw) {
303
- const fenced = stripCodeFence(raw.trim());
304
- // Fast path: a well-formed (optionally fenced) JSON body.
305
- try {
306
- return JSON.parse(fenced);
307
- }
308
- catch {
309
- // Fall through to lenient extraction + repair.
310
- }
311
- const starts = [];
312
- for (let i = 0; i < fenced.length; i++) {
313
- const ch = fenced[i];
314
- if (ch === '{' || ch === '[')
315
- starts.push(i);
316
- }
317
- // Walk starts in document order. At each start prefer a strict
318
- // balanced span (most trustworthy) and fall back to a repaired one
319
- // for a trailing-comma / truncated tail, so the earliest intended
320
- // object wins even when it needs repair. Return the first plain
321
- // object; remember the first value of any shape so a legitimately
322
- // array/scalar reply still surfaces to the caller's own object check.
323
- let firstValue;
324
- for (const start of starts) {
325
- let parsedHere;
326
- for (const repair of [false, true]) {
327
- const candidate = extractJsonValue(fenced, start, repair);
328
- if (candidate === undefined)
329
- continue;
330
- try {
331
- parsedHere = { value: JSON.parse(candidate) };
332
- }
333
- catch {
334
- continue; // not parseable this way — try repair, then next start
335
- }
336
- break; // prefer the strict span at this start over its repair
337
- }
338
- if (parsedHere === undefined)
339
- continue;
340
- if (isPlainObject(parsedHere.value))
341
- return parsedHere.value;
342
- if (firstValue === undefined)
343
- firstValue = parsedHere;
344
- }
345
- if (firstValue !== undefined)
346
- return firstValue.value;
347
- throw new Error('adjudicate: judge response is not valid JSON');
348
- }
349
- function isPlainObject(value) {
350
- return typeof value === 'object' && value !== null && !Array.isArray(value);
351
- }
352
- // Strip a single Markdown code fence that wraps the whole string.
353
- function stripCodeFence(text) {
354
- const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
355
- return fence ? fence[1].trim() : text;
356
- }
357
- // Scan from `start` (a `{`/`[` index), tracking string and
358
- // bracket-nesting state, and emit the balanced JSON value rooted
359
- // there. Anything after the top-level value closes is ignored (so a
360
- // trailing code fence or commentary does not matter). With
361
- // `repair === false` the span is returned only if it actually closes,
362
- // and trailing commas are left intact — so the caller can prefer a
363
- // cleanly-balanced span before attempting repair; if input ends
364
- // before the value closes, undefined is returned. With
365
- // `repair === true` common damage is fixed: a trailing comma before a
366
- // close is removed, an unterminated string is closed, and any
367
- // brackets still open at end-of-input are closed in order.
368
- function extractJsonValue(text, start, repair) {
369
- const stack = [];
370
- let out = '';
371
- let inString = false;
372
- let escaped = false;
373
- for (let i = start; i < text.length; i++) {
374
- const ch = text[i];
375
- if (inString) {
376
- out += ch;
377
- if (escaped)
378
- escaped = false;
379
- else if (ch === '\\')
380
- escaped = true;
381
- else if (ch === '"')
382
- inString = false;
383
- continue;
384
- }
385
- if (ch === '"') {
386
- inString = true;
387
- out += ch;
388
- continue;
389
- }
390
- if (ch === '{' || ch === '[') {
391
- stack.push(ch === '{' ? '}' : ']');
392
- out += ch;
393
- continue;
394
- }
395
- if (ch === '}' || ch === ']') {
396
- if (repair)
397
- out = dropTrailingComma(out);
398
- out += ch;
399
- stack.pop();
400
- if (stack.length === 0)
401
- return out; // top-level value complete
402
- continue;
403
- }
404
- out += ch;
405
- }
406
- // End of input before the top-level value closed.
407
- if (!repair)
408
- return undefined; // strict pass: no balanced span here
409
- if (inString)
410
- out += '"';
411
- out = dropTrailingComma(out);
412
- while (stack.length > 0)
413
- out += stack.pop();
414
- return out;
415
- }
416
- // Remove a trailing comma (and any whitespace after it) at the end of
417
- // the accumulated output, so `{"a":1,}` / `[1,2,]` and truncated
418
- // `{"a":1,` repair to valid JSON.
419
- function dropTrailingComma(out) {
420
- return out.replace(/,(\s*)$/, '$1');
210
+ // CODE-specific adjudication strategy: the CODE judge prompt above, the
211
+ // DR-004 `Output shall include` required-field extraction, and the
212
+ // verbatim long-form payload fields.
213
+ const CODE_ADJUDICATION = {
214
+ buildJudgePrompt: (input, finalText) => buildJudgePrompt(input, finalText),
215
+ extractRequiredFields,
216
+ verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
217
+ };
218
+ // LLM judge DR-004 §4. Delegates to the shared adjudicator with the
219
+ // CODE strategy: it lists each declared outcome verbatim, asks
220
+ // ports.callJudge for a JSON `{ guard, …payloadFields }` response, and
221
+ // returns the parsed object once the chosen guard is one of the
222
+ // input.result keys. Adjudicator failures (malformed JSON,
223
+ // missing/unknown guard) are control-plane errors and propagate via
224
+ // throw per slc/link.md.
225
+ async function adjudicate(input, finalText, ports, signal, boundary) {
226
+ return (await adjudicatePlayerOutput(CODE_ADJUDICATION, input, finalText, ports, signal, boundary));
421
227
  }
422
228
  // Boss-event classifier — DR-004 §3.
423
229
  // Every non-empty Boss turn goes through ports.callJudge. Slash-prefixed
@@ -595,11 +401,12 @@ function buildClassifierPrompt(text, state) {
595
401
  return lines.join('\n');
596
402
  }
597
403
  // Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
598
- // codingMachine invokes from every player-invoking state. Per turn:
599
- // resolve playerId, compose the player prompt, await
600
- // ports.callPlayer, adjudicate the finalText. PlayerResult status of
601
- // 'aborted' or 'error' throws so XState routes via onError → #failed
602
- // (the single fail-stop sink for both Captain errors and player
404
+ // codingMachine invokes from every player-invoking state, built by the
405
+ // shared createPlayerBridge with the CODE binding, composer, and
406
+ // adjudication strategy. Per turn: resolve playerId, compose the player
407
+ // prompt, await ports.callPlayer, adjudicate the finalText. PlayerResult
408
+ // status of 'aborted' or 'error' throws so XState routes via onError →
409
+ // #failed (the single fail-stop sink for both Captain errors and player
603
410
  // failures). Captain remains the orchestrator and adjudicator; it is not
604
411
  // encoded as the delegated FSM actor.
605
412
  //
@@ -609,31 +416,12 @@ function buildClassifierPrompt(text, state) {
609
416
  // on actor.stop(), not on Boss abort. When omitted (e.g. direct
610
417
  // captainBridge tests), the bridge falls back to XState's signal.
611
418
  function captainBridge(ports, getActiveSignal, boundary, onControlPlaneError) {
612
- return fromPromise(async ({ input, signal }) => {
613
- const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
614
- const playerId = resolvePlayerId(input);
615
- const prompt = composePlayerPrompt(input);
616
- const result = boundary
617
- ? await boundary.callPlayer(input, playerId, prompt, activeSignal)
618
- : await ports.callPlayer(playerId, prompt, activeSignal, {
619
- resume: false,
620
- });
621
- if (result.status !== 'ok') {
622
- throw new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
623
- }
624
- if (result.finalText === undefined) {
625
- throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
626
- }
627
- try {
628
- const output = await adjudicate(input, result.finalText, ports, activeSignal, boundary);
629
- validateBossReplyOutput(input, output);
630
- return output;
631
- }
632
- catch (error) {
633
- onControlPlaneError?.(error);
634
- throw error;
635
- }
636
- });
419
+ return createPlayerBridge({
420
+ resolvePlayerId: (input) => resolvePlayerId(input),
421
+ composePlayerPrompt: (input) => composePlayerPrompt(input),
422
+ adjudication: CODE_ADJUDICATION,
423
+ resumableStateIds: registeredResumableStateIds,
424
+ }, ports, getActiveSignal, boundary, onControlPlaneError);
637
425
  }
638
426
  // Captain pane display — PBRT-3 / PBRT-14.
639
427
  // The Captain pane is a stream of three glyphs plus one bare
@@ -686,17 +474,6 @@ const stateMetadata = (() => {
686
474
  return m;
687
475
  })();
688
476
  const registeredResumableStateIds = new Set(enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map((transition) => transition.target));
689
- function validateBossReplyOutput(input, output) {
690
- if (output.guard !== 'needsBossReply')
691
- return;
692
- if (typeof output.question !== 'string') {
693
- throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
694
- }
695
- const stateId = input.stateId;
696
- if (!registeredResumableStateIds.has(stateId)) {
697
- throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId));
698
- }
699
- }
700
477
  const QUIESCENT_STATES = new Set([
701
478
  'ready',
702
479
  'awaitBossReply',
@@ -815,31 +592,40 @@ function stateTelemetryPayload(from, to, event, context) {
815
592
  }
816
593
  return payload;
817
594
  }
818
- function structuredStateTelemetryPayload(previousState, state, event, context) {
819
- const payload = {
820
- from: previousState?.value ?? null,
821
- to: state.value,
822
- event: normalizeEventForTelemetry(event) ?? null,
823
- previousState: previousState ?? null,
824
- state,
825
- };
826
- if (state.stateId === 'awaitBossReply') {
827
- const pendingBossQuestion = pendingBossQuestionFromContext(context);
828
- if (pendingBossQuestion !== undefined) {
829
- payload.pendingBossQuestion = pendingBossQuestion;
830
- }
595
+ // Captain-pane status lines for a root transition (PBRT-3 / PBRT-14):
596
+ // the `→ guard` outcome line for the settling transition, then either
597
+ // the awaitBossReply question + rider-less marker pair or the state's
598
+ // entry line (with `lastError` data on `failed`).
599
+ function statusesForState(state, context, event) {
600
+ const to = state.stateId;
601
+ if (to === undefined || !CAPTAIN_PANE_STATES.has(to))
602
+ return [];
603
+ const statuses = [];
604
+ const transitionLine = formatTransition(event);
605
+ if (transitionLine !== undefined) {
606
+ statuses.push({ message: transitionLine });
831
607
  }
832
- if (state.stateId === 'failed') {
833
- const lastError = normalizeErrorFull(context.lastError);
834
- if (lastError !== undefined)
835
- payload.lastError = lastError;
608
+ if (to === 'awaitBossReply') {
609
+ statuses.push({ message: formatAwaitBossReplyQuestion(context) }, { message: formatAwaitBossReplyMarker(context) });
610
+ }
611
+ else {
612
+ const entryLine = formatStateEntry(to);
613
+ if (entryLine !== undefined) {
614
+ const lastError = to === 'failed' ? normalizeErrorCompact(context.lastError) : undefined;
615
+ statuses.push({
616
+ message: entryLine,
617
+ ...(lastError === undefined
618
+ ? {}
619
+ : {
620
+ data: snapshotJsonValue({ lastError }, 'failed status data'),
621
+ }),
622
+ });
623
+ }
836
624
  }
837
- return snapshotJsonValue(payload, 'FSM telemetry payload');
625
+ return statuses;
838
626
  }
839
627
  // Internal export surface for tests. Not part of the stable public API;
840
- // the leading underscore signals "subject to change." Each member is
841
- // referenced here so `noUnusedLocals` stays clean while later tasks
842
- // wire the factory body to use them.
628
+ // the leading underscore signals "subject to change."
843
629
  export const _internal = {
844
630
  composePlayerPrompt,
845
631
  resolvePlayerId,
@@ -860,995 +646,25 @@ export const _internal = {
860
646
  normalizeEventForTelemetry,
861
647
  VERBATIM_PAYLOAD_FIELDS,
862
648
  };
863
- export default function createPlaybookRuntime(options) {
864
- const boundOptions = snapshotCodePlaybookOptions(options);
865
- let actor;
866
- let session;
867
- let initialized = false;
868
- let initInFlight;
869
- let disposalPromise;
870
- let disposed = false;
871
- let savedPorts;
872
- let runtimePorts;
873
- // The Boss's per-turn AbortSignal, surfaced to captainBridge so
874
- // ports.callPlayer / callJudge see the right cancellation source.
875
- // null between turns; set by handleBossInput.
876
- let activeSignal;
877
- let activeTurnId;
878
- let controlPlaneError;
879
- // Previous root-machine state for the inspect-driven telemetry /
880
- // status emitter. undefined before the first inspect firing.
881
- let priorState;
882
- let suppressInspectionEmissions = false;
883
- let traceSequence = 0;
884
- let turnSequence = 0;
885
- let judgeCallSequence = 0;
886
- let playerCallSequence = 0;
887
- let playbookCallSequence = 0;
888
- const playerResumeTokens = new Map();
889
- const activePlayerIds = new Set();
890
- const playbookCallTurnIds = new Map();
891
- const judgeQueue = new PQueue({ concurrency: 1 });
892
- const emissionQueue = new PQueue({ concurrency: 1 });
893
- const activeEmissionCalls = new Set();
894
- // All trace, state-telemetry, and status work shares this one queue.
895
- // Inspection callbacks enqueue a complete ordered batch synchronously;
896
- // imperative boundaries await their queued work directly.
897
- let emissionFailure;
898
- function enqueueEmission(fn) {
899
- const queued = emissionQueue.add(fn).then(() => undefined);
900
- activeEmissionCalls.add(queued);
901
- void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
902
- activeEmissionCalls.delete(queued);
903
- emissionFailure ??= error;
904
- });
905
- return queued;
906
- }
907
- async function drainEmissions() {
908
- while (true) {
909
- const active = [...activeEmissionCalls];
910
- if (active.length > 0)
911
- await Promise.allSettled(active);
912
- await emissionQueue.onIdle();
913
- if (activeEmissionCalls.size === 0 &&
914
- emissionQueue.size === 0 &&
915
- emissionQueue.pending === 0) {
916
- break;
917
- }
918
- }
919
- if (emissionFailure !== undefined) {
920
- const error = emissionFailure;
921
- emissionFailure = undefined;
922
- throw error;
923
- }
924
- }
925
- function requireSession() {
926
- if (!session) {
927
- throw new Error('createPlaybookRuntime: init must be called first');
928
- }
929
- return session;
930
- }
931
- function requireHostPorts() {
932
- if (!savedPorts) {
933
- throw new Error('createPlaybookRuntime: init must be called first');
934
- }
935
- return savedPorts;
936
- }
937
- function createTraceEvent(type, payload, position = {}) {
938
- const currentSession = requireSession();
939
- const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
940
- return {
941
- schemaVersion: 2,
942
- sessionId: currentSession.sessionId,
943
- playbookId: currentSession.playbookId,
944
- rootSessionId: currentSession.rootSessionId,
945
- ...(currentSession.parentSessionId !== undefined
946
- ? { parentSessionId: currentSession.parentSessionId }
947
- : {}),
948
- ...(currentSession.parentCallId !== undefined
949
- ? { parentCallId: currentSession.parentCallId }
950
- : {}),
951
- depth: currentSession.depth,
952
- sequence: ++traceSequence,
953
- timestamp: Date.now(),
954
- type,
955
- ...(position.turnId !== undefined ? { turnId: position.turnId } : {}),
956
- ...(position.callId !== undefined ? { callId: position.callId } : {}),
957
- payload: safePayload,
958
- };
959
- }
960
- function emitTrace(type, payload, position = {}) {
961
- const currentSession = requireSession();
962
- const event = createTraceEvent(type, payload, position);
963
- return enqueueEmission(() => currentSession.ports.emitTelemetry({
964
- topic: 'playbook.trace',
965
- payload: event,
966
- }));
967
- }
968
- function stateIdentity(stateId) {
969
- return stateId === undefined ? {} : { stateId };
970
- }
971
- function currentState() {
972
- if (!actor) {
973
- throw new Error('createPlaybookRuntime: actor is not initialized');
974
- }
975
- return normalizePlaybookSnapshot(actor.getSnapshot(), {
976
- pendingCall: nestedBridge.getPendingCall(),
977
- });
978
- }
979
- function stateTracePayload(state = currentState()) {
980
- return {
981
- state,
982
- ...stateIdentity(state.stateId),
983
- };
984
- }
985
- function createRuntimePorts(hostPorts) {
986
- return {
987
- callPlayer: (playerId, prompt, signal, callOptions) => hostPorts.callPlayer(playerId, prompt, signal, callOptions),
988
- callCaptain: (prompt, signal, callOptions) => hostPorts.callCaptain(prompt, signal, callOptions),
989
- callJudge: (prompt, signal) => hostPorts.callJudge(prompt, signal),
990
- callPlaybook: (request, signal) => hostPorts.callPlaybook(request, signal),
991
- emitStatus: (message, data) => {
992
- const descriptor = actor ? currentState() : undefined;
993
- const safeData = data === undefined
994
- ? undefined
995
- : snapshotJsonValue(data, 'status data');
996
- const trace = createTraceEvent('status.emitted', {
997
- message,
998
- ...(safeData !== undefined ? { data: safeData } : {}),
999
- ...(descriptor !== undefined
1000
- ? {
1001
- state: descriptor,
1002
- ...stateIdentity(descriptor.stateId),
1003
- }
1004
- : {}),
1005
- }, activeTurnId !== undefined ? { turnId: activeTurnId } : {});
1006
- return enqueueEmission(async () => {
1007
- await hostPorts.emitTelemetry({
1008
- topic: 'playbook.trace',
1009
- payload: trace,
1010
- });
1011
- await hostPorts.emitStatus(message, safeData);
1012
- });
1013
- },
1014
- emitTelemetry: (event) => {
1015
- if (typeof event.topic !== 'string' || event.topic.length === 0) {
1016
- throw new TypeError('telemetry topic must be a non-empty string');
1017
- }
1018
- const payload = snapshotJsonValue(event.payload, 'telemetry payload');
1019
- return enqueueEmission(() => hostPorts.emitTelemetry({ topic: event.topic, payload }));
1020
- },
1021
- };
1022
- }
1023
- async function emitCallStarted(startedType, finishedType, identity, position) {
1024
- try {
1025
- await emitTrace(startedType, identity, position);
1026
- }
1027
- catch (error) {
1028
- controlPlaneError ??= error;
1029
- try {
1030
- await emitTrace(finishedType, { ...identity, status: 'error', error: normalizeError(error) }, position);
1031
- }
1032
- catch {
1033
- // Preserve the start failure after one best-effort finish attempt.
1034
- }
1035
- throw error;
1036
- }
1037
- }
1038
- const boundary = {
1039
- async callPlayer(input, playerId, prompt, signal) {
1040
- // State-entry telemetry/status must precede the call they describe.
1041
- await drainEmissions();
1042
- const turnId = activeTurnId;
1043
- const callId = `player-${++playerCallSequence}`;
1044
- const stateId = input.stateId;
1045
- const resume = playerResumeTokens.get(playerId) ?? false;
1046
- const identity = {
1047
- purpose: 'captain',
1048
- ...stateIdentity(stateId),
1049
- sourceItem: input.sourceItem,
1050
- playerId,
1051
- resume,
1052
- };
1053
- if (activePlayerIds.has(playerId)) {
1054
- const error = new Error(`simultaneous calls to resolved player ${playerId} are not allowed`);
1055
- await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, {
1056
- ...(turnId !== undefined ? { turnId } : {}),
1057
- callId,
1058
- });
1059
- await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, {
1060
- ...(turnId !== undefined ? { turnId } : {}),
1061
- callId,
1062
- });
1063
- throw error;
1064
- }
1065
- activePlayerIds.add(playerId);
1066
- try {
1067
- await emitTrace('player.call.started', { ...identity, prompt }, {
1068
- ...(turnId !== undefined ? { turnId } : {}),
1069
- callId,
1070
- });
1071
- let rawResult;
1072
- try {
1073
- rawResult = await requireHostPorts().callPlayer(playerId, prompt, signal, { resume });
1074
- // A host promise is not required to honor cancellation. Do not let
1075
- // a late result mutate continuity or publish a successful finish.
1076
- signal.throwIfAborted();
1077
- }
1078
- catch (error) {
1079
- if (!signal.aborted)
1080
- controlPlaneError ??= error;
1081
- try {
1082
- await emitTrace('player.call.finished', {
1083
- ...identity,
1084
- status: signal.aborted ? 'aborted' : 'error',
1085
- error: normalizeError(error),
1086
- }, {
1087
- ...(turnId !== undefined ? { turnId } : {}),
1088
- callId,
1089
- });
1090
- }
1091
- catch {
1092
- // The original non-abort port rejection remains authoritative.
1093
- }
1094
- // A thrown port call carries no authoritative result, so the
1095
- // prior token remains available for a later explicit resume.
1096
- throw error;
1097
- }
1098
- let result;
1099
- try {
1100
- result = validatePlayerResult(rawResult);
1101
- }
1102
- catch (error) {
1103
- if (!signal.aborted)
1104
- controlPlaneError ??= error;
1105
- try {
1106
- await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, {
1107
- ...(turnId !== undefined ? { turnId } : {}),
1108
- callId,
1109
- });
1110
- }
1111
- catch {
1112
- // The malformed host result remains authoritative.
1113
- }
1114
- throw error;
1115
- }
1116
- if (typeof result.resumeToken === 'string' &&
1117
- result.resumeToken.trim().length > 0) {
1118
- playerResumeTokens.set(playerId, result.resumeToken);
1119
- }
1120
- else {
1121
- playerResumeTokens.delete(playerId);
1122
- }
1123
- await emitTrace('player.call.finished', {
1124
- ...identity,
1125
- status: result.status,
1126
- ...(result.finalText !== undefined
1127
- ? { finalText: result.finalText }
1128
- : {}),
1129
- ...(result.error !== undefined
1130
- ? { error: normalizeError(result.error) }
1131
- : {}),
1132
- ...(result.resumeToken !== undefined
1133
- ? { resumeToken: result.resumeToken }
1134
- : {}),
1135
- }, {
1136
- ...(turnId !== undefined ? { turnId } : {}),
1137
- callId,
1138
- });
1139
- return result;
1140
- }
1141
- finally {
1142
- activePlayerIds.delete(playerId);
1143
- }
1144
- },
1145
- async callJudge(purpose, stateId, prompt, signal) {
1146
- return judgeQueue.add(async () => {
1147
- signal.throwIfAborted();
1148
- // A transition/status queued synchronously by XState must reach
1149
- // the host before the judge call that follows it.
1150
- await drainEmissions();
1151
- signal.throwIfAborted();
1152
- const turnId = activeTurnId;
1153
- const callId = `judge-${++judgeCallSequence}`;
1154
- const identity = { purpose, ...stateIdentity(stateId) };
1155
- await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, {
1156
- ...(turnId !== undefined ? { turnId } : {}),
1157
- callId,
1158
- });
1159
- let reply;
1160
- try {
1161
- reply = await requireHostPorts().callJudge(prompt, signal);
1162
- signal.throwIfAborted();
1163
- }
1164
- catch (error) {
1165
- if (!isAbortFailure(error, signal)) {
1166
- controlPlaneError ??= error;
1167
- }
1168
- await emitTrace('judge.call.finished', {
1169
- ...identity,
1170
- status: signal.aborted ? 'aborted' : 'error',
1171
- error: normalizeError(error),
1172
- }, {
1173
- ...(turnId !== undefined ? { turnId } : {}),
1174
- callId,
1175
- });
1176
- throw error;
1177
- }
1178
- if (typeof reply !== 'string') {
1179
- const error = new TypeError('judge reply must be a string');
1180
- controlPlaneError ??= error;
1181
- await emitTrace('judge.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, {
1182
- ...(turnId !== undefined ? { turnId } : {}),
1183
- callId,
1184
- });
1185
- throw error;
1186
- }
1187
- // Keep the success finish outside the port-call catch. If a
1188
- // telemetry sink records this boundary and then rejects, that sink
1189
- // failure must not synthesize a second, contradictory finish.
1190
- await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply }, {
1191
- ...(turnId !== undefined ? { turnId } : {}),
1192
- callId,
1193
- });
1194
- return reply;
1195
- });
1196
- },
1197
- };
1198
- const nestedBridge = createNestedPlaybookBridge({
1199
- nextCallId: () => `playbook-${++playbookCallSequence}`,
1200
- getBoundarySignal: () => activeSignal,
1201
- callPlaybook: (request, signal) => requireHostPorts().callPlaybook(request, signal),
1202
- emitStarted: async (event) => {
1203
- playbookCallTurnIds.set(event.callId, activeTurnId);
1204
- await emitTrace('playbook.call.started', {
1205
- stateId: event.stateId,
1206
- playbookId: event.playbookId,
1207
- text: event.text,
1208
- }, {
1209
- ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
1210
- callId: event.callId,
1211
- });
1212
- },
1213
- emitFinished: async (event) => {
1214
- const turnId = playbookCallTurnIds.get(event.callId);
1215
- try {
1216
- await emitTrace('playbook.call.finished', {
1217
- stateId: event.stateId,
1218
- playbookId: event.playbookId,
1219
- text: event.text,
1220
- result: event.result,
1221
- }, {
1222
- ...(turnId !== undefined ? { turnId } : {}),
1223
- callId: event.callId,
1224
- });
1225
- }
1226
- finally {
1227
- playbookCallTurnIds.delete(event.callId);
1228
- }
1229
- },
1230
- drain: drainEmissions,
1231
- bindResumeSignal: (signal) => {
1232
- activeSignal = signal;
1233
- },
1234
- onControlPlaneError: (error) => {
1235
- if (!activeSignal?.aborted)
1236
- controlPlaneError ??= error;
1237
- },
1238
- onBackgroundError: (error) => {
1239
- emissionFailure ??= error;
1240
- },
1241
- });
1242
- function tracePositionForActiveTurn() {
1243
- return activeTurnId === undefined ? {} : { turnId: activeTurnId };
1244
- }
1245
- function enqueueTransitionEmission(payload, state, statuses, position) {
1246
- const currentSession = requireSession();
1247
- const transitionTrace = createTraceEvent('fsm.transition', payload, position);
1248
- const statusEmissions = statuses.map(({ message, data }) => ({
1249
- message,
1250
- data,
1251
- trace: createTraceEvent('status.emitted', {
1252
- message,
1253
- ...(data === undefined ? {} : { data }),
1254
- state,
1255
- ...stateIdentity(state.stateId),
1256
- }, position),
1257
- }));
1258
- void enqueueEmission(async () => {
1259
- await currentSession.ports.emitTelemetry({
1260
- topic: 'playbook.trace',
1261
- payload: transitionTrace,
1262
- });
1263
- await currentSession.ports.emitTelemetry({
1264
- topic: 'playbook.fsm.state',
1265
- payload,
1266
- });
1267
- for (const status of statusEmissions) {
1268
- await currentSession.ports.emitTelemetry({
1269
- topic: 'playbook.trace',
1270
- payload: status.trace,
1271
- });
1272
- await currentSession.ports.emitStatus(status.message, status.data);
1273
- }
1274
- }).catch(() => undefined);
1275
- }
1276
- function latchInspectionError(error) {
1277
- if (activeSignal !== undefined)
1278
- controlPlaneError ??= error;
1279
- else
1280
- emissionFailure ??= error;
1281
- }
1282
- function buildActor(ports, machineSnapshot) {
1283
- priorState = undefined;
1284
- let builtActor;
1285
- builtActor = createActor(codingMachine.provide({
1286
- actors: {
1287
- player: captainBridge(ports, () => activeSignal, boundary, (error) => {
1288
- if (!activeSignal?.aborted)
1289
- controlPlaneError ??= error;
1290
- }),
1291
- },
1292
- }), {
1293
- input: boundOptions,
1294
- // DR-014 §1: a restore rehydrates the persisted machine snapshot;
1295
- // XState derives context/value from it and ignores `input` then.
1296
- ...(machineSnapshot === undefined
1297
- ? {}
1298
- : {
1299
- snapshot: machineSnapshot,
1300
- }),
1301
- inspect: (inspectionEvent) => {
1302
- if (inspectionEvent.type !== '@xstate.snapshot')
1303
- return;
1304
- if (inspectionEvent.actorRef !== builtActor)
1305
- return;
1306
- if (suppressInspectionEmissions)
1307
- return;
1308
- try {
1309
- const snap = inspectionEvent.snapshot;
1310
- const state = normalizePlaybookSnapshot(snap);
1311
- const to = state.stateId;
1312
- if (to === undefined) {
1313
- throw new Error('CODE root snapshot must expose exactly one playbook state id');
1314
- }
1315
- const previousState = priorState;
1316
- const context = snap.context;
1317
- const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
1318
- const statuses = [];
1319
- if (CAPTAIN_PANE_STATES.has(to)) {
1320
- const transitionLine = formatTransition(inspectionEvent.event);
1321
- if (transitionLine !== undefined) {
1322
- statuses.push({ message: transitionLine });
1323
- }
1324
- if (to === 'awaitBossReply') {
1325
- statuses.push({ message: formatAwaitBossReplyQuestion(context) }, { message: formatAwaitBossReplyMarker(context) });
1326
- }
1327
- else {
1328
- const entryLine = formatStateEntry(to);
1329
- if (entryLine !== undefined) {
1330
- const lastError = to === 'failed'
1331
- ? normalizeErrorCompact(snap.context.lastError)
1332
- : undefined;
1333
- statuses.push({
1334
- message: entryLine,
1335
- ...(lastError === undefined
1336
- ? {}
1337
- : {
1338
- data: snapshotJsonValue({ lastError }, 'failed status data'),
1339
- }),
1340
- });
1341
- }
1342
- }
1343
- }
1344
- enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn());
1345
- priorState = state;
1346
- }
1347
- catch (error) {
1348
- latchInspectionError(error);
1349
- }
1350
- },
1351
- });
1352
- return builtActor;
1353
- }
1354
- function runResultFor(outcome, error) {
1355
- const state = currentState();
1356
- if (outcome === 'quiescent' || outcome === 'no-action') {
1357
- return { outcome, state };
1358
- }
1359
- if (outcome === 'suspended') {
1360
- const pendingCall = nestedBridge.getPendingCall();
1361
- if (!pendingCall) {
1362
- throw new Error('suspended runtime has no pending playbook call');
1363
- }
1364
- return { outcome, state, pendingCall };
1365
- }
1366
- if (outcome === 'terminal') {
1367
- const output = actor?.getSnapshot()
1368
- ?.output;
1369
- if (output !== undefined) {
1370
- return {
1371
- outcome,
1372
- state,
1373
- output: snapshotJsonValue(output, 'terminal playbook output'),
1374
- };
1375
- }
1376
- return { outcome, state };
1377
- }
1378
- const failure = error ??
1379
- (outcome === 'failed'
1380
- ? actor?.getSnapshot()
1381
- ?.context?.lastError
1382
- : outcome === 'aborted'
1383
- ? activeSignal?.reason
1384
- : undefined);
1385
- return {
1386
- outcome,
1387
- state,
1388
- ...(failure !== undefined ? { error: normalizeError(failure) } : {}),
1389
- };
1390
- }
1391
- function settledOutcome(signal) {
1392
- if (nestedBridge.getPendingCall())
1393
- return 'suspended';
1394
- if (signal.aborted)
1395
- return 'aborted';
1396
- const state = currentState();
1397
- if (state.status === 'error') {
1398
- const actorError = actor?.getSnapshot()?.error;
1399
- throw actorError ?? new Error('CODE actor entered error status');
1400
- }
1401
- if (state.status === 'done')
1402
- return 'terminal';
1403
- if (state.stateId === 'failed')
1404
- return 'failed';
1405
- return 'quiescent';
1406
- }
1407
- function settlementTracePayload(result) {
1408
- return {
1409
- ...result,
1410
- ...stateIdentity(result.state.stateId),
1411
- };
1412
- }
1413
- // Shared failed-start cleanup for init and restore: stop the actor,
1414
- // abort/drain nested and host work, optionally emit one best-effort
1415
- // session.disposed boundary, and unbind every closure field so dispose
1416
- // stays callable. The caller rethrows its original failure. A restore
1417
- // failure skips the disposal trace — the parked session was never
1418
- // re-bound in this process, so its persisted snapshot stays
1419
- // authoritative (DR-014 §2).
1420
- async function cleanupFailedStart(cause, options) {
1421
- let finalState;
1422
- if (options.emitDisposal && actor) {
1423
- try {
1424
- finalState = currentState();
1425
- }
1426
- catch {
1427
- // A state that cannot even normalize has no disposal descriptor.
1428
- }
1429
- }
1430
- suppressInspectionEmissions = true;
1431
- try {
1432
- actor?.stop();
1433
- }
1434
- catch {
1435
- // Preserve the original startup failure.
1436
- }
1437
- try {
1438
- await nestedBridge.abortPending(cause);
1439
- }
1440
- catch {
1441
- // Preserve the original startup failure.
1442
- }
1443
- try {
1444
- await judgeQueue.onIdle();
1445
- await drainEmissions();
1446
- }
1447
- catch {
1448
- // Preserve the original startup failure.
1449
- }
1450
- if (options.emitDisposal) {
1451
- try {
1452
- await emitTrace('session.disposed', finalState === undefined
1453
- ? {}
1454
- : {
1455
- state: finalState,
1456
- ...stateIdentity(finalState.stateId),
1457
- });
1458
- await drainEmissions();
1459
- }
1460
- catch {
1461
- // The session-start error remains authoritative.
1462
- }
1463
- }
1464
- playerResumeTokens.clear();
1465
- activePlayerIds.clear();
1466
- playbookCallTurnIds.clear();
1467
- activeEmissionCalls.clear();
1468
- emissionQueue.clear();
1469
- judgeQueue.clear();
1470
- actor = undefined;
1471
- session = undefined;
1472
- savedPorts = undefined;
1473
- runtimePorts = undefined;
1474
- activeSignal = undefined;
1475
- activeTurnId = undefined;
1476
- controlPlaneError = undefined;
1477
- emissionFailure = undefined;
1478
- priorState = undefined;
1479
- suppressInspectionEmissions = false;
1480
- initialized = false;
1481
- traceSequence = 0;
1482
- turnSequence = 0;
1483
- judgeCallSequence = 0;
1484
- playerCallSequence = 0;
1485
- playbookCallSequence = 0;
1486
- }
1487
- const runtime = {
1488
- async init(nextSession) {
1489
- if (initialized || disposed || disposalPromise !== undefined) {
1490
- throw new Error('createPlaybookRuntime.init: already initialized');
1491
- }
1492
- const boundSession = snapshotPlaybookSession(nextSession);
1493
- initialized = true;
1494
- let finishInitialization;
1495
- const initialization = new Promise((resolve) => {
1496
- finishInitialization = resolve;
1497
- });
1498
- initInFlight = initialization;
1499
- const initTask = (async () => {
1500
- session = boundSession;
1501
- savedPorts = boundSession.ports;
1502
- runtimePorts = createRuntimePorts(boundSession.ports);
1503
- suppressInspectionEmissions = false;
1504
- actor = buildActor(runtimePorts);
1505
- await emitTrace('session.started', stateTracePayload());
1506
- actor.start();
1507
- await drainEmissions();
1508
- })();
1509
- try {
1510
- await initTask;
1511
- }
1512
- catch (error) {
1513
- await cleanupFailedStart(error, { emitDisposal: true });
1514
- throw error;
1515
- }
1516
- finally {
1517
- finishInitialization();
1518
- if (initInFlight === initialization)
1519
- initInFlight = undefined;
1520
- }
1521
- },
1522
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1523
- // Defined only at a safe capture point — initialized, not disposing
1524
- // or disposed, no active public boundary, no pending nested call,
1525
- // and the actor quiescent with status `active`.
1526
- exportSnapshot() {
1527
- if (!actor || !session || disposed || disposalPromise !== undefined) {
1528
- return undefined;
1529
- }
1530
- if (activeSignal !== undefined)
1531
- return undefined;
1532
- if (nestedBridge.getPendingCall())
1533
- return undefined;
1534
- const state = currentState();
1535
- if (state.status !== 'active' || !state.quiescent)
1536
- return undefined;
1537
- const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
1538
- const context = actor.getSnapshot()
1539
- .context;
1540
- const pending = pendingBossQuestionFromContext(context ?? {});
1541
- return {
1542
- schemaVersion: 1,
1543
- playbookId: session.playbookId,
1544
- machine,
1545
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
1546
- sequences: {
1547
- trace: traceSequence,
1548
- turn: turnSequence,
1549
- judgeCall: judgeCallSequence,
1550
- playerCall: playerCallSequence,
1551
- playbookCall: playbookCallSequence,
1552
- },
1553
- state,
1554
- pendingBossQuestions: pending === undefined
1555
- ? []
1556
- : [
1557
- {
1558
- questionId: pending.questionId,
1559
- player: pending.player,
1560
- question: pending.question,
1561
- sourceItem: pending.sourceItem,
1562
- },
1563
- ],
1564
- };
1565
- },
1566
- // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
1567
- // exported snapshot under the same immutable session identity.
1568
- // Emits no `session.started`, transition trace, or human status —
1569
- // the session already started; the next public boundary continues
1570
- // the contiguous trace sequence.
1571
- async restore(nextSession, snapshot) {
1572
- if (initialized || disposed || disposalPromise !== undefined) {
1573
- throw new Error('createPlaybookRuntime.restore: already initialized');
1574
- }
1575
- const boundSession = snapshotPlaybookSession(nextSession);
1576
- const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId);
1577
- initialized = true;
1578
- let finishInitialization;
1579
- const initialization = new Promise((resolve) => {
1580
- finishInitialization = resolve;
1581
- });
1582
- initInFlight = initialization;
1583
- const initTask = (async () => {
1584
- session = boundSession;
1585
- savedPorts = boundSession.ports;
1586
- runtimePorts = createRuntimePorts(boundSession.ports);
1587
- traceSequence = boundSnapshot.sequences.trace;
1588
- turnSequence = boundSnapshot.sequences.turn;
1589
- judgeCallSequence = boundSnapshot.sequences.judgeCall;
1590
- playerCallSequence = boundSnapshot.sequences.playerCall;
1591
- playbookCallSequence = boundSnapshot.sequences.playbookCall;
1592
- playerResumeTokens.clear();
1593
- for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
1594
- playerResumeTokens.set(playerId, token);
1595
- }
1596
- suppressInspectionEmissions = true;
1597
- actor = buildActor(runtimePorts, boundSnapshot.machine);
1598
- actor.start();
1599
- const restoredState = currentState();
1600
- if (restoredState.status !== 'active') {
1601
- throw new Error(`createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`);
1602
- }
1603
- suppressInspectionEmissions = false;
1604
- priorState = restoredState;
1605
- await drainEmissions();
1606
- })();
1607
- try {
1608
- await initTask;
1609
- }
1610
- catch (error) {
1611
- await cleanupFailedStart(error, { emitDisposal: false });
1612
- throw error;
1613
- }
1614
- finally {
1615
- finishInitialization();
1616
- if (initInFlight === initialization)
1617
- initInFlight = undefined;
1618
- }
1619
- },
1620
- async handleBossInput({ text, signal, }) {
1621
- if (!actor || !savedPorts) {
1622
- throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
1623
- }
1624
- if (disposed || disposalPromise !== undefined) {
1625
- throw new Error('createPlaybookRuntime.handleBossInput: runtime is disposing or disposed');
1626
- }
1627
- if (activeSignal !== undefined) {
1628
- throw new Error('createPlaybookRuntime.handleBossInput: another runtime turn is active');
1629
- }
1630
- const turnId = ++turnSequence;
1631
- activeTurnId = turnId;
1632
- activeSignal = signal;
1633
- controlPlaneError = undefined;
1634
- let result;
1635
- let operationError;
1636
- try {
1637
- await emitTrace('boss.input.received', { text }, { turnId });
1638
- // 1. Classify non-empty text into an FSM event through the judge.
1639
- const event = await classifyBossText(text, runtimePorts, signal, actor.getSnapshot(), boundary);
1640
- // Empty input, no-action classifier output, or invalid classifier
1641
- // output — nothing to send.
1642
- if (event === undefined) {
1643
- result = runResultFor('no-action');
1644
- }
1645
- else {
1646
- // 2. Captain-pane classification line (PBRT-14): the bare
1647
- // FSM event type, emitted before the FSM advances.
1648
- await runtimePorts.emitStatus(formatClassification(event.type));
1649
- // 3. A final actor cannot accept new events; reconstruct only after
1650
- // classification produced a real event.
1651
- if (actor.getSnapshot().status === 'done') {
1652
- actor.stop();
1653
- actor = buildActor(runtimePorts);
1654
- actor.start();
1655
- }
1656
- actor.send(event);
1657
- await waitForPlaybookQuiescence(actor, {
1658
- pendingCalls: nestedBridge,
1659
- });
1660
- if (controlPlaneError !== undefined)
1661
- throw controlPlaneError;
1662
- result = runResultFor(settledOutcome(signal));
1663
- }
1664
- }
1665
- catch (error) {
1666
- operationError = error;
1667
- }
1668
- let drainError;
1669
- try {
1670
- await drainEmissions();
1671
- }
1672
- catch (error) {
1673
- drainError = error;
1674
- }
1675
- const latchedControlError = controlPlaneError;
1676
- const primaryError = latchedControlError ?? drainError ?? operationError;
1677
- const abortError = latchedControlError === undefined &&
1678
- drainError === undefined &&
1679
- operationError !== undefined &&
1680
- isAbortFailure(operationError, signal);
1681
- const settlementResult = primaryError === undefined
1682
- ? (result ?? runResultFor('no-action'))
1683
- : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
1684
- let settlementEmissionError;
1685
- try {
1686
- await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
1687
- }
1688
- catch (error) {
1689
- settlementEmissionError = error;
1690
- }
1691
- try {
1692
- await drainEmissions();
1693
- }
1694
- catch (error) {
1695
- settlementEmissionError ??= error;
1696
- }
1697
- const failure = controlPlaneError ??
1698
- latchedControlError ??
1699
- drainError ??
1700
- (abortError
1701
- ? (settlementEmissionError ?? operationError)
1702
- : (operationError ?? settlementEmissionError));
1703
- activeSignal = undefined;
1704
- activeTurnId = undefined;
1705
- controlPlaneError = undefined;
1706
- if (failure !== undefined &&
1707
- !(abortError && settlementEmissionError === undefined)) {
1708
- throw failure;
1709
- }
1710
- return settlementResult;
1711
- },
1712
- async resumePlaybookCall(input) {
1713
- if (!actor || !savedPorts) {
1714
- throw new Error('createPlaybookRuntime.resumePlaybookCall: init must be called first');
1715
- }
1716
- if (disposed || disposalPromise !== undefined) {
1717
- throw new Error('createPlaybookRuntime.resumePlaybookCall: runtime is disposing or disposed');
1718
- }
1719
- if (activeSignal !== undefined) {
1720
- throw new Error('createPlaybookRuntime.resumePlaybookCall: another runtime turn is active');
1721
- }
1722
- activeTurnId = playbookCallTurnIds.get(input.callId);
1723
- activeSignal = input.signal;
1724
- controlPlaneError = undefined;
1725
- let result;
1726
- let operationError;
1727
- try {
1728
- await nestedBridge.resume(input);
1729
- }
1730
- catch (error) {
1731
- operationError = error;
1732
- }
1733
- try {
1734
- await waitForPlaybookQuiescence(actor, {
1735
- pendingCalls: nestedBridge,
1736
- });
1737
- result = runResultFor(settledOutcome(input.signal));
1738
- }
1739
- catch (error) {
1740
- operationError ??= error;
1741
- }
1742
- let drainError;
1743
- try {
1744
- await drainEmissions();
1745
- }
1746
- catch (error) {
1747
- drainError = error;
1748
- }
1749
- const failure = controlPlaneError ?? drainError ?? operationError;
1750
- activeSignal = undefined;
1751
- activeTurnId = undefined;
1752
- controlPlaneError = undefined;
1753
- if (failure !== undefined)
1754
- throw failure;
1755
- if (result === undefined) {
1756
- throw new Error('playbook resume produced no runtime result');
1757
- }
1758
- return result;
1759
- },
1760
- dispose() {
1761
- if (disposalPromise !== undefined)
1762
- return disposalPromise;
1763
- if (disposed)
1764
- return Promise.resolve();
1765
- if (activeSignal !== undefined) {
1766
- return Promise.reject(new Error('createPlaybookRuntime.dispose: cannot dispose during an active runtime boundary'));
1767
- }
1768
- const task = (async () => {
1769
- const failures = [];
1770
- try {
1771
- if (initInFlight !== undefined) {
1772
- try {
1773
- await initInFlight;
1774
- }
1775
- catch {
1776
- // Dispose still releases whatever an unsuccessful init bound.
1777
- }
1778
- }
1779
- const finalState = actor ? currentState() : undefined;
1780
- // Stop the root before settling a suspended child. Its rejection
1781
- // must not re-enter CODE and start fresh work during disposal.
1782
- if (actor)
1783
- actor.stop();
1784
- try {
1785
- await nestedBridge.dispose();
1786
- }
1787
- catch (error) {
1788
- failures.push(error);
1789
- }
1790
- try {
1791
- await drainEmissions();
1792
- }
1793
- catch (error) {
1794
- failures.push(error);
1795
- }
1796
- if (session !== undefined) {
1797
- try {
1798
- await emitTrace('session.disposed', finalState === undefined
1799
- ? {}
1800
- : {
1801
- state: finalState,
1802
- ...stateIdentity(finalState.stateId),
1803
- });
1804
- await drainEmissions();
1805
- }
1806
- catch (error) {
1807
- failures.push(error);
1808
- }
1809
- }
1810
- }
1811
- finally {
1812
- playerResumeTokens.clear();
1813
- activePlayerIds.clear();
1814
- playbookCallTurnIds.clear();
1815
- activeEmissionCalls.clear();
1816
- emissionQueue.clear();
1817
- judgeQueue.clear();
1818
- actor = undefined;
1819
- activeSignal = undefined;
1820
- activeTurnId = undefined;
1821
- controlPlaneError = undefined;
1822
- emissionFailure = undefined;
1823
- savedPorts = undefined;
1824
- runtimePorts = undefined;
1825
- session = undefined;
1826
- disposed = true;
1827
- }
1828
- if (failures.length === 1)
1829
- throw failures[0];
1830
- if (failures.length > 1) {
1831
- throw new AggregateError(failures, 'playbook runtime disposal failed');
1832
- }
1833
- })();
1834
- disposalPromise = task;
1835
- return task;
1836
- },
1837
- // @internal — test-only escape hatch for inspecting the
1838
- // underlying actor's snapshot. Most state assertions are now
1839
- // expressible via the recorded emitStatus / emitTelemetry
1840
- // calls (DR-004 §9); the hatch stays for the few cases where
1841
- // direct context inspection is clearer (e.g., the dispose
1842
- // teardown test).
1843
- _getActor() {
1844
- return actor;
1845
- },
1846
- _getBoundary() {
1847
- return boundary;
1848
- },
1849
- _getNestedBridge() {
1850
- return nestedBridge;
1851
- },
1852
- };
1853
- return runtime;
1854
- }
649
+ // The CODE-specific spec handed to the shared runtime factory
650
+ // (slc/link.md §Output, DR-019). The generic machinery — actor wiring,
651
+ // boundary tracing, Boss-turn lifecycle, nested-playbook bridge, and the
652
+ // DR-014 parked-session snapshot capability — lives in
653
+ // @sublang/playbook/xstate-runtime; this spec carries only what is
654
+ // CODE-specific.
655
+ const runtimeSpec = {
656
+ label: 'CODE',
657
+ snapshotOptions: snapshotCodePlaybookOptions,
658
+ resolvePlayerId: (input) => resolvePlayerId(input),
659
+ composePlayerPrompt: (input) => composePlayerPrompt(input),
660
+ buildJudgePrompt: CODE_ADJUDICATION.buildJudgePrompt,
661
+ extractRequiredFields,
662
+ verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
663
+ resumableStateIds: registeredResumableStateIds,
664
+ classifyBossText: (text, ports, signal, snapshotOrState, boundary) => classifyBossText(text, ports, signal, snapshotOrState, boundary),
665
+ classificationStatus: (event) => formatClassification(event.type),
666
+ statusesForState,
667
+ normalizeTransitionEvent: (event) => normalizeEventForTelemetry(event),
668
+ };
669
+ const createPlaybookRuntime = createXStatePlaybookRuntime(codingMachine, runtimeSpec);
670
+ export default createPlaybookRuntime;