@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
@@ -233,9 +233,37 @@ export function normalizeErrorFull(err) {
233
233
  return undefined;
234
234
  return normalizeError(err);
235
235
  }
236
+ // slc/link.md §Abort: cancellation is causal identity with the applicable
237
+ // signal's reason — never an `AbortError` name, never bare signal state. A
238
+ // distinct failure observed while the signal is aborted stays a non-abort
239
+ // control error and takes precedence (mirrors DECIDE's bespoke reference).
236
240
  function isAbortFailure(error, signal) {
237
- return (signal.aborted &&
238
- (error === signal.reason || normalizeError(error).name === 'AbortError'));
241
+ return signal.aborted && Object.is(error, signal.reason);
242
+ }
243
+ function abortReasonClassifier(...sources) {
244
+ const captured = sources.filter((source) => source !== undefined);
245
+ return Object.freeze({
246
+ isAbortReason: (error) => captured.some((source) => source instanceof AbortSignal
247
+ ? isAbortFailure(error, source)
248
+ : source.isAbortReason(error)),
249
+ });
250
+ }
251
+ /**
252
+ * gears2fsm's canonical Boss-reply wait state. On the runtime's Boss-facing
253
+ * surfaces — state telemetry, status lines, the exported snapshot, and the
254
+ * control view — a context question counts as *pending* only while the
255
+ * machine sits in this state awaiting the reply. Later states retain the
256
+ * answered question in context (the resumed player prompt is composed from
257
+ * it), so an unconditional projection would resurrect it: a failure the
258
+ * resumed player reached would export a question nobody is waiting on,
259
+ * disagreeing with the gated telemetry a mirroring host's ledger follows
260
+ * and failing the shell's snapshot-equality settlement check.
261
+ */
262
+ const BOSS_REPLY_WAIT_STATE_ID = 'awaitBossReply';
263
+ function pendingBossQuestionForState(state, context) {
264
+ if (state.stateId !== BOSS_REPLY_WAIT_STATE_ID)
265
+ return undefined;
266
+ return pendingBossQuestionFromContext(context);
239
267
  }
240
268
  /** Read the FSM context's single pending Boss question, when well-formed. */
241
269
  export function pendingBossQuestionFromContext(context) {
@@ -466,7 +494,9 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
466
494
  prompt = spec.composePlayerPrompt(input);
467
495
  }
468
496
  catch (error) {
469
- onControlPlaneError?.(error);
497
+ if (!isAbortFailure(error, activeSignal)) {
498
+ onControlPlaneError?.(error);
499
+ }
470
500
  throw error;
471
501
  }
472
502
  const callPlayer = (resume) => boundary
@@ -506,7 +536,9 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
506
536
  return output;
507
537
  }
508
538
  catch (error) {
509
- onControlPlaneError?.(error);
539
+ if (!isAbortFailure(error, activeSignal)) {
540
+ onControlPlaneError?.(error);
541
+ }
510
542
  throw error;
511
543
  }
512
544
  });
@@ -670,7 +702,7 @@ export function resumableStateIdsFromMachine(machine) {
670
702
  if (!isPlainObject(config) || !isPlainObject(config.states)) {
671
703
  return new Set();
672
704
  }
673
- const awaitState = config.states.awaitBossReply;
705
+ const awaitState = config.states[BOSS_REPLY_WAIT_STATE_ID];
674
706
  if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
675
707
  return new Set();
676
708
  }
