@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
@@ -375,7 +375,19 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
375
375
  * ordinary textual entry event, send it without a judge call, carrying the
376
376
  * exact Boss text in `textField`. Absent: every non-empty turn classifies.
377
377
  */
378
- entryEvent?: { type: string; textField: string };
378
+ entryEvent?: {
379
+ type: string;
380
+ textField: string;
381
+ /**
382
+ * DR-034: the FSM context member this machine's entry action copies the
383
+ * exact Boss text into. Where it is named, the failure-state retry
384
+ * builds its payload from that member of the live snapshot instead of
385
+ * from the process-local recorded event, so the action derives the same
386
+ * before and after `restore`. Absent: the recorded event stays the
387
+ * source and the action lives only as long as the process.
388
+ */
389
+ contextField?: string;
390
+ };
379
391
  /**
380
392
  * Exact flat Boss-event contracts whose non-text fields the judge may
381
393
  * select. `entryEvent` and scalar `BOSS_REPLY` contracts are supplied by
@@ -575,11 +587,54 @@ export function normalizeErrorFull(
575
587
  return normalizeError(err);
576
588
  }
577
589
 
590
+ // slc/link.md §Abort: cancellation is causal identity with the applicable
591
+ // signal's reason — never an `AbortError` name, never bare signal state. A
592
+ // distinct failure observed while the signal is aborted stays a non-abort
593
+ // control error and takes precedence (mirrors DECIDE's bespoke reference).
578
594
  function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
579
- return (
580
- signal.aborted &&
581
- (error === signal.reason || normalizeError(error).name === 'AbortError')
595
+ return signal.aborted && Object.is(error, signal.reason);
596
+ }
597
+
598
+ interface AbortReasonClassifier {
599
+ isAbortReason(error: unknown): boolean;
600
+ }
601
+
602
+ function abortReasonClassifier(
603
+ ...sources: readonly (AbortSignal | AbortReasonClassifier | undefined)[]
604
+ ): AbortReasonClassifier {
605
+ const captured = sources.filter(
606
+ (source): source is AbortSignal | AbortReasonClassifier =>
607
+ source !== undefined,
582
608
  );
609
+ return Object.freeze({
610
+ isAbortReason: (error: unknown): boolean =>
611
+ captured.some((source) =>
612
+ source instanceof AbortSignal
613
+ ? isAbortFailure(error, source)
614
+ : source.isAbortReason(error),
615
+ ),
616
+ });
617
+ }
618
+
619
+ /**
620
+ * gears2fsm's canonical Boss-reply wait state. On the runtime's Boss-facing
621
+ * surfaces — state telemetry, status lines, the exported snapshot, and the
622
+ * control view — a context question counts as *pending* only while the
623
+ * machine sits in this state awaiting the reply. Later states retain the
624
+ * answered question in context (the resumed player prompt is composed from
625
+ * it), so an unconditional projection would resurrect it: a failure the
626
+ * resumed player reached would export a question nobody is waiting on,
627
+ * disagreeing with the gated telemetry a mirroring host's ledger follows
628
+ * and failing the shell's snapshot-equality settlement check.
629
+ */
630
+ const BOSS_REPLY_WAIT_STATE_ID = 'awaitBossReply';
631
+
632
+ function pendingBossQuestionForState(
633
+ state: PlaybookState,
634
+ context: Record<string, unknown>,
635
+ ): PlaybookPendingBossQuestionContext | undefined {
636
+ if (state.stateId !== BOSS_REPLY_WAIT_STATE_ID) return undefined;
637
+ return pendingBossQuestionFromContext(context);
583
638
  }
584
639
 
585
640
  /** Read the FSM context's single pending Boss question, when well-formed. */
@@ -911,7 +966,9 @@ export function createPlayerBridge(
911
966
  roleId = spec.resolveRoleId(input);
912
967
  prompt = spec.composePlayerPrompt(input);
913
968
  } catch (error) {
914
- onControlPlaneError?.(error);
969
+ if (!isAbortFailure(error, activeSignal)) {
970
+ onControlPlaneError?.(error);
971
+ }
915
972
  throw error;
916
973
  }
917
974
  const callPlayer = (resume: string | false) =>
@@ -969,7 +1026,9 @@ export function createPlayerBridge(
969
1026
  validateBossReplyOutput(input, output, spec.resumableStateIds);
970
1027
  return output;
971
1028
  } catch (error) {
972
- onControlPlaneError?.(error);
1029
+ if (!isAbortFailure(error, activeSignal)) {
1030
+ onControlPlaneError?.(error);
1031
+ }
973
1032
  throw error;
974
1033
  }
975
1034
  },
@@ -1171,7 +1230,7 @@ export function resumableStateIdsFromMachine(
1171
1230
  if (!isPlainObject(config) || !isPlainObject(config.states)) {
1172
1231
  return new Set();
1173
1232
  }
1174
- const awaitState = config.states.awaitBossReply;
1233
+ const awaitState = config.states[BOSS_REPLY_WAIT_STATE_ID];
1175
1234
  if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
1176
1235
  return new Set();
1177
1236
  }
