@sublang/playbook 6.0.0 → 7.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.
@@ -22,6 +22,7 @@ import type {
22
22
  PlaybookSession,
23
23
  PlaybookState,
24
24
  PlaybookStateValue,
25
+ PlaybookSuspendedCall,
25
26
  PlayerResult,
26
27
  } from './runtime.js';
27
28
 
@@ -757,23 +758,122 @@ const SNAPSHOT_SEQUENCE_KEYS = [
757
758
  'playbookCall',
758
759
  ] as const;
759
760
 
760
- // DR-014 §1: validate and detach a host-supplied runtime snapshot before
761
- // restore touches any state. Rejects a schema-version or playbook-id
762
- // mismatch with a path-named error.
761
+ export interface PlaybookRuntimeSnapshotValidationOptions {
762
+ /**
763
+ * Opt in only when the restore path will prepare and confirm the suspended
764
+ * call transaction. The default is fail-closed so a legacy restore cannot
765
+ * reopen or ignore it.
766
+ */
767
+ allowSuspendedCall?: boolean;
768
+ }
769
+
770
+ function snapshotSuspendedCall(
771
+ value: unknown,
772
+ path = 'runtime snapshot suspendedCall',
773
+ ): PlaybookSuspendedCall {
774
+ const captured = snapshotJsonValue(value, path);
775
+ if (!isRecord(captured)) {
776
+ throw new TypeError(`${path} must be an object`);
777
+ }
778
+ rejectUnknownKeys(
779
+ captured,
780
+ ['callId', 'stateId', 'playbookId', 'text', 'childSessionId', 'turnId'],
781
+ path,
782
+ );
783
+ const call: PlaybookSuspendedCall = {
784
+ callId: requireNonEmptyString(captured.callId, `${path}.callId`),
785
+ stateId: requireNonEmptyString(captured.stateId, `${path}.stateId`),
786
+ playbookId: requireNonEmptyString(
787
+ captured.playbookId,
788
+ `${path}.playbookId`,
789
+ ),
790
+ text: requireNonEmptyString(captured.text, `${path}.text`),
791
+ childSessionId: requireNonEmptyString(
792
+ captured.childSessionId,
793
+ `${path}.childSessionId`,
794
+ ),
795
+ };
796
+ if (own(captured, 'turnId')) {
797
+ if (
798
+ !Number.isSafeInteger(captured.turnId) ||
799
+ (captured.turnId as number) <= 0
800
+ ) {
801
+ throw new TypeError(`${path}.turnId must be a positive integer`);
802
+ }
803
+ call.turnId = captured.turnId as number;
804
+ }
805
+ return Object.freeze(call);
806
+ }
807
+
808
+ // DR-014 §1 / DR-031 §5: validate and detach a host-supplied runtime
809
+ // snapshot before restore touches any state. A suspended schema-2 call is
810
+ // rejected unless the restore path explicitly promises to seed and claim it.
763
811
  export function assertPlaybookRuntimeSnapshot(
764
812
  value: unknown,
765
813
  expectedPlaybookId: string,
814
+ options: PlaybookRuntimeSnapshotValidationOptions = {},
766
815
  ): PlaybookRuntimeSnapshot {
767
- if (!isRecord(value)) {
816
+ const snapshot = snapshotJsonValue(value, 'runtime snapshot');
817
+ if (!isRecord(snapshot)) {
768
818
  throw new TypeError('runtime snapshot must be an object');
769
819
  }
770
- if (value.schemaVersion !== 1) {
820
+ const capturedOptions = snapshotJsonValue(
821
+ options,
822
+ 'runtime snapshot validation options',
823
+ );
824
+ if (!isRecord(capturedOptions)) {
825
+ throw new TypeError('runtime snapshot validation options must be an object');
826
+ }
827
+ rejectUnknownKeys(
828
+ capturedOptions,
829
+ ['allowSuspendedCall'],
830
+ 'runtime snapshot validation options',
831
+ );
832
+ if (
833
+ capturedOptions.allowSuspendedCall !== undefined &&
834
+ typeof capturedOptions.allowSuspendedCall !== 'boolean'
835
+ ) {
836
+ throw new TypeError(
837
+ 'runtime snapshot validation options.allowSuspendedCall must be boolean',
838
+ );
839
+ }
840
+ const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
841
+ if (snapshot.schemaVersion !== 1 && snapshot.schemaVersion !== 2) {
842
+ throw new TypeError(
843
+ `runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1 or 2)`,
844
+ );
845
+ }
846
+ const schemaVersion = snapshot.schemaVersion;
847
+ rejectUnknownKeys(
848
+ snapshot,
849
+ [
850
+ 'schemaVersion',
851
+ 'playbookId',
852
+ 'machine',
853
+ 'playerResumeTokens',
854
+ 'sequences',
855
+ 'state',
856
+ 'pendingBossQuestions',
857
+ 'suspendedCall',
858
+ ],
859
+ 'runtime snapshot',
860
+ );
861
+ if (schemaVersion === 1 && own(snapshot, 'suspendedCall')) {
771
862
  throw new TypeError(
772
- `runtime snapshot schemaVersion ${String(value.schemaVersion)} is not supported (expected 1)`,
863
+ 'runtime snapshot schemaVersion 1 must not carry suspendedCall',
773
864
  );
774
865
  }
866
+ let suspendedCall: PlaybookSuspendedCall | undefined;
867
+ if (schemaVersion === 2 && own(snapshot, 'suspendedCall')) {
868
+ suspendedCall = snapshotSuspendedCall(snapshot.suspendedCall);
869
+ if (!allowSuspendedCall) {
870
+ throw new TypeError(
871
+ 'runtime snapshot suspendedCall requires a restore path that explicitly allows it',
872
+ );
873
+ }
874
+ }
775
875
  const playbookId = requireNonEmptyString(
776
- value.playbookId,
876
+ snapshot.playbookId,
777
877
  'runtime snapshot playbookId',
778
878
  );
779
879
  if (playbookId !== expectedPlaybookId) {
@@ -781,17 +881,17 @@ export function assertPlaybookRuntimeSnapshot(
781
881
  `runtime snapshot playbookId ${playbookId} does not match runtime playbook ${expectedPlaybookId}`,
782
882
  );
783
883
  }
784
- if (!isRecord(value.machine)) {
884
+ if (!isRecord(snapshot.machine)) {
785
885
  throw new TypeError('runtime snapshot machine must be an object');
786
886
  }
787
- const machine = snapshotJsonValue(value.machine, 'runtime snapshot machine');
788
- if (!isRecord(value.playerResumeTokens)) {
887
+ const machine = snapshot.machine;
888
+ if (!isRecord(snapshot.playerResumeTokens)) {
789
889
  throw new TypeError(
790
890
  'runtime snapshot playerResumeTokens must be an object',
791
891
  );
792
892
  }
793
893
  const playerResumeTokens: Record<string, string> = {};
794
- for (const [playerId, token] of Object.entries(value.playerResumeTokens)) {
894
+ for (const [playerId, token] of Object.entries(snapshot.playerResumeTokens)) {
795
895
  defineEnumerableDataProperty(
796
896
  playerResumeTokens,
797
897
  playerId,
@@ -801,12 +901,17 @@ export function assertPlaybookRuntimeSnapshot(
801
901
  ),
802
902
  );
803
903
  }
804
- if (!isRecord(value.sequences)) {
904
+ if (!isRecord(snapshot.sequences)) {
805
905
  throw new TypeError('runtime snapshot sequences must be an object');
806
906
  }
907
+ rejectUnknownKeys(
908
+ snapshot.sequences,
909
+ [...SNAPSHOT_SEQUENCE_KEYS, 'captainCall'],
910
+ 'runtime snapshot sequences',
911
+ );
807
912
  const sequences = {} as PlaybookRuntimeSnapshot['sequences'];
808
913
  for (const key of SNAPSHOT_SEQUENCE_KEYS) {
809
- const sequence = value.sequences[key];
914
+ const sequence = snapshot.sequences[key];
810
915
  if (!Number.isSafeInteger(sequence) || (sequence as number) < 0) {
811
916
  throw new TypeError(
812
917
  `runtime snapshot sequences.${key} must be a non-negative integer`,
@@ -814,7 +919,7 @@ export function assertPlaybookRuntimeSnapshot(
814
919
  }
815
920
  sequences[key] = sequence as number;
816
921
  }
817
- const captainCall = value.sequences.captainCall;
922
+ const captainCall = snapshot.sequences.captainCall;
818
923
  if (captainCall !== undefined) {
819
924
  if (!Number.isSafeInteger(captainCall) || (captainCall as number) < 0) {
820
925
  throw new TypeError(
@@ -823,20 +928,57 @@ export function assertPlaybookRuntimeSnapshot(
823
928
  }
824
929
  sequences.captainCall = captainCall as number;
825
930
  }
826
- validateState(value.state, 'runtime snapshot state');
827
- const state = snapshotJsonValue(
828
- value.state,
829
- 'runtime snapshot state',
830
- ) as unknown as PlaybookState;
831
- if (!Array.isArray(value.pendingBossQuestions)) {
931
+ validateState(snapshot.state, 'runtime snapshot state');
932
+ const state = snapshot.state as unknown as PlaybookState;
933
+ if (state.tags.includes(SUSPENDED_TAG) && suspendedCall === undefined) {
934
+ throw new TypeError(
935
+ `runtime snapshot state tagged ${SUSPENDED_TAG} requires schemaVersion 2 suspendedCall`,
936
+ );
937
+ }
938
+ if (suspendedCall) {
939
+ if (sequences.playbookCall === 0) {
940
+ throw new TypeError(
941
+ 'runtime snapshot suspendedCall requires sequences.playbookCall greater than zero',
942
+ );
943
+ }
944
+ if (
945
+ suspendedCall.turnId !== undefined &&
946
+ suspendedCall.turnId > sequences.turn
947
+ ) {
948
+ throw new TypeError(
949
+ 'runtime snapshot suspendedCall.turnId must not exceed sequences.turn',
950
+ );
951
+ }
952
+ if (state.status !== 'active' || !state.quiescent) {
953
+ throw new TypeError(
954
+ 'runtime snapshot suspendedCall requires an active quiescent state',
955
+ );
956
+ }
957
+ if (!state.tags.includes(SUSPENDED_TAG)) {
958
+ throw new TypeError(
959
+ `runtime snapshot suspendedCall requires state tag ${SUSPENDED_TAG}`,
960
+ );
961
+ }
962
+ if (!state.activeStateIds.includes(suspendedCall.stateId)) {
963
+ throw new TypeError(
964
+ 'runtime snapshot suspendedCall.stateId must be active in snapshot state',
965
+ );
966
+ }
967
+ }
968
+ if (!Array.isArray(snapshot.pendingBossQuestions)) {
832
969
  throw new TypeError(
833
970
  'runtime snapshot pendingBossQuestions must be an array',
834
971
  );
835
972
  }
836
- const pendingBossQuestions = value.pendingBossQuestions.map(
973
+ const pendingBossQuestions = snapshot.pendingBossQuestions.map(
837
974
  (entry, index) => {
838
975
  const path = `runtime snapshot pendingBossQuestions[${index}]`;
839
976
  if (!isRecord(entry)) throw new TypeError(`${path} must be an object`);
977
+ rejectUnknownKeys(
978
+ entry,
979
+ ['questionId', 'player', 'question', 'sourceItem'],
980
+ path,
981
+ );
840
982
  const question: PlaybookPendingBossQuestion = {
841
983
  questionId: requireNonEmptyString(
842
984
  entry.questionId,
@@ -856,14 +998,21 @@ export function assertPlaybookRuntimeSnapshot(
856
998
  return Object.freeze(question);
857
999
  },
858
1000
  );
859
- return Object.freeze({
860
- schemaVersion: 1,
1001
+ const fields = {
861
1002
  playbookId,
862
1003
  machine,
863
1004
  playerResumeTokens: Object.freeze(playerResumeTokens),
864
1005
  sequences: Object.freeze(sequences),
865
1006
  state,
866
1007
  pendingBossQuestions: Object.freeze(pendingBossQuestions),
1008
+ };
1009
+ if (schemaVersion === 1) {
1010
+ return Object.freeze({ schemaVersion: 1, ...fields });
1011
+ }
1012
+ return Object.freeze({
1013
+ schemaVersion: 2,
1014
+ ...fields,
1015
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
867
1016
  });
868
1017
  }
869
1018
 
@@ -916,15 +1065,24 @@ export class NestedPlaybookCallError extends Error {
916
1065
  interface ActiveCall {
917
1066
  readonly callId: string;
918
1067
  readonly input: NestedPlaybookInput;
1068
+ readonly turnId?: number;
919
1069
  readonly deferred: Deferred<JsonValue | undefined>;
920
1070
  readonly finished: Deferred<void>;
921
1071
  readonly controller: AbortController;
922
1072
  readonly signal: AbortSignal;
923
- phase: 'starting' | 'suspended' | 'settling';
1073
+ phase: 'starting' | 'restoring' | 'suspended' | 'settling';
924
1074
  childSessionId?: string;
925
1075
  abortListener?: () => void;
926
1076
  settlement?: Promise<void>;
927
1077
  runError?: unknown;
1078
+ restoreRolledBack?: boolean;
1079
+ }
1080
+
1081
+ interface NestedPlaybookRestoreMode {
1082
+ readonly call?: PlaybookSuspendedCall;
1083
+ state: 'armed' | 'claimed' | 'failed';
1084
+ active?: ActiveCall;
1085
+ error?: unknown;
928
1086
  }
929
1087
 
930
1088
  export interface PendingCallObserver {
@@ -938,6 +1096,15 @@ export interface NestedPlaybookBridge<
938
1096
  TInput extends NestedPlaybookInput = NestedPlaybookInput,
939
1097
  > extends PendingCallObserver {
940
1098
  actorLogic: PromiseActorLogic<JsonValue | undefined, TInput>;
1099
+ /** Arm fail-closed actor startup for a snapshot with zero or one nested call. */
1100
+ prepareRestore(call?: PlaybookSuspendedCall): void;
1101
+ /**
1102
+ * Commit restore startup after the persisted machine recreated exactly the
1103
+ * expected zero or one nested invocation.
1104
+ */
1105
+ confirmRestore(): void;
1106
+ /** Complete durable identity; undefined until a normal or restored call suspends. */
1107
+ getSuspendedCall(): PlaybookSuspendedCall | undefined;
941
1108
  resume(input: {
942
1109
  callId: string;
943
1110
  result: PlaybookCallResult;
@@ -1267,6 +1434,7 @@ export function createNestedPlaybookBridge<
1267
1434
  TInput extends NestedPlaybookInput = NestedPlaybookInput,
1268
1435
  >(options: NestedPlaybookBridgeOptions): NestedPlaybookBridge<TInput> {
1269
1436
  let current: ActiveCall | undefined;
1437
+ let restoreMode: NestedPlaybookRestoreMode | undefined;
1270
1438
  let disposed = false;
1271
1439
  const usedCallIds = new Set<string>();
1272
1440
  const pendingListeners = new Set<
@@ -1307,10 +1475,38 @@ export function createNestedPlaybookBridge<
1307
1475
  }
1308
1476
  : undefined;
1309
1477
 
1310
- const clear = (active: ActiveCall): void => {
1478
+ const suspendedIdentity = (
1479
+ active: ActiveCall | undefined,
1480
+ ): PlaybookSuspendedCall | undefined =>
1481
+ active?.phase === 'suspended' && active.childSessionId
1482
+ ? Object.freeze({
1483
+ callId: active.callId,
1484
+ stateId: active.input.stateId,
1485
+ playbookId: active.input.playbookId,
1486
+ text: active.input.text,
1487
+ childSessionId: active.childSessionId,
1488
+ ...(active.turnId === undefined ? {} : { turnId: active.turnId }),
1489
+ })
1490
+ : undefined;
1491
+
1492
+ const failRestoreMode = (
1493
+ mode: NestedPlaybookRestoreMode,
1494
+ error: unknown,
1495
+ ): void => {
1496
+ mode.state = 'failed';
1497
+ mode.error = error;
1498
+ reportControlPlaneError(error);
1499
+ };
1500
+
1501
+ const detachAbortListener = (active: ActiveCall): void => {
1311
1502
  if (active.abortListener) {
1312
1503
  active.signal.removeEventListener('abort', active.abortListener);
1504
+ active.abortListener = undefined;
1313
1505
  }
1506
+ };
1507
+
1508
+ const clear = (active: ActiveCall): void => {
1509
+ detachAbortListener(active);
1314
1510
  if (current === active) current = undefined;
1315
1511
  };
1316
1512
 
@@ -1443,35 +1639,202 @@ export function createNestedPlaybookBridge<
1443
1639
  }
1444
1640
  };
1445
1641
 
1642
+ const rollbackRestoredCall = (
1643
+ mode: NestedPlaybookRestoreMode,
1644
+ error: unknown,
1645
+ ): ActiveCall | undefined => {
1646
+ const active = mode.active;
1647
+ mode.state = 'failed';
1648
+ mode.error = error;
1649
+ mode.active = undefined;
1650
+ if (!active) return undefined;
1651
+ active.phase = 'settling';
1652
+ active.restoreRolledBack = true;
1653
+ clear(active);
1654
+ usedCallIds.delete(active.callId);
1655
+ active.deferred.reject(error);
1656
+ return active;
1657
+ };
1658
+
1659
+ const publishSuspendedCall = (active: ActiveCall): void => {
1660
+ if (active.phase !== 'suspended') {
1661
+ throw new Error(`playbook call ${active.callId} is not suspended`);
1662
+ }
1663
+ const abortListener = (): void => {
1664
+ if (active.phase !== 'suspended') return;
1665
+ const result = resultFromThrown(
1666
+ active.input.playbookId,
1667
+ active.childSessionId,
1668
+ active.signal.reason ?? new Error('Nested playbook invocation aborted'),
1669
+ true,
1670
+ );
1671
+ void settlePending(active, result).catch((error: unknown) => {
1672
+ reportBackgroundError(error);
1673
+ });
1674
+ };
1675
+ active.abortListener = abortListener;
1676
+ active.signal.addEventListener('abort', abortListener, { once: true });
1677
+ const pendingCall = pendingIdentity(active);
1678
+ if (!pendingCall) {
1679
+ throw new Error('suspended call identity was not recorded');
1680
+ }
1681
+ for (const listener of pendingListeners) {
1682
+ try {
1683
+ listener(pendingCall);
1684
+ } catch (error) {
1685
+ reportBackgroundError(error);
1686
+ }
1687
+ }
1688
+ if (active.signal.aborted) abortListener();
1689
+ };
1690
+
1691
+ const waitOnSuspendedCall = async (
1692
+ active: ActiveCall,
1693
+ ): Promise<JsonValue | undefined> => {
1694
+ publishSuspendedCall(active);
1695
+ return await active.deferred.promise;
1696
+ };
1697
+
1446
1698
  const actorLogic = fromPromise<JsonValue | undefined, TInput>(
1447
1699
  async ({ input, signal: invocationSignal }) => {
1448
1700
  if (disposed) {
1449
1701
  rejectControlPlane(new Error('nested playbook bridge is disposed'));
1450
1702
  }
1703
+ const normalizedInput = (() => {
1704
+ try {
1705
+ return {
1706
+ stateId: requireNonEmptyString(
1707
+ input.stateId,
1708
+ 'playbook input stateId',
1709
+ ),
1710
+ playbookId: requireNonEmptyString(
1711
+ input.playbookId,
1712
+ 'playbook input playbookId',
1713
+ ),
1714
+ text: requireNonEmptyString(input.text, 'playbook input text'),
1715
+ };
1716
+ } catch (error) {
1717
+ const mode = restoreMode;
1718
+ if (mode) {
1719
+ if (mode.state === 'claimed') {
1720
+ rollbackRestoredCall(mode, error);
1721
+ reportControlPlaneError(error);
1722
+ } else failRestoreMode(mode, error);
1723
+ throw error;
1724
+ }
1725
+ return rejectControlPlane(error);
1726
+ }
1727
+ })();
1728
+
1729
+ const mode = restoreMode;
1730
+ if (mode) {
1731
+ if (mode.state !== 'armed') {
1732
+ const callId = mode.call?.callId ?? 'without a descriptor';
1733
+ const error = new Error(
1734
+ mode.state === 'claimed'
1735
+ ? `restored playbook call ${callId} was claimed more than once`
1736
+ : `restored playbook call ${callId} is no longer claimable`,
1737
+ );
1738
+ if (mode.state === 'claimed') rollbackRestoredCall(mode, error);
1739
+ else mode.error ??= error;
1740
+ reportControlPlaneError(error);
1741
+ throw error;
1742
+ }
1743
+ const seed = mode.call;
1744
+ if (!seed) {
1745
+ const error = new Error(
1746
+ 'restored machine invoked a nested playbook without a suspendedCall descriptor',
1747
+ );
1748
+ failRestoreMode(mode, error);
1749
+ throw error;
1750
+ }
1751
+ for (const field of ['stateId', 'playbookId', 'text'] as const) {
1752
+ if (normalizedInput[field] !== seed[field]) {
1753
+ const error = new Error(
1754
+ `restored playbook call ${seed.callId} ${field} does not match its persisted input`,
1755
+ );
1756
+ failRestoreMode(mode, error);
1757
+ throw error;
1758
+ }
1759
+ }
1760
+ if (usedCallIds.has(seed.callId)) {
1761
+ const error = new Error(
1762
+ `restored duplicate playbook call id ${seed.callId}`,
1763
+ );
1764
+ failRestoreMode(mode, error);
1765
+ throw error;
1766
+ }
1767
+ const controller = new AbortController();
1768
+ let callSignal: AbortSignal;
1769
+ try {
1770
+ callSignal = combineAbortSignals(
1771
+ invocationSignal,
1772
+ options.getBoundarySignal?.(),
1773
+ controller.signal,
1774
+ );
1775
+ } catch (error) {
1776
+ failRestoreMode(mode, error);
1777
+ throw error;
1778
+ }
1779
+ const active: ActiveCall = {
1780
+ callId: seed.callId,
1781
+ input: normalizedInput,
1782
+ ...(seed.turnId === undefined
1783
+ ? {}
1784
+ : { turnId: seed.turnId }),
1785
+ deferred: deferred<JsonValue | undefined>(),
1786
+ finished: deferred<void>(),
1787
+ controller,
1788
+ signal: callSignal,
1789
+ phase: 'restoring',
1790
+ childSessionId: seed.childSessionId,
1791
+ };
1792
+ usedCallIds.add(active.callId);
1793
+ current = active;
1794
+ mode.state = 'claimed';
1795
+ mode.active = active;
1796
+ const restoreAbortListener = (): void => {
1797
+ if (
1798
+ restoreMode !== mode ||
1799
+ mode.state !== 'claimed' ||
1800
+ mode.active !== active ||
1801
+ active.phase !== 'restoring'
1802
+ ) {
1803
+ return;
1804
+ }
1805
+ rollbackRestoredCall(
1806
+ mode,
1807
+ active.signal.reason ??
1808
+ new Error('Restored nested playbook invocation aborted'),
1809
+ );
1810
+ };
1811
+ active.abortListener = restoreAbortListener;
1812
+ active.signal.addEventListener('abort', restoreAbortListener, {
1813
+ once: true,
1814
+ });
1815
+ if (active.signal.aborted) restoreAbortListener();
1816
+ try {
1817
+ return await active.deferred.promise;
1818
+ } catch (error) {
1819
+ if (!active.restoreRolledBack) active.runError = error;
1820
+ throw error;
1821
+ } finally {
1822
+ active.finished.resolve(undefined);
1823
+ }
1824
+ }
1825
+
1451
1826
  if (current) {
1452
1827
  rejectControlPlane(
1453
1828
  new Error(`playbook call ${current.callId} is already outstanding`),
1454
1829
  );
1455
1830
  }
1456
- const [normalizedInput, callId] = (() => {
1831
+
1832
+ const callId = (() => {
1457
1833
  try {
1458
- return [
1459
- {
1460
- stateId: requireNonEmptyString(
1461
- input.stateId,
1462
- 'playbook input stateId',
1463
- ),
1464
- playbookId: requireNonEmptyString(
1465
- input.playbookId,
1466
- 'playbook input playbookId',
1467
- ),
1468
- text: requireNonEmptyString(input.text, 'playbook input text'),
1469
- },
1470
- requireNonEmptyString(
1471
- options.nextCallId(),
1472
- 'allocated playbook call id',
1473
- ),
1474
- ] as const;
1834
+ return requireNonEmptyString(
1835
+ options.nextCallId(),
1836
+ 'allocated playbook call id',
1837
+ );
1475
1838
  } catch (error) {
1476
1839
  return rejectControlPlane(error);
1477
1840
  }
@@ -1665,34 +2028,7 @@ export function createNestedPlaybookBridge<
1665
2028
 
1666
2029
  active.phase = 'suspended';
1667
2030
  active.childSessionId = start.childSessionId;
1668
- const abortListener = (): void => {
1669
- if (active.phase !== 'suspended') return;
1670
- const result = resultFromThrown(
1671
- active.input.playbookId,
1672
- active.childSessionId,
1673
- active.signal.reason ??
1674
- new Error('Nested playbook invocation aborted'),
1675
- true,
1676
- );
1677
- void settlePending(active, result).catch((error: unknown) => {
1678
- reportBackgroundError(error);
1679
- });
1680
- };
1681
- active.abortListener = abortListener;
1682
- active.signal.addEventListener('abort', abortListener, { once: true });
1683
- const pendingCall = pendingIdentity(active);
1684
- if (!pendingCall) {
1685
- throw new Error('suspended call identity was not recorded');
1686
- }
1687
- for (const listener of pendingListeners) {
1688
- try {
1689
- listener(pendingCall);
1690
- } catch (error) {
1691
- reportBackgroundError(error);
1692
- }
1693
- }
1694
- if (active.signal.aborted) abortListener();
1695
- return await active.deferred.promise;
2031
+ return await waitOnSuspendedCall(active);
1696
2032
  } catch (error) {
1697
2033
  active.runError = error;
1698
2034
  throw error;
@@ -1705,6 +2041,16 @@ export function createNestedPlaybookBridge<
1705
2041
  const abortPending = async (
1706
2042
  error: unknown = new Error('Nested playbook call aborted'),
1707
2043
  ): Promise<void> => {
2044
+ const mode = restoreMode;
2045
+ if (mode) {
2046
+ restoreMode = undefined;
2047
+ const restored =
2048
+ mode.state === 'claimed'
2049
+ ? rollbackRestoredCall(mode, error)
2050
+ : undefined;
2051
+ if (restored) await restored.finished.promise;
2052
+ return;
2053
+ }
1708
2054
  const active = current;
1709
2055
  if (!active) return;
1710
2056
  if (!active.controller.signal.aborted) active.controller.abort(error);
@@ -1740,6 +2086,79 @@ export function createNestedPlaybookBridge<
1740
2086
  return {
1741
2087
  actorLogic,
1742
2088
  getPendingCall: () => pendingIdentity(current),
2089
+ getSuspendedCall: () => suspendedIdentity(current),
2090
+ prepareRestore(call) {
2091
+ // Capture the complete host-owned descriptor before observing or
2092
+ // mutating bridge state, so a rejected preparation cannot leave state.
2093
+ const captured =
2094
+ call === undefined
2095
+ ? undefined
2096
+ : snapshotSuspendedCall(call, 'restored playbook call');
2097
+ if (disposed) {
2098
+ rejectControlPlane(new Error('nested playbook bridge is disposed'));
2099
+ }
2100
+ if (current) {
2101
+ rejectControlPlane(
2102
+ new Error(`playbook call ${current.callId} is already outstanding`),
2103
+ );
2104
+ }
2105
+ if (restoreMode) {
2106
+ rejectControlPlane(
2107
+ new Error('nested playbook bridge restore is already prepared'),
2108
+ );
2109
+ }
2110
+ if (captured && usedCallIds.has(captured.callId)) {
2111
+ rejectControlPlane(
2112
+ new Error(`restored duplicate playbook call id ${captured.callId}`),
2113
+ );
2114
+ }
2115
+ restoreMode = {
2116
+ ...(captured === undefined ? {} : { call: captured }),
2117
+ state: 'armed',
2118
+ };
2119
+ },
2120
+ confirmRestore() {
2121
+ const mode = restoreMode;
2122
+ if (!mode) {
2123
+ throw new Error('nested playbook bridge restore is not prepared');
2124
+ }
2125
+ if (mode.state === 'failed') {
2126
+ restoreMode = undefined;
2127
+ throw mode.error;
2128
+ }
2129
+ if (mode.call === undefined) {
2130
+ restoreMode = undefined;
2131
+ return;
2132
+ }
2133
+ if (mode.state !== 'claimed' || !mode.active) {
2134
+ const error = new Error(
2135
+ `restored playbook call ${mode.call.callId} was not claimed by actor startup`,
2136
+ );
2137
+ restoreMode = undefined;
2138
+ reportControlPlaneError(error);
2139
+ throw error;
2140
+ }
2141
+ const active = mode.active;
2142
+ if (active.signal.aborted) {
2143
+ const error =
2144
+ active.signal.reason ??
2145
+ new Error('Restored nested playbook invocation aborted');
2146
+ rollbackRestoredCall(mode, error);
2147
+ restoreMode = undefined;
2148
+ throw error;
2149
+ }
2150
+ try {
2151
+ detachAbortListener(active);
2152
+ active.phase = 'suspended';
2153
+ restoreMode = undefined;
2154
+ publishSuspendedCall(active);
2155
+ } catch (error) {
2156
+ rollbackRestoredCall(mode, error);
2157
+ restoreMode = undefined;
2158
+ reportControlPlaneError(error);
2159
+ throw error;
2160
+ }
2161
+ },
1743
2162
  subscribePendingCall(listener) {
1744
2163
  if (disposed) return () => undefined;
1745
2164
  pendingListeners.add(listener);
@@ -1807,6 +2226,7 @@ export function createNestedPlaybookBridge<
1807
2226
  throw active.runError;
1808
2227
  }
1809
2228
  } finally {
2229
+ restoreMode = undefined;
1810
2230
  pendingListeners.clear();
1811
2231
  }
1812
2232
  },