@sublang/playbook 3.1.0 → 5.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 (31) hide show
  1. package/README.md +64 -99
  2. package/docs/assets/playbook-venn.svg +13 -0
  3. package/docs/cli.md +83 -9
  4. package/docs/configuration.md +5 -3
  5. package/package.json +7 -4
  6. package/reference/sdlc/captain.md +70 -83
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
  10. package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +464 -968
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +696 -993
  14. package/reference/sdlc/code.playbook/bin/adapter-sdk.js +247 -0
  15. package/reference/sdlc/code.playbook/bin/playbook.js +54 -9
  16. package/reference/sdlc/code.playbook/bin/run.js +97 -0
  17. package/reference/sdlc/code.playbook/code.playbook.js +17 -0
  18. package/reference/sdlc/code.playbook/code.playbook.ts +17 -0
  19. package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
  20. package/reference/sdlc/code.playbook/playbook-captain.js +1784 -215
  21. package/reference/sdlc/code.playbook/playbook-captain.ts +2293 -330
  22. package/reference/sdlc/code.playbook/playbook.config.template.yaml +7 -0
  23. package/reference/sdlc/discuss.playbook/discuss.playbook.js +41 -9
  24. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +42 -9
  25. package/slc/gears2fsm.md +54 -2
  26. package/slc/link.md +293 -25
  27. package/src/runtime.d.ts +29 -1
  28. package/src/runtime.ts +47 -0
  29. package/src/xstate-playbook-runtime.d.ts +97 -5
  30. package/src/xstate-playbook-runtime.js +769 -29
  31. package/src/xstate-playbook-runtime.ts +962 -34
@@ -41,6 +41,9 @@ import type {
41
41
  CaptainResult,
42
42
  JsonValue,
43
43
  PlaybookCallResult,
44
+ PlaybookControlAction,
45
+ PlaybookControlReceipt,
46
+ PlaybookControlView,
44
47
  PlaybookPorts,
45
48
  PlaybookRunResult,
46
49
  PlaybookRuntime,
@@ -124,9 +127,24 @@ export interface RuntimeBoundaryCalls {
124
127
  input: PlaybookCaptainInput,
125
128
  prompt: string,
126
129
  signal: AbortSignal,
130
+ callOptions?: XStateCaptainCallOptions,
127
131
  ): Promise<CaptainResult>;
128
132
  }
129
133
 
134
+ /**
135
+ * Presentation selection for one traced direct-Captain call
136
+ * (slc/link.md §Captain adjudication). `'visible'` (the default) is the
137
+ * workflow form: the port receives `{ visibility: 'visible', resume: false }`
138
+ * and the trace pair carries both members. `'hidden'` is the controller form
139
+ * (DR-029): the port receives `{ visibility: 'hidden', resume: false }`
140
+ * while the host's session-Captain wrapper owns the actual durable-conversation
141
+ * resume selection, so the trace pair carries `visibility: 'hidden'` and no
142
+ * `resume` member — the pinned token never enters runtime telemetry.
143
+ */
144
+ export interface XStateCaptainCallOptions {
145
+ visibility?: 'visible' | 'hidden';
146
+ }
147
+
130
148
  export interface ScheduledStatus {
131
149
  message: string;
132
150
  data?: JsonValue;
@@ -177,6 +195,37 @@ function isFsmResultFailure(error: unknown): boolean {
177
195
  );
178
196
  }
179
197
 
198
+ // ---------------------------------------------------------------------------
199
+ // DR-028: both call boundaries treat an `ok` result whose `finalText` is
200
+ // missing, empty, or whitespace-only under one empty predicate, and that
201
+ // shape earns exactly one corrective re-ask — the same composed call
202
+ // re-issued once through the same boundary — before a second such result
203
+ // follows the existing failure path. The retry marker distinguishes the
204
+ // re-askable empty-`ok` Captain failure from the never-retried non-`ok`
205
+ // statuses; it is applied only when the failure's finish trace emitted
206
+ // cleanly, because a rejecting finish sink is a control-plane error whose
207
+ // turn gets no corrective re-ask (PBRT-47).
208
+ // ---------------------------------------------------------------------------
209
+
210
+ function isEmptyFinalText(finalText: string | undefined): boolean {
211
+ return finalText === undefined || finalText.trim().length === 0;
212
+ }
213
+
214
+ const emptyOkRetryFailures = new WeakSet<object>();
215
+
216
+ function markEmptyOkRetryFailure(error: Error): Error {
217
+ emptyOkRetryFailures.add(error);
218
+ return error;
219
+ }
220
+
221
+ function isEmptyOkRetryFailure(error: unknown): boolean {
222
+ return (
223
+ typeof error === 'object' &&
224
+ error !== null &&
225
+ emptyOkRetryFailures.has(error as object)
226
+ );
227
+ }
228
+
180
229
  // ---------------------------------------------------------------------------
181
230
  // DR-022: the engine's compatibility self-report. A linked thin module
182
231
  // records the values current at link time in `spec.compat`; the factory
