@sublang/playbook 16.0.0 → 17.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 (40) hide show
  1. package/README.md +2 -0
  2. package/docs/cli.md +36 -18
  3. package/docs/configuration.md +7 -5
  4. package/docs/embedding.md +26 -5
  5. package/package.json +4 -4
  6. package/reference/sdlc/captain.md +19 -10
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +6 -2
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +19 -7
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +25 -10
  10. package/reference/sdlc/captain.playbook/captain.gears.md +16 -8
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +1 -1
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -2
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +4 -3
  14. package/reference/sdlc/code.playbook/bin/interactive-session.js +14 -1
  15. package/reference/sdlc/code.playbook/bin/portable-codec.js +3 -0
  16. package/reference/sdlc/code.playbook/bin/repository-effects.js +1 -0
  17. package/reference/sdlc/code.playbook/bin/run.js +128 -85
  18. package/reference/sdlc/code.playbook/bin/session-host.js +40 -8
  19. package/reference/sdlc/code.playbook/bin/session-store.js +162 -34
  20. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  21. package/reference/sdlc/code.playbook/host-capabilities.d.ts +3 -0
  22. package/reference/sdlc/code.playbook/playbook-captain.d.ts +32 -2
  23. package/reference/sdlc/code.playbook/playbook-captain.js +729 -284
  24. package/reference/sdlc/code.playbook/playbook-captain.ts +771 -358
  25. package/reference/sdlc/code.playbook/session-host.d.ts +4 -1
  26. package/reference/sdlc/code.playbook/session-store.d.ts +17 -0
  27. package/reference/sdlc/code.playbook/session-store.js +1 -0
  28. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +5 -5
  29. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +5 -5
  30. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +5 -5
  31. package/slc/gears2fsm.md +5 -5
  32. package/slc/link.md +77 -33
  33. package/src/runtime.d.ts +46 -2
  34. package/src/runtime.ts +42 -2
  35. package/src/xstate-playbook-runtime.d.ts +9 -8
  36. package/src/xstate-playbook-runtime.js +471 -215
  37. package/src/xstate-playbook-runtime.ts +525 -238
  38. package/src/xstate-runtime.d.ts +3 -4
  39. package/src/xstate-runtime.js +52 -0
  40. package/src/xstate-runtime.ts +59 -4
