@sublang/playbook 0.9.0 → 1.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.
Files changed (48) hide show
  1. package/README.md +183 -151
  2. package/package.json +46 -6
  3. package/reference/sdlc/captain.md +102 -0
  4. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
  5. package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
  6. package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
  7. package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
  8. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
  9. package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
  10. package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +152 -10
  12. package/reference/sdlc/code.playbook/bin/run.js +893 -0
  13. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
  14. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
  17. package/reference/sdlc/code.playbook/code.fsm.js +334 -102
  18. package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
  19. package/reference/sdlc/code.playbook/code.gears.md +11 -10
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +18 -9
  21. package/reference/sdlc/code.playbook/code.playbook.js +1095 -200
  22. package/reference/sdlc/code.playbook/code.playbook.ts +1437 -256
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
  24. package/reference/sdlc/code.playbook/code.registry.js +0 -3
  25. package/reference/sdlc/code.playbook/code.registry.ts +0 -6
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
  27. package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +10 -0
  30. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
  31. package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
  32. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
  33. package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
  34. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
  35. package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
  36. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
  37. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
  38. package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
  39. package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
  40. package/slc/gears2fsm.md +557 -57
  41. package/slc/link.md +1097 -80
  42. package/slc/optimize.md +88 -0
  43. package/slc/text2gears.md +247 -5
  44. package/src/runtime.d.ts +145 -3
  45. package/src/runtime.ts +200 -2
  46. package/src/xstate-runtime.d.ts +94 -0
  47. package/src/xstate-runtime.js +1247 -0
  48. package/src/xstate-runtime.ts +1802 -0
@@ -11,12 +11,40 @@
11
11
  // alias's first alternative)
12
12
  // Boss event: free-text judge classification
13
13
  // Adjudication: LLM-judge per state
14
- // Contract: PlayerResult / PlaybookPorts / PlaybookRuntime imported
15
- // and re-exported from @sublang/playbook/runtime
14
+ // Contract: PlayerResult / PlaybookPorts / PlaybookSession /
15
+ // PlaybookRuntime imported and re-exported from
16
+ // @sublang/playbook/runtime
16
17
  // (slc/link.md §Output, DR-004 Addendum A4)
18
+ import PQueue from 'p-queue';
17
19
  import { createActor, fromPromise } from 'xstate';