@@ -248,6 +297,53 @@ function assertRuntimeCompat(
248
297
  // to preserve their existing observable behavior exactly.
249
298
  // ---------------------------------------------------------------------------
250
299
 
300
+ /**
301
+ * One direct-Captain actor invocation handed to a spec's `captainStrategy`
302
+ * (slc/link.md §Captain adjudication, controller form). The engine owns
303
+ * signal combination, emission draining, trace pairing, the shared
304
+ * Captain/judge lane, and control-plane latching; the strategy owns the
305
+ * playbook-specific call pipeline — e.g. the controller's hidden decision
306
+ * call, `{ action, … }` control-JSON validation with its single corrective
307
+ * re-ask, and controller-port submission.
308
+ */
309
+ export interface XStateCaptainStrategyRun<TOptions> {
310
+ input: PlaybookCaptainInput;
311
+ /** The prompt composed by the spec's Captain composer for `input`. */
312
+ prompt: string;
313
+ /** Combined invocation-lifetime + active-boundary abort signal. */
314
+ signal: AbortSignal;
315
+ /** The immutable validated runtime options. */
316
+ options: TOptions;
317
+ /** The bound immutable playbook session identity. */
318
+ session: PlaybookSession;
319
+ /**
320
+ * One traced Captain call through the shared serialized lane; every call —
321
+ * initial or corrective — emits its own paired `captain.call.started` /
322
+ * `captain.call.finished` boundary. Throws the boundary's authoritative
323
+ * failure for non-`ok` and empty-`ok` results exactly as the default
324
+ * pipeline does.
325
+ */
326
+ callCaptain(
327
+ prompt: string,
328
+ callOptions?: XStateCaptainCallOptions,
329
+ ): Promise<CaptainResult>;
330
+ /**
331
+ * DR-028: true when `error` is the boundary's re-askable empty-`ok`
332
+ * marker; the strategy may re-issue the same composed call exactly once.
333
+ */
334
+ isEmptyOkRetry(error: unknown): boolean;
335
+ /**
336
+ * Mark `error` as a recoverable FSM-result failure: it travels the invoked
337
+ * actor's XState `onError` path without being latched as a control-plane
338
+ * error, so the machine's authored recovery arms can route it.
339
+ */
340
+ recoverableFailure<E extends Error>(error: E): E;
341
+ }
342
+
343
+ export type XStateCaptainStrategy<TOptions> = (
344
+ run: XStateCaptainStrategyRun<TOptions>,
345
+ ) => Promise<PlaybookActorOutput>;
346
+
251
347
  export interface XStatePlaybookRuntimeSpec<TOptions> {
252
348
  /** Diagnostic label used in internal invariant errors. Default 'playbook'. */
253
349
  label?: string;
@@ -276,14 +372,25 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
276
372
  * the XState machine alone.
277
373
  */
278
374
  bossEvents?: readonly XStateBossEventSpec[];
279
- /** Boss-input classifier override; default: generic parked-state classifier. */
375
+ /** Boss-input classifier override; default: generic parked-state classifier. Receives the bound validated options last so a fully deterministic controller mapping can consult host-supplied option members (slc/link.md §Boss-event mapping). */
280
376
  classifyBossText?: (
281
377
  text: string,
282
378
  ports: PlaybookPorts,
283
379
  signal: AbortSignal,
284
380
  snapshotOrState: unknown,
285
381
  boundary?: RuntimeBoundaryCalls,
382
+ options?: TOptions,
286
383
  ) => Promise<EventObject | undefined>;
384
+ /**
385
+ * Direct-Captain actor strategy override (slc/link.md §Captain
386
+ * adjudication, controller form): replaces the default visible-call +
387
+ * hidden-judge pipeline for every `captain` state of this machine. The
388
+ * engine still composes the prompt, combines signals, traces each call as
389
+ * its own pair, and latches control-plane errors; failures the strategy
390
+ * marks with `recoverableFailure` travel the actor's `onError` path as
391
+ * recoverable FSM-result failures instead.
392
+ */
393
+ captainStrategy?: XStateCaptainStrategy<TOptions>;
287
394
  /** Status line emitted after classification names an event. Default: none. */
288
395
  classificationStatus?: (event: EventObject) => string | undefined;
289
396
  /** Map a player-invoking state's input to the host player id. Default: lowercased player name. */
@@ -300,6 +407,18 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
300
407
  extractRequiredFields?: (description: string) => string[];
301
408
  /** Required fields carried verbatim from the player's finalText instead of judge JSON. Default: none. */
302
409
  verbatimPayloadFields?: ReadonlySet<string>;
410
+ /**
411
+ * DR-029 / PBRT-52: the runtime-authored ControlView context
412
+ * projection — the exact FSM context members `describe()` may expose,
413
+ * in the order the view lists them. Only this artifact knows which of
414
+ * its context members are safe and relevant for a controller prompt, so
415
+ * the engine exports what is named here and nothing else: a member the
416
+ * artifact has not named stays private, and a member added to the FSM
417
+ * later stays private until someone names it. Absent or empty: the view
418
+ * carries no context at all. `pendingBossQuestion` and `lastError` are
419
+ * surfaced first-class by the view and shall not be named here.
420
+ */
421
+ controlContextFields?: readonly string[];
303
422
  /** States that may suspend for a Boss reply. Default: targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
304
423
  resumableStateIds?: ReadonlySet<string>;
305
424
  /** Human status lines for a root transition. Default: entry lines with question/failure surfacing. */
@@ -730,8 +849,10 @@ function validateBossReplyOutput(
730
849
  // ---------------------------------------------------------------------------
731
850
  // Delegated-player actor bridge. One PromiseActorLogic the machine invokes
732
851
  // from every player-invoking state: resolve the playerId, compose the prompt,
733
- // await callPlayer, adjudicate the finalText. A non-`ok` result or missing
734
- // finalText throws so XState routes via onError to the FSM's failure sink.
852
+ // await callPlayer, adjudicate the finalText. An `ok` result with a missing,
853
+ // empty, or whitespace-only finalText earns exactly one corrective re-ask of
854
+ // the same composed call (DR-028); a non-`ok` result, or a second such empty
855
+ // result, throws so XState routes via onError to the FSM's failure sink.
735
856
  //
736
857
  // `getActiveSignal` flows the Boss's public-boundary signal into the host
737
858
  // port calls — fromPromise hands the bridge XState's actor-scoped signal,
@@ -757,17 +878,40 @@ export function createPlayerBridge(
757
878
  const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
758
879
  const playerId = spec.resolvePlayerId(input);
759
880
  const prompt = spec.composePlayerPrompt(input);
760
- const result = boundary
761
- ? await boundary.callPlayer(input, playerId, prompt, activeSignal)
762
- : await ports.callPlayer(playerId, prompt, activeSignal, {
763
- resume: false,
764
- });
881
+ const callPlayer = (resume: string | false) =>
882
+ boundary
883
+ ? boundary.callPlayer(input, playerId, prompt, activeSignal)
884
+ : ports.callPlayer(playerId, prompt, activeSignal, { resume });
885
+ let result = await callPlayer(false);
886
+ if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
887
+ // An abort that lands between the empty first result and the
888
+ // corrective call ends the turn as ordinary abort settlement with
889
+ // no second host call — aborts are never retried (DR-028 via
890
+ // DR-025's transport exclusion) — matching the direct-Captain
891
+ // boundary, whose queued corrective call re-checks the signal
892
+ // before starting.
893
+ activeSignal.throwIfAborted();
894
+ // DR-028: exactly one corrective re-ask of the same composed call
895
+ // through the same path, traced by the boundary as its own
896
+ // player-call pair. The traced boundary re-reads its token map
897
+ // (PBRT-38), so the corrective call continues the player session
898
+ // when the first result carried a resume token and starts fresh
899
+ // when it cleared one; the portless verification path mirrors that
900
+ // by carrying the first result's token.
901
+ result = await callPlayer(
902
+ typeof result.resumeToken === 'string' &&
903
+ result.resumeToken.trim().length > 0
904
+ ? result.resumeToken
905
+ : false,
906
+ );
907
+ }
765
908
  if (result.status !== 'ok') {
766
909
  throw new Error(
767
910
  result.error ?? `captainBridge: callPlayer status "${result.status}"`,
768
911
  );
769
912
  }
770
- if (result.finalText === undefined) {
913
+ const finalText = result.finalText ?? '';
914
+ if (isEmptyFinalText(finalText)) {
771
915
  throw new Error(
772
916
  'captainBridge: callPlayer returned status=ok with no finalText',
773
917
  );
@@ -776,7 +920,7 @@ export function createPlayerBridge(
776
920
  const output = await adjudicatePlayerOutput(
777
921
  spec.adjudication,
778
922
  input,
779
- result.finalText,
923
+ finalText,
780
924
  ports,
781
925
  activeSignal,
782
926
  boundary,
@@ -799,8 +943,17 @@ export function createPlayerBridge(
799
943
  // field as an undeclared extra key.
800
944
  // ---------------------------------------------------------------------------
801
945
 
802
- function buildCaptainJudgePrompt(
803
- input: PlaybookCaptainInput,
946
+ /**
947
+ * Default direct-Captain adjudicator prompt (DR-025). The single statement of
948
+ * the `{ guard, …structuralPayloadFields }` reply contract, shared with the
949
+ * compiled default Captain artifact so the wording cannot drift.
950
+ */
951
+ export function defaultBuildCaptainJudgePrompt(
952
+ input: {
953
+ readonly stateId: string;
954
+ readonly sourceItem: string;
955
+ readonly result: Readonly<Record<string, string>>;
956
+ },
804
957
  finalText: string,
805
958
  ): string {
806
959
  const lines: string[] = [];
@@ -932,6 +1085,98 @@ export function resumableStateIdsFromMachine(
932
1085
  return new Set(transitionTargets(bossReply));
933
1086
  }
934
1087
 
1088
+ // ---------------------------------------------------------------------------
1089
+ // DR-029 control surface: the FSM's explicit-state-jump event and the
1090
+ // source state descriptions that label runtime-advertised actions.
1091
+ // ---------------------------------------------------------------------------
1092
+
1093
+ /** The FSM's explicit-state-jump event type (slc/link.md §Boss-event mapping). */
1094
+ const JUMP_EVENT_TYPE = 'BOSS_INTERRUPT';
1095
+
1096
+ /**
1097
+ * Source state descriptions by state key, node id, and `meta.playbook`
1098
+ * state id, read from `machine.config`. Control actions are labeled from
1099
+ * these descriptions (DR-029); a state without one has no entry.
1100
+ */
1101
+ export function stateDescriptionsFromMachine(
1102
+ machine: AnyStateMachine,
1103
+ ): ReadonlyMap<string, string> {
1104
+ const descriptions = new Map<string, string>();
1105
+ const record = (key: unknown, description: string): void => {
1106
+ if (typeof key !== 'string' || key.length === 0) return;
1107
+ if (!descriptions.has(key)) descriptions.set(key, description);
1108
+ };
1109
+ const visit = (key: string, stateDef: unknown): void => {
1110
+ if (!isPlainObject(stateDef)) return;
1111
+ const playbook = isPlainObject(stateDef.meta)
1112
+ ? (stateDef.meta as Record<string, unknown>).playbook
1113
+ : undefined;
1114
+ const description =
1115
+ isPlainObject(playbook) && typeof playbook.description === 'string'
1116
+ ? playbook.description
1117
+ : typeof stateDef.description === 'string'
1118
+ ? stateDef.description
1119
+ : undefined;
1120
+ if (description !== undefined && description.length > 0) {
1121
+ record(key, description);
1122
+ record(stateDef.id, description);
1123
+ if (isPlainObject(playbook)) record(playbook.stateId, description);
1124
+ }
1125
+ if (isPlainObject(stateDef.states)) {
1126
+ for (const [childKey, child] of Object.entries(stateDef.states)) {
1127
+ visit(childKey, child);
1128
+ }
1129
+ }
1130
+ };
1131
+ const config = (machine as unknown as { config?: unknown }).config;
1132
+ if (isPlainObject(config) && isPlainObject(config.states)) {
1133
+ for (const [key, stateDef] of Object.entries(config.states)) {
1134
+ visit(key, stateDef);
1135
+ }
1136
+ }
1137
+ return descriptions;
1138
+ }
1139
+
1140
+ /**
1141
+ * First configured target of `eventType` from the state with `stateId`,
1142
+ * falling back to the machine root's own transitions. Used only to pick the
1143
+ * source description that labels a retry action, and only for events that
1144
+ * carry no recorded `targetId`: a guarded multi-arm list keyed on the
1145
+ * event's `targetId` (the root `BOSS_INTERRUPT` shape) resumes the recorded
1146
+ * target, not the first configured arm, so the recorded event outranks this
1147
+ * fallback.
1148
+ */
1149
+ function firstTransitionTarget(
1150
+ machine: AnyStateMachine,
1151
+ stateId: string | undefined,
1152
+ eventType: string,
1153
+ ): string | undefined {
1154
+ const config = (machine as unknown as { config?: unknown }).config;
1155
+ if (!isPlainObject(config)) return undefined;
1156
+ const candidates: unknown[] = [];
1157
+ if (stateId !== undefined && isPlainObject(config.states)) {
1158
+ const state = config.states[stateId];
1159
+ if (isPlainObject(state) && isPlainObject(state.on)) {
1160
+ candidates.push(state.on[eventType]);
1161
+ }
1162
+ }
1163
+ if (isPlainObject(config.on)) candidates.push(config.on[eventType]);
1164
+ for (const candidate of candidates) {
1165
+ if (candidate === undefined) continue;
1166
+ const targets = transitionTargets(candidate);
1167
+ if (targets.length > 0) return targets[0];
1168
+ }
1169
+ return undefined;
1170
+ }
1171
+
1172
+ function deepFreeze<T>(value: T): T {
1173
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
1174
+ Object.freeze(value);
1175
+ for (const member of Object.values(value)) deepFreeze(member);
1176
+ }
1177
+ return value;
1178
+ }
1179
+
935
1180
  // ---------------------------------------------------------------------------
936
1181
  // Default transition/status derivation.
937
1182
  // ---------------------------------------------------------------------------
@@ -1390,6 +1635,16 @@ interface TracePosition {
1390
1635
  callId?: string;
1391
1636
  }
1392
1637
 
1638
+ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
1639
+ const visit = (stateDef: unknown): boolean => {
1640
+ if (!isPlainObject(stateDef)) return false;
1641
+ if (stateDef.type === 'parallel') return true;
1642
+ if (!isPlainObject(stateDef.states)) return false;
1643
+ return Object.values(stateDef.states).some(visit);
1644
+ };
1645
+ return visit((machine as unknown as { config?: unknown }).config);
1646
+ }
1647
+
1393
1648
  /**
1394
1649
  * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
1395
1650
  * under the slc/link.md contract. The factory provides every actor kind the
@@ -1397,8 +1652,9 @@ interface TracePosition {
1397
1652
  * (literal and dynamic) — and implements the full runtime lifecycle including
1398
1653
  * the optional parked-session snapshot capability (DR-014).
1399
1654
  *
1400
- * Scope: single-region root machines (each snapshot exposes exactly one
1401
- * playbook state id). Parallel-region FSMs keep their own linked runtimes.
1655
+ * Scope: machines that declare no parallel state (each snapshot exposes
1656
+ * exactly one playbook state id). Parallel-region FSMs keep their own linked
1657
+ * runtimes.
1402
1658
  */
1403
1659
  export function createXStatePlaybookRuntime<TOptions>(
1404
1660
  machine: AnyStateMachine,
@@ -1408,9 +1664,33 @@ export function createXStatePlaybookRuntime<TOptions>(
1408
1664
  // DR-022 / PBRT-50: reject an incompatible artifact declaration before any
1409
1665
  // machine interpretation, against this loaded engine's own self-report.
1410
1666
  assertRuntimeCompat(spec.compat, label);
1667
+ if (machineDeclaresParallelState(machine)) {
1668
+ throw new Error(
1669
+ `${label} uses a parallel state; the shared runtime supports only single-region FSMs`,
1670
+ );
1671
+ }
1411
1672
  const declaredActors = collectInvokeSources(machine);
1412
1673
  const resumableStateIds =
1413
1674
  spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
1675
+ // DR-029: source state descriptions label the control actions the
1676
+ // runtime advertises through `describe()`.
1677
+ const stateDescriptions = stateDescriptionsFromMachine(machine);
1678
+ // PBRT-52: the artifact's own ControlView context projection. Nothing is
1679
+ // exported by default, so an FSM context member — including one added
1680
+ // after this artifact was linked — is private until named here. The two
1681
+ // members the view surfaces first-class are rejected at construction
1682
+ // rather than silently ignored, so an artifact cannot believe it is
1683
+ // exporting them through this list.
1684
+ const controlContextFields: readonly string[] = spec.controlContextFields
1685
+ ? [...spec.controlContextFields]
1686
+ : [];
1687
+ for (const field of controlContextFields) {
1688
+ if (field === 'pendingBossQuestion' || field === 'lastError') {
1689
+ throw new Error(
1690
+ `${label} controlContextFields must not name ${field}: the control view surfaces it first-class`,
1691
+ );
1692
+ }
1693
+ }
1414
1694
  const resolvePlayerIdSpec = spec.resolvePlayerId;
1415
1695
  const composePlayerPrompt =
1416
1696
  spec.composePlayerPrompt ??
@@ -1442,7 +1722,9 @@ export function createXStatePlaybookRuntime<TOptions>(
1442
1722
  spec.entryEvent,
1443
1723
  spec.bossEvents ?? [],
1444
1724
  );
1445
- const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
1725
+ const classifyBossText: NonNullable<
1726
+ XStatePlaybookRuntimeSpec<TOptions>['classifyBossText']
1727
+ > = spec.classifyBossText ?? derivedClassifyBossText;
1446
1728
  const normalizeTransitionEvent =
1447
1729
  spec.normalizeTransitionEvent ??
1448
1730
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
@@ -1484,6 +1766,19 @@ export function createXStatePlaybookRuntime<TOptions>(
1484
1766
  let playerCallSequence = 0;
1485
1767
  let playbookCallSequence = 0;
1486
1768
  let captainCallSequence = 0;
1769
+ let applyCallSequence = 0;
1770
+ // DR-029: the last event a public Boss boundary sent into the
1771
+ // machine — classified, deterministic entry, or Boss reply — kept with
1772
+ // its recorded payload so a failure-state retry action can replay the
1773
+ // event that drove the run into `failed`. Process-local: the schema-1
1774
+ // parked snapshot does not persist it (PBRT-50: no schema bump).
1775
+ let lastBossEvent: EventObject | undefined;
1776
+ // DR-029: process-local at-most-once `apply` execution — the accepted receipt
1777
+ // recorded for each idempotency key, returned verbatim on a repeated
1778
+ // key. A key whose call settled `rejected` or threw before reaching
1779
+ // acceptance records nothing, so a later call with that key may still
1780
+ // execute.
1781
+ const appliedReceipts = new Map<string, PlaybookControlReceipt>();
1487
1782
  const playerResumeTokens = new Map<string, string>();
1488
1783
  const activePlayerIds = new Set<string>();
1489
1784
  const playbookCallTurnIds = new Map<string, number | undefined>();
@@ -1663,13 +1958,22 @@ export function createXStatePlaybookRuntime<TOptions>(
1663
1958
  startedType:
1664
1959
  | 'player.call.started'
1665
1960
  | 'judge.call.started'
1666
- | 'captain.call.started',
1961
+ | 'captain.call.started'
1962
+ | 'apply.started',
1667
1963
  finishedType:
1668
1964
  | 'player.call.finished'
1669
1965
  | 'judge.call.finished'
1670
- | 'captain.call.finished',
1966
+ | 'captain.call.finished'
1967
+ | 'apply.finished',
1671
1968
  identity: Record<string, unknown>,
1672
1969
  position: TracePosition,
1970
+ // Base payload of the best-effort finish emitted when the start sink
1971
+ // rejects; it defaults to the payload the start carried, which the
1972
+ // player, judge, and captain pairs take as-is. The apply pair cannot:
1973
+ // its finish carries the receipt disposition and none of the
1974
+ // start-only fields, so it passes its own canonical pre-acceptance
1975
+ // base (slc/link.md §Playbook trace).
1976
+ finishIdentity: Record<string, unknown> = identity,
1673
1977
  ): Promise<void> {
1674
1978
  try {
1675
1979
  await emitTrace(startedType, identity, position);
@@ -1678,7 +1982,11 @@ export function createXStatePlaybookRuntime<TOptions>(
1678
1982
  try {
1679
1983
  await emitTrace(
1680
1984
  finishedType,
1681
- { ...identity, status: 'error', error: normalizeError(error) },
1985
+ {
1986
+ ...finishIdentity,
1987
+ status: 'error',
1988
+ error: normalizeError(error),
1989
+ },
1682
1990
  position,
1683
1991
  );
1684
1992
  } catch {
@@ -1736,6 +2044,11 @@ export function createXStatePlaybookRuntime<TOptions>(
1736
2044
 
1737
2045
  let rawResult: unknown;
1738
2046
  try {
2047
+ // An abort may land while the awaited started emission drains
2048
+ // (e.g. fired from the trace sink itself); the host call must
2049
+ // never start after abort, so settle the already-started pair
2050
+ // as `aborted` through the catch below.
2051
+ signal.throwIfAborted();
1739
2052
  rawResult = await requireHostPorts().callPlayer(
1740
2053
  playerId,
1741
2054
  prompt,
@@ -1837,6 +2150,11 @@ export function createXStatePlaybookRuntime<TOptions>(
1837
2150
  );
1838
2151
  let reply: unknown;
1839
2152
  try {
2153
+ // An abort may land while the awaited started emission drains
2154
+ // (e.g. fired from the trace sink itself); the host call must
2155
+ // never start after abort, so settle the already-started pair
2156
+ // as `aborted` through the catch below.
2157
+ signal.throwIfAborted();
1840
2158
  reply = await requireHostPorts().callJudge(prompt, signal);
1841
2159
  signal.throwIfAborted();
1842
2160
  } catch (error) {
@@ -1880,18 +2198,23 @@ export function createXStatePlaybookRuntime<TOptions>(
1880
2198
  }) as Promise<string>;
1881
2199
  },
1882
2200
 
1883
- async callCaptain(input, prompt, signal): Promise<CaptainResult> {
2201
+ async callCaptain(input, prompt, signal, callOptions): Promise<CaptainResult> {
1884
2202
  return judgeQueue.add(async () => {
1885
2203
  signal.throwIfAborted();
1886
2204
  await drainEmissions();
1887
2205
  signal.throwIfAborted();
1888
2206
  const turnId = activeTurnId;
1889
2207
  const callId = `captain-${++captainCallSequence}`;
2208
+ const visibility = callOptions?.visibility ?? 'visible';
1890
2209
  const identity = {
1891
2210
  ...stateIdentity(input.stateId),
1892
2211
  sourceItem: input.sourceItem,
1893
- visibility: 'visible' as const,
1894
- resume: false as const,
2212
+ visibility,
2213
+ // The visible workflow form owns its `resume: false` selection;
2214
+ // a hidden controller call's durable-conversation resume
2215
+ // selection is host-owned (DR-029), so its trace pair carries
2216
+ // no resume member and no token.
2217
+ ...(visibility === 'visible' ? { resume: false as const } : {}),
1895
2218
  ...(input.allowedTools === undefined
1896
2219
  ? {}
1897
2220
  : { allowedTools: [...input.allowedTools] }),
@@ -1909,8 +2232,13 @@ export function createXStatePlaybookRuntime<TOptions>(
1909
2232
  );
1910
2233
  let rawResult: unknown;
1911
2234
  try {
2235
+ // An abort may land while the awaited started emission drains
2236
+ // (e.g. fired from the trace sink itself); the host call must
2237
+ // never start after abort, so settle the already-started pair
2238
+ // as `aborted` through the catch below.
2239
+ signal.throwIfAborted();
1912
2240
  rawResult = await requireHostPorts().callCaptain(prompt, signal, {
1913
- visibility: 'visible',
2241
+ visibility,
1914
2242
  resume: false,
1915
2243
  ...(input.allowedTools !== undefined
1916
2244
  ? { allowedTools: input.allowedTools }
@@ -1947,6 +2275,7 @@ export function createXStatePlaybookRuntime<TOptions>(
1947
2275
  // authoritative for the actor's error path even when the required
1948
2276
  // finish emission fails or a coincident boundary abort lands.
1949
2277
  let resultFailure: Error | undefined;
2278
+ let emptyOkRetry = false;
1950
2279
  if (result.status !== 'ok') {
1951
2280
  resultFailure = markFsmResultFailure(
1952
2281
  new Error(
@@ -1954,12 +2283,13 @@ export function createXStatePlaybookRuntime<TOptions>(
1954
2283
  `captainActor: callCaptain status "${result.status}"`,
1955
2284
  ),
1956
2285
  );
1957
- } else if (result.finalText === undefined || result.finalText === '') {
2286
+ } else if (isEmptyFinalText(result.finalText)) {
1958
2287
  resultFailure = markFsmResultFailure(
1959
2288
  new Error(
1960
2289
  'captainActor: callCaptain returned status=ok with no finalText',
1961
2290
  ),
1962
2291
  );
2292
+ emptyOkRetry = true;
1963
2293
  }
1964
2294
  try {
1965
2295
  await emitTrace(
@@ -1981,12 +2311,17 @@ export function createXStatePlaybookRuntime<TOptions>(
1981
2311
  } catch (error) {
1982
2312
  // Keep the finish-sink failure in the emission queue for public
1983
2313
  // cleanup evidence, but do not replace an authoritative result
1984
- // failure on the invoked actor's XState onError path.
2314
+ // failure on the invoked actor's XState onError path. A failure
2315
+ // thrown here is never marked re-askable: a rejecting finish
2316
+ // sink stays a control-plane error with no corrective re-ask
2317
+ // (PBRT-47).
1985
2318
  if (resultFailure !== undefined) throw resultFailure;
1986
2319
  throw error;
1987
2320
  }
1988
2321
  if (resultFailure !== undefined) {
1989
- throw resultFailure;
2322
+ throw emptyOkRetry
2323
+ ? markEmptyOkRetryFailure(resultFailure)
2324
+ : resultFailure;
1990
2325
  }
1991
2326
  return result;
1992
2327
  }) as Promise<CaptainResult>;
@@ -2032,19 +2367,53 @@ export function createXStatePlaybookRuntime<TOptions>(
2032
2367
  try {
2033
2368
  await drainEmissions();
2034
2369
  const prompt = composeCaptainPrompt(input);
2035
- const result = await boundary.callCaptain!(input, prompt, active);
2370
+ if (spec.captainStrategy !== undefined) {
2371
+ // Controller form (slc/link.md §Captain adjudication): the
2372
+ // spec's strategy owns the call pipeline; the engine still
2373
+ // owns tracing, the shared lane, signal combination, and the
2374
+ // control-plane latch in the catch below.
2375
+ const output = await spec.captainStrategy({
2376
+ input,
2377
+ prompt,
2378
+ signal: active,
2379
+ options: boundOptions,
2380
+ session: requireSession(),
2381
+ callCaptain: (callPrompt, callOptions) =>
2382
+ boundary.callCaptain!(input, callPrompt, active, callOptions),
2383
+ isEmptyOkRetry: isEmptyOkRetryFailure,
2384
+ recoverableFailure: <E extends Error>(error: E): E => {
2385
+ markFsmResultFailure(error);
2386
+ return error;
2387
+ },
2388
+ });
2389
+ validateBossReplyOutput(input, output, resumableStateIds);
2390
+ return output;
2391
+ }
2392
+ let result: CaptainResult;
2393
+ try {
2394
+ result = await boundary.callCaptain!(input, prompt, active);
2395
+ } catch (error) {
2396
+ if (!isEmptyOkRetryFailure(error)) throw error;
2397
+ // DR-028: exactly one corrective re-ask of the same composed
2398
+ // call through the same boundary, traced as its own
2399
+ // started/finished pair, its result read under the unchanged
2400
+ // rules — a second empty `ok` result throws from the boundary
2401
+ // exactly as the first did, with no further re-ask.
2402
+ result = await boundary.callCaptain!(input, prompt, active);
2403
+ }
2036
2404
  // The boundary owns result validation (PBRT-47) and throws the
2037
2405
  // authoritative failure itself, so a returned result is always
2038
2406
  // `ok` with visible text. Assert that invariant rather than
2039
2407
  // restating the failure semantics, which would drift.
2040
- if (result.status !== 'ok' || !result.finalText) {
2408
+ const finalText = result.finalText ?? '';
2409
+ if (result.status !== 'ok' || isEmptyFinalText(finalText)) {
2041
2410
  throw new Error(
2042
2411
  'captainActor: boundary returned an unvalidated Captain result',
2043
2412
  );
2044
2413
  }
2045
- const judgePrompt = buildCaptainJudgePrompt(
2414
+ const judgePrompt = defaultBuildCaptainJudgePrompt(
2046
2415
  input,
2047
- result.finalText,
2416
+ finalText,
2048
2417
  );
2049
2418
  const raw = await boundary.callJudge(
2050
2419
  'captain-output-adjudication',
@@ -2055,7 +2424,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2055
2424
  const output = adjudicateCaptainOutput(
2056
2425
  extractFields,
2057
2426
  input,
2058
- result.finalText,
2427
+ finalText,
2059
2428
  raw,
2060
2429
  );
2061
2430
  validateBossReplyOutput(input, output, resumableStateIds);
@@ -2275,6 +2644,20 @@ export function createXStatePlaybookRuntime<TOptions>(
2275
2644
  else emissionFailure ??= error;
2276
2645
  }
2277
2646
 
2647
+ // PBRT-6: the single seam that stops this runtime's actor. Stopping a
2648
+ // still-running actor fires one more `@xstate.snapshot` for the
2649
+ // *unchanged* state value with `status: 'stopped'`, which the inspect
2650
+ // callback cannot distinguish from a state entry — unsuppressed it
2651
+ // re-emits the parked state's statuses and a phantom self-loop
2652
+ // transition. Suppression is a property of stopping, not a rule each
2653
+ // caller must remember, so every stop goes through here; a caller that
2654
+ // builds a replacement actor clears the flag before starting it.
2655
+ function stopActor(): void {
2656
+ if (!actor) return;
2657
+ suppressInspectionEmissions = true;
2658
+ actor.stop();
2659
+ }
2660
+
2278
2661
  function buildActor(
2279
2662
  ports: PlaybookPorts,
2280
2663
  machineSnapshot?: JsonValue,
@@ -2425,9 +2808,8 @@ export function createXStatePlaybookRuntime<TOptions>(
2425
2808
  // A state that cannot even normalize has no disposal descriptor.
2426
2809
  }
2427
2810
  }
2428
- suppressInspectionEmissions = true;
2429
2811
  try {
2430
- actor?.stop();
2812
+ stopActor();
2431
2813
  } catch {
2432
2814
  // Preserve the original startup failure.
2433
2815
  }
@@ -2464,6 +2846,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2464
2846
  activeEmissionCalls.clear();
2465
2847
  emissionQueue.clear();
2466
2848
  judgeQueue.clear();
2849
+ appliedReceipts.clear();
2467
2850
  actor = undefined;
2468
2851
  session = undefined;
2469
2852
  savedPorts = undefined;
@@ -2473,6 +2856,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2473
2856
  controlPlaneError = undefined;
2474
2857
  emissionFailure = undefined;
2475
2858
  priorState = undefined;
2859
+ lastBossEvent = undefined;
2476
2860
  suppressInspectionEmissions = false;
2477
2861
  initialized = false;
2478
2862
  traceSequence = 0;
@@ -2481,6 +2865,183 @@ export function createXStatePlaybookRuntime<TOptions>(
2481
2865
  playerCallSequence = 0;
2482
2866
  playbookCallSequence = 0;
2483
2867
  captainCallSequence = 0;
2868
+ applyCallSequence = 0;
2869
+ }
2870
+
2871
+ // -----------------------------------------------------------------
2872
+ // DR-029 control surface: action derivation shared by `describe`
2873
+ // and by `apply`'s live revalidation.
2874
+ // -----------------------------------------------------------------
2875
+
2876
+ interface DerivedControlAction {
2877
+ action: PlaybookControlAction;
2878
+ event: EventObject;
2879
+ }
2880
+
2881
+ function snapshotCan(snapshot: unknown, event: EventObject): boolean {
2882
+ const can = (snapshot as { can?: unknown } | null)?.can;
2883
+ return (
2884
+ typeof can === 'function' &&
2885
+ (can as (candidate: EventObject) => boolean).call(snapshot, event) ===
2886
+ true
2887
+ );
2888
+ }
2889
+
2890
+ // The failure-state retry entry replays the recorded last classified
2891
+ // event with its recorded payload. A candidate whose event the live
2892
+ // snapshot does not accept — or whose payload the runtime never
2893
+ // recorded — is excluded rather than completed with invented text.
2894
+ function retryActionFor(
2895
+ snapshot: unknown,
2896
+ stateId: string | undefined,
2897
+ ): DerivedControlAction | undefined {
2898
+ if (stateId !== 'failed' || lastBossEvent === undefined) {
2899
+ return undefined;
2900
+ }
2901
+ if (!snapshotCan(snapshot, lastBossEvent)) return undefined;
2902
+ // A recorded explicit-state-jump event names the exact state its
2903
+ // replay re-enters: the root BOSS_INTERRUPT shape is a guarded
2904
+ // multi-arm list keyed on `targetId`, so the first configured arm
2905
+ // may label a different state than the one the recorded event
2906
+ // actually resumes.
2907
+ const recordedTargetId =
2908
+ lastBossEvent.type === JUMP_EVENT_TYPE
2909
+ ? (lastBossEvent as { targetId?: unknown }).targetId
2910
+ : undefined;
2911
+ const target =
2912
+ typeof recordedTargetId === 'string' &&
2913
+ recordedTargetId.trim().length > 0
2914
+ ? recordedTargetId
2915
+ : firstTransitionTarget(machine, stateId, lastBossEvent.type);
2916
+ // PBRT-52: a label is written from a source state description, never
2917
+ // from an identifier. Falling back to the target id — or, with no
2918
+ // resolvable target, to the FSM event type — makes the label *be* the
2919
+ // internal name, which defeats the substitution the label exists for
2920
+ // and puts a machine identifier into Boss-facing text
2921
+ // (CAPPLAY-5). A candidate whose label can only be an id is excluded
2922
+ // exactly like one whose payload cannot be sourced.
2923
+ const description =
2924
+ (target === undefined ? undefined : stateDescriptions.get(target)) ??
2925
+ stateDescriptions.get(stateId);
2926
+ if (description === undefined) return undefined;
2927
+ return {
2928
+ action: {
2929
+ id: `retry:${lastBossEvent.type}`,
2930
+ label: `Retry: ${description}`,
2931
+ },
2932
+ event: lastBossEvent,
2933
+ };
2934
+ }
2935
+
2936
+ function deriveControlActions(snapshot: unknown): DerivedControlAction[] {
2937
+ // Actions derive only at the safe point the parked snapshot also
2938
+ // uses — quiescent actor with status `active` and no pending nested
2939
+ // call. Anywhere else the view still describes the state while
2940
+ // advertising nothing.
2941
+ let state: PlaybookState;
2942
+ try {
2943
+ state = normalizePlaybookSnapshot(snapshot, {
2944
+ pendingCall: nestedBridge.getPendingCall(),
2945
+ });
2946
+ } catch {
2947
+ return [];
2948
+ }
2949
+ if (
2950
+ state.status !== 'active' ||
2951
+ !state.quiescent ||
2952
+ nestedBridge.getPendingCall()
2953
+ ) {
2954
+ return [];
2955
+ }
2956
+ const derived: DerivedControlAction[] = [];
2957
+ const retry = retryActionFor(snapshot, state.stateId);
2958
+ if (retry !== undefined) derived.push(retry);
2959
+ // Jump entries: resumable targets whose explicit-state-jump event the
2960
+ // live snapshot accepts (state guards included), sent with the
2961
+ // advertised target id and optional textual fields omitted.
2962
+ for (const targetId of [...resumableStateIds].sort()) {
2963
+ const event = { type: JUMP_EVENT_TYPE, targetId } as EventObject;
2964
+ if (!snapshotCan(snapshot, event)) continue;
2965
+ // PBRT-52: no published description for the target, no Boss-appropriate
2966
+ // label. A jump cannot borrow another state's meaning without naming
2967
+ // the wrong state, so the entry is not advertised at all rather than
2968
+ // labeled with its own target id.
2969
+ const description = stateDescriptions.get(targetId);
2970
+ if (description === undefined) continue;
2971
+ derived.push({
2972
+ action: {
2973
+ id: `jump:${targetId}`,
2974
+ label: `Resume from: ${description}`,
2975
+ },
2976
+ event,
2977
+ });
2978
+ }
2979
+ return derived;
2980
+ }
2981
+
2982
+ // PBRT-52: the control view's context is the artifact's declared
2983
+ // projection, not a serialization of whatever the FSM happens to hold.
2984
+ // Only the runtime knows which of its context members are safe and
2985
+ // relevant for a controller prompt — an allow-by-default export cannot
2986
+ // keep player output, resolved player identities, or option values out
2987
+ // of a prompt whose host is required to exclude them
2988
+ // (CAPTAIN-9) — so nothing is exported unless
2989
+ // `controlContextFields` names it, in the order it names them. Each
2990
+ // named member is still sanitized: raw `Error` values are normalized
2991
+ // and a value that cannot be made JSON-safe is dropped, never thrown,
2992
+ // since `describe` must stay side-effect free and total.
2993
+ function projectControlContext(
2994
+ context: Record<string, unknown>,
2995
+ ): JsonValue | undefined {
2996
+ const projected: Record<string, JsonValue> = {};
2997
+ for (const key of controlContextFields) {
2998
+ const value = context[key];
2999
+ if (value === undefined) continue;
3000
+ try {
3001
+ projected[key] = snapshotJsonValue(
3002
+ value instanceof Error ? normalizeError(value) : value,
3003
+ `control context ${key}`,
3004
+ );
3005
+ } catch {
3006
+ // Declared but not JSON-safe — dropped.
3007
+ }
3008
+ }
3009
+ return Object.keys(projected).length === 0 ? undefined : projected;
3010
+ }
3011
+
3012
+ // PBRT-52: the view's Boss-facing state description — the meaning of the
3013
+ // state the runtime is in, written by the artifact's own source, from the
3014
+ // same descriptions its action labels are written from. A control view is
3015
+ // the only grounding a controller host has for a status answer, and an
3016
+ // internal state id is not Boss-appropriate text
3017
+ // (CAPPLAY-5), so the runtime publishes the meaning
3018
+ // rather than leaving the host to substitute the identifier for it. A
3019
+ // state whose source declares no description publishes none: an id is
3020
+ // never promoted into a description by default.
3021
+ function stateDescriptionFor(state: PlaybookState): string | undefined {
3022
+ const keys = [
3023
+ ...(state.stateId === undefined ? [] : [state.stateId]),
3024
+ ...(typeof state.value === 'string' ? [state.value] : []),
3025
+ ...state.activeStateIds,
3026
+ ];
3027
+ for (const key of keys) {
3028
+ const description = stateDescriptions.get(key);
3029
+ if (description !== undefined) return description;
3030
+ }
3031
+ return undefined;
3032
+ }
3033
+
3034
+ function receiptTracePayload(
3035
+ receipt: PlaybookControlReceipt,
3036
+ ): Record<string, unknown> {
3037
+ return {
3038
+ disposition: receipt.disposition,
3039
+ ...(receipt.disposition === 'rejected'
3040
+ ? { reason: receipt.reason }
3041
+ : {}),
3042
+ ...(receipt.disposition === 'failed' ? { error: receipt.error } : {}),
3043
+ ...(receipt.disposition === 'executed' ? { run: receipt.run } : {}),
3044
+ };
2484
3045
  }
2485
3046
 
2486
3047
  const runtime = {
@@ -2602,6 +3163,11 @@ export function createXStatePlaybookRuntime<TOptions>(
2602
3163
  // Every Captain call already consumed at least one trace number,
2603
3164
  // so the global trace counter is a collision-safe id floor.
2604
3165
  boundSnapshot.sequences.trace;
3166
+ // The schema-1 snapshot carries no apply counter (PBRT-50: no
3167
+ // schema bump); every apply boundary consumed trace numbers, so
3168
+ // the persisted trace counter is a collision-safe id floor here
3169
+ // too, keeping `apply-<n>` call ids unique across restore.
3170
+ applyCallSequence = boundSnapshot.sequences.trace;
2605
3171
  playerResumeTokens.clear();
2606
3172
  for (const [playerId, token] of Object.entries(
2607
3173
  boundSnapshot.playerResumeTokens,
@@ -2632,6 +3198,349 @@ export function createXStatePlaybookRuntime<TOptions>(
2632
3198
  }
2633
3199
  },
2634
3200
 
3201
+ // DR-029 / PBRT-52: side-effect-free control view over the live
3202
+ // snapshot, valid at parked quiescence outside an active boundary.
3203
+ // The view is detached and frozen; producing it emits nothing and
3204
+ // moves nothing.
3205
+ describe(): PlaybookControlView {
3206
+ if (disposed || disposalPromise !== undefined) {
3207
+ throw new Error(
3208
+ 'createPlaybookRuntime.describe: runtime is disposing or disposed',
3209
+ );
3210
+ }
3211
+ if (!actor || !savedPorts) {
3212
+ throw new Error(
3213
+ 'createPlaybookRuntime.describe: init must be called first',
3214
+ );
3215
+ }
3216
+ if (activeSignal !== undefined) {
3217
+ throw new Error(
3218
+ 'createPlaybookRuntime.describe: another runtime turn is active',
3219
+ );
3220
+ }
3221
+ const snapshot = actor.getSnapshot();
3222
+ const state = currentState();
3223
+ const context = ((snapshot as { context?: unknown }).context ??
3224
+ {}) as Record<string, unknown>;
3225
+ const pending = pendingBossQuestionFromContext(context);
3226
+ const lastError = normalizeErrorFull(context.lastError);
3227
+ const projectedContext = projectControlContext(context);
3228
+ const stateDescription = stateDescriptionFor(state);
3229
+ return deepFreeze({
3230
+ state,
3231
+ ...(stateDescription === undefined ? {} : { stateDescription }),
3232
+ ...(projectedContext !== undefined
3233
+ ? { context: projectedContext }
3234
+ : {}),
3235
+ pendingQuestions:
3236
+ pending === undefined
3237
+ ? []
3238
+ : [
3239
+ {
3240
+ questionId: pending.questionId,
3241
+ player: pending.player,
3242
+ question: pending.question,
3243
+ sourceItem: pending.sourceItem,
3244
+ },
3245
+ ],
3246
+ ...(lastError !== undefined ? { lastError } : {}),
3247
+ actions: deriveControlActions(snapshot).map(({ action }) => action),
3248
+ });
3249
+ },
3250
+
3251
+ // DR-029 / PBRT-52: revalidate the named action against the live
3252
+ // state and execute it at most once per idempotency key. The receipt
3253
+ // discriminates rejected-before-any-effect from executed and from
3254
+ // failed-after-effects-may-exist; a repeated key returns the recorded
3255
+ // receipt without re-execution. A rejection settles before acceptance,
3256
+ // so — like a key whose call threw before reaching acceptance — it
3257
+ // records nothing and the key may execute later, once the action is
3258
+ // advertised.
3259
+ async apply(input: {
3260
+ actionId: string;
3261
+ key: string;
3262
+ signal: AbortSignal;
3263
+ }): Promise<PlaybookControlReceipt> {
3264
+ if (input === null || typeof input !== 'object') {
3265
+ throw new TypeError(
3266
+ 'createPlaybookRuntime.apply: input must be an object',
3267
+ );
3268
+ }
3269
+ const { actionId, key, signal } = input;
3270
+ if (typeof actionId !== 'string' || actionId.length === 0) {
3271
+ throw new TypeError(
3272
+ 'createPlaybookRuntime.apply: actionId must be a non-empty string',
3273
+ );
3274
+ }
3275
+ if (typeof key !== 'string' || key.length === 0) {
3276
+ throw new TypeError(
3277
+ 'createPlaybookRuntime.apply: key must be a non-empty string',
3278
+ );
3279
+ }
3280
+ if (!(signal instanceof AbortSignal)) {
3281
+ throw new TypeError(
3282
+ 'createPlaybookRuntime.apply: signal must be an AbortSignal',
3283
+ );
3284
+ }
3285
+ if (disposed || disposalPromise !== undefined) {
3286
+ throw new Error(
3287
+ 'createPlaybookRuntime.apply: runtime is disposing or disposed',
3288
+ );
3289
+ }
3290
+ if (!actor || !savedPorts) {
3291
+ throw new Error(
3292
+ 'createPlaybookRuntime.apply: init must be called first',
3293
+ );
3294
+ }
3295
+ if (activeSignal !== undefined) {
3296
+ throw new Error(
3297
+ 'createPlaybookRuntime.apply: another runtime turn is active',
3298
+ );
3299
+ }
3300
+ // Settlement is final: a repeated key returns the recorded receipt
3301
+ // with no revalidation, no execution, and no new trace pair.
3302
+ const recorded = appliedReceipts.get(key);
3303
+ if (recorded !== undefined) return recorded;
3304
+ // An abort before acceptance ends the call with no receipt
3305
+ // recorded, like every other pre-acceptance failure.
3306
+ signal.throwIfAborted();
3307
+
3308
+ const turnId = ++turnSequence;
3309
+ const callId = `apply-${++applyCallSequence}`;
3310
+ const position: TracePosition = { turnId, callId };
3311
+ activeTurnId = turnId;
3312
+ activeSignal = signal;
3313
+ controlPlaneError = undefined;
3314
+ // Every receipt variant is normalized and frozen where it is built,
3315
+ // inside the guarded region, so the recording step below cannot
3316
+ // throw after effects exist.
3317
+ const settledReceipt = (
3318
+ value: PlaybookControlReceipt,
3319
+ ): PlaybookControlReceipt =>
3320
+ deepFreeze(
3321
+ snapshotJsonValue(
3322
+ value,
3323
+ 'apply receipt',
3324
+ ) as unknown as PlaybookControlReceipt,
3325
+ );
3326
+ let receipt: PlaybookControlReceipt | undefined;
3327
+ let operationError: unknown;
3328
+ let settlementError: unknown;
3329
+ // Acceptance is the line past which this boundary owes a receipt and
3330
+ // can no longer signal by throwing: the action may have run, and a
3331
+ // caller that gets an exception instead of a receipt is left with an
3332
+ // executed effect it cannot record and a key it will not reuse.
3333
+ let accepted = false;
3334
+ // Publication is the second line this boundary respects. Before it,
3335
+ // nothing has left the runtime: a settlement failure past acceptance
3336
+ // is a post-acceptance control-plane error PBRT-52 settles as the
3337
+ // `failed` receipt, and folding it in replaces the receipt recorded
3338
+ // at acceptance so the finish trace, the returned receipt, and any
3339
+ // replay of the key all report one settlement. Past publication that
3340
+ // agreement is no longer achievable — the disposition is already on
3341
+ // the wire — so the fold refuses to run, by construction rather than
3342
+ // by call ordering. Only the first settlement error is latched, so
3343
+ // one fold is all there is to do.
3344
+ let folded = false;
3345
+ let published = false;
3346
+ const foldSettlementFailure = (): void => {
3347
+ if (published || !accepted || folded) return;
3348
+ if (settlementError === undefined) return;
3349
+ folded = true;
3350
+ receipt = settledReceipt({
3351
+ disposition: 'failed',
3352
+ error: normalizeError(settlementError),
3353
+ });
3354
+ appliedReceipts.set(key, receipt);
3355
+ };
3356
+ // A settlement failure that lands after the receipt is published says
3357
+ // nothing about the effect: the action ran, the caller's receipt is
3358
+ // true, and only the telemetry delivery failed. Rewriting `executed`
3359
+ // to `failed` there would make the runtime lie to its only caller
3360
+ // about work that succeeded, irrecoverably — accepted receipts are
3361
+ // final for their key. Past publication such a failure is therefore
3362
+ // re-latched onto the emission channel, surfacing from the next
3363
+ // public boundary's drain, and `apply` still does not throw past
3364
+ // acceptance (PBRT-52).
3365
+ const latchDeliveryFailure = (error: unknown): void => {
3366
+ emissionFailure ??= error;
3367
+ };
3368
+ try {
3369
+ try {
3370
+ const identity = {
3371
+ actionId,
3372
+ key,
3373
+ ...stateIdentity(currentState().stateId),
3374
+ };
3375
+ // Every apply finish carries the receipt disposition and no
3376
+ // start-only field — `stateId` is on the start alone
3377
+ // (slc/link.md §Playbook trace). Both finishes reachable
3378
+ // before acceptance settle with no effect behind them, so both
3379
+ // carry the canonical `rejected` disposition and the reason
3380
+ // that ended the call, alongside the transport marker.
3381
+ const preAcceptanceFinish = (
3382
+ reason: string,
3383
+ ): Record<string, unknown> => ({
3384
+ actionId,
3385
+ key,
3386
+ ...receiptTracePayload({ disposition: 'rejected', reason }),
3387
+ });
3388
+ await emitCallStarted(
3389
+ 'apply.started',
3390
+ 'apply.finished',
3391
+ identity,
3392
+ position,
3393
+ preAcceptanceFinish('apply.started trace sink rejected'),
3394
+ );
3395
+ // An abort may land while the awaited started emission drains
3396
+ // (e.g. fired from the trace sink itself); the action must
3397
+ // never execute after abort. Settle the already-started pair
3398
+ // as `aborted` — carrying the canonical rejected-before-any-
3399
+ // effect receipt disposition required of every apply finish —
3400
+ // and end the call pre-acceptance: no receipt is recorded and
3401
+ // the key stays free.
3402
+ if (signal.aborted) {
3403
+ try {
3404
+ await emitTrace(
3405
+ 'apply.finished',
3406
+ {
3407
+ ...preAcceptanceFinish('aborted before acceptance'),
3408
+ status: 'aborted',
3409
+ error: normalizeError(signal.reason),
3410
+ },
3411
+ position,
3412
+ );
3413
+ } catch (error) {
3414
+ // A rejecting finish sink surfaces at the boundary like
3415
+ // any settlement failure (see the precedence below).
3416
+ settlementError ??= error;
3417
+ }
3418
+ signal.throwIfAborted();
3419
+ }
3420
+ const snapshot = actor.getSnapshot();
3421
+ const candidate = deriveControlActions(snapshot).find(
3422
+ ({ action }) => action.id === actionId,
3423
+ );
3424
+ if (candidate === undefined) {
3425
+ receipt = settledReceipt({
3426
+ disposition: 'rejected',
3427
+ reason: `action ${JSON.stringify(
3428
+ actionId,
3429
+ )} is not currently advertised`,
3430
+ });
3431
+ } else {
3432
+ // Acceptance: from here every outcome records a receipt under
3433
+ // the key, so the action can never execute twice.
3434
+ accepted = true;
3435
+ try {
3436
+ actor.send(candidate.event);
3437
+ await waitForPlaybookQuiescence(actor, {
3438
+ pendingCalls: nestedBridge,
3439
+ });
3440
+ if (controlPlaneError !== undefined) throw controlPlaneError;
3441
+ const run = runResultFor(settledOutcome(signal));
3442
+ receipt = settledReceipt(
3443
+ run.outcome === 'failed' || run.outcome === 'aborted'
3444
+ ? {
3445
+ disposition: 'failed',
3446
+ error:
3447
+ ('error' in run ? run.error : undefined) ??
3448
+ normalizeError(
3449
+ new Error(
3450
+ `apply settled with outcome ${run.outcome}`,
3451
+ ),
3452
+ ),
3453
+ }
3454
+ : { disposition: 'executed', run },
3455
+ );
3456
+ } catch (error) {
3457
+ // Effects may exist: a post-acceptance failure is the
3458
+ // receipt, not a control-plane rejection (DR-029).
3459
+ receipt = settledReceipt({
3460
+ disposition: 'failed',
3461
+ error: normalizeError(error),
3462
+ });
3463
+ }
3464
+ }
3465
+ } catch (error) {
3466
+ operationError = error; // pre-acceptance: no receipt is recorded
3467
+ }
3468
+
3469
+ // Record acceptance before the settlement emissions, so a crash
3470
+ // between acceptance and settlement can never re-execute the
3471
+ // action: the recorded receipt survives and a replayed key
3472
+ // returns it. A rejection settled before acceptance: it is
3473
+ // returned and traced but never recorded, so its key stays free
3474
+ // to execute once the action is advertised.
3475
+ if (receipt !== undefined && receipt.disposition !== 'rejected') {
3476
+ appliedReceipts.set(key, receipt);
3477
+ }
3478
+ try {
3479
+ await drainEmissions();
3480
+ } catch (error) {
3481
+ settlementError = error;
3482
+ }
3483
+ // Fold before the finish emission, the last point at which the
3484
+ // traced disposition and the returned one can still be made the
3485
+ // same value.
3486
+ foldSettlementFailure();
3487
+ if (receipt !== undefined) {
3488
+ // Publication: this disposition is now the settlement, for the
3489
+ // trace, for the caller, and for every replay of the key.
3490
+ published = true;
3491
+ try {
3492
+ await emitTrace(
3493
+ 'apply.finished',
3494
+ { actionId, key, ...receiptTracePayload(receipt) },
3495
+ position,
3496
+ );
3497
+ } catch (error) {
3498
+ if (accepted) latchDeliveryFailure(error);
3499
+ else settlementError ??= error;
3500
+ }
3501
+ // Drain even when the finish emission rejected, so this call
3502
+ // leaves no queued emission behind it. Before acceptance the
3503
+ // failure is consumed and thrown, as every pre-acceptance failure
3504
+ // is; past it the failure is re-latched instead — the effect
3505
+ // happened, so the delivery failure travels on the emission
3506
+ // channel to the next boundary rather than rewriting what
3507
+ // happened or vanishing here.
3508
+ try {
3509
+ await drainEmissions();
3510
+ } catch (error) {
3511
+ if (accepted) latchDeliveryFailure(error);
3512
+ else settlementError ??= error;
3513
+ }
3514
+ }
3515
+ } finally {
3516
+ // Always release the boundary sentinel, even on a path no
3517
+ // constructible input reaches today, so a defect here can never
3518
+ // wedge every later public boundary behind "another runtime turn
3519
+ // is active".
3520
+ activeSignal = undefined;
3521
+ activeTurnId = undefined;
3522
+ controlPlaneError = undefined;
3523
+ }
3524
+ // Past acceptance every settlement failure has been folded into the
3525
+ // receipt, so nothing is left to throw and the caller always leaves
3526
+ // with the settlement of the effect it may have caused (PBRT-52).
3527
+ if (accepted && receipt !== undefined) return receipt;
3528
+ // Before acceptance no effect exists and no receipt is owed, so a
3529
+ // failure still surfaces by throwing. Settlement failures (a
3530
+ // rejecting finish sink, a drain-latched emission failure) outrank
3531
+ // the operation error, matching the `drainError ?? operationError`
3532
+ // precedence of the other public boundaries. A start-sink failure is
3533
+ // unaffected: its latched drain error is the start error itself.
3534
+ const failure = settlementError ?? operationError;
3535
+ if (failure !== undefined) throw failure;
3536
+ if (receipt === undefined) {
3537
+ throw new Error(
3538
+ 'createPlaybookRuntime.apply: no receipt was produced',
3539
+ );
3540
+ }
3541
+ return receipt;
3542
+ },
3543
+
2635
3544
  async handleBossInput({
2636
3545
  text,
2637
3546
  signal,
@@ -2686,6 +3595,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2686
3595
  signal,
2687
3596
  snapshot,
2688
3597
  boundary,
3598
+ boundOptions,
2689
3599
  );
2690
3600
  }
2691
3601
  signal.throwIfAborted();
@@ -2705,10 +3615,24 @@ export function createXStatePlaybookRuntime<TOptions>(
2705
3615
  // 3. A final actor cannot accept new events; reconstruct only
2706
3616
  // after classification produced a real event.
2707
3617
  if (actor.getSnapshot().status === 'done') {
2708
- actor.stop();
3618
+ stopActor();
2709
3619
  actor = buildActor(runtimePorts!);
3620
+ // The replacement actor's snapshots are real state entries.
3621
+ suppressInspectionEmissions = false;
2710
3622
  actor.start();
2711
3623
  }
3624
+ // DR-029: keep the classified event with its recorded payload
3625
+ // as the retry-replay source. Recording is sanitizing, not
3626
+ // load-bearing: an override classifier's non-JSON-safe event is
3627
+ // simply not recorded, and the turn proceeds unchanged.
3628
+ try {
3629
+ lastBossEvent = snapshotJsonValue(
3630
+ event,
3631
+ 'recorded Boss event',
3632
+ ) as unknown as EventObject;
3633
+ } catch {
3634
+ lastBossEvent = undefined;
3635
+ }
2712
3636
  actor.send(event);
2713
3637
  await waitForPlaybookQuiescence(actor, {
2714
3638
  pendingCalls: nestedBridge,
@@ -2852,7 +3776,9 @@ export function createXStatePlaybookRuntime<TOptions>(
2852
3776
  const finalState = actor ? currentState() : undefined;
2853
3777
  // Stop the root before settling a suspended child. Its rejection
2854
3778
  // must not re-enter the FSM and start fresh work during disposal.
2855
- if (actor) actor.stop();
3779
+ // `stopActor` suppresses inspection first, so the stop snapshot
3780
+ // adds nothing beside the `session.disposed` trace below (PBRT-6).
3781
+ stopActor();
2856
3782
  try {
2857
3783
  await nestedBridge.dispose();
2858
3784
  } catch (error) {
@@ -2886,11 +3812,13 @@ export function createXStatePlaybookRuntime<TOptions>(
2886
3812
  activeEmissionCalls.clear();
2887
3813
  emissionQueue.clear();
2888
3814
  judgeQueue.clear();
3815
+ appliedReceipts.clear();
2889
3816
  actor = undefined;
2890
3817
  activeSignal = undefined;
2891
3818
  activeTurnId = undefined;
2892
3819
  controlPlaneError = undefined;
2893
3820
  emissionFailure = undefined;
3821
+ lastBossEvent = undefined;
2894
3822
  savedPorts = undefined;
2895
3823
  runtimePorts = undefined;
2896
3824
  session = undefined;