@sublang/playbook 7.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/docs/cli.md +88 -43
- package/docs/configuration.md +221 -119
- package/docs/embedding.md +78 -27
- package/package.json +4 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
- package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
- package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
- package/reference/sdlc/code.playbook/bin/run.js +283 -298
- package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
- package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +18 -15
- package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +25 -15
- package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
- package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
- package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
- package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +29 -23
- package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +45 -24
- package/slc/link.md +297 -135
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +21 -16
- package/src/runtime.ts +20 -23
- package/src/xstate-playbook-runtime.d.ts +34 -20
- package/src/xstate-playbook-runtime.js +973 -400
- package/src/xstate-playbook-runtime.ts +1203 -457
- package/src/xstate-runtime.d.ts +17 -7
- package/src/xstate-runtime.js +198 -81
- package/src/xstate-runtime.ts +339 -112
|
@@ -66,13 +66,13 @@ export interface PlaybookPendingBossQuestionContext {
|
|
|
66
66
|
questionId: string;
|
|
67
67
|
resumeStateId: string;
|
|
68
68
|
sourceItem: string;
|
|
69
|
-
|
|
69
|
+
asker: { kind: 'captain' } | { kind: 'role'; roleId: string };
|
|
70
70
|
question: string;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export interface PlaybookPlayerInput {
|
|
74
74
|
stateId: string;
|
|
75
|
-
|
|
75
|
+
role: string;
|
|
76
76
|
sourceItem: string;
|
|
77
77
|
prompt: string;
|
|
78
78
|
result: Readonly<Record<string, string>>;
|
|
@@ -113,7 +113,7 @@ export type JudgePurpose =
|
|
|
113
113
|
export interface RuntimeBoundaryCalls {
|
|
114
114
|
callPlayer(
|
|
115
115
|
input: PlaybookPlayerInput,
|
|
116
|
-
|
|
116
|
+
roleId: string,
|
|
117
117
|
prompt: string,
|
|
118
118
|
signal: AbortSignal,
|
|
119
119
|
): Promise<PlayerResult>;
|
|
@@ -151,11 +151,14 @@ export interface ScheduledStatus {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
/** Boss-facing identity for one FSM state whose invoked actor is `player`. */
|
|
154
|
-
export interface
|
|
155
|
-
|
|
154
|
+
export interface XStateRoleStateStatus {
|
|
155
|
+
role: string;
|
|
156
156
|
label: string;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/** Invocation-scoped lookup exposed only while composing a player prompt. */
|
|
160
|
+
export type XStatePromptIdentity = (roleId: string) => string;
|
|
161
|
+
|
|
159
162
|
export interface XStateBossEventFieldSpec {
|
|
160
163
|
/** The judge supplies routing data; the runtime supplies exact Boss text. */
|
|
161
164
|
source: 'judge' | 'text';
|
|
@@ -247,7 +250,7 @@ export const RUNTIME_ABI = 1;
|
|
|
247
250
|
|
|
248
251
|
/** The linked-artifact schema versions this engine accepts (DR-022). */
|
|
249
252
|
export const SUPPORTED_ARTIFACT_SCHEMAS: readonly number[] = Object.freeze([
|
|
250
|
-
|
|
253
|
+
2,
|
|
251
254
|
]);
|
|
252
255
|
|
|
253
256
|
/** A linked artifact's declared link-time compatibility values (DR-022). */
|
|
@@ -259,14 +262,17 @@ export interface XStatePlaybookRuntimeCompat {
|
|
|
259
262
|
}
|
|
260
263
|
|
|
261
264
|
// PBRT-50: validate a declaration against the loaded engine, schema first,
|
|
262
|
-
// so one clear diagnostic covers a fully skewed artifact.
|
|
263
|
-
//
|
|
264
|
-
// loading unchanged (DR-019 §4), so there is nothing to check.
|
|
265
|
+
// so one clear diagnostic covers a fully skewed artifact. Declaration-free
|
|
266
|
+
// artifacts are schema 1 and cannot be interpreted as local-role artifacts.
|
|
265
267
|
function assertRuntimeCompat(
|
|
266
268
|
compat: XStatePlaybookRuntimeCompat | undefined,
|
|
267
269
|
label: string,
|
|
268
270
|
): void {
|
|
269
|
-
if (compat === undefined)
|
|
271
|
+
if (compat === undefined) {
|
|
272
|
+
throw new TypeError(
|
|
273
|
+
`${label} spec.compat is required for local-role artifacts`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
270
276
|
if (compat === null || typeof compat !== 'object') {
|
|
271
277
|
throw new TypeError(`${label} spec.compat must be an object`);
|
|
272
278
|
}
|
|
@@ -355,8 +361,8 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
|
|
|
355
361
|
label?: string;
|
|
356
362
|
/**
|
|
357
363
|
* Link-time compatibility declaration checked at construction against the
|
|
358
|
-
* loaded engine's self-report (DR-022). Absent
|
|
359
|
-
*
|
|
364
|
+
* loaded engine's self-report (DR-022). Absent declarations reject because
|
|
365
|
+
* their overloaded player metadata has no safe local-role interpretation.
|
|
360
366
|
*/
|
|
361
367
|
compat?: XStatePlaybookRuntimeCompat;
|
|
362
368
|
/** Validate and JSON-snapshot the caller's per-run options. */
|
|
@@ -369,7 +375,19 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
|
|
|
369
375
|
* ordinary textual entry event, send it without a judge call, carrying the
|
|
370
376
|
* exact Boss text in `textField`. Absent: every non-empty turn classifies.
|
|
371
377
|
*/
|
|
372
|
-
entryEvent?: {
|
|
378
|
+
entryEvent?: {
|
|
379
|
+
type: string;
|
|
380
|
+
textField: string;
|
|
381
|
+
/**
|
|
382
|
+
* DR-034: the FSM context member this machine's entry action copies the
|
|
383
|
+
* exact Boss text into. Where it is named, the failure-state retry
|
|
384
|
+
* builds its payload from that member of the live snapshot instead of
|
|
385
|
+
* from the process-local recorded event, so the action derives the same
|
|
386
|
+
* before and after `restore`. Absent: the recorded event stays the
|
|
387
|
+
* source and the action lives only as long as the process.
|
|
388
|
+
*/
|
|
389
|
+
contextField?: string;
|
|
390
|
+
};
|
|
373
391
|
/**
|
|
374
392
|
* Exact flat Boss-event contracts whose non-text fields the judge may
|
|
375
393
|
* select. `entryEvent` and scalar `BOSS_REPLY` contracts are supplied by
|
|
@@ -397,14 +415,15 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
|
|
|
397
415
|
* recoverable FSM-result failures instead.
|
|
398
416
|
*/
|
|
399
417
|
captainStrategy?: XStateCaptainStrategy<TOptions>;
|
|
400
|
-
/** Status line emitted after classification;
|
|
418
|
+
/** Status line emitted after classification; metadata defaults to the event type. */
|
|
401
419
|
classificationStatus?: (event: EventObject) => string | undefined;
|
|
402
|
-
/** Complete FSM-derived Boss-facing metadata for every `player` state
|
|
403
|
-
|
|
404
|
-
/** Map a player-invoking state's input to the host player id. Default: lowercased player name. */
|
|
405
|
-
resolvePlayerId?: (input: PlaybookPlayerInput, options: TOptions) => string;
|
|
420
|
+
/** Complete FSM-derived Boss-facing metadata for every `player` state. */
|
|
421
|
+
roleStates?: Readonly<Record<string, XStateRoleStateStatus>>;
|
|
406
422
|
/** Compose the player prompt. Default: continuation blocks + `<field>` placeholder substitution. */
|
|
407
|
-
composePlayerPrompt?: (
|
|
423
|
+
composePlayerPrompt?: (
|
|
424
|
+
input: PlaybookPlayerInput,
|
|
425
|
+
promptIdentity: XStatePromptIdentity,
|
|
426
|
+
) => string;
|
|
408
427
|
/** Compose the direct-Captain prompt. Default: continuation blocks + placeholder substitution with deterministic JSON rendering. */
|
|
409
428
|
composeCaptainPrompt?: (input: PlaybookCaptainInput) => string;
|
|
410
429
|
/** Linker-known exceptions to the default kebab-token → camel-field mapping. */
|
|
@@ -568,11 +587,54 @@ export function normalizeErrorFull(
|
|
|
568
587
|
return normalizeError(err);
|
|
569
588
|
}
|
|
570
589
|
|
|
590
|
+
// slc/link.md §Abort: cancellation is causal identity with the applicable
|
|
591
|
+
// signal's reason — never an `AbortError` name, never bare signal state. A
|
|
592
|
+
// distinct failure observed while the signal is aborted stays a non-abort
|
|
593
|
+
// control error and takes precedence (mirrors DECIDE's bespoke reference).
|
|
571
594
|
function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
|
|
572
|
-
return (
|
|
573
|
-
|
|
574
|
-
|
|
595
|
+
return signal.aborted && Object.is(error, signal.reason);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
interface AbortReasonClassifier {
|
|
599
|
+
isAbortReason(error: unknown): boolean;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function abortReasonClassifier(
|
|
603
|
+
...sources: readonly (AbortSignal | AbortReasonClassifier | undefined)[]
|
|
604
|
+
): AbortReasonClassifier {
|
|
605
|
+
const captured = sources.filter(
|
|
606
|
+
(source): source is AbortSignal | AbortReasonClassifier =>
|
|
607
|
+
source !== undefined,
|
|
575
608
|
);
|
|
609
|
+
return Object.freeze({
|
|
610
|
+
isAbortReason: (error: unknown): boolean =>
|
|
611
|
+
captured.some((source) =>
|
|
612
|
+
source instanceof AbortSignal
|
|
613
|
+
? isAbortFailure(error, source)
|
|
614
|
+
: source.isAbortReason(error),
|
|
615
|
+
),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* gears2fsm's canonical Boss-reply wait state. On the runtime's Boss-facing
|
|
621
|
+
* surfaces — state telemetry, status lines, the exported snapshot, and the
|
|
622
|
+
* control view — a context question counts as *pending* only while the
|
|
623
|
+
* machine sits in this state awaiting the reply. Later states retain the
|
|
624
|
+
* answered question in context (the resumed player prompt is composed from
|
|
625
|
+
* it), so an unconditional projection would resurrect it: a failure the
|
|
626
|
+
* resumed player reached would export a question nobody is waiting on,
|
|
627
|
+
* disagreeing with the gated telemetry a mirroring host's ledger follows
|
|
628
|
+
* and failing the shell's snapshot-equality settlement check.
|
|
629
|
+
*/
|
|
630
|
+
const BOSS_REPLY_WAIT_STATE_ID = 'awaitBossReply';
|
|
631
|
+
|
|
632
|
+
function pendingBossQuestionForState(
|
|
633
|
+
state: PlaybookState,
|
|
634
|
+
context: Record<string, unknown>,
|
|
635
|
+
): PlaybookPendingBossQuestionContext | undefined {
|
|
636
|
+
if (state.stateId !== BOSS_REPLY_WAIT_STATE_ID) return undefined;
|
|
637
|
+
return pendingBossQuestionFromContext(context);
|
|
576
638
|
}
|
|
577
639
|
|
|
578
640
|
/** Read the FSM context's single pending Boss question, when well-formed. */
|
|
@@ -594,16 +656,34 @@ export function pendingBossQuestionFromContext(
|
|
|
594
656
|
typeof candidate.questionId !== 'string' ||
|
|
595
657
|
typeof candidate.resumeStateId !== 'string' ||
|
|
596
658
|
typeof candidate.sourceItem !== 'string' ||
|
|
597
|
-
|
|
659
|
+
!isPlainObject(candidate.asker) ||
|
|
598
660
|
typeof candidate.question !== 'string'
|
|
599
661
|
) {
|
|
600
662
|
return undefined;
|
|
601
663
|
}
|
|
664
|
+
let asker: PlaybookPendingBossQuestionContext['asker'];
|
|
665
|
+
if (candidate.asker.kind === 'captain') {
|
|
666
|
+
if (Object.keys(candidate.asker).some((key) => key !== 'kind')) {
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
669
|
+
asker = { kind: 'captain' };
|
|
670
|
+
} else if (
|
|
671
|
+
candidate.asker.kind === 'role' &&
|
|
672
|
+
typeof candidate.asker.roleId === 'string' &&
|
|
673
|
+
candidate.asker.roleId.trim().length > 0 &&
|
|
674
|
+
Object.keys(candidate.asker).every(
|
|
675
|
+
(key) => key === 'kind' || key === 'roleId',
|
|
676
|
+
)
|
|
677
|
+
) {
|
|
678
|
+
asker = { kind: 'role', roleId: candidate.asker.roleId };
|
|
679
|
+
} else {
|
|
680
|
+
return undefined;
|
|
681
|
+
}
|
|
602
682
|
return {
|
|
603
683
|
questionId: candidate.questionId,
|
|
604
684
|
resumeStateId: candidate.resumeStateId,
|
|
605
685
|
sourceItem: candidate.sourceItem,
|
|
606
|
-
|
|
686
|
+
asker,
|
|
607
687
|
question: candidate.question,
|
|
608
688
|
};
|
|
609
689
|
}
|
|
@@ -707,11 +787,6 @@ export function defaultComposeCaptainPrompt(
|
|
|
707
787
|
return blocks.join('\n\n');
|
|
708
788
|
}
|
|
709
789
|
|
|
710
|
-
/** Default player binding: each player to its lowercased name. */
|
|
711
|
-
export function defaultResolvePlayerId(input: PlaybookPlayerInput): string {
|
|
712
|
-
return input.player.toLowerCase();
|
|
713
|
-
}
|
|
714
|
-
|
|
715
790
|
/**
|
|
716
791
|
* Default required-field extraction (slc/link.md §Captain adjudication).
|
|
717
792
|
* Limited to the description's `Output shall include` / `输出应包含` clause;
|
|
@@ -748,7 +823,7 @@ export function defaultBuildJudgePrompt(
|
|
|
748
823
|
'and outcome descriptions. Reply with exactly one JSON object and no prose.',
|
|
749
824
|
);
|
|
750
825
|
lines.push('');
|
|
751
|
-
lines.push(`The ${input.
|
|
826
|
+
lines.push(`The ${input.role} role just produced this output:`);
|
|
752
827
|
lines.push('');
|
|
753
828
|
lines.push('```');
|
|
754
829
|
lines.push(finalText);
|
|
@@ -856,7 +931,8 @@ function validateBossReplyOutput(
|
|
|
856
931
|
|
|
857
932
|
// ---------------------------------------------------------------------------
|
|
858
933
|
// Delegated-player actor bridge. One PromiseActorLogic the machine invokes
|
|
859
|
-
// from every player-invoking state:
|
|
934
|
+
// from every player-invoking state: retain the role, resolve any bound player
|
|
935
|
+
// identity privately, compose the prompt through the ephemeral identity lookup,
|
|
860
936
|
// await callPlayer, adjudicate the finalText. An `ok` result with a missing,
|
|
861
937
|
// empty, or whitespace-only finalText earns exactly one corrective re-ask of
|
|
862
938
|
// the same composed call (DR-028); a non-`ok` result, or a second such empty
|
|
@@ -867,8 +943,8 @@ function validateBossReplyOutput(
|
|
|
867
943
|
// which only fires on actor.stop(), not on Boss abort.
|
|
868
944
|
// ---------------------------------------------------------------------------
|
|
869
945
|
|
|
870
|
-
|
|
871
|
-
|
|
946
|
+
interface PlayerBridgeSpec {
|
|
947
|
+
resolveRoleId: (input: PlaybookPlayerInput) => string;
|
|
872
948
|
composePlayerPrompt: (input: PlaybookPlayerInput) => string;
|
|
873
949
|
adjudication: PlayerAdjudicationSpec;
|
|
874
950
|
resumableStateIds: ReadonlySet<string>;
|
|
@@ -884,12 +960,26 @@ export function createPlayerBridge(
|
|
|
884
960
|
return fromPromise<PlaybookActorOutput, PlaybookPlayerInput>(
|
|
885
961
|
async ({ input, signal }) => {
|
|
886
962
|
const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
|
|
887
|
-
|
|
888
|
-
|
|
963
|
+
let roleId: string;
|
|
964
|
+
let prompt: string;
|
|
965
|
+
try {
|
|
966
|
+
roleId = spec.resolveRoleId(input);
|
|
967
|
+
prompt = spec.composePlayerPrompt(input);
|
|
968
|
+
} catch (error) {
|
|
969
|
+
if (!isAbortFailure(error, activeSignal)) {
|
|
970
|
+
onControlPlaneError?.(error);
|
|
971
|
+
}
|
|
972
|
+
throw error;
|
|
973
|
+
}
|
|
889
974
|
const callPlayer = (resume: string | false) =>
|
|
890
975
|
boundary
|
|
891
|
-
? boundary.callPlayer(
|
|
892
|
-
|
|
976
|
+
? boundary.callPlayer(
|
|
977
|
+
input,
|
|
978
|
+
roleId,
|
|
979
|
+
prompt,
|
|
980
|
+
activeSignal,
|
|
981
|
+
)
|
|
982
|
+
: ports.callPlayer(roleId, prompt, activeSignal, { resume });
|
|
893
983
|
let result = await callPlayer(false);
|
|
894
984
|
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
895
985
|
// An abort that lands between the empty first result and the
|
|
@@ -936,7 +1026,9 @@ export function createPlayerBridge(
|
|
|
936
1026
|
validateBossReplyOutput(input, output, spec.resumableStateIds);
|
|
937
1027
|
return output;
|
|
938
1028
|
} catch (error) {
|
|
939
|
-
|
|
1029
|
+
if (!isAbortFailure(error, activeSignal)) {
|
|
1030
|
+
onControlPlaneError?.(error);
|
|
1031
|
+
}
|
|
940
1032
|
throw error;
|
|
941
1033
|
}
|
|
942
1034
|
},
|
|
@@ -1063,10 +1155,10 @@ function collectInvokeSources(machine: AnyStateMachine): ReadonlySet<string> {
|
|
|
1063
1155
|
return sources;
|
|
1064
1156
|
}
|
|
1065
1157
|
|
|
1066
|
-
function
|
|
1158
|
+
function collectPlayerStateRoles(
|
|
1067
1159
|
machine: AnyStateMachine,
|
|
1068
1160
|
): ReadonlyMap<string, string> {
|
|
1069
|
-
const
|
|
1161
|
+
const roles = new Map<string, string>();
|
|
1070
1162
|
const visit = (stateDef: unknown, stateKey: string): void => {
|
|
1071
1163
|
if (!isPlainObject(stateDef)) return;
|
|
1072
1164
|
const invoke = stateDef.invoke;
|
|
@@ -1092,15 +1184,15 @@ function collectPlayerStatePlayers(
|
|
|
1092
1184
|
'player state metadata must use a non-empty state id',
|
|
1093
1185
|
);
|
|
1094
1186
|
}
|
|
1095
|
-
const
|
|
1096
|
-
? playbookMeta.
|
|
1187
|
+
const role = isPlainObject(playbookMeta)
|
|
1188
|
+
? playbookMeta.role
|
|
1097
1189
|
: undefined;
|
|
1098
|
-
if (typeof
|
|
1190
|
+
if (typeof role !== 'string' || role.trim().length === 0) {
|
|
1099
1191
|
throw new TypeError(
|
|
1100
|
-
`player state ${stateId} meta.playbook.
|
|
1192
|
+
`player state ${stateId} meta.playbook.role must be a non-empty string`,
|
|
1101
1193
|
);
|
|
1102
1194
|
}
|
|
1103
|
-
|
|
1195
|
+
roles.set(stateId, role);
|
|
1104
1196
|
}
|
|
1105
1197
|
if (isPlainObject(stateDef.states)) {
|
|
1106
1198
|
for (const [childKey, child] of Object.entries(stateDef.states)) {
|
|
@@ -1114,7 +1206,7 @@ function collectPlayerStatePlayers(
|
|
|
1114
1206
|
visit(stateDef, stateKey);
|
|
1115
1207
|
}
|
|
1116
1208
|
}
|
|
1117
|
-
return
|
|
1209
|
+
return roles;
|
|
1118
1210
|
}
|
|
1119
1211
|
|
|
1120
1212
|
function transitionTargets(transition: unknown): string[] {
|
|
@@ -1138,7 +1230,7 @@ export function resumableStateIdsFromMachine(
|
|
|
1138
1230
|
if (!isPlainObject(config) || !isPlainObject(config.states)) {
|
|
1139
1231
|
return new Set();
|
|
1140
1232
|
}
|
|
1141
|
-
const awaitState = config.states
|
|
1233
|
+
const awaitState = config.states[BOSS_REPLY_WAIT_STATE_ID];
|
|
1142
1234
|
if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
|
|
1143
1235
|
return new Set();
|
|
1144
1236
|
}
|
|
@@ -1245,6 +1337,43 @@ function deepFreeze<T>(value: T): T {
|
|
|
1245
1337
|
|
|
1246
1338
|
const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
|
|
1247
1339
|
|
|
1340
|
+
// Bounded escalation for aborted script process groups: SIGTERM first, then
|
|
1341
|
+
// SIGKILL after this grace, so settlement (gated on the shell's own exit)
|
|
1342
|
+
// stays bounded even for TERM-immune commands.
|
|
1343
|
+
const SCRIPT_ABORT_KILL_GRACE_MS = 2000;
|
|
1344
|
+
|
|
1345
|
+
class ScriptProcessGroupTeardownError extends Error {
|
|
1346
|
+
constructor(
|
|
1347
|
+
pid: number,
|
|
1348
|
+
message: string,
|
|
1349
|
+
cause?: unknown,
|
|
1350
|
+
) {
|
|
1351
|
+
super(
|
|
1352
|
+
`script process group ${pid} teardown could not be confirmed: ${message}`,
|
|
1353
|
+
cause === undefined ? undefined : { cause },
|
|
1354
|
+
);
|
|
1355
|
+
this.name = 'ScriptProcessGroupTeardownError';
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function isNoSuchProcess(error: unknown): boolean {
|
|
1360
|
+
return (
|
|
1361
|
+
typeof error === 'object' &&
|
|
1362
|
+
error !== null &&
|
|
1363
|
+
'code' in error &&
|
|
1364
|
+
(error as { code?: unknown }).code === 'ESRCH'
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function isProcessPermissionDenied(error: unknown): boolean {
|
|
1369
|
+
return (
|
|
1370
|
+
typeof error === 'object' &&
|
|
1371
|
+
error !== null &&
|
|
1372
|
+
'code' in error &&
|
|
1373
|
+
(error as { code?: unknown }).code === 'EPERM'
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1248
1377
|
function makeDefaultNormalizeTransitionEvent(
|
|
1249
1378
|
transitionEventFields: readonly string[],
|
|
1250
1379
|
): (event: unknown) => JsonValue {
|
|
@@ -1268,55 +1397,68 @@ function makeDefaultNormalizeTransitionEvent(
|
|
|
1268
1397
|
};
|
|
1269
1398
|
}
|
|
1270
1399
|
|
|
1271
|
-
function
|
|
1272
|
-
value:
|
|
1400
|
+
function snapshotRoleStateStatuses(
|
|
1401
|
+
value: unknown,
|
|
1273
1402
|
label: string,
|
|
1274
1403
|
machine: AnyStateMachine,
|
|
1275
1404
|
stateDescriptions: ReadonlyMap<string, string>,
|
|
1276
|
-
): ReadonlyMap<string,
|
|
1277
|
-
if (value === undefined)
|
|
1278
|
-
|
|
1279
|
-
|
|
1405
|
+
): ReadonlyMap<string, XStateRoleStateStatus> {
|
|
1406
|
+
if (value === undefined) {
|
|
1407
|
+
throw new TypeError(`${label} roleStates must be supplied for schema 2`);
|
|
1408
|
+
}
|
|
1409
|
+
const captured = snapshotJsonValue(value, `${label} roleStates`);
|
|
1410
|
+
if (!isPlainObject(captured)) {
|
|
1411
|
+
throw new TypeError(`${label} roleStates must be an object`);
|
|
1280
1412
|
}
|
|
1281
|
-
const declared =
|
|
1282
|
-
const statuses = new Map<string,
|
|
1283
|
-
for (const [stateId, candidate] of Object.entries(
|
|
1413
|
+
const declared = collectPlayerStateRoles(machine);
|
|
1414
|
+
const statuses = new Map<string, XStateRoleStateStatus>();
|
|
1415
|
+
for (const [stateId, candidate] of Object.entries(captured)) {
|
|
1284
1416
|
if (!declared.has(stateId)) {
|
|
1285
1417
|
throw new TypeError(
|
|
1286
|
-
`${label}
|
|
1418
|
+
`${label} roleStates.${stateId} does not name a player state`,
|
|
1287
1419
|
);
|
|
1288
1420
|
}
|
|
1421
|
+
if (isPlainObject(candidate)) {
|
|
1422
|
+
const extra = Object.keys(candidate).find(
|
|
1423
|
+
(key) => key !== 'role' && key !== 'label',
|
|
1424
|
+
);
|
|
1425
|
+
if (extra !== undefined) {
|
|
1426
|
+
throw new TypeError(
|
|
1427
|
+
`${label} roleStates.${stateId}.${extra} is not allowed`,
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1289
1431
|
if (
|
|
1290
1432
|
!isPlainObject(candidate) ||
|
|
1291
|
-
typeof candidate.
|
|
1292
|
-
candidate.
|
|
1433
|
+
typeof candidate.role !== 'string' ||
|
|
1434
|
+
candidate.role.trim().length === 0 ||
|
|
1293
1435
|
typeof candidate.label !== 'string' ||
|
|
1294
1436
|
candidate.label.trim().length === 0
|
|
1295
1437
|
) {
|
|
1296
1438
|
throw new TypeError(
|
|
1297
|
-
`${label}
|
|
1439
|
+
`${label} roleStates.${stateId} must carry non-empty role and label strings`,
|
|
1298
1440
|
);
|
|
1299
1441
|
}
|
|
1300
1442
|
const expectedLabel = stateDescriptions.get(stateId);
|
|
1301
1443
|
if (candidate.label !== expectedLabel) {
|
|
1302
1444
|
throw new TypeError(
|
|
1303
|
-
`${label}
|
|
1445
|
+
`${label} roleStates.${stateId}.label must equal its FSM description`,
|
|
1304
1446
|
);
|
|
1305
1447
|
}
|
|
1306
|
-
if (candidate.
|
|
1448
|
+
if (candidate.role !== declared.get(stateId)) {
|
|
1307
1449
|
throw new TypeError(
|
|
1308
|
-
`${label}
|
|
1450
|
+
`${label} roleStates.${stateId}.role must equal its FSM role`,
|
|
1309
1451
|
);
|
|
1310
1452
|
}
|
|
1311
1453
|
statuses.set(stateId, {
|
|
1312
|
-
|
|
1454
|
+
role: candidate.role,
|
|
1313
1455
|
label: candidate.label,
|
|
1314
1456
|
});
|
|
1315
1457
|
}
|
|
1316
1458
|
for (const stateId of declared.keys()) {
|
|
1317
1459
|
if (!statuses.has(stateId)) {
|
|
1318
1460
|
throw new TypeError(
|
|
1319
|
-
`${label}
|
|
1461
|
+
`${label} roleStates must declare player state ${stateId}`,
|
|
1320
1462
|
);
|
|
1321
1463
|
}
|
|
1322
1464
|
}
|
|
@@ -1331,39 +1473,14 @@ function settlingGuard(event: unknown): string | undefined {
|
|
|
1331
1473
|
: undefined;
|
|
1332
1474
|
}
|
|
1333
1475
|
|
|
1334
|
-
function
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
const stateId = state.stateId;
|
|
1339
|
-
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) return [];
|
|
1340
|
-
if (stateId === 'awaitBossReply') {
|
|
1341
|
-
const pending = pendingBossQuestionFromContext(context);
|
|
1342
|
-
return [
|
|
1343
|
-
{
|
|
1344
|
-
message:
|
|
1345
|
-
pending === undefined
|
|
1346
|
-
? 'Awaiting Boss reply.'
|
|
1347
|
-
: `${pending.player} asks: ${pending.question}`,
|
|
1348
|
-
},
|
|
1349
|
-
];
|
|
1350
|
-
}
|
|
1351
|
-
if (stateId === 'failed') {
|
|
1352
|
-
const lastError = normalizeErrorFull(context.lastError);
|
|
1353
|
-
return [
|
|
1354
|
-
{
|
|
1355
|
-
message: 'Workflow failed; awaiting Boss recovery.',
|
|
1356
|
-
...(lastError === undefined
|
|
1357
|
-
? {}
|
|
1358
|
-
: { data: snapshotJsonValue({ lastError }, 'failed status data') }),
|
|
1359
|
-
},
|
|
1360
|
-
];
|
|
1361
|
-
}
|
|
1362
|
-
return [{ message: `Entered ${stateId}.` }];
|
|
1476
|
+
function askerLabel(
|
|
1477
|
+
asker: PlaybookPendingBossQuestionContext['asker'],
|
|
1478
|
+
): string {
|
|
1479
|
+
return asker.kind === 'captain' ? 'Captain' : asker.roleId;
|
|
1363
1480
|
}
|
|
1364
1481
|
|
|
1365
1482
|
function makeDefaultStatusesForState(
|
|
1366
|
-
|
|
1483
|
+
roleStates: ReadonlyMap<string, XStateRoleStateStatus>,
|
|
1367
1484
|
): NonNullable<XStatePlaybookRuntimeSpec<unknown>['statusesForState']> {
|
|
1368
1485
|
return (state, context, event): ScheduledStatus[] => {
|
|
1369
1486
|
const statuses: ScheduledStatus[] = [];
|
|
@@ -1374,18 +1491,18 @@ function makeDefaultStatusesForState(
|
|
|
1374
1491
|
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
|
|
1375
1492
|
return statuses;
|
|
1376
1493
|
}
|
|
1377
|
-
if (stateId ===
|
|
1494
|
+
if (stateId === BOSS_REPLY_WAIT_STATE_ID) {
|
|
1378
1495
|
const pending = pendingBossQuestionFromContext(context);
|
|
1379
1496
|
if (pending === undefined) {
|
|
1380
1497
|
return [...statuses, { message: 'Awaiting Boss reply.' }];
|
|
1381
1498
|
}
|
|
1382
1499
|
return [
|
|
1383
1500
|
...statuses,
|
|
1384
|
-
{ message: `${pending.
|
|
1501
|
+
{ message: `${askerLabel(pending.asker)} asks: ${pending.question}` },
|
|
1385
1502
|
{
|
|
1386
1503
|
message:
|
|
1387
1504
|
`◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
|
|
1388
|
-
`${pending.
|
|
1505
|
+
`${askerLabel(pending.asker)} · ${pending.sourceItem}`,
|
|
1389
1506
|
},
|
|
1390
1507
|
];
|
|
1391
1508
|
}
|
|
@@ -1406,10 +1523,10 @@ function makeDefaultStatusesForState(
|
|
|
1406
1523
|
},
|
|
1407
1524
|
];
|
|
1408
1525
|
}
|
|
1409
|
-
const
|
|
1410
|
-
if (
|
|
1526
|
+
const roleState = roleStates.get(stateId);
|
|
1527
|
+
if (roleState !== undefined) {
|
|
1411
1528
|
statuses.push({
|
|
1412
|
-
message: `⤷ ${
|
|
1529
|
+
message: `⤷ ${roleState.role}: ${roleState.label}`,
|
|
1413
1530
|
});
|
|
1414
1531
|
}
|
|
1415
1532
|
return statuses;
|
|
@@ -1654,7 +1771,15 @@ function makeDefaultClassifyBossText(
|
|
|
1654
1771
|
const state = classifierState(snapshotOrState);
|
|
1655
1772
|
const stateId = typeof state.value === 'string' ? state.value : undefined;
|
|
1656
1773
|
const currentState = stateId ?? JSON.stringify(state.value ?? null);
|
|
1657
|
-
|
|
1774
|
+
// The classifier shares the reply-wait pendingness of every other
|
|
1775
|
+
// surface: outside the wait, a context question a later state retains
|
|
1776
|
+
// is answered history, so the prompt must not present it as pending —
|
|
1777
|
+
// a judge told a question awaits at the failure state is steered toward
|
|
1778
|
+
// a reply it cannot select or toward no action at all.
|
|
1779
|
+
const pending =
|
|
1780
|
+
stateId === BOSS_REPLY_WAIT_STATE_ID
|
|
1781
|
+
? pendingBossQuestionFromContext(state.context)
|
|
1782
|
+
: undefined;
|
|
1658
1783
|
const configuredTypes = configuredEventTypesForState(machine, stateId);
|
|
1659
1784
|
const applicable = [...contracts.values()].filter(
|
|
1660
1785
|
(contract) =>
|
|
@@ -1672,7 +1797,7 @@ function makeDefaultClassifyBossText(
|
|
|
1672
1797
|
if (pending !== undefined) {
|
|
1673
1798
|
lines.push(
|
|
1674
1799
|
`Pending question id: ${pending.questionId}`,
|
|
1675
|
-
`Pending
|
|
1800
|
+
`Pending asker: ${askerLabel(pending.asker)}`,
|
|
1676
1801
|
`Pending Boss question: ${pending.question}`,
|
|
1677
1802
|
);
|
|
1678
1803
|
}
|
|
@@ -1827,6 +1952,74 @@ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
|
|
|
1827
1952
|
return visit((machine as unknown as { config?: unknown }).config);
|
|
1828
1953
|
}
|
|
1829
1954
|
|
|
1955
|
+
// PBRT-52: the factory's domain is FLAT single-region machines — every
|
|
1956
|
+
// state a direct child of the root, so each snapshot exposes exactly one
|
|
1957
|
+
// playbook state id and every state-keyed lookup (deterministic entries,
|
|
1958
|
+
// retry, reply-wait pendingness, configured events, descriptions) indexes
|
|
1959
|
+
// one unambiguous identity. A compound child would be accepted and then
|
|
1960
|
+
// silently misbehave on all of those gates, so it is rejected up front
|
|
1961
|
+
// exactly like a parallel region.
|
|
1962
|
+
function machineDeclaresNestedState(machine: AnyStateMachine): boolean {
|
|
1963
|
+
const config = (machine as unknown as { config?: unknown }).config;
|
|
1964
|
+
if (!isPlainObject(config) || !isPlainObject(config.states)) return false;
|
|
1965
|
+
return Object.values(config.states).some(
|
|
1966
|
+
(stateDef) =>
|
|
1967
|
+
isPlainObject(stateDef) &&
|
|
1968
|
+
isPlainObject(stateDef.states) &&
|
|
1969
|
+
Object.keys(stateDef.states).length > 0,
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// PBRT-52: the factory's lookups index states by their root key, and the
|
|
1974
|
+
// published playbook identity is `meta.playbook.stateId` — the two must
|
|
1975
|
+
// coincide or a machine can advertise a pending question or retry under an
|
|
1976
|
+
// identity no lookup resolves. A state with no string stateId is just as
|
|
1977
|
+
// dead: every snapshot identity derives from that member, so the first
|
|
1978
|
+
// entry would fail the exactly-one-state-id inspection at runtime.
|
|
1979
|
+
// gears2fsm keeps identity and key equal by construction; a hand-authored
|
|
1980
|
+
// artifact that splits or omits them fails here instead of at a silently
|
|
1981
|
+
// dead gate.
|
|
1982
|
+
function assertFlatStateIdentity(
|
|
1983
|
+
machine: AnyStateMachine,
|
|
1984
|
+
label: string,
|
|
1985
|
+
): void {
|
|
1986
|
+
const config = (machine as unknown as { config?: unknown }).config;
|
|
1987
|
+
const states =
|
|
1988
|
+
isPlainObject(config) && isPlainObject(config.states)
|
|
1989
|
+
? config.states
|
|
1990
|
+
: undefined;
|
|
1991
|
+
// A machine with no root states has no playbook identity to expose; its
|
|
1992
|
+
// first snapshot would fail the exactly-one-state-id inspection, so it
|
|
1993
|
+
// fails construction with the defect named instead.
|
|
1994
|
+
if (states === undefined || Object.keys(states).length === 0) {
|
|
1995
|
+
throw new Error(
|
|
1996
|
+
`${label} declares no root states; the shared runtime requires at ` +
|
|
1997
|
+
'least one flat playbook state',
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
for (const [key, stateDef] of Object.entries(states)) {
|
|
2001
|
+
if (!isPlainObject(stateDef)) continue;
|
|
2002
|
+
const meta = isPlainObject(stateDef.meta) ? stateDef.meta : undefined;
|
|
2003
|
+
const playbook =
|
|
2004
|
+
meta !== undefined && isPlainObject(meta.playbook)
|
|
2005
|
+
? meta.playbook
|
|
2006
|
+
: undefined;
|
|
2007
|
+
const stateId = playbook?.stateId;
|
|
2008
|
+
if (typeof stateId !== 'string') {
|
|
2009
|
+
throw new Error(
|
|
2010
|
+
`${label} state ${key} declares no string meta.playbook.stateId; ` +
|
|
2011
|
+
'the shared runtime derives every playbook state identity from it',
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
if (stateId !== key) {
|
|
2015
|
+
throw new Error(
|
|
2016
|
+
`${label} state ${key} declares meta.playbook.stateId ${stateId}; ` +
|
|
2017
|
+
'the shared runtime requires the playbook state id to equal the state key',
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
|
|
1830
2023
|
/**
|
|
1831
2024
|
* Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
|
|
1832
2025
|
* under the slc/link.md contract. The factory provides every actor kind the
|
|
@@ -1834,9 +2027,10 @@ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
|
|
|
1834
2027
|
* (literal and dynamic) — and implements the full runtime lifecycle including
|
|
1835
2028
|
* the optional parked-session snapshot capability (DR-014).
|
|
1836
2029
|
*
|
|
1837
|
-
* Scope: machines
|
|
1838
|
-
*
|
|
1839
|
-
*
|
|
2030
|
+
* Scope: flat single-region machines — no parallel state, no compound
|
|
2031
|
+
* child states, and every root state's `meta.playbook.stateId` equal to its
|
|
2032
|
+
* state key — so each snapshot exposes exactly one playbook state id.
|
|
2033
|
+
* Parallel-region FSMs keep their own linked runtimes.
|
|
1840
2034
|
*/
|
|
1841
2035
|
export function createXStatePlaybookRuntime<TOptions>(
|
|
1842
2036
|
machine: AnyStateMachine,
|
|
@@ -1846,24 +2040,50 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1846
2040
|
// DR-022 / PBRT-50: reject an incompatible artifact declaration before any
|
|
1847
2041
|
// machine interpretation, against this loaded engine's own self-report.
|
|
1848
2042
|
assertRuntimeCompat(spec.compat, label);
|
|
2043
|
+
const specDescriptors = Object.getOwnPropertyDescriptors(spec);
|
|
2044
|
+
if (Object.prototype.hasOwnProperty.call(specDescriptors, 'playerStates')) {
|
|
2045
|
+
throw new TypeError(
|
|
2046
|
+
`${label} schema-2 artifacts must supply roleStates, not playerStates`,
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
if (Object.prototype.hasOwnProperty.call(specDescriptors, 'resolvePlayerId')) {
|
|
2050
|
+
throw new TypeError(
|
|
2051
|
+
`${label} schema-2 artifacts must not derive concrete player bindings`,
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
1849
2054
|
if (machineDeclaresParallelState(machine)) {
|
|
1850
2055
|
throw new Error(
|
|
1851
2056
|
`${label} uses a parallel state; the shared runtime supports only single-region FSMs`,
|
|
1852
2057
|
);
|
|
1853
2058
|
}
|
|
2059
|
+
if (machineDeclaresNestedState(machine)) {
|
|
2060
|
+
throw new Error(
|
|
2061
|
+
`${label} declares a compound state; the shared runtime supports only flat single-region FSMs`,
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
assertFlatStateIdentity(machine, label);
|
|
1854
2065
|
const declaredActors = collectInvokeSources(machine);
|
|
1855
2066
|
const resumableStateIds =
|
|
1856
2067
|
spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
|
|
1857
2068
|
// DR-029: source state descriptions label the control actions the
|
|
1858
2069
|
// runtime advertises through `describe()`.
|
|
1859
2070
|
const stateDescriptions = stateDescriptionsFromMachine(machine);
|
|
1860
|
-
const
|
|
1861
|
-
|
|
1862
|
-
|
|
2071
|
+
const roleStatesDescriptor = specDescriptors.roleStates;
|
|
2072
|
+
if (
|
|
2073
|
+
roleStatesDescriptor !== undefined &&
|
|
2074
|
+
!Object.prototype.hasOwnProperty.call(roleStatesDescriptor, 'value')
|
|
2075
|
+
) {
|
|
2076
|
+
throw new TypeError(`${label} roleStates must be an own data property`);
|
|
2077
|
+
}
|
|
2078
|
+
const roleStates = snapshotRoleStateStatuses(
|
|
2079
|
+
roleStatesDescriptor?.value,
|
|
1863
2080
|
label,
|
|
1864
2081
|
machine,
|
|
1865
2082
|
stateDescriptions,
|
|
1866
2083
|
);
|
|
2084
|
+
const declaredRoleIds = Object.freeze([
|
|
2085
|
+
...new Set([...roleStates.values()].map(({ role }) => role)),
|
|
2086
|
+
]);
|
|
1867
2087
|
// PBRT-52: the artifact's own ControlView context projection. Nothing is
|
|
1868
2088
|
// exported by default, so an FSM context member — including one added
|
|
1869
2089
|
// after this artifact was linked — is private until named here. The two
|
|
@@ -1880,7 +2100,6 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1880
2100
|
);
|
|
1881
2101
|
}
|
|
1882
2102
|
}
|
|
1883
|
-
const resolvePlayerIdSpec = spec.resolvePlayerId;
|
|
1884
2103
|
const composePlayerPrompt =
|
|
1885
2104
|
spec.composePlayerPrompt ??
|
|
1886
2105
|
((input: PlaybookPlayerInput) =>
|
|
@@ -1919,14 +2138,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1919
2138
|
makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
|
|
1920
2139
|
const statusesForState =
|
|
1921
2140
|
spec.statusesForState ??
|
|
1922
|
-
(
|
|
1923
|
-
? makeDefaultStatusesForState(playerStates)
|
|
1924
|
-
: legacyStatusesForState);
|
|
2141
|
+
makeDefaultStatusesForState(roleStates);
|
|
1925
2142
|
const classificationStatus =
|
|
1926
2143
|
spec.classificationStatus ??
|
|
1927
|
-
(
|
|
1928
|
-
? (event: EventObject) => event.type
|
|
1929
|
-
: () => undefined);
|
|
2144
|
+
((event: EventObject) => event.type);
|
|
1930
2145
|
const machineInput =
|
|
1931
2146
|
spec.machineInput ?? ((options: TOptions) => options as unknown);
|
|
1932
2147
|
const scriptCwd =
|
|
@@ -1951,6 +2166,20 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1951
2166
|
// ports.callPlayer / callCaptain / callJudge see the right cancellation
|
|
1952
2167
|
// source. undefined between turns; set by the public boundaries.
|
|
1953
2168
|
let activeSignal: AbortSignal | undefined;
|
|
2169
|
+
// Immutable cancellation provenance for the active public boundary. A
|
|
2170
|
+
// nested resume widens it to include both invocation and resume signals;
|
|
2171
|
+
// mutable `activeSignal` alone cannot classify a late invocation reason.
|
|
2172
|
+
let activeAborts: AbortReasonClassifier | undefined;
|
|
2173
|
+
// The bridge binds the provenance of a child result immediately before
|
|
2174
|
+
// its promise actor settles. The next root snapshot/error consumes this
|
|
2175
|
+
// one-shot so background settlement emissions retain their owner.
|
|
2176
|
+
let actorSettlementAborts: AbortReasonClassifier | undefined;
|
|
2177
|
+
let actorSettlementErrorAborts: AbortReasonClassifier | undefined;
|
|
2178
|
+
// Exact cancellation observed by an emission owned by the active
|
|
2179
|
+
// boundary. Ordinary runs settle from their signal/state; apply also
|
|
2180
|
+
// needs this phase-local evidence to fold a pre-publication failure into
|
|
2181
|
+
// its accepted receipt.
|
|
2182
|
+
let activeAbortEmission: unknown;
|
|
1954
2183
|
let activeTurnId: number | undefined;
|
|
1955
2184
|
let controlPlaneError: unknown;
|
|
1956
2185
|
// Previous root-machine state for the inspect-driven telemetry /
|
|
@@ -1977,8 +2206,8 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1977
2206
|
// acceptance records nothing, so a later call with that key may still
|
|
1978
2207
|
// execute.
|
|
1979
2208
|
const appliedReceipts = new Map<string, PlaybookControlReceipt>();
|
|
1980
|
-
const
|
|
1981
|
-
const
|
|
2209
|
+
const privateResumeTokens = new Map<string, string>();
|
|
2210
|
+
const activePlayerKeys = new Set<string>();
|
|
1982
2211
|
const playbookCallTurnIds = new Map<string, number | undefined>();
|
|
1983
2212
|
// Captain and judge work share one serialized lane (slc/link.md
|
|
1984
2213
|
// §Session lifecycle).
|
|
@@ -1989,87 +2218,229 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1989
2218
|
// All trace, state-telemetry, and status work shares this one queue.
|
|
1990
2219
|
// Inspection callbacks enqueue a complete ordered batch synchronously;
|
|
1991
2220
|
// imperative boundaries await their queued work directly.
|
|
1992
|
-
let emissionFailure: unknown;
|
|
2221
|
+
let emissionFailure: { readonly error: unknown } | undefined;
|
|
2222
|
+
|
|
2223
|
+
function bindSession(nextSession: PlaybookSession): PlaybookSession {
|
|
2224
|
+
const bound = snapshotPlaybookSession(nextSession);
|
|
2225
|
+
if (bound.roleBindings === undefined) return bound;
|
|
2226
|
+
const actual = Object.keys(bound.roleBindings).sort();
|
|
2227
|
+
const expected = [...declaredRoleIds].sort();
|
|
2228
|
+
const missing = expected.filter((roleId) => !actual.includes(roleId));
|
|
2229
|
+
const extra = actual.filter((roleId) => !expected.includes(roleId));
|
|
2230
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
2231
|
+
throw new TypeError(
|
|
2232
|
+
`${label} session roleBindings must cover exactly [${expected.join(', ')}]` +
|
|
2233
|
+
`${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
|
|
2234
|
+
`${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`,
|
|
2235
|
+
);
|
|
2236
|
+
}
|
|
2237
|
+
return bound;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function requireRoleId(input: PlaybookPlayerInput): string {
|
|
2241
|
+
const roleId = input.role;
|
|
2242
|
+
if (
|
|
2243
|
+
typeof roleId !== 'string' ||
|
|
2244
|
+
roleId.trim().length === 0 ||
|
|
2245
|
+
!declaredRoleIds.includes(roleId)
|
|
2246
|
+
) {
|
|
2247
|
+
throw new TypeError(
|
|
2248
|
+
`${label} player input role must name a declared local role`,
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
return roleId;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function resolvedPlayerId(roleId: string): string | undefined {
|
|
2255
|
+
return session?.roleBindings?.[roleId]?.playerId;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function promptIdentity(roleId: string): string {
|
|
2259
|
+
if (!declaredRoleIds.includes(roleId)) {
|
|
2260
|
+
throw new TypeError(
|
|
2261
|
+
`${label} prompt identity lookup rejected undeclared role ${roleId}`,
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
return session?.roleBindings?.[roleId]?.promptIdentity ?? roleId;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
function composeBoundPlayerPrompt(input: PlaybookPlayerInput): string {
|
|
2268
|
+
let active = true;
|
|
2269
|
+
const lookup: XStatePromptIdentity = (roleId) => {
|
|
2270
|
+
if (!active) {
|
|
2271
|
+
throw new Error(
|
|
2272
|
+
`${label} prompt identity lookup is no longer active`,
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
return promptIdentity(roleId);
|
|
2276
|
+
};
|
|
2277
|
+
try {
|
|
2278
|
+
return composePlayerPrompt(input, lookup);
|
|
2279
|
+
} finally {
|
|
2280
|
+
active = false;
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
1993
2283
|
|
|
1994
|
-
function
|
|
2284
|
+
function continuationKey(
|
|
2285
|
+
roleId: string,
|
|
2286
|
+
playerId: string | undefined,
|
|
2287
|
+
): string {
|
|
2288
|
+
return playerId ?? roleId;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
function roleTokensByContinuationKey(
|
|
2292
|
+
tokens: Readonly<Record<string, string>>,
|
|
2293
|
+
): Map<string, string> {
|
|
2294
|
+
const byKey = new Map<string, string>();
|
|
2295
|
+
for (const [roleId, token] of Object.entries(tokens)) {
|
|
2296
|
+
if (!declaredRoleIds.includes(roleId)) {
|
|
2297
|
+
throw new TypeError(
|
|
2298
|
+
`runtime role tokens contain unknown role ${roleId}`,
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
const key = continuationKey(roleId, resolvedPlayerId(roleId));
|
|
2302
|
+
const existing = byKey.get(key);
|
|
2303
|
+
if (existing !== undefined && existing !== token) {
|
|
2304
|
+
throw new TypeError(
|
|
2305
|
+
`runtime snapshot assigns conflicting tokens to roles bound to player ${key}`,
|
|
2306
|
+
);
|
|
2307
|
+
}
|
|
2308
|
+
byKey.set(key, token);
|
|
2309
|
+
}
|
|
2310
|
+
const rolesByKey = new Map<string, string[]>();
|
|
2311
|
+
for (const roleId of declaredRoleIds) {
|
|
2312
|
+
const key = continuationKey(roleId, resolvedPlayerId(roleId));
|
|
2313
|
+
rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
|
|
2314
|
+
}
|
|
2315
|
+
for (const [key, roles] of rolesByKey) {
|
|
2316
|
+
if (roles.length < 2) continue;
|
|
2317
|
+
const present = roles.filter((roleId) => tokens[roleId] !== undefined);
|
|
2318
|
+
if (present.length !== 0 && present.length !== roles.length) {
|
|
2319
|
+
throw new TypeError(
|
|
2320
|
+
`runtime role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`,
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
return byKey;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
function selectPlayerResume(
|
|
2328
|
+
roleId: string,
|
|
2329
|
+
playerId: string | undefined,
|
|
2330
|
+
): string | false {
|
|
2331
|
+
const key = continuationKey(roleId, playerId);
|
|
1995
2332
|
const selected = session?.playerSessions
|
|
1996
|
-
? session.playerSessions.select(
|
|
1997
|
-
:
|
|
2333
|
+
? session.playerSessions.select(roleId)
|
|
2334
|
+
: privateResumeTokens.get(key) ?? false;
|
|
1998
2335
|
if (
|
|
1999
2336
|
selected !== false &&
|
|
2000
2337
|
(typeof selected !== 'string' || selected.trim().length === 0)
|
|
2001
2338
|
) {
|
|
2002
2339
|
throw new TypeError(
|
|
2003
|
-
`player session store returned an invalid resume token for ${
|
|
2340
|
+
`player session store returned an invalid resume token for role ${roleId}`,
|
|
2004
2341
|
);
|
|
2005
2342
|
}
|
|
2006
2343
|
return selected;
|
|
2007
2344
|
}
|
|
2008
2345
|
|
|
2009
2346
|
function updatePlayerResume(
|
|
2010
|
-
|
|
2011
|
-
|
|
2347
|
+
roleId: string,
|
|
2348
|
+
playerId: string | undefined,
|
|
2349
|
+
result: PlayerResult,
|
|
2012
2350
|
): void {
|
|
2351
|
+
const resumeToken = result.resumeToken;
|
|
2352
|
+
if (resumeToken === undefined && result.status !== 'ok') return;
|
|
2353
|
+
const key = continuationKey(roleId, playerId);
|
|
2013
2354
|
if (session?.playerSessions) {
|
|
2014
|
-
session.playerSessions.update(
|
|
2015
|
-
} else if (resumeToken !== undefined
|
|
2016
|
-
|
|
2355
|
+
session.playerSessions.update(roleId, resumeToken);
|
|
2356
|
+
} else if (resumeToken !== undefined) {
|
|
2357
|
+
privateResumeTokens.set(key, resumeToken);
|
|
2017
2358
|
} else {
|
|
2018
|
-
|
|
2359
|
+
privateResumeTokens.delete(key);
|
|
2019
2360
|
}
|
|
2020
2361
|
}
|
|
2021
2362
|
|
|
2022
|
-
function
|
|
2023
|
-
const raw =
|
|
2024
|
-
|
|
2025
|
-
|
|
2363
|
+
function snapshotRoleResumeTokens(): Record<string, string> {
|
|
2364
|
+
const raw = snapshotJsonValue(
|
|
2365
|
+
session?.playerSessions
|
|
2366
|
+
? session.playerSessions.snapshot()
|
|
2367
|
+
: Object.fromEntries(
|
|
2368
|
+
declaredRoleIds.flatMap((roleId) => {
|
|
2369
|
+
const token = privateResumeTokens.get(
|
|
2370
|
+
continuationKey(roleId, resolvedPlayerId(roleId)),
|
|
2371
|
+
);
|
|
2372
|
+
return token === undefined ? [] : [[roleId, token]];
|
|
2373
|
+
}),
|
|
2374
|
+
),
|
|
2375
|
+
'player session store snapshot',
|
|
2376
|
+
);
|
|
2026
2377
|
if (!isPlainObject(raw)) {
|
|
2027
2378
|
throw new TypeError('player session store snapshot must be an object');
|
|
2028
2379
|
}
|
|
2029
2380
|
const detached: Record<string, string> = {};
|
|
2030
|
-
for (const [
|
|
2031
|
-
if (
|
|
2381
|
+
for (const [roleId, token] of Object.entries(raw)) {
|
|
2382
|
+
if (!declaredRoleIds.includes(roleId)) {
|
|
2032
2383
|
throw new TypeError(
|
|
2033
|
-
|
|
2384
|
+
`player session store snapshot contains unknown role ${roleId}`,
|
|
2034
2385
|
);
|
|
2035
2386
|
}
|
|
2036
2387
|
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
2037
2388
|
throw new TypeError(
|
|
2038
|
-
`player session store snapshot token for ${
|
|
2389
|
+
`player session store snapshot token for ${roleId} must be a non-empty string`,
|
|
2039
2390
|
);
|
|
2040
2391
|
}
|
|
2041
|
-
detached[
|
|
2392
|
+
detached[roleId] = token;
|
|
2042
2393
|
}
|
|
2394
|
+
roleTokensByContinuationKey(detached);
|
|
2043
2395
|
return detached;
|
|
2044
2396
|
}
|
|
2045
2397
|
|
|
2046
|
-
function
|
|
2398
|
+
function restoreRoleResumeTokens(
|
|
2047
2399
|
tokens: Readonly<Record<string, string>>,
|
|
2048
2400
|
): void {
|
|
2401
|
+
const byKey = roleTokensByContinuationKey(tokens);
|
|
2049
2402
|
if (session?.playerSessions) {
|
|
2050
2403
|
session.playerSessions.restore(tokens);
|
|
2051
2404
|
return;
|
|
2052
2405
|
}
|
|
2053
|
-
|
|
2054
|
-
for (const [
|
|
2055
|
-
playerResumeTokens.set(playerId, token);
|
|
2056
|
-
}
|
|
2406
|
+
privateResumeTokens.clear();
|
|
2407
|
+
for (const [key, token] of byKey) privateResumeTokens.set(key, token);
|
|
2057
2408
|
}
|
|
2058
2409
|
|
|
2059
|
-
function enqueueEmission(
|
|
2410
|
+
function enqueueEmission(
|
|
2411
|
+
fn: () => Promise<void>,
|
|
2412
|
+
aborts: AbortReasonClassifier | undefined = activeAborts,
|
|
2413
|
+
): Promise<void> {
|
|
2414
|
+
// The emission belongs to the boundary enqueueing it: a rejection
|
|
2415
|
+
// causally identical to that boundary's abort reason is the
|
|
2416
|
+
// cancellation's own evidence — never latched, so it cannot poison a
|
|
2417
|
+
// later unrelated boundary (DR-036).
|
|
2418
|
+
const enqueueAborts = aborts;
|
|
2060
2419
|
const queued = emissionQueue.add(fn).then(() => undefined);
|
|
2061
2420
|
activeEmissionCalls.add(queued);
|
|
2062
2421
|
void queued.then(
|
|
2063
2422
|
() => activeEmissionCalls.delete(queued),
|
|
2064
2423
|
(error: unknown) => {
|
|
2065
2424
|
activeEmissionCalls.delete(queued);
|
|
2066
|
-
|
|
2425
|
+
if (enqueueAborts?.isAbortReason(error)) {
|
|
2426
|
+
// Record evidence only when it also belongs to the public
|
|
2427
|
+
// boundary that is still active. A background A cancellation
|
|
2428
|
+
// racing an unrelated B boundary is forgiven under A and must
|
|
2429
|
+
// not change B's settlement.
|
|
2430
|
+
if (activeAborts?.isAbortReason(error)) {
|
|
2431
|
+
activeAbortEmission ??= error;
|
|
2432
|
+
}
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
emissionFailure ??= { error };
|
|
2067
2436
|
},
|
|
2068
2437
|
);
|
|
2069
2438
|
return queued;
|
|
2070
2439
|
}
|
|
2071
2440
|
|
|
2072
|
-
async function drainEmissions(
|
|
2441
|
+
async function drainEmissions(
|
|
2442
|
+
_aborts: AbortReasonClassifier | undefined = activeAborts,
|
|
2443
|
+
): Promise<void> {
|
|
2073
2444
|
while (true) {
|
|
2074
2445
|
const active = [...activeEmissionCalls];
|
|
2075
2446
|
if (active.length > 0) await Promise.allSettled(active);
|
|
@@ -2083,8 +2454,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2083
2454
|
}
|
|
2084
2455
|
}
|
|
2085
2456
|
if (emissionFailure !== undefined) {
|
|
2086
|
-
const error = emissionFailure;
|
|
2457
|
+
const { error } = emissionFailure;
|
|
2087
2458
|
emissionFailure = undefined;
|
|
2459
|
+
// The failure was classified as distinct by its enqueue owner. If a
|
|
2460
|
+
// later public boundary drains it, retain that classification in the
|
|
2461
|
+
// boundary latch before throwing; its signal must not reinterpret
|
|
2462
|
+
// the same object as cancellation (DR-036 decision 2).
|
|
2463
|
+
if (activeSignal !== undefined) controlPlaneError ??= error;
|
|
2088
2464
|
throw error;
|
|
2089
2465
|
}
|
|
2090
2466
|
}
|
|
@@ -2111,7 +2487,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2111
2487
|
const currentSession = requireSession();
|
|
2112
2488
|
const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
2113
2489
|
return {
|
|
2114
|
-
schemaVersion:
|
|
2490
|
+
schemaVersion: 3,
|
|
2115
2491
|
sessionId: currentSession.sessionId,
|
|
2116
2492
|
playbookId: currentSession.playbookId,
|
|
2117
2493
|
rootSessionId: currentSession.rootSessionId,
|
|
@@ -2135,14 +2511,17 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2135
2511
|
type: PlaybookTraceType,
|
|
2136
2512
|
payload: unknown,
|
|
2137
2513
|
position: TracePosition = {},
|
|
2514
|
+
aborts?: AbortReasonClassifier,
|
|
2138
2515
|
): Promise<void> {
|
|
2139
2516
|
const currentSession = requireSession();
|
|
2140
2517
|
const event = createTraceEvent(type, payload, position);
|
|
2141
|
-
return enqueueEmission(
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2518
|
+
return enqueueEmission(
|
|
2519
|
+
() =>
|
|
2520
|
+
currentSession.ports.emitTelemetry({
|
|
2521
|
+
topic: 'playbook.trace',
|
|
2522
|
+
payload: event,
|
|
2523
|
+
}),
|
|
2524
|
+
aborts,
|
|
2146
2525
|
);
|
|
2147
2526
|
}
|
|
2148
2527
|
|
|
@@ -2230,6 +2609,11 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2230
2609
|
| 'apply.finished',
|
|
2231
2610
|
identity: Record<string, unknown>,
|
|
2232
2611
|
position: TracePosition,
|
|
2612
|
+
// The applicable combined signal: a start-sink rejection causally
|
|
2613
|
+
// identical to its reason is the cancellation itself, not a control
|
|
2614
|
+
// error — the pair finishes `aborted` and nothing latches
|
|
2615
|
+
// (slc/link.md §Abort).
|
|
2616
|
+
signal: AbortSignal,
|
|
2233
2617
|
// Base payload of the best-effort finish emitted when the start sink
|
|
2234
2618
|
// rejects; it defaults to the payload the start carried, which the
|
|
2235
2619
|
// player, judge, and captain pairs take as-is. The apply pair cannot:
|
|
@@ -2241,13 +2625,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2241
2625
|
try {
|
|
2242
2626
|
await emitTrace(startedType, identity, position);
|
|
2243
2627
|
} catch (error) {
|
|
2244
|
-
controlPlaneError ??= error;
|
|
2628
|
+
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
2245
2629
|
try {
|
|
2246
2630
|
await emitTrace(
|
|
2247
2631
|
finishedType,
|
|
2248
2632
|
{
|
|
2249
2633
|
...finishIdentity,
|
|
2250
|
-
status: 'error',
|
|
2634
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
2251
2635
|
error: normalizeError(error),
|
|
2252
2636
|
},
|
|
2253
2637
|
position,
|
|
@@ -2260,25 +2644,31 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2260
2644
|
}
|
|
2261
2645
|
|
|
2262
2646
|
const boundary: RuntimeBoundaryCalls = {
|
|
2263
|
-
async callPlayer(
|
|
2647
|
+
async callPlayer(
|
|
2648
|
+
input,
|
|
2649
|
+
roleId,
|
|
2650
|
+
prompt,
|
|
2651
|
+
signal,
|
|
2652
|
+
): Promise<PlayerResult> {
|
|
2264
2653
|
// State-entry telemetry/status must precede the call they describe.
|
|
2265
2654
|
await drainEmissions();
|
|
2266
2655
|
const turnId = activeTurnId;
|
|
2267
2656
|
const stateId = input.stateId;
|
|
2657
|
+
const playerId = resolvedPlayerId(roleId);
|
|
2268
2658
|
let resume: string | false;
|
|
2269
2659
|
try {
|
|
2270
2660
|
signal.throwIfAborted();
|
|
2271
|
-
resume = selectPlayerResume(playerId);
|
|
2661
|
+
resume = selectPlayerResume(roleId, playerId);
|
|
2272
2662
|
} catch (error) {
|
|
2273
|
-
if (!signal
|
|
2663
|
+
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
2274
2664
|
throw error;
|
|
2275
2665
|
}
|
|
2276
2666
|
const callId = `player-${++playerCallSequence}`;
|
|
2277
2667
|
const identity = {
|
|
2278
|
-
purpose: 'captain' as const,
|
|
2279
2668
|
...stateIdentity(stateId),
|
|
2280
2669
|
sourceItem: input.sourceItem,
|
|
2281
|
-
|
|
2670
|
+
roleId,
|
|
2671
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
2282
2672
|
resume,
|
|
2283
2673
|
};
|
|
2284
2674
|
const position: TracePosition = {
|
|
@@ -2286,15 +2676,17 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2286
2676
|
callId,
|
|
2287
2677
|
};
|
|
2288
2678
|
|
|
2289
|
-
|
|
2679
|
+
const playerKey = continuationKey(roleId, playerId);
|
|
2680
|
+
if (activePlayerKeys.has(playerKey)) {
|
|
2290
2681
|
const error = new Error(
|
|
2291
|
-
`simultaneous calls to
|
|
2682
|
+
`simultaneous calls to player key ${playerKey} are not allowed`,
|
|
2292
2683
|
);
|
|
2293
2684
|
await emitCallStarted(
|
|
2294
2685
|
'player.call.started',
|
|
2295
2686
|
'player.call.finished',
|
|
2296
2687
|
{ ...identity, prompt },
|
|
2297
2688
|
position,
|
|
2689
|
+
signal,
|
|
2298
2690
|
);
|
|
2299
2691
|
await emitTrace(
|
|
2300
2692
|
'player.call.finished',
|
|
@@ -2303,13 +2695,15 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2303
2695
|
);
|
|
2304
2696
|
throw error;
|
|
2305
2697
|
}
|
|
2306
|
-
|
|
2698
|
+
activePlayerKeys.add(playerKey);
|
|
2307
2699
|
|
|
2308
2700
|
try {
|
|
2309
|
-
await
|
|
2701
|
+
await emitCallStarted(
|
|
2310
2702
|
'player.call.started',
|
|
2703
|
+
'player.call.finished',
|
|
2311
2704
|
{ ...identity, prompt },
|
|
2312
2705
|
position,
|
|
2706
|
+
signal,
|
|
2313
2707
|
);
|
|
2314
2708
|
|
|
2315
2709
|
let rawResult: unknown;
|
|
@@ -2320,7 +2714,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2320
2714
|
// as `aborted` through the catch below.
|
|
2321
2715
|
signal.throwIfAborted();
|
|
2322
2716
|
rawResult = await requireHostPorts().callPlayer(
|
|
2323
|
-
|
|
2717
|
+
roleId,
|
|
2324
2718
|
prompt,
|
|
2325
2719
|
signal,
|
|
2326
2720
|
{ resume },
|
|
@@ -2329,13 +2723,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2329
2723
|
// a late result mutate continuity or publish a successful finish.
|
|
2330
2724
|
signal.throwIfAborted();
|
|
2331
2725
|
} catch (error) {
|
|
2332
|
-
if (!signal
|
|
2726
|
+
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
2333
2727
|
try {
|
|
2334
2728
|
await emitTrace(
|
|
2335
2729
|
'player.call.finished',
|
|
2336
2730
|
{
|
|
2337
2731
|
...identity,
|
|
2338
|
-
status: signal
|
|
2732
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
2339
2733
|
error: normalizeError(error),
|
|
2340
2734
|
},
|
|
2341
2735
|
position,
|
|
@@ -2352,7 +2746,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2352
2746
|
try {
|
|
2353
2747
|
result = validatePlayerResult(rawResult);
|
|
2354
2748
|
} catch (error) {
|
|
2355
|
-
if (!signal
|
|
2749
|
+
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
2356
2750
|
try {
|
|
2357
2751
|
await emitTrace(
|
|
2358
2752
|
'player.call.finished',
|
|
@@ -2366,15 +2760,9 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2366
2760
|
}
|
|
2367
2761
|
|
|
2368
2762
|
try {
|
|
2369
|
-
updatePlayerResume(
|
|
2370
|
-
playerId,
|
|
2371
|
-
typeof result.resumeToken === 'string' &&
|
|
2372
|
-
result.resumeToken.trim().length > 0
|
|
2373
|
-
? result.resumeToken
|
|
2374
|
-
: undefined,
|
|
2375
|
-
);
|
|
2763
|
+
updatePlayerResume(roleId, playerId, result);
|
|
2376
2764
|
} catch (error) {
|
|
2377
|
-
if (!signal
|
|
2765
|
+
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
2378
2766
|
try {
|
|
2379
2767
|
await emitTrace(
|
|
2380
2768
|
'player.call.finished',
|
|
@@ -2406,7 +2794,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2406
2794
|
);
|
|
2407
2795
|
return result;
|
|
2408
2796
|
} finally {
|
|
2409
|
-
|
|
2797
|
+
activePlayerKeys.delete(playerKey);
|
|
2410
2798
|
}
|
|
2411
2799
|
},
|
|
2412
2800
|
|
|
@@ -2430,6 +2818,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2430
2818
|
'judge.call.finished',
|
|
2431
2819
|
{ ...identity, prompt },
|
|
2432
2820
|
position,
|
|
2821
|
+
signal,
|
|
2433
2822
|
);
|
|
2434
2823
|
let reply: unknown;
|
|
2435
2824
|
try {
|
|
@@ -2448,7 +2837,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2448
2837
|
'judge.call.finished',
|
|
2449
2838
|
{
|
|
2450
2839
|
...identity,
|
|
2451
|
-
status: signal
|
|
2840
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
2452
2841
|
error: normalizeError(error),
|
|
2453
2842
|
},
|
|
2454
2843
|
position,
|
|
@@ -2512,6 +2901,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2512
2901
|
'captain.call.finished',
|
|
2513
2902
|
{ ...identity, prompt },
|
|
2514
2903
|
position,
|
|
2904
|
+
signal,
|
|
2515
2905
|
);
|
|
2516
2906
|
let rawResult: unknown;
|
|
2517
2907
|
try {
|
|
@@ -2534,7 +2924,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2534
2924
|
'captain.call.finished',
|
|
2535
2925
|
{
|
|
2536
2926
|
...identity,
|
|
2537
|
-
status: signal
|
|
2927
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
2538
2928
|
error: normalizeError(error),
|
|
2539
2929
|
},
|
|
2540
2930
|
position,
|
|
@@ -2611,19 +3001,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2611
3001
|
},
|
|
2612
3002
|
};
|
|
2613
3003
|
|
|
2614
|
-
function resolvePlayerId(input: PlaybookPlayerInput): string {
|
|
2615
|
-
return resolvePlayerIdSpec
|
|
2616
|
-
? resolvePlayerIdSpec(input, boundOptions)
|
|
2617
|
-
: defaultResolvePlayerId(input);
|
|
2618
|
-
}
|
|
2619
|
-
|
|
2620
3004
|
function playerActor(
|
|
2621
3005
|
ports: PlaybookPorts,
|
|
2622
3006
|
): PromiseActorLogic<PlaybookActorOutput, PlaybookPlayerInput> {
|
|
2623
3007
|
return createPlayerBridge(
|
|
2624
3008
|
{
|
|
2625
|
-
|
|
2626
|
-
composePlayerPrompt,
|
|
3009
|
+
resolveRoleId: requireRoleId,
|
|
3010
|
+
composePlayerPrompt: composeBoundPlayerPrompt,
|
|
2627
3011
|
adjudication,
|
|
2628
3012
|
resumableStateIds,
|
|
2629
3013
|
},
|
|
@@ -2631,7 +3015,9 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2631
3015
|
() => activeSignal,
|
|
2632
3016
|
boundary,
|
|
2633
3017
|
(error) => {
|
|
2634
|
-
if (
|
|
3018
|
+
if (activeSignal === undefined || !isAbortFailure(error, activeSignal)) {
|
|
3019
|
+
controlPlaneError ??= error;
|
|
3020
|
+
}
|
|
2635
3021
|
},
|
|
2636
3022
|
);
|
|
2637
3023
|
}
|
|
@@ -2717,7 +3103,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2717
3103
|
// failure state (PBRT-47); everything else here — a drained
|
|
2718
3104
|
// emission failure, prompt composition, the port itself,
|
|
2719
3105
|
// adjudication — is control plane.
|
|
2720
|
-
if (!active
|
|
3106
|
+
if (!isAbortFailure(error, active) && !isFsmResultFailure(error)) {
|
|
2721
3107
|
controlPlaneError ??= error;
|
|
2722
3108
|
}
|
|
2723
3109
|
throw error;
|
|
@@ -2744,53 +3130,187 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2744
3130
|
const failedGuard = guards[1] ?? guards[0];
|
|
2745
3131
|
const cwd = boundScriptCwd ?? process.cwd();
|
|
2746
3132
|
const ports = runtimePorts ?? requireHostPorts();
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
3133
|
+
// slc/link.md §Script execution: an already-aborted turn spawns
|
|
3134
|
+
// nothing, and the thrown signal reason keeps the rejection
|
|
3135
|
+
// causally classified as the abort it is.
|
|
3136
|
+
active.throwIfAborted();
|
|
3137
|
+
|
|
3138
|
+
// Abort ownership — the listener that terminates the group and
|
|
3139
|
+
// the escalation timer — spans the whole invocation body, not
|
|
3140
|
+
// just the spawn-to-close window: an abort landing during the
|
|
3141
|
+
// post-exit emission tail must still kill surviving group
|
|
3142
|
+
// members before the actor settles (slc/link.md §Script
|
|
3143
|
+
// execution). One finally releases both.
|
|
3144
|
+
let child: ReturnType<typeof spawn> | undefined;
|
|
3145
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
3146
|
+
const signalGroup = (sig: NodeJS.Signals): void => {
|
|
3147
|
+
if (child?.pid !== undefined) {
|
|
3148
|
+
try {
|
|
3149
|
+
process.kill(-child.pid, sig);
|
|
3150
|
+
} catch {
|
|
3151
|
+
// Confirmation belongs to the bounded liveness probe below:
|
|
3152
|
+
// a failed signal can mean ESRCH, EPERM, or another fault.
|
|
3153
|
+
}
|
|
2766
3154
|
}
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
3155
|
+
};
|
|
3156
|
+
// After a SIGKILL is posted, settlement waits for the group to
|
|
3157
|
+
// stop being signalable — bounded by the same grace so an
|
|
3158
|
+
// unreapable member outside the runtime's control cannot stall
|
|
3159
|
+
// the turn forever. Observed teardown is milliseconds.
|
|
3160
|
+
let groupGonePromise: Promise<void> | undefined;
|
|
3161
|
+
const awaitGroupGone = (): Promise<void> => {
|
|
3162
|
+
const pid = child?.pid;
|
|
3163
|
+
if (pid === undefined) return Promise.resolve();
|
|
3164
|
+
groupGonePromise ??= (async () => {
|
|
3165
|
+
const teardownFailure = (
|
|
3166
|
+
message: string,
|
|
3167
|
+
cause?: unknown,
|
|
3168
|
+
): ScriptProcessGroupTeardownError => {
|
|
3169
|
+
const failure = new ScriptProcessGroupTeardownError(
|
|
3170
|
+
pid,
|
|
3171
|
+
message,
|
|
3172
|
+
cause,
|
|
3173
|
+
);
|
|
3174
|
+
// A teardown failure is not an authored script result.
|
|
3175
|
+
// Surface it at the active public boundary even though
|
|
3176
|
+
// XState also routes the rejected actor through onError.
|
|
3177
|
+
controlPlaneError ??= failure;
|
|
3178
|
+
return failure;
|
|
3179
|
+
};
|
|
3180
|
+
const deadline = Date.now() + SCRIPT_ABORT_KILL_GRACE_MS;
|
|
3181
|
+
let lastProbeError: unknown;
|
|
3182
|
+
for (;;) {
|
|
3183
|
+
try {
|
|
3184
|
+
process.kill(-pid, 0);
|
|
3185
|
+
} catch (error) {
|
|
3186
|
+
if (isNoSuchProcess(error)) return;
|
|
3187
|
+
// EPERM confirms that at least one process in the group
|
|
3188
|
+
// still exists but is not signalable by this process. Keep
|
|
3189
|
+
// waiting for ESRCH within the bound; every other probe
|
|
3190
|
+
// error makes confirmation itself unreliable immediately.
|
|
3191
|
+
if (!isProcessPermissionDenied(error)) {
|
|
3192
|
+
throw teardownFailure(
|
|
3193
|
+
'the liveness probe failed',
|
|
3194
|
+
error,
|
|
3195
|
+
);
|
|
3196
|
+
}
|
|
3197
|
+
lastProbeError = error;
|
|
3198
|
+
}
|
|
3199
|
+
if (Date.now() >= deadline) {
|
|
3200
|
+
throw teardownFailure(
|
|
3201
|
+
`the group remained signalable after ${SCRIPT_ABORT_KILL_GRACE_MS}ms`,
|
|
3202
|
+
lastProbeError,
|
|
3203
|
+
);
|
|
3204
|
+
}
|
|
3205
|
+
await new Promise((tick) => setTimeout(tick, 5));
|
|
3206
|
+
}
|
|
3207
|
+
})();
|
|
3208
|
+
return groupGonePromise;
|
|
3209
|
+
};
|
|
3210
|
+
const onAbort = (): void => {
|
|
3211
|
+
signalGroup('SIGTERM');
|
|
3212
|
+
killTimer = setTimeout(
|
|
3213
|
+
() => signalGroup('SIGKILL'),
|
|
3214
|
+
SCRIPT_ABORT_KILL_GRACE_MS,
|
|
3215
|
+
);
|
|
3216
|
+
};
|
|
3217
|
+
// An abort observed once the shell has already exited rejects
|
|
3218
|
+
// with the signal's reason before guard resolution and before
|
|
3219
|
+
// starting any further script emission — after killing whatever
|
|
3220
|
+
// group members outlived the shell. The shell's own exit ended
|
|
3221
|
+
// the TERM grace's purpose, so escalation is immediate here.
|
|
3222
|
+
const settleIfAborted = async (): Promise<void> => {
|
|
3223
|
+
if (!active.aborted) return;
|
|
3224
|
+
signalGroup('SIGKILL');
|
|
3225
|
+
await awaitGroupGone();
|
|
3226
|
+
active.throwIfAborted();
|
|
3227
|
+
};
|
|
3228
|
+
let invocationFailed = false;
|
|
3229
|
+
try {
|
|
3230
|
+
const exitStatus = await new Promise<number>(
|
|
3231
|
+
(resolve, reject) => {
|
|
3232
|
+
try {
|
|
3233
|
+
// detached: the shell leads its own POSIX process group,
|
|
3234
|
+
// so an abort can terminate the command's whole group — a
|
|
3235
|
+
// lone SIGTERM to the wrapper never reaches backgrounded
|
|
3236
|
+
// members.
|
|
3237
|
+
child = spawn('sh', ['-c', input.command], {
|
|
3238
|
+
cwd,
|
|
3239
|
+
stdio: 'ignore',
|
|
3240
|
+
detached: true,
|
|
3241
|
+
});
|
|
3242
|
+
} catch (error) {
|
|
3243
|
+
reject(error);
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
// On abort, terminate the group and escalate — but settle
|
|
3247
|
+
// only from 'close', after the shell itself has exited, so
|
|
3248
|
+
// the turn never reports quiescence while the script still
|
|
3249
|
+
// runs (slc/link.md §Abort). SIGKILL is untrappable, so
|
|
3250
|
+
// 'close' is bounded by the grace.
|
|
3251
|
+
active.addEventListener('abort', onAbort, { once: true });
|
|
3252
|
+
child.on('error', (error) => {
|
|
3253
|
+
reject(error);
|
|
3254
|
+
});
|
|
3255
|
+
child.on('close', (code) => {
|
|
3256
|
+
if (active.aborted) {
|
|
3257
|
+
// The shell may exit cooperatively on the group SIGTERM
|
|
3258
|
+
// while a TERM-immune same-group descendant survives;
|
|
3259
|
+
// the group stays addressable while any member lives,
|
|
3260
|
+
// so kill it and await its disappearance before
|
|
3261
|
+
// settling (slc/link.md §Script execution).
|
|
3262
|
+
signalGroup('SIGKILL');
|
|
3263
|
+
void awaitGroupGone().then(
|
|
3264
|
+
() =>
|
|
3265
|
+
reject(active.reason),
|
|
3266
|
+
reject,
|
|
3267
|
+
);
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
resolve(typeof code === 'number' ? code : 1);
|
|
3271
|
+
});
|
|
3272
|
+
},
|
|
3273
|
+
);
|
|
2777
3274
|
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
3275
|
+
await settleIfAborted();
|
|
3276
|
+
|
|
3277
|
+
await ports.emitStatus(
|
|
3278
|
+
`Executed script for ${input.stateId} (exit ${exitStatus}).`,
|
|
3279
|
+
);
|
|
3280
|
+
await settleIfAborted();
|
|
3281
|
+
await ports.emitTelemetry({
|
|
3282
|
+
topic: 'playbook.script',
|
|
3283
|
+
payload: {
|
|
3284
|
+
stateId: input.stateId,
|
|
3285
|
+
sourceItem: input.sourceItem,
|
|
3286
|
+
exitStatus,
|
|
3287
|
+
},
|
|
3288
|
+
});
|
|
3289
|
+
await settleIfAborted();
|
|
2789
3290
|
|
|
2790
|
-
|
|
2791
|
-
|
|
3291
|
+
if (exitStatus === 0) {
|
|
3292
|
+
return { guard: okGuard, exitStatus: 0 };
|
|
3293
|
+
}
|
|
3294
|
+
return { guard: failedGuard, exitStatus };
|
|
3295
|
+
} catch (error) {
|
|
3296
|
+
// Preserve the invocation's authoritative exact cancellation or
|
|
3297
|
+
// distinct sink failure after teardown succeeds. The finally
|
|
3298
|
+
// block may replace it only with a distinct teardown failure
|
|
3299
|
+
// when the process group cannot be confirmed gone.
|
|
3300
|
+
invocationFailed = true;
|
|
3301
|
+
throw error;
|
|
3302
|
+
} finally {
|
|
3303
|
+
try {
|
|
3304
|
+
if (active.aborted) {
|
|
3305
|
+
signalGroup('SIGKILL');
|
|
3306
|
+
await awaitGroupGone();
|
|
3307
|
+
if (!invocationFailed) active.throwIfAborted();
|
|
3308
|
+
}
|
|
3309
|
+
} finally {
|
|
3310
|
+
active.removeEventListener('abort', onAbort);
|
|
3311
|
+
if (killTimer !== undefined) clearTimeout(killTimer);
|
|
3312
|
+
}
|
|
2792
3313
|
}
|
|
2793
|
-
return { guard: failedGuard, exitStatus };
|
|
2794
3314
|
},
|
|
2795
3315
|
);
|
|
2796
3316
|
}
|
|
@@ -2800,7 +3320,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2800
3320
|
getBoundarySignal: () => activeSignal,
|
|
2801
3321
|
callPlaybook: (request, signal) =>
|
|
2802
3322
|
requireHostPorts().callPlaybook(request, signal),
|
|
2803
|
-
emitStarted: async (event) => {
|
|
3323
|
+
emitStarted: async (event, aborts) => {
|
|
2804
3324
|
playbookCallTurnIds.set(event.callId, activeTurnId);
|
|
2805
3325
|
await emitTrace(
|
|
2806
3326
|
'playbook.call.started',
|
|
@@ -2813,9 +3333,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2813
3333
|
...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
|
|
2814
3334
|
callId: event.callId,
|
|
2815
3335
|
},
|
|
3336
|
+
aborts,
|
|
2816
3337
|
);
|
|
2817
3338
|
},
|
|
2818
|
-
emitFinished: async (event) => {
|
|
3339
|
+
emitFinished: async (event, aborts) => {
|
|
2819
3340
|
const turnId = playbookCallTurnIds.get(event.callId);
|
|
2820
3341
|
try {
|
|
2821
3342
|
await emitTrace(
|
|
@@ -2830,20 +3351,35 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2830
3351
|
...(turnId !== undefined ? { turnId } : {}),
|
|
2831
3352
|
callId: event.callId,
|
|
2832
3353
|
},
|
|
3354
|
+
aborts,
|
|
2833
3355
|
);
|
|
2834
3356
|
} finally {
|
|
2835
3357
|
playbookCallTurnIds.delete(event.callId);
|
|
2836
3358
|
}
|
|
2837
3359
|
},
|
|
2838
3360
|
drain: drainEmissions,
|
|
2839
|
-
bindResumeSignal: (signal) => {
|
|
3361
|
+
bindResumeSignal: (signal, aborts) => {
|
|
2840
3362
|
activeSignal = signal;
|
|
3363
|
+
activeAborts = aborts ?? abortReasonClassifier(signal);
|
|
3364
|
+
},
|
|
3365
|
+
bindActorSettlement: (aborts) => {
|
|
3366
|
+
actorSettlementAborts = aborts;
|
|
2841
3367
|
},
|
|
2842
|
-
onControlPlaneError: (error) => {
|
|
2843
|
-
|
|
3368
|
+
onControlPlaneError: (error, aborts) => {
|
|
3369
|
+
// The shared bridge classifies before reporting against its own
|
|
3370
|
+
// invocation-and-resume signals; classify once more here against
|
|
3371
|
+
// the boundary signal so a report that is the active boundary's
|
|
3372
|
+
// exact abort reason can never masquerade as a control error
|
|
3373
|
+
// (slc/link.md §Abort).
|
|
3374
|
+
if (
|
|
3375
|
+
!aborts?.isAbortReason(error) &&
|
|
3376
|
+
!activeAborts?.isAbortReason(error)
|
|
3377
|
+
) {
|
|
3378
|
+
controlPlaneError ??= error;
|
|
3379
|
+
}
|
|
2844
3380
|
},
|
|
2845
|
-
onBackgroundError: (error) => {
|
|
2846
|
-
emissionFailure ??= error;
|
|
3381
|
+
onBackgroundError: (error, aborts) => {
|
|
3382
|
+
if (!aborts?.isAbortReason(error)) emissionFailure ??= { error };
|
|
2847
3383
|
},
|
|
2848
3384
|
});
|
|
2849
3385
|
|
|
@@ -2864,11 +3400,9 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2864
3400
|
previousState: previousState ?? null,
|
|
2865
3401
|
state,
|
|
2866
3402
|
};
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
payload.pendingBossQuestion = pendingBossQuestion;
|
|
2871
|
-
}
|
|
3403
|
+
const pendingBossQuestion = pendingBossQuestionForState(state, context);
|
|
3404
|
+
if (pendingBossQuestion !== undefined) {
|
|
3405
|
+
payload.pendingBossQuestion = pendingBossQuestion;
|
|
2872
3406
|
}
|
|
2873
3407
|
if (state.stateId === 'failed') {
|
|
2874
3408
|
const lastError = normalizeErrorFull(context.lastError);
|
|
@@ -2882,6 +3416,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2882
3416
|
state: PlaybookState,
|
|
2883
3417
|
statuses: readonly ScheduledStatus[],
|
|
2884
3418
|
position: TracePosition,
|
|
3419
|
+
aborts?: AbortReasonClassifier,
|
|
2885
3420
|
): void {
|
|
2886
3421
|
const currentSession = requireSession();
|
|
2887
3422
|
const transitionTrace = createTraceEvent(
|
|
@@ -2903,28 +3438,61 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2903
3438
|
position,
|
|
2904
3439
|
),
|
|
2905
3440
|
}));
|
|
2906
|
-
void enqueueEmission(
|
|
2907
|
-
|
|
2908
|
-
topic: 'playbook.trace',
|
|
2909
|
-
payload: transitionTrace,
|
|
2910
|
-
});
|
|
2911
|
-
await currentSession.ports.emitTelemetry({
|
|
2912
|
-
topic: 'playbook.fsm.state',
|
|
2913
|
-
payload,
|
|
2914
|
-
});
|
|
2915
|
-
for (const status of statusEmissions) {
|
|
3441
|
+
void enqueueEmission(
|
|
3442
|
+
async () => {
|
|
2916
3443
|
await currentSession.ports.emitTelemetry({
|
|
2917
3444
|
topic: 'playbook.trace',
|
|
2918
|
-
payload:
|
|
3445
|
+
payload: transitionTrace,
|
|
2919
3446
|
});
|
|
2920
|
-
await currentSession.ports.
|
|
2921
|
-
|
|
2922
|
-
|
|
3447
|
+
await currentSession.ports.emitTelemetry({
|
|
3448
|
+
topic: 'playbook.fsm.state',
|
|
3449
|
+
payload,
|
|
3450
|
+
});
|
|
3451
|
+
for (const status of statusEmissions) {
|
|
3452
|
+
await currentSession.ports.emitTelemetry({
|
|
3453
|
+
topic: 'playbook.trace',
|
|
3454
|
+
payload: status.trace,
|
|
3455
|
+
});
|
|
3456
|
+
await currentSession.ports.emitStatus(status.message, status.data);
|
|
3457
|
+
}
|
|
3458
|
+
},
|
|
3459
|
+
aborts,
|
|
3460
|
+
).catch(() => undefined);
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
// One classifying latch for every runtime-observed error — inspection
|
|
3464
|
+
// failures and root-actor errors alike. Outside a boundary the error
|
|
3465
|
+
// rides the emission channel, which the next boundary's (or init's)
|
|
3466
|
+
// drain throws; inside a boundary it is a control-plane error unless it
|
|
3467
|
+
// is the boundary signal's own abort reason (slc/link.md §Abort).
|
|
3468
|
+
function latchRuntimeError(
|
|
3469
|
+
error: unknown,
|
|
3470
|
+
aborts: AbortReasonClassifier | undefined = activeAborts,
|
|
3471
|
+
): void {
|
|
3472
|
+
if (aborts?.isAbortReason(error)) return;
|
|
3473
|
+
if (activeSignal === undefined) emissionFailure ??= { error };
|
|
3474
|
+
else controlPlaneError ??= error;
|
|
2923
3475
|
}
|
|
2924
3476
|
|
|
2925
|
-
function
|
|
2926
|
-
|
|
2927
|
-
|
|
3477
|
+
function consumeActorSettlementAborts(
|
|
3478
|
+
forSnapshot = false,
|
|
3479
|
+
): AbortReasonClassifier | undefined {
|
|
3480
|
+
const aborts = actorSettlementAborts ?? actorSettlementErrorAborts;
|
|
3481
|
+
actorSettlementAborts = undefined;
|
|
3482
|
+
actorSettlementErrorAborts = undefined;
|
|
3483
|
+
if (forSnapshot && aborts !== undefined) {
|
|
3484
|
+
// XState can report an errored root through both its inspection
|
|
3485
|
+
// snapshot and subscriber. Keep the same provenance through that
|
|
3486
|
+
// synchronous notification only; an ordinary transition must not
|
|
3487
|
+
// lend it to a later unrelated actor error.
|
|
3488
|
+
actorSettlementErrorAborts = aborts;
|
|
3489
|
+
queueMicrotask(() => {
|
|
3490
|
+
if (actorSettlementErrorAborts === aborts) {
|
|
3491
|
+
actorSettlementErrorAborts = undefined;
|
|
3492
|
+
}
|
|
3493
|
+
});
|
|
3494
|
+
}
|
|
3495
|
+
return aborts;
|
|
2928
3496
|
}
|
|
2929
3497
|
|
|
2930
3498
|
// PBRT-6: the single seam that stops this runtime's actor. Stopping a
|
|
@@ -2968,6 +3536,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2968
3536
|
if (inspectionEvent.type !== '@xstate.snapshot') return;
|
|
2969
3537
|
if (inspectionEvent.actorRef !== builtActor) return;
|
|
2970
3538
|
if (suppressInspectionEmissions) return;
|
|
3539
|
+
const settlementAborts = consumeActorSettlementAborts(true);
|
|
2971
3540
|
try {
|
|
2972
3541
|
const snap = inspectionEvent.snapshot;
|
|
2973
3542
|
const state = normalizePlaybookSnapshot(snap);
|
|
@@ -2995,13 +3564,25 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2995
3564
|
state,
|
|
2996
3565
|
statuses,
|
|
2997
3566
|
tracePositionForActiveTurn(),
|
|
3567
|
+
settlementAborts,
|
|
2998
3568
|
);
|
|
2999
3569
|
priorState = state;
|
|
3000
3570
|
} catch (error) {
|
|
3001
|
-
|
|
3571
|
+
latchRuntimeError(error, settlementAborts);
|
|
3002
3572
|
}
|
|
3003
3573
|
},
|
|
3004
3574
|
});
|
|
3575
|
+
// A synchronously-errored actor is already quiescent, so the turn's
|
|
3576
|
+
// quiescence wait never subscribes and XState would report the error
|
|
3577
|
+
// as unhandled after the boundary returns. Observe it through the
|
|
3578
|
+
// classifying latch: mid-boundary it is the control-plane error
|
|
3579
|
+
// unless it is the abort reason itself; at startup it rides the
|
|
3580
|
+
// emission channel so `init`'s own drain rejects with it and the
|
|
3581
|
+
// failed-start cleanup runs (slc/link.md §Session lifecycle).
|
|
3582
|
+
builtActor.subscribe({
|
|
3583
|
+
error: (error) =>
|
|
3584
|
+
latchRuntimeError(error, consumeActorSettlementAborts()),
|
|
3585
|
+
});
|
|
3005
3586
|
return builtActor;
|
|
3006
3587
|
}
|
|
3007
3588
|
|
|
@@ -3024,14 +3605,20 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3024
3605
|
const output = (
|
|
3025
3606
|
actor?.getSnapshot() as { output?: unknown } | undefined
|
|
3026
3607
|
)?.output;
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3608
|
+
const stateDescription = stateDescriptionFor(state);
|
|
3609
|
+
return {
|
|
3610
|
+
outcome,
|
|
3611
|
+
state,
|
|
3612
|
+
...(stateDescription === undefined ? {} : { stateDescription }),
|
|
3613
|
+
...(output === undefined
|
|
3614
|
+
? {}
|
|
3615
|
+
: {
|
|
3616
|
+
output: snapshotJsonValue(
|
|
3617
|
+
output,
|
|
3618
|
+
'terminal playbook output',
|
|
3619
|
+
),
|
|
3620
|
+
}),
|
|
3621
|
+
};
|
|
3035
3622
|
}
|
|
3036
3623
|
const failure =
|
|
3037
3624
|
error ??
|
|
@@ -3050,15 +3637,23 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3050
3637
|
|
|
3051
3638
|
function settledOutcome(signal: AbortSignal): BossSettlementOutcome {
|
|
3052
3639
|
if (nestedBridge.getPendingCall()) return 'suspended';
|
|
3053
|
-
if (signal.aborted) return 'aborted';
|
|
3054
3640
|
const state = currentState();
|
|
3055
3641
|
if (state.status === 'error') {
|
|
3642
|
+
// An errored actor outranks a coincident abort unless the actor's
|
|
3643
|
+
// error is the abort reason itself (slc/link.md §Abort).
|
|
3056
3644
|
const actorError = (
|
|
3057
3645
|
actor?.getSnapshot() as { error?: unknown } | undefined
|
|
3058
3646
|
)?.error;
|
|
3647
|
+
if (actorError !== undefined && isAbortFailure(actorError, signal)) {
|
|
3648
|
+
return 'aborted';
|
|
3649
|
+
}
|
|
3059
3650
|
throw actorError ?? new Error(`${label} actor entered error status`);
|
|
3060
3651
|
}
|
|
3652
|
+
// Terminal completion outranks a coincident abort: the work finished,
|
|
3653
|
+
// and reporting `aborted` would hide a terminal machine behind a
|
|
3654
|
+
// settlement a later turn silently restarts (slc/link.md §Abort).
|
|
3061
3655
|
if (state.status === 'done') return 'terminal';
|
|
3656
|
+
if (signal.aborted) return 'aborted';
|
|
3062
3657
|
if (state.stateId === 'failed') return 'failed';
|
|
3063
3658
|
return 'quiescent';
|
|
3064
3659
|
}
|
|
@@ -3123,8 +3718,8 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3123
3718
|
// The session-start error remains authoritative.
|
|
3124
3719
|
}
|
|
3125
3720
|
}
|
|
3126
|
-
|
|
3127
|
-
|
|
3721
|
+
privateResumeTokens.clear();
|
|
3722
|
+
activePlayerKeys.clear();
|
|
3128
3723
|
playbookCallTurnIds.clear();
|
|
3129
3724
|
activeEmissionCalls.clear();
|
|
3130
3725
|
emissionQueue.clear();
|
|
@@ -3135,6 +3730,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3135
3730
|
savedPorts = undefined;
|
|
3136
3731
|
runtimePorts = undefined;
|
|
3137
3732
|
activeSignal = undefined;
|
|
3733
|
+
activeAborts = undefined;
|
|
3734
|
+
actorSettlementAborts = undefined;
|
|
3735
|
+
actorSettlementErrorAborts = undefined;
|
|
3736
|
+
activeAbortEmission = undefined;
|
|
3138
3737
|
activeTurnId = undefined;
|
|
3139
3738
|
controlPlaneError = undefined;
|
|
3140
3739
|
emissionFailure = undefined;
|
|
@@ -3170,32 +3769,57 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3170
3769
|
);
|
|
3171
3770
|
}
|
|
3172
3771
|
|
|
3772
|
+
// DR-034: where the artifact names the FSM context member its entry
|
|
3773
|
+
// action copies the exact Boss text into, that member of the live
|
|
3774
|
+
// snapshot is the retry payload's source. The persisted machine snapshot
|
|
3775
|
+
// carries it, so the candidate derives identically in the process that
|
|
3776
|
+
// exported the snapshot and in one that restored it, and a failure
|
|
3777
|
+
// reached after a Boss reply — whose recorded event the failure state
|
|
3778
|
+
// refuses — is recoverable too. Naming the member is the artifact's
|
|
3779
|
+
// statement that it holds the entry text: a same-named member is never
|
|
3780
|
+
// assumed, since inferring one would turn any matching context member
|
|
3781
|
+
// into a replay payload without its author saying so.
|
|
3782
|
+
// Declared and absent or empty excludes the candidate rather than
|
|
3783
|
+
// falling back to the record, which would make the action depend on the
|
|
3784
|
+
// process again — the very thing this source exists to end.
|
|
3785
|
+
function retryEventFrom(snapshot: unknown): EventObject | undefined {
|
|
3786
|
+
const entryEvent = spec.entryEvent;
|
|
3787
|
+
if (entryEvent?.contextField === undefined) return lastBossEvent;
|
|
3788
|
+
const context = (snapshot as { context?: unknown } | null)?.context;
|
|
3789
|
+
const text = isPlainObject(context)
|
|
3790
|
+
? context[entryEvent.contextField]
|
|
3791
|
+
: undefined;
|
|
3792
|
+
if (typeof text !== 'string' || text.trim() === '') return undefined;
|
|
3793
|
+
return { type: entryEvent.type, [entryEvent.textField]: text };
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3173
3796
|
// The failure-state retry entry replays the recorded last classified
|
|
3174
|
-
// event with its recorded payload
|
|
3175
|
-
//
|
|
3176
|
-
//
|
|
3797
|
+
// event with its recorded payload, or the entry event the declared
|
|
3798
|
+
// context member above sources. A candidate whose event the live
|
|
3799
|
+
// snapshot does not accept — or whose payload the runtime can source
|
|
3800
|
+
// from neither — is excluded rather than completed with invented text.
|
|
3177
3801
|
function retryActionFor(
|
|
3178
3802
|
snapshot: unknown,
|
|
3179
3803
|
stateId: string | undefined,
|
|
3180
3804
|
): DerivedControlAction | undefined {
|
|
3181
|
-
if (stateId !== 'failed'
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
if (!snapshotCan(snapshot,
|
|
3805
|
+
if (stateId !== 'failed') return undefined;
|
|
3806
|
+
const retryEvent = retryEventFrom(snapshot);
|
|
3807
|
+
if (retryEvent === undefined) return undefined;
|
|
3808
|
+
if (!snapshotCan(snapshot, retryEvent)) return undefined;
|
|
3185
3809
|
// A recorded explicit-state-jump event names the exact state its
|
|
3186
3810
|
// replay re-enters: the root BOSS_INTERRUPT shape is a guarded
|
|
3187
3811
|
// multi-arm list keyed on `targetId`, so the first configured arm
|
|
3188
3812
|
// may label a different state than the one the recorded event
|
|
3189
3813
|
// actually resumes.
|
|
3190
3814
|
const recordedTargetId =
|
|
3191
|
-
|
|
3192
|
-
? (
|
|
3815
|
+
retryEvent.type === JUMP_EVENT_TYPE
|
|
3816
|
+
? (retryEvent as { targetId?: unknown }).targetId
|
|
3193
3817
|
: undefined;
|
|
3194
3818
|
const target =
|
|
3195
3819
|
typeof recordedTargetId === 'string' &&
|
|
3196
3820
|
recordedTargetId.trim().length > 0
|
|
3197
3821
|
? recordedTargetId
|
|
3198
|
-
: firstTransitionTarget(machine, stateId,
|
|
3822
|
+
: firstTransitionTarget(machine, stateId, retryEvent.type);
|
|
3199
3823
|
// PBRT-52: a label is written from a source state description, never
|
|
3200
3824
|
// from an identifier. Falling back to the target id — or, with no
|
|
3201
3825
|
// resolvable target, to the FSM event type — makes the label *be* the
|
|
@@ -3209,10 +3833,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3209
3833
|
if (description === undefined) return undefined;
|
|
3210
3834
|
return {
|
|
3211
3835
|
action: {
|
|
3212
|
-
id: `retry:${
|
|
3836
|
+
id: `retry:${retryEvent.type}`,
|
|
3213
3837
|
label: `Retry: ${description}`,
|
|
3214
3838
|
},
|
|
3215
|
-
event:
|
|
3839
|
+
event: retryEvent,
|
|
3216
3840
|
};
|
|
3217
3841
|
}
|
|
3218
3842
|
|
|
@@ -3332,7 +3956,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3332
3956
|
if (initialized || disposed || disposalPromise !== undefined) {
|
|
3333
3957
|
throw new Error('createPlaybookRuntime.init: already initialized');
|
|
3334
3958
|
}
|
|
3335
|
-
const boundSession =
|
|
3959
|
+
const boundSession = bindSession(nextSession);
|
|
3336
3960
|
initialized = true;
|
|
3337
3961
|
let finishInitialization!: () => void;
|
|
3338
3962
|
const initialization = new Promise<void>((resolve) => {
|
|
@@ -3406,12 +4030,12 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3406
4030
|
);
|
|
3407
4031
|
const context = (actor.getSnapshot() as { context?: unknown })
|
|
3408
4032
|
.context as Record<string, unknown>;
|
|
3409
|
-
const pending =
|
|
4033
|
+
const pending = pendingBossQuestionForState(state, context ?? {});
|
|
3410
4034
|
return {
|
|
3411
|
-
schemaVersion:
|
|
4035
|
+
schemaVersion: 3,
|
|
3412
4036
|
playbookId: session.playbookId,
|
|
3413
4037
|
machine: machineSnapshot,
|
|
3414
|
-
|
|
4038
|
+
roleResumeTokens: snapshotRoleResumeTokens(),
|
|
3415
4039
|
sequences: {
|
|
3416
4040
|
trace: traceSequence,
|
|
3417
4041
|
turn: turnSequence,
|
|
@@ -3429,7 +4053,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3429
4053
|
: [
|
|
3430
4054
|
{
|
|
3431
4055
|
questionId: pending.questionId,
|
|
3432
|
-
|
|
4056
|
+
asker: pending.asker,
|
|
3433
4057
|
question: pending.question,
|
|
3434
4058
|
sourceItem: pending.sourceItem,
|
|
3435
4059
|
},
|
|
@@ -3450,16 +4074,21 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3450
4074
|
if (initialized || disposed || disposalPromise !== undefined) {
|
|
3451
4075
|
throw new Error('createPlaybookRuntime.restore: already initialized');
|
|
3452
4076
|
}
|
|
3453
|
-
const boundSession =
|
|
4077
|
+
const boundSession = bindSession(nextSession);
|
|
3454
4078
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(
|
|
3455
4079
|
snapshot,
|
|
3456
4080
|
boundSession.playbookId,
|
|
3457
4081
|
{ allowSuspendedCall: true },
|
|
3458
4082
|
);
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
4083
|
+
if (
|
|
4084
|
+
declaredActors.has('captain') &&
|
|
4085
|
+
boundSnapshot.sequences.captainCall === undefined
|
|
4086
|
+
) {
|
|
4087
|
+
throw new TypeError(
|
|
4088
|
+
'runtime snapshot sequences.captainCall is required for a direct-Captain artifact',
|
|
4089
|
+
);
|
|
4090
|
+
}
|
|
4091
|
+
const suspendedCall = boundSnapshot.suspendedCall;
|
|
3463
4092
|
let priorExternalPlayerTokens:
|
|
3464
4093
|
| Readonly<Record<string, string>>
|
|
3465
4094
|
| undefined;
|
|
@@ -3479,22 +4108,17 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3479
4108
|
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
3480
4109
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
3481
4110
|
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
3482
|
-
captainCallSequence =
|
|
3483
|
-
boundSnapshot.sequences.captainCall ??
|
|
3484
|
-
// Legacy schema-v1 snapshots predate this dedicated counter.
|
|
3485
|
-
// Every Captain call already consumed at least one trace number,
|
|
3486
|
-
// so the global trace counter is a collision-safe id floor.
|
|
3487
|
-
boundSnapshot.sequences.trace;
|
|
4111
|
+
captainCallSequence = boundSnapshot.sequences.captainCall ?? 0;
|
|
3488
4112
|
// The runtime snapshot carries no apply counter (PBRT-50); every
|
|
3489
4113
|
// apply boundary consumed trace numbers, so the persisted trace
|
|
3490
4114
|
// counter is a collision-safe id floor here too, keeping
|
|
3491
4115
|
// `apply-<n>` call ids unique across restore.
|
|
3492
4116
|
applyCallSequence = boundSnapshot.sequences.trace;
|
|
3493
4117
|
if (boundSession.playerSessions) {
|
|
3494
|
-
priorExternalPlayerTokens =
|
|
4118
|
+
priorExternalPlayerTokens = snapshotRoleResumeTokens();
|
|
3495
4119
|
externalStoreRestoreAttempted = true;
|
|
3496
4120
|
}
|
|
3497
|
-
|
|
4121
|
+
restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
|
|
3498
4122
|
nestedBridge.prepareRestore(suspendedCall);
|
|
3499
4123
|
if (suspendedCall !== undefined) {
|
|
3500
4124
|
playbookCallTurnIds.set(
|
|
@@ -3505,7 +4129,26 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3505
4129
|
suppressInspectionEmissions = true;
|
|
3506
4130
|
actor = buildActor(runtimePorts, boundSnapshot.machine);
|
|
3507
4131
|
actor.start();
|
|
3508
|
-
|
|
4132
|
+
// A start-time actor error rides the startup emission channel
|
|
4133
|
+
// (latchRuntimeError); consume both latches here so the original
|
|
4134
|
+
// error outranks the derived status check below.
|
|
4135
|
+
{
|
|
4136
|
+
const startupFailure = emissionFailure;
|
|
4137
|
+
if (
|
|
4138
|
+
controlPlaneError !== undefined ||
|
|
4139
|
+
startupFailure !== undefined
|
|
4140
|
+
) {
|
|
4141
|
+
const startupError =
|
|
4142
|
+
controlPlaneError !== undefined
|
|
4143
|
+
? controlPlaneError
|
|
4144
|
+
: startupFailure!.error;
|
|
4145
|
+
controlPlaneError = undefined;
|
|
4146
|
+
if (emissionFailure === startupFailure) {
|
|
4147
|
+
emissionFailure = undefined;
|
|
4148
|
+
}
|
|
4149
|
+
throw startupError;
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
3509
4152
|
const restoredState = normalizePlaybookSnapshot(
|
|
3510
4153
|
actor.getSnapshot(),
|
|
3511
4154
|
suspendedCall === undefined
|
|
@@ -3588,7 +4231,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3588
4231
|
const state = currentState();
|
|
3589
4232
|
const context = ((snapshot as { context?: unknown }).context ??
|
|
3590
4233
|
{}) as Record<string, unknown>;
|
|
3591
|
-
const pending =
|
|
4234
|
+
const pending = pendingBossQuestionForState(state, context);
|
|
3592
4235
|
const lastError = normalizeErrorFull(context.lastError);
|
|
3593
4236
|
const projectedContext = projectControlContext(context);
|
|
3594
4237
|
const stateDescription = stateDescriptionFor(state);
|
|
@@ -3604,7 +4247,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3604
4247
|
: [
|
|
3605
4248
|
{
|
|
3606
4249
|
questionId: pending.questionId,
|
|
3607
|
-
|
|
4250
|
+
asker: pending.asker,
|
|
3608
4251
|
question: pending.question,
|
|
3609
4252
|
sourceItem: pending.sourceItem,
|
|
3610
4253
|
},
|
|
@@ -3676,6 +4319,8 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3676
4319
|
const position: TracePosition = { turnId, callId };
|
|
3677
4320
|
activeTurnId = turnId;
|
|
3678
4321
|
activeSignal = signal;
|
|
4322
|
+
activeAborts = abortReasonClassifier(signal);
|
|
4323
|
+
activeAbortEmission = undefined;
|
|
3679
4324
|
controlPlaneError = undefined;
|
|
3680
4325
|
// Every receipt variant is normalized and frozen where it is built,
|
|
3681
4326
|
// inside the guarded region, so the recording step below cannot
|
|
@@ -3727,9 +4372,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3727
4372
|
// final for their key. Past publication such a failure is therefore
|
|
3728
4373
|
// re-latched onto the emission channel, surfacing from the next
|
|
3729
4374
|
// public boundary's drain, and `apply` still does not throw past
|
|
3730
|
-
// acceptance (PBRT-52).
|
|
4375
|
+
// acceptance (PBRT-52). A delivery rejection causally identical to
|
|
4376
|
+
// this call's own abort reason evidences the cancellation and is
|
|
4377
|
+
// dropped — never carried to a later unrelated boundary
|
|
4378
|
+
// (slc/link.md §Abort).
|
|
3731
4379
|
const latchDeliveryFailure = (error: unknown): void => {
|
|
3732
|
-
|
|
4380
|
+
if (isAbortFailure(error, signal)) return;
|
|
4381
|
+
emissionFailure ??= { error };
|
|
3733
4382
|
};
|
|
3734
4383
|
try {
|
|
3735
4384
|
try {
|
|
@@ -3756,6 +4405,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3756
4405
|
'apply.finished',
|
|
3757
4406
|
identity,
|
|
3758
4407
|
position,
|
|
4408
|
+
signal,
|
|
3759
4409
|
preAcceptanceFinish('apply.started trace sink rejected'),
|
|
3760
4410
|
);
|
|
3761
4411
|
// An abort may land while the awaited started emission drains
|
|
@@ -3846,6 +4496,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3846
4496
|
} catch (error) {
|
|
3847
4497
|
settlementError = error;
|
|
3848
4498
|
}
|
|
4499
|
+
// Exact cancellation is not a control-plane latch, but after apply
|
|
4500
|
+
// acceptance and before publication it is still settlement evidence
|
|
4501
|
+
// and therefore folds into the owed failed receipt (DR-036 §4).
|
|
4502
|
+
settlementError ??= activeAbortEmission;
|
|
3849
4503
|
// Fold before the finish emission, the last point at which the
|
|
3850
4504
|
// traced disposition and the returned one can still be made the
|
|
3851
4505
|
// same value.
|
|
@@ -3884,6 +4538,8 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3884
4538
|
// wedge every later public boundary behind "another runtime turn
|
|
3885
4539
|
// is active".
|
|
3886
4540
|
activeSignal = undefined;
|
|
4541
|
+
activeAborts = undefined;
|
|
4542
|
+
activeAbortEmission = undefined;
|
|
3887
4543
|
activeTurnId = undefined;
|
|
3888
4544
|
controlPlaneError = undefined;
|
|
3889
4545
|
}
|
|
@@ -3932,136 +4588,171 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3932
4588
|
const turnId = ++turnSequence;
|
|
3933
4589
|
activeTurnId = turnId;
|
|
3934
4590
|
activeSignal = signal;
|
|
4591
|
+
activeAborts = abortReasonClassifier(signal);
|
|
4592
|
+
activeAbortEmission = undefined;
|
|
3935
4593
|
controlPlaneError = undefined;
|
|
3936
4594
|
let result: PlaybookRunResult | undefined;
|
|
3937
4595
|
let operationError: unknown;
|
|
4596
|
+
// The boundary sentinel releases on every exit: a settlement defect
|
|
4597
|
+
// past the drain — a snapshot normalization throw inside
|
|
4598
|
+
// `runResultFor` included — must never wedge every later public
|
|
4599
|
+
// boundary and `dispose` itself behind "another runtime turn is
|
|
4600
|
+
// active". Mirrors the apply boundary's finally.
|
|
3938
4601
|
try {
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
const trimmed = text.trim();
|
|
3945
|
-
if (trimmed !== '') {
|
|
3946
|
-
const snapshot = actor.getSnapshot();
|
|
3947
|
-
const terminal = snapshot.status === 'done';
|
|
3948
|
-
const stateId = normalizePlaybookSnapshot(snapshot).stateId;
|
|
3949
|
-
if (
|
|
3950
|
-
spec.entryEvent !== undefined &&
|
|
3951
|
-
(stateId === 'ready' || terminal)
|
|
3952
|
-
) {
|
|
3953
|
-
event = {
|
|
3954
|
-
type: spec.entryEvent.type,
|
|
3955
|
-
[spec.entryEvent.textField]: text,
|
|
3956
|
-
};
|
|
3957
|
-
} else {
|
|
3958
|
-
event = await classifyBossText(
|
|
3959
|
-
text,
|
|
3960
|
-
runtimePorts!,
|
|
3961
|
-
signal,
|
|
3962
|
-
snapshot,
|
|
3963
|
-
boundary,
|
|
3964
|
-
boundOptions,
|
|
3965
|
-
);
|
|
3966
|
-
}
|
|
3967
|
-
signal.throwIfAborted();
|
|
3968
|
-
}
|
|
3969
|
-
// Empty input, no-action classifier output, or invalid classifier
|
|
3970
|
-
// output — nothing to send.
|
|
3971
|
-
if (event === undefined) {
|
|
3972
|
-
result = runResultFor('no-action');
|
|
3973
|
-
} else {
|
|
3974
|
-
// 2. Optional Captain-pane classification line: the bare FSM
|
|
3975
|
-
// event type, emitted before the FSM advances.
|
|
3976
|
-
const statusLine = classificationStatus(event);
|
|
3977
|
-
if (statusLine !== undefined) {
|
|
3978
|
-
await runtimePorts!.emitStatus(statusLine);
|
|
3979
|
-
}
|
|
4602
|
+
try {
|
|
4603
|
+
await emitTrace('boss.input.received', { text }, { turnId });
|
|
4604
|
+
// Record the attempted input, then refuse a boundary that entered
|
|
4605
|
+
// aborted before deterministic mapping or the classifier can
|
|
4606
|
+
// perform host-visible work (DR-036 §5).
|
|
3980
4607
|
signal.throwIfAborted();
|
|
3981
|
-
//
|
|
3982
|
-
//
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
4608
|
+
// 1. Map the Boss text to an FSM event: deterministic exact entry
|
|
4609
|
+
// where applicable (slc/link.md §Boss-event mapping), judge
|
|
4610
|
+
// classification otherwise.
|
|
4611
|
+
let event: EventObject | undefined;
|
|
4612
|
+
const trimmed = text.trim();
|
|
4613
|
+
if (trimmed !== '') {
|
|
4614
|
+
const snapshot = actor.getSnapshot();
|
|
4615
|
+
const terminal = snapshot.status === 'done';
|
|
4616
|
+
const stateId = normalizePlaybookSnapshot(snapshot).stateId;
|
|
4617
|
+
// PBRT-1 / slc/link.md §Boss-event mapping: the idle entry, the
|
|
4618
|
+
// recoverable failure state, and the reconstructed terminal all
|
|
4619
|
+
// accept exactly one ordinary textual entry event, so delivered
|
|
4620
|
+
// text enters deterministically — no judge call to spend and no
|
|
4621
|
+
// classifier whim to settle a restart as no action. Every other
|
|
4622
|
+
// parked state — a reply wait or an authored mid-workflow
|
|
4623
|
+
// checkpoint — classifies under its own Boss-event contracts.
|
|
4624
|
+
if (
|
|
4625
|
+
spec.entryEvent !== undefined &&
|
|
4626
|
+
(stateId === 'ready' || stateId === 'failed' || terminal)
|
|
4627
|
+
) {
|
|
4628
|
+
event = {
|
|
4629
|
+
type: spec.entryEvent.type,
|
|
4630
|
+
[spec.entryEvent.textField]: text,
|
|
4631
|
+
};
|
|
4632
|
+
} else {
|
|
4633
|
+
event = await classifyBossText(
|
|
4634
|
+
text,
|
|
4635
|
+
runtimePorts!,
|
|
4636
|
+
signal,
|
|
4637
|
+
snapshot,
|
|
4638
|
+
boundary,
|
|
4639
|
+
boundOptions,
|
|
4640
|
+
);
|
|
4641
|
+
}
|
|
4642
|
+
signal.throwIfAborted();
|
|
3989
4643
|
}
|
|
3990
|
-
//
|
|
3991
|
-
//
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4644
|
+
// Empty input, no-action classifier output, or invalid classifier
|
|
4645
|
+
// output — nothing to send.
|
|
4646
|
+
if (event === undefined) {
|
|
4647
|
+
result = runResultFor('no-action');
|
|
4648
|
+
} else {
|
|
4649
|
+
// 2. Optional Captain-pane classification line: the bare FSM
|
|
4650
|
+
// event type, emitted before the FSM advances.
|
|
4651
|
+
const statusLine = classificationStatus(event);
|
|
4652
|
+
if (statusLine !== undefined) {
|
|
4653
|
+
await runtimePorts!.emitStatus(statusLine);
|
|
4654
|
+
}
|
|
4655
|
+
signal.throwIfAborted();
|
|
4656
|
+
// 3. A final actor cannot accept new events; reconstruct only
|
|
4657
|
+
// after classification produced a real event.
|
|
4658
|
+
if (actor.getSnapshot().status === 'done') {
|
|
4659
|
+
stopActor();
|
|
4660
|
+
actor = buildActor(runtimePorts!);
|
|
4661
|
+
// The replacement actor's snapshots are real state entries.
|
|
4662
|
+
suppressInspectionEmissions = false;
|
|
4663
|
+
actor.start();
|
|
4664
|
+
}
|
|
4665
|
+
// DR-029: keep the classified event with its recorded payload
|
|
4666
|
+
// as the retry-replay source. Recording is sanitizing, not
|
|
4667
|
+
// load-bearing: an override classifier's non-JSON-safe event is
|
|
4668
|
+
// simply not recorded, and the turn proceeds unchanged.
|
|
4669
|
+
try {
|
|
4670
|
+
lastBossEvent = snapshotJsonValue(
|
|
4671
|
+
event,
|
|
4672
|
+
'recorded Boss event',
|
|
4673
|
+
) as unknown as EventObject;
|
|
4674
|
+
} catch {
|
|
4675
|
+
lastBossEvent = undefined;
|
|
4676
|
+
}
|
|
4677
|
+
actor.send(event);
|
|
4678
|
+
await waitForPlaybookQuiescence(actor, {
|
|
4679
|
+
pendingCalls: nestedBridge,
|
|
4680
|
+
});
|
|
4681
|
+
if (controlPlaneError !== undefined) throw controlPlaneError;
|
|
4682
|
+
result = runResultFor(settledOutcome(signal));
|
|
4001
4683
|
}
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
pendingCalls: nestedBridge,
|
|
4005
|
-
});
|
|
4006
|
-
if (controlPlaneError !== undefined) throw controlPlaneError;
|
|
4007
|
-
result = runResultFor(settledOutcome(signal));
|
|
4684
|
+
} catch (error) {
|
|
4685
|
+
operationError = error;
|
|
4008
4686
|
}
|
|
4009
|
-
} catch (error) {
|
|
4010
|
-
operationError = error;
|
|
4011
|
-
}
|
|
4012
4687
|
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4688
|
+
let drainError: unknown;
|
|
4689
|
+
try {
|
|
4690
|
+
await drainEmissions();
|
|
4691
|
+
} catch (error) {
|
|
4692
|
+
drainError = error;
|
|
4693
|
+
}
|
|
4694
|
+
const latchedControlError = controlPlaneError;
|
|
4695
|
+
// A drain rejection that is the exact abort reason evidences the
|
|
4696
|
+
// cancellation, not a control-plane failure (slc/link.md §Abort).
|
|
4697
|
+
const drainAbort =
|
|
4698
|
+
drainError !== undefined && isAbortFailure(drainError, signal);
|
|
4699
|
+
const effectiveDrainError = drainAbort ? undefined : drainError;
|
|
4700
|
+
const primaryError =
|
|
4701
|
+
latchedControlError ?? effectiveDrainError ?? operationError;
|
|
4702
|
+
const abortError =
|
|
4703
|
+
latchedControlError === undefined &&
|
|
4704
|
+
effectiveDrainError === undefined &&
|
|
4705
|
+
((operationError !== undefined &&
|
|
4706
|
+
isAbortFailure(operationError, signal)) ||
|
|
4707
|
+
(drainAbort && operationError === undefined));
|
|
4708
|
+
const settlementResult =
|
|
4709
|
+
primaryError === undefined
|
|
4710
|
+
? (result ?? runResultFor('no-action'))
|
|
4711
|
+
: runResultFor(abortError ? 'aborted' : 'failed', primaryError);
|
|
4712
|
+
|
|
4713
|
+
let settlementEmissionError: unknown;
|
|
4714
|
+
try {
|
|
4715
|
+
await emitTrace(
|
|
4716
|
+
'boss.input.settled',
|
|
4717
|
+
settlementTracePayload(settlementResult),
|
|
4718
|
+
{ turnId },
|
|
4719
|
+
);
|
|
4720
|
+
} catch (error) {
|
|
4721
|
+
settlementEmissionError = error;
|
|
4722
|
+
}
|
|
4723
|
+
try {
|
|
4724
|
+
await drainEmissions();
|
|
4725
|
+
} catch (error) {
|
|
4726
|
+
settlementEmissionError ??= error;
|
|
4727
|
+
}
|
|
4728
|
+
if (
|
|
4729
|
+
settlementEmissionError !== undefined &&
|
|
4730
|
+
isAbortFailure(settlementEmissionError, signal)
|
|
4731
|
+
) {
|
|
4732
|
+
settlementEmissionError = undefined;
|
|
4733
|
+
}
|
|
4734
|
+
const failure =
|
|
4735
|
+
controlPlaneError ??
|
|
4736
|
+
latchedControlError ??
|
|
4737
|
+
effectiveDrainError ??
|
|
4738
|
+
(abortError
|
|
4739
|
+
? (settlementEmissionError ?? operationError)
|
|
4740
|
+
: (operationError ?? settlementEmissionError));
|
|
4057
4741
|
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4742
|
+
if (
|
|
4743
|
+
failure !== undefined &&
|
|
4744
|
+
!(abortError && settlementEmissionError === undefined)
|
|
4745
|
+
) {
|
|
4746
|
+
throw failure;
|
|
4747
|
+
}
|
|
4748
|
+
return settlementResult;
|
|
4749
|
+
} finally {
|
|
4750
|
+
activeSignal = undefined;
|
|
4751
|
+
activeAborts = undefined;
|
|
4752
|
+
activeAbortEmission = undefined;
|
|
4753
|
+
activeTurnId = undefined;
|
|
4754
|
+
controlPlaneError = undefined;
|
|
4063
4755
|
}
|
|
4064
|
-
return settlementResult;
|
|
4065
4756
|
},
|
|
4066
4757
|
|
|
4067
4758
|
async resumePlaybookCall(input: {
|
|
@@ -4086,37 +4777,88 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
4086
4777
|
}
|
|
4087
4778
|
activeTurnId = playbookCallTurnIds.get(input.callId);
|
|
4088
4779
|
activeSignal = input.signal;
|
|
4780
|
+
activeAborts = abortReasonClassifier(input.signal);
|
|
4781
|
+
activeAbortEmission = undefined;
|
|
4089
4782
|
controlPlaneError = undefined;
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
try {
|
|
4093
|
-
await nestedBridge.resume(input);
|
|
4094
|
-
} catch (error) {
|
|
4095
|
-
operationError = error;
|
|
4096
|
-
}
|
|
4783
|
+
// The boundary sentinel releases on every exit, mirroring
|
|
4784
|
+
// `handleBossInput` and the apply boundary.
|
|
4097
4785
|
try {
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4786
|
+
let result: PlaybookRunResult | undefined;
|
|
4787
|
+
let operationError: unknown;
|
|
4788
|
+
try {
|
|
4789
|
+
await nestedBridge.resume(input);
|
|
4790
|
+
} catch (error) {
|
|
4791
|
+
operationError = error;
|
|
4792
|
+
}
|
|
4793
|
+
try {
|
|
4794
|
+
await waitForPlaybookQuiescence(actor, {
|
|
4795
|
+
pendingCalls: nestedBridge,
|
|
4796
|
+
});
|
|
4797
|
+
result = runResultFor(settledOutcome(input.signal));
|
|
4798
|
+
} catch (error) {
|
|
4799
|
+
operationError ??= error;
|
|
4800
|
+
}
|
|
4801
|
+
// A resume refused because its signal was already aborted
|
|
4802
|
+
// delivers nothing: the pending call survives for a later
|
|
4803
|
+
// resume, and the boundary settles `aborted` rather than
|
|
4804
|
+
// advertising `suspended` (slc/link.md §Nested playbook bridge).
|
|
4805
|
+
if (
|
|
4806
|
+
operationError !== undefined &&
|
|
4807
|
+
isAbortFailure(operationError, input.signal) &&
|
|
4808
|
+
nestedBridge.getPendingCall()?.callId === input.callId
|
|
4809
|
+
) {
|
|
4810
|
+
result = {
|
|
4811
|
+
outcome: 'aborted',
|
|
4812
|
+
state: currentState(),
|
|
4813
|
+
error: normalizeError(input.signal.reason),
|
|
4814
|
+
};
|
|
4815
|
+
}
|
|
4816
|
+
let drainError: unknown;
|
|
4817
|
+
try {
|
|
4818
|
+
await drainEmissions();
|
|
4819
|
+
} catch (error) {
|
|
4820
|
+
drainError = error;
|
|
4821
|
+
}
|
|
4822
|
+
const aborts = activeAborts ?? abortReasonClassifier(input.signal);
|
|
4823
|
+
// A control-plane latch has already classified its failure as
|
|
4824
|
+
// distinct under the owning operation. Never reinterpret it
|
|
4825
|
+
// against this later resume signal (DR-036 decision 2).
|
|
4826
|
+
const controlFailure = controlPlaneError;
|
|
4827
|
+
const drainAbort =
|
|
4828
|
+
controlFailure === undefined &&
|
|
4829
|
+
drainError !== undefined &&
|
|
4830
|
+
aborts.isAbortReason(drainError);
|
|
4831
|
+
const operationAbort =
|
|
4832
|
+
controlFailure === undefined &&
|
|
4833
|
+
operationError !== undefined &&
|
|
4834
|
+
aborts.isAbortReason(operationError);
|
|
4835
|
+
const abortEvidence =
|
|
4836
|
+
activeAbortEmission ??
|
|
4837
|
+
(drainAbort ? drainError : undefined) ??
|
|
4838
|
+
(operationAbort ? operationError : undefined);
|
|
4839
|
+
const failure =
|
|
4840
|
+
controlFailure ??
|
|
4841
|
+
(drainAbort ? undefined : drainError) ??
|
|
4842
|
+
(operationAbort ? undefined : operationError);
|
|
4843
|
+
if (failure !== undefined) throw failure;
|
|
4844
|
+
if (
|
|
4845
|
+
abortEvidence !== undefined &&
|
|
4846
|
+
result?.outcome !== 'terminal' &&
|
|
4847
|
+
result?.outcome !== 'suspended'
|
|
4848
|
+
) {
|
|
4849
|
+
result = runResultFor('aborted', abortEvidence);
|
|
4850
|
+
}
|
|
4851
|
+
if (result === undefined) {
|
|
4852
|
+
throw new Error('playbook resume produced no runtime result');
|
|
4853
|
+
}
|
|
4854
|
+
return result;
|
|
4855
|
+
} finally {
|
|
4856
|
+
activeSignal = undefined;
|
|
4857
|
+
activeAborts = undefined;
|
|
4858
|
+
activeAbortEmission = undefined;
|
|
4859
|
+
activeTurnId = undefined;
|
|
4860
|
+
controlPlaneError = undefined;
|
|
4118
4861
|
}
|
|
4119
|
-
return result;
|
|
4120
4862
|
},
|
|
4121
4863
|
|
|
4122
4864
|
dispose(): Promise<void> {
|
|
@@ -4176,9 +4918,9 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
4176
4918
|
// engagement tree. Child disposal must not erase a token its
|
|
4177
4919
|
// caller will resume. The private fallback remains runtime-owned.
|
|
4178
4920
|
if (session?.playerSessions === undefined) {
|
|
4179
|
-
|
|
4921
|
+
privateResumeTokens.clear();
|
|
4180
4922
|
}
|
|
4181
|
-
|
|
4923
|
+
activePlayerKeys.clear();
|
|
4182
4924
|
playbookCallTurnIds.clear();
|
|
4183
4925
|
activeEmissionCalls.clear();
|
|
4184
4926
|
emissionQueue.clear();
|
|
@@ -4186,6 +4928,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
4186
4928
|
appliedReceipts.clear();
|
|
4187
4929
|
actor = undefined;
|
|
4188
4930
|
activeSignal = undefined;
|
|
4931
|
+
activeAborts = undefined;
|
|
4932
|
+
actorSettlementAborts = undefined;
|
|
4933
|
+
actorSettlementErrorAborts = undefined;
|
|
4934
|
+
activeAbortEmission = undefined;
|
|
4189
4935
|
activeTurnId = undefined;
|
|
4190
4936
|
controlPlaneError = undefined;
|
|
4191
4937
|
emissionFailure = undefined;
|