@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
@@ -1,6 +1,14 @@
1
1
  import { type AnyActorRef, type PromiseActorLogic, type SnapshotFrom } from 'xstate';
2
2
  import type { CaptainResult, JsonValue, NormalizedError, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlaybookSuspendedCall, PlayerResult } from './runtime.js';
3
3
  export * from './xstate-playbook-runtime.js';
4
+ /**
5
+ * Immutable cancellation provenance for one runtime operation. The captured
6
+ * signal identities do not change when a mutable runtime advances to another
7
+ * public boundary, while each signal's eventual reason remains observable.
8
+ */
9
+ interface AbortReasonClassifier {
10
+ isAbortReason(error: unknown): boolean;
11
+ }
4
12
  /**
5
13
  * Compose invocation-lifetime and imperative-boundary cancellation without
6
14
  * installing a second forwarding listener in each generated runtime.
@@ -22,7 +30,7 @@ export declare function hiddenControlEnvelope(prompt: string): string;
22
30
  export declare function normalizeError(error: unknown): NormalizedError;
23
31
  export interface PlaybookStateMetadata {
24
32
  stateId: string;
25
- description: string;
33
+ description?: string;
26
34
  }
27
35
  /** Read stable state identity without consulting XState's private `_nodes`. */
28
36
  export declare function activePlaybookStateMetadata(snapshot: unknown): readonly PlaybookStateMetadata[];
@@ -59,12 +67,14 @@ export interface NestedPlaybookBridgeOptions {
59
67
  /** Active public runtime boundary whose abort also owns a new child call. */
60
68
  getBoundarySignal?(): AbortSignal | undefined;
61
69
  callPlaybook(request: PlaybookCallRequest, signal: AbortSignal): Promise<PlaybookCallStart>;
62
- emitStarted(event: PlaybookCallStarted): Promise<void>;
63
- emitFinished(event: PlaybookCallFinished): Promise<void>;
64
- drain(): Promise<void>;
65
- bindResumeSignal?(signal: AbortSignal): void;
66
- onControlPlaneError?(error: unknown): void;
67
- onBackgroundError?(error: unknown): void;
70
+ emitStarted(event: PlaybookCallStarted, aborts?: AbortReasonClassifier): Promise<void>;
71
+ emitFinished(event: PlaybookCallFinished, aborts?: AbortReasonClassifier): Promise<void>;
72
+ drain(aborts?: AbortReasonClassifier): Promise<void>;
73
+ bindResumeSignal?(signal: AbortSignal, aborts?: AbortReasonClassifier): void;
74
+ /** Bind provenance to the root transition caused by this child result. */
75
+ bindActorSettlement?(aborts: AbortReasonClassifier): void;
76
+ onControlPlaneError?(error: unknown, aborts?: AbortReasonClassifier): void;
77
+ onBackgroundError?(error: unknown, aborts?: AbortReasonClassifier): void;
68
78
  }
69
79
  export declare class NestedPlaybookCallError extends Error {
70
80
  readonly result: PlaybookCallResult;
@@ -31,9 +31,19 @@ function withAbort(promise, signal) {
31
31
  });
32
32
  });
33
33
  }
34
+ // slc/link.md §Abort: cancellation is causal identity with the applicable
35
+ // signal's reason; an `AbortError`-named rejection that is not that exact
36
+ // reason is a control-plane failure to surface, never an abort to swallow.
34
37
  function isAbortReason(error, signal) {
35
- return (signal.aborted &&
36
- (error === signal.reason || normalizeError(error).name === 'AbortError'));
38
+ return signal.aborted && Object.is(error, signal.reason);
39
+ }
40
+ function createAbortReasonClassifier(...sources) {
41
+ const captured = Object.freeze(sources.filter((source) => source !== undefined));
42
+ return Object.freeze({
43
+ isAbortReason: (error) => captured.some((source) => source instanceof AbortSignal
44
+ ? isAbortReason(error, source)
45
+ : source.isAbortReason(error)),
46
+ });
37
47
  }