@@ -66,6 +66,8 @@ import type {
66
66
  PlaybookEffectLogicalOperation,
67
67
  PlaybookFailureCause,
68
68
  PlaybookPendingBossQuestion,
69
+ PlaybookPendingCall,
70
+ PlaybookStepRecord,
69
71
  PlaybookPorts,
70
72
  PlaybookRepositoryReceipt,
71
73
  PlaybookRunResult,
@@ -544,6 +546,11 @@ interface XStateRepositoryCohortResult<T> {
544
546
  }
545
547
 
546
548
  export interface XStateRepositoryCapability {
549
+ observe?(): Promise<PlaybookRepositoryReceipt['baseline']>;
550
+ acquire?(options: { readonly signal: AbortSignal }): Promise<{
551
+ assertOwner(): Promise<void>;
552
+ release(): Promise<void>;
553
+ }>;
547
554
  runExclusive<T>(options: {
548
555
  readonly signal: AbortSignal;
549
556
  readonly effectBoundary: XStateEffectBoundarySeed;
@@ -950,14 +957,7 @@ interface XStatePlaybookRuntimeSpecBase<TOptions> {
950
957
  entryEvent?: {
951
958
  type: string;
952
959
  textField: string;
953
- /**
954
- * DR-034: the FSM context member this machine's entry action copies the
955
- * exact Boss text into. Where it is named, the failure-state retry
956
- * builds its payload from that member of the live snapshot instead of
957
- * from the process-local recorded event, so the action derives the same
958
- * before and after `restore`. Absent: the recorded event stays the
959
- * source and the action lives only as long as the process.
960
- */
960
+ /** @deprecated Step checkpoints now carry the accepted input directly. */
961
961
  contextField?: string;
962
962
  };
963
963
  /**
@@ -1831,6 +1831,7 @@ function buildGovernedJudgePrompt(
1831
1831
  '```',
1832
1832
  '',
1833
1833
  'Pick exactly one declared `guard` and reply with exactly that outcome\'s reply shape below: `guard` plus its semantic-owned fields and nothing else.',
1834
+ 'If no declared outcome matches the player output, instead reply exactly {"blocked":"explain the missing outcome or contradictory requirements"}. Never force a match or invent a guard. A blocked reply pauses the playbook for Boss; it is not completion.',
1834
1835
  'A field listed as runtime-supplied is owned by presentation, effect, or runtime evidence; the runtime fills it itself, and a reply that includes one is structurally invalid.',
1835
1836
  '',
1836
1837
  ];
@@ -1979,6 +1980,7 @@ interface PlayerBridgeSpec {
1979
1980
  adjudication: PlayerAdjudicationSpec;
1980
1981
  resumableStateIds: ReadonlySet<string>;
1981
1982
  allowsCorrectiveReplay?: (result: PlayerResult) => boolean;
1983
+ run?: (input: PlaybookPlayerInput, signal: AbortSignal, execute: () => Promise<PlaybookActorOutput>) => Promise<PlaybookActorOutput>;
1982
1984
  }
1983
1985
 
1984
1986
  export function createPlayerBridge(
@@ -1988,8 +1990,7 @@ export function createPlayerBridge(
1988
1990
  boundary?: RuntimeBoundaryCalls,
1989
1991
  onControlPlaneError?: (error: unknown) => void,
1990
1992
  ): PromiseActorLogic<PlaybookActorOutput, PlaybookPlayerInput> {
1991
- return fromPromise<PlaybookActorOutput, PlaybookPlayerInput>(
1992
- async ({ input, signal }) => {
1993
+ const execute = async ({ input, signal }: { input: PlaybookPlayerInput; signal: AbortSignal }): Promise<PlaybookActorOutput> => {
1993
1994
  const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
1994
1995
  let roleId: string;
1995
1996
  let prompt: string;
@@ -2079,8 +2080,9 @@ export function createPlayerBridge(
2079
2080
  }
2080
2081
  throw error;
2081
2082
  }
2082
- },
2083
- );
2083
+ };
2084
+ return fromPromise<PlaybookActorOutput, PlaybookPlayerInput>((args) => spec.run
2085
+ ? spec.run(args.input, args.signal, () => execute(args)) : execute(args));
2084
2086
  }
2085
2087
 
2086
2088
  // ---------------------------------------------------------------------------
@@ -3224,11 +3226,12 @@ function makeDefaultStatusesForState(
3224
3226
  }
3225
3227
  return [
3226
3228
  ...statuses,
3227
- { message: `${askerLabel(pending.asker)} asks: ${pending.question}` },
3229
+ { message: `${askerLabel(pending.asker)} asks: ${pending.question}`, data: { kind: 'boss-question' } },
3228
3230
  {
3229
3231
  message:
3230
3232
  `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
3231
3233
  `${askerLabel(pending.asker)} · ${pending.sourceItem}`,
3234
+ data: { kind: 'boss-question' },
3232
3235
  },
3233
3236
  ];
3234
3237
  }
@@ -4106,11 +4109,13 @@ export function createXStatePlaybookRuntime<
4106
4109
  statuses.push(
4107
4110
  {
4108
4111
  message: `${askerLabel(pending.asker)} asks: ${pending.question}`,
4112
+ data: { kind: 'boss-question' },
4109
4113
  },
4110
4114
  {
4111
4115
  message:
4112
4116
  `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
4113
4117
  `${askerLabel(pending.asker)} · ${pending.sourceItem}`,
4118
+ data: { kind: 'boss-question' },
4114
4119
  },
4115
4120
  );
4116
4121
  }
@@ -4210,6 +4215,7 @@ export function createXStatePlaybookRuntime<
4210
4215
  `${label} current host effect ledger`,
4211
4216
  );
4212
4217
  let effectLedgerMirror = currentEffectLedger();
4218
+ let recoveryCheckpoint: PlaybookRuntimeSnapshot['recoveryCheckpoint'];
4213
4219
  let retainedEffectSourceSessionId: string | undefined;
4214
4220
  let retainedEffectReconciliation:
4215
4221
  | PlaybookRuntimeSnapshot['retainedEffectReconciliation']
@@ -4363,12 +4369,6 @@ export function createXStatePlaybookRuntime<
4363
4369
  let playbookCallSequence = 0;
4364
4370
  let captainCallSequence = 0;
4365
4371
  let applyCallSequence = 0;
4366
- // DR-029: the last event a public Boss boundary sent into the
4367
- // machine — classified, deterministic entry, or Boss reply — kept with
4368
- // its recorded payload so a failure-state retry action can replay the
4369
- // event that drove the run into `failed`. Process-local: the durable
4370
- // runtime snapshot does not persist it (PBRT-50).
4371
- let lastBossEvent: EventObject | undefined;
4372
4372
  // DR-029: process-local at-most-once `apply` execution — the accepted receipt
4373
4373
  // recorded for each idempotency key, returned verbatim on a repeated
4374
4374
  // key. A key whose call settled `rejected` or threw before reaching
@@ -4605,7 +4605,7 @@ export function createXStatePlaybookRuntime<
4605
4605
  function boundaryExcludesEffect(boundary: PlaybookEffectBoundary): boolean {
4606
4606
  return (
4607
4607
  boundary.logicalOperationId === undefined &&
4608
- boundary.physicalReceipt?.classification === 'unchanged'
4608
+ (boundary.physicalReceipt?.classification === 'unchanged' || boundary.restored !== undefined)
4609
4609
  );
4610
4610
  }
4611
4611
 
@@ -4677,7 +4677,7 @@ export function createXStatePlaybookRuntime<
4677
4677
  ({ sequence }) =>
4678
4678
  sequence > reconstructedGovernedPrefixSequence!,
4679
4679
  );
4680
- if (candidate === undefined || candidate.sourceStateId !== state.stateId) {
4680
+ if (candidate === undefined || candidate.restored !== undefined || candidate.sourceStateId !== state.stateId) {
4681
4681
  return;
4682
4682
  }
4683
4683
  const persisted = persistedBoundaryReconciliation(candidate, ledger);
@@ -6341,6 +6341,401 @@ export function createXStatePlaybookRuntime<
6341
6341
  );
6342
6342
  }
6343
6343
 
6344
+ let acceptedStepResult: PlaybookRuntimeSnapshot['recoveryCheckpoint'];
6345
+ let requiredRecoveryBaseline: PlaybookRepositoryReceipt['baseline'] | undefined;
6346
+
6347
+ function captureRecoveryCheckpoint(stateId: string, prompt: string, boundaryPrefix?: number): void {
6348
+ if (requiredRecoveryBaseline !== undefined) return;
6349
+ // Capture after the entry emissions drain: the invocation is now a
6350
+ // real active child in XState's persisted snapshot, before host work.
6351
+ const id = recoveryCheckpoint?.stateId === stateId ? recoveryCheckpoint.id : undefined;
6352
+ recoveryCheckpoint = undefined;
6353
+ if (!actor || currentState().stateId !== stateId) return;
6354
+ try {
6355
+ const current = currentEffectLedger();
6356
+ recoveryCheckpoint = deepFreeze({
6357
+ stateId,
6358
+ prompt,
6359
+ ...(id === undefined ? {} : { id }),
6360
+ machine: detachPersistedMachineSnapshot(actor.getPersistedSnapshot()),
6361
+ boundaryPrefix: boundaryPrefix ?? current.boundaries.length,
6362
+ });
6363
+ } catch {
6364
+ // An in-memory-only context is still executable; it simply cannot
6365
+ // promise a durable step retry. Keep the legacy control behavior.
6366
+ }
6367
+ }
6368
+
6369
+ function stepReplayIsSafe(): boolean {
6370
+ if (!recoveryCheckpoint || hasUnresolvedReconciliation()) return false;
6371
+ const current = currentEffectLedger();
6372
+ return current.boundaries
6373
+ .slice(recoveryCheckpoint.boundaryPrefix)
6374
+ .every(
6375
+ (boundary) =>
6376
+ runtimeBoundaryIsOwned(boundary) &&
6377
+ boundary.logicalOperationId === undefined &&
6378
+ (boundary.physicalReceipt?.classification === 'unchanged' || boundary.restored !== undefined),
6379
+ );
6380
+ }
6381
+
6382
+ function repairableReadOnlyBoundary(): PlaybookEffectBoundary | undefined {
6383
+ if (!recoveryCheckpoint || !repositoryCapability?.observe || !repositoryCapability.acquire ||
6384
+ retainedEffectReconciliationRequired || deferredReconciliationOperationId !== undefined) return undefined;
6385
+ const suffix = currentEffectLedger().boundaries.slice(recoveryCheckpoint.boundaryPrefix);
6386
+ if (suffix.length !== 1) return undefined;
6387
+ const saved = suffix[0]!;
6388
+ if (!runtimeBoundaryIsOwned(saved) || saved.restored !== undefined ||
6389
+ saved.sourceStateId !== recoveryCheckpoint.stateId || saved.cohortId !== undefined ||
6390
+ saved.logicalOperationId !== undefined || saved.dispositions.some((item) => item !== 'unchanged') ||
6391
+ saved.physicalReceipt === undefined || saved.after?.head !== saved.baseline.head ||
6392
+ saved.physicalReceipt.classification === 'unchanged' ||
6393
+ [...unresolvedSemanticBoundaryIds].some((id) => id !== saved.boundaryId)) return undefined;
6394
+ return saved;
6395
+ }
6396
+
6397
+ async function confirmReadOnlyRestoration(saved: PlaybookEffectBoundary, signal: AbortSignal): Promise<void> {
6398
+ const claim = await repositoryCapability!.acquire!({ signal });
6399
+ try {
6400
+ await claim.assertOwner();
6401
+ const restored = snapshotJsonValue(await repositoryCapability!.observe!()) as unknown as PlaybookRepositoryReceipt['baseline'];
6402
+ if (!isDeepStrictEqual(restored, saved.baseline)) {
6403
+ throw new Error(`${label} preparation has not restored the stopped step's repository; it remains paused`);
6404
+ }
6405
+ signal.throwIfAborted();
6406
+ effectLedgerMirror = assertPlaybookEffectLedger(await effectLedgerCapability!.writeAhead([
6407
+ { kind: 'replace-boundaries', replacements: [{ expected: saved, next: { ...saved, restored } }] },
6408
+ ]));
6409
+ if (!isDeepStrictEqual(effectLedgerMirror.boundaries.find(({ boundaryId }) => boundaryId === saved.boundaryId)?.restored, restored)) {
6410
+ throw new Error(`${label} repository restoration was not durably acknowledged`);
6411
+ }
6412
+ refreshUnresolvedSemanticReconciliation(effectLedgerMirror);
6413
+ } finally { await claim.release(); }
6414
+ }
6415
+
6416
+ // A completed commit with saved presentation needs only the missing
6417
+ // tool-free judgment. Never repeat the player to recover that evidence.
6418
+ function recoverableJudgment(): PlaybookEffectBoundary | undefined {
6419
+ if (
6420
+ !recoveryCheckpoint || recoveryCheckpoint.result !== undefined || recoveryCheckpoint.delivered ||
6421
+ retainedEffectReconciliationRequired ||
6422
+ deferredReconciliationOperationId !== undefined
6423
+ )
6424
+ return undefined;
6425
+ const suffix = currentEffectLedger().boundaries.slice(
6426
+ recoveryCheckpoint.boundaryPrefix,
6427
+ );
6428
+ if (suffix.length !== 1) return undefined;
6429
+ const saved = suffix[0]!;
6430
+ if (
6431
+ !runtimeBoundaryIsOwned(saved) ||
6432
+ saved.sourceStateId !== recoveryCheckpoint.stateId ||
6433
+ saved.logicalOperationId !== undefined ||
6434
+ saved.correctionBudget.spent ||
6435
+ saved.physicalReceipt?.classification !== 'one-descendant-commit' ||
6436
+ !saved.finalText?.trim() ||
6437
+ governedOutcomesForBoundary(saved) === undefined ||
6438
+ (saved.semanticCandidate !== undefined &&
6439
+ persistedBoundaryReconciliation(saved, currentEffectLedger())
6440
+ ?.reconciliation.status !== 'resolved') ||
6441
+ [...unresolvedSemanticBoundaryIds].some((id) => id !== saved.boundaryId)
6442
+ )
6443
+ return undefined;
6444
+ return saved;
6445
+ }
6446
+
6447
+ async function recoverJudgment(
6448
+ saved: PlaybookEffectBoundary,
6449
+ signal: AbortSignal,
6450
+ ): Promise<void> {
6451
+ const checkpoint = recoveryCheckpoint!;
6452
+ const persisted = checkpoint.machine as unknown as {
6453
+ children: Record<string, { snapshot: { input: PlaybookPlayerInput } }>;
6454
+ };
6455
+ const input = Object.values(persisted.children)[0]!;
6456
+ const actorInput = input.snapshot.input;
6457
+ const outcomes = governedOutcomesForBoundary(saved)!;
6458
+ const candidate =
6459
+ saved.semanticCandidate ??
6460
+ parseGovernedSemanticCandidate(
6461
+ await boundary.callJudge(
6462
+ 'player-output-adjudication',
6463
+ saved.sourceStateId,
6464
+ buildGovernedJudgePrompt(actorInput, saved.finalText!, outcomes),
6465
+ signal,
6466
+ ),
6467
+ );
6468
+ const result = reconcilePlaybookSemanticEvidence({
6469
+ outcomes,
6470
+ semanticCandidate: candidate,
6471
+ finalText: saved.finalText,
6472
+ receipt: saved.physicalReceipt,
6473
+ });
6474
+ signal.throwIfAborted();
6475
+ const next = {
6476
+ ...saved,
6477
+ semanticCandidate: snapshotJsonValue(result.evidence.semanticCandidate),
6478
+ };
6479
+ const acknowledged = assertPlaybookEffectLedger(
6480
+ await effectLedgerCapability!.writeAhead([
6481
+ {
6482
+ kind: 'replace-boundaries',
6483
+ replacements: [{ expected: saved, next }],
6484
+ },
6485
+ ]),
6486
+ );
6487
+ if (
6488
+ !isDeepStrictEqual(
6489
+ acknowledged.boundaries.find((b) => b.boundaryId === saved.boundaryId),
6490
+ next,
6491
+ )
6492
+ ) {
6493
+ throw new Error(
6494
+ `${label} recovered judgment was not acknowledged exactly`,
6495
+ );
6496
+ }
6497
+ effectLedgerMirror = acknowledged;
6498
+ refreshUnresolvedSemanticReconciliation(acknowledged);
6499
+ if (result.status !== 'resolved') {
6500
+ const error = attachPlaybookFailureCause(new Error(
6501
+ result.status === 'unresolved' && 'reason' in result.cause.evidence && result.cause.evidence.reason
6502
+ ? String(result.cause.evidence.reason)
6503
+ : `${label} saved result could not be reconciled; the playbook remains parked`,
6504
+ ), result.status === 'unresolved' ? result.cause : runtimeDefectCause('Saved result still needs a Boss answer'));
6505
+ const paused = detachPersistedMachineSnapshot(actor!.getPersistedSnapshot()) as Record<string, JsonValue>;
6506
+ stopActor();
6507
+ actor = buildActor(runtimePorts!, {
6508
+ ...paused, context: { ...(paused.context as Record<string, JsonValue>), lastError: snapshotJsonValue(normalizeErrorFull(error)) },
6509
+ });
6510
+ suppressInspectionEmissions = false;
6511
+ actor.start();
6512
+ throw error;
6513
+ }
6514
+ await resumeCheckpoint({ ...checkpoint, result: snapshotJsonValue(result.output) });
6515
+ }
6516
+
6517
+ async function resumeCheckpoint(checkpoint: NonNullable<PlaybookRuntimeSnapshot['recoveryCheckpoint']>): Promise<void> {
6518
+ acceptedStepResult = checkpoint;
6519
+ try {
6520
+ stopActor();
6521
+ actor = buildActor(runtimePorts!, checkpoint.machine);
6522
+ suppressInspectionEmissions = false;
6523
+ actor.start();
6524
+ await waitForPlaybookQuiescence(actor, { pendingCalls: nestedBridge });
6525
+ } finally { acceptedStepResult = undefined; }
6526
+ }
6527
+
6528
+ function exportRuntimeSnapshot(checkpoint?: { interrupted?: true; child?: PlaybookPendingCall }): PlaybookRuntimeSnapshot | undefined {
6529
+ if (!actor || !session || disposed || disposalPromise !== undefined) {
6530
+ return undefined;
6531
+ }
6532
+ if (activeSignal !== undefined && checkpoint === undefined) return undefined;
6533
+ if (deferredSettlementClosure !== undefined) return undefined;
6534
+ const pendingCall = checkpoint?.child ?? nestedBridge.getPendingCall();
6535
+ const bridgeSuspendedCall = checkpoint?.child ? nestedBridge.checkpointCall(checkpoint.child) : nestedBridge.getSuspendedCall();
6536
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
6537
+ return undefined;
6538
+ }
6539
+ let suspendedCall: typeof bridgeSuspendedCall;
6540
+ if (bridgeSuspendedCall !== undefined) {
6541
+ if (
6542
+ pendingCall?.callId !== bridgeSuspendedCall.callId ||
6543
+ pendingCall?.playbookId !== bridgeSuspendedCall.playbookId ||
6544
+ pendingCall?.childSessionId !== bridgeSuspendedCall.childSessionId
6545
+ ) {
6546
+ return undefined;
6547
+ }
6548
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
6549
+ return undefined;
6550
+ }
6551
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
6552
+ if (
6553
+ bridgeSuspendedCall.turnId !== undefined &&
6554
+ bridgeSuspendedCall.turnId !== turnId
6555
+ ) {
6556
+ return undefined;
6557
+ }
6558
+ suspendedCall = {
6559
+ ...bridgeSuspendedCall,
6560
+ ...(turnId === undefined ? {} : { turnId }),
6561
+ ...(hasGovernedPlayerStates
6562
+ ? {
6563
+ effectBoundaryPrefixSequence:
6564
+ playbookCallEffectPrefixes.get(
6565
+ bridgeSuspendedCall.callId,
6566
+ ) ?? null,
6567
+ }
6568
+ : {}),
6569
+ };
6570
+ }
6571
+ const interrupted = checkpoint?.interrupted && recoveryCheckpoint && machine.root.states.failed;
6572
+ const state = interrupted
6573
+ ? normalizePlaybookSnapshot(machine.resolveState({ value: 'failed', context: {} }))
6574
+ : checkpoint?.child ? normalizePlaybookSnapshot(actor.getSnapshot(), { pendingCall }) : currentState();
6575
+ if (state.status !== 'active' || !state.quiescent) return undefined;
6576
+ const context = (actor.getSnapshot() as { context?: unknown })
6577
+ .context as Record<string, unknown>;
6578
+ // DR-063 §2: the failed state's `lastError` is persisted as every live
6579
+ // surface publishes it, completed from the runtime's own cause slot, so
6580
+ // a thrown value that refused the cause still explains itself after a
6581
+ // restart.
6582
+ const machineSnapshot = detachPersistedMachineSnapshot(
6583
+ interrupted ? { ...recoveryCheckpoint!.machine as object, value: 'failed', children: {} } : actor.getPersistedSnapshot(),
6584
+ state.stateId === 'failed'
6585
+ ? { lastError: interrupted ? { name: 'InterruptedStep', message: 'The process stopped during this step. Review the recorded work before choosing how to continue.', cause: runtimeDefectCause('The process stopped during this step. Review the recorded work before choosing how to continue.') } : failedStateError(context ?? {}) }
6586
+ : {},
6587
+ );
6588
+ effectLedgerMirror = currentEffectLedger();
6589
+ refreshRetainedEffectReconciliation(effectLedgerMirror);
6590
+ syncDeferredReconciliationOverlay();
6591
+ refreshUnresolvedSemanticReconciliation(effectLedgerMirror);
6592
+ const pendingQuestions = !hasUnresolvedReconciliation()
6593
+ ? pendingBossQuestionsFor(state, context ?? {})
6594
+ : [];
6595
+ const interruptedPrefix = interrupted ? activeEffectLedgerPrefixSequence : undefined;
6596
+ const interruptedAttempts = new Set(effectLedgerMirror.boundaries.filter(({ sequence }) => interruptedPrefix !== undefined && sequence > interruptedPrefix).map(({ attemptId }) => attemptId));
6597
+ const failedEffectAttempt = interrupted && interruptedPrefix !== undefined && interruptedAttempts.size <= 1
6598
+ ? { boundaryPrefix: interruptedPrefix, attemptId: interruptedAttempts.values().next().value ?? null }
6599
+ :
6600
+ hasGovernedPlayerStates &&
6601
+ !interrupted && state.stateId === 'failed' &&
6602
+ failedAttemptMatchesCurrentLedger(effectLedgerMirror)
6603
+ ? {
6604
+ boundaryPrefix: failedEffectBoundaryPrefix!,
6605
+ attemptId: failedGovernedAttemptId ?? null,
6606
+ }
6607
+ : undefined;
6608
+ return {
6609
+ schemaVersion: 4,
6610
+ playbookId: session.playbookId,
6611
+ machine: machineSnapshot,
6612
+ roleResumeTokens: snapshotRoleResumeTokens(),
6613
+ sequences: {
6614
+ trace: traceSequence,
6615
+ turn: turnSequence,
6616
+ judgeCall: judgeCallSequence,
6617
+ playerCall: playerCallSequence,
6618
+ playbookCall: playbookCallSequence,
6619
+ ...(declaredActors.has('captain')
6620
+ ? { captainCall: captainCallSequence }
6621
+ : {}),
6622
+ },
6623
+ state,
6624
+ pendingBossQuestions: pendingQuestions.map((pending) => ({
6625
+ questionId: pending.questionId,
6626
+ asker: pending.asker,
6627
+ question: pending.question,
6628
+ sourceItem: pending.sourceItem,
6629
+ })),
6630
+ effectLedger: effectLedgerMirror,
6631
+ ...(retainedEffectSourceSessionId === undefined
6632
+ ? {}
6633
+ : { retainedEffectSourceSessionId }),
6634
+ ...(retainedEffectReconciliation === undefined
6635
+ ? {}
6636
+ : { retainedEffectReconciliation }),
6637
+ ...(failedEffectAttempt === undefined
6638
+ ? {}
6639
+ : { failedEffectAttempt }),
6640
+ ...((state.stateId === 'failed' || pendingQuestions.length > 0) && recoveryCheckpoint !== undefined
6641
+ ? { recoveryCheckpoint } : {}),
6642
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
6643
+ };
6644
+ }
6645
+
6646
+ async function runJournalledStep(
6647
+ kind: PlaybookStepRecord['kind'],
6648
+ input: PlaybookPlayerInput | PlaybookCaptainInput | PlaybookScriptInput,
6649
+ signal: AbortSignal,
6650
+ execute: () => Promise<PlaybookActorOutput>,
6651
+ ): Promise<PlaybookActorOutput> {
6652
+ const saved = acceptedStepResult;
6653
+ let output: PlaybookActorOutput;
6654
+ if (saved?.stateId === input.stateId && saved.result !== undefined) {
6655
+ acceptedStepResult = undefined;
6656
+ recoveryCheckpoint = saved;
6657
+ output = snapshotJsonValue(saved.result) as PlaybookActorOutput;
6658
+ } else {
6659
+ const record = savedPorts?.recordStep;
6660
+ await drainEmissions();
6661
+ signal.throwIfAborted();
6662
+ try {
6663
+ captureRecoveryCheckpoint(input.stateId, 'command' in input ? input.command : 'role' in input ? composeBoundPlayerPrompt(input) : composeCaptainPrompt(input));
6664
+ } catch (error) {
6665
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
6666
+ throw error;
6667
+ }
6668
+ if (record) {
6669
+ const id = randomUUID();
6670
+ if (recoveryCheckpoint) recoveryCheckpoint = { ...recoveryCheckpoint, id };
6671
+ const step = { id, kind, stateId: input.stateId };
6672
+ await record(step, exportRuntimeSnapshot({ interrupted: true }));
6673
+ signal.throwIfAborted();
6674
+ const result = snapshotJsonValue(await execute());
6675
+ // Keep the known output if its save loses acknowledgement. A drained
6676
+ // failure can then preserve it without treating the call as unexecuted.
6677
+ if (recoveryCheckpoint?.id === id) recoveryCheckpoint = { ...recoveryCheckpoint, result };
6678
+ await record({ ...step, result });
6679
+ output = result as PlaybookActorOutput;
6680
+ } else {
6681
+ output = await execute();
6682
+ }
6683
+ }
6684
+ if (recoveryCheckpoint?.stateId === input.stateId) {
6685
+ const { result: _result, ...start } = recoveryCheckpoint;
6686
+ recoveryCheckpoint = { ...start, delivered: true };
6687
+ }
6688
+ return output;
6689
+ }
6690
+
6691
+ function validateRecoveryCheckpoint(
6692
+ checkpoint: PlaybookRuntimeSnapshot['recoveryCheckpoint'],
6693
+ ): void {
6694
+ if (!checkpoint) return;
6695
+ const node = machine.root.states[checkpoint.stateId];
6696
+ const persisted = checkpoint.machine as Record<string, JsonValue>;
6697
+ const children = persisted.children;
6698
+ if (
6699
+ !node ||
6700
+ node.invoke.length !== 1 ||
6701
+ !['player', 'captain', 'script'].includes(String(node.invoke[0]!.src)) ||
6702
+ !isPlainObject(children) ||
6703
+ Object.keys(children).length !== 1
6704
+ ) {
6705
+ throw new TypeError(
6706
+ `${label} recovery checkpoint does not name one current invocation`,
6707
+ );
6708
+ }
6709
+ const child = children[node.invoke[0]!.id] as
6710
+ Record<string, JsonValue> | undefined;
6711
+ if (
6712
+ !child ||
6713
+ child.src !== node.invoke[0]!.src ||
6714
+ !isPlainObject(child.snapshot) ||
6715
+ child.snapshot.status !== 'active' ||
6716
+ !isPlainObject(child.snapshot.input) ||
6717
+ child.snapshot.input.stateId !== checkpoint.stateId
6718
+ ) {
6719
+ throw new TypeError(
6720
+ `${label} recovery checkpoint invocation is incompatible`,
6721
+ );
6722
+ }
6723
+ if (checkpoint.result !== undefined) {
6724
+ const output = checkpoint.result;
6725
+ const input = child.snapshot.input;
6726
+ if (!isPlainObject(output) || typeof output.guard !== 'string' ||
6727
+ !isPlainObject(input.result) || !Object.hasOwn(input.result, output.guard) ||
6728
+ (node.invoke[0]!.src === 'script' &&
6729
+ (!Number.isInteger(output.exitStatus) || Object.keys(output).sort().join(',') !== 'exitStatus,guard' ||
6730
+ output.guard !== Object.keys(input.result)[output.exitStatus === 0 ? 0 : Math.min(1, Object.keys(input.result).length - 1)]))) {
6731
+ throw new TypeError(`${label} saved step result is incompatible with its invocation`);
6732
+ }
6733
+ for (const field of extractFields(String(input.result[output.guard]))) {
6734
+ if (output[field] === undefined || output[field] === null) throw new TypeError(`${label} saved step result is missing ${field}`);
6735
+ }
6736
+ }
6737
+ }
6738
+
6344
6739
  function captureEffectLedgerPrefixSequence(): number | undefined {
6345
6740
  if (!hasGovernedPlayerStates) return undefined;
6346
6741
  try {
@@ -7153,14 +7548,23 @@ export function createXStatePlaybookRuntime<
7153
7548
  // captured it under this exclusive claim. A host that supplies
7154
7549
  // none states no pre-existing change, exactly as an empty
7155
7550
  // baseline projection does.
7156
- operation: (context) =>
7157
- runTracedPlayerCall(
7551
+ operation: async (context) => {
7552
+ if (requiredRecoveryBaseline !== undefined) {
7553
+ if (!isDeepStrictEqual(snapshotJsonValue(context.baseline), requiredRecoveryBaseline)) {
7554
+ throw new Error(`${label} repository changed after preparation; the stopped step remains paused`);
7555
+ }
7556
+ requiredRecoveryBaseline = undefined;
7557
+ captureRecoveryCheckpoint(input.stateId, freshPrompt,
7558
+ currentEffectLedger().boundaries.findIndex(({ boundaryId }) => boundaryId === effectBoundary.boundaryId));
7559
+ }
7560
+ return runTracedPlayerCall(
7158
7561
  selectedResume,
7159
7562
  effectAuthorizedPreExistingBlock(
7160
7563
  effectBoundary,
7161
7564
  context?.baseline,
7162
7565
  ),
7163
- ),
7566
+ );
7567
+ },
7164
7568
  completeEffectBoundary: completionEvidenceFor(
7165
7569
  input,
7166
7570
  roleId,
@@ -7454,6 +7858,7 @@ export function createXStatePlaybookRuntime<
7454
7858
  ): PromiseActorLogic<PlaybookActorOutput, PlaybookPlayerInput> {
7455
7859
  return createPlayerBridge(
7456
7860
  {
7861
+ run: (input, signal, execute) => runJournalledStep('player', input, signal, execute),
7457
7862
  resolveRoleId: requireRoleId,
7458
7863
  validateInput: (input) =>
7459
7864
  assertGovernedPlayerInput(
@@ -7487,7 +7892,7 @@ export function createXStatePlaybookRuntime<
7487
7892
  PlaybookCaptainInput
7488
7893
  > {
7489
7894
  return fromPromise<PlaybookActorOutput, PlaybookCaptainInput>(
7490
- async ({ input, signal }) => {
7895
+ ({ input, signal }) => runJournalledStep('captain', input, signal, async () => {
7491
7896
  const active = combineAbortSignals(signal, activeSignal);
7492
7897
  try {
7493
7898
  await drainEmissions();
@@ -7564,7 +7969,7 @@ export function createXStatePlaybookRuntime<
7564
7969
  }
7565
7970
  throw error;
7566
7971
  }
7567
- },
7972
+ }),
7568
7973
  );
7569
7974
  }
7570
7975
 
@@ -7578,7 +7983,7 @@ export function createXStatePlaybookRuntime<
7578
7983
  PlaybookScriptInput
7579
7984
  > {
7580
7985
  return fromPromise<PlaybookActorOutput, PlaybookScriptInput>(
7581
- async ({ input, signal }) => {
7986
+ ({ input, signal }) => runJournalledStep('script', input, signal, async () => {
7582
7987
  await drainEmissions();
7583
7988
  const active = combineAbortSignals(signal, activeSignal);
7584
7989
  const guards = Object.keys(input.result);
@@ -7767,7 +8172,7 @@ export function createXStatePlaybookRuntime<
7767
8172
  if (killTimer !== undefined) clearTimeout(killTimer);
7768
8173
  }
7769
8174
  }
7770
- },
8175
+ }),
7771
8176
  );
7772
8177
  }
7773
8178
 
@@ -8169,6 +8574,10 @@ export function createXStatePlaybookRuntime<
8169
8574
  latchRuntimeError(error);
8170
8575
  }
8171
8576
  if (state.stateId === 'failed') {
8577
+ if (previousState?.stateId !== 'failed' &&
8578
+ previousState?.stateId !== recoveryCheckpoint?.stateId) {
8579
+ recoveryCheckpoint = undefined;
8580
+ }
8172
8581
  if (
8173
8582
  previousState?.stateId !== 'failed' ||
8174
8583
  activeGovernedBoundarySeen
@@ -8453,7 +8862,7 @@ export function createXStatePlaybookRuntime<
8453
8862
  activeDeferredContinuation = undefined;
8454
8863
  deferInspectionEmissions = false;
8455
8864
  deferredInspectionEmissions = [];
8456
- lastBossEvent = undefined;
8865
+ recoveryCheckpoint = undefined;
8457
8866
  suppressInspectionEmissions = false;
8458
8867
  initialized = false;
8459
8868
  traceSequence = 0;
@@ -8475,6 +8884,9 @@ export function createXStatePlaybookRuntime<
8475
8884
  event?: EventObject;
8476
8885
  deferredRestoreOperationId?: string;
8477
8886
  unresolvedEffectAction?: 'reconcile' | 'abandon';
8887
+ retryStep?: true;
8888
+ retryJudgment?: PlaybookEffectBoundary;
8889
+ restoreReadOnly?: PlaybookEffectBoundary;
8478
8890
  }
8479
8891
 
8480
8892
  function snapshotCan(snapshot: unknown, event: EventObject): boolean {
@@ -8486,78 +8898,24 @@ export function createXStatePlaybookRuntime<
8486
8898
  );
8487
8899
  }
8488
8900
 
8489
- // DR-034: where the artifact names the FSM context member its entry
8490
- // action copies the exact Boss text into, that member of the live
8491
- // snapshot is the retry payload's source. The persisted machine snapshot
8492
- // carries it, so the candidate derives identically in the process that
8493
- // exported the snapshot and in one that restored it, and a failure
8494
- // reached after a Boss reply — whose recorded event the failure state
8495
- // refuses — is recoverable too. Naming the member is the artifact's
8496
- // statement that it holds the entry text: a same-named member is never
8497
- // assumed, since inferring one would turn any matching context member
8498
- // into a replay payload without its author saying so.
8499
- // Declared and absent or empty excludes the candidate rather than
8500
- // falling back to the record, which would make the action depend on the
8501
- // process again — the very thing this source exists to end.
8502
- function retryEventFrom(snapshot: unknown): EventObject | undefined {
8503
- const entryEvent = spec.entryEvent;
8504
- if (entryEvent?.contextField === undefined) return lastBossEvent;
8505
- const context = (snapshot as { context?: unknown } | null)?.context;
8506
- const text = isPlainObject(context)
8507
- ? context[entryEvent.contextField]
8508
- : undefined;
8509
- if (typeof text !== 'string' || text.trim() === '') return undefined;
8510
- return { type: entryEvent.type, [entryEvent.textField]: text };
8511
- }
8512
-
8513
- // The failure-state retry entry replays the recorded last classified
8514
- // event with its recorded payload, or the entry event the declared
8515
- // context member above sources. A candidate whose event the live
8516
- // snapshot does not accept — or whose payload the runtime can source
8517
- // from neither — is excluded rather than completed with invented text.
8518
- // The caller has already applied the PBRT-71 replay fence.
8519
- function retryActionFor(
8520
- snapshot: unknown,
8521
- stateId: string | undefined,
8522
- ): DerivedControlAction | undefined {
8523
- if (stateId !== 'failed') return undefined;
8524
- const retryEvent = retryEventFrom(snapshot);
8525
- if (retryEvent === undefined) return undefined;
8526
- if (!snapshotCan(snapshot, retryEvent)) return undefined;
8527
- // A recorded explicit-state-jump event names the exact state its
8528
- // replay re-enters: the root BOSS_INTERRUPT shape is a guarded
8529
- // multi-arm list keyed on `targetId`, so the first configured arm
8530
- // may label a different state than the one the recorded event
8531
- // actually resumes.
8532
- const recordedTargetId =
8533
- retryEvent.type === JUMP_EVENT_TYPE
8534
- ? (retryEvent as { targetId?: unknown }).targetId
8535
- : undefined;
8536
- const target =
8537
- typeof recordedTargetId === 'string' &&
8538
- recordedTargetId.trim().length > 0
8539
- ? recordedTargetId
8540
- : firstTransitionTarget(machine, stateId, retryEvent.type);
8541
- // PBRT-52: a label is written from a source state description, never
8542
- // from an identifier. Falling back to the target id — or, with no
8543
- // resolvable target, to the FSM event type — makes the label *be* the
8544
- // internal name, which defeats the substitution the label exists for
8545
- // and puts a machine identifier into Boss-facing text
8546
- // (CAPPLAY-5). A candidate whose label can only be an id is excluded
8547
- // exactly like one whose payload cannot be sourced.
8548
- const description =
8549
- (target === undefined ? undefined : stateDescriptions.get(target)) ??
8550
- stateDescriptions.get(stateId);
8551
- if (description === undefined) return undefined;
8901
+ function retryActionFor(stateId: string | undefined): DerivedControlAction | undefined {
8902
+ if (stateId !== 'failed' || !recoveryCheckpoint) return undefined;
8903
+ const description = stateDescriptions.get(recoveryCheckpoint.stateId);
8904
+ const savedResult = recoveryCheckpoint.result !== undefined;
8905
+ const safe = savedResult
8906
+ ? !hasUnresolvedReconciliation() && currentEffectLedger().boundaries.slice(recoveryCheckpoint.boundaryPrefix)
8907
+ .every((boundary) => runtimeBoundaryIsOwned(boundary) && boundary.sourceStateId === recoveryCheckpoint!.stateId)
8908
+ : stepReplayIsSafe();
8909
+ if (description === undefined || !safe) return undefined;
8910
+ const entry = spec.entryEvent;
8911
+ const target = entry && firstTransitionTarget(machine, stateId, entry.type);
8552
8912
  return {
8553
8913
  action: {
8554
- id: `retry:${retryEvent.type}`,
8555
- label: `Retry: ${description}`,
8556
- // DR-063 §3: retrying a failed call is `ready` — the runtime cannot
8557
- // see whether the Boss changed the outside world since it failed.
8914
+ id: target === recoveryCheckpoint.stateId ? `retry:${entry!.type}` : 'retry:step',
8915
+ label: `${savedResult ? 'Continue from saved result' : 'Retry'}: ${description}`,
8558
8916
  standing: 'ready',
8559
8917
  },
8560
- event: retryEvent,
8918
+ retryStep: true,
8561
8919
  };
8562
8920
  }
8563
8921
 
@@ -8614,6 +8972,18 @@ export function createXStatePlaybookRuntime<
8614
8972
  return [];
8615
8973
  }
8616
8974
  const derived: DerivedControlAction[] = [];
8975
+ const repairable = state.stateId === 'failed' ? repairableReadOnlyBoundary() : undefined;
8976
+ if (repairable !== undefined) derived.push({
8977
+ action: { id: 'retry:restored-step', label: 'Restore the repository and retry the read-only step', standing: 'ready' },
8978
+ retryStep: true,
8979
+ restoreReadOnly: repairable,
8980
+ });
8981
+ const judgment = state.stateId === 'failed' ? recoverableJudgment() : undefined;
8982
+ if (judgment !== undefined) derived.push({
8983
+ action: { id: 'retry:adjudication', label: 'Retry assessment of the saved result', standing: 'ready' },
8984
+ retryJudgment: judgment,
8985
+ });
8986
+ if (judgment !== undefined && !hasUnresolvedReconciliation()) return derived;
8617
8987
  if (hasUnresolvedReconciliation()) {
8618
8988
  const operation =
8619
8989
  deferredReconciliationOperationId === undefined
@@ -8650,17 +9020,13 @@ export function createXStatePlaybookRuntime<
8650
9020
  );
8651
9021
  return derived;
8652
9022
  }
8653
- // PBRT-71 / DR-040 §4: out of the failure state, a jump restarts the
8654
- // failed attempt's work exactly as the retry does, so one fence gates
8655
- // both. Unless every governed boundary of that attempt carries a
8656
- // complete `unchanged` receipt, neither is advertised — and `apply`,
8657
- // which revalidates against this list, accepts neither — so no
8658
- // control can replay a player call whose effect may already exist.
9023
+ const retry = retryActionFor(state.stateId);
9024
+ if (retry !== undefined) derived.push(retry);
9025
+ // A jump can repeat earlier work, unlike checkpoint retry. Its whole
9026
+ // failed attempt must therefore still pass the replay fence.
8659
9027
  if (state.stateId === 'failed' && !failedAttemptAllowsReplay()) {
8660
9028
  return derived;
8661
9029
  }
8662
- const retry = retryActionFor(snapshot, state.stateId);
8663
- if (retry !== undefined) derived.push(retry);
8664
9030
  // Jump entries: resumable targets whose explicit-state-jump event the
8665
9031
  // live snapshot accepts (state guards included), sent with the
8666
9032
  // advertised target id and optional textual fields omitted.
@@ -8792,6 +9158,7 @@ export function createXStatePlaybookRuntime<
8792
9158
  boundSession.playbookId,
8793
9159
  { allowSuspendedCall: true },
8794
9160
  );
9161
+ validateRecoveryCheckpoint(boundSnapshot.recoveryCheckpoint);
8795
9162
  if (
8796
9163
  kind === 'adopt' &&
8797
9164
  effectLedgerCapability !== undefined &&
@@ -8899,6 +9266,7 @@ export function createXStatePlaybookRuntime<
8899
9266
  initInFlight = initialization;
8900
9267
  const initTask = (async () => {
8901
9268
  session = boundSession;
9269
+ recoveryCheckpoint = boundSnapshot.recoveryCheckpoint;
8902
9270
  syncDeferredReconciliationOverlay();
8903
9271
  refreshUnresolvedSemanticReconciliation(effectLedgerMirror);
8904
9272
  prepareReconstructedGovernedDelivery(
@@ -9133,6 +9501,7 @@ export function createXStatePlaybookRuntime<
9133
9501
  signal: AbortSignal,
9134
9502
  turnId: number,
9135
9503
  classificationLine?: string,
9504
+ onAccepted?: () => void,
9136
9505
  ): Promise<'continued' | 'unresolved'> {
9137
9506
  if (repositoryCapability === undefined) {
9138
9507
  throw new Error(
@@ -9183,6 +9552,7 @@ export function createXStatePlaybookRuntime<
9183
9552
  effectBoundary.roleId, boundPlayerId,
9184
9553
  );
9185
9554
  continuationStarted = true;
9555
+ onAccepted?.();
9186
9556
  actor!.send(event);
9187
9557
  // The host has durably started this boundary and the FSM has
9188
9558
  // moved: publish the buffered classification line and the
@@ -9519,114 +9889,7 @@ export function createXStatePlaybookRuntime<
9519
9889
  // Defined only at a safe capture point — initialized, not disposing
9520
9890
  // or disposed, no active public boundary, and the actor quiescent with
9521
9891
  // status `active`.
9522
- exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
9523
- if (!actor || !session || disposed || disposalPromise !== undefined) {
9524
- return undefined;
9525
- }
9526
- if (activeSignal !== undefined) return undefined;
9527
- if (deferredSettlementClosure !== undefined) return undefined;
9528
- const pendingCall = nestedBridge.getPendingCall();
9529
- const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
9530
- if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
9531
- return undefined;
9532
- }
9533
- let suspendedCall: typeof bridgeSuspendedCall;
9534
- if (bridgeSuspendedCall !== undefined) {
9535
- if (
9536
- pendingCall?.callId !== bridgeSuspendedCall.callId ||
9537
- pendingCall?.playbookId !== bridgeSuspendedCall.playbookId ||
9538
- pendingCall?.childSessionId !== bridgeSuspendedCall.childSessionId
9539
- ) {
9540
- return undefined;
9541
- }
9542
- if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
9543
- return undefined;
9544
- }
9545
- const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
9546
- if (
9547
- bridgeSuspendedCall.turnId !== undefined &&
9548
- bridgeSuspendedCall.turnId !== turnId
9549
- ) {
9550
- return undefined;
9551
- }
9552
- suspendedCall = {
9553
- ...bridgeSuspendedCall,
9554
- ...(turnId === undefined ? {} : { turnId }),
9555
- ...(hasGovernedPlayerStates
9556
- ? {
9557
- effectBoundaryPrefixSequence:
9558
- playbookCallEffectPrefixes.get(
9559
- bridgeSuspendedCall.callId,
9560
- ) ?? null,
9561
- }
9562
- : {}),
9563
- };
9564
- }
9565
- const state = currentState();
9566
- if (state.status !== 'active' || !state.quiescent) return undefined;
9567
- const context = (actor.getSnapshot() as { context?: unknown })
9568
- .context as Record<string, unknown>;
9569
- // DR-063 §2: the failed state's `lastError` is persisted as every live
9570
- // surface publishes it, completed from the runtime's own cause slot, so
9571
- // a thrown value that refused the cause still explains itself after a
9572
- // restart.
9573
- const machineSnapshot = detachPersistedMachineSnapshot(
9574
- actor.getPersistedSnapshot(),
9575
- state.stateId === 'failed'
9576
- ? { lastError: failedStateError(context ?? {}) }
9577
- : {},
9578
- );
9579
- effectLedgerMirror = currentEffectLedger();
9580
- refreshRetainedEffectReconciliation(effectLedgerMirror);
9581
- syncDeferredReconciliationOverlay();
9582
- refreshUnresolvedSemanticReconciliation(effectLedgerMirror);
9583
- const pendingQuestions = !hasUnresolvedReconciliation()
9584
- ? pendingBossQuestionsFor(state, context ?? {})
9585
- : [];
9586
- const failedEffectAttempt =
9587
- hasGovernedPlayerStates &&
9588
- state.stateId === 'failed' &&
9589
- failedAttemptMatchesCurrentLedger(effectLedgerMirror)
9590
- ? {
9591
- boundaryPrefix: failedEffectBoundaryPrefix!,
9592
- attemptId: failedGovernedAttemptId ?? null,
9593
- }
9594
- : undefined;
9595
- return {
9596
- schemaVersion: 4,
9597
- playbookId: session.playbookId,
9598
- machine: machineSnapshot,
9599
- roleResumeTokens: snapshotRoleResumeTokens(),
9600
- sequences: {
9601
- trace: traceSequence,
9602
- turn: turnSequence,
9603
- judgeCall: judgeCallSequence,
9604
- playerCall: playerCallSequence,
9605
- playbookCall: playbookCallSequence,
9606
- ...(declaredActors.has('captain')
9607
- ? { captainCall: captainCallSequence }
9608
- : {}),
9609
- },
9610
- state,
9611
- pendingBossQuestions: pendingQuestions.map((pending) => ({
9612
- questionId: pending.questionId,
9613
- asker: pending.asker,
9614
- question: pending.question,
9615
- sourceItem: pending.sourceItem,
9616
- })),
9617
- effectLedger: effectLedgerMirror,
9618
- ...(retainedEffectSourceSessionId === undefined
9619
- ? {}
9620
- : { retainedEffectSourceSessionId }),
9621
- ...(retainedEffectReconciliation === undefined
9622
- ? {}
9623
- : { retainedEffectReconciliation }),
9624
- ...(failedEffectAttempt === undefined
9625
- ? {}
9626
- : { failedEffectAttempt }),
9627
- ...(suspendedCall === undefined ? {} : { suspendedCall }),
9628
- };
9629
- },
9892
+ exportSnapshot: (checkpoint?: { child?: PlaybookPendingCall }) => exportRuntimeSnapshot(checkpoint?.child ? { child: checkpoint.child } : undefined),
9630
9893
 
9631
9894
  // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
9632
9895
  // exported snapshot under the same immutable session identity.
@@ -9697,6 +9960,31 @@ export function createXStatePlaybookRuntime<
9697
9960
  !hasUnresolvedReconciliation()
9698
9961
  ? stateDescriptionFor(state)
9699
9962
  : undefined;
9963
+ const actions = deriveControlActions(snapshot).map(({ action }) => action);
9964
+ const retry = actions.find(({ id, standing }) =>
9965
+ id.startsWith('retry:') &&
9966
+ (standing ?? 'ready') === 'ready');
9967
+ const stoppedBoundary = recoveryCheckpoint === undefined ? undefined :
9968
+ currentEffectLedger().boundaries.slice(recoveryCheckpoint.boundaryPrefix)
9969
+ .filter((boundary) => runtimeBoundaryIsOwned(boundary) && boundary.sourceStateId === recoveryCheckpoint!.stateId).at(-1);
9970
+ const recovery = recoveryCheckpoint === undefined ||
9971
+ (pendingQuestions.length === 0 && retry === undefined) ? undefined : {
9972
+ prompt: recoveryCheckpoint.prompt,
9973
+ ...(stoppedBoundary === undefined ? {} : { evidence: snapshotJsonValue({
9974
+ declaredResults: stoppedBoundary.sourceOutcomeSchema,
9975
+ ...(stoppedBoundary.finalText === undefined ? {} : { savedPlayerText: stoppedBoundary.finalText }),
9976
+ ...(stoppedBoundary.semanticCandidate === undefined ? {} : { selectedResult: stoppedBoundary.semanticCandidate }),
9977
+ ...(stoppedBoundary.physicalReceipt === undefined ? {} : { repositoryReceipt: stoppedBoundary.physicalReceipt }),
9978
+ }) }),
9979
+ ...(repairableReadOnlyBoundary() === undefined ? {} : {
9980
+ preparation: `This read-only step changed the repository. Restore its exact original HEAD and visible files before retrying; do not accept its saved result. Preserve user work. You may put generated outputs aside and prevent their return using local environment or ignore settings. Original observation: ${JSON.stringify(repairableReadOnlyBoundary()!.baseline)}. Observed after the failed step: ${JSON.stringify(repairableReadOnlyBoundary()!.after)}.`,
9981
+ }),
9982
+ ...(stateDescriptions.get(recoveryCheckpoint.stateId) === undefined ? {} :
9983
+ { description: stateDescriptions.get(recoveryCheckpoint.stateId)! }),
9984
+ continuation: pendingQuestions.length === 0
9985
+ ? { kind: 'runtime' as const, actionId: retry!.id }
9986
+ : { kind: 'reply' as const },
9987
+ };
9700
9988
  return deepFreeze({
9701
9989
  state,
9702
9990
  ...(stateDescription === undefined ? {} : { stateDescription }),
@@ -9710,7 +9998,8 @@ export function createXStatePlaybookRuntime<
9710
9998
  sourceItem: pending.sourceItem,
9711
9999
  })),
9712
10000
  ...(lastError !== undefined ? { lastError } : {}),
9713
- actions: deriveControlActions(snapshot).map(({ action }) => action),
10001
+ actions,
10002
+ ...(recovery === undefined ? {} : { recovery }),
9714
10003
  });
9715
10004
  },
9716
10005
 
@@ -9911,7 +10200,10 @@ export function createXStatePlaybookRuntime<
9911
10200
  accepted = true;
9912
10201
  try {
9913
10202
  let run: PlaybookRunResult;
9914
- if (candidate.unresolvedEffectAction === 'abandon') {
10203
+ if (candidate.retryJudgment !== undefined) {
10204
+ await recoverJudgment(candidate.retryJudgment, signal);
10205
+ run = runResultFor(settledOutcome(signal));
10206
+ } else if (candidate.unresolvedEffectAction === 'abandon') {
9915
10207
  signal.throwIfAborted();
9916
10208
  run = runResultFor('unresolved-effect');
9917
10209
  } else if (
@@ -9934,6 +10226,19 @@ export function createXStatePlaybookRuntime<
9934
10226
  ? 'no-action'
9935
10227
  : 'quiescent',
9936
10228
  );
10229
+ } else if (candidate.retryStep) {
10230
+ if (candidate.restoreReadOnly !== undefined) {
10231
+ await confirmReadOnlyRestoration(candidate.restoreReadOnly, signal);
10232
+ }
10233
+ const checkpoint = recoveryCheckpoint!;
10234
+ requiredRecoveryBaseline = currentEffectLedger().boundaries
10235
+ .slice(checkpoint.boundaryPrefix).find(({ restored }) => restored !== undefined)?.restored;
10236
+ try {
10237
+ await resumeCheckpoint(checkpoint);
10238
+ } finally {
10239
+ requiredRecoveryBaseline = undefined;
10240
+ }
10241
+ run = runResultFor(settledOutcome(signal));
9937
10242
  } else {
9938
10243
  actor.send(candidate.event!);
9939
10244
  await waitForPlaybookQuiescence(actor, {
@@ -9946,6 +10251,7 @@ export function createXStatePlaybookRuntime<
9946
10251
  run.outcome === 'failed' || run.outcome === 'aborted'
9947
10252
  ? {
9948
10253
  disposition: 'failed',
10254
+ run,
9949
10255
  error:
9950
10256
  ('error' in run ? run.error : undefined) ??
9951
10257
  normalizeError(
@@ -10059,9 +10365,11 @@ export function createXStatePlaybookRuntime<
10059
10365
  async handleBossInput({
10060
10366
  text,
10061
10367
  signal,
10368
+ onAccepted,
10062
10369
  }: {
10063
10370
  text: string;
10064
10371
  signal: AbortSignal;
10372
+ onAccepted?: () => void;
10065
10373
  }): Promise<PlaybookRunResult> {
10066
10374
  if (!actor || !savedPorts) {
10067
10375
  throw new Error(
@@ -10207,16 +10515,9 @@ export function createXStatePlaybookRuntime<
10207
10515
  signal,
10208
10516
  turnId,
10209
10517
  statusLine,
10518
+ onAccepted,
10210
10519
  );
10211
10520
  if (continuation === 'continued') {
10212
- try {
10213
- lastBossEvent = snapshotJsonValue(
10214
- event,
10215
- 'recorded Boss event',
10216
- ) as unknown as EventObject;
10217
- } catch {
10218
- lastBossEvent = undefined;
10219
- }
10220
10521
  if (controlPlaneError !== undefined) {
10221
10522
  throw controlPlaneError;
10222
10523
  }
@@ -10226,7 +10527,6 @@ export function createXStatePlaybookRuntime<
10226
10527
  : 'no-action',
10227
10528
  );
10228
10529
  } else {
10229
- lastBossEvent = undefined;
10230
10530
  result = runResultFor('no-action');
10231
10531
  }
10232
10532
  handledDeferred = true;
@@ -10239,7 +10539,6 @@ export function createXStatePlaybookRuntime<
10239
10539
  deferredOperation.operationId,
10240
10540
  signal,
10241
10541
  );
10242
- lastBossEvent = undefined;
10243
10542
  result = runResultFor('no-action');
10244
10543
  handledDeferred = true;
10245
10544
  }
@@ -10268,18 +10567,7 @@ export function createXStatePlaybookRuntime<
10268
10567
  suppressInspectionEmissions = false;
10269
10568
  actor.start();
10270
10569
  }
10271
- // DR-029: keep the classified event with its recorded payload
10272
- // as the retry-replay source. Recording is sanitizing, not
10273
- // load-bearing: an override classifier's non-JSON-safe event is
10274
- // simply not recorded, and the turn proceeds unchanged.
10275
- try {
10276
- lastBossEvent = snapshotJsonValue(
10277
- event,
10278
- 'recorded Boss event',
10279
- ) as unknown as EventObject;
10280
- } catch {
10281
- lastBossEvent = undefined;
10282
- }
10570
+ onAccepted?.();
10283
10571
  actor.send(event);
10284
10572
  await waitForPlaybookQuiescence(actor, {
10285
10573
  pendingCalls: nestedBridge,
@@ -10587,7 +10875,6 @@ export function createXStatePlaybookRuntime<
10587
10875
  failedGovernedAttemptId = undefined;
10588
10876
  controlPlaneError = undefined;
10589
10877
  emissionFailure = undefined;
10590
- lastBossEvent = undefined;
10591
10878
  retainedEffectSourceSessionId = undefined;
10592
10879
  retainedEffectReconciliation = undefined;
10593
10880
  retainedEffectReconciliationRequired = false;