@sublang/playbook 8.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 (30) hide show
  1. package/README.md +3 -3
  2. package/docs/cli.md +15 -15
  3. package/docs/configuration.md +13 -8
  4. package/docs/embedding.md +7 -2
  5. package/package.json +1 -1
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +14 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +18 -4
  8. package/reference/sdlc/code.playbook/code.fsm.d.ts +4 -1
  9. package/reference/sdlc/code.playbook/code.fsm.js +11 -4
  10. package/reference/sdlc/code.playbook/code.fsm.ts +12 -4
  11. package/reference/sdlc/code.playbook/code.playbook.js +14 -3
  12. package/reference/sdlc/code.playbook/code.playbook.ts +13 -3
  13. package/reference/sdlc/code.playbook/playbook-captain.js +44 -10
  14. package/reference/sdlc/code.playbook/playbook-captain.ts +47 -10
  15. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
  16. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +2 -0
  17. package/reference/sdlc/decide.playbook/decide.playbook.js +299 -117
  18. package/reference/sdlc/decide.playbook/decide.playbook.ts +395 -131
  19. package/reference/sdlc/review.playbook/review.playbook.js +14 -3
  20. package/reference/sdlc/review.playbook/review.playbook.ts +13 -3
  21. package/slc/gears2fsm.md +19 -2
  22. package/slc/link.md +184 -42
  23. package/src/runtime.d.ts +1 -0
  24. package/src/runtime.ts +1 -0
  25. package/src/xstate-playbook-runtime.d.ts +13 -3
  26. package/src/xstate-playbook-runtime.js +732 -251
  27. package/src/xstate-playbook-runtime.ts +873 -280
  28. package/src/xstate-runtime.d.ts +17 -7
  29. package/src/xstate-runtime.js +135 -57
  30. package/src/xstate-runtime.ts +243 -84
@@ -30,19 +30,28 @@ function snapshotDecideRuntimeOptions(value) {
30
30
  }
31
31
  return Object.freeze({});
32
32
  }
