@sublang/playbook 7.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +20 -7
  2. package/docs/cli.md +88 -43
  3. package/docs/configuration.md +221 -119
  4. package/docs/embedding.md +78 -27
  5. package/package.json +4 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +18 -15
  18. package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +25 -15
  22. package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +29 -23
  48. package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +45 -24
  53. package/slc/link.md +297 -135
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +21 -16
  56. package/src/runtime.ts +20 -23
  57. package/src/xstate-playbook-runtime.d.ts +34 -20
  58. package/src/xstate-playbook-runtime.js +973 -400
  59. package/src/xstate-playbook-runtime.ts +1203 -457
  60. package/src/xstate-runtime.d.ts +17 -7
  61. package/src/xstate-runtime.js +198 -81
  62. package/src/xstate-runtime.ts +339 -112
@@ -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);
@@ -268,6 +280,25 @@ function capturedSessionStore(descriptors) {
268
280
  restore: method('restore'),
269
281
  });
270
282
  }
283
+ function capturedRoleBindings(descriptors) {
284
+ const captured = snapshotJsonValue(capturedDataValue(descriptors, 'roleBindings', 'playbook session roleBindings'), 'playbook session roleBindings');
285
+ if (!isRecord(captured)) {
286
+ throw new TypeError('playbook session roleBindings must be an object');
287
+ }
288
+ const bindings = {};
289
+ for (const [roleId, value] of Object.entries(captured)) {
290
+ requireNonEmptyString(roleId, 'playbook session roleBindings role id');
291
+ if (!isRecord(value)) {
292
+ throw new TypeError(`playbook session roleBindings.${roleId} must be an object`);
293
+ }
294
+ rejectUnknownKeys(value, ['playerId', 'promptIdentity'], `playbook session roleBindings.${roleId}`);
295
+ defineEnumerableDataProperty(bindings, roleId, Object.freeze({
296
+ playerId: requireNonEmptyString(value.playerId, `playbook session roleBindings.${roleId}.playerId`),
297
+ promptIdentity: requireNonEmptyString(value.promptIdentity, `playbook session roleBindings.${roleId}.promptIdentity`),
298
+ }));
299
+ }
300
+ return Object.freeze(bindings);
301
+ }
271
302
  /** Validate session causality and detach its immutable identity from the host. */