@@ -772,6 +804,28 @@ function deepFreeze(value) {
772
804
  // Default transition/status derivation.
773
805
  // ---------------------------------------------------------------------------
774
806
  const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
807
+ // Bounded escalation for aborted script process groups: SIGTERM first, then
808
+ // SIGKILL after this grace, so settlement (gated on the shell's own exit)
809
+ // stays bounded even for TERM-immune commands.
810
+ const SCRIPT_ABORT_KILL_GRACE_MS = 2000;
811
+ class ScriptProcessGroupTeardownError extends Error {
812
+ constructor(pid, message, cause) {
813
+ super(`script process group ${pid} teardown could not be confirmed: ${message}`, cause === undefined ? undefined : { cause });
814
+ this.name = 'ScriptProcessGroupTeardownError';
815
+ }
816
+ }
817
+ function isNoSuchProcess(error) {
818
+ return (typeof error === 'object' &&
819
+ error !== null &&
820
+ 'code' in error &&
821
+ error.code === 'ESRCH');
822
+ }
823
+ function isProcessPermissionDenied(error) {
824
+ return (typeof error === 'object' &&
825
+ error !== null &&
826
+ 'code' in error &&
827
+ error.code === 'EPERM');
828
+ }
775
829
  function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
776
830
  return (event) => {
777
831
  if (event === null || typeof event !== 'object') {
@@ -861,7 +915,7 @@ function makeDefaultStatusesForState(roleStates) {
861
915
  if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
862
916
  return statuses;
863
917
  }
864
- if (stateId === 'awaitBossReply') {
918
+ if (stateId === BOSS_REPLY_WAIT_STATE_ID) {
865
919
  const pending = pendingBossQuestionFromContext(context);
866
920
  if (pending === undefined) {
867
921
  return [...statuses, { message: 'Awaiting Boss reply.' }];
@@ -1063,7 +1117,14 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
1063
1117
  const state = classifierState(snapshotOrState);
1064
1118
  const stateId = typeof state.value === 'string' ? state.value : undefined;
1065
1119
  const currentState = stateId ?? JSON.stringify(state.value ?? null);
1066
- const pending = pendingBossQuestionFromContext(state.context);
1120
+ // The classifier shares the reply-wait pendingness of every other
1121
+ // surface: outside the wait, a context question a later state retains
1122
+ // is answered history, so the prompt must not present it as pending —
1123
+ // a judge told a question awaits at the failure state is steered toward
1124
+ // a reply it cannot select or toward no action at all.
1125
+ const pending = stateId === BOSS_REPLY_WAIT_STATE_ID
1126
+ ? pendingBossQuestionFromContext(state.context)
1127
+ : undefined;
1067
1128
  const configuredTypes = configuredEventTypesForState(machine, stateId);
1068
1129
  const applicable = [...contracts.values()].filter((contract) => configuredTypes.has(contract.type) &&
1069
1130
  (contract.type !== 'BOSS_REPLY' || pending !== undefined));
@@ -1179,6 +1240,60 @@ function machineDeclaresParallelState(machine) {
1179
1240
  };
1180
1241
  return visit(machine.config);
1181
1242
  }
1243
+ // PBRT-52: the factory's domain is FLAT single-region machines — every
1244
+ // state a direct child of the root, so each snapshot exposes exactly one
1245
+ // playbook state id and every state-keyed lookup (deterministic entries,
1246
+ // retry, reply-wait pendingness, configured events, descriptions) indexes
1247
+ // one unambiguous identity. A compound child would be accepted and then
1248
+ // silently misbehave on all of those gates, so it is rejected up front
1249
+ // exactly like a parallel region.
1250
+ function machineDeclaresNestedState(machine) {
1251
+ const config = machine.config;
1252
+ if (!isPlainObject(config) || !isPlainObject(config.states))
1253
+ return false;
1254
+ return Object.values(config.states).some((stateDef) => isPlainObject(stateDef) &&
1255
+ isPlainObject(stateDef.states) &&
1256
+ Object.keys(stateDef.states).length > 0);
1257
+ }
1258
+ // PBRT-52: the factory's lookups index states by their root key, and the
1259
+ // published playbook identity is `meta.playbook.stateId` — the two must
1260
+ // coincide or a machine can advertise a pending question or retry under an
1261
+ // identity no lookup resolves. A state with no string stateId is just as
1262
+ // dead: every snapshot identity derives from that member, so the first
1263
+ // entry would fail the exactly-one-state-id inspection at runtime.
1264
+ // gears2fsm keeps identity and key equal by construction; a hand-authored
1265
+ // artifact that splits or omits them fails here instead of at a silently
1266
+ // dead gate.
1267
+ function assertFlatStateIdentity(machine, label) {
1268
+ const config = machine.config;
1269
+ const states = isPlainObject(config) && isPlainObject(config.states)
1270
+ ? config.states
1271
+ : undefined;
1272
+ // A machine with no root states has no playbook identity to expose; its
1273
+ // first snapshot would fail the exactly-one-state-id inspection, so it
1274
+ // fails construction with the defect named instead.
1275
+ if (states === undefined || Object.keys(states).length === 0) {
1276
+ throw new Error(`${label} declares no root states; the shared runtime requires at ` +
1277
+ 'least one flat playbook state');
1278
+ }
1279
+ for (const [key, stateDef] of Object.entries(states)) {
1280
+ if (!isPlainObject(stateDef))
1281
+ continue;
1282
+ const meta = isPlainObject(stateDef.meta) ? stateDef.meta : undefined;
1283
+ const playbook = meta !== undefined && isPlainObject(meta.playbook)
1284
+ ? meta.playbook
1285
+ : undefined;
1286
+ const stateId = playbook?.stateId;
1287
+ if (typeof stateId !== 'string') {
1288
+ throw new Error(`${label} state ${key} declares no string meta.playbook.stateId; ` +
1289
+ 'the shared runtime derives every playbook state identity from it');
1290
+ }
1291
+ if (stateId !== key) {
1292
+ throw new Error(`${label} state ${key} declares meta.playbook.stateId ${stateId}; ` +
1293
+ 'the shared runtime requires the playbook state id to equal the state key');
1294
+ }
1295
+ }
1296
+ }
1182
1297
  /**
1183
1298
  * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
1184
1299
  * under the slc/link.md contract. The factory provides every actor kind the
@@ -1186,9 +1301,10 @@ function machineDeclaresParallelState(machine) {
1186
1301
  * (literal and dynamic) — and implements the full runtime lifecycle including
1187
1302
  * the optional parked-session snapshot capability (DR-014).
1188
1303
  *
1189
- * Scope: machines that declare no parallel state (each snapshot exposes
1190
- * exactly one playbook state id). Parallel-region FSMs keep their own linked
1191
- * runtimes.
1304
+ * Scope: flat single-region machines no parallel state, no compound
1305
+ * child states, and every root state's `meta.playbook.stateId` equal to its
1306
+ * state key — so each snapshot exposes exactly one playbook state id.
1307
+ * Parallel-region FSMs keep their own linked runtimes.
1192
1308
  */
1193
1309
  export function createXStatePlaybookRuntime(machine, spec) {
1194
1310
  const label = spec.label ?? 'playbook';
@@ -1205,6 +1321,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
1205
1321
  if (machineDeclaresParallelState(machine)) {
1206
1322
  throw new Error(`${label} uses a parallel state; the shared runtime supports only single-region FSMs`);
1207
1323
  }
1324
+ if (machineDeclaresNestedState(machine)) {
1325
+ throw new Error(`${label} declares a compound state; the shared runtime supports only flat single-region FSMs`);
1326
+ }
1327
+ assertFlatStateIdentity(machine, label);
1208
1328
  const declaredActors = collectInvokeSources(machine);
1209
1329
  const resumableStateIds = spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
1210
1330
  // DR-029: source state descriptions label the control actions the
@@ -1282,6 +1402,20 @@ export function createXStatePlaybookRuntime(machine, spec) {
1282
1402
  // ports.callPlayer / callCaptain / callJudge see the right cancellation
1283
1403
  // source. undefined between turns; set by the public boundaries.
1284
1404
  let activeSignal;
1405
+ // Immutable cancellation provenance for the active public boundary. A
1406
+ // nested resume widens it to include both invocation and resume signals;
1407
+ // mutable `activeSignal` alone cannot classify a late invocation reason.
1408
+ let activeAborts;
1409
+ // The bridge binds the provenance of a child result immediately before
1410
+ // its promise actor settles. The next root snapshot/error consumes this
1411
+ // one-shot so background settlement emissions retain their owner.
1412
+ let actorSettlementAborts;
1413
+ let actorSettlementErrorAborts;
1414
+ // Exact cancellation observed by an emission owned by the active
1415
+ // boundary. Ordinary runs settle from their signal/state; apply also
1416
+ // needs this phase-local evidence to fold a pre-publication failure into
1417
+ // its accepted receipt.
1418
+ let activeAbortEmission;
1285
1419
  let activeTurnId;
1286
1420
  let controlPlaneError;
1287
1421
  // Previous root-machine state for the inspect-driven telemetry /
@@ -1457,16 +1591,31 @@ export function createXStatePlaybookRuntime(machine, spec) {
1457
1591
  for (const [key, token] of byKey)
1458
1592
  privateResumeTokens.set(key, token);
1459
1593
  }
1460
- function enqueueEmission(fn) {
1594
+ function enqueueEmission(fn, aborts = activeAborts) {
1595
+ // The emission belongs to the boundary enqueueing it: a rejection
1596
+ // causally identical to that boundary's abort reason is the
1597
+ // cancellation's own evidence — never latched, so it cannot poison a
1598
+ // later unrelated boundary (DR-036).
1599
+ const enqueueAborts = aborts;
1461
1600
  const queued = emissionQueue.add(fn).then(() => undefined);
1462
1601
  activeEmissionCalls.add(queued);
1463
1602
  void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
1464
1603
  activeEmissionCalls.delete(queued);
1465
- emissionFailure ??= error;
1604
+ if (enqueueAborts?.isAbortReason(error)) {
1605
+ // Record evidence only when it also belongs to the public
1606
+ // boundary that is still active. A background A cancellation
1607
+ // racing an unrelated B boundary is forgiven under A and must
1608
+ // not change B's settlement.
1609
+ if (activeAborts?.isAbortReason(error)) {
1610
+ activeAbortEmission ??= error;
1611
+ }
1612
+ return;
1613
+ }
1614
+ emissionFailure ??= { error };
1466
1615
  });
1467
1616
  return queued;
1468
1617
  }
1469
- async function drainEmissions() {
1618
+ async function drainEmissions(_aborts = activeAborts) {
1470
1619
  while (true) {
1471
1620
  const active = [...activeEmissionCalls];
1472
1621
  if (active.length > 0)
@@ -1479,8 +1628,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
1479
1628
  }
1480
1629
  }
1481
1630
  if (emissionFailure !== undefined) {
1482
- const error = emissionFailure;
1631
+ const { error } = emissionFailure;
1483
1632
  emissionFailure = undefined;
1633
+ // The failure was classified as distinct by its enqueue owner. If a
1634
+ // later public boundary drains it, retain that classification in the
1635
+ // boundary latch before throwing; its signal must not reinterpret
1636
+ // the same object as cancellation (DR-036 decision 2).
1637
+ if (activeSignal !== undefined)
1638
+ controlPlaneError ??= error;
1484
1639
  throw error;
1485
1640
  }
1486
1641
  }
@@ -1519,13 +1674,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
1519
1674
  payload: safePayload,
1520
1675
  };
1521
1676
  }
1522
- function emitTrace(type, payload, position = {}) {
1677
+ function emitTrace(type, payload, position = {}, aborts) {
1523
1678
  const currentSession = requireSession();
1524
1679
  const event = createTraceEvent(type, payload, position);
1525
1680
  return enqueueEmission(() => currentSession.ports.emitTelemetry({
1526
1681
  topic: 'playbook.trace',
1527
1682
  payload: event,
1528
- }));
1683
+ }), aborts);
1529
1684
  }
1530
1685
  function stateIdentity(stateId) {
1531
1686
  return stateId === undefined ? {} : { stateId };
@@ -1583,6 +1738,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
1583
1738
  };
1584
1739
  }
1585
1740
  async function emitCallStarted(startedType, finishedType, identity, position,
1741
+ // The applicable combined signal: a start-sink rejection causally
1742
+ // identical to its reason is the cancellation itself, not a control
1743
+ // error — the pair finishes `aborted` and nothing latches
1744
+ // (slc/link.md §Abort).
1745
+ signal,
1586
1746
  // Base payload of the best-effort finish emitted when the start sink
1587
1747
  // rejects; it defaults to the payload the start carried, which the
1588
1748
  // player, judge, and captain pairs take as-is. The apply pair cannot:
@@ -1594,11 +1754,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
1594
1754
  await emitTrace(startedType, identity, position);
1595
1755
  }
1596
1756
  catch (error) {
1597
- controlPlaneError ??= error;
1757
+ if (!isAbortFailure(error, signal))
1758
+ controlPlaneError ??= error;
1598
1759
  try {
1599
1760
  await emitTrace(finishedType, {
1600
1761
  ...finishIdentity,
1601
- status: 'error',
1762
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1602
1763
  error: normalizeError(error),
1603
1764
  }, position);
1604
1765
  }
@@ -1621,7 +1782,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1621
1782
  resume = selectPlayerResume(roleId, playerId);
1622
1783
  }
1623
1784
  catch (error) {
1624
- if (!signal.aborted)
1785
+ if (!isAbortFailure(error, signal))
1625
1786
  controlPlaneError ??= error;
1626
1787
  throw error;
1627
1788
  }
@@ -1640,13 +1801,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
1640
1801
  const playerKey = continuationKey(roleId, playerId);
1641
1802
  if (activePlayerKeys.has(playerKey)) {
1642
1803
  const error = new Error(`simultaneous calls to player key ${playerKey} are not allowed`);
1643
- await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position);
1804
+ await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position, signal);
1644
1805
  await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
