@sublang/playbook 4.0.0 → 6.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 +69 -122
- package/docs/assets/playbook-venn.svg +13 -0
- package/docs/cli.md +43 -26
- package/docs/configuration.md +63 -18
- package/docs/embedding.md +24 -16
- package/package.json +43 -22
- package/reference/sdlc/captain.md +70 -83
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
- package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
- package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
- package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
- package/reference/sdlc/captain.playbook/captain.playbook.js +466 -976
- package/reference/sdlc/captain.playbook/captain.playbook.ts +698 -1001
- package/reference/sdlc/code.md +55 -97
- package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
- package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
- package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
- package/reference/sdlc/code.playbook/code.gears.md +51 -263
- package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
- package/reference/sdlc/code.playbook/code.playbook.js +69 -639
- package/reference/sdlc/code.playbook/code.playbook.ts +90 -850
- package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
- package/reference/sdlc/code.playbook/code.registry.js +20 -78
- package/reference/sdlc/code.playbook/code.registry.ts +58 -122
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +1877 -251
- package/reference/sdlc/code.playbook/playbook-captain.ts +2385 -352
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
- package/reference/sdlc/decide.md +54 -0
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
- package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
- package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
- package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
- package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +511 -370
- package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +616 -451
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
- package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
- package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
- package/reference/sdlc/review.md +81 -0
- package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
- package/reference/sdlc/review.playbook/review.fsm.js +524 -0
- package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
- package/reference/sdlc/review.playbook/review.gears.md +112 -0
- package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
- package/reference/sdlc/review.playbook/review.playbook.js +112 -0
- package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
- package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
- package/reference/sdlc/review.playbook/review.registry.js +73 -0
- package/reference/sdlc/review.playbook/review.registry.ts +138 -0
- package/slc/gears2fsm.md +67 -6
- package/slc/link.md +339 -25
- package/slc/text2gears.md +22 -2
- package/src/runtime.d.ts +36 -1
- package/src/runtime.ts +59 -0
- package/src/xstate-playbook-runtime.d.ts +96 -7
- package/src/xstate-playbook-runtime.js +1018 -49
- package/src/xstate-playbook-runtime.ts +1283 -59
- package/src/xstate-runtime.js +25 -0
- package/src/xstate-runtime.ts +51 -0
- package/reference/sdlc/discuss.md +0 -93
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
- package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
- package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
|
@@ -42,6 +42,30 @@ function isFsmResultFailure(error) {
|
|
|
42
42
|
fsmResultFailures.has(error));
|
|
43
43
|
}
|
|
44
44
|
// ---------------------------------------------------------------------------
|
|
45
|
+
// DR-028: both call boundaries treat an `ok` result whose `finalText` is
|
|
46
|
+
// missing, empty, or whitespace-only under one empty predicate, and that
|
|
47
|
+
// shape earns exactly one corrective re-ask — the same composed call
|
|
48
|
+
// re-issued once through the same boundary — before a second such result
|
|
49
|
+
// follows the existing failure path. The retry marker distinguishes the
|
|
50
|
+
// re-askable empty-`ok` Captain failure from the never-retried non-`ok`
|
|
51
|
+
// statuses; it is applied only when the failure's finish trace emitted
|
|
52
|
+
// cleanly, because a rejecting finish sink is a control-plane error whose
|
|
53
|
+
// turn gets no corrective re-ask (PBRT-47).
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
function isEmptyFinalText(finalText) {
|
|
56
|
+
return finalText === undefined || finalText.trim().length === 0;
|
|
57
|
+
}
|
|
58
|
+
const emptyOkRetryFailures = new WeakSet();
|
|
59
|
+
function markEmptyOkRetryFailure(error) {
|
|
60
|
+
emptyOkRetryFailures.add(error);
|
|
61
|
+
return error;
|
|
62
|
+
}
|
|
63
|
+
function isEmptyOkRetryFailure(error) {
|
|
64
|
+
return (typeof error === 'object' &&
|
|
65
|
+
error !== null &&
|
|
66
|
+
emptyOkRetryFailures.has(error));
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
45
69
|
// DR-022: the engine's compatibility self-report. A linked thin module
|
|
46
70
|
// records the values current at link time in `spec.compat`; the factory
|
|
47
71
|
// checks that declaration against this very module — the engine instance
|
|
@@ -425,19 +449,39 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
|
|
|
425
449
|
const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
|
|
426
450
|
const playerId = spec.resolvePlayerId(input);
|
|
427
451
|
const prompt = spec.composePlayerPrompt(input);
|
|
428
|
-
const
|
|
429
|
-
?
|
|
430
|
-
:
|
|
431
|
-
|
|
432
|
-
|
|
452
|
+
const callPlayer = (resume) => boundary
|
|
453
|
+
? boundary.callPlayer(input, playerId, prompt, activeSignal)
|
|
454
|
+
: ports.callPlayer(playerId, prompt, activeSignal, { resume });
|
|
455
|
+
let result = await callPlayer(false);
|
|
456
|
+
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
457
|
+
// An abort that lands between the empty first result and the
|
|
458
|
+
// corrective call ends the turn as ordinary abort settlement with
|
|
459
|
+
// no second host call — aborts are never retried (DR-028 via
|
|
460
|
+
// DR-025's transport exclusion) — matching the direct-Captain
|
|
461
|
+
// boundary, whose queued corrective call re-checks the signal
|
|
462
|
+
// before starting.
|
|
463
|
+
activeSignal.throwIfAborted();
|
|
464
|
+
// DR-028: exactly one corrective re-ask of the same composed call
|
|
465
|
+
// through the same path, traced by the boundary as its own
|
|
466
|
+
// player-call pair. The traced boundary re-reads its token map
|
|
467
|
+
// (PBRT-38), so the corrective call continues the player session
|
|
468
|
+
// when the first result carried a resume token and starts fresh
|
|
469
|
+
// when it cleared one; the portless verification path mirrors that
|
|
470
|
+
// by carrying the first result's token.
|
|
471
|
+
result = await callPlayer(typeof result.resumeToken === 'string' &&
|
|
472
|
+
result.resumeToken.trim().length > 0
|
|
473
|
+
? result.resumeToken
|
|
474
|
+
: false);
|
|
475
|
+
}
|
|
433
476
|
if (result.status !== 'ok') {
|
|
434
477
|
throw new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
|
|
435
478
|
}
|
|
436
|
-
|
|
479
|
+
const finalText = result.finalText ?? '';
|
|
480
|
+
if (isEmptyFinalText(finalText)) {
|
|
437
481
|
throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
|
|
438
482
|
}
|
|
439
483
|
try {
|
|
440
|
-
const output = await adjudicatePlayerOutput(spec.adjudication, input,
|
|
484
|
+
const output = await adjudicatePlayerOutput(spec.adjudication, input, finalText, ports, activeSignal, boundary);
|
|
441
485
|
validateBossReplyOutput(input, output, spec.resumableStateIds);
|
|
442
486
|
return output;
|
|
443
487
|
}
|
|
@@ -545,6 +589,48 @@ function collectInvokeSources(machine) {
|
|
|
545
589
|
visit(machine.config);
|
|
546
590
|
return sources;
|
|
547
591
|
}
|
|
592
|
+
function collectPlayerStatePlayers(machine) {
|
|
593
|
+
const players = new Map();
|
|
594
|
+
const visit = (stateDef, stateKey) => {
|
|
595
|
+
if (!isPlainObject(stateDef))
|
|
596
|
+
return;
|
|
597
|
+
const invoke = stateDef.invoke;
|
|
598
|
+
const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : [];
|
|
599
|
+
if (invokes.some((entry) => isPlainObject(entry) && entry.src === 'player')) {
|
|
600
|
+
const playbookMeta = isPlainObject(stateDef.meta)
|
|
601
|
+
? stateDef.meta.playbook
|
|
602
|
+
: undefined;
|
|
603
|
+
const stateId = isPlainObject(playbookMeta) &&
|
|
604
|
+
typeof playbookMeta.stateId === 'string'
|
|
605
|
+
? playbookMeta.stateId
|
|
606
|
+
: typeof stateDef.id === 'string'
|
|
607
|
+
? stateDef.id
|
|
608
|
+
: stateKey;
|
|
609
|
+
if (stateId.trim().length === 0) {
|
|
610
|
+
throw new TypeError('player state metadata must use a non-empty state id');
|
|
611
|
+
}
|
|
612
|
+
const player = isPlainObject(playbookMeta)
|
|
613
|
+
? playbookMeta.player
|
|
614
|
+
: undefined;
|
|
615
|
+
if (typeof player !== 'string' || player.trim().length === 0) {
|
|
616
|
+
throw new TypeError(`player state ${stateId} meta.playbook.player must be a non-empty string`);
|
|
617
|
+
}
|
|
618
|
+
players.set(stateId, player);
|
|
619
|
+
}
|
|
620
|
+
if (isPlainObject(stateDef.states)) {
|
|
621
|
+
for (const [childKey, child] of Object.entries(stateDef.states)) {
|
|
622
|
+
visit(child, childKey);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
const config = machine.config;
|
|
627
|
+
if (isPlainObject(config) && isPlainObject(config.states)) {
|
|
628
|
+
for (const [stateKey, stateDef] of Object.entries(config.states)) {
|
|
629
|
+
visit(stateDef, stateKey);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return players;
|
|
633
|
+
}
|
|
548
634
|
function transitionTargets(transition) {
|
|
549
635
|
const arms = Array.isArray(transition) ? transition : [transition];
|
|
550
636
|
const targets = [];
|
|
@@ -574,6 +660,95 @@ export function resumableStateIdsFromMachine(machine) {
|
|
|
574
660
|
return new Set(transitionTargets(bossReply));
|
|
575
661
|
}
|
|
576
662
|
// ---------------------------------------------------------------------------
|
|
663
|
+
// DR-029 control surface: the FSM's explicit-state-jump event and the
|
|
664
|
+
// source state descriptions that label runtime-advertised actions.
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
/** The FSM's explicit-state-jump event type (slc/link.md §Boss-event mapping). */
|
|
667
|
+
const JUMP_EVENT_TYPE = 'BOSS_INTERRUPT';
|
|
668
|
+
/**
|
|
669
|
+
* Source state descriptions by state key, node id, and `meta.playbook`
|
|
670
|
+
* state id, read from `machine.config`. Control actions are labeled from
|
|
671
|
+
* these descriptions (DR-029); a state without one has no entry.
|
|
672
|
+
*/
|
|
673
|
+
export function stateDescriptionsFromMachine(machine) {
|
|
674
|
+
const descriptions = new Map();
|
|
675
|
+
const record = (key, description) => {
|
|
676
|
+
if (typeof key !== 'string' || key.length === 0)
|
|
677
|
+
return;
|
|
678
|
+
if (!descriptions.has(key))
|
|
679
|
+
descriptions.set(key, description);
|
|
680
|
+
};
|
|
681
|
+
const visit = (key, stateDef) => {
|
|
682
|
+
if (!isPlainObject(stateDef))
|
|
683
|
+
return;
|
|
684
|
+
const playbook = isPlainObject(stateDef.meta)
|
|
685
|
+
? stateDef.meta.playbook
|
|
686
|
+
: undefined;
|
|
687
|
+
const description = isPlainObject(playbook) && typeof playbook.description === 'string'
|
|
688
|
+
? playbook.description
|
|
689
|
+
: typeof stateDef.description === 'string'
|
|
690
|
+
? stateDef.description
|
|
691
|
+
: undefined;
|
|
692
|
+
if (description !== undefined && description.length > 0) {
|
|
693
|
+
record(key, description);
|
|
694
|
+
record(stateDef.id, description);
|
|
695
|
+
if (isPlainObject(playbook))
|
|
696
|
+
record(playbook.stateId, description);
|
|
697
|
+
}
|
|
698
|
+
if (isPlainObject(stateDef.states)) {
|
|
699
|
+
for (const [childKey, child] of Object.entries(stateDef.states)) {
|
|
700
|
+
visit(childKey, child);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
const config = machine.config;
|
|
705
|
+
if (isPlainObject(config) && isPlainObject(config.states)) {
|
|
706
|
+
for (const [key, stateDef] of Object.entries(config.states)) {
|
|
707
|
+
visit(key, stateDef);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return descriptions;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* First configured target of `eventType` from the state with `stateId`,
|
|
714
|
+
* falling back to the machine root's own transitions. Used only to pick the
|
|
715
|
+
* source description that labels a retry action, and only for events that
|
|
716
|
+
* carry no recorded `targetId`: a guarded multi-arm list keyed on the
|
|
717
|
+
* event's `targetId` (the root `BOSS_INTERRUPT` shape) resumes the recorded
|
|
718
|
+
* target, not the first configured arm, so the recorded event outranks this
|
|
719
|
+
* fallback.
|
|
720
|
+
*/
|
|
721
|
+
function firstTransitionTarget(machine, stateId, eventType) {
|
|
722
|
+
const config = machine.config;
|
|
723
|
+
if (!isPlainObject(config))
|
|
724
|
+
return undefined;
|
|
725
|
+
const candidates = [];
|
|
726
|
+
if (stateId !== undefined && isPlainObject(config.states)) {
|
|
727
|
+
const state = config.states[stateId];
|
|
728
|
+
if (isPlainObject(state) && isPlainObject(state.on)) {
|
|
729
|
+
candidates.push(state.on[eventType]);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (isPlainObject(config.on))
|
|
733
|
+
candidates.push(config.on[eventType]);
|
|
734
|
+
for (const candidate of candidates) {
|
|
735
|
+
if (candidate === undefined)
|
|
736
|
+
continue;
|
|
737
|
+
const targets = transitionTargets(candidate);
|
|
738
|
+
if (targets.length > 0)
|
|
739
|
+
return targets[0];
|
|
740
|
+
}
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
function deepFreeze(value) {
|
|
744
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
745
|
+
Object.freeze(value);
|
|
746
|
+
for (const member of Object.values(value))
|
|
747
|
+
deepFreeze(member);
|
|
748
|
+
}
|
|
749
|
+
return value;
|
|
750
|
+
}
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
577
752
|
// Default transition/status derivation.
|
|
578
753
|
// ---------------------------------------------------------------------------
|
|
579
754
|
const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
|
|
@@ -599,16 +774,65 @@ function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
|
|
|
599
774
|
return snapshotJsonValue(out, 'FSM event');
|
|
600
775
|
};
|
|
601
776
|
}
|
|
602
|
-
function
|
|
777
|
+
function snapshotPlayerStateStatuses(value, label, machine, stateDescriptions) {
|
|
778
|
+
if (value === undefined)
|
|
779
|
+
return new Map();
|
|
780
|
+
if (!isPlainObject(value)) {
|
|
781
|
+
throw new TypeError(`${label} playerStates must be an object`);
|
|
782
|
+
}
|
|
783
|
+
const declared = collectPlayerStatePlayers(machine);
|
|
784
|
+
const statuses = new Map();
|
|
785
|
+
for (const [stateId, candidate] of Object.entries(value)) {
|
|
786
|
+
if (!declared.has(stateId)) {
|
|
787
|
+
throw new TypeError(`${label} playerStates.${stateId} does not name a player state`);
|
|
788
|
+
}
|
|
789
|
+
if (!isPlainObject(candidate) ||
|
|
790
|
+
typeof candidate.player !== 'string' ||
|
|
791
|
+
candidate.player.trim().length === 0 ||
|
|
792
|
+
typeof candidate.label !== 'string' ||
|
|
793
|
+
candidate.label.trim().length === 0) {
|
|
794
|
+
throw new TypeError(`${label} playerStates.${stateId} must carry non-empty player and label strings`);
|
|
795
|
+
}
|
|
796
|
+
const expectedLabel = stateDescriptions.get(stateId);
|
|
797
|
+
if (candidate.label !== expectedLabel) {
|
|
798
|
+
throw new TypeError(`${label} playerStates.${stateId}.label must equal its FSM description`);
|
|
799
|
+
}
|
|
800
|
+
if (candidate.player !== declared.get(stateId)) {
|
|
801
|
+
throw new TypeError(`${label} playerStates.${stateId}.player must equal its FSM player`);
|
|
802
|
+
}
|
|
803
|
+
statuses.set(stateId, {
|
|
804
|
+
player: candidate.player,
|
|
805
|
+
label: candidate.label,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
for (const stateId of declared.keys()) {
|
|
809
|
+
if (!statuses.has(stateId)) {
|
|
810
|
+
throw new TypeError(`${label} playerStates must declare player state ${stateId}`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return statuses;
|
|
814
|
+
}
|
|
815
|
+
function settlingGuard(event) {
|
|
816
|
+
if (!isPlainObject(event) || !isPlainObject(event.output))
|
|
817
|
+
return undefined;
|
|
818
|
+
const guard = event.output.guard;
|
|
819
|
+
return typeof guard === 'string' && guard.trim().length > 0
|
|
820
|
+
? guard
|
|
821
|
+
: undefined;
|
|
822
|
+
}
|
|
823
|
+
function legacyStatusesForState(state, context) {
|
|
603
824
|
const stateId = state.stateId;
|
|
604
825
|
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId))
|
|
605
826
|
return [];
|
|
606
827
|
if (stateId === 'awaitBossReply') {
|
|
607
828
|
const pending = pendingBossQuestionFromContext(context);
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
829
|
+
return [
|
|
830
|
+
{
|
|
831
|
+
message: pending === undefined
|
|
832
|
+
? 'Awaiting Boss reply.'
|
|
833
|
+
: `${pending.player} asks: ${pending.question}`,
|
|
834
|
+
},
|
|
835
|
+
];
|
|
612
836
|
}
|
|
613
837
|
if (stateId === 'failed') {
|
|
614
838
|
const lastError = normalizeErrorFull(context.lastError);
|
|
@@ -623,6 +847,53 @@ function defaultStatusesForState(state, context) {
|
|
|
623
847
|
}
|
|
624
848
|
return [{ message: `Entered ${stateId}.` }];
|
|
625
849
|
}
|
|
850
|
+
function makeDefaultStatusesForState(playerStates) {
|
|
851
|
+
return (state, context, event) => {
|
|
852
|
+
const statuses = [];
|
|
853
|
+
const guard = settlingGuard(event);
|
|
854
|
+
if (guard !== undefined)
|
|
855
|
+
statuses.push({ message: `→ ${guard}` });
|
|
856
|
+
const stateId = state.stateId;
|
|
857
|
+
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
|
|
858
|
+
return statuses;
|
|
859
|
+
}
|
|
860
|
+
if (stateId === 'awaitBossReply') {
|
|
861
|
+
const pending = pendingBossQuestionFromContext(context);
|
|
862
|
+
if (pending === undefined) {
|
|
863
|
+
return [...statuses, { message: 'Awaiting Boss reply.' }];
|
|
864
|
+
}
|
|
865
|
+
return [
|
|
866
|
+
...statuses,
|
|
867
|
+
{ message: `${pending.player} asks: ${pending.question}` },
|
|
868
|
+
{
|
|
869
|
+
message: `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
|
|
870
|
+
`${pending.player} · ${pending.sourceItem}`,
|
|
871
|
+
},
|
|
872
|
+
];
|
|
873
|
+
}
|
|
874
|
+
if (stateId === 'failed') {
|
|
875
|
+
const lastError = normalizeErrorCompact(context.lastError);
|
|
876
|
+
return [
|
|
877
|
+
...statuses,
|
|
878
|
+
{
|
|
879
|
+
message: '◆ workflow failed; awaiting Boss recovery.',
|
|
880
|
+
...(lastError === undefined
|
|
881
|
+
? {}
|
|
882
|
+
: {
|
|
883
|
+
data: snapshotJsonValue({ lastError }, 'failed status data'),
|
|
884
|
+
}),
|
|
885
|
+
},
|
|
886
|
+
];
|
|
887
|
+
}
|
|
888
|
+
const playerState = playerStates.get(stateId);
|
|
889
|
+
if (playerState !== undefined) {
|
|
890
|
+
statuses.push({
|
|
891
|
+
message: `⤷ ${playerState.player}: ${playerState.label}`,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
return statuses;
|
|
895
|
+
};
|
|
896
|
+
}
|
|
626
897
|
function classifierState(snapshotOrState) {
|
|
627
898
|
if (snapshotOrState !== null &&
|
|
628
899
|
typeof snapshotOrState === 'object' &&
|
|
@@ -892,6 +1163,18 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
|
|
|
892
1163
|
return event;
|
|
893
1164
|
};
|
|
894
1165
|
}
|
|
1166
|
+
function machineDeclaresParallelState(machine) {
|
|
1167
|
+
const visit = (stateDef) => {
|
|
1168
|
+
if (!isPlainObject(stateDef))
|
|
1169
|
+
return false;
|
|
1170
|
+
if (stateDef.type === 'parallel')
|
|
1171
|
+
return true;
|
|
1172
|
+
if (!isPlainObject(stateDef.states))
|
|
1173
|
+
return false;
|
|
1174
|
+
return Object.values(stateDef.states).some(visit);
|
|
1175
|
+
};
|
|
1176
|
+
return visit(machine.config);
|
|
1177
|
+
}
|
|
895
1178
|
/**
|
|
896
1179
|
* Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
|
|
897
1180
|
* under the slc/link.md contract. The factory provides every actor kind the
|
|
@@ -899,16 +1182,39 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
|
|
|
899
1182
|
* (literal and dynamic) — and implements the full runtime lifecycle including
|
|
900
1183
|
* the optional parked-session snapshot capability (DR-014).
|
|
901
1184
|
*
|
|
902
|
-
* Scope:
|
|
903
|
-
* playbook state id). Parallel-region FSMs keep their own linked
|
|
1185
|
+
* Scope: machines that declare no parallel state (each snapshot exposes
|
|
1186
|
+
* exactly one playbook state id). Parallel-region FSMs keep their own linked
|
|
1187
|
+
* runtimes.
|
|
904
1188
|
*/
|
|
905
1189
|
export function createXStatePlaybookRuntime(machine, spec) {
|
|
906
1190
|
const label = spec.label ?? 'playbook';
|
|
907
1191
|
// DR-022 / PBRT-50: reject an incompatible artifact declaration before any
|
|
908
1192
|
// machine interpretation, against this loaded engine's own self-report.
|
|
909
1193
|
assertRuntimeCompat(spec.compat, label);
|
|
1194
|
+
if (machineDeclaresParallelState(machine)) {
|
|
1195
|
+
throw new Error(`${label} uses a parallel state; the shared runtime supports only single-region FSMs`);
|
|
1196
|
+
}
|
|
910
1197
|
const declaredActors = collectInvokeSources(machine);
|
|
911
1198
|
const resumableStateIds = spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
|
|
1199
|
+
// DR-029: source state descriptions label the control actions the
|
|
1200
|
+
// runtime advertises through `describe()`.
|
|
1201
|
+
const stateDescriptions = stateDescriptionsFromMachine(machine);
|
|
1202
|
+
const hasCanonicalStatusProfile = spec.playerStates !== undefined;
|
|
1203
|
+
const playerStates = snapshotPlayerStateStatuses(spec.playerStates, label, machine, stateDescriptions);
|
|
1204
|
+
// PBRT-52: the artifact's own ControlView context projection. Nothing is
|
|
1205
|
+
// exported by default, so an FSM context member — including one added
|
|
1206
|
+
// after this artifact was linked — is private until named here. The two
|
|
1207
|
+
// members the view surfaces first-class are rejected at construction
|
|
1208
|
+
// rather than silently ignored, so an artifact cannot believe it is
|
|
1209
|
+
// exporting them through this list.
|
|
1210
|
+
const controlContextFields = spec.controlContextFields
|
|
1211
|
+
? [...spec.controlContextFields]
|
|
1212
|
+
: [];
|
|
1213
|
+
for (const field of controlContextFields) {
|
|
1214
|
+
if (field === 'pendingBossQuestion' || field === 'lastError') {
|
|
1215
|
+
throw new Error(`${label} controlContextFields must not name ${field}: the control view surfaces it first-class`);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
912
1218
|
const resolvePlayerIdSpec = spec.resolvePlayerId;
|
|
913
1219
|
const composePlayerPrompt = spec.composePlayerPrompt ??
|
|
914
1220
|
((input) => defaultComposePlayerPrompt(input, spec.placeholderFields));
|
|
@@ -934,7 +1240,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
934
1240
|
const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
|
|
935
1241
|
const normalizeTransitionEvent = spec.normalizeTransitionEvent ??
|
|
936
1242
|
makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
|
|
937
|
-
const statusesForState = spec.statusesForState ??
|
|
1243
|
+
const statusesForState = spec.statusesForState ??
|
|
1244
|
+
(hasCanonicalStatusProfile
|
|
1245
|
+
? makeDefaultStatusesForState(playerStates)
|
|
1246
|
+
: legacyStatusesForState);
|
|
1247
|
+
const classificationStatus = spec.classificationStatus ??
|
|
1248
|
+
(hasCanonicalStatusProfile
|
|
1249
|
+
? (event) => event.type
|
|
1250
|
+
: () => undefined);
|
|
938
1251
|
const machineInput = spec.machineInput ?? ((options) => options);
|
|
939
1252
|
const scriptCwd = spec.scriptCwd ??
|
|
940
1253
|
((options) => {
|
|
@@ -968,6 +1281,19 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
968
1281
|
let playerCallSequence = 0;
|
|
969
1282
|
let playbookCallSequence = 0;
|
|
970
1283
|
let captainCallSequence = 0;
|
|
1284
|
+
let applyCallSequence = 0;
|
|
1285
|
+
// DR-029: the last event a public Boss boundary sent into the
|
|
1286
|
+
// machine — classified, deterministic entry, or Boss reply — kept with
|
|
1287
|
+
// its recorded payload so a failure-state retry action can replay the
|
|
1288
|
+
// event that drove the run into `failed`. Process-local: the schema-1
|
|
1289
|
+
// parked snapshot does not persist it (PBRT-50: no schema bump).
|
|
1290
|
+
let lastBossEvent;
|
|
1291
|
+
// DR-029: process-local at-most-once `apply` execution — the accepted receipt
|
|
1292
|
+
// recorded for each idempotency key, returned verbatim on a repeated
|
|
1293
|
+
// key. A key whose call settled `rejected` or threw before reaching
|
|
1294
|
+
// acceptance records nothing, so a later call with that key may still
|
|
1295
|
+
// execute.
|
|
1296
|
+
const appliedReceipts = new Map();
|
|
971
1297
|
const playerResumeTokens = new Map();
|
|
972
1298
|
const activePlayerIds = new Set();
|
|
973
1299
|
const playbookCallTurnIds = new Map();
|
|
@@ -980,6 +1306,56 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
980
1306
|
// Inspection callbacks enqueue a complete ordered batch synchronously;
|
|
981
1307
|
// imperative boundaries await their queued work directly.
|
|
982
1308
|
let emissionFailure;
|
|
1309
|
+
function selectPlayerResume(playerId) {
|
|
1310
|
+
const selected = session?.playerSessions
|
|
1311
|
+
? session.playerSessions.select(playerId)
|
|
1312
|
+
: playerResumeTokens.get(playerId) ?? false;
|
|
1313
|
+
if (selected !== false &&
|
|
1314
|
+
(typeof selected !== 'string' || selected.trim().length === 0)) {
|
|
1315
|
+
throw new TypeError(`player session store returned an invalid resume token for ${playerId}`);
|
|
1316
|
+
}
|
|
1317
|
+
return selected;
|
|
1318
|
+
}
|
|
1319
|
+
function updatePlayerResume(playerId, resumeToken) {
|
|
1320
|
+
if (session?.playerSessions) {
|
|
1321
|
+
session.playerSessions.update(playerId, resumeToken);
|
|
1322
|
+
}
|
|
1323
|
+
else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
|
|
1324
|
+
playerResumeTokens.set(playerId, resumeToken);
|
|
1325
|
+
}
|
|
1326
|
+
else {
|
|
1327
|
+
playerResumeTokens.delete(playerId);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
function snapshotPlayerResumeTokens() {
|
|
1331
|
+
const raw = session?.playerSessions
|
|
1332
|
+
? session.playerSessions.snapshot()
|
|
1333
|
+
: Object.fromEntries(playerResumeTokens);
|
|
1334
|
+
if (!isPlainObject(raw)) {
|
|
1335
|
+
throw new TypeError('player session store snapshot must be an object');
|
|
1336
|
+
}
|
|
1337
|
+
const detached = {};
|
|
1338
|
+
for (const [playerId, token] of Object.entries(raw)) {
|
|
1339
|
+
if (playerId.trim().length === 0) {
|
|
1340
|
+
throw new TypeError('player session store snapshot player ids must be non-empty');
|
|
1341
|
+
}
|
|
1342
|
+
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
1343
|
+
throw new TypeError(`player session store snapshot token for ${playerId} must be a non-empty string`);
|
|
1344
|
+
}
|
|
1345
|
+
detached[playerId] = token;
|
|
1346
|
+
}
|
|
1347
|
+
return detached;
|
|
1348
|
+
}
|
|
1349
|
+
function restorePlayerResumeTokens(tokens) {
|
|
1350
|
+
if (session?.playerSessions) {
|
|
1351
|
+
session.playerSessions.restore(tokens);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
playerResumeTokens.clear();
|
|
1355
|
+
for (const [playerId, token] of Object.entries(tokens)) {
|
|
1356
|
+
playerResumeTokens.set(playerId, token);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
983
1359
|
function enqueueEmission(fn) {
|
|
984
1360
|
const queued = emissionQueue.add(fn).then(() => undefined);
|
|
985
1361
|
activeEmissionCalls.add(queued);
|
|
@@ -1105,14 +1481,25 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1105
1481
|
},
|
|
1106
1482
|
};
|
|
1107
1483
|
}
|
|
1108
|
-
async function emitCallStarted(startedType, finishedType, identity, position
|
|
1484
|
+
async function emitCallStarted(startedType, finishedType, identity, position,
|
|
1485
|
+
// Base payload of the best-effort finish emitted when the start sink
|
|
1486
|
+
// rejects; it defaults to the payload the start carried, which the
|
|
1487
|
+
// player, judge, and captain pairs take as-is. The apply pair cannot:
|
|
1488
|
+
// its finish carries the receipt disposition and none of the
|
|
1489
|
+
// start-only fields, so it passes its own canonical pre-acceptance
|
|
1490
|
+
// base (slc/link.md §Playbook trace).
|
|
1491
|
+
finishIdentity = identity) {
|
|
1109
1492
|
try {
|
|
1110
1493
|
await emitTrace(startedType, identity, position);
|
|
1111
1494
|
}
|
|
1112
1495
|
catch (error) {
|
|
1113
1496
|
controlPlaneError ??= error;
|
|
1114
1497
|
try {
|
|
1115
|
-
await emitTrace(finishedType, {
|
|
1498
|
+
await emitTrace(finishedType, {
|
|
1499
|
+
...finishIdentity,
|
|
1500
|
+
status: 'error',
|
|
1501
|
+
error: normalizeError(error),
|
|
1502
|
+
}, position);
|
|
1116
1503
|
}
|
|
1117
1504
|
catch {
|
|
1118
1505
|
// Preserve the start failure after one best-effort finish attempt.
|
|
@@ -1125,9 +1512,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1125
1512
|
// State-entry telemetry/status must precede the call they describe.
|
|
1126
1513
|
await drainEmissions();
|
|
1127
1514
|
const turnId = activeTurnId;
|
|
1128
|
-
const callId = `player-${++playerCallSequence}`;
|
|
1129
1515
|
const stateId = input.stateId;
|
|
1130
|
-
|
|
1516
|
+
let resume;
|
|
1517
|
+
try {
|
|
1518
|
+
signal.throwIfAborted();
|
|
1519
|
+
resume = selectPlayerResume(playerId);
|
|
1520
|
+
}
|
|
1521
|
+
catch (error) {
|
|
1522
|
+
if (!signal.aborted)
|
|
1523
|
+
controlPlaneError ??= error;
|
|
1524
|
+
throw error;
|
|
1525
|
+
}
|
|
1526
|
+
const callId = `player-${++playerCallSequence}`;
|
|
1131
1527
|
const identity = {
|
|
1132
1528
|
purpose: 'captain',
|
|
1133
1529
|
...stateIdentity(stateId),
|
|
@@ -1150,6 +1546,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1150
1546
|
await emitTrace('player.call.started', { ...identity, prompt }, position);
|
|
1151
1547
|
let rawResult;
|
|
1152
1548
|
try {
|
|
1549
|
+
// An abort may land while the awaited started emission drains
|
|
1550
|
+
// (e.g. fired from the trace sink itself); the host call must
|
|
1551
|
+
// never start after abort, so settle the already-started pair
|
|
1552
|
+
// as `aborted` through the catch below.
|
|
1553
|
+
signal.throwIfAborted();
|
|
1153
1554
|
rawResult = await requireHostPorts().callPlayer(playerId, prompt, signal, { resume });
|
|
1154
1555
|
// A host promise is not required to honor cancellation. Do not let
|
|
1155
1556
|
// a late result mutate continuity or publish a successful finish.
|
|
@@ -1187,12 +1588,22 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1187
1588
|
}
|
|
1188
1589
|
throw error;
|
|
1189
1590
|
}
|
|
1190
|
-
|
|
1191
|
-
result.resumeToken
|
|
1192
|
-
|
|
1591
|
+
try {
|
|
1592
|
+
updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
|
|
1593
|
+
result.resumeToken.trim().length > 0
|
|
1594
|
+
? result.resumeToken
|
|
1595
|
+
: undefined);
|
|
1193
1596
|
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1597
|
+
catch (error) {
|
|
1598
|
+
if (!signal.aborted)
|
|
1599
|
+
controlPlaneError ??= error;
|
|
1600
|
+
try {
|
|
1601
|
+
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
1602
|
+
}
|
|
1603
|
+
catch {
|
|
1604
|
+
// The continuation-store failure remains authoritative.
|
|
1605
|
+
}
|
|
1606
|
+
throw error;
|
|
1196
1607
|
}
|
|
1197
1608
|
await emitTrace('player.call.finished', {
|
|
1198
1609
|
...identity,
|
|
@@ -1230,6 +1641,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1230
1641
|
await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position);
|
|
1231
1642
|
let reply;
|
|
1232
1643
|
try {
|
|
1644
|
+
// An abort may land while the awaited started emission drains
|
|
1645
|
+
// (e.g. fired from the trace sink itself); the host call must
|
|
1646
|
+
// never start after abort, so settle the already-started pair
|
|
1647
|
+
// as `aborted` through the catch below.
|
|
1648
|
+
signal.throwIfAborted();
|
|
1233
1649
|
reply = await requireHostPorts().callJudge(prompt, signal);
|
|
1234
1650
|
signal.throwIfAborted();
|
|
1235
1651
|
}
|
|
@@ -1261,18 +1677,23 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1261
1677
|
return reply;
|
|
1262
1678
|
});
|
|
1263
1679
|
},
|
|
1264
|
-
async callCaptain(input, prompt, signal) {
|
|
1680
|
+
async callCaptain(input, prompt, signal, callOptions) {
|
|
1265
1681
|
return judgeQueue.add(async () => {
|
|
1266
1682
|
signal.throwIfAborted();
|
|
1267
1683
|
await drainEmissions();
|
|
1268
1684
|
signal.throwIfAborted();
|
|
1269
1685
|
const turnId = activeTurnId;
|
|
1270
1686
|
const callId = `captain-${++captainCallSequence}`;
|
|
1687
|
+
const visibility = callOptions?.visibility ?? 'visible';
|
|
1271
1688
|
const identity = {
|
|
1272
1689
|
...stateIdentity(input.stateId),
|
|
1273
1690
|
sourceItem: input.sourceItem,
|
|
1274
|
-
visibility
|
|
1275
|
-
resume: false
|
|
1691
|
+
visibility,
|
|
1692
|
+
// The visible workflow form owns its `resume: false` selection;
|
|
1693
|
+
// a hidden controller call's durable-conversation resume
|
|
1694
|
+
// selection is host-owned (DR-029), so its trace pair carries
|
|
1695
|
+
// no resume member and no token.
|
|
1696
|
+
...(visibility === 'visible' ? { resume: false } : {}),
|
|
1276
1697
|
...(input.allowedTools === undefined
|
|
1277
1698
|
? {}
|
|
1278
1699
|
: { allowedTools: [...input.allowedTools] }),
|
|
@@ -1284,8 +1705,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1284
1705
|
await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position);
|
|
1285
1706
|
let rawResult;
|
|
1286
1707
|
try {
|
|
1708
|
+
// An abort may land while the awaited started emission drains
|
|
1709
|
+
// (e.g. fired from the trace sink itself); the host call must
|
|
1710
|
+
// never start after abort, so settle the already-started pair
|
|
1711
|
+
// as `aborted` through the catch below.
|
|
1712
|
+
signal.throwIfAborted();
|
|
1287
1713
|
rawResult = await requireHostPorts().callCaptain(prompt, signal, {
|
|
1288
|
-
visibility
|
|
1714
|
+
visibility,
|
|
1289
1715
|
resume: false,
|
|
1290
1716
|
...(input.allowedTools !== undefined
|
|
1291
1717
|
? { allowedTools: input.allowedTools }
|
|
@@ -1317,12 +1743,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1317
1743
|
// authoritative for the actor's error path even when the required
|
|
1318
1744
|
// finish emission fails or a coincident boundary abort lands.
|
|
1319
1745
|
let resultFailure;
|
|
1746
|
+
let emptyOkRetry = false;
|
|
1320
1747
|
if (result.status !== 'ok') {
|
|
1321
1748
|
resultFailure = markFsmResultFailure(new Error(result.error ??
|
|
1322
1749
|
`captainActor: callCaptain status "${result.status}"`));
|
|
1323
1750
|
}
|
|
1324
|
-
else if (result.finalText
|
|
1751
|
+
else if (isEmptyFinalText(result.finalText)) {
|
|
1325
1752
|
resultFailure = markFsmResultFailure(new Error('captainActor: callCaptain returned status=ok with no finalText'));
|
|
1753
|
+
emptyOkRetry = true;
|
|
1326
1754
|
}
|
|
1327
1755
|
try {
|
|
1328
1756
|
await emitTrace('captain.call.finished', {
|
|
@@ -1341,13 +1769,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1341
1769
|
catch (error) {
|
|
1342
1770
|
// Keep the finish-sink failure in the emission queue for public
|
|
1343
1771
|
// cleanup evidence, but do not replace an authoritative result
|
|
1344
|
-
// failure on the invoked actor's XState onError path.
|
|
1772
|
+
// failure on the invoked actor's XState onError path. A failure
|
|
1773
|
+
// thrown here is never marked re-askable: a rejecting finish
|
|
1774
|
+
// sink stays a control-plane error with no corrective re-ask
|
|
1775
|
+
// (PBRT-47).
|
|
1345
1776
|
if (resultFailure !== undefined)
|
|
1346
1777
|
throw resultFailure;
|
|
1347
1778
|
throw error;
|
|
1348
1779
|
}
|
|
1349
1780
|
if (resultFailure !== undefined) {
|
|
1350
|
-
throw
|
|
1781
|
+
throw emptyOkRetry
|
|
1782
|
+
? markEmptyOkRetryFailure(resultFailure)
|
|
1783
|
+
: resultFailure;
|
|
1351
1784
|
}
|
|
1352
1785
|
return result;
|
|
1353
1786
|
});
|
|
@@ -1379,17 +1812,52 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1379
1812
|
try {
|
|
1380
1813
|
await drainEmissions();
|
|
1381
1814
|
const prompt = composeCaptainPrompt(input);
|
|
1382
|
-
|
|
1815
|
+
if (spec.captainStrategy !== undefined) {
|
|
1816
|
+
// Controller form (slc/link.md §Captain adjudication): the
|
|
1817
|
+
// spec's strategy owns the call pipeline; the engine still
|
|
1818
|
+
// owns tracing, the shared lane, signal combination, and the
|
|
1819
|
+
// control-plane latch in the catch below.
|
|
1820
|
+
const output = await spec.captainStrategy({
|
|
1821
|
+
input,
|
|
1822
|
+
prompt,
|
|
1823
|
+
signal: active,
|
|
1824
|
+
options: boundOptions,
|
|
1825
|
+
session: requireSession(),
|
|
1826
|
+
callCaptain: (callPrompt, callOptions) => boundary.callCaptain(input, callPrompt, active, callOptions),
|
|
1827
|
+
isEmptyOkRetry: isEmptyOkRetryFailure,
|
|
1828
|
+
recoverableFailure: (error) => {
|
|
1829
|
+
markFsmResultFailure(error);
|
|
1830
|
+
return error;
|
|
1831
|
+
},
|
|
1832
|
+
});
|
|
1833
|
+
validateBossReplyOutput(input, output, resumableStateIds);
|
|
1834
|
+
return output;
|
|
1835
|
+
}
|
|
1836
|
+
let result;
|
|
1837
|
+
try {
|
|
1838
|
+
result = await boundary.callCaptain(input, prompt, active);
|
|
1839
|
+
}
|
|
1840
|
+
catch (error) {
|
|
1841
|
+
if (!isEmptyOkRetryFailure(error))
|
|
1842
|
+
throw error;
|
|
1843
|
+
// DR-028: exactly one corrective re-ask of the same composed
|
|
1844
|
+
// call through the same boundary, traced as its own
|
|
1845
|
+
// started/finished pair, its result read under the unchanged
|
|
1846
|
+
// rules — a second empty `ok` result throws from the boundary
|
|
1847
|
+
// exactly as the first did, with no further re-ask.
|
|
1848
|
+
result = await boundary.callCaptain(input, prompt, active);
|
|
1849
|
+
}
|
|
1383
1850
|
// The boundary owns result validation (PBRT-47) and throws the
|
|
1384
1851
|
// authoritative failure itself, so a returned result is always
|
|
1385
1852
|
// `ok` with visible text. Assert that invariant rather than
|
|
1386
1853
|
// restating the failure semantics, which would drift.
|
|
1387
|
-
|
|
1854
|
+
const finalText = result.finalText ?? '';
|
|
1855
|
+
if (result.status !== 'ok' || isEmptyFinalText(finalText)) {
|
|
1388
1856
|
throw new Error('captainActor: boundary returned an unvalidated Captain result');
|
|
1389
1857
|
}
|
|
1390
|
-
const judgePrompt = defaultBuildCaptainJudgePrompt(input,
|
|
1858
|
+
const judgePrompt = defaultBuildCaptainJudgePrompt(input, finalText);
|
|
1391
1859
|
const raw = await boundary.callJudge('captain-output-adjudication', input.stateId, judgePrompt, active);
|
|
1392
|
-
const output = adjudicateCaptainOutput(extractFields, input,
|
|
1860
|
+
const output = adjudicateCaptainOutput(extractFields, input, finalText, raw);
|
|
1393
1861
|
validateBossReplyOutput(input, output, resumableStateIds);
|
|
1394
1862
|
return output;
|
|
1395
1863
|
}
|
|
@@ -1569,6 +2037,20 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1569
2037
|
else
|
|
1570
2038
|
emissionFailure ??= error;
|
|
1571
2039
|
}
|
|
2040
|
+
// PBRT-6: the single seam that stops this runtime's actor. Stopping a
|
|
2041
|
+
// still-running actor fires one more `@xstate.snapshot` for the
|
|
2042
|
+
// *unchanged* state value with `status: 'stopped'`, which the inspect
|
|
2043
|
+
// callback cannot distinguish from a state entry — unsuppressed it
|
|
2044
|
+
// re-emits the parked state's statuses and a phantom self-loop
|
|
2045
|
+
// transition. Suppression is a property of stopping, not a rule each
|
|
2046
|
+
// caller must remember, so every stop goes through here; a caller that
|
|
2047
|
+
// builds a replacement actor clears the flag before starting it.
|
|
2048
|
+
function stopActor() {
|
|
2049
|
+
if (!actor)
|
|
2050
|
+
return;
|
|
2051
|
+
suppressInspectionEmissions = true;
|
|
2052
|
+
actor.stop();
|
|
2053
|
+
}
|
|
1572
2054
|
function buildActor(ports, machineSnapshot) {
|
|
1573
2055
|
priorState = undefined;
|
|
1574
2056
|
const actors = {};
|
|
@@ -1695,9 +2177,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1695
2177
|
// A state that cannot even normalize has no disposal descriptor.
|
|
1696
2178
|
}
|
|
1697
2179
|
}
|
|
1698
|
-
suppressInspectionEmissions = true;
|
|
1699
2180
|
try {
|
|
1700
|
-
|
|
2181
|
+
stopActor();
|
|
1701
2182
|
}
|
|
1702
2183
|
catch {
|
|
1703
2184
|
// Preserve the original startup failure.
|
|
@@ -1735,6 +2216,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1735
2216
|
activeEmissionCalls.clear();
|
|
1736
2217
|
emissionQueue.clear();
|
|
1737
2218
|
judgeQueue.clear();
|
|
2219
|
+
appliedReceipts.clear();
|
|
1738
2220
|
actor = undefined;
|
|
1739
2221
|
session = undefined;
|
|
1740
2222
|
savedPorts = undefined;
|
|
@@ -1744,6 +2226,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1744
2226
|
controlPlaneError = undefined;
|
|
1745
2227
|
emissionFailure = undefined;
|
|
1746
2228
|
priorState = undefined;
|
|
2229
|
+
lastBossEvent = undefined;
|
|
1747
2230
|
suppressInspectionEmissions = false;
|
|
1748
2231
|
initialized = false;
|
|
1749
2232
|
traceSequence = 0;
|
|
@@ -1752,6 +2235,159 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1752
2235
|
playerCallSequence = 0;
|
|
1753
2236
|
playbookCallSequence = 0;
|
|
1754
2237
|
captainCallSequence = 0;
|
|
2238
|
+
applyCallSequence = 0;
|
|
2239
|
+
}
|
|
2240
|
+
function snapshotCan(snapshot, event) {
|
|
2241
|
+
const can = snapshot?.can;
|
|
2242
|
+
return (typeof can === 'function' &&
|
|
2243
|
+
can.call(snapshot, event) ===
|
|
2244
|
+
true);
|
|
2245
|
+
}
|
|
2246
|
+
// The failure-state retry entry replays the recorded last classified
|
|
2247
|
+
// event with its recorded payload. A candidate whose event the live
|
|
2248
|
+
// snapshot does not accept — or whose payload the runtime never
|
|
2249
|
+
// recorded — is excluded rather than completed with invented text.
|
|
2250
|
+
function retryActionFor(snapshot, stateId) {
|
|
2251
|
+
if (stateId !== 'failed' || lastBossEvent === undefined) {
|
|
2252
|
+
return undefined;
|
|
2253
|
+
}
|
|
2254
|
+
if (!snapshotCan(snapshot, lastBossEvent))
|
|
2255
|
+
return undefined;
|
|
2256
|
+
// A recorded explicit-state-jump event names the exact state its
|
|
2257
|
+
// replay re-enters: the root BOSS_INTERRUPT shape is a guarded
|
|
2258
|
+
// multi-arm list keyed on `targetId`, so the first configured arm
|
|
2259
|
+
// may label a different state than the one the recorded event
|
|
2260
|
+
// actually resumes.
|
|
2261
|
+
const recordedTargetId = lastBossEvent.type === JUMP_EVENT_TYPE
|
|
2262
|
+
? lastBossEvent.targetId
|
|
2263
|
+
: undefined;
|
|
2264
|
+
const target = typeof recordedTargetId === 'string' &&
|
|
2265
|
+
recordedTargetId.trim().length > 0
|
|
2266
|
+
? recordedTargetId
|
|
2267
|
+
: firstTransitionTarget(machine, stateId, lastBossEvent.type);
|
|
2268
|
+
// PBRT-52: a label is written from a source state description, never
|
|
2269
|
+
// from an identifier. Falling back to the target id — or, with no
|
|
2270
|
+
// resolvable target, to the FSM event type — makes the label *be* the
|
|
2271
|
+
// internal name, which defeats the substitution the label exists for
|
|
2272
|
+
// and puts a machine identifier into Boss-facing text
|
|
2273
|
+
// (CAPPLAY-5). A candidate whose label can only be an id is excluded
|
|
2274
|
+
// exactly like one whose payload cannot be sourced.
|
|
2275
|
+
const description = (target === undefined ? undefined : stateDescriptions.get(target)) ??
|
|
2276
|
+
stateDescriptions.get(stateId);
|
|
2277
|
+
if (description === undefined)
|
|
2278
|
+
return undefined;
|
|
2279
|
+
return {
|
|
2280
|
+
action: {
|
|
2281
|
+
id: `retry:${lastBossEvent.type}`,
|
|
2282
|
+
label: `Retry: ${description}`,
|
|
2283
|
+
},
|
|
2284
|
+
event: lastBossEvent,
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
function deriveControlActions(snapshot) {
|
|
2288
|
+
// Actions derive only at the safe point the parked snapshot also
|
|
2289
|
+
// uses — quiescent actor with status `active` and no pending nested
|
|
2290
|
+
// call. Anywhere else the view still describes the state while
|
|
2291
|
+
// advertising nothing.
|
|
2292
|
+
let state;
|
|
2293
|
+
try {
|
|
2294
|
+
state = normalizePlaybookSnapshot(snapshot, {
|
|
2295
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
catch {
|
|
2299
|
+
return [];
|
|
2300
|
+
}
|
|
2301
|
+
if (state.status !== 'active' ||
|
|
2302
|
+
!state.quiescent ||
|
|
2303
|
+
nestedBridge.getPendingCall()) {
|
|
2304
|
+
return [];
|
|
2305
|
+
}
|
|
2306
|
+
const derived = [];
|
|
2307
|
+
const retry = retryActionFor(snapshot, state.stateId);
|
|
2308
|
+
if (retry !== undefined)
|
|
2309
|
+
derived.push(retry);
|
|
2310
|
+
// Jump entries: resumable targets whose explicit-state-jump event the
|
|
2311
|
+
// live snapshot accepts (state guards included), sent with the
|
|
2312
|
+
// advertised target id and optional textual fields omitted.
|
|
2313
|
+
for (const targetId of [...resumableStateIds].sort()) {
|
|
2314
|
+
const event = { type: JUMP_EVENT_TYPE, targetId };
|
|
2315
|
+
if (!snapshotCan(snapshot, event))
|
|
2316
|
+
continue;
|
|
2317
|
+
// PBRT-52: no published description for the target, no Boss-appropriate
|
|
2318
|
+
// label. A jump cannot borrow another state's meaning without naming
|
|
2319
|
+
// the wrong state, so the entry is not advertised at all rather than
|
|
2320
|
+
// labeled with its own target id.
|
|
2321
|
+
const description = stateDescriptions.get(targetId);
|
|
2322
|
+
if (description === undefined)
|
|
2323
|
+
continue;
|
|
2324
|
+
derived.push({
|
|
2325
|
+
action: {
|
|
2326
|
+
id: `jump:${targetId}`,
|
|
2327
|
+
label: `Resume from: ${description}`,
|
|
2328
|
+
},
|
|
2329
|
+
event,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
return derived;
|
|
2333
|
+
}
|
|
2334
|
+
// PBRT-52: the control view's context is the artifact's declared
|
|
2335
|
+
// projection, not a serialization of whatever the FSM happens to hold.
|
|
2336
|
+
// Only the runtime knows which of its context members are safe and
|
|
2337
|
+
// relevant for a controller prompt — an allow-by-default export cannot
|
|
2338
|
+
// keep player output, resolved player identities, or option values out
|
|
2339
|
+
// of a prompt whose host is required to exclude them
|
|
2340
|
+
// (CAPTAIN-9) — so nothing is exported unless
|
|
2341
|
+
// `controlContextFields` names it, in the order it names them. Each
|
|
2342
|
+
// named member is still sanitized: raw `Error` values are normalized
|
|
2343
|
+
// and a value that cannot be made JSON-safe is dropped, never thrown,
|
|
2344
|
+
// since `describe` must stay side-effect free and total.
|
|
2345
|
+
function projectControlContext(context) {
|
|
2346
|
+
const projected = {};
|
|
2347
|
+
for (const key of controlContextFields) {
|
|
2348
|
+
const value = context[key];
|
|
2349
|
+
if (value === undefined)
|
|
2350
|
+
continue;
|
|
2351
|
+
try {
|
|
2352
|
+
projected[key] = snapshotJsonValue(value instanceof Error ? normalizeError(value) : value, `control context ${key}`);
|
|
2353
|
+
}
|
|
2354
|
+
catch {
|
|
2355
|
+
// Declared but not JSON-safe — dropped.
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
return Object.keys(projected).length === 0 ? undefined : projected;
|
|
2359
|
+
}
|
|
2360
|
+
// PBRT-52: the view's Boss-facing state description — the meaning of the
|
|
2361
|
+
// state the runtime is in, written by the artifact's own source, from the
|
|
2362
|
+
// same descriptions its action labels are written from. A control view is
|
|
2363
|
+
// the only grounding a controller host has for a status answer, and an
|
|
2364
|
+
// internal state id is not Boss-appropriate text
|
|
2365
|
+
// (CAPPLAY-5), so the runtime publishes the meaning
|
|
2366
|
+
// rather than leaving the host to substitute the identifier for it. A
|
|
2367
|
+
// state whose source declares no description publishes none: an id is
|
|
2368
|
+
// never promoted into a description by default.
|
|
2369
|
+
function stateDescriptionFor(state) {
|
|
2370
|
+
const keys = [
|
|
2371
|
+
...(state.stateId === undefined ? [] : [state.stateId]),
|
|
2372
|
+
...(typeof state.value === 'string' ? [state.value] : []),
|
|
2373
|
+
...state.activeStateIds,
|
|
2374
|
+
];
|
|
2375
|
+
for (const key of keys) {
|
|
2376
|
+
const description = stateDescriptions.get(key);
|
|
2377
|
+
if (description !== undefined)
|
|
2378
|
+
return description;
|
|
2379
|
+
}
|
|
2380
|
+
return undefined;
|
|
2381
|
+
}
|
|
2382
|
+
function receiptTracePayload(receipt) {
|
|
2383
|
+
return {
|
|
2384
|
+
disposition: receipt.disposition,
|
|
2385
|
+
...(receipt.disposition === 'rejected'
|
|
2386
|
+
? { reason: receipt.reason }
|
|
2387
|
+
: {}),
|
|
2388
|
+
...(receipt.disposition === 'failed' ? { error: receipt.error } : {}),
|
|
2389
|
+
...(receipt.disposition === 'executed' ? { run: receipt.run } : {}),
|
|
2390
|
+
};
|
|
1755
2391
|
}
|
|
1756
2392
|
const runtime = {
|
|
1757
2393
|
async init(nextSession) {
|
|
@@ -1811,7 +2447,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1811
2447
|
schemaVersion: 1,
|
|
1812
2448
|
playbookId: session.playbookId,
|
|
1813
2449
|
machine: machineSnapshot,
|
|
1814
|
-
playerResumeTokens:
|
|
2450
|
+
playerResumeTokens: snapshotPlayerResumeTokens(),
|
|
1815
2451
|
sequences: {
|
|
1816
2452
|
trace: traceSequence,
|
|
1817
2453
|
turn: turnSequence,
|
|
@@ -1846,6 +2482,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1846
2482
|
}
|
|
1847
2483
|
const boundSession = snapshotPlaybookSession(nextSession);
|
|
1848
2484
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId);
|
|
2485
|
+
let priorExternalPlayerTokens;
|
|
2486
|
+
let externalStoreRestoreAttempted = false;
|
|
1849
2487
|
initialized = true;
|
|
1850
2488
|
let finishInitialization;
|
|
1851
2489
|
const initialization = new Promise((resolve) => {
|
|
@@ -1867,10 +2505,16 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1867
2505
|
// Every Captain call already consumed at least one trace number,
|
|
1868
2506
|
// so the global trace counter is a collision-safe id floor.
|
|
1869
2507
|
boundSnapshot.sequences.trace;
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
2508
|
+
// The schema-1 snapshot carries no apply counter (PBRT-50: no
|
|
2509
|
+
// schema bump); every apply boundary consumed trace numbers, so
|
|
2510
|
+
// the persisted trace counter is a collision-safe id floor here
|
|
2511
|
+
// too, keeping `apply-<n>` call ids unique across restore.
|
|
2512
|
+
applyCallSequence = boundSnapshot.sequences.trace;
|
|
2513
|
+
if (boundSession.playerSessions) {
|
|
2514
|
+
priorExternalPlayerTokens = snapshotPlayerResumeTokens();
|
|
2515
|
+
externalStoreRestoreAttempted = true;
|
|
1873
2516
|
}
|
|
2517
|
+
restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
|
|
1874
2518
|
suppressInspectionEmissions = true;
|
|
1875
2519
|
actor = buildActor(runtimePorts, boundSnapshot.machine);
|
|
1876
2520
|
actor.start();
|
|
@@ -1886,8 +2530,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1886
2530
|
await initTask;
|
|
1887
2531
|
}
|
|
1888
2532
|
catch (error) {
|
|
1889
|
-
|
|
1890
|
-
|
|
2533
|
+
let failure = error;
|
|
2534
|
+
if (externalStoreRestoreAttempted &&
|
|
2535
|
+
priorExternalPlayerTokens !== undefined) {
|
|
2536
|
+
try {
|
|
2537
|
+
boundSession.playerSessions.restore(priorExternalPlayerTokens);
|
|
2538
|
+
}
|
|
2539
|
+
catch (rollbackError) {
|
|
2540
|
+
failure = new AggregateError([error, rollbackError], 'createPlaybookRuntime.restore and player continuation rollback failed');
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
await cleanupFailedStart(failure, { emitDisposal: false });
|
|
2544
|
+
throw failure;
|
|
1891
2545
|
}
|
|
1892
2546
|
finally {
|
|
1893
2547
|
finishInitialization();
|
|
@@ -1895,6 +2549,301 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1895
2549
|
initInFlight = undefined;
|
|
1896
2550
|
}
|
|
1897
2551
|
},
|
|
2552
|
+
// DR-029 / PBRT-52: side-effect-free control view over the live
|
|
2553
|
+
// snapshot, valid at parked quiescence outside an active boundary.
|
|
2554
|
+
// The view is detached and frozen; producing it emits nothing and
|
|
2555
|
+
// moves nothing.
|
|
2556
|
+
describe() {
|
|
2557
|
+
if (disposed || disposalPromise !== undefined) {
|
|
2558
|
+
throw new Error('createPlaybookRuntime.describe: runtime is disposing or disposed');
|
|
2559
|
+
}
|
|
2560
|
+
if (!actor || !savedPorts) {
|
|
2561
|
+
throw new Error('createPlaybookRuntime.describe: init must be called first');
|
|
2562
|
+
}
|
|
2563
|
+
if (activeSignal !== undefined) {
|
|
2564
|
+
throw new Error('createPlaybookRuntime.describe: another runtime turn is active');
|
|
2565
|
+
}
|
|
2566
|
+
const snapshot = actor.getSnapshot();
|
|
2567
|
+
const state = currentState();
|
|
2568
|
+
const context = (snapshot.context ??
|
|
2569
|
+
{});
|
|
2570
|
+
const pending = pendingBossQuestionFromContext(context);
|
|
2571
|
+
const lastError = normalizeErrorFull(context.lastError);
|
|
2572
|
+
const projectedContext = projectControlContext(context);
|
|
2573
|
+
const stateDescription = stateDescriptionFor(state);
|
|
2574
|
+
return deepFreeze({
|
|
2575
|
+
state,
|
|
2576
|
+
...(stateDescription === undefined ? {} : { stateDescription }),
|
|
2577
|
+
...(projectedContext !== undefined
|
|
2578
|
+
? { context: projectedContext }
|
|
2579
|
+
: {}),
|
|
2580
|
+
pendingQuestions: pending === undefined
|
|
2581
|
+
? []
|
|
2582
|
+
: [
|
|
2583
|
+
{
|
|
2584
|
+
questionId: pending.questionId,
|
|
2585
|
+
player: pending.player,
|
|
2586
|
+
question: pending.question,
|
|
2587
|
+
sourceItem: pending.sourceItem,
|
|
2588
|
+
},
|
|
2589
|
+
],
|
|
2590
|
+
...(lastError !== undefined ? { lastError } : {}),
|
|
2591
|
+
actions: deriveControlActions(snapshot).map(({ action }) => action),
|
|
2592
|
+
});
|
|
2593
|
+
},
|
|
2594
|
+
// DR-029 / PBRT-52: revalidate the named action against the live
|
|
2595
|
+
// state and execute it at most once per idempotency key. The receipt
|
|
2596
|
+
// discriminates rejected-before-any-effect from executed and from
|
|
2597
|
+
// failed-after-effects-may-exist; a repeated key returns the recorded
|
|
2598
|
+
// receipt without re-execution. A rejection settles before acceptance,
|
|
2599
|
+
// so — like a key whose call threw before reaching acceptance — it
|
|
2600
|
+
// records nothing and the key may execute later, once the action is
|
|
2601
|
+
// advertised.
|
|
2602
|
+
async apply(input) {
|
|
2603
|
+
if (input === null || typeof input !== 'object') {
|
|
2604
|
+
throw new TypeError('createPlaybookRuntime.apply: input must be an object');
|
|
2605
|
+
}
|
|
2606
|
+
const { actionId, key, signal } = input;
|
|
2607
|
+
if (typeof actionId !== 'string' || actionId.length === 0) {
|
|
2608
|
+
throw new TypeError('createPlaybookRuntime.apply: actionId must be a non-empty string');
|
|
2609
|
+
}
|
|
2610
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
2611
|
+
throw new TypeError('createPlaybookRuntime.apply: key must be a non-empty string');
|
|
2612
|
+
}
|
|
2613
|
+
if (!(signal instanceof AbortSignal)) {
|
|
2614
|
+
throw new TypeError('createPlaybookRuntime.apply: signal must be an AbortSignal');
|
|
2615
|
+
}
|
|
2616
|
+
if (disposed || disposalPromise !== undefined) {
|
|
2617
|
+
throw new Error('createPlaybookRuntime.apply: runtime is disposing or disposed');
|
|
2618
|
+
}
|
|
2619
|
+
if (!actor || !savedPorts) {
|
|
2620
|
+
throw new Error('createPlaybookRuntime.apply: init must be called first');
|
|
2621
|
+
}
|
|
2622
|
+
if (activeSignal !== undefined) {
|
|
2623
|
+
throw new Error('createPlaybookRuntime.apply: another runtime turn is active');
|
|
2624
|
+
}
|
|
2625
|
+
// Settlement is final: a repeated key returns the recorded receipt
|
|
2626
|
+
// with no revalidation, no execution, and no new trace pair.
|
|
2627
|
+
const recorded = appliedReceipts.get(key);
|
|
2628
|
+
if (recorded !== undefined)
|
|
2629
|
+
return recorded;
|
|
2630
|
+
// An abort before acceptance ends the call with no receipt
|
|
2631
|
+
// recorded, like every other pre-acceptance failure.
|
|
2632
|
+
signal.throwIfAborted();
|
|
2633
|
+
const turnId = ++turnSequence;
|
|
2634
|
+
const callId = `apply-${++applyCallSequence}`;
|
|
2635
|
+
const position = { turnId, callId };
|
|
2636
|
+
activeTurnId = turnId;
|
|
2637
|
+
activeSignal = signal;
|
|
2638
|
+
controlPlaneError = undefined;
|
|
2639
|
+
// Every receipt variant is normalized and frozen where it is built,
|
|
2640
|
+
// inside the guarded region, so the recording step below cannot
|
|
2641
|
+
// throw after effects exist.
|
|
2642
|
+
const settledReceipt = (value) => deepFreeze(snapshotJsonValue(value, 'apply receipt'));
|
|
2643
|
+
let receipt;
|
|
2644
|
+
let operationError;
|
|
2645
|
+
let settlementError;
|
|
2646
|
+
// Acceptance is the line past which this boundary owes a receipt and
|
|
2647
|
+
// can no longer signal by throwing: the action may have run, and a
|
|
2648
|
+
// caller that gets an exception instead of a receipt is left with an
|
|
2649
|
+
// executed effect it cannot record and a key it will not reuse.
|
|
2650
|
+
let accepted = false;
|
|
2651
|
+
// Publication is the second line this boundary respects. Before it,
|
|
2652
|
+
// nothing has left the runtime: a settlement failure past acceptance
|
|
2653
|
+
// is a post-acceptance control-plane error PBRT-52 settles as the
|
|
2654
|
+
// `failed` receipt, and folding it in replaces the receipt recorded
|
|
2655
|
+
// at acceptance so the finish trace, the returned receipt, and any
|
|
2656
|
+
// replay of the key all report one settlement. Past publication that
|
|
2657
|
+
// agreement is no longer achievable — the disposition is already on
|
|
2658
|
+
// the wire — so the fold refuses to run, by construction rather than
|
|
2659
|
+
// by call ordering. Only the first settlement error is latched, so
|
|
2660
|
+
// one fold is all there is to do.
|
|
2661
|
+
let folded = false;
|
|
2662
|
+
let published = false;
|
|
2663
|
+
const foldSettlementFailure = () => {
|
|
2664
|
+
if (published || !accepted || folded)
|
|
2665
|
+
return;
|
|
2666
|
+
if (settlementError === undefined)
|
|
2667
|
+
return;
|
|
2668
|
+
folded = true;
|
|
2669
|
+
receipt = settledReceipt({
|
|
2670
|
+
disposition: 'failed',
|
|
2671
|
+
error: normalizeError(settlementError),
|
|
2672
|
+
});
|
|
2673
|
+
appliedReceipts.set(key, receipt);
|
|
2674
|
+
};
|
|
2675
|
+
// A settlement failure that lands after the receipt is published says
|
|
2676
|
+
// nothing about the effect: the action ran, the caller's receipt is
|
|
2677
|
+
// true, and only the telemetry delivery failed. Rewriting `executed`
|
|
2678
|
+
// to `failed` there would make the runtime lie to its only caller
|
|
2679
|
+
// about work that succeeded, irrecoverably — accepted receipts are
|
|
2680
|
+
// final for their key. Past publication such a failure is therefore
|
|
2681
|
+
// re-latched onto the emission channel, surfacing from the next
|
|
2682
|
+
// public boundary's drain, and `apply` still does not throw past
|
|
2683
|
+
// acceptance (PBRT-52).
|
|
2684
|
+
const latchDeliveryFailure = (error) => {
|
|
2685
|
+
emissionFailure ??= error;
|
|
2686
|
+
};
|
|
2687
|
+
try {
|
|
2688
|
+
try {
|
|
2689
|
+
const identity = {
|
|
2690
|
+
actionId,
|
|
2691
|
+
key,
|
|
2692
|
+
...stateIdentity(currentState().stateId),
|
|
2693
|
+
};
|
|
2694
|
+
// Every apply finish carries the receipt disposition and no
|
|
2695
|
+
// start-only field — `stateId` is on the start alone
|
|
2696
|
+
// (slc/link.md §Playbook trace). Both finishes reachable
|
|
2697
|
+
// before acceptance settle with no effect behind them, so both
|
|
2698
|
+
// carry the canonical `rejected` disposition and the reason
|
|
2699
|
+
// that ended the call, alongside the transport marker.
|
|
2700
|
+
const preAcceptanceFinish = (reason) => ({
|
|
2701
|
+
actionId,
|
|
2702
|
+
key,
|
|
2703
|
+
...receiptTracePayload({ disposition: 'rejected', reason }),
|
|
2704
|
+
});
|
|
2705
|
+
await emitCallStarted('apply.started', 'apply.finished', identity, position, preAcceptanceFinish('apply.started trace sink rejected'));
|
|
2706
|
+
// An abort may land while the awaited started emission drains
|
|
2707
|
+
// (e.g. fired from the trace sink itself); the action must
|
|
2708
|
+
// never execute after abort. Settle the already-started pair
|
|
2709
|
+
// as `aborted` — carrying the canonical rejected-before-any-
|
|
2710
|
+
// effect receipt disposition required of every apply finish —
|
|
2711
|
+
// and end the call pre-acceptance: no receipt is recorded and
|
|
2712
|
+
// the key stays free.
|
|
2713
|
+
if (signal.aborted) {
|
|
2714
|
+
try {
|
|
2715
|
+
await emitTrace('apply.finished', {
|
|
2716
|
+
...preAcceptanceFinish('aborted before acceptance'),
|
|
2717
|
+
status: 'aborted',
|
|
2718
|
+
error: normalizeError(signal.reason),
|
|
2719
|
+
}, position);
|
|
2720
|
+
}
|
|
2721
|
+
catch (error) {
|
|
2722
|
+
// A rejecting finish sink surfaces at the boundary like
|
|
2723
|
+
// any settlement failure (see the precedence below).
|
|
2724
|
+
settlementError ??= error;
|
|
2725
|
+
}
|
|
2726
|
+
signal.throwIfAborted();
|
|
2727
|
+
}
|
|
2728
|
+
const snapshot = actor.getSnapshot();
|
|
2729
|
+
const candidate = deriveControlActions(snapshot).find(({ action }) => action.id === actionId);
|
|
2730
|
+
if (candidate === undefined) {
|
|
2731
|
+
receipt = settledReceipt({
|
|
2732
|
+
disposition: 'rejected',
|
|
2733
|
+
reason: `action ${JSON.stringify(actionId)} is not currently advertised`,
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
else {
|
|
2737
|
+
// Acceptance: from here every outcome records a receipt under
|
|
2738
|
+
// the key, so the action can never execute twice.
|
|
2739
|
+
accepted = true;
|
|
2740
|
+
try {
|
|
2741
|
+
actor.send(candidate.event);
|
|
2742
|
+
await waitForPlaybookQuiescence(actor, {
|
|
2743
|
+
pendingCalls: nestedBridge,
|
|
2744
|
+
});
|
|
2745
|
+
if (controlPlaneError !== undefined)
|
|
2746
|
+
throw controlPlaneError;
|
|
2747
|
+
const run = runResultFor(settledOutcome(signal));
|
|
2748
|
+
receipt = settledReceipt(run.outcome === 'failed' || run.outcome === 'aborted'
|
|
2749
|
+
? {
|
|
2750
|
+
disposition: 'failed',
|
|
2751
|
+
error: ('error' in run ? run.error : undefined) ??
|
|
2752
|
+
normalizeError(new Error(`apply settled with outcome ${run.outcome}`)),
|
|
2753
|
+
}
|
|
2754
|
+
: { disposition: 'executed', run });
|
|
2755
|
+
}
|
|
2756
|
+
catch (error) {
|
|
2757
|
+
// Effects may exist: a post-acceptance failure is the
|
|
2758
|
+
// receipt, not a control-plane rejection (DR-029).
|
|
2759
|
+
receipt = settledReceipt({
|
|
2760
|
+
disposition: 'failed',
|
|
2761
|
+
error: normalizeError(error),
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
catch (error) {
|
|
2767
|
+
operationError = error; // pre-acceptance: no receipt is recorded
|
|
2768
|
+
}
|
|
2769
|
+
// Record acceptance before the settlement emissions, so a crash
|
|
2770
|
+
// between acceptance and settlement can never re-execute the
|
|
2771
|
+
// action: the recorded receipt survives and a replayed key
|
|
2772
|
+
// returns it. A rejection settled before acceptance: it is
|
|
2773
|
+
// returned and traced but never recorded, so its key stays free
|
|
2774
|
+
// to execute once the action is advertised.
|
|
2775
|
+
if (receipt !== undefined && receipt.disposition !== 'rejected') {
|
|
2776
|
+
appliedReceipts.set(key, receipt);
|
|
2777
|
+
}
|
|
2778
|
+
try {
|
|
2779
|
+
await drainEmissions();
|
|
2780
|
+
}
|
|
2781
|
+
catch (error) {
|
|
2782
|
+
settlementError = error;
|
|
2783
|
+
}
|
|
2784
|
+
// Fold before the finish emission, the last point at which the
|
|
2785
|
+
// traced disposition and the returned one can still be made the
|
|
2786
|
+
// same value.
|
|
2787
|
+
foldSettlementFailure();
|
|
2788
|
+
if (receipt !== undefined) {
|
|
2789
|
+
// Publication: this disposition is now the settlement, for the
|
|
2790
|
+
// trace, for the caller, and for every replay of the key.
|
|
2791
|
+
published = true;
|
|
2792
|
+
try {
|
|
2793
|
+
await emitTrace('apply.finished', { actionId, key, ...receiptTracePayload(receipt) }, position);
|
|
2794
|
+
}
|
|
2795
|
+
catch (error) {
|
|
2796
|
+
if (accepted)
|
|
2797
|
+
latchDeliveryFailure(error);
|
|
2798
|
+
else
|
|
2799
|
+
settlementError ??= error;
|
|
2800
|
+
}
|
|
2801
|
+
// Drain even when the finish emission rejected, so this call
|
|
2802
|
+
// leaves no queued emission behind it. Before acceptance the
|
|
2803
|
+
// failure is consumed and thrown, as every pre-acceptance failure
|
|
2804
|
+
// is; past it the failure is re-latched instead — the effect
|
|
2805
|
+
// happened, so the delivery failure travels on the emission
|
|
2806
|
+
// channel to the next boundary rather than rewriting what
|
|
2807
|
+
// happened or vanishing here.
|
|
2808
|
+
try {
|
|
2809
|
+
await drainEmissions();
|
|
2810
|
+
}
|
|
2811
|
+
catch (error) {
|
|
2812
|
+
if (accepted)
|
|
2813
|
+
latchDeliveryFailure(error);
|
|
2814
|
+
else
|
|
2815
|
+
settlementError ??= error;
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
finally {
|
|
2820
|
+
// Always release the boundary sentinel, even on a path no
|
|
2821
|
+
// constructible input reaches today, so a defect here can never
|
|
2822
|
+
// wedge every later public boundary behind "another runtime turn
|
|
2823
|
+
// is active".
|
|
2824
|
+
activeSignal = undefined;
|
|
2825
|
+
activeTurnId = undefined;
|
|
2826
|
+
controlPlaneError = undefined;
|
|
2827
|
+
}
|
|
2828
|
+
// Past acceptance every settlement failure has been folded into the
|
|
2829
|
+
// receipt, so nothing is left to throw and the caller always leaves
|
|
2830
|
+
// with the settlement of the effect it may have caused (PBRT-52).
|
|
2831
|
+
if (accepted && receipt !== undefined)
|
|
2832
|
+
return receipt;
|
|
2833
|
+
// Before acceptance no effect exists and no receipt is owed, so a
|
|
2834
|
+
// failure still surfaces by throwing. Settlement failures (a
|
|
2835
|
+
// rejecting finish sink, a drain-latched emission failure) outrank
|
|
2836
|
+
// the operation error, matching the `drainError ?? operationError`
|
|
2837
|
+
// precedence of the other public boundaries. A start-sink failure is
|
|
2838
|
+
// unaffected: its latched drain error is the start error itself.
|
|
2839
|
+
const failure = settlementError ?? operationError;
|
|
2840
|
+
if (failure !== undefined)
|
|
2841
|
+
throw failure;
|
|
2842
|
+
if (receipt === undefined) {
|
|
2843
|
+
throw new Error('createPlaybookRuntime.apply: no receipt was produced');
|
|
2844
|
+
}
|
|
2845
|
+
return receipt;
|
|
2846
|
+
},
|
|
1898
2847
|
async handleBossInput({ text, signal, }) {
|
|
1899
2848
|
if (!actor || !savedPorts) {
|
|
1900
2849
|
throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
|
|
@@ -1930,7 +2879,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1930
2879
|
};
|
|
1931
2880
|
}
|
|
1932
2881
|
else {
|
|
1933
|
-
event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary);
|
|
2882
|
+
event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
|
|
1934
2883
|
}
|
|
1935
2884
|
signal.throwIfAborted();
|
|
1936
2885
|
}
|
|
@@ -1942,7 +2891,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1942
2891
|
else {
|
|
1943
2892
|
// 2. Optional Captain-pane classification line: the bare FSM
|
|
1944
2893
|
// event type, emitted before the FSM advances.
|
|
1945
|
-
const statusLine =
|
|
2894
|
+
const statusLine = classificationStatus(event);
|
|
1946
2895
|
if (statusLine !== undefined) {
|
|
1947
2896
|
await runtimePorts.emitStatus(statusLine);
|
|
1948
2897
|
}
|
|
@@ -1950,10 +2899,22 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1950
2899
|
// 3. A final actor cannot accept new events; reconstruct only
|
|
1951
2900
|
// after classification produced a real event.
|
|
1952
2901
|
if (actor.getSnapshot().status === 'done') {
|
|
1953
|
-
|
|
2902
|
+
stopActor();
|
|
1954
2903
|
actor = buildActor(runtimePorts);
|
|
2904
|
+
// The replacement actor's snapshots are real state entries.
|
|
2905
|
+
suppressInspectionEmissions = false;
|
|
1955
2906
|
actor.start();
|
|
1956
2907
|
}
|
|
2908
|
+
// DR-029: keep the classified event with its recorded payload
|
|
2909
|
+
// as the retry-replay source. Recording is sanitizing, not
|
|
2910
|
+
// load-bearing: an override classifier's non-JSON-safe event is
|
|
2911
|
+
// simply not recorded, and the turn proceeds unchanged.
|
|
2912
|
+
try {
|
|
2913
|
+
lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
|
|
2914
|
+
}
|
|
2915
|
+
catch {
|
|
2916
|
+
lastBossEvent = undefined;
|
|
2917
|
+
}
|
|
1957
2918
|
actor.send(event);
|
|
1958
2919
|
await waitForPlaybookQuiescence(actor, {
|
|
1959
2920
|
pendingCalls: nestedBridge,
|
|
@@ -2080,8 +3041,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2080
3041
|
const finalState = actor ? currentState() : undefined;
|
|
2081
3042
|
// Stop the root before settling a suspended child. Its rejection
|
|
2082
3043
|
// must not re-enter the FSM and start fresh work during disposal.
|
|
2083
|
-
|
|
2084
|
-
|
|
3044
|
+
// `stopActor` suppresses inspection first, so the stop snapshot
|
|
3045
|
+
// adds nothing beside the `session.disposed` trace below (PBRT-6).
|
|
3046
|
+
stopActor();
|
|
2085
3047
|
try {
|
|
2086
3048
|
await nestedBridge.dispose();
|
|
2087
3049
|
}
|
|
@@ -2110,17 +3072,24 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2110
3072
|
}
|
|
2111
3073
|
}
|
|
2112
3074
|
finally {
|
|
2113
|
-
|
|
3075
|
+
// A composing host owns the shared store for the complete root
|
|
3076
|
+
// engagement tree. Child disposal must not erase a token its
|
|
3077
|
+
// caller will resume. The private fallback remains runtime-owned.
|
|
3078
|
+
if (session?.playerSessions === undefined) {
|
|
3079
|
+
playerResumeTokens.clear();
|
|
3080
|
+
}
|
|
2114
3081
|
activePlayerIds.clear();
|
|
2115
3082
|
playbookCallTurnIds.clear();
|
|
2116
3083
|
activeEmissionCalls.clear();
|
|
2117
3084
|
emissionQueue.clear();
|
|
2118
3085
|
judgeQueue.clear();
|
|
3086
|
+
appliedReceipts.clear();
|
|
2119
3087
|
actor = undefined;
|
|
2120
3088
|
activeSignal = undefined;
|
|
2121
3089
|
activeTurnId = undefined;
|
|
2122
3090
|
controlPlaneError = undefined;
|
|
2123
3091
|
emissionFailure = undefined;
|
|
3092
|
+
lastBossEvent = undefined;
|
|
2124
3093
|
savedPorts = undefined;
|
|
2125
3094
|
runtimePorts = undefined;
|
|
2126
3095
|
session = undefined;
|