33
- const STATE_DESCRIPTIONS = {
34
- ready: 'Waiting for a topic to decide.',
35
- askCoderProposal: 'Coder independently proposes a spec design.',
36
- askReviewerProposal: 'Reviewer independently proposes a spec design.',
37
- waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
38
- waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
39
- commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
40
- awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
41
- reviewCommit: 'REVIEW examines the committed proposal.',
42
- failed: 'DECIDE failed and is waiting for a new topic.',
43
- reportedReviewFailure: 'DECIDE reports REVIEW’s failure and its last commit.',
44
- done: 'DECIDE completed with an approved commit.',
45
- };
33
+ function authoredStateDescriptions(states) {
34
+ const descriptions = {};
35
+ const visit = (children) => {
36
+ for (const state of Object.values(children ?? {})) {
37
+ const stateId = state.meta?.playbook?.stateId;
38
+ const description = state.meta?.playbook?.description;
39
+ if (typeof stateId === 'string' &&
40
+ typeof description === 'string' &&
41
+ description.trim().length > 0) {
42
+ const existing = descriptions[stateId];
43
+ if (existing !== undefined && existing !== description) {
44
+ throw new Error(`DECIDE state ${stateId} declares conflicting descriptions`);
45
+ }
46
+ descriptions[stateId] = description;
47
+ }
48
+ visit(state.states);
49
+ }
50
+ };
51
+ visit(states);
52
+ return Object.freeze(descriptions);
53
+ }
54
+ const STATE_DESCRIPTIONS = authoredStateDescriptions(decideMachine.config.states);
46
55
  const ROLE_STATES = [
47
56
  { stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
48
57
  {
@@ -401,6 +410,14 @@ function isEmptyFinalText(finalText) {
401
410
  function isAbortFailure(error, signal) {
402
411
  return signal.aborted && Object.is(error, signal.reason);
403
412
  }
413
+ function abortReasonClassifier(...sources) {
414
+ const captured = sources.filter((source) => source !== undefined);
415
+ return Object.freeze({
416
+ isAbortReason: (error) => captured.some((source) => source instanceof AbortSignal
417
+ ? isAbortFailure(error, source)
418
+ : source.isAbortReason(error)),
419
+ });
420
+ }
404
421
  function pendingQuestionsFromContext(context) {
405
422
  const pending = context.pendingBossQuestions;
406
423
  if (pending === undefined ||
@@ -440,6 +457,22 @@ const STATUS_STATE_IDS = new Set([
440
457
  ...WAIT_STATE_IDS,
441
458
  'failed',
442
459
  ]);
460
+ // PBRT-45: a question is pending only while its authored reply-wait state
461
+ // is active. The context retains an answered question through the resumed
462
+ // player call so the Q+A continuation prompt can quote it, and each branch
463
+ // keeps its own entry through the parallel region — so an unfiltered
464
+ // projection would report the answered question as still awaiting during
465
+ // the resume, and both branch questions after only one remains pending.
466
+ const RESUME_WAIT_STATE_IDS = {
467
+ ...Object.fromEntries(Object.entries(WAIT_STATE_RESUME_IDS).map(([waitStateId, resumeStateId]) => [
468
+ resumeStateId,
469
+ waitStateId,
470
+ ])),
471
+ commitCoderProposal: 'awaitBossReply',
472
+ };
473
+ function pendingQuestionsForState(state, context) {
474
+ return pendingQuestionsFromContext(context).filter((pending) => state.activeStateIds.includes(RESUME_WAIT_STATE_IDS[pending.resumeStateId] ?? ''));
475
+ }
443
476
  function questionForWaitState(stateId, pendingQuestions) {
444
477
  const resumeStateId = WAIT_STATE_RESUME_IDS[stateId];
445
478
  if (resumeStateId !== undefined) {
@@ -478,7 +511,7 @@ function normalizedTransitionEvent(event) {
478
511
  return snapshotJsonValue(descriptor, 'FSM event');
479
512
  }
480
513
  function telemetryPayload(previousState, state, event, context) {
481
- const pendingBossQuestions = pendingQuestionsFromContext(context);
514
+ const pendingBossQuestions = pendingQuestionsForState(state, context);
482
515
  const prior = previousState ?? state;
483
516
  const payload = {
484
517
  from: prior.value,
@@ -500,6 +533,9 @@ export const createPlaybookRuntime = (options) => {
500
533
  let sessionIdentity;
501
534
  let actor;
502
535
  let currentSignal;
536
+ let currentAborts;
537
+ const actorSettlementAborts = [];
538
+ let actorSettlementErrorAborts;
503
539
  let currentTurnId;
504
540
  let previousState;
505
541
  let suppressInspectionEmissions = false;
@@ -536,24 +572,27 @@ export const createPlaybookRuntime = (options) => {
536
572
  if (!isAbortFailure(error, signal))
537
573
  controlPlaneError ??= error;
538
574
  };
539
- const latchInspectionError = (error) => {
540
- if (currentSignal !== undefined) {
541
- latchControlPlaneError(error, currentSignal);
542
- }
543
- else {
575
+ const latchInspectionError = (error, aborts = currentAborts) => {
576
+ if (aborts?.isAbortReason(error))
577
+ return;
578
+ if (currentSignal !== undefined)
579
+ controlPlaneError ??= error;
580
+ else
544
581
  collectFailure(emissionFailures, error);
545
- }
546
582
  };
547
- const enqueue = (fn) => {
583
+ const enqueue = (fn, aborts = currentAborts) => {
584
+ const enqueueAborts = aborts;
548
585
  const queued = emissionQueue.add(fn);
549
586
  activeEmissionCalls.add(queued);
550
587
  void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
551
588
  activeEmissionCalls.delete(queued);
552
- collectFailure(emissionFailures, error);
589
+ if (!enqueueAborts?.isAbortReason(error)) {
590
+ collectFailure(emissionFailures, error);
591
+ }
553
592
  });
554
593
  return queued;
555
594
  };
556
- const flush = async () => {
595
+ const flush = async (_aborts = currentAborts) => {
557
596
  while (true) {
558
597
  const active = [...activeEmissionCalls];
559
598
  if (active.length > 0)
@@ -569,9 +608,15 @@ export const createPlaybookRuntime = (options) => {
569
608
  return;
570
609
  const failures = emissionFailures;
571
610
  emissionFailures = [];
572
- if (failures.length === 1)
573
- throw failures[0];
574
- throw new AggregateError(failures, 'decide runtime emissions failed');
611
+ const failure = failures.length === 1
612
+ ? failures[0]
613
+ : new AggregateError(failures, 'decide runtime emissions failed');
614
+ // Enqueue ownership already classified every stored failure as distinct.
615
+ // Preserve that classification if an unrelated public boundary drains
616
+ // it with a signal whose reason happens to be the same object.
617
+ if (currentSignal !== undefined)
618
+ controlPlaneError ??= failure;
619
+ throw failure;
575
620
  };
576
621
  const drainBoundaryCallsAndEmissions = async () => {
577
622
  while (true) {
@@ -737,7 +782,7 @@ export const createPlaybookRuntime = (options) => {
737
782
  const stateIdentity = (state) => {
738
783
  return state.stateId === undefined ? {} : { stateId: state.stateId };
739
784
  };
740
- const enqueueTracedEmission = (type, payload, meta = {}, describedEmission) => {
785
+ const enqueueTracedEmission = (type, payload, meta = {}, describedEmission, aborts) => {
741
786
  const runtimePorts = requirePorts();
742
787
  const identity = requireSessionIdentity();
743
788
  const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
@@ -763,9 +808,9 @@ export const createPlaybookRuntime = (options) => {
763
808
  return enqueue(async () => {
764
809
  await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
765
810
  await describedEmission?.(runtimePorts);
766
- });
811
+ }, aborts);
767
812
  };
768
- const emitTrace = (type, payload, meta = {}) => enqueueTracedEmission(type, payload, meta);
813
+ const emitTrace = (type, payload, meta = {}, aborts) => enqueueTracedEmission(type, payload, meta, undefined, aborts);
769
814
  const emitBoundaryStatus = async (message, state) => {
770
815
  const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
771
816
  await enqueueTracedEmission('status.emitted', {
@@ -777,20 +822,24 @@ export const createPlaybookRuntime = (options) => {
777
822
  }, { turnId: currentTurnId }, (runtimePorts) => runtimePorts.emitStatus(message));
778
823
  };
779
824
  const emitCallStarted = async (startedType, finishedType, identity, meta, signal) => {
825
+ const aborts = abortReasonClassifier(signal);
780
826
  try {
781
- await emitTrace(startedType, identity, meta);
827
+ await emitTrace(startedType, identity, meta, aborts);
782
828
  }
783
829
  catch (error) {
784
830
  latchControlPlaneError(error, signal);
785
831
  try {
786
832
  await emitTrace(finishedType, {
787
833
  ...identity,
788
- status: 'error',
834
+ // A started-trace sink rejection causally identical to the
835
+ // boundary reason is the abort's own evidence: the pair
836
+ // finishes 'aborted', not 'error' (DR-036 §4).
837
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
789
838
  error: normalizeErrorFull(error) ?? {
790
839
  name: 'Error',
791
840
  message: String(error),
792
841
  },
793
- }, meta);
842
+ }, meta, aborts);
794
843
  }
795
844
  catch {
796
845
  // Preserve the start failure after one best-effort finish attempt.
@@ -799,6 +848,7 @@ export const createPlaybookRuntime = (options) => {
799
848
  }
800
849
  };
801
850
  const runJudgeCall = async (prompt, signal, purpose, callStateId) => {
851
+ const aborts = abortReasonClassifier(signal);
802
852
  const identity = {
803
853
  purpose,
804
854
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
@@ -826,15 +876,18 @@ export const createPlaybookRuntime = (options) => {
826
876
  latchControlPlaneError(error, signal);
827
877
  await emitTrace('judge.call.finished', {
828
878
  ...identity,
829
- status: signal.aborted ? 'aborted' : 'error',
879
+ // Only the exact abort reason is cancellation; a distinct
880
+ // failure under an aborted signal stays an error
881
+ // (slc/link.md §Abort).
882
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
830
883
  error: normalizeErrorFull(error) ?? {
831
884
  name: 'Error',
832
885
  message: String(error),
833
886
  },
834
- }, { turnId: currentTurnId, callId });
887
+ }, { turnId: currentTurnId, callId }, aborts);
835
888
  throw error;
836
889
  }
837
- await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply: finalText }, { turnId: currentTurnId, callId });
890
+ await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply: finalText }, { turnId: currentTurnId, callId }, aborts);
838
891
  return finalText;
839
892
  });
840
893
  if (queued === undefined) {
@@ -844,6 +897,7 @@ export const createPlaybookRuntime = (options) => {
844
897
  };
845
898
  const callJudge = (prompt, signal, purpose, callStateId) => trackBoundaryCall(runJudgeCall(prompt, signal, purpose, callStateId));
846
899
  const runPlayerCall = async (input, signal) => {
900
+ const aborts = abortReasonClassifier(signal);
847
901
  if (!ROLE_ID_SET.has(input.role)) {
848
902
  throw new TypeError(`DECIDE player input role must name a declared local role`);
849
903
  }
@@ -870,12 +924,15 @@ export const createPlaybookRuntime = (options) => {
870
924
  };
871
925
  const emitFailure = (error) => emitTrace('player.call.finished', {
872
926
  ...identity,
873
- status: signal.aborted ? 'aborted' : 'error',
927
+ // Only the exact abort reason is cancellation; a distinct
928
+ // failure under an aborted signal stays an error
929
+ // (slc/link.md §Abort).
930
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
874
931
  error: normalizeErrorFull(error) ?? {
875
932
  name: 'Error',
876
933
  message: String(error),
877
934
  },
878
- }, { turnId: currentTurnId, callId });
935
+ }, { turnId: currentTurnId, callId }, aborts);
879
936
  if (inFlightPlayerKeys.has(playerKey)) {
880
937
  const error = new Error(`resolved player key "${playerKey}" already has an in-flight call`);
881
938
  await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
@@ -952,7 +1009,7 @@ export const createPlaybookRuntime = (options) => {
952
1009
  ...(result.error !== undefined
953
1010
  ? { error: normalizeErrorFull(result.error) }
954
1011
  : {}),
955
- }, { turnId: currentTurnId, callId });
1012
+ }, { turnId: currentTurnId, callId }, aborts);
956
1013
  return {
957
1014
  roleId,
958
1015
  ...(playerId === undefined ? {} : { playerId }),
@@ -968,52 +1025,59 @@ export const createPlaybookRuntime = (options) => {
968
1025
  };
969
1026
  const player = fromPromise(async ({ input, signal }) => {
970
1027
  const combined = combineSignals(signal, currentSignal);
971
- // XState starts invoked actors while publishing the entering snapshot.
972
- // Yield through the runtime emission queue before crossing the player
973
- // boundary so state trace/status always precede its call-start trace.
1028
+ const settlementAborts = abortReasonClassifier(combined);
974
1029
  try {
975
- await flush();
976
- }
977
- catch (error) {
978
- latchControlPlaneError(error, combined);
979
- throw error;
980
- }
981
- combined.throwIfAborted();
982
- let { roleId, playerId, result } = await callPlayer(input, combined);
983
- if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
984
- // DR-028: an `ok` result whose finalText is missing, empty, or
985
- // whitespace-only earns exactly one corrective re-ask — the same
986
- // composed call repeated, traced by runPlayerCall as its own
987
- // player-call pair, with the resume selection re-read from the
988
- // token map the first result left (PBRT-38). An abort that lands
989
- // between the two calls ends the turn without the re-ask (aborts
990
- // are never retried), and a rejecting finish emission rejects
991
- // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1030
+ // XState starts invoked actors while publishing the entering snapshot.
1031
+ // Yield through the runtime emission queue before crossing the player
1032
+ // boundary so state trace/status always precede its call-start trace.
992
1033
  combined.throwIfAborted();
993
- ({ roleId, playerId, result } = await callPlayer(input, combined));
994
- }
995
- if (result.status !== 'ok') {
996
- throw new Error(`${roleLabel(roleId)}${playerId === undefined ? '' : ` (${playerId})`} returned status "${result.status}"${result.error ? `: ${result.error}` : ''}`);
997
- }
998
- const finalText = result.finalText ?? '';
999
- if (isEmptyFinalText(finalText)) {
1000
- throw new Error(`${roleLabel(roleId)}${playerId === undefined ? '' : ` (${playerId})`} returned status "ok" with no finalText`);
1001
- }
1002
- combined.throwIfAborted();
1003
- try {
1004
- const prompt = buildAdjudicatorPrompt(input, finalText);
1005
- return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
1034
+ try {
1035
+ await flush(settlementAborts);
1036
+ }
1037
+ catch (error) {
1038
+ latchControlPlaneError(error, combined);
1039
+ throw error;
1040
+ }
1041
+ combined.throwIfAborted();
1042
+ let { roleId, playerId, result } = await callPlayer(input, combined);
1043
+ if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
1044
+ // DR-028: an `ok` result whose finalText is missing, empty, or
1045
+ // whitespace-only earns exactly one corrective re-ask — the same
1046
+ // composed call repeated, traced by runPlayerCall as its own
1047
+ // player-call pair, with the resume selection re-read from the
1048
+ // token map the first result left (PBRT-38). An abort that lands
1049
+ // between the two calls ends the turn without the re-ask (aborts
1050
+ // are never retried), and a rejecting finish emission rejects
1051
+ // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1052
+ combined.throwIfAborted();
1053
+ ({ roleId, playerId, result } = await callPlayer(input, combined));
1054
+ }
1055
+ if (result.status !== 'ok') {
1056
+ throw new Error(`${roleLabel(roleId)}${playerId === undefined ? '' : ` (${playerId})`} returned status "${result.status}"${result.error ? `: ${result.error}` : ''}`);
1057
+ }
1058
+ const finalText = result.finalText ?? '';
1059
+ if (isEmptyFinalText(finalText)) {
1060
+ throw new Error(`${roleLabel(roleId)}${playerId === undefined ? '' : ` (${playerId})`} returned status "ok" with no finalText`);
1061
+ }
1062
+ combined.throwIfAborted();
1063
+ try {
1064
+ const prompt = buildAdjudicatorPrompt(input, finalText);
1065
+ return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
1066
+ }
1067
+ catch (error) {
1068
+ latchControlPlaneError(error, combined);
1069
+ throw error;
1070
+ }
1006
1071
  }
1007
- catch (error) {
1008
- latchControlPlaneError(error, combined);
1009
- throw error;
1072
+ finally {
1073
+ actorSettlementAborts.push(settlementAborts);
1010
1074
  }
1011
1075
  });
1012
1076
  nestedBridge = createNestedPlaybookBridge({
1013
1077
  nextCallId: () => `playbook-${++playbookCallSequence}`,
1014
1078
  getBoundarySignal: () => currentSignal,
1015
1079
  callPlaybook: (request, signal) => trackBoundaryCall(Promise.resolve(requirePorts().callPlaybook(request, signal))),
1016
- emitStarted: async (event) => {
1080
+ emitStarted: async (event, aborts) => {
1017
1081
  playbookCallTurnIds.set(event.callId, currentTurnId);
1018
1082
  await emitTrace('playbook.call.started', {
1019
1083
  stateId: event.stateId,
@@ -1022,9 +1086,9 @@ export const createPlaybookRuntime = (options) => {
1022
1086
  }, {
1023
1087
  ...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
1024
1088
  callId: event.callId,
1025
- });
1089
+ }, aborts);
1026
1090
  },
1027
- emitFinished: async (event) => {
1091
+ emitFinished: async (event, aborts) => {
1028
1092
  const turnId = playbookCallTurnIds.get(event.callId);
1029
1093
  try {
1030
1094
  await emitTrace('playbook.call.finished', {
@@ -1035,29 +1099,48 @@ export const createPlaybookRuntime = (options) => {
1035
1099
  }, {
1036
1100
  ...(turnId === undefined ? {} : { turnId }),
1037
1101
  callId: event.callId,
1038
- });
1102
+ }, aborts);
1039
1103
  }
1040
1104
  finally {
1041
1105
  playbookCallTurnIds.delete(event.callId);
1042
1106
  }
1043
1107
  },
1044
1108
  drain: flush,
1045
- bindResumeSignal: (signal) => {
1109
+ bindResumeSignal: (signal, aborts) => {
1046
1110
  currentSignal = signal;
1111
+ currentAborts = aborts ?? abortReasonClassifier(signal);
1047
1112
  },
1048
- onControlPlaneError: (error) => {
1049
- const signal = currentSignal;
1050
- if (!signal || !isAbortFailure(error, signal)) {
1113
+ bindActorSettlement: (aborts) => {
1114
+ actorSettlementAborts.push(aborts);
1115
+ },
1116
+ onControlPlaneError: (error, aborts) => {
1117
+ if (!aborts?.isAbortReason(error) &&
1118
+ !currentAborts?.isAbortReason(error)) {
1051
1119
  controlPlaneError ??= error;
1052
1120
  }
1053
1121
  },
1054
- onBackgroundError: (error) => {
1055
- collectFailure(emissionFailures, error);
1122
+ onBackgroundError: (error, aborts) => {
1123
+ if (!aborts?.isAbortReason(error)) {
1124
+ collectFailure(emissionFailures, error);
1125
+ }
1056
1126
  },
1057
1127
  });
1058
1128
  const providedMachine = decideMachine.provide({
1059
1129
  actors: { player, playbook: nestedBridge.actorLogic },
1060
1130
  });
1131
+ const consumeActorSettlementAborts = (forSnapshot = false) => {
1132
+ const aborts = actorSettlementAborts.shift() ?? actorSettlementErrorAborts;
1133
+ actorSettlementErrorAborts = undefined;
1134
+ if (forSnapshot && aborts !== undefined) {
1135
+ actorSettlementErrorAborts = aborts;
1136
+ queueMicrotask(() => {
1137
+ if (actorSettlementErrorAborts === aborts) {
1138
+ actorSettlementErrorAborts = undefined;
1139
+ }
1140
+ });
1141
+ }
1142
+ return aborts;
1143
+ };
1061
1144
  const inspect = (event) => {
1062
1145
  if (event.type !== '@xstate.snapshot')
1063
1146
  return;
@@ -1065,6 +1148,7 @@ export const createPlaybookRuntime = (options) => {
1065
1148
  return;
1066
1149
  if (suppressInspectionEmissions)
1067
1150
  return;
1151
+ const settlementAborts = consumeActorSettlementAborts(true);
1068
1152
  try {
1069
1153
  const snapshot = event.snapshot;
1070
1154
  const state = normalizePlaybookSnapshot(snapshot);
@@ -1075,7 +1159,7 @@ export const createPlaybookRuntime = (options) => {
1075
1159
  void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
1076
1160
  topic: TELEMETRY_TOPIC,
1077
1161
  payload: describedFsmPayload,
1078
- })).catch(() => undefined);
1162
+ }), settlementAborts).catch(() => undefined);
1079
1163
  const priorIds = new Set(previousState?.activeStateIds ?? []);
1080
1164
  previousState = state;
1081
1165
  const pendingQuestions = pendingQuestionsFromContext(context);
@@ -1088,7 +1172,7 @@ export const createPlaybookRuntime = (options) => {
1088
1172
  ...(data !== undefined ? { data } : {}),
1089
1173
  };
1090
1174
  assertJsonSafe(tracePayload);
1091
- void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data)).catch(() => undefined);
1175
+ void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data), settlementAborts).catch(() => undefined);
1092
1176
  };
1093
1177
  for (const activeStateId of state.activeStateIds) {
1094
1178
  if (priorIds.has(activeStateId) ||
@@ -1116,7 +1200,7 @@ export const createPlaybookRuntime = (options) => {
1116
1200
  }
1117
1201
  }
1118
1202
  catch (error) {
1119
- latchInspectionError(error);
1203
+ latchInspectionError(error, settlementAborts);
1120
1204
  }
1121
1205
  };
1122
1206
  const createRuntimeActor = (machineSnapshot) => {
@@ -1132,6 +1216,15 @@ export const createPlaybookRuntime = (options) => {
1132
1216
  }),
1133
1217
  inspect,
1134
1218
  });
1219
+ // A synchronous FSM action throw errors the actor without any pending
1220
+ // boundary await to observe it; unobserved, XState would surface it via
1221
+ // reportUnhandledError as an uncaughtException. Observe it here: latch
1222
+ // it as a control error while a turn signal is active (unless it is
1223
+ // the abort reason itself), otherwise collect it with the emission
1224
+ // failures (slc/link.md §Abort).
1225
+ actor.subscribe({
1226
+ error: (error) => latchInspectionError(error, consumeActorSettlementAborts()),
1227
+ });
1135
1228
  };
1136
1229
  // PBRT-6: the single seam that stops this runtime's actor. Stopping a
1137
1230
  // still-running actor fires one more `@xstate.snapshot` for the *unchanged*
@@ -1167,7 +1260,7 @@ export const createPlaybookRuntime = (options) => {
1167
1260
  const state = normalizePlaybookSnapshot(snapshot, {
1168
1261
  pendingCall: nestedBridge.getPendingCall(),
1169
1262
  });
1170
- const pendingQuestions = pendingQuestionsFromContext(context);
1263
+ const pendingQuestions = pendingQuestionsForState(state, context);
1171
1264
  if (pendingQuestions.length === 0 &&
1172
1265
  (snapshot.status === 'done' ||
1173
1266
  state.activeStateIds.includes('ready') ||
@@ -1191,33 +1284,53 @@ export const createPlaybookRuntime = (options) => {
1191
1284
  const pendingCall = nestedBridge.getPendingCall();
1192
1285
  const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
1193
1286
  const context = snapshot.context;
1194
- if (signal?.aborted) {
1195
- return {
1196
- outcome: 'aborted',
1197
- state,
1198
- ...(signal.reason === undefined
1199
- ? {}
1200
- : {
1201
- error: normalizeErrorFull(signal.reason) ?? {
1202
- name: 'AbortError',
1203
- message: String(signal.reason),
1204
- },
1205
- }),
1206
- };
1207
- }
1287
+ const abortedResult = (abortSignal) => ({
1288
+ outcome: 'aborted',
1289
+ state,
1290
+ ...(abortSignal.reason === undefined
1291
+ ? {}
1292
+ : {
1293
+ error: normalizeErrorFull(abortSignal.reason) ?? {
1294
+ name: 'AbortError',
1295
+ message: String(abortSignal.reason),
1296
+ },
1297
+ }),
1298
+ });
1299
+ if (snapshot.status === 'error') {
1300
+ // An errored actor outranks a coincident abort unless the actor's
1301
+ // error is the abort reason itself (slc/link.md §Abort).
1302
+ const actorError = snapshot.error;
1303
+ if (actorError !== undefined &&
1304
+ signal !== undefined &&
1305
+ isAbortFailure(actorError, signal)) {
1306
+ return abortedResult(signal);
1307
+ }
1308
+ throw (actorError ?? new Error('decide runtime actor entered error status'));
1309
+ }
1310
+ // Terminal completion outranks a coincident abort (DR-036 §3): reporting
1311
+ // 'aborted' over a completed machine would hide a terminal state that the
1312
+ // next turn silently restarts, duplicating the workflow's side effects.
1208
1313
  if (snapshot.status === 'done') {
1209
1314
  const output = snapshot.output;
1210
1315
  if (output !== undefined)
1211
1316
  assertJsonSafe(output, 'terminal output');
1317
+ const stateDescription = state.activeStateIds.includes('done')
1318
+ ? STATE_DESCRIPTIONS.done
1319
+ : state.activeStateIds.includes('reportedReviewFailure')
1320
+ ? STATE_DESCRIPTIONS.reportedReviewFailure
1321
+ : undefined;
1322
+ if (stateDescription === undefined) {
1323
+ throw new Error('decide runtime: completed actor has no authored final-state description');
1324
+ }
1212
1325
  return {
1213
1326
  outcome: 'terminal',
1214
1327
  state,
1328
+ stateDescription,
1215
1329
  ...(output === undefined ? {} : { output }),
1216
1330
  };
1217
1331
  }
1218
- if (snapshot.status === 'error') {
1219
- throw (snapshot.error ??
1220
- new Error('decide runtime actor entered error status'));
1332
+ if (signal?.aborted) {
1333
+ return abortedResult(signal);
1221
1334
  }
1222
1335
  if (state.activeStateIds.includes('failed')) {
1223
1336
  const error = normalizeErrorFull(context.lastError);
@@ -1288,6 +1401,9 @@ export const createPlaybookRuntime = (options) => {
1288
1401
  judgeQueue.clear();
1289
1402
  actor = undefined;
1290
1403
  currentSignal = undefined;
1404
+ currentAborts = undefined;
1405
+ actorSettlementAborts.length = 0;
1406
+ actorSettlementErrorAborts = undefined;
1291
1407
  currentTurnId = undefined;
1292
1408
  ports = undefined;
1293
1409
  sessionIdentity = undefined;
@@ -1401,7 +1517,7 @@ export const createPlaybookRuntime = (options) => {
1401
1517
  playbookCall: playbookCallSequence,
1402
1518
  },
1403
1519
  state,
1404
- pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
1520
+ pendingBossQuestions: pendingQuestionsForState(state, context).map((pending) => ({
1405
1521
  questionId: pending.questionId,
1406
1522
  asker: pending.asker,
1407
1523
  question: pending.question,
@@ -1500,12 +1616,17 @@ export const createPlaybookRuntime = (options) => {
1500
1616
  const turnId = ++turnSequence;
1501
1617
  currentTurnId = turnId;
1502
1618
  currentSignal = turn.signal;
1619
+ currentAborts = abortReasonClassifier(turn.signal);
1503
1620
  controlPlaneError = undefined;
1504
1621
  let result = resultForSnapshot(turn.signal);
1505
1622
  let settlement = result;
1506
1623
  const failures = [];
1507
1624
  try {
1508
1625
  await emitTrace('boss.input.received', { text: turn.text }, { turnId });
1626
+ // A boundary entered aborted records the attempted input, then refuses
1627
+ // delivery before deterministic mapping or the classifier can perform
1628
+ // any host-visible work (DR-036 §5).
1629
+ turn.signal.throwIfAborted();
1509
1630
  if (turn.text.trim().length === 0) {
1510
1631
  const state = currentState();
1511
1632
  result = { outcome: 'no-action', state };
@@ -1541,16 +1662,19 @@ export const createPlaybookRuntime = (options) => {
1541
1662
  }
1542
1663
  catch (error) {
1543
1664
  const primaryError = controlPlaneError;
1665
+ // Only a rejection that is the exact abort reason settles as the
1666
+ // cancellation; a distinct failure observed while the signal is
1667
+ // aborted remains a control error (slc/link.md §Abort).
1544
1668
  if (primaryError !== undefined) {
1545
1669
  collectFailure(failures, primaryError);
1546
1670
  }
1547
- else if (!turn.signal.aborted) {
1671
+ else if (!isAbortFailure(error, turn.signal)) {
1548
1672
  collectFailure(failures, error);
1549
1673
  }
1550
1674
  const state = currentState();
1551
1675
  const effectiveError = primaryError ?? error;
1552
1676
  result =
1553
- turn.signal.aborted && primaryError === undefined
1677
+ isAbortFailure(error, turn.signal) && primaryError === undefined
1554
1678
  ? resultForSnapshot(turn.signal)
1555
1679
  : {
1556
1680
  outcome: 'failed',
@@ -1571,12 +1695,14 @@ export const createPlaybookRuntime = (options) => {
1571
1695
  catch (error) {
1572
1696
  const primaryError = controlPlaneError;
1573
1697
  const effectiveError = primaryError ?? error;
1574
- collectFailure(failures, effectiveError);
1698
+ // A drain rejection that is the exact abort reason evidences the
1699
+ // cancellation, not a control-plane failure (slc/link.md §Abort).
1700
+ const drainAborted = isAbortFailure(effectiveError, turn.signal);
1701
+ if (!drainAborted)
1702
+ collectFailure(failures, effectiveError);
1575
1703
  const state = currentState();
1576
1704
  result = {
1577
- outcome: turn.signal.aborted && primaryError === undefined
1578
- ? 'aborted'
1579
- : 'failed',
1705
+ outcome: drainAborted ? 'aborted' : 'failed',
1580
1706
  state,
1581
1707
  error: normalizeErrorFull(effectiveError) ?? {
1582
1708
  name: 'Error',
@@ -1585,21 +1711,31 @@ export const createPlaybookRuntime = (options) => {
1585
1711
  };
1586
1712
  settlement = { ...result, ...stateIdentity(state) };
1587
1713
  }
1588
- currentSignal = undefined;
1589
1714
  try {
1590
1715
  await emitTrace('boss.input.settled', settlement, { turnId });
1591
1716
  }
1592
1717
  catch (error) {
1593
- collectFailure(failures, error);
1718
+ // A settlement-trace rejection that is the exact abort reason also
1719
+ // evidences the cancellation (slc/link.md §Abort).
1720
+ if (!isAbortFailure(error, turn.signal)) {
1721
+ collectFailure(failures, error);
1722
+ }
1594
1723
  }
1595
1724
  try {
1596
1725
  await flush();
1597
1726
  }
1598
1727
  catch (error) {
1599
- collectFailure(failures, error);
1728
+ // A late flush rejection that is the exact abort reason likewise
1729
+ // evidences the cancellation; the settled result already labels
1730
+ // the turn aborted then (slc/link.md §Abort).
1731
+ if (!isAbortFailure(error, turn.signal)) {
1732
+ collectFailure(failures, error);
1733
+ }
1600
1734
  }
1601
1735
  finally {
1602
1736
  const primaryError = controlPlaneError;
1737
+ currentSignal = undefined;
1738
+ currentAborts = undefined;
1603
1739
  currentTurnId = undefined;
1604
1740
  controlPlaneError = undefined;
1605
1741
  if (primaryError !== undefined)
@@ -1625,6 +1761,7 @@ export const createPlaybookRuntime = (options) => {
1625
1761
  }
1626
1762
  currentTurnId = playbookCallTurnIds.get(callId);
1627
1763
  currentSignal = signal;
1764
+ currentAborts = abortReasonClassifier(signal);
1628
1765
  controlPlaneError = undefined;
1629
1766
  let runResult;
1630
1767
  let operationError;
@@ -1654,14 +1791,50 @@ export const createPlaybookRuntime = (options) => {
1654
1791
  catch (error) {
1655
1792
  drainError = error;
1656
1793
  }
1657
- const failure = controlPlaneError ?? drainError ?? operationError;
1794
+ const aborts = currentAborts ?? abortReasonClassifier(signal);
1795
+ // The control latch has already classified its failure as distinct
1796
+ // under the operation that owned it. Only still-unclassified drain and
1797
+ // operation candidates may be cancellation evidence for this resume.
1798
+ const controlFailure = controlPlaneError;
1799
+ const drainAbort = controlFailure === undefined &&
1800
+ drainError !== undefined &&
1801
+ aborts.isAbortReason(drainError);
1802
+ const operationAbort = controlFailure === undefined &&
1803
+ operationError !== undefined &&
1804
+ aborts.isAbortReason(operationError);
1805
+ const abortEvidence = (drainAbort ? drainError : undefined) ??
1806
+ (operationAbort ? operationError : undefined);
1807
+ const failure = controlFailure ??
1808
+ (drainAbort ? undefined : drainError) ??
1809
+ (operationAbort ? undefined : operationError);
1658
1810
  currentSignal = undefined;
1811
+ currentAborts = undefined;
1659
1812
  currentTurnId = undefined;
1660
1813
  controlPlaneError = undefined;
1661
1814
  if (failure !== undefined)
1662
1815
  throw failure;
1816
+ if (abortEvidence !== undefined &&
1817
+ runResult?.outcome !== 'terminal' &&
1818
+ runResult?.outcome !== 'suspended') {
1819
+ const state = currentState();
1820
+ runResult = {
1821
+ outcome: 'aborted',
1822
+ state,
1823
+ error: normalizeErrorFull(abortEvidence) ?? {
1824
+ name: 'AbortError',
1825
+ message: String(abortEvidence),
1826
+ },
1827
+ };
1828
+ }
1663
1829
  if (runResult === undefined) {
1664
- throw new Error('decide runtime: playbook resume produced no result');
1830
+ if (signal.aborted) {
1831
+ // Every candidate was the abort's own evidence: settle on the
1832
+ // machine's state under the aborted boundary signal (DR-036 §4).
1833
+ runResult = resultForSnapshot(signal);
1834
+ }
1835
+ else {
1836
+ throw new Error('decide runtime: playbook resume produced no result');
1837
+ }
1665
1838
  }
1666
1839
  return runResult;
1667
1840
  },
@@ -1719,6 +1892,9 @@ export const createPlaybookRuntime = (options) => {
1719
1892
  judgeQueue.clear();
1720
1893
  actor = undefined;
1721
1894
  currentSignal = undefined;
1895
+ currentAborts = undefined;
1896
+ actorSettlementAborts.length = 0;
1897
+ actorSettlementErrorAborts = undefined;
1722
1898
  currentTurnId = undefined;
1723
1899
  ports = undefined;
1724
1900
  sessionIdentity = undefined;
@@ -1734,6 +1910,11 @@ export const createPlaybookRuntime = (options) => {
1734
1910
  })();
1735
1911
  return disposalPromise;
1736
1912
  },
1913
+ // @internal — test-only parity with the shared factory's bridge escape
1914
+ // hatch. This is hidden by the PlaybookRuntime return type.
1915
+ _getNestedBridge() {
1916
+ return nestedBridge;
1917
+ },
1737
1918
  };
1738
1919
  };
1739
1920
  export const _internal = {
@@ -1746,6 +1927,7 @@ export const _internal = {
1746
1927
  parseAdjudication,
1747
1928
  combineSignals,
1748
1929
  pendingQuestionsFromContext,
1930
+ pendingQuestionsForState,
1749
1931
  normalizeErrorCompact,
1750
1932
  normalizeErrorFull,
1751
1933
  STATE_DESCRIPTIONS,