38
48
  const NEVER_ABORTED_SIGNAL = new AbortController().signal;
39
49
  /**
@@ -73,7 +83,7 @@ export function registerPlaybookAbortCleanup(signal, cleanup) {
73
83
  // the bridge's allSettled drain observes its outcome.
74
84
  void cleanup.catch(() => undefined);
75
85
  }
76
- async function drainPlaybookAbortCleanups(signal) {
86
+ async function drainPlaybookAbortCleanups(signal, aborts) {
77
87
  const failures = [];
78
88
  while (true) {
79
89
  const pending = abortCleanups.get(signal);
@@ -83,8 +93,10 @@ async function drainPlaybookAbortCleanups(signal) {
83
93
  pending.clear();
84
94
  const outcomes = await Promise.allSettled(batch);
85
95
  for (const outcome of outcomes) {
86
- if (outcome.status === 'rejected')
96
+ if (outcome.status === 'rejected' &&
97
+ !aborts.isAbortReason(outcome.reason)) {
87
98
  failures.push(outcome.reason);
99
+ }
88
100
  }
89
101
  }
90
102
  abortCleanups.delete(signal);
@@ -484,12 +496,23 @@ export function activePlaybookStateMetadata(snapshot) {
484
496
  throw new TypeError(`${nodeId}.meta.playbook must be an object`);
485
497
  }
486
498
  const stateId = requireNonEmptyString(meta.playbook.stateId, `${nodeId}.meta.playbook.stateId`);
487
- const description = requireNonEmptyString(meta.playbook.description, `${nodeId}.meta.playbook.description`);
499
+ // Description is optional: a state may declare none and stay fully
500
+ // usable, merely carrying no `stateDescription` downstream. A declared
501
+ // description must still be a nonempty string.
502
+ const description = meta.playbook.description === undefined
503
+ ? undefined
504
+ : requireNonEmptyString(meta.playbook.description, `${nodeId}.meta.playbook.description`);
488
505
  const previous = byStateId.get(stateId);
489
- if (previous && previous.description !== description) {
506
+ if (previous?.description !== undefined &&
507
+ description !== undefined &&
508
+ previous.description !== description) {
490
509
  throw new TypeError(`active state id ${stateId} has conflicting descriptions`);
491
510
  }
492
- byStateId.set(stateId, { stateId, description });
511
+ const effective = description ?? previous?.description;
512
+ byStateId.set(stateId, {
513
+ stateId,
514
+ ...(effective === undefined ? {} : { description: effective }),
515
+ });
493
516
  }
494
517
  return [...byStateId.values()].sort((left, right) => left.stateId.localeCompare(right.stateId));
495
518
  }
@@ -948,22 +971,26 @@ export function createNestedPlaybookBridge(options) {
948
971
  let disposed = false;
949
972
  const usedCallIds = new Set();
950
973
  const pendingListeners = new Set();
951
- const reportBackgroundError = (error) => {
974
+ const reportBackgroundError = (error, aborts) => {
975
+ if (aborts?.isAbortReason(error))
976
+ return;
952
977
  try {
953
- options.onBackgroundError?.(error);
978
+ options.onBackgroundError?.(error, aborts);
954
979
  }
955
980
  catch {
956
981
  // Background observers are a terminal sink and cannot own cleanup.
957
982
  }
958
983
  };
959
- const reportControlPlaneError = (error) => {
984
+ const reportControlPlaneError = (error, aborts) => {
985
+ if (aborts?.isAbortReason(error))
986
+ return;
960
987
  try {
961
- options.onControlPlaneError?.(error);
988
+ options.onControlPlaneError?.(error, aborts);
962
989
  }
963
990
  catch (callbackError) {
964
991
  // Observability callbacks must never prevent terminal cleanup of the
965
992
  // invocation they are observing.
966
- reportBackgroundError(callbackError);
993
+ reportBackgroundError(callbackError, aborts);
967
994
  }
968
995
  };
969
996
  const rejectControlPlane = (error) => {
@@ -1003,27 +1030,37 @@ export function createNestedPlaybookBridge(options) {
1003
1030
  if (current === active)
1004
1031
  current = undefined;
1005
1032
  };
1006
- const emitFinish = async (active, result) => {
1033
+ // A failure causally identical to an applicable abort reason is the
1034
+ // cancellation's own evidence, never a control-plane error.
1035
+ const reportNonAbortControlError = (error, aborts) => {
1036
+ reportControlPlaneError(error, aborts);
1037
+ };
1038
+ const emitFinish = async (active, result, aborts) => {
1007
1039
  await options.emitFinished({
1008
1040
  callId: active.callId,
1009
1041
  stateId: active.input.stateId,
1010
1042
  playbookId: active.input.playbookId,
1011
1043
  text: active.input.text,
1012
1044
  result,
1013
- });
1014
- await options.drain();
1045
+ }, aborts);
1046
+ await options.drain(aborts);
1015
1047
  };
1016
1048
  const finishImmediate = async (active, result, controlError, resultAfterAbortCleanup) => {
1049
+ const aborts = active.aborts;
1017
1050
  let effectiveResult = result;
1018
1051
  let cleanupControlError;
1019
1052
  if (result.status === 'aborted' || active.signal.aborted) {
1020
1053
  try {
1021
- await drainPlaybookAbortCleanups(active.signal);
1054
+ await drainPlaybookAbortCleanups(active.signal, aborts);
1022
1055
  }
1023
1056
  catch (error) {
1024
- cleanupControlError = error;
1025
- reportControlPlaneError(error);
1026
- effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, error, false);
1057
+ // A cleanup rejection identical to an applicable abort reason is
1058
+ // the cancellation's own evidence — no latch, no result override.
1059
+ if (!aborts.isAbortReason(error)) {
1060
+ cleanupControlError = error;
1061
+ reportControlPlaneError(error, aborts);
1062
+ effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, error, false);
1063
+ }
1027
1064
  }
1028
1065
  if (cleanupControlError === undefined && resultAfterAbortCleanup) {
1029
1066
  effectiveResult = resultAfterAbortCleanup();
@@ -1031,10 +1068,10 @@ export function createNestedPlaybookBridge(options) {
1031
1068
  }
1032
1069
  let finishControlError;
1033
1070
  try {
1034
- await emitFinish(active, effectiveResult);
1071
+ await emitFinish(active, effectiveResult, aborts);
1035
1072
  }
1036
1073
  catch (error) {
1037
- reportControlPlaneError(error);
1074
+ reportNonAbortControlError(error, aborts);
1038
1075
  finishControlError = error;
1039
1076
  }
1040
1077
  finally {
@@ -1042,6 +1079,7 @@ export function createNestedPlaybookBridge(options) {
1042
1079
  // emission fails, do not leave a permanently unresumable call in the
1043
1080
  // bridge and prevent disposal or a later invocation.
1044
1081
  clear(active);
1082
+ options.bindActorSettlement?.(aborts);
1045
1083
  }
1046
1084
  if (controlError !== undefined)
1047
1085
  throw controlError;
@@ -1051,7 +1089,7 @@ export function createNestedPlaybookBridge(options) {
1051
1089
  throw finishControlError;
1052
1090
  return outputOrThrow(effectiveResult);
1053
1091
  };
1054
- const settlePending = async (active, result, controlError) => {
1092
+ const settlePending = async (active, result, controlError, aborts = active.aborts) => {
1055
1093
  if (active.phase === 'settling' && active.settlement) {
1056
1094
  await active.settlement;
1057
1095
  return;
@@ -1065,32 +1103,40 @@ export function createNestedPlaybookBridge(options) {
1065
1103
  let cleanupControlError;
1066
1104
  if (result.status === 'aborted' || active.signal.aborted) {
1067
1105
  if (result.status !== 'aborted' && active.signal.aborted) {
1068
- effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ??
1069
- new Error('Nested playbook invocation aborted'), true);
1106
+ effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason, true);
1070
1107
  }
1071
1108
  try {
1072
- await drainPlaybookAbortCleanups(active.signal);
1109
+ await drainPlaybookAbortCleanups(active.signal, aborts);
1073
1110
  }
1074
1111
  catch (cleanupError) {
1075
- cleanupControlError = cleanupError;
1076
- reportControlPlaneError(cleanupError);
1077
- effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, cleanupError, false);
1112
+ // A cleanup rejection identical to an applicable abort reason is
1113
+ // the cancellation's own evidence — no latch, no result override.
1114
+ if (!aborts.isAbortReason(cleanupError)) {
1115
+ cleanupControlError = cleanupError;
1116
+ reportControlPlaneError(cleanupError, aborts);
1117
+ effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, cleanupError, false);
1118
+ }
1078
1119
  }
1079
1120
  }
1080
1121
  try {
1081
- await emitFinish(active, effectiveResult);
1122
+ await emitFinish(active, effectiveResult, aborts);
1082
1123
  }
1083
1124
  catch (error) {
1084
1125
  // A finish event is the durable return boundary. If it cannot be
1085
1126
  // emitted and drained, the child result must not remain retryable:
1086
1127
  // clear the identity and fail the promise actor so its parent takes
1087
- // onError instead of observing a phantom suspended child.
1088
- reportControlPlaneError(error);
1128
+ // onError instead of observing a phantom suspended child. A finish
1129
+ // rejection that is an applicable abort reason — the invocation's
1130
+ // or the settling resume's — evidences cancellation, not a
1131
+ // control-plane failure (slc/link.md §Abort).
1132
+ reportControlPlaneError(error, aborts);
1089
1133
  clear(active);
1134
+ options.bindActorSettlement?.(aborts);
1090
1135
  active.deferred.reject(error);
1091
1136
  throw error;
1092
1137
  }
1093
1138
  clear(active);
1139
+ options.bindActorSettlement?.(aborts);
1094
1140
  if (controlError !== undefined) {
1095
1141
  active.deferred.reject(controlError);
1096
1142
  }
@@ -1126,6 +1172,7 @@ export function createNestedPlaybookBridge(options) {
1126
1172
  active.restoreRolledBack = true;
1127
1173
  clear(active);
1128
1174
  usedCallIds.delete(active.callId);
1175
+ options.bindActorSettlement?.(active.aborts);
1129
1176
  active.deferred.reject(error);
1130
1177
  return active;
1131
1178
  };
@@ -1136,9 +1183,9 @@ export function createNestedPlaybookBridge(options) {
1136
1183
  const abortListener = () => {
1137
1184
  if (active.phase !== 'suspended')
1138
1185
  return;
1139
- const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ?? new Error('Nested playbook invocation aborted'), true);
1186
+ const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason, true);
1140
1187
  void settlePending(active, result).catch((error) => {
1141
- reportBackgroundError(error);
1188
+ reportBackgroundError(error, active.aborts);
1142
1189
  });
1143
1190
  };
1144
1191
  active.abortListener = abortListener;
@@ -1152,7 +1199,7 @@ export function createNestedPlaybookBridge(options) {
1152
1199
  listener(pendingCall);
1153
1200
  }
1154
1201
  catch (error) {
1155
- reportBackgroundError(error);
1202
+ reportBackgroundError(error, active.aborts);
1156
1203
  }
1157
1204
  }
1158
1205
  if (active.signal.aborted)
@@ -1222,8 +1269,11 @@ export function createNestedPlaybookBridge(options) {
1222
1269
  }
1223
1270
  const controller = new AbortController();
1224
1271
  let callSignal;
1272
+ let callAborts;
1225
1273
  try {
1226
- callSignal = combineAbortSignals(invocationSignal, options.getBoundarySignal?.(), controller.signal);
1274
+ const boundarySignal = options.getBoundarySignal?.();
1275
+ callSignal = combineAbortSignals(invocationSignal, boundarySignal, controller.signal);
1276
+ callAborts = createAbortReasonClassifier(invocationSignal, boundarySignal, controller.signal);
1227
1277
  }
1228
1278
  catch (error) {
1229
1279
  failRestoreMode(mode, error);
@@ -1239,6 +1289,7 @@ export function createNestedPlaybookBridge(options) {
1239
1289
  finished: deferred(),
1240
1290
  controller,
1241
1291
  signal: callSignal,
1292
+ aborts: callAborts,
1242
1293
  phase: 'restoring',
1243
1294
  childSessionId: seed.childSessionId,
1244
1295
  };
@@ -1253,8 +1304,7 @@ export function createNestedPlaybookBridge(options) {
1253
1304
  active.phase !== 'restoring') {
1254
1305
  return;
1255
1306
  }
1256
- rollbackRestoredCall(mode, active.signal.reason ??
1257
- new Error('Restored nested playbook invocation aborted'));
1307
+ rollbackRestoredCall(mode, active.signal.reason);
1258
1308
  };
1259
1309
  active.abortListener = restoreAbortListener;
1260
1310
  active.signal.addEventListener('abort', restoreAbortListener, {
@@ -1291,8 +1341,11 @@ export function createNestedPlaybookBridge(options) {
1291
1341
  usedCallIds.add(callId);
1292
1342
  const controller = new AbortController();
1293
1343
  let callSignal;
1344
+ let callAborts;
1294
1345
  try {
1295
- callSignal = combineAbortSignals(invocationSignal, options.getBoundarySignal?.(), controller.signal);
1346
+ const boundarySignal = options.getBoundarySignal?.();
1347
+ callSignal = combineAbortSignals(invocationSignal, boundarySignal, controller.signal);
1348
+ callAborts = createAbortReasonClassifier(invocationSignal, boundarySignal, controller.signal);
1296
1349
  }
1297
1350
  catch (error) {
1298
1351
  return rejectControlPlane(error);
@@ -1304,6 +1357,7 @@ export function createNestedPlaybookBridge(options) {
1304
1357
  finished: deferred(),
1305
1358
  controller,
1306
1359
  signal: callSignal,
1360
+ aborts: callAborts,
1307
1361
  phase: 'starting',
1308
1362
  };
1309
1363
  current = active;
@@ -1312,26 +1366,39 @@ export function createNestedPlaybookBridge(options) {
1312
1366
  // for their entering state. Yield through the runtime's global queue so
1313
1367
  // that transition/status telemetry is enqueued before call.started.
1314
1368
  try {
1315
- await options.drain();
1369
+ await options.drain(active.aborts);
1316
1370
  }
1317
1371
  catch (error) {
1318
- reportControlPlaneError(error);
1372
+ reportNonAbortControlError(error, active.aborts);
1319
1373
  clear(active);
1320
1374
  throw error;
1321
1375
  }
1322
1376
  try {
1323
- await options.emitStarted({ callId, ...normalizedInput });
1377
+ await options.emitStarted({ callId, ...normalizedInput }, active.aborts);
1324
1378
  }
1325
1379
  catch (error) {
1326
- reportControlPlaneError(error);
1327
- return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, false), error);
1380
+ // A start-sink rejection identical to the applicable abort
1381
+ // reason is the cancellation itself: the pair finishes
1382
+ // `aborted` and nothing is reported (slc/link.md §Abort).
1383
+ const controlError = active.aborts.isAbortReason(error)
1384
+ ? undefined
1385
+ : error;
1386
+ if (controlError !== undefined) {
1387
+ reportControlPlaneError(controlError, active.aborts);
1388
+ }
1389
+ return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, controlError === undefined), controlError);
1328
1390
  }
1329
1391
  try {
1330
- await options.drain();
1392
+ await options.drain(active.aborts);
1331
1393
  }
1332
1394
  catch (error) {
1333
- reportControlPlaneError(error);
1334
- return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, false), error);
1395
+ const controlError = active.aborts.isAbortReason(error)
1396
+ ? undefined
1397
+ : error;
1398
+ if (controlError !== undefined) {
1399
+ reportControlPlaneError(controlError, active.aborts);
1400
+ }
1401
+ return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, controlError === undefined), controlError);
1335
1402
  }
1336
1403
  const request = {
1337
1404
  callId,
@@ -1354,7 +1421,7 @@ export function createNestedPlaybookBridge(options) {
1354
1421
  throw error;
1355
1422
  });
1356
1423
  const openingCleanup = starting.then(() => undefined, (error) => {
1357
- if (isAbortReason(error, active.signal))
1424
+ if (active.aborts.isAbortReason(error))
1358
1425
  return;
1359
1426
  throw error;
1360
1427
  });
@@ -1371,11 +1438,14 @@ export function createNestedPlaybookBridge(options) {
1371
1438
  rawStart = await withAbort(starting, active.signal);
1372
1439
  }
1373
1440
  catch (error) {
1374
- const controlError = active.signal.aborted ? undefined : error;
1375
- if (controlError !== undefined)
1376
- reportControlPlaneError(controlError);
1377
- const result = resultFromThrown(normalizedInput.playbookId, undefined, error, active.signal.aborted);
1378
- return await finishImmediate(active, result, controlError, active.signal.aborted
1441
+ const controlError = active.aborts.isAbortReason(error)
1442
+ ? undefined
1443
+ : error;
1444
+ if (controlError !== undefined) {
1445
+ reportControlPlaneError(controlError, active.aborts);
1446
+ }
1447
+ const result = resultFromThrown(normalizedInput.playbookId, undefined, error, controlError === undefined && active.signal.aborted);
1448
+ return await finishImmediate(active, result, controlError, controlError === undefined && active.signal.aborted
1379
1449
  ? () => resultFromThrown(normalizedInput.playbookId, startSettled
1380
1450
  ? assignedChildSessionId(observedStart)
1381
1451
  : undefined, error, true)
@@ -1503,8 +1573,7 @@ export function createNestedPlaybookBridge(options) {
1503
1573
  }
1504
1574
  const active = mode.active;
1505
1575
  if (active.signal.aborted) {
1506
- const error = active.signal.reason ??
1507
- new Error('Restored nested playbook invocation aborted');
1576
+ const error = active.signal.reason;
1508
1577
  rollbackRestoredCall(mode, error);
1509
1578
  restoreMode = undefined;
1510
1579
  throw error;
@@ -1532,7 +1601,7 @@ export function createNestedPlaybookBridge(options) {
1532
1601
  listener(pendingCall);
1533
1602
  }
1534
1603
  catch (error) {
1535
- reportBackgroundError(error);
1604
+ reportBackgroundError(error, current?.aborts);
1536
1605
  }
1537
1606
  }
1538
1607
  return () => pendingListeners.delete(listener);
@@ -1560,8 +1629,17 @@ export function createNestedPlaybookBridge(options) {
1560
1629
  }
1561
1630
  throw error;
1562
1631
  }
1563
- options.bindResumeSignal?.(signal);
1564
- await settlePending(active, validatedResult);
1632
+ // A resume whose signal is already aborted delivers nothing: the
1633
+ // validated child result is not consumed, no finish is emitted, and
1634
+ // the pending call survives for a later resume with a fresh signal
1635
+ // (slc/link.md §Nested playbook bridge). Identity and validation
1636
+ // control errors above still win — they are the caller's defects.
1637
+ if (signal.aborted) {
1638
+ throw signal.reason;
1639
+ }
1640
+ const resumeAborts = createAbortReasonClassifier(active.aborts, signal);
1641
+ options.bindResumeSignal?.(signal, resumeAborts);
1642
+ await settlePending(active, validatedResult, undefined, resumeAborts);
1565
1643
  },
1566
1644
  abortPending,
1567
1645
  async dispose() {