1645
1806
  throw error;
1646
1807
  }
1647
1808
  activePlayerKeys.add(playerKey);
1648
1809
  try {
1649
- await emitTrace('player.call.started', { ...identity, prompt }, position);
1810
+ await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position, signal);
1650
1811
  let rawResult;
1651
1812
  try {
1652
1813
  // An abort may land while the awaited started emission drains
@@ -1660,12 +1821,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
1660
1821
  signal.throwIfAborted();
1661
1822
  }
1662
1823
  catch (error) {
1663
- if (!signal.aborted)
1824
+ if (!isAbortFailure(error, signal))
1664
1825
  controlPlaneError ??= error;
1665
1826
  try {
1666
1827
  await emitTrace('player.call.finished', {
1667
1828
  ...identity,
1668
- status: signal.aborted ? 'aborted' : 'error',
1829
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1669
1830
  error: normalizeError(error),
1670
1831
  }, position);
1671
1832
  }
@@ -1681,7 +1842,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1681
1842
  result = validatePlayerResult(rawResult);
1682
1843
  }
1683
1844
  catch (error) {
1684
- if (!signal.aborted)
1845
+ if (!isAbortFailure(error, signal))
1685
1846
  controlPlaneError ??= error;
1686
1847
  try {
1687
1848
  await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
@@ -1695,7 +1856,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1695
1856
  updatePlayerResume(roleId, playerId, result);
1696
1857
  }
1697
1858
  catch (error) {
1698
- if (!signal.aborted)
1859
+ if (!isAbortFailure(error, signal))
1699
1860
  controlPlaneError ??= error;
1700
1861
  try {
1701
1862
  await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
@@ -1738,7 +1899,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1738
1899
  ...(turnId !== undefined ? { turnId } : {}),
1739
1900
  callId,
1740
1901
  };
1741
- await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position);
1902
+ await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position, signal);
1742
1903
  let reply;
1743
1904
  try {
1744
1905
  // An abort may land while the awaited started emission drains
@@ -1755,7 +1916,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1755
1916
  }
1756
1917
  await emitTrace('judge.call.finished', {
1757
1918
  ...identity,
1758
- status: signal.aborted ? 'aborted' : 'error',
1919
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1759
1920
  error: normalizeError(error),
1760
1921
  }, position);
1761
1922
  throw error;
@@ -1802,7 +1963,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1802
1963
  ...(turnId !== undefined ? { turnId } : {}),
1803
1964
  callId,
1804
1965
  };
1805
- await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position);
1966
+ await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position, signal);
1806
1967
  let rawResult;
1807
1968
  try {
1808
1969
  // An abort may land while the awaited started emission drains
@@ -1824,7 +1985,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1824
1985
  controlPlaneError ??= error;
1825
1986
  await emitTrace('captain.call.finished', {
1826
1987
  ...identity,
1827
- status: signal.aborted ? 'aborted' : 'error',
1988
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1828
1989
  error: normalizeError(error),
1829
1990
  }, position);
1830
1991
  throw error;
@@ -1893,8 +2054,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
1893
2054
  adjudication,
1894
2055
  resumableStateIds,
1895
2056
  }, ports, () => activeSignal, boundary, (error) => {
1896
- if (!activeSignal?.aborted)
2057
+ if (activeSignal === undefined || !isAbortFailure(error, activeSignal)) {
1897
2058
  controlPlaneError ??= error;
2059
+ }
1898
2060
  });
1899
2061
  }
1900
2062
  // Direct-Captain actor (slc/link.md §Captain prompt composition,
@@ -1961,7 +2123,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1961
2123
  // failure state (PBRT-47); everything else here — a drained
1962
2124
  // emission failure, prompt composition, the port itself,
1963
2125
  // adjudication — is control plane.
