@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
@@ -114,20 +114,51 @@ function snapshotDecideRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
114
114
  return Object.freeze({});
115
115
  }
116
116
 
117
- const STATE_DESCRIPTIONS: Readonly<Record<string, string>> = {
118
- ready: 'Waiting for a topic to decide.',
119
- askCoderProposal: 'Coder independently proposes a spec design.',
120
- askReviewerProposal: 'Reviewer independently proposes a spec design.',
121
- waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
122
- waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
123
- commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
124
- awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
125
- reviewCommit: 'REVIEW examines the committed proposal.',
126
- failed: 'DECIDE failed and is waiting for a new topic.',
127
- reportedReviewFailure:
128
- 'DECIDE reports REVIEW’s failure and its last commit.',
129
- done: 'DECIDE completed with an approved commit.',
130
- };
117
+ interface AuthoredStateConfig {
118
+ readonly meta?: {
119
+ readonly playbook?: {
120
+ readonly stateId?: unknown;
121
+ readonly description?: unknown;
122
+ };
123
+ };
124
+ readonly states?: Readonly<Record<string, AuthoredStateConfig>>;
125
+ }
126
+
127
+ function authoredStateDescriptions(
128
+ states: Readonly<Record<string, AuthoredStateConfig>> | undefined,
129
+ ): Readonly<Record<string, string>> {
130
+ const descriptions: Record<string, string> = {};
131
+ const visit = (
132
+ children: Readonly<Record<string, AuthoredStateConfig>> | undefined,
133
+ ): void => {
134
+ for (const state of Object.values(children ?? {})) {
135
+ const stateId = state.meta?.playbook?.stateId;
136
+ const description = state.meta?.playbook?.description;
137
+ if (
138
+ typeof stateId === 'string' &&
139
+ typeof description === 'string' &&
140
+ description.trim().length > 0
141
+ ) {
142
+ const existing = descriptions[stateId];
143
+ if (existing !== undefined && existing !== description) {
144
+ throw new Error(
145
+ `DECIDE state ${stateId} declares conflicting descriptions`,
146
+ );
147
+ }
148
+ descriptions[stateId] = description;
149
+ }
150
+ visit(state.states);
151
+ }
152
+ };
153
+ visit(states);
154
+ return Object.freeze(descriptions);
155
+ }
156
+
157
+ const STATE_DESCRIPTIONS = authoredStateDescriptions(
158
+ decideMachine.config.states as
159
+ | Readonly<Record<string, AuthoredStateConfig>>
160
+ | undefined,
161
+ );
131
162
 
132
163
  const ROLE_STATES = [
133
164
  { stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
@@ -584,6 +615,27 @@ function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
584
615
  return signal.aborted && Object.is(error, signal.reason);
585
616
  }
586
617
 
618
+ interface AbortReasonClassifier {
619
+ isAbortReason(error: unknown): boolean;
620
+ }
621
+
622
+ function abortReasonClassifier(
623
+ ...sources: readonly (AbortSignal | AbortReasonClassifier | undefined)[]
624
+ ): AbortReasonClassifier {
625
+ const captured = sources.filter(
626
+ (source): source is AbortSignal | AbortReasonClassifier =>
627
+ source !== undefined,
628
+ );
629
+ return Object.freeze({
630
+ isAbortReason: (error: unknown): boolean =>
631
+ captured.some((source) =>
632
+ source instanceof AbortSignal
633
+ ? isAbortFailure(error, source)
634
+ : source.isAbortReason(error),
635
+ ),
636
+ });
637
+ }
638
+
587
639
  function pendingQuestionsFromContext(
588
640
  context: Record<string, unknown>,
589
641
  ): PendingBossQuestion[] {
@@ -635,6 +687,33 @@ const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
635
687
  'failed',
636
688
  ]);
637
689
 
