@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,15 +11,30 @@
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)
17
18
 
19
+ import PQueue from 'p-queue';
18
20
  import { createActor, fromPromise } from 'xstate';
21
+ import type { InspectionEvent, SnapshotFrom } from 'xstate';
22
+ import {
23
+ assertPlaybookRuntimeSnapshot,
24
+ combineAbortSignals,
25
+ createNestedPlaybookBridge,
26
+ detachPersistedMachineSnapshot,
27
+ normalizeError,
28
+ normalizePlaybookSnapshot,
29
+ snapshotJsonValue,
30
+ snapshotPlaybookSession,
31
+ validatePlayerResult,
32
+ waitForPlaybookQuiescence,
33
+ } from '../../../src/xstate-runtime.js';
19
34
  import {
20
35
  codingMachine,
21
- type CaptainInput,
22
- type CaptainOutput,
36
+ type PlayerInput,
37
+ type PlayerOutput,
23
38
  type CodingEvent,
24
39
  type CodingInput,
25
40
  } from './code.fsm.js';
@@ -29,22 +44,88 @@ import {
29
44
  enumerateRootEvents,
30
45
  } from './code.fsm.introspect.js';
31
46
  import type {
47
+ CaptainCallOptions,
48
+ CaptainResult,
49
+ JsonValue,
50
+ NormalizedError,
51
+ PlayerCallOptions,
52
+ PlaybookCallRequest,
53
+ PlaybookCallResult,
54
+ PlaybookCallStart,
55
+ PlaybookPendingCall,
56
+ PlaybookRunResult,
57
+ PlaybookRuntimeSnapshot,
58
+ PlaybookSession,
59
+ PlaybookState,
60
+ PlaybookStateValue,
61
+ PlaybookTraceEvent,
62
+ PlaybookTraceType,
32
63
  PlaybookPorts,
33
64
  PlaybookRuntime,
65
+ PlaybookRuntimeFactory,
34
66
  PlayerResult,
35
67
  } from '@sublang/playbook/runtime';
36
68
 
37
- // Public contract. `PlayerResult`, `PlaybookPorts`, and `PlaybookRuntime`
38
- // are re-exported from the shared `@sublang/playbook/runtime` module
69
+ // Public contract. `PlayerResult`, `PlaybookPorts`, `PlaybookSession`, and
70
+ // `PlaybookRuntime` are re-exported from the shared runtime module
39
71
  // (slc/link.md §Output, DR-004 Addendum A4) so this playbook and any
40
72
  // future one resolve one contract definition rather than redefining it.
41
73
  // `CodePlaybookOptions` and the default `createPlaybookRuntime` factory
42
74
  // (typed `PlaybookRuntimeFactory<CodePlaybookOptions>`) stay
43
75
  // CODE-specific.
44
- export type { PlayerResult, PlaybookPorts, PlaybookRuntime };
76
+ export type {
77
+ CaptainCallOptions,
78
+ CaptainResult,
79
+ JsonValue,
80
+ NormalizedError,
81
+ PlayerCallOptions,
82
+ PlaybookCallRequest,
83
+ PlaybookCallResult,
84
+ PlaybookCallStart,
85
+ PlaybookPendingCall,
86
+ PlaybookRunResult,
87
+ PlayerResult,
88
+ PlaybookPorts,
89
+ PlaybookSession,
90
+ PlaybookState,
91
+ PlaybookStateValue,
92
+ PlaybookTraceEvent,
93
+ PlaybookTraceType,
94
+ PlaybookRuntime,
95
+ PlaybookRuntimeFactory,
96
+ PlaybookRuntimeSnapshot,
97
+ };
45
98
 
46
99
  export type CodePlaybookOptions = CodingInput;
47
100
 