1964
- if (!active.aborted && !isFsmResultFailure(error)) {
2126
+ if (!isAbortFailure(error, active) && !isFsmResultFailure(error)) {
1965
2127
  controlPlaneError ??= error;
1966
2128
  }
1967
2129
  throw error;
@@ -1982,56 +2144,177 @@ export function createXStatePlaybookRuntime(machine, spec) {
1982
2144
  const failedGuard = guards[1] ?? guards[0];
1983
2145
  const cwd = boundScriptCwd ?? process.cwd();
1984
2146
  const ports = runtimePorts ?? requireHostPorts();
1985
- const exitStatus = await new Promise((resolve, reject) => {
1986
- let child;
1987
- try {
1988
- child = spawn('sh', ['-c', input.command], {
1989
- cwd,
1990
- stdio: 'ignore',
1991
- });
2147
+ // slc/link.md §Script execution: an already-aborted turn spawns
2148
+ // nothing, and the thrown signal reason keeps the rejection
2149
+ // causally classified as the abort it is.
2150
+ active.throwIfAborted();
2151
+ // Abort ownership — the listener that terminates the group and
2152
+ // the escalation timer — spans the whole invocation body, not
2153
+ // just the spawn-to-close window: an abort landing during the
2154
+ // post-exit emission tail must still kill surviving group
2155
+ // members before the actor settles (slc/link.md §Script
2156
+ // execution). One finally releases both.
2157
+ let child;
2158
+ let killTimer;
2159
+ const signalGroup = (sig) => {
2160
+ if (child?.pid !== undefined) {
2161
+ try {
2162
+ process.kill(-child.pid, sig);
2163
+ }
2164
+ catch {
2165
+ // Confirmation belongs to the bounded liveness probe below:
2166
+ // a failed signal can mean ESRCH, EPERM, or another fault.
2167
+ }
1992
2168
  }
1993
- catch (error) {
1994
- reject(error);
2169
+ };
2170
+ // After a SIGKILL is posted, settlement waits for the group to
2171
+ // stop being signalable — bounded by the same grace so an
2172
+ // unreapable member outside the runtime's control cannot stall
2173
+ // the turn forever. Observed teardown is milliseconds.
2174
+ let groupGonePromise;
2175
+ const awaitGroupGone = () => {
2176
+ const pid = child?.pid;
2177
+ if (pid === undefined)
2178
+ return Promise.resolve();
2179
+ groupGonePromise ??= (async () => {
2180
+ const teardownFailure = (message, cause) => {
2181
+ const failure = new ScriptProcessGroupTeardownError(pid, message, cause);
2182
+ // A teardown failure is not an authored script result.
2183
+ // Surface it at the active public boundary even though
2184
+ // XState also routes the rejected actor through onError.
2185
+ controlPlaneError ??= failure;
2186
+ return failure;
2187
+ };
2188
+ const deadline = Date.now() + SCRIPT_ABORT_KILL_GRACE_MS;
2189
+ let lastProbeError;
2190
+ for (;;) {
2191
+ try {
2192
+ process.kill(-pid, 0);
2193
+ }
2194
+ catch (error) {
2195
+ if (isNoSuchProcess(error))
2196
+ return;
2197
+ // EPERM confirms that at least one process in the group
2198
+ // still exists but is not signalable by this process. Keep
2199
+ // waiting for ESRCH within the bound; every other probe
2200
+ // error makes confirmation itself unreliable immediately.
2201
+ if (!isProcessPermissionDenied(error)) {
2202
+ throw teardownFailure('the liveness probe failed', error);
2203
+ }
2204
+ lastProbeError = error;
2205
+ }
2206
+ if (Date.now() >= deadline) {
2207
+ throw teardownFailure(`the group remained signalable after ${SCRIPT_ABORT_KILL_GRACE_MS}ms`, lastProbeError);
2208
+ }
2209
+ await new Promise((tick) => setTimeout(tick, 5));
2210
+ }
2211
+ })();
2212
+ return groupGonePromise;
2213
+ };
2214
+ const onAbort = () => {
2215
+ signalGroup('SIGTERM');
2216
+ killTimer = setTimeout(() => signalGroup('SIGKILL'), SCRIPT_ABORT_KILL_GRACE_MS);
2217
+ };
2218
+ // An abort observed once the shell has already exited rejects
2219
+ // with the signal's reason before guard resolution and before
2220
+ // starting any further script emission — after killing whatever
2221
+ // group members outlived the shell. The shell's own exit ended
2222
+ // the TERM grace's purpose, so escalation is immediate here.
2223
+ const settleIfAborted = async () => {
2224
+ if (!active.aborted)
1995
2225
  return;
2226
+ signalGroup('SIGKILL');
2227
+ await awaitGroupGone();
2228
+ active.throwIfAborted();
2229
+ };
2230
+ let invocationFailed = false;
2231
+ try {
2232
+ const exitStatus = await new Promise((resolve, reject) => {
2233
+ try {
2234
+ // detached: the shell leads its own POSIX process group,
2235
+ // so an abort can terminate the command's whole group — a
2236
+ // lone SIGTERM to the wrapper never reaches backgrounded
2237
+ // members.
2238
+ child = spawn('sh', ['-c', input.command], {
2239
+ cwd,
2240
+ stdio: 'ignore',
2241
+ detached: true,
2242
+ });
2243
+ }
2244
+ catch (error) {
2245
+ reject(error);
2246
+ return;
2247
+ }
2248
+ // On abort, terminate the group and escalate — but settle
2249
+ // only from 'close', after the shell itself has exited, so
2250
+ // the turn never reports quiescence while the script still
2251
+ // runs (slc/link.md §Abort). SIGKILL is untrappable, so
2252
+ // 'close' is bounded by the grace.
2253
+ active.addEventListener('abort', onAbort, { once: true });
2254
+ child.on('error', (error) => {
2255
+ reject(error);
2256
+ });
2257
+ child.on('close', (code) => {
2258
+ if (active.aborted) {
2259
+ // The shell may exit cooperatively on the group SIGTERM
2260
+ // while a TERM-immune same-group descendant survives;
2261
+ // the group stays addressable while any member lives,
2262
+ // so kill it and await its disappearance before
2263
+ // settling (slc/link.md §Script execution).
2264
+ signalGroup('SIGKILL');
2265
+ void awaitGroupGone().then(() => reject(active.reason), reject);
2266
+ return;
2267
+ }
2268
+ resolve(typeof code === 'number' ? code : 1);
2269
+ });
2270
+ });
2271
+ await settleIfAborted();
2272
+ await ports.emitStatus(`Executed script for ${input.stateId} (exit ${exitStatus}).`);
2273
+ await settleIfAborted();
2274
+ await ports.emitTelemetry({
2275
+ topic: 'playbook.script',
2276
+ payload: {
2277
+ stateId: input.stateId,
2278
+ sourceItem: input.sourceItem,
2279
+ exitStatus,
2280
+ },
2281
+ });
2282
+ await settleIfAborted();
2283
+ if (exitStatus === 0) {
2284
+ return { guard: okGuard, exitStatus: 0 };
1996
2285
  }
1997
- const onAbort = () => {
1998
- child.kill('SIGTERM');
1999
- reject(active.reason ?? new Error('script aborted'));
2000
- };
2001
- if (active.aborted) {
2002
- onAbort();
2003
- return;
2286
+ return { guard: failedGuard, exitStatus };
2287
+ }
2288
+ catch (error) {
2289
+ // Preserve the invocation's authoritative exact cancellation or
2290
+ // distinct sink failure after teardown succeeds. The finally
2291
+ // block may replace it only with a distinct teardown failure
2292
+ // when the process group cannot be confirmed gone.
2293
+ invocationFailed = true;
2294
+ throw error;
2295
+ }
2296
+ finally {
2297
+ try {
2298
+ if (active.aborted) {
2299
+ signalGroup('SIGKILL');
2300
+ await awaitGroupGone();
2301
+ if (!invocationFailed)
2302
+ active.throwIfAborted();
2303
+ }
2004
2304
  }
2005
- active.addEventListener('abort', onAbort, { once: true });
2006
- child.on('error', (error) => {
2007
- active.removeEventListener('abort', onAbort);
2008
- reject(error);
2009
- });
2010
- child.on('close', (code) => {
2305
+ finally {
2011
2306
  active.removeEventListener('abort', onAbort);
2012
- resolve(typeof code === 'number' ? code : 1);
2013
- });
2014
- });
2015
- await ports.emitStatus(`Executed script for ${input.stateId} (exit ${exitStatus}).`);
2016
- await ports.emitTelemetry({
2017
- topic: 'playbook.script',
2018
- payload: {
2019
- stateId: input.stateId,
2020
- sourceItem: input.sourceItem,
2021
- exitStatus,
2022
- },
2023
- });
2024
- if (exitStatus === 0) {
2025
- return { guard: okGuard, exitStatus: 0 };
2307
+ if (killTimer !== undefined)
2308
+ clearTimeout(killTimer);
2309
+ }
2026
2310
  }
2027
- return { guard: failedGuard, exitStatus };
2028
2311
  });
2029
2312
  }
2030
2313
  const nestedBridge = createNestedPlaybookBridge({
2031
2314
  nextCallId: () => `playbook-${++playbookCallSequence}`,
2032
2315
  getBoundarySignal: () => activeSignal,
2033
2316
  callPlaybook: (request, signal) => requireHostPorts().callPlaybook(request, signal),
2034
- emitStarted: async (event) => {
2317
+ emitStarted: async (event, aborts) => {
2035
2318
  playbookCallTurnIds.set(event.callId, activeTurnId);
2036
2319
  await emitTrace('playbook.call.started', {
2037
2320
  stateId: event.stateId,
@@ -2040,9 +2323,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
2040
2323
  }, {
2041
2324
  ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
2042
2325
  callId: event.callId,
2043
- });
2326
+ }, aborts);
2044
2327
  },
2045
- emitFinished: async (event) => {
2328
+ emitFinished: async (event, aborts) => {
2046
2329
  const turnId = playbookCallTurnIds.get(event.callId);
2047
2330
  try {
2048
2331
  await emitTrace('playbook.call.finished', {
@@ -2053,22 +2336,34 @@ export function createXStatePlaybookRuntime(machine, spec) {
2053
2336
  }, {
2054
2337
  ...(turnId !== undefined ? { turnId } : {}),
2055
2338
  callId: event.callId,
2056
- });
2339
+ }, aborts);
2057
2340
  }
2058
2341
  finally {
2059
2342
  playbookCallTurnIds.delete(event.callId);
2060
2343
  }
2061
2344
  },
2062
2345
  drain: drainEmissions,
2063
- bindResumeSignal: (signal) => {
2346
+ bindResumeSignal: (signal, aborts) => {
2064
2347
  activeSignal = signal;
2348
+ activeAborts = aborts ?? abortReasonClassifier(signal);
2349
+ },
2350
+ bindActorSettlement: (aborts) => {
2351
+ actorSettlementAborts = aborts;
2065
2352
  },
2066
- onControlPlaneError: (error) => {
2067
- if (!activeSignal?.aborted)
2353
+ onControlPlaneError: (error, aborts) => {
2354
+ // The shared bridge classifies before reporting against its own
2355
+ // invocation-and-resume signals; classify once more here against
2356
+ // the boundary signal so a report that is the active boundary's
2357
+ // exact abort reason can never masquerade as a control error
2358
+ // (slc/link.md §Abort).
2359
+ if (!aborts?.isAbortReason(error) &&
2360
+ !activeAborts?.isAbortReason(error)) {
2068
2361
  controlPlaneError ??= error;
2362
+ }
2069
2363
  },
2070
- onBackgroundError: (error) => {
2071
- emissionFailure ??= error;
2364
+ onBackgroundError: (error, aborts) => {
2365
+ if (!aborts?.isAbortReason(error))
2366
+ emissionFailure ??= { error };
2072
2367
  },
2073
2368
  });
2074
2369
  function tracePositionForActiveTurn() {
@@ -2082,11 +2377,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
2082
2377
  previousState: previousState ?? null,
2083
2378
  state,
2084
2379
  };
2085
- if (state.stateId === 'awaitBossReply') {
2086
- const pendingBossQuestion = pendingBossQuestionFromContext(context);
2087
- if (pendingBossQuestion !== undefined) {
2088
- payload.pendingBossQuestion = pendingBossQuestion;
2089
- }
2380
+ const pendingBossQuestion = pendingBossQuestionForState(state, context);
2381
+ if (pendingBossQuestion !== undefined) {
2382
+ payload.pendingBossQuestion = pendingBossQuestion;
2090
2383
  }
2091
2384
  if (state.stateId === 'failed') {
2092
2385
  const lastError = normalizeErrorFull(context.lastError);
@@ -2095,7 +2388,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2095
2388
  }
2096
2389
  return snapshotJsonValue(payload, 'FSM telemetry payload');
2097
2390
  }
2098
- function enqueueTransitionEmission(payload, state, statuses, position) {
2391
+ function enqueueTransitionEmission(payload, state, statuses, position, aborts) {
2099
2392
  const currentSession = requireSession();
2100
2393
  const transitionTrace = createTraceEvent('fsm.transition', payload, position);
2101
2394
  const statusEmissions = statuses.map(({ message, data }) => ({
@@ -2124,13 +2417,38 @@ export function createXStatePlaybookRuntime(machine, spec) {
2124
2417
  });
2125
2418
  await currentSession.ports.emitStatus(status.message, status.data);
2126
2419
  }
2127
- }).catch(() => undefined);
2420
+ }, aborts).catch(() => undefined);
2128
2421
  }
2129
- function latchInspectionError(error) {
2130
- if (activeSignal !== undefined)
2131
- controlPlaneError ??= error;
2422
+ // One classifying latch for every runtime-observed error — inspection
2423
+ // failures and root-actor errors alike. Outside a boundary the error
2424
+ // rides the emission channel, which the next boundary's (or init's)
2425
+ // drain throws; inside a boundary it is a control-plane error unless it
2426
+ // is the boundary signal's own abort reason (slc/link.md §Abort).
2427
+ function latchRuntimeError(error, aborts = activeAborts) {
2428
+ if (aborts?.isAbortReason(error))
2429
+ return;
2430
+ if (activeSignal === undefined)
2431
+ emissionFailure ??= { error };
2132
2432
  else
2133
- emissionFailure ??= error;
2433
+ controlPlaneError ??= error;
2434
+ }
2435
+ function consumeActorSettlementAborts(forSnapshot = false) {
2436
+ const aborts = actorSettlementAborts ?? actorSettlementErrorAborts;
2437
+ actorSettlementAborts = undefined;
2438
+ actorSettlementErrorAborts = undefined;
2439
+ if (forSnapshot && aborts !== undefined) {
2440
+ // XState can report an errored root through both its inspection
2441
+ // snapshot and subscriber. Keep the same provenance through that
2442
+ // synchronous notification only; an ordinary transition must not
2443
+ // lend it to a later unrelated actor error.
2444
+ actorSettlementErrorAborts = aborts;
2445
+ queueMicrotask(() => {
2446
+ if (actorSettlementErrorAborts === aborts) {
2447
+ actorSettlementErrorAborts = undefined;
2448
+ }
2449
+ });
2450
+ }
2451
+ return aborts;
2134
2452
  }
2135
2453
  // PBRT-6: the single seam that stops this runtime's actor. Stopping a
2136
2454
  // still-running actor fires one more `@xstate.snapshot` for the
@@ -2176,6 +2494,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2176
2494
  return;
2177
2495
  if (suppressInspectionEmissions)
2178
2496
  return;
2497
+ const settlementAborts = consumeActorSettlementAborts(true);
2179
2498
  try {
2180
2499
  const snap = inspectionEvent.snapshot;
2181
2500
  const state = normalizePlaybookSnapshot(snap);
@@ -2187,14 +2506,24 @@ export function createXStatePlaybookRuntime(machine, spec) {
2187
2506
  {});
2188
2507
  const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
2189
2508
  const statuses = statusesForState(state, context, inspectionEvent.event);
2190
- enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn());
2509
+ enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn(), settlementAborts);
2191
2510
  priorState = state;
2192
2511
  }
2193
2512
  catch (error) {
2194
- latchInspectionError(error);
2513
+ latchRuntimeError(error, settlementAborts);
2195
2514
  }
2196
2515
  },
2197
2516
  });