690
+ // PBRT-45: a question is pending only while its authored reply-wait state
691
+ // is active. The context retains an answered question through the resumed
692
+ // player call so the Q+A continuation prompt can quote it, and each branch
693
+ // keeps its own entry through the parallel region — so an unfiltered
694
+ // projection would report the answered question as still awaiting during
695
+ // the resume, and both branch questions after only one remains pending.
696
+ const RESUME_WAIT_STATE_IDS: Readonly<Record<string, string>> = {
697
+ ...Object.fromEntries(
698
+ Object.entries(WAIT_STATE_RESUME_IDS).map(([waitStateId, resumeStateId]) => [
699
+ resumeStateId,
700
+ waitStateId,
701
+ ]),
702
+ ),
703
+ commitCoderProposal: 'awaitBossReply',
704
+ };
705
+
706
+ function pendingQuestionsForState(
707
+ state: PlaybookState,
708
+ context: Record<string, unknown>,
709
+ ): PendingBossQuestion[] {
710
+ return pendingQuestionsFromContext(context).filter((pending) =>
711
+ state.activeStateIds.includes(
712
+ RESUME_WAIT_STATE_IDS[pending.resumeStateId] ?? '',
713
+ ),
714
+ );
715
+ }
716
+
638
717
  function questionForWaitState(
639
718
  stateId: string,
640
719
  pendingQuestions: readonly PendingBossQuestion[],
@@ -688,7 +767,7 @@ function telemetryPayload(
688
767
  event: unknown,
689
768
  context: Record<string, unknown>,
690
769
  ): JsonValue {
691
- const pendingBossQuestions = pendingQuestionsFromContext(context);
770
+ const pendingBossQuestions = pendingQuestionsForState(state, context);
692
771
  const prior = previousState ?? state;
693
772
  const payload = {
694
773
  from: prior.value,
@@ -716,6 +795,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
716
795
  let sessionIdentity: SessionIdentity | undefined;
717
796
  let actor: ReturnType<typeof createActor> | undefined;
718
797
  let currentSignal: AbortSignal | undefined;
798
+ let currentAborts: AbortReasonClassifier | undefined;
799
+ const actorSettlementAborts: AbortReasonClassifier[] = [];
800
+ let actorSettlementErrorAborts: AbortReasonClassifier | undefined;
719
801
  let currentTurnId: number | undefined;
720
802
  let previousState: PlaybookState | undefined;
721
803
  let suppressInspectionEmissions = false;
@@ -754,26 +836,35 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
754
836
  ): void => {
755
837
  if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
756
838
  };
757
- const latchInspectionError = (error: unknown): void => {
758
- if (currentSignal !== undefined) {
759
- latchControlPlaneError(error, currentSignal);
760
- } else {
761
- collectFailure(emissionFailures, error);
762
- }
839
+ const latchInspectionError = (
840
+ error: unknown,
841
+ aborts: AbortReasonClassifier | undefined = currentAborts,
842
+ ): void => {
843
+ if (aborts?.isAbortReason(error)) return;
844
+ if (currentSignal !== undefined) controlPlaneError ??= error;
845
+ else collectFailure(emissionFailures, error);
763
846
  };
764
- const enqueue = (fn: () => Promise<void>): Promise<void> => {
847
+ const enqueue = (
848
+ fn: () => Promise<void>,
849
+ aborts: AbortReasonClassifier | undefined = currentAborts,
850
+ ): Promise<void> => {
851
+ const enqueueAborts = aborts;
765
852
  const queued = emissionQueue.add(fn);
766
853
  activeEmissionCalls.add(queued);
767
854
  void queued.then(
768
855
  () => activeEmissionCalls.delete(queued),
769
856
  (error: unknown) => {
770
857
  activeEmissionCalls.delete(queued);
771
- collectFailure(emissionFailures, error);
858
+ if (!enqueueAborts?.isAbortReason(error)) {
859
+ collectFailure(emissionFailures, error);
860
+ }
772
861
  },
773
862
  );
774
863
  return queued;
775
864
  };
776
- const flush = async (): Promise<void> => {
865
+ const flush = async (
866
+ _aborts: AbortReasonClassifier | undefined = currentAborts,
867
+ ): Promise<void> => {
777
868
  while (true) {
778
869
  const active = [...activeEmissionCalls];
779
870
  if (active.length > 0) await Promise.allSettled(active);
@@ -789,8 +880,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
789
880
  if (emissionFailures.length === 0) return;
790
881
  const failures = emissionFailures;
791
882
  emissionFailures = [];
792
- if (failures.length === 1) throw failures[0];
793
- throw new AggregateError(failures, 'decide runtime emissions failed');
883
+ const failure =
884
+ failures.length === 1
885
+ ? failures[0]
886
+ : new AggregateError(failures, 'decide runtime emissions failed');
887
+ // Enqueue ownership already classified every stored failure as distinct.
888
+ // Preserve that classification if an unrelated public boundary drains
889
+ // it with a signal whose reason happens to be the same object.
890
+ if (currentSignal !== undefined) controlPlaneError ??= failure;
891
+ throw failure;
794
892
  };
795
893
  const drainBoundaryCallsAndEmissions = async (): Promise<void> => {
796
894
  while (true) {
@@ -1012,6 +1110,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1012
1110
  payload: unknown,
1013
1111
  meta: { turnId?: number; callId?: string } = {},
1014
1112
  describedEmission?: (runtimePorts: PlaybookPorts) => Promise<void>,
1113
+ aborts?: AbortReasonClassifier,
1015
1114
  ): Promise<void> => {
1016
1115
  const runtimePorts = requirePorts();
1017
1116
  const identity = requireSessionIdentity();
@@ -1035,16 +1134,20 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1035
1134
  ...(meta.callId !== undefined ? { callId: meta.callId } : {}),
1036
1135
  payload: jsonPayload,
1037
1136
  });
1038
- return enqueue(async () => {
1039
- await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
1040
- await describedEmission?.(runtimePorts);
1041
- });
1137
+ return enqueue(
1138
+ async () => {
1139
+ await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
1140
+ await describedEmission?.(runtimePorts);
1141
+ },
1142
+ aborts,
1143
+ );
1042
1144
  };
1043
1145
  const emitTrace = (
1044
1146
  type: PlaybookTraceType,
1045
1147
  payload: unknown,
1046
1148
  meta: { turnId?: number; callId?: string } = {},
1047
- ): Promise<void> => enqueueTracedEmission(type, payload, meta);
1149
+ aborts?: AbortReasonClassifier,
1150
+ ): Promise<void> => enqueueTracedEmission(type, payload, meta, undefined, aborts);
1048
1151
  const emitBoundaryStatus = async (
1049
1152
  message: string,
1050
1153
  state: PlaybookState,
@@ -1073,8 +1176,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1073
1176
  meta: { turnId?: number; callId?: string },
1074
1177
  signal: AbortSignal,
1075
1178
  ): Promise<void> => {
1179
+ const aborts = abortReasonClassifier(signal);
1076
1180
  try {
1077
- await emitTrace(startedType, identity, meta);
1181
+ await emitTrace(startedType, identity, meta, aborts);
1078
1182
  } catch (error) {
1079
1183
  latchControlPlaneError(error, signal);
1080
1184
  try {
@@ -1082,13 +1186,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1082
1186
  finishedType,
1083
1187
  {
1084
1188
  ...identity,
1085
- status: 'error',
1189
+ // A started-trace sink rejection causally identical to the
1190
+ // boundary reason is the abort's own evidence: the pair
1191
+ // finishes 'aborted', not 'error' (DR-036 §4).
1192
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1086
1193
  error: normalizeErrorFull(error) ?? {
1087
1194
  name: 'Error',
1088
1195
  message: String(error),
1089
1196
  },
1090
1197
  },
1091
1198
  meta,
1199
+ aborts,
1092
1200
  );
1093
1201
  } catch {
1094
1202
  // Preserve the start failure after one best-effort finish attempt.
@@ -1103,6 +1211,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1103
1211
  purpose: 'boss-input-classification' | 'player-output-adjudication',
1104
1212
  callStateId: string | undefined,
1105
1213
  ): Promise<string> => {
1214
+ const aborts = abortReasonClassifier(signal);
1106
1215
  const identity = {
1107
1216
  purpose,
1108
1217
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
@@ -1138,13 +1247,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1138
1247
  'judge.call.finished',
1139
1248
  {
1140
1249
  ...identity,
1141
- status: signal.aborted ? 'aborted' : 'error',
1250
+ // Only the exact abort reason is cancellation; a distinct
1251
+ // failure under an aborted signal stays an error
1252
+ // (slc/link.md §Abort).
1253
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1142
1254
  error: normalizeErrorFull(error) ?? {
1143
1255
  name: 'Error',
1144
1256
  message: String(error),
1145
1257
  },
1146
1258
  },
1147
1259
  { turnId: currentTurnId, callId },
1260
+ aborts,
1148
1261
  );
1149
1262
  throw error;
1150
1263
  }
@@ -1153,6 +1266,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1153
1266
  'judge.call.finished',
1154
1267
  { ...identity, status: 'ok', reply: finalText },
1155
1268
  { turnId: currentTurnId, callId },
1269
+ aborts,
1156
1270
  );
1157
1271
  return finalText;
1158
1272
  });
@@ -1178,6 +1292,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1178
1292
  playerId?: string;
1179
1293
  result: PlayerResult;
1180
1294
  }> => {
1295
+ const aborts = abortReasonClassifier(signal);
1181
1296
  if (!ROLE_ID_SET.has(input.role)) {
1182
1297
  throw new TypeError(
1183
1298
  `DECIDE player input role must name a declared local role`,
@@ -1208,13 +1323,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1208
1323
  'player.call.finished',
1209
1324
  {
1210
1325
  ...identity,
1211
- status: signal.aborted ? 'aborted' : 'error',
1326
+ // Only the exact abort reason is cancellation; a distinct
1327
+ // failure under an aborted signal stays an error
1328
+ // (slc/link.md §Abort).
1329
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1212
1330
  error: normalizeErrorFull(error) ?? {
1213
1331
  name: 'Error',
1214
1332
  message: String(error),
1215
1333
  },
1216
1334
  },
1217
1335
  { turnId: currentTurnId, callId },
1336
+ aborts,
1218
1337
  );
1219
1338
  if (inFlightPlayerKeys.has(playerKey)) {
1220
1339
  const error = new Error(
@@ -1310,6 +1429,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1310
1429
  : {}),
1311
1430
  },
1312
1431
  { turnId: currentTurnId, callId },
1432
+ aborts,
1313
1433
  );
1314
1434
  return {
1315
1435
  roleId,
@@ -1335,64 +1455,70 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1335
1455
  const player = fromPromise<PlayerOutput, PlayerInput>(
1336
1456
  async ({ input, signal }) => {
1337
1457
  const combined = combineSignals(signal, currentSignal);
1338
- // XState starts invoked actors while publishing the entering snapshot.
1339
- // Yield through the runtime emission queue before crossing the player
1340
- // boundary so state trace/status always precede its call-start trace.
1458
+ const settlementAborts = abortReasonClassifier(combined);
1341
1459
  try {
1342
- await flush();
1343
- } catch (error) {
1344
- latchControlPlaneError(error, combined);
1345
- throw error;
1346
- }
1347
- combined.throwIfAborted();
1348
-
1349
- let { roleId, playerId, result } = await callPlayer(input, combined);
1350
- if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
1351
- // DR-028: an `ok` result whose finalText is missing, empty, or
1352
- // whitespace-only earns exactly one corrective re-ask — the same
1353
- // composed call repeated, traced by runPlayerCall as its own
1354
- // player-call pair, with the resume selection re-read from the
1355
- // token map the first result left (PBRT-38). An abort that lands
1356
- // between the two calls ends the turn without the re-ask (aborts
1357
- // are never retried), and a rejecting finish emission rejects
1358
- // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1460
+ // XState starts invoked actors while publishing the entering snapshot.
1461
+ // Yield through the runtime emission queue before crossing the player
1462
+ // boundary so state trace/status always precede its call-start trace.
1463
+ combined.throwIfAborted();
1464
+ try {
1465
+ await flush(settlementAborts);
1466
+ } catch (error) {
1467
+ latchControlPlaneError(error, combined);
1468
+ throw error;
1469
+ }
1359
1470
  combined.throwIfAborted();
1360
- ({ roleId, playerId, result } = await callPlayer(input, combined));
1361
- }
1362
- if (result.status !== 'ok') {
1363
- throw new Error(
1364
- `${roleLabel(roleId)}${
1365
- playerId === undefined ? '' : ` (${playerId})`
1366
- } returned status "${result.status}"${
1367
- result.error ? `: ${result.error}` : ''
1368
- }`,
1369
- );
1370
- }
1371
- const finalText = result.finalText ?? '';
1372
- if (isEmptyFinalText(finalText)) {
1373
- throw new Error(
1374
- `${roleLabel(roleId)}${
1375
- playerId === undefined ? '' : ` (${playerId})`
1376
- } returned status "ok" with no finalText`,
1377
- );
1378
- }
1379
- combined.throwIfAborted();
1380
1471
 
1381
- try {
1382
- const prompt = buildAdjudicatorPrompt(input, finalText);
1383
- return parseAdjudication(
1384
- await callJudge(
1385
- prompt,
1386
- combined,
1387
- 'player-output-adjudication',
1388
- input.stateId,
1389
- ),
1390
- input,
1391
- finalText,
1392
- );
1393
- } catch (error) {
1394
- latchControlPlaneError(error, combined);
1395
- throw error;
1472
+ let { roleId, playerId, result } = await callPlayer(input, combined);
1473
+ if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
1474
+ // DR-028: an `ok` result whose finalText is missing, empty, or
1475
+ // whitespace-only earns exactly one corrective re-ask — the same
1476
+ // composed call repeated, traced by runPlayerCall as its own
1477
+ // player-call pair, with the resume selection re-read from the
1478
+ // token map the first result left (PBRT-38). An abort that lands
1479
+ // between the two calls ends the turn without the re-ask (aborts
1480
+ // are never retried), and a rejecting finish emission rejects
1481
+ // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1482
+ combined.throwIfAborted();
1483
+ ({ roleId, playerId, result } = await callPlayer(input, combined));
1484
+ }
1485
+ if (result.status !== 'ok') {
1486
+ throw new Error(
1487
+ `${roleLabel(roleId)}${
1488
+ playerId === undefined ? '' : ` (${playerId})`
1489
+ } returned status "${result.status}"${
1490
+ result.error ? `: ${result.error}` : ''
1491
+ }`,
1492
+ );
1493
+ }
1494
+ const finalText = result.finalText ?? '';
1495
+ if (isEmptyFinalText(finalText)) {
1496
+ throw new Error(
1497
+ `${roleLabel(roleId)}${
1498
+ playerId === undefined ? '' : ` (${playerId})`
1499
+ } returned status "ok" with no finalText`,
1500
+ );
1501
+ }
1502
+ combined.throwIfAborted();
1503
+
1504
+ try {
1505
+ const prompt = buildAdjudicatorPrompt(input, finalText);
1506
+ return parseAdjudication(
1507
+ await callJudge(
1508
+ prompt,
1509
+ combined,
1510
+ 'player-output-adjudication',
1511
+ input.stateId,
1512
+ ),
1513
+ input,
1514
+ finalText,
1515
+ );
1516
+ } catch (error) {
1517
+ latchControlPlaneError(error, combined);
1518
+ throw error;
1519
+ }
1520
+ } finally {
1521
+ actorSettlementAborts.push(settlementAborts);
1396
1522
  }
1397
1523
  },
1398
1524
  );
@@ -1404,7 +1530,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1404
1530
  trackBoundaryCall(
1405
1531
  Promise.resolve(requirePorts().callPlaybook(request, signal)),
1406
1532
  ),
1407
- emitStarted: async (event) => {
1533
+ emitStarted: async (event, aborts) => {
1408
1534
  playbookCallTurnIds.set(event.callId, currentTurnId);
1409
1535
  await emitTrace(
1410
1536
  'playbook.call.started',
@@ -1417,9 +1543,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1417
1543
  ...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
1418
1544
  callId: event.callId,
1419
1545
  },
1546
+ aborts,
1420
1547
  );
1421
1548
  },
1422
- emitFinished: async (event) => {
1549
+ emitFinished: async (event, aborts) => {
1423
1550
  const turnId = playbookCallTurnIds.get(event.callId);
1424
1551
  try {
1425
1552
  await emitTrace(
@@ -1434,23 +1561,32 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1434
1561
  ...(turnId === undefined ? {} : { turnId }),
1435
1562
  callId: event.callId,
1436
1563
  },
1564
+ aborts,
1437
1565
  );
1438
1566
  } finally {
1439
1567
  playbookCallTurnIds.delete(event.callId);
1440
1568
  }
1441
1569
  },
1442
1570
  drain: flush,
1443
- bindResumeSignal: (signal) => {
1571
+ bindResumeSignal: (signal, aborts) => {
1444
1572
  currentSignal = signal;
1573
+ currentAborts = aborts ?? abortReasonClassifier(signal);
1574
+ },
1575
+ bindActorSettlement: (aborts) => {
1576
+ actorSettlementAborts.push(aborts);
1445
1577
  },
1446
- onControlPlaneError: (error) => {
1447
- const signal = currentSignal;
1448
- if (!signal || !isAbortFailure(error, signal)) {
1578
+ onControlPlaneError: (error, aborts) => {
1579
+ if (
1580
+ !aborts?.isAbortReason(error) &&
1581
+ !currentAborts?.isAbortReason(error)
1582
+ ) {
1449
1583
  controlPlaneError ??= error;
1450
1584
  }
1451
1585
  },
1452
- onBackgroundError: (error) => {
1453
- collectFailure(emissionFailures, error);
1586
+ onBackgroundError: (error, aborts) => {
1587
+ if (!aborts?.isAbortReason(error)) {
1588
+ collectFailure(emissionFailures, error);
1589
+ }
1454
1590
  },
1455
1591
  });
1456
1592
 
@@ -1458,10 +1594,27 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1458
1594
  actors: { player, playbook: nestedBridge.actorLogic },
1459
1595
  });
1460
1596
 
1597
+ const consumeActorSettlementAborts = (
1598
+ forSnapshot = false,
1599
+ ): AbortReasonClassifier | undefined => {
1600
+ const aborts = actorSettlementAborts.shift() ?? actorSettlementErrorAborts;
1601
+ actorSettlementErrorAborts = undefined;
1602
+ if (forSnapshot && aborts !== undefined) {
1603
+ actorSettlementErrorAborts = aborts;
1604
+ queueMicrotask(() => {
1605
+ if (actorSettlementErrorAborts === aborts) {
1606
+ actorSettlementErrorAborts = undefined;
1607
+ }
1608
+ });
1609
+ }
1610
+ return aborts;
1611
+ };
1612
+
1461
1613
  const inspect = (event: InspectionEvent): void => {
1462
1614
  if (event.type !== '@xstate.snapshot') return;
1463
1615
  if (actor === undefined || event.actorRef !== actor) return;
1464
1616
  if (suppressInspectionEmissions) return;
1617
+ const settlementAborts = consumeActorSettlementAborts(true);
1465
1618
  try {
1466
1619
  const snapshot = event.snapshot as SnapshotFrom<typeof decideMachine>;
1467
1620
  const state = normalizePlaybookSnapshot(snapshot);
@@ -1482,6 +1635,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1482
1635
  topic: TELEMETRY_TOPIC,
1483
1636
  payload: describedFsmPayload,
1484
1637
  }),
1638
+ settlementAborts,
1485
1639
  ).catch(() => undefined);
