@sublang/playbook 0.9.0 → 1.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 +183 -151
- package/package.json +46 -6
- package/reference/sdlc/captain.md +102 -0
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
- package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
- package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
- package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
- package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +152 -10
- package/reference/sdlc/code.playbook/bin/run.js +893 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
- package/reference/sdlc/code.playbook/code.fsm.js +334 -102
- package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
- package/reference/sdlc/code.playbook/code.gears.md +11 -10
- package/reference/sdlc/code.playbook/code.playbook.d.ts +18 -9
- package/reference/sdlc/code.playbook/code.playbook.js +1095 -200
- package/reference/sdlc/code.playbook/code.playbook.ts +1437 -256
- package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
- package/reference/sdlc/code.playbook/code.registry.js +0 -3
- package/reference/sdlc/code.playbook/code.registry.ts +0 -6
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
- package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
- package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +10 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
- package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
- package/slc/gears2fsm.md +557 -57
- package/slc/link.md +1097 -80
- package/slc/optimize.md +88 -0
- package/slc/text2gears.md +247 -5
- package/src/runtime.d.ts +145 -3
- package/src/runtime.ts +200 -2
- package/src/xstate-runtime.d.ts +94 -0
- package/src/xstate-runtime.js +1247 -0
- package/src/xstate-runtime.ts +1802 -0
|
@@ -11,12 +11,40 @@
|
|
|
11
11
|
// alias's first alternative)
|
|
12
12
|
// Boss event: free-text judge classification
|
|
13
13
|
// Adjudication: LLM-judge per state
|
|
14
|
-
// Contract: PlayerResult / PlaybookPorts /
|
|
15
|
-
// and re-exported from
|
|
14
|
+
// Contract: PlayerResult / PlaybookPorts / PlaybookSession /
|
|
15
|
+
// PlaybookRuntime imported and re-exported from
|
|
16
|
+
// @sublang/playbook/runtime
|
|
16
17
|
// (slc/link.md §Output, DR-004 Addendum A4)
|
|
18
|
+
import PQueue from 'p-queue';
|
|
17
19
|
import { createActor, fromPromise } from 'xstate';
|
|
20
|
+
import { assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
|
|
18
21
|
import { codingMachine, } from './code.fsm.js';
|
|
19
22
|
import { enumerateAwaitBossReply, enumerateCaptainStates, enumerateRootEvents, } from './code.fsm.introspect.js';
|
|
23
|
+
function snapshotCodePlaybookOptions(value) {
|
|
24
|
+
const captured = snapshotJsonValue(value, 'CODE runtime options');
|
|
25
|
+
if (captured === null ||
|
|
26
|
+
typeof captured !== 'object' ||
|
|
27
|
+
Array.isArray(captured)) {
|
|
28
|
+
throw new TypeError('CODE runtime options must be an object');
|
|
29
|
+
}
|
|
30
|
+
const record = captured;
|
|
31
|
+
const allowed = new Set([
|
|
32
|
+
'intent',
|
|
33
|
+
'irNumber',
|
|
34
|
+
'coderPlayer',
|
|
35
|
+
'reviewerPlayer',
|
|
36
|
+
'committerPlayer',
|
|
37
|
+
]);
|
|
38
|
+
for (const [key, option] of Object.entries(record)) {
|
|
39
|
+
if (!allowed.has(key)) {
|
|
40
|
+
throw new TypeError(`CODE runtime options.${key} is not declared`);
|
|
41
|
+
}
|
|
42
|
+
if (typeof option !== 'string') {
|
|
43
|
+
throw new TypeError(`CODE runtime options.${key} must be a string`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return captured;
|
|
47
|
+
}
|
|
20
48
|
const BOSS_REPLY_ERRORS = {
|
|
21
49
|
missingQuestion: "needsBossReply outcome missing 'question' field",
|
|
22
50
|
unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
|
|
@@ -36,49 +64,61 @@ const VERBATIM_PAYLOAD_FIELDS = new Set([
|
|
|
36
64
|
function normalizeErrorCompact(err) {
|
|
37
65
|
if (err === undefined || err === null)
|
|
38
66
|
return undefined;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
if (typeof err === 'object') {
|
|
43
|
-
const o = err;
|
|
44
|
-
if (typeof o.message === 'string') {
|
|
45
|
-
return {
|
|
46
|
-
name: typeof o.name === 'string' ? o.name : 'Error',
|
|
47
|
-
message: o.message,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return { name: 'Error', message: String(err) };
|
|
67
|
+
const normalized = normalizeError(err);
|
|
68
|
+
return { name: normalized.name, message: normalized.message };
|
|
52
69
|
}
|
|
53
70
|
// Normalize an unknown error value to the full `{ name, message, stack }`
|
|
54
71
|
// shape used by telemetry emissions. Returns `undefined` for nullish
|
|
55
72
|
// input. `stack` is omitted when not available on the source value.
|
|
56
73
|
function normalizeErrorFull(err) {
|
|
57
|
-
|
|
58
|
-
if (compact === undefined)
|
|
74
|
+
if (err === undefined || err === null)
|
|
59
75
|
return undefined;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (typeof stack === 'string') {
|
|
66
|
-
return { ...compact, stack };
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return compact;
|
|
76
|
+
return normalizeError(err);
|
|
77
|
+
}
|
|
78
|
+
function isAbortFailure(error, signal) {
|
|
79
|
+
return (signal.aborted &&
|
|
80
|
+
(error === signal.reason || normalizeError(error).name === 'AbortError'));
|
|
70
81
|
}
|
|
71
82
|
// Normalize any `error` field inside a telemetry event so failed
|
|
72
83
|
// transitions don't leak raw Error instances through the channel.
|
|
73
84
|
function normalizeEventForTelemetry(event) {
|
|
74
|
-
if (event ===
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
if (event === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
return normalizeEventValue(event, 'FSM event', new Set());
|
|
88
|
+
}
|
|
89
|
+
function normalizeEventValue(value, path, ancestors) {
|
|
90
|
+
if (Array.isArray(value))
|
|
91
|
+
return snapshotJsonValue(value, path);
|
|
92
|
+
if (value === null || typeof value !== 'object') {
|
|
93
|
+
return snapshotJsonValue(value, path);
|
|
94
|
+
}
|
|
95
|
+
if (ancestors.has(value)) {
|
|
96
|
+
throw new TypeError(`${path} must not contain a JSON cycle`);
|
|
97
|
+
}
|
|
98
|
+
const prototype = Object.getPrototypeOf(value);
|
|
99
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
100
|
+
return snapshotJsonValue(value, path);
|
|
101
|
+
}
|
|
102
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
103
|
+
return snapshotJsonValue(value, path);
|
|
104
|
+
}
|
|
105
|
+
const nextAncestors = new Set(ancestors).add(value);
|
|
106
|
+
const normalized = {};
|
|
107
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
108
|
+
if (!descriptor.enumerable) {
|
|
109
|
+
throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
|
|
110
|
+
}
|
|
111
|
+
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
112
|
+
throw new TypeError(`${path}.${key} must be a JSON data property`);
|
|
113
|
+
}
|
|
114
|
+
if (descriptor.value === undefined)
|
|
115
|
+
continue;
|
|
116
|
+
normalized[key] =
|
|
117
|
+
key === 'error'
|
|
118
|
+
? snapshotJsonValue(normalizeError(descriptor.value), `${path}.error`)
|
|
119
|
+
: normalizeEventValue(descriptor.value, `${path}.${key}`, nextAncestors);
|
|
120
|
+
}
|
|
121
|
+
return snapshotJsonValue(normalized, path);
|
|
82
122
|
}
|
|
83
123
|
// Internal capabilities (DR-004 §10). Each ships with its final
|
|
84
124
|
// signature; behavior lands in the per-capability task noted by the
|
|
@@ -169,9 +209,11 @@ function resolvePlayerId(input) {
|
|
|
169
209
|
// object once the chosen guard is one of the input.result keys.
|
|
170
210
|
// Adjudicator failures (malformed JSON, missing/unknown guard) are
|
|
171
211
|
// control-plane errors and propagate via throw per slc/link.md.
|
|
172
|
-
async function adjudicate(input, finalText, ports, signal) {
|
|
212
|
+
async function adjudicate(input, finalText, ports, signal, boundary) {
|
|
173
213
|
const prompt = buildJudgePrompt(input, finalText);
|
|
174
|
-
const raw =
|
|
214
|
+
const raw = boundary
|
|
215
|
+
? await boundary.callJudge('player-output-adjudication', input.stateId, prompt, signal)
|
|
216
|
+
: await ports.callJudge(prompt, signal);
|
|
175
217
|
const parsed = parseJudgeJson(raw);
|
|
176
218
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
177
219
|
throw new Error('adjudicate: judge response is not a JSON object');
|
|
@@ -384,19 +426,22 @@ function dropTrailingComma(out) {
|
|
|
384
426
|
// classifier is state-aware so awaitBossReply can distinguish a direct
|
|
385
427
|
// answer (BOSS_REPLY) from a fresh directive that abandons the pending
|
|
386
428
|
// question through the FSM's existing transitions.
|
|
387
|
-
async function classifyBossText(text, ports, signal, snapshotOrState) {
|
|
429
|
+
async function classifyBossText(text, ports, signal, snapshotOrState, boundary) {
|
|
388
430
|
const trimmed = text.trim();
|
|
389
431
|
if (trimmed === '')
|
|
390
432
|
return undefined;
|
|
391
|
-
return classifyWithLlm(text, ports, signal, snapshotOrState);
|
|
433
|
+
return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
|
|
392
434
|
}
|
|
393
435
|
const rootEvents = enumerateRootEvents(codingMachine);
|
|
394
436
|
const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
|
|
395
437
|
const bossInterruptTargetIds = new Set(bossInterruptTargets.map((target) => target.stateId));
|
|
396
|
-
async function classifyWithLlm(text, ports, signal, snapshotOrState) {
|
|
438
|
+
async function classifyWithLlm(text, ports, signal, snapshotOrState, boundary) {
|
|
397
439
|
const state = classifierState(snapshotOrState);
|
|
398
440
|
const prompt = buildClassifierPrompt(text, state);
|
|
399
|
-
const
|
|
441
|
+
const stateId = typeof state.value === 'string' ? state.value : undefined;
|
|
442
|
+
const raw = boundary
|
|
443
|
+
? await boundary.callJudge('boss-input-classification', stateId, prompt, signal)
|
|
444
|
+
: await ports.callJudge(prompt, signal);
|
|
400
445
|
let parsed;
|
|
401
446
|
try {
|
|
402
447
|
parsed = parseJudgeJson(raw);
|
|
@@ -479,7 +524,26 @@ async function classifyWithLlm(text, ports, signal, snapshotOrState) {
|
|
|
479
524
|
await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
|
|
480
525
|
return undefined;
|
|
481
526
|
}
|
|
482
|
-
|
|
527
|
+
const pending = pendingBossQuestionFromContext(state.context);
|
|
528
|
+
if (!pending) {
|
|
529
|
+
await ports.emitStatus('Classifier returned BOSS_REPLY without a pending question');
|
|
530
|
+
return undefined;
|
|
531
|
+
}
|
|
532
|
+
if (payload.questionId !== undefined &&
|
|
533
|
+
typeof payload.questionId !== 'string') {
|
|
534
|
+
await ports.emitStatus('Classifier supplied a non-string questionId for BOSS_REPLY');
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
if (typeof payload.questionId === 'string' &&
|
|
538
|
+
payload.questionId !== pending.questionId) {
|
|
539
|
+
await ports.emitStatus(`Classifier supplied unknown questionId for BOSS_REPLY: ${payload.questionId}`);
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
type: 'BOSS_REPLY',
|
|
544
|
+
answer: payload.answer,
|
|
545
|
+
questionId: pending.questionId,
|
|
546
|
+
};
|
|
483
547
|
}
|
|
484
548
|
default:
|
|
485
549
|
await ports.emitStatus(`Classifier returned unknown event type: ${eventType}`);
|
|
@@ -503,7 +567,9 @@ function classifierState(snapshotOrState) {
|
|
|
503
567
|
return { value: snapshotOrState, context: {} };
|
|
504
568
|
}
|
|
505
569
|
function buildClassifierPrompt(text, state) {
|
|
506
|
-
const currentState = typeof state.value === 'string'
|
|
570
|
+
const currentState = typeof state.value === 'string'
|
|
571
|
+
? state.value
|
|
572
|
+
: JSON.stringify(state.value ?? null);
|
|
507
573
|
const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
|
|
508
574
|
const lines = [
|
|
509
575
|
'Classify the following Boss message into exactly one of these events.',
|
|
@@ -513,14 +579,14 @@ function buildClassifierPrompt(text, state) {
|
|
|
513
579
|
`Current state: ${currentState}`,
|
|
514
580
|
];
|
|
515
581
|
if (pendingBossQuestion !== undefined) {
|
|
516
|
-
lines.push(`Pending
|
|
582
|
+
lines.push(`Pending question id: ${pendingBossQuestion.questionId}`, `Pending asking player: ${pendingBossQuestion.player}`, `Pending Boss question: ${pendingBossQuestion.question}`);
|
|
517
583
|
}
|
|
518
584
|
lines.push('', 'Events:', '- START_CODING: payload { intent: "<free-form goal>" }', '- CONTINUE_IR: payload { irNumber: "<number>" }', '- SUMMARIZE_IR: payload { irNumber: "<number>" }', '- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }', ' targetId must be one of these jumpable states:');
|
|
519
585
|
for (const target of bossInterruptTargets) {
|
|
520
586
|
lines.push(` - ${target.stateId}: ${target.description}`);
|
|
521
587
|
}
|
|
522
588
|
if (currentState === 'awaitBossReply') {
|
|
523
|
-
lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
|
|
589
|
+
lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }');
|
|
524
590
|
}
|
|
525
591
|
else {
|
|
526
592
|
lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
|
|
@@ -528,35 +594,45 @@ function buildClassifierPrompt(text, state) {
|
|
|
528
594
|
lines.push('', 'Boss message:', '```', text, '```');
|
|
529
595
|
return lines.join('\n');
|
|
530
596
|
}
|
|
531
|
-
//
|
|
532
|
-
// codingMachine invokes from every
|
|
597
|
+
// Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
|
|
598
|
+
// codingMachine invokes from every player-invoking state. Per turn:
|
|
533
599
|
// resolve playerId, compose the player prompt, await
|
|
534
600
|
// ports.callPlayer, adjudicate the finalText. PlayerResult status of
|
|
535
601
|
// 'aborted' or 'error' throws so XState routes via onError → #failed
|
|
536
602
|
// (the single fail-stop sink for both Captain errors and player
|
|
537
|
-
// failures).
|
|
603
|
+
// failures). Captain remains the orchestrator and adjudicator; it is not
|
|
604
|
+
// encoded as the delegated FSM actor.
|
|
538
605
|
//
|
|
539
606
|
// `getActiveSignal` is the runtime's hook for flowing the Boss's
|
|
540
607
|
// `handleBossInput.signal` into the host port calls — fromPromise
|
|
541
608
|
// hands the bridge XState's actor-scoped signal, which only fires
|
|
542
609
|
// on actor.stop(), not on Boss abort. When omitted (e.g. direct
|
|
543
610
|
// captainBridge tests), the bridge falls back to XState's signal.
|
|
544
|
-
function captainBridge(ports, getActiveSignal) {
|
|
611
|
+
function captainBridge(ports, getActiveSignal, boundary, onControlPlaneError) {
|
|
545
612
|
return fromPromise(async ({ input, signal }) => {
|
|
546
|
-
const activeSignal = getActiveSignal?.()
|
|
613
|
+
const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
|
|
547
614
|
const playerId = resolvePlayerId(input);
|
|
548
615
|
const prompt = composePlayerPrompt(input);
|
|
549
|
-
const result =
|
|
616
|
+
const result = boundary
|
|
617
|
+
? await boundary.callPlayer(input, playerId, prompt, activeSignal)
|
|
618
|
+
: await ports.callPlayer(playerId, prompt, activeSignal, {
|
|
619
|
+
resume: false,
|
|
620
|
+
});
|
|
550
621
|
if (result.status !== 'ok') {
|
|
551
|
-
throw new Error(result.error ??
|
|
552
|
-
`captainBridge: callPlayer status "${result.status}"`);
|
|
622
|
+
throw new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
|
|
553
623
|
}
|
|
554
624
|
if (result.finalText === undefined) {
|
|
555
625
|
throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
|
|
556
626
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
627
|
+
try {
|
|
628
|
+
const output = await adjudicate(input, result.finalText, ports, activeSignal, boundary);
|
|
629
|
+
validateBossReplyOutput(input, output);
|
|
630
|
+
return output;
|
|
631
|
+
}
|
|
632
|
+
catch (error) {
|
|
633
|
+
onControlPlaneError?.(error);
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
560
636
|
});
|
|
561
637
|
}
|
|
562
638
|
// Captain pane display — PBRT-3 / PBRT-14.
|
|
@@ -565,7 +641,7 @@ function captainBridge(ports, getActiveSignal) {
|
|
|
565
641
|
// glance:
|
|
566
642
|
// (no glyph) bare FSM event type — host renders as captain speech
|
|
567
643
|
// (e.g., `captain> START_CODING`)
|
|
568
|
-
// ⤷
|
|
644
|
+
// ⤷ player-invoking state entry: `<Player>: <label>`
|
|
569
645
|
// → transition guard outcome (`· field=N` tallies
|
|
570
646
|
// appended); the host presenter owns any visual
|
|
571
647
|
// nesting under the preceding ⤷ entry
|
|
@@ -602,17 +678,13 @@ const stateMetadata = (() => {
|
|
|
602
678
|
for (const s of enumerateCaptainStates(codingMachine)) {
|
|
603
679
|
const label = STATE_LABELS[s.stateId];
|
|
604
680
|
if (!label) {
|
|
605
|
-
throw new Error(`code.playbook.ts: STATE_LABELS missing entry for
|
|
681
|
+
throw new Error(`code.playbook.ts: STATE_LABELS missing entry for player-invoking state '${s.stateId}'`);
|
|
606
682
|
}
|
|
607
683
|
const input = s.getInput({});
|
|
608
684
|
m.set(s.stateId, { player: input.player, sourceItem: s.sourceItem, label });
|
|
609
685
|
}
|
|
610
686
|
return m;
|
|
611
687
|
})();
|
|
612
|
-
const stateIdBySourceItem = new Map([...stateMetadata.entries()].map(([stateId, meta]) => [
|
|
613
|
-
meta.sourceItem,
|
|
614
|
-
stateId,
|
|
615
|
-
]));
|
|
616
688
|
const registeredResumableStateIds = new Set(enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map((transition) => transition.target));
|
|
617
689
|
function validateBossReplyOutput(input, output) {
|
|
618
690
|
if (output.guard !== 'needsBossReply')
|
|
@@ -620,9 +692,9 @@ function validateBossReplyOutput(input, output) {
|
|
|
620
692
|
if (typeof output.question !== 'string') {
|
|
621
693
|
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
622
694
|
}
|
|
623
|
-
const stateId =
|
|
624
|
-
if (
|
|
625
|
-
throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId
|
|
695
|
+
const stateId = input.stateId;
|
|
696
|
+
if (!registeredResumableStateIds.has(stateId)) {
|
|
697
|
+
throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId));
|
|
626
698
|
}
|
|
627
699
|
}
|
|
628
700
|
const QUIESCENT_STATES = new Set([
|
|
@@ -635,11 +707,8 @@ const QUIESCENT_STATES = new Set([
|
|
|
635
707
|
// pane per PBRT-3: the readline returning to its `boss>` prompt is
|
|
636
708
|
// the implicit "turn over" signal, so a `◆ ready` / `◆ done`
|
|
637
709
|
// tombstone is redundant.
|
|
638
|
-
const SUPPRESSED_ENTRY_STATES = new Set([
|
|
639
|
-
|
|
640
|
-
'done',
|
|
641
|
-
]);
|
|
642
|
-
// Captain-pane surface (PBRT-3): every captain-invoking state plus
|
|
710
|
+
const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
|
|
711
|
+
// Captain-pane surface (PBRT-3): every player-invoking state plus
|
|
643
712
|
// the quiescent states whose entry still carries information
|
|
644
713
|
// (failure with `lastError`, awaitBossReply with the pending
|
|
645
714
|
// question). `ready` and `done` flow through the inspect handler
|
|
@@ -656,12 +725,14 @@ function pendingBossQuestionFromContext(context) {
|
|
|
656
725
|
}
|
|
657
726
|
const candidate = pending;
|
|
658
727
|
if (typeof candidate.resumeStateId !== 'string' ||
|
|
728
|
+
typeof candidate.questionId !== 'string' ||
|
|
659
729
|
typeof candidate.sourceItem !== 'string' ||
|
|
660
730
|
typeof candidate.player !== 'string' ||
|
|
661
731
|
typeof candidate.question !== 'string') {
|
|
662
732
|
return undefined;
|
|
663
733
|
}
|
|
664
734
|
return {
|
|
735
|
+
questionId: candidate.questionId,
|
|
665
736
|
resumeStateId: candidate.resumeStateId,
|
|
666
737
|
sourceItem: candidate.sourceItem,
|
|
667
738
|
player: candidate.player,
|
|
@@ -726,9 +797,9 @@ function formatClassification(eventType) {
|
|
|
726
797
|
}
|
|
727
798
|
function stateTelemetryPayload(from, to, event, context) {
|
|
728
799
|
const payload = {
|
|
729
|
-
from,
|
|
800
|
+
from: from ?? null,
|
|
730
801
|
to,
|
|
731
|
-
event: normalizeEventForTelemetry(event),
|
|
802
|
+
event: normalizeEventForTelemetry(event) ?? null,
|
|
732
803
|
};
|
|
733
804
|
if (to === 'awaitBossReply') {
|
|
734
805
|
const pendingBossQuestion = pendingBossQuestionFromContext(context);
|
|
@@ -744,6 +815,27 @@ function stateTelemetryPayload(from, to, event, context) {
|
|
|
744
815
|
}
|
|
745
816
|
return payload;
|
|
746
817
|
}
|
|
818
|
+
function structuredStateTelemetryPayload(previousState, state, event, context) {
|
|
819
|
+
const payload = {
|
|
820
|
+
from: previousState?.value ?? null,
|
|
821
|
+
to: state.value,
|
|
822
|
+
event: normalizeEventForTelemetry(event) ?? null,
|
|
823
|
+
previousState: previousState ?? null,
|
|
824
|
+
state,
|
|
825
|
+
};
|
|
826
|
+
if (state.stateId === 'awaitBossReply') {
|
|
827
|
+
const pendingBossQuestion = pendingBossQuestionFromContext(context);
|
|
828
|
+
if (pendingBossQuestion !== undefined) {
|
|
829
|
+
payload.pendingBossQuestion = pendingBossQuestion;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (state.stateId === 'failed') {
|
|
833
|
+
const lastError = normalizeErrorFull(context.lastError);
|
|
834
|
+
if (lastError !== undefined)
|
|
835
|
+
payload.lastError = lastError;
|
|
836
|
+
}
|
|
837
|
+
return snapshotJsonValue(payload, 'FSM telemetry payload');
|
|
838
|
+
}
|
|
747
839
|
// Internal export surface for tests. Not part of the stable public API;
|
|
748
840
|
// the leading underscore signals "subject to change." Each member is
|
|
749
841
|
// referenced here so `noUnusedLocals` stays clean while later tasks
|
|
@@ -769,163 +861,978 @@ export const _internal = {
|
|
|
769
861
|
VERBATIM_PAYLOAD_FIELDS,
|
|
770
862
|
};
|
|
771
863
|
export default function createPlaybookRuntime(options) {
|
|
864
|
+
const boundOptions = snapshotCodePlaybookOptions(options);
|
|
772
865
|
let actor;
|
|
866
|
+
let session;
|
|
867
|
+
let initialized = false;
|
|
868
|
+
let initInFlight;
|
|
869
|
+
let disposalPromise;
|
|
870
|
+
let disposed = false;
|
|
773
871
|
let savedPorts;
|
|
872
|
+
let runtimePorts;
|
|
774
873
|
// The Boss's per-turn AbortSignal, surfaced to captainBridge so
|
|
775
874
|
// ports.callPlayer / callJudge see the right cancellation source.
|
|
776
875
|
// null between turns; set by handleBossInput.
|
|
777
876
|
let activeSignal;
|
|
877
|
+
let activeTurnId;
|
|
878
|
+
let controlPlaneError;
|
|
778
879
|
// Previous root-machine state for the inspect-driven telemetry /
|
|
779
880
|
// status emitter. undefined before the first inspect firing.
|
|
780
881
|
let priorState;
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
let
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
882
|
+
let suppressInspectionEmissions = false;
|
|
883
|
+
let traceSequence = 0;
|
|
884
|
+
let turnSequence = 0;
|
|
885
|
+
let judgeCallSequence = 0;
|
|
886
|
+
let playerCallSequence = 0;
|
|
887
|
+
let playbookCallSequence = 0;
|
|
888
|
+
const playerResumeTokens = new Map();
|
|
889
|
+
const activePlayerIds = new Set();
|
|
890
|
+
const playbookCallTurnIds = new Map();
|
|
891
|
+
const judgeQueue = new PQueue({ concurrency: 1 });
|
|
892
|
+
const emissionQueue = new PQueue({ concurrency: 1 });
|
|
893
|
+
const activeEmissionCalls = new Set();
|
|
894
|
+
// All trace, state-telemetry, and status work shares this one queue.
|
|
895
|
+
// Inspection callbacks enqueue a complete ordered batch synchronously;
|
|
896
|
+
// imperative boundaries await their queued work directly.
|
|
897
|
+
let emissionFailure;
|
|
898
|
+
function enqueueEmission(fn) {
|
|
899
|
+
const queued = emissionQueue.add(fn).then(() => undefined);
|
|
900
|
+
activeEmissionCalls.add(queued);
|
|
901
|
+
void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
|
|
902
|
+
activeEmissionCalls.delete(queued);
|
|
903
|
+
emissionFailure ??= error;
|
|
904
|
+
});
|
|
905
|
+
return queued;
|
|
906
|
+
}
|
|
907
|
+
async function drainEmissions() {
|
|
908
|
+
while (true) {
|
|
909
|
+
const active = [...activeEmissionCalls];
|
|
910
|
+
if (active.length > 0)
|
|
911
|
+
await Promise.allSettled(active);
|
|
912
|
+
await emissionQueue.onIdle();
|
|
913
|
+
if (activeEmissionCalls.size === 0 &&
|
|
914
|
+
emissionQueue.size === 0 &&
|
|
915
|
+
emissionQueue.pending === 0) {
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (emissionFailure !== undefined) {
|
|
920
|
+
const error = emissionFailure;
|
|
921
|
+
emissionFailure = undefined;
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function requireSession() {
|
|
926
|
+
if (!session) {
|
|
927
|
+
throw new Error('createPlaybookRuntime: init must be called first');
|
|
928
|
+
}
|
|
929
|
+
return session;
|
|
930
|
+
}
|
|
931
|
+
function requireHostPorts() {
|
|
932
|
+
if (!savedPorts) {
|
|
933
|
+
throw new Error('createPlaybookRuntime: init must be called first');
|
|
934
|
+
}
|
|
935
|
+
return savedPorts;
|
|
936
|
+
}
|
|
937
|
+
function createTraceEvent(type, payload, position = {}) {
|
|
938
|
+
const currentSession = requireSession();
|
|
939
|
+
const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
940
|
+
return {
|
|
941
|
+
schemaVersion: 2,
|
|
942
|
+
sessionId: currentSession.sessionId,
|
|
943
|
+
playbookId: currentSession.playbookId,
|
|
944
|
+
rootSessionId: currentSession.rootSessionId,
|
|
945
|
+
...(currentSession.parentSessionId !== undefined
|
|
946
|
+
? { parentSessionId: currentSession.parentSessionId }
|
|
947
|
+
: {}),
|
|
948
|
+
...(currentSession.parentCallId !== undefined
|
|
949
|
+
? { parentCallId: currentSession.parentCallId }
|
|
950
|
+
: {}),
|
|
951
|
+
depth: currentSession.depth,
|
|
952
|
+
sequence: ++traceSequence,
|
|
953
|
+
timestamp: Date.now(),
|
|
954
|
+
type,
|
|
955
|
+
...(position.turnId !== undefined ? { turnId: position.turnId } : {}),
|
|
956
|
+
...(position.callId !== undefined ? { callId: position.callId } : {}),
|
|
957
|
+
payload: safePayload,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
function emitTrace(type, payload, position = {}) {
|
|
961
|
+
const currentSession = requireSession();
|
|
962
|
+
const event = createTraceEvent(type, payload, position);
|
|
963
|
+
return enqueueEmission(() => currentSession.ports.emitTelemetry({
|
|
964
|
+
topic: 'playbook.trace',
|
|
965
|
+
payload: event,
|
|
966
|
+
}));
|
|
967
|
+
}
|
|
968
|
+
function stateIdentity(stateId) {
|
|
969
|
+
return stateId === undefined ? {} : { stateId };
|
|
970
|
+
}
|
|
971
|
+
function currentState() {
|
|
972
|
+
if (!actor) {
|
|
973
|
+
throw new Error('createPlaybookRuntime: actor is not initialized');
|
|
974
|
+
}
|
|
975
|
+
return normalizePlaybookSnapshot(actor.getSnapshot(), {
|
|
976
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
function stateTracePayload(state = currentState()) {
|
|
980
|
+
return {
|
|
981
|
+
state,
|
|
982
|
+
...stateIdentity(state.stateId),
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
function createRuntimePorts(hostPorts) {
|
|
986
|
+
return {
|
|
987
|
+
callPlayer: (playerId, prompt, signal, callOptions) => hostPorts.callPlayer(playerId, prompt, signal, callOptions),
|
|
988
|
+
callCaptain: (prompt, signal, callOptions) => hostPorts.callCaptain(prompt, signal, callOptions),
|
|
989
|
+
callJudge: (prompt, signal) => hostPorts.callJudge(prompt, signal),
|
|
990
|
+
callPlaybook: (request, signal) => hostPorts.callPlaybook(request, signal),
|
|
991
|
+
emitStatus: (message, data) => {
|
|
992
|
+
const descriptor = actor ? currentState() : undefined;
|
|
993
|
+
const safeData = data === undefined
|
|
994
|
+
? undefined
|
|
995
|
+
: snapshotJsonValue(data, 'status data');
|
|
996
|
+
const trace = createTraceEvent('status.emitted', {
|
|
997
|
+
message,
|
|
998
|
+
...(safeData !== undefined ? { data: safeData } : {}),
|
|
999
|
+
...(descriptor !== undefined
|
|
1000
|
+
? {
|
|
1001
|
+
state: descriptor,
|
|
1002
|
+
...stateIdentity(descriptor.stateId),
|
|
1003
|
+
}
|
|
1004
|
+
: {}),
|
|
1005
|
+
}, activeTurnId !== undefined ? { turnId: activeTurnId } : {});
|
|
1006
|
+
return enqueueEmission(async () => {
|
|
1007
|
+
await hostPorts.emitTelemetry({
|
|
1008
|
+
topic: 'playbook.trace',
|
|
1009
|
+
payload: trace,
|
|
1010
|
+
});
|
|
1011
|
+
await hostPorts.emitStatus(message, safeData);
|
|
1012
|
+
});
|
|
1013
|
+
},
|
|
1014
|
+
emitTelemetry: (event) => {
|
|
1015
|
+
if (typeof event.topic !== 'string' || event.topic.length === 0) {
|
|
1016
|
+
throw new TypeError('telemetry topic must be a non-empty string');
|
|
1017
|
+
}
|
|
1018
|
+
const payload = snapshotJsonValue(event.payload, 'telemetry payload');
|
|
1019
|
+
return enqueueEmission(() => hostPorts.emitTelemetry({ topic: event.topic, payload }));
|
|
1020
|
+
},
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
async function emitCallStarted(startedType, finishedType, identity, position) {
|
|
1024
|
+
try {
|
|
1025
|
+
await emitTrace(startedType, identity, position);
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
controlPlaneError ??= error;
|
|
1029
|
+
try {
|
|
1030
|
+
await emitTrace(finishedType, { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
// Preserve the start failure after one best-effort finish attempt.
|
|
1034
|
+
}
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
const boundary = {
|
|
1039
|
+
async callPlayer(input, playerId, prompt, signal) {
|
|
1040
|
+
// State-entry telemetry/status must precede the call they describe.
|
|
1041
|
+
await drainEmissions();
|
|
1042
|
+
const turnId = activeTurnId;
|
|
1043
|
+
const callId = `player-${++playerCallSequence}`;
|
|
1044
|
+
const stateId = input.stateId;
|
|
1045
|
+
const resume = playerResumeTokens.get(playerId) ?? false;
|
|
1046
|
+
const identity = {
|
|
1047
|
+
purpose: 'captain',
|
|
1048
|
+
...stateIdentity(stateId),
|
|
1049
|
+
sourceItem: input.sourceItem,
|
|
1050
|
+
playerId,
|
|
1051
|
+
resume,
|
|
1052
|
+
};
|
|
1053
|
+
if (activePlayerIds.has(playerId)) {
|
|
1054
|
+
const error = new Error(`simultaneous calls to resolved player ${playerId} are not allowed`);
|
|
1055
|
+
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, {
|
|
1056
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1057
|
+
callId,
|
|
1058
|
+
});
|
|
1059
|
+
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, {
|
|
1060
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1061
|
+
callId,
|
|
1062
|
+
});
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
activePlayerIds.add(playerId);
|
|
1066
|
+
try {
|
|
1067
|
+
await emitTrace('player.call.started', { ...identity, prompt }, {
|
|
1068
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1069
|
+
callId,
|
|
1070
|
+
});
|
|
1071
|
+
let rawResult;
|
|
1072
|
+
try {
|
|
1073
|
+
rawResult = await requireHostPorts().callPlayer(playerId, prompt, signal, { resume });
|
|
1074
|
+
// A host promise is not required to honor cancellation. Do not let
|
|
1075
|
+
// a late result mutate continuity or publish a successful finish.
|
|
1076
|
+
signal.throwIfAborted();
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
if (!signal.aborted)
|
|
1080
|
+
controlPlaneError ??= error;
|
|
792
1081
|
try {
|
|
793
|
-
await
|
|
1082
|
+
await emitTrace('player.call.finished', {
|
|
1083
|
+
...identity,
|
|
1084
|
+
status: signal.aborted ? 'aborted' : 'error',
|
|
1085
|
+
error: normalizeError(error),
|
|
1086
|
+
}, {
|
|
1087
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1088
|
+
callId,
|
|
1089
|
+
});
|
|
794
1090
|
}
|
|
795
1091
|
catch {
|
|
796
|
-
//
|
|
797
|
-
// surfaces real failures via handleBossInput throws.
|
|
1092
|
+
// The original non-abort port rejection remains authoritative.
|
|
798
1093
|
}
|
|
1094
|
+
// A thrown port call carries no authoritative result, so the
|
|
1095
|
+
// prior token remains available for a later explicit resume.
|
|
1096
|
+
throw error;
|
|
799
1097
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1098
|
+
let result;
|
|
1099
|
+
try {
|
|
1100
|
+
result = validatePlayerResult(rawResult);
|
|
1101
|
+
}
|
|
1102
|
+
catch (error) {
|
|
1103
|
+
if (!signal.aborted)
|
|
1104
|
+
controlPlaneError ??= error;
|
|
1105
|
+
try {
|
|
1106
|
+
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, {
|
|
1107
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1108
|
+
callId,
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
// The malformed host result remains authoritative.
|
|
1113
|
+
}
|
|
1114
|
+
throw error;
|
|
1115
|
+
}
|
|
1116
|
+
if (typeof result.resumeToken === 'string' &&
|
|
1117
|
+
result.resumeToken.trim().length > 0) {
|
|
1118
|
+
playerResumeTokens.set(playerId, result.resumeToken);
|
|
1119
|
+
}
|
|
1120
|
+
else {
|
|
1121
|
+
playerResumeTokens.delete(playerId);
|
|
1122
|
+
}
|
|
1123
|
+
await emitTrace('player.call.finished', {
|
|
1124
|
+
...identity,
|
|
1125
|
+
status: result.status,
|
|
1126
|
+
...(result.finalText !== undefined
|
|
1127
|
+
? { finalText: result.finalText }
|
|
1128
|
+
: {}),
|
|
1129
|
+
...(result.error !== undefined
|
|
1130
|
+
? { error: normalizeError(result.error) }
|
|
1131
|
+
: {}),
|
|
1132
|
+
...(result.resumeToken !== undefined
|
|
1133
|
+
? { resumeToken: result.resumeToken }
|
|
1134
|
+
: {}),
|
|
1135
|
+
}, {
|
|
1136
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1137
|
+
callId,
|
|
1138
|
+
});
|
|
1139
|
+
return result;
|
|
1140
|
+
}
|
|
1141
|
+
finally {
|
|
1142
|
+
activePlayerIds.delete(playerId);
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
async callJudge(purpose, stateId, prompt, signal) {
|
|
1146
|
+
return judgeQueue.add(async () => {
|
|
1147
|
+
signal.throwIfAborted();
|
|
1148
|
+
// A transition/status queued synchronously by XState must reach
|
|
1149
|
+
// the host before the judge call that follows it.
|
|
1150
|
+
await drainEmissions();
|
|
1151
|
+
signal.throwIfAborted();
|
|
1152
|
+
const turnId = activeTurnId;
|
|
1153
|
+
const callId = `judge-${++judgeCallSequence}`;
|
|
1154
|
+
const identity = { purpose, ...stateIdentity(stateId) };
|
|
1155
|
+
await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, {
|
|
1156
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1157
|
+
callId,
|
|
1158
|
+
});
|
|
1159
|
+
let reply;
|
|
1160
|
+
try {
|
|
1161
|
+
reply = await requireHostPorts().callJudge(prompt, signal);
|
|
1162
|
+
signal.throwIfAborted();
|
|
1163
|
+
}
|
|
1164
|
+
catch (error) {
|
|
1165
|
+
if (!isAbortFailure(error, signal)) {
|
|
1166
|
+
controlPlaneError ??= error;
|
|
1167
|
+
}
|
|
1168
|
+
await emitTrace('judge.call.finished', {
|
|
1169
|
+
...identity,
|
|
1170
|
+
status: signal.aborted ? 'aborted' : 'error',
|
|
1171
|
+
error: normalizeError(error),
|
|
1172
|
+
}, {
|
|
1173
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1174
|
+
callId,
|
|
1175
|
+
});
|
|
1176
|
+
throw error;
|
|
1177
|
+
}
|
|
1178
|
+
if (typeof reply !== 'string') {
|
|
1179
|
+
const error = new TypeError('judge reply must be a string');
|
|
1180
|
+
controlPlaneError ??= error;
|
|
1181
|
+
await emitTrace('judge.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, {
|
|
1182
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1183
|
+
callId,
|
|
1184
|
+
});
|
|
1185
|
+
throw error;
|
|
1186
|
+
}
|
|
1187
|
+
// Keep the success finish outside the port-call catch. If a
|
|
1188
|
+
// telemetry sink records this boundary and then rejects, that sink
|
|
1189
|
+
// failure must not synthesize a second, contradictory finish.
|
|
1190
|
+
await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply }, {
|
|
1191
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1192
|
+
callId,
|
|
1193
|
+
});
|
|
1194
|
+
return reply;
|
|
1195
|
+
});
|
|
1196
|
+
},
|
|
1197
|
+
};
|
|
1198
|
+
const nestedBridge = createNestedPlaybookBridge({
|
|
1199
|
+
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
1200
|
+
getBoundarySignal: () => activeSignal,
|
|
1201
|
+
callPlaybook: (request, signal) => requireHostPorts().callPlaybook(request, signal),
|
|
1202
|
+
emitStarted: async (event) => {
|
|
1203
|
+
playbookCallTurnIds.set(event.callId, activeTurnId);
|
|
1204
|
+
await emitTrace('playbook.call.started', {
|
|
1205
|
+
stateId: event.stateId,
|
|
1206
|
+
playbookId: event.playbookId,
|
|
1207
|
+
text: event.text,
|
|
1208
|
+
}, {
|
|
1209
|
+
...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
|
|
1210
|
+
callId: event.callId,
|
|
1211
|
+
});
|
|
1212
|
+
},
|
|
1213
|
+
emitFinished: async (event) => {
|
|
1214
|
+
const turnId = playbookCallTurnIds.get(event.callId);
|
|
1215
|
+
try {
|
|
1216
|
+
await emitTrace('playbook.call.finished', {
|
|
1217
|
+
stateId: event.stateId,
|
|
1218
|
+
playbookId: event.playbookId,
|
|
1219
|
+
text: event.text,
|
|
1220
|
+
result: event.result,
|
|
1221
|
+
}, {
|
|
1222
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1223
|
+
callId: event.callId,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
playbookCallTurnIds.delete(event.callId);
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
drain: drainEmissions,
|
|
1231
|
+
bindResumeSignal: (signal) => {
|
|
1232
|
+
activeSignal = signal;
|
|
1233
|
+
},
|
|
1234
|
+
onControlPlaneError: (error) => {
|
|
1235
|
+
if (!activeSignal?.aborted)
|
|
1236
|
+
controlPlaneError ??= error;
|
|
1237
|
+
},
|
|
1238
|
+
onBackgroundError: (error) => {
|
|
1239
|
+
emissionFailure ??= error;
|
|
1240
|
+
},
|
|
1241
|
+
});
|
|
1242
|
+
function tracePositionForActiveTurn() {
|
|
1243
|
+
return activeTurnId === undefined ? {} : { turnId: activeTurnId };
|
|
803
1244
|
}
|
|
804
|
-
function
|
|
805
|
-
|
|
1245
|
+
function enqueueTransitionEmission(payload, state, statuses, position) {
|
|
1246
|
+
const currentSession = requireSession();
|
|
1247
|
+
const transitionTrace = createTraceEvent('fsm.transition', payload, position);
|
|
1248
|
+
const statusEmissions = statuses.map(({ message, data }) => ({
|
|
1249
|
+
message,
|
|
1250
|
+
data,
|
|
1251
|
+
trace: createTraceEvent('status.emitted', {
|
|
1252
|
+
message,
|
|
1253
|
+
...(data === undefined ? {} : { data }),
|
|
1254
|
+
state,
|
|
1255
|
+
...stateIdentity(state.stateId),
|
|
1256
|
+
}, position),
|
|
1257
|
+
}));
|
|
1258
|
+
void enqueueEmission(async () => {
|
|
1259
|
+
await currentSession.ports.emitTelemetry({
|
|
1260
|
+
topic: 'playbook.trace',
|
|
1261
|
+
payload: transitionTrace,
|
|
1262
|
+
});
|
|
1263
|
+
await currentSession.ports.emitTelemetry({
|
|
1264
|
+
topic: 'playbook.fsm.state',
|
|
1265
|
+
payload,
|
|
1266
|
+
});
|
|
1267
|
+
for (const status of statusEmissions) {
|
|
1268
|
+
await currentSession.ports.emitTelemetry({
|
|
1269
|
+
topic: 'playbook.trace',
|
|
1270
|
+
payload: status.trace,
|
|
1271
|
+
});
|
|
1272
|
+
await currentSession.ports.emitStatus(status.message, status.data);
|
|
1273
|
+
}
|
|
1274
|
+
}).catch(() => undefined);
|
|
1275
|
+
}
|
|
1276
|
+
function latchInspectionError(error) {
|
|
1277
|
+
if (activeSignal !== undefined)
|
|
1278
|
+
controlPlaneError ??= error;
|
|
1279
|
+
else
|
|
1280
|
+
emissionFailure ??= error;
|
|
806
1281
|
}
|
|
807
|
-
function buildActor(ports) {
|
|
1282
|
+
function buildActor(ports, machineSnapshot) {
|
|
808
1283
|
priorState = undefined;
|
|
809
|
-
|
|
810
|
-
|
|
1284
|
+
let builtActor;
|
|
1285
|
+
builtActor = createActor(codingMachine.provide({
|
|
1286
|
+
actors: {
|
|
1287
|
+
player: captainBridge(ports, () => activeSignal, boundary, (error) => {
|
|
1288
|
+
if (!activeSignal?.aborted)
|
|
1289
|
+
controlPlaneError ??= error;
|
|
1290
|
+
}),
|
|
1291
|
+
},
|
|
811
1292
|
}), {
|
|
812
|
-
input:
|
|
1293
|
+
input: boundOptions,
|
|
1294
|
+
// DR-014 §1: a restore rehydrates the persisted machine snapshot;
|
|
1295
|
+
// XState derives context/value from it and ignores `input` then.
|
|
1296
|
+
...(machineSnapshot === undefined
|
|
1297
|
+
? {}
|
|
1298
|
+
: {
|
|
1299
|
+
snapshot: machineSnapshot,
|
|
1300
|
+
}),
|
|
813
1301
|
inspect: (inspectionEvent) => {
|
|
814
1302
|
if (inspectionEvent.type !== '@xstate.snapshot')
|
|
815
1303
|
return;
|
|
816
|
-
|
|
817
|
-
// Filter out captain sub-actor (fromPromise) snapshots —
|
|
818
|
-
// only the root codingMachine snapshot has a string value.
|
|
819
|
-
if (typeof snap.value !== 'string')
|
|
1304
|
+
if (inspectionEvent.actorRef !== builtActor)
|
|
820
1305
|
return;
|
|
821
|
-
|
|
822
|
-
if (priorState === to)
|
|
1306
|
+
if (suppressInspectionEmissions)
|
|
823
1307
|
return;
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1308
|
+
try {
|
|
1309
|
+
const snap = inspectionEvent.snapshot;
|
|
1310
|
+
const state = normalizePlaybookSnapshot(snap);
|
|
1311
|
+
const to = state.stateId;
|
|
1312
|
+
if (to === undefined) {
|
|
1313
|
+
throw new Error('CODE root snapshot must expose exactly one playbook state id');
|
|
1314
|
+
}
|
|
1315
|
+
const previousState = priorState;
|
|
1316
|
+
const context = snap.context;
|
|
1317
|
+
const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
|
|
1318
|
+
const statuses = [];
|
|
1319
|
+
if (CAPTAIN_PANE_STATES.has(to)) {
|
|
1320
|
+
const transitionLine = formatTransition(inspectionEvent.event);
|
|
1321
|
+
if (transitionLine !== undefined) {
|
|
1322
|
+
statuses.push({ message: transitionLine });
|
|
1323
|
+
}
|
|
1324
|
+
if (to === 'awaitBossReply') {
|
|
1325
|
+
statuses.push({ message: formatAwaitBossReplyQuestion(context) }, { message: formatAwaitBossReplyMarker(context) });
|
|
1326
|
+
}
|
|
1327
|
+
else {
|
|
1328
|
+
const entryLine = formatStateEntry(to);
|
|
1329
|
+
if (entryLine !== undefined) {
|
|
1330
|
+
const lastError = to === 'failed'
|
|
1331
|
+
? normalizeErrorCompact(snap.context.lastError)
|
|
1332
|
+
: undefined;
|
|
1333
|
+
statuses.push({
|
|
1334
|
+
message: entryLine,
|
|
1335
|
+
...(lastError === undefined
|
|
1336
|
+
? {}
|
|
1337
|
+
: {
|
|
1338
|
+
data: snapshotJsonValue({ lastError }, 'failed status data'),
|
|
1339
|
+
}),
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn());
|
|
1345
|
+
priorState = state;
|
|
862
1346
|
}
|
|
863
|
-
|
|
864
|
-
|
|
1347
|
+
catch (error) {
|
|
1348
|
+
latchInspectionError(error);
|
|
865
1349
|
}
|
|
866
1350
|
},
|
|
867
1351
|
});
|
|
1352
|
+
return builtActor;
|
|
868
1353
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1354
|
+
function runResultFor(outcome, error) {
|
|
1355
|
+
const state = currentState();
|
|
1356
|
+
if (outcome === 'quiescent' || outcome === 'no-action') {
|
|
1357
|
+
return { outcome, state };
|
|
1358
|
+
}
|
|
1359
|
+
if (outcome === 'suspended') {
|
|
1360
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1361
|
+
if (!pendingCall) {
|
|
1362
|
+
throw new Error('suspended runtime has no pending playbook call');
|
|
1363
|
+
}
|
|
1364
|
+
return { outcome, state, pendingCall };
|
|
1365
|
+
}
|
|
1366
|
+
if (outcome === 'terminal') {
|
|
1367
|
+
const output = actor?.getSnapshot()
|
|
1368
|
+
?.output;
|
|
1369
|
+
if (output !== undefined) {
|
|
1370
|
+
return {
|
|
1371
|
+
outcome,
|
|
1372
|
+
state,
|
|
1373
|
+
output: snapshotJsonValue(output, 'terminal playbook output'),
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
return { outcome, state };
|
|
1377
|
+
}
|
|
1378
|
+
const failure = error ??
|
|
1379
|
+
(outcome === 'failed'
|
|
1380
|
+
? actor?.getSnapshot()
|
|
1381
|
+
?.context?.lastError
|
|
1382
|
+
: outcome === 'aborted'
|
|
1383
|
+
? activeSignal?.reason
|
|
1384
|
+
: undefined);
|
|
1385
|
+
return {
|
|
1386
|
+
outcome,
|
|
1387
|
+
state,
|
|
1388
|
+
...(failure !== undefined ? { error: normalizeError(failure) } : {}),
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
function settledOutcome(signal) {
|
|
1392
|
+
if (nestedBridge.getPendingCall())
|
|
1393
|
+
return 'suspended';
|
|
1394
|
+
if (signal.aborted)
|
|
1395
|
+
return 'aborted';
|
|
1396
|
+
const state = currentState();
|
|
1397
|
+
if (state.status === 'error') {
|
|
1398
|
+
const actorError = actor?.getSnapshot()?.error;
|
|
1399
|
+
throw actorError ?? new Error('CODE actor entered error status');
|
|
1400
|
+
}
|
|
1401
|
+
if (state.status === 'done')
|
|
1402
|
+
return 'terminal';
|
|
1403
|
+
if (state.stateId === 'failed')
|
|
1404
|
+
return 'failed';
|
|
1405
|
+
return 'quiescent';
|
|
1406
|
+
}
|
|
1407
|
+
function settlementTracePayload(result) {
|
|
1408
|
+
return {
|
|
1409
|
+
...result,
|
|
1410
|
+
...stateIdentity(result.state.stateId),
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
// Shared failed-start cleanup for init and restore: stop the actor,
|
|
1414
|
+
// abort/drain nested and host work, optionally emit one best-effort
|
|
1415
|
+
// session.disposed boundary, and unbind every closure field so dispose
|
|
1416
|
+
// stays callable. The caller rethrows its original failure. A restore
|
|
1417
|
+
// failure skips the disposal trace — the parked session was never
|
|
1418
|
+
// re-bound in this process, so its persisted snapshot stays
|
|
1419
|
+
// authoritative (DR-014 §2).
|
|
1420
|
+
async function cleanupFailedStart(cause, options) {
|
|
1421
|
+
let finalState;
|
|
1422
|
+
if (options.emitDisposal && actor) {
|
|
1423
|
+
try {
|
|
1424
|
+
finalState = currentState();
|
|
1425
|
+
}
|
|
1426
|
+
catch {
|
|
1427
|
+
// A state that cannot even normalize has no disposal descriptor.
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
suppressInspectionEmissions = true;
|
|
1431
|
+
try {
|
|
1432
|
+
actor?.stop();
|
|
1433
|
+
}
|
|
1434
|
+
catch {
|
|
1435
|
+
// Preserve the original startup failure.
|
|
1436
|
+
}
|
|
1437
|
+
try {
|
|
1438
|
+
await nestedBridge.abortPending(cause);
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
// Preserve the original startup failure.
|
|
1442
|
+
}
|
|
1443
|
+
try {
|
|
1444
|
+
await judgeQueue.onIdle();
|
|
874
1445
|
await drainEmissions();
|
|
1446
|
+
}
|
|
1447
|
+
catch {
|
|
1448
|
+
// Preserve the original startup failure.
|
|
1449
|
+
}
|
|
1450
|
+
if (options.emitDisposal) {
|
|
1451
|
+
try {
|
|
1452
|
+
await emitTrace('session.disposed', finalState === undefined
|
|
1453
|
+
? {}
|
|
1454
|
+
: {
|
|
1455
|
+
state: finalState,
|
|
1456
|
+
...stateIdentity(finalState.stateId),
|
|
1457
|
+
});
|
|
1458
|
+
await drainEmissions();
|
|
1459
|
+
}
|
|
1460
|
+
catch {
|
|
1461
|
+
// The session-start error remains authoritative.
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
playerResumeTokens.clear();
|
|
1465
|
+
activePlayerIds.clear();
|
|
1466
|
+
playbookCallTurnIds.clear();
|
|
1467
|
+
activeEmissionCalls.clear();
|
|
1468
|
+
emissionQueue.clear();
|
|
1469
|
+
judgeQueue.clear();
|
|
1470
|
+
actor = undefined;
|
|
1471
|
+
session = undefined;
|
|
1472
|
+
savedPorts = undefined;
|
|
1473
|
+
runtimePorts = undefined;
|
|
1474
|
+
activeSignal = undefined;
|
|
1475
|
+
activeTurnId = undefined;
|
|
1476
|
+
controlPlaneError = undefined;
|
|
1477
|
+
emissionFailure = undefined;
|
|
1478
|
+
priorState = undefined;
|
|
1479
|
+
suppressInspectionEmissions = false;
|
|
1480
|
+
initialized = false;
|
|
1481
|
+
traceSequence = 0;
|
|
1482
|
+
turnSequence = 0;
|
|
1483
|
+
judgeCallSequence = 0;
|
|
1484
|
+
playerCallSequence = 0;
|
|
1485
|
+
playbookCallSequence = 0;
|
|
1486
|
+
}
|
|
1487
|
+
const runtime = {
|
|
1488
|
+
async init(nextSession) {
|
|
1489
|
+
if (initialized || disposed || disposalPromise !== undefined) {
|
|
1490
|
+
throw new Error('createPlaybookRuntime.init: already initialized');
|
|
1491
|
+
}
|
|
1492
|
+
const boundSession = snapshotPlaybookSession(nextSession);
|
|
1493
|
+
initialized = true;
|
|
1494
|
+
let finishInitialization;
|
|
1495
|
+
const initialization = new Promise((resolve) => {
|
|
1496
|
+
finishInitialization = resolve;
|
|
1497
|
+
});
|
|
1498
|
+
initInFlight = initialization;
|
|
1499
|
+
const initTask = (async () => {
|
|
1500
|
+
session = boundSession;
|
|
1501
|
+
savedPorts = boundSession.ports;
|
|
1502
|
+
runtimePorts = createRuntimePorts(boundSession.ports);
|
|
1503
|
+
suppressInspectionEmissions = false;
|
|
1504
|
+
actor = buildActor(runtimePorts);
|
|
1505
|
+
await emitTrace('session.started', stateTracePayload());
|
|
1506
|
+
actor.start();
|
|
1507
|
+
await drainEmissions();
|
|
1508
|
+
})();
|
|
1509
|
+
try {
|
|
1510
|
+
await initTask;
|
|
1511
|
+
}
|
|
1512
|
+
catch (error) {
|
|
1513
|
+
await cleanupFailedStart(error, { emitDisposal: true });
|
|
1514
|
+
throw error;
|
|
1515
|
+
}
|
|
1516
|
+
finally {
|
|
1517
|
+
finishInitialization();
|
|
1518
|
+
if (initInFlight === initialization)
|
|
1519
|
+
initInFlight = undefined;
|
|
1520
|
+
}
|
|
1521
|
+
},
|
|
1522
|
+
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
|
|
1523
|
+
// Defined only at a safe capture point — initialized, not disposing
|
|
1524
|
+
// or disposed, no active public boundary, no pending nested call,
|
|
1525
|
+
// and the actor quiescent with status `active`.
|
|
1526
|
+
exportSnapshot() {
|
|
1527
|
+
if (!actor || !session || disposed || disposalPromise !== undefined) {
|
|
1528
|
+
return undefined;
|
|
1529
|
+
}
|
|
1530
|
+
if (activeSignal !== undefined)
|
|
1531
|
+
return undefined;
|
|
1532
|
+
if (nestedBridge.getPendingCall())
|
|
1533
|
+
return undefined;
|
|
1534
|
+
const state = currentState();
|
|
1535
|
+
if (state.status !== 'active' || !state.quiescent)
|
|
1536
|
+
return undefined;
|
|
1537
|
+
const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
|
|
1538
|
+
const context = actor.getSnapshot()
|
|
1539
|
+
.context;
|
|
1540
|
+
const pending = pendingBossQuestionFromContext(context ?? {});
|
|
1541
|
+
return {
|
|
1542
|
+
schemaVersion: 1,
|
|
1543
|
+
playbookId: session.playbookId,
|
|
1544
|
+
machine,
|
|
1545
|
+
playerResumeTokens: Object.fromEntries(playerResumeTokens),
|
|
1546
|
+
sequences: {
|
|
1547
|
+
trace: traceSequence,
|
|
1548
|
+
turn: turnSequence,
|
|
1549
|
+
judgeCall: judgeCallSequence,
|
|
1550
|
+
playerCall: playerCallSequence,
|
|
1551
|
+
playbookCall: playbookCallSequence,
|
|
1552
|
+
},
|
|
1553
|
+
state,
|
|
1554
|
+
pendingBossQuestions: pending === undefined
|
|
1555
|
+
? []
|
|
1556
|
+
: [
|
|
1557
|
+
{
|
|
1558
|
+
questionId: pending.questionId,
|
|
1559
|
+
player: pending.player,
|
|
1560
|
+
question: pending.question,
|
|
1561
|
+
sourceItem: pending.sourceItem,
|
|
1562
|
+
},
|
|
1563
|
+
],
|
|
1564
|
+
};
|
|
1565
|
+
},
|
|
1566
|
+
// DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
|
|
1567
|
+
// exported snapshot under the same immutable session identity.
|
|
1568
|
+
// Emits no `session.started`, transition trace, or human status —
|
|
1569
|
+
// the session already started; the next public boundary continues
|
|
1570
|
+
// the contiguous trace sequence.
|
|
1571
|
+
async restore(nextSession, snapshot) {
|
|
1572
|
+
if (initialized || disposed || disposalPromise !== undefined) {
|
|
1573
|
+
throw new Error('createPlaybookRuntime.restore: already initialized');
|
|
1574
|
+
}
|
|
1575
|
+
const boundSession = snapshotPlaybookSession(nextSession);
|
|
1576
|
+
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId);
|
|
1577
|
+
initialized = true;
|
|
1578
|
+
let finishInitialization;
|
|
1579
|
+
const initialization = new Promise((resolve) => {
|
|
1580
|
+
finishInitialization = resolve;
|
|
1581
|
+
});
|
|
1582
|
+
initInFlight = initialization;
|
|
1583
|
+
const initTask = (async () => {
|
|
1584
|
+
session = boundSession;
|
|
1585
|
+
savedPorts = boundSession.ports;
|
|
1586
|
+
runtimePorts = createRuntimePorts(boundSession.ports);
|
|
1587
|
+
traceSequence = boundSnapshot.sequences.trace;
|
|
1588
|
+
turnSequence = boundSnapshot.sequences.turn;
|
|
1589
|
+
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
1590
|
+
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1591
|
+
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
1592
|
+
playerResumeTokens.clear();
|
|
1593
|
+
for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
|
|
1594
|
+
playerResumeTokens.set(playerId, token);
|
|
1595
|
+
}
|
|
1596
|
+
suppressInspectionEmissions = true;
|
|
1597
|
+
actor = buildActor(runtimePorts, boundSnapshot.machine);
|
|
1598
|
+
actor.start();
|
|
1599
|
+
const restoredState = currentState();
|
|
1600
|
+
if (restoredState.status !== 'active') {
|
|
1601
|
+
throw new Error(`createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`);
|
|
1602
|
+
}
|
|
1603
|
+
suppressInspectionEmissions = false;
|
|
1604
|
+
priorState = restoredState;
|
|
1605
|
+
await drainEmissions();
|
|
1606
|
+
})();
|
|
1607
|
+
try {
|
|
1608
|
+
await initTask;
|
|
1609
|
+
}
|
|
1610
|
+
catch (error) {
|
|
1611
|
+
await cleanupFailedStart(error, { emitDisposal: false });
|
|
1612
|
+
throw error;
|
|
1613
|
+
}
|
|
1614
|
+
finally {
|
|
1615
|
+
finishInitialization();
|
|
1616
|
+
if (initInFlight === initialization)
|
|
1617
|
+
initInFlight = undefined;
|
|
1618
|
+
}
|
|
875
1619
|
},
|
|
876
1620
|
async handleBossInput({ text, signal, }) {
|
|
877
1621
|
if (!actor || !savedPorts) {
|
|
878
1622
|
throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
|
|
879
1623
|
}
|
|
1624
|
+
if (disposed || disposalPromise !== undefined) {
|
|
1625
|
+
throw new Error('createPlaybookRuntime.handleBossInput: runtime is disposing or disposed');
|
|
1626
|
+
}
|
|
1627
|
+
if (activeSignal !== undefined) {
|
|
1628
|
+
throw new Error('createPlaybookRuntime.handleBossInput: another runtime turn is active');
|
|
1629
|
+
}
|
|
1630
|
+
const turnId = ++turnSequence;
|
|
1631
|
+
activeTurnId = turnId;
|
|
880
1632
|
activeSignal = signal;
|
|
1633
|
+
controlPlaneError = undefined;
|
|
1634
|
+
let result;
|
|
1635
|
+
let operationError;
|
|
881
1636
|
try {
|
|
1637
|
+
await emitTrace('boss.input.received', { text }, { turnId });
|
|
882
1638
|
// 1. Classify non-empty text into an FSM event through the judge.
|
|
883
|
-
const event = await classifyBossText(text,
|
|
1639
|
+
const event = await classifyBossText(text, runtimePorts, signal, actor.getSnapshot(), boundary);
|
|
884
1640
|
// Empty input, no-action classifier output, or invalid classifier
|
|
885
1641
|
// output — nothing to send.
|
|
886
1642
|
if (event === undefined) {
|
|
887
|
-
|
|
888
|
-
return;
|
|
1643
|
+
result = runResultFor('no-action');
|
|
889
1644
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
actor
|
|
902
|
-
actor
|
|
1645
|
+
else {
|
|
1646
|
+
// 2. Captain-pane classification line (PBRT-14): the bare
|
|
1647
|
+
// FSM event type, emitted before the FSM advances.
|
|
1648
|
+
await runtimePorts.emitStatus(formatClassification(event.type));
|
|
1649
|
+
// 3. A final actor cannot accept new events; reconstruct only after
|
|
1650
|
+
// classification produced a real event.
|
|
1651
|
+
if (actor.getSnapshot().status === 'done') {
|
|
1652
|
+
actor.stop();
|
|
1653
|
+
actor = buildActor(runtimePorts);
|
|
1654
|
+
actor.start();
|
|
1655
|
+
}
|
|
1656
|
+
actor.send(event);
|
|
1657
|
+
await waitForPlaybookQuiescence(actor, {
|
|
1658
|
+
pendingCalls: nestedBridge,
|
|
1659
|
+
});
|
|
1660
|
+
if (controlPlaneError !== undefined)
|
|
1661
|
+
throw controlPlaneError;
|
|
1662
|
+
result = runResultFor(settledOutcome(signal));
|
|
903
1663
|
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
// snapshot and returns (DR-004 §8 natural rejection).
|
|
911
|
-
await driveToQuiescence(actor);
|
|
912
|
-
// Drain transition emissions before returning so the Boss
|
|
913
|
-
// sees the final status line for this turn.
|
|
1664
|
+
}
|
|
1665
|
+
catch (error) {
|
|
1666
|
+
operationError = error;
|
|
1667
|
+
}
|
|
1668
|
+
let drainError;
|
|
1669
|
+
try {
|
|
914
1670
|
await drainEmissions();
|
|
915
1671
|
}
|
|
916
|
-
|
|
917
|
-
|
|
1672
|
+
catch (error) {
|
|
1673
|
+
drainError = error;
|
|
918
1674
|
}
|
|
1675
|
+
const latchedControlError = controlPlaneError;
|
|
1676
|
+
const primaryError = latchedControlError ?? drainError ?? operationError;
|
|
1677
|
+
const abortError = latchedControlError === undefined &&
|
|
1678
|
+
drainError === undefined &&
|
|
1679
|
+
operationError !== undefined &&
|
|
1680
|
+
isAbortFailure(operationError, signal);
|
|
1681
|
+
const settlementResult = primaryError === undefined
|
|
1682
|
+
? (result ?? runResultFor('no-action'))
|
|
1683
|
+
: runResultFor(abortError ? 'aborted' : 'failed', primaryError);
|
|
1684
|
+
let settlementEmissionError;
|
|
1685
|
+
try {
|
|
1686
|
+
await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
|
|
1687
|
+
}
|
|
1688
|
+
catch (error) {
|
|
1689
|
+
settlementEmissionError = error;
|
|
1690
|
+
}
|
|
1691
|
+
try {
|
|
1692
|
+
await drainEmissions();
|
|
1693
|
+
}
|
|
1694
|
+
catch (error) {
|
|
1695
|
+
settlementEmissionError ??= error;
|
|
1696
|
+
}
|
|
1697
|
+
const failure = controlPlaneError ??
|
|
1698
|
+
latchedControlError ??
|
|
1699
|
+
drainError ??
|
|
1700
|
+
(abortError
|
|
1701
|
+
? (settlementEmissionError ?? operationError)
|
|
1702
|
+
: (operationError ?? settlementEmissionError));
|
|
1703
|
+
activeSignal = undefined;
|
|
1704
|
+
activeTurnId = undefined;
|
|
1705
|
+
controlPlaneError = undefined;
|
|
1706
|
+
if (failure !== undefined &&
|
|
1707
|
+
!(abortError && settlementEmissionError === undefined)) {
|
|
1708
|
+
throw failure;
|
|
1709
|
+
}
|
|
1710
|
+
return settlementResult;
|
|
919
1711
|
},
|
|
920
|
-
async
|
|
921
|
-
if (actor) {
|
|
922
|
-
|
|
923
|
-
actor = undefined;
|
|
1712
|
+
async resumePlaybookCall(input) {
|
|
1713
|
+
if (!actor || !savedPorts) {
|
|
1714
|
+
throw new Error('createPlaybookRuntime.resumePlaybookCall: init must be called first');
|
|
924
1715
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1716
|
+
if (disposed || disposalPromise !== undefined) {
|
|
1717
|
+
throw new Error('createPlaybookRuntime.resumePlaybookCall: runtime is disposing or disposed');
|
|
1718
|
+
}
|
|
1719
|
+
if (activeSignal !== undefined) {
|
|
1720
|
+
throw new Error('createPlaybookRuntime.resumePlaybookCall: another runtime turn is active');
|
|
1721
|
+
}
|
|
1722
|
+
activeTurnId = playbookCallTurnIds.get(input.callId);
|
|
1723
|
+
activeSignal = input.signal;
|
|
1724
|
+
controlPlaneError = undefined;
|
|
1725
|
+
let result;
|
|
1726
|
+
let operationError;
|
|
1727
|
+
try {
|
|
1728
|
+
await nestedBridge.resume(input);
|
|
1729
|
+
}
|
|
1730
|
+
catch (error) {
|
|
1731
|
+
operationError = error;
|
|
1732
|
+
}
|
|
1733
|
+
try {
|
|
1734
|
+
await waitForPlaybookQuiescence(actor, {
|
|
1735
|
+
pendingCalls: nestedBridge,
|
|
1736
|
+
});
|
|
1737
|
+
result = runResultFor(settledOutcome(input.signal));
|
|
1738
|
+
}
|
|
1739
|
+
catch (error) {
|
|
1740
|
+
operationError ??= error;
|
|
1741
|
+
}
|
|
1742
|
+
let drainError;
|
|
1743
|
+
try {
|
|
1744
|
+
await drainEmissions();
|
|
1745
|
+
}
|
|
1746
|
+
catch (error) {
|
|
1747
|
+
drainError = error;
|
|
1748
|
+
}
|
|
1749
|
+
const failure = controlPlaneError ?? drainError ?? operationError;
|
|
1750
|
+
activeSignal = undefined;
|
|
1751
|
+
activeTurnId = undefined;
|
|
1752
|
+
controlPlaneError = undefined;
|
|
1753
|
+
if (failure !== undefined)
|
|
1754
|
+
throw failure;
|
|
1755
|
+
if (result === undefined) {
|
|
1756
|
+
throw new Error('playbook resume produced no runtime result');
|
|
1757
|
+
}
|
|
1758
|
+
return result;
|
|
1759
|
+
},
|
|
1760
|
+
dispose() {
|
|
1761
|
+
if (disposalPromise !== undefined)
|
|
1762
|
+
return disposalPromise;
|
|
1763
|
+
if (disposed)
|
|
1764
|
+
return Promise.resolve();
|
|
1765
|
+
if (activeSignal !== undefined) {
|
|
1766
|
+
return Promise.reject(new Error('createPlaybookRuntime.dispose: cannot dispose during an active runtime boundary'));
|
|
1767
|
+
}
|
|
1768
|
+
const task = (async () => {
|
|
1769
|
+
const failures = [];
|
|
1770
|
+
try {
|
|
1771
|
+
if (initInFlight !== undefined) {
|
|
1772
|
+
try {
|
|
1773
|
+
await initInFlight;
|
|
1774
|
+
}
|
|
1775
|
+
catch {
|
|
1776
|
+
// Dispose still releases whatever an unsuccessful init bound.
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
const finalState = actor ? currentState() : undefined;
|
|
1780
|
+
// Stop the root before settling a suspended child. Its rejection
|
|
1781
|
+
// must not re-enter CODE and start fresh work during disposal.
|
|
1782
|
+
if (actor)
|
|
1783
|
+
actor.stop();
|
|
1784
|
+
try {
|
|
1785
|
+
await nestedBridge.dispose();
|
|
1786
|
+
}
|
|
1787
|
+
catch (error) {
|
|
1788
|
+
failures.push(error);
|
|
1789
|
+
}
|
|
1790
|
+
try {
|
|
1791
|
+
await drainEmissions();
|
|
1792
|
+
}
|
|
1793
|
+
catch (error) {
|
|
1794
|
+
failures.push(error);
|
|
1795
|
+
}
|
|
1796
|
+
if (session !== undefined) {
|
|
1797
|
+
try {
|
|
1798
|
+
await emitTrace('session.disposed', finalState === undefined
|
|
1799
|
+
? {}
|
|
1800
|
+
: {
|
|
1801
|
+
state: finalState,
|
|
1802
|
+
...stateIdentity(finalState.stateId),
|
|
1803
|
+
});
|
|
1804
|
+
await drainEmissions();
|
|
1805
|
+
}
|
|
1806
|
+
catch (error) {
|
|
1807
|
+
failures.push(error);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
finally {
|
|
1812
|
+
playerResumeTokens.clear();
|
|
1813
|
+
activePlayerIds.clear();
|
|
1814
|
+
playbookCallTurnIds.clear();
|
|
1815
|
+
activeEmissionCalls.clear();
|
|
1816
|
+
emissionQueue.clear();
|
|
1817
|
+
judgeQueue.clear();
|
|
1818
|
+
actor = undefined;
|
|
1819
|
+
activeSignal = undefined;
|
|
1820
|
+
activeTurnId = undefined;
|
|
1821
|
+
controlPlaneError = undefined;
|
|
1822
|
+
emissionFailure = undefined;
|
|
1823
|
+
savedPorts = undefined;
|
|
1824
|
+
runtimePorts = undefined;
|
|
1825
|
+
session = undefined;
|
|
1826
|
+
disposed = true;
|
|
1827
|
+
}
|
|
1828
|
+
if (failures.length === 1)
|
|
1829
|
+
throw failures[0];
|
|
1830
|
+
if (failures.length > 1) {
|
|
1831
|
+
throw new AggregateError(failures, 'playbook runtime disposal failed');
|
|
1832
|
+
}
|
|
1833
|
+
})();
|
|
1834
|
+
disposalPromise = task;
|
|
1835
|
+
return task;
|
|
929
1836
|
},
|
|
930
1837
|
// @internal — test-only escape hatch for inspecting the
|
|
931
1838
|
// underlying actor's snapshot. Most state assertions are now
|
|
@@ -936,24 +1843,12 @@ export default function createPlaybookRuntime(options) {
|
|
|
936
1843
|
_getActor() {
|
|
937
1844
|
return actor;
|
|
938
1845
|
},
|
|
1846
|
+
_getBoundary() {
|
|
1847
|
+
return boundary;
|
|
1848
|
+
},
|
|
1849
|
+
_getNestedBridge() {
|
|
1850
|
+
return nestedBridge;
|
|
1851
|
+
},
|
|
939
1852
|
};
|
|
940
1853
|
return runtime;
|
|
941
1854
|
}
|
|
942
|
-
function driveToQuiescence(actor) {
|
|
943
|
-
return new Promise((resolve) => {
|
|
944
|
-
if (isQuiescent(actor.getSnapshot())) {
|
|
945
|
-
resolve();
|
|
946
|
-
return;
|
|
947
|
-
}
|
|
948
|
-
const sub = actor.subscribe((snap) => {
|
|
949
|
-
if (isQuiescent(snap)) {
|
|
950
|
-
sub.unsubscribe();
|
|
951
|
-
resolve();
|
|
952
|
-
}
|
|
953
|
-
});
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
function isQuiescent(snap) {
|
|
957
|
-
const v = snap.value;
|
|
958
|
-
return typeof v === 'string' && QUIESCENT_STATES.has(v);
|
|
959
|
-
}
|