@@ -1278,6 +1337,43 @@ function deepFreeze<T>(value: T): T {
1278
1337
 
1279
1338
  const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
1280
1339
 
1340
+ // Bounded escalation for aborted script process groups: SIGTERM first, then
1341
+ // SIGKILL after this grace, so settlement (gated on the shell's own exit)
1342
+ // stays bounded even for TERM-immune commands.
1343
+ const SCRIPT_ABORT_KILL_GRACE_MS = 2000;
1344
+
1345
+ class ScriptProcessGroupTeardownError extends Error {
1346
+ constructor(
1347
+ pid: number,
1348
+ message: string,
1349
+ cause?: unknown,
1350
+ ) {
1351
+ super(
1352
+ `script process group ${pid} teardown could not be confirmed: ${message}`,
1353
+ cause === undefined ? undefined : { cause },
1354
+ );
1355
+ this.name = 'ScriptProcessGroupTeardownError';
1356
+ }
1357
+ }
1358
+
1359
+ function isNoSuchProcess(error: unknown): boolean {
1360
+ return (
1361
+ typeof error === 'object' &&
1362
+ error !== null &&
1363
+ 'code' in error &&
1364
+ (error as { code?: unknown }).code === 'ESRCH'
1365
+ );
1366
+ }
1367
+
1368
+ function isProcessPermissionDenied(error: unknown): boolean {
1369
+ return (
1370
+ typeof error === 'object' &&
1371
+ error !== null &&
1372
+ 'code' in error &&
1373
+ (error as { code?: unknown }).code === 'EPERM'
1374
+ );
1375
+ }
1376
+
1281
1377
  function makeDefaultNormalizeTransitionEvent(
1282
1378
  transitionEventFields: readonly string[],
1283
1379
  ): (event: unknown) => JsonValue {
@@ -1395,7 +1491,7 @@ function makeDefaultStatusesForState(
1395
1491
  if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
1396
1492
  return statuses;
1397
1493
  }
1398
- if (stateId === 'awaitBossReply') {
1494
+ if (stateId === BOSS_REPLY_WAIT_STATE_ID) {
1399
1495
  const pending = pendingBossQuestionFromContext(context);
1400
1496
  if (pending === undefined) {
1401
1497
  return [...statuses, { message: 'Awaiting Boss reply.' }];
@@ -1675,7 +1771,15 @@ function makeDefaultClassifyBossText(
1675
1771
  const state = classifierState(snapshotOrState);
1676
1772
  const stateId = typeof state.value === 'string' ? state.value : undefined;
1677
1773
  const currentState = stateId ?? JSON.stringify(state.value ?? null);
1678
- const pending = pendingBossQuestionFromContext(state.context);
1774
+ // The classifier shares the reply-wait pendingness of every other
1775
+ // surface: outside the wait, a context question a later state retains
1776
+ // is answered history, so the prompt must not present it as pending —
1777
+ // a judge told a question awaits at the failure state is steered toward
1778
+ // a reply it cannot select or toward no action at all.
1779
+ const pending =
1780
+ stateId === BOSS_REPLY_WAIT_STATE_ID
1781
+ ? pendingBossQuestionFromContext(state.context)
1782
+ : undefined;
1679
1783
  const configuredTypes = configuredEventTypesForState(machine, stateId);
1680
1784
  const applicable = [...contracts.values()].filter(
1681
1785
  (contract) =>
@@ -1848,6 +1952,74 @@ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
1848
1952
  return visit((machine as unknown as { config?: unknown }).config);
1849
1953
  }
1850
1954
 
1955
+ // PBRT-52: the factory's domain is FLAT single-region machines — every
1956
+ // state a direct child of the root, so each snapshot exposes exactly one
1957
+ // playbook state id and every state-keyed lookup (deterministic entries,
1958
+ // retry, reply-wait pendingness, configured events, descriptions) indexes
1959
+ // one unambiguous identity. A compound child would be accepted and then
1960
+ // silently misbehave on all of those gates, so it is rejected up front
1961
+ // exactly like a parallel region.
1962
+ function machineDeclaresNestedState(machine: AnyStateMachine): boolean {
1963
+ const config = (machine as unknown as { config?: unknown }).config;
1964
+ if (!isPlainObject(config) || !isPlainObject(config.states)) return false;
1965
+ return Object.values(config.states).some(
1966
+ (stateDef) =>
1967
+ isPlainObject(stateDef) &&
1968
+ isPlainObject(stateDef.states) &&
1969
+ Object.keys(stateDef.states).length > 0,
1970
+ );
1971
+ }
1972
+
1973
+ // PBRT-52: the factory's lookups index states by their root key, and the
1974
+ // published playbook identity is `meta.playbook.stateId` — the two must
1975
+ // coincide or a machine can advertise a pending question or retry under an
1976
+ // identity no lookup resolves. A state with no string stateId is just as
1977
+ // dead: every snapshot identity derives from that member, so the first
1978
+ // entry would fail the exactly-one-state-id inspection at runtime.
1979
+ // gears2fsm keeps identity and key equal by construction; a hand-authored
1980
+ // artifact that splits or omits them fails here instead of at a silently
1981
+ // dead gate.
1982
+ function assertFlatStateIdentity(
1983
+ machine: AnyStateMachine,
1984
+ label: string,
1985
+ ): void {
1986
+ const config = (machine as unknown as { config?: unknown }).config;
1987
+ const states =
1988
+ isPlainObject(config) && isPlainObject(config.states)
1989
+ ? config.states
1990
+ : undefined;
1991
+ // A machine with no root states has no playbook identity to expose; its
1992
+ // first snapshot would fail the exactly-one-state-id inspection, so it
1993
+ // fails construction with the defect named instead.
1994
+ if (states === undefined || Object.keys(states).length === 0) {
1995
+ throw new Error(
1996
+ `${label} declares no root states; the shared runtime requires at ` +
1997
+ 'least one flat playbook state',
1998
+ );
1999
+ }
2000
+ for (const [key, stateDef] of Object.entries(states)) {
2001
+ if (!isPlainObject(stateDef)) continue;
2002
+ const meta = isPlainObject(stateDef.meta) ? stateDef.meta : undefined;
2003
+ const playbook =
2004
+ meta !== undefined && isPlainObject(meta.playbook)
2005
+ ? meta.playbook
2006
+ : undefined;
2007
+ const stateId = playbook?.stateId;
2008
+ if (typeof stateId !== 'string') {
2009
+ throw new Error(
2010
+ `${label} state ${key} declares no string meta.playbook.stateId; ` +
2011
+ 'the shared runtime derives every playbook state identity from it',
2012
+ );
2013
+ }
2014
+ if (stateId !== key) {
2015
+ throw new Error(
2016
+ `${label} state ${key} declares meta.playbook.stateId ${stateId}; ` +
2017
+ 'the shared runtime requires the playbook state id to equal the state key',
2018
+ );
2019
+ }
2020
+ }
2021
+ }
2022
+
1851
2023
  /**
1852
2024
  * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
1853
2025
  * under the slc/link.md contract. The factory provides every actor kind the
@@ -1855,9 +2027,10 @@ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
1855
2027
  * (literal and dynamic) — and implements the full runtime lifecycle including
1856
2028
  * the optional parked-session snapshot capability (DR-014).
1857
2029
  *
1858
- * Scope: machines that declare no parallel state (each snapshot exposes
1859
- * exactly one playbook state id). Parallel-region FSMs keep their own linked
1860
- * runtimes.
2030
+ * Scope: flat single-region machines no parallel state, no compound
2031
+ * child states, and every root state's `meta.playbook.stateId` equal to its
2032
+ * state key — so each snapshot exposes exactly one playbook state id.
2033
+ * Parallel-region FSMs keep their own linked runtimes.
1861
2034
  */
1862
2035
  export function createXStatePlaybookRuntime<TOptions>(
1863
2036
  machine: AnyStateMachine,
@@ -1883,6 +2056,12 @@ export function createXStatePlaybookRuntime<TOptions>(
1883
2056
  `${label} uses a parallel state; the shared runtime supports only single-region FSMs`,
1884
2057
  );
1885
2058
  }
2059
+ if (machineDeclaresNestedState(machine)) {
2060
+ throw new Error(
2061
+ `${label} declares a compound state; the shared runtime supports only flat single-region FSMs`,
2062
+ );
2063
+ }
2064
+ assertFlatStateIdentity(machine, label);
1886
2065
  const declaredActors = collectInvokeSources(machine);
1887
2066
  const resumableStateIds =
1888
2067
  spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
@@ -1987,6 +2166,20 @@ export function createXStatePlaybookRuntime<TOptions>(
1987
2166
  // ports.callPlayer / callCaptain / callJudge see the right cancellation
1988
2167
  // source. undefined between turns; set by the public boundaries.
1989
2168
  let activeSignal: AbortSignal | undefined;
2169
+ // Immutable cancellation provenance for the active public boundary. A
2170
+ // nested resume widens it to include both invocation and resume signals;
2171
+ // mutable `activeSignal` alone cannot classify a late invocation reason.
2172
+ let activeAborts: AbortReasonClassifier | undefined;
2173
+ // The bridge binds the provenance of a child result immediately before
2174
+ // its promise actor settles. The next root snapshot/error consumes this
2175
+ // one-shot so background settlement emissions retain their owner.
2176
+ let actorSettlementAborts: AbortReasonClassifier | undefined;
2177
+ let actorSettlementErrorAborts: AbortReasonClassifier | undefined;
2178
+ // Exact cancellation observed by an emission owned by the active
2179
+ // boundary. Ordinary runs settle from their signal/state; apply also
2180
+ // needs this phase-local evidence to fold a pre-publication failure into
2181
+ // its accepted receipt.
2182
+ let activeAbortEmission: unknown;
1990
2183
  let activeTurnId: number | undefined;
1991
2184
  let controlPlaneError: unknown;
1992
2185
  // Previous root-machine state for the inspect-driven telemetry /
@@ -2025,7 +2218,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2025
2218
  // All trace, state-telemetry, and status work shares this one queue.
2026
2219
  // Inspection callbacks enqueue a complete ordered batch synchronously;
2027
2220
  // imperative boundaries await their queued work directly.
2028
- let emissionFailure: unknown;
2221
+ let emissionFailure: { readonly error: unknown } | undefined;
2029
2222
 
2030
2223
  function bindSession(nextSession: PlaybookSession): PlaybookSession {
2031
2224
  const bound = snapshotPlaybookSession(nextSession);
@@ -2214,20 +2407,40 @@ export function createXStatePlaybookRuntime<TOptions>(
2214
2407
  for (const [key, token] of byKey) privateResumeTokens.set(key, token);
2215
2408
  }
2216
2409
 
2217
- function enqueueEmission(fn: () => Promise<void>): Promise<void> {
2410
+ function enqueueEmission(
2411
+ fn: () => Promise<void>,
2412
+ aborts: AbortReasonClassifier | undefined = activeAborts,
2413
+ ): Promise<void> {
2414
+ // The emission belongs to the boundary enqueueing it: a rejection
2415
+ // causally identical to that boundary's abort reason is the
2416
+ // cancellation's own evidence — never latched, so it cannot poison a
2417
+ // later unrelated boundary (DR-036).
2418
+ const enqueueAborts = aborts;
2218
2419
  const queued = emissionQueue.add(fn).then(() => undefined);
2219
2420
  activeEmissionCalls.add(queued);
2220
2421
  void queued.then(
2221
2422
  () => activeEmissionCalls.delete(queued),
2222
2423
  (error: unknown) => {
2223
2424
  activeEmissionCalls.delete(queued);
2224
- emissionFailure ??= error;
2425
+ if (enqueueAborts?.isAbortReason(error)) {
2426
+ // Record evidence only when it also belongs to the public
2427
+ // boundary that is still active. A background A cancellation
2428
+ // racing an unrelated B boundary is forgiven under A and must
2429
+ // not change B's settlement.
2430
+ if (activeAborts?.isAbortReason(error)) {
2431
+ activeAbortEmission ??= error;
2432
+ }
2433
+ return;
2434
+ }
2435
+ emissionFailure ??= { error };
2225
2436
  },
2226
2437
  );
2227
2438
  return queued;
2228
2439
  }
2229
2440
 
2230
- async function drainEmissions(): Promise<void> {
2441
+ async function drainEmissions(
2442
+ _aborts: AbortReasonClassifier | undefined = activeAborts,
2443
+ ): Promise<void> {
2231
2444
  while (true) {
2232
2445
  const active = [...activeEmissionCalls];
2233
2446
  if (active.length > 0) await Promise.allSettled(active);
@@ -2241,8 +2454,13 @@ export function createXStatePlaybookRuntime<TOptions>(
2241
2454
  }
2242
2455
  }
2243
2456
  if (emissionFailure !== undefined) {
2244
- const error = emissionFailure;
2457
+ const { error } = emissionFailure;
2245
2458
  emissionFailure = undefined;
2459
+ // The failure was classified as distinct by its enqueue owner. If a
2460
+ // later public boundary drains it, retain that classification in the
2461
+ // boundary latch before throwing; its signal must not reinterpret
2462
+ // the same object as cancellation (DR-036 decision 2).
2463
+ if (activeSignal !== undefined) controlPlaneError ??= error;
2246
2464
  throw error;
2247
2465
  }
2248
2466
  }
@@ -2293,14 +2511,17 @@ export function createXStatePlaybookRuntime<TOptions>(
2293
2511
  type: PlaybookTraceType,
2294
2512
  payload: unknown,
2295
2513
  position: TracePosition = {},
2514
+ aborts?: AbortReasonClassifier,
2296
2515
  ): Promise<void> {
2297
2516
  const currentSession = requireSession();
2298
2517
  const event = createTraceEvent(type, payload, position);
2299
- return enqueueEmission(() =>
2300
- currentSession.ports.emitTelemetry({
2301
- topic: 'playbook.trace',
2302
- payload: event,
2303
- }),
2518
+ return enqueueEmission(
2519
+ () =>
2520
+ currentSession.ports.emitTelemetry({
2521
+ topic: 'playbook.trace',
2522
+ payload: event,
2523
+ }),
2524
+ aborts,
2304
2525
  );
2305
2526
  }
2306
2527
 
@@ -2388,6 +2609,11 @@ export function createXStatePlaybookRuntime<TOptions>(
2388
2609
  | 'apply.finished',
2389
2610
  identity: Record<string, unknown>,
2390
2611
  position: TracePosition,
2612
+ // The applicable combined signal: a start-sink rejection causally
2613
+ // identical to its reason is the cancellation itself, not a control
2614
+ // error — the pair finishes `aborted` and nothing latches
2615
+ // (slc/link.md §Abort).
2616
+ signal: AbortSignal,
2391
2617
  // Base payload of the best-effort finish emitted when the start sink
2392
2618
  // rejects; it defaults to the payload the start carried, which the
2393
2619
  // player, judge, and captain pairs take as-is. The apply pair cannot:
@@ -2399,13 +2625,13 @@ export function createXStatePlaybookRuntime<TOptions>(
2399
2625
  try {
2400
2626
  await emitTrace(startedType, identity, position);
2401
2627
  } catch (error) {
2402
- controlPlaneError ??= error;
2628
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
2403
2629
  try {
2404
2630
  await emitTrace(
2405
2631
  finishedType,
2406
2632
  {
2407
2633
  ...finishIdentity,
2408
- status: 'error',
2634
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
2409
2635
  error: normalizeError(error),
2410
2636
  },
2411
2637
  position,
@@ -2434,7 +2660,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2434
2660
  signal.throwIfAborted();
2435
2661
  resume = selectPlayerResume(roleId, playerId);
2436
2662
  } catch (error) {
2437
- if (!signal.aborted) controlPlaneError ??= error;
2663
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
2438
2664
  throw error;
2439
2665
  }
2440
2666
  const callId = `player-${++playerCallSequence}`;
@@ -2460,6 +2686,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2460
2686
  'player.call.finished',
2461
2687
  { ...identity, prompt },
2462
2688
  position,
2689
+ signal,
2463
2690
  );
2464
2691
  await emitTrace(
2465
2692
  'player.call.finished',
@@ -2471,10 +2698,12 @@ export function createXStatePlaybookRuntime<TOptions>(
2471
2698
  activePlayerKeys.add(playerKey);
2472
2699
 
2473
2700
  try {
2474
- await emitTrace(
2701
+ await emitCallStarted(
2475
2702
  'player.call.started',
2703
+ 'player.call.finished',
2476
2704
  { ...identity, prompt },
2477
2705
  position,
2706
+ signal,
2478
2707
  );
2479
2708
 
2480
2709
  let rawResult: unknown;
@@ -2494,13 +2723,13 @@ export function createXStatePlaybookRuntime<TOptions>(
2494
2723
  // a late result mutate continuity or publish a successful finish.
2495
2724
  signal.throwIfAborted();
2496
2725
  } catch (error) {
2497
- if (!signal.aborted) controlPlaneError ??= error;
2726
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
2498
2727
  try {
2499
2728
  await emitTrace(
2500
2729
  'player.call.finished',
2501
2730
  {
2502
2731
  ...identity,
2503
- status: signal.aborted ? 'aborted' : 'error',
2732
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
2504
2733
  error: normalizeError(error),
2505
2734
  },
2506
2735
  position,
@@ -2517,7 +2746,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2517
2746
  try {
2518
2747
  result = validatePlayerResult(rawResult);
2519
2748
  } catch (error) {
2520
- if (!signal.aborted) controlPlaneError ??= error;
2749
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
2521
2750
  try {
2522
2751
  await emitTrace(
2523
2752
  'player.call.finished',
@@ -2533,7 +2762,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2533
2762
  try {
2534
2763
  updatePlayerResume(roleId, playerId, result);
2535
2764
  } catch (error) {
2536
- if (!signal.aborted) controlPlaneError ??= error;
2765
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
2537
2766
  try {
2538
2767
  await emitTrace(
2539
2768
  'player.call.finished',
@@ -2589,6 +2818,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2589
2818
  'judge.call.finished',
2590
2819
  { ...identity, prompt },
2591
2820
  position,
2821
+ signal,
2592
2822
  );
2593
2823
  let reply: unknown;
2594
2824
  try {
@@ -2607,7 +2837,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2607
2837
  'judge.call.finished',
2608
2838
  {
2609
2839
  ...identity,
2610
- status: signal.aborted ? 'aborted' : 'error',
2840
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
2611
2841
  error: normalizeError(error),
2612
2842
  },
2613
2843
  position,
@@ -2671,6 +2901,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2671
2901
  'captain.call.finished',
2672
2902
  { ...identity, prompt },
2673
2903
  position,
2904
+ signal,
2674
2905
  );
2675
2906
  let rawResult: unknown;
2676
2907
  try {
@@ -2693,7 +2924,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2693
2924
  'captain.call.finished',
2694
2925
  {
2695
2926
  ...identity,
2696
- status: signal.aborted ? 'aborted' : 'error',
2927
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
2697
2928
  error: normalizeError(error),
2698
2929
  },
2699
2930
  position,
@@ -2784,7 +3015,9 @@ export function createXStatePlaybookRuntime<TOptions>(
2784
3015
  () => activeSignal,
2785
3016
  boundary,
2786
3017
  (error) => {
2787
- if (!activeSignal?.aborted) controlPlaneError ??= error;
3018
+ if (activeSignal === undefined || !isAbortFailure(error, activeSignal)) {
3019
+ controlPlaneError ??= error;
3020
+ }
2788
3021
  },
2789
3022
  );
2790
3023
  }
@@ -2870,7 +3103,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2870
3103
  // failure state (PBRT-47); everything else here — a drained
2871
3104
  // emission failure, prompt composition, the port itself,
2872
3105
  // adjudication — is control plane.
2873
- if (!active.aborted && !isFsmResultFailure(error)) {
3106
+ if (!isAbortFailure(error, active) && !isFsmResultFailure(error)) {
2874
3107
  controlPlaneError ??= error;
2875
3108
  }
2876
3109
  throw error;
@@ -2897,53 +3130,187 @@ export function createXStatePlaybookRuntime<TOptions>(
2897
3130
  const failedGuard = guards[1] ?? guards[0];
2898
3131
  const cwd = boundScriptCwd ?? process.cwd();
2899
3132
  const ports = runtimePorts ?? requireHostPorts();
2900
-
2901
- const exitStatus = await new Promise<number>((resolve, reject) => {
2902
- let child: ReturnType<typeof spawn>;
2903
- try {
2904
- child = spawn('sh', ['-c', input.command], {
2905
- cwd,
2906
- stdio: 'ignore',
2907
- });
2908
- } catch (error) {
2909
- reject(error);
2910
- return;
2911
- }
2912
- const onAbort = (): void => {
2913
- child.kill('SIGTERM');
2914
- reject(active.reason ?? new Error('script aborted'));
2915
- };
2916
- if (active.aborted) {
2917
- onAbort();
2918
- return;
3133
+ // slc/link.md §Script execution: an already-aborted turn spawns
3134
+ // nothing, and the thrown signal reason keeps the rejection
3135
+ // causally classified as the abort it is.
3136
+ active.throwIfAborted();
3137
+
3138
+ // Abort ownership — the listener that terminates the group and
3139
+ // the escalation timer — spans the whole invocation body, not
3140
+ // just the spawn-to-close window: an abort landing during the
3141
+ // post-exit emission tail must still kill surviving group
3142
+ // members before the actor settles (slc/link.md §Script
3143
+ // execution). One finally releases both.
3144
+ let child: ReturnType<typeof spawn> | undefined;
3145
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
3146
+ const signalGroup = (sig: NodeJS.Signals): void => {
3147
+ if (child?.pid !== undefined) {
3148
+ try {
3149
+ process.kill(-child.pid, sig);
3150
+ } catch {
3151
+ // Confirmation belongs to the bounded liveness probe below:
3152
+ // a failed signal can mean ESRCH, EPERM, or another fault.
3153
+ }
2919
3154
  }
2920
- active.addEventListener('abort', onAbort, { once: true });
2921
- child.on('error', (error) => {
2922
- active.removeEventListener('abort', onAbort);
2923
- reject(error);
2924
- });
2925
- child.on('close', (code) => {
2926
- active.removeEventListener('abort', onAbort);
2927
- resolve(typeof code === 'number' ? code : 1);
2928
- });
2929
- });
3155
+ };
3156
+ // After a SIGKILL is posted, settlement waits for the group to
3157
+ // stop being signalable — bounded by the same grace so an
3158
+ // unreapable member outside the runtime's control cannot stall
3159
+ // the turn forever. Observed teardown is milliseconds.
3160
+ let groupGonePromise: Promise<void> | undefined;
3161
+ const awaitGroupGone = (): Promise<void> => {
3162
+ const pid = child?.pid;
3163
+ if (pid === undefined) return Promise.resolve();
3164
+ groupGonePromise ??= (async () => {
3165
+ const teardownFailure = (
3166
+ message: string,
3167
+ cause?: unknown,
3168
+ ): ScriptProcessGroupTeardownError => {
3169
+ const failure = new ScriptProcessGroupTeardownError(
3170
+ pid,
3171
+ message,
3172
+ cause,
3173
+ );
3174
+ // A teardown failure is not an authored script result.
3175
+ // Surface it at the active public boundary even though
3176
+ // XState also routes the rejected actor through onError.
3177
+ controlPlaneError ??= failure;
3178
+ return failure;
3179
+ };
3180
+ const deadline = Date.now() + SCRIPT_ABORT_KILL_GRACE_MS;
3181
+ let lastProbeError: unknown;
3182
+ for (;;) {
3183
+ try {
3184
+ process.kill(-pid, 0);
3185
+ } catch (error) {
3186
+ if (isNoSuchProcess(error)) return;
3187
+ // EPERM confirms that at least one process in the group
3188
+ // still exists but is not signalable by this process. Keep
3189
+ // waiting for ESRCH within the bound; every other probe
3190
+ // error makes confirmation itself unreliable immediately.
3191
+ if (!isProcessPermissionDenied(error)) {
3192
+ throw teardownFailure(
3193
+ 'the liveness probe failed',
3194
+ error,
3195
+ );
3196
+ }
3197
+ lastProbeError = error;
3198
+ }
3199
+ if (Date.now() >= deadline) {
3200
+ throw teardownFailure(
3201
+ `the group remained signalable after ${SCRIPT_ABORT_KILL_GRACE_MS}ms`,
3202
+ lastProbeError,
3203
+ );
3204
+ }
3205
+ await new Promise((tick) => setTimeout(tick, 5));
3206
+ }
3207
+ })();
3208
+ return groupGonePromise;
3209
+ };
3210
+ const onAbort = (): void => {
3211
+ signalGroup('SIGTERM');
3212
+ killTimer = setTimeout(
3213
+ () => signalGroup('SIGKILL'),
3214
+ SCRIPT_ABORT_KILL_GRACE_MS,
3215
+ );
3216
+ };
3217
+ // An abort observed once the shell has already exited rejects
3218
+ // with the signal's reason before guard resolution and before
3219
+ // starting any further script emission — after killing whatever
3220
+ // group members outlived the shell. The shell's own exit ended
3221
+ // the TERM grace's purpose, so escalation is immediate here.
3222
+ const settleIfAborted = async (): Promise<void> => {
3223
+ if (!active.aborted) return;
3224
+ signalGroup('SIGKILL');
3225
+ await awaitGroupGone();
3226
+ active.throwIfAborted();
3227
+ };
3228
+ let invocationFailed = false;
3229
+ try {
3230
+ const exitStatus = await new Promise<number>(
3231
+ (resolve, reject) => {
3232
+ try {
3233
+ // detached: the shell leads its own POSIX process group,
3234
+ // so an abort can terminate the command's whole group — a
3235
+ // lone SIGTERM to the wrapper never reaches backgrounded
3236
+ // members.
3237
+ child = spawn('sh', ['-c', input.command], {
3238
+ cwd,
3239
+ stdio: 'ignore',
3240
+ detached: true,
3241
+ });
3242
+ } catch (error) {
3243
+ reject(error);
3244
+ return;
3245
+ }
3246
+ // On abort, terminate the group and escalate — but settle
3247
+ // only from 'close', after the shell itself has exited, so
3248
+ // the turn never reports quiescence while the script still
3249
+ // runs (slc/link.md §Abort). SIGKILL is untrappable, so
3250
+ // 'close' is bounded by the grace.
3251
+ active.addEventListener('abort', onAbort, { once: true });
3252
+ child.on('error', (error) => {
3253
+ reject(error);
3254
+ });
3255
+ child.on('close', (code) => {
3256
+ if (active.aborted) {
3257
+ // The shell may exit cooperatively on the group SIGTERM
3258
+ // while a TERM-immune same-group descendant survives;
3259
+ // the group stays addressable while any member lives,
3260
+ // so kill it and await its disappearance before
3261
+ // settling (slc/link.md §Script execution).
3262
+ signalGroup('SIGKILL');
3263
+ void awaitGroupGone().then(
3264
+ () =>
3265
+ reject(active.reason),
3266
+ reject,
3267
+ );
3268
+ return;
3269
+ }
3270
+ resolve(typeof code === 'number' ? code : 1);
3271
+ });
3272
+ },
3273
+ );
2930
3274
 
2931
- await ports.emitStatus(
2932
- `Executed script for ${input.stateId} (exit ${exitStatus}).`,
2933
- );
2934
- await ports.emitTelemetry({
2935
- topic: 'playbook.script',
2936
- payload: {
2937
- stateId: input.stateId,
2938
- sourceItem: input.sourceItem,
2939
- exitStatus,
2940
- },
2941
- });
3275
+ await settleIfAborted();
3276
+
3277
+ await ports.emitStatus(
3278
+ `Executed script for ${input.stateId} (exit ${exitStatus}).`,
3279
+ );
3280
+ await settleIfAborted();
3281
+ await ports.emitTelemetry({
3282
+ topic: 'playbook.script',
3283
+ payload: {
3284
+ stateId: input.stateId,
3285
+ sourceItem: input.sourceItem,
3286
+ exitStatus,
3287
+ },
3288
+ });
3289
+ await settleIfAborted();
2942
3290
 
2943
- if (exitStatus === 0) {
2944
- return { guard: okGuard, exitStatus: 0 };
3291
+ if (exitStatus === 0) {
3292
+ return { guard: okGuard, exitStatus: 0 };
3293
+ }
3294
+ return { guard: failedGuard, exitStatus };
3295
+ } catch (error) {
3296
+ // Preserve the invocation's authoritative exact cancellation or
3297
+ // distinct sink failure after teardown succeeds. The finally
3298
+ // block may replace it only with a distinct teardown failure
3299
+ // when the process group cannot be confirmed gone.
3300
+ invocationFailed = true;
3301
+ throw error;
3302
+ } finally {
3303
+ try {
3304
+ if (active.aborted) {
3305
+ signalGroup('SIGKILL');
3306
+ await awaitGroupGone();
3307
+ if (!invocationFailed) active.throwIfAborted();
3308
+ }
3309
+ } finally {
3310
+ active.removeEventListener('abort', onAbort);
3311
+ if (killTimer !== undefined) clearTimeout(killTimer);
3312
+ }
2945
3313
  }
2946
- return { guard: failedGuard, exitStatus };
2947
3314
  },
2948
3315
  );
2949
3316
  }
@@ -2953,7 +3320,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2953
3320
  getBoundarySignal: () => activeSignal,
2954
3321
  callPlaybook: (request, signal) =>
2955
3322
  requireHostPorts().callPlaybook(request, signal),
2956
- emitStarted: async (event) => {
3323
+ emitStarted: async (event, aborts) => {
2957
3324
  playbookCallTurnIds.set(event.callId, activeTurnId);
2958
3325
  await emitTrace(
2959
3326
  'playbook.call.started',
@@ -2966,9 +3333,10 @@ export function createXStatePlaybookRuntime<TOptions>(
2966
3333
  ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
2967
3334
  callId: event.callId,
2968
3335
  },
3336
+ aborts,
2969
3337
  );
2970
3338
  },
2971
- emitFinished: async (event) => {
3339
+ emitFinished: async (event, aborts) => {
2972
3340
  const turnId = playbookCallTurnIds.get(event.callId);
2973
3341
  try {
2974
3342
  await emitTrace(
@@ -2983,20 +3351,35 @@ export function createXStatePlaybookRuntime<TOptions>(
2983
3351
  ...(turnId !== undefined ? { turnId } : {}),
2984
3352
  callId: event.callId,
2985
3353
  },
3354
+ aborts,
2986
3355
  );
2987
3356
  } finally {
2988
3357
  playbookCallTurnIds.delete(event.callId);
2989
3358
  }
2990
3359
  },
2991
3360
  drain: drainEmissions,
2992
- bindResumeSignal: (signal) => {
3361
+ bindResumeSignal: (signal, aborts) => {
2993
3362
  activeSignal = signal;
3363
+ activeAborts = aborts ?? abortReasonClassifier(signal);
2994
3364
  },
2995
- onControlPlaneError: (error) => {
2996
- if (!activeSignal?.aborted) controlPlaneError ??= error;
3365
+ bindActorSettlement: (aborts) => {
3366
+ actorSettlementAborts = aborts;
3367
+ },
3368
+ onControlPlaneError: (error, aborts) => {
3369
+ // The shared bridge classifies before reporting against its own
3370
+ // invocation-and-resume signals; classify once more here against
3371
+ // the boundary signal so a report that is the active boundary's
3372
+ // exact abort reason can never masquerade as a control error
3373
+ // (slc/link.md §Abort).
3374
+ if (
3375
+ !aborts?.isAbortReason(error) &&
3376
+ !activeAborts?.isAbortReason(error)
3377
+ ) {
3378
+ controlPlaneError ??= error;
3379
+ }
2997
3380
  },
2998
- onBackgroundError: (error) => {
2999
- emissionFailure ??= error;
3381
+ onBackgroundError: (error, aborts) => {
3382
+ if (!aborts?.isAbortReason(error)) emissionFailure ??= { error };
3000
3383
  },
3001
3384
  });
3002
3385
 
@@ -3017,11 +3400,9 @@ export function createXStatePlaybookRuntime<TOptions>(
3017
3400
  previousState: previousState ?? null,
3018
3401
  state,
3019
3402
  };
3020
- if (state.stateId === 'awaitBossReply') {
3021
- const pendingBossQuestion = pendingBossQuestionFromContext(context);
3022
- if (pendingBossQuestion !== undefined) {
3023
- payload.pendingBossQuestion = pendingBossQuestion;
3024
- }
3403
+ const pendingBossQuestion = pendingBossQuestionForState(state, context);
3404
+ if (pendingBossQuestion !== undefined) {
3405
+ payload.pendingBossQuestion = pendingBossQuestion;
3025
3406
  }
3026
3407
  if (state.stateId === 'failed') {
3027
3408
  const lastError = normalizeErrorFull(context.lastError);
@@ -3035,6 +3416,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3035
3416
  state: PlaybookState,
3036
3417
  statuses: readonly ScheduledStatus[],
3037
3418
  position: TracePosition,
3419
+ aborts?: AbortReasonClassifier,
3038
3420
  ): void {
3039
3421
  const currentSession = requireSession();
3040
3422
  const transitionTrace = createTraceEvent(
@@ -3056,28 +3438,61 @@ export function createXStatePlaybookRuntime<TOptions>(
3056
3438
  position,
3057
3439
  ),
3058
3440
  }));
3059
- void enqueueEmission(async () => {
3060
- await currentSession.ports.emitTelemetry({
3061
- topic: 'playbook.trace',
3062
- payload: transitionTrace,
3063
- });
3064
- await currentSession.ports.emitTelemetry({
3065
- topic: 'playbook.fsm.state',
3066
- payload,
3067
- });
3068
- for (const status of statusEmissions) {
3441
+ void enqueueEmission(
3442
+ async () => {
3069
3443
  await currentSession.ports.emitTelemetry({
3070
3444
  topic: 'playbook.trace',
3071
- payload: status.trace,
3445
+ payload: transitionTrace,
3072
3446
  });
3073
- await currentSession.ports.emitStatus(status.message, status.data);
3074
- }
3075
- }).catch(() => undefined);
3447
+ await currentSession.ports.emitTelemetry({
3448
+ topic: 'playbook.fsm.state',
3449
+ payload,
3450
+ });
3451
+ for (const status of statusEmissions) {
3452
+ await currentSession.ports.emitTelemetry({
3453
+ topic: 'playbook.trace',
3454
+ payload: status.trace,
3455
+ });
3456
+ await currentSession.ports.emitStatus(status.message, status.data);
3457
+ }
3458
+ },
3459
+ aborts,
3460
+ ).catch(() => undefined);
3461
+ }
3462
+
3463
+ // One classifying latch for every runtime-observed error — inspection
3464
+ // failures and root-actor errors alike. Outside a boundary the error
3465
+ // rides the emission channel, which the next boundary's (or init's)
3466
+ // drain throws; inside a boundary it is a control-plane error unless it
3467
+ // is the boundary signal's own abort reason (slc/link.md §Abort).
3468
+ function latchRuntimeError(
3469
+ error: unknown,
3470
+ aborts: AbortReasonClassifier | undefined = activeAborts,
3471
+ ): void {
3472
+ if (aborts?.isAbortReason(error)) return;
3473
+ if (activeSignal === undefined) emissionFailure ??= { error };
3474
+ else controlPlaneError ??= error;
3076
3475
  }
3077
3476
 
3078
- function latchInspectionError(error: unknown): void {
3079
- if (activeSignal !== undefined) controlPlaneError ??= error;
3080
- else emissionFailure ??= error;
3477
+ function consumeActorSettlementAborts(
3478
+ forSnapshot = false,
3479
+ ): AbortReasonClassifier | undefined {
3480
+ const aborts = actorSettlementAborts ?? actorSettlementErrorAborts;
3481
+ actorSettlementAborts = undefined;
3482
+ actorSettlementErrorAborts = undefined;
3483
+ if (forSnapshot && aborts !== undefined) {
3484
+ // XState can report an errored root through both its inspection
3485
+ // snapshot and subscriber. Keep the same provenance through that
3486
+ // synchronous notification only; an ordinary transition must not
3487
+ // lend it to a later unrelated actor error.
3488
+ actorSettlementErrorAborts = aborts;
3489
+ queueMicrotask(() => {
3490
+ if (actorSettlementErrorAborts === aborts) {
3491
+ actorSettlementErrorAborts = undefined;
3492
+ }
3493
+ });
3494
+ }
3495
+ return aborts;
3081
3496
  }
3082
3497
 
3083
3498
  // PBRT-6: the single seam that stops this runtime's actor. Stopping a
@@ -3121,6 +3536,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3121
3536
  if (inspectionEvent.type !== '@xstate.snapshot') return;
3122
3537
  if (inspectionEvent.actorRef !== builtActor) return;
3123
3538
  if (suppressInspectionEmissions) return;
3539
+ const settlementAborts = consumeActorSettlementAborts(true);
3124
3540
  try {
3125
3541
  const snap = inspectionEvent.snapshot;
3126
3542
  const state = normalizePlaybookSnapshot(snap);
@@ -3148,13 +3564,25 @@ export function createXStatePlaybookRuntime<TOptions>(
3148
3564
  state,
3149
3565
  statuses,
3150
3566
  tracePositionForActiveTurn(),
3567
+ settlementAborts,
3151
3568
  );
3152
3569
  priorState = state;
3153
3570
  } catch (error) {
3154
- latchInspectionError(error);
3571
+ latchRuntimeError(error, settlementAborts);
3155
3572
  }
3156
3573
  },
3157
3574
  });
3575
+ // A synchronously-errored actor is already quiescent, so the turn's
3576
+ // quiescence wait never subscribes and XState would report the error
3577
+ // as unhandled after the boundary returns. Observe it through the
3578
+ // classifying latch: mid-boundary it is the control-plane error
3579
+ // unless it is the abort reason itself; at startup it rides the
3580
+ // emission channel so `init`'s own drain rejects with it and the
3581
+ // failed-start cleanup runs (slc/link.md §Session lifecycle).
3582
+ builtActor.subscribe({
3583
+ error: (error) =>
3584
+ latchRuntimeError(error, consumeActorSettlementAborts()),
3585
+ });
3158
3586
  return builtActor;
3159
3587
  }
3160
3588
 
@@ -3177,14 +3605,20 @@ export function createXStatePlaybookRuntime<TOptions>(
3177
3605
  const output = (
3178
3606
  actor?.getSnapshot() as { output?: unknown } | undefined
3179
3607
  )?.output;
3180
- if (output !== undefined) {
3181
- return {
3182
- outcome,
3183
- state,
3184
- output: snapshotJsonValue(output, 'terminal playbook output'),
3185
- };
3186
- }
3187
- return { outcome, state };
3608
+ const stateDescription = stateDescriptionFor(state);
3609
+ return {
3610
+ outcome,
3611
+ state,
3612
+ ...(stateDescription === undefined ? {} : { stateDescription }),
3613
+ ...(output === undefined
3614
+ ? {}
3615
+ : {
3616
+ output: snapshotJsonValue(
3617
+ output,
3618
+ 'terminal playbook output',
3619
+ ),
3620
+ }),
3621
+ };
3188
3622
  }
3189
3623
  const failure =
3190
3624
  error ??
@@ -3203,15 +3637,23 @@ export function createXStatePlaybookRuntime<TOptions>(
3203
3637
 
3204
3638
  function settledOutcome(signal: AbortSignal): BossSettlementOutcome {
3205
3639
  if (nestedBridge.getPendingCall()) return 'suspended';
3206
- if (signal.aborted) return 'aborted';
3207
3640
  const state = currentState();
3208
3641
  if (state.status === 'error') {
3642
+ // An errored actor outranks a coincident abort unless the actor's
3643
+ // error is the abort reason itself (slc/link.md §Abort).
3209
3644
  const actorError = (
3210
3645
  actor?.getSnapshot() as { error?: unknown } | undefined
3211
3646
  )?.error;
3647
+ if (actorError !== undefined && isAbortFailure(actorError, signal)) {
3648
+ return 'aborted';
3649
+ }
3212
3650
  throw actorError ?? new Error(`${label} actor entered error status`);
3213
3651
  }
3652
+ // Terminal completion outranks a coincident abort: the work finished,
3653
+ // and reporting `aborted` would hide a terminal machine behind a
3654
+ // settlement a later turn silently restarts (slc/link.md §Abort).
3214
3655
  if (state.status === 'done') return 'terminal';
3656
+ if (signal.aborted) return 'aborted';
3215
3657
  if (state.stateId === 'failed') return 'failed';
3216
3658
  return 'quiescent';
3217
3659
  }
@@ -3288,6 +3730,10 @@ export function createXStatePlaybookRuntime<TOptions>(
3288
3730
  savedPorts = undefined;
3289
3731
  runtimePorts = undefined;
3290
3732
  activeSignal = undefined;
3733
+ activeAborts = undefined;
3734
+ actorSettlementAborts = undefined;
3735
+ actorSettlementErrorAborts = undefined;
3736
+ activeAbortEmission = undefined;
3291
3737
  activeTurnId = undefined;
3292
3738
  controlPlaneError = undefined;
3293
3739
  emissionFailure = undefined;
@@ -3323,32 +3769,57 @@ export function createXStatePlaybookRuntime<TOptions>(
3323
3769
  );
3324
3770
  }
3325
3771
 
3772
+ // DR-034: where the artifact names the FSM context member its entry
3773
+ // action copies the exact Boss text into, that member of the live
3774
+ // snapshot is the retry payload's source. The persisted machine snapshot
3775
+ // carries it, so the candidate derives identically in the process that
3776
+ // exported the snapshot and in one that restored it, and a failure
3777
+ // reached after a Boss reply — whose recorded event the failure state
3778
+ // refuses — is recoverable too. Naming the member is the artifact's
3779
+ // statement that it holds the entry text: a same-named member is never
3780
+ // assumed, since inferring one would turn any matching context member
3781
+ // into a replay payload without its author saying so.
3782
+ // Declared and absent or empty excludes the candidate rather than
3783
+ // falling back to the record, which would make the action depend on the
3784
+ // process again — the very thing this source exists to end.
3785
+ function retryEventFrom(snapshot: unknown): EventObject | undefined {
3786
+ const entryEvent = spec.entryEvent;
3787
+ if (entryEvent?.contextField === undefined) return lastBossEvent;
3788
+ const context = (snapshot as { context?: unknown } | null)?.context;
3789
+ const text = isPlainObject(context)
3790
+ ? context[entryEvent.contextField]
3791
+ : undefined;
3792
+ if (typeof text !== 'string' || text.trim() === '') return undefined;
3793
+ return { type: entryEvent.type, [entryEvent.textField]: text };
3794
+ }
3795
+
3326
3796
  // The failure-state retry entry replays the recorded last classified
3327
- // event with its recorded payload. A candidate whose event the live
3328
- // snapshot does not accept or whose payload the runtime never
3329
- // recordedis excluded rather than completed with invented text.
3797
+ // event with its recorded payload, or the entry event the declared
3798
+ // context member above sources. A candidate whose event the live
3799
+ // snapshot does not accept or whose payload the runtime can source
3800
+ // from neither — is excluded rather than completed with invented text.
3330
3801
  function retryActionFor(
3331
3802
  snapshot: unknown,
3332
3803
  stateId: string | undefined,
3333
3804
  ): DerivedControlAction | undefined {
3334
- if (stateId !== 'failed' || lastBossEvent === undefined) {
3335
- return undefined;
3336
- }
3337
- if (!snapshotCan(snapshot, lastBossEvent)) return undefined;
3805
+ if (stateId !== 'failed') return undefined;
3806
+ const retryEvent = retryEventFrom(snapshot);
3807
+ if (retryEvent === undefined) return undefined;
3808
+ if (!snapshotCan(snapshot, retryEvent)) return undefined;
3338
3809
  // A recorded explicit-state-jump event names the exact state its
3339
3810
  // replay re-enters: the root BOSS_INTERRUPT shape is a guarded
3340
3811
  // multi-arm list keyed on `targetId`, so the first configured arm
3341
3812
  // may label a different state than the one the recorded event
3342
3813
  // actually resumes.
3343
3814
  const recordedTargetId =
3344
- lastBossEvent.type === JUMP_EVENT_TYPE
3345
- ? (lastBossEvent as { targetId?: unknown }).targetId
3815
+ retryEvent.type === JUMP_EVENT_TYPE
3816
+ ? (retryEvent as { targetId?: unknown }).targetId
3346
3817
  : undefined;
3347
3818
  const target =
3348
3819
  typeof recordedTargetId === 'string' &&
3349
3820
  recordedTargetId.trim().length > 0
3350
3821
  ? recordedTargetId
3351
- : firstTransitionTarget(machine, stateId, lastBossEvent.type);
3822
+ : firstTransitionTarget(machine, stateId, retryEvent.type);
3352
3823
  // PBRT-52: a label is written from a source state description, never
3353
3824
  // from an identifier. Falling back to the target id — or, with no
3354
3825
  // resolvable target, to the FSM event type — makes the label *be* the
@@ -3362,10 +3833,10 @@ export function createXStatePlaybookRuntime<TOptions>(
3362
3833
  if (description === undefined) return undefined;
3363
3834
  return {
3364
3835
  action: {
3365
- id: `retry:${lastBossEvent.type}`,
3836
+ id: `retry:${retryEvent.type}`,
3366
3837
  label: `Retry: ${description}`,
3367
3838
  },
3368
- event: lastBossEvent,
3839
+ event: retryEvent,
3369
3840
  };
3370
3841
  }
3371
3842
 
@@ -3559,7 +4030,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3559
4030
  );
3560
4031
  const context = (actor.getSnapshot() as { context?: unknown })
3561
4032
  .context as Record<string, unknown>;
3562
- const pending = pendingBossQuestionFromContext(context ?? {});
4033
+ const pending = pendingBossQuestionForState(state, context ?? {});
3563
4034
  return {
3564
4035
  schemaVersion: 3,
3565
4036
  playbookId: session.playbookId,
@@ -3658,7 +4129,26 @@ export function createXStatePlaybookRuntime<TOptions>(
3658
4129
  suppressInspectionEmissions = true;
3659
4130
  actor = buildActor(runtimePorts, boundSnapshot.machine);
3660
4131
  actor.start();
3661
- if (controlPlaneError !== undefined) throw controlPlaneError;
4132
+ // A start-time actor error rides the startup emission channel
4133
+ // (latchRuntimeError); consume both latches here so the original
4134
+ // error outranks the derived status check below.
4135
+ {
4136
+ const startupFailure = emissionFailure;
4137
+ if (
4138
+ controlPlaneError !== undefined ||
4139
+ startupFailure !== undefined
4140
+ ) {
4141
+ const startupError =
4142
+ controlPlaneError !== undefined
4143
+ ? controlPlaneError
4144
+ : startupFailure!.error;
4145
+ controlPlaneError = undefined;
4146
+ if (emissionFailure === startupFailure) {
4147
+ emissionFailure = undefined;
4148
+ }
4149
+ throw startupError;
4150
+ }
4151
+ }
3662
4152
  const restoredState = normalizePlaybookSnapshot(
3663
4153
  actor.getSnapshot(),
3664
4154
  suspendedCall === undefined
@@ -3741,7 +4231,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3741
4231
  const state = currentState();
3742
4232
  const context = ((snapshot as { context?: unknown }).context ??
3743
4233
  {}) as Record<string, unknown>;
3744
- const pending = pendingBossQuestionFromContext(context);
4234
+ const pending = pendingBossQuestionForState(state, context);
3745
4235
  const lastError = normalizeErrorFull(context.lastError);
3746
4236
  const projectedContext = projectControlContext(context);
3747
4237
  const stateDescription = stateDescriptionFor(state);
@@ -3829,6 +4319,8 @@ export function createXStatePlaybookRuntime<TOptions>(
3829
4319
  const position: TracePosition = { turnId, callId };
3830
4320
  activeTurnId = turnId;
3831
4321
  activeSignal = signal;
4322
+ activeAborts = abortReasonClassifier(signal);
4323
+ activeAbortEmission = undefined;
3832
4324
  controlPlaneError = undefined;
3833
4325
  // Every receipt variant is normalized and frozen where it is built,
3834
4326
  // inside the guarded region, so the recording step below cannot
@@ -3880,9 +4372,13 @@ export function createXStatePlaybookRuntime<TOptions>(
3880
4372
  // final for their key. Past publication such a failure is therefore
3881
4373
  // re-latched onto the emission channel, surfacing from the next
3882
4374
  // public boundary's drain, and `apply` still does not throw past
3883
- // acceptance (PBRT-52).
4375
+ // acceptance (PBRT-52). A delivery rejection causally identical to
4376
+ // this call's own abort reason evidences the cancellation and is
4377
+ // dropped — never carried to a later unrelated boundary
4378
+ // (slc/link.md §Abort).
3884
4379
  const latchDeliveryFailure = (error: unknown): void => {
3885
- emissionFailure ??= error;
4380
+ if (isAbortFailure(error, signal)) return;
4381
+ emissionFailure ??= { error };
3886
4382
  };
3887
4383
  try {
3888
4384
  try {
@@ -3909,6 +4405,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3909
4405
  'apply.finished',
3910
4406
  identity,
3911
4407
  position,
4408
+ signal,
3912
4409
  preAcceptanceFinish('apply.started trace sink rejected'),
3913
4410
  );
3914
4411
  // An abort may land while the awaited started emission drains
@@ -3999,6 +4496,10 @@ export function createXStatePlaybookRuntime<TOptions>(
3999
4496
  } catch (error) {
4000
4497
  settlementError = error;
4001
4498
  }
4499
+ // Exact cancellation is not a control-plane latch, but after apply
4500
+ // acceptance and before publication it is still settlement evidence
4501
+ // and therefore folds into the owed failed receipt (DR-036 §4).
4502
+ settlementError ??= activeAbortEmission;
4002
4503
  // Fold before the finish emission, the last point at which the
4003
4504
  // traced disposition and the returned one can still be made the
4004
4505
  // same value.
@@ -4037,6 +4538,8 @@ export function createXStatePlaybookRuntime<TOptions>(
4037
4538
  // wedge every later public boundary behind "another runtime turn
4038
4539
  // is active".
4039
4540
  activeSignal = undefined;
4541
+ activeAborts = undefined;
4542
+ activeAbortEmission = undefined;
4040
4543
  activeTurnId = undefined;
4041
4544
  controlPlaneError = undefined;
4042
4545
  }
@@ -4085,136 +4588,171 @@ export function createXStatePlaybookRuntime<TOptions>(
4085
4588
  const turnId = ++turnSequence;
4086
4589
  activeTurnId = turnId;
4087
4590
  activeSignal = signal;
4591
+ activeAborts = abortReasonClassifier(signal);
4592
+ activeAbortEmission = undefined;
4088
4593
  controlPlaneError = undefined;
4089
4594
  let result: PlaybookRunResult | undefined;
4090
4595
  let operationError: unknown;
4596
+ // The boundary sentinel releases on every exit: a settlement defect
4597
+ // past the drain — a snapshot normalization throw inside
4598
+ // `runResultFor` included — must never wedge every later public
4599
+ // boundary and `dispose` itself behind "another runtime turn is
4600
+ // active". Mirrors the apply boundary's finally.
4091
4601
  try {
4092
- await emitTrace('boss.input.received', { text }, { turnId });
4093
- // 1. Map the Boss text to an FSM event: deterministic exact entry
4094
- // where applicable (slc/link.md §Boss-event mapping), judge
4095
- // classification otherwise.
4096
- let event: EventObject | undefined;
4097
- const trimmed = text.trim();
4098
- if (trimmed !== '') {
4099
- const snapshot = actor.getSnapshot();
4100
- const terminal = snapshot.status === 'done';
4101
- const stateId = normalizePlaybookSnapshot(snapshot).stateId;
4102
- if (
4103
- spec.entryEvent !== undefined &&
4104
- (stateId === 'ready' || terminal)
4105
- ) {
4106
- event = {
4107
- type: spec.entryEvent.type,
4108
- [spec.entryEvent.textField]: text,
4109
- };
4110
- } else {
4111
- event = await classifyBossText(
4112
- text,
4113
- runtimePorts!,
4114
- signal,
4115
- snapshot,
4116
- boundary,
4117
- boundOptions,
4118
- );
4119
- }
4120
- signal.throwIfAborted();
4121
- }
4122
- // Empty input, no-action classifier output, or invalid classifier
4123
- // output — nothing to send.
4124
- if (event === undefined) {
4125
- result = runResultFor('no-action');
4126
- } else {
4127
- // 2. Optional Captain-pane classification line: the bare FSM
4128
- // event type, emitted before the FSM advances.
4129
- const statusLine = classificationStatus(event);
4130
- if (statusLine !== undefined) {
4131
- await runtimePorts!.emitStatus(statusLine);
4132
- }
4602
+ try {
4603
+ await emitTrace('boss.input.received', { text }, { turnId });
4604
+ // Record the attempted input, then refuse a boundary that entered
4605
+ // aborted before deterministic mapping or the classifier can
4606
+ // perform host-visible work (DR-036 §5).
4133
4607
  signal.throwIfAborted();
4134
- // 3. A final actor cannot accept new events; reconstruct only
4135
- // after classification produced a real event.
4136
- if (actor.getSnapshot().status === 'done') {
4137
- stopActor();
4138
- actor = buildActor(runtimePorts!);
4139
- // The replacement actor's snapshots are real state entries.
4140
- suppressInspectionEmissions = false;
4141
- actor.start();
4608
+ // 1. Map the Boss text to an FSM event: deterministic exact entry
4609
+ // where applicable (slc/link.md §Boss-event mapping), judge
4610
+ // classification otherwise.
4611
+ let event: EventObject | undefined;
4612
+ const trimmed = text.trim();
4613
+ if (trimmed !== '') {
4614
+ const snapshot = actor.getSnapshot();
4615
+ const terminal = snapshot.status === 'done';
4616
+ const stateId = normalizePlaybookSnapshot(snapshot).stateId;
4617
+ // PBRT-1 / slc/link.md §Boss-event mapping: the idle entry, the
4618
+ // recoverable failure state, and the reconstructed terminal all
4619
+ // accept exactly one ordinary textual entry event, so delivered
4620
+ // text enters deterministically — no judge call to spend and no
4621
+ // classifier whim to settle a restart as no action. Every other
4622
+ // parked state — a reply wait or an authored mid-workflow
4623
+ // checkpoint — classifies under its own Boss-event contracts.
4624
+ if (
4625
+ spec.entryEvent !== undefined &&
4626
+ (stateId === 'ready' || stateId === 'failed' || terminal)
4627
+ ) {
4628
+ event = {
4629
+ type: spec.entryEvent.type,
4630
+ [spec.entryEvent.textField]: text,
4631
+ };
4632
+ } else {
4633
+ event = await classifyBossText(
4634
+ text,
4635
+ runtimePorts!,
4636
+ signal,
4637
+ snapshot,
4638
+ boundary,
4639
+ boundOptions,
4640
+ );
4641
+ }
4642
+ signal.throwIfAborted();
4142
4643
  }
4143
- // DR-029: keep the classified event with its recorded payload
4144
- // as the retry-replay source. Recording is sanitizing, not
4145
- // load-bearing: an override classifier's non-JSON-safe event is
4146
- // simply not recorded, and the turn proceeds unchanged.
4147
- try {
4148
- lastBossEvent = snapshotJsonValue(
4149
- event,
4150
- 'recorded Boss event',
4151
- ) as unknown as EventObject;
4152
- } catch {
4153
- lastBossEvent = undefined;
4644
+ // Empty input, no-action classifier output, or invalid classifier
4645
+ // output nothing to send.
4646
+ if (event === undefined) {
4647
+ result = runResultFor('no-action');
4648
+ } else {
4649
+ // 2. Optional Captain-pane classification line: the bare FSM
4650
+ // event type, emitted before the FSM advances.
4651
+ const statusLine = classificationStatus(event);
4652
+ if (statusLine !== undefined) {
4653
+ await runtimePorts!.emitStatus(statusLine);
4654
+ }
4655
+ signal.throwIfAborted();
4656
+ // 3. A final actor cannot accept new events; reconstruct only
4657
+ // after classification produced a real event.
4658
+ if (actor.getSnapshot().status === 'done') {
4659
+ stopActor();
4660
+ actor = buildActor(runtimePorts!);
4661
+ // The replacement actor's snapshots are real state entries.
4662
+ suppressInspectionEmissions = false;
4663
+ actor.start();
4664
+ }
4665
+ // DR-029: keep the classified event with its recorded payload
4666
+ // as the retry-replay source. Recording is sanitizing, not
4667
+ // load-bearing: an override classifier's non-JSON-safe event is
4668
+ // simply not recorded, and the turn proceeds unchanged.
4669
+ try {
4670
+ lastBossEvent = snapshotJsonValue(
4671
+ event,
4672
+ 'recorded Boss event',
4673
+ ) as unknown as EventObject;
4674
+ } catch {
4675
+ lastBossEvent = undefined;
4676
+ }
4677
+ actor.send(event);
4678
+ await waitForPlaybookQuiescence(actor, {
4679
+ pendingCalls: nestedBridge,
4680
+ });
4681
+ if (controlPlaneError !== undefined) throw controlPlaneError;
4682
+ result = runResultFor(settledOutcome(signal));
4154
4683
  }
4155
- actor.send(event);
4156
- await waitForPlaybookQuiescence(actor, {
4157
- pendingCalls: nestedBridge,
4158
- });
4159
- if (controlPlaneError !== undefined) throw controlPlaneError;
4160
- result = runResultFor(settledOutcome(signal));
4684
+ } catch (error) {
4685
+ operationError = error;
4161
4686
  }
4162
- } catch (error) {
4163
- operationError = error;
4164
- }
4165
4687
 
4166
- let drainError: unknown;
4167
- try {
4168
- await drainEmissions();
4169
- } catch (error) {
4170
- drainError = error;
4171
- }
4172
- const latchedControlError = controlPlaneError;
4173
- const primaryError =
4174
- latchedControlError ?? drainError ?? operationError;
4175
- const abortError =
4176
- latchedControlError === undefined &&
4177
- drainError === undefined &&
4178
- operationError !== undefined &&
4179
- isAbortFailure(operationError, signal);
4180
- const settlementResult =
4181
- primaryError === undefined
4182
- ? (result ?? runResultFor('no-action'))
4183
- : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
4184
-
4185
- let settlementEmissionError: unknown;
4186
- try {
4187
- await emitTrace(
4188
- 'boss.input.settled',
4189
- settlementTracePayload(settlementResult),
4190
- { turnId },
4191
- );
4192
- } catch (error) {
4193
- settlementEmissionError = error;
4194
- }
4195
- try {
4196
- await drainEmissions();
4197
- } catch (error) {
4198
- settlementEmissionError ??= error;
4199
- }
4200
- const failure =
4201
- controlPlaneError ??
4202
- latchedControlError ??
4203
- drainError ??
4204
- (abortError
4205
- ? (settlementEmissionError ?? operationError)
4206
- : (operationError ?? settlementEmissionError));
4207
- activeSignal = undefined;
4208
- activeTurnId = undefined;
4209
- controlPlaneError = undefined;
4688
+ let drainError: unknown;
4689
+ try {
4690
+ await drainEmissions();
4691
+ } catch (error) {
4692
+ drainError = error;
4693
+ }
4694
+ const latchedControlError = controlPlaneError;
4695
+ // A drain rejection that is the exact abort reason evidences the
4696
+ // cancellation, not a control-plane failure (slc/link.md §Abort).
4697
+ const drainAbort =
4698
+ drainError !== undefined && isAbortFailure(drainError, signal);
4699
+ const effectiveDrainError = drainAbort ? undefined : drainError;
4700
+ const primaryError =
4701
+ latchedControlError ?? effectiveDrainError ?? operationError;
4702
+ const abortError =
4703
+ latchedControlError === undefined &&
4704
+ effectiveDrainError === undefined &&
4705
+ ((operationError !== undefined &&
4706
+ isAbortFailure(operationError, signal)) ||
4707
+ (drainAbort && operationError === undefined));
4708
+ const settlementResult =
4709
+ primaryError === undefined
4710
+ ? (result ?? runResultFor('no-action'))
4711
+ : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
4712
+
4713
+ let settlementEmissionError: unknown;
4714
+ try {
4715
+ await emitTrace(
4716
+ 'boss.input.settled',
4717
+ settlementTracePayload(settlementResult),
4718
+ { turnId },
4719
+ );
4720
+ } catch (error) {
4721
+ settlementEmissionError = error;
4722
+ }
4723
+ try {
4724
+ await drainEmissions();
4725
+ } catch (error) {
4726
+ settlementEmissionError ??= error;
4727
+ }
4728
+ if (
4729
+ settlementEmissionError !== undefined &&
4730
+ isAbortFailure(settlementEmissionError, signal)
4731
+ ) {
4732
+ settlementEmissionError = undefined;
4733
+ }
4734
+ const failure =
4735
+ controlPlaneError ??
4736
+ latchedControlError ??
4737
+ effectiveDrainError ??
4738
+ (abortError
4739
+ ? (settlementEmissionError ?? operationError)
4740
+ : (operationError ?? settlementEmissionError));
4210
4741
 
4211
- if (
4212
- failure !== undefined &&
4213
- !(abortError && settlementEmissionError === undefined)
4214
- ) {
4215
- throw failure;
4742
+ if (
4743
+ failure !== undefined &&
4744
+ !(abortError && settlementEmissionError === undefined)
4745
+ ) {
4746
+ throw failure;
4747
+ }
4748
+ return settlementResult;
4749
+ } finally {
4750
+ activeSignal = undefined;
4751
+ activeAborts = undefined;
4752
+ activeAbortEmission = undefined;
4753
+ activeTurnId = undefined;
4754
+ controlPlaneError = undefined;
4216
4755
  }
4217
- return settlementResult;
4218
4756
  },
4219
4757
 
4220
4758
  async resumePlaybookCall(input: {
@@ -4239,37 +4777,88 @@ export function createXStatePlaybookRuntime<TOptions>(
4239
4777
  }
4240
4778
  activeTurnId = playbookCallTurnIds.get(input.callId);
4241
4779
  activeSignal = input.signal;
4780
+ activeAborts = abortReasonClassifier(input.signal);
4781
+ activeAbortEmission = undefined;
4242
4782
  controlPlaneError = undefined;
4243
- let result: PlaybookRunResult | undefined;
4244
- let operationError: unknown;
4783
+ // The boundary sentinel releases on every exit, mirroring
4784
+ // `handleBossInput` and the apply boundary.
4245
4785
  try {
4246
- await nestedBridge.resume(input);
4247
- } catch (error) {
4248
- operationError = error;
4249
- }
4250
- try {
4251
- await waitForPlaybookQuiescence(actor, {
4252
- pendingCalls: nestedBridge,
4253
- });
4254
- result = runResultFor(settledOutcome(input.signal));
4255
- } catch (error) {
4256
- operationError ??= error;
4257
- }
4258
- let drainError: unknown;
4259
- try {
4260
- await drainEmissions();
4261
- } catch (error) {
4262
- drainError = error;
4263
- }
4264
- const failure = controlPlaneError ?? drainError ?? operationError;
4265
- activeSignal = undefined;
4266
- activeTurnId = undefined;
4267
- controlPlaneError = undefined;
4268
- if (failure !== undefined) throw failure;
4269
- if (result === undefined) {
4270
- throw new Error('playbook resume produced no runtime result');
4786
+ let result: PlaybookRunResult | undefined;
4787
+ let operationError: unknown;
4788
+ try {
4789
+ await nestedBridge.resume(input);
4790
+ } catch (error) {
4791
+ operationError = error;
4792
+ }
4793
+ try {
4794
+ await waitForPlaybookQuiescence(actor, {
4795
+ pendingCalls: nestedBridge,
4796
+ });
4797
+ result = runResultFor(settledOutcome(input.signal));
4798
+ } catch (error) {
4799
+ operationError ??= error;
4800
+ }
4801
+ // A resume refused because its signal was already aborted
4802
+ // delivers nothing: the pending call survives for a later
4803
+ // resume, and the boundary settles `aborted` rather than
4804
+ // advertising `suspended` (slc/link.md §Nested playbook bridge).
4805
+ if (
4806
+ operationError !== undefined &&
4807
+ isAbortFailure(operationError, input.signal) &&
4808
+ nestedBridge.getPendingCall()?.callId === input.callId
4809
+ ) {
4810
+ result = {
4811
+ outcome: 'aborted',
4812
+ state: currentState(),
4813
+ error: normalizeError(input.signal.reason),
4814
+ };
4815
+ }
4816
+ let drainError: unknown;
4817
+ try {
4818
+ await drainEmissions();
4819
+ } catch (error) {
4820
+ drainError = error;
4821
+ }
4822
+ const aborts = activeAborts ?? abortReasonClassifier(input.signal);
4823
+ // A control-plane latch has already classified its failure as
4824
+ // distinct under the owning operation. Never reinterpret it
4825
+ // against this later resume signal (DR-036 decision 2).
4826
+ const controlFailure = controlPlaneError;
4827
+ const drainAbort =
4828
+ controlFailure === undefined &&
4829
+ drainError !== undefined &&
4830
+ aborts.isAbortReason(drainError);
4831
+ const operationAbort =
4832
+ controlFailure === undefined &&
4833
+ operationError !== undefined &&
4834
+ aborts.isAbortReason(operationError);
4835
+ const abortEvidence =
4836
+ activeAbortEmission ??
4837
+ (drainAbort ? drainError : undefined) ??
4838
+ (operationAbort ? operationError : undefined);
4839
+ const failure =
4840
+ controlFailure ??
4841
+ (drainAbort ? undefined : drainError) ??
4842
+ (operationAbort ? undefined : operationError);
4843
+ if (failure !== undefined) throw failure;
4844
+ if (
4845
+ abortEvidence !== undefined &&
4846
+ result?.outcome !== 'terminal' &&
4847
+ result?.outcome !== 'suspended'
4848
+ ) {
4849
+ result = runResultFor('aborted', abortEvidence);
4850
+ }
4851
+ if (result === undefined) {
4852
+ throw new Error('playbook resume produced no runtime result');
4853
+ }
4854
+ return result;
4855
+ } finally {
4856
+ activeSignal = undefined;
4857
+ activeAborts = undefined;
4858
+ activeAbortEmission = undefined;
4859
+ activeTurnId = undefined;
4860
+ controlPlaneError = undefined;
4271
4861
  }
4272
- return result;
4273
4862
  },
4274
4863
 
4275
4864
  dispose(): Promise<void> {
@@ -4339,6 +4928,10 @@ export function createXStatePlaybookRuntime<TOptions>(
4339
4928
  appliedReceipts.clear();
4340
4929
  actor = undefined;
4341
4930
  activeSignal = undefined;
4931
+ activeAborts = undefined;
4932
+ actorSettlementAborts = undefined;
4933
+ actorSettlementErrorAborts = undefined;
4934
+ activeAbortEmission = undefined;
4342
4935
  activeTurnId = undefined;
4343
4936
  controlPlaneError = undefined;
4344
4937
  emissionFailure = undefined;