1486
1640
 
1487
1641
  const priorIds = new Set(previousState?.activeStateIds ?? []);
@@ -1507,6 +1661,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1507
1661
  tracePayload,
1508
1662
  { turnId: currentTurnId },
1509
1663
  (emissionPorts) => emissionPorts.emitStatus(message, data),
1664
+ settlementAborts,
1510
1665
  ).catch(() => undefined);
1511
1666
  };
1512
1667
 
@@ -1552,7 +1707,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1552
1707
  );
1553
1708
  }
1554
1709
  } catch (error) {
1555
- latchInspectionError(error);
1710
+ latchInspectionError(error, settlementAborts);
1556
1711
  }
1557
1712
  };
1558
1713
 
@@ -1571,6 +1726,16 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1571
1726
  }),
1572
1727
  inspect,
1573
1728
  });
1729
+ // A synchronous FSM action throw errors the actor without any pending
1730
+ // boundary await to observe it; unobserved, XState would surface it via
1731
+ // reportUnhandledError as an uncaughtException. Observe it here: latch
1732
+ // it as a control error while a turn signal is active (unless it is
1733
+ // the abort reason itself), otherwise collect it with the emission
1734
+ // failures (slc/link.md §Abort).
1735
+ actor.subscribe({
1736
+ error: (error) =>
1737
+ latchInspectionError(error, consumeActorSettlementAborts()),
1738
+ });
1574
1739
  };