2517
+ // A synchronously-errored actor is already quiescent, so the turn's
2518
+ // quiescence wait never subscribes and XState would report the error
2519
+ // as unhandled after the boundary returns. Observe it through the
2520
+ // classifying latch: mid-boundary it is the control-plane error
2521
+ // unless it is the abort reason itself; at startup it rides the
2522
+ // emission channel so `init`'s own drain rejects with it and the
2523
+ // failed-start cleanup runs (slc/link.md §Session lifecycle).
2524
+ builtActor.subscribe({
2525
+ error: (error) => latchRuntimeError(error, consumeActorSettlementAborts()),
2526
+ });
2198
2527
  return builtActor;
2199
2528
  }
2200
2529
  function runResultFor(outcome, error) {
@@ -2211,14 +2540,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
2211
2540
  }
2212
2541
  if (outcome === 'terminal') {
2213
2542
  const output = actor?.getSnapshot()?.output;
2214
- if (output !== undefined) {
2215
- return {
2216
- outcome,
2217
- state,
2218
- output: snapshotJsonValue(output, 'terminal playbook output'),
2219
- };
2220
- }
2221
- return { outcome, state };
2543
+ const stateDescription = stateDescriptionFor(state);
2544
+ return {
2545
+ outcome,
2546
+ state,
2547
+ ...(stateDescription === undefined ? {} : { stateDescription }),
2548
+ ...(output === undefined
2549
+ ? {}
2550
+ : {
2551
+ output: snapshotJsonValue(output, 'terminal playbook output'),
2552
+ }),
2553
+ };
2222
2554
  }
2223
2555
  const failure = error ??
