@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
|
@@ -78,15 +78,15 @@ function isEmptyOkRetryFailure(error) {
|
|
|
78
78
|
export const RUNTIME_ABI = 1;
|
|
79
79
|
/** The linked-artifact schema versions this engine accepts (DR-022). */
|
|
80
80
|
export const SUPPORTED_ARTIFACT_SCHEMAS = Object.freeze([
|
|
81
|
-
|
|
81
|
+
2,
|
|
82
82
|
]);
|
|
83
83
|
// PBRT-50: validate a declaration against the loaded engine, schema first,
|
|
84
|
-
// so one clear diagnostic covers a fully skewed artifact.
|
|
85
|
-
//
|
|
86
|
-
// loading unchanged (DR-019 §4), so there is nothing to check.
|
|
84
|
+
// so one clear diagnostic covers a fully skewed artifact. Declaration-free
|
|
85
|
+
// artifacts are schema 1 and cannot be interpreted as local-role artifacts.
|
|
87
86
|
function assertRuntimeCompat(compat, label) {
|
|
88
|
-
if (compat === undefined)
|
|
89
|
-
|
|
87
|
+
if (compat === undefined) {
|
|
88
|
+
throw new TypeError(`${label} spec.compat is required for local-role artifacts`);
|
|
89
|
+
}
|
|
90
90
|
if (compat === null || typeof compat !== 'object') {
|
|
91
91
|
throw new TypeError(`${label} spec.compat must be an object`);
|
|
92
92
|
}
|
|
@@ -233,9 +233,37 @@ export function normalizeErrorFull(err) {
|
|
|
233
233
|
return undefined;
|
|
234
234
|
return normalizeError(err);
|
|
235
235
|
}
|
|
236
|
+
// slc/link.md §Abort: cancellation is causal identity with the applicable
|
|
237
|
+
// signal's reason — never an `AbortError` name, never bare signal state. A
|
|
238
|
+
// distinct failure observed while the signal is aborted stays a non-abort
|
|
239
|
+
// control error and takes precedence (mirrors DECIDE's bespoke reference).
|
|
236
240
|
function isAbortFailure(error, signal) {
|
|
237
|
-
return
|
|
238
|
-
|
|
241
|
+
return signal.aborted && Object.is(error, signal.reason);
|
|
242
|
+
}
|
|
243
|
+
function abortReasonClassifier(...sources) {
|
|
244
|
+
const captured = sources.filter((source) => source !== undefined);
|
|
245
|
+
return Object.freeze({
|
|
246
|
+
isAbortReason: (error) => captured.some((source) => source instanceof AbortSignal
|
|
247
|
+
? isAbortFailure(error, source)
|
|
248
|
+
: source.isAbortReason(error)),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* gears2fsm's canonical Boss-reply wait state. On the runtime's Boss-facing
|
|
253
|
+
* surfaces — state telemetry, status lines, the exported snapshot, and the
|
|
254
|
+
* control view — a context question counts as *pending* only while the
|
|
255
|
+
* machine sits in this state awaiting the reply. Later states retain the
|
|
256
|
+
* answered question in context (the resumed player prompt is composed from
|
|
257
|
+
* it), so an unconditional projection would resurrect it: a failure the
|
|
258
|
+
* resumed player reached would export a question nobody is waiting on,
|
|
259
|
+
* disagreeing with the gated telemetry a mirroring host's ledger follows
|
|
260
|
+
* and failing the shell's snapshot-equality settlement check.
|
|
261
|
+
*/
|
|
262
|
+
const BOSS_REPLY_WAIT_STATE_ID = 'awaitBossReply';
|
|
263
|
+
function pendingBossQuestionForState(state, context) {
|
|
264
|
+
if (state.stateId !== BOSS_REPLY_WAIT_STATE_ID)
|
|
265
|
+
return undefined;
|
|
266
|
+
return pendingBossQuestionFromContext(context);
|
|
239
267
|
}
|
|
240
268
|
/** Read the FSM context's single pending Boss question, when well-formed. */
|
|
241
269
|
export function pendingBossQuestionFromContext(context) {
|
|
@@ -249,15 +277,31 @@ export function pendingBossQuestionFromContext(context) {
|
|
|
249
277
|
if (typeof candidate.questionId !== 'string' ||
|
|
250
278
|
typeof candidate.resumeStateId !== 'string' ||
|
|
251
279
|
typeof candidate.sourceItem !== 'string' ||
|
|
252
|
-
|
|
280
|
+
!isPlainObject(candidate.asker) ||
|
|
253
281
|
typeof candidate.question !== 'string') {
|
|
254
282
|
return undefined;
|
|
255
283
|
}
|
|
284
|
+
let asker;
|
|
285
|
+
if (candidate.asker.kind === 'captain') {
|
|
286
|
+
if (Object.keys(candidate.asker).some((key) => key !== 'kind')) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
asker = { kind: 'captain' };
|
|
290
|
+
}
|
|
291
|
+
else if (candidate.asker.kind === 'role' &&
|
|
292
|
+
typeof candidate.asker.roleId === 'string' &&
|
|
293
|
+
candidate.asker.roleId.trim().length > 0 &&
|
|
294
|
+
Object.keys(candidate.asker).every((key) => key === 'kind' || key === 'roleId')) {
|
|
295
|
+
asker = { kind: 'role', roleId: candidate.asker.roleId };
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
256
300
|
return {
|
|
257
301
|
questionId: candidate.questionId,
|
|
258
302
|
resumeStateId: candidate.resumeStateId,
|
|
259
303
|
sourceItem: candidate.sourceItem,
|
|
260
|
-
|
|
304
|
+
asker,
|
|
261
305
|
question: candidate.question,
|
|
262
306
|
};
|
|
263
307
|
}
|
|
@@ -339,10 +383,6 @@ export function defaultComposeCaptainPrompt(input, placeholderFields = {}) {
|
|
|
339
383
|
blocks.push(body);
|
|
340
384
|
return blocks.join('\n\n');
|
|
341
385
|
}
|
|
342
|
-
/** Default player binding: each player to its lowercased name. */
|
|
343
|
-
export function defaultResolvePlayerId(input) {
|
|
344
|
-
return input.player.toLowerCase();
|
|
345
|
-
}
|
|
346
386
|
/**
|
|
347
387
|
* Default required-field extraction (slc/link.md §Captain adjudication).
|
|
348
388
|
* Limited to the description's `Output shall include` / `输出应包含` clause;
|
|
@@ -375,7 +415,7 @@ export function defaultBuildJudgePrompt(input, finalText) {
|
|
|
375
415
|
'seek external evidence. Decide only from the supplied player output ' +
|
|
376
416
|
'and outcome descriptions. Reply with exactly one JSON object and no prose.');
|
|
377
417
|
lines.push('');
|
|
378
|
-
lines.push(`The ${input.
|
|
418
|
+
lines.push(`The ${input.role} role just produced this output:`);
|
|
379
419
|
lines.push('');
|
|
380
420
|
lines.push('```');
|
|
381
421
|
lines.push(finalText);
|
|
@@ -447,11 +487,21 @@ function validateBossReplyOutput(input, output, resumableStateIds) {
|
|
|
447
487
|
export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onControlPlaneError) {
|
|
448
488
|
return fromPromise(async ({ input, signal }) => {
|
|
449
489
|
const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
|
|
450
|
-
|
|
451
|
-
|
|
490
|
+
let roleId;
|
|
491
|
+
let prompt;
|
|
492
|
+
try {
|
|
493
|
+
roleId = spec.resolveRoleId(input);
|
|
494
|
+
prompt = spec.composePlayerPrompt(input);
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
if (!isAbortFailure(error, activeSignal)) {
|
|
498
|
+
onControlPlaneError?.(error);
|
|
499
|
+
}
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
452
502
|
const callPlayer = (resume) => boundary
|
|
453
|
-
? boundary.callPlayer(input,
|
|
454
|
-
: ports.callPlayer(
|
|
503
|
+
? boundary.callPlayer(input, roleId, prompt, activeSignal)
|
|
504
|
+
: ports.callPlayer(roleId, prompt, activeSignal, { resume });
|
|
455
505
|
let result = await callPlayer(false);
|
|
456
506
|
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
457
507
|
// An abort that lands between the empty first result and the
|
|
@@ -486,7 +536,9 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
|
|
|
486
536
|
return output;
|
|
487
537
|
}
|
|
488
538
|
catch (error) {
|
|
489
|
-
|
|
539
|
+
if (!isAbortFailure(error, activeSignal)) {
|
|
540
|
+
onControlPlaneError?.(error);
|
|
541
|
+
}
|
|
490
542
|
throw error;
|
|
491
543
|
}
|
|
492
544
|
});
|
|
@@ -589,8 +641,8 @@ function collectInvokeSources(machine) {
|
|
|
589
641
|
visit(machine.config);
|
|
590
642
|
return sources;
|
|
591
643
|
}
|
|
592
|
-
function
|
|
593
|
-
const
|
|
644
|
+
function collectPlayerStateRoles(machine) {
|
|
645
|
+
const roles = new Map();
|
|
594
646
|
const visit = (stateDef, stateKey) => {
|
|
595
647
|
if (!isPlainObject(stateDef))
|
|
596
648
|
return;
|
|
@@ -609,13 +661,13 @@ function collectPlayerStatePlayers(machine) {
|
|
|
609
661
|
if (stateId.trim().length === 0) {
|
|
610
662
|
throw new TypeError('player state metadata must use a non-empty state id');
|
|
611
663
|
}
|
|
612
|
-
const
|
|
613
|
-
? playbookMeta.
|
|
664
|
+
const role = isPlainObject(playbookMeta)
|
|
665
|
+
? playbookMeta.role
|
|
614
666
|
: undefined;
|
|
615
|
-
if (typeof
|
|
616
|
-
throw new TypeError(`player state ${stateId} meta.playbook.
|
|
667
|
+
if (typeof role !== 'string' || role.trim().length === 0) {
|
|
668
|
+
throw new TypeError(`player state ${stateId} meta.playbook.role must be a non-empty string`);
|
|
617
669
|
}
|
|
618
|
-
|
|
670
|
+
roles.set(stateId, role);
|
|
619
671
|
}
|
|
620
672
|
if (isPlainObject(stateDef.states)) {
|
|
621
673
|
for (const [childKey, child] of Object.entries(stateDef.states)) {
|
|
@@ -629,7 +681,7 @@ function collectPlayerStatePlayers(machine) {
|
|
|
629
681
|
visit(stateDef, stateKey);
|
|
630
682
|
}
|
|
631
683
|
}
|
|
632
|
-
return
|
|
684
|
+
return roles;
|
|
633
685
|
}
|
|
634
686
|
function transitionTargets(transition) {
|
|
635
687
|
const arms = Array.isArray(transition) ? transition : [transition];
|
|
@@ -650,7 +702,7 @@ export function resumableStateIdsFromMachine(machine) {
|
|
|
650
702
|
if (!isPlainObject(config) || !isPlainObject(config.states)) {
|
|
651
703
|
return new Set();
|
|
652
704
|
}
|
|
653
|
-
const awaitState = config.states
|
|
705
|
+
const awaitState = config.states[BOSS_REPLY_WAIT_STATE_ID];
|
|
654
706
|
if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
|
|
655
707
|
return new Set();
|
|
656
708
|
}
|
|
@@ -752,6 +804,28 @@ function deepFreeze(value) {
|
|
|
752
804
|
// Default transition/status derivation.
|
|
753
805
|
// ---------------------------------------------------------------------------
|
|
754
806
|
const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
|
|
807
|
+
// Bounded escalation for aborted script process groups: SIGTERM first, then
|
|
808
|
+
// SIGKILL after this grace, so settlement (gated on the shell's own exit)
|
|
809
|
+
// stays bounded even for TERM-immune commands.
|
|
810
|
+
const SCRIPT_ABORT_KILL_GRACE_MS = 2000;
|
|
811
|
+
class ScriptProcessGroupTeardownError extends Error {
|
|
812
|
+
constructor(pid, message, cause) {
|
|
813
|
+
super(`script process group ${pid} teardown could not be confirmed: ${message}`, cause === undefined ? undefined : { cause });
|
|
814
|
+
this.name = 'ScriptProcessGroupTeardownError';
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function isNoSuchProcess(error) {
|
|
818
|
+
return (typeof error === 'object' &&
|
|
819
|
+
error !== null &&
|
|
820
|
+
'code' in error &&
|
|
821
|
+
error.code === 'ESRCH');
|
|
822
|
+
}
|
|
823
|
+
function isProcessPermissionDenied(error) {
|
|
824
|
+
return (typeof error === 'object' &&
|
|
825
|
+
error !== null &&
|
|
826
|
+
'code' in error &&
|
|
827
|
+
error.code === 'EPERM');
|
|
828
|
+
}
|
|
755
829
|
function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
|
|
756
830
|
return (event) => {
|
|
757
831
|
if (event === null || typeof event !== 'object') {
|
|
@@ -774,40 +848,48 @@ function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
|
|
|
774
848
|
return snapshotJsonValue(out, 'FSM event');
|
|
775
849
|
};
|
|
776
850
|
}
|
|
777
|
-
function
|
|
778
|
-
if (value === undefined)
|
|
779
|
-
|
|
780
|
-
if (!isPlainObject(value)) {
|
|
781
|
-
throw new TypeError(`${label} playerStates must be an object`);
|
|
851
|
+
function snapshotRoleStateStatuses(value, label, machine, stateDescriptions) {
|
|
852
|
+
if (value === undefined) {
|
|
853
|
+
throw new TypeError(`${label} roleStates must be supplied for schema 2`);
|
|
782
854
|
}
|
|
783
|
-
const
|
|
855
|
+
const captured = snapshotJsonValue(value, `${label} roleStates`);
|
|
856
|
+
if (!isPlainObject(captured)) {
|
|
857
|
+
throw new TypeError(`${label} roleStates must be an object`);
|
|
858
|
+
}
|
|
859
|
+
const declared = collectPlayerStateRoles(machine);
|
|
784
860
|
const statuses = new Map();
|
|
785
|
-
for (const [stateId, candidate] of Object.entries(
|
|
861
|
+
for (const [stateId, candidate] of Object.entries(captured)) {
|
|
786
862
|
if (!declared.has(stateId)) {
|
|
787
|
-
throw new TypeError(`${label}
|
|
863
|
+
throw new TypeError(`${label} roleStates.${stateId} does not name a player state`);
|
|
864
|
+
}
|
|
865
|
+
if (isPlainObject(candidate)) {
|
|
866
|
+
const extra = Object.keys(candidate).find((key) => key !== 'role' && key !== 'label');
|
|
867
|
+
if (extra !== undefined) {
|
|
868
|
+
throw new TypeError(`${label} roleStates.${stateId}.${extra} is not allowed`);
|
|
869
|
+
}
|
|
788
870
|
}
|
|
789
871
|
if (!isPlainObject(candidate) ||
|
|
790
|
-
typeof candidate.
|
|
791
|
-
candidate.
|
|
872
|
+
typeof candidate.role !== 'string' ||
|
|
873
|
+
candidate.role.trim().length === 0 ||
|
|
792
874
|
typeof candidate.label !== 'string' ||
|
|
793
875
|
candidate.label.trim().length === 0) {
|
|
794
|
-
throw new TypeError(`${label}
|
|
876
|
+
throw new TypeError(`${label} roleStates.${stateId} must carry non-empty role and label strings`);
|
|
795
877
|
}
|
|
796
878
|
const expectedLabel = stateDescriptions.get(stateId);
|
|
797
879
|
if (candidate.label !== expectedLabel) {
|
|
798
|
-
throw new TypeError(`${label}
|
|
880
|
+
throw new TypeError(`${label} roleStates.${stateId}.label must equal its FSM description`);
|
|
799
881
|
}
|
|
800
|
-
if (candidate.
|
|
801
|
-
throw new TypeError(`${label}
|
|
882
|
+
if (candidate.role !== declared.get(stateId)) {
|
|
883
|
+
throw new TypeError(`${label} roleStates.${stateId}.role must equal its FSM role`);
|
|
802
884
|
}
|
|
803
885
|
statuses.set(stateId, {
|
|
804
|
-
|
|
886
|
+
role: candidate.role,
|
|
805
887
|
label: candidate.label,
|
|
806
888
|
});
|
|
807
889
|
}
|
|
808
890
|
for (const stateId of declared.keys()) {
|
|
809
891
|
if (!statuses.has(stateId)) {
|
|
810
|
-
throw new TypeError(`${label}
|
|
892
|
+
throw new TypeError(`${label} roleStates must declare player state ${stateId}`);
|
|
811
893
|
}
|
|
812
894
|
}
|
|
813
895
|
return statuses;
|
|
@@ -820,34 +902,10 @@ function settlingGuard(event) {
|
|
|
820
902
|
? guard
|
|
821
903
|
: undefined;
|
|
822
904
|
}
|
|
823
|
-
function
|
|
824
|
-
|
|
825
|
-
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId))
|
|
826
|
-
return [];
|
|
827
|
-
if (stateId === 'awaitBossReply') {
|
|
828
|
-
const pending = pendingBossQuestionFromContext(context);
|
|
829
|
-
return [
|
|
830
|
-
{
|
|
831
|
-
message: pending === undefined
|
|
832
|
-
? 'Awaiting Boss reply.'
|
|
833
|
-
: `${pending.player} asks: ${pending.question}`,
|
|
834
|
-
},
|
|
835
|
-
];
|
|
836
|
-
}
|
|
837
|
-
if (stateId === 'failed') {
|
|
838
|
-
const lastError = normalizeErrorFull(context.lastError);
|
|
839
|
-
return [
|
|
840
|
-
{
|
|
841
|
-
message: 'Workflow failed; awaiting Boss recovery.',
|
|
842
|
-
...(lastError === undefined
|
|
843
|
-
? {}
|
|
844
|
-
: { data: snapshotJsonValue({ lastError }, 'failed status data') }),
|
|
845
|
-
},
|
|
846
|
-
];
|
|
847
|
-
}
|
|
848
|
-
return [{ message: `Entered ${stateId}.` }];
|
|
905
|
+
function askerLabel(asker) {
|
|
906
|
+
return asker.kind === 'captain' ? 'Captain' : asker.roleId;
|
|
849
907
|
}
|
|
850
|
-
function makeDefaultStatusesForState(
|
|
908
|
+
function makeDefaultStatusesForState(roleStates) {
|
|
851
909
|
return (state, context, event) => {
|
|
852
910
|
const statuses = [];
|
|
853
911
|
const guard = settlingGuard(event);
|
|
@@ -857,17 +915,17 @@ function makeDefaultStatusesForState(playerStates) {
|
|
|
857
915
|
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
|
|
858
916
|
return statuses;
|
|
859
917
|
}
|
|
860
|
-
if (stateId ===
|
|
918
|
+
if (stateId === BOSS_REPLY_WAIT_STATE_ID) {
|
|
861
919
|
const pending = pendingBossQuestionFromContext(context);
|
|
862
920
|
if (pending === undefined) {
|
|
863
921
|
return [...statuses, { message: 'Awaiting Boss reply.' }];
|
|
864
922
|
}
|
|
865
923
|
return [
|
|
866
924
|
...statuses,
|
|
867
|
-
{ message: `${pending.
|
|
925
|
+
{ message: `${askerLabel(pending.asker)} asks: ${pending.question}` },
|
|
868
926
|
{
|
|
869
927
|
message: `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
|
|
870
|
-
`${pending.
|
|
928
|
+
`${askerLabel(pending.asker)} · ${pending.sourceItem}`,
|
|
871
929
|
},
|
|
872
930
|
];
|
|
873
931
|
}
|
|
@@ -885,10 +943,10 @@ function makeDefaultStatusesForState(playerStates) {
|
|
|
885
943
|
},
|
|
886
944
|
];
|
|
887
945
|
}
|
|
888
|
-
const
|
|
889
|
-
if (
|
|
946
|
+
const roleState = roleStates.get(stateId);
|
|
947
|
+
if (roleState !== undefined) {
|
|
890
948
|
statuses.push({
|
|
891
|
-
message: `⤷ ${
|
|
949
|
+
message: `⤷ ${roleState.role}: ${roleState.label}`,
|
|
892
950
|
});
|
|
893
951
|
}
|
|
894
952
|
return statuses;
|
|
@@ -1059,7 +1117,14 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
|
|
|
1059
1117
|
const state = classifierState(snapshotOrState);
|
|
1060
1118
|
const stateId = typeof state.value === 'string' ? state.value : undefined;
|
|
1061
1119
|
const currentState = stateId ?? JSON.stringify(state.value ?? null);
|
|
1062
|
-
|
|
1120
|
+
// The classifier shares the reply-wait pendingness of every other
|
|
1121
|
+
// surface: outside the wait, a context question a later state retains
|
|
1122
|
+
// is answered history, so the prompt must not present it as pending —
|
|
1123
|
+
// a judge told a question awaits at the failure state is steered toward
|
|
1124
|
+
// a reply it cannot select or toward no action at all.
|
|
1125
|
+
const pending = stateId === BOSS_REPLY_WAIT_STATE_ID
|
|
1126
|
+
? pendingBossQuestionFromContext(state.context)
|
|
1127
|
+
: undefined;
|
|
1063
1128
|
const configuredTypes = configuredEventTypesForState(machine, stateId);
|
|
1064
1129
|
const applicable = [...contracts.values()].filter((contract) => configuredTypes.has(contract.type) &&
|
|
1065
1130
|
(contract.type !== 'BOSS_REPLY' || pending !== undefined));
|
|
@@ -1071,7 +1136,7 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
|
|
|
1071
1136
|
`Current state: ${currentState}`,
|
|
1072
1137
|
];
|
|
1073
1138
|
if (pending !== undefined) {
|
|
1074
|
-
lines.push(`Pending question id: ${pending.questionId}`, `Pending
|
|
1139
|
+
lines.push(`Pending question id: ${pending.questionId}`, `Pending asker: ${askerLabel(pending.asker)}`, `Pending Boss question: ${pending.question}`);
|
|
1075
1140
|
}
|
|
1076
1141
|
lines.push('', 'Allowed JSON objects:', '- { "type": "NO_ACTION" }');
|
|
1077
1142
|
for (const contract of applicable) {
|
|
@@ -1175,6 +1240,60 @@ function machineDeclaresParallelState(machine) {
|
|
|
1175
1240
|
};
|
|
1176
1241
|
return visit(machine.config);
|
|
1177
1242
|
}
|
|
1243
|
+
// PBRT-52: the factory's domain is FLAT single-region machines — every
|
|
1244
|
+
// state a direct child of the root, so each snapshot exposes exactly one
|
|
1245
|
+
// playbook state id and every state-keyed lookup (deterministic entries,
|
|
1246
|
+
// retry, reply-wait pendingness, configured events, descriptions) indexes
|
|
1247
|
+
// one unambiguous identity. A compound child would be accepted and then
|
|
1248
|
+
// silently misbehave on all of those gates, so it is rejected up front
|
|
1249
|
+
// exactly like a parallel region.
|
|
1250
|
+
function machineDeclaresNestedState(machine) {
|
|
1251
|
+
const config = machine.config;
|
|
1252
|
+
if (!isPlainObject(config) || !isPlainObject(config.states))
|
|
1253
|
+
return false;
|
|
1254
|
+
return Object.values(config.states).some((stateDef) => isPlainObject(stateDef) &&
|
|
1255
|
+
isPlainObject(stateDef.states) &&
|
|
1256
|
+
Object.keys(stateDef.states).length > 0);
|
|
1257
|
+
}
|
|
1258
|
+
// PBRT-52: the factory's lookups index states by their root key, and the
|
|
1259
|
+
// published playbook identity is `meta.playbook.stateId` — the two must
|
|
1260
|
+
// coincide or a machine can advertise a pending question or retry under an
|
|
1261
|
+
// identity no lookup resolves. A state with no string stateId is just as
|
|
1262
|
+
// dead: every snapshot identity derives from that member, so the first
|
|
1263
|
+
// entry would fail the exactly-one-state-id inspection at runtime.
|
|
1264
|
+
// gears2fsm keeps identity and key equal by construction; a hand-authored
|
|
1265
|
+
// artifact that splits or omits them fails here instead of at a silently
|
|
1266
|
+
// dead gate.
|
|
1267
|
+
function assertFlatStateIdentity(machine, label) {
|
|
1268
|
+
const config = machine.config;
|
|
1269
|
+
const states = isPlainObject(config) && isPlainObject(config.states)
|
|
1270
|
+
? config.states
|
|
1271
|
+
: undefined;
|
|
1272
|
+
// A machine with no root states has no playbook identity to expose; its
|
|
1273
|
+
// first snapshot would fail the exactly-one-state-id inspection, so it
|
|
1274
|
+
// fails construction with the defect named instead.
|
|
1275
|
+
if (states === undefined || Object.keys(states).length === 0) {
|
|
1276
|
+
throw new Error(`${label} declares no root states; the shared runtime requires at ` +
|
|
1277
|
+
'least one flat playbook state');
|
|
1278
|
+
}
|
|
1279
|
+
for (const [key, stateDef] of Object.entries(states)) {
|
|
1280
|
+
if (!isPlainObject(stateDef))
|
|
1281
|
+
continue;
|
|
1282
|
+
const meta = isPlainObject(stateDef.meta) ? stateDef.meta : undefined;
|
|
1283
|
+
const playbook = meta !== undefined && isPlainObject(meta.playbook)
|
|
1284
|
+
? meta.playbook
|
|
1285
|
+
: undefined;
|
|
1286
|
+
const stateId = playbook?.stateId;
|
|
1287
|
+
if (typeof stateId !== 'string') {
|
|
1288
|
+
throw new Error(`${label} state ${key} declares no string meta.playbook.stateId; ` +
|
|
1289
|
+
'the shared runtime derives every playbook state identity from it');
|
|
1290
|
+
}
|
|
1291
|
+
if (stateId !== key) {
|
|
1292
|
+
throw new Error(`${label} state ${key} declares meta.playbook.stateId ${stateId}; ` +
|
|
1293
|
+
'the shared runtime requires the playbook state id to equal the state key');
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1178
1297
|
/**
|
|
1179
1298
|
* Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
|
|
1180
1299
|
* under the slc/link.md contract. The factory provides every actor kind the
|
|
@@ -1182,25 +1301,44 @@ function machineDeclaresParallelState(machine) {
|
|
|
1182
1301
|
* (literal and dynamic) — and implements the full runtime lifecycle including
|
|
1183
1302
|
* the optional parked-session snapshot capability (DR-014).
|
|
1184
1303
|
*
|
|
1185
|
-
* Scope: machines
|
|
1186
|
-
*
|
|
1187
|
-
*
|
|
1304
|
+
* Scope: flat single-region machines — no parallel state, no compound
|
|
1305
|
+
* child states, and every root state's `meta.playbook.stateId` equal to its
|
|
1306
|
+
* state key — so each snapshot exposes exactly one playbook state id.
|
|
1307
|
+
* Parallel-region FSMs keep their own linked runtimes.
|
|
1188
1308
|
*/
|
|
1189
1309
|
export function createXStatePlaybookRuntime(machine, spec) {
|
|
1190
1310
|
const label = spec.label ?? 'playbook';
|
|
1191
1311
|
// DR-022 / PBRT-50: reject an incompatible artifact declaration before any
|
|
1192
1312
|
// machine interpretation, against this loaded engine's own self-report.
|
|
1193
1313
|
assertRuntimeCompat(spec.compat, label);
|
|
1314
|
+
const specDescriptors = Object.getOwnPropertyDescriptors(spec);
|
|
1315
|
+
if (Object.prototype.hasOwnProperty.call(specDescriptors, 'playerStates')) {
|
|
1316
|
+
throw new TypeError(`${label} schema-2 artifacts must supply roleStates, not playerStates`);
|
|
1317
|
+
}
|
|
1318
|
+
if (Object.prototype.hasOwnProperty.call(specDescriptors, 'resolvePlayerId')) {
|
|
1319
|
+
throw new TypeError(`${label} schema-2 artifacts must not derive concrete player bindings`);
|
|
1320
|
+
}
|
|
1194
1321
|
if (machineDeclaresParallelState(machine)) {
|
|
1195
1322
|
throw new Error(`${label} uses a parallel state; the shared runtime supports only single-region FSMs`);
|
|
1196
1323
|
}
|
|
1324
|
+
if (machineDeclaresNestedState(machine)) {
|
|
1325
|
+
throw new Error(`${label} declares a compound state; the shared runtime supports only flat single-region FSMs`);
|
|
1326
|
+
}
|
|
1327
|
+
assertFlatStateIdentity(machine, label);
|
|
1197
1328
|
const declaredActors = collectInvokeSources(machine);
|
|
1198
1329
|
const resumableStateIds = spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
|
|
1199
1330
|
// DR-029: source state descriptions label the control actions the
|
|
1200
1331
|
// runtime advertises through `describe()`.
|
|
1201
1332
|
const stateDescriptions = stateDescriptionsFromMachine(machine);
|
|
1202
|
-
const
|
|
1203
|
-
|
|
1333
|
+
const roleStatesDescriptor = specDescriptors.roleStates;
|
|
1334
|
+
if (roleStatesDescriptor !== undefined &&
|
|
1335
|
+
!Object.prototype.hasOwnProperty.call(roleStatesDescriptor, 'value')) {
|
|
1336
|
+
throw new TypeError(`${label} roleStates must be an own data property`);
|
|
1337
|
+
}
|
|
1338
|
+
const roleStates = snapshotRoleStateStatuses(roleStatesDescriptor?.value, label, machine, stateDescriptions);
|
|
1339
|
+
const declaredRoleIds = Object.freeze([
|
|
1340
|
+
...new Set([...roleStates.values()].map(({ role }) => role)),
|
|
1341
|
+
]);
|
|
1204
1342
|
// PBRT-52: the artifact's own ControlView context projection. Nothing is
|
|
1205
1343
|
// exported by default, so an FSM context member — including one added
|
|
1206
1344
|
// after this artifact was linked — is private until named here. The two
|
|
@@ -1215,7 +1353,6 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1215
1353
|
throw new Error(`${label} controlContextFields must not name ${field}: the control view surfaces it first-class`);
|
|
1216
1354
|
}
|
|
1217
1355
|
}
|
|
1218
|
-
const resolvePlayerIdSpec = spec.resolvePlayerId;
|
|
1219
1356
|
const composePlayerPrompt = spec.composePlayerPrompt ??
|
|
1220
1357
|
((input) => defaultComposePlayerPrompt(input, spec.placeholderFields));
|
|
1221
1358
|
const composeCaptainPrompt = spec.composeCaptainPrompt ??
|
|
@@ -1241,13 +1378,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1241
1378
|
const normalizeTransitionEvent = spec.normalizeTransitionEvent ??
|
|
1242
1379
|
makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
|
|
1243
1380
|
const statusesForState = spec.statusesForState ??
|
|
1244
|
-
(
|
|
1245
|
-
? makeDefaultStatusesForState(playerStates)
|
|
1246
|
-
: legacyStatusesForState);
|
|
1381
|
+
makeDefaultStatusesForState(roleStates);
|
|
1247
1382
|
const classificationStatus = spec.classificationStatus ??
|
|
1248
|
-
(
|
|
1249
|
-
? (event) => event.type
|
|
1250
|
-
: () => undefined);
|
|
1383
|
+
((event) => event.type);
|
|
1251
1384
|
const machineInput = spec.machineInput ?? ((options) => options);
|
|
1252
1385
|
const scriptCwd = spec.scriptCwd ??
|
|
1253
1386
|
((options) => {
|
|
@@ -1269,6 +1402,20 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1269
1402
|
// ports.callPlayer / callCaptain / callJudge see the right cancellation
|
|
1270
1403
|
// source. undefined between turns; set by the public boundaries.
|
|
1271
1404
|
let activeSignal;
|
|
1405
|
+
// Immutable cancellation provenance for the active public boundary. A
|
|
1406
|
+
// nested resume widens it to include both invocation and resume signals;
|
|
1407
|
+
// mutable `activeSignal` alone cannot classify a late invocation reason.
|
|
1408
|
+
let activeAborts;
|
|
1409
|
+
// The bridge binds the provenance of a child result immediately before
|
|
1410
|
+
// its promise actor settles. The next root snapshot/error consumes this
|
|
1411
|
+
// one-shot so background settlement emissions retain their owner.
|
|
1412
|
+
let actorSettlementAborts;
|
|
1413
|
+
let actorSettlementErrorAborts;
|
|
1414
|
+
// Exact cancellation observed by an emission owned by the active
|
|
1415
|
+
// boundary. Ordinary runs settle from their signal/state; apply also
|
|
1416
|
+
// needs this phase-local evidence to fold a pre-publication failure into
|
|
1417
|
+
// its accepted receipt.
|
|
1418
|
+
let activeAbortEmission;
|
|
1272
1419
|
let activeTurnId;
|
|
1273
1420
|
let controlPlaneError;
|
|
1274
1421
|
// Previous root-machine state for the inspect-driven telemetry /
|
|
@@ -1294,8 +1441,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1294
1441
|
// acceptance records nothing, so a later call with that key may still
|
|
1295
1442
|
// execute.
|
|
1296
1443
|
const appliedReceipts = new Map();
|
|
1297
|
-
const
|
|
1298
|
-
const
|
|
1444
|
+
const privateResumeTokens = new Map();
|
|
1445
|
+
const activePlayerKeys = new Set();
|
|
1299
1446
|
const playbookCallTurnIds = new Map();
|
|
1300
1447
|
// Captain and judge work share one serialized lane (slc/link.md
|
|
1301
1448
|
// §Session lifecycle).
|
|
@@ -1306,66 +1453,169 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1306
1453
|
// Inspection callbacks enqueue a complete ordered batch synchronously;
|
|
1307
1454
|
// imperative boundaries await their queued work directly.
|
|
1308
1455
|
let emissionFailure;
|
|
1309
|
-
function
|
|
1456
|
+
function bindSession(nextSession) {
|
|
1457
|
+
const bound = snapshotPlaybookSession(nextSession);
|
|
1458
|
+
if (bound.roleBindings === undefined)
|
|
1459
|
+
return bound;
|
|
1460
|
+
const actual = Object.keys(bound.roleBindings).sort();
|
|
1461
|
+
const expected = [...declaredRoleIds].sort();
|
|
1462
|
+
const missing = expected.filter((roleId) => !actual.includes(roleId));
|
|
1463
|
+
const extra = actual.filter((roleId) => !expected.includes(roleId));
|
|
1464
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
1465
|
+
throw new TypeError(`${label} session roleBindings must cover exactly [${expected.join(', ')}]` +
|
|
1466
|
+
`${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
|
|
1467
|
+
`${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`);
|
|
1468
|
+
}
|
|
1469
|
+
return bound;
|
|
1470
|
+
}
|
|
1471
|
+
function requireRoleId(input) {
|
|
1472
|
+
const roleId = input.role;
|
|
1473
|
+
if (typeof roleId !== 'string' ||
|
|
1474
|
+
roleId.trim().length === 0 ||
|
|
1475
|
+
!declaredRoleIds.includes(roleId)) {
|
|
1476
|
+
throw new TypeError(`${label} player input role must name a declared local role`);
|
|
1477
|
+
}
|
|
1478
|
+
return roleId;
|
|
1479
|
+
}
|
|
1480
|
+
function resolvedPlayerId(roleId) {
|
|
1481
|
+
return session?.roleBindings?.[roleId]?.playerId;
|
|
1482
|
+
}
|
|
1483
|
+
function promptIdentity(roleId) {
|
|
1484
|
+
if (!declaredRoleIds.includes(roleId)) {
|
|
1485
|
+
throw new TypeError(`${label} prompt identity lookup rejected undeclared role ${roleId}`);
|
|
1486
|
+
}
|
|
1487
|
+
return session?.roleBindings?.[roleId]?.promptIdentity ?? roleId;
|
|
1488
|
+
}
|
|
1489
|
+
function composeBoundPlayerPrompt(input) {
|
|
1490
|
+
let active = true;
|
|
1491
|
+
const lookup = (roleId) => {
|
|
1492
|
+
if (!active) {
|
|
1493
|
+
throw new Error(`${label} prompt identity lookup is no longer active`);
|
|
1494
|
+
}
|
|
1495
|
+
return promptIdentity(roleId);
|
|
1496
|
+
};
|
|
1497
|
+
try {
|
|
1498
|
+
return composePlayerPrompt(input, lookup);
|
|
1499
|
+
}
|
|
1500
|
+
finally {
|
|
1501
|
+
active = false;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
function continuationKey(roleId, playerId) {
|
|
1505
|
+
return playerId ?? roleId;
|
|
1506
|
+
}
|
|
1507
|
+
function roleTokensByContinuationKey(tokens) {
|
|
1508
|
+
const byKey = new Map();
|
|
1509
|
+
for (const [roleId, token] of Object.entries(tokens)) {
|
|
1510
|
+
if (!declaredRoleIds.includes(roleId)) {
|
|
1511
|
+
throw new TypeError(`runtime role tokens contain unknown role ${roleId}`);
|
|
1512
|
+
}
|
|
1513
|
+
const key = continuationKey(roleId, resolvedPlayerId(roleId));
|
|
1514
|
+
const existing = byKey.get(key);
|
|
1515
|
+
if (existing !== undefined && existing !== token) {
|
|
1516
|
+
throw new TypeError(`runtime snapshot assigns conflicting tokens to roles bound to player ${key}`);
|
|
1517
|
+
}
|
|
1518
|
+
byKey.set(key, token);
|
|
1519
|
+
}
|
|
1520
|
+
const rolesByKey = new Map();
|
|
1521
|
+
for (const roleId of declaredRoleIds) {
|
|
1522
|
+
const key = continuationKey(roleId, resolvedPlayerId(roleId));
|
|
1523
|
+
rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
|
|
1524
|
+
}
|
|
1525
|
+
for (const [key, roles] of rolesByKey) {
|
|
1526
|
+
if (roles.length < 2)
|
|
1527
|
+
continue;
|
|
1528
|
+
const present = roles.filter((roleId) => tokens[roleId] !== undefined);
|
|
1529
|
+
if (present.length !== 0 && present.length !== roles.length) {
|
|
1530
|
+
throw new TypeError(`runtime role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return byKey;
|
|
1534
|
+
}
|
|
1535
|
+
function selectPlayerResume(roleId, playerId) {
|
|
1536
|
+
const key = continuationKey(roleId, playerId);
|
|
1310
1537
|
const selected = session?.playerSessions
|
|
1311
|
-
? session.playerSessions.select(
|
|
1312
|
-
:
|
|
1538
|
+
? session.playerSessions.select(roleId)
|
|
1539
|
+
: privateResumeTokens.get(key) ?? false;
|
|
1313
1540
|
if (selected !== false &&
|
|
1314
1541
|
(typeof selected !== 'string' || selected.trim().length === 0)) {
|
|
1315
|
-
throw new TypeError(`player session store returned an invalid resume token for ${
|
|
1542
|
+
throw new TypeError(`player session store returned an invalid resume token for role ${roleId}`);
|
|
1316
1543
|
}
|
|
1317
1544
|
return selected;
|
|
1318
1545
|
}
|
|
1319
|
-
function updatePlayerResume(playerId,
|
|
1546
|
+
function updatePlayerResume(roleId, playerId, result) {
|
|
1547
|
+
const resumeToken = result.resumeToken;
|
|
1548
|
+
if (resumeToken === undefined && result.status !== 'ok')
|
|
1549
|
+
return;
|
|
1550
|
+
const key = continuationKey(roleId, playerId);
|
|
1320
1551
|
if (session?.playerSessions) {
|
|
1321
|
-
session.playerSessions.update(
|
|
1552
|
+
session.playerSessions.update(roleId, resumeToken);
|
|
1322
1553
|
}
|
|
1323
|
-
else if (resumeToken !== undefined
|
|
1324
|
-
|
|
1554
|
+
else if (resumeToken !== undefined) {
|
|
1555
|
+
privateResumeTokens.set(key, resumeToken);
|
|
1325
1556
|
}
|
|
1326
1557
|
else {
|
|
1327
|
-
|
|
1558
|
+
privateResumeTokens.delete(key);
|
|
1328
1559
|
}
|
|
1329
1560
|
}
|
|
1330
|
-
function
|
|
1331
|
-
const raw = session?.playerSessions
|
|
1561
|
+
function snapshotRoleResumeTokens() {
|
|
1562
|
+
const raw = snapshotJsonValue(session?.playerSessions
|
|
1332
1563
|
? session.playerSessions.snapshot()
|
|
1333
|
-
: Object.fromEntries(
|
|
1564
|
+
: Object.fromEntries(declaredRoleIds.flatMap((roleId) => {
|
|
1565
|
+
const token = privateResumeTokens.get(continuationKey(roleId, resolvedPlayerId(roleId)));
|
|
1566
|
+
return token === undefined ? [] : [[roleId, token]];
|
|
1567
|
+
})), 'player session store snapshot');
|
|
1334
1568
|
if (!isPlainObject(raw)) {
|
|
1335
1569
|
throw new TypeError('player session store snapshot must be an object');
|
|
1336
1570
|
}
|
|
1337
1571
|
const detached = {};
|
|
1338
|
-
for (const [
|
|
1339
|
-
if (
|
|
1340
|
-
throw new TypeError(
|
|
1572
|
+
for (const [roleId, token] of Object.entries(raw)) {
|
|
1573
|
+
if (!declaredRoleIds.includes(roleId)) {
|
|
1574
|
+
throw new TypeError(`player session store snapshot contains unknown role ${roleId}`);
|
|
1341
1575
|
}
|
|
1342
1576
|
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
1343
|
-
throw new TypeError(`player session store snapshot token for ${
|
|
1577
|
+
throw new TypeError(`player session store snapshot token for ${roleId} must be a non-empty string`);
|
|
1344
1578
|
}
|
|
1345
|
-
detached[
|
|
1579
|
+
detached[roleId] = token;
|
|
1346
1580
|
}
|
|
1581
|
+
roleTokensByContinuationKey(detached);
|
|
1347
1582
|
return detached;
|
|
1348
1583
|
}
|
|
1349
|
-
function
|
|
1584
|
+
function restoreRoleResumeTokens(tokens) {
|
|
1585
|
+
const byKey = roleTokensByContinuationKey(tokens);
|
|
1350
1586
|
if (session?.playerSessions) {
|
|
1351
1587
|
session.playerSessions.restore(tokens);
|
|
1352
1588
|
return;
|
|
1353
1589
|
}
|
|
1354
|
-
|
|
1355
|
-
for (const [
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1590
|
+
privateResumeTokens.clear();
|
|
1591
|
+
for (const [key, token] of byKey)
|
|
1592
|
+
privateResumeTokens.set(key, token);
|
|
1593
|
+
}
|
|
1594
|
+
function enqueueEmission(fn, aborts = activeAborts) {
|
|
1595
|
+
// The emission belongs to the boundary enqueueing it: a rejection
|
|
1596
|
+
// causally identical to that boundary's abort reason is the
|
|
1597
|
+
// cancellation's own evidence — never latched, so it cannot poison a
|
|
1598
|
+
// later unrelated boundary (DR-036).
|
|
1599
|
+
const enqueueAborts = aborts;
|
|
1360
1600
|
const queued = emissionQueue.add(fn).then(() => undefined);
|
|
1361
1601
|
activeEmissionCalls.add(queued);
|
|
1362
1602
|
void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
|
|
1363
1603
|
activeEmissionCalls.delete(queued);
|
|
1364
|
-
|
|
1604
|
+
if (enqueueAborts?.isAbortReason(error)) {
|
|
1605
|
+
// Record evidence only when it also belongs to the public
|
|
1606
|
+
// boundary that is still active. A background A cancellation
|
|
1607
|
+
// racing an unrelated B boundary is forgiven under A and must
|
|
1608
|
+
// not change B's settlement.
|
|
1609
|
+
if (activeAborts?.isAbortReason(error)) {
|
|
1610
|
+
activeAbortEmission ??= error;
|
|
1611
|
+
}
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
emissionFailure ??= { error };
|
|
1365
1615
|
});
|
|
1366
1616
|
return queued;
|
|
1367
1617
|
}
|
|
1368
|
-
async function drainEmissions() {
|
|
1618
|
+
async function drainEmissions(_aborts = activeAborts) {
|
|
1369
1619
|
while (true) {
|
|
1370
1620
|
const active = [...activeEmissionCalls];
|
|
1371
1621
|
if (active.length > 0)
|
|
@@ -1378,8 +1628,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1378
1628
|
}
|
|
1379
1629
|
}
|
|
1380
1630
|
if (emissionFailure !== undefined) {
|
|
1381
|
-
const error = emissionFailure;
|
|
1631
|
+
const { error } = emissionFailure;
|
|
1382
1632
|
emissionFailure = undefined;
|
|
1633
|
+
// The failure was classified as distinct by its enqueue owner. If a
|
|
1634
|
+
// later public boundary drains it, retain that classification in the
|
|
1635
|
+
// boundary latch before throwing; its signal must not reinterpret
|
|
1636
|
+
// the same object as cancellation (DR-036 decision 2).
|
|
1637
|
+
if (activeSignal !== undefined)
|
|
1638
|
+
controlPlaneError ??= error;
|
|
1383
1639
|
throw error;
|
|
1384
1640
|
}
|
|
1385
1641
|
}
|
|
@@ -1399,7 +1655,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1399
1655
|
const currentSession = requireSession();
|
|
1400
1656
|
const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
1401
1657
|
return {
|
|
1402
|
-
schemaVersion:
|
|
1658
|
+
schemaVersion: 3,
|
|
1403
1659
|
sessionId: currentSession.sessionId,
|
|
1404
1660
|
playbookId: currentSession.playbookId,
|
|
1405
1661
|
rootSessionId: currentSession.rootSessionId,
|
|
@@ -1418,13 +1674,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1418
1674
|
payload: safePayload,
|
|
1419
1675
|
};
|
|
1420
1676
|
}
|
|
1421
|
-
function emitTrace(type, payload, position = {}) {
|
|
1677
|
+
function emitTrace(type, payload, position = {}, aborts) {
|
|
1422
1678
|
const currentSession = requireSession();
|
|
1423
1679
|
const event = createTraceEvent(type, payload, position);
|
|
1424
1680
|
return enqueueEmission(() => currentSession.ports.emitTelemetry({
|
|
1425
1681
|
topic: 'playbook.trace',
|
|
1426
1682
|
payload: event,
|
|
1427
|
-
}));
|
|
1683
|
+
}), aborts);
|
|
1428
1684
|
}
|
|
1429
1685
|
function stateIdentity(stateId) {
|
|
1430
1686
|
return stateId === undefined ? {} : { stateId };
|
|
@@ -1482,6 +1738,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1482
1738
|
};
|
|
1483
1739
|
}
|
|
1484
1740
|
async function emitCallStarted(startedType, finishedType, identity, position,
|
|
1741
|
+
// The applicable combined signal: a start-sink rejection causally
|
|
1742
|
+
// identical to its reason is the cancellation itself, not a control
|
|
1743
|
+
// error — the pair finishes `aborted` and nothing latches
|
|
1744
|
+
// (slc/link.md §Abort).
|
|
1745
|
+
signal,
|
|
1485
1746
|
// Base payload of the best-effort finish emitted when the start sink
|
|
1486
1747
|
// rejects; it defaults to the payload the start carried, which the
|
|
1487
1748
|
// player, judge, and captain pairs take as-is. The apply pair cannot:
|
|
@@ -1493,11 +1754,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1493
1754
|
await emitTrace(startedType, identity, position);
|
|
1494
1755
|
}
|
|
1495
1756
|
catch (error) {
|
|
1496
|
-
|
|
1757
|
+
if (!isAbortFailure(error, signal))
|
|
1758
|
+
controlPlaneError ??= error;
|
|
1497
1759
|
try {
|
|
1498
1760
|
await emitTrace(finishedType, {
|
|
1499
1761
|
...finishIdentity,
|
|
1500
|
-
status: 'error',
|
|
1762
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1501
1763
|
error: normalizeError(error),
|
|
1502
1764
|
}, position);
|
|
1503
1765
|
}
|
|
@@ -1508,42 +1770,44 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1508
1770
|
}
|
|
1509
1771
|
}
|
|
1510
1772
|
const boundary = {
|
|
1511
|
-
async callPlayer(input,
|
|
1773
|
+
async callPlayer(input, roleId, prompt, signal) {
|
|
1512
1774
|
// State-entry telemetry/status must precede the call they describe.
|
|
1513
1775
|
await drainEmissions();
|
|
1514
1776
|
const turnId = activeTurnId;
|
|
1515
1777
|
const stateId = input.stateId;
|
|
1778
|
+
const playerId = resolvedPlayerId(roleId);
|
|
1516
1779
|
let resume;
|
|
1517
1780
|
try {
|
|
1518
1781
|
signal.throwIfAborted();
|
|
1519
|
-
resume = selectPlayerResume(playerId);
|
|
1782
|
+
resume = selectPlayerResume(roleId, playerId);
|
|
1520
1783
|
}
|
|
1521
1784
|
catch (error) {
|
|
1522
|
-
if (!signal
|
|
1785
|
+
if (!isAbortFailure(error, signal))
|
|
1523
1786
|
controlPlaneError ??= error;
|
|
1524
1787
|
throw error;
|
|
1525
1788
|
}
|
|
1526
1789
|
const callId = `player-${++playerCallSequence}`;
|
|
1527
1790
|
const identity = {
|
|
1528
|
-
purpose: 'captain',
|
|
1529
1791
|
...stateIdentity(stateId),
|
|
1530
1792
|
sourceItem: input.sourceItem,
|
|
1531
|
-
|
|
1793
|
+
roleId,
|
|
1794
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
1532
1795
|
resume,
|
|
1533
1796
|
};
|
|
1534
1797
|
const position = {
|
|
1535
1798
|
...(turnId !== undefined ? { turnId } : {}),
|
|
1536
1799
|
callId,
|
|
1537
1800
|
};
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1801
|
+
const playerKey = continuationKey(roleId, playerId);
|
|
1802
|
+
if (activePlayerKeys.has(playerKey)) {
|
|
1803
|
+
const error = new Error(`simultaneous calls to player key ${playerKey} are not allowed`);
|
|
1804
|
+
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position, signal);
|
|
1541
1805
|
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
1542
1806
|
throw error;
|
|
1543
1807
|
}
|
|
1544
|
-
|
|
1808
|
+
activePlayerKeys.add(playerKey);
|
|
1545
1809
|
try {
|
|
1546
|
-
await
|
|
1810
|
+
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position, signal);
|
|
1547
1811
|
let rawResult;
|
|
1548
1812
|
try {
|
|
1549
1813
|
// An abort may land while the awaited started emission drains
|
|
@@ -1551,18 +1815,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1551
1815
|
// never start after abort, so settle the already-started pair
|
|
1552
1816
|
// as `aborted` through the catch below.
|
|
1553
1817
|
signal.throwIfAborted();
|
|
1554
|
-
rawResult = await requireHostPorts().callPlayer(
|
|
1818
|
+
rawResult = await requireHostPorts().callPlayer(roleId, prompt, signal, { resume });
|
|
1555
1819
|
// A host promise is not required to honor cancellation. Do not let
|
|
1556
1820
|
// a late result mutate continuity or publish a successful finish.
|
|
1557
1821
|
signal.throwIfAborted();
|
|
1558
1822
|
}
|
|
1559
1823
|
catch (error) {
|
|
1560
|
-
if (!signal
|
|
1824
|
+
if (!isAbortFailure(error, signal))
|
|
1561
1825
|
controlPlaneError ??= error;
|
|
1562
1826
|
try {
|
|
1563
1827
|
await emitTrace('player.call.finished', {
|
|
1564
1828
|
...identity,
|
|
1565
|
-
status: signal
|
|
1829
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1566
1830
|
error: normalizeError(error),
|
|
1567
1831
|
}, position);
|
|
1568
1832
|
}
|
|
@@ -1578,7 +1842,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1578
1842
|
result = validatePlayerResult(rawResult);
|
|
1579
1843
|
}
|
|
1580
1844
|
catch (error) {
|
|
1581
|
-
if (!signal
|
|
1845
|
+
if (!isAbortFailure(error, signal))
|
|
1582
1846
|
controlPlaneError ??= error;
|
|
1583
1847
|
try {
|
|
1584
1848
|
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
@@ -1589,13 +1853,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1589
1853
|
throw error;
|
|
1590
1854
|
}
|
|
1591
1855
|
try {
|
|
1592
|
-
updatePlayerResume(playerId,
|
|
1593
|
-
result.resumeToken.trim().length > 0
|
|
1594
|
-
? result.resumeToken
|
|
1595
|
-
: undefined);
|
|
1856
|
+
updatePlayerResume(roleId, playerId, result);
|
|
1596
1857
|
}
|
|
1597
1858
|
catch (error) {
|
|
1598
|
-
if (!signal
|
|
1859
|
+
if (!isAbortFailure(error, signal))
|
|
1599
1860
|
controlPlaneError ??= error;
|
|
1600
1861
|
try {
|
|
1601
1862
|
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
@@ -1621,7 +1882,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1621
1882
|
return result;
|
|
1622
1883
|
}
|
|
1623
1884
|
finally {
|
|
1624
|
-
|
|
1885
|
+
activePlayerKeys.delete(playerKey);
|
|
1625
1886
|
}
|
|
1626
1887
|
},
|
|
1627
1888
|
async callJudge(purpose, stateId, prompt, signal) {
|
|
@@ -1638,7 +1899,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1638
1899
|
...(turnId !== undefined ? { turnId } : {}),
|
|
1639
1900
|
callId,
|
|
1640
1901
|
};
|
|
1641
|
-
await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position);
|
|
1902
|
+
await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position, signal);
|
|
1642
1903
|
let reply;
|
|
1643
1904
|
try {
|
|
1644
1905
|
// An abort may land while the awaited started emission drains
|
|
@@ -1655,7 +1916,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1655
1916
|
}
|
|
1656
1917
|
await emitTrace('judge.call.finished', {
|
|
1657
1918
|
...identity,
|
|
1658
|
-
status: signal
|
|
1919
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1659
1920
|
error: normalizeError(error),
|
|
1660
1921
|
}, position);
|
|
1661
1922
|
throw error;
|
|
@@ -1702,7 +1963,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1702
1963
|
...(turnId !== undefined ? { turnId } : {}),
|
|
1703
1964
|
callId,
|
|
1704
1965
|
};
|
|
1705
|
-
await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position);
|
|
1966
|
+
await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position, signal);
|
|
1706
1967
|
let rawResult;
|
|
1707
1968
|
try {
|
|
1708
1969
|
// An abort may land while the awaited started emission drains
|
|
@@ -1724,7 +1985,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1724
1985
|
controlPlaneError ??= error;
|
|
1725
1986
|
await emitTrace('captain.call.finished', {
|
|
1726
1987
|
...identity,
|
|
1727
|
-
status: signal
|
|
1988
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1728
1989
|
error: normalizeError(error),
|
|
1729
1990
|
}, position);
|
|
1730
1991
|
throw error;
|
|
@@ -1786,20 +2047,16 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1786
2047
|
});
|
|
1787
2048
|
},
|
|
1788
2049
|
};
|
|
1789
|
-
function resolvePlayerId(input) {
|
|
1790
|
-
return resolvePlayerIdSpec
|
|
1791
|
-
? resolvePlayerIdSpec(input, boundOptions)
|
|
1792
|
-
: defaultResolvePlayerId(input);
|
|
1793
|
-
}
|
|
1794
2050
|
function playerActor(ports) {
|
|
1795
2051
|
return createPlayerBridge({
|
|
1796
|
-
|
|
1797
|
-
composePlayerPrompt,
|
|
2052
|
+
resolveRoleId: requireRoleId,
|
|
2053
|
+
composePlayerPrompt: composeBoundPlayerPrompt,
|
|
1798
2054
|
adjudication,
|
|
1799
2055
|
resumableStateIds,
|
|
1800
2056
|
}, ports, () => activeSignal, boundary, (error) => {
|
|
1801
|
-
if (!activeSignal
|
|
2057
|
+
if (activeSignal === undefined || !isAbortFailure(error, activeSignal)) {
|
|
1802
2058
|
controlPlaneError ??= error;
|
|
2059
|
+
}
|
|
1803
2060
|
});
|
|
1804
2061
|
}
|
|
1805
2062
|
// Direct-Captain actor (slc/link.md §Captain prompt composition,
|
|
@@ -1866,7 +2123,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1866
2123
|
// failure state (PBRT-47); everything else here — a drained
|
|
1867
2124
|
// emission failure, prompt composition, the port itself,
|
|
1868
2125
|
// adjudication — is control plane.
|
|
1869
|
-
if (!active
|
|
2126
|
+
if (!isAbortFailure(error, active) && !isFsmResultFailure(error)) {
|
|
1870
2127
|
controlPlaneError ??= error;
|
|
1871
2128
|
}
|
|
1872
2129
|
throw error;
|
|
@@ -1887,56 +2144,177 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1887
2144
|
const failedGuard = guards[1] ?? guards[0];
|
|
1888
2145
|
const cwd = boundScriptCwd ?? process.cwd();
|
|
1889
2146
|
const ports = runtimePorts ?? requireHostPorts();
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
2147
|
+
// slc/link.md §Script execution: an already-aborted turn spawns
|
|
2148
|
+
// nothing, and the thrown signal reason keeps the rejection
|
|
2149
|
+
// causally classified as the abort it is.
|
|
2150
|
+
active.throwIfAborted();
|
|
2151
|
+
// Abort ownership — the listener that terminates the group and
|
|
2152
|
+
// the escalation timer — spans the whole invocation body, not
|
|
2153
|
+
// just the spawn-to-close window: an abort landing during the
|
|
2154
|
+
// post-exit emission tail must still kill surviving group
|
|
2155
|
+
// members before the actor settles (slc/link.md §Script
|
|
2156
|
+
// execution). One finally releases both.
|
|
2157
|
+
let child;
|
|
2158
|
+
let killTimer;
|
|
2159
|
+
const signalGroup = (sig) => {
|
|
2160
|
+
if (child?.pid !== undefined) {
|
|
2161
|
+
try {
|
|
2162
|
+
process.kill(-child.pid, sig);
|
|
2163
|
+
}
|
|
2164
|
+
catch {
|
|
2165
|
+
// Confirmation belongs to the bounded liveness probe below:
|
|
2166
|
+
// a failed signal can mean ESRCH, EPERM, or another fault.
|
|
2167
|
+
}
|
|
1897
2168
|
}
|
|
1898
|
-
|
|
1899
|
-
|
|
2169
|
+
};
|
|
2170
|
+
// After a SIGKILL is posted, settlement waits for the group to
|
|
2171
|
+
// stop being signalable — bounded by the same grace so an
|
|
2172
|
+
// unreapable member outside the runtime's control cannot stall
|
|
2173
|
+
// the turn forever. Observed teardown is milliseconds.
|
|
2174
|
+
let groupGonePromise;
|
|
2175
|
+
const awaitGroupGone = () => {
|
|
2176
|
+
const pid = child?.pid;
|
|
2177
|
+
if (pid === undefined)
|
|
2178
|
+
return Promise.resolve();
|
|
2179
|
+
groupGonePromise ??= (async () => {
|
|
2180
|
+
const teardownFailure = (message, cause) => {
|
|
2181
|
+
const failure = new ScriptProcessGroupTeardownError(pid, message, cause);
|
|
2182
|
+
// A teardown failure is not an authored script result.
|
|
2183
|
+
// Surface it at the active public boundary even though
|
|
2184
|
+
// XState also routes the rejected actor through onError.
|
|
2185
|
+
controlPlaneError ??= failure;
|
|
2186
|
+
return failure;
|
|
2187
|
+
};
|
|
2188
|
+
const deadline = Date.now() + SCRIPT_ABORT_KILL_GRACE_MS;
|
|
2189
|
+
let lastProbeError;
|
|
2190
|
+
for (;;) {
|
|
2191
|
+
try {
|
|
2192
|
+
process.kill(-pid, 0);
|
|
2193
|
+
}
|
|
2194
|
+
catch (error) {
|
|
2195
|
+
if (isNoSuchProcess(error))
|
|
2196
|
+
return;
|
|
2197
|
+
// EPERM confirms that at least one process in the group
|
|
2198
|
+
// still exists but is not signalable by this process. Keep
|
|
2199
|
+
// waiting for ESRCH within the bound; every other probe
|
|
2200
|
+
// error makes confirmation itself unreliable immediately.
|
|
2201
|
+
if (!isProcessPermissionDenied(error)) {
|
|
2202
|
+
throw teardownFailure('the liveness probe failed', error);
|
|
2203
|
+
}
|
|
2204
|
+
lastProbeError = error;
|
|
2205
|
+
}
|
|
2206
|
+
if (Date.now() >= deadline) {
|
|
2207
|
+
throw teardownFailure(`the group remained signalable after ${SCRIPT_ABORT_KILL_GRACE_MS}ms`, lastProbeError);
|
|
2208
|
+
}
|
|
2209
|
+
await new Promise((tick) => setTimeout(tick, 5));
|
|
2210
|
+
}
|
|
2211
|
+
})();
|
|
2212
|
+
return groupGonePromise;
|
|
2213
|
+
};
|
|
2214
|
+
const onAbort = () => {
|
|
2215
|
+
signalGroup('SIGTERM');
|
|
2216
|
+
killTimer = setTimeout(() => signalGroup('SIGKILL'), SCRIPT_ABORT_KILL_GRACE_MS);
|
|
2217
|
+
};
|
|
2218
|
+
// An abort observed once the shell has already exited rejects
|
|
2219
|
+
// with the signal's reason before guard resolution and before
|
|
2220
|
+
// starting any further script emission — after killing whatever
|
|
2221
|
+
// group members outlived the shell. The shell's own exit ended
|
|
2222
|
+
// the TERM grace's purpose, so escalation is immediate here.
|
|
2223
|
+
const settleIfAborted = async () => {
|
|
2224
|
+
if (!active.aborted)
|
|
1900
2225
|
return;
|
|
2226
|
+
signalGroup('SIGKILL');
|
|
2227
|
+
await awaitGroupGone();
|
|
2228
|
+
active.throwIfAborted();
|
|
2229
|
+
};
|
|
2230
|
+
let invocationFailed = false;
|
|
2231
|
+
try {
|
|
2232
|
+
const exitStatus = await new Promise((resolve, reject) => {
|
|
2233
|
+
try {
|
|
2234
|
+
// detached: the shell leads its own POSIX process group,
|
|
2235
|
+
// so an abort can terminate the command's whole group — a
|
|
2236
|
+
// lone SIGTERM to the wrapper never reaches backgrounded
|
|
2237
|
+
// members.
|
|
2238
|
+
child = spawn('sh', ['-c', input.command], {
|
|
2239
|
+
cwd,
|
|
2240
|
+
stdio: 'ignore',
|
|
2241
|
+
detached: true,
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
catch (error) {
|
|
2245
|
+
reject(error);
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
// On abort, terminate the group and escalate — but settle
|
|
2249
|
+
// only from 'close', after the shell itself has exited, so
|
|
2250
|
+
// the turn never reports quiescence while the script still
|
|
2251
|
+
// runs (slc/link.md §Abort). SIGKILL is untrappable, so
|
|
2252
|
+
// 'close' is bounded by the grace.
|
|
2253
|
+
active.addEventListener('abort', onAbort, { once: true });
|
|
2254
|
+
child.on('error', (error) => {
|
|
2255
|
+
reject(error);
|
|
2256
|
+
});
|
|
2257
|
+
child.on('close', (code) => {
|
|
2258
|
+
if (active.aborted) {
|
|
2259
|
+
// The shell may exit cooperatively on the group SIGTERM
|
|
2260
|
+
// while a TERM-immune same-group descendant survives;
|
|
2261
|
+
// the group stays addressable while any member lives,
|
|
2262
|
+
// so kill it and await its disappearance before
|
|
2263
|
+
// settling (slc/link.md §Script execution).
|
|
2264
|
+
signalGroup('SIGKILL');
|
|
2265
|
+
void awaitGroupGone().then(() => reject(active.reason), reject);
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
resolve(typeof code === 'number' ? code : 1);
|
|
2269
|
+
});
|
|
2270
|
+
});
|
|
2271
|
+
await settleIfAborted();
|
|
2272
|
+
await ports.emitStatus(`Executed script for ${input.stateId} (exit ${exitStatus}).`);
|
|
2273
|
+
await settleIfAborted();
|
|
2274
|
+
await ports.emitTelemetry({
|
|
2275
|
+
topic: 'playbook.script',
|
|
2276
|
+
payload: {
|
|
2277
|
+
stateId: input.stateId,
|
|
2278
|
+
sourceItem: input.sourceItem,
|
|
2279
|
+
exitStatus,
|
|
2280
|
+
},
|
|
2281
|
+
});
|
|
2282
|
+
await settleIfAborted();
|
|
2283
|
+
if (exitStatus === 0) {
|
|
2284
|
+
return { guard: okGuard, exitStatus: 0 };
|
|
1901
2285
|
}
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
2286
|
+
return { guard: failedGuard, exitStatus };
|
|
2287
|
+
}
|
|
2288
|
+
catch (error) {
|
|
2289
|
+
// Preserve the invocation's authoritative exact cancellation or
|
|
2290
|
+
// distinct sink failure after teardown succeeds. The finally
|
|
2291
|
+
// block may replace it only with a distinct teardown failure
|
|
2292
|
+
// when the process group cannot be confirmed gone.
|
|
2293
|
+
invocationFailed = true;
|
|
2294
|
+
throw error;
|
|
2295
|
+
}
|
|
2296
|
+
finally {
|
|
2297
|
+
try {
|
|
2298
|
+
if (active.aborted) {
|
|
2299
|
+
signalGroup('SIGKILL');
|
|
2300
|
+
await awaitGroupGone();
|
|
2301
|
+
if (!invocationFailed)
|
|
2302
|
+
active.throwIfAborted();
|
|
2303
|
+
}
|
|
1909
2304
|
}
|
|
1910
|
-
|
|
1911
|
-
child.on('error', (error) => {
|
|
1912
|
-
active.removeEventListener('abort', onAbort);
|
|
1913
|
-
reject(error);
|
|
1914
|
-
});
|
|
1915
|
-
child.on('close', (code) => {
|
|
2305
|
+
finally {
|
|
1916
2306
|
active.removeEventListener('abort', onAbort);
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
await ports.emitStatus(`Executed script for ${input.stateId} (exit ${exitStatus}).`);
|
|
1921
|
-
await ports.emitTelemetry({
|
|
1922
|
-
topic: 'playbook.script',
|
|
1923
|
-
payload: {
|
|
1924
|
-
stateId: input.stateId,
|
|
1925
|
-
sourceItem: input.sourceItem,
|
|
1926
|
-
exitStatus,
|
|
1927
|
-
},
|
|
1928
|
-
});
|
|
1929
|
-
if (exitStatus === 0) {
|
|
1930
|
-
return { guard: okGuard, exitStatus: 0 };
|
|
2307
|
+
if (killTimer !== undefined)
|
|
2308
|
+
clearTimeout(killTimer);
|
|
2309
|
+
}
|
|
1931
2310
|
}
|
|
1932
|
-
return { guard: failedGuard, exitStatus };
|
|
1933
2311
|
});
|
|
1934
2312
|
}
|
|
1935
2313
|
const nestedBridge = createNestedPlaybookBridge({
|
|
1936
2314
|
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
1937
2315
|
getBoundarySignal: () => activeSignal,
|
|
1938
2316
|
callPlaybook: (request, signal) => requireHostPorts().callPlaybook(request, signal),
|
|
1939
|
-
emitStarted: async (event) => {
|
|
2317
|
+
emitStarted: async (event, aborts) => {
|
|
1940
2318
|
playbookCallTurnIds.set(event.callId, activeTurnId);
|
|
1941
2319
|
await emitTrace('playbook.call.started', {
|
|
1942
2320
|
stateId: event.stateId,
|
|
@@ -1945,9 +2323,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1945
2323
|
}, {
|
|
1946
2324
|
...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
|
|
1947
2325
|
callId: event.callId,
|
|
1948
|
-
});
|
|
2326
|
+
}, aborts);
|
|
1949
2327
|
},
|
|
1950
|
-
emitFinished: async (event) => {
|
|
2328
|
+
emitFinished: async (event, aborts) => {
|
|
1951
2329
|
const turnId = playbookCallTurnIds.get(event.callId);
|
|
1952
2330
|
try {
|
|
1953
2331
|
await emitTrace('playbook.call.finished', {
|
|
@@ -1958,22 +2336,34 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1958
2336
|
}, {
|
|
1959
2337
|
...(turnId !== undefined ? { turnId } : {}),
|
|
1960
2338
|
callId: event.callId,
|
|
1961
|
-
});
|
|
2339
|
+
}, aborts);
|
|
1962
2340
|
}
|
|
1963
2341
|
finally {
|
|
1964
2342
|
playbookCallTurnIds.delete(event.callId);
|
|
1965
2343
|
}
|
|
1966
2344
|
},
|
|
1967
2345
|
drain: drainEmissions,
|
|
1968
|
-
bindResumeSignal: (signal) => {
|
|
2346
|
+
bindResumeSignal: (signal, aborts) => {
|
|
1969
2347
|
activeSignal = signal;
|
|
2348
|
+
activeAborts = aborts ?? abortReasonClassifier(signal);
|
|
1970
2349
|
},
|
|
1971
|
-
|
|
1972
|
-
|
|
2350
|
+
bindActorSettlement: (aborts) => {
|
|
2351
|
+
actorSettlementAborts = aborts;
|
|
2352
|
+
},
|
|
2353
|
+
onControlPlaneError: (error, aborts) => {
|
|
2354
|
+
// The shared bridge classifies before reporting against its own
|
|
2355
|
+
// invocation-and-resume signals; classify once more here against
|
|
2356
|
+
// the boundary signal so a report that is the active boundary's
|
|
2357
|
+
// exact abort reason can never masquerade as a control error
|
|
2358
|
+
// (slc/link.md §Abort).
|
|
2359
|
+
if (!aborts?.isAbortReason(error) &&
|
|
2360
|
+
!activeAborts?.isAbortReason(error)) {
|
|
1973
2361
|
controlPlaneError ??= error;
|
|
2362
|
+
}
|
|
1974
2363
|
},
|
|
1975
|
-
onBackgroundError: (error) => {
|
|
1976
|
-
|
|
2364
|
+
onBackgroundError: (error, aborts) => {
|
|
2365
|
+
if (!aborts?.isAbortReason(error))
|
|
2366
|
+
emissionFailure ??= { error };
|
|
1977
2367
|
},
|
|
1978
2368
|
});
|
|
1979
2369
|
function tracePositionForActiveTurn() {
|
|
@@ -1987,11 +2377,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1987
2377
|
previousState: previousState ?? null,
|
|
1988
2378
|
state,
|
|
1989
2379
|
};
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
payload.pendingBossQuestion = pendingBossQuestion;
|
|
1994
|
-
}
|
|
2380
|
+
const pendingBossQuestion = pendingBossQuestionForState(state, context);
|
|
2381
|
+
if (pendingBossQuestion !== undefined) {
|
|
2382
|
+
payload.pendingBossQuestion = pendingBossQuestion;
|
|
1995
2383
|
}
|
|
1996
2384
|
if (state.stateId === 'failed') {
|
|
1997
2385
|
const lastError = normalizeErrorFull(context.lastError);
|
|
@@ -2000,7 +2388,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2000
2388
|
}
|
|
2001
2389
|
return snapshotJsonValue(payload, 'FSM telemetry payload');
|
|
2002
2390
|
}
|
|
2003
|
-
function enqueueTransitionEmission(payload, state, statuses, position) {
|
|
2391
|
+
function enqueueTransitionEmission(payload, state, statuses, position, aborts) {
|
|
2004
2392
|
const currentSession = requireSession();
|
|
2005
2393
|
const transitionTrace = createTraceEvent('fsm.transition', payload, position);
|
|
2006
2394
|
const statusEmissions = statuses.map(({ message, data }) => ({
|
|
@@ -2029,13 +2417,38 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2029
2417
|
});
|
|
2030
2418
|
await currentSession.ports.emitStatus(status.message, status.data);
|
|
2031
2419
|
}
|
|
2032
|
-
}).catch(() => undefined);
|
|
2420
|
+
}, aborts).catch(() => undefined);
|
|
2033
2421
|
}
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2422
|
+
// One classifying latch for every runtime-observed error — inspection
|
|
2423
|
+
// failures and root-actor errors alike. Outside a boundary the error
|
|
2424
|
+
// rides the emission channel, which the next boundary's (or init's)
|
|
2425
|
+
// drain throws; inside a boundary it is a control-plane error unless it
|
|
2426
|
+
// is the boundary signal's own abort reason (slc/link.md §Abort).
|
|
2427
|
+
function latchRuntimeError(error, aborts = activeAborts) {
|
|
2428
|
+
if (aborts?.isAbortReason(error))
|
|
2429
|
+
return;
|
|
2430
|
+
if (activeSignal === undefined)
|
|
2431
|
+
emissionFailure ??= { error };
|
|
2037
2432
|
else
|
|
2038
|
-
|
|
2433
|
+
controlPlaneError ??= error;
|
|
2434
|
+
}
|
|
2435
|
+
function consumeActorSettlementAborts(forSnapshot = false) {
|
|
2436
|
+
const aborts = actorSettlementAborts ?? actorSettlementErrorAborts;
|
|
2437
|
+
actorSettlementAborts = undefined;
|
|
2438
|
+
actorSettlementErrorAborts = undefined;
|
|
2439
|
+
if (forSnapshot && aborts !== undefined) {
|
|
2440
|
+
// XState can report an errored root through both its inspection
|
|
2441
|
+
// snapshot and subscriber. Keep the same provenance through that
|
|
2442
|
+
// synchronous notification only; an ordinary transition must not
|
|
2443
|
+
// lend it to a later unrelated actor error.
|
|
2444
|
+
actorSettlementErrorAborts = aborts;
|
|
2445
|
+
queueMicrotask(() => {
|
|
2446
|
+
if (actorSettlementErrorAborts === aborts) {
|
|
2447
|
+
actorSettlementErrorAborts = undefined;
|
|
2448
|
+
}
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
return aborts;
|
|
2039
2452
|
}
|
|
2040
2453
|
// PBRT-6: the single seam that stops this runtime's actor. Stopping a
|
|
2041
2454
|
// still-running actor fires one more `@xstate.snapshot` for the
|
|
@@ -2081,6 +2494,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2081
2494
|
return;
|
|
2082
2495
|
if (suppressInspectionEmissions)
|
|
2083
2496
|
return;
|
|
2497
|
+
const settlementAborts = consumeActorSettlementAborts(true);
|
|
2084
2498
|
try {
|
|
2085
2499
|
const snap = inspectionEvent.snapshot;
|
|
2086
2500
|
const state = normalizePlaybookSnapshot(snap);
|
|
@@ -2092,14 +2506,24 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2092
2506
|
{});
|
|
2093
2507
|
const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
|
|
2094
2508
|
const statuses = statusesForState(state, context, inspectionEvent.event);
|
|
2095
|
-
enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn());
|
|
2509
|
+
enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn(), settlementAborts);
|
|
2096
2510
|
priorState = state;
|
|
2097
2511
|
}
|
|
2098
2512
|
catch (error) {
|
|
2099
|
-
|
|
2513
|
+
latchRuntimeError(error, settlementAborts);
|
|
2100
2514
|
}
|
|
2101
2515
|
},
|
|
2102
2516
|
});
|
|
2517
|
+
// A synchronously-errored actor is already quiescent, so the turn's
|
|
2518
|
+
// quiescence wait never subscribes and XState would report the error
|
|
2519
|
+
// as unhandled after the boundary returns. Observe it through the
|
|
2520
|
+
// classifying latch: mid-boundary it is the control-plane error
|
|
2521
|
+
// unless it is the abort reason itself; at startup it rides the
|
|
2522
|
+
// emission channel so `init`'s own drain rejects with it and the
|
|
2523
|
+
// failed-start cleanup runs (slc/link.md §Session lifecycle).
|
|
2524
|
+
builtActor.subscribe({
|
|
2525
|
+
error: (error) => latchRuntimeError(error, consumeActorSettlementAborts()),
|
|
2526
|
+
});
|
|
2103
2527
|
return builtActor;
|
|
2104
2528
|
}
|
|
2105
2529
|
function runResultFor(outcome, error) {
|
|
@@ -2116,14 +2540,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2116
2540
|
}
|
|
2117
2541
|
if (outcome === 'terminal') {
|
|
2118
2542
|
const output = actor?.getSnapshot()?.output;
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2543
|
+
const stateDescription = stateDescriptionFor(state);
|
|
2544
|
+
return {
|
|
2545
|
+
outcome,
|
|
2546
|
+
state,
|
|
2547
|
+
...(stateDescription === undefined ? {} : { stateDescription }),
|
|
2548
|
+
...(output === undefined
|
|
2549
|
+
? {}
|
|
2550
|
+
: {
|
|
2551
|
+
output: snapshotJsonValue(output, 'terminal playbook output'),
|
|
2552
|
+
}),
|
|
2553
|
+
};
|
|
2127
2554
|
}
|
|
2128
2555
|
const failure = error ??
|
|
2129
2556
|
(outcome === 'failed'
|
|
@@ -2141,15 +2568,23 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2141
2568
|
function settledOutcome(signal) {
|
|
2142
2569
|
if (nestedBridge.getPendingCall())
|
|
2143
2570
|
return 'suspended';
|
|
2144
|
-
if (signal.aborted)
|
|
2145
|
-
return 'aborted';
|
|
2146
2571
|
const state = currentState();
|
|
2147
2572
|
if (state.status === 'error') {
|
|
2573
|
+
// An errored actor outranks a coincident abort unless the actor's
|
|
2574
|
+
// error is the abort reason itself (slc/link.md §Abort).
|
|
2148
2575
|
const actorError = actor?.getSnapshot()?.error;
|
|
2576
|
+
if (actorError !== undefined && isAbortFailure(actorError, signal)) {
|
|
2577
|
+
return 'aborted';
|
|
2578
|
+
}
|
|
2149
2579
|
throw actorError ?? new Error(`${label} actor entered error status`);
|
|
2150
2580
|
}
|
|
2581
|
+
// Terminal completion outranks a coincident abort: the work finished,
|
|
2582
|
+
// and reporting `aborted` would hide a terminal machine behind a
|
|
2583
|
+
// settlement a later turn silently restarts (slc/link.md §Abort).
|
|
2151
2584
|
if (state.status === 'done')
|
|
2152
2585
|
return 'terminal';
|
|
2586
|
+
if (signal.aborted)
|
|
2587
|
+
return 'aborted';
|
|
2153
2588
|
if (state.stateId === 'failed')
|
|
2154
2589
|
return 'failed';
|
|
2155
2590
|
return 'quiescent';
|
|
@@ -2210,8 +2645,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2210
2645
|
// The session-start error remains authoritative.
|
|
2211
2646
|
}
|
|
2212
2647
|
}
|
|
2213
|
-
|
|
2214
|
-
|
|
2648
|
+
privateResumeTokens.clear();
|
|
2649
|
+
activePlayerKeys.clear();
|
|
2215
2650
|
playbookCallTurnIds.clear();
|
|
2216
2651
|
activeEmissionCalls.clear();
|
|
2217
2652
|
emissionQueue.clear();
|
|
@@ -2222,6 +2657,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2222
2657
|
savedPorts = undefined;
|
|
2223
2658
|
runtimePorts = undefined;
|
|
2224
2659
|
activeSignal = undefined;
|
|
2660
|
+
activeAborts = undefined;
|
|
2661
|
+
actorSettlementAborts = undefined;
|
|
2662
|
+
actorSettlementErrorAborts = undefined;
|
|
2663
|
+
activeAbortEmission = undefined;
|
|
2225
2664
|
activeTurnId = undefined;
|
|
2226
2665
|
controlPlaneError = undefined;
|
|
2227
2666
|
emissionFailure = undefined;
|
|
@@ -2243,28 +2682,56 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2243
2682
|
can.call(snapshot, event) ===
|
|
2244
2683
|
true);
|
|
2245
2684
|
}
|
|
2685
|
+
// DR-034: where the artifact names the FSM context member its entry
|
|
2686
|
+
// action copies the exact Boss text into, that member of the live
|
|
2687
|
+
// snapshot is the retry payload's source. The persisted machine snapshot
|
|
2688
|
+
// carries it, so the candidate derives identically in the process that
|
|
2689
|
+
// exported the snapshot and in one that restored it, and a failure
|
|
2690
|
+
// reached after a Boss reply — whose recorded event the failure state
|
|
2691
|
+
// refuses — is recoverable too. Naming the member is the artifact's
|
|
2692
|
+
// statement that it holds the entry text: a same-named member is never
|
|
2693
|
+
// assumed, since inferring one would turn any matching context member
|
|
2694
|
+
// into a replay payload without its author saying so.
|
|
2695
|
+
// Declared and absent or empty excludes the candidate rather than
|
|
2696
|
+
// falling back to the record, which would make the action depend on the
|
|
2697
|
+
// process again — the very thing this source exists to end.
|
|
2698
|
+
function retryEventFrom(snapshot) {
|
|
2699
|
+
const entryEvent = spec.entryEvent;
|
|
2700
|
+
if (entryEvent?.contextField === undefined)
|
|
2701
|
+
return lastBossEvent;
|
|
2702
|
+
const context = snapshot?.context;
|
|
2703
|
+
const text = isPlainObject(context)
|
|
2704
|
+
? context[entryEvent.contextField]
|
|
2705
|
+
: undefined;
|
|
2706
|
+
if (typeof text !== 'string' || text.trim() === '')
|
|
2707
|
+
return undefined;
|
|
2708
|
+
return { type: entryEvent.type, [entryEvent.textField]: text };
|
|
2709
|
+
}
|
|
2246
2710
|
// The failure-state retry entry replays the recorded last classified
|
|
2247
|
-
// event with its recorded payload
|
|
2248
|
-
//
|
|
2249
|
-
//
|
|
2711
|
+
// event with its recorded payload, or the entry event the declared
|
|
2712
|
+
// context member above sources. A candidate whose event the live
|
|
2713
|
+
// snapshot does not accept — or whose payload the runtime can source
|
|
2714
|
+
// from neither — is excluded rather than completed with invented text.
|
|
2250
2715
|
function retryActionFor(snapshot, stateId) {
|
|
2251
|
-
if (stateId !== 'failed'
|
|
2716
|
+
if (stateId !== 'failed')
|
|
2252
2717
|
return undefined;
|
|
2253
|
-
|
|
2254
|
-
if (
|
|
2718
|
+
const retryEvent = retryEventFrom(snapshot);
|
|
2719
|
+
if (retryEvent === undefined)
|
|
2720
|
+
return undefined;
|
|
2721
|
+
if (!snapshotCan(snapshot, retryEvent))
|
|
2255
2722
|
return undefined;
|
|
2256
2723
|
// A recorded explicit-state-jump event names the exact state its
|
|
2257
2724
|
// replay re-enters: the root BOSS_INTERRUPT shape is a guarded
|
|
2258
2725
|
// multi-arm list keyed on `targetId`, so the first configured arm
|
|
2259
2726
|
// may label a different state than the one the recorded event
|
|
2260
2727
|
// actually resumes.
|
|
2261
|
-
const recordedTargetId =
|
|
2262
|
-
?
|
|
2728
|
+
const recordedTargetId = retryEvent.type === JUMP_EVENT_TYPE
|
|
2729
|
+
? retryEvent.targetId
|
|
2263
2730
|
: undefined;
|
|
2264
2731
|
const target = typeof recordedTargetId === 'string' &&
|
|
2265
2732
|
recordedTargetId.trim().length > 0
|
|
2266
2733
|
? recordedTargetId
|
|
2267
|
-
: firstTransitionTarget(machine, stateId,
|
|
2734
|
+
: firstTransitionTarget(machine, stateId, retryEvent.type);
|
|
2268
2735
|
// PBRT-52: a label is written from a source state description, never
|
|
2269
2736
|
// from an identifier. Falling back to the target id — or, with no
|
|
2270
2737
|
// resolvable target, to the FSM event type — makes the label *be* the
|
|
@@ -2278,10 +2745,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2278
2745
|
return undefined;
|
|
2279
2746
|
return {
|
|
2280
2747
|
action: {
|
|
2281
|
-
id: `retry:${
|
|
2748
|
+
id: `retry:${retryEvent.type}`,
|
|
2282
2749
|
label: `Retry: ${description}`,
|
|
2283
2750
|
},
|
|
2284
|
-
event:
|
|
2751
|
+
event: retryEvent,
|
|
2285
2752
|
};
|
|
2286
2753
|
}
|
|
2287
2754
|
function deriveControlActions(snapshot) {
|
|
@@ -2394,7 +2861,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2394
2861
|
if (initialized || disposed || disposalPromise !== undefined) {
|
|
2395
2862
|
throw new Error('createPlaybookRuntime.init: already initialized');
|
|
2396
2863
|
}
|
|
2397
|
-
const boundSession =
|
|
2864
|
+
const boundSession = bindSession(nextSession);
|
|
2398
2865
|
initialized = true;
|
|
2399
2866
|
let finishInitialization;
|
|
2400
2867
|
const initialization = new Promise((resolve) => {
|
|
@@ -2466,12 +2933,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2466
2933
|
const machineSnapshot = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
|
|
2467
2934
|
const context = actor.getSnapshot()
|
|
2468
2935
|
.context;
|
|
2469
|
-
const pending =
|
|
2936
|
+
const pending = pendingBossQuestionForState(state, context ?? {});
|
|
2470
2937
|
return {
|
|
2471
|
-
schemaVersion:
|
|
2938
|
+
schemaVersion: 3,
|
|
2472
2939
|
playbookId: session.playbookId,
|
|
2473
2940
|
machine: machineSnapshot,
|
|
2474
|
-
|
|
2941
|
+
roleResumeTokens: snapshotRoleResumeTokens(),
|
|
2475
2942
|
sequences: {
|
|
2476
2943
|
trace: traceSequence,
|
|
2477
2944
|
turn: turnSequence,
|
|
@@ -2488,7 +2955,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2488
2955
|
: [
|
|
2489
2956
|
{
|
|
2490
2957
|
questionId: pending.questionId,
|
|
2491
|
-
|
|
2958
|
+
asker: pending.asker,
|
|
2492
2959
|
question: pending.question,
|
|
2493
2960
|
sourceItem: pending.sourceItem,
|
|
2494
2961
|
},
|
|
@@ -2505,11 +2972,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2505
2972
|
if (initialized || disposed || disposalPromise !== undefined) {
|
|
2506
2973
|
throw new Error('createPlaybookRuntime.restore: already initialized');
|
|
2507
2974
|
}
|
|
2508
|
-
const boundSession =
|
|
2975
|
+
const boundSession = bindSession(nextSession);
|
|
2509
2976
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId, { allowSuspendedCall: true });
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2977
|
+
if (declaredActors.has('captain') &&
|
|
2978
|
+
boundSnapshot.sequences.captainCall === undefined) {
|
|
2979
|
+
throw new TypeError('runtime snapshot sequences.captainCall is required for a direct-Captain artifact');
|
|
2980
|
+
}
|
|
2981
|
+
const suspendedCall = boundSnapshot.suspendedCall;
|
|
2513
2982
|
let priorExternalPlayerTokens;
|
|
2514
2983
|
let externalStoreRestoreAttempted = false;
|
|
2515
2984
|
initialized = true;
|
|
@@ -2527,22 +2996,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2527
2996
|
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
2528
2997
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
2529
2998
|
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
2530
|
-
captainCallSequence =
|
|
2531
|
-
boundSnapshot.sequences.captainCall ??
|
|
2532
|
-
// Legacy schema-v1 snapshots predate this dedicated counter.
|
|
2533
|
-
// Every Captain call already consumed at least one trace number,
|
|
2534
|
-
// so the global trace counter is a collision-safe id floor.
|
|
2535
|
-
boundSnapshot.sequences.trace;
|
|
2999
|
+
captainCallSequence = boundSnapshot.sequences.captainCall ?? 0;
|
|
2536
3000
|
// The runtime snapshot carries no apply counter (PBRT-50); every
|
|
2537
3001
|
// apply boundary consumed trace numbers, so the persisted trace
|
|
2538
3002
|
// counter is a collision-safe id floor here too, keeping
|
|
2539
3003
|
// `apply-<n>` call ids unique across restore.
|
|
2540
3004
|
applyCallSequence = boundSnapshot.sequences.trace;
|
|
2541
3005
|
if (boundSession.playerSessions) {
|
|
2542
|
-
priorExternalPlayerTokens =
|
|
3006
|
+
priorExternalPlayerTokens = snapshotRoleResumeTokens();
|
|
2543
3007
|
externalStoreRestoreAttempted = true;
|
|
2544
3008
|
}
|
|
2545
|
-
|
|
3009
|
+
restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
|
|
2546
3010
|
nestedBridge.prepareRestore(suspendedCall);
|
|
2547
3011
|
if (suspendedCall !== undefined) {
|
|
2548
3012
|
playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
|
|
@@ -2550,8 +3014,23 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2550
3014
|
suppressInspectionEmissions = true;
|
|
2551
3015
|
actor = buildActor(runtimePorts, boundSnapshot.machine);
|
|
2552
3016
|
actor.start();
|
|
2553
|
-
|
|
2554
|
-
|
|
3017
|
+
// A start-time actor error rides the startup emission channel
|
|
3018
|
+
// (latchRuntimeError); consume both latches here so the original
|
|
3019
|
+
// error outranks the derived status check below.
|
|
3020
|
+
{
|
|
3021
|
+
const startupFailure = emissionFailure;
|
|
3022
|
+
if (controlPlaneError !== undefined ||
|
|
3023
|
+
startupFailure !== undefined) {
|
|
3024
|
+
const startupError = controlPlaneError !== undefined
|
|
3025
|
+
? controlPlaneError
|
|
3026
|
+
: startupFailure.error;
|
|
3027
|
+
controlPlaneError = undefined;
|
|
3028
|
+
if (emissionFailure === startupFailure) {
|
|
3029
|
+
emissionFailure = undefined;
|
|
3030
|
+
}
|
|
3031
|
+
throw startupError;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
2555
3034
|
const restoredState = normalizePlaybookSnapshot(actor.getSnapshot(), suspendedCall === undefined
|
|
2556
3035
|
? {}
|
|
2557
3036
|
: {
|
|
@@ -2617,7 +3096,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2617
3096
|
const state = currentState();
|
|
2618
3097
|
const context = (snapshot.context ??
|
|
2619
3098
|
{});
|
|
2620
|
-
const pending =
|
|
3099
|
+
const pending = pendingBossQuestionForState(state, context);
|
|
2621
3100
|
const lastError = normalizeErrorFull(context.lastError);
|
|
2622
3101
|
const projectedContext = projectControlContext(context);
|
|
2623
3102
|
const stateDescription = stateDescriptionFor(state);
|
|
@@ -2632,7 +3111,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2632
3111
|
: [
|
|
2633
3112
|
{
|
|
2634
3113
|
questionId: pending.questionId,
|
|
2635
|
-
|
|
3114
|
+
asker: pending.asker,
|
|
2636
3115
|
question: pending.question,
|
|
2637
3116
|
sourceItem: pending.sourceItem,
|
|
2638
3117
|
},
|
|
@@ -2685,6 +3164,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2685
3164
|
const position = { turnId, callId };
|
|
2686
3165
|
activeTurnId = turnId;
|
|
2687
3166
|
activeSignal = signal;
|
|
3167
|
+
activeAborts = abortReasonClassifier(signal);
|
|
3168
|
+
activeAbortEmission = undefined;
|
|
2688
3169
|
controlPlaneError = undefined;
|
|
2689
3170
|
// Every receipt variant is normalized and frozen where it is built,
|
|
2690
3171
|
// inside the guarded region, so the recording step below cannot
|
|
@@ -2730,9 +3211,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2730
3211
|
// final for their key. Past publication such a failure is therefore
|
|
2731
3212
|
// re-latched onto the emission channel, surfacing from the next
|
|
2732
3213
|
// public boundary's drain, and `apply` still does not throw past
|
|
2733
|
-
// acceptance (PBRT-52).
|
|
3214
|
+
// acceptance (PBRT-52). A delivery rejection causally identical to
|
|
3215
|
+
// this call's own abort reason evidences the cancellation and is
|
|
3216
|
+
// dropped — never carried to a later unrelated boundary
|
|
3217
|
+
// (slc/link.md §Abort).
|
|
2734
3218
|
const latchDeliveryFailure = (error) => {
|
|
2735
|
-
|
|
3219
|
+
if (isAbortFailure(error, signal))
|
|
3220
|
+
return;
|
|
3221
|
+
emissionFailure ??= { error };
|
|
2736
3222
|
};
|
|
2737
3223
|
try {
|
|
2738
3224
|
try {
|
|
@@ -2752,7 +3238,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2752
3238
|
key,
|
|
2753
3239
|
...receiptTracePayload({ disposition: 'rejected', reason }),
|
|
2754
3240
|
});
|
|
2755
|
-
await emitCallStarted('apply.started', 'apply.finished', identity, position, preAcceptanceFinish('apply.started trace sink rejected'));
|
|
3241
|
+
await emitCallStarted('apply.started', 'apply.finished', identity, position, signal, preAcceptanceFinish('apply.started trace sink rejected'));
|
|
2756
3242
|
// An abort may land while the awaited started emission drains
|
|
2757
3243
|
// (e.g. fired from the trace sink itself); the action must
|
|
2758
3244
|
// never execute after abort. Settle the already-started pair
|
|
@@ -2831,6 +3317,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2831
3317
|
catch (error) {
|
|
2832
3318
|
settlementError = error;
|
|
2833
3319
|
}
|
|
3320
|
+
// Exact cancellation is not a control-plane latch, but after apply
|
|
3321
|
+
// acceptance and before publication it is still settlement evidence
|
|
3322
|
+
// and therefore folds into the owed failed receipt (DR-036 §4).
|
|
3323
|
+
settlementError ??= activeAbortEmission;
|
|
2834
3324
|
// Fold before the finish emission, the last point at which the
|
|
2835
3325
|
// traced disposition and the returned one can still be made the
|
|
2836
3326
|
// same value.
|
|
@@ -2872,6 +3362,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2872
3362
|
// wedge every later public boundary behind "another runtime turn
|
|
2873
3363
|
// is active".
|
|
2874
3364
|
activeSignal = undefined;
|
|
3365
|
+
activeAborts = undefined;
|
|
3366
|
+
activeAbortEmission = undefined;
|
|
2875
3367
|
activeTurnId = undefined;
|
|
2876
3368
|
controlPlaneError = undefined;
|
|
2877
3369
|
}
|
|
@@ -2907,119 +3399,152 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2907
3399
|
const turnId = ++turnSequence;
|
|
2908
3400
|
activeTurnId = turnId;
|
|
2909
3401
|
activeSignal = signal;
|
|
3402
|
+
activeAborts = abortReasonClassifier(signal);
|
|
3403
|
+
activeAbortEmission = undefined;
|
|
2910
3404
|
controlPlaneError = undefined;
|
|
2911
3405
|
let result;
|
|
2912
3406
|
let operationError;
|
|
3407
|
+
// The boundary sentinel releases on every exit: a settlement defect
|
|
3408
|
+
// past the drain — a snapshot normalization throw inside
|
|
3409
|
+
// `runResultFor` included — must never wedge every later public
|
|
3410
|
+
// boundary and `dispose` itself behind "another runtime turn is
|
|
3411
|
+
// active". Mirrors the apply boundary's finally.
|
|
2913
3412
|
try {
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
const trimmed = text.trim();
|
|
2920
|
-
if (trimmed !== '') {
|
|
2921
|
-
const snapshot = actor.getSnapshot();
|
|
2922
|
-
const terminal = snapshot.status === 'done';
|
|
2923
|
-
const stateId = normalizePlaybookSnapshot(snapshot).stateId;
|
|
2924
|
-
if (spec.entryEvent !== undefined &&
|
|
2925
|
-
(stateId === 'ready' || terminal)) {
|
|
2926
|
-
event = {
|
|
2927
|
-
type: spec.entryEvent.type,
|
|
2928
|
-
[spec.entryEvent.textField]: text,
|
|
2929
|
-
};
|
|
2930
|
-
}
|
|
2931
|
-
else {
|
|
2932
|
-
event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
|
|
2933
|
-
}
|
|
2934
|
-
signal.throwIfAborted();
|
|
2935
|
-
}
|
|
2936
|
-
// Empty input, no-action classifier output, or invalid classifier
|
|
2937
|
-
// output — nothing to send.
|
|
2938
|
-
if (event === undefined) {
|
|
2939
|
-
result = runResultFor('no-action');
|
|
2940
|
-
}
|
|
2941
|
-
else {
|
|
2942
|
-
// 2. Optional Captain-pane classification line: the bare FSM
|
|
2943
|
-
// event type, emitted before the FSM advances.
|
|
2944
|
-
const statusLine = classificationStatus(event);
|
|
2945
|
-
if (statusLine !== undefined) {
|
|
2946
|
-
await runtimePorts.emitStatus(statusLine);
|
|
2947
|
-
}
|
|
3413
|
+
try {
|
|
3414
|
+
await emitTrace('boss.input.received', { text }, { turnId });
|
|
3415
|
+
// Record the attempted input, then refuse a boundary that entered
|
|
3416
|
+
// aborted before deterministic mapping or the classifier can
|
|
3417
|
+
// perform host-visible work (DR-036 §5).
|
|
2948
3418
|
signal.throwIfAborted();
|
|
2949
|
-
//
|
|
2950
|
-
//
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
3419
|
+
// 1. Map the Boss text to an FSM event: deterministic exact entry
|
|
3420
|
+
// where applicable (slc/link.md §Boss-event mapping), judge
|
|
3421
|
+
// classification otherwise.
|
|
3422
|
+
let event;
|
|
3423
|
+
const trimmed = text.trim();
|
|
3424
|
+
if (trimmed !== '') {
|
|
3425
|
+
const snapshot = actor.getSnapshot();
|
|
3426
|
+
const terminal = snapshot.status === 'done';
|
|
3427
|
+
const stateId = normalizePlaybookSnapshot(snapshot).stateId;
|
|
3428
|
+
// PBRT-1 / slc/link.md §Boss-event mapping: the idle entry, the
|
|
3429
|
+
// recoverable failure state, and the reconstructed terminal all
|
|
3430
|
+
// accept exactly one ordinary textual entry event, so delivered
|
|
3431
|
+
// text enters deterministically — no judge call to spend and no
|
|
3432
|
+
// classifier whim to settle a restart as no action. Every other
|
|
3433
|
+
// parked state — a reply wait or an authored mid-workflow
|
|
3434
|
+
// checkpoint — classifies under its own Boss-event contracts.
|
|
3435
|
+
if (spec.entryEvent !== undefined &&
|
|
3436
|
+
(stateId === 'ready' || stateId === 'failed' || terminal)) {
|
|
3437
|
+
event = {
|
|
3438
|
+
type: spec.entryEvent.type,
|
|
3439
|
+
[spec.entryEvent.textField]: text,
|
|
3440
|
+
};
|
|
3441
|
+
}
|
|
3442
|
+
else {
|
|
3443
|
+
event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
|
|
3444
|
+
}
|
|
3445
|
+
signal.throwIfAborted();
|
|
2957
3446
|
}
|
|
2958
|
-
//
|
|
2959
|
-
//
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
try {
|
|
2963
|
-
lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
|
|
3447
|
+
// Empty input, no-action classifier output, or invalid classifier
|
|
3448
|
+
// output — nothing to send.
|
|
3449
|
+
if (event === undefined) {
|
|
3450
|
+
result = runResultFor('no-action');
|
|
2964
3451
|
}
|
|
2965
|
-
|
|
2966
|
-
|
|
3452
|
+
else {
|
|
3453
|
+
// 2. Optional Captain-pane classification line: the bare FSM
|
|
3454
|
+
// event type, emitted before the FSM advances.
|
|
3455
|
+
const statusLine = classificationStatus(event);
|
|
3456
|
+
if (statusLine !== undefined) {
|
|
3457
|
+
await runtimePorts.emitStatus(statusLine);
|
|
3458
|
+
}
|
|
3459
|
+
signal.throwIfAborted();
|
|
3460
|
+
// 3. A final actor cannot accept new events; reconstruct only
|
|
3461
|
+
// after classification produced a real event.
|
|
3462
|
+
if (actor.getSnapshot().status === 'done') {
|
|
3463
|
+
stopActor();
|
|
3464
|
+
actor = buildActor(runtimePorts);
|
|
3465
|
+
// The replacement actor's snapshots are real state entries.
|
|
3466
|
+
suppressInspectionEmissions = false;
|
|
3467
|
+
actor.start();
|
|
3468
|
+
}
|
|
3469
|
+
// DR-029: keep the classified event with its recorded payload
|
|
3470
|
+
// as the retry-replay source. Recording is sanitizing, not
|
|
3471
|
+
// load-bearing: an override classifier's non-JSON-safe event is
|
|
3472
|
+
// simply not recorded, and the turn proceeds unchanged.
|
|
3473
|
+
try {
|
|
3474
|
+
lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
|
|
3475
|
+
}
|
|
3476
|
+
catch {
|
|
3477
|
+
lastBossEvent = undefined;
|
|
3478
|
+
}
|
|
3479
|
+
actor.send(event);
|
|
3480
|
+
await waitForPlaybookQuiescence(actor, {
|
|
3481
|
+
pendingCalls: nestedBridge,
|
|
3482
|
+
});
|
|
3483
|
+
if (controlPlaneError !== undefined)
|
|
3484
|
+
throw controlPlaneError;
|
|
3485
|
+
result = runResultFor(settledOutcome(signal));
|
|
2967
3486
|
}
|
|
2968
|
-
actor.send(event);
|
|
2969
|
-
await waitForPlaybookQuiescence(actor, {
|
|
2970
|
-
pendingCalls: nestedBridge,
|
|
2971
|
-
});
|
|
2972
|
-
if (controlPlaneError !== undefined)
|
|
2973
|
-
throw controlPlaneError;
|
|
2974
|
-
result = runResultFor(settledOutcome(signal));
|
|
2975
3487
|
}
|
|
3488
|
+
catch (error) {
|
|
3489
|
+
operationError = error;
|
|
3490
|
+
}
|
|
3491
|
+
let drainError;
|
|
3492
|
+
try {
|
|
3493
|
+
await drainEmissions();
|
|
3494
|
+
}
|
|
3495
|
+
catch (error) {
|
|
3496
|
+
drainError = error;
|
|
3497
|
+
}
|
|
3498
|
+
const latchedControlError = controlPlaneError;
|
|
3499
|
+
// A drain rejection that is the exact abort reason evidences the
|
|
3500
|
+
// cancellation, not a control-plane failure (slc/link.md §Abort).
|
|
3501
|
+
const drainAbort = drainError !== undefined && isAbortFailure(drainError, signal);
|
|
3502
|
+
const effectiveDrainError = drainAbort ? undefined : drainError;
|
|
3503
|
+
const primaryError = latchedControlError ?? effectiveDrainError ?? operationError;
|
|
3504
|
+
const abortError = latchedControlError === undefined &&
|
|
3505
|
+
effectiveDrainError === undefined &&
|
|
3506
|
+
((operationError !== undefined &&
|
|
3507
|
+
isAbortFailure(operationError, signal)) ||
|
|
3508
|
+
(drainAbort && operationError === undefined));
|
|
3509
|
+
const settlementResult = primaryError === undefined
|
|
3510
|
+
? (result ?? runResultFor('no-action'))
|
|
3511
|
+
: runResultFor(abortError ? 'aborted' : 'failed', primaryError);
|
|
3512
|
+
let settlementEmissionError;
|
|
3513
|
+
try {
|
|
3514
|
+
await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
|
|
3515
|
+
}
|
|
3516
|
+
catch (error) {
|
|
3517
|
+
settlementEmissionError = error;
|
|
3518
|
+
}
|
|
3519
|
+
try {
|
|
3520
|
+
await drainEmissions();
|
|
3521
|
+
}
|
|
3522
|
+
catch (error) {
|
|
3523
|
+
settlementEmissionError ??= error;
|
|
3524
|
+
}
|
|
3525
|
+
if (settlementEmissionError !== undefined &&
|
|
3526
|
+
isAbortFailure(settlementEmissionError, signal)) {
|
|
3527
|
+
settlementEmissionError = undefined;
|
|
3528
|
+
}
|
|
3529
|
+
const failure = controlPlaneError ??
|
|
3530
|
+
latchedControlError ??
|
|
3531
|
+
effectiveDrainError ??
|
|
3532
|
+
(abortError
|
|
3533
|
+
? (settlementEmissionError ?? operationError)
|
|
3534
|
+
: (operationError ?? settlementEmissionError));
|
|
3535
|
+
if (failure !== undefined &&
|
|
3536
|
+
!(abortError && settlementEmissionError === undefined)) {
|
|
3537
|
+
throw failure;
|
|
3538
|
+
}
|
|
3539
|
+
return settlementResult;
|
|
2976
3540
|
}
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
}
|
|
2984
|
-
catch (error) {
|
|
2985
|
-
drainError = error;
|
|
2986
|
-
}
|
|
2987
|
-
const latchedControlError = controlPlaneError;
|
|
2988
|
-
const primaryError = latchedControlError ?? drainError ?? operationError;
|
|
2989
|
-
const abortError = latchedControlError === undefined &&
|
|
2990
|
-
drainError === undefined &&
|
|
2991
|
-
operationError !== undefined &&
|
|
2992
|
-
isAbortFailure(operationError, signal);
|
|
2993
|
-
const settlementResult = primaryError === undefined
|
|
2994
|
-
? (result ?? runResultFor('no-action'))
|
|
2995
|
-
: runResultFor(abortError ? 'aborted' : 'failed', primaryError);
|
|
2996
|
-
let settlementEmissionError;
|
|
2997
|
-
try {
|
|
2998
|
-
await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
|
|
2999
|
-
}
|
|
3000
|
-
catch (error) {
|
|
3001
|
-
settlementEmissionError = error;
|
|
3002
|
-
}
|
|
3003
|
-
try {
|
|
3004
|
-
await drainEmissions();
|
|
3005
|
-
}
|
|
3006
|
-
catch (error) {
|
|
3007
|
-
settlementEmissionError ??= error;
|
|
3008
|
-
}
|
|
3009
|
-
const failure = controlPlaneError ??
|
|
3010
|
-
latchedControlError ??
|
|
3011
|
-
drainError ??
|
|
3012
|
-
(abortError
|
|
3013
|
-
? (settlementEmissionError ?? operationError)
|
|
3014
|
-
: (operationError ?? settlementEmissionError));
|
|
3015
|
-
activeSignal = undefined;
|
|
3016
|
-
activeTurnId = undefined;
|
|
3017
|
-
controlPlaneError = undefined;
|
|
3018
|
-
if (failure !== undefined &&
|
|
3019
|
-
!(abortError && settlementEmissionError === undefined)) {
|
|
3020
|
-
throw failure;
|
|
3541
|
+
finally {
|
|
3542
|
+
activeSignal = undefined;
|
|
3543
|
+
activeAborts = undefined;
|
|
3544
|
+
activeAbortEmission = undefined;
|
|
3545
|
+
activeTurnId = undefined;
|
|
3546
|
+
controlPlaneError = undefined;
|
|
3021
3547
|
}
|
|
3022
|
-
return settlementResult;
|
|
3023
3548
|
},
|
|
3024
3549
|
async resumePlaybookCall(input) {
|
|
3025
3550
|
if (!actor || !savedPorts) {
|
|
@@ -3033,41 +3558,85 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
3033
3558
|
}
|
|
3034
3559
|
activeTurnId = playbookCallTurnIds.get(input.callId);
|
|
3035
3560
|
activeSignal = input.signal;
|
|
3561
|
+
activeAborts = abortReasonClassifier(input.signal);
|
|
3562
|
+
activeAbortEmission = undefined;
|
|
3036
3563
|
controlPlaneError = undefined;
|
|
3037
|
-
|
|
3038
|
-
|
|
3564
|
+
// The boundary sentinel releases on every exit, mirroring
|
|
3565
|
+
// `handleBossInput` and the apply boundary.
|
|
3039
3566
|
try {
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3567
|
+
let result;
|
|
3568
|
+
let operationError;
|
|
3569
|
+
try {
|
|
3570
|
+
await nestedBridge.resume(input);
|
|
3571
|
+
}
|
|
3572
|
+
catch (error) {
|
|
3573
|
+
operationError = error;
|
|
3574
|
+
}
|
|
3575
|
+
try {
|
|
3576
|
+
await waitForPlaybookQuiescence(actor, {
|
|
3577
|
+
pendingCalls: nestedBridge,
|
|
3578
|
+
});
|
|
3579
|
+
result = runResultFor(settledOutcome(input.signal));
|
|
3580
|
+
}
|
|
3581
|
+
catch (error) {
|
|
3582
|
+
operationError ??= error;
|
|
3583
|
+
}
|
|
3584
|
+
// A resume refused because its signal was already aborted
|
|
3585
|
+
// delivers nothing: the pending call survives for a later
|
|
3586
|
+
// resume, and the boundary settles `aborted` rather than
|
|
3587
|
+
// advertising `suspended` (slc/link.md §Nested playbook bridge).
|
|
3588
|
+
if (operationError !== undefined &&
|
|
3589
|
+
isAbortFailure(operationError, input.signal) &&
|
|
3590
|
+
nestedBridge.getPendingCall()?.callId === input.callId) {
|
|
3591
|
+
result = {
|
|
3592
|
+
outcome: 'aborted',
|
|
3593
|
+
state: currentState(),
|
|
3594
|
+
error: normalizeError(input.signal.reason),
|
|
3595
|
+
};
|
|
3596
|
+
}
|
|
3597
|
+
let drainError;
|
|
3598
|
+
try {
|
|
3599
|
+
await drainEmissions();
|
|
3600
|
+
}
|
|
3601
|
+
catch (error) {
|
|
3602
|
+
drainError = error;
|
|
3603
|
+
}
|
|
3604
|
+
const aborts = activeAborts ?? abortReasonClassifier(input.signal);
|
|
3605
|
+
// A control-plane latch has already classified its failure as
|
|
3606
|
+
// distinct under the owning operation. Never reinterpret it
|
|
3607
|
+
// against this later resume signal (DR-036 decision 2).
|
|
3608
|
+
const controlFailure = controlPlaneError;
|
|
3609
|
+
const drainAbort = controlFailure === undefined &&
|
|
3610
|
+
drainError !== undefined &&
|
|
3611
|
+
aborts.isAbortReason(drainError);
|
|
3612
|
+
const operationAbort = controlFailure === undefined &&
|
|
3613
|
+
operationError !== undefined &&
|
|
3614
|
+
aborts.isAbortReason(operationError);
|
|
3615
|
+
const abortEvidence = activeAbortEmission ??
|
|
3616
|
+
(drainAbort ? drainError : undefined) ??
|
|
3617
|
+
(operationAbort ? operationError : undefined);
|
|
3618
|
+
const failure = controlFailure ??
|
|
3619
|
+
(drainAbort ? undefined : drainError) ??
|
|
3620
|
+
(operationAbort ? undefined : operationError);
|
|
3621
|
+
if (failure !== undefined)
|
|
3622
|
+
throw failure;
|
|
3623
|
+
if (abortEvidence !== undefined &&
|
|
3624
|
+
result?.outcome !== 'terminal' &&
|
|
3625
|
+
result?.outcome !== 'suspended') {
|
|
3626
|
+
result = runResultFor('aborted', abortEvidence);
|
|
3627
|
+
}
|
|
3628
|
+
if (result === undefined) {
|
|
3629
|
+
throw new Error('playbook resume produced no runtime result');
|
|
3630
|
+
}
|
|
3631
|
+
return result;
|
|
3060
3632
|
}
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
if (result === undefined) {
|
|
3068
|
-
throw new Error('playbook resume produced no runtime result');
|
|
3633
|
+
finally {
|
|
3634
|
+
activeSignal = undefined;
|
|
3635
|
+
activeAborts = undefined;
|
|
3636
|
+
activeAbortEmission = undefined;
|
|
3637
|
+
activeTurnId = undefined;
|
|
3638
|
+
controlPlaneError = undefined;
|
|
3069
3639
|
}
|
|
3070
|
-
return result;
|
|
3071
3640
|
},
|
|
3072
3641
|
dispose() {
|
|
3073
3642
|
if (disposalPromise !== undefined)
|
|
@@ -3126,9 +3695,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
3126
3695
|
// engagement tree. Child disposal must not erase a token its
|
|
3127
3696
|
// caller will resume. The private fallback remains runtime-owned.
|
|
3128
3697
|
if (session?.playerSessions === undefined) {
|
|
3129
|
-
|
|
3698
|
+
privateResumeTokens.clear();
|
|
3130
3699
|
}
|
|
3131
|
-
|
|
3700
|
+
activePlayerKeys.clear();
|
|
3132
3701
|
playbookCallTurnIds.clear();
|
|
3133
3702
|
activeEmissionCalls.clear();
|
|
3134
3703
|
emissionQueue.clear();
|
|
@@ -3136,6 +3705,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
3136
3705
|
appliedReceipts.clear();
|
|
3137
3706
|
actor = undefined;
|
|
3138
3707
|
activeSignal = undefined;
|
|
3708
|
+
activeAborts = undefined;
|
|
3709
|
+
actorSettlementAborts = undefined;
|
|
3710
|
+
actorSettlementErrorAborts = undefined;
|
|
3711
|
+
activeAbortEmission = undefined;
|
|
3139
3712
|
activeTurnId = undefined;
|
|
3140
3713
|
controlPlaneError = undefined;
|
|
3141
3714
|
emissionFailure = undefined;
|