@sublang/playbook 0.8.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 (55) hide show
  1. package/README.md +243 -193
  2. package/package.json +52 -17
  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 +580 -0
  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 +470 -182
  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 +1098 -202
  22. package/reference/sdlc/code.playbook/code.playbook.ts +1440 -258
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +17 -5
  24. package/reference/sdlc/code.playbook/code.registry.js +49 -34
  25. package/reference/sdlc/code.playbook/code.registry.ts +75 -41
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +16 -8
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1005 -240
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1310 -301
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +68 -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
  49. package/reference/sdlc/code.playbook/bin/playbook-code.js +0 -487
  50. package/reference/sdlc/code.playbook/code.tmux-play.d.ts +0 -4
  51. package/reference/sdlc/code.playbook/code.tmux-play.js +0 -11
  52. package/reference/sdlc/code.playbook/code.tmux-play.ts +0 -29
  53. package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +0 -72
  54. package/reference/sdlc/code.playbook/tmux-play.config.yaml +0 -55
  55. package/reference/sdlc/code.playbook/tmux-play.production.config.yaml +0 -38
@@ -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 &&
@@ -177,8 +282,9 @@ function composePlayerPrompt(input: CaptainInput): string {
177
282
  // Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
178
283
  // Committer (= Coder | Reviewer per code.gears.md) resolves to the
179
284
  // configured alias when present: `input.committerPlayer`, the
180
- // validated `captain.options.code.committer` (PBRT-8 / PBRT-30),
181
- // already a baked player id ('coder' / 'reviewer'). Absent a
285
+ // validated `captain.options.playbooks.code.options.committer`
286
+ // (PBRT-8 / PBRT-30), already a baked player id ('coder' / 'reviewer').
287
+ // Absent a
182
288
  // configured alias it falls back to the DR-004 §2 baked binding by
183
289
  // populated <playerName>Player field: prefer `coderPlayer` (CODE-18
184
290
  // wires only coderPlayer; CODE-19 wires both so coderPlayer still wins
@@ -188,7 +294,7 @@ function composePlayerPrompt(input: CaptainInput): string {
188
294
  // set. The alias selects only the host pane; it is not a PBRT-4
189
295
  // identity string, so it leaves <coder-llm> / <reviewer-llm>
190
296
  // untouched and `input.player` stays `Committer` (PLAYBOOK-3).
191
- function resolvePlayerId(input: CaptainInput): string {
297
+ function resolvePlayerId(input: PlayerInput): string {
192
298
  switch (input.player) {
193
299
  case 'Coder':
194
300
  return 'coder';
@@ -201,13 +307,28 @@ function resolvePlayerId(input: CaptainInput): string {
201
307
  return 'coder';
202
308
  default: {
203
309
  const exhaustive: never = input.player;
204
- throw new Error(
205
- `resolvePlayerId: unknown player ${String(exhaustive)}`,
206
- );
310
+ throw new Error(`resolvePlayerId: unknown player ${String(exhaustive)}`);
207
311
  }
208
312
  }
209
313
  }
210
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
+
211
332
  // LLM judge — DR-004 §4. Builds a prompt that lists each declared
212
333
  // outcome verbatim, asks ports.callJudge for a JSON
213
334
  // `{ guard, …payloadFields }` response, and returns the parsed
@@ -215,13 +336,21 @@ function resolvePlayerId(input: CaptainInput): string {
215
336
  // Adjudicator failures (malformed JSON, missing/unknown guard) are
216
337
  // control-plane errors and propagate via throw per slc/link.md.
217
338
  async function adjudicate(
218
- input: CaptainInput,
339
+ input: PlayerInput,
219
340
  finalText: string,
220
341
  ports: PlaybookPorts,
221
342
  signal: AbortSignal,
222
- ): Promise<CaptainOutput> {
343
+ boundary?: RuntimeBoundaryCalls,
344
+ ): Promise<PlayerOutput> {
223
345
  const prompt = buildJudgePrompt(input, finalText);
224
- 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);
225
354
  const parsed = parseJudgeJson(raw);
226
355
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
227
356
  throw new Error('adjudicate: judge response is not a JSON object');
@@ -263,7 +392,7 @@ async function adjudicate(
263
392
  );
264
393
  }
265
394
  }
266
- return obj as CaptainOutput;
395
+ return obj as PlayerOutput;
267
396
  }
268
397
 
269
398
  function extractRequiredFields(description: string): string[] {
@@ -275,7 +404,7 @@ function extractRequiredFields(description: string): string[] {
275
404
  return fields;
276
405
  }
277
406
 
278
- function buildJudgePrompt(input: CaptainInput, finalText: string): string {
407
+ function buildJudgePrompt(input: PlayerInput, finalText: string): string {
279
408
  const lines: string[] = [];
280
409
  lines.push(`The ${input.player} just produced this output:`);
281
410
  lines.push('');
@@ -443,11 +572,12 @@ async function classifyBossText(
443
572
  ports: PlaybookPorts,
444
573
  signal: AbortSignal,
445
574
  snapshotOrState?: unknown,
575
+ boundary?: RuntimeBoundaryCalls,
446
576
  ): Promise<CodingEvent | undefined> {
447
577
  const trimmed = text.trim();
448
578
  if (trimmed === '') return undefined;
449
579
 
450
- return classifyWithLlm(text, ports, signal, snapshotOrState);
580
+ return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
451
581
  }
452
582
 
453
583
  // JumpableStateId is internal to code.fsm.ts (not exported), so
@@ -468,10 +598,19 @@ async function classifyWithLlm(
468
598
  ports: PlaybookPorts,
469
599
  signal: AbortSignal,
470
600
  snapshotOrState?: unknown,
601
+ boundary?: RuntimeBoundaryCalls,
471
602
  ): Promise<CodingEvent | undefined> {
472
603
  const state = classifierState(snapshotOrState);
473
604
  const prompt = buildClassifierPrompt(text, state);
474
- 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);
475
614
  let parsed: unknown;
476
615
  try {
477
616
  parsed = parseJudgeJson(raw);
@@ -561,7 +700,36 @@ async function classifyWithLlm(
561
700
  await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
562
701
  return undefined;
563
702
  }
564
- 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
+ };
565
733
  }
566
734
  default:
567
735
  await ports.emitStatus(
@@ -601,7 +769,9 @@ function classifierState(snapshotOrState: unknown): ClassifierState {
601
769
 
602
770
  function buildClassifierPrompt(text: string, state: ClassifierState): string {
603
771
  const currentState =
604
- typeof state.value === 'string' ? state.value : 'unknown';
772
+ typeof state.value === 'string'
773
+ ? state.value
774
+ : JSON.stringify(state.value ?? null);
605
775
  const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
606
776
  const lines = [
607
777
  'Classify the following Boss message into exactly one of these events.',
@@ -612,8 +782,9 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
612
782
  ];
613
783
  if (pendingBossQuestion !== undefined) {
614
784
  lines.push(
785
+ `Pending question id: ${pendingBossQuestion.questionId}`,
786
+ `Pending asking player: ${pendingBossQuestion.player}`,
615
787
  `Pending Boss question: ${pendingBossQuestion.question}`,
616
- `Pending resume state: ${pendingBossQuestion.resumeStateId}`,
617
788
  );
618
789
  }
619
790
  lines.push(
@@ -629,27 +800,24 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
629
800
  lines.push(` - ${target.stateId}: ${target.description}`);
630
801
  }
631
802
  if (currentState === 'awaitBossReply') {
632
- 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
+ );
633
806
  } else {
634
807
  lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
635
808
  }
636
- lines.push(
637
- '',
638
- 'Boss message:',
639
- '```',
640
- text,
641
- '```',
642
- );
809
+ lines.push('', 'Boss message:', '```', text, '```');
643
810
  return lines.join('\n');
644
811
  }
645
812
 
646
- // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
647
- // 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:
648
815
  // resolve playerId, compose the player prompt, await
649
816
  // ports.callPlayer, adjudicate the finalText. PlayerResult status of
650
817
  // 'aborted' or 'error' throws so XState routes via onError → #failed
651
818
  // (the single fail-stop sink for both Captain errors and player
652
- // failures).
819
+ // failures). Captain remains the orchestrator and adjudicator; it is not
820
+ // encoded as the delegated FSM actor.
653
821
  //
654
822
  // `getActiveSignal` is the runtime's hook for flowing the Boss's
655
823
  // `handleBossInput.signal` into the host port calls — fromPromise
@@ -659,34 +827,43 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
659
827
  function captainBridge(
660
828
  ports: PlaybookPorts,
661
829
  getActiveSignal?: () => AbortSignal | undefined,
830
+ boundary?: RuntimeBoundaryCalls,
831
+ onControlPlaneError?: (error: unknown) => void,
662
832
  ) {
663
- return fromPromise<CaptainOutput, CaptainInput>(
664
- async ({ input, signal }) => {
665
- const activeSignal = getActiveSignal?.() ?? signal;
666
- const playerId = resolvePlayerId(input);
667
- const prompt = composePlayerPrompt(input);
668
- const result = await ports.callPlayer(playerId, prompt, activeSignal);
669
- if (result.status !== 'ok') {
670
- throw new Error(
671
- result.error ??
672
- `captainBridge: callPlayer status "${result.status}"`,
673
- );
674
- }
675
- if (result.finalText === undefined) {
676
- throw new Error(
677
- 'captainBridge: callPlayer returned status=ok with no finalText',
678
- );
679
- }
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 {
680
853
  const output = await adjudicate(
681
854
  input,
682
855
  result.finalText,
683
856
  ports,
684
857
  activeSignal,
858
+ boundary,
685
859
  );
686
860
  validateBossReplyOutput(input, output);
687
861
  return output;
688
- },
689
- );
862
+ } catch (error) {
863
+ onControlPlaneError?.(error);
864
+ throw error;
865
+ }
866
+ });
690
867
  }
691
868
 
692
869
  // Captain pane display — PBRT-3 / PBRT-14.
@@ -695,7 +872,7 @@ function captainBridge(
695
872
  // glance:
696
873
  // (no glyph) bare FSM event type — host renders as captain speech
697
874
  // (e.g., `captain> START_CODING`)
698
- // ⤷ captain-invoking state entry: `<Player>: <label>`
875
+ // ⤷ player-invoking state entry: `<Player>: <label>`
699
876
  // → transition guard outcome (`· field=N` tallies
700
877
  // appended); the host presenter owns any visual
701
878
  // nesting under the preceding ⤷ entry
@@ -734,7 +911,7 @@ const STATE_LABELS: Readonly<Record<string, string>> = {
734
911
  };
735
912
 
736
913
  interface StateMetadata {
737
- player: CaptainInput['player'];
914
+ player: PlayerInput['player'];
738
915
  sourceItem: string;
739
916
  label: string;
740
917
  }
@@ -745,7 +922,7 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
745
922
  const label = STATE_LABELS[s.stateId];
746
923
  if (!label) {
747
924
  throw new Error(
748
- `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}'`,
749
926
  );
750
927
  }
751
928
  const input = s.getInput({});
@@ -754,13 +931,6 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
754
931
  return m;
755
932
  })();
756
933
 
757
- const stateIdBySourceItem: ReadonlyMap<string, string> = new Map(
758
- [...stateMetadata.entries()].map(([stateId, meta]) => [
759
- meta.sourceItem,
760
- stateId,
761
- ]),
762
- );
763
-
764
934
  const registeredResumableStateIds: ReadonlySet<string> = new Set(
765
935
  enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map(
766
936
  (transition) => transition.target,
@@ -768,18 +938,16 @@ const registeredResumableStateIds: ReadonlySet<string> = new Set(
768
938
  );
769
939
 
770
940
  function validateBossReplyOutput(
771
- input: CaptainInput,
772
- output: CaptainOutput,
941
+ input: PlayerInput,
942
+ output: PlayerOutput,
773
943
  ): void {
774
944
  if (output.guard !== 'needsBossReply') return;
775
945
  if (typeof output.question !== 'string') {
776
946
  throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
777
947
  }
778
- const stateId = stateIdBySourceItem.get(input.sourceItem);
779
- if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
780
- throw new Error(
781
- BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem),
782
- );
948
+ const stateId = input.stateId;
949
+ if (!registeredResumableStateIds.has(stateId)) {
950
+ throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId));
783
951
  }
784
952
  }
785
953
 
@@ -794,12 +962,9 @@ const QUIESCENT_STATES: ReadonlySet<string> = new Set([
794
962
  // pane per PBRT-3: the readline returning to its `boss>` prompt is
795
963
  // the implicit "turn over" signal, so a `◆ ready` / `◆ done`
796
964
  // tombstone is redundant.
797
- const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set([
798
- 'ready',
799
- 'done',
800
- ]);
965
+ const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
801
966
 
802
- // Captain-pane surface (PBRT-3): every captain-invoking state plus
967
+ // Captain-pane surface (PBRT-3): every player-invoking state plus
803
968
  // the quiescent states whose entry still carries information
804
969
  // (failure with `lastError`, awaitBossReply with the pending
805
970
  // question). `ready` and `done` flow through the inspect handler
@@ -810,7 +975,12 @@ const CAPTAIN_PANE_STATES: ReadonlySet<string> = new Set([
810
975
  ...QUIESCENT_STATES,
811
976
  ]);
812
977
 
978
+ type BossReplyQuestionId = NonNullable<
979
+ Extract<CodingEvent, { type: 'BOSS_REPLY' }>['questionId']
980
+ >;
981
+
813
982
  interface PendingBossQuestionForStatus {
983
+ questionId: BossReplyQuestionId;
814
984
  resumeStateId: string;
815
985
  sourceItem: string;
816
986
  player: string;
@@ -829,6 +999,7 @@ function pendingBossQuestionFromContext(
829
999
  >;
830
1000
  if (
831
1001
  typeof candidate.resumeStateId !== 'string' ||
1002
+ typeof candidate.questionId !== 'string' ||
832
1003
  typeof candidate.sourceItem !== 'string' ||
833
1004
  typeof candidate.player !== 'string' ||
834
1005
  typeof candidate.question !== 'string'
@@ -836,6 +1007,7 @@ function pendingBossQuestionFromContext(
836
1007
  return undefined;
837
1008
  }
838
1009
  return {
1010
+ questionId: candidate.questionId as BossReplyQuestionId,
839
1011
  resumeStateId: candidate.resumeStateId,
840
1012
  sourceItem: candidate.sourceItem,
841
1013
  player: candidate.player,
@@ -863,9 +1035,7 @@ function formatAwaitBossReplyQuestion(
863
1035
  // It carries only the resume target, asking player, and source item;
864
1036
  // the former `q="<first 80 chars>"` excerpt rider is dropped now that
865
1037
  // the full question rides the captain-speech line above.
866
- function formatAwaitBossReplyMarker(
867
- context: Record<string, unknown>,
868
- ): string {
1038
+ function formatAwaitBossReplyMarker(context: Record<string, unknown>): string {
869
1039
  const pending = pendingBossQuestionFromContext(context);
870
1040
  const resumeStateId = pending?.resumeStateId ?? 'unknown';
871
1041
  const player = pending?.player ?? 'unknown';
@@ -911,9 +1081,9 @@ function stateTelemetryPayload(
911
1081
  context: Record<string, unknown>,
912
1082
  ): Record<string, unknown> {
913
1083
  const payload: Record<string, unknown> = {
914
- from,
1084
+ from: from ?? null,
915
1085
  to,
916
- event: normalizeEventForTelemetry(event),
1086
+ event: normalizeEventForTelemetry(event) ?? null,
917
1087
  };
918
1088
  if (to === 'awaitBossReply') {
919
1089
  const pendingBossQuestion = pendingBossQuestionFromContext(context);
@@ -930,6 +1100,32 @@ function stateTelemetryPayload(
930
1100
  return payload;
931
1101
  }
932
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
+
933
1129
  // Internal export surface for tests. Not part of the stable public API;
934
1130
  // the leading underscore signals "subject to change." Each member is
935
1131
  // referenced here so `noUnusedLocals` stays clean while later tasks
@@ -955,125 +1151,946 @@ export const _internal = {
955
1151
  VERBATIM_PAYLOAD_FIELDS,
956
1152
  };
957
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
+
958
1167
  export default function createPlaybookRuntime(
959
1168
  options: CodePlaybookOptions,
960
1169
  ): PlaybookRuntime {
1170
+ const boundOptions = snapshotCodePlaybookOptions(options);
961
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;
962
1177
  let savedPorts: PlaybookPorts | undefined;
1178
+ let runtimePorts: PlaybookPorts | undefined;
963
1179
  // The Boss's per-turn AbortSignal, surfaced to captainBridge so
964
1180
  // ports.callPlayer / callJudge see the right cancellation source.
965
1181
  // null between turns; set by handleBossInput.
966
1182
  let activeSignal: AbortSignal | undefined;
1183
+ let activeTurnId: number | undefined;
1184
+ let controlPlaneError: unknown;
967
1185
  // Previous root-machine state for the inspect-driven telemetry /
968
1186
  // status emitter. undefined before the first inspect firing.
969
- let priorState: unknown;
970
-
971
- // Emission queue. slc/link.md says emissions "shall be ordered,
972
- // awaited, and never-dropped"; subscribe/inspect callbacks are
973
- // synchronous and can't await, so each emit is enqueued and a
974
- // single drainer processes them sequentially.
975
- const emitQueue: Array<() => Promise<void>> = [];
976
- let drainer: Promise<void> | undefined;
977
-
978
- function enqueueEmit(fn: () => Promise<void>): void {
979
- emitQueue.push(fn);
980
- if (!drainer) {
981
- drainer = (async () => {
982
- 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;
983
1453
  try {
984
- 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
+ );
985
1466
  } catch {
986
- // Suppress host-side emission errors; the control plane
987
- // surfaces real failures via handleBossInput throws.
1467
+ // The original non-abort port rejection remains authoritative.
988
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;
989
1472
  }
990
- drainer = undefined;
991
- })();
992
- }
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 };
993
1654
  }
994
1655
 
995
- function drainEmissions(): Promise<void> {
996
- 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;
997
1709
  }
998
1710
 
999
1711
  function buildActor(
1000
1712
  ports: PlaybookPorts,
1713
+ machineSnapshot?: JsonValue,
1001
1714
  ): ReturnType<typeof createActor> {
1002
1715
  priorState = undefined;
1003
- return createActor(
1716
+ let builtActor: ReturnType<typeof createActor>;
1717
+ builtActor = createActor(
1004
1718
  codingMachine.provide({
1005
- 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
+ },
1006
1729
  }),
1007
1730
  {
1008
- input: options,
1009
- inspect: (inspectionEvent) => {
1010
- if (inspectionEvent.type !== '@xstate.snapshot') return;
1011
- const snap = inspectionEvent.snapshot as {
1012
- value?: unknown;
1013
- context?: Record<string, unknown>;
1014
- };
1015
- // Filter out captain sub-actor (fromPromise) snapshots —
1016
- // only the root codingMachine snapshot has a string value.
1017
- if (typeof snap.value !== 'string') return;
1018
- const to = snap.value;
1019
- if (priorState === to) return;
1020
- const from = priorState;
1021
- priorState = to;
1022
- // Telemetry on every transition (PBRT-14).
1023
- const context = snap.context ?? {};
1024
- enqueueEmit(() =>
1025
- ports.emitTelemetry({
1026
- topic: 'playbook.fsm.state',
1027
- payload: stateTelemetryPayload(
1028
- from,
1029
- to,
1030
- inspectionEvent.event,
1031
- context,
1032
- ),
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
+ >,
1033
1740
  }),
1034
- );
1035
- // Captain pane (PBRT-3 / PBRT-14): show the transition
1036
- // guard first (when this is an actor-done transition with
1037
- // a known guard), then the new state entry, then any
1038
- // context riders the entering state cares about. Terminal
1039
- // entry to `failed` carries `lastError` as the data arg.
1040
- if (!CAPTAIN_PANE_STATES.has(to)) return;
1041
- const transitionLine = formatTransition(inspectionEvent.event);
1042
- if (transitionLine !== undefined) {
1043
- enqueueEmit(() => ports.emitStatus(transitionLine));
1044
- }
1045
- // awaitBossReply surfaces two lines per PBRT-3 / PBRT-14: the
1046
- // full player question as captain speech, then the rider-less
1047
- // routing marker. The full-question telemetry rides
1048
- // stateTelemetryPayload above.
1049
- if (to === 'awaitBossReply') {
1050
- const questionLine = formatAwaitBossReplyQuestion(context);
1051
- const markerLine = formatAwaitBossReplyMarker(context);
1052
- enqueueEmit(() => ports.emitStatus(questionLine));
1053
- enqueueEmit(() => ports.emitStatus(markerLine));
1054
- return;
1055
- }
1056
- const entryLine = formatStateEntry(to);
1057
- if (entryLine === undefined) return;
1058
- if (to === 'failed') {
1059
- const lastError = (snap.context as { lastError?: unknown })
1060
- ?.lastError;
1061
- const data = { lastError: normalizeErrorCompact(lastError) };
1062
- enqueueEmit(() => ports.emitStatus(entryLine, data));
1063
- } else {
1064
- 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);
1065
1807
  }
1066
1808
  },
1067
1809
  },
1068
1810
  );
1811
+ return builtActor;
1069
1812
  }
1070
1813
 
1071
- const runtime = {
1072
- async init(ports: PlaybookPorts): Promise<void> {
1073
- savedPorts = ports;
1074
- actor = buildActor(ports);
1075
- 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();
1076
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
+ }
1077
2094
  },
1078
2095
 
1079
2096
  async handleBossInput({
@@ -1082,68 +2099,249 @@ export default function createPlaybookRuntime(
1082
2099
  }: {
1083
2100
  text: string;
1084
2101
  signal: AbortSignal;
1085
- }): Promise<void> {
2102
+ }): Promise<PlaybookRunResult> {
1086
2103
  if (!actor || !savedPorts) {
1087
2104
  throw new Error(
1088
2105
  'createPlaybookRuntime.handleBossInput: init must be called first',
1089
2106
  );
1090
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;
1091
2120
  activeSignal = signal;
2121
+ controlPlaneError = undefined;
2122
+ let result: PlaybookRunResult | undefined;
2123
+ let operationError: unknown;
1092
2124
  try {
2125
+ await emitTrace('boss.input.received', { text }, { turnId });
1093
2126
  // 1. Classify non-empty text into an FSM event through the judge.
1094
2127
  const event = await classifyBossText(
1095
2128
  text,
1096
- savedPorts,
2129
+ runtimePorts!,
1097
2130
  signal,
1098
2131
  actor.getSnapshot(),
2132
+ boundary,
1099
2133
  );
1100
2134
  // Empty input, no-action classifier output, or invalid classifier
1101
2135
  // output — nothing to send.
1102
2136
  if (event === undefined) {
1103
- await drainEmissions();
1104
- 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));
1105
2155
  }
1106
- // 2. Captain-pane classification line (PBRT-14): the bare
1107
- // FSM event type, emitted before the FSM advances so the
1108
- // host can render it as captain speech (e.g.,
1109
- // `captain> START_CODING`). Enqueued so it interleaves
1110
- // cleanly with the inspect-driven transition emissions.
1111
- const echoPorts = savedPorts;
1112
- enqueueEmit(() =>
1113
- 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 },
1114
2184
  );
1115
- // 3. final state ('done') cannot accept new events — dispose
1116
- // and reconstruct per DR-004 §5.
1117
- if (actor.getSnapshot().status === 'done') {
1118
- actor.stop();
1119
- actor = buildActor(savedPorts);
1120
- actor.start();
1121
- }
1122
- // 4. Send the event.
1123
- actor.send(event);
1124
- // 5. Drive to quiescence. On signal-abort we take no FSM
1125
- // action: the captain bridge's awaited callPlayer rejects
1126
- // naturally, the bridge throws, XState routes through
1127
- // onError → #failed, and this loop sees the quiescent
1128
- // snapshot and returns (DR-004 §8 natural rejection).
1129
- await driveToQuiescence(actor);
1130
- // Drain transition emissions before returning so the Boss
1131
- // sees the final status line for this turn.
2185
+ } catch (error) {
2186
+ settlementEmissionError = error;
2187
+ }
2188
+ try {
1132
2189
  await drainEmissions();
1133
- } finally {
1134
- 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;
1135
2209
  }
2210
+ return settlementResult;
1136
2211
  },
1137
2212
 
1138
- async dispose(): Promise<void> {
1139
- if (actor) {
1140
- actor.stop();
1141
- 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
+ );
1142
2222
  }
1143
- // Drain any in-flight emissions per slc/link.md §Session
1144
- // lifecycle ("stop the actor and drain pending port emissions").
1145
- await drainEmissions();
1146
- 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;
1147
2345
  },
1148
2346
 
1149
2347
  // @internal — test-only escape hatch for inspecting the
@@ -1155,28 +2353,12 @@ export default function createPlaybookRuntime(
1155
2353
  _getActor() {
1156
2354
  return actor;
1157
2355
  },
2356
+ _getBoundary() {
2357
+ return boundary;
2358
+ },
2359
+ _getNestedBridge() {
2360
+ return nestedBridge;
2361
+ },
1158
2362
  };
1159
2363
  return runtime as PlaybookRuntime;
1160
2364
  }
1161
-
1162
- function driveToQuiescence(
1163
- actor: ReturnType<typeof createActor>,
1164
- ): Promise<void> {
1165
- return new Promise<void>((resolve) => {
1166
- if (isQuiescent(actor.getSnapshot())) {
1167
- resolve();
1168
- return;
1169
- }
1170
- const sub = actor.subscribe((snap) => {
1171
- if (isQuiescent(snap)) {
1172
- sub.unsubscribe();
1173
- resolve();
1174
- }
1175
- });
1176
- });
1177
- }
1178
-
1179
- function isQuiescent(snap: { value: unknown }): boolean {
1180
- const v = snap.value;
1181
- return typeof v === 'string' && QUIESCENT_STATES.has(v);
1182
- }