2224
2556
  (outcome === 'failed'
@@ -2236,15 +2568,23 @@ export function createXStatePlaybookRuntime(machine, spec) {
2236
2568
  function settledOutcome(signal) {
2237
2569
  if (nestedBridge.getPendingCall())
2238
2570
  return 'suspended';
2239
- if (signal.aborted)
2240
- return 'aborted';
2241
2571
  const state = currentState();
2242
2572
  if (state.status === 'error') {
2573
+ // An errored actor outranks a coincident abort unless the actor's
2574
+ // error is the abort reason itself (slc/link.md §Abort).
2243
2575
  const actorError = actor?.getSnapshot()?.error;
2576
+ if (actorError !== undefined && isAbortFailure(actorError, signal)) {
2577
+ return 'aborted';
2578
+ }
2244
2579
  throw actorError ?? new Error(`${label} actor entered error status`);
2245
2580
  }
2581
+ // Terminal completion outranks a coincident abort: the work finished,
2582
+ // and reporting `aborted` would hide a terminal machine behind a
2583
+ // settlement a later turn silently restarts (slc/link.md §Abort).
2246
2584
  if (state.status === 'done')
2247
2585
  return 'terminal';
2586
+ if (signal.aborted)
2587
+ return 'aborted';
2248
2588
  if (state.stateId === 'failed')
2249
2589
  return 'failed';
2250
2590
  return 'quiescent';
@@ -2317,6 +2657,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
2317
2657
  savedPorts = undefined;
2318
2658
  runtimePorts = undefined;
2319
2659
  activeSignal = undefined;
2660
+ activeAborts = undefined;
2661
+ actorSettlementAborts = undefined;
2662
+ actorSettlementErrorAborts = undefined;
2663
+ activeAbortEmission = undefined;
2320
2664
  activeTurnId = undefined;
2321
2665
  controlPlaneError = undefined;
2322
2666
  emissionFailure = undefined;
@@ -2338,28 +2682,56 @@ export function createXStatePlaybookRuntime(machine, spec) {
2338
2682
  can.call(snapshot, event) ===
2339
2683
  true);
2340
2684
  }
2685
+ // DR-034: where the artifact names the FSM context member its entry
2686
+ // action copies the exact Boss text into, that member of the live
2687
+ // snapshot is the retry payload's source. The persisted machine snapshot
2688
+ // carries it, so the candidate derives identically in the process that
2689
+ // exported the snapshot and in one that restored it, and a failure
2690
+ // reached after a Boss reply — whose recorded event the failure state
2691
+ // refuses — is recoverable too. Naming the member is the artifact's
2692
+ // statement that it holds the entry text: a same-named member is never
2693
+ // assumed, since inferring one would turn any matching context member
2694
+ // into a replay payload without its author saying so.
2695
+ // Declared and absent or empty excludes the candidate rather than
2696
+ // falling back to the record, which would make the action depend on the
2697
+ // process again — the very thing this source exists to end.
2698
+ function retryEventFrom(snapshot) {
2699
+ const entryEvent = spec.entryEvent;
2700
+ if (entryEvent?.contextField === undefined)
2701
+ return lastBossEvent;
2702
+ const context = snapshot?.context;
2703
+ const text = isPlainObject(context)
2704
+ ? context[entryEvent.contextField]
2705
+ : undefined;
2706
+ if (typeof text !== 'string' || text.trim() === '')
2707
+ return undefined;
2708
+ return { type: entryEvent.type, [entryEvent.textField]: text };
2709
+ }
2341
2710
  // The failure-state retry entry replays the recorded last classified
2342
- // event with its recorded payload. A candidate whose event the live
2343
- // snapshot does not accept or whose payload the runtime never
2344
- // recordedis excluded rather than completed with invented text.
2711
+ // event with its recorded payload, or the entry event the declared
2712
+ // context member above sources. A candidate whose event the live
2713
+ // snapshot does not accept or whose payload the runtime can source
2714
+ // from neither — is excluded rather than completed with invented text.
2345
2715
  function retryActionFor(snapshot, stateId) {
2346
- if (stateId !== 'failed' || lastBossEvent === undefined) {
2716
+ if (stateId !== 'failed')
2347
2717
  return undefined;
2348
- }
2349
- if (!snapshotCan(snapshot, lastBossEvent))
2718
+ const retryEvent = retryEventFrom(snapshot);
2719
+ if (retryEvent === undefined)
2720
+ return undefined;
2721
+ if (!snapshotCan(snapshot, retryEvent))
2350
2722
  return undefined;
2351
2723
  // A recorded explicit-state-jump event names the exact state its
2352
2724
  // replay re-enters: the root BOSS_INTERRUPT shape is a guarded
2353
2725
  // multi-arm list keyed on `targetId`, so the first configured arm
2354
2726
  // may label a different state than the one the recorded event
2355
2727
  // actually resumes.
2356
- const recordedTargetId = lastBossEvent.type === JUMP_EVENT_TYPE
2357
- ? lastBossEvent.targetId
2728
+ const recordedTargetId = retryEvent.type === JUMP_EVENT_TYPE
2729
+ ? retryEvent.targetId
2358
2730
  : undefined;
2359
2731
  const target = typeof recordedTargetId === 'string' &&
2360
2732
  recordedTargetId.trim().length > 0
2361
2733
  ? recordedTargetId
2362
- : firstTransitionTarget(machine, stateId, lastBossEvent.type);
2734
+ : firstTransitionTarget(machine, stateId, retryEvent.type);
2363
2735
  // PBRT-52: a label is written from a source state description, never
2364
2736
  // from an identifier. Falling back to the target id — or, with no
2365
2737
  // resolvable target, to the FSM event type — makes the label *be* the
@@ -2373,10 +2745,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
2373
2745
  return undefined;
2374
2746
  return {
2375
2747
  action: {
2376
- id: `retry:${lastBossEvent.type}`,
2748
+ id: `retry:${retryEvent.type}`,
2377
2749
  label: `Retry: ${description}`,
2378
2750
  },
2379
- event: lastBossEvent,
2751
+ event: retryEvent,
2380
2752
  };
2381
2753
  }
2382
2754
  function deriveControlActions(snapshot) {
@@ -2561,7 +2933,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2561
2933
  const machineSnapshot = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
2562
2934
  const context = actor.getSnapshot()
2563
2935
  .context;
2564
- const pending = pendingBossQuestionFromContext(context ?? {});
2936
+ const pending = pendingBossQuestionForState(state, context ?? {});
2565
2937
  return {
2566
2938
  schemaVersion: 3,
2567
2939
  playbookId: session.playbookId,
@@ -2642,8 +3014,23 @@ export function createXStatePlaybookRuntime(machine, spec) {
2642
3014
  suppressInspectionEmissions = true;
2643
3015
  actor = buildActor(runtimePorts, boundSnapshot.machine);
2644
3016
  actor.start();
2645
- if (controlPlaneError !== undefined)
2646
- throw controlPlaneError;
3017
+ // A start-time actor error rides the startup emission channel
3018
+ // (latchRuntimeError); consume both latches here so the original
3019
+ // error outranks the derived status check below.
3020
+ {
3021
+ const startupFailure = emissionFailure;
3022
+ if (controlPlaneError !== undefined ||
3023
+ startupFailure !== undefined) {
3024
+ const startupError = controlPlaneError !== undefined
3025
+ ? controlPlaneError
3026
+ : startupFailure.error;
3027
+ controlPlaneError = undefined;
3028
+ if (emissionFailure === startupFailure) {
3029
+ emissionFailure = undefined;
3030
+ }
3031
+ throw startupError;
3032
+ }
3033
+ }
2647
3034
  const restoredState = normalizePlaybookSnapshot(actor.getSnapshot(), suspendedCall === undefined
2648
3035
  ? {}
2649
3036
  : {
@@ -2709,7 +3096,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2709
3096
  const state = currentState();
2710
3097
  const context = (snapshot.context ??
2711
3098
  {});
2712
- const pending = pendingBossQuestionFromContext(context);
3099
+ const pending = pendingBossQuestionForState(state, context);
2713
3100
  const lastError = normalizeErrorFull(context.lastError);
2714
3101
  const projectedContext = projectControlContext(context);
2715
3102
  const stateDescription = stateDescriptionFor(state);
@@ -2777,6 +3164,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
2777
3164
  const position = { turnId, callId };
2778
3165
  activeTurnId = turnId;
2779
3166
  activeSignal = signal;
3167
+ activeAborts = abortReasonClassifier(signal);
3168
+ activeAbortEmission = undefined;
2780
3169
  controlPlaneError = undefined;
2781
3170
  // Every receipt variant is normalized and frozen where it is built,
2782
3171
  // inside the guarded region, so the recording step below cannot
@@ -2822,9 +3211,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
2822
3211
  // final for their key. Past publication such a failure is therefore
2823
3212
  // re-latched onto the emission channel, surfacing from the next
2824
3213
  // public boundary's drain, and `apply` still does not throw past
2825
- // acceptance (PBRT-52).
3214
+ // acceptance (PBRT-52). A delivery rejection causally identical to
3215
+ // this call's own abort reason evidences the cancellation and is
3216
+ // dropped — never carried to a later unrelated boundary
3217
+ // (slc/link.md §Abort).
2826
3218
  const latchDeliveryFailure = (error) => {
2827
- emissionFailure ??= error;
3219
+ if (isAbortFailure(error, signal))
3220
+ return;
3221
+ emissionFailure ??= { error };
2828
3222
  };
2829
3223
  try {
2830
3224
  try {
@@ -2844,7 +3238,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2844
3238
  key,
2845
3239
  ...receiptTracePayload({ disposition: 'rejected', reason }),
2846
3240
  });
2847
- await emitCallStarted('apply.started', 'apply.finished', identity, position, preAcceptanceFinish('apply.started trace sink rejected'));
3241
+ await emitCallStarted('apply.started', 'apply.finished', identity, position, signal, preAcceptanceFinish('apply.started trace sink rejected'));
2848
3242
  // An abort may land while the awaited started emission drains
2849
3243
  // (e.g. fired from the trace sink itself); the action must
2850
3244
  // never execute after abort. Settle the already-started pair
@@ -2923,6 +3317,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
2923
3317
  catch (error) {
2924
3318
  settlementError = error;
2925
3319
  }
3320
+ // Exact cancellation is not a control-plane latch, but after apply
3321
+ // acceptance and before publication it is still settlement evidence
3322
+ // and therefore folds into the owed failed receipt (DR-036 §4).
3323
+ settlementError ??= activeAbortEmission;
2926
3324
  // Fold before the finish emission, the last point at which the
2927
3325
  // traced disposition and the returned one can still be made the
2928
3326
  // same value.
@@ -2964,6 +3362,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
2964
3362
  // wedge every later public boundary behind "another runtime turn
2965
3363
  // is active".
2966
3364
  activeSignal = undefined;
3365
+ activeAborts = undefined;
3366
+ activeAbortEmission = undefined;
2967
3367
  activeTurnId = undefined;
2968
3368
  controlPlaneError = undefined;
2969
3369
  }
@@ -2999,119 +3399,152 @@ export function createXStatePlaybookRuntime(machine, spec) {
2999
3399
  const turnId = ++turnSequence;
3000
3400
  activeTurnId = turnId;
3001
3401
  activeSignal = signal;
3402
+ activeAborts = abortReasonClassifier(signal);
3403
+ activeAbortEmission = undefined;
3002
3404
  controlPlaneError = undefined;
3003
3405
  let result;
3004
3406
  let operationError;
3407
+ // The boundary sentinel releases on every exit: a settlement defect
3408
+ // past the drain — a snapshot normalization throw inside
3409
+ // `runResultFor` included — must never wedge every later public
3410
+ // boundary and `dispose` itself behind "another runtime turn is
3411
+ // active". Mirrors the apply boundary's finally.
3005
3412
  try {
3006
- await emitTrace('boss.input.received', { text }, { turnId });
3007
- // 1. Map the Boss text to an FSM event: deterministic exact entry
3008
- // where applicable (slc/link.md §Boss-event mapping), judge
3009
- // classification otherwise.
3010
- let event;
3011
- const trimmed = text.trim();
3012
- if (trimmed !== '') {
3013
- const snapshot = actor.getSnapshot();
3014
- const terminal = snapshot.status === 'done';
3015
- const stateId = normalizePlaybookSnapshot(snapshot).stateId;
3016
- if (spec.entryEvent !== undefined &&
3017
- (stateId === 'ready' || terminal)) {
3018
- event = {
3019
- type: spec.entryEvent.type,
3020
- [spec.entryEvent.textField]: text,
3021
- };
3022
- }
3023
- else {
3024
- event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
3025
- }
3026
- signal.throwIfAborted();
3027
- }
3028
- // Empty input, no-action classifier output, or invalid classifier
3029
- // output — nothing to send.
3030
- if (event === undefined) {
3031
- result = runResultFor('no-action');
3032
- }
3033
- else {
3034
- // 2. Optional Captain-pane classification line: the bare FSM
3035
- // event type, emitted before the FSM advances.
3036
- const statusLine = classificationStatus(event);
3037
- if (statusLine !== undefined) {
3038
- await runtimePorts.emitStatus(statusLine);
3039
- }
3413
+ try {
3414
+ await emitTrace('boss.input.received', { text }, { turnId });
3415
+ // Record the attempted input, then refuse a boundary that entered
3416
+ // aborted before deterministic mapping or the classifier can
3417
+ // perform host-visible work (DR-036 §5).
3040
3418
  signal.throwIfAborted();
3041
- // 3. A final actor cannot accept new events; reconstruct only
3042
- // after classification produced a real event.
3043
- if (actor.getSnapshot().status === 'done') {
3044
- stopActor();
3045
- actor = buildActor(runtimePorts);
3046
- // The replacement actor's snapshots are real state entries.
3047
- suppressInspectionEmissions = false;
3048
- actor.start();
3419
+ // 1. Map the Boss text to an FSM event: deterministic exact entry
3420
+ // where applicable (slc/link.md §Boss-event mapping), judge
3421
+ // classification otherwise.
3422
+ let event;
3423
+ const trimmed = text.trim();
3424
+ if (trimmed !== '') {
3425
+ const snapshot = actor.getSnapshot();
3426
+ const terminal = snapshot.status === 'done';
3427
+ const stateId = normalizePlaybookSnapshot(snapshot).stateId;
3428
+ // PBRT-1 / slc/link.md §Boss-event mapping: the idle entry, the
3429
+ // recoverable failure state, and the reconstructed terminal all
3430
+ // accept exactly one ordinary textual entry event, so delivered
3431
+ // text enters deterministically — no judge call to spend and no
3432
+ // classifier whim to settle a restart as no action. Every other
3433
+ // parked state — a reply wait or an authored mid-workflow
3434
+ // checkpoint — classifies under its own Boss-event contracts.
3435
+ if (spec.entryEvent !== undefined &&
3436
+ (stateId === 'ready' || stateId === 'failed' || terminal)) {
3437
+ event = {
3438
+ type: spec.entryEvent.type,
3439
+ [spec.entryEvent.textField]: text,
3440
+ };
3441
+ }
3442
+ else {
3443
+ event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
3444
+ }
3445
+ signal.throwIfAborted();
3049
3446
  }
3050
- // DR-029: keep the classified event with its recorded payload
3051
- // as the retry-replay source. Recording is sanitizing, not
3052
- // load-bearing: an override classifier's non-JSON-safe event is
3053
- // simply not recorded, and the turn proceeds unchanged.
3054
- try {
3055
- lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
3447
+ // Empty input, no-action classifier output, or invalid classifier
3448
+ // output nothing to send.
3449
+ if (event === undefined) {
3450
+ result = runResultFor('no-action');
3056
3451
  }
3057
- catch {
3058
- lastBossEvent = undefined;
3452
+ else {
3453
+ // 2. Optional Captain-pane classification line: the bare FSM
3454
+ // event type, emitted before the FSM advances.
3455
+ const statusLine = classificationStatus(event);
3456
+ if (statusLine !== undefined) {
3457
+ await runtimePorts.emitStatus(statusLine);
3458
+ }
3459
+ signal.throwIfAborted();
3460
+ // 3. A final actor cannot accept new events; reconstruct only
3461
+ // after classification produced a real event.
3462
+ if (actor.getSnapshot().status === 'done') {
3463
+ stopActor();
3464
+ actor = buildActor(runtimePorts);
3465
+ // The replacement actor's snapshots are real state entries.
3466
+ suppressInspectionEmissions = false;
3467
+ actor.start();
3468
+ }
3469
+ // DR-029: keep the classified event with its recorded payload
3470
+ // as the retry-replay source. Recording is sanitizing, not
3471
+ // load-bearing: an override classifier's non-JSON-safe event is
3472
+ // simply not recorded, and the turn proceeds unchanged.
3473
+ try {
3474
+ lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
3475
+ }
3476
+ catch {
3477
+ lastBossEvent = undefined;
3478
+ }
3479
+ actor.send(event);
3480
+ await waitForPlaybookQuiescence(actor, {
3481
+ pendingCalls: nestedBridge,
3482
+ });
3483
+ if (controlPlaneError !== undefined)
3484
+ throw controlPlaneError;
3485
+ result = runResultFor(settledOutcome(signal));
3059
3486
  }
3060
- actor.send(event);
3061
- await waitForPlaybookQuiescence(actor, {
3062
- pendingCalls: nestedBridge,
3063
- });
3064
- if (controlPlaneError !== undefined)
3065
- throw controlPlaneError;
3066
- result = runResultFor(settledOutcome(signal));
3067
3487
  }
3488
+ catch (error) {
3489
+ operationError = error;
3490
+ }
3491
+ let drainError;
3492
+ try {
3493
+ await drainEmissions();
3494
+ }
3495
+ catch (error) {
3496
+ drainError = error;
3497
+ }
3498
+ const latchedControlError = controlPlaneError;
3499
+ // A drain rejection that is the exact abort reason evidences the
3500
+ // cancellation, not a control-plane failure (slc/link.md §Abort).
3501
+ const drainAbort = drainError !== undefined && isAbortFailure(drainError, signal);
3502
+ const effectiveDrainError = drainAbort ? undefined : drainError;
3503
+ const primaryError = latchedControlError ?? effectiveDrainError ?? operationError;
3504
+ const abortError = latchedControlError === undefined &&
3505
+ effectiveDrainError === undefined &&
3506
+ ((operationError !== undefined &&
3507
+ isAbortFailure(operationError, signal)) ||
3508
+ (drainAbort && operationError === undefined));
3509
+ const settlementResult = primaryError === undefined
3510
+ ? (result ?? runResultFor('no-action'))
3511
+ : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
3512
+ let settlementEmissionError;
3513
+ try {
3514
+ await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
3515
+ }
3516
+ catch (error) {
3517
+ settlementEmissionError = error;
3518
+ }
3519
+ try {
3520
+ await drainEmissions();
3521
+ }
3522
+ catch (error) {
3523
+ settlementEmissionError ??= error;
3524
+ }
3525
+ if (settlementEmissionError !== undefined &&
3526
+ isAbortFailure(settlementEmissionError, signal)) {
3527
+ settlementEmissionError = undefined;
3528
+ }
3529
+ const failure = controlPlaneError ??
3530
+ latchedControlError ??
3531
+ effectiveDrainError ??
3532
+ (abortError
3533
+ ? (settlementEmissionError ?? operationError)
3534
+ : (operationError ?? settlementEmissionError));
3535
+ if (failure !== undefined &&
3536
+ !(abortError && settlementEmissionError === undefined)) {
3537
+ throw failure;
3538
+ }
3539
+ return settlementResult;
3068
3540
  }
3069
- catch (error) {
3070
- operationError = error;
3071
- }
3072
- let drainError;
3073
- try {
3074
- await drainEmissions();
3075
- }
3076
- catch (error) {
3077
- drainError = error;
3078
- }
3079
- const latchedControlError = controlPlaneError;
3080
- const primaryError = latchedControlError ?? drainError ?? operationError;
3081
- const abortError = latchedControlError === undefined &&
3082
- drainError === undefined &&
3083
- operationError !== undefined &&
3084
- isAbortFailure(operationError, signal);
3085
- const settlementResult = primaryError === undefined
3086
- ? (result ?? runResultFor('no-action'))
3087
- : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
3088
- let settlementEmissionError;
3089
- try {
3090
- await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
3091
- }
3092
- catch (error) {
3093
- settlementEmissionError = error;
3094
- }
3095
- try {
3096
- await drainEmissions();
3097
- }
3098
- catch (error) {
3099
- settlementEmissionError ??= error;
3100
- }
3101
- const failure = controlPlaneError ??
3102
- latchedControlError ??
3103
- drainError ??
3104
- (abortError
3105
- ? (settlementEmissionError ?? operationError)
3106
- : (operationError ?? settlementEmissionError));
3107
- activeSignal = undefined;
3108
- activeTurnId = undefined;
3109
- controlPlaneError = undefined;
3110
- if (failure !== undefined &&
3111
- !(abortError && settlementEmissionError === undefined)) {
3112
- throw failure;
3541
+ finally {
3542
+ activeSignal = undefined;
3543
+ activeAborts = undefined;
3544
+ activeAbortEmission = undefined;
3545
+ activeTurnId = undefined;
3546
+ controlPlaneError = undefined;
3113
3547
  }
3114
- return settlementResult;
3115
3548
  },
3116
3549
  async resumePlaybookCall(input) {
3117
3550
  if (!actor || !savedPorts) {
@@ -3125,41 +3558,85 @@ export function createXStatePlaybookRuntime(machine, spec) {
3125
3558
  }
3126
3559
  activeTurnId = playbookCallTurnIds.get(input.callId);
3127
3560
  activeSignal = input.signal;
3561
+ activeAborts = abortReasonClassifier(input.signal);
3562
+ activeAbortEmission = undefined;
3128
3563
  controlPlaneError = undefined;
3129
- let result;
3130
- let operationError;
3131
- try {
3132
- await nestedBridge.resume(input);
3133
- }
3134
- catch (error) {
3135
- operationError = error;
3136
- }
3137
- try {
3138
- await waitForPlaybookQuiescence(actor, {
3139
- pendingCalls: nestedBridge,
3140
- });
3141
- result = runResultFor(settledOutcome(input.signal));
3142
- }
3143
- catch (error) {
3144
- operationError ??= error;
3145
- }
3146
- let drainError;
3564
+ // The boundary sentinel releases on every exit, mirroring
3565
+ // `handleBossInput` and the apply boundary.
3147
3566
  try {
3148
- await drainEmissions();
3149
- }
3150
- catch (error) {
3151
- drainError = error;
3567
+ let result;
3568
+ let operationError;
3569
+ try {
3570
+ await nestedBridge.resume(input);
3571
+ }
3572
+ catch (error) {
3573
+ operationError = error;
3574
+ }
3575
+ try {
3576
+ await waitForPlaybookQuiescence(actor, {
3577
+ pendingCalls: nestedBridge,
3578
+ });
3579
+ result = runResultFor(settledOutcome(input.signal));
3580
+ }
3581
+ catch (error) {
3582
+ operationError ??= error;
3583
+ }
3584
+ // A resume refused because its signal was already aborted
3585
+ // delivers nothing: the pending call survives for a later
3586
+ // resume, and the boundary settles `aborted` rather than
3587
+ // advertising `suspended` (slc/link.md §Nested playbook bridge).
3588
+ if (operationError !== undefined &&
3589
+ isAbortFailure(operationError, input.signal) &&
3590
+ nestedBridge.getPendingCall()?.callId === input.callId) {
3591
+ result = {
3592
+ outcome: 'aborted',
3593
+ state: currentState(),
3594
+ error: normalizeError(input.signal.reason),
3595
+ };
3596
+ }
3597
+ let drainError;
3598
+ try {
3599
+ await drainEmissions();
3600
+ }
3601
+ catch (error) {
3602
+ drainError = error;
3603
+ }
3604
+ const aborts = activeAborts ?? abortReasonClassifier(input.signal);
3605
+ // A control-plane latch has already classified its failure as
3606
+ // distinct under the owning operation. Never reinterpret it
3607
+ // against this later resume signal (DR-036 decision 2).
3608
+ const controlFailure = controlPlaneError;
3609
+ const drainAbort = controlFailure === undefined &&
3610
+ drainError !== undefined &&
3611
+ aborts.isAbortReason(drainError);
3612
+ const operationAbort = controlFailure === undefined &&
3613
+ operationError !== undefined &&
3614
+ aborts.isAbortReason(operationError);
3615
+ const abortEvidence = activeAbortEmission ??
3616
+ (drainAbort ? drainError : undefined) ??
3617
+ (operationAbort ? operationError : undefined);
3618
+ const failure = controlFailure ??
3619
+ (drainAbort ? undefined : drainError) ??
3620
+ (operationAbort ? undefined : operationError);
3621
+ if (failure !== undefined)
3622
+ throw failure;
3623
+ if (abortEvidence !== undefined &&
3624
+ result?.outcome !== 'terminal' &&
3625
+ result?.outcome !== 'suspended') {
3626
+ result = runResultFor('aborted', abortEvidence);
3627
+ }
3628
+ if (result === undefined) {
3629
+ throw new Error('playbook resume produced no runtime result');
3630
+ }
3631
+ return result;
3152
3632
  }
3153
- const failure = controlPlaneError ?? drainError ?? operationError;
3154
- activeSignal = undefined;
3155
- activeTurnId = undefined;
3156
- controlPlaneError = undefined;
3157
- if (failure !== undefined)
3158
- throw failure;
3159
- if (result === undefined) {
3160
- throw new Error('playbook resume produced no runtime result');
3633
+ finally {
3634
+ activeSignal = undefined;
3635
+ activeAborts = undefined;
3636
+ activeAbortEmission = undefined;
3637
+ activeTurnId = undefined;
3638
+ controlPlaneError = undefined;
3161
3639
  }
3162
- return result;
3163
3640
  },
3164
3641
  dispose() {
3165
3642
  if (disposalPromise !== undefined)
@@ -3228,6 +3705,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
3228
3705
  appliedReceipts.clear();
3229
3706
  actor = undefined;
3230
3707
  activeSignal = undefined;
3708
+ activeAborts = undefined;
3709
+ actorSettlementAborts = undefined;
3710
+ actorSettlementErrorAborts = undefined;
3711
+ activeAbortEmission = undefined;
3231
3712
  activeTurnId = undefined;
3232
3713
  controlPlaneError = undefined;
3233
3714
  emissionFailure = undefined;