272
303
  export function snapshotPlaybookSession(session) {
273
304
  if (!isRecord(session)) {
@@ -287,6 +318,7 @@ export function snapshotPlaybookSession(session) {
287
318
  const hasParentSessionId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentSessionId');
288
319
  const hasParentCallId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentCallId');
289
320
  const hasPlayerSessions = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'playerSessions');
321
+ const hasRoleBindings = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'roleBindings');
290
322
  let parentSessionId;
291
323
  let parentCallId;
292
324
  if (depth === 0) {
@@ -320,6 +352,9 @@ export function snapshotPlaybookSession(session) {
320
352
  const playerSessions = hasPlayerSessions
321
353
  ? capturedSessionStore(sessionDescriptors)
322
354
  : undefined;
355
+ const roleBindings = hasRoleBindings
356
+ ? capturedRoleBindings(sessionDescriptors)
357
+ : undefined;
323
358
  return Object.freeze({
324
359
  sessionId,
325
360
  playbookId,
@@ -327,6 +362,7 @@ export function snapshotPlaybookSession(session) {
327
362
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
328
363
  ...(parentCallId === undefined ? {} : { parentCallId }),
329
364
  depth,
365
+ ...(roleBindings === undefined ? {} : { roleBindings }),
330
366
  ...(playerSessions === undefined ? {} : { playerSessions }),
331
367
  ports,
332
368
  });
@@ -460,12 +496,23 @@ export function activePlaybookStateMetadata(snapshot) {
460
496
  throw new TypeError(`${nodeId}.meta.playbook must be an object`);
461
497
  }
462
498
  const stateId = requireNonEmptyString(meta.playbook.stateId, `${nodeId}.meta.playbook.stateId`);
463
- 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`);
464
505
  const previous = byStateId.get(stateId);
465
- if (previous && previous.description !== description) {
506
+ if (previous?.description !== undefined &&
507
+ description !== undefined &&
508
+ previous.description !== description) {
466
509
  throw new TypeError(`active state id ${stateId} has conflicting descriptions`);
467
510
  }
468
- byStateId.set(stateId, { stateId, description });
511
+ const effective = description ?? previous?.description;
512
+ byStateId.set(stateId, {
513
+ stateId,
514
+ ...(effective === undefined ? {} : { description: effective }),
515
+ });
469
516
  }
470
517
  return [...byStateId.values()].sort((left, right) => left.stateId.localeCompare(right.stateId));
471
518
  }
@@ -548,9 +595,10 @@ function snapshotSuspendedCall(value, path = 'runtime snapshot suspendedCall') {
548
595
  }
549
596
  return Object.freeze(call);
550
597
  }
551
- // DR-014 §1 / DR-031 §5: validate and detach a host-supplied runtime
552
- // snapshot before restore touches any state. A suspended schema-2 call is
553
- // rejected unless the restore path explicitly promises to seed and claim it.
598
+ // DR-014 §1 / DR-031 §5 / DR-032: validate and detach a host-supplied
599
+ // schema-3 runtime snapshot before restore touches any state. A suspended
600
+ // call is rejected unless the restore path explicitly promises to seed and
601
+ // claim it; older schemas are rejected rather than guessing role identity.
554
602
  export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options = {}) {
555
603
  const snapshot = snapshotJsonValue(value, 'runtime snapshot');
556
604
  if (!isRecord(snapshot)) {
@@ -566,25 +614,21 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
566
614
  throw new TypeError('runtime snapshot validation options.allowSuspendedCall must be boolean');
567
615
  }
568
616
  const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
569
- if (snapshot.schemaVersion !== 1 && snapshot.schemaVersion !== 2) {
570
- throw new TypeError(`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1 or 2)`);
617
+ if (snapshot.schemaVersion !== 3) {
618
+ throw new TypeError(`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
571
619
  }
572
- const schemaVersion = snapshot.schemaVersion;
573
620
  rejectUnknownKeys(snapshot, [
574
621
  'schemaVersion',
575
622
  'playbookId',
576
623
  'machine',
577
- 'playerResumeTokens',
624
+ 'roleResumeTokens',
578
625
  'sequences',
579
626
  'state',
580
627
  'pendingBossQuestions',
581
628
  'suspendedCall',
582
629
  ], 'runtime snapshot');
583
- if (schemaVersion === 1 && own(snapshot, 'suspendedCall')) {
584
- throw new TypeError('runtime snapshot schemaVersion 1 must not carry suspendedCall');
585
- }
586
630
  let suspendedCall;
587
- if (schemaVersion === 2 && own(snapshot, 'suspendedCall')) {
631
+ if (own(snapshot, 'suspendedCall')) {
588
632
  suspendedCall = snapshotSuspendedCall(snapshot.suspendedCall);
589
633
  if (!allowSuspendedCall) {
590
634
  throw new TypeError('runtime snapshot suspendedCall requires a restore path that explicitly allows it');
@@ -598,12 +642,12 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
598
642
  throw new TypeError('runtime snapshot machine must be an object');
599
643
  }
600
644
  const machine = snapshot.machine;
601
- if (!isRecord(snapshot.playerResumeTokens)) {
602
- throw new TypeError('runtime snapshot playerResumeTokens must be an object');
645
+ if (!isRecord(snapshot.roleResumeTokens)) {
646
+ throw new TypeError('runtime snapshot roleResumeTokens must be an object');
603
647
  }
604
- const playerResumeTokens = {};
605
- for (const [playerId, token] of Object.entries(snapshot.playerResumeTokens)) {
606
- defineEnumerableDataProperty(playerResumeTokens, playerId, requireNonEmptyString(token, `runtime snapshot playerResumeTokens.${playerId}`));
648
+ const roleResumeTokens = {};
649
+ for (const [roleId, token] of Object.entries(snapshot.roleResumeTokens)) {
650
+ defineEnumerableDataProperty(roleResumeTokens, requireNonEmptyString(roleId, 'runtime snapshot roleResumeTokens role id'), requireNonEmptyString(token, `runtime snapshot roleResumeTokens.${roleId}`));
607
651
  }
608
652
  if (!isRecord(snapshot.sequences)) {
609
653
  throw new TypeError('runtime snapshot sequences must be an object');
@@ -627,7 +671,7 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
627
671
  validateState(snapshot.state, 'runtime snapshot state');
628
672
  const state = snapshot.state;
629
673
  if (state.tags.includes(SUSPENDED_TAG) && suspendedCall === undefined) {
630
- throw new TypeError(`runtime snapshot state tagged ${SUSPENDED_TAG} requires schemaVersion 2 suspendedCall`);
674
+ throw new TypeError(`runtime snapshot state tagged ${SUSPENDED_TAG} requires suspendedCall`);
631
675
  }
632
676
  if (suspendedCall) {
633
677
  if (sequences.playbookCall === 0) {
@@ -654,10 +698,28 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
654
698
  const path = `runtime snapshot pendingBossQuestions[${index}]`;
655
699
  if (!isRecord(entry))
656
700
  throw new TypeError(`${path} must be an object`);
657
- rejectUnknownKeys(entry, ['questionId', 'player', 'question', 'sourceItem'], path);
701
+ rejectUnknownKeys(entry, ['questionId', 'asker', 'question', 'sourceItem'], path);
702
+ if (!isRecord(entry.asker)) {
703
+ throw new TypeError(`${path}.asker must be an object`);
704
+ }
705
+ let asker;
706
+ if (entry.asker.kind === 'captain') {
707
+ rejectUnknownKeys(entry.asker, ['kind'], `${path}.asker`);
708
+ asker = Object.freeze({ kind: 'captain' });
709
+ }
710
+ else if (entry.asker.kind === 'role') {
711
+ rejectUnknownKeys(entry.asker, ['kind', 'roleId'], `${path}.asker`);
712
+ asker = Object.freeze({
713
+ kind: 'role',
714
+ roleId: requireNonEmptyString(entry.asker.roleId, `${path}.asker.roleId`),
715
+ });
716
+ }
717
+ else {
718
+ throw new TypeError(`${path}.asker.kind must be "captain" or "role"`);
719
+ }
658
720
  const question = {
659
721
  questionId: requireNonEmptyString(entry.questionId, `${path}.questionId`),
660
- player: requireNonEmptyString(entry.player, `${path}.player`),
722
+ asker,
661
723
  question: requireNonEmptyString(entry.question, `${path}.question`),
662
724
  ...(entry.sourceItem === undefined
663
725
  ? {}
@@ -670,16 +732,13 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
670
732
  const fields = {
671
733
  playbookId,
672
734
  machine,
673
- playerResumeTokens: Object.freeze(playerResumeTokens),
735
+ roleResumeTokens: Object.freeze(roleResumeTokens),
674
736
  sequences: Object.freeze(sequences),
675
737
  state,
676
738
  pendingBossQuestions: Object.freeze(pendingBossQuestions),
677
739
  };
678
- if (schemaVersion === 1) {
679
- return Object.freeze({ schemaVersion: 1, ...fields });
680
- }
681
740
  return Object.freeze({
682
- schemaVersion: 2,
741
+ schemaVersion: 3,
683
742
  ...fields,
684
743
  ...(suspendedCall === undefined ? {} : { suspendedCall }),
685
744
  });
@@ -773,6 +832,9 @@ export function validatePlayerResult(value, path = 'player result') {
773
832
  rejectUnknownKeys(result, ['status', 'resumeToken', 'finalText', 'error'], path);
774
833
  validateRunStatus(result.status, `${path}.status`);
775
834
  validateOptionalString(result, 'resumeToken', path);
835
+ if (result.resumeToken !== undefined) {
836
+ requireNonEmptyString(result.resumeToken, `${path}.resumeToken`);
837
+ }
776
838
  validateOptionalString(result, 'finalText', path);
777
839
  validateOptionalString(result, 'error', path);
778
840
  return result;
@@ -909,22 +971,26 @@ export function createNestedPlaybookBridge(options) {
909
971
  let disposed = false;
910
972
  const usedCallIds = new Set();
911
973
  const pendingListeners = new Set();
912
- const reportBackgroundError = (error) => {
974
+ const reportBackgroundError = (error, aborts) => {
975
+ if (aborts?.isAbortReason(error))
976
+ return;
913
977
  try {
914
- options.onBackgroundError?.(error);
978
+ options.onBackgroundError?.(error, aborts);
915
979
  }
916
980
  catch {
917
981
  // Background observers are a terminal sink and cannot own cleanup.
918
982
  }
919
983
  };
920
- const reportControlPlaneError = (error) => {
984
+ const reportControlPlaneError = (error, aborts) => {
985
+ if (aborts?.isAbortReason(error))
986
+ return;
921
987
  try {
922
- options.onControlPlaneError?.(error);
988
+ options.onControlPlaneError?.(error, aborts);
923
989
  }
924
990
  catch (callbackError) {
925
991
  // Observability callbacks must never prevent terminal cleanup of the
926
992
  // invocation they are observing.
927
- reportBackgroundError(callbackError);
993
+ reportBackgroundError(callbackError, aborts);
928
994
  }
929
995
  };
930
996
  const rejectControlPlane = (error) => {
@@ -964,27 +1030,37 @@ export function createNestedPlaybookBridge(options) {
964
1030
  if (current === active)
965
1031
  current = undefined;
966
1032
  };
967
- 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) => {
968
1039
  await options.emitFinished({
969
1040
  callId: active.callId,
970
1041
  stateId: active.input.stateId,
971
1042
  playbookId: active.input.playbookId,
972
1043
  text: active.input.text,
973
1044
  result,
974
- });
975
- await options.drain();
1045
+ }, aborts);
1046
+ await options.drain(aborts);
976
1047
  };
977
1048
  const finishImmediate = async (active, result, controlError, resultAfterAbortCleanup) => {
1049
+ const aborts = active.aborts;
978
1050
  let effectiveResult = result;
979
1051
  let cleanupControlError;
980
1052
  if (result.status === 'aborted' || active.signal.aborted) {
981
1053
  try {
982
- await drainPlaybookAbortCleanups(active.signal);
1054
+ await drainPlaybookAbortCleanups(active.signal, aborts);
983
1055
  }
984
1056
  catch (error) {
985
- cleanupControlError = error;
986
- reportControlPlaneError(error);
987
- 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
+ }
988
1064
  }
989
1065
  if (cleanupControlError === undefined && resultAfterAbortCleanup) {
990
1066
  effectiveResult = resultAfterAbortCleanup();
@@ -992,10 +1068,10 @@ export function createNestedPlaybookBridge(options) {
992
1068
  }
993
1069
  let finishControlError;
994
1070
  try {
995
- await emitFinish(active, effectiveResult);
1071
+ await emitFinish(active, effectiveResult, aborts);
996
1072
  }
997
1073
  catch (error) {
998
- reportControlPlaneError(error);
1074
+ reportNonAbortControlError(error, aborts);
999
1075
  finishControlError = error;
1000
1076
  }
1001
1077
  finally {
@@ -1003,6 +1079,7 @@ export function createNestedPlaybookBridge(options) {
1003
1079
  // emission fails, do not leave a permanently unresumable call in the
1004
1080
  // bridge and prevent disposal or a later invocation.
1005
1081
  clear(active);
1082
+ options.bindActorSettlement?.(aborts);
1006
1083
  }
1007
1084
  if (controlError !== undefined)
1008
1085
  throw controlError;
@@ -1012,7 +1089,7 @@ export function createNestedPlaybookBridge(options) {
1012
1089
  throw finishControlError;
1013
1090
  return outputOrThrow(effectiveResult);
1014
1091
  };
1015
- const settlePending = async (active, result, controlError) => {
1092
+ const settlePending = async (active, result, controlError, aborts = active.aborts) => {
1016
1093
  if (active.phase === 'settling' && active.settlement) {
1017
1094
  await active.settlement;
1018
1095
  return;
@@ -1026,32 +1103,40 @@ export function createNestedPlaybookBridge(options) {
1026
1103
  let cleanupControlError;
1027
1104
  if (result.status === 'aborted' || active.signal.aborted) {
1028
1105
  if (result.status !== 'aborted' && active.signal.aborted) {
1029
- effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ??
1030
- new Error('Nested playbook invocation aborted'), true);
1106
+ effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason, true);
1031
1107
  }
1032
1108
  try {
1033
- await drainPlaybookAbortCleanups(active.signal);
1109
+ await drainPlaybookAbortCleanups(active.signal, aborts);
1034
1110
  }
1035
1111
  catch (cleanupError) {
1036
- cleanupControlError = cleanupError;
1037
- reportControlPlaneError(cleanupError);
1038
- 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
+ }
1039
1119
  }
1040
1120
  }
1041
1121
  try {
1042
- await emitFinish(active, effectiveResult);
1122
+ await emitFinish(active, effectiveResult, aborts);
1043
1123
  }
1044
1124
  catch (error) {
1045
1125
  // A finish event is the durable return boundary. If it cannot be
1046
1126
  // emitted and drained, the child result must not remain retryable:
1047
1127
  // clear the identity and fail the promise actor so its parent takes
1048
- // onError instead of observing a phantom suspended child.
1049
- 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);
1050
1133
  clear(active);
1134
+ options.bindActorSettlement?.(aborts);
1051
1135
  active.deferred.reject(error);
1052
1136
  throw error;
1053
1137
  }
1054
1138
  clear(active);
1139
+ options.bindActorSettlement?.(aborts);
1055
1140
  if (controlError !== undefined) {
1056
1141
  active.deferred.reject(controlError);
1057
1142
  }
@@ -1087,6 +1172,7 @@ export function createNestedPlaybookBridge(options) {
1087
1172
  active.restoreRolledBack = true;
1088
1173
  clear(active);
1089
1174
  usedCallIds.delete(active.callId);
1175
+ options.bindActorSettlement?.(active.aborts);
1090
1176
  active.deferred.reject(error);
1091
1177
  return active;
1092
1178
  };
@@ -1097,9 +1183,9 @@ export function createNestedPlaybookBridge(options) {
1097
1183
  const abortListener = () => {
1098
1184
  if (active.phase !== 'suspended')
1099
1185
  return;
1100
- 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);
1101
1187
  void settlePending(active, result).catch((error) => {
1102
- reportBackgroundError(error);
1188
+ reportBackgroundError(error, active.aborts);
1103
1189
  });
1104
1190
  };
1105
1191
  active.abortListener = abortListener;
@@ -1113,7 +1199,7 @@ export function createNestedPlaybookBridge(options) {
1113
1199
  listener(pendingCall);
1114
1200
  }
1115
1201
  catch (error) {
1116
- reportBackgroundError(error);
1202
+ reportBackgroundError(error, active.aborts);
1117
1203
  }
1118
1204
  }
1119
1205
  if (active.signal.aborted)
@@ -1183,8 +1269,11 @@ export function createNestedPlaybookBridge(options) {
1183
1269
  }
1184
1270
  const controller = new AbortController();
1185
1271
  let callSignal;
1272
+ let callAborts;
1186
1273
  try {
1187
- 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);
1188
1277
  }
1189
1278
  catch (error) {
1190
1279
  failRestoreMode(mode, error);
@@ -1200,6 +1289,7 @@ export function createNestedPlaybookBridge(options) {
1200
1289
  finished: deferred(),
1201
1290
  controller,
1202
1291
  signal: callSignal,
1292
+ aborts: callAborts,
1203
1293
  phase: 'restoring',
1204
1294
  childSessionId: seed.childSessionId,
1205
1295
  };
@@ -1214,8 +1304,7 @@ export function createNestedPlaybookBridge(options) {
1214
1304
  active.phase !== 'restoring') {
1215
1305
  return;
1216
1306
  }
1217
- rollbackRestoredCall(mode, active.signal.reason ??
1218
- new Error('Restored nested playbook invocation aborted'));
1307
+ rollbackRestoredCall(mode, active.signal.reason);
1219
1308
  };
1220
1309
  active.abortListener = restoreAbortListener;
1221
1310
  active.signal.addEventListener('abort', restoreAbortListener, {
@@ -1252,8 +1341,11 @@ export function createNestedPlaybookBridge(options) {
1252
1341
  usedCallIds.add(callId);
1253
1342
  const controller = new AbortController();
1254
1343
  let callSignal;
1344
+ let callAborts;
1255
1345
  try {
1256
- 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);
1257
1349
  }
1258
1350
  catch (error) {
1259
1351
  return rejectControlPlane(error);
@@ -1265,6 +1357,7 @@ export function createNestedPlaybookBridge(options) {
1265
1357
  finished: deferred(),
1266
1358
  controller,
1267
1359
  signal: callSignal,
1360
+ aborts: callAborts,
1268
1361
  phase: 'starting',
1269
1362
  };
1270
1363
  current = active;
@@ -1273,26 +1366,39 @@ export function createNestedPlaybookBridge(options) {
1273
1366
  // for their entering state. Yield through the runtime's global queue so
1274
1367
  // that transition/status telemetry is enqueued before call.started.
1275
1368
  try {
1276
- await options.drain();
1369
+ await options.drain(active.aborts);
1277
1370
  }
1278
1371
  catch (error) {
1279
- reportControlPlaneError(error);
1372
+ reportNonAbortControlError(error, active.aborts);
1280
1373
  clear(active);
1281
1374
  throw error;
1282
1375
  }
1283
1376
  try {
1284
- await options.emitStarted({ callId, ...normalizedInput });
1377
+ await options.emitStarted({ callId, ...normalizedInput }, active.aborts);
1285
1378
  }
1286
1379
  catch (error) {
1287
- reportControlPlaneError(error);
1288
- 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);
1289
1390
  }
1290
1391
  try {
1291
- await options.drain();
1392
+ await options.drain(active.aborts);
1292
1393
  }
1293
1394
  catch (error) {
1294
- reportControlPlaneError(error);
1295
- 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);
1296
1402
  }
1297
1403
  const request = {
1298
1404
  callId,
@@ -1315,7 +1421,7 @@ export function createNestedPlaybookBridge(options) {
1315
1421
  throw error;
1316
1422
  });
1317
1423
  const openingCleanup = starting.then(() => undefined, (error) => {
1318
- if (isAbortReason(error, active.signal))
1424
+ if (active.aborts.isAbortReason(error))
1319
1425
  return;
1320
1426
  throw error;
1321
1427
  });
@@ -1332,11 +1438,14 @@ export function createNestedPlaybookBridge(options) {
1332
1438
  rawStart = await withAbort(starting, active.signal);
1333
1439
  }
1334
1440
  catch (error) {
1335
- const controlError = active.signal.aborted ? undefined : error;
1336
- if (controlError !== undefined)
1337
- reportControlPlaneError(controlError);
1338
- const result = resultFromThrown(normalizedInput.playbookId, undefined, error, active.signal.aborted);
1339
- 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
1340
1449
  ? () => resultFromThrown(normalizedInput.playbookId, startSettled
1341
1450
  ? assignedChildSessionId(observedStart)
1342
1451
  : undefined, error, true)
@@ -1464,8 +1573,7 @@ export function createNestedPlaybookBridge(options) {
1464
1573
  }
1465
1574
  const active = mode.active;
1466
1575
  if (active.signal.aborted) {
1467
- const error = active.signal.reason ??
1468
- new Error('Restored nested playbook invocation aborted');
1576
+ const error = active.signal.reason;
1469
1577
  rollbackRestoredCall(mode, error);
1470
1578
  restoreMode = undefined;
1471
1579
  throw error;
@@ -1493,7 +1601,7 @@ export function createNestedPlaybookBridge(options) {
1493
1601
  listener(pendingCall);
1494
1602
  }
1495
1603
  catch (error) {
1496
- reportBackgroundError(error);
1604
+ reportBackgroundError(error, current?.aborts);
1497
1605
  }
1498
1606
  }
1499
1607
  return () => pendingListeners.delete(listener);
@@ -1521,8 +1629,17 @@ export function createNestedPlaybookBridge(options) {
1521
1629
  }
1522
1630
  throw error;
1523
1631
  }
1524
- options.bindResumeSignal?.(signal);
1525
- 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);
1526
1643
  },
1527
1644
  abortPending,
1528
1645
  async dispose() {