101
+ function snapshotCodePlaybookOptions(value: unknown): CodePlaybookOptions {
102
+ const captured = snapshotJsonValue(value, 'CODE runtime options');
103
+ if (
104
+ captured === null ||
105
+ typeof captured !== 'object' ||
106
+ Array.isArray(captured)
107
+ ) {
108
+ throw new TypeError('CODE runtime options must be an object');
109
+ }
110
+ const record = captured as Readonly<Record<string, JsonValue>>;
111
+ const allowed = new Set([
112
+ 'intent',
113
+ 'irNumber',
114
+ 'coderPlayer',
115
+ 'reviewerPlayer',
116
+ 'committerPlayer',
117
+ ]);
118
+ for (const [key, option] of Object.entries(record)) {
119
+ if (!allowed.has(key)) {
120
+ throw new TypeError(`CODE runtime options.${key} is not declared`);
121
+ }
122
+ if (typeof option !== 'string') {
123
+ throw new TypeError(`CODE runtime options.${key} must be a string`);
124
+ }
125
+ }
126
+ return captured as unknown as CodePlaybookOptions;
127
+ }
128
+
48
129
  const BOSS_REPLY_ERRORS = {
49
130
  missingQuestion: "needsBossReply outcome missing 'question' field",
50
131
  unregisteredState: (stateId: string) =>
@@ -68,19 +149,8 @@ function normalizeErrorCompact(
68
149
  err: unknown,
69
150
  ): { name: string; message: string } | undefined {
70
151
  if (err === undefined || err === null) return undefined;
71
- if (err instanceof Error) {
72
- return { name: err.name, message: err.message };
73
- }
74
- if (typeof err === 'object') {
75
- const o = err as Record<string, unknown>;
76
- if (typeof o.message === 'string') {
77
- return {
78
- name: typeof o.name === 'string' ? o.name : 'Error',
79
- message: o.message,
80
- };
81
- }
82
- }
83
- return { name: 'Error', message: String(err) };
152
+ const normalized = normalizeError(err);
153
+ return { name: normalized.name, message: normalized.message };
84
154
  }
85
155
 
86
156
  // Normalize an unknown error value to the full `{ name, message, stack }`
@@ -89,30 +159,65 @@ function normalizeErrorCompact(
89
159
  function normalizeErrorFull(
90
160
  err: unknown,
91
161
  ): { name: string; message: string; stack?: string } | undefined {
92
- const compact = normalizeErrorCompact(err);
93
- if (compact === undefined) return undefined;
94
- if (err instanceof Error) {
95
- return err.stack !== undefined ? { ...compact, stack: err.stack } : compact;
96
- }
97
- if (typeof err === 'object' && err !== null) {
98
- const stack = (err as Record<string, unknown>).stack;
99
- if (typeof stack === 'string') {
100
- return { ...compact, stack };
101
- }
102
- }
103
- return compact;
162
+ if (err === undefined || err === null) return undefined;
163
+ return normalizeError(err);
164
+ }
165
+
166
+ function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
167
+ return (
168
+ signal.aborted &&
169
+ (error === signal.reason || normalizeError(error).name === 'AbortError')
170
+ );
104
171
  }
105
172
 
106
173
  // Normalize any `error` field inside a telemetry event so failed
107
174
  // transitions don't leak raw Error instances through the channel.
108
175
  function normalizeEventForTelemetry(event: unknown): unknown {
109
- if (event === null || typeof event !== 'object' || Array.isArray(event)) {
110
- return event;
176
+ if (event === undefined) return undefined;
177
+ return normalizeEventValue(event, 'FSM event', new Set());
178
+ }
179
+
180
+ function normalizeEventValue(
181
+ value: unknown,
182
+ path: string,
183
+ ancestors: ReadonlySet<object>,
184
+ ): JsonValue {
185
+ if (Array.isArray(value)) return snapshotJsonValue(value, path);
186
+ if (value === null || typeof value !== 'object') {
187
+ return snapshotJsonValue(value, path);
188
+ }
189
+ if (ancestors.has(value)) {
190
+ throw new TypeError(`${path} must not contain a JSON cycle`);
111
191
  }
112
- const e = event as Record<string, unknown>;
113
- if (!('error' in e)) return event;
114
- const normalized = normalizeErrorFull(e.error);
115
- return { ...e, error: normalized };
192
+ const prototype = Object.getPrototypeOf(value) as unknown;
193
+ if (prototype !== Object.prototype && prototype !== null) {
194
+ return snapshotJsonValue(value, path);
195
+ }
196
+ if (Object.getOwnPropertySymbols(value).length > 0) {
197
+ return snapshotJsonValue(value, path);
198
+ }
199
+ const nextAncestors = new Set(ancestors).add(value);
200
+ const normalized: Record<string, JsonValue> = {};
201
+ for (const [key, descriptor] of Object.entries(
202
+ Object.getOwnPropertyDescriptors(value),
203
+ )) {
204
+ if (!descriptor.enumerable) {
205
+ throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
206
+ }
207
+ if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
208
+ throw new TypeError(`${path}.${key} must be a JSON data property`);
209
+ }
210
+ if (descriptor.value === undefined) continue;
211
+ normalized[key] =
212
+ key === 'error'
213
+ ? snapshotJsonValue(normalizeError(descriptor.value), `${path}.error`)
214
+ : normalizeEventValue(
215
+ descriptor.value,
216
+ `${path}.${key}`,
217
+ nextAncestors,
218
+ );
219
+ }
220
+ return snapshotJsonValue(normalized, path);
116
221
  }
117
222
 
118
223
  // Internal capabilities (DR-004 §10). Each ships with its final
@@ -131,7 +236,7 @@ function normalizeEventForTelemetry(event: unknown): unknown {
131
236
  // reply, the continuation preamble and Q/A blocks precede every
132
237
  // ordinary block. The FSM's prompt body is never re-flowed.
133
238
 
134
- function composePlayerPrompt(input: CaptainInput): string {
239
+ function composePlayerPrompt(input: PlayerInput): string {
135
240
  const blocks: string[] = [];
136
241
  if (
137
242
  input.pendingBossQuestion !== undefined &&
@@ -189,7 +294,7 @@ function composePlayerPrompt(input: CaptainInput): string {
189
294
  // set. The alias selects only the host pane; it is not a PBRT-4
190
295
  // identity string, so it leaves <coder-llm> / <reviewer-llm>
191
296
  // untouched and `input.player` stays `Committer` (PLAYBOOK-3).
192
- function resolvePlayerId(input: CaptainInput): string {
297
+ function resolvePlayerId(input: PlayerInput): string {
193
298
  switch (input.player) {
194
299
  case 'Coder':
195
300
  return 'coder';
@@ -202,13 +307,28 @@ function resolvePlayerId(input: CaptainInput): string {
202
307
  return 'coder';
203
308
  default: {
204
309
  const exhaustive: never = input.player;
205
- throw new Error(
206
- `resolvePlayerId: unknown player ${String(exhaustive)}`,
207
- );
310
+ throw new Error(`resolvePlayerId: unknown player ${String(exhaustive)}`);
208
311
  }
209
312
  }
210
313
  }
211
314
 
315
+ type JudgePurpose = 'boss-input-classification' | 'player-output-adjudication';
316
+
317
+ interface RuntimeBoundaryCalls {
318
+ callPlayer(
319
+ input: PlayerInput,
320
+ playerId: string,
321
+ prompt: string,
322
+ signal: AbortSignal,
323
+ ): Promise<PlayerResult>;
324
+ callJudge(
325
+ purpose: JudgePurpose,
326
+ stateId: string | undefined,
327
+ prompt: string,
328
+ signal: AbortSignal,
329
+ ): Promise<string>;
330
+ }
331
+
212
332
  // LLM judge — DR-004 §4. Builds a prompt that lists each declared
213
333
  // outcome verbatim, asks ports.callJudge for a JSON
214
334
  // `{ guard, …payloadFields }` response, and returns the parsed
@@ -216,13 +336,21 @@ function resolvePlayerId(input: CaptainInput): string {
216
336
  // Adjudicator failures (malformed JSON, missing/unknown guard) are
217
337
  // control-plane errors and propagate via throw per slc/link.md.
218
338
  async function adjudicate(
219
- input: CaptainInput,
339
+ input: PlayerInput,
220
340
  finalText: string,
221
341
  ports: PlaybookPorts,
222
342
  signal: AbortSignal,
223
- ): Promise<CaptainOutput> {
343
+ boundary?: RuntimeBoundaryCalls,
344
+ ): Promise<PlayerOutput> {
224
345
  const prompt = buildJudgePrompt(input, finalText);
225
- const raw = await ports.callJudge(prompt, signal);
346
+ const raw = boundary
347
+ ? await boundary.callJudge(
348
+ 'player-output-adjudication',
349
+ input.stateId,
350
+ prompt,
351
+ signal,
352
+ )
353
+ : await ports.callJudge(prompt, signal);
226
354
  const parsed = parseJudgeJson(raw);
227
355
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
228
356
  throw new Error('adjudicate: judge response is not a JSON object');
@@ -264,7 +392,7 @@ async function adjudicate(
264
392
  );
265
393
  }
266
394
  }
267
- return obj as CaptainOutput;
395
+ return obj as PlayerOutput;
268
396
  }
269
397
 
270
398
  function extractRequiredFields(description: string): string[] {
@@ -276,7 +404,7 @@ function extractRequiredFields(description: string): string[] {
276
404
  return fields;
277
405
  }
278
406
 
279
- function buildJudgePrompt(input: CaptainInput, finalText: string): string {
407
+ function buildJudgePrompt(input: PlayerInput, finalText: string): string {
280
408
  const lines: string[] = [];
281
409
  lines.push(`The ${input.player} just produced this output:`);
282
410
  lines.push('');
@@ -444,11 +572,12 @@ async function classifyBossText(
444
572
  ports: PlaybookPorts,
445
573
  signal: AbortSignal,
446
574
  snapshotOrState?: unknown,
575
+ boundary?: RuntimeBoundaryCalls,
447
576
  ): Promise<CodingEvent | undefined> {
448
577
  const trimmed = text.trim();
449
578
  if (trimmed === '') return undefined;
450
579
 
451
- return classifyWithLlm(text, ports, signal, snapshotOrState);
580
+ return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
452
581
  }
453
582
 
454
583
  // JumpableStateId is internal to code.fsm.ts (not exported), so
@@ -469,10 +598,19 @@ async function classifyWithLlm(
469
598
  ports: PlaybookPorts,
470
599
  signal: AbortSignal,
471
600
  snapshotOrState?: unknown,
601
+ boundary?: RuntimeBoundaryCalls,
472
602
  ): Promise<CodingEvent | undefined> {
473
603
  const state = classifierState(snapshotOrState);
474
604
  const prompt = buildClassifierPrompt(text, state);
475
- const raw = await ports.callJudge(prompt, signal);
605
+ const stateId = typeof state.value === 'string' ? state.value : undefined;
606
+ const raw = boundary
607
+ ? await boundary.callJudge(
608
+ 'boss-input-classification',
609
+ stateId,
610
+ prompt,
611
+ signal,
612
+ )
613
+ : await ports.callJudge(prompt, signal);
476
614
  let parsed: unknown;
477
615
  try {
478
616
  parsed = parseJudgeJson(raw);
@@ -562,7 +700,36 @@ async function classifyWithLlm(
562
700
  await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
563
701
  return undefined;
564
702
  }
565
- return { type: 'BOSS_REPLY', answer: payload.answer };
703
+ const pending = pendingBossQuestionFromContext(state.context);
704
+ if (!pending) {
705
+ await ports.emitStatus(
706
+ 'Classifier returned BOSS_REPLY without a pending question',
707
+ );
708
+ return undefined;
709
+ }
710
+ if (
711
+ payload.questionId !== undefined &&
712
+ typeof payload.questionId !== 'string'
713
+ ) {
714
+ await ports.emitStatus(
715
+ 'Classifier supplied a non-string questionId for BOSS_REPLY',
716
+ );
717
+ return undefined;
718
+ }
719
+ if (
720
+ typeof payload.questionId === 'string' &&
721
+ payload.questionId !== pending.questionId
722
+ ) {
723
+ await ports.emitStatus(
724
+ `Classifier supplied unknown questionId for BOSS_REPLY: ${payload.questionId}`,
725
+ );
726
+ return undefined;
727
+ }
728
+ return {
729
+ type: 'BOSS_REPLY',
730
+ answer: payload.answer,
731
+ questionId: pending.questionId,
732
+ };
566
733
  }
567
734
  default:
568
735
  await ports.emitStatus(
@@ -602,7 +769,9 @@ function classifierState(snapshotOrState: unknown): ClassifierState {
602
769
 
603
770
  function buildClassifierPrompt(text: string, state: ClassifierState): string {
604
771
  const currentState =
605
- typeof state.value === 'string' ? state.value : 'unknown';
772
+ typeof state.value === 'string'
773
+ ? state.value
774
+ : JSON.stringify(state.value ?? null);
606
775
  const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
607
776
  const lines = [
608
777
  'Classify the following Boss message into exactly one of these events.',
@@ -613,8 +782,9 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
613
782
  ];
614
783
  if (pendingBossQuestion !== undefined) {
615
784
  lines.push(
785
+ `Pending question id: ${pendingBossQuestion.questionId}`,
786
+ `Pending asking player: ${pendingBossQuestion.player}`,
616
787
  `Pending Boss question: ${pendingBossQuestion.question}`,
617
- `Pending resume state: ${pendingBossQuestion.resumeStateId}`,
618
788
  );
619
789
  }
620
790
  lines.push(
@@ -630,27 +800,24 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
630
800
  lines.push(` - ${target.stateId}: ${target.description}`);
631
801
  }
632
802
  if (currentState === 'awaitBossReply') {
633
- lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
803
+ lines.push(
804
+ '- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }',
805
+ );
634
806
  } else {
635
807
  lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
636
808
  }
637
- lines.push(
638
- '',
639
- 'Boss message:',
640
- '```',
641
- text,
642
- '```',
643
- );
809
+ lines.push('', 'Boss message:', '```', text, '```');
644
810
  return lines.join('\n');
645
811
  }
646
812
 
647
- // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
648
- // codingMachine invokes from every captain-invoking state. Per turn:
813
+ // Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
814
+ // codingMachine invokes from every player-invoking state. Per turn:
649
815
  // resolve playerId, compose the player prompt, await
650
816
  // ports.callPlayer, adjudicate the finalText. PlayerResult status of
651
817
  // 'aborted' or 'error' throws so XState routes via onError → #failed
652
818
  // (the single fail-stop sink for both Captain errors and player
653
- // failures).
819
+ // failures). Captain remains the orchestrator and adjudicator; it is not
820
+ // encoded as the delegated FSM actor.
654
821
  //
655
822
  // `getActiveSignal` is the runtime's hook for flowing the Boss's
656
823
  // `handleBossInput.signal` into the host port calls — fromPromise
@@ -660,34 +827,43 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
660
827
  function captainBridge(
661
828
  ports: PlaybookPorts,
662
829
  getActiveSignal?: () => AbortSignal | undefined,
830
+ boundary?: RuntimeBoundaryCalls,
831
+ onControlPlaneError?: (error: unknown) => void,
663
832
  ) {
664
- return fromPromise<CaptainOutput, CaptainInput>(
665
- async ({ input, signal }) => {
666
- const activeSignal = getActiveSignal?.() ?? signal;
667
- const playerId = resolvePlayerId(input);
668
- const prompt = composePlayerPrompt(input);
669
- const result = await ports.callPlayer(playerId, prompt, activeSignal);
670
- if (result.status !== 'ok') {
671
- throw new Error(
672
- result.error ??
673
- `captainBridge: callPlayer status "${result.status}"`,
674
- );
675
- }
676
- if (result.finalText === undefined) {
677
- throw new Error(
678
- 'captainBridge: callPlayer returned status=ok with no finalText',
679
- );
680
- }
833
+ return fromPromise<PlayerOutput, PlayerInput>(async ({ input, signal }) => {
834
+ const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
835
+ const playerId = resolvePlayerId(input);
836
+ const prompt = composePlayerPrompt(input);
837
+ const result = boundary
838
+ ? await boundary.callPlayer(input, playerId, prompt, activeSignal)
839
+ : await ports.callPlayer(playerId, prompt, activeSignal, {
840
+ resume: false,
841
+ });
842
+ if (result.status !== 'ok') {
843
+ throw new Error(
844
+ result.error ?? `captainBridge: callPlayer status "${result.status}"`,
845
+ );
846
+ }
847
+ if (result.finalText === undefined) {
848
+ throw new Error(
849
+ 'captainBridge: callPlayer returned status=ok with no finalText',
850
+ );
851
+ }
852
+ try {
681
853
  const output = await adjudicate(
682
854
  input,
683
855
  result.finalText,
684
856
  ports,
685
857
  activeSignal,
858
+ boundary,
686
859
  );
687
860
  validateBossReplyOutput(input, output);
688
861
  return output;
689
- },
690
- );
862
+ } catch (error) {
863
+ onControlPlaneError?.(error);
864
+ throw error;
865
+ }
866
+ });
691
867
  }
692
868
 
693
869
  // Captain pane display — PBRT-3 / PBRT-14.
@@ -696,7 +872,7 @@ function captainBridge(
696
872
  // glance:
697
873
  // (no glyph) bare FSM event type — host renders as captain speech
698
874
  // (e.g., `captain> START_CODING`)
699
- // ⤷ captain-invoking state entry: `<Player>: <label>`
875
+ // ⤷ player-invoking state entry: `<Player>: <label>`
700
876
  // → transition guard outcome (`· field=N` tallies
701
877
  // appended); the host presenter owns any visual
702
878
  // nesting under the preceding ⤷ entry
@@ -735,7 +911,7 @@ const STATE_LABELS: Readonly<Record<string, string>> = {
735
911
  };
736
912
 
737
913
  interface StateMetadata {
738
- player: CaptainInput['player'];
914
+ player: PlayerInput['player'];
739
915
  sourceItem: string;
740
916
  label: string;
741
917
  }
@@ -746,7 +922,7 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
746
922
  const label = STATE_LABELS[s.stateId];
747
923
  if (!label) {
748
924
  throw new Error(
749
- `code.playbook.ts: STATE_LABELS missing entry for captain-invoking state '${s.stateId}'`,
925
+ `code.playbook.ts: STATE_LABELS missing entry for player-invoking state '${s.stateId}'`,
750
926
  );
751
927
  }
752
928
  const input = s.getInput({});
@@ -755,13 +931,6 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
755
931
  return m;
756
932
  })();
757
933
 
758
- const stateIdBySourceItem: ReadonlyMap<string, string> = new Map(
759
- [...stateMetadata.entries()].map(([stateId, meta]) => [
760
- meta.sourceItem,
761
- stateId,
762
- ]),
763
- );
764
-
765
934
  const registeredResumableStateIds: ReadonlySet<string> = new Set(
766
935
  enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map(
767
936
  (transition) => transition.target,
@@ -769,18 +938,16 @@ const registeredResumableStateIds: ReadonlySet<string> = new Set(
769
938
  );
770
939
 
771
940
  function validateBossReplyOutput(
772
- input: CaptainInput,
773
- output: CaptainOutput,
941
+ input: PlayerInput,
942
+ output: PlayerOutput,
774
943
  ): void {
775
944
  if (output.guard !== 'needsBossReply') return;
776
945
  if (typeof output.question !== 'string') {
777
946
  throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
778
947
  }
779
- const stateId = stateIdBySourceItem.get(input.sourceItem);
780
- if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
781
- throw new Error(
782
- BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem),
783
- );
948
+ const stateId = input.stateId;
949
+ if (!registeredResumableStateIds.has(stateId)) {
950
+ throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId));
784
951
  }
785
952
  }
786
953
 
@@ -795,12 +962,9 @@ const QUIESCENT_STATES: ReadonlySet<string> = new Set([
795
962
  // pane per PBRT-3: the readline returning to its `boss>` prompt is
796
963
  // the implicit "turn over" signal, so a `◆ ready` / `◆ done`
797
964
  // tombstone is redundant.
798
- const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set([
799
- 'ready',
800
- 'done',
801
- ]);
965
+ const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
802
966
 
803
- // Captain-pane surface (PBRT-3): every captain-invoking state plus
967
+ // Captain-pane surface (PBRT-3): every player-invoking state plus
804
968
  // the quiescent states whose entry still carries information
805
969
  // (failure with `lastError`, awaitBossReply with the pending
806
970
  // question). `ready` and `done` flow through the inspect handler
@@ -811,7 +975,12 @@ const CAPTAIN_PANE_STATES: ReadonlySet<string> = new Set([
811
975
  ...QUIESCENT_STATES,
812
976
  ]);
813
977
 
978
+ type BossReplyQuestionId = NonNullable<
979
+ Extract<CodingEvent, { type: 'BOSS_REPLY' }>['questionId']
980
+ >;
981
+
814
982
  interface PendingBossQuestionForStatus {
983
+ questionId: BossReplyQuestionId;
815
984
  resumeStateId: string;
816
985
  sourceItem: string;
817
986
  player: string;
@@ -830,6 +999,7 @@ function pendingBossQuestionFromContext(
830
999
  >;
831
1000
  if (
832
1001
  typeof candidate.resumeStateId !== 'string' ||
1002
+ typeof candidate.questionId !== 'string' ||
833
1003
  typeof candidate.sourceItem !== 'string' ||
834
1004
  typeof candidate.player !== 'string' ||
835
1005
  typeof candidate.question !== 'string'
@@ -837,6 +1007,7 @@ function pendingBossQuestionFromContext(
837
1007
  return undefined;
838
1008
  }
839
1009
  return {
1010
+ questionId: candidate.questionId as BossReplyQuestionId,
840
1011
  resumeStateId: candidate.resumeStateId,
841
1012
  sourceItem: candidate.sourceItem,
842
1013
  player: candidate.player,
@@ -864,9 +1035,7 @@ function formatAwaitBossReplyQuestion(
864
1035
  // It carries only the resume target, asking player, and source item;
865
1036
  // the former `q="<first 80 chars>"` excerpt rider is dropped now that
866
1037
  // the full question rides the captain-speech line above.
867
- function formatAwaitBossReplyMarker(
868
- context: Record<string, unknown>,
869
- ): string {
1038
+ function formatAwaitBossReplyMarker(context: Record<string, unknown>): string {
870
1039
  const pending = pendingBossQuestionFromContext(context);
871
1040
  const resumeStateId = pending?.resumeStateId ?? 'unknown';
872
1041
  const player = pending?.player ?? 'unknown';
@@ -912,9 +1081,9 @@ function stateTelemetryPayload(
912
1081
  context: Record<string, unknown>,
913
1082
  ): Record<string, unknown> {
914
1083
  const payload: Record<string, unknown> = {
915
- from,
1084
+ from: from ?? null,
916
1085
  to,
917
- event: normalizeEventForTelemetry(event),
1086
+ event: normalizeEventForTelemetry(event) ?? null,
918
1087
  };
919
1088
  if (to === 'awaitBossReply') {
920
1089
  const pendingBossQuestion = pendingBossQuestionFromContext(context);
@@ -931,6 +1100,32 @@ function stateTelemetryPayload(
931
1100
  return payload;
932
1101
  }
933
1102
 
1103
+ function structuredStateTelemetryPayload(
1104
+ previousState: PlaybookState | undefined,
1105
+ state: PlaybookState,
1106
+ event: unknown,
1107
+ context: Record<string, unknown>,
1108
+ ): JsonValue {
1109
+ const payload: Record<string, unknown> = {
1110
+ from: previousState?.value ?? null,
1111
+ to: state.value,
1112
+ event: normalizeEventForTelemetry(event) ?? null,
1113
+ previousState: previousState ?? null,
1114
+ state,
1115
+ };
1116
+ if (state.stateId === 'awaitBossReply') {
1117
+ const pendingBossQuestion = pendingBossQuestionFromContext(context);
1118
+ if (pendingBossQuestion !== undefined) {
1119
+ payload.pendingBossQuestion = pendingBossQuestion;
1120
+ }
1121
+ }
1122
+ if (state.stateId === 'failed') {
1123
+ const lastError = normalizeErrorFull(context.lastError);
1124
+ if (lastError !== undefined) payload.lastError = lastError;
1125
+ }
1126
+ return snapshotJsonValue(payload, 'FSM telemetry payload');
1127
+ }
1128
+
934
1129
  // Internal export surface for tests. Not part of the stable public API;
935
1130
  // the leading underscore signals "subject to change." Each member is
936
1131
  // referenced here so `noUnusedLocals` stays clean while later tasks
@@ -956,125 +1151,946 @@ export const _internal = {
956
1151
  VERBATIM_PAYLOAD_FIELDS,
957
1152
  };
958
1153
 
1154
+ type BossSettlementOutcome =
1155
+ | 'no-action'
1156
+ | 'quiescent'
1157
+ | 'failed'
1158
+ | 'terminal'
1159
+ | 'aborted'
1160
+ | 'suspended';
1161
+
1162
+ interface TracePosition {
1163
+ turnId?: number;
1164
+ callId?: string;
1165
+ }
1166
+
959
1167
  export default function createPlaybookRuntime(
960
1168
  options: CodePlaybookOptions,
961
1169
  ): PlaybookRuntime {
1170
+ const boundOptions = snapshotCodePlaybookOptions(options);
962
1171
  let actor: ReturnType<typeof createActor> | undefined;
1172
+ let session: PlaybookSession | undefined;
1173
+ let initialized = false;
1174
+ let initInFlight: Promise<void> | undefined;
1175
+ let disposalPromise: Promise<void> | undefined;
1176
+ let disposed = false;
963
1177
  let savedPorts: PlaybookPorts | undefined;
1178
+ let runtimePorts: PlaybookPorts | undefined;
964
1179
  // The Boss's per-turn AbortSignal, surfaced to captainBridge so
965
1180
  // ports.callPlayer / callJudge see the right cancellation source.
966
1181
  // null between turns; set by handleBossInput.
967
1182
  let activeSignal: AbortSignal | undefined;
1183
+ let activeTurnId: number | undefined;
1184
+ let controlPlaneError: unknown;
968
1185
  // Previous root-machine state for the inspect-driven telemetry /
969
1186
  // status emitter. undefined before the first inspect firing.
970
- let priorState: unknown;
971
-
972
- // Emission queue. slc/link.md says emissions "shall be ordered,
973
- // awaited, and never-dropped"; subscribe/inspect callbacks are
974
- // synchronous and can't await, so each emit is enqueued and a
975
- // single drainer processes them sequentially.
976
- const emitQueue: Array<() => Promise<void>> = [];
977
- let drainer: Promise<void> | undefined;
978
-
979
- function enqueueEmit(fn: () => Promise<void>): void {
980
- emitQueue.push(fn);
981
- if (!drainer) {
982
- drainer = (async () => {
983
- while (emitQueue.length > 0) {
1187
+ let priorState: PlaybookState | undefined;
1188
+ let suppressInspectionEmissions = false;
1189
+
1190
+ let traceSequence = 0;
1191
+ let turnSequence = 0;
1192
+ let judgeCallSequence = 0;
1193
+ let playerCallSequence = 0;
1194
+ let playbookCallSequence = 0;
1195
+ const playerResumeTokens = new Map<string, string>();
1196
+ const activePlayerIds = new Set<string>();
1197
+ const playbookCallTurnIds = new Map<string, number | undefined>();
1198
+ const judgeQueue = new PQueue({ concurrency: 1 });
1199
+ const emissionQueue = new PQueue({ concurrency: 1 });
1200
+ const activeEmissionCalls = new Set<Promise<void>>();
1201
+
1202
+ // All trace, state-telemetry, and status work shares this one queue.
1203
+ // Inspection callbacks enqueue a complete ordered batch synchronously;
1204
+ // imperative boundaries await their queued work directly.
1205
+ let emissionFailure: unknown;
1206
+
1207
+ function enqueueEmission(fn: () => Promise<void>): Promise<void> {
1208
+ const queued = emissionQueue.add(fn).then(() => undefined);
1209
+ activeEmissionCalls.add(queued);
1210
+ void queued.then(
1211
+ () => activeEmissionCalls.delete(queued),
1212
+ (error: unknown) => {
1213
+ activeEmissionCalls.delete(queued);
1214
+ emissionFailure ??= error;
1215
+ },
1216
+ );
1217
+ return queued;
1218
+ }
1219
+
1220
+ async function drainEmissions(): Promise<void> {
1221
+ while (true) {
1222
+ const active = [...activeEmissionCalls];
1223
+ if (active.length > 0) await Promise.allSettled(active);
1224
+ await emissionQueue.onIdle();
1225
+ if (
1226
+ activeEmissionCalls.size === 0 &&
1227
+ emissionQueue.size === 0 &&
1228
+ emissionQueue.pending === 0
1229
+ ) {
1230
+ break;
1231
+ }
1232
+ }
1233
+ if (emissionFailure !== undefined) {
1234
+ const error = emissionFailure;
1235
+ emissionFailure = undefined;
1236
+ throw error;
1237
+ }
1238
+ }
1239
+
1240
+ function requireSession(): PlaybookSession {
1241
+ if (!session) {
1242
+ throw new Error('createPlaybookRuntime: init must be called first');
1243
+ }
1244
+ return session;
1245
+ }
1246
+
1247
+ function requireHostPorts(): PlaybookPorts {
1248
+ if (!savedPorts) {
1249
+ throw new Error('createPlaybookRuntime: init must be called first');
1250
+ }
1251
+ return savedPorts;
1252
+ }
1253
+
1254
+ function createTraceEvent(
1255
+ type: PlaybookTraceType,
1256
+ payload: unknown,
1257
+ position: TracePosition = {},
1258
+ ): PlaybookTraceEvent {
1259
+ const currentSession = requireSession();
1260
+ const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
1261
+ return {
1262
+ schemaVersion: 2,
1263
+ sessionId: currentSession.sessionId,
1264
+ playbookId: currentSession.playbookId,
1265
+ rootSessionId: currentSession.rootSessionId,
1266
+ ...(currentSession.parentSessionId !== undefined
1267
+ ? { parentSessionId: currentSession.parentSessionId }
1268
+ : {}),
1269
+ ...(currentSession.parentCallId !== undefined
1270
+ ? { parentCallId: currentSession.parentCallId }
1271
+ : {}),
1272
+ depth: currentSession.depth,
1273
+ sequence: ++traceSequence,
1274
+ timestamp: Date.now(),
1275
+ type,
1276
+ ...(position.turnId !== undefined ? { turnId: position.turnId } : {}),
1277
+ ...(position.callId !== undefined ? { callId: position.callId } : {}),
1278
+ payload: safePayload,
1279
+ };
1280
+ }
1281
+
1282
+ function emitTrace(
1283
+ type: PlaybookTraceType,
1284
+ payload: unknown,
1285
+ position: TracePosition = {},
1286
+ ): Promise<void> {
1287
+ const currentSession = requireSession();
1288
+ const event = createTraceEvent(type, payload, position);
1289
+ return enqueueEmission(() =>
1290
+ currentSession.ports.emitTelemetry({
1291
+ topic: 'playbook.trace',
1292
+ payload: event,
1293
+ }),
1294
+ );
1295
+ }
1296
+
1297
+ function stateIdentity(stateId: string | undefined): { stateId?: string } {
1298
+ return stateId === undefined ? {} : { stateId };
1299
+ }
1300
+
1301
+ function currentState(): PlaybookState {
1302
+ if (!actor) {
1303
+ throw new Error('createPlaybookRuntime: actor is not initialized');
1304
+ }
1305
+ return normalizePlaybookSnapshot(actor.getSnapshot(), {
1306
+ pendingCall: nestedBridge.getPendingCall(),
1307
+ });
1308
+ }
1309
+
1310
+ function stateTracePayload(state = currentState()): Record<string, unknown> {
1311
+ return {
1312
+ state,
1313
+ ...stateIdentity(state.stateId),
1314
+ };
1315
+ }
1316
+
1317
+ function createRuntimePorts(hostPorts: PlaybookPorts): PlaybookPorts {
1318
+ return {
1319
+ callPlayer: (playerId, prompt, signal, callOptions) =>
1320
+ hostPorts.callPlayer(playerId, prompt, signal, callOptions),
1321
+ callCaptain: (prompt, signal, callOptions) =>
1322
+ hostPorts.callCaptain(prompt, signal, callOptions),
1323
+ callJudge: (prompt, signal) => hostPorts.callJudge(prompt, signal),
1324
+ callPlaybook: (request, signal) =>
1325
+ hostPorts.callPlaybook(request, signal),
1326
+ emitStatus: (message, data) => {
1327
+ const descriptor = actor ? currentState() : undefined;
1328
+ const safeData =
1329
+ data === undefined
1330
+ ? undefined
1331
+ : snapshotJsonValue(data, 'status data');
1332
+ const trace = createTraceEvent(
1333
+ 'status.emitted',
1334
+ {
1335
+ message,
1336
+ ...(safeData !== undefined ? { data: safeData } : {}),
1337
+ ...(descriptor !== undefined
1338
+ ? {
1339
+ state: descriptor,
1340
+ ...stateIdentity(descriptor.stateId),
1341
+ }
1342
+ : {}),
1343
+ },
1344
+ activeTurnId !== undefined ? { turnId: activeTurnId } : {},
1345
+ );
1346
+ return enqueueEmission(async () => {
1347
+ await hostPorts.emitTelemetry({
1348
+ topic: 'playbook.trace',
1349
+ payload: trace,
1350
+ });
1351
+ await hostPorts.emitStatus(message, safeData);
1352
+ });
1353
+ },
1354
+ emitTelemetry: (event) => {
1355
+ if (typeof event.topic !== 'string' || event.topic.length === 0) {
1356
+ throw new TypeError('telemetry topic must be a non-empty string');
1357
+ }
1358
+ const payload = snapshotJsonValue(event.payload, 'telemetry payload');
1359
+ return enqueueEmission(() =>
1360
+ hostPorts.emitTelemetry({ topic: event.topic, payload }),
1361
+ );
1362
+ },
1363
+ };
1364
+ }
1365
+
1366
+ async function emitCallStarted(
1367
+ startedType: 'player.call.started' | 'judge.call.started',
1368
+ finishedType: 'player.call.finished' | 'judge.call.finished',
1369
+ identity: Record<string, unknown>,
1370
+ position: TracePosition,
1371
+ ): Promise<void> {
1372
+ try {
1373
+ await emitTrace(startedType, identity, position);
1374
+ } catch (error) {
1375
+ controlPlaneError ??= error;
1376
+ try {
1377
+ await emitTrace(
1378
+ finishedType,
1379
+ { ...identity, status: 'error', error: normalizeError(error) },
1380
+ position,
1381
+ );
1382
+ } catch {
1383
+ // Preserve the start failure after one best-effort finish attempt.
1384
+ }
1385
+ throw error;
1386
+ }
1387
+ }
1388
+
1389
+ const boundary: RuntimeBoundaryCalls = {
1390
+ async callPlayer(input, playerId, prompt, signal): Promise<PlayerResult> {
1391
+ // State-entry telemetry/status must precede the call they describe.
1392
+ await drainEmissions();
1393
+ const turnId = activeTurnId;
1394
+ const callId = `player-${++playerCallSequence}`;
1395
+ const stateId = input.stateId;
1396
+ const resume = playerResumeTokens.get(playerId) ?? false;
1397
+ const identity = {
1398
+ purpose: 'captain' as const,
1399
+ ...stateIdentity(stateId),
1400
+ sourceItem: input.sourceItem,
1401
+ playerId,
1402
+ resume,
1403
+ };
1404
+
1405
+ if (activePlayerIds.has(playerId)) {
1406
+ const error = new Error(
1407
+ `simultaneous calls to resolved player ${playerId} are not allowed`,
1408
+ );
1409
+ await emitCallStarted(
1410
+ 'player.call.started',
1411
+ 'player.call.finished',
1412
+ { ...identity, prompt },
1413
+ {
1414
+ ...(turnId !== undefined ? { turnId } : {}),
1415
+ callId,
1416
+ },
1417
+ );
1418
+ await emitTrace(
1419
+ 'player.call.finished',
1420
+ { ...identity, status: 'error', error: normalizeError(error) },
1421
+ {
1422
+ ...(turnId !== undefined ? { turnId } : {}),
1423
+ callId,
1424
+ },
1425
+ );
1426
+ throw error;
1427
+ }
1428
+ activePlayerIds.add(playerId);
1429
+
1430
+ try {
1431
+ await emitTrace(
1432
+ 'player.call.started',
1433
+ { ...identity, prompt },
1434
+ {
1435
+ ...(turnId !== undefined ? { turnId } : {}),
1436
+ callId,
1437
+ },
1438
+ );
1439
+
1440
+ let rawResult: unknown;
1441
+ try {
1442
+ rawResult = await requireHostPorts().callPlayer(
1443
+ playerId,
1444
+ prompt,
1445
+ signal,
1446
+ { resume },
1447
+ );
1448
+ // A host promise is not required to honor cancellation. Do not let
1449
+ // a late result mutate continuity or publish a successful finish.
1450
+ signal.throwIfAborted();
1451
+ } catch (error) {
1452
+ if (!signal.aborted) controlPlaneError ??= error;
984
1453
  try {
985
- await emitQueue.shift()!();
1454
+ await emitTrace(
1455
+ 'player.call.finished',
1456
+ {
1457
+ ...identity,
1458
+ status: signal.aborted ? 'aborted' : 'error',
1459
+ error: normalizeError(error),
1460
+ },
1461
+ {
1462
+ ...(turnId !== undefined ? { turnId } : {}),
1463
+ callId,
1464
+ },
1465
+ );
986
1466
  } catch {
987
- // Suppress host-side emission errors; the control plane
988
- // surfaces real failures via handleBossInput throws.
1467
+ // The original non-abort port rejection remains authoritative.
989
1468
  }
1469
+ // A thrown port call carries no authoritative result, so the
1470
+ // prior token remains available for a later explicit resume.
1471
+ throw error;
990
1472
  }
991
- drainer = undefined;
992
- })();
993
- }
1473
+
1474
+ let result: PlayerResult;
1475
+ try {
1476
+ result = validatePlayerResult(rawResult);
1477
+ } catch (error) {
1478
+ if (!signal.aborted) controlPlaneError ??= error;
1479
+ try {
1480
+ await emitTrace(
1481
+ 'player.call.finished',
1482
+ { ...identity, status: 'error', error: normalizeError(error) },
1483
+ {
1484
+ ...(turnId !== undefined ? { turnId } : {}),
1485
+ callId,
1486
+ },
1487
+ );
1488
+ } catch {
1489
+ // The malformed host result remains authoritative.
1490
+ }
1491
+ throw error;
1492
+ }
1493
+
1494
+ if (
1495
+ typeof result.resumeToken === 'string' &&
1496
+ result.resumeToken.trim().length > 0
1497
+ ) {
1498
+ playerResumeTokens.set(playerId, result.resumeToken);
1499
+ } else {
1500
+ playerResumeTokens.delete(playerId);
1501
+ }
1502
+
1503
+ await emitTrace(
1504
+ 'player.call.finished',
1505
+ {
1506
+ ...identity,
1507
+ status: result.status,
1508
+ ...(result.finalText !== undefined
1509
+ ? { finalText: result.finalText }
1510
+ : {}),
1511
+ ...(result.error !== undefined
1512
+ ? { error: normalizeError(result.error) }
1513
+ : {}),
1514
+ ...(result.resumeToken !== undefined
1515
+ ? { resumeToken: result.resumeToken }
1516
+ : {}),
1517
+ },
1518
+ {
1519
+ ...(turnId !== undefined ? { turnId } : {}),
1520
+ callId,
1521
+ },
1522
+ );
1523
+ return result;
1524
+ } finally {
1525
+ activePlayerIds.delete(playerId);
1526
+ }
1527
+ },
1528
+
1529
+ async callJudge(purpose, stateId, prompt, signal): Promise<string> {
1530
+ return judgeQueue.add(async () => {
1531
+ signal.throwIfAborted();
1532
+ // A transition/status queued synchronously by XState must reach
1533
+ // the host before the judge call that follows it.
1534
+ await drainEmissions();
1535
+ signal.throwIfAborted();
1536
+ const turnId = activeTurnId;
1537
+ const callId = `judge-${++judgeCallSequence}`;
1538
+ const identity = { purpose, ...stateIdentity(stateId) };
1539
+
1540
+ await emitCallStarted(
1541
+ 'judge.call.started',
1542
+ 'judge.call.finished',
1543
+ { ...identity, prompt },
1544
+ {
1545
+ ...(turnId !== undefined ? { turnId } : {}),
1546
+ callId,
1547
+ },
1548
+ );
1549
+ let reply: unknown;
1550
+ try {
1551
+ reply = await requireHostPorts().callJudge(prompt, signal);
1552
+ signal.throwIfAborted();
1553
+ } catch (error) {
1554
+ if (!isAbortFailure(error, signal)) {
1555
+ controlPlaneError ??= error;
1556
+ }
1557
+ await emitTrace(
1558
+ 'judge.call.finished',
1559
+ {
1560
+ ...identity,
1561
+ status: signal.aborted ? 'aborted' : 'error',
1562
+ error: normalizeError(error),
1563
+ },
1564
+ {
1565
+ ...(turnId !== undefined ? { turnId } : {}),
1566
+ callId,
1567
+ },
1568
+ );
1569
+ throw error;
1570
+ }
1571
+ if (typeof reply !== 'string') {
1572
+ const error = new TypeError('judge reply must be a string');
1573
+ controlPlaneError ??= error;
1574
+ await emitTrace(
1575
+ 'judge.call.finished',
1576
+ { ...identity, status: 'error', error: normalizeError(error) },
1577
+ {
1578
+ ...(turnId !== undefined ? { turnId } : {}),
1579
+ callId,
1580
+ },
1581
+ );
1582
+ throw error;
1583
+ }
1584
+ // Keep the success finish outside the port-call catch. If a
1585
+ // telemetry sink records this boundary and then rejects, that sink
1586
+ // failure must not synthesize a second, contradictory finish.
1587
+ await emitTrace(
1588
+ 'judge.call.finished',
1589
+ { ...identity, status: 'ok', reply },
1590
+ {
1591
+ ...(turnId !== undefined ? { turnId } : {}),
1592
+ callId,
1593
+ },
1594
+ );
1595
+ return reply;
1596
+ });
1597
+ },
1598
+ };
1599
+
1600
+ const nestedBridge = createNestedPlaybookBridge({
1601
+ nextCallId: () => `playbook-${++playbookCallSequence}`,
1602
+ getBoundarySignal: () => activeSignal,
1603
+ callPlaybook: (request, signal) =>
1604
+ requireHostPorts().callPlaybook(request, signal),
1605
+ emitStarted: async (event) => {
1606
+ playbookCallTurnIds.set(event.callId, activeTurnId);
1607
+ await emitTrace(
1608
+ 'playbook.call.started',
1609
+ {
1610
+ stateId: event.stateId,
1611
+ playbookId: event.playbookId,
1612
+ text: event.text,
1613
+ },
1614
+ {
1615
+ ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
1616
+ callId: event.callId,
1617
+ },
1618
+ );
1619
+ },
1620
+ emitFinished: async (event) => {
1621
+ const turnId = playbookCallTurnIds.get(event.callId);
1622
+ try {
1623
+ await emitTrace(
1624
+ 'playbook.call.finished',
1625
+ {
1626
+ stateId: event.stateId,
1627
+ playbookId: event.playbookId,
1628
+ text: event.text,
1629
+ result: event.result,
1630
+ },
1631
+ {
1632
+ ...(turnId !== undefined ? { turnId } : {}),
1633
+ callId: event.callId,
1634
+ },
1635
+ );
1636
+ } finally {
1637
+ playbookCallTurnIds.delete(event.callId);
1638
+ }
1639
+ },
1640
+ drain: drainEmissions,
1641
+ bindResumeSignal: (signal) => {
1642
+ activeSignal = signal;
1643
+ },
1644
+ onControlPlaneError: (error) => {
1645
+ if (!activeSignal?.aborted) controlPlaneError ??= error;
1646
+ },
1647
+ onBackgroundError: (error) => {
1648
+ emissionFailure ??= error;
1649
+ },
1650
+ });
1651
+
1652
+ function tracePositionForActiveTurn(): TracePosition {
1653
+ return activeTurnId === undefined ? {} : { turnId: activeTurnId };
994
1654
  }
995
1655
 
996
- function drainEmissions(): Promise<void> {
997
- return drainer ?? Promise.resolve();
1656
+ interface ScheduledStatus {
1657
+ message: string;
1658
+ data?: JsonValue;
1659
+ }
1660
+
1661
+ function enqueueTransitionEmission(
1662
+ payload: JsonValue,
1663
+ state: PlaybookState,
1664
+ statuses: readonly ScheduledStatus[],
1665
+ position: TracePosition,
1666
+ ): void {
1667
+ const currentSession = requireSession();
1668
+ const transitionTrace = createTraceEvent(
1669
+ 'fsm.transition',
1670
+ payload,
1671
+ position,
1672
+ );
1673
+ const statusEmissions = statuses.map(({ message, data }) => ({
1674
+ message,
1675
+ data,
1676
+ trace: createTraceEvent(
1677
+ 'status.emitted',
1678
+ {
1679
+ message,
1680
+ ...(data === undefined ? {} : { data }),
1681
+ state,
1682
+ ...stateIdentity(state.stateId),
1683
+ },
1684
+ position,
1685
+ ),
1686
+ }));
1687
+ void enqueueEmission(async () => {
1688
+ await currentSession.ports.emitTelemetry({
1689
+ topic: 'playbook.trace',
1690
+ payload: transitionTrace,
1691
+ });
1692
+ await currentSession.ports.emitTelemetry({
1693
+ topic: 'playbook.fsm.state',
1694
+ payload,
1695
+ });
1696
+ for (const status of statusEmissions) {
1697
+ await currentSession.ports.emitTelemetry({
1698
+ topic: 'playbook.trace',
1699
+ payload: status.trace,
1700
+ });
1701
+ await currentSession.ports.emitStatus(status.message, status.data);
1702
+ }
1703
+ }).catch(() => undefined);
1704
+ }
1705
+
1706
+ function latchInspectionError(error: unknown): void {
1707
+ if (activeSignal !== undefined) controlPlaneError ??= error;
1708
+ else emissionFailure ??= error;
998
1709
  }
999
1710
 
1000
1711
  function buildActor(
1001
1712
  ports: PlaybookPorts,
1713
+ machineSnapshot?: JsonValue,
1002
1714
  ): ReturnType<typeof createActor> {
1003
1715
  priorState = undefined;
1004
- return createActor(
1716
+ let builtActor: ReturnType<typeof createActor>;
1717
+ builtActor = createActor(
1005
1718
  codingMachine.provide({
1006
- actors: { captain: captainBridge(ports, () => activeSignal) },
1719
+ actors: {
1720
+ player: captainBridge(
1721
+ ports,
1722
+ () => activeSignal,
1723
+ boundary,
1724
+ (error) => {
1725
+ if (!activeSignal?.aborted) controlPlaneError ??= error;
1726
+ },
1727
+ ),
1728
+ },
1007
1729
  }),
1008
1730
  {
1009
- input: options,
1010
- inspect: (inspectionEvent) => {
1011
- if (inspectionEvent.type !== '@xstate.snapshot') return;
1012
- const snap = inspectionEvent.snapshot as {
1013
- value?: unknown;
1014
- context?: Record<string, unknown>;
1015
- };
1016
- // Filter out captain sub-actor (fromPromise) snapshots —
1017
- // only the root codingMachine snapshot has a string value.
1018
- if (typeof snap.value !== 'string') return;
1019
- const to = snap.value;
1020
- if (priorState === to) return;
1021
- const from = priorState;
1022
- priorState = to;
1023
- // Telemetry on every transition (PBRT-14).
1024
- const context = snap.context ?? {};
1025
- enqueueEmit(() =>
1026
- ports.emitTelemetry({
1027
- topic: 'playbook.fsm.state',
1028
- payload: stateTelemetryPayload(
1029
- from,
1030
- to,
1031
- inspectionEvent.event,
1032
- context,
1033
- ),
1731
+ input: boundOptions,
1732
+ // DR-014 §1: a restore rehydrates the persisted machine snapshot;
1733
+ // XState derives context/value from it and ignores `input` then.
1734
+ ...(machineSnapshot === undefined
1735
+ ? {}
1736
+ : {
1737
+ snapshot: machineSnapshot as unknown as SnapshotFrom<
1738
+ typeof codingMachine
1739
+ >,
1034
1740
  }),
1035
- );
1036
- // Captain pane (PBRT-3 / PBRT-14): show the transition
1037
- // guard first (when this is an actor-done transition with
1038
- // a known guard), then the new state entry, then any
1039
- // context riders the entering state cares about. Terminal
1040
- // entry to `failed` carries `lastError` as the data arg.
1041
- if (!CAPTAIN_PANE_STATES.has(to)) return;
1042
- const transitionLine = formatTransition(inspectionEvent.event);
1043
- if (transitionLine !== undefined) {
1044
- enqueueEmit(() => ports.emitStatus(transitionLine));
1045
- }
1046
- // awaitBossReply surfaces two lines per PBRT-3 / PBRT-14: the
1047
- // full player question as captain speech, then the rider-less
1048
- // routing marker. The full-question telemetry rides
1049
- // stateTelemetryPayload above.
1050
- if (to === 'awaitBossReply') {
1051
- const questionLine = formatAwaitBossReplyQuestion(context);
1052
- const markerLine = formatAwaitBossReplyMarker(context);
1053
- enqueueEmit(() => ports.emitStatus(questionLine));
1054
- enqueueEmit(() => ports.emitStatus(markerLine));
1055
- return;
1056
- }
1057
- const entryLine = formatStateEntry(to);
1058
- if (entryLine === undefined) return;
1059
- if (to === 'failed') {
1060
- const lastError = (snap.context as { lastError?: unknown })
1061
- ?.lastError;
1062
- const data = { lastError: normalizeErrorCompact(lastError) };
1063
- enqueueEmit(() => ports.emitStatus(entryLine, data));
1064
- } else {
1065
- enqueueEmit(() => ports.emitStatus(entryLine));
1741
+ inspect: (inspectionEvent: InspectionEvent) => {
1742
+ if (inspectionEvent.type !== '@xstate.snapshot') return;
1743
+ if (inspectionEvent.actorRef !== builtActor) return;
1744
+ if (suppressInspectionEmissions) return;
1745
+ try {
1746
+ const snap = inspectionEvent.snapshot as SnapshotFrom<
1747
+ typeof codingMachine
1748
+ >;
1749
+ const state = normalizePlaybookSnapshot(snap);
1750
+ const to = state.stateId;
1751
+ if (to === undefined) {
1752
+ throw new Error(
1753
+ 'CODE root snapshot must expose exactly one playbook state id',
1754
+ );
1755
+ }
1756
+ const previousState = priorState;
1757
+ const context = snap.context as Record<string, unknown>;
1758
+ const payload = structuredStateTelemetryPayload(
1759
+ previousState,
1760
+ state,
1761
+ inspectionEvent.event,
1762
+ context,
1763
+ );
1764
+ const statuses: ScheduledStatus[] = [];
1765
+ if (CAPTAIN_PANE_STATES.has(to)) {
1766
+ const transitionLine = formatTransition(inspectionEvent.event);
1767
+ if (transitionLine !== undefined) {
1768
+ statuses.push({ message: transitionLine });
1769
+ }
1770
+ if (to === 'awaitBossReply') {
1771
+ statuses.push(
1772
+ { message: formatAwaitBossReplyQuestion(context) },
1773
+ { message: formatAwaitBossReplyMarker(context) },
1774
+ );
1775
+ } else {
1776
+ const entryLine = formatStateEntry(to);
1777
+ if (entryLine !== undefined) {
1778
+ const lastError =
1779
+ to === 'failed'
1780
+ ? normalizeErrorCompact(
1781
+ (snap.context as { lastError?: unknown }).lastError,
1782
+ )
1783
+ : undefined;
1784
+ statuses.push({
1785
+ message: entryLine,
1786
+ ...(lastError === undefined
1787
+ ? {}
1788
+ : {
1789
+ data: snapshotJsonValue(
1790
+ { lastError },
1791
+ 'failed status data',
1792
+ ),
1793
+ }),
1794
+ });
1795
+ }
1796
+ }
1797
+ }
1798
+ enqueueTransitionEmission(
1799
+ payload,
1800
+ state,
1801
+ statuses,
1802
+ tracePositionForActiveTurn(),
1803
+ );
1804
+ priorState = state;
1805
+ } catch (error) {
1806
+ latchInspectionError(error);
1066
1807
  }
1067
1808
  },
1068
1809
  },
1069
1810
  );
1811
+ return builtActor;
1070
1812
  }
1071
1813
 
1072
- const runtime = {
1073
- async init(ports: PlaybookPorts): Promise<void> {
1074
- savedPorts = ports;
1075
- actor = buildActor(ports);
1076
- actor.start();
1814
+ function runResultFor(
1815
+ outcome: BossSettlementOutcome,
1816
+ error?: unknown,
1817
+ ): PlaybookRunResult {
1818
+ const state = currentState();
1819
+ if (outcome === 'quiescent' || outcome === 'no-action') {
1820
+ return { outcome, state };
1821
+ }
1822
+ if (outcome === 'suspended') {
1823
+ const pendingCall = nestedBridge.getPendingCall();
1824
+ if (!pendingCall) {
1825
+ throw new Error('suspended runtime has no pending playbook call');
1826
+ }
1827
+ return { outcome, state, pendingCall };
1828
+ }
1829
+ if (outcome === 'terminal') {
1830
+ const output = (actor?.getSnapshot() as { output?: unknown } | undefined)
1831
+ ?.output;
1832
+ if (output !== undefined) {
1833
+ return {
1834
+ outcome,
1835
+ state,
1836
+ output: snapshotJsonValue(output, 'terminal playbook output'),
1837
+ };
1838
+ }
1839
+ return { outcome, state };
1840
+ }
1841
+ const failure =
1842
+ error ??
1843
+ (outcome === 'failed'
1844
+ ? (actor?.getSnapshot() as { context?: { lastError?: unknown } })
1845
+ ?.context?.lastError
1846
+ : outcome === 'aborted'
1847
+ ? activeSignal?.reason
1848
+ : undefined);
1849
+ return {
1850
+ outcome,
1851
+ state,
1852
+ ...(failure !== undefined ? { error: normalizeError(failure) } : {}),
1853
+ };
1854
+ }
1855
+
1856
+ function settledOutcome(signal: AbortSignal): BossSettlementOutcome {
1857
+ if (nestedBridge.getPendingCall()) return 'suspended';
1858
+ if (signal.aborted) return 'aborted';
1859
+ const state = currentState();
1860
+ if (state.status === 'error') {
1861
+ const actorError = (
1862
+ actor?.getSnapshot() as { error?: unknown } | undefined
1863
+ )?.error;
1864
+ throw actorError ?? new Error('CODE actor entered error status');
1865
+ }
1866
+ if (state.status === 'done') return 'terminal';
1867
+ if (state.stateId === 'failed') return 'failed';
1868
+ return 'quiescent';
1869
+ }
1870
+
1871
+ function settlementTracePayload(
1872
+ result: PlaybookRunResult,
1873
+ ): Record<string, unknown> {
1874
+ return {
1875
+ ...result,
1876
+ ...stateIdentity(result.state.stateId),
1877
+ };
1878
+ }
1879
+
1880
+ // Shared failed-start cleanup for init and restore: stop the actor,
1881
+ // abort/drain nested and host work, optionally emit one best-effort
1882
+ // session.disposed boundary, and unbind every closure field so dispose
1883
+ // stays callable. The caller rethrows its original failure. A restore
1884
+ // failure skips the disposal trace — the parked session was never
1885
+ // re-bound in this process, so its persisted snapshot stays
1886
+ // authoritative (DR-014 §2).
1887
+ async function cleanupFailedStart(
1888
+ cause: unknown,
1889
+ options: { emitDisposal: boolean },
1890
+ ): Promise<void> {
1891
+ let finalState: PlaybookState | undefined;
1892
+ if (options.emitDisposal && actor) {
1893
+ try {
1894
+ finalState = currentState();
1895
+ } catch {
1896
+ // A state that cannot even normalize has no disposal descriptor.
1897
+ }
1898
+ }
1899
+ suppressInspectionEmissions = true;
1900
+ try {
1901
+ actor?.stop();
1902
+ } catch {
1903
+ // Preserve the original startup failure.
1904
+ }
1905
+ try {
1906
+ await nestedBridge.abortPending(cause);
1907
+ } catch {
1908
+ // Preserve the original startup failure.
1909
+ }
1910
+ try {
1911
+ await judgeQueue.onIdle();
1077
1912
  await drainEmissions();
1913
+ } catch {
1914
+ // Preserve the original startup failure.
1915
+ }
1916
+ if (options.emitDisposal) {
1917
+ try {
1918
+ await emitTrace(
1919
+ 'session.disposed',
1920
+ finalState === undefined
1921
+ ? {}
1922
+ : {
1923
+ state: finalState,
1924
+ ...stateIdentity(finalState.stateId),
1925
+ },
1926
+ );
1927
+ await drainEmissions();
1928
+ } catch {
1929
+ // The session-start error remains authoritative.
1930
+ }
1931
+ }
1932
+ playerResumeTokens.clear();
1933
+ activePlayerIds.clear();
1934
+ playbookCallTurnIds.clear();
1935
+ activeEmissionCalls.clear();
1936
+ emissionQueue.clear();
1937
+ judgeQueue.clear();
1938
+ actor = undefined;
1939
+ session = undefined;
1940
+ savedPorts = undefined;
1941
+ runtimePorts = undefined;
1942
+ activeSignal = undefined;
1943
+ activeTurnId = undefined;
1944
+ controlPlaneError = undefined;
1945
+ emissionFailure = undefined;
1946
+ priorState = undefined;
1947
+ suppressInspectionEmissions = false;
1948
+ initialized = false;
1949
+ traceSequence = 0;
1950
+ turnSequence = 0;
1951
+ judgeCallSequence = 0;
1952
+ playerCallSequence = 0;
1953
+ playbookCallSequence = 0;
1954
+ }
1955
+
1956
+ const runtime = {
1957
+ async init(nextSession: PlaybookSession): Promise<void> {
1958
+ if (initialized || disposed || disposalPromise !== undefined) {
1959
+ throw new Error('createPlaybookRuntime.init: already initialized');
1960
+ }
1961
+ const boundSession = snapshotPlaybookSession(nextSession);
1962
+ initialized = true;
1963
+ let finishInitialization!: () => void;
1964
+ const initialization = new Promise<void>((resolve) => {
1965
+ finishInitialization = resolve;
1966
+ });
1967
+ initInFlight = initialization;
1968
+ const initTask = (async () => {
1969
+ session = boundSession;
1970
+ savedPorts = boundSession.ports;
1971
+ runtimePorts = createRuntimePorts(boundSession.ports);
1972
+ suppressInspectionEmissions = false;
1973
+ actor = buildActor(runtimePorts);
1974
+ await emitTrace('session.started', stateTracePayload());
1975
+ actor.start();
1976
+ await drainEmissions();
1977
+ })();
1978
+ try {
1979
+ await initTask;
1980
+ } catch (error) {
1981
+ await cleanupFailedStart(error, { emitDisposal: true });
1982
+ throw error;
1983
+ } finally {
1984
+ finishInitialization();
1985
+ if (initInFlight === initialization) initInFlight = undefined;
1986
+ }
1987
+ },
1988
+
1989
+ // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1990
+ // Defined only at a safe capture point — initialized, not disposing
1991
+ // or disposed, no active public boundary, no pending nested call,
1992
+ // and the actor quiescent with status `active`.
1993
+ exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
1994
+ if (!actor || !session || disposed || disposalPromise !== undefined) {
1995
+ return undefined;
1996
+ }
1997
+ if (activeSignal !== undefined) return undefined;
1998
+ if (nestedBridge.getPendingCall()) return undefined;
1999
+ const state = currentState();
2000
+ if (state.status !== 'active' || !state.quiescent) return undefined;
2001
+ const machine = detachPersistedMachineSnapshot(
2002
+ actor.getPersistedSnapshot(),
2003
+ );
2004
+ const context = (actor.getSnapshot() as { context?: unknown })
2005
+ .context as Record<string, unknown>;
2006
+ const pending = pendingBossQuestionFromContext(context ?? {});
2007
+ return {
2008
+ schemaVersion: 1,
2009
+ playbookId: session.playbookId,
2010
+ machine,
2011
+ playerResumeTokens: Object.fromEntries(playerResumeTokens),
2012
+ sequences: {
2013
+ trace: traceSequence,
2014
+ turn: turnSequence,
2015
+ judgeCall: judgeCallSequence,
2016
+ playerCall: playerCallSequence,
2017
+ playbookCall: playbookCallSequence,
2018
+ },
2019
+ state,
2020
+ pendingBossQuestions:
2021
+ pending === undefined
2022
+ ? []
2023
+ : [
2024
+ {
2025
+ questionId: pending.questionId,
2026
+ player: pending.player,
2027
+ question: pending.question,
2028
+ sourceItem: pending.sourceItem,
2029
+ },
2030
+ ],
2031
+ };
2032
+ },
2033
+
2034
+ // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
2035
+ // exported snapshot under the same immutable session identity.
2036
+ // Emits no `session.started`, transition trace, or human status —
2037
+ // the session already started; the next public boundary continues
2038
+ // the contiguous trace sequence.
2039
+ async restore(
2040
+ nextSession: PlaybookSession,
2041
+ snapshot: PlaybookRuntimeSnapshot,
2042
+ ): Promise<void> {
2043
+ if (initialized || disposed || disposalPromise !== undefined) {
2044
+ throw new Error('createPlaybookRuntime.restore: already initialized');
2045
+ }
2046
+ const boundSession = snapshotPlaybookSession(nextSession);
2047
+ const boundSnapshot = assertPlaybookRuntimeSnapshot(
2048
+ snapshot,
2049
+ boundSession.playbookId,
2050
+ );
2051
+ initialized = true;
2052
+ let finishInitialization!: () => void;
2053
+ const initialization = new Promise<void>((resolve) => {
2054
+ finishInitialization = resolve;
2055
+ });
2056
+ initInFlight = initialization;
2057
+ const initTask = (async () => {
2058
+ session = boundSession;
2059
+ savedPorts = boundSession.ports;
2060
+ runtimePorts = createRuntimePorts(boundSession.ports);
2061
+ traceSequence = boundSnapshot.sequences.trace;
2062
+ turnSequence = boundSnapshot.sequences.turn;
2063
+ judgeCallSequence = boundSnapshot.sequences.judgeCall;
2064
+ playerCallSequence = boundSnapshot.sequences.playerCall;
2065
+ playbookCallSequence = boundSnapshot.sequences.playbookCall;
2066
+ playerResumeTokens.clear();
2067
+ for (const [playerId, token] of Object.entries(
2068
+ boundSnapshot.playerResumeTokens,
2069
+ )) {
2070
+ playerResumeTokens.set(playerId, token);
2071
+ }
2072
+ suppressInspectionEmissions = true;
2073
+ actor = buildActor(runtimePorts, boundSnapshot.machine);
2074
+ actor.start();
2075
+ const restoredState = currentState();
2076
+ if (restoredState.status !== 'active') {
2077
+ throw new Error(
2078
+ `createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`,
2079
+ );
2080
+ }
2081
+ suppressInspectionEmissions = false;
2082
+ priorState = restoredState;
2083
+ await drainEmissions();
2084
+ })();
2085
+ try {
2086
+ await initTask;
2087
+ } catch (error) {
2088
+ await cleanupFailedStart(error, { emitDisposal: false });
2089
+ throw error;
2090
+ } finally {
2091
+ finishInitialization();
2092
+ if (initInFlight === initialization) initInFlight = undefined;
2093
+ }
1078
2094
  },
1079
2095
 
1080
2096
  async handleBossInput({
@@ -1083,68 +2099,249 @@ export default function createPlaybookRuntime(
1083
2099
  }: {
1084
2100
  text: string;
1085
2101
  signal: AbortSignal;
1086
- }): Promise<void> {
2102
+ }): Promise<PlaybookRunResult> {
1087
2103
  if (!actor || !savedPorts) {
1088
2104
  throw new Error(
1089
2105
  'createPlaybookRuntime.handleBossInput: init must be called first',
1090
2106
  );
1091
2107
  }
2108
+ if (disposed || disposalPromise !== undefined) {
2109
+ throw new Error(
2110
+ 'createPlaybookRuntime.handleBossInput: runtime is disposing or disposed',
2111
+ );
2112
+ }
2113
+ if (activeSignal !== undefined) {
2114
+ throw new Error(
2115
+ 'createPlaybookRuntime.handleBossInput: another runtime turn is active',
2116
+ );
2117
+ }
2118
+ const turnId = ++turnSequence;
2119
+ activeTurnId = turnId;
1092
2120
  activeSignal = signal;
2121
+ controlPlaneError = undefined;
2122
+ let result: PlaybookRunResult | undefined;
2123
+ let operationError: unknown;
1093
2124
  try {
2125
+ await emitTrace('boss.input.received', { text }, { turnId });
1094
2126
  // 1. Classify non-empty text into an FSM event through the judge.
1095
2127
  const event = await classifyBossText(
1096
2128
  text,
1097
- savedPorts,
2129
+ runtimePorts!,
1098
2130
  signal,
1099
2131
  actor.getSnapshot(),
2132
+ boundary,
1100
2133
  );
1101
2134
  // Empty input, no-action classifier output, or invalid classifier
1102
2135
  // output — nothing to send.
1103
2136
  if (event === undefined) {
1104
- await drainEmissions();
1105
- return;
2137
+ result = runResultFor('no-action');
2138
+ } else {
2139
+ // 2. Captain-pane classification line (PBRT-14): the bare
2140
+ // FSM event type, emitted before the FSM advances.
2141
+ await runtimePorts!.emitStatus(formatClassification(event.type));
2142
+ // 3. A final actor cannot accept new events; reconstruct only after
2143
+ // classification produced a real event.
2144
+ if (actor.getSnapshot().status === 'done') {
2145
+ actor.stop();
2146
+ actor = buildActor(runtimePorts!);
2147
+ actor.start();
2148
+ }
2149
+ actor.send(event);
2150
+ await waitForPlaybookQuiescence(actor, {
2151
+ pendingCalls: nestedBridge,
2152
+ });
2153
+ if (controlPlaneError !== undefined) throw controlPlaneError;
2154
+ result = runResultFor(settledOutcome(signal));
1106
2155
  }
1107
- // 2. Captain-pane classification line (PBRT-14): the bare
1108
- // FSM event type, emitted before the FSM advances so the
1109
- // host can render it as captain speech (e.g.,
1110
- // `captain> START_CODING`). Enqueued so it interleaves
1111
- // cleanly with the inspect-driven transition emissions.
1112
- const echoPorts = savedPorts;
1113
- enqueueEmit(() =>
1114
- echoPorts.emitStatus(formatClassification(event.type)),
2156
+ } catch (error) {
2157
+ operationError = error;
2158
+ }
2159
+
2160
+ let drainError: unknown;
2161
+ try {
2162
+ await drainEmissions();
2163
+ } catch (error) {
2164
+ drainError = error;
2165
+ }
2166
+ const latchedControlError = controlPlaneError;
2167
+ const primaryError = latchedControlError ?? drainError ?? operationError;
2168
+ const abortError =
2169
+ latchedControlError === undefined &&
2170
+ drainError === undefined &&
2171
+ operationError !== undefined &&
2172
+ isAbortFailure(operationError, signal);
2173
+ const settlementResult =
2174
+ primaryError === undefined
2175
+ ? (result ?? runResultFor('no-action'))
2176
+ : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
2177
+
2178
+ let settlementEmissionError: unknown;
2179
+ try {
2180
+ await emitTrace(
2181
+ 'boss.input.settled',
2182
+ settlementTracePayload(settlementResult),
2183
+ { turnId },
1115
2184
  );
1116
- // 3. final state ('done') cannot accept new events — dispose
1117
- // and reconstruct per DR-004 §5.
1118
- if (actor.getSnapshot().status === 'done') {
1119
- actor.stop();
1120
- actor = buildActor(savedPorts);
1121
- actor.start();
1122
- }
1123
- // 4. Send the event.
1124
- actor.send(event);
1125
- // 5. Drive to quiescence. On signal-abort we take no FSM
1126
- // action: the captain bridge's awaited callPlayer rejects
1127
- // naturally, the bridge throws, XState routes through
1128
- // onError → #failed, and this loop sees the quiescent
1129
- // snapshot and returns (DR-004 §8 natural rejection).
1130
- await driveToQuiescence(actor);
1131
- // Drain transition emissions before returning so the Boss
1132
- // sees the final status line for this turn.
2185
+ } catch (error) {
2186
+ settlementEmissionError = error;
2187
+ }
2188
+ try {
1133
2189
  await drainEmissions();
1134
- } finally {
1135
- activeSignal = undefined;
2190
+ } catch (error) {
2191
+ settlementEmissionError ??= error;
2192
+ }
2193
+ const failure =
2194
+ controlPlaneError ??
2195
+ latchedControlError ??
2196
+ drainError ??
2197
+ (abortError
2198
+ ? (settlementEmissionError ?? operationError)
2199
+ : (operationError ?? settlementEmissionError));
2200
+ activeSignal = undefined;
2201
+ activeTurnId = undefined;
2202
+ controlPlaneError = undefined;
2203
+
2204
+ if (
2205
+ failure !== undefined &&
2206
+ !(abortError && settlementEmissionError === undefined)
2207
+ ) {
2208
+ throw failure;
1136
2209
  }
2210
+ return settlementResult;
1137
2211
  },
1138
2212
 
1139
- async dispose(): Promise<void> {
1140
- if (actor) {
1141
- actor.stop();
1142
- actor = undefined;
2213
+ async resumePlaybookCall(input: {
2214
+ callId: string;
2215
+ result: PlaybookCallResult;
2216
+ signal: AbortSignal;
2217
+ }): Promise<PlaybookRunResult> {
2218
+ if (!actor || !savedPorts) {
2219
+ throw new Error(
2220
+ 'createPlaybookRuntime.resumePlaybookCall: init must be called first',
2221
+ );
1143
2222
  }
1144
- // Drain any in-flight emissions per slc/link.md §Session
1145
- // lifecycle ("stop the actor and drain pending port emissions").
1146
- await drainEmissions();
1147
- savedPorts = undefined;
2223
+ if (disposed || disposalPromise !== undefined) {
2224
+ throw new Error(
2225
+ 'createPlaybookRuntime.resumePlaybookCall: runtime is disposing or disposed',
2226
+ );
2227
+ }
2228
+ if (activeSignal !== undefined) {
2229
+ throw new Error(
2230
+ 'createPlaybookRuntime.resumePlaybookCall: another runtime turn is active',
2231
+ );
2232
+ }
2233
+ activeTurnId = playbookCallTurnIds.get(input.callId);
2234
+ activeSignal = input.signal;
2235
+ controlPlaneError = undefined;
2236
+ let result: PlaybookRunResult | undefined;
2237
+ let operationError: unknown;
2238
+ try {
2239
+ await nestedBridge.resume(input);
2240
+ } catch (error) {
2241
+ operationError = error;
2242
+ }
2243
+ try {
2244
+ await waitForPlaybookQuiescence(actor, {
2245
+ pendingCalls: nestedBridge,
2246
+ });
2247
+ result = runResultFor(settledOutcome(input.signal));
2248
+ } catch (error) {
2249
+ operationError ??= error;
2250
+ }
2251
+ let drainError: unknown;
2252
+ try {
2253
+ await drainEmissions();
2254
+ } catch (error) {
2255
+ drainError = error;
2256
+ }
2257
+ const failure = controlPlaneError ?? drainError ?? operationError;
2258
+ activeSignal = undefined;
2259
+ activeTurnId = undefined;
2260
+ controlPlaneError = undefined;
2261
+ if (failure !== undefined) throw failure;
2262
+ if (result === undefined) {
2263
+ throw new Error('playbook resume produced no runtime result');
2264
+ }
2265
+ return result;
2266
+ },
2267
+
2268
+ dispose(): Promise<void> {
2269
+ if (disposalPromise !== undefined) return disposalPromise;
2270
+ if (disposed) return Promise.resolve();
2271
+ if (activeSignal !== undefined) {
2272
+ return Promise.reject(
2273
+ new Error(
2274
+ 'createPlaybookRuntime.dispose: cannot dispose during an active runtime boundary',
2275
+ ),
2276
+ );
2277
+ }
2278
+ const task = (async (): Promise<void> => {
2279
+ const failures: unknown[] = [];
2280
+ try {
2281
+ if (initInFlight !== undefined) {
2282
+ try {
2283
+ await initInFlight;
2284
+ } catch {
2285
+ // Dispose still releases whatever an unsuccessful init bound.
2286
+ }
2287
+ }
2288
+ const finalState = actor ? currentState() : undefined;
2289
+ // Stop the root before settling a suspended child. Its rejection
2290
+ // must not re-enter CODE and start fresh work during disposal.
2291
+ if (actor) actor.stop();
2292
+ try {
2293
+ await nestedBridge.dispose();
2294
+ } catch (error) {
2295
+ failures.push(error);
2296
+ }
2297
+ try {
2298
+ await drainEmissions();
2299
+ } catch (error) {
2300
+ failures.push(error);
2301
+ }
2302
+ if (session !== undefined) {
2303
+ try {
2304
+ await emitTrace(
2305
+ 'session.disposed',
2306
+ finalState === undefined
2307
+ ? {}
2308
+ : {
2309
+ state: finalState,
2310
+ ...stateIdentity(finalState.stateId),
2311
+ },
2312
+ );
2313
+ await drainEmissions();
2314
+ } catch (error) {
2315
+ failures.push(error);
2316
+ }
2317
+ }
2318
+ } finally {
2319
+ playerResumeTokens.clear();
2320
+ activePlayerIds.clear();
2321
+ playbookCallTurnIds.clear();
2322
+ activeEmissionCalls.clear();
2323
+ emissionQueue.clear();
2324
+ judgeQueue.clear();
2325
+ actor = undefined;
2326
+ activeSignal = undefined;
2327
+ activeTurnId = undefined;
2328
+ controlPlaneError = undefined;
2329
+ emissionFailure = undefined;
2330
+ savedPorts = undefined;
2331
+ runtimePorts = undefined;
2332
+ session = undefined;
2333
+ disposed = true;
2334
+ }
2335
+ if (failures.length === 1) throw failures[0];
2336
+ if (failures.length > 1) {
2337
+ throw new AggregateError(
2338
+ failures,
2339
+ 'playbook runtime disposal failed',
2340
+ );
2341
+ }
2342
+ })();
2343
+ disposalPromise = task;
2344
+ return task;
1148
2345
  },
1149
2346
 
1150
2347
  // @internal — test-only escape hatch for inspecting the
@@ -1156,28 +2353,12 @@ export default function createPlaybookRuntime(
1156
2353
  _getActor() {
1157
2354
  return actor;
1158
2355
  },
2356
+ _getBoundary() {
2357
+ return boundary;
2358
+ },
2359
+ _getNestedBridge() {
2360
+ return nestedBridge;
2361
+ },
1159
2362
  };
1160
2363
  return runtime as PlaybookRuntime;
1161
2364
  }
1162
-
1163
- function driveToQuiescence(
1164
- actor: ReturnType<typeof createActor>,
1165
- ): Promise<void> {
1166
- return new Promise<void>((resolve) => {
1167
- if (isQuiescent(actor.getSnapshot())) {
1168
- resolve();
1169
- return;
1170
- }
1171
- const sub = actor.subscribe((snap) => {
1172
- if (isQuiescent(snap)) {
1173
- sub.unsubscribe();
1174
- resolve();
1175
- }
1176
- });
1177
- });
1178
- }
1179
-
1180
- function isQuiescent(snap: { value: unknown }): boolean {
1181
- const v = snap.value;
1182
- return typeof v === 'string' && QUIESCENT_STATES.has(v);
1183
- }