@sublang/playbook 7.0.0 → 9.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 (62) hide show
  1. package/README.md +20 -7
  2. package/docs/cli.md +88 -43
  3. package/docs/configuration.md +221 -119
  4. package/docs/embedding.md +78 -27
  5. package/package.json +4 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +18 -15
  18. package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +25 -15
  22. package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +29 -23
  48. package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +45 -24
  53. package/slc/link.md +297 -135
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +21 -16
  56. package/src/runtime.ts +20 -23
  57. package/src/xstate-playbook-runtime.d.ts +34 -20
  58. package/src/xstate-playbook-runtime.js +973 -400
  59. package/src/xstate-playbook-runtime.ts +1203 -457
  60. package/src/xstate-runtime.d.ts +17 -7
  61. package/src/xstate-runtime.js +198 -81
  62. package/src/xstate-runtime.ts +339 -112
@@ -78,15 +78,15 @@ function isEmptyOkRetryFailure(error) {
78
78
  export const RUNTIME_ABI = 1;
79
79
  /** The linked-artifact schema versions this engine accepts (DR-022). */
80
80
  export const SUPPORTED_ARTIFACT_SCHEMAS = Object.freeze([
81
- 1,
81
+ 2,
82
82
  ]);
83
83
  // PBRT-50: validate a declaration against the loaded engine, schema first,
84
- // so one clear diagnostic covers a fully skewed artifact. Absent means a
85
- // legacy artifact emitted before the DR-022 contract; those must keep
86
- // loading unchanged (DR-019 §4), so there is nothing to check.
84
+ // so one clear diagnostic covers a fully skewed artifact. Declaration-free
85
+ // artifacts are schema 1 and cannot be interpreted as local-role artifacts.
87
86
  function assertRuntimeCompat(compat, label) {
88
- if (compat === undefined)
89
- return;
87
+ if (compat === undefined) {
88
+ throw new TypeError(`${label} spec.compat is required for local-role artifacts`);
89
+ }
90
90
  if (compat === null || typeof compat !== 'object') {
91
91
  throw new TypeError(`${label} spec.compat must be an object`);
92
92
  }
@@ -233,9 +233,37 @@ export function normalizeErrorFull(err) {
233
233
  return undefined;
234
234
  return normalizeError(err);
235
235
  }
236
+ // slc/link.md §Abort: cancellation is causal identity with the applicable
237
+ // signal's reason — never an `AbortError` name, never bare signal state. A
238
+ // distinct failure observed while the signal is aborted stays a non-abort
239
+ // control error and takes precedence (mirrors DECIDE's bespoke reference).
236
240
  function isAbortFailure(error, signal) {
237
- return (signal.aborted &&
238
- (error === signal.reason || normalizeError(error).name === 'AbortError'));
241
+ return signal.aborted && Object.is(error, signal.reason);
242
+ }
243
+ function abortReasonClassifier(...sources) {
244
+ const captured = sources.filter((source) => source !== undefined);
245
+ return Object.freeze({
246
+ isAbortReason: (error) => captured.some((source) => source instanceof AbortSignal
247
+ ? isAbortFailure(error, source)
248
+ : source.isAbortReason(error)),
249
+ });
250
+ }
251
+ /**
252
+ * gears2fsm's canonical Boss-reply wait state. On the runtime's Boss-facing
253
+ * surfaces — state telemetry, status lines, the exported snapshot, and the
254
+ * control view — a context question counts as *pending* only while the
255
+ * machine sits in this state awaiting the reply. Later states retain the
256
+ * answered question in context (the resumed player prompt is composed from
257
+ * it), so an unconditional projection would resurrect it: a failure the
258
+ * resumed player reached would export a question nobody is waiting on,
259
+ * disagreeing with the gated telemetry a mirroring host's ledger follows
260
+ * and failing the shell's snapshot-equality settlement check.
261
+ */
262
+ const BOSS_REPLY_WAIT_STATE_ID = 'awaitBossReply';
263
+ function pendingBossQuestionForState(state, context) {
264
+ if (state.stateId !== BOSS_REPLY_WAIT_STATE_ID)
265
+ return undefined;
266
+ return pendingBossQuestionFromContext(context);
239
267
  }
240
268
  /** Read the FSM context's single pending Boss question, when well-formed. */
241
269
  export function pendingBossQuestionFromContext(context) {
@@ -249,15 +277,31 @@ export function pendingBossQuestionFromContext(context) {
249
277
  if (typeof candidate.questionId !== 'string' ||
250
278
  typeof candidate.resumeStateId !== 'string' ||
251
279
  typeof candidate.sourceItem !== 'string' ||
252
- typeof candidate.player !== 'string' ||
280
+ !isPlainObject(candidate.asker) ||
253
281
  typeof candidate.question !== 'string') {
254
282
  return undefined;
255
283
  }
284
+ let asker;
285
+ if (candidate.asker.kind === 'captain') {
286
+ if (Object.keys(candidate.asker).some((key) => key !== 'kind')) {
287
+ return undefined;
288
+ }
289
+ asker = { kind: 'captain' };
290
+ }
291
+ else if (candidate.asker.kind === 'role' &&
292
+ typeof candidate.asker.roleId === 'string' &&
293
+ candidate.asker.roleId.trim().length > 0 &&
294
+ Object.keys(candidate.asker).every((key) => key === 'kind' || key === 'roleId')) {
295
+ asker = { kind: 'role', roleId: candidate.asker.roleId };
296
+ }
297
+ else {
298
+ return undefined;
299
+ }
256
300
  return {
257
301
  questionId: candidate.questionId,
258
302
  resumeStateId: candidate.resumeStateId,
259
303
  sourceItem: candidate.sourceItem,
260
- player: candidate.player,
304
+ asker,
261
305
  question: candidate.question,
262
306
  };
263
307
  }
@@ -339,10 +383,6 @@ export function defaultComposeCaptainPrompt(input, placeholderFields = {}) {
339
383
  blocks.push(body);
340
384
  return blocks.join('\n\n');
341
385
  }
342
- /** Default player binding: each player to its lowercased name. */
343
- export function defaultResolvePlayerId(input) {
344
- return input.player.toLowerCase();
345
- }
346
386
  /**
347
387
  * Default required-field extraction (slc/link.md §Captain adjudication).
348
388
  * Limited to the description's `Output shall include` / `输出应包含` clause;
@@ -375,7 +415,7 @@ export function defaultBuildJudgePrompt(input, finalText) {
375
415
  'seek external evidence. Decide only from the supplied player output ' +
376
416
  'and outcome descriptions. Reply with exactly one JSON object and no prose.');
377
417
  lines.push('');
378
- lines.push(`The ${input.player} just produced this output:`);
418
+ lines.push(`The ${input.role} role just produced this output:`);
379
419
  lines.push('');
380
420
  lines.push('```');
381
421
  lines.push(finalText);
@@ -447,11 +487,21 @@ function validateBossReplyOutput(input, output, resumableStateIds) {
447
487
  export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onControlPlaneError) {
448
488
  return fromPromise(async ({ input, signal }) => {
449
489
  const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
450
- const playerId = spec.resolvePlayerId(input);
451
- const prompt = spec.composePlayerPrompt(input);
490
+ let roleId;
491
+ let prompt;
492
+ try {
493
+ roleId = spec.resolveRoleId(input);
494
+ prompt = spec.composePlayerPrompt(input);
495
+ }
496
+ catch (error) {
497
+ if (!isAbortFailure(error, activeSignal)) {
498
+ onControlPlaneError?.(error);
499
+ }
500
+ throw error;
501
+ }
452
502
  const callPlayer = (resume) => boundary
453
- ? boundary.callPlayer(input, playerId, prompt, activeSignal)
454
- : ports.callPlayer(playerId, prompt, activeSignal, { resume });
503
+ ? boundary.callPlayer(input, roleId, prompt, activeSignal)
504
+ : ports.callPlayer(roleId, prompt, activeSignal, { resume });
455
505
  let result = await callPlayer(false);
456
506
  if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
457
507
  // An abort that lands between the empty first result and the
@@ -486,7 +536,9 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
486
536
  return output;
487
537
  }
488
538
  catch (error) {
489
- onControlPlaneError?.(error);
539
+ if (!isAbortFailure(error, activeSignal)) {
540
+ onControlPlaneError?.(error);
541
+ }
490
542
  throw error;
491
543
  }
492
544
  });
@@ -589,8 +641,8 @@ function collectInvokeSources(machine) {
589
641
  visit(machine.config);
590
642
  return sources;
591
643
  }
592
- function collectPlayerStatePlayers(machine) {
593
- const players = new Map();
644
+ function collectPlayerStateRoles(machine) {
645
+ const roles = new Map();
594
646
  const visit = (stateDef, stateKey) => {
595
647
  if (!isPlainObject(stateDef))
596
648
  return;
@@ -609,13 +661,13 @@ function collectPlayerStatePlayers(machine) {
609
661
  if (stateId.trim().length === 0) {
610
662
  throw new TypeError('player state metadata must use a non-empty state id');
611
663
  }
612
- const player = isPlainObject(playbookMeta)
613
- ? playbookMeta.player
664
+ const role = isPlainObject(playbookMeta)
665
+ ? playbookMeta.role
614
666
  : undefined;
615
- if (typeof player !== 'string' || player.trim().length === 0) {
616
- throw new TypeError(`player state ${stateId} meta.playbook.player must be a non-empty string`);
667
+ if (typeof role !== 'string' || role.trim().length === 0) {
668
+ throw new TypeError(`player state ${stateId} meta.playbook.role must be a non-empty string`);
617
669
  }
618
- players.set(stateId, player);
670
+ roles.set(stateId, role);
619
671
  }
620
672
  if (isPlainObject(stateDef.states)) {
621
673
  for (const [childKey, child] of Object.entries(stateDef.states)) {
@@ -629,7 +681,7 @@ function collectPlayerStatePlayers(machine) {
629
681
  visit(stateDef, stateKey);
630
682
  }
631
683
  }
632
- return players;
684
+ return roles;
633
685
  }
634
686
  function transitionTargets(transition) {
635
687
  const arms = Array.isArray(transition) ? transition : [transition];
@@ -650,7 +702,7 @@ export function resumableStateIdsFromMachine(machine) {
650
702
  if (!isPlainObject(config) || !isPlainObject(config.states)) {
651
703
  return new Set();
652
704
  }
653
- const awaitState = config.states.awaitBossReply;
705
+ const awaitState = config.states[BOSS_REPLY_WAIT_STATE_ID];
654
706
  if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
655
707
  return new Set();
656
708
  }
@@ -752,6 +804,28 @@ function deepFreeze(value) {
752
804
  // Default transition/status derivation.
753
805
  // ---------------------------------------------------------------------------
754
806
  const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
807
+ // Bounded escalation for aborted script process groups: SIGTERM first, then
808
+ // SIGKILL after this grace, so settlement (gated on the shell's own exit)
809
+ // stays bounded even for TERM-immune commands.
810
+ const SCRIPT_ABORT_KILL_GRACE_MS = 2000;
811
+ class ScriptProcessGroupTeardownError extends Error {
812
+ constructor(pid, message, cause) {
813
+ super(`script process group ${pid} teardown could not be confirmed: ${message}`, cause === undefined ? undefined : { cause });
814
+ this.name = 'ScriptProcessGroupTeardownError';
815
+ }
816
+ }
817
+ function isNoSuchProcess(error) {
818
+ return (typeof error === 'object' &&
819
+ error !== null &&
820
+ 'code' in error &&
821
+ error.code === 'ESRCH');
822
+ }
823
+ function isProcessPermissionDenied(error) {
824
+ return (typeof error === 'object' &&
825
+ error !== null &&
826
+ 'code' in error &&
827
+ error.code === 'EPERM');
828
+ }
755
829
  function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
756
830
  return (event) => {
757
831
  if (event === null || typeof event !== 'object') {
@@ -774,40 +848,48 @@ function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
774
848
  return snapshotJsonValue(out, 'FSM event');
775
849
  };
776
850
  }
777
- function snapshotPlayerStateStatuses(value, label, machine, stateDescriptions) {
778
- if (value === undefined)
779
- return new Map();
780
- if (!isPlainObject(value)) {
781
- throw new TypeError(`${label} playerStates must be an object`);
851
+ function snapshotRoleStateStatuses(value, label, machine, stateDescriptions) {
852
+ if (value === undefined) {
853
+ throw new TypeError(`${label} roleStates must be supplied for schema 2`);
782
854
  }
783
- const declared = collectPlayerStatePlayers(machine);
855
+ const captured = snapshotJsonValue(value, `${label} roleStates`);
856
+ if (!isPlainObject(captured)) {
857
+ throw new TypeError(`${label} roleStates must be an object`);
858
+ }
859
+ const declared = collectPlayerStateRoles(machine);
784
860
  const statuses = new Map();
785
- for (const [stateId, candidate] of Object.entries(value)) {
861
+ for (const [stateId, candidate] of Object.entries(captured)) {
786
862
  if (!declared.has(stateId)) {
787
- throw new TypeError(`${label} playerStates.${stateId} does not name a player state`);
863
+ throw new TypeError(`${label} roleStates.${stateId} does not name a player state`);
864
+ }
865
+ if (isPlainObject(candidate)) {
866
+ const extra = Object.keys(candidate).find((key) => key !== 'role' && key !== 'label');
867
+ if (extra !== undefined) {
868
+ throw new TypeError(`${label} roleStates.${stateId}.${extra} is not allowed`);
869
+ }
788
870
  }
789
871
  if (!isPlainObject(candidate) ||
790
- typeof candidate.player !== 'string' ||
791
- candidate.player.trim().length === 0 ||
872
+ typeof candidate.role !== 'string' ||
873
+ candidate.role.trim().length === 0 ||
792
874
  typeof candidate.label !== 'string' ||
793
875
  candidate.label.trim().length === 0) {
794
- throw new TypeError(`${label} playerStates.${stateId} must carry non-empty player and label strings`);
876
+ throw new TypeError(`${label} roleStates.${stateId} must carry non-empty role and label strings`);
795
877
  }
796
878
  const expectedLabel = stateDescriptions.get(stateId);
797
879
  if (candidate.label !== expectedLabel) {
798
- throw new TypeError(`${label} playerStates.${stateId}.label must equal its FSM description`);
880
+ throw new TypeError(`${label} roleStates.${stateId}.label must equal its FSM description`);
799
881
  }
800
- if (candidate.player !== declared.get(stateId)) {
801
- throw new TypeError(`${label} playerStates.${stateId}.player must equal its FSM player`);
882
+ if (candidate.role !== declared.get(stateId)) {
883
+ throw new TypeError(`${label} roleStates.${stateId}.role must equal its FSM role`);
802
884
  }
803
885
  statuses.set(stateId, {
804
- player: candidate.player,
886
+ role: candidate.role,
805
887
  label: candidate.label,
806
888
  });
807
889
  }
808
890
  for (const stateId of declared.keys()) {
809
891
  if (!statuses.has(stateId)) {
810
- throw new TypeError(`${label} playerStates must declare player state ${stateId}`);
892
+ throw new TypeError(`${label} roleStates must declare player state ${stateId}`);
811
893
  }
812
894
  }
813
895
  return statuses;
@@ -820,34 +902,10 @@ function settlingGuard(event) {
820
902
  ? guard
821
903
  : undefined;
822
904
  }
823
- function legacyStatusesForState(state, context) {
824
- const stateId = state.stateId;
825
- if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId))
826
- return [];
827
- if (stateId === 'awaitBossReply') {
828
- const pending = pendingBossQuestionFromContext(context);
829
- return [
830
- {
831
- message: pending === undefined
832
- ? 'Awaiting Boss reply.'
833
- : `${pending.player} asks: ${pending.question}`,
834
- },
835
- ];
836
- }
837
- if (stateId === 'failed') {
838
- const lastError = normalizeErrorFull(context.lastError);
839
- return [
840
- {
841
- message: 'Workflow failed; awaiting Boss recovery.',
842
- ...(lastError === undefined
843
- ? {}
844
- : { data: snapshotJsonValue({ lastError }, 'failed status data') }),
845
- },
846
- ];
847
- }
848
- return [{ message: `Entered ${stateId}.` }];
905
+ function askerLabel(asker) {
906
+ return asker.kind === 'captain' ? 'Captain' : asker.roleId;
849
907
  }
850
- function makeDefaultStatusesForState(playerStates) {
908
+ function makeDefaultStatusesForState(roleStates) {
851
909
  return (state, context, event) => {
852
910
  const statuses = [];
853
911
  const guard = settlingGuard(event);
@@ -857,17 +915,17 @@ function makeDefaultStatusesForState(playerStates) {
857
915
  if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
858
916
  return statuses;
859
917
  }
860
- if (stateId === 'awaitBossReply') {
918
+ if (stateId === BOSS_REPLY_WAIT_STATE_ID) {
861
919
  const pending = pendingBossQuestionFromContext(context);
862
920
  if (pending === undefined) {
863
921
  return [...statuses, { message: 'Awaiting Boss reply.' }];
864
922
  }
865
923
  return [
866
924
  ...statuses,
867
- { message: `${pending.player} asks: ${pending.question}` },
925
+ { message: `${askerLabel(pending.asker)} asks: ${pending.question}` },
868
926
  {
869
927
  message: `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
870
- `${pending.player} · ${pending.sourceItem}`,
928
+ `${askerLabel(pending.asker)} · ${pending.sourceItem}`,
871
929
  },
872
930
  ];
873
931
  }
@@ -885,10 +943,10 @@ function makeDefaultStatusesForState(playerStates) {
885
943
  },
886
944
  ];
887
945
  }
888
- const playerState = playerStates.get(stateId);
889
- if (playerState !== undefined) {
946
+ const roleState = roleStates.get(stateId);
947
+ if (roleState !== undefined) {
890
948
  statuses.push({
891
- message: `⤷ ${playerState.player}: ${playerState.label}`,
949
+ message: `⤷ ${roleState.role}: ${roleState.label}`,
892
950
  });
893
951
  }
894
952
  return statuses;
@@ -1059,7 +1117,14 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
1059
1117
  const state = classifierState(snapshotOrState);
1060
1118
  const stateId = typeof state.value === 'string' ? state.value : undefined;
1061
1119
  const currentState = stateId ?? JSON.stringify(state.value ?? null);
1062
- const pending = pendingBossQuestionFromContext(state.context);
1120
+ // The classifier shares the reply-wait pendingness of every other
1121
+ // surface: outside the wait, a context question a later state retains
1122
+ // is answered history, so the prompt must not present it as pending —
1123
+ // a judge told a question awaits at the failure state is steered toward
1124
+ // a reply it cannot select or toward no action at all.
1125
+ const pending = stateId === BOSS_REPLY_WAIT_STATE_ID
1126
+ ? pendingBossQuestionFromContext(state.context)
1127
+ : undefined;
1063
1128
  const configuredTypes = configuredEventTypesForState(machine, stateId);
1064
1129
  const applicable = [...contracts.values()].filter((contract) => configuredTypes.has(contract.type) &&
1065
1130
  (contract.type !== 'BOSS_REPLY' || pending !== undefined));
@@ -1071,7 +1136,7 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
1071
1136
  `Current state: ${currentState}`,
1072
1137
  ];
1073
1138
  if (pending !== undefined) {
1074
- lines.push(`Pending question id: ${pending.questionId}`, `Pending asking player: ${pending.player}`, `Pending Boss question: ${pending.question}`);
1139
+ lines.push(`Pending question id: ${pending.questionId}`, `Pending asker: ${askerLabel(pending.asker)}`, `Pending Boss question: ${pending.question}`);
1075
1140
  }
1076
1141
  lines.push('', 'Allowed JSON objects:', '- { "type": "NO_ACTION" }');
1077
1142
  for (const contract of applicable) {
@@ -1175,6 +1240,60 @@ function machineDeclaresParallelState(machine) {
1175
1240
  };
1176
1241
  return visit(machine.config);
1177
1242
  }
1243
+ // PBRT-52: the factory's domain is FLAT single-region machines — every
1244
+ // state a direct child of the root, so each snapshot exposes exactly one
1245
+ // playbook state id and every state-keyed lookup (deterministic entries,
1246
+ // retry, reply-wait pendingness, configured events, descriptions) indexes
1247
+ // one unambiguous identity. A compound child would be accepted and then
1248
+ // silently misbehave on all of those gates, so it is rejected up front
1249
+ // exactly like a parallel region.
1250
+ function machineDeclaresNestedState(machine) {
1251
+ const config = machine.config;
1252
+ if (!isPlainObject(config) || !isPlainObject(config.states))
1253
+ return false;
1254
+ return Object.values(config.states).some((stateDef) => isPlainObject(stateDef) &&
1255
+ isPlainObject(stateDef.states) &&
1256
+ Object.keys(stateDef.states).length > 0);
1257
+ }
1258
+ // PBRT-52: the factory's lookups index states by their root key, and the
1259
+ // published playbook identity is `meta.playbook.stateId` — the two must
1260
+ // coincide or a machine can advertise a pending question or retry under an
1261
+ // identity no lookup resolves. A state with no string stateId is just as
1262
+ // dead: every snapshot identity derives from that member, so the first
1263
+ // entry would fail the exactly-one-state-id inspection at runtime.
1264
+ // gears2fsm keeps identity and key equal by construction; a hand-authored
1265
+ // artifact that splits or omits them fails here instead of at a silently
1266
+ // dead gate.
1267
+ function assertFlatStateIdentity(machine, label) {
1268
+ const config = machine.config;
1269
+ const states = isPlainObject(config) && isPlainObject(config.states)
1270
+ ? config.states
1271
+ : undefined;
1272
+ // A machine with no root states has no playbook identity to expose; its
1273
+ // first snapshot would fail the exactly-one-state-id inspection, so it
1274
+ // fails construction with the defect named instead.
1275
+ if (states === undefined || Object.keys(states).length === 0) {
1276
+ throw new Error(`${label} declares no root states; the shared runtime requires at ` +
1277
+ 'least one flat playbook state');
1278
+ }
1279
+ for (const [key, stateDef] of Object.entries(states)) {
1280
+ if (!isPlainObject(stateDef))
1281
+ continue;
1282
+ const meta = isPlainObject(stateDef.meta) ? stateDef.meta : undefined;
1283
+ const playbook = meta !== undefined && isPlainObject(meta.playbook)
1284
+ ? meta.playbook
1285
+ : undefined;
1286
+ const stateId = playbook?.stateId;
1287
+ if (typeof stateId !== 'string') {
1288
+ throw new Error(`${label} state ${key} declares no string meta.playbook.stateId; ` +
1289
+ 'the shared runtime derives every playbook state identity from it');
1290
+ }
1291
+ if (stateId !== key) {
1292
+ throw new Error(`${label} state ${key} declares meta.playbook.stateId ${stateId}; ` +
1293
+ 'the shared runtime requires the playbook state id to equal the state key');
1294
+ }
1295
+ }
1296
+ }
1178
1297
  /**
1179
1298
  * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
1180
1299
  * under the slc/link.md contract. The factory provides every actor kind the
@@ -1182,25 +1301,44 @@ function machineDeclaresParallelState(machine) {
1182
1301
  * (literal and dynamic) — and implements the full runtime lifecycle including
1183
1302
  * the optional parked-session snapshot capability (DR-014).
1184
1303
  *
1185
- * Scope: machines that declare no parallel state (each snapshot exposes
1186
- * exactly one playbook state id). Parallel-region FSMs keep their own linked
1187
- * runtimes.
1304
+ * Scope: flat single-region machines no parallel state, no compound
1305
+ * child states, and every root state's `meta.playbook.stateId` equal to its
1306
+ * state key — so each snapshot exposes exactly one playbook state id.
1307
+ * Parallel-region FSMs keep their own linked runtimes.
1188
1308
  */
1189
1309
  export function createXStatePlaybookRuntime(machine, spec) {
1190
1310
  const label = spec.label ?? 'playbook';
1191
1311
  // DR-022 / PBRT-50: reject an incompatible artifact declaration before any
1192
1312
  // machine interpretation, against this loaded engine's own self-report.
1193
1313
  assertRuntimeCompat(spec.compat, label);
1314
+ const specDescriptors = Object.getOwnPropertyDescriptors(spec);
1315
+ if (Object.prototype.hasOwnProperty.call(specDescriptors, 'playerStates')) {
1316
+ throw new TypeError(`${label} schema-2 artifacts must supply roleStates, not playerStates`);
1317
+ }
1318
+ if (Object.prototype.hasOwnProperty.call(specDescriptors, 'resolvePlayerId')) {
1319
+ throw new TypeError(`${label} schema-2 artifacts must not derive concrete player bindings`);
1320
+ }
1194
1321
  if (machineDeclaresParallelState(machine)) {
1195
1322
  throw new Error(`${label} uses a parallel state; the shared runtime supports only single-region FSMs`);
1196
1323
  }
1324
+ if (machineDeclaresNestedState(machine)) {
1325
+ throw new Error(`${label} declares a compound state; the shared runtime supports only flat single-region FSMs`);
1326
+ }
1327
+ assertFlatStateIdentity(machine, label);
1197
1328
  const declaredActors = collectInvokeSources(machine);
1198
1329
  const resumableStateIds = spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
1199
1330
  // DR-029: source state descriptions label the control actions the
1200
1331
  // runtime advertises through `describe()`.
1201
1332
  const stateDescriptions = stateDescriptionsFromMachine(machine);
1202
- const hasCanonicalStatusProfile = spec.playerStates !== undefined;
1203
- const playerStates = snapshotPlayerStateStatuses(spec.playerStates, label, machine, stateDescriptions);
1333
+ const roleStatesDescriptor = specDescriptors.roleStates;
1334
+ if (roleStatesDescriptor !== undefined &&
1335
+ !Object.prototype.hasOwnProperty.call(roleStatesDescriptor, 'value')) {
1336
+ throw new TypeError(`${label} roleStates must be an own data property`);
1337
+ }
1338
+ const roleStates = snapshotRoleStateStatuses(roleStatesDescriptor?.value, label, machine, stateDescriptions);
1339
+ const declaredRoleIds = Object.freeze([
1340
+ ...new Set([...roleStates.values()].map(({ role }) => role)),
1341
+ ]);
1204
1342
  // PBRT-52: the artifact's own ControlView context projection. Nothing is
1205
1343
  // exported by default, so an FSM context member — including one added
1206
1344
  // after this artifact was linked — is private until named here. The two
@@ -1215,7 +1353,6 @@ export function createXStatePlaybookRuntime(machine, spec) {
1215
1353
  throw new Error(`${label} controlContextFields must not name ${field}: the control view surfaces it first-class`);
1216
1354
  }
1217
1355
  }
1218
- const resolvePlayerIdSpec = spec.resolvePlayerId;
1219
1356
  const composePlayerPrompt = spec.composePlayerPrompt ??
1220
1357
  ((input) => defaultComposePlayerPrompt(input, spec.placeholderFields));
1221
1358
  const composeCaptainPrompt = spec.composeCaptainPrompt ??
@@ -1241,13 +1378,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
1241
1378
  const normalizeTransitionEvent = spec.normalizeTransitionEvent ??
1242
1379
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
1243
1380
  const statusesForState = spec.statusesForState ??
1244
- (hasCanonicalStatusProfile
1245
- ? makeDefaultStatusesForState(playerStates)
1246
- : legacyStatusesForState);
1381
+ makeDefaultStatusesForState(roleStates);
1247
1382
  const classificationStatus = spec.classificationStatus ??
1248
- (hasCanonicalStatusProfile
1249
- ? (event) => event.type
1250
- : () => undefined);
1383
+ ((event) => event.type);
1251
1384
  const machineInput = spec.machineInput ?? ((options) => options);
1252
1385
  const scriptCwd = spec.scriptCwd ??
1253
1386
  ((options) => {
@@ -1269,6 +1402,20 @@ export function createXStatePlaybookRuntime(machine, spec) {
1269
1402
  // ports.callPlayer / callCaptain / callJudge see the right cancellation
1270
1403
  // source. undefined between turns; set by the public boundaries.
1271
1404
  let activeSignal;
1405
+ // Immutable cancellation provenance for the active public boundary. A
1406
+ // nested resume widens it to include both invocation and resume signals;
1407
+ // mutable `activeSignal` alone cannot classify a late invocation reason.
1408
+ let activeAborts;
1409
+ // The bridge binds the provenance of a child result immediately before
1410
+ // its promise actor settles. The next root snapshot/error consumes this
1411
+ // one-shot so background settlement emissions retain their owner.
1412
+ let actorSettlementAborts;
1413
+ let actorSettlementErrorAborts;
1414
+ // Exact cancellation observed by an emission owned by the active
1415
+ // boundary. Ordinary runs settle from their signal/state; apply also
1416
+ // needs this phase-local evidence to fold a pre-publication failure into
1417
+ // its accepted receipt.
1418
+ let activeAbortEmission;
1272
1419
  let activeTurnId;
1273
1420
  let controlPlaneError;
1274
1421
  // Previous root-machine state for the inspect-driven telemetry /
@@ -1294,8 +1441,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
1294
1441
  // acceptance records nothing, so a later call with that key may still
1295
1442
  // execute.
1296
1443
  const appliedReceipts = new Map();
1297
- const playerResumeTokens = new Map();
1298
- const activePlayerIds = new Set();
1444
+ const privateResumeTokens = new Map();
1445
+ const activePlayerKeys = new Set();
1299
1446
  const playbookCallTurnIds = new Map();
1300
1447
  // Captain and judge work share one serialized lane (slc/link.md
1301
1448
  // §Session lifecycle).
@@ -1306,66 +1453,169 @@ export function createXStatePlaybookRuntime(machine, spec) {
1306
1453
  // Inspection callbacks enqueue a complete ordered batch synchronously;
1307
1454
  // imperative boundaries await their queued work directly.
1308
1455
  let emissionFailure;
1309
- function selectPlayerResume(playerId) {
1456
+ function bindSession(nextSession) {
1457
+ const bound = snapshotPlaybookSession(nextSession);
1458
+ if (bound.roleBindings === undefined)
1459
+ return bound;
1460
+ const actual = Object.keys(bound.roleBindings).sort();
1461
+ const expected = [...declaredRoleIds].sort();
1462
+ const missing = expected.filter((roleId) => !actual.includes(roleId));
1463
+ const extra = actual.filter((roleId) => !expected.includes(roleId));
1464
+ if (missing.length > 0 || extra.length > 0) {
1465
+ throw new TypeError(`${label} session roleBindings must cover exactly [${expected.join(', ')}]` +
1466
+ `${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
1467
+ `${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`);
1468
+ }
1469
+ return bound;
1470
+ }
1471
+ function requireRoleId(input) {
1472
+ const roleId = input.role;
1473
+ if (typeof roleId !== 'string' ||
1474
+ roleId.trim().length === 0 ||
1475
+ !declaredRoleIds.includes(roleId)) {
1476
+ throw new TypeError(`${label} player input role must name a declared local role`);
1477
+ }
1478
+ return roleId;
1479
+ }
1480
+ function resolvedPlayerId(roleId) {
1481
+ return session?.roleBindings?.[roleId]?.playerId;
1482
+ }
1483
+ function promptIdentity(roleId) {
1484
+ if (!declaredRoleIds.includes(roleId)) {
1485
+ throw new TypeError(`${label} prompt identity lookup rejected undeclared role ${roleId}`);
1486
+ }
1487
+ return session?.roleBindings?.[roleId]?.promptIdentity ?? roleId;
1488
+ }
1489
+ function composeBoundPlayerPrompt(input) {
1490
+ let active = true;
1491
+ const lookup = (roleId) => {
1492
+ if (!active) {
1493
+ throw new Error(`${label} prompt identity lookup is no longer active`);
1494
+ }
1495
+ return promptIdentity(roleId);
1496
+ };
1497
+ try {
1498
+ return composePlayerPrompt(input, lookup);
1499
+ }
1500
+ finally {
1501
+ active = false;
1502
+ }
1503
+ }
1504
+ function continuationKey(roleId, playerId) {
1505
+ return playerId ?? roleId;
1506
+ }
1507
+ function roleTokensByContinuationKey(tokens) {
1508
+ const byKey = new Map();
1509
+ for (const [roleId, token] of Object.entries(tokens)) {
1510
+ if (!declaredRoleIds.includes(roleId)) {
1511
+ throw new TypeError(`runtime role tokens contain unknown role ${roleId}`);
1512
+ }
1513
+ const key = continuationKey(roleId, resolvedPlayerId(roleId));
1514
+ const existing = byKey.get(key);
1515
+ if (existing !== undefined && existing !== token) {
1516
+ throw new TypeError(`runtime snapshot assigns conflicting tokens to roles bound to player ${key}`);
1517
+ }
1518
+ byKey.set(key, token);
1519
+ }
1520
+ const rolesByKey = new Map();
1521
+ for (const roleId of declaredRoleIds) {
1522
+ const key = continuationKey(roleId, resolvedPlayerId(roleId));
1523
+ rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
1524
+ }
1525
+ for (const [key, roles] of rolesByKey) {
1526
+ if (roles.length < 2)
1527
+ continue;
1528
+ const present = roles.filter((roleId) => tokens[roleId] !== undefined);
1529
+ if (present.length !== 0 && present.length !== roles.length) {
1530
+ throw new TypeError(`runtime role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`);
1531
+ }
1532
+ }
1533
+ return byKey;
1534
+ }
1535
+ function selectPlayerResume(roleId, playerId) {
1536
+ const key = continuationKey(roleId, playerId);
1310
1537
  const selected = session?.playerSessions
1311
- ? session.playerSessions.select(playerId)
1312
- : playerResumeTokens.get(playerId) ?? false;
1538
+ ? session.playerSessions.select(roleId)
1539
+ : privateResumeTokens.get(key) ?? false;
1313
1540
  if (selected !== false &&
1314
1541
  (typeof selected !== 'string' || selected.trim().length === 0)) {
1315
- throw new TypeError(`player session store returned an invalid resume token for ${playerId}`);
1542
+ throw new TypeError(`player session store returned an invalid resume token for role ${roleId}`);
1316
1543
  }
1317
1544
  return selected;
1318
1545
  }
1319
- function updatePlayerResume(playerId, resumeToken) {
1546
+ function updatePlayerResume(roleId, playerId, result) {
1547
+ const resumeToken = result.resumeToken;
1548
+ if (resumeToken === undefined && result.status !== 'ok')
1549
+ return;
1550
+ const key = continuationKey(roleId, playerId);
1320
1551
  if (session?.playerSessions) {
1321
- session.playerSessions.update(playerId, resumeToken);
1552
+ session.playerSessions.update(roleId, resumeToken);
1322
1553
  }
1323
- else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
1324
- playerResumeTokens.set(playerId, resumeToken);
1554
+ else if (resumeToken !== undefined) {
1555
+ privateResumeTokens.set(key, resumeToken);
1325
1556
  }
1326
1557
  else {
1327
- playerResumeTokens.delete(playerId);
1558
+ privateResumeTokens.delete(key);
1328
1559
  }
1329
1560
  }
1330
- function snapshotPlayerResumeTokens() {
1331
- const raw = session?.playerSessions
1561
+ function snapshotRoleResumeTokens() {
1562
+ const raw = snapshotJsonValue(session?.playerSessions
1332
1563
  ? session.playerSessions.snapshot()
1333
- : Object.fromEntries(playerResumeTokens);
1564
+ : Object.fromEntries(declaredRoleIds.flatMap((roleId) => {
1565
+ const token = privateResumeTokens.get(continuationKey(roleId, resolvedPlayerId(roleId)));
1566
+ return token === undefined ? [] : [[roleId, token]];
1567
+ })), 'player session store snapshot');
1334
1568
  if (!isPlainObject(raw)) {
1335
1569
  throw new TypeError('player session store snapshot must be an object');
1336
1570
  }
1337
1571
  const detached = {};
1338
- for (const [playerId, token] of Object.entries(raw)) {
1339
- if (playerId.trim().length === 0) {
1340
- throw new TypeError('player session store snapshot player ids must be non-empty');
1572
+ for (const [roleId, token] of Object.entries(raw)) {
1573
+ if (!declaredRoleIds.includes(roleId)) {
1574
+ throw new TypeError(`player session store snapshot contains unknown role ${roleId}`);
1341
1575
  }
1342
1576
  if (typeof token !== 'string' || token.trim().length === 0) {
1343
- throw new TypeError(`player session store snapshot token for ${playerId} must be a non-empty string`);
1577
+ throw new TypeError(`player session store snapshot token for ${roleId} must be a non-empty string`);
1344
1578
  }
1345
- detached[playerId] = token;
1579
+ detached[roleId] = token;
1346
1580
  }
1581
+ roleTokensByContinuationKey(detached);
1347
1582
  return detached;
1348
1583
  }
1349
- function restorePlayerResumeTokens(tokens) {
1584
+ function restoreRoleResumeTokens(tokens) {
1585
+ const byKey = roleTokensByContinuationKey(tokens);
1350
1586
  if (session?.playerSessions) {
1351
1587
  session.playerSessions.restore(tokens);
1352
1588
  return;
1353
1589
  }
1354
- playerResumeTokens.clear();
1355
- for (const [playerId, token] of Object.entries(tokens)) {
1356
- playerResumeTokens.set(playerId, token);
1357
- }
1358
- }
1359
- function enqueueEmission(fn) {
1590
+ privateResumeTokens.clear();
1591
+ for (const [key, token] of byKey)
1592
+ privateResumeTokens.set(key, token);
1593
+ }
1594
+ function enqueueEmission(fn, aborts = activeAborts) {
1595
+ // The emission belongs to the boundary enqueueing it: a rejection
1596
+ // causally identical to that boundary's abort reason is the
1597
+ // cancellation's own evidence — never latched, so it cannot poison a
1598
+ // later unrelated boundary (DR-036).
1599
+ const enqueueAborts = aborts;
1360
1600
  const queued = emissionQueue.add(fn).then(() => undefined);
1361
1601
  activeEmissionCalls.add(queued);
1362
1602
  void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
1363
1603
  activeEmissionCalls.delete(queued);
1364
- emissionFailure ??= error;
1604
+ if (enqueueAborts?.isAbortReason(error)) {
1605
+ // Record evidence only when it also belongs to the public
1606
+ // boundary that is still active. A background A cancellation
1607
+ // racing an unrelated B boundary is forgiven under A and must
1608
+ // not change B's settlement.
1609
+ if (activeAborts?.isAbortReason(error)) {
1610
+ activeAbortEmission ??= error;
1611
+ }
1612
+ return;
1613
+ }
1614
+ emissionFailure ??= { error };
1365
1615
  });
1366
1616
  return queued;
1367
1617
  }
1368
- async function drainEmissions() {
1618
+ async function drainEmissions(_aborts = activeAborts) {
1369
1619
  while (true) {
1370
1620
  const active = [...activeEmissionCalls];
1371
1621
  if (active.length > 0)
@@ -1378,8 +1628,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
1378
1628
  }
1379
1629
  }
1380
1630
  if (emissionFailure !== undefined) {
1381
- const error = emissionFailure;
1631
+ const { error } = emissionFailure;
1382
1632
  emissionFailure = undefined;
1633
+ // The failure was classified as distinct by its enqueue owner. If a
1634
+ // later public boundary drains it, retain that classification in the
1635
+ // boundary latch before throwing; its signal must not reinterpret
1636
+ // the same object as cancellation (DR-036 decision 2).
1637
+ if (activeSignal !== undefined)
1638
+ controlPlaneError ??= error;
1383
1639
  throw error;
1384
1640
  }
1385
1641
  }
@@ -1399,7 +1655,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1399
1655
  const currentSession = requireSession();
1400
1656
  const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
1401
1657
  return {
1402
- schemaVersion: 2,
1658
+ schemaVersion: 3,
1403
1659
  sessionId: currentSession.sessionId,
1404
1660
  playbookId: currentSession.playbookId,
1405
1661
  rootSessionId: currentSession.rootSessionId,
@@ -1418,13 +1674,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
1418
1674
  payload: safePayload,
1419
1675
  };
1420
1676
  }
1421
- function emitTrace(type, payload, position = {}) {
1677
+ function emitTrace(type, payload, position = {}, aborts) {
1422
1678
  const currentSession = requireSession();
1423
1679
  const event = createTraceEvent(type, payload, position);
1424
1680
  return enqueueEmission(() => currentSession.ports.emitTelemetry({
1425
1681
  topic: 'playbook.trace',
1426
1682
  payload: event,
1427
- }));
1683
+ }), aborts);
1428
1684
  }
1429
1685
  function stateIdentity(stateId) {
1430
1686
  return stateId === undefined ? {} : { stateId };
@@ -1482,6 +1738,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
1482
1738
  };
1483
1739
  }
1484
1740
  async function emitCallStarted(startedType, finishedType, identity, position,
1741
+ // The applicable combined signal: a start-sink rejection causally
1742
+ // identical to its reason is the cancellation itself, not a control
1743
+ // error — the pair finishes `aborted` and nothing latches
1744
+ // (slc/link.md §Abort).
1745
+ signal,
1485
1746
  // Base payload of the best-effort finish emitted when the start sink
1486
1747
  // rejects; it defaults to the payload the start carried, which the
1487
1748
  // player, judge, and captain pairs take as-is. The apply pair cannot:
@@ -1493,11 +1754,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
1493
1754
  await emitTrace(startedType, identity, position);
1494
1755
  }
1495
1756
  catch (error) {
1496
- controlPlaneError ??= error;
1757
+ if (!isAbortFailure(error, signal))
1758
+ controlPlaneError ??= error;
1497
1759
  try {
1498
1760
  await emitTrace(finishedType, {
1499
1761
  ...finishIdentity,
1500
- status: 'error',
1762
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1501
1763
  error: normalizeError(error),
1502
1764
  }, position);
1503
1765
  }
@@ -1508,42 +1770,44 @@ export function createXStatePlaybookRuntime(machine, spec) {
1508
1770
  }
1509
1771
  }
1510
1772
  const boundary = {
1511
- async callPlayer(input, playerId, prompt, signal) {
1773
+ async callPlayer(input, roleId, prompt, signal) {
1512
1774
  // State-entry telemetry/status must precede the call they describe.
1513
1775
  await drainEmissions();
1514
1776
  const turnId = activeTurnId;
1515
1777
  const stateId = input.stateId;
1778
+ const playerId = resolvedPlayerId(roleId);
1516
1779
  let resume;
1517
1780
  try {
1518
1781
  signal.throwIfAborted();
1519
- resume = selectPlayerResume(playerId);
1782
+ resume = selectPlayerResume(roleId, playerId);
1520
1783
  }
1521
1784
  catch (error) {
1522
- if (!signal.aborted)
1785
+ if (!isAbortFailure(error, signal))
1523
1786
  controlPlaneError ??= error;
1524
1787
  throw error;
1525
1788
  }
1526
1789
  const callId = `player-${++playerCallSequence}`;
1527
1790
  const identity = {
1528
- purpose: 'captain',
1529
1791
  ...stateIdentity(stateId),
1530
1792
  sourceItem: input.sourceItem,
1531
- playerId,
1793
+ roleId,
1794
+ ...(playerId === undefined ? {} : { playerId }),
1532
1795
  resume,
1533
1796
  };
1534
1797
  const position = {
1535
1798
  ...(turnId !== undefined ? { turnId } : {}),
1536
1799
  callId,
1537
1800
  };
1538
- if (activePlayerIds.has(playerId)) {
1539
- const error = new Error(`simultaneous calls to resolved player ${playerId} are not allowed`);
1540
- await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position);
1801
+ const playerKey = continuationKey(roleId, playerId);
1802
+ if (activePlayerKeys.has(playerKey)) {
1803
+ const error = new Error(`simultaneous calls to player key ${playerKey} are not allowed`);
1804
+ await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position, signal);
1541
1805
  await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
1542
1806
  throw error;
1543
1807
  }
1544
- activePlayerIds.add(playerId);
1808
+ activePlayerKeys.add(playerKey);
1545
1809
  try {
1546
- await emitTrace('player.call.started', { ...identity, prompt }, position);
1810
+ await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position, signal);
1547
1811
  let rawResult;
1548
1812
  try {
1549
1813
  // An abort may land while the awaited started emission drains
@@ -1551,18 +1815,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
1551
1815
  // never start after abort, so settle the already-started pair
1552
1816
  // as `aborted` through the catch below.
1553
1817
  signal.throwIfAborted();
1554
- rawResult = await requireHostPorts().callPlayer(playerId, prompt, signal, { resume });
1818
+ rawResult = await requireHostPorts().callPlayer(roleId, prompt, signal, { resume });
1555
1819
  // A host promise is not required to honor cancellation. Do not let
1556
1820
  // a late result mutate continuity or publish a successful finish.
1557
1821
  signal.throwIfAborted();
1558
1822
  }
1559
1823
  catch (error) {
1560
- if (!signal.aborted)
1824
+ if (!isAbortFailure(error, signal))
1561
1825
  controlPlaneError ??= error;
1562
1826
  try {
1563
1827
  await emitTrace('player.call.finished', {
1564
1828
  ...identity,
1565
- status: signal.aborted ? 'aborted' : 'error',
1829
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1566
1830
  error: normalizeError(error),
1567
1831
  }, position);
1568
1832
  }
@@ -1578,7 +1842,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1578
1842
  result = validatePlayerResult(rawResult);
1579
1843
  }
1580
1844
  catch (error) {
1581
- if (!signal.aborted)
1845
+ if (!isAbortFailure(error, signal))
1582
1846
  controlPlaneError ??= error;
1583
1847
  try {
1584
1848
  await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
@@ -1589,13 +1853,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
1589
1853
  throw error;
1590
1854
  }
1591
1855
  try {
1592
- updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
1593
- result.resumeToken.trim().length > 0
1594
- ? result.resumeToken
1595
- : undefined);
1856
+ updatePlayerResume(roleId, playerId, result);
1596
1857
  }
1597
1858
  catch (error) {
1598
- if (!signal.aborted)
1859
+ if (!isAbortFailure(error, signal))
1599
1860
  controlPlaneError ??= error;
1600
1861
  try {
1601
1862
  await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
@@ -1621,7 +1882,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1621
1882
  return result;
1622
1883
  }
1623
1884
  finally {
1624
- activePlayerIds.delete(playerId);
1885
+ activePlayerKeys.delete(playerKey);
1625
1886
  }
1626
1887
  },
1627
1888
  async callJudge(purpose, stateId, prompt, signal) {
@@ -1638,7 +1899,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1638
1899
  ...(turnId !== undefined ? { turnId } : {}),
1639
1900
  callId,
1640
1901
  };
1641
- await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position);
1902
+ await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position, signal);
1642
1903
  let reply;
1643
1904
  try {
1644
1905
  // An abort may land while the awaited started emission drains
@@ -1655,7 +1916,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1655
1916
  }
1656
1917
  await emitTrace('judge.call.finished', {
1657
1918
  ...identity,
1658
- status: signal.aborted ? 'aborted' : 'error',
1919
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1659
1920
  error: normalizeError(error),
1660
1921
  }, position);
1661
1922
  throw error;
@@ -1702,7 +1963,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1702
1963
  ...(turnId !== undefined ? { turnId } : {}),
1703
1964
  callId,
1704
1965
  };
1705
- await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position);
1966
+ await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position, signal);
1706
1967
  let rawResult;
1707
1968
  try {
1708
1969
  // An abort may land while the awaited started emission drains
@@ -1724,7 +1985,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1724
1985
  controlPlaneError ??= error;
1725
1986
  await emitTrace('captain.call.finished', {
1726
1987
  ...identity,
1727
- status: signal.aborted ? 'aborted' : 'error',
1988
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1728
1989
  error: normalizeError(error),
1729
1990
  }, position);
1730
1991
  throw error;
@@ -1786,20 +2047,16 @@ export function createXStatePlaybookRuntime(machine, spec) {
1786
2047
  });
1787
2048
  },
1788
2049
  };
1789
- function resolvePlayerId(input) {
1790
- return resolvePlayerIdSpec
1791
- ? resolvePlayerIdSpec(input, boundOptions)
1792
- : defaultResolvePlayerId(input);
1793
- }
1794
2050
  function playerActor(ports) {
1795
2051
  return createPlayerBridge({
1796
- resolvePlayerId,
1797
- composePlayerPrompt,
2052
+ resolveRoleId: requireRoleId,
2053
+ composePlayerPrompt: composeBoundPlayerPrompt,
1798
2054
  adjudication,
1799
2055
  resumableStateIds,
1800
2056
  }, ports, () => activeSignal, boundary, (error) => {
1801
- if (!activeSignal?.aborted)
2057
+ if (activeSignal === undefined || !isAbortFailure(error, activeSignal)) {
1802
2058
  controlPlaneError ??= error;
2059
+ }
1803
2060
  });
1804
2061
  }
1805
2062
  // Direct-Captain actor (slc/link.md §Captain prompt composition,
@@ -1866,7 +2123,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1866
2123
  // failure state (PBRT-47); everything else here — a drained
1867
2124
  // emission failure, prompt composition, the port itself,
1868
2125
  // adjudication — is control plane.
1869
- if (!active.aborted && !isFsmResultFailure(error)) {
2126
+ if (!isAbortFailure(error, active) && !isFsmResultFailure(error)) {
1870
2127
  controlPlaneError ??= error;
1871
2128
  }
1872
2129
  throw error;
@@ -1887,56 +2144,177 @@ export function createXStatePlaybookRuntime(machine, spec) {
1887
2144
  const failedGuard = guards[1] ?? guards[0];
1888
2145
  const cwd = boundScriptCwd ?? process.cwd();
1889
2146
  const ports = runtimePorts ?? requireHostPorts();
1890
- const exitStatus = await new Promise((resolve, reject) => {
1891
- let child;
1892
- try {
1893
- child = spawn('sh', ['-c', input.command], {
1894
- cwd,
1895
- stdio: 'ignore',
1896
- });
2147
+ // slc/link.md §Script execution: an already-aborted turn spawns
2148
+ // nothing, and the thrown signal reason keeps the rejection
2149
+ // causally classified as the abort it is.
2150
+ active.throwIfAborted();
2151
+ // Abort ownership — the listener that terminates the group and
2152
+ // the escalation timer — spans the whole invocation body, not
2153
+ // just the spawn-to-close window: an abort landing during the
2154
+ // post-exit emission tail must still kill surviving group
2155
+ // members before the actor settles (slc/link.md §Script
2156
+ // execution). One finally releases both.
2157
+ let child;
2158
+ let killTimer;
2159
+ const signalGroup = (sig) => {
2160
+ if (child?.pid !== undefined) {
2161
+ try {
2162
+ process.kill(-child.pid, sig);
2163
+ }
2164
+ catch {
2165
+ // Confirmation belongs to the bounded liveness probe below:
2166
+ // a failed signal can mean ESRCH, EPERM, or another fault.
2167
+ }
1897
2168
  }
1898
- catch (error) {
1899
- reject(error);
2169
+ };
2170
+ // After a SIGKILL is posted, settlement waits for the group to
2171
+ // stop being signalable — bounded by the same grace so an
2172
+ // unreapable member outside the runtime's control cannot stall
2173
+ // the turn forever. Observed teardown is milliseconds.
2174
+ let groupGonePromise;
2175
+ const awaitGroupGone = () => {
2176
+ const pid = child?.pid;
2177
+ if (pid === undefined)
2178
+ return Promise.resolve();
2179
+ groupGonePromise ??= (async () => {
2180
+ const teardownFailure = (message, cause) => {
2181
+ const failure = new ScriptProcessGroupTeardownError(pid, message, cause);
2182
+ // A teardown failure is not an authored script result.
2183
+ // Surface it at the active public boundary even though
2184
+ // XState also routes the rejected actor through onError.
2185
+ controlPlaneError ??= failure;
2186
+ return failure;
2187
+ };
2188
+ const deadline = Date.now() + SCRIPT_ABORT_KILL_GRACE_MS;
2189
+ let lastProbeError;
2190
+ for (;;) {
2191
+ try {
2192
+ process.kill(-pid, 0);
2193
+ }
2194
+ catch (error) {
2195
+ if (isNoSuchProcess(error))
2196
+ return;
2197
+ // EPERM confirms that at least one process in the group
2198
+ // still exists but is not signalable by this process. Keep
2199
+ // waiting for ESRCH within the bound; every other probe
2200
+ // error makes confirmation itself unreliable immediately.
2201
+ if (!isProcessPermissionDenied(error)) {
2202
+ throw teardownFailure('the liveness probe failed', error);
2203
+ }
2204
+ lastProbeError = error;
2205
+ }
2206
+ if (Date.now() >= deadline) {
2207
+ throw teardownFailure(`the group remained signalable after ${SCRIPT_ABORT_KILL_GRACE_MS}ms`, lastProbeError);
2208
+ }
2209
+ await new Promise((tick) => setTimeout(tick, 5));
2210
+ }
2211
+ })();
2212
+ return groupGonePromise;
2213
+ };
2214
+ const onAbort = () => {
2215
+ signalGroup('SIGTERM');
2216
+ killTimer = setTimeout(() => signalGroup('SIGKILL'), SCRIPT_ABORT_KILL_GRACE_MS);
2217
+ };
2218
+ // An abort observed once the shell has already exited rejects
2219
+ // with the signal's reason before guard resolution and before
2220
+ // starting any further script emission — after killing whatever
2221
+ // group members outlived the shell. The shell's own exit ended
2222
+ // the TERM grace's purpose, so escalation is immediate here.
2223
+ const settleIfAborted = async () => {
2224
+ if (!active.aborted)
1900
2225
  return;
2226
+ signalGroup('SIGKILL');
2227
+ await awaitGroupGone();
2228
+ active.throwIfAborted();
2229
+ };
2230
+ let invocationFailed = false;
2231
+ try {
2232
+ const exitStatus = await new Promise((resolve, reject) => {
2233
+ try {
2234
+ // detached: the shell leads its own POSIX process group,
2235
+ // so an abort can terminate the command's whole group — a
2236
+ // lone SIGTERM to the wrapper never reaches backgrounded
2237
+ // members.
2238
+ child = spawn('sh', ['-c', input.command], {
2239
+ cwd,
2240
+ stdio: 'ignore',
2241
+ detached: true,
2242
+ });
2243
+ }
2244
+ catch (error) {
2245
+ reject(error);
2246
+ return;
2247
+ }
2248
+ // On abort, terminate the group and escalate — but settle
2249
+ // only from 'close', after the shell itself has exited, so
2250
+ // the turn never reports quiescence while the script still
2251
+ // runs (slc/link.md §Abort). SIGKILL is untrappable, so
2252
+ // 'close' is bounded by the grace.
2253
+ active.addEventListener('abort', onAbort, { once: true });
2254
+ child.on('error', (error) => {
2255
+ reject(error);
2256
+ });
2257
+ child.on('close', (code) => {
2258
+ if (active.aborted) {
2259
+ // The shell may exit cooperatively on the group SIGTERM
2260
+ // while a TERM-immune same-group descendant survives;
2261
+ // the group stays addressable while any member lives,
2262
+ // so kill it and await its disappearance before
2263
+ // settling (slc/link.md §Script execution).
2264
+ signalGroup('SIGKILL');
2265
+ void awaitGroupGone().then(() => reject(active.reason), reject);
2266
+ return;
2267
+ }
2268
+ resolve(typeof code === 'number' ? code : 1);
2269
+ });
2270
+ });
2271
+ await settleIfAborted();
2272
+ await ports.emitStatus(`Executed script for ${input.stateId} (exit ${exitStatus}).`);
2273
+ await settleIfAborted();
2274
+ await ports.emitTelemetry({
2275
+ topic: 'playbook.script',
2276
+ payload: {
2277
+ stateId: input.stateId,
2278
+ sourceItem: input.sourceItem,
2279
+ exitStatus,
2280
+ },
2281
+ });
2282
+ await settleIfAborted();
2283
+ if (exitStatus === 0) {
2284
+ return { guard: okGuard, exitStatus: 0 };
1901
2285
  }
1902
- const onAbort = () => {
1903
- child.kill('SIGTERM');
1904
- reject(active.reason ?? new Error('script aborted'));
1905
- };
1906
- if (active.aborted) {
1907
- onAbort();
1908
- return;
2286
+ return { guard: failedGuard, exitStatus };
2287
+ }
2288
+ catch (error) {
2289
+ // Preserve the invocation's authoritative exact cancellation or
2290
+ // distinct sink failure after teardown succeeds. The finally
2291
+ // block may replace it only with a distinct teardown failure
2292
+ // when the process group cannot be confirmed gone.
2293
+ invocationFailed = true;
2294
+ throw error;
2295
+ }
2296
+ finally {
2297
+ try {
2298
+ if (active.aborted) {
2299
+ signalGroup('SIGKILL');
2300
+ await awaitGroupGone();
2301
+ if (!invocationFailed)
2302
+ active.throwIfAborted();
2303
+ }
1909
2304
  }
1910
- active.addEventListener('abort', onAbort, { once: true });
1911
- child.on('error', (error) => {
1912
- active.removeEventListener('abort', onAbort);
1913
- reject(error);
1914
- });
1915
- child.on('close', (code) => {
2305
+ finally {
1916
2306
  active.removeEventListener('abort', onAbort);
1917
- resolve(typeof code === 'number' ? code : 1);
1918
- });
1919
- });
1920
- await ports.emitStatus(`Executed script for ${input.stateId} (exit ${exitStatus}).`);
1921
- await ports.emitTelemetry({
1922
- topic: 'playbook.script',
1923
- payload: {
1924
- stateId: input.stateId,
1925
- sourceItem: input.sourceItem,
1926
- exitStatus,
1927
- },
1928
- });
1929
- if (exitStatus === 0) {
1930
- return { guard: okGuard, exitStatus: 0 };
2307
+ if (killTimer !== undefined)
2308
+ clearTimeout(killTimer);
2309
+ }
1931
2310
  }
1932
- return { guard: failedGuard, exitStatus };
1933
2311
  });
1934
2312
  }
1935
2313
  const nestedBridge = createNestedPlaybookBridge({
1936
2314
  nextCallId: () => `playbook-${++playbookCallSequence}`,
1937
2315
  getBoundarySignal: () => activeSignal,
1938
2316
  callPlaybook: (request, signal) => requireHostPorts().callPlaybook(request, signal),
1939
- emitStarted: async (event) => {
2317
+ emitStarted: async (event, aborts) => {
1940
2318
  playbookCallTurnIds.set(event.callId, activeTurnId);
1941
2319
  await emitTrace('playbook.call.started', {
1942
2320
  stateId: event.stateId,
@@ -1945,9 +2323,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
1945
2323
  }, {
1946
2324
  ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
1947
2325
  callId: event.callId,
1948
- });
2326
+ }, aborts);
1949
2327
  },
1950
- emitFinished: async (event) => {
2328
+ emitFinished: async (event, aborts) => {
1951
2329
  const turnId = playbookCallTurnIds.get(event.callId);
1952
2330
  try {
1953
2331
  await emitTrace('playbook.call.finished', {
@@ -1958,22 +2336,34 @@ export function createXStatePlaybookRuntime(machine, spec) {
1958
2336
  }, {
1959
2337
  ...(turnId !== undefined ? { turnId } : {}),
1960
2338
  callId: event.callId,
1961
- });
2339
+ }, aborts);
1962
2340
  }
1963
2341
  finally {
1964
2342
  playbookCallTurnIds.delete(event.callId);
1965
2343
  }
1966
2344
  },
1967
2345
  drain: drainEmissions,
1968
- bindResumeSignal: (signal) => {
2346
+ bindResumeSignal: (signal, aborts) => {
1969
2347
  activeSignal = signal;
2348
+ activeAborts = aborts ?? abortReasonClassifier(signal);
1970
2349
  },
1971
- onControlPlaneError: (error) => {
1972
- if (!activeSignal?.aborted)
2350
+ bindActorSettlement: (aborts) => {
2351
+ actorSettlementAborts = aborts;
2352
+ },
2353
+ onControlPlaneError: (error, aborts) => {
2354
+ // The shared bridge classifies before reporting against its own
2355
+ // invocation-and-resume signals; classify once more here against
2356
+ // the boundary signal so a report that is the active boundary's
2357
+ // exact abort reason can never masquerade as a control error
2358
+ // (slc/link.md §Abort).
2359
+ if (!aborts?.isAbortReason(error) &&
2360
+ !activeAborts?.isAbortReason(error)) {
1973
2361
  controlPlaneError ??= error;
2362
+ }
1974
2363
  },
1975
- onBackgroundError: (error) => {
1976
- emissionFailure ??= error;
2364
+ onBackgroundError: (error, aborts) => {
2365
+ if (!aborts?.isAbortReason(error))
2366
+ emissionFailure ??= { error };
1977
2367
  },
1978
2368
  });
1979
2369
  function tracePositionForActiveTurn() {
@@ -1987,11 +2377,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
1987
2377
  previousState: previousState ?? null,
1988
2378
  state,
1989
2379
  };
1990
- if (state.stateId === 'awaitBossReply') {
1991
- const pendingBossQuestion = pendingBossQuestionFromContext(context);
1992
- if (pendingBossQuestion !== undefined) {
1993
- payload.pendingBossQuestion = pendingBossQuestion;
1994
- }
2380
+ const pendingBossQuestion = pendingBossQuestionForState(state, context);
2381
+ if (pendingBossQuestion !== undefined) {
2382
+ payload.pendingBossQuestion = pendingBossQuestion;
1995
2383
  }
1996
2384
  if (state.stateId === 'failed') {
1997
2385
  const lastError = normalizeErrorFull(context.lastError);
@@ -2000,7 +2388,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2000
2388
  }
2001
2389
  return snapshotJsonValue(payload, 'FSM telemetry payload');
2002
2390
  }
2003
- function enqueueTransitionEmission(payload, state, statuses, position) {
2391
+ function enqueueTransitionEmission(payload, state, statuses, position, aborts) {
2004
2392
  const currentSession = requireSession();
2005
2393
  const transitionTrace = createTraceEvent('fsm.transition', payload, position);
2006
2394
  const statusEmissions = statuses.map(({ message, data }) => ({
@@ -2029,13 +2417,38 @@ export function createXStatePlaybookRuntime(machine, spec) {
2029
2417
  });
2030
2418
  await currentSession.ports.emitStatus(status.message, status.data);
2031
2419
  }
2032
- }).catch(() => undefined);
2420
+ }, aborts).catch(() => undefined);
2033
2421
  }
2034
- function latchInspectionError(error) {
2035
- if (activeSignal !== undefined)
2036
- controlPlaneError ??= error;
2422
+ // One classifying latch for every runtime-observed error — inspection
2423
+ // failures and root-actor errors alike. Outside a boundary the error
2424
+ // rides the emission channel, which the next boundary's (or init's)
2425
+ // drain throws; inside a boundary it is a control-plane error unless it
2426
+ // is the boundary signal's own abort reason (slc/link.md §Abort).
2427
+ function latchRuntimeError(error, aborts = activeAborts) {
2428
+ if (aborts?.isAbortReason(error))
2429
+ return;
2430
+ if (activeSignal === undefined)
2431
+ emissionFailure ??= { error };
2037
2432
  else
2038
- emissionFailure ??= error;
2433
+ controlPlaneError ??= error;
2434
+ }
2435
+ function consumeActorSettlementAborts(forSnapshot = false) {
2436
+ const aborts = actorSettlementAborts ?? actorSettlementErrorAborts;
2437
+ actorSettlementAborts = undefined;
2438
+ actorSettlementErrorAborts = undefined;
2439
+ if (forSnapshot && aborts !== undefined) {
2440
+ // XState can report an errored root through both its inspection
2441
+ // snapshot and subscriber. Keep the same provenance through that
2442
+ // synchronous notification only; an ordinary transition must not
2443
+ // lend it to a later unrelated actor error.
2444
+ actorSettlementErrorAborts = aborts;
2445
+ queueMicrotask(() => {
2446
+ if (actorSettlementErrorAborts === aborts) {
2447
+ actorSettlementErrorAborts = undefined;
2448
+ }
2449
+ });
2450
+ }
2451
+ return aborts;
2039
2452
  }
2040
2453
  // PBRT-6: the single seam that stops this runtime's actor. Stopping a
2041
2454
  // still-running actor fires one more `@xstate.snapshot` for the
@@ -2081,6 +2494,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2081
2494
  return;
2082
2495
  if (suppressInspectionEmissions)
2083
2496
  return;
2497
+ const settlementAborts = consumeActorSettlementAborts(true);
2084
2498
  try {
2085
2499
  const snap = inspectionEvent.snapshot;
2086
2500
  const state = normalizePlaybookSnapshot(snap);
@@ -2092,14 +2506,24 @@ export function createXStatePlaybookRuntime(machine, spec) {
2092
2506
  {});
2093
2507
  const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
2094
2508
  const statuses = statusesForState(state, context, inspectionEvent.event);
2095
- enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn());
2509
+ enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn(), settlementAborts);
2096
2510
  priorState = state;
2097
2511
  }
2098
2512
  catch (error) {
2099
- latchInspectionError(error);
2513
+ latchRuntimeError(error, settlementAborts);
2100
2514
  }
2101
2515
  },
2102
2516
  });
2517
+ // A synchronously-errored actor is already quiescent, so the turn's
2518
+ // quiescence wait never subscribes and XState would report the error
2519
+ // as unhandled after the boundary returns. Observe it through the
2520
+ // classifying latch: mid-boundary it is the control-plane error
2521
+ // unless it is the abort reason itself; at startup it rides the
2522
+ // emission channel so `init`'s own drain rejects with it and the
2523
+ // failed-start cleanup runs (slc/link.md §Session lifecycle).
2524
+ builtActor.subscribe({
2525
+ error: (error) => latchRuntimeError(error, consumeActorSettlementAborts()),
2526
+ });
2103
2527
  return builtActor;
2104
2528
  }
2105
2529
  function runResultFor(outcome, error) {
@@ -2116,14 +2540,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
2116
2540
  }
2117
2541
  if (outcome === 'terminal') {
2118
2542
  const output = actor?.getSnapshot()?.output;
2119
- if (output !== undefined) {
2120
- return {
2121
- outcome,
2122
- state,
2123
- output: snapshotJsonValue(output, 'terminal playbook output'),
2124
- };
2125
- }
2126
- return { outcome, state };
2543
+ const stateDescription = stateDescriptionFor(state);
2544
+ return {
2545
+ outcome,
2546
+ state,
2547
+ ...(stateDescription === undefined ? {} : { stateDescription }),
2548
+ ...(output === undefined
2549
+ ? {}
2550
+ : {
2551
+ output: snapshotJsonValue(output, 'terminal playbook output'),
2552
+ }),
2553
+ };
2127
2554
  }
2128
2555
  const failure = error ??
2129
2556
  (outcome === 'failed'
@@ -2141,15 +2568,23 @@ export function createXStatePlaybookRuntime(machine, spec) {
2141
2568
  function settledOutcome(signal) {
2142
2569
  if (nestedBridge.getPendingCall())
2143
2570
  return 'suspended';
2144
- if (signal.aborted)
2145
- return 'aborted';
2146
2571
  const state = currentState();
2147
2572
  if (state.status === 'error') {
2573
+ // An errored actor outranks a coincident abort unless the actor's
2574
+ // error is the abort reason itself (slc/link.md §Abort).
2148
2575
  const actorError = actor?.getSnapshot()?.error;
2576
+ if (actorError !== undefined && isAbortFailure(actorError, signal)) {
2577
+ return 'aborted';
2578
+ }
2149
2579
  throw actorError ?? new Error(`${label} actor entered error status`);
2150
2580
  }
2581
+ // Terminal completion outranks a coincident abort: the work finished,
2582
+ // and reporting `aborted` would hide a terminal machine behind a
2583
+ // settlement a later turn silently restarts (slc/link.md §Abort).
2151
2584
  if (state.status === 'done')
2152
2585
  return 'terminal';
2586
+ if (signal.aborted)
2587
+ return 'aborted';
2153
2588
  if (state.stateId === 'failed')
2154
2589
  return 'failed';
2155
2590
  return 'quiescent';
@@ -2210,8 +2645,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
2210
2645
  // The session-start error remains authoritative.
2211
2646
  }
2212
2647
  }
2213
- playerResumeTokens.clear();
2214
- activePlayerIds.clear();
2648
+ privateResumeTokens.clear();
2649
+ activePlayerKeys.clear();
2215
2650
  playbookCallTurnIds.clear();
2216
2651
  activeEmissionCalls.clear();
2217
2652
  emissionQueue.clear();
@@ -2222,6 +2657,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
2222
2657
  savedPorts = undefined;
2223
2658
  runtimePorts = undefined;
2224
2659
  activeSignal = undefined;
2660
+ activeAborts = undefined;
2661
+ actorSettlementAborts = undefined;
2662
+ actorSettlementErrorAborts = undefined;
2663
+ activeAbortEmission = undefined;
2225
2664
  activeTurnId = undefined;
2226
2665
  controlPlaneError = undefined;
2227
2666
  emissionFailure = undefined;
@@ -2243,28 +2682,56 @@ export function createXStatePlaybookRuntime(machine, spec) {
2243
2682
  can.call(snapshot, event) ===
2244
2683
  true);
2245
2684
  }
2685
+ // DR-034: where the artifact names the FSM context member its entry
2686
+ // action copies the exact Boss text into, that member of the live
2687
+ // snapshot is the retry payload's source. The persisted machine snapshot
2688
+ // carries it, so the candidate derives identically in the process that
2689
+ // exported the snapshot and in one that restored it, and a failure
2690
+ // reached after a Boss reply — whose recorded event the failure state
2691
+ // refuses — is recoverable too. Naming the member is the artifact's
2692
+ // statement that it holds the entry text: a same-named member is never
2693
+ // assumed, since inferring one would turn any matching context member
2694
+ // into a replay payload without its author saying so.
2695
+ // Declared and absent or empty excludes the candidate rather than
2696
+ // falling back to the record, which would make the action depend on the
2697
+ // process again — the very thing this source exists to end.
2698
+ function retryEventFrom(snapshot) {
2699
+ const entryEvent = spec.entryEvent;
2700
+ if (entryEvent?.contextField === undefined)
2701
+ return lastBossEvent;
2702
+ const context = snapshot?.context;
2703
+ const text = isPlainObject(context)
2704
+ ? context[entryEvent.contextField]
2705
+ : undefined;
2706
+ if (typeof text !== 'string' || text.trim() === '')
2707
+ return undefined;
2708
+ return { type: entryEvent.type, [entryEvent.textField]: text };
2709
+ }
2246
2710
  // The failure-state retry entry replays the recorded last classified
2247
- // event with its recorded payload. A candidate whose event the live
2248
- // snapshot does not accept or whose payload the runtime never
2249
- // recordedis excluded rather than completed with invented text.
2711
+ // event with its recorded payload, or the entry event the declared
2712
+ // context member above sources. A candidate whose event the live
2713
+ // snapshot does not accept or whose payload the runtime can source
2714
+ // from neither — is excluded rather than completed with invented text.
2250
2715
  function retryActionFor(snapshot, stateId) {
2251
- if (stateId !== 'failed' || lastBossEvent === undefined) {
2716
+ if (stateId !== 'failed')
2252
2717
  return undefined;
2253
- }
2254
- if (!snapshotCan(snapshot, lastBossEvent))
2718
+ const retryEvent = retryEventFrom(snapshot);
2719
+ if (retryEvent === undefined)
2720
+ return undefined;
2721
+ if (!snapshotCan(snapshot, retryEvent))
2255
2722
  return undefined;
2256
2723
  // A recorded explicit-state-jump event names the exact state its
2257
2724
  // replay re-enters: the root BOSS_INTERRUPT shape is a guarded
2258
2725
  // multi-arm list keyed on `targetId`, so the first configured arm
2259
2726
  // may label a different state than the one the recorded event
2260
2727
  // actually resumes.
2261
- const recordedTargetId = lastBossEvent.type === JUMP_EVENT_TYPE
2262
- ? lastBossEvent.targetId
2728
+ const recordedTargetId = retryEvent.type === JUMP_EVENT_TYPE
2729
+ ? retryEvent.targetId
2263
2730
  : undefined;
2264
2731
  const target = typeof recordedTargetId === 'string' &&
2265
2732
  recordedTargetId.trim().length > 0
2266
2733
  ? recordedTargetId
2267
- : firstTransitionTarget(machine, stateId, lastBossEvent.type);
2734
+ : firstTransitionTarget(machine, stateId, retryEvent.type);
2268
2735
  // PBRT-52: a label is written from a source state description, never
2269
2736
  // from an identifier. Falling back to the target id — or, with no
2270
2737
  // resolvable target, to the FSM event type — makes the label *be* the
@@ -2278,10 +2745,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
2278
2745
  return undefined;
2279
2746
  return {
2280
2747
  action: {
2281
- id: `retry:${lastBossEvent.type}`,
2748
+ id: `retry:${retryEvent.type}`,
2282
2749
  label: `Retry: ${description}`,
2283
2750
  },
2284
- event: lastBossEvent,
2751
+ event: retryEvent,
2285
2752
  };
2286
2753
  }
2287
2754
  function deriveControlActions(snapshot) {
@@ -2394,7 +2861,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2394
2861
  if (initialized || disposed || disposalPromise !== undefined) {
2395
2862
  throw new Error('createPlaybookRuntime.init: already initialized');
2396
2863
  }
2397
- const boundSession = snapshotPlaybookSession(nextSession);
2864
+ const boundSession = bindSession(nextSession);
2398
2865
  initialized = true;
2399
2866
  let finishInitialization;
2400
2867
  const initialization = new Promise((resolve) => {
@@ -2466,12 +2933,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
2466
2933
  const machineSnapshot = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
2467
2934
  const context = actor.getSnapshot()
2468
2935
  .context;
2469
- const pending = pendingBossQuestionFromContext(context ?? {});
2936
+ const pending = pendingBossQuestionForState(state, context ?? {});
2470
2937
  return {
2471
- schemaVersion: 2,
2938
+ schemaVersion: 3,
2472
2939
  playbookId: session.playbookId,
2473
2940
  machine: machineSnapshot,
2474
- playerResumeTokens: snapshotPlayerResumeTokens(),
2941
+ roleResumeTokens: snapshotRoleResumeTokens(),
2475
2942
  sequences: {
2476
2943
  trace: traceSequence,
2477
2944
  turn: turnSequence,
@@ -2488,7 +2955,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2488
2955
  : [
2489
2956
  {
2490
2957
  questionId: pending.questionId,
2491
- player: pending.player,
2958
+ asker: pending.asker,
2492
2959
  question: pending.question,
2493
2960
  sourceItem: pending.sourceItem,
2494
2961
  },
@@ -2505,11 +2972,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
2505
2972
  if (initialized || disposed || disposalPromise !== undefined) {
2506
2973
  throw new Error('createPlaybookRuntime.restore: already initialized');
2507
2974
  }
2508
- const boundSession = snapshotPlaybookSession(nextSession);
2975
+ const boundSession = bindSession(nextSession);
2509
2976
  const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId, { allowSuspendedCall: true });
2510
- const suspendedCall = boundSnapshot.schemaVersion === 2
2511
- ? boundSnapshot.suspendedCall
2512
- : undefined;
2977
+ if (declaredActors.has('captain') &&
2978
+ boundSnapshot.sequences.captainCall === undefined) {
2979
+ throw new TypeError('runtime snapshot sequences.captainCall is required for a direct-Captain artifact');
2980
+ }
2981
+ const suspendedCall = boundSnapshot.suspendedCall;
2513
2982
  let priorExternalPlayerTokens;
2514
2983
  let externalStoreRestoreAttempted = false;
2515
2984
  initialized = true;
@@ -2527,22 +2996,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
2527
2996
  judgeCallSequence = boundSnapshot.sequences.judgeCall;
2528
2997
  playerCallSequence = boundSnapshot.sequences.playerCall;
2529
2998
  playbookCallSequence = boundSnapshot.sequences.playbookCall;
2530
- captainCallSequence =
2531
- boundSnapshot.sequences.captainCall ??
2532
- // Legacy schema-v1 snapshots predate this dedicated counter.
2533
- // Every Captain call already consumed at least one trace number,
2534
- // so the global trace counter is a collision-safe id floor.
2535
- boundSnapshot.sequences.trace;
2999
+ captainCallSequence = boundSnapshot.sequences.captainCall ?? 0;
2536
3000
  // The runtime snapshot carries no apply counter (PBRT-50); every
2537
3001
  // apply boundary consumed trace numbers, so the persisted trace
2538
3002
  // counter is a collision-safe id floor here too, keeping
2539
3003
  // `apply-<n>` call ids unique across restore.
2540
3004
  applyCallSequence = boundSnapshot.sequences.trace;
2541
3005
  if (boundSession.playerSessions) {
2542
- priorExternalPlayerTokens = snapshotPlayerResumeTokens();
3006
+ priorExternalPlayerTokens = snapshotRoleResumeTokens();
2543
3007
  externalStoreRestoreAttempted = true;
2544
3008
  }
2545
- restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
3009
+ restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
2546
3010
  nestedBridge.prepareRestore(suspendedCall);
2547
3011
  if (suspendedCall !== undefined) {
2548
3012
  playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
@@ -2550,8 +3014,23 @@ export function createXStatePlaybookRuntime(machine, spec) {
2550
3014
  suppressInspectionEmissions = true;
2551
3015
  actor = buildActor(runtimePorts, boundSnapshot.machine);
2552
3016
  actor.start();
2553
- if (controlPlaneError !== undefined)
2554
- throw controlPlaneError;
3017
+ // A start-time actor error rides the startup emission channel
3018
+ // (latchRuntimeError); consume both latches here so the original
3019
+ // error outranks the derived status check below.
3020
+ {
3021
+ const startupFailure = emissionFailure;
3022
+ if (controlPlaneError !== undefined ||
3023
+ startupFailure !== undefined) {
3024
+ const startupError = controlPlaneError !== undefined
3025
+ ? controlPlaneError
3026
+ : startupFailure.error;
3027
+ controlPlaneError = undefined;
3028
+ if (emissionFailure === startupFailure) {
3029
+ emissionFailure = undefined;
3030
+ }
3031
+ throw startupError;
3032
+ }
3033
+ }
2555
3034
  const restoredState = normalizePlaybookSnapshot(actor.getSnapshot(), suspendedCall === undefined
2556
3035
  ? {}
2557
3036
  : {
@@ -2617,7 +3096,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2617
3096
  const state = currentState();
2618
3097
  const context = (snapshot.context ??
2619
3098
  {});
2620
- const pending = pendingBossQuestionFromContext(context);
3099
+ const pending = pendingBossQuestionForState(state, context);
2621
3100
  const lastError = normalizeErrorFull(context.lastError);
2622
3101
  const projectedContext = projectControlContext(context);
2623
3102
  const stateDescription = stateDescriptionFor(state);
@@ -2632,7 +3111,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2632
3111
  : [
2633
3112
  {
2634
3113
  questionId: pending.questionId,
2635
- player: pending.player,
3114
+ asker: pending.asker,
2636
3115
  question: pending.question,
2637
3116
  sourceItem: pending.sourceItem,
2638
3117
  },
@@ -2685,6 +3164,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
2685
3164
  const position = { turnId, callId };
2686
3165
  activeTurnId = turnId;
2687
3166
  activeSignal = signal;
3167
+ activeAborts = abortReasonClassifier(signal);
3168
+ activeAbortEmission = undefined;
2688
3169
  controlPlaneError = undefined;
2689
3170
  // Every receipt variant is normalized and frozen where it is built,
2690
3171
  // inside the guarded region, so the recording step below cannot
@@ -2730,9 +3211,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
2730
3211
  // final for their key. Past publication such a failure is therefore
2731
3212
  // re-latched onto the emission channel, surfacing from the next
2732
3213
  // public boundary's drain, and `apply` still does not throw past
2733
- // acceptance (PBRT-52).
3214
+ // acceptance (PBRT-52). A delivery rejection causally identical to
3215
+ // this call's own abort reason evidences the cancellation and is
3216
+ // dropped — never carried to a later unrelated boundary
3217
+ // (slc/link.md §Abort).
2734
3218
  const latchDeliveryFailure = (error) => {
2735
- emissionFailure ??= error;
3219
+ if (isAbortFailure(error, signal))
3220
+ return;
3221
+ emissionFailure ??= { error };
2736
3222
  };
2737
3223
  try {
2738
3224
  try {
@@ -2752,7 +3238,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2752
3238
  key,
2753
3239
  ...receiptTracePayload({ disposition: 'rejected', reason }),
2754
3240
  });
2755
- await emitCallStarted('apply.started', 'apply.finished', identity, position, preAcceptanceFinish('apply.started trace sink rejected'));
3241
+ await emitCallStarted('apply.started', 'apply.finished', identity, position, signal, preAcceptanceFinish('apply.started trace sink rejected'));
2756
3242
  // An abort may land while the awaited started emission drains
2757
3243
  // (e.g. fired from the trace sink itself); the action must
2758
3244
  // never execute after abort. Settle the already-started pair
@@ -2831,6 +3317,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
2831
3317
  catch (error) {
2832
3318
  settlementError = error;
2833
3319
  }
3320
+ // Exact cancellation is not a control-plane latch, but after apply
3321
+ // acceptance and before publication it is still settlement evidence
3322
+ // and therefore folds into the owed failed receipt (DR-036 §4).
3323
+ settlementError ??= activeAbortEmission;
2834
3324
  // Fold before the finish emission, the last point at which the
2835
3325
  // traced disposition and the returned one can still be made the
2836
3326
  // same value.
@@ -2872,6 +3362,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
2872
3362
  // wedge every later public boundary behind "another runtime turn
2873
3363
  // is active".
2874
3364
  activeSignal = undefined;
3365
+ activeAborts = undefined;
3366
+ activeAbortEmission = undefined;
2875
3367
  activeTurnId = undefined;
2876
3368
  controlPlaneError = undefined;
2877
3369
  }
@@ -2907,119 +3399,152 @@ export function createXStatePlaybookRuntime(machine, spec) {
2907
3399
  const turnId = ++turnSequence;
2908
3400
  activeTurnId = turnId;
2909
3401
  activeSignal = signal;
3402
+ activeAborts = abortReasonClassifier(signal);
3403
+ activeAbortEmission = undefined;
2910
3404
  controlPlaneError = undefined;
2911
3405
  let result;
2912
3406
  let operationError;
3407
+ // The boundary sentinel releases on every exit: a settlement defect
3408
+ // past the drain — a snapshot normalization throw inside
3409
+ // `runResultFor` included — must never wedge every later public
3410
+ // boundary and `dispose` itself behind "another runtime turn is
3411
+ // active". Mirrors the apply boundary's finally.
2913
3412
  try {
2914
- await emitTrace('boss.input.received', { text }, { turnId });
2915
- // 1. Map the Boss text to an FSM event: deterministic exact entry
2916
- // where applicable (slc/link.md §Boss-event mapping), judge
2917
- // classification otherwise.
2918
- let event;
2919
- const trimmed = text.trim();
2920
- if (trimmed !== '') {
2921
- const snapshot = actor.getSnapshot();
2922
- const terminal = snapshot.status === 'done';
2923
- const stateId = normalizePlaybookSnapshot(snapshot).stateId;
2924
- if (spec.entryEvent !== undefined &&
2925
- (stateId === 'ready' || terminal)) {
2926
- event = {
2927
- type: spec.entryEvent.type,
2928
- [spec.entryEvent.textField]: text,
2929
- };
2930
- }
2931
- else {
2932
- event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
2933
- }
2934
- signal.throwIfAborted();
2935
- }
2936
- // Empty input, no-action classifier output, or invalid classifier
2937
- // output — nothing to send.
2938
- if (event === undefined) {
2939
- result = runResultFor('no-action');
2940
- }
2941
- else {
2942
- // 2. Optional Captain-pane classification line: the bare FSM
2943
- // event type, emitted before the FSM advances.
2944
- const statusLine = classificationStatus(event);
2945
- if (statusLine !== undefined) {
2946
- await runtimePorts.emitStatus(statusLine);
2947
- }
3413
+ try {
3414
+ await emitTrace('boss.input.received', { text }, { turnId });
3415
+ // Record the attempted input, then refuse a boundary that entered
3416
+ // aborted before deterministic mapping or the classifier can
3417
+ // perform host-visible work (DR-036 §5).
2948
3418
  signal.throwIfAborted();
2949
- // 3. A final actor cannot accept new events; reconstruct only
2950
- // after classification produced a real event.
2951
- if (actor.getSnapshot().status === 'done') {
2952
- stopActor();
2953
- actor = buildActor(runtimePorts);
2954
- // The replacement actor's snapshots are real state entries.
2955
- suppressInspectionEmissions = false;
2956
- actor.start();
3419
+ // 1. Map the Boss text to an FSM event: deterministic exact entry
3420
+ // where applicable (slc/link.md §Boss-event mapping), judge
3421
+ // classification otherwise.
3422
+ let event;
3423
+ const trimmed = text.trim();
3424
+ if (trimmed !== '') {
3425
+ const snapshot = actor.getSnapshot();
3426
+ const terminal = snapshot.status === 'done';
3427
+ const stateId = normalizePlaybookSnapshot(snapshot).stateId;
3428
+ // PBRT-1 / slc/link.md §Boss-event mapping: the idle entry, the
3429
+ // recoverable failure state, and the reconstructed terminal all
3430
+ // accept exactly one ordinary textual entry event, so delivered
3431
+ // text enters deterministically — no judge call to spend and no
3432
+ // classifier whim to settle a restart as no action. Every other
3433
+ // parked state — a reply wait or an authored mid-workflow
3434
+ // checkpoint — classifies under its own Boss-event contracts.
3435
+ if (spec.entryEvent !== undefined &&
3436
+ (stateId === 'ready' || stateId === 'failed' || terminal)) {
3437
+ event = {
3438
+ type: spec.entryEvent.type,
3439
+ [spec.entryEvent.textField]: text,
3440
+ };
3441
+ }
3442
+ else {
3443
+ event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
3444
+ }
3445
+ signal.throwIfAborted();
2957
3446
  }
2958
- // DR-029: keep the classified event with its recorded payload
2959
- // as the retry-replay source. Recording is sanitizing, not
2960
- // load-bearing: an override classifier's non-JSON-safe event is
2961
- // simply not recorded, and the turn proceeds unchanged.
2962
- try {
2963
- lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
3447
+ // Empty input, no-action classifier output, or invalid classifier
3448
+ // output nothing to send.
3449
+ if (event === undefined) {
3450
+ result = runResultFor('no-action');
2964
3451
  }
2965
- catch {
2966
- lastBossEvent = undefined;
3452
+ else {
3453
+ // 2. Optional Captain-pane classification line: the bare FSM
3454
+ // event type, emitted before the FSM advances.
3455
+ const statusLine = classificationStatus(event);
3456
+ if (statusLine !== undefined) {
3457
+ await runtimePorts.emitStatus(statusLine);
3458
+ }
3459
+ signal.throwIfAborted();
3460
+ // 3. A final actor cannot accept new events; reconstruct only
3461
+ // after classification produced a real event.
3462
+ if (actor.getSnapshot().status === 'done') {
3463
+ stopActor();
3464
+ actor = buildActor(runtimePorts);
3465
+ // The replacement actor's snapshots are real state entries.
3466
+ suppressInspectionEmissions = false;
3467
+ actor.start();
3468
+ }
3469
+ // DR-029: keep the classified event with its recorded payload
3470
+ // as the retry-replay source. Recording is sanitizing, not
3471
+ // load-bearing: an override classifier's non-JSON-safe event is
3472
+ // simply not recorded, and the turn proceeds unchanged.
3473
+ try {
3474
+ lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
3475
+ }
3476
+ catch {
3477
+ lastBossEvent = undefined;
3478
+ }
3479
+ actor.send(event);
3480
+ await waitForPlaybookQuiescence(actor, {
3481
+ pendingCalls: nestedBridge,
3482
+ });
3483
+ if (controlPlaneError !== undefined)
3484
+ throw controlPlaneError;
3485
+ result = runResultFor(settledOutcome(signal));
2967
3486
  }
2968
- actor.send(event);
2969
- await waitForPlaybookQuiescence(actor, {
2970
- pendingCalls: nestedBridge,
2971
- });
2972
- if (controlPlaneError !== undefined)
2973
- throw controlPlaneError;
2974
- result = runResultFor(settledOutcome(signal));
2975
3487
  }
3488
+ catch (error) {
3489
+ operationError = error;
3490
+ }
3491
+ let drainError;
3492
+ try {
3493
+ await drainEmissions();
3494
+ }
3495
+ catch (error) {
3496
+ drainError = error;
3497
+ }
3498
+ const latchedControlError = controlPlaneError;
3499
+ // A drain rejection that is the exact abort reason evidences the
3500
+ // cancellation, not a control-plane failure (slc/link.md §Abort).
3501
+ const drainAbort = drainError !== undefined && isAbortFailure(drainError, signal);
3502
+ const effectiveDrainError = drainAbort ? undefined : drainError;
3503
+ const primaryError = latchedControlError ?? effectiveDrainError ?? operationError;
3504
+ const abortError = latchedControlError === undefined &&
3505
+ effectiveDrainError === undefined &&
3506
+ ((operationError !== undefined &&
3507
+ isAbortFailure(operationError, signal)) ||
3508
+ (drainAbort && operationError === undefined));
3509
+ const settlementResult = primaryError === undefined
3510
+ ? (result ?? runResultFor('no-action'))
3511
+ : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
3512
+ let settlementEmissionError;
3513
+ try {
3514
+ await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
3515
+ }
3516
+ catch (error) {
3517
+ settlementEmissionError = error;
3518
+ }
3519
+ try {
3520
+ await drainEmissions();
3521
+ }
3522
+ catch (error) {
3523
+ settlementEmissionError ??= error;
3524
+ }
3525
+ if (settlementEmissionError !== undefined &&
3526
+ isAbortFailure(settlementEmissionError, signal)) {
3527
+ settlementEmissionError = undefined;
3528
+ }
3529
+ const failure = controlPlaneError ??
3530
+ latchedControlError ??
3531
+ effectiveDrainError ??
3532
+ (abortError
3533
+ ? (settlementEmissionError ?? operationError)
3534
+ : (operationError ?? settlementEmissionError));
3535
+ if (failure !== undefined &&
3536
+ !(abortError && settlementEmissionError === undefined)) {
3537
+ throw failure;
3538
+ }
3539
+ return settlementResult;
2976
3540
  }
2977
- catch (error) {
2978
- operationError = error;
2979
- }
2980
- let drainError;
2981
- try {
2982
- await drainEmissions();
2983
- }
2984
- catch (error) {
2985
- drainError = error;
2986
- }
2987
- const latchedControlError = controlPlaneError;
2988
- const primaryError = latchedControlError ?? drainError ?? operationError;
2989
- const abortError = latchedControlError === undefined &&
2990
- drainError === undefined &&
2991
- operationError !== undefined &&
2992
- isAbortFailure(operationError, signal);
2993
- const settlementResult = primaryError === undefined
2994
- ? (result ?? runResultFor('no-action'))
2995
- : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
2996
- let settlementEmissionError;
2997
- try {
2998
- await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
2999
- }
3000
- catch (error) {
3001
- settlementEmissionError = error;
3002
- }
3003
- try {
3004
- await drainEmissions();
3005
- }
3006
- catch (error) {
3007
- settlementEmissionError ??= error;
3008
- }
3009
- const failure = controlPlaneError ??
3010
- latchedControlError ??
3011
- drainError ??
3012
- (abortError
3013
- ? (settlementEmissionError ?? operationError)
3014
- : (operationError ?? settlementEmissionError));
3015
- activeSignal = undefined;
3016
- activeTurnId = undefined;
3017
- controlPlaneError = undefined;
3018
- if (failure !== undefined &&
3019
- !(abortError && settlementEmissionError === undefined)) {
3020
- throw failure;
3541
+ finally {
3542
+ activeSignal = undefined;
3543
+ activeAborts = undefined;
3544
+ activeAbortEmission = undefined;
3545
+ activeTurnId = undefined;
3546
+ controlPlaneError = undefined;
3021
3547
  }
3022
- return settlementResult;
3023
3548
  },
3024
3549
  async resumePlaybookCall(input) {
3025
3550
  if (!actor || !savedPorts) {
@@ -3033,41 +3558,85 @@ export function createXStatePlaybookRuntime(machine, spec) {
3033
3558
  }
3034
3559
  activeTurnId = playbookCallTurnIds.get(input.callId);
3035
3560
  activeSignal = input.signal;
3561
+ activeAborts = abortReasonClassifier(input.signal);
3562
+ activeAbortEmission = undefined;
3036
3563
  controlPlaneError = undefined;
3037
- let result;
3038
- let operationError;
3564
+ // The boundary sentinel releases on every exit, mirroring
3565
+ // `handleBossInput` and the apply boundary.
3039
3566
  try {
3040
- await nestedBridge.resume(input);
3041
- }
3042
- catch (error) {
3043
- operationError = error;
3044
- }
3045
- try {
3046
- await waitForPlaybookQuiescence(actor, {
3047
- pendingCalls: nestedBridge,
3048
- });
3049
- result = runResultFor(settledOutcome(input.signal));
3050
- }
3051
- catch (error) {
3052
- operationError ??= error;
3053
- }
3054
- let drainError;
3055
- try {
3056
- await drainEmissions();
3057
- }
3058
- catch (error) {
3059
- drainError = error;
3567
+ let result;
3568
+ let operationError;
3569
+ try {
3570
+ await nestedBridge.resume(input);
3571
+ }
3572
+ catch (error) {
3573
+ operationError = error;
3574
+ }
3575
+ try {
3576
+ await waitForPlaybookQuiescence(actor, {
3577
+ pendingCalls: nestedBridge,
3578
+ });
3579
+ result = runResultFor(settledOutcome(input.signal));
3580
+ }
3581
+ catch (error) {
3582
+ operationError ??= error;
3583
+ }
3584
+ // A resume refused because its signal was already aborted
3585
+ // delivers nothing: the pending call survives for a later
3586
+ // resume, and the boundary settles `aborted` rather than
3587
+ // advertising `suspended` (slc/link.md §Nested playbook bridge).
3588
+ if (operationError !== undefined &&
3589
+ isAbortFailure(operationError, input.signal) &&
3590
+ nestedBridge.getPendingCall()?.callId === input.callId) {
3591
+ result = {
3592
+ outcome: 'aborted',
3593
+ state: currentState(),
3594
+ error: normalizeError(input.signal.reason),
3595
+ };
3596
+ }
3597
+ let drainError;
3598
+ try {
3599
+ await drainEmissions();
3600
+ }
3601
+ catch (error) {
3602
+ drainError = error;
3603
+ }
3604
+ const aborts = activeAborts ?? abortReasonClassifier(input.signal);
3605
+ // A control-plane latch has already classified its failure as
3606
+ // distinct under the owning operation. Never reinterpret it
3607
+ // against this later resume signal (DR-036 decision 2).
3608
+ const controlFailure = controlPlaneError;
3609
+ const drainAbort = controlFailure === undefined &&
3610
+ drainError !== undefined &&
3611
+ aborts.isAbortReason(drainError);
3612
+ const operationAbort = controlFailure === undefined &&
3613
+ operationError !== undefined &&
3614
+ aborts.isAbortReason(operationError);
3615
+ const abortEvidence = activeAbortEmission ??
3616
+ (drainAbort ? drainError : undefined) ??
3617
+ (operationAbort ? operationError : undefined);
3618
+ const failure = controlFailure ??
3619
+ (drainAbort ? undefined : drainError) ??
3620
+ (operationAbort ? undefined : operationError);
3621
+ if (failure !== undefined)
3622
+ throw failure;
3623
+ if (abortEvidence !== undefined &&
3624
+ result?.outcome !== 'terminal' &&
3625
+ result?.outcome !== 'suspended') {
3626
+ result = runResultFor('aborted', abortEvidence);
3627
+ }
3628
+ if (result === undefined) {
3629
+ throw new Error('playbook resume produced no runtime result');
3630
+ }
3631
+ return result;
3060
3632
  }
3061
- const failure = controlPlaneError ?? drainError ?? operationError;
3062
- activeSignal = undefined;
3063
- activeTurnId = undefined;
3064
- controlPlaneError = undefined;
3065
- if (failure !== undefined)
3066
- throw failure;
3067
- if (result === undefined) {
3068
- throw new Error('playbook resume produced no runtime result');
3633
+ finally {
3634
+ activeSignal = undefined;
3635
+ activeAborts = undefined;
3636
+ activeAbortEmission = undefined;
3637
+ activeTurnId = undefined;
3638
+ controlPlaneError = undefined;
3069
3639
  }
3070
- return result;
3071
3640
  },
3072
3641
  dispose() {
3073
3642
  if (disposalPromise !== undefined)
@@ -3126,9 +3695,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
3126
3695
  // engagement tree. Child disposal must not erase a token its
3127
3696
  // caller will resume. The private fallback remains runtime-owned.
3128
3697
  if (session?.playerSessions === undefined) {
3129
- playerResumeTokens.clear();
3698
+ privateResumeTokens.clear();
3130
3699
  }
3131
- activePlayerIds.clear();
3700
+ activePlayerKeys.clear();
3132
3701
  playbookCallTurnIds.clear();
3133
3702
  activeEmissionCalls.clear();
3134
3703
  emissionQueue.clear();
@@ -3136,6 +3705,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
3136
3705
  appliedReceipts.clear();
3137
3706
  actor = undefined;
3138
3707
  activeSignal = undefined;
3708
+ activeAborts = undefined;
3709
+ actorSettlementAborts = undefined;
3710
+ actorSettlementErrorAborts = undefined;
3711
+ activeAbortEmission = undefined;
3139
3712
  activeTurnId = undefined;
3140
3713
  controlPlaneError = undefined;
3141
3714
  emissionFailure = undefined;