20
+ import { assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
18
21
  import { codingMachine, } from './code.fsm.js';
19
22
  import { enumerateAwaitBossReply, enumerateCaptainStates, enumerateRootEvents, } from './code.fsm.introspect.js';
23
+ function snapshotCodePlaybookOptions(value) {
24
+ const captured = snapshotJsonValue(value, 'CODE runtime options');
25
+ if (captured === null ||
26
+ typeof captured !== 'object' ||
27
+ Array.isArray(captured)) {
28
+ throw new TypeError('CODE runtime options must be an object');
29
+ }
30
+ const record = captured;
31
+ const allowed = new Set([
32
+ 'intent',
33
+ 'irNumber',
34
+ 'coderPlayer',
35
+ 'reviewerPlayer',
36
+ 'committerPlayer',
37
+ ]);
38
+ for (const [key, option] of Object.entries(record)) {
39
+ if (!allowed.has(key)) {
40
+ throw new TypeError(`CODE runtime options.${key} is not declared`);
41
+ }
42
+ if (typeof option !== 'string') {
43
+ throw new TypeError(`CODE runtime options.${key} must be a string`);
44
+ }
45
+ }
46
+ return captured;
47
+ }
20
48
  const BOSS_REPLY_ERRORS = {
21
49
  missingQuestion: "needsBossReply outcome missing 'question' field",
22
50
  unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
@@ -36,49 +64,61 @@ const VERBATIM_PAYLOAD_FIELDS = new Set([
36
64
  function normalizeErrorCompact(err) {
37
65
  if (err === undefined || err === null)
38
66
  return undefined;
39
- if (err instanceof Error) {
40
- return { name: err.name, message: err.message };
41
- }
42
- if (typeof err === 'object') {
43
- const o = err;
44
- if (typeof o.message === 'string') {
45
- return {
46
- name: typeof o.name === 'string' ? o.name : 'Error',
47
- message: o.message,
48
- };
49
- }
50
- }
51
- return { name: 'Error', message: String(err) };
67
+ const normalized = normalizeError(err);
68
+ return { name: normalized.name, message: normalized.message };
52
69
  }
53
70
  // Normalize an unknown error value to the full `{ name, message, stack }`
54
71
  // shape used by telemetry emissions. Returns `undefined` for nullish
55
72
  // input. `stack` is omitted when not available on the source value.
56
73
  function normalizeErrorFull(err) {
57
- const compact = normalizeErrorCompact(err);
58
- if (compact === undefined)
74
+ if (err === undefined || err === null)
59
75
  return undefined;
60
- if (err instanceof Error) {
61
- return err.stack !== undefined ? { ...compact, stack: err.stack } : compact;
62
- }
63
- if (typeof err === 'object' && err !== null) {
64
- const stack = err.stack;
65
- if (typeof stack === 'string') {
66
- return { ...compact, stack };
67
- }
68
- }
69
- return compact;
76
+ return normalizeError(err);
77
+ }
78
+ function isAbortFailure(error, signal) {
79
+ return (signal.aborted &&
80
+ (error === signal.reason || normalizeError(error).name === 'AbortError'));
70
81
  }
71
82
  // Normalize any `error` field inside a telemetry event so failed
72
83
  // transitions don't leak raw Error instances through the channel.
73
84
  function normalizeEventForTelemetry(event) {
74
- if (event === null || typeof event !== 'object' || Array.isArray(event)) {
75
- return event;
76
- }
77
- const e = event;
78
- if (!('error' in e))
79
- return event;
80
- const normalized = normalizeErrorFull(e.error);
81
- return { ...e, error: normalized };
85
+ if (event === undefined)
86
+ return undefined;
87
+ return normalizeEventValue(event, 'FSM event', new Set());
88
+ }
89
+ function normalizeEventValue(value, path, ancestors) {
90
+ if (Array.isArray(value))
91
+ return snapshotJsonValue(value, path);
92
+ if (value === null || typeof value !== 'object') {
93
+ return snapshotJsonValue(value, path);
94
+ }
95
+ if (ancestors.has(value)) {
96
+ throw new TypeError(`${path} must not contain a JSON cycle`);
97
+ }
98
+ const prototype = Object.getPrototypeOf(value);
99
+ if (prototype !== Object.prototype && prototype !== null) {
100
+ return snapshotJsonValue(value, path);
101
+ }
102
+ if (Object.getOwnPropertySymbols(value).length > 0) {
103
+ return snapshotJsonValue(value, path);
104
+ }
105
+ const nextAncestors = new Set(ancestors).add(value);
106
+ const normalized = {};
107
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
108
+ if (!descriptor.enumerable) {
109
+ throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
110
+ }
111
+ if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
112
+ throw new TypeError(`${path}.${key} must be a JSON data property`);
113
+ }
114
+ if (descriptor.value === undefined)
115
+ continue;
116
+ normalized[key] =
117
+ key === 'error'
118
+ ? snapshotJsonValue(normalizeError(descriptor.value), `${path}.error`)
119
+ : normalizeEventValue(descriptor.value, `${path}.${key}`, nextAncestors);
120
+ }
121
+ return snapshotJsonValue(normalized, path);
82
122
  }
83
123
  // Internal capabilities (DR-004 §10). Each ships with its final
84
124
  // signature; behavior lands in the per-capability task noted by the
@@ -169,9 +209,11 @@ function resolvePlayerId(input) {
169
209
  // object once the chosen guard is one of the input.result keys.
170
210
  // Adjudicator failures (malformed JSON, missing/unknown guard) are
171
211
  // control-plane errors and propagate via throw per slc/link.md.
172
- async function adjudicate(input, finalText, ports, signal) {
212
+ async function adjudicate(input, finalText, ports, signal, boundary) {
173
213
  const prompt = buildJudgePrompt(input, finalText);
174
- const raw = await ports.callJudge(prompt, signal);
214
+ const raw = boundary
215
+ ? await boundary.callJudge('player-output-adjudication', input.stateId, prompt, signal)
216
+ : await ports.callJudge(prompt, signal);
175
217
  const parsed = parseJudgeJson(raw);
176
218
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
177
219
  throw new Error('adjudicate: judge response is not a JSON object');
@@ -384,19 +426,22 @@ function dropTrailingComma(out) {
384
426
  // classifier is state-aware so awaitBossReply can distinguish a direct
385
427
  // answer (BOSS_REPLY) from a fresh directive that abandons the pending
386
428
  // question through the FSM's existing transitions.
387
- async function classifyBossText(text, ports, signal, snapshotOrState) {
429
+ async function classifyBossText(text, ports, signal, snapshotOrState, boundary) {
388
430
  const trimmed = text.trim();
389
431
  if (trimmed === '')
390
432
  return undefined;
391
- return classifyWithLlm(text, ports, signal, snapshotOrState);
433
+ return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
392
434
  }
393
435
  const rootEvents = enumerateRootEvents(codingMachine);
394
436
  const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
395
437
  const bossInterruptTargetIds = new Set(bossInterruptTargets.map((target) => target.stateId));
396
- async function classifyWithLlm(text, ports, signal, snapshotOrState) {
438
+ async function classifyWithLlm(text, ports, signal, snapshotOrState, boundary) {
397
439
  const state = classifierState(snapshotOrState);
398
440
  const prompt = buildClassifierPrompt(text, state);
399
- const raw = await ports.callJudge(prompt, signal);
441
+ const stateId = typeof state.value === 'string' ? state.value : undefined;
442
+ const raw = boundary
443
+ ? await boundary.callJudge('boss-input-classification', stateId, prompt, signal)
444
+ : await ports.callJudge(prompt, signal);
400
445
  let parsed;
401
446
  try {
402
447
  parsed = parseJudgeJson(raw);
@@ -479,7 +524,26 @@ async function classifyWithLlm(text, ports, signal, snapshotOrState) {
479
524
  await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
480
525
  return undefined;
481
526
  }
482
- return { type: 'BOSS_REPLY', answer: payload.answer };
527
+ const pending = pendingBossQuestionFromContext(state.context);
528
+ if (!pending) {
529
+ await ports.emitStatus('Classifier returned BOSS_REPLY without a pending question');
530
+ return undefined;
531
+ }
532
+ if (payload.questionId !== undefined &&
533
+ typeof payload.questionId !== 'string') {
534
+ await ports.emitStatus('Classifier supplied a non-string questionId for BOSS_REPLY');
535
+ return undefined;
536
+ }
537
+ if (typeof payload.questionId === 'string' &&
538
+ payload.questionId !== pending.questionId) {
539
+ await ports.emitStatus(`Classifier supplied unknown questionId for BOSS_REPLY: ${payload.questionId}`);
540
+ return undefined;
541
+ }
542
+ return {
543
+ type: 'BOSS_REPLY',
544
+ answer: payload.answer,
545
+ questionId: pending.questionId,
546
+ };
483
547
  }
484
548
  default:
485
549
  await ports.emitStatus(`Classifier returned unknown event type: ${eventType}`);
@@ -503,7 +567,9 @@ function classifierState(snapshotOrState) {
503
567
  return { value: snapshotOrState, context: {} };
504
568
  }
505
569
  function buildClassifierPrompt(text, state) {
506
- const currentState = typeof state.value === 'string' ? state.value : 'unknown';
570
+ const currentState = typeof state.value === 'string'
571
+ ? state.value
572
+ : JSON.stringify(state.value ?? null);
507
573
  const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
508
574
  const lines = [
509
575
  'Classify the following Boss message into exactly one of these events.',
@@ -513,14 +579,14 @@ function buildClassifierPrompt(text, state) {
513
579
  `Current state: ${currentState}`,
514
580
  ];
515
581
  if (pendingBossQuestion !== undefined) {
516
- lines.push(`Pending Boss question: ${pendingBossQuestion.question}`, `Pending resume state: ${pendingBossQuestion.resumeStateId}`);
582
+ lines.push(`Pending question id: ${pendingBossQuestion.questionId}`, `Pending asking player: ${pendingBossQuestion.player}`, `Pending Boss question: ${pendingBossQuestion.question}`);
517
583
  }
518
584
  lines.push('', 'Events:', '- START_CODING: payload { intent: "<free-form goal>" }', '- CONTINUE_IR: payload { irNumber: "<number>" }', '- SUMMARIZE_IR: payload { irNumber: "<number>" }', '- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }', ' targetId must be one of these jumpable states:');
519
585
  for (const target of bossInterruptTargets) {
520
586
  lines.push(` - ${target.stateId}: ${target.description}`);
521
587
  }
522
588
  if (currentState === 'awaitBossReply') {
523
- lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
589
+ lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }');
524
590
  }
525
591
  else {
526
592
  lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
@@ -528,35 +594,45 @@ function buildClassifierPrompt(text, state) {
528
594
  lines.push('', 'Boss message:', '```', text, '```');
529
595
  return lines.join('\n');
530
596
  }
531
- // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
532
- // codingMachine invokes from every captain-invoking state. Per turn:
597
+ // Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
598
+ // codingMachine invokes from every player-invoking state. Per turn:
533
599
  // resolve playerId, compose the player prompt, await
534
600
  // ports.callPlayer, adjudicate the finalText. PlayerResult status of
535
601
  // 'aborted' or 'error' throws so XState routes via onError → #failed
536
602
  // (the single fail-stop sink for both Captain errors and player
537
- // failures).
603
+ // failures). Captain remains the orchestrator and adjudicator; it is not
604
+ // encoded as the delegated FSM actor.
538
605
  //
539
606
  // `getActiveSignal` is the runtime's hook for flowing the Boss's
540
607
  // `handleBossInput.signal` into the host port calls — fromPromise
541
608
  // hands the bridge XState's actor-scoped signal, which only fires
542
609
  // on actor.stop(), not on Boss abort. When omitted (e.g. direct
543
610
  // captainBridge tests), the bridge falls back to XState's signal.
544
- function captainBridge(ports, getActiveSignal) {
611
+ function captainBridge(ports, getActiveSignal, boundary, onControlPlaneError) {
545
612
  return fromPromise(async ({ input, signal }) => {
546
- const activeSignal = getActiveSignal?.() ?? signal;
613
+ const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
547
614
  const playerId = resolvePlayerId(input);
548
615
  const prompt = composePlayerPrompt(input);
549
- const result = await ports.callPlayer(playerId, prompt, activeSignal);
616
+ const result = boundary
617
+ ? await boundary.callPlayer(input, playerId, prompt, activeSignal)
618
+ : await ports.callPlayer(playerId, prompt, activeSignal, {
619
+ resume: false,
620
+ });
550
621
  if (result.status !== 'ok') {
551
- throw new Error(result.error ??
552
- `captainBridge: callPlayer status "${result.status}"`);
622
+ throw new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
553
623
  }
554
624
  if (result.finalText === undefined) {
555
625
  throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
556
626
  }
557
- const output = await adjudicate(input, result.finalText, ports, activeSignal);
558
- validateBossReplyOutput(input, output);
559
- return output;
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
+ }
560
636
  });
561
637
  }
562
638
  // Captain pane display — PBRT-3 / PBRT-14.
@@ -565,7 +641,7 @@ function captainBridge(ports, getActiveSignal) {
565
641
  // glance:
566
642
  // (no glyph) bare FSM event type — host renders as captain speech
567
643
  // (e.g., `captain> START_CODING`)
568
- // ⤷ captain-invoking state entry: `<Player>: <label>`
644
+ // ⤷ player-invoking state entry: `<Player>: <label>`
569
645
  // → transition guard outcome (`· field=N` tallies
570
646
  // appended); the host presenter owns any visual
571
647
  // nesting under the preceding ⤷ entry
@@ -602,17 +678,13 @@ const stateMetadata = (() => {
602
678
  for (const s of enumerateCaptainStates(codingMachine)) {
603
679
  const label = STATE_LABELS[s.stateId];
604
680
  if (!label) {
605
- throw new Error(`code.playbook.ts: STATE_LABELS missing entry for captain-invoking state '${s.stateId}'`);
681
+ throw new Error(`code.playbook.ts: STATE_LABELS missing entry for player-invoking state '${s.stateId}'`);
606
682
  }
607
683
  const input = s.getInput({});
608
684
  m.set(s.stateId, { player: input.player, sourceItem: s.sourceItem, label });
609
685
  }
610
686
  return m;
611
687
  })();
612
- const stateIdBySourceItem = new Map([...stateMetadata.entries()].map(([stateId, meta]) => [
613
- meta.sourceItem,
614
- stateId,
615
- ]));
616
688
  const registeredResumableStateIds = new Set(enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map((transition) => transition.target));
617
689
  function validateBossReplyOutput(input, output) {
618
690
  if (output.guard !== 'needsBossReply')
@@ -620,9 +692,9 @@ function validateBossReplyOutput(input, output) {
620
692
  if (typeof output.question !== 'string') {
621
693
  throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
622
694
  }
623
- const stateId = stateIdBySourceItem.get(input.sourceItem);
624
- if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
625
- throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem));
695
+ const stateId = input.stateId;
696
+ if (!registeredResumableStateIds.has(stateId)) {
697
+ throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId));
626
698
  }
627
699
  }
628
700
  const QUIESCENT_STATES = new Set([
@@ -635,11 +707,8 @@ const QUIESCENT_STATES = new Set([
635
707
  // pane per PBRT-3: the readline returning to its `boss>` prompt is
636
708
  // the implicit "turn over" signal, so a `◆ ready` / `◆ done`
637
709
  // tombstone is redundant.
638
- const SUPPRESSED_ENTRY_STATES = new Set([
639
- 'ready',
640
- 'done',
641
- ]);
642
- // Captain-pane surface (PBRT-3): every captain-invoking state plus
710
+ const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
711
+ // Captain-pane surface (PBRT-3): every player-invoking state plus
643
712
  // the quiescent states whose entry still carries information
644
713
  // (failure with `lastError`, awaitBossReply with the pending
645
714
  // question). `ready` and `done` flow through the inspect handler
@@ -656,12 +725,14 @@ function pendingBossQuestionFromContext(context) {
656
725
  }
657
726
  const candidate = pending;
658
727
  if (typeof candidate.resumeStateId !== 'string' ||
728
+ typeof candidate.questionId !== 'string' ||
659
729
  typeof candidate.sourceItem !== 'string' ||
660
730
  typeof candidate.player !== 'string' ||
661
731
  typeof candidate.question !== 'string') {
662
732
  return undefined;
663
733
  }
664
734
  return {
735
+ questionId: candidate.questionId,
665
736
  resumeStateId: candidate.resumeStateId,
666
737
  sourceItem: candidate.sourceItem,
667
738
  player: candidate.player,
@@ -726,9 +797,9 @@ function formatClassification(eventType) {
726
797
  }
727
798
  function stateTelemetryPayload(from, to, event, context) {
728
799
  const payload = {
729
- from,
800
+ from: from ?? null,
730
801
  to,
731
- event: normalizeEventForTelemetry(event),
802
+ event: normalizeEventForTelemetry(event) ?? null,
732
803
  };
733
804
  if (to === 'awaitBossReply') {
734
805
  const pendingBossQuestion = pendingBossQuestionFromContext(context);
@@ -744,6 +815,27 @@ function stateTelemetryPayload(from, to, event, context) {
744
815
  }
745
816
  return payload;
746
817
  }
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
+ }
831
+ }
832
+ if (state.stateId === 'failed') {
833
+ const lastError = normalizeErrorFull(context.lastError);
834
+ if (lastError !== undefined)
835
+ payload.lastError = lastError;
836
+ }
837
+ return snapshotJsonValue(payload, 'FSM telemetry payload');
838
+ }
747
839
  // Internal export surface for tests. Not part of the stable public API;
748
840
  // the leading underscore signals "subject to change." Each member is
749
841
  // referenced here so `noUnusedLocals` stays clean while later tasks
@@ -769,163 +861,978 @@ export const _internal = {
769
861
  VERBATIM_PAYLOAD_FIELDS,
770
862
  };
771
863
  export default function createPlaybookRuntime(options) {
864
+ const boundOptions = snapshotCodePlaybookOptions(options);
772
865
  let actor;
866
+ let session;
867
+ let initialized = false;
868
+ let initInFlight;
869
+ let disposalPromise;
870
+ let disposed = false;
773
871
  let savedPorts;
872
+ let runtimePorts;
774
873
  // The Boss's per-turn AbortSignal, surfaced to captainBridge so
775
874
  // ports.callPlayer / callJudge see the right cancellation source.
776
875
  // null between turns; set by handleBossInput.
777
876
  let activeSignal;
877
+ let activeTurnId;
878
+ let controlPlaneError;
778
879
  // Previous root-machine state for the inspect-driven telemetry /
779
880
  // status emitter. undefined before the first inspect firing.
780
881
  let priorState;
781
- // Emission queue. slc/link.md says emissions "shall be ordered,
782
- // awaited, and never-dropped"; subscribe/inspect callbacks are
783
- // synchronous and can't await, so each emit is enqueued and a
784
- // single drainer processes them sequentially.
785
- const emitQueue = [];
786
- let drainer;
787
- function enqueueEmit(fn) {
788
- emitQueue.push(fn);
789
- if (!drainer) {
790
- drainer = (async () => {
791
- while (emitQueue.length > 0) {
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;
792
1081
  try {
793
- await emitQueue.shift()();
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
+ });
794
1090
  }
795
1091
  catch {
796
- // Suppress host-side emission errors; the control plane
797
- // surfaces real failures via handleBossInput throws.
1092
+ // The original non-abort port rejection remains authoritative.
798
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;
799
1097
  }
800
- drainer = undefined;
801
- })();
802
- }
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 };
803
1244
  }
804
- function drainEmissions() {
805
- return drainer ?? Promise.resolve();
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;
806
1281
  }
807
- function buildActor(ports) {
1282
+ function buildActor(ports, machineSnapshot) {
808
1283
  priorState = undefined;
809
- return createActor(codingMachine.provide({
810
- actors: { captain: captainBridge(ports, () => activeSignal) },
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
+ },
811
1292
  }), {
812
- input: options,
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
+ }),
813
1301
  inspect: (inspectionEvent) => {
814
1302
  if (inspectionEvent.type !== '@xstate.snapshot')
815
1303
  return;
816
- const snap = inspectionEvent.snapshot;
817
- // Filter out captain sub-actor (fromPromise) snapshots —
818
- // only the root codingMachine snapshot has a string value.
819
- if (typeof snap.value !== 'string')
1304
+ if (inspectionEvent.actorRef !== builtActor)
820
1305
  return;
821
- const to = snap.value;
822
- if (priorState === to)
1306
+ if (suppressInspectionEmissions)
823
1307
  return;
824
- const from = priorState;
825
- priorState = to;
826
- // Telemetry on every transition (PBRT-14).
827
- const context = snap.context ?? {};
828
- enqueueEmit(() => ports.emitTelemetry({
829
- topic: 'playbook.fsm.state',
830
- payload: stateTelemetryPayload(from, to, inspectionEvent.event, context),
831
- }));
832
- // Captain pane (PBRT-3 / PBRT-14): show the transition
833
- // guard first (when this is an actor-done transition with
834
- // a known guard), then the new state entry, then any
835
- // context riders the entering state cares about. Terminal
836
- // entry to `failed` carries `lastError` as the data arg.
837
- if (!CAPTAIN_PANE_STATES.has(to))
838
- return;
839
- const transitionLine = formatTransition(inspectionEvent.event);
840
- if (transitionLine !== undefined) {
841
- enqueueEmit(() => ports.emitStatus(transitionLine));
842
- }
843
- // awaitBossReply surfaces two lines per PBRT-3 / PBRT-14: the
844
- // full player question as captain speech, then the rider-less
845
- // routing marker. The full-question telemetry rides
846
- // stateTelemetryPayload above.
847
- if (to === 'awaitBossReply') {
848
- const questionLine = formatAwaitBossReplyQuestion(context);
849
- const markerLine = formatAwaitBossReplyMarker(context);
850
- enqueueEmit(() => ports.emitStatus(questionLine));
851
- enqueueEmit(() => ports.emitStatus(markerLine));
852
- return;
853
- }
854
- const entryLine = formatStateEntry(to);
855
- if (entryLine === undefined)
856
- return;
857
- if (to === 'failed') {
858
- const lastError = snap.context
859
- ?.lastError;
860
- const data = { lastError: normalizeErrorCompact(lastError) };
861
- enqueueEmit(() => ports.emitStatus(entryLine, data));
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;
862
1346
  }
863
- else {
864
- enqueueEmit(() => ports.emitStatus(entryLine));
1347
+ catch (error) {
1348
+ latchInspectionError(error);
865
1349
  }
866
1350
  },
867
1351
  });
1352
+ return builtActor;
868
1353
  }
869
- const runtime = {
870
- async init(ports) {
871
- savedPorts = ports;
872
- actor = buildActor(ports);
873
- actor.start();
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();
874
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
+ }
875
1619
  },
876
1620
  async handleBossInput({ text, signal, }) {
877
1621
  if (!actor || !savedPorts) {
878
1622
  throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
879
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;
880
1632
  activeSignal = signal;
1633
+ controlPlaneError = undefined;
1634
+ let result;
1635
+ let operationError;
881
1636
  try {
1637
+ await emitTrace('boss.input.received', { text }, { turnId });
882
1638
  // 1. Classify non-empty text into an FSM event through the judge.
883
- const event = await classifyBossText(text, savedPorts, signal, actor.getSnapshot());
1639
+ const event = await classifyBossText(text, runtimePorts, signal, actor.getSnapshot(), boundary);
884
1640
  // Empty input, no-action classifier output, or invalid classifier
885
1641
  // output — nothing to send.
886
1642
  if (event === undefined) {
887
- await drainEmissions();
888
- return;
1643
+ result = runResultFor('no-action');
889
1644
  }
890
- // 2. Captain-pane classification line (PBRT-14): the bare
891
- // FSM event type, emitted before the FSM advances so the
892
- // host can render it as captain speech (e.g.,
893
- // `captain> START_CODING`). Enqueued so it interleaves
894
- // cleanly with the inspect-driven transition emissions.
895
- const echoPorts = savedPorts;
896
- enqueueEmit(() => echoPorts.emitStatus(formatClassification(event.type)));
897
- // 3. final state ('done') cannot accept new events — dispose
898
- // and reconstruct per DR-004 §5.
899
- if (actor.getSnapshot().status === 'done') {
900
- actor.stop();
901
- actor = buildActor(savedPorts);
902
- actor.start();
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));
903
1663
  }
904
- // 4. Send the event.
905
- actor.send(event);
906
- // 5. Drive to quiescence. On signal-abort we take no FSM
907
- // action: the captain bridge's awaited callPlayer rejects
908
- // naturally, the bridge throws, XState routes through
909
- // onError → #failed, and this loop sees the quiescent
910
- // snapshot and returns (DR-004 §8 natural rejection).
911
- await driveToQuiescence(actor);
912
- // Drain transition emissions before returning so the Boss
913
- // sees the final status line for this turn.
1664
+ }
1665
+ catch (error) {
1666
+ operationError = error;
1667
+ }
1668
+ let drainError;
1669
+ try {
914
1670
  await drainEmissions();
915
1671
  }
916
- finally {
917
- activeSignal = undefined;
1672
+ catch (error) {
1673
+ drainError = error;
918
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;
919
1711
  },
920
- async dispose() {
921
- if (actor) {
922
- actor.stop();
923
- actor = undefined;
1712
+ async resumePlaybookCall(input) {
1713
+ if (!actor || !savedPorts) {
1714
+ throw new Error('createPlaybookRuntime.resumePlaybookCall: init must be called first');
924
1715
  }
925
- // Drain any in-flight emissions per slc/link.md §Session
926
- // lifecycle ("stop the actor and drain pending port emissions").
927
- await drainEmissions();
928
- savedPorts = undefined;
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;
929
1836
  },
930
1837
  // @internal — test-only escape hatch for inspecting the
931
1838
  // underlying actor's snapshot. Most state assertions are now
@@ -936,24 +1843,12 @@ export default function createPlaybookRuntime(options) {
936
1843
  _getActor() {
937
1844
  return actor;
938
1845
  },
1846
+ _getBoundary() {
1847
+ return boundary;
1848
+ },
1849
+ _getNestedBridge() {
1850
+ return nestedBridge;
1851
+ },
939
1852
  };
940
1853
  return runtime;
941
1854
  }
942
- function driveToQuiescence(actor) {
943
- return new Promise((resolve) => {
944
- if (isQuiescent(actor.getSnapshot())) {
945
- resolve();
946
- return;
947
- }
948
- const sub = actor.subscribe((snap) => {
949
- if (isQuiescent(snap)) {
950
- sub.unsubscribe();
951
- resolve();
952
- }
953
- });
954
- });
955
- }
956
- function isQuiescent(snap) {
957
- const v = snap.value;
958
- return typeof v === 'string' && QUIESCENT_STATES.has(v);
959
- }