1575
1740
 
1576
1741
  // PBRT-6: the single seam that stops this runtime's actor. Stopping a
@@ -1610,7 +1775,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1610
1775
  const state = normalizePlaybookSnapshot(snapshot, {
1611
1776
  pendingCall: nestedBridge.getPendingCall(),
1612
1777
  });
1613
- const pendingQuestions = pendingQuestionsFromContext(context);
1778
+ const pendingQuestions = pendingQuestionsForState(state, context);
1614
1779
  if (
1615
1780
  pendingQuestions.length === 0 &&
1616
1781
  (snapshot.status === 'done' ||
@@ -1644,34 +1809,58 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1644
1809
  const pendingCall = nestedBridge.getPendingCall();
1645
1810
  const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
1646
1811
  const context = snapshot.context as unknown as Record<string, unknown>;
1647
- if (signal?.aborted) {
1648
- return {
1649
- outcome: 'aborted',
1650
- state,
1651
- ...(signal.reason === undefined
1652
- ? {}
1653
- : {
1654
- error: normalizeErrorFull(signal.reason) ?? {
1655
- name: 'AbortError',
1656
- message: String(signal.reason),
1657
- },
1658
- }),
1659
- };
1812
+ const abortedResult = (abortSignal: AbortSignal): PlaybookRunResult => ({
1813
+ outcome: 'aborted',
1814
+ state,
1815
+ ...(abortSignal.reason === undefined
1816
+ ? {}
1817
+ : {
1818
+ error: normalizeErrorFull(abortSignal.reason) ?? {
1819
+ name: 'AbortError',
1820
+ message: String(abortSignal.reason),
1821
+ },
1822
+ }),
1823
+ });
1824
+ if (snapshot.status === 'error') {
1825
+ // An errored actor outranks a coincident abort unless the actor's
1826
+ // error is the abort reason itself (slc/link.md §Abort).
1827
+ const actorError = (snapshot as { error?: unknown }).error;
1828
+ if (
1829
+ actorError !== undefined &&
1830
+ signal !== undefined &&
1831
+ isAbortFailure(actorError, signal)
1832
+ ) {
1833
+ return abortedResult(signal);
1834
+ }
1835
+ throw (
1836
+ actorError ?? new Error('decide runtime actor entered error status')
1837
+ );
1660
1838
  }
1839
+ // Terminal completion outranks a coincident abort (DR-036 §3): reporting
1840
+ // 'aborted' over a completed machine would hide a terminal state that the
1841
+ // next turn silently restarts, duplicating the workflow's side effects.
1661
1842
  if (snapshot.status === 'done') {
1662
1843
  const output = (snapshot as { output?: unknown }).output;
1663
1844
  if (output !== undefined) assertJsonSafe(output, 'terminal output');
1845
+ const stateDescription = state.activeStateIds.includes('done')
1846
+ ? STATE_DESCRIPTIONS.done
1847
+ : state.activeStateIds.includes('reportedReviewFailure')
1848
+ ? STATE_DESCRIPTIONS.reportedReviewFailure
1849
+ : undefined;
1850
+ if (stateDescription === undefined) {
1851
+ throw new Error(
1852
+ 'decide runtime: completed actor has no authored final-state description',
1853
+ );
1854
+ }
1664
1855
  return {
1665
1856
  outcome: 'terminal',
1666
1857
  state,
1858
+ stateDescription,
1667
1859
  ...(output === undefined ? {} : { output }),
1668
1860
  };
1669
1861
  }
1670
- if (snapshot.status === 'error') {
1671
- throw (
1672
- (snapshot as { error?: unknown }).error ??
1673
- new Error('decide runtime actor entered error status')
1674
- );
1862
+ if (signal?.aborted) {
1863
+ return abortedResult(signal);
1675
1864
  }
1676
1865
  if (state.activeStateIds.includes('failed')) {
1677
1866
  const error = normalizeErrorFull(context.lastError);
@@ -1741,6 +1930,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1741
1930
  judgeQueue.clear();
1742
1931
  actor = undefined;
1743
1932
  currentSignal = undefined;
1933
+ currentAborts = undefined;
1934
+ actorSettlementAborts.length = 0;
1935
+ actorSettlementErrorAborts = undefined;
1744
1936
  currentTurnId = undefined;
1745
1937
  ports = undefined;
1746
1938
  sessionIdentity = undefined;
@@ -1866,7 +2058,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1866
2058
  playbookCall: playbookCallSequence,
1867
2059
  },
1868
2060
  state,
1869
- pendingBossQuestions: pendingQuestionsFromContext(context).map(
2061
+ pendingBossQuestions: pendingQuestionsForState(state, context).map(
1870
2062
  (pending) => ({
1871
2063
  questionId: pending.questionId,
1872
2064
  asker: pending.asker,
@@ -1993,12 +2185,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1993
2185
  const turnId = ++turnSequence;
1994
2186
  currentTurnId = turnId;
1995
2187
  currentSignal = turn.signal;
2188
+ currentAborts = abortReasonClassifier(turn.signal);
1996
2189
  controlPlaneError = undefined;
1997
2190
  let result: PlaybookRunResult = resultForSnapshot(turn.signal);
1998
2191
  let settlement: unknown = result;
1999
2192
  const failures: unknown[] = [];
2000
2193
  try {
2001
2194
  await emitTrace('boss.input.received', { text: turn.text }, { turnId });
2195
+ // A boundary entered aborted records the attempted input, then refuses
2196
+ // delivery before deterministic mapping or the classifier can perform
2197
+ // any host-visible work (DR-036 §5).
2198
+ turn.signal.throwIfAborted();
2002
2199
  if (turn.text.trim().length === 0) {
2003
2200
  const state = currentState();
2004
2201
  result = { outcome: 'no-action', state };
@@ -2031,15 +2228,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2031
2228
  };
2032
2229
  } catch (error) {
2033
2230
  const primaryError = controlPlaneError;
2231
+ // Only a rejection that is the exact abort reason settles as the
2232
+ // cancellation; a distinct failure observed while the signal is
2233
+ // aborted remains a control error (slc/link.md §Abort).
2034
2234
  if (primaryError !== undefined) {
2035
2235
  collectFailure(failures, primaryError);
2036
- } else if (!turn.signal.aborted) {
2236
+ } else if (!isAbortFailure(error, turn.signal)) {
2037
2237
  collectFailure(failures, error);
2038
2238
  }
2039
2239
  const state = currentState();
2040
2240
  const effectiveError = primaryError ?? error;
2041
2241
  result =
2042
- turn.signal.aborted && primaryError === undefined
2242
+ isAbortFailure(error, turn.signal) && primaryError === undefined
2043
2243
  ? resultForSnapshot(turn.signal)
2044
2244
  : {
2045
2245
  outcome: 'failed',
@@ -2060,13 +2260,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2060
2260
  } catch (error) {
2061
2261
  const primaryError = controlPlaneError;
2062
2262
  const effectiveError = primaryError ?? error;
2063
- collectFailure(failures, effectiveError);
2263
+ // A drain rejection that is the exact abort reason evidences the
2264
+ // cancellation, not a control-plane failure (slc/link.md §Abort).
2265
+ const drainAborted = isAbortFailure(effectiveError, turn.signal);
2266
+ if (!drainAborted) collectFailure(failures, effectiveError);
2064
2267
  const state = currentState();
2065
2268
  result = {
2066
- outcome:
2067
- turn.signal.aborted && primaryError === undefined
2068
- ? 'aborted'
2069
- : 'failed',
2269
+ outcome: drainAborted ? 'aborted' : 'failed',
2070
2270
  state,
2071
2271
  error: normalizeErrorFull(effectiveError) ?? {
2072
2272
  name: 'Error',
@@ -2075,18 +2275,28 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2075
2275
  };
2076
2276
  settlement = { ...result, ...stateIdentity(state) };
2077
2277
  }
2078
- currentSignal = undefined;
2079
2278
  try {
2080
2279
  await emitTrace('boss.input.settled', settlement, { turnId });
2081
2280
  } catch (error) {
2082
- collectFailure(failures, error);
2281
+ // A settlement-trace rejection that is the exact abort reason also
2282
+ // evidences the cancellation (slc/link.md §Abort).
2283
+ if (!isAbortFailure(error, turn.signal)) {
2284
+ collectFailure(failures, error);
2285
+ }
2083
2286
  }
2084
2287
  try {
2085
2288
  await flush();
2086
2289
  } catch (error) {
2087
- collectFailure(failures, error);
2290
+ // A late flush rejection that is the exact abort reason likewise
2291
+ // evidences the cancellation; the settled result already labels
2292
+ // the turn aborted then (slc/link.md §Abort).
2293
+ if (!isAbortFailure(error, turn.signal)) {
2294
+ collectFailure(failures, error);
2295
+ }
2088
2296
  } finally {
2089
2297
  const primaryError = controlPlaneError;
2298
+ currentSignal = undefined;
2299
+ currentAborts = undefined;
2090
2300
  currentTurnId = undefined;
2091
2301
  controlPlaneError = undefined;
2092
2302
  if (primaryError !== undefined) throw primaryError;
@@ -2122,6 +2332,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2122
2332
  }
2123
2333
  currentTurnId = playbookCallTurnIds.get(callId);
2124
2334
  currentSignal = signal;
2335
+ currentAborts = abortReasonClassifier(signal);
2125
2336
  controlPlaneError = undefined;
2126
2337
  let runResult: PlaybookRunResult | undefined;
2127
2338
  let operationError: unknown;
@@ -2148,13 +2359,56 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2148
2359
  } catch (error) {
2149
2360
  drainError = error;
2150
2361
  }
2151
- const failure = controlPlaneError ?? drainError ?? operationError;
2362
+ const aborts = currentAborts ?? abortReasonClassifier(signal);
2363
+ // The control latch has already classified its failure as distinct
2364
+ // under the operation that owned it. Only still-unclassified drain and
2365
+ // operation candidates may be cancellation evidence for this resume.
2366
+ const controlFailure = controlPlaneError;
2367
+ const drainAbort =
2368
+ controlFailure === undefined &&
2369
+ drainError !== undefined &&
2370
+ aborts.isAbortReason(drainError);
2371
+ const operationAbort =
2372
+ controlFailure === undefined &&
2373
+ operationError !== undefined &&
2374
+ aborts.isAbortReason(operationError);
2375
+ const abortEvidence =
2376
+ (drainAbort ? drainError : undefined) ??
2377
+ (operationAbort ? operationError : undefined);
2378
+ const failure =
2379
+ controlFailure ??
2380
+ (drainAbort ? undefined : drainError) ??
2381
+ (operationAbort ? undefined : operationError);
2152
2382
  currentSignal = undefined;
2383
+ currentAborts = undefined;
2153
2384
  currentTurnId = undefined;
2154
2385
  controlPlaneError = undefined;
2155
2386
  if (failure !== undefined) throw failure;
2387
+ if (
2388
+ abortEvidence !== undefined &&
2389
+ runResult?.outcome !== 'terminal' &&
2390
+ runResult?.outcome !== 'suspended'
2391
+ ) {
2392
+ const state = currentState();
2393
+ runResult = {
2394
+ outcome: 'aborted',
2395
+ state,
2396
+ error: normalizeErrorFull(abortEvidence) ?? {
2397
+ name: 'AbortError',
2398
+ message: String(abortEvidence),
2399
+ },
2400
+ };
2401
+ }
2156
2402
  if (runResult === undefined) {
2157
- throw new Error('decide runtime: playbook resume produced no result');
2403
+ if (signal.aborted) {
2404
+ // Every candidate was the abort's own evidence: settle on the
2405
+ // machine's state under the aborted boundary signal (DR-036 §4).
2406
+ runResult = resultForSnapshot(signal);
2407
+ } else {
2408
+ throw new Error(
2409
+ 'decide runtime: playbook resume produced no result',
2410
+ );
2411
+ }
2158
2412
  }
2159
2413
  return runResult;
2160
2414
  },
@@ -2210,6 +2464,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2210
2464
  judgeQueue.clear();
2211
2465
  actor = undefined;
2212
2466
  currentSignal = undefined;
2467
+ currentAborts = undefined;
2468
+ actorSettlementAborts.length = 0;
2469
+ actorSettlementErrorAborts = undefined;
2213
2470
  currentTurnId = undefined;
2214
2471
  ports = undefined;
2215
2472
  sessionIdentity = undefined;
@@ -2224,6 +2481,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2224
2481
  })();
2225
2482
  return disposalPromise;
2226
2483
  },
2484
+
2485
+ // @internal — test-only parity with the shared factory's bridge escape
2486
+ // hatch. This is hidden by the PlaybookRuntime return type.
2487
+ _getNestedBridge() {
2488
+ return nestedBridge;
2489
+ },
2227
2490
  };
2228
2491
  };
2229
2492
 
@@ -2237,6 +2500,7 @@ export const _internal = {
2237
2500
  parseAdjudication,
2238
2501
  combineSignals,
2239
2502
  pendingQuestionsFromContext,
2503
+ pendingQuestionsForState,
2240
2504
  normalizeErrorCompact,
2241
2505
  normalizeErrorFull,
2242
2506
  STATE_DESCRIPTIONS,