@sublang/playbook 15.1.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.
- package/README.md +2 -0
- package/docs/cli.md +36 -18
- package/docs/configuration.md +7 -5
- package/docs/embedding.md +28 -7
- package/package.json +4 -4
- package/reference/sdlc/branch.playbook/branch.fsm.d.ts +63 -33
- package/reference/sdlc/branch.playbook/branch.fsm.js +190 -108
- package/reference/sdlc/branch.playbook/branch.fsm.ts +269 -147
- package/reference/sdlc/branch.playbook/branch.gears.md +7 -17
- package/reference/sdlc/branch.playbook/branch.playbook.d.ts +12 -16
- package/reference/sdlc/branch.playbook/branch.playbook.js +112 -101
- package/reference/sdlc/branch.playbook/branch.playbook.ts +128 -171
- package/reference/sdlc/branch.playbook/branch.registry.d.ts +3 -2
- package/reference/sdlc/branch.playbook/branch.registry.ts +6 -2
- package/reference/sdlc/captain.md +19 -10
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +6 -2
- package/reference/sdlc/captain.playbook/captain.fsm.js +19 -7
- package/reference/sdlc/captain.playbook/captain.fsm.ts +25 -10
- package/reference/sdlc/captain.playbook/captain.gears.md +16 -8
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +1 -1
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -2
- package/reference/sdlc/captain.playbook/captain.playbook.ts +4 -3
- package/reference/sdlc/code.playbook/bin/interactive-session.js +14 -1
- package/reference/sdlc/code.playbook/bin/portable-codec.js +3 -0
- package/reference/sdlc/code.playbook/bin/repository-effects.js +1 -0
- package/reference/sdlc/code.playbook/bin/run.js +128 -85
- package/reference/sdlc/code.playbook/bin/session-host.js +40 -8
- package/reference/sdlc/code.playbook/bin/session-store.js +162 -34
- package/reference/sdlc/code.playbook/code.fsm.d.ts +146 -93
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +8 -8
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +13 -13
- package/reference/sdlc/code.playbook/code.fsm.js +609 -649
- package/reference/sdlc/code.playbook/code.fsm.ts +890 -821
- package/reference/sdlc/code.playbook/code.gears.md +39 -29
- package/reference/sdlc/code.playbook/code.playbook.d.ts +13 -16
- package/reference/sdlc/code.playbook/code.playbook.js +166 -147
- package/reference/sdlc/code.playbook/code.playbook.ts +180 -229
- package/reference/sdlc/code.playbook/code.registry.d.ts +3 -2
- package/reference/sdlc/code.playbook/code.registry.ts +6 -2
- package/reference/sdlc/code.playbook/host-capabilities.d.ts +3 -0
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +74 -2
- package/reference/sdlc/code.playbook/playbook-captain.js +1548 -468
- package/reference/sdlc/code.playbook/playbook-captain.ts +1993 -573
- package/reference/sdlc/code.playbook/session-host.d.ts +4 -1
- package/reference/sdlc/code.playbook/session-store.d.ts +17 -0
- package/reference/sdlc/code.playbook/session-store.js +1 -0
- package/reference/sdlc/decide.md +1 -0
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +273 -157
- package/reference/sdlc/decide.playbook/decide.fsm.js +765 -732
- package/reference/sdlc/decide.playbook/decide.fsm.ts +1167 -945
- package/reference/sdlc/decide.playbook/decide.gears.md +28 -21
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -59
- package/reference/sdlc/decide.playbook/decide.playbook.js +155 -3696
- package/reference/sdlc/decide.playbook/decide.playbook.ts +186 -5244
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +8 -4
- package/reference/sdlc/decide.playbook/decide.registry.js +2 -2
- package/reference/sdlc/decide.playbook/decide.registry.ts +13 -6
- package/reference/sdlc/dev.playbook/dev.fsm.d.ts +116 -134
- package/reference/sdlc/dev.playbook/dev.fsm.js +517 -575
- package/reference/sdlc/dev.playbook/dev.fsm.ts +667 -787
- package/reference/sdlc/dev.playbook/dev.gears.md +52 -54
- package/reference/sdlc/dev.playbook/dev.playbook.d.ts +13 -10
- package/reference/sdlc/dev.playbook/dev.playbook.js +128 -120
- package/reference/sdlc/dev.playbook/dev.playbook.ts +143 -193
- package/reference/sdlc/dev.playbook/dev.registry.d.ts +3 -2
- package/reference/sdlc/dev.playbook/dev.registry.ts +6 -2
- package/reference/sdlc/pr.playbook/pr.fsm.d.ts +95 -88
- package/reference/sdlc/pr.playbook/pr.fsm.js +461 -518
- package/reference/sdlc/pr.playbook/pr.fsm.ts +533 -663
- package/reference/sdlc/pr.playbook/pr.gears.md +47 -63
- package/reference/sdlc/pr.playbook/pr.playbook.d.ts +13 -23
- package/reference/sdlc/pr.playbook/pr.playbook.js +118 -112
- package/reference/sdlc/pr.playbook/pr.playbook.ts +136 -190
- package/reference/sdlc/pr.playbook/pr.registry.d.ts +3 -2
- package/reference/sdlc/pr.playbook/pr.registry.ts +4 -2
- package/reference/sdlc/review.md +3 -3
- package/reference/sdlc/review.playbook/review.fsm.d.ts +150 -116
- package/reference/sdlc/review.playbook/review.fsm.js +479 -550
- package/reference/sdlc/review.playbook/review.fsm.ts +717 -662
- package/reference/sdlc/review.playbook/review.gears.md +32 -29
- package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -12
- package/reference/sdlc/review.playbook/review.playbook.js +180 -155
- package/reference/sdlc/review.playbook/review.playbook.ts +190 -230
- package/reference/sdlc/review.playbook/review.registry.d.ts +6 -5
- package/reference/sdlc/review.playbook/review.registry.js +3 -3
- package/reference/sdlc/review.playbook/review.registry.ts +9 -5
- package/slc/gears2fsm.md +77 -8
- package/slc/link.md +127 -60
- package/slc/materialize-link.mjs +51 -13
- package/slc/optimize.md +4 -1
- package/slc/prefix-prompts.mjs +29 -28
- package/slc/prefix.md +27 -24
- package/slc/slc.pin-inputs.json +11 -2
- package/slc/text2gears.md +71 -11
- package/src/runtime.d.ts +46 -2
- package/src/runtime.ts +42 -2
- package/src/xstate-playbook-runtime.d.ts +63 -23
- package/src/xstate-playbook-runtime.js +1637 -475
- package/src/xstate-playbook-runtime.ts +2105 -532
- package/src/xstate-runtime.d.ts +3 -4
- package/src/xstate-runtime.js +52 -0
- 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,
|
|
@@ -158,7 +160,7 @@ export interface RuntimeBoundaryCalls {
|
|
|
158
160
|
* with the cause the boundary decided at the call itself, where the role,
|
|
159
161
|
* the resolved player, and the reported error are known.
|
|
160
162
|
*/
|
|
161
|
-
markPlayerResultFailure?(error: Error): Error;
|
|
163
|
+
markPlayerResultFailure?(error: Error, result?: PlayerResult): Error;
|
|
162
164
|
callJudge(
|
|
163
165
|
purpose: JudgePurpose,
|
|
164
166
|
stateId: string | undefined,
|
|
@@ -339,8 +341,8 @@ export function playerFailureCause(input: {
|
|
|
339
341
|
/**
|
|
340
342
|
* DR-063 §1: the cause of an unresolved governed settlement. A reconciled
|
|
341
343
|
* mismatch supplies its own receipt-read cause; the reasons a runtime owns map
|
|
342
|
-
* to adjudication, abort, and runtime-defect codes.
|
|
343
|
-
*
|
|
344
|
+
* to adjudication, abort, and runtime-defect codes. Exported so linked
|
|
345
|
+
* machinery of an artifact's own decides the same reasons.
|
|
344
346
|
*/
|
|
345
347
|
export function governedSettlementCause(
|
|
346
348
|
reason: string,
|
|
@@ -365,11 +367,12 @@ export function governedSettlementCause(
|
|
|
365
367
|
|
|
366
368
|
/**
|
|
367
369
|
* DR-063 §2: the causes decided for thrown values that cannot carry the
|
|
368
|
-
* marker — a string, a frozen error —
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
370
|
+
* marker — a string, a frozen error — and for the record a machine keeps in
|
|
371
|
+
* place of the thrown value, kept by identity for as long as the failure
|
|
372
|
+
* stands, so every surface that reads the FSM's own `lastError` publishes the
|
|
373
|
+
* cause decided for exactly that failure, whatever turn reads it. A value
|
|
374
|
+
* that carries the marker answers from it; the map is bounded, since only
|
|
375
|
+
* values that lack the marker need an entry.
|
|
373
376
|
*/
|
|
374
377
|
export interface PlaybookFailureCauseRetention {
|
|
375
378
|
retain(error: unknown, cause: PlaybookFailureCause): void;
|
|
@@ -396,6 +399,23 @@ export function createFailureCauseRetention(): PlaybookFailureCauseRetention {
|
|
|
396
399
|
};
|
|
397
400
|
}
|
|
398
401
|
|
|
402
|
+
/**
|
|
403
|
+
* DR-063 §2: the value an invoked actor's rejection carried into the machine,
|
|
404
|
+
* read from the XState error event that delivered it, or `undefined` for any
|
|
405
|
+
* other event.
|
|
406
|
+
*/
|
|
407
|
+
function actorErrorOf(event: unknown): unknown {
|
|
408
|
+
if (typeof event !== 'object' || event === null) return undefined;
|
|
409
|
+
try {
|
|
410
|
+
const { type, error } = event as { type?: unknown; error?: unknown };
|
|
411
|
+
return typeof type === 'string' && type.startsWith('xstate.error.actor.')
|
|
412
|
+
? error
|
|
413
|
+
: undefined;
|
|
414
|
+
} catch {
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
399
419
|
|
|
400
420
|
// ---------------------------------------------------------------------------
|
|
401
421
|
// DR-028: both call boundaries treat an `ok` result whose `finalText` is
|
|
@@ -508,7 +528,29 @@ type XStateEffectBoundarySeed = Omit<
|
|
|
508
528
|
'playbookId' | 'canonicalWorktree' | 'baseline' | 'cohortId'
|
|
509
529
|
>;
|
|
510
530
|
|
|
531
|
+
/** One member's completion inside a repository cohort (DR-067 §1). */
|
|
532
|
+
type XStateRepositoryCohortCompletion<T> =
|
|
533
|
+
XStateRepositoryExclusiveCompletion<T> & { readonly roleId: string };
|
|
534
|
+
|
|
535
|
+
interface XStateRepositoryCohortResult<T> {
|
|
536
|
+
readonly baseline: PlaybookRepositoryReceipt['baseline'];
|
|
537
|
+
readonly invocationId: string;
|
|
538
|
+
readonly operations: Readonly<
|
|
539
|
+
Record<
|
|
540
|
+
string,
|
|
541
|
+
XStateRepositoryOperationSettlement<T> | XStateRepositoryOperationRejection
|
|
542
|
+
>
|
|
543
|
+
>;
|
|
544
|
+
readonly receipts: Readonly<Record<string, PlaybookRepositoryReceipt>>;
|
|
545
|
+
readonly effectLedger: PlaybookEffectLedger;
|
|
546
|
+
}
|
|
547
|
+
|
|
511
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
|
+
}>;
|
|
512
554
|
runExclusive<T>(options: {
|
|
513
555
|
readonly signal: AbortSignal;
|
|
514
556
|
readonly effectBoundary: XStateEffectBoundarySeed;
|
|
@@ -542,6 +584,34 @@ export interface XStateRepositoryCapability {
|
|
|
542
584
|
readonly signal: AbortSignal;
|
|
543
585
|
readonly operationId: string;
|
|
544
586
|
}): Promise<XStateRepositoryDeferredParked | XStateRepositoryDeferredRestoreResult>;
|
|
587
|
+
/**
|
|
588
|
+
* DR-067 §1 / PBRT-73: one repository claim over the working leaves of a
|
|
589
|
+
* parallel state entered together — every member an `unchanged` boundary
|
|
590
|
+
* of one cohort, the operations run concurrently under that claim, and
|
|
591
|
+
* one completion callback per member. Required for an artifact that
|
|
592
|
+
* declares a parallel state.
|
|
593
|
+
*/
|
|
594
|
+
runCohort?<T>(options: {
|
|
595
|
+
readonly signal: AbortSignal;
|
|
596
|
+
readonly invocationId: string;
|
|
597
|
+
readonly roleIds: readonly string[];
|
|
598
|
+
readonly dispositionsByRole: Readonly<Record<string, readonly ['unchanged']>>;
|
|
599
|
+
readonly effectBoundaries: Readonly<Record<string, XStateEffectBoundarySeed>>;
|
|
600
|
+
readonly operations: Readonly<
|
|
601
|
+
Record<
|
|
602
|
+
string,
|
|
603
|
+
(context: {
|
|
604
|
+
readonly baseline: PlaybookRepositoryReceipt['baseline'];
|
|
605
|
+
readonly identity: unknown;
|
|
606
|
+
readonly invocationId: string;
|
|
607
|
+
readonly roleId: string;
|
|
608
|
+
}) => Promise<T>
|
|
609
|
+
>
|
|
610
|
+
>;
|
|
611
|
+
readonly completeEffectBoundary: (
|
|
612
|
+
completion: XStateRepositoryCohortCompletion<T>,
|
|
613
|
+
) => XStateRepositoryCompletionEvidence | Promise<XStateRepositoryCompletionEvidence>;
|
|
614
|
+
}): Promise<XStateRepositoryCohortResult<T>>;
|
|
545
615
|
}
|
|
546
616
|
|
|
547
617
|
function assertNoConfiguredHostCapabilities(value: unknown, label: string): void {
|
|
@@ -643,6 +713,7 @@ function configuredOptionsFromFactoryInput(
|
|
|
643
713
|
function repositoryCapabilityFromHostCapabilities(
|
|
644
714
|
hostCapabilities: object | undefined,
|
|
645
715
|
label: string,
|
|
716
|
+
requireCohort = false,
|
|
646
717
|
): XStateRepositoryCapability {
|
|
647
718
|
const descriptor =
|
|
648
719
|
hostCapabilities === undefined
|
|
@@ -659,10 +730,15 @@ function repositoryCapabilityFromHostCapabilities(
|
|
|
659
730
|
Array.isArray(repository) ||
|
|
660
731
|
typeof (repository as { runExclusive?: unknown }).runExclusive !==
|
|
661
732
|
'function' ||
|
|
662
|
-
typeof (repository as { runDeferred?: unknown }).runDeferred !==
|
|
733
|
+
typeof (repository as { runDeferred?: unknown }).runDeferred !==
|
|
734
|
+
'function' ||
|
|
735
|
+
(requireCohort &&
|
|
736
|
+
typeof (repository as { runCohort?: unknown }).runCohort !== 'function')
|
|
663
737
|
) {
|
|
664
738
|
throw new TypeError(
|
|
665
|
-
|
|
739
|
+
requireCohort
|
|
740
|
+
? `${label} schema-3 factory input hostCapabilities.repository must be an own data property exposing runExclusive, runCohort, and runDeferred`
|
|
741
|
+
: `${label} schema-3 factory input hostCapabilities.repository must be an own data property exposing runExclusive and runDeferred`,
|
|
666
742
|
);
|
|
667
743
|
}
|
|
668
744
|
return repository as XStateRepositoryCapability;
|
|
@@ -881,14 +957,7 @@ interface XStatePlaybookRuntimeSpecBase<TOptions> {
|
|
|
881
957
|
entryEvent?: {
|
|
882
958
|
type: string;
|
|
883
959
|
textField: string;
|
|
884
|
-
/**
|
|
885
|
-
* DR-034: the FSM context member this machine's entry action copies the
|
|
886
|
-
* exact Boss text into. Where it is named, the failure-state retry
|
|
887
|
-
* builds its payload from that member of the live snapshot instead of
|
|
888
|
-
* from the process-local recorded event, so the action derives the same
|
|
889
|
-
* before and after `restore`. Absent: the recorded event stays the
|
|
890
|
-
* source and the action lives only as long as the process.
|
|
891
|
-
*/
|
|
960
|
+
/** @deprecated Step checkpoints now carry the accepted input directly. */
|
|
892
961
|
contextField?: string;
|
|
893
962
|
};
|
|
894
963
|
/**
|
|
@@ -1118,7 +1187,7 @@ export function normalizeErrorFull(
|
|
|
1118
1187
|
// slc/link.md §Abort: cancellation is causal identity with the applicable
|
|
1119
1188
|
// signal's reason — never an `AbortError` name, never bare signal state. A
|
|
1120
1189
|
// distinct failure observed while the signal is aborted stays a non-abort
|
|
1121
|
-
// control error and takes precedence
|
|
1190
|
+
// control error and takes precedence.
|
|
1122
1191
|
function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
|
|
1123
1192
|
return signal.aborted && Object.is(error, signal.reason);
|
|
1124
1193
|
}
|
|
@@ -1169,7 +1238,38 @@ function pendingBossQuestionForState(
|
|
|
1169
1238
|
export function pendingBossQuestionFromContext(
|
|
1170
1239
|
context: Record<string, unknown>,
|
|
1171
1240
|
): PlaybookPendingBossQuestionContext | undefined {
|
|
1172
|
-
|
|
1241
|
+
return pendingBossQuestionRecord(context.pendingBossQuestion);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* DR-067 §1: the keyed form of gears2fsm's Boss-reply suspension. Each
|
|
1246
|
+
* well-formed `context.pendingBossQuestions[stateId]` record whose own
|
|
1247
|
+
* `questionId` equals its key counts only while a wait state that resumes
|
|
1248
|
+
* its `resumeStateId` is active, so an answered question the context
|
|
1249
|
+
* retains for the resumed prompt never reads as pending. Ordered by
|
|
1250
|
+
* question id.
|
|
1251
|
+
*/
|
|
1252
|
+
function keyedPendingBossQuestions(
|
|
1253
|
+
context: Record<string, unknown>,
|
|
1254
|
+
resumesFromActiveWait: (resumeStateId: string) => boolean,
|
|
1255
|
+
): PlaybookPendingBossQuestionContext[] {
|
|
1256
|
+
const keyed = context.pendingBossQuestions;
|
|
1257
|
+
if (!isPlainObject(keyed)) return [];
|
|
1258
|
+
const pending: PlaybookPendingBossQuestionContext[] = [];
|
|
1259
|
+
for (const [key, value] of Object.entries(keyed)) {
|
|
1260
|
+
const record = pendingBossQuestionRecord(value);
|
|
1261
|
+
if (record === undefined || record.questionId !== key) continue;
|
|
1262
|
+
if (!resumesFromActiveWait(record.resumeStateId)) continue;
|
|
1263
|
+
pending.push(record);
|
|
1264
|
+
}
|
|
1265
|
+
return pending.sort((left, right) =>
|
|
1266
|
+
left.questionId.localeCompare(right.questionId),
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function pendingBossQuestionRecord(
|
|
1271
|
+
pending: unknown,
|
|
1272
|
+
): PlaybookPendingBossQuestionContext | undefined {
|
|
1173
1273
|
if (
|
|
1174
1274
|
pending === undefined ||
|
|
1175
1275
|
pending === null ||
|
|
@@ -1647,8 +1747,8 @@ export function defaultBuildJudgePrompt(
|
|
|
1647
1747
|
* so the judge omits it. Rendering the clause verbatim asked the judge for
|
|
1648
1748
|
* `question`, `planningResult`, or `evaluatedRevision`, which the reconciler
|
|
1649
1749
|
* rejects as a structural error, spending the single correction on a
|
|
1650
|
-
* self-inflicted defect. Exported so
|
|
1651
|
-
*
|
|
1750
|
+
* self-inflicted defect. Exported so linked machinery of an artifact's own
|
|
1751
|
+
* renders the identical contract instead of restating it.
|
|
1652
1752
|
*/
|
|
1653
1753
|
export function renderGovernedOutcomeContract(
|
|
1654
1754
|
guard: string,
|
|
@@ -1731,6 +1831,7 @@ function buildGovernedJudgePrompt(
|
|
|
1731
1831
|
'```',
|
|
1732
1832
|
'',
|
|
1733
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.',
|
|
1734
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.',
|
|
1735
1836
|
'',
|
|
1736
1837
|
];
|
|
@@ -1879,6 +1980,7 @@ interface PlayerBridgeSpec {
|
|
|
1879
1980
|
adjudication: PlayerAdjudicationSpec;
|
|
1880
1981
|
resumableStateIds: ReadonlySet<string>;
|
|
1881
1982
|
allowsCorrectiveReplay?: (result: PlayerResult) => boolean;
|
|
1983
|
+
run?: (input: PlaybookPlayerInput, signal: AbortSignal, execute: () => Promise<PlaybookActorOutput>) => Promise<PlaybookActorOutput>;
|
|
1882
1984
|
}
|
|
1883
1985
|
|
|
1884
1986
|
export function createPlayerBridge(
|
|
@@ -1888,8 +1990,7 @@ export function createPlayerBridge(
|
|
|
1888
1990
|
boundary?: RuntimeBoundaryCalls,
|
|
1889
1991
|
onControlPlaneError?: (error: unknown) => void,
|
|
1890
1992
|
): PromiseActorLogic<PlaybookActorOutput, PlaybookPlayerInput> {
|
|
1891
|
-
|
|
1892
|
-
async ({ input, signal }) => {
|
|
1993
|
+
const execute = async ({ input, signal }: { input: PlaybookPlayerInput; signal: AbortSignal }): Promise<PlaybookActorOutput> => {
|
|
1893
1994
|
const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
|
|
1894
1995
|
let roleId: string;
|
|
1895
1996
|
let prompt: string;
|
|
@@ -1943,7 +2044,7 @@ export function createPlayerBridge(
|
|
|
1943
2044
|
const failure = new Error(
|
|
1944
2045
|
result.error ?? `captainBridge: callPlayer status "${result.status}"`,
|
|
1945
2046
|
);
|
|
1946
|
-
throw boundary?.markPlayerResultFailure?.(failure) ?? failure;
|
|
2047
|
+
throw boundary?.markPlayerResultFailure?.(failure, result) ?? failure;
|
|
1947
2048
|
}
|
|
1948
2049
|
const finalText = result.finalText ?? '';
|
|
1949
2050
|
if (isEmptyFinalText(finalText)) {
|
|
@@ -1979,8 +2080,9 @@ export function createPlayerBridge(
|
|
|
1979
2080
|
}
|
|
1980
2081
|
throw error;
|
|
1981
2082
|
}
|
|
1982
|
-
}
|
|
1983
|
-
)
|
|
2083
|
+
};
|
|
2084
|
+
return fromPromise<PlaybookActorOutput, PlaybookPlayerInput>((args) => spec.run
|
|
2085
|
+
? spec.run(args.input, args.signal, () => execute(args)) : execute(args));
|
|
1984
2086
|
}
|
|
1985
2087
|
|
|
1986
2088
|
// ---------------------------------------------------------------------------
|
|
@@ -2170,7 +2272,81 @@ function transitionTargets(transition: unknown): string[] {
|
|
|
2170
2272
|
return targets;
|
|
2171
2273
|
}
|
|
2172
2274
|
|
|
2173
|
-
|
|
2275
|
+
function transitionArmTargets(transition: unknown): string[] {
|
|
2276
|
+
const arms = Array.isArray(transition) ? transition : [transition];
|
|
2277
|
+
const targets: string[] = [];
|
|
2278
|
+
for (const arm of arms) {
|
|
2279
|
+
if (typeof arm === 'string') {
|
|
2280
|
+
targets.push(arm);
|
|
2281
|
+
} else if (isPlainObject(arm) && typeof arm.target === 'string') {
|
|
2282
|
+
targets.push(arm.target);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
return targets;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
function playbookStateIdOf(stateDef: unknown): string | undefined {
|
|
2289
|
+
if (!isPlainObject(stateDef) || !isPlainObject(stateDef.meta)) {
|
|
2290
|
+
return undefined;
|
|
2291
|
+
}
|
|
2292
|
+
const playbook = stateDef.meta.playbook;
|
|
2293
|
+
return isPlainObject(playbook) && typeof playbook.stateId === 'string'
|
|
2294
|
+
? playbook.stateId
|
|
2295
|
+
: undefined;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
function stateTagsOf(stateDef: Record<string, unknown>): readonly string[] {
|
|
2299
|
+
const tags = stateDef.tags;
|
|
2300
|
+
if (typeof tags === 'string') return [tags];
|
|
2301
|
+
return Array.isArray(tags)
|
|
2302
|
+
? tags.filter((tag): tag is string => typeof tag === 'string')
|
|
2303
|
+
: [];
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/** Every explicit node `id` in the config, mapped to its playbook state id. */
|
|
2307
|
+
function playbookStateIdsByNodeId(config: unknown): ReadonlyMap<string, string> {
|
|
2308
|
+
const byNodeId = new Map<string, string>();
|
|
2309
|
+
const visit = (stateDef: unknown): void => {
|
|
2310
|
+
if (!isPlainObject(stateDef)) return;
|
|
2311
|
+
const stateId = playbookStateIdOf(stateDef);
|
|
2312
|
+
if (typeof stateDef.id === 'string' && stateId !== undefined) {
|
|
2313
|
+
byNodeId.set(stateDef.id, stateId);
|
|
2314
|
+
}
|
|
2315
|
+
if (isPlainObject(stateDef.states)) {
|
|
2316
|
+
for (const child of Object.values(stateDef.states)) visit(child);
|
|
2317
|
+
}
|
|
2318
|
+
};
|
|
2319
|
+
if (isPlainObject(config) && isPlainObject(config.states)) {
|
|
2320
|
+
for (const child of Object.values(config.states)) visit(child);
|
|
2321
|
+
}
|
|
2322
|
+
return byNodeId;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/**
|
|
2326
|
+
* One transition target written inside a parallel region, resolved to the
|
|
2327
|
+
* playbook state id it names: an `#id` reference through the node ids, and a
|
|
2328
|
+
* bare sibling key through the region's own leaves.
|
|
2329
|
+
*/
|
|
2330
|
+
function resolveRegionTarget(
|
|
2331
|
+
target: string,
|
|
2332
|
+
regionStates: Record<string, unknown>,
|
|
2333
|
+
byNodeId: ReadonlyMap<string, string>,
|
|
2334
|
+
): string {
|
|
2335
|
+
if (target.startsWith('#')) {
|
|
2336
|
+
const nodeId = target.slice(1);
|
|
2337
|
+
return byNodeId.get(nodeId) ?? nodeId;
|
|
2338
|
+
}
|
|
2339
|
+
if (Object.prototype.hasOwnProperty.call(regionStates, target)) {
|
|
2340
|
+
return playbookStateIdOf(regionStates[target]) ?? target;
|
|
2341
|
+
}
|
|
2342
|
+
return target;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
/**
|
|
2346
|
+
* Targets of the FSM's `awaitBossReply` BOSS_REPLY transitions, plus — for
|
|
2347
|
+
* the parallel shape gears2fsm compiles (DR-067) — the targets of each
|
|
2348
|
+
* region wait leaf's branch-local BOSS_REPLY arms, resolved to state ids.
|
|
2349
|
+
*/
|
|
2174
2350
|
export function resumableStateIdsFromMachine(
|
|
2175
2351
|
machine: AnyStateMachine,
|
|
2176
2352
|
): ReadonlySet<string> {
|
|
@@ -2178,13 +2354,321 @@ export function resumableStateIdsFromMachine(
|
|
|
2178
2354
|
if (!isPlainObject(config) || !isPlainObject(config.states)) {
|
|
2179
2355
|
return new Set();
|
|
2180
2356
|
}
|
|
2357
|
+
const resumable = new Set<string>();
|
|
2181
2358
|
const awaitState = config.states[BOSS_REPLY_WAIT_STATE_ID];
|
|
2182
|
-
if (
|
|
2183
|
-
|
|
2359
|
+
if (isPlainObject(awaitState) && isPlainObject(awaitState.on)) {
|
|
2360
|
+
const bossReply = awaitState.on.BOSS_REPLY;
|
|
2361
|
+
if (bossReply !== undefined) {
|
|
2362
|
+
for (const target of transitionTargets(bossReply)) resumable.add(target);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
const byNodeId = playbookStateIdsByNodeId(config);
|
|
2366
|
+
for (const stateDef of Object.values(config.states)) {
|
|
2367
|
+
if (
|
|
2368
|
+
!isPlainObject(stateDef) ||
|
|
2369
|
+
stateDef.type !== 'parallel' ||
|
|
2370
|
+
!isPlainObject(stateDef.states)
|
|
2371
|
+
) {
|
|
2372
|
+
continue;
|
|
2373
|
+
}
|
|
2374
|
+
for (const region of Object.values(stateDef.states)) {
|
|
2375
|
+
if (!isPlainObject(region) || !isPlainObject(region.states)) continue;
|
|
2376
|
+
const regionStates = region.states;
|
|
2377
|
+
for (const leaf of Object.values(regionStates)) {
|
|
2378
|
+
if (!isPlainObject(leaf) || !isPlainObject(leaf.on)) continue;
|
|
2379
|
+
const bossReply = leaf.on.BOSS_REPLY;
|
|
2380
|
+
if (bossReply === undefined) continue;
|
|
2381
|
+
for (const target of transitionArmTargets(bossReply)) {
|
|
2382
|
+
resumable.add(resolveRegionTarget(target, regionStates, byNodeId));
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
return resumable;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
/** One region of a compiled parallel state (DR-067 §1). */
|
|
2391
|
+
interface XStateParallelRegion {
|
|
2392
|
+
readonly key: string;
|
|
2393
|
+
readonly roleId: string;
|
|
2394
|
+
readonly workingStateId: string;
|
|
2395
|
+
readonly waitStateId: string;
|
|
2396
|
+
readonly finalStateId: string;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
/** One root `type: 'parallel'` state of the compiled shape. */
|
|
2400
|
+
interface XStateParallelState {
|
|
2401
|
+
readonly stateId: string;
|
|
2402
|
+
readonly regions: readonly XStateParallelRegion[];
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
/**
|
|
2406
|
+
* The parallel profile derived from a machine that declares a parallel
|
|
2407
|
+
* state (DR-067 §1): its parallel states, each wait state's resume targets
|
|
2408
|
+
* — region waits and the root reply wait alike — and, per state id, the
|
|
2409
|
+
* config nodes whose `on` maps an active state contributes.
|
|
2410
|
+
*/
|
|
2411
|
+
interface XStateParallelProfile {
|
|
2412
|
+
readonly states: readonly XStateParallelState[];
|
|
2413
|
+
readonly parallelStateByWorkingStateId: ReadonlyMap<
|
|
2414
|
+
string,
|
|
2415
|
+
XStateParallelState
|
|
2416
|
+
>;
|
|
2417
|
+
readonly waitResumeTargets: ReadonlyMap<string, ReadonlySet<string>>;
|
|
2418
|
+
readonly transitionNodesByStateId: ReadonlyMap<
|
|
2419
|
+
string,
|
|
2420
|
+
readonly Record<string, unknown>[]
|
|
2421
|
+
>;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/** One registered cohort member awaiting its cohort's settlement. */
|
|
2425
|
+
interface XStateCohortMember {
|
|
2426
|
+
readonly region: XStateParallelRegion;
|
|
2427
|
+
readonly input: PlaybookPlayerInput;
|
|
2428
|
+
readonly roleId: string;
|
|
2429
|
+
readonly playerId: string | undefined;
|
|
2430
|
+
readonly playerKey: string;
|
|
2431
|
+
readonly signal: AbortSignal;
|
|
2432
|
+
readonly effectBoundary: XStateEffectBoundarySeed;
|
|
2433
|
+
readonly run: (
|
|
2434
|
+
callSignal: AbortSignal,
|
|
2435
|
+
cohortCancellation: AbortSignal,
|
|
2436
|
+
preExistingBlock: string | undefined,
|
|
2437
|
+
) => Promise<PlayerResult>;
|
|
2438
|
+
readonly reportCollision: (callSignal: AbortSignal) => Promise<never>;
|
|
2439
|
+
readonly settled: DeferredValue<PlayerResult>;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
/** The cohort of one parallel state within one public boundary. */
|
|
2443
|
+
interface PendingCohort {
|
|
2444
|
+
readonly boundarySequence: number;
|
|
2445
|
+
readonly members: Map<string, XStateCohortMember>;
|
|
2446
|
+
started: boolean;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
function malformedParallelState(
|
|
2450
|
+
label: string,
|
|
2451
|
+
key: string,
|
|
2452
|
+
reason: string,
|
|
2453
|
+
): Error {
|
|
2454
|
+
return new Error(
|
|
2455
|
+
`${label} parallel state ${key} is not the compiled parallel shape: ${reason}`,
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
/**
|
|
2460
|
+
* DR-067 §1 / PBRT-52: the factory's domain is flat root states plus root
|
|
2461
|
+
* `type: 'parallel'` states of the one shape gears2fsm compiles — each
|
|
2462
|
+
* region a compound state with exactly one player-invoking `playbook.busy`
|
|
2463
|
+
* working leaf, exactly one `playbook.parked` wait leaf whose BOSS_REPLY
|
|
2464
|
+
* arms resume that working leaf, and exactly one final leaf, every leaf
|
|
2465
|
+
* carrying a stable `meta.playbook.stateId` distinct from every other
|
|
2466
|
+
* state's, and the parent's `onDone` the join. Any other compound state
|
|
2467
|
+
* keeps the flat-domain rejection. Returns `undefined` for a flat machine.
|
|
2468
|
+
*/
|
|
2469
|
+
function parallelProfileFromMachine(
|
|
2470
|
+
machine: AnyStateMachine,
|
|
2471
|
+
label: string,
|
|
2472
|
+
): XStateParallelProfile | undefined {
|
|
2473
|
+
const config = (machine as unknown as { config?: unknown }).config;
|
|
2474
|
+
if (!isPlainObject(config) || !isPlainObject(config.states)) {
|
|
2475
|
+
return undefined;
|
|
2476
|
+
}
|
|
2477
|
+
const rootStates = config.states;
|
|
2478
|
+
const parallelKeys = Object.entries(rootStates)
|
|
2479
|
+
.filter(
|
|
2480
|
+
([, stateDef]) => isPlainObject(stateDef) && stateDef.type === 'parallel',
|
|
2481
|
+
)
|
|
2482
|
+
.map(([key]) => key);
|
|
2483
|
+
for (const stateDef of Object.values(rootStates)) {
|
|
2484
|
+
if (
|
|
2485
|
+
isPlainObject(stateDef) &&
|
|
2486
|
+
stateDef.type !== 'parallel' &&
|
|
2487
|
+
isPlainObject(stateDef.states) &&
|
|
2488
|
+
Object.keys(stateDef.states).length > 0
|
|
2489
|
+
) {
|
|
2490
|
+
throw new Error(
|
|
2491
|
+
`${label} declares a compound state; the shared runtime supports only flat single-region FSMs`,
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
if (parallelKeys.length === 0) return undefined;
|
|
2496
|
+
|
|
2497
|
+
const byNodeId = playbookStateIdsByNodeId(config);
|
|
2498
|
+
const seenStateIds = new Set<string>();
|
|
2499
|
+
for (const stateDef of Object.values(rootStates)) {
|
|
2500
|
+
const stateId = playbookStateIdOf(stateDef);
|
|
2501
|
+
if (stateId !== undefined) seenStateIds.add(stateId);
|
|
2502
|
+
}
|
|
2503
|
+
const states: XStateParallelState[] = [];
|
|
2504
|
+
const parallelStateByWorkingStateId = new Map<string, XStateParallelState>();
|
|
2505
|
+
const waitResumeTargets = new Map<string, ReadonlySet<string>>();
|
|
2506
|
+
const transitionNodesByStateId = new Map<
|
|
2507
|
+
string,
|
|
2508
|
+
readonly Record<string, unknown>[]
|
|
2509
|
+
>();
|
|
2510
|
+
for (const key of parallelKeys) {
|
|
2511
|
+
const parent = rootStates[key] as Record<string, unknown>;
|
|
2512
|
+
if (parent.onDone === undefined) {
|
|
2513
|
+
throw malformedParallelState(label, key, 'it declares no onDone join');
|
|
2514
|
+
}
|
|
2515
|
+
if (!isPlainObject(parent.states) || Object.keys(parent.states).length < 2) {
|
|
2516
|
+
throw malformedParallelState(
|
|
2517
|
+
label,
|
|
2518
|
+
key,
|
|
2519
|
+
'it must declare at least two regions',
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
2522
|
+
const regions: XStateParallelRegion[] = [];
|
|
2523
|
+
const roleIds = new Set<string>();
|
|
2524
|
+
for (const [regionKey, region] of Object.entries(parent.states)) {
|
|
2525
|
+
const regionReason = (reason: string) =>
|
|
2526
|
+
malformedParallelState(label, key, `region ${regionKey} ${reason}`);
|
|
2527
|
+
if (
|
|
2528
|
+
!isPlainObject(region) ||
|
|
2529
|
+
region.type === 'parallel' ||
|
|
2530
|
+
region.type === 'final' ||
|
|
2531
|
+
region.type === 'history' ||
|
|
2532
|
+
!isPlainObject(region.states)
|
|
2533
|
+
) {
|
|
2534
|
+
throw regionReason('must be a compound state of leaves');
|
|
2535
|
+
}
|
|
2536
|
+
const regionStates = region.states;
|
|
2537
|
+
let working: [string, Record<string, unknown>] | undefined;
|
|
2538
|
+
let wait: [string, Record<string, unknown>] | undefined;
|
|
2539
|
+
let final: [string, Record<string, unknown>] | undefined;
|
|
2540
|
+
for (const [leafKey, leaf] of Object.entries(regionStates)) {
|
|
2541
|
+
if (!isPlainObject(leaf)) {
|
|
2542
|
+
throw regionReason(`leaf ${leafKey} must be a state object`);
|
|
2543
|
+
}
|
|
2544
|
+
if (
|
|
2545
|
+
(isPlainObject(leaf.states) && Object.keys(leaf.states).length > 0) ||
|
|
2546
|
+
leaf.type === 'parallel' ||
|
|
2547
|
+
leaf.type === 'history'
|
|
2548
|
+
) {
|
|
2549
|
+
throw regionReason(`leaf ${leafKey} must be an atomic state`);
|
|
2550
|
+
}
|
|
2551
|
+
const leafStateId = playbookStateIdOf(leaf);
|
|
2552
|
+
if (leafStateId === undefined || leafStateId.trim().length === 0) {
|
|
2553
|
+
throw regionReason(
|
|
2554
|
+
`leaf ${leafKey} must carry a string meta.playbook.stateId`,
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
if (seenStateIds.has(leafStateId)) {
|
|
2558
|
+
throw regionReason(
|
|
2559
|
+
`leaf ${leafKey} reuses playbook state id ${leafStateId}`,
|
|
2560
|
+
);
|
|
2561
|
+
}
|
|
2562
|
+
seenStateIds.add(leafStateId);
|
|
2563
|
+
const invoke = leaf.invoke;
|
|
2564
|
+
const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : [];
|
|
2565
|
+
const tags = stateTagsOf(leaf);
|
|
2566
|
+
if (leaf.type === 'final') {
|
|
2567
|
+
if (final !== undefined) throw regionReason('declares two final leaves');
|
|
2568
|
+
final = [leafKey, leaf];
|
|
2569
|
+
} else if (
|
|
2570
|
+
invokes.length === 1 &&
|
|
2571
|
+
isPlainObject(invokes[0]) &&
|
|
2572
|
+
invokes[0].src === 'player' &&
|
|
2573
|
+
tags.includes('playbook.busy')
|
|
2574
|
+
) {
|
|
2575
|
+
if (working !== undefined) {
|
|
2576
|
+
throw regionReason('declares two working leaves');
|
|
2577
|
+
}
|
|
2578
|
+
working = [leafKey, leaf];
|
|
2579
|
+
} else if (
|
|
2580
|
+
invokes.length === 0 &&
|
|
2581
|
+
tags.includes('playbook.parked') &&
|
|
2582
|
+
isPlainObject(leaf.on) &&
|
|
2583
|
+
leaf.on.BOSS_REPLY !== undefined
|
|
2584
|
+
) {
|
|
2585
|
+
if (wait !== undefined) throw regionReason('declares two wait leaves');
|
|
2586
|
+
wait = [leafKey, leaf];
|
|
2587
|
+
} else {
|
|
2588
|
+
throw regionReason(
|
|
2589
|
+
`leaf ${leafKey} is neither its player-invoking playbook.busy working leaf, its playbook.parked BOSS_REPLY wait leaf, nor its final leaf`,
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
if (working === undefined || wait === undefined || final === undefined) {
|
|
2594
|
+
throw regionReason(
|
|
2595
|
+
'must hold exactly one working leaf, one wait leaf, and one final leaf',
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
if (region.initial !== working[0]) {
|
|
2599
|
+
throw regionReason('must start in its working leaf');
|
|
2600
|
+
}
|
|
2601
|
+
const workingStateId = playbookStateIdOf(working[1])!;
|
|
2602
|
+
const waitStateId = playbookStateIdOf(wait[1])!;
|
|
2603
|
+
const finalStateId = playbookStateIdOf(final[1])!;
|
|
2604
|
+
const replyTargets = transitionArmTargets(
|
|
2605
|
+
(wait[1].on as Record<string, unknown>).BOSS_REPLY,
|
|
2606
|
+
).map((target) => resolveRegionTarget(target, regionStates, byNodeId));
|
|
2607
|
+
const regionStateIds = new Set([waitStateId, finalStateId]);
|
|
2608
|
+
if (
|
|
2609
|
+
!replyTargets.includes(workingStateId) ||
|
|
2610
|
+
replyTargets.some((target) => regionStateIds.has(target))
|
|
2611
|
+
) {
|
|
2612
|
+
throw regionReason(
|
|
2613
|
+
'wait leaf BOSS_REPLY arms must resume its working leaf',
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
const workingMeta = (working[1].meta as Record<string, unknown>)
|
|
2617
|
+
.playbook as Record<string, unknown>;
|
|
2618
|
+
const roleId = workingMeta.role;
|
|
2619
|
+
if (typeof roleId !== 'string' || roleId.trim().length === 0) {
|
|
2620
|
+
throw regionReason('working leaf must declare meta.playbook.role');
|
|
2621
|
+
}
|
|
2622
|
+
if (roleIds.has(roleId)) {
|
|
2623
|
+
throw malformedParallelState(
|
|
2624
|
+
label,
|
|
2625
|
+
key,
|
|
2626
|
+
`role ${roleId} works in more than one region`,
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
roleIds.add(roleId);
|
|
2630
|
+
regions.push({
|
|
2631
|
+
key: regionKey,
|
|
2632
|
+
roleId,
|
|
2633
|
+
workingStateId,
|
|
2634
|
+
waitStateId,
|
|
2635
|
+
finalStateId,
|
|
2636
|
+
});
|
|
2637
|
+
waitResumeTargets.set(waitStateId, new Set([workingStateId]));
|
|
2638
|
+
for (const [, leaf] of [working, wait, final]) {
|
|
2639
|
+
transitionNodesByStateId.set(playbookStateIdOf(leaf)!, [region, leaf]);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
const parallelState: XStateParallelState = {
|
|
2643
|
+
stateId: key,
|
|
2644
|
+
regions: Object.freeze(regions),
|
|
2645
|
+
};
|
|
2646
|
+
states.push(parallelState);
|
|
2647
|
+
for (const region of regions) {
|
|
2648
|
+
parallelStateByWorkingStateId.set(region.workingStateId, parallelState);
|
|
2649
|
+
}
|
|
2650
|
+
transitionNodesByStateId.set(key, [parent]);
|
|
2651
|
+
}
|
|
2652
|
+
for (const [key, stateDef] of Object.entries(rootStates)) {
|
|
2653
|
+
if (!isPlainObject(stateDef) || parallelKeys.includes(key)) continue;
|
|
2654
|
+
transitionNodesByStateId.set(key, [stateDef]);
|
|
2655
|
+
}
|
|
2656
|
+
const rootWait = rootStates[BOSS_REPLY_WAIT_STATE_ID];
|
|
2657
|
+
if (isPlainObject(rootWait) && isPlainObject(rootWait.on)) {
|
|
2658
|
+
const bossReply = rootWait.on.BOSS_REPLY;
|
|
2659
|
+
if (bossReply !== undefined) {
|
|
2660
|
+
waitResumeTargets.set(
|
|
2661
|
+
BOSS_REPLY_WAIT_STATE_ID,
|
|
2662
|
+
new Set(transitionTargets(bossReply)),
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2184
2665
|
}
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2666
|
+
return Object.freeze({
|
|
2667
|
+
states: Object.freeze(states),
|
|
2668
|
+
parallelStateByWorkingStateId,
|
|
2669
|
+
waitResumeTargets,
|
|
2670
|
+
transitionNodesByStateId,
|
|
2671
|
+
});
|
|
2188
2672
|
}
|
|
2189
2673
|
|
|
2190
2674
|
// ---------------------------------------------------------------------------
|
|
@@ -2742,11 +3226,12 @@ function makeDefaultStatusesForState(
|
|
|
2742
3226
|
}
|
|
2743
3227
|
return [
|
|
2744
3228
|
...statuses,
|
|
2745
|
-
{ message: `${askerLabel(pending.asker)} asks: ${pending.question}
|
|
3229
|
+
{ message: `${askerLabel(pending.asker)} asks: ${pending.question}`, data: { kind: 'boss-question' } },
|
|
2746
3230
|
{
|
|
2747
3231
|
message:
|
|
2748
3232
|
`◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
|
|
2749
3233
|
`${askerLabel(pending.asker)} · ${pending.sourceItem}`,
|
|
3234
|
+
data: { kind: 'boss-question' },
|
|
2750
3235
|
},
|
|
2751
3236
|
];
|
|
2752
3237
|
}
|
|
@@ -2997,10 +3482,63 @@ function eventContractPrompt(contract: XStateBossEventSpec): string {
|
|
|
2997
3482
|
}`;
|
|
2998
3483
|
}
|
|
2999
3484
|
|
|
3485
|
+
/**
|
|
3486
|
+
* DR-067 §1: the questions pending in a parallel-declaring machine — the
|
|
3487
|
+
* keyed records whose resume state an active wait state (a region wait leaf
|
|
3488
|
+
* or the root reply wait) resumes.
|
|
3489
|
+
*/
|
|
3490
|
+
function parallelPendingBossQuestions(
|
|
3491
|
+
profile: XStateParallelProfile,
|
|
3492
|
+
activeStateIds: readonly string[],
|
|
3493
|
+
context: Record<string, unknown>,
|
|
3494
|
+
): PlaybookPendingBossQuestionContext[] {
|
|
3495
|
+
return keyedPendingBossQuestions(context, (resumeStateId) =>
|
|
3496
|
+
activeStateIds.some(
|
|
3497
|
+
(stateId) =>
|
|
3498
|
+
profile.waitResumeTargets.get(stateId)?.has(resumeStateId) === true,
|
|
3499
|
+
),
|
|
3500
|
+
);
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
/** Event types the root and every active state node of a parallel machine configure. */
|
|
3504
|
+
function parallelConfiguredEventTypes(
|
|
3505
|
+
machine: AnyStateMachine,
|
|
3506
|
+
profile: XStateParallelProfile,
|
|
3507
|
+
activeStateIds: readonly string[],
|
|
3508
|
+
): ReadonlySet<string> {
|
|
3509
|
+
const configured = new Set<string>();
|
|
3510
|
+
const config = (machine as unknown as { config?: unknown }).config;
|
|
3511
|
+
if (!isPlainObject(config)) return configured;
|
|
3512
|
+
if (isPlainObject(config.on)) {
|
|
3513
|
+
for (const type of Object.keys(config.on)) configured.add(type);
|
|
3514
|
+
}
|
|
3515
|
+
for (const stateId of activeStateIds) {
|
|
3516
|
+
for (const node of profile.transitionNodesByStateId.get(stateId) ?? []) {
|
|
3517
|
+
if (!isPlainObject(node.on)) continue;
|
|
3518
|
+
for (const type of Object.keys(node.on)) configured.add(type);
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
return configured;
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
function activeStateIdsOf(snapshotOrState: unknown): readonly string[] {
|
|
3525
|
+
const listed = (snapshotOrState as { activeStateIds?: unknown } | null)
|
|
3526
|
+
?.activeStateIds;
|
|
3527
|
+
if (Array.isArray(listed)) {
|
|
3528
|
+
return listed.filter((stateId): stateId is string => typeof stateId === 'string');
|
|
3529
|
+
}
|
|
3530
|
+
try {
|
|
3531
|
+
return normalizePlaybookSnapshot(snapshotOrState).activeStateIds;
|
|
3532
|
+
} catch {
|
|
3533
|
+
return [];
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3000
3537
|
function makeDefaultClassifyBossText(
|
|
3001
3538
|
machine: AnyStateMachine,
|
|
3002
3539
|
entryEvent: { type: string; textField: string } | undefined,
|
|
3003
3540
|
bossEvents: readonly XStateBossEventSpec[],
|
|
3541
|
+
parallelProfile?: XStateParallelProfile,
|
|
3004
3542
|
): (
|
|
3005
3543
|
text: string,
|
|
3006
3544
|
ports: PlaybookPorts,
|
|
@@ -3020,16 +3558,54 @@ function makeDefaultClassifyBossText(
|
|
|
3020
3558
|
// is answered history, so the prompt must not present it as pending —
|
|
3021
3559
|
// a judge told a question awaits at the failure state is steered toward
|
|
3022
3560
|
// a reply it cannot select or toward no action at all.
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3561
|
+
let pendingQuestions: readonly PlaybookPendingBossQuestionContext[];
|
|
3562
|
+
let configuredTypes: ReadonlySet<string>;
|
|
3563
|
+
if (parallelProfile === undefined) {
|
|
3564
|
+
const pending =
|
|
3565
|
+
stateId === BOSS_REPLY_WAIT_STATE_ID
|
|
3566
|
+
? pendingBossQuestionFromContext(state.context)
|
|
3567
|
+
: undefined;
|
|
3568
|
+
pendingQuestions = pending === undefined ? [] : [pending];
|
|
3569
|
+
configuredTypes = configuredEventTypesForState(machine, stateId);
|
|
3570
|
+
} else {
|
|
3571
|
+
// DR-067 §1: every question pending in any active wait is offered, and
|
|
3572
|
+
// the events of every active state node apply.
|
|
3573
|
+
const activeStateIds = activeStateIdsOf(snapshotOrState);
|
|
3574
|
+
pendingQuestions = parallelPendingBossQuestions(
|
|
3575
|
+
parallelProfile,
|
|
3576
|
+
activeStateIds,
|
|
3577
|
+
state.context,
|
|
3578
|
+
);
|
|
3579
|
+
configuredTypes = parallelConfiguredEventTypes(
|
|
3580
|
+
machine,
|
|
3581
|
+
parallelProfile,
|
|
3582
|
+
activeStateIds,
|
|
3583
|
+
);
|
|
3584
|
+
}
|
|
3585
|
+
// Several pending questions: a reply must name the one it answers, and
|
|
3586
|
+
// the classifier never guesses among them (gears2fsm §Parallel groups).
|
|
3587
|
+
const severalPending = pendingQuestions.length > 1;
|
|
3588
|
+
const applicable = [...contracts.values()]
|
|
3589
|
+
.filter(
|
|
3590
|
+
(contract) =>
|
|
3591
|
+
configuredTypes.has(contract.type) &&
|
|
3592
|
+
(contract.type !== 'BOSS_REPLY' || pendingQuestions.length > 0),
|
|
3593
|
+
)
|
|
3594
|
+
.map((contract): XStateBossEventSpec =>
|
|
3595
|
+
contract.type === 'BOSS_REPLY' && severalPending
|
|
3596
|
+
? {
|
|
3597
|
+
type: 'BOSS_REPLY',
|
|
3598
|
+
fields: {
|
|
3599
|
+
questionId: {
|
|
3600
|
+
source: 'judge',
|
|
3601
|
+
required: true,
|
|
3602
|
+
values: pendingQuestions.map(({ questionId }) => questionId),
|
|
3603
|
+
},
|
|
3604
|
+
answer: { source: 'text', required: true },
|
|
3605
|
+
},
|
|
3606
|
+
}
|
|
3607
|
+
: contract,
|
|
3608
|
+
);
|
|
3033
3609
|
|
|
3034
3610
|
const lines = [
|
|
3035
3611
|
'Classify the following Boss message into exactly one event.',
|
|
@@ -3038,13 +3614,18 @@ function makeDefaultClassifyBossText(
|
|
|
3038
3614
|
'',
|
|
3039
3615
|
`Current state: ${currentState}`,
|
|
3040
3616
|
];
|
|
3041
|
-
|
|
3617
|
+
for (const pending of pendingQuestions) {
|
|
3042
3618
|
lines.push(
|
|
3043
3619
|
`Pending question id: ${pending.questionId}`,
|
|
3044
3620
|
`Pending asker: ${askerLabel(pending.asker)}`,
|
|
3045
3621
|
`Pending Boss question: ${pending.question}`,
|
|
3046
3622
|
);
|
|
3047
3623
|
}
|
|
3624
|
+
if (severalPending) {
|
|
3625
|
+
lines.push(
|
|
3626
|
+
'Several Boss questions are pending: a BOSS_REPLY must name the questionId of the one question it answers.',
|
|
3627
|
+
);
|
|
3628
|
+
}
|
|
3048
3629
|
lines.push('', 'Allowed JSON objects:', '- { "type": "NO_ACTION" }');
|
|
3049
3630
|
for (const contract of applicable) {
|
|
3050
3631
|
lines.push(`- ${eventContractPrompt(contract)}`);
|
|
@@ -3137,20 +3718,23 @@ function makeDefaultClassifyBossText(
|
|
|
3137
3718
|
}
|
|
3138
3719
|
|
|
3139
3720
|
if (eventType === 'BOSS_REPLY') {
|
|
3140
|
-
if (
|
|
3721
|
+
if (pendingQuestions.length === 0) {
|
|
3141
3722
|
await ports.emitStatus(
|
|
3142
3723
|
'Classifier returned BOSS_REPLY without a pending question',
|
|
3143
3724
|
);
|
|
3144
3725
|
return undefined;
|
|
3145
3726
|
}
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3727
|
+
if (!severalPending) {
|
|
3728
|
+
const pending = pendingQuestions[0]!;
|
|
3729
|
+
const questionId = event.questionId;
|
|
3730
|
+
if (questionId !== undefined && questionId !== pending.questionId) {
|
|
3731
|
+
await ports.emitStatus(
|
|
3732
|
+
`Classifier supplied unknown questionId for BOSS_REPLY: ${String(questionId)}`,
|
|
3733
|
+
);
|
|
3734
|
+
return undefined;
|
|
3735
|
+
}
|
|
3736
|
+
event.questionId = pending.questionId;
|
|
3152
3737
|
}
|
|
3153
|
-
event.questionId = pending.questionId;
|
|
3154
3738
|
}
|
|
3155
3739
|
const can = (snapshotOrState as { can?: unknown } | null)?.can;
|
|
3156
3740
|
if (
|
|
@@ -3187,34 +3771,6 @@ interface TracePosition {
|
|
|
3187
3771
|
callId?: string;
|
|
3188
3772
|
}
|
|
3189
3773
|
|
|
3190
|
-
function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
|
|
3191
|
-
const visit = (stateDef: unknown): boolean => {
|
|
3192
|
-
if (!isPlainObject(stateDef)) return false;
|
|
3193
|
-
if (stateDef.type === 'parallel') return true;
|
|
3194
|
-
if (!isPlainObject(stateDef.states)) return false;
|
|
3195
|
-
return Object.values(stateDef.states).some(visit);
|
|
3196
|
-
};
|
|
3197
|
-
return visit((machine as unknown as { config?: unknown }).config);
|
|
3198
|
-
}
|
|
3199
|
-
|
|
3200
|
-
// PBRT-52: the factory's domain is FLAT single-region machines — every
|
|
3201
|
-
// state a direct child of the root, so each snapshot exposes exactly one
|
|
3202
|
-
// playbook state id and every state-keyed lookup (deterministic entries,
|
|
3203
|
-
// retry, reply-wait pendingness, configured events, descriptions) indexes
|
|
3204
|
-
// one unambiguous identity. A compound child would be accepted and then
|
|
3205
|
-
// silently misbehave on all of those gates, so it is rejected up front
|
|
3206
|
-
// exactly like a parallel region.
|
|
3207
|
-
function machineDeclaresNestedState(machine: AnyStateMachine): boolean {
|
|
3208
|
-
const config = (machine as unknown as { config?: unknown }).config;
|
|
3209
|
-
if (!isPlainObject(config) || !isPlainObject(config.states)) return false;
|
|
3210
|
-
return Object.values(config.states).some(
|
|
3211
|
-
(stateDef) =>
|
|
3212
|
-
isPlainObject(stateDef) &&
|
|
3213
|
-
isPlainObject(stateDef.states) &&
|
|
3214
|
-
Object.keys(stateDef.states).length > 0,
|
|
3215
|
-
);
|
|
3216
|
-
}
|
|
3217
|
-
|
|
3218
3774
|
// PBRT-52: the factory's lookups index states by their root key, and the
|
|
3219
3775
|
// published playbook identity is `meta.playbook.stateId` — the two must
|
|
3220
3776
|
// coincide or a machine can advertise a pending question or retry under an
|
|
@@ -3309,10 +3865,12 @@ function assertUnfinishedFinalStateIds(
|
|
|
3309
3865
|
* the optional parked-session snapshot capability (DR-014) and the retained-
|
|
3310
3866
|
* snapshot adoption capability (DR-038).
|
|
3311
3867
|
*
|
|
3312
|
-
* Scope: flat single-region machines —
|
|
3313
|
-
*
|
|
3314
|
-
* state
|
|
3315
|
-
*
|
|
3868
|
+
* Scope: flat single-region machines — every root state's
|
|
3869
|
+
* `meta.playbook.stateId` equal to its state key, so each snapshot exposes
|
|
3870
|
+
* exactly one playbook state id — plus the parallel profile of DR-067: root
|
|
3871
|
+
* `type: 'parallel'` states of the shape gears2fsm compiles, whose regions
|
|
3872
|
+
* run as one all-`unchanged` repository cohort with keyed pending questions.
|
|
3873
|
+
* A machine declaring a parallel state omits `adopt`.
|
|
3316
3874
|
*/
|
|
3317
3875
|
export function createXStatePlaybookRuntime<
|
|
3318
3876
|
TOptions,
|
|
@@ -3348,16 +3906,9 @@ export function createXStatePlaybookRuntime<
|
|
|
3348
3906
|
`${label} artifacts must not derive concrete player bindings`,
|
|
3349
3907
|
);
|
|
3350
3908
|
}
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
);
|
|
3355
|
-
}
|
|
3356
|
-
if (machineDeclaresNestedState(machine)) {
|
|
3357
|
-
throw new Error(
|
|
3358
|
-
`${label} declares a compound state; the shared runtime supports only flat single-region FSMs`,
|
|
3359
|
-
);
|
|
3360
|
-
}
|
|
3909
|
+
// DR-067 §1: a flat machine, or one whose only compound states are root
|
|
3910
|
+
// parallel states of the compiled shape; everything else is rejected.
|
|
3911
|
+
const parallelProfile = parallelProfileFromMachine(machine, label);
|
|
3361
3912
|
assertFlatStateIdentity(machine, label);
|
|
3362
3913
|
assertUnfinishedFinalStateIds(
|
|
3363
3914
|
spec.unfinishedFinalStateIds,
|
|
@@ -3398,6 +3949,18 @@ export function createXStatePlaybookRuntime<
|
|
|
3398
3949
|
const declaredRoleIds = Object.freeze([
|
|
3399
3950
|
...new Set([...roleStates.values()].map(({ role }) => role)),
|
|
3400
3951
|
]);
|
|
3952
|
+
// DR-067 §1: the Boss-relevant states of a parallel-declaring machine —
|
|
3953
|
+
// each player state, each wait state, and the failure state. A status is
|
|
3954
|
+
// scheduled for each one newly entered, and a status trace names a
|
|
3955
|
+
// `stateId` only while exactly one is active.
|
|
3956
|
+
const bossRelevantStateIds: ReadonlySet<string> | undefined =
|
|
3957
|
+
parallelProfile === undefined
|
|
3958
|
+
? undefined
|
|
3959
|
+
: new Set([
|
|
3960
|
+
...roleStates.keys(),
|
|
3961
|
+
...parallelProfile.waitResumeTargets.keys(),
|
|
3962
|
+
'failed',
|
|
3963
|
+
]);
|
|
3401
3964
|
// PBRT-52: the artifact's own ControlView context projection. Nothing is
|
|
3402
3965
|
// exported by default, so an FSM context member — including one added
|
|
3403
3966
|
// after this artifact was linked — is private until named here. The two
|
|
@@ -3462,6 +4025,7 @@ export function createXStatePlaybookRuntime<
|
|
|
3462
4025
|
machine,
|
|
3463
4026
|
spec.entryEvent,
|
|
3464
4027
|
spec.bossEvents ?? [],
|
|
4028
|
+
parallelProfile,
|
|
3465
4029
|
);
|
|
3466
4030
|
const classifyBossText: NonNullable<
|
|
3467
4031
|
XStatePlaybookRuntimeSpec<TOptions>['classifyBossText']
|
|
@@ -3485,20 +4049,140 @@ export function createXStatePlaybookRuntime<
|
|
|
3485
4049
|
return typeof cwd === 'string' ? cwd : undefined;
|
|
3486
4050
|
});
|
|
3487
4051
|
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
4052
|
+
// DR-067 §1: the questions pending in a state — the singular reply-wait
|
|
4053
|
+
// question of a flat machine, or a parallel machine's keyed questions
|
|
4054
|
+
// whose own wait state is active.
|
|
4055
|
+
const pendingBossQuestionsFor = (
|
|
4056
|
+
state: PlaybookState,
|
|
4057
|
+
context: Record<string, unknown>,
|
|
4058
|
+
): readonly PlaybookPendingBossQuestionContext[] => {
|
|
4059
|
+
if (parallelProfile === undefined) {
|
|
4060
|
+
const pending = pendingBossQuestionForState(state, context);
|
|
4061
|
+
return pending === undefined ? [] : [pending];
|
|
4062
|
+
}
|
|
4063
|
+
return parallelPendingBossQuestions(
|
|
4064
|
+
parallelProfile,
|
|
4065
|
+
state.activeStateIds,
|
|
4066
|
+
context,
|
|
3494
4067
|
);
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
4068
|
+
};
|
|
4069
|
+
const singlePendingBossQuestion = (
|
|
4070
|
+
state: PlaybookState,
|
|
4071
|
+
context: Record<string, unknown>,
|
|
4072
|
+
): PlaybookPendingBossQuestionContext | undefined => {
|
|
4073
|
+
const pending = pendingBossQuestionsFor(state, context);
|
|
4074
|
+
return pending.length === 1 ? pending[0] : undefined;
|
|
4075
|
+
};
|
|
4076
|
+
// DR-067 §1: a status trace names a `stateId` only while exactly one
|
|
4077
|
+
// Boss-relevant state is active; a flat machine's is its one state.
|
|
4078
|
+
const statusTraceStateId = (state: PlaybookState): string | undefined => {
|
|
4079
|
+
if (bossRelevantStateIds === undefined) return state.stateId;
|
|
4080
|
+
const relevant = state.activeStateIds.filter((stateId) =>
|
|
4081
|
+
bossRelevantStateIds.has(stateId),
|
|
4082
|
+
);
|
|
4083
|
+
return relevant.length === 1 ? relevant[0] : undefined;
|
|
4084
|
+
};
|
|
4085
|
+
// DR-067 §1: a parallel machine schedules statuses for each newly entered
|
|
4086
|
+
// Boss-relevant state — a player state's role line, a wait state's
|
|
4087
|
+
// question lines, and the failure line — diffed against the previous
|
|
4088
|
+
// snapshot's active state ids, so a sibling still running is not
|
|
4089
|
+
// announced again.
|
|
4090
|
+
const parallelStatusesFor = (
|
|
4091
|
+
previousState: PlaybookState | undefined,
|
|
4092
|
+
state: PlaybookState,
|
|
4093
|
+
context: Record<string, unknown>,
|
|
4094
|
+
): ScheduledStatus[] => {
|
|
4095
|
+
const prior = new Set(previousState?.activeStateIds ?? []);
|
|
4096
|
+
const statuses: ScheduledStatus[] = [];
|
|
4097
|
+
for (const stateId of state.activeStateIds) {
|
|
4098
|
+
if (prior.has(stateId) || !bossRelevantStateIds!.has(stateId)) continue;
|
|
4099
|
+
const resumeTargets = parallelProfile!.waitResumeTargets.get(stateId);
|
|
4100
|
+
if (resumeTargets !== undefined) {
|
|
4101
|
+
const questions = keyedPendingBossQuestions(context, (resumeStateId) =>
|
|
4102
|
+
resumeTargets.has(resumeStateId),
|
|
4103
|
+
);
|
|
4104
|
+
if (questions.length === 0) {
|
|
4105
|
+
statuses.push({ message: 'Awaiting Boss reply.' });
|
|
4106
|
+
continue;
|
|
4107
|
+
}
|
|
4108
|
+
for (const pending of questions) {
|
|
4109
|
+
statuses.push(
|
|
4110
|
+
{
|
|
4111
|
+
message: `${askerLabel(pending.asker)} asks: ${pending.question}`,
|
|
4112
|
+
data: { kind: 'boss-question' },
|
|
4113
|
+
},
|
|
4114
|
+
{
|
|
4115
|
+
message:
|
|
4116
|
+
`◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
|
|
4117
|
+
`${askerLabel(pending.asker)} · ${pending.sourceItem}`,
|
|
4118
|
+
data: { kind: 'boss-question' },
|
|
4119
|
+
},
|
|
4120
|
+
);
|
|
4121
|
+
}
|
|
4122
|
+
continue;
|
|
4123
|
+
}
|
|
4124
|
+
if (stateId === 'failed') {
|
|
4125
|
+
const lastError = normalizeErrorCompact(context.lastError);
|
|
4126
|
+
statuses.push({
|
|
4127
|
+
message: '◆ workflow failed; awaiting Boss recovery.',
|
|
4128
|
+
...(lastError === undefined
|
|
4129
|
+
? {}
|
|
4130
|
+
: {
|
|
4131
|
+
data: snapshotJsonValue({ lastError }, 'failed status data'),
|
|
4132
|
+
}),
|
|
4133
|
+
});
|
|
4134
|
+
continue;
|
|
4135
|
+
}
|
|
4136
|
+
const roleState = roleStates.get(stateId);
|
|
4137
|
+
if (roleState !== undefined) {
|
|
4138
|
+
statuses.push({ message: `⤷ ${roleState.role}: ${roleState.label}` });
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
return statuses;
|
|
4142
|
+
};
|
|
4143
|
+
// DR-067 §1: each parallel state's region roles, in region order, are one
|
|
4144
|
+
// concurrent role set the FSM exports; a host that forwards those sets in
|
|
4145
|
+
// its schema-3 authority is held to them before any agent call.
|
|
4146
|
+
const assertDeclaredCohorts = (hostCapabilities: object): void => {
|
|
4147
|
+
const authority = Object.getOwnPropertyDescriptor(
|
|
4148
|
+
hostCapabilities,
|
|
4149
|
+
'authority',
|
|
4150
|
+
)?.value;
|
|
4151
|
+
if (!isPlainObject(authority) || !Array.isArray(authority.concurrentRoleSets)) {
|
|
4152
|
+
return;
|
|
4153
|
+
}
|
|
4154
|
+
const declared = authority.concurrentRoleSets as readonly unknown[];
|
|
4155
|
+
for (const parallelState of parallelProfile!.states) {
|
|
4156
|
+
const roles = parallelState.regions.map(({ roleId }) => roleId);
|
|
4157
|
+
if (
|
|
4158
|
+
!declared.some(
|
|
4159
|
+
(candidate) =>
|
|
4160
|
+
Array.isArray(candidate) &&
|
|
4161
|
+
candidate.length === roles.length &&
|
|
4162
|
+
candidate.every((roleId, index) => roleId === roles[index]),
|
|
4163
|
+
)
|
|
4164
|
+
) {
|
|
4165
|
+
throw new TypeError(
|
|
4166
|
+
`${label} parallel state ${parallelState.stateId} roles [${roles.join(', ')}] are not a declared concurrent role set`,
|
|
4167
|
+
);
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
};
|
|
4171
|
+
|
|
4172
|
+
const createPlaybookRuntime = function createPlaybookRuntime(
|
|
4173
|
+
factoryOptions: unknown,
|
|
4174
|
+
): PlaybookRuntime {
|
|
4175
|
+
const construction = configuredOptionsFromFactoryInput(
|
|
4176
|
+
factoryOptions,
|
|
4177
|
+
label,
|
|
4178
|
+
);
|
|
4179
|
+
const configuredOptions = construction.configuredOptions as TOptions;
|
|
4180
|
+
const effectLedgerCapability = construction.effectLedger;
|
|
4181
|
+
const hasGovernedPlayerStates =
|
|
4182
|
+
Object.keys(outcomeAuthority.governedPlayerStates).length > 0;
|
|
4183
|
+
const acceptedOutcomeConsumer = createAcceptedOutcomeConsumer(
|
|
4184
|
+
(source, acceptedOutcome) => {
|
|
4185
|
+
const governedPlayerStates = outcomeAuthority.governedPlayerStates;
|
|
3502
4186
|
if (
|
|
3503
4187
|
!Object.prototype.hasOwnProperty.call(governedPlayerStates, source)
|
|
3504
4188
|
) {
|
|
@@ -3519,14 +4203,19 @@ export function createXStatePlaybookRuntime<
|
|
|
3519
4203
|
? repositoryCapabilityFromHostCapabilities(
|
|
3520
4204
|
construction.hostCapabilities,
|
|
3521
4205
|
label,
|
|
4206
|
+
parallelProfile !== undefined,
|
|
3522
4207
|
)
|
|
3523
4208
|
: undefined;
|
|
4209
|
+
if (parallelProfile !== undefined) {
|
|
4210
|
+
assertDeclaredCohorts(construction.hostCapabilities);
|
|
4211
|
+
}
|
|
3524
4212
|
const currentEffectLedger = (): PlaybookEffectLedger =>
|
|
3525
4213
|
assertPlaybookEffectLedger(
|
|
3526
4214
|
effectLedgerCapability.snapshot(),
|
|
3527
4215
|
`${label} current host effect ledger`,
|
|
3528
4216
|
);
|
|
3529
4217
|
let effectLedgerMirror = currentEffectLedger();
|
|
4218
|
+
let recoveryCheckpoint: PlaybookRuntimeSnapshot['recoveryCheckpoint'];
|
|
3530
4219
|
let retainedEffectSourceSessionId: string | undefined;
|
|
3531
4220
|
let retainedEffectReconciliation:
|
|
3532
4221
|
| PlaybookRuntimeSnapshot['retainedEffectReconciliation']
|
|
@@ -3591,9 +4280,11 @@ export function createXStatePlaybookRuntime<
|
|
|
3591
4280
|
// mutable `activeSignal` alone cannot classify a late invocation reason.
|
|
3592
4281
|
let activeAborts: AbortReasonClassifier | undefined;
|
|
3593
4282
|
// The bridge binds the provenance of a child result immediately before
|
|
3594
|
-
// its promise actor settles. The next root snapshot/error consumes
|
|
3595
|
-
//
|
|
3596
|
-
|
|
4283
|
+
// its promise actor settles. The next root snapshot/error consumes it so
|
|
4284
|
+
// background settlement emissions retain their owner. A flat machine has
|
|
4285
|
+
// one settling actor at a time, so a later binding replaces an unconsumed
|
|
4286
|
+
// one; a parallel machine's settlements queue in order (DR-067 §1).
|
|
4287
|
+
const actorSettlementAborts: (AbortReasonClassifier | undefined)[] = [];
|
|
3597
4288
|
let actorSettlementErrorAborts: AbortReasonClassifier | undefined;
|
|
3598
4289
|
// Exact cancellation observed by an emission owned by the active
|
|
3599
4290
|
// boundary. Ordinary runs settle from their signal/state; apply also
|
|
@@ -3643,11 +4334,29 @@ export function createXStatePlaybookRuntime<
|
|
|
3643
4334
|
// each successful result, so it is never stale.
|
|
3644
4335
|
let pendingResultFailureCause: PlaybookFailureCause | undefined;
|
|
3645
4336
|
// DR-063 §2: the causes decided for thrown values that cannot carry the
|
|
3646
|
-
// marker — a string, a frozen error —
|
|
4337
|
+
// marker — a string, a frozen error — and for the record a machine keeps
|
|
4338
|
+
// in place of the thrown value, kept by identity for as long as the
|
|
3647
4339
|
// failure stands, so every surface that reads the FSM's own `lastError`
|
|
3648
|
-
// publishes the cause decided for exactly that
|
|
3649
|
-
// it.
|
|
4340
|
+
// publishes the cause decided for exactly that failure, whatever turn
|
|
4341
|
+
// reads it.
|
|
3650
4342
|
const failureCauses = createFailureCauseRetention();
|
|
4343
|
+
// DR-063 §2 / DR-067 §1: the cause decided for each non-`ok` player
|
|
4344
|
+
// result, keyed by the result itself, so concurrent region calls never
|
|
4345
|
+
// lend one another the single slot above.
|
|
4346
|
+
const resultFailureCauses = new WeakMap<object, PlaybookFailureCause>();
|
|
4347
|
+
// DR-067 §1: the parallel profile's cohort bookkeeping. Every public
|
|
4348
|
+
// boundary advances the sequence; a parallel state's working leaves run
|
|
4349
|
+
// as one cohort at most once per boundary, and each cohort's semantic
|
|
4350
|
+
// completions — which the host settles concurrently — reconcile one at a
|
|
4351
|
+
// time so their durable correction-budget writes cannot race.
|
|
4352
|
+
let publicBoundarySequence = 0;
|
|
4353
|
+
const consumedCohortBoundaries = new Map<string, number>();
|
|
4354
|
+
const pendingCohorts = new Map<string, PendingCohort>();
|
|
4355
|
+
const cohortCompletionQueue = new PQueue({ concurrency: 1 });
|
|
4356
|
+
// DR-067 §1: every player, judge, and nested call a parallel machine
|
|
4357
|
+
// starts, so a boundary's drain waits for a cancelled sibling whose port
|
|
4358
|
+
// ignores its signal before it settles.
|
|
4359
|
+
const activeBoundaryCalls = new Set<Promise<unknown>>();
|
|
3651
4360
|
// Previous root-machine state for the inspect-driven telemetry /
|
|
3652
4361
|
// status emitter. undefined before the first inspect firing.
|
|
3653
4362
|
let priorState: PlaybookState | undefined;
|
|
@@ -3660,12 +4369,6 @@ export function createXStatePlaybookRuntime<
|
|
|
3660
4369
|
let playbookCallSequence = 0;
|
|
3661
4370
|
let captainCallSequence = 0;
|
|
3662
4371
|
let applyCallSequence = 0;
|
|
3663
|
-
// DR-029: the last event a public Boss boundary sent into the
|
|
3664
|
-
// machine — classified, deterministic entry, or Boss reply — kept with
|
|
3665
|
-
// its recorded payload so a failure-state retry action can replay the
|
|
3666
|
-
// event that drove the run into `failed`. Process-local: the durable
|
|
3667
|
-
// runtime snapshot does not persist it (PBRT-50).
|
|
3668
|
-
let lastBossEvent: EventObject | undefined;
|
|
3669
4372
|
// DR-029: process-local at-most-once `apply` execution — the accepted receipt
|
|
3670
4373
|
// recorded for each idempotency key, returned verbatim on a repeated
|
|
3671
4374
|
// key. A key whose call settled `rejected` or threw before reaching
|
|
@@ -3902,7 +4605,7 @@ export function createXStatePlaybookRuntime<
|
|
|
3902
4605
|
function boundaryExcludesEffect(boundary: PlaybookEffectBoundary): boolean {
|
|
3903
4606
|
return (
|
|
3904
4607
|
boundary.logicalOperationId === undefined &&
|
|
3905
|
-
boundary.physicalReceipt?.classification === 'unchanged'
|
|
4608
|
+
(boundary.physicalReceipt?.classification === 'unchanged' || boundary.restored !== undefined)
|
|
3906
4609
|
);
|
|
3907
4610
|
}
|
|
3908
4611
|
|
|
@@ -3974,7 +4677,7 @@ export function createXStatePlaybookRuntime<
|
|
|
3974
4677
|
({ sequence }) =>
|
|
3975
4678
|
sequence > reconstructedGovernedPrefixSequence!,
|
|
3976
4679
|
);
|
|
3977
|
-
if (candidate === undefined || candidate.sourceStateId !== state.stateId) {
|
|
4680
|
+
if (candidate === undefined || candidate.restored !== undefined || candidate.sourceStateId !== state.stateId) {
|
|
3978
4681
|
return;
|
|
3979
4682
|
}
|
|
3980
4683
|
const persisted = persistedBoundaryReconciliation(candidate, ledger);
|
|
@@ -4716,7 +5419,7 @@ export function createXStatePlaybookRuntime<
|
|
|
4716
5419
|
...(descriptor !== undefined
|
|
4717
5420
|
? {
|
|
4718
5421
|
state: descriptor,
|
|
4719
|
-
...stateIdentity(descriptor
|
|
5422
|
+
...stateIdentity(statusTraceStateId(descriptor)),
|
|
4720
5423
|
}
|
|
4721
5424
|
: {}),
|
|
4722
5425
|
},
|
|
@@ -4767,9 +5470,12 @@ export function createXStatePlaybookRuntime<
|
|
|
4767
5470
|
// start-only fields, so it passes its own canonical pre-acceptance
|
|
4768
5471
|
// base (slc/link.md §Playbook trace).
|
|
4769
5472
|
finishIdentity: Record<string, unknown> = identity,
|
|
5473
|
+
// DR-067 §1: the per-call abort classification a parallel machine's
|
|
5474
|
+
// player pairs enqueue under; absent, the active boundary's.
|
|
5475
|
+
aborts?: AbortReasonClassifier,
|
|
4770
5476
|
): Promise<void> {
|
|
4771
5477
|
try {
|
|
4772
|
-
await emitTrace(startedType, identity, position);
|
|
5478
|
+
await emitTrace(startedType, identity, position, aborts);
|
|
4773
5479
|
} catch (error) {
|
|
4774
5480
|
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
4775
5481
|
try {
|
|
@@ -4781,6 +5487,7 @@ export function createXStatePlaybookRuntime<
|
|
|
4781
5487
|
error: normalizeError(error),
|
|
4782
5488
|
},
|
|
4783
5489
|
position,
|
|
5490
|
+
aborts,
|
|
4784
5491
|
);
|
|
4785
5492
|
} catch {
|
|
4786
5493
|
// Preserve the start failure after one best-effort finish attempt.
|
|
@@ -5010,12 +5717,46 @@ export function createXStatePlaybookRuntime<
|
|
|
5010
5717
|
...(semanticCandidate === undefined ? {} : { semanticCandidate }),
|
|
5011
5718
|
correctionBudget: { limit: 1, spent: true },
|
|
5012
5719
|
};
|
|
5720
|
+
// DR-067 §1 / PBRT-69: a cohort's shared receipt becomes visible for
|
|
5721
|
+
// every member in one ledger revision — publishing only this member's
|
|
5722
|
+
// spend would leave the invalid half-complete cohort the ledger admits
|
|
5723
|
+
// nowhere.
|
|
5724
|
+
const cohortMembers =
|
|
5725
|
+
current.cohortId === undefined
|
|
5726
|
+
? [current]
|
|
5727
|
+
: currentLedger.boundaries.filter(
|
|
5728
|
+
({ cohortId }) => cohortId === current.cohortId,
|
|
5729
|
+
);
|
|
5730
|
+
const replacements = cohortMembers.map((member) => {
|
|
5731
|
+
if (member.boundaryId === current.boundaryId) {
|
|
5732
|
+
return { expected: current, next };
|
|
5733
|
+
}
|
|
5734
|
+
if (
|
|
5735
|
+
member.physicalReceipt !== undefined &&
|
|
5736
|
+
!isDeepStrictEqual(member.physicalReceipt, receipt)
|
|
5737
|
+
) {
|
|
5738
|
+
throw new TypeError(
|
|
5739
|
+
`${label} correction budget cohort conflicts with its shared repository receipt`,
|
|
5740
|
+
);
|
|
5741
|
+
}
|
|
5742
|
+
return {
|
|
5743
|
+
expected: member,
|
|
5744
|
+
next: {
|
|
5745
|
+
...member,
|
|
5746
|
+
...(receipt.after === undefined ? {} : { after: receipt.after }),
|
|
5747
|
+
physicalReceipt: receipt,
|
|
5748
|
+
},
|
|
5749
|
+
};
|
|
5750
|
+
}) as [
|
|
5751
|
+
{ readonly expected: PlaybookEffectBoundary; readonly next: PlaybookEffectBoundary },
|
|
5752
|
+
...{
|
|
5753
|
+
readonly expected: PlaybookEffectBoundary;
|
|
5754
|
+
readonly next: PlaybookEffectBoundary;
|
|
5755
|
+
}[],
|
|
5756
|
+
];
|
|
5013
5757
|
const acknowledged = assertPlaybookEffectLedger(
|
|
5014
5758
|
await effectLedgerCapability.writeAhead([
|
|
5015
|
-
{
|
|
5016
|
-
kind: 'replace-boundaries',
|
|
5017
|
-
replacements: [{ expected: current, next }],
|
|
5018
|
-
},
|
|
5759
|
+
{ kind: 'replace-boundaries', replacements },
|
|
5019
5760
|
]),
|
|
5020
5761
|
`${label} semantic correction budget acknowledgement`,
|
|
5021
5762
|
);
|
|
@@ -5401,7 +6142,7 @@ export function createXStatePlaybookRuntime<
|
|
|
5401
6142
|
function acknowledgeGovernedPlayerResult(
|
|
5402
6143
|
value: unknown,
|
|
5403
6144
|
boundaryId: string,
|
|
5404
|
-
source: 'runExclusive' | 'runDeferred' = 'runExclusive',
|
|
6145
|
+
source: 'runExclusive' | 'runDeferred' | 'runCohort' = 'runExclusive',
|
|
5405
6146
|
): PlayerResult {
|
|
5406
6147
|
if (!isPlainObject(value) || !isPlainObject(value.operation)) {
|
|
5407
6148
|
throw new TypeError(
|
|
@@ -5512,7 +6253,7 @@ export function createXStatePlaybookRuntime<
|
|
|
5512
6253
|
);
|
|
5513
6254
|
governedOutput = undefined;
|
|
5514
6255
|
}
|
|
5515
|
-
} else if (source
|
|
6256
|
+
} else if (source !== 'runDeferred' && value.deferredStatus !== undefined) {
|
|
5516
6257
|
throw new TypeError(
|
|
5517
6258
|
`${label} non-deferred settlement returned a deferred binding status`,
|
|
5518
6259
|
);
|
|
@@ -5600,6 +6341,401 @@ export function createXStatePlaybookRuntime<
|
|
|
5600
6341
|
);
|
|
5601
6342
|
}
|
|
5602
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
|
+
|
|
5603
6739
|
function captureEffectLedgerPrefixSequence(): number | undefined {
|
|
5604
6740
|
if (!hasGovernedPlayerStates) return undefined;
|
|
5605
6741
|
try {
|
|
@@ -5672,6 +6808,371 @@ export function createXStatePlaybookRuntime<
|
|
|
5672
6808
|
failedGovernedAttemptId = attemptIds.values().next().value;
|
|
5673
6809
|
}
|
|
5674
6810
|
|
|
6811
|
+
// -----------------------------------------------------------------
|
|
6812
|
+
// DR-067 §1: the parallel profile's repository cohort. The working
|
|
6813
|
+
// leaves of one parallel state entered together run under one
|
|
6814
|
+
// `runCohort` claim, each member an `unchanged` boundary: a member's
|
|
6815
|
+
// result is adjudicated through the one reconciler in serial, a spent
|
|
6816
|
+
// correction rewrites every member's boundary in one acknowledged batch,
|
|
6817
|
+
// members are acknowledged and released in completion order, and a
|
|
6818
|
+
// member's failure cancels its siblings and fails the cohort with the
|
|
6819
|
+
// cause decided first.
|
|
6820
|
+
// -----------------------------------------------------------------
|
|
6821
|
+
|
|
6822
|
+
function trackBoundaryCall<T>(call: Promise<T>): Promise<T> {
|
|
6823
|
+
if (parallelProfile === undefined) return call;
|
|
6824
|
+
activeBoundaryCalls.add(call);
|
|
6825
|
+
const forget = (): void => {
|
|
6826
|
+
activeBoundaryCalls.delete(call);
|
|
6827
|
+
};
|
|
6828
|
+
void call.then(forget, forget);
|
|
6829
|
+
return call;
|
|
6830
|
+
}
|
|
6831
|
+
|
|
6832
|
+
async function drainBoundaryCalls(): Promise<void> {
|
|
6833
|
+
while (activeBoundaryCalls.size > 0) {
|
|
6834
|
+
await Promise.allSettled([...activeBoundaryCalls]);
|
|
6835
|
+
}
|
|
6836
|
+
}
|
|
6837
|
+
|
|
6838
|
+
function cohortParallelStateFor(
|
|
6839
|
+
stateId: string,
|
|
6840
|
+
): XStateParallelState | undefined {
|
|
6841
|
+
const parallelState =
|
|
6842
|
+
parallelProfile?.parallelStateByWorkingStateId.get(stateId);
|
|
6843
|
+
if (parallelState === undefined) return undefined;
|
|
6844
|
+
if (
|
|
6845
|
+
consumedCohortBoundaries.get(parallelState.stateId) ===
|
|
6846
|
+
publicBoundarySequence
|
|
6847
|
+
) {
|
|
6848
|
+
return undefined;
|
|
6849
|
+
}
|
|
6850
|
+
const pending = pendingCohorts.get(parallelState.stateId);
|
|
6851
|
+
if (
|
|
6852
|
+
pending !== undefined &&
|
|
6853
|
+
pending.boundarySequence === publicBoundarySequence &&
|
|
6854
|
+
!pending.started
|
|
6855
|
+
) {
|
|
6856
|
+
return parallelState;
|
|
6857
|
+
}
|
|
6858
|
+
const active = currentState().activeStateIds;
|
|
6859
|
+
return parallelState.regions.every(({ workingStateId }) =>
|
|
6860
|
+
active.includes(workingStateId),
|
|
6861
|
+
)
|
|
6862
|
+
? parallelState
|
|
6863
|
+
: undefined;
|
|
6864
|
+
}
|
|
6865
|
+
|
|
6866
|
+
function startCohort(
|
|
6867
|
+
parallelState: XStateParallelState,
|
|
6868
|
+
cohort: PendingCohort,
|
|
6869
|
+
): void {
|
|
6870
|
+
cohort.started = true;
|
|
6871
|
+
consumedCohortBoundaries.set(
|
|
6872
|
+
parallelState.stateId,
|
|
6873
|
+
cohort.boundarySequence,
|
|
6874
|
+
);
|
|
6875
|
+
if (pendingCohorts.get(parallelState.stateId) === cohort) {
|
|
6876
|
+
pendingCohorts.delete(parallelState.stateId);
|
|
6877
|
+
}
|
|
6878
|
+
}
|
|
6879
|
+
|
|
6880
|
+
async function joinCohort(
|
|
6881
|
+
parallelState: XStateParallelState,
|
|
6882
|
+
member: XStateCohortMember,
|
|
6883
|
+
): Promise<PlayerResult> {
|
|
6884
|
+
let cohort = pendingCohorts.get(parallelState.stateId);
|
|
6885
|
+
if (
|
|
6886
|
+
cohort === undefined ||
|
|
6887
|
+
cohort.boundarySequence !== publicBoundarySequence ||
|
|
6888
|
+
cohort.started
|
|
6889
|
+
) {
|
|
6890
|
+
cohort = {
|
|
6891
|
+
boundarySequence: publicBoundarySequence,
|
|
6892
|
+
members: new Map(),
|
|
6893
|
+
started: false,
|
|
6894
|
+
};
|
|
6895
|
+
pendingCohorts.set(parallelState.stateId, cohort);
|
|
6896
|
+
}
|
|
6897
|
+
const registered = cohort;
|
|
6898
|
+
if (registered.members.has(member.region.key)) {
|
|
6899
|
+
throw new Error(
|
|
6900
|
+
`${label} parallel state ${parallelState.stateId} registered region ${member.region.key} twice`,
|
|
6901
|
+
);
|
|
6902
|
+
}
|
|
6903
|
+
registered.members.set(member.region.key, member);
|
|
6904
|
+
// A member whose invocation stops before every sibling registered —
|
|
6905
|
+
// its region exited, or the boundary aborted — leaves no cohort to
|
|
6906
|
+
// run: each registered member settles with its own cancellation and
|
|
6907
|
+
// the boundary's cohort is spent.
|
|
6908
|
+
const withdraw = (): void => {
|
|
6909
|
+
if (registered.started) return;
|
|
6910
|
+
startCohort(parallelState, registered);
|
|
6911
|
+
for (const candidate of registered.members.values()) {
|
|
6912
|
+
candidate.settled.reject(
|
|
6913
|
+
candidate.signal.aborted
|
|
6914
|
+
? candidate.signal.reason
|
|
6915
|
+
: member.signal.reason,
|
|
6916
|
+
);
|
|
6917
|
+
}
|
|
6918
|
+
};
|
|
6919
|
+
if (member.signal.aborted) {
|
|
6920
|
+
withdraw();
|
|
6921
|
+
} else {
|
|
6922
|
+
member.signal.addEventListener('abort', withdraw, { once: true });
|
|
6923
|
+
}
|
|
6924
|
+
if (
|
|
6925
|
+
!registered.started &&
|
|
6926
|
+
registered.members.size === parallelState.regions.length
|
|
6927
|
+
) {
|
|
6928
|
+
startCohort(parallelState, registered);
|
|
6929
|
+
void settleCohort(parallelState, registered);
|
|
6930
|
+
}
|
|
6931
|
+
return await member.settled.promise;
|
|
6932
|
+
}
|
|
6933
|
+
|
|
6934
|
+
async function settleCohort(
|
|
6935
|
+
parallelState: XStateParallelState,
|
|
6936
|
+
cohort: PendingCohort,
|
|
6937
|
+
): Promise<void> {
|
|
6938
|
+
const members = parallelState.regions.map(
|
|
6939
|
+
({ key }) => cohort.members.get(key)!,
|
|
6940
|
+
);
|
|
6941
|
+
const cancellation = new AbortController();
|
|
6942
|
+
const completionOrder: XStateCohortMember[] = [];
|
|
6943
|
+
const decided = new Map<
|
|
6944
|
+
XStateCohortMember,
|
|
6945
|
+
{
|
|
6946
|
+
readonly error: unknown;
|
|
6947
|
+
readonly cause: PlaybookFailureCause | undefined;
|
|
6948
|
+
readonly sequence: number;
|
|
6949
|
+
}
|
|
6950
|
+
>();
|
|
6951
|
+
let decisionSequence = 0;
|
|
6952
|
+
// DR-063 §2: each member failure is one decision, ordered as it was
|
|
6953
|
+
// decided, with the cause decided for exactly that value at that
|
|
6954
|
+
// moment — so a value a sibling later rethrows cannot rename it.
|
|
6955
|
+
const decide = (
|
|
6956
|
+
member: XStateCohortMember,
|
|
6957
|
+
error: unknown,
|
|
6958
|
+
cause: PlaybookFailureCause | undefined = failureCauses.causeOf(error),
|
|
6959
|
+
): void => {
|
|
6960
|
+
if (decided.has(member)) return;
|
|
6961
|
+
decided.set(member, { error, cause, sequence: ++decisionSequence });
|
|
6962
|
+
};
|
|
6963
|
+
const isCancellation = (error: unknown): boolean =>
|
|
6964
|
+
cancellation.signal.aborted &&
|
|
6965
|
+
Object.is(error, cancellation.signal.reason) &&
|
|
6966
|
+
!activeAborts?.isAbortReason(error);
|
|
6967
|
+
const operationFor =
|
|
6968
|
+
(member: XStateCohortMember) =>
|
|
6969
|
+
async (
|
|
6970
|
+
context:
|
|
6971
|
+
| { readonly baseline?: PlaybookRepositoryReceipt['baseline'] }
|
|
6972
|
+
| undefined,
|
|
6973
|
+
): Promise<PlayerResult> => {
|
|
6974
|
+
const callSignal = combineAbortSignals(
|
|
6975
|
+
member.signal,
|
|
6976
|
+
cancellation.signal,
|
|
6977
|
+
);
|
|
6978
|
+
try {
|
|
6979
|
+
if (activePlayerKeys.has(member.playerKey)) {
|
|
6980
|
+
await member.reportCollision(callSignal);
|
|
6981
|
+
}
|
|
6982
|
+
activePlayerKeys.add(member.playerKey);
|
|
6983
|
+
try {
|
|
6984
|
+
const result = await member.run(
|
|
6985
|
+
callSignal,
|
|
6986
|
+
cancellation.signal,
|
|
6987
|
+
effectAuthorizedPreExistingBlock(
|
|
6988
|
+
member.effectBoundary,
|
|
6989
|
+
context?.baseline,
|
|
6990
|
+
),
|
|
6991
|
+
);
|
|
6992
|
+
if (result.status !== 'ok' && !cancellation.signal.aborted) {
|
|
6993
|
+
// DR-063 §2: the failure built for a non-`ok` member result
|
|
6994
|
+
// carries the cause decided for exactly that result, and its
|
|
6995
|
+
// siblings are cancelled with it.
|
|
6996
|
+
const cause =
|
|
6997
|
+
resultFailureCauses.get(result) ??
|
|
6998
|
+
runtimeDefectCause(
|
|
6999
|
+
`callPlayer status ${JSON.stringify(result.status)}`,
|
|
7000
|
+
);
|
|
7001
|
+
const failure = attachPlaybookFailureCause(
|
|
7002
|
+
markFsmResultFailure(
|
|
7003
|
+
new Error(
|
|
7004
|
+
result.error ??
|
|
7005
|
+
`captainBridge: callPlayer status "${result.status}"`,
|
|
7006
|
+
),
|
|
7007
|
+
),
|
|
7008
|
+
cause,
|
|
7009
|
+
);
|
|
7010
|
+
failureCauses.retain(failure, cause);
|
|
7011
|
+
decide(member, failure, cause);
|
|
7012
|
+
cancellation.abort(failure);
|
|
7013
|
+
}
|
|
7014
|
+
return result;
|
|
7015
|
+
} finally {
|
|
7016
|
+
activePlayerKeys.delete(member.playerKey);
|
|
7017
|
+
}
|
|
7018
|
+
} catch (error) {
|
|
7019
|
+
if (!isCancellation(error)) decide(member, error);
|
|
7020
|
+
if (!cancellation.signal.aborted) cancellation.abort(error);
|
|
7021
|
+
throw error;
|
|
7022
|
+
} finally {
|
|
7023
|
+
completionOrder.push(member);
|
|
7024
|
+
}
|
|
7025
|
+
};
|
|
7026
|
+
const completeEffectBoundary = (
|
|
7027
|
+
completion: XStateRepositoryCohortCompletion<PlayerResult>,
|
|
7028
|
+
): Promise<XStateRepositoryCompletionEvidence> =>
|
|
7029
|
+
cohortCompletionQueue.add(async () => {
|
|
7030
|
+
const member = members.find(
|
|
7031
|
+
({ roleId }) => roleId === completion.roleId,
|
|
7032
|
+
);
|
|
7033
|
+
if (
|
|
7034
|
+
member === undefined ||
|
|
7035
|
+
completion.boundary.boundaryId !== member.effectBoundary.boundaryId
|
|
7036
|
+
) {
|
|
7037
|
+
throw new TypeError(
|
|
7038
|
+
`${label} repository cohort completed an undeclared member`,
|
|
7039
|
+
);
|
|
7040
|
+
}
|
|
7041
|
+
const evidence = await completionEvidenceFor(
|
|
7042
|
+
member.input,
|
|
7043
|
+
member.roleId,
|
|
7044
|
+
member.playerId,
|
|
7045
|
+
member.signal,
|
|
7046
|
+
undefined,
|
|
7047
|
+
)(completion);
|
|
7048
|
+
const settlement = governedSettlementsByBoundaryId.get(
|
|
7049
|
+
member.effectBoundary.boundaryId,
|
|
7050
|
+
);
|
|
7051
|
+
if (settlement?.status === 'unresolved') {
|
|
7052
|
+
decide(member, settlement.error);
|
|
7053
|
+
}
|
|
7054
|
+
return evidence;
|
|
7055
|
+
}) as Promise<XStateRepositoryCompletionEvidence>;
|
|
7056
|
+
const runSignal = activeSignal ?? members[0]!.signal;
|
|
7057
|
+
const invocationId = randomUUID();
|
|
7058
|
+
let settled: XStateRepositoryCohortResult<PlayerResult>;
|
|
7059
|
+
try {
|
|
7060
|
+
for (const member of members) member.signal.throwIfAborted();
|
|
7061
|
+
settled = await repositoryCapability!.runCohort!<PlayerResult>({
|
|
7062
|
+
signal: runSignal,
|
|
7063
|
+
invocationId,
|
|
7064
|
+
roleIds: members.map(({ roleId }) => roleId),
|
|
7065
|
+
dispositionsByRole: Object.fromEntries(
|
|
7066
|
+
members.map(({ roleId }) => [roleId, ['unchanged'] as const]),
|
|
7067
|
+
),
|
|
7068
|
+
effectBoundaries: Object.fromEntries(
|
|
7069
|
+
members.map(({ roleId, effectBoundary }) => [
|
|
7070
|
+
roleId,
|
|
7071
|
+
effectBoundary,
|
|
7072
|
+
]),
|
|
7073
|
+
),
|
|
7074
|
+
operations: Object.fromEntries(
|
|
7075
|
+
members.map((member) => [member.roleId, operationFor(member)]),
|
|
7076
|
+
),
|
|
7077
|
+
completeEffectBoundary,
|
|
7078
|
+
});
|
|
7079
|
+
if (!isPlainObject(settled) || settled.invocationId !== invocationId) {
|
|
7080
|
+
throw new TypeError(
|
|
7081
|
+
`${label} repository cohort changed its invocation identity`,
|
|
7082
|
+
);
|
|
7083
|
+
}
|
|
7084
|
+
} catch (error) {
|
|
7085
|
+
for (const member of members) {
|
|
7086
|
+
governedSettlementsByBoundaryId.delete(
|
|
7087
|
+
member.effectBoundary.boundaryId,
|
|
7088
|
+
);
|
|
7089
|
+
governedCompletionEvidenceByBoundaryId.delete(
|
|
7090
|
+
member.effectBoundary.boundaryId,
|
|
7091
|
+
);
|
|
7092
|
+
refreshGovernedBoundaryStart(member.effectBoundary.boundaryId);
|
|
7093
|
+
}
|
|
7094
|
+
if (!isAbortFailure(error, runSignal)) controlPlaneError ??= error;
|
|
7095
|
+
for (const member of members) member.settled.reject(error);
|
|
7096
|
+
return;
|
|
7097
|
+
}
|
|
7098
|
+
const releaseOrder = [
|
|
7099
|
+
...completionOrder,
|
|
7100
|
+
...members.filter((member) => !completionOrder.includes(member)),
|
|
7101
|
+
];
|
|
7102
|
+
const acknowledged = new Map<XStateCohortMember, PlayerResult>();
|
|
7103
|
+
const failures = new Map<XStateCohortMember, unknown>();
|
|
7104
|
+
for (const member of releaseOrder) {
|
|
7105
|
+
const operation = isPlainObject(settled.operations)
|
|
7106
|
+
? settled.operations[member.roleId]
|
|
7107
|
+
: undefined;
|
|
7108
|
+
try {
|
|
7109
|
+
const result = acknowledgeGovernedPlayerResult(
|
|
7110
|
+
{
|
|
7111
|
+
operation,
|
|
7112
|
+
receipt: isPlainObject(settled.receipts)
|
|
7113
|
+
? settled.receipts[member.roleId]
|
|
7114
|
+
: undefined,
|
|
7115
|
+
effectLedger: settled.effectLedger,
|
|
7116
|
+
},
|
|
7117
|
+
member.effectBoundary.boundaryId,
|
|
7118
|
+
'runCohort',
|
|
7119
|
+
);
|
|
7120
|
+
acknowledged.set(member, result);
|
|
7121
|
+
const settlement = governedPlayerSettlements.get(result);
|
|
7122
|
+
if (settlement?.status === 'unresolved') {
|
|
7123
|
+
failures.set(member, settlement.error);
|
|
7124
|
+
} else if (result.status !== 'ok') {
|
|
7125
|
+
failures.set(member, decided.get(member)?.error);
|
|
7126
|
+
}
|
|
7127
|
+
} catch (error) {
|
|
7128
|
+
failures.set(member, error);
|
|
7129
|
+
const operationReason =
|
|
7130
|
+
isPlainObject(operation) &&
|
|
7131
|
+
operation.status === 'rejected' &&
|
|
7132
|
+
Object.is(operation.reason, error);
|
|
7133
|
+
if (!operationReason) {
|
|
7134
|
+
// An acknowledgement the host could not prove is a control-plane
|
|
7135
|
+
// defect, exactly as on the exclusive path.
|
|
7136
|
+
if (!isAbortFailure(error, runSignal)) controlPlaneError ??= error;
|
|
7137
|
+
decide(member, error);
|
|
7138
|
+
}
|
|
7139
|
+
} finally {
|
|
7140
|
+
governedSettlementsByBoundaryId.delete(
|
|
7141
|
+
member.effectBoundary.boundaryId,
|
|
7142
|
+
);
|
|
7143
|
+
governedCompletionEvidenceByBoundaryId.delete(
|
|
7144
|
+
member.effectBoundary.boundaryId,
|
|
7145
|
+
);
|
|
7146
|
+
}
|
|
7147
|
+
}
|
|
7148
|
+
if (failures.size === 0) {
|
|
7149
|
+
for (const member of releaseOrder) {
|
|
7150
|
+
member.settled.resolve(acknowledged.get(member)!);
|
|
7151
|
+
}
|
|
7152
|
+
return;
|
|
7153
|
+
}
|
|
7154
|
+
for (const result of acknowledged.values()) {
|
|
7155
|
+
governedPlayerSettlements.delete(result);
|
|
7156
|
+
}
|
|
7157
|
+
const first = [...decided.values()].sort(
|
|
7158
|
+
(left, right) => left.sequence - right.sequence,
|
|
7159
|
+
)[0];
|
|
7160
|
+
const failure =
|
|
7161
|
+
first?.error ??
|
|
7162
|
+
failures.get(releaseOrder.find((member) => failures.has(member))!) ??
|
|
7163
|
+
markFsmResultFailure(
|
|
7164
|
+
new Error(
|
|
7165
|
+
`${label} parallel state ${parallelState.stateId} cohort failed`,
|
|
7166
|
+
),
|
|
7167
|
+
);
|
|
7168
|
+
const cause = first?.cause ?? failureCauses.causeOf(failure);
|
|
7169
|
+
if (cause !== undefined) {
|
|
7170
|
+
failureCauses.retain(failure, cause);
|
|
7171
|
+
attachPlaybookFailureCause(failure, cause);
|
|
7172
|
+
}
|
|
7173
|
+
for (const member of members) member.settled.reject(failure);
|
|
7174
|
+
}
|
|
7175
|
+
|
|
5675
7176
|
const boundary: RuntimeBoundaryCalls = {
|
|
5676
7177
|
async callPlayer(
|
|
5677
7178
|
input,
|
|
@@ -5729,16 +7230,192 @@ export function createXStatePlaybookRuntime<
|
|
|
5729
7230
|
};
|
|
5730
7231
|
|
|
5731
7232
|
const playerKey = continuationKey(roleId, playerId);
|
|
5732
|
-
|
|
7233
|
+
const runTracedPlayerCall = async (
|
|
7234
|
+
resume: string | false = selectedResume,
|
|
7235
|
+
// DR-062 §4: composed from this call's own baseline observation,
|
|
7236
|
+
// so the prompt the trace records is the prompt the player is sent.
|
|
7237
|
+
preExistingBlock?: string,
|
|
7238
|
+
// DR-067 §1: a cohort member's call signal also carries its
|
|
7239
|
+
// cohort's sibling cancellation.
|
|
7240
|
+
callSignal: AbortSignal = signal,
|
|
7241
|
+
cohortCancellation?: AbortSignal,
|
|
7242
|
+
): Promise<PlayerResult> => {
|
|
7243
|
+
const identity = callIdentity(resume);
|
|
7244
|
+
// DR-067 §1: a parallel machine classifies aborts per call, so a
|
|
7245
|
+
// trace sink rejecting with a sibling's cancellation is forgiven
|
|
7246
|
+
// for that call alone.
|
|
7247
|
+
const traceAborts =
|
|
7248
|
+
parallelProfile === undefined
|
|
7249
|
+
? undefined
|
|
7250
|
+
: abortReasonClassifier(callSignal, activeAborts);
|
|
7251
|
+
const withPreExisting = (text: string): string =>
|
|
7252
|
+
preExistingBlock === undefined
|
|
7253
|
+
? text
|
|
7254
|
+
: `${text}\n\n${preExistingBlock}`;
|
|
7255
|
+
const callPrompt = withPreExisting(
|
|
7256
|
+
resume === false ? freshPrompt : prompt,
|
|
7257
|
+
);
|
|
7258
|
+
const callFreshPrompt = withPreExisting(freshPrompt);
|
|
7259
|
+
await emitCallStarted(
|
|
7260
|
+
'player.call.started',
|
|
7261
|
+
'player.call.finished',
|
|
7262
|
+
{ ...identity, prompt: callPrompt },
|
|
7263
|
+
position,
|
|
7264
|
+
callSignal,
|
|
7265
|
+
undefined,
|
|
7266
|
+
traceAborts,
|
|
7267
|
+
);
|
|
7268
|
+
|
|
7269
|
+
let rawResult: unknown;
|
|
7270
|
+
try {
|
|
7271
|
+
// An abort may land while the awaited started emission drains
|
|
7272
|
+
// (e.g. fired from the trace sink itself); the host call must
|
|
7273
|
+
// never start after abort, so settle the already-started pair
|
|
7274
|
+
// as `aborted` through the catch below.
|
|
7275
|
+
callSignal.throwIfAborted();
|
|
7276
|
+
rawResult = await requireHostPorts().callPlayer(
|
|
7277
|
+
roleId,
|
|
7278
|
+
callPrompt,
|
|
7279
|
+
callSignal,
|
|
7280
|
+
{
|
|
7281
|
+
resume,
|
|
7282
|
+
...(callPrompt === callFreshPrompt
|
|
7283
|
+
? {}
|
|
7284
|
+
: { freshPrompt: callFreshPrompt }),
|
|
7285
|
+
},
|
|
7286
|
+
);
|
|
7287
|
+
// A host promise is not required to honor cancellation. Do not
|
|
7288
|
+
// let a late result mutate continuity or publish a successful
|
|
7289
|
+
// finish.
|
|
7290
|
+
callSignal.throwIfAborted();
|
|
7291
|
+
} catch (error) {
|
|
7292
|
+
if (!isAbortFailure(error, callSignal)) controlPlaneError ??= error;
|
|
7293
|
+
try {
|
|
7294
|
+
await emitTrace(
|
|
7295
|
+
'player.call.finished',
|
|
7296
|
+
{
|
|
7297
|
+
...identity,
|
|
7298
|
+
status: isAbortFailure(error, callSignal)
|
|
7299
|
+
? 'aborted'
|
|
7300
|
+
: 'error',
|
|
7301
|
+
error: normalizeError(error),
|
|
7302
|
+
},
|
|
7303
|
+
position,
|
|
7304
|
+
traceAborts,
|
|
7305
|
+
);
|
|
7306
|
+
} catch {
|
|
7307
|
+
// The original non-abort port rejection remains authoritative.
|
|
7308
|
+
}
|
|
7309
|
+
// A thrown port call carries no authoritative result, so the
|
|
7310
|
+
// prior token remains available for a later explicit resume.
|
|
7311
|
+
throw decidePlayerCallFailure(
|
|
7312
|
+
error,
|
|
7313
|
+
roleId,
|
|
7314
|
+
playerId,
|
|
7315
|
+
callSignal,
|
|
7316
|
+
cohortCancellation,
|
|
7317
|
+
);
|
|
7318
|
+
}
|
|
7319
|
+
|
|
7320
|
+
let result: PlayerResult;
|
|
7321
|
+
try {
|
|
7322
|
+
result = validatePlayerResult(rawResult);
|
|
7323
|
+
} catch (error) {
|
|
7324
|
+
if (!isAbortFailure(error, callSignal)) controlPlaneError ??= error;
|
|
7325
|
+
try {
|
|
7326
|
+
await emitTrace(
|
|
7327
|
+
'player.call.finished',
|
|
7328
|
+
{ ...identity, status: 'error', error: normalizeError(error) },
|
|
7329
|
+
position,
|
|
7330
|
+
traceAborts,
|
|
7331
|
+
);
|
|
7332
|
+
} catch {
|
|
7333
|
+
// The malformed host result remains authoritative.
|
|
7334
|
+
}
|
|
7335
|
+
throw decidePlayerCallFailure(
|
|
7336
|
+
error,
|
|
7337
|
+
roleId,
|
|
7338
|
+
playerId,
|
|
7339
|
+
callSignal,
|
|
7340
|
+
cohortCancellation,
|
|
7341
|
+
);
|
|
7342
|
+
}
|
|
7343
|
+
|
|
7344
|
+
try {
|
|
7345
|
+
updatePlayerResume(roleId, playerId, result);
|
|
7346
|
+
} catch (error) {
|
|
7347
|
+
if (!isAbortFailure(error, callSignal)) controlPlaneError ??= error;
|
|
7348
|
+
try {
|
|
7349
|
+
await emitTrace(
|
|
7350
|
+
'player.call.finished',
|
|
7351
|
+
{ ...identity, status: 'error', error: normalizeError(error) },
|
|
7352
|
+
position,
|
|
7353
|
+
traceAborts,
|
|
7354
|
+
);
|
|
7355
|
+
} catch {
|
|
7356
|
+
// The continuation-store failure remains authoritative.
|
|
7357
|
+
}
|
|
7358
|
+
throw error;
|
|
7359
|
+
}
|
|
7360
|
+
|
|
7361
|
+
await emitTrace(
|
|
7362
|
+
'player.call.finished',
|
|
7363
|
+
{
|
|
7364
|
+
...identity,
|
|
7365
|
+
status: result.status,
|
|
7366
|
+
...(result.finalText !== undefined
|
|
7367
|
+
? { finalText: result.finalText }
|
|
7368
|
+
: {}),
|
|
7369
|
+
...(result.error !== undefined
|
|
7370
|
+
? { error: normalizeError(result.error) }
|
|
7371
|
+
: {}),
|
|
7372
|
+
...(result.resumeToken !== undefined
|
|
7373
|
+
? { resumeToken: result.resumeToken }
|
|
7374
|
+
: {}),
|
|
7375
|
+
},
|
|
7376
|
+
position,
|
|
7377
|
+
traceAborts,
|
|
7378
|
+
);
|
|
7379
|
+
// DR-063 §2: the player bridge builds the failure Error for a
|
|
7380
|
+
// non-`ok` result, so the cause is decided here, where the role,
|
|
7381
|
+
// the player, and the reported error are known, and a successful
|
|
7382
|
+
// result clears it so no later failure inherits it.
|
|
7383
|
+
if (result.status === 'ok') {
|
|
7384
|
+
pendingResultFailureCause = undefined;
|
|
7385
|
+
} else if (result.status === 'aborted') {
|
|
7386
|
+
pendingResultFailureCause = ABORTED_FAILURE_CAUSE;
|
|
7387
|
+
} else {
|
|
7388
|
+
pendingResultFailureCause = playerFailureCause({
|
|
7389
|
+
roleId,
|
|
7390
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
7391
|
+
error:
|
|
7392
|
+
result.error ??
|
|
7393
|
+
`callPlayer status ${JSON.stringify(result.status)}`,
|
|
7394
|
+
});
|
|
7395
|
+
}
|
|
7396
|
+
if (pendingResultFailureCause !== undefined) {
|
|
7397
|
+
resultFailureCauses.set(result, pendingResultFailureCause);
|
|
7398
|
+
}
|
|
7399
|
+
return result;
|
|
7400
|
+
};
|
|
7401
|
+
const reportPlayerKeyCollision = async (
|
|
7402
|
+
callSignal: AbortSignal,
|
|
7403
|
+
): Promise<never> => {
|
|
5733
7404
|
const error = new Error(
|
|
5734
7405
|
`simultaneous calls to player key ${playerKey} are not allowed`,
|
|
5735
7406
|
);
|
|
7407
|
+
const traceAborts =
|
|
7408
|
+
parallelProfile === undefined
|
|
7409
|
+
? undefined
|
|
7410
|
+
: abortReasonClassifier(callSignal, activeAborts);
|
|
5736
7411
|
await emitCallStarted(
|
|
5737
7412
|
'player.call.started',
|
|
5738
7413
|
'player.call.finished',
|
|
5739
7414
|
{ ...callIdentity(selectedResume), prompt },
|
|
5740
7415
|
position,
|
|
5741
|
-
|
|
7416
|
+
callSignal,
|
|
7417
|
+
undefined,
|
|
7418
|
+
traceAborts,
|
|
5742
7419
|
);
|
|
5743
7420
|
await emitTrace(
|
|
5744
7421
|
'player.call.finished',
|
|
@@ -5748,147 +7425,63 @@ export function createXStatePlaybookRuntime<
|
|
|
5748
7425
|
error: normalizeError(error),
|
|
5749
7426
|
},
|
|
5750
7427
|
position,
|
|
7428
|
+
traceAborts,
|
|
5751
7429
|
);
|
|
5752
7430
|
throw error;
|
|
5753
|
-
}
|
|
5754
|
-
activePlayerKeys.add(playerKey);
|
|
7431
|
+
};
|
|
5755
7432
|
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
signal,
|
|
7433
|
+
// DR-067 §1: the working leaves of one parallel state entered
|
|
7434
|
+
// together run as one all-`unchanged` repository cohort, at most once
|
|
7435
|
+
// per public boundary; a corrective re-ask and a single region
|
|
7436
|
+
// resuming after its Boss reply take the exclusive path below.
|
|
7437
|
+
const cohortState =
|
|
7438
|
+
deferredContinuation === undefined
|
|
7439
|
+
? cohortParallelStateFor(input.stateId)
|
|
7440
|
+
: undefined;
|
|
7441
|
+
if (cohortState !== undefined) {
|
|
7442
|
+
const effectBoundary = governedBoundarySeed(
|
|
7443
|
+
input,
|
|
7444
|
+
roleId,
|
|
7445
|
+
callId,
|
|
7446
|
+
turnId,
|
|
7447
|
+
);
|
|
7448
|
+
if (
|
|
7449
|
+
effectBoundary === undefined ||
|
|
7450
|
+
repositoryCapability?.runCohort === undefined
|
|
7451
|
+
) {
|
|
7452
|
+
throw new Error(
|
|
7453
|
+
`${label} parallel state ${cohortState.stateId} requires governed player states and repository.runCohort`,
|
|
5778
7454
|
);
|
|
7455
|
+
}
|
|
7456
|
+
activeGovernedBoundarySeen = true;
|
|
7457
|
+
return await joinCohort(cohortState, {
|
|
7458
|
+
region: cohortState.regions.find(
|
|
7459
|
+
({ workingStateId }) => workingStateId === input.stateId,
|
|
7460
|
+
)!,
|
|
7461
|
+
input,
|
|
7462
|
+
roleId,
|
|
7463
|
+
playerId,
|
|
7464
|
+
playerKey,
|
|
7465
|
+
signal,
|
|
7466
|
+
effectBoundary,
|
|
7467
|
+
run: (callSignal, cohortCancellation, preExistingBlock) =>
|
|
7468
|
+
runTracedPlayerCall(
|
|
7469
|
+
selectedResume,
|
|
7470
|
+
preExistingBlock,
|
|
7471
|
+
callSignal,
|
|
7472
|
+
cohortCancellation,
|
|
7473
|
+
),
|
|
7474
|
+
reportCollision: reportPlayerKeyCollision,
|
|
7475
|
+
settled: deferredValue<PlayerResult>(),
|
|
7476
|
+
});
|
|
7477
|
+
}
|
|
5779
7478
|
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
// never start after abort, so settle the already-started pair
|
|
5785
|
-
// as `aborted` through the catch below.
|
|
5786
|
-
signal.throwIfAborted();
|
|
5787
|
-
rawResult = await requireHostPorts().callPlayer(
|
|
5788
|
-
roleId,
|
|
5789
|
-
callPrompt,
|
|
5790
|
-
signal,
|
|
5791
|
-
{
|
|
5792
|
-
resume,
|
|
5793
|
-
...(callPrompt === callFreshPrompt
|
|
5794
|
-
? {}
|
|
5795
|
-
: { freshPrompt: callFreshPrompt }),
|
|
5796
|
-
},
|
|
5797
|
-
);
|
|
5798
|
-
// A host promise is not required to honor cancellation. Do not
|
|
5799
|
-
// let a late result mutate continuity or publish a successful
|
|
5800
|
-
// finish.
|
|
5801
|
-
signal.throwIfAborted();
|
|
5802
|
-
} catch (error) {
|
|
5803
|
-
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
5804
|
-
try {
|
|
5805
|
-
await emitTrace(
|
|
5806
|
-
'player.call.finished',
|
|
5807
|
-
{
|
|
5808
|
-
...identity,
|
|
5809
|
-
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
5810
|
-
error: normalizeError(error),
|
|
5811
|
-
},
|
|
5812
|
-
position,
|
|
5813
|
-
);
|
|
5814
|
-
} catch {
|
|
5815
|
-
// The original non-abort port rejection remains authoritative.
|
|
5816
|
-
}
|
|
5817
|
-
// A thrown port call carries no authoritative result, so the
|
|
5818
|
-
// prior token remains available for a later explicit resume.
|
|
5819
|
-
throw decidePlayerCallFailure(error, roleId, playerId, signal);
|
|
5820
|
-
}
|
|
5821
|
-
|
|
5822
|
-
let result: PlayerResult;
|
|
5823
|
-
try {
|
|
5824
|
-
result = validatePlayerResult(rawResult);
|
|
5825
|
-
} catch (error) {
|
|
5826
|
-
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
5827
|
-
try {
|
|
5828
|
-
await emitTrace(
|
|
5829
|
-
'player.call.finished',
|
|
5830
|
-
{ ...identity, status: 'error', error: normalizeError(error) },
|
|
5831
|
-
position,
|
|
5832
|
-
);
|
|
5833
|
-
} catch {
|
|
5834
|
-
// The malformed host result remains authoritative.
|
|
5835
|
-
}
|
|
5836
|
-
throw decidePlayerCallFailure(error, roleId, playerId, signal);
|
|
5837
|
-
}
|
|
5838
|
-
|
|
5839
|
-
try {
|
|
5840
|
-
updatePlayerResume(roleId, playerId, result);
|
|
5841
|
-
} catch (error) {
|
|
5842
|
-
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
5843
|
-
try {
|
|
5844
|
-
await emitTrace(
|
|
5845
|
-
'player.call.finished',
|
|
5846
|
-
{ ...identity, status: 'error', error: normalizeError(error) },
|
|
5847
|
-
position,
|
|
5848
|
-
);
|
|
5849
|
-
} catch {
|
|
5850
|
-
// The continuation-store failure remains authoritative.
|
|
5851
|
-
}
|
|
5852
|
-
throw error;
|
|
5853
|
-
}
|
|
5854
|
-
|
|
5855
|
-
await emitTrace(
|
|
5856
|
-
'player.call.finished',
|
|
5857
|
-
{
|
|
5858
|
-
...identity,
|
|
5859
|
-
status: result.status,
|
|
5860
|
-
...(result.finalText !== undefined
|
|
5861
|
-
? { finalText: result.finalText }
|
|
5862
|
-
: {}),
|
|
5863
|
-
...(result.error !== undefined
|
|
5864
|
-
? { error: normalizeError(result.error) }
|
|
5865
|
-
: {}),
|
|
5866
|
-
...(result.resumeToken !== undefined
|
|
5867
|
-
? { resumeToken: result.resumeToken }
|
|
5868
|
-
: {}),
|
|
5869
|
-
},
|
|
5870
|
-
position,
|
|
5871
|
-
);
|
|
5872
|
-
// DR-063 §2: the player bridge builds the failure Error for a
|
|
5873
|
-
// non-`ok` result, so the cause is decided here, where the role,
|
|
5874
|
-
// the player, and the reported error are known, and a successful
|
|
5875
|
-
// result clears it so no later failure inherits it.
|
|
5876
|
-
if (result.status === 'ok') {
|
|
5877
|
-
pendingResultFailureCause = undefined;
|
|
5878
|
-
} else if (result.status === 'aborted') {
|
|
5879
|
-
pendingResultFailureCause = ABORTED_FAILURE_CAUSE;
|
|
5880
|
-
} else {
|
|
5881
|
-
pendingResultFailureCause = playerFailureCause({
|
|
5882
|
-
roleId,
|
|
5883
|
-
...(playerId === undefined ? {} : { playerId }),
|
|
5884
|
-
error:
|
|
5885
|
-
result.error ??
|
|
5886
|
-
`callPlayer status ${JSON.stringify(result.status)}`,
|
|
5887
|
-
});
|
|
5888
|
-
}
|
|
5889
|
-
return result;
|
|
5890
|
-
};
|
|
7479
|
+
if (activePlayerKeys.has(playerKey)) {
|
|
7480
|
+
await reportPlayerKeyCollision(signal);
|
|
7481
|
+
}
|
|
7482
|
+
activePlayerKeys.add(playerKey);
|
|
5891
7483
|
|
|
7484
|
+
try {
|
|
5892
7485
|
const effectBoundary = governedBoundarySeed(
|
|
5893
7486
|
input,
|
|
5894
7487
|
roleId,
|
|
@@ -5955,14 +7548,23 @@ export function createXStatePlaybookRuntime<
|
|
|
5955
7548
|
// captured it under this exclusive claim. A host that supplies
|
|
5956
7549
|
// none states no pre-existing change, exactly as an empty
|
|
5957
7550
|
// baseline projection does.
|
|
5958
|
-
operation: (context) =>
|
|
5959
|
-
|
|
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(
|
|
5960
7561
|
selectedResume,
|
|
5961
7562
|
effectAuthorizedPreExistingBlock(
|
|
5962
7563
|
effectBoundary,
|
|
5963
7564
|
context?.baseline,
|
|
5964
7565
|
),
|
|
5965
|
-
)
|
|
7566
|
+
);
|
|
7567
|
+
},
|
|
5966
7568
|
completeEffectBoundary: completionEvidenceFor(
|
|
5967
7569
|
input,
|
|
5968
7570
|
roleId,
|
|
@@ -5993,10 +7595,13 @@ export function createXStatePlaybookRuntime<
|
|
|
5993
7595
|
}
|
|
5994
7596
|
},
|
|
5995
7597
|
|
|
5996
|
-
markPlayerResultFailure(error): Error {
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
7598
|
+
markPlayerResultFailure(error, result): Error {
|
|
7599
|
+
const cause =
|
|
7600
|
+
(result === undefined ? undefined : resultFailureCauses.get(result)) ??
|
|
7601
|
+
pendingResultFailureCause;
|
|
7602
|
+
if (cause === undefined) return error;
|
|
7603
|
+
failureCauses.retain(error, cause);
|
|
7604
|
+
return attachPlaybookFailureCause(error, cause);
|
|
6000
7605
|
},
|
|
6001
7606
|
|
|
6002
7607
|
takeGovernedPlayerOutput(result): GovernedPlayerSettlement | undefined {
|
|
@@ -6237,12 +7842,23 @@ export function createXStatePlaybookRuntime<
|
|
|
6237
7842
|
}) as Promise<CaptainResult>;
|
|
6238
7843
|
},
|
|
6239
7844
|
};
|
|
7845
|
+
if (parallelProfile !== undefined) {
|
|
7846
|
+
// DR-067 §1: a parallel machine's boundary drain waits for every player
|
|
7847
|
+
// and judge call it started.
|
|
7848
|
+
const untrackedCallPlayer = boundary.callPlayer;
|
|
7849
|
+
const untrackedCallJudge = boundary.callJudge;
|
|
7850
|
+
boundary.callPlayer = (input, roleId, freshPrompt, signal) =>
|
|
7851
|
+
trackBoundaryCall(untrackedCallPlayer(input, roleId, freshPrompt, signal));
|
|
7852
|
+
boundary.callJudge = (purpose, stateId, prompt, signal) =>
|
|
7853
|
+
trackBoundaryCall(untrackedCallJudge(purpose, stateId, prompt, signal));
|
|
7854
|
+
}
|
|
6240
7855
|
|
|
6241
7856
|
function playerActor(
|
|
6242
7857
|
ports: PlaybookPorts,
|
|
6243
7858
|
): PromiseActorLogic<PlaybookActorOutput, PlaybookPlayerInput> {
|
|
6244
7859
|
return createPlayerBridge(
|
|
6245
7860
|
{
|
|
7861
|
+
run: (input, signal, execute) => runJournalledStep('player', input, signal, execute),
|
|
6246
7862
|
resolveRoleId: requireRoleId,
|
|
6247
7863
|
validateInput: (input) =>
|
|
6248
7864
|
assertGovernedPlayerInput(
|
|
@@ -6276,7 +7892,7 @@ export function createXStatePlaybookRuntime<
|
|
|
6276
7892
|
PlaybookCaptainInput
|
|
6277
7893
|
> {
|
|
6278
7894
|
return fromPromise<PlaybookActorOutput, PlaybookCaptainInput>(
|
|
6279
|
-
|
|
7895
|
+
({ input, signal }) => runJournalledStep('captain', input, signal, async () => {
|
|
6280
7896
|
const active = combineAbortSignals(signal, activeSignal);
|
|
6281
7897
|
try {
|
|
6282
7898
|
await drainEmissions();
|
|
@@ -6353,7 +7969,7 @@ export function createXStatePlaybookRuntime<
|
|
|
6353
7969
|
}
|
|
6354
7970
|
throw error;
|
|
6355
7971
|
}
|
|
6356
|
-
},
|
|
7972
|
+
}),
|
|
6357
7973
|
);
|
|
6358
7974
|
}
|
|
6359
7975
|
|
|
@@ -6367,7 +7983,7 @@ export function createXStatePlaybookRuntime<
|
|
|
6367
7983
|
PlaybookScriptInput
|
|
6368
7984
|
> {
|
|
6369
7985
|
return fromPromise<PlaybookActorOutput, PlaybookScriptInput>(
|
|
6370
|
-
|
|
7986
|
+
({ input, signal }) => runJournalledStep('script', input, signal, async () => {
|
|
6371
7987
|
await drainEmissions();
|
|
6372
7988
|
const active = combineAbortSignals(signal, activeSignal);
|
|
6373
7989
|
const guards = Object.keys(input.result);
|
|
@@ -6556,15 +8172,19 @@ export function createXStatePlaybookRuntime<
|
|
|
6556
8172
|
if (killTimer !== undefined) clearTimeout(killTimer);
|
|
6557
8173
|
}
|
|
6558
8174
|
}
|
|
6559
|
-
},
|
|
8175
|
+
}),
|
|
6560
8176
|
);
|
|
6561
8177
|
}
|
|
6562
8178
|
|
|
6563
8179
|
const nestedBridge = createNestedPlaybookBridge({
|
|
6564
8180
|
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
6565
8181
|
getBoundarySignal: () => activeSignal,
|
|
6566
|
-
callPlaybook: (request, signal) =>
|
|
6567
|
-
requireHostPorts().callPlaybook(request, signal)
|
|
8182
|
+
callPlaybook: (request, signal) => {
|
|
8183
|
+
const call = requireHostPorts().callPlaybook(request, signal);
|
|
8184
|
+
return parallelProfile === undefined
|
|
8185
|
+
? call
|
|
8186
|
+
: trackBoundaryCall(Promise.resolve(call));
|
|
8187
|
+
},
|
|
6568
8188
|
emitStarted: async (event, aborts) => {
|
|
6569
8189
|
playbookCallTurnIds.set(event.callId, activeTurnId);
|
|
6570
8190
|
if (hasGovernedPlayerStates) {
|
|
@@ -6615,7 +8235,8 @@ export function createXStatePlaybookRuntime<
|
|
|
6615
8235
|
activeAborts = aborts ?? abortReasonClassifier(signal);
|
|
6616
8236
|
},
|
|
6617
8237
|
bindActorSettlement: (aborts) => {
|
|
6618
|
-
actorSettlementAborts =
|
|
8238
|
+
if (parallelProfile === undefined) actorSettlementAborts.length = 0;
|
|
8239
|
+
actorSettlementAborts.push(aborts);
|
|
6619
8240
|
},
|
|
6620
8241
|
onControlPlaneError: (error, aborts) => {
|
|
6621
8242
|
// The shared bridge classifies before reporting against its own
|
|
@@ -6662,14 +8283,44 @@ export function createXStatePlaybookRuntime<
|
|
|
6662
8283
|
};
|
|
6663
8284
|
}
|
|
6664
8285
|
|
|
8286
|
+
// DR-063 §2: the failure that parks the machine is the error its parking
|
|
8287
|
+
// transition carried, but a compiled machine keeps a JSON-safe record of
|
|
8288
|
+
// it as `lastError` (gears2fsm requires JSON-safe context), so neither
|
|
8289
|
+
// that value's identity nor its marker reaches the failed state. The cause
|
|
8290
|
+
// decided for the carried error is retained under the value the machine
|
|
8291
|
+
// kept, so every surface of this turn and of each later one publishes the
|
|
8292
|
+
// runtime's own decision for as long as that failure stands. A kept value
|
|
8293
|
+
// that already answers keeps its cause.
|
|
8294
|
+
function bindParkedFailureCause(
|
|
8295
|
+
context: Record<string, unknown>,
|
|
8296
|
+
event: unknown,
|
|
8297
|
+
): void {
|
|
8298
|
+
const kept = context.lastError;
|
|
8299
|
+
if (kept === undefined || kept === null) return;
|
|
8300
|
+
if (failureCauses.causeOf(kept) !== undefined) return;
|
|
8301
|
+
const decided = failureCauses.causeOf(actorErrorOf(event));
|
|
8302
|
+
if (decided !== undefined) failureCauses.retain(kept, decided);
|
|
8303
|
+
}
|
|
8304
|
+
|
|
6665
8305
|
// DR-063 §2: a rejected player port or a result the runtime cannot read is
|
|
6666
|
-
// a `player-failed` failure named by role; an abort is `aborted`.
|
|
8306
|
+
// a `player-failed` failure named by role; an abort is `aborted`. A
|
|
8307
|
+
// cancellation the runtime itself issued to a cohort member because its
|
|
8308
|
+
// sibling failed is not this player's failure and decides nothing, so the
|
|
8309
|
+
// originator's cause stands (DR-067 §1, playbook-55).
|
|
6667
8310
|
function decidePlayerCallFailure(
|
|
6668
8311
|
error: unknown,
|
|
6669
8312
|
roleId: string,
|
|
6670
8313
|
playerId: string | undefined,
|
|
6671
8314
|
signal: AbortSignal,
|
|
8315
|
+
cohortCancellation?: AbortSignal,
|
|
6672
8316
|
): unknown {
|
|
8317
|
+
if (
|
|
8318
|
+
cohortCancellation?.aborted === true &&
|
|
8319
|
+
Object.is(error, cohortCancellation.reason) &&
|
|
8320
|
+
!activeAborts?.isAbortReason(error)
|
|
8321
|
+
) {
|
|
8322
|
+
return error;
|
|
8323
|
+
}
|
|
6673
8324
|
const cause = isAbortFailure(error, signal)
|
|
6674
8325
|
? ABORTED_FAILURE_CAUSE
|
|
6675
8326
|
: playerFailureCause({
|
|
@@ -6728,12 +8379,21 @@ export function createXStatePlaybookRuntime<
|
|
|
6728
8379
|
previousState: previousState ?? null,
|
|
6729
8380
|
state,
|
|
6730
8381
|
};
|
|
6731
|
-
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
6735
|
-
|
|
6736
|
-
|
|
8382
|
+
if (parallelProfile === undefined) {
|
|
8383
|
+
const pendingBossQuestion = pendingBossQuestionForState(state, context);
|
|
8384
|
+
if (
|
|
8385
|
+
pendingBossQuestion !== undefined &&
|
|
8386
|
+
!hasUnresolvedReconciliation()
|
|
8387
|
+
) {
|
|
8388
|
+
payload.pendingBossQuestion = pendingBossQuestion;
|
|
8389
|
+
}
|
|
8390
|
+
} else if (!hasUnresolvedReconciliation()) {
|
|
8391
|
+
// DR-067 §1: a parallel machine's telemetry carries every question
|
|
8392
|
+
// pending in an active wait, in the plural.
|
|
8393
|
+
const pendingBossQuestions = pendingBossQuestionsFor(state, context);
|
|
8394
|
+
if (pendingBossQuestions.length > 0) {
|
|
8395
|
+
payload.pendingBossQuestions = pendingBossQuestions;
|
|
8396
|
+
}
|
|
6737
8397
|
}
|
|
6738
8398
|
if (state.stateId === 'failed') {
|
|
6739
8399
|
const lastError = failedStateError(context);
|
|
@@ -6768,7 +8428,7 @@ export function createXStatePlaybookRuntime<
|
|
|
6768
8428
|
message,
|
|
6769
8429
|
...(data === undefined ? {} : { data }),
|
|
6770
8430
|
state,
|
|
6771
|
-
...stateIdentity(state
|
|
8431
|
+
...stateIdentity(statusTraceStateId(state)),
|
|
6772
8432
|
},
|
|
6773
8433
|
position,
|
|
6774
8434
|
),
|
|
@@ -6818,8 +8478,7 @@ export function createXStatePlaybookRuntime<
|
|
|
6818
8478
|
function consumeActorSettlementAborts(
|
|
6819
8479
|
forSnapshot = false,
|
|
6820
8480
|
): AbortReasonClassifier | undefined {
|
|
6821
|
-
const aborts = actorSettlementAborts ?? actorSettlementErrorAborts;
|
|
6822
|
-
actorSettlementAborts = undefined;
|
|
8481
|
+
const aborts = actorSettlementAborts.shift() ?? actorSettlementErrorAborts;
|
|
6823
8482
|
actorSettlementErrorAborts = undefined;
|
|
6824
8483
|
if (forSnapshot && aborts !== undefined) {
|
|
6825
8484
|
// XState can report an errored root through both its inspection
|
|
@@ -6891,7 +8550,14 @@ export function createXStatePlaybookRuntime<
|
|
|
6891
8550
|
try {
|
|
6892
8551
|
const snap = inspectionEvent.snapshot;
|
|
6893
8552
|
const state = normalizePlaybookSnapshot(snap);
|
|
6894
|
-
if (
|
|
8553
|
+
if (
|
|
8554
|
+
state.stateId === undefined &&
|
|
8555
|
+
!(
|
|
8556
|
+
parallelProfile?.states.some(({ stateId }) =>
|
|
8557
|
+
state.activeStateIds.includes(stateId),
|
|
8558
|
+
) ?? false
|
|
8559
|
+
)
|
|
8560
|
+
) {
|
|
6895
8561
|
throw new Error(
|
|
6896
8562
|
`${label} root snapshot must expose exactly one playbook state id`,
|
|
6897
8563
|
);
|
|
@@ -6908,6 +8574,10 @@ export function createXStatePlaybookRuntime<
|
|
|
6908
8574
|
latchRuntimeError(error);
|
|
6909
8575
|
}
|
|
6910
8576
|
if (state.stateId === 'failed') {
|
|
8577
|
+
if (previousState?.stateId !== 'failed' &&
|
|
8578
|
+
previousState?.stateId !== recoveryCheckpoint?.stateId) {
|
|
8579
|
+
recoveryCheckpoint = undefined;
|
|
8580
|
+
}
|
|
6911
8581
|
if (
|
|
6912
8582
|
previousState?.stateId !== 'failed' ||
|
|
6913
8583
|
activeGovernedBoundarySeen
|
|
@@ -6921,6 +8591,9 @@ export function createXStatePlaybookRuntime<
|
|
|
6921
8591
|
}
|
|
6922
8592
|
const context = ((snap as { context?: unknown }).context ??
|
|
6923
8593
|
{}) as Record<string, unknown>;
|
|
8594
|
+
if (state.stateId === 'failed') {
|
|
8595
|
+
bindParkedFailureCause(context, inspectionEvent.event);
|
|
8596
|
+
}
|
|
6924
8597
|
if (
|
|
6925
8598
|
state.stateId === BOSS_REPLY_WAIT_STATE_ID &&
|
|
6926
8599
|
deferredReconciliationOperationId !== undefined
|
|
@@ -6942,7 +8615,9 @@ export function createXStatePlaybookRuntime<
|
|
|
6942
8615
|
context,
|
|
6943
8616
|
);
|
|
6944
8617
|
const stateStatuses = completeFailedStatuses(
|
|
6945
|
-
|
|
8618
|
+
bossRelevantStateIds !== undefined && usesDefaultStatuses
|
|
8619
|
+
? parallelStatusesFor(previousState, state, context)
|
|
8620
|
+
: statusesForState(state, context, inspectionEvent.event),
|
|
6946
8621
|
state,
|
|
6947
8622
|
context,
|
|
6948
8623
|
);
|
|
@@ -7149,13 +8824,17 @@ export function createXStatePlaybookRuntime<
|
|
|
7149
8824
|
emissionQueue.clear();
|
|
7150
8825
|
judgeQueue.clear();
|
|
7151
8826
|
appliedReceipts.clear();
|
|
8827
|
+
pendingCohorts.clear();
|
|
8828
|
+
consumedCohortBoundaries.clear();
|
|
8829
|
+
activeBoundaryCalls.clear();
|
|
8830
|
+
cohortCompletionQueue.clear();
|
|
7152
8831
|
actor = undefined;
|
|
7153
8832
|
session = undefined;
|
|
7154
8833
|
savedPorts = undefined;
|
|
7155
8834
|
runtimePorts = undefined;
|
|
7156
8835
|
activeSignal = undefined;
|
|
7157
8836
|
activeAborts = undefined;
|
|
7158
|
-
actorSettlementAborts =
|
|
8837
|
+
actorSettlementAborts.length = 0;
|
|
7159
8838
|
actorSettlementErrorAborts = undefined;
|
|
7160
8839
|
activeAbortEmission = undefined;
|
|
7161
8840
|
activeTurnId = undefined;
|
|
@@ -7183,7 +8862,7 @@ export function createXStatePlaybookRuntime<
|
|
|
7183
8862
|
activeDeferredContinuation = undefined;
|
|
7184
8863
|
deferInspectionEmissions = false;
|
|
7185
8864
|
deferredInspectionEmissions = [];
|
|
7186
|
-
|
|
8865
|
+
recoveryCheckpoint = undefined;
|
|
7187
8866
|
suppressInspectionEmissions = false;
|
|
7188
8867
|
initialized = false;
|
|
7189
8868
|
traceSequence = 0;
|
|
@@ -7205,6 +8884,9 @@ export function createXStatePlaybookRuntime<
|
|
|
7205
8884
|
event?: EventObject;
|
|
7206
8885
|
deferredRestoreOperationId?: string;
|
|
7207
8886
|
unresolvedEffectAction?: 'reconcile' | 'abandon';
|
|
8887
|
+
retryStep?: true;
|
|
8888
|
+
retryJudgment?: PlaybookEffectBoundary;
|
|
8889
|
+
restoreReadOnly?: PlaybookEffectBoundary;
|
|
7208
8890
|
}
|
|
7209
8891
|
|
|
7210
8892
|
function snapshotCan(snapshot: unknown, event: EventObject): boolean {
|
|
@@ -7216,78 +8898,24 @@ export function createXStatePlaybookRuntime<
|
|
|
7216
8898
|
);
|
|
7217
8899
|
}
|
|
7218
8900
|
|
|
7219
|
-
|
|
7220
|
-
|
|
7221
|
-
|
|
7222
|
-
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
// falling back to the record, which would make the action depend on the
|
|
7231
|
-
// process again — the very thing this source exists to end.
|
|
7232
|
-
function retryEventFrom(snapshot: unknown): EventObject | undefined {
|
|
7233
|
-
const entryEvent = spec.entryEvent;
|
|
7234
|
-
if (entryEvent?.contextField === undefined) return lastBossEvent;
|
|
7235
|
-
const context = (snapshot as { context?: unknown } | null)?.context;
|
|
7236
|
-
const text = isPlainObject(context)
|
|
7237
|
-
? context[entryEvent.contextField]
|
|
7238
|
-
: undefined;
|
|
7239
|
-
if (typeof text !== 'string' || text.trim() === '') return undefined;
|
|
7240
|
-
return { type: entryEvent.type, [entryEvent.textField]: text };
|
|
7241
|
-
}
|
|
7242
|
-
|
|
7243
|
-
// The failure-state retry entry replays the recorded last classified
|
|
7244
|
-
// event with its recorded payload, or the entry event the declared
|
|
7245
|
-
// context member above sources. A candidate whose event the live
|
|
7246
|
-
// snapshot does not accept — or whose payload the runtime can source
|
|
7247
|
-
// from neither — is excluded rather than completed with invented text.
|
|
7248
|
-
function retryActionFor(
|
|
7249
|
-
snapshot: unknown,
|
|
7250
|
-
stateId: string | undefined,
|
|
7251
|
-
): DerivedControlAction | undefined {
|
|
7252
|
-
if (stateId !== 'failed') return undefined;
|
|
7253
|
-
if (!failedAttemptAllowsReplay()) return undefined;
|
|
7254
|
-
const retryEvent = retryEventFrom(snapshot);
|
|
7255
|
-
if (retryEvent === undefined) return undefined;
|
|
7256
|
-
if (!snapshotCan(snapshot, retryEvent)) return undefined;
|
|
7257
|
-
// A recorded explicit-state-jump event names the exact state its
|
|
7258
|
-
// replay re-enters: the root BOSS_INTERRUPT shape is a guarded
|
|
7259
|
-
// multi-arm list keyed on `targetId`, so the first configured arm
|
|
7260
|
-
// may label a different state than the one the recorded event
|
|
7261
|
-
// actually resumes.
|
|
7262
|
-
const recordedTargetId =
|
|
7263
|
-
retryEvent.type === JUMP_EVENT_TYPE
|
|
7264
|
-
? (retryEvent as { targetId?: unknown }).targetId
|
|
7265
|
-
: undefined;
|
|
7266
|
-
const target =
|
|
7267
|
-
typeof recordedTargetId === 'string' &&
|
|
7268
|
-
recordedTargetId.trim().length > 0
|
|
7269
|
-
? recordedTargetId
|
|
7270
|
-
: firstTransitionTarget(machine, stateId, retryEvent.type);
|
|
7271
|
-
// PBRT-52: a label is written from a source state description, never
|
|
7272
|
-
// from an identifier. Falling back to the target id — or, with no
|
|
7273
|
-
// resolvable target, to the FSM event type — makes the label *be* the
|
|
7274
|
-
// internal name, which defeats the substitution the label exists for
|
|
7275
|
-
// and puts a machine identifier into Boss-facing text
|
|
7276
|
-
// (CAPPLAY-5). A candidate whose label can only be an id is excluded
|
|
7277
|
-
// exactly like one whose payload cannot be sourced.
|
|
7278
|
-
const description =
|
|
7279
|
-
(target === undefined ? undefined : stateDescriptions.get(target)) ??
|
|
7280
|
-
stateDescriptions.get(stateId);
|
|
7281
|
-
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);
|
|
7282
8912
|
return {
|
|
7283
8913
|
action: {
|
|
7284
|
-
id: `retry:${
|
|
7285
|
-
label:
|
|
7286
|
-
// DR-063 §3: retrying a failed call is `ready` — the runtime cannot
|
|
7287
|
-
// 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}`,
|
|
7288
8916
|
standing: 'ready',
|
|
7289
8917
|
},
|
|
7290
|
-
|
|
8918
|
+
retryStep: true,
|
|
7291
8919
|
};
|
|
7292
8920
|
}
|
|
7293
8921
|
|
|
@@ -7344,6 +8972,18 @@ export function createXStatePlaybookRuntime<
|
|
|
7344
8972
|
return [];
|
|
7345
8973
|
}
|
|
7346
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;
|
|
7347
8987
|
if (hasUnresolvedReconciliation()) {
|
|
7348
8988
|
const operation =
|
|
7349
8989
|
deferredReconciliationOperationId === undefined
|
|
@@ -7380,8 +9020,13 @@ export function createXStatePlaybookRuntime<
|
|
|
7380
9020
|
);
|
|
7381
9021
|
return derived;
|
|
7382
9022
|
}
|
|
7383
|
-
const retry = retryActionFor(
|
|
9023
|
+
const retry = retryActionFor(state.stateId);
|
|
7384
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.
|
|
9027
|
+
if (state.stateId === 'failed' && !failedAttemptAllowsReplay()) {
|
|
9028
|
+
return derived;
|
|
9029
|
+
}
|
|
7385
9030
|
// Jump entries: resumable targets whose explicit-state-jump event the
|
|
7386
9031
|
// live snapshot accepts (state guards included), sent with the
|
|
7387
9032
|
// advertised target id and optional textual fields omitted.
|
|
@@ -7447,9 +9092,12 @@ export function createXStatePlaybookRuntime<
|
|
|
7447
9092
|
// state whose source declares no description publishes none: an id is
|
|
7448
9093
|
// never promoted into a description by default.
|
|
7449
9094
|
function stateDescriptionFor(state: PlaybookState): string | undefined {
|
|
9095
|
+
// DR-067 §1: inside a parallel state, the meaning published is the
|
|
9096
|
+
// root state's — the parallel parent's own description.
|
|
7450
9097
|
const keys = [
|
|
7451
9098
|
...(state.stateId === undefined ? [] : [state.stateId]),
|
|
7452
9099
|
...(typeof state.value === 'string' ? [state.value] : []),
|
|
9100
|
+
...(isPlainObject(state.value) ? Object.keys(state.value) : []),
|
|
7453
9101
|
...state.activeStateIds,
|
|
7454
9102
|
];
|
|
7455
9103
|
for (const key of keys) {
|
|
@@ -7510,6 +9158,7 @@ export function createXStatePlaybookRuntime<
|
|
|
7510
9158
|
boundSession.playbookId,
|
|
7511
9159
|
{ allowSuspendedCall: true },
|
|
7512
9160
|
);
|
|
9161
|
+
validateRecoveryCheckpoint(boundSnapshot.recoveryCheckpoint);
|
|
7513
9162
|
if (
|
|
7514
9163
|
kind === 'adopt' &&
|
|
7515
9164
|
effectLedgerCapability !== undefined &&
|
|
@@ -7617,6 +9266,7 @@ export function createXStatePlaybookRuntime<
|
|
|
7617
9266
|
initInFlight = initialization;
|
|
7618
9267
|
const initTask = (async () => {
|
|
7619
9268
|
session = boundSession;
|
|
9269
|
+
recoveryCheckpoint = boundSnapshot.recoveryCheckpoint;
|
|
7620
9270
|
syncDeferredReconciliationOverlay();
|
|
7621
9271
|
refreshUnresolvedSemanticReconciliation(effectLedgerMirror);
|
|
7622
9272
|
prepareReconstructedGovernedDelivery(
|
|
@@ -7820,7 +9470,7 @@ export function createXStatePlaybookRuntime<
|
|
|
7820
9470
|
?.context ?? {}) as Record<string, unknown>;
|
|
7821
9471
|
const actual =
|
|
7822
9472
|
state?.stateId === BOSS_REPLY_WAIT_STATE_ID
|
|
7823
|
-
?
|
|
9473
|
+
? singlePendingBossQuestion(state, context)
|
|
7824
9474
|
: undefined;
|
|
7825
9475
|
if (!isDeepStrictEqual(actual, expected)) {
|
|
7826
9476
|
throw new Error(
|
|
@@ -7851,6 +9501,7 @@ export function createXStatePlaybookRuntime<
|
|
|
7851
9501
|
signal: AbortSignal,
|
|
7852
9502
|
turnId: number,
|
|
7853
9503
|
classificationLine?: string,
|
|
9504
|
+
onAccepted?: () => void,
|
|
7854
9505
|
): Promise<'continued' | 'unresolved'> {
|
|
7855
9506
|
if (repositoryCapability === undefined) {
|
|
7856
9507
|
throw new Error(
|
|
@@ -7901,6 +9552,7 @@ export function createXStatePlaybookRuntime<
|
|
|
7901
9552
|
effectBoundary.roleId, boundPlayerId,
|
|
7902
9553
|
);
|
|
7903
9554
|
continuationStarted = true;
|
|
9555
|
+
onAccepted?.();
|
|
7904
9556
|
actor!.send(event);
|
|
7905
9557
|
// The host has durably started this boundary and the FSM has
|
|
7906
9558
|
// moved: publish the buffered classification line and the
|
|
@@ -8177,7 +9829,7 @@ export function createXStatePlaybookRuntime<
|
|
|
8177
9829
|
const state = normalizePlaybookSnapshot(snapshot);
|
|
8178
9830
|
const context = ((snapshot as { context?: unknown }).context ??
|
|
8179
9831
|
{}) as Record<string, unknown>;
|
|
8180
|
-
const pending =
|
|
9832
|
+
const pending = singlePendingBossQuestion(state, context);
|
|
8181
9833
|
if (
|
|
8182
9834
|
pending === undefined ||
|
|
8183
9835
|
currentBoundDeferredOperation(pending)?.operationId !== operationId
|
|
@@ -8237,120 +9889,7 @@ export function createXStatePlaybookRuntime<
|
|
|
8237
9889
|
// Defined only at a safe capture point — initialized, not disposing
|
|
8238
9890
|
// or disposed, no active public boundary, and the actor quiescent with
|
|
8239
9891
|
// status `active`.
|
|
8240
|
-
exportSnapshot():
|
|
8241
|
-
if (!actor || !session || disposed || disposalPromise !== undefined) {
|
|
8242
|
-
return undefined;
|
|
8243
|
-
}
|
|
8244
|
-
if (activeSignal !== undefined) return undefined;
|
|
8245
|
-
if (deferredSettlementClosure !== undefined) return undefined;
|
|
8246
|
-
const pendingCall = nestedBridge.getPendingCall();
|
|
8247
|
-
const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
|
|
8248
|
-
if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
|
|
8249
|
-
return undefined;
|
|
8250
|
-
}
|
|
8251
|
-
let suspendedCall: typeof bridgeSuspendedCall;
|
|
8252
|
-
if (bridgeSuspendedCall !== undefined) {
|
|
8253
|
-
if (
|
|
8254
|
-
pendingCall?.callId !== bridgeSuspendedCall.callId ||
|
|
8255
|
-
pendingCall?.playbookId !== bridgeSuspendedCall.playbookId ||
|
|
8256
|
-
pendingCall?.childSessionId !== bridgeSuspendedCall.childSessionId
|
|
8257
|
-
) {
|
|
8258
|
-
return undefined;
|
|
8259
|
-
}
|
|
8260
|
-
if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
|
|
8261
|
-
return undefined;
|
|
8262
|
-
}
|
|
8263
|
-
const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
|
|
8264
|
-
if (
|
|
8265
|
-
bridgeSuspendedCall.turnId !== undefined &&
|
|
8266
|
-
bridgeSuspendedCall.turnId !== turnId
|
|
8267
|
-
) {
|
|
8268
|
-
return undefined;
|
|
8269
|
-
}
|
|
8270
|
-
suspendedCall = {
|
|
8271
|
-
...bridgeSuspendedCall,
|
|
8272
|
-
...(turnId === undefined ? {} : { turnId }),
|
|
8273
|
-
...(hasGovernedPlayerStates
|
|
8274
|
-
? {
|
|
8275
|
-
effectBoundaryPrefixSequence:
|
|
8276
|
-
playbookCallEffectPrefixes.get(
|
|
8277
|
-
bridgeSuspendedCall.callId,
|
|
8278
|
-
) ?? null,
|
|
8279
|
-
}
|
|
8280
|
-
: {}),
|
|
8281
|
-
};
|
|
8282
|
-
}
|
|
8283
|
-
const state = currentState();
|
|
8284
|
-
if (state.status !== 'active' || !state.quiescent) return undefined;
|
|
8285
|
-
const context = (actor.getSnapshot() as { context?: unknown })
|
|
8286
|
-
.context as Record<string, unknown>;
|
|
8287
|
-
// DR-063 §2: the failed state's `lastError` is persisted as every live
|
|
8288
|
-
// surface publishes it, completed from the runtime's own cause slot, so
|
|
8289
|
-
// a thrown value that refused the cause still explains itself after a
|
|
8290
|
-
// restart.
|
|
8291
|
-
const machineSnapshot = detachPersistedMachineSnapshot(
|
|
8292
|
-
actor.getPersistedSnapshot(),
|
|
8293
|
-
state.stateId === 'failed'
|
|
8294
|
-
? { lastError: failedStateError(context ?? {}) }
|
|
8295
|
-
: {},
|
|
8296
|
-
);
|
|
8297
|
-
effectLedgerMirror = currentEffectLedger();
|
|
8298
|
-
refreshRetainedEffectReconciliation(effectLedgerMirror);
|
|
8299
|
-
syncDeferredReconciliationOverlay();
|
|
8300
|
-
refreshUnresolvedSemanticReconciliation(effectLedgerMirror);
|
|
8301
|
-
const pending =
|
|
8302
|
-
!hasUnresolvedReconciliation()
|
|
8303
|
-
? pendingBossQuestionForState(state, context ?? {})
|
|
8304
|
-
: undefined;
|
|
8305
|
-
const failedEffectAttempt =
|
|
8306
|
-
hasGovernedPlayerStates &&
|
|
8307
|
-
state.stateId === 'failed' &&
|
|
8308
|
-
failedAttemptMatchesCurrentLedger(effectLedgerMirror)
|
|
8309
|
-
? {
|
|
8310
|
-
boundaryPrefix: failedEffectBoundaryPrefix!,
|
|
8311
|
-
attemptId: failedGovernedAttemptId ?? null,
|
|
8312
|
-
}
|
|
8313
|
-
: undefined;
|
|
8314
|
-
return {
|
|
8315
|
-
schemaVersion: 4,
|
|
8316
|
-
playbookId: session.playbookId,
|
|
8317
|
-
machine: machineSnapshot,
|
|
8318
|
-
roleResumeTokens: snapshotRoleResumeTokens(),
|
|
8319
|
-
sequences: {
|
|
8320
|
-
trace: traceSequence,
|
|
8321
|
-
turn: turnSequence,
|
|
8322
|
-
judgeCall: judgeCallSequence,
|
|
8323
|
-
playerCall: playerCallSequence,
|
|
8324
|
-
playbookCall: playbookCallSequence,
|
|
8325
|
-
...(declaredActors.has('captain')
|
|
8326
|
-
? { captainCall: captainCallSequence }
|
|
8327
|
-
: {}),
|
|
8328
|
-
},
|
|
8329
|
-
state,
|
|
8330
|
-
pendingBossQuestions:
|
|
8331
|
-
pending === undefined
|
|
8332
|
-
? []
|
|
8333
|
-
: [
|
|
8334
|
-
{
|
|
8335
|
-
questionId: pending.questionId,
|
|
8336
|
-
asker: pending.asker,
|
|
8337
|
-
question: pending.question,
|
|
8338
|
-
sourceItem: pending.sourceItem,
|
|
8339
|
-
},
|
|
8340
|
-
],
|
|
8341
|
-
effectLedger: effectLedgerMirror,
|
|
8342
|
-
...(retainedEffectSourceSessionId === undefined
|
|
8343
|
-
? {}
|
|
8344
|
-
: { retainedEffectSourceSessionId }),
|
|
8345
|
-
...(retainedEffectReconciliation === undefined
|
|
8346
|
-
? {}
|
|
8347
|
-
: { retainedEffectReconciliation }),
|
|
8348
|
-
...(failedEffectAttempt === undefined
|
|
8349
|
-
? {}
|
|
8350
|
-
: { failedEffectAttempt }),
|
|
8351
|
-
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
8352
|
-
};
|
|
8353
|
-
},
|
|
9892
|
+
exportSnapshot: (checkpoint?: { child?: PlaybookPendingCall }) => exportRuntimeSnapshot(checkpoint?.child ? { child: checkpoint.child } : undefined),
|
|
8354
9893
|
|
|
8355
9894
|
// DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
|
|
8356
9895
|
// exported snapshot under the same immutable session identity.
|
|
@@ -8365,17 +9904,23 @@ export function createXStatePlaybookRuntime<
|
|
|
8365
9904
|
},
|
|
8366
9905
|
|
|
8367
9906
|
// DR-038 §§1,5 / PBRT-61/PBRT-65: adoption is restore under a fresh
|
|
8368
|
-
// engagement identity and counter lineage, exposed separately so
|
|
8369
|
-
// capability-less
|
|
8370
|
-
//
|
|
9907
|
+
// engagement identity and counter lineage, exposed separately so a
|
|
9908
|
+
// capability-less runtime can omit it. Runtime-visible preflight
|
|
9909
|
+
// mismatches reject before effects; after preflight the new
|
|
8371
9910
|
// session.started boundary owns failed-start cleanup just like init.
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
9911
|
+
// DR-067 §2: retained-snapshot adoption stays in the flat domain, so a
|
|
9912
|
+
// machine that declares a parallel state omits the member.
|
|
9913
|
+
...(parallelProfile === undefined
|
|
9914
|
+
? {
|
|
9915
|
+
async adopt(
|
|
9916
|
+
nextSession: PlaybookSession,
|
|
9917
|
+
snapshot: PlaybookRuntimeSnapshot,
|
|
9918
|
+
context: PlaybookAdoptionContext,
|
|
9919
|
+
): Promise<void> {
|
|
9920
|
+
await rehydrateSnapshot('adopt', nextSession, snapshot, context);
|
|
9921
|
+
},
|
|
9922
|
+
}
|
|
9923
|
+
: {}),
|
|
8379
9924
|
|
|
8380
9925
|
// DR-029 / PBRT-52: side-effect-free control view over the live
|
|
8381
9926
|
// snapshot, valid at parked quiescence outside an active boundary.
|
|
@@ -8403,10 +9948,9 @@ export function createXStatePlaybookRuntime<
|
|
|
8403
9948
|
const state = currentState();
|
|
8404
9949
|
const context = ((snapshot as { context?: unknown }).context ??
|
|
8405
9950
|
{}) as Record<string, unknown>;
|
|
8406
|
-
const
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
: undefined;
|
|
9951
|
+
const pendingQuestions = !hasUnresolvedReconciliation()
|
|
9952
|
+
? pendingBossQuestionsFor(state, context)
|
|
9953
|
+
: [];
|
|
8410
9954
|
const lastError =
|
|
8411
9955
|
state.stateId === 'failed'
|
|
8412
9956
|
? failedStateError(context)
|
|
@@ -8416,25 +9960,46 @@ export function createXStatePlaybookRuntime<
|
|
|
8416
9960
|
!hasUnresolvedReconciliation()
|
|
8417
9961
|
? stateDescriptionFor(state)
|
|
8418
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
|
+
};
|
|
8419
9988
|
return deepFreeze({
|
|
8420
9989
|
state,
|
|
8421
9990
|
...(stateDescription === undefined ? {} : { stateDescription }),
|
|
8422
9991
|
...(projectedContext !== undefined
|
|
8423
9992
|
? { context: projectedContext }
|
|
8424
9993
|
: {}),
|
|
8425
|
-
pendingQuestions:
|
|
8426
|
-
pending
|
|
8427
|
-
|
|
8428
|
-
|
|
8429
|
-
|
|
8430
|
-
|
|
8431
|
-
asker: pending.asker,
|
|
8432
|
-
question: pending.question,
|
|
8433
|
-
sourceItem: pending.sourceItem,
|
|
8434
|
-
},
|
|
8435
|
-
],
|
|
9994
|
+
pendingQuestions: pendingQuestions.map((pending) => ({
|
|
9995
|
+
questionId: pending.questionId,
|
|
9996
|
+
asker: pending.asker,
|
|
9997
|
+
question: pending.question,
|
|
9998
|
+
sourceItem: pending.sourceItem,
|
|
9999
|
+
})),
|
|
8436
10000
|
...(lastError !== undefined ? { lastError } : {}),
|
|
8437
|
-
actions
|
|
10001
|
+
actions,
|
|
10002
|
+
...(recovery === undefined ? {} : { recovery }),
|
|
8438
10003
|
});
|
|
8439
10004
|
},
|
|
8440
10005
|
|
|
@@ -8501,6 +10066,7 @@ export function createXStatePlaybookRuntime<
|
|
|
8501
10066
|
const callId = `apply-${++applyCallSequence}`;
|
|
8502
10067
|
const position: TracePosition = { turnId, callId };
|
|
8503
10068
|
activeTurnId = turnId;
|
|
10069
|
+
publicBoundarySequence += 1;
|
|
8504
10070
|
beginAutomaticReplayBoundary();
|
|
8505
10071
|
activeSignal = signal;
|
|
8506
10072
|
activeAborts = abortReasonClassifier(signal);
|
|
@@ -8634,7 +10200,10 @@ export function createXStatePlaybookRuntime<
|
|
|
8634
10200
|
accepted = true;
|
|
8635
10201
|
try {
|
|
8636
10202
|
let run: PlaybookRunResult;
|
|
8637
|
-
if (candidate.
|
|
10203
|
+
if (candidate.retryJudgment !== undefined) {
|
|
10204
|
+
await recoverJudgment(candidate.retryJudgment, signal);
|
|
10205
|
+
run = runResultFor(settledOutcome(signal));
|
|
10206
|
+
} else if (candidate.unresolvedEffectAction === 'abandon') {
|
|
8638
10207
|
signal.throwIfAborted();
|
|
8639
10208
|
run = runResultFor('unresolved-effect');
|
|
8640
10209
|
} else if (
|
|
@@ -8657,6 +10226,19 @@ export function createXStatePlaybookRuntime<
|
|
|
8657
10226
|
? 'no-action'
|
|
8658
10227
|
: 'quiescent',
|
|
8659
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));
|
|
8660
10242
|
} else {
|
|
8661
10243
|
actor.send(candidate.event!);
|
|
8662
10244
|
await waitForPlaybookQuiescence(actor, {
|
|
@@ -8669,6 +10251,7 @@ export function createXStatePlaybookRuntime<
|
|
|
8669
10251
|
run.outcome === 'failed' || run.outcome === 'aborted'
|
|
8670
10252
|
? {
|
|
8671
10253
|
disposition: 'failed',
|
|
10254
|
+
run,
|
|
8672
10255
|
error:
|
|
8673
10256
|
('error' in run ? run.error : undefined) ??
|
|
8674
10257
|
normalizeError(
|
|
@@ -8701,6 +10284,7 @@ export function createXStatePlaybookRuntime<
|
|
|
8701
10284
|
if (receipt !== undefined && receipt.disposition !== 'rejected') {
|
|
8702
10285
|
appliedReceipts.set(key, receipt);
|
|
8703
10286
|
}
|
|
10287
|
+
if (parallelProfile !== undefined) await drainBoundaryCalls();
|
|
8704
10288
|
try {
|
|
8705
10289
|
await drainEmissions();
|
|
8706
10290
|
} catch (error) {
|
|
@@ -8781,9 +10365,11 @@ export function createXStatePlaybookRuntime<
|
|
|
8781
10365
|
async handleBossInput({
|
|
8782
10366
|
text,
|
|
8783
10367
|
signal,
|
|
10368
|
+
onAccepted,
|
|
8784
10369
|
}: {
|
|
8785
10370
|
text: string;
|
|
8786
10371
|
signal: AbortSignal;
|
|
10372
|
+
onAccepted?: () => void;
|
|
8787
10373
|
}): Promise<PlaybookRunResult> {
|
|
8788
10374
|
if (!actor || !savedPorts) {
|
|
8789
10375
|
throw new Error(
|
|
@@ -8804,6 +10390,7 @@ export function createXStatePlaybookRuntime<
|
|
|
8804
10390
|
refreshRetainedEffectFenceFromHost();
|
|
8805
10391
|
const turnId = ++turnSequence;
|
|
8806
10392
|
activeTurnId = turnId;
|
|
10393
|
+
publicBoundarySequence += 1;
|
|
8807
10394
|
beginAutomaticReplayBoundary();
|
|
8808
10395
|
activeSignal = signal;
|
|
8809
10396
|
activeAborts = abortReasonClassifier(signal);
|
|
@@ -8845,7 +10432,7 @@ export function createXStatePlaybookRuntime<
|
|
|
8845
10432
|
const stateId = normalizePlaybookSnapshot(snapshot).stateId;
|
|
8846
10433
|
const snapshotContext = ((snapshot as { context?: unknown })
|
|
8847
10434
|
.context ?? {}) as Record<string, unknown>;
|
|
8848
|
-
deferredPending =
|
|
10435
|
+
deferredPending = singlePendingBossQuestion(
|
|
8849
10436
|
normalizePlaybookSnapshot(snapshot),
|
|
8850
10437
|
snapshotContext,
|
|
8851
10438
|
);
|
|
@@ -8928,16 +10515,9 @@ export function createXStatePlaybookRuntime<
|
|
|
8928
10515
|
signal,
|
|
8929
10516
|
turnId,
|
|
8930
10517
|
statusLine,
|
|
10518
|
+
onAccepted,
|
|
8931
10519
|
);
|
|
8932
10520
|
if (continuation === 'continued') {
|
|
8933
|
-
try {
|
|
8934
|
-
lastBossEvent = snapshotJsonValue(
|
|
8935
|
-
event,
|
|
8936
|
-
'recorded Boss event',
|
|
8937
|
-
) as unknown as EventObject;
|
|
8938
|
-
} catch {
|
|
8939
|
-
lastBossEvent = undefined;
|
|
8940
|
-
}
|
|
8941
10521
|
if (controlPlaneError !== undefined) {
|
|
8942
10522
|
throw controlPlaneError;
|
|
8943
10523
|
}
|
|
@@ -8947,7 +10527,6 @@ export function createXStatePlaybookRuntime<
|
|
|
8947
10527
|
: 'no-action',
|
|
8948
10528
|
);
|
|
8949
10529
|
} else {
|
|
8950
|
-
lastBossEvent = undefined;
|
|
8951
10530
|
result = runResultFor('no-action');
|
|
8952
10531
|
}
|
|
8953
10532
|
handledDeferred = true;
|
|
@@ -8960,7 +10539,6 @@ export function createXStatePlaybookRuntime<
|
|
|
8960
10539
|
deferredOperation.operationId,
|
|
8961
10540
|
signal,
|
|
8962
10541
|
);
|
|
8963
|
-
lastBossEvent = undefined;
|
|
8964
10542
|
result = runResultFor('no-action');
|
|
8965
10543
|
handledDeferred = true;
|
|
8966
10544
|
}
|
|
@@ -8989,18 +10567,7 @@ export function createXStatePlaybookRuntime<
|
|
|
8989
10567
|
suppressInspectionEmissions = false;
|
|
8990
10568
|
actor.start();
|
|
8991
10569
|
}
|
|
8992
|
-
|
|
8993
|
-
// as the retry-replay source. Recording is sanitizing, not
|
|
8994
|
-
// load-bearing: an override classifier's non-JSON-safe event is
|
|
8995
|
-
// simply not recorded, and the turn proceeds unchanged.
|
|
8996
|
-
try {
|
|
8997
|
-
lastBossEvent = snapshotJsonValue(
|
|
8998
|
-
event,
|
|
8999
|
-
'recorded Boss event',
|
|
9000
|
-
) as unknown as EventObject;
|
|
9001
|
-
} catch {
|
|
9002
|
-
lastBossEvent = undefined;
|
|
9003
|
-
}
|
|
10570
|
+
onAccepted?.();
|
|
9004
10571
|
actor.send(event);
|
|
9005
10572
|
await waitForPlaybookQuiescence(actor, {
|
|
9006
10573
|
pendingCalls: nestedBridge,
|
|
@@ -9012,6 +10579,7 @@ export function createXStatePlaybookRuntime<
|
|
|
9012
10579
|
operationError = error;
|
|
9013
10580
|
}
|
|
9014
10581
|
|
|
10582
|
+
if (parallelProfile !== undefined) await drainBoundaryCalls();
|
|
9015
10583
|
let drainError: unknown;
|
|
9016
10584
|
try {
|
|
9017
10585
|
await drainEmissions();
|
|
@@ -9125,6 +10693,7 @@ export function createXStatePlaybookRuntime<
|
|
|
9125
10693
|
return runResultFor('no-action');
|
|
9126
10694
|
}
|
|
9127
10695
|
activeTurnId = playbookCallTurnIds.get(input.callId);
|
|
10696
|
+
publicBoundarySequence += 1;
|
|
9128
10697
|
bindAutomaticReplayBoundary(
|
|
9129
10698
|
playbookCallEffectPrefixes.has(input.callId)
|
|
9130
10699
|
? playbookCallEffectPrefixes.get(input.callId)
|
|
@@ -9169,6 +10738,7 @@ export function createXStatePlaybookRuntime<
|
|
|
9169
10738
|
error: normalizeError(input.signal.reason),
|
|
9170
10739
|
};
|
|
9171
10740
|
}
|
|
10741
|
+
if (parallelProfile !== undefined) await drainBoundaryCalls();
|
|
9172
10742
|
let drainError: unknown;
|
|
9173
10743
|
try {
|
|
9174
10744
|
await drainEmissions();
|
|
@@ -9286,10 +10856,14 @@ export function createXStatePlaybookRuntime<
|
|
|
9286
10856
|
emissionQueue.clear();
|
|
9287
10857
|
judgeQueue.clear();
|
|
9288
10858
|
appliedReceipts.clear();
|
|
10859
|
+
pendingCohorts.clear();
|
|
10860
|
+
consumedCohortBoundaries.clear();
|
|
10861
|
+
activeBoundaryCalls.clear();
|
|
10862
|
+
cohortCompletionQueue.clear();
|
|
9289
10863
|
actor = undefined;
|
|
9290
10864
|
activeSignal = undefined;
|
|
9291
10865
|
activeAborts = undefined;
|
|
9292
|
-
actorSettlementAborts =
|
|
10866
|
+
actorSettlementAborts.length = 0;
|
|
9293
10867
|
actorSettlementErrorAborts = undefined;
|
|
9294
10868
|
activeAbortEmission = undefined;
|
|
9295
10869
|
activeTurnId = undefined;
|
|
@@ -9301,7 +10875,6 @@ export function createXStatePlaybookRuntime<
|
|
|
9301
10875
|
failedGovernedAttemptId = undefined;
|
|
9302
10876
|
controlPlaneError = undefined;
|
|
9303
10877
|
emissionFailure = undefined;
|
|
9304
|
-
lastBossEvent = undefined;
|
|
9305
10878
|
retainedEffectSourceSessionId = undefined;
|
|
9306
10879
|
retainedEffectReconciliation = undefined;
|
|
9307
10880
|
retainedEffectReconciliationRequired = false;
|