@sublang/playbook 0.8.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 +243 -193
- package/package.json +52 -17
- 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 +580 -0
- 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 +470 -182
- 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 +1098 -202
- package/reference/sdlc/code.playbook/code.playbook.ts +1440 -258
- package/reference/sdlc/code.playbook/code.registry.d.ts +17 -5
- package/reference/sdlc/code.playbook/code.registry.js +49 -34
- package/reference/sdlc/code.playbook/code.registry.ts +75 -41
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +16 -8
- package/reference/sdlc/code.playbook/playbook-captain.js +1005 -240
- package/reference/sdlc/code.playbook/playbook-captain.ts +1310 -301
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +68 -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
- package/reference/sdlc/code.playbook/bin/playbook-code.js +0 -487
- package/reference/sdlc/code.playbook/code.tmux-play.d.ts +0 -4
- package/reference/sdlc/code.playbook/code.tmux-play.js +0 -11
- package/reference/sdlc/code.playbook/code.tmux-play.ts +0 -29
- package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +0 -72
- package/reference/sdlc/code.playbook/tmux-play.config.yaml +0 -55
- package/reference/sdlc/code.playbook/tmux-play.production.config.yaml +0 -38
|
@@ -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
|
|
@@ -131,8 +171,9 @@ function composePlayerPrompt(input) {
|
|
|
131
171
|
// Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
|
|
132
172
|
// Committer (= Coder | Reviewer per code.gears.md) resolves to the
|
|
133
173
|
// configured alias when present: `input.committerPlayer`, the
|
|
134
|
-
// validated `captain.options.code.committer`
|
|
135
|
-
// already a baked player id ('coder' / 'reviewer').
|
|
174
|
+
// validated `captain.options.playbooks.code.options.committer`
|
|
175
|
+
// (PBRT-8 / PBRT-30), already a baked player id ('coder' / 'reviewer').
|
|
176
|
+
// Absent a
|
|
136
177
|
// configured alias it falls back to the DR-004 §2 baked binding by
|
|
137
178
|
// populated <playerName>Player field: prefer `coderPlayer` (CODE-18
|
|
138
179
|
// wires only coderPlayer; CODE-19 wires both so coderPlayer still wins
|
|
@@ -168,9 +209,11 @@ function resolvePlayerId(input) {
|
|
|
168
209
|
// object once the chosen guard is one of the input.result keys.
|
|
169
210
|
// Adjudicator failures (malformed JSON, missing/unknown guard) are
|
|
170
211
|
// control-plane errors and propagate via throw per slc/link.md.
|
|
171
|
-
async function adjudicate(input, finalText, ports, signal) {
|
|
212
|
+
async function adjudicate(input, finalText, ports, signal, boundary) {
|
|
172
213
|
const prompt = buildJudgePrompt(input, finalText);
|
|
173
|
-
const raw =
|
|
214
|
+
const raw = boundary
|
|
215
|
+
? await boundary.callJudge('player-output-adjudication', input.stateId, prompt, signal)
|
|
216
|
+
: await ports.callJudge(prompt, signal);
|
|
174
217
|
const parsed = parseJudgeJson(raw);
|
|
175
218
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
176
219
|
throw new Error('adjudicate: judge response is not a JSON object');
|
|
@@ -383,19 +426,22 @@ function dropTrailingComma(out) {
|
|
|
383
426
|
// classifier is state-aware so awaitBossReply can distinguish a direct
|
|
384
427
|
// answer (BOSS_REPLY) from a fresh directive that abandons the pending
|
|
385
428
|
// question through the FSM's existing transitions.
|
|
386
|
-
async function classifyBossText(text, ports, signal, snapshotOrState) {
|
|
429
|
+
async function classifyBossText(text, ports, signal, snapshotOrState, boundary) {
|
|
387
430
|
const trimmed = text.trim();
|
|
388
431
|
if (trimmed === '')
|
|
389
432
|
return undefined;
|
|
390
|
-
return classifyWithLlm(text, ports, signal, snapshotOrState);
|
|
433
|
+
return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
|
|
391
434
|
}
|
|
392
435
|
const rootEvents = enumerateRootEvents(codingMachine);
|
|
393
436
|
const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
|
|
394
437
|
const bossInterruptTargetIds = new Set(bossInterruptTargets.map((target) => target.stateId));
|
|
395
|
-
async function classifyWithLlm(text, ports, signal, snapshotOrState) {
|
|
438
|
+
async function classifyWithLlm(text, ports, signal, snapshotOrState, boundary) {
|
|
396
439
|
const state = classifierState(snapshotOrState);
|
|
397
440
|
const prompt = buildClassifierPrompt(text, state);
|
|
398
|
-
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);
|
|
399
445
|
let parsed;
|
|
400
446
|
try {
|
|
401
447
|
parsed = parseJudgeJson(raw);
|
|
@@ -478,7 +524,26 @@ async function classifyWithLlm(text, ports, signal, snapshotOrState) {
|
|
|
478
524
|
await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
|
|
479
525
|
return undefined;
|
|
480
526
|
}
|
|
481
|
-
|
|
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
|
+
};
|
|
482
547
|
}
|
|
483
548
|
default:
|
|
484
549
|
await ports.emitStatus(`Classifier returned unknown event type: ${eventType}`);
|
|
@@ -502,7 +567,9 @@ function classifierState(snapshotOrState) {
|
|
|
502
567
|
return { value: snapshotOrState, context: {} };
|
|
503
568
|
}
|
|
504
569
|
function buildClassifierPrompt(text, state) {
|
|
505
|
-
const currentState = typeof state.value === 'string'
|
|
570
|
+
const currentState = typeof state.value === 'string'
|
|
571
|
+
? state.value
|
|
572
|
+
: JSON.stringify(state.value ?? null);
|
|
506
573
|
const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
|
|
507
574
|
const lines = [
|
|
508
575
|
'Classify the following Boss message into exactly one of these events.',
|
|
@@ -512,14 +579,14 @@ function buildClassifierPrompt(text, state) {
|
|
|
512
579
|
`Current state: ${currentState}`,
|
|
513
580
|
];
|
|
514
581
|
if (pendingBossQuestion !== undefined) {
|
|
515
|
-
lines.push(`Pending
|
|
582
|
+
lines.push(`Pending question id: ${pendingBossQuestion.questionId}`, `Pending asking player: ${pendingBossQuestion.player}`, `Pending Boss question: ${pendingBossQuestion.question}`);
|
|
516
583
|
}
|
|
517
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:');
|
|
518
585
|
for (const target of bossInterruptTargets) {
|
|
519
586
|
lines.push(` - ${target.stateId}: ${target.description}`);
|
|
520
587
|
}
|
|
521
588
|
if (currentState === 'awaitBossReply') {
|
|
522
|
-
lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
|
|
589
|
+
lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }');
|
|
523
590
|
}
|
|
524
591
|
else {
|
|
525
592
|
lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
|
|
@@ -527,35 +594,45 @@ function buildClassifierPrompt(text, state) {
|
|
|
527
594
|
lines.push('', 'Boss message:', '```', text, '```');
|
|
528
595
|
return lines.join('\n');
|
|
529
596
|
}
|
|
530
|
-
//
|
|
531
|
-
// 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:
|
|
532
599
|
// resolve playerId, compose the player prompt, await
|
|
533
600
|
// ports.callPlayer, adjudicate the finalText. PlayerResult status of
|
|
534
601
|
// 'aborted' or 'error' throws so XState routes via onError → #failed
|
|
535
602
|
// (the single fail-stop sink for both Captain errors and player
|
|
536
|
-
// failures).
|
|
603
|
+
// failures). Captain remains the orchestrator and adjudicator; it is not
|
|
604
|
+
// encoded as the delegated FSM actor.
|
|
537
605
|
//
|
|
538
606
|
// `getActiveSignal` is the runtime's hook for flowing the Boss's
|
|
539
607
|
// `handleBossInput.signal` into the host port calls — fromPromise
|
|
540
608
|
// hands the bridge XState's actor-scoped signal, which only fires
|
|
541
609
|
// on actor.stop(), not on Boss abort. When omitted (e.g. direct
|
|
542
610
|
// captainBridge tests), the bridge falls back to XState's signal.
|
|
543
|
-
function captainBridge(ports, getActiveSignal) {
|
|
611
|
+
function captainBridge(ports, getActiveSignal, boundary, onControlPlaneError) {
|
|
544
612
|
return fromPromise(async ({ input, signal }) => {
|
|
545
|
-
const activeSignal = getActiveSignal?.()
|
|
613
|
+
const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
|
|
546
614
|
const playerId = resolvePlayerId(input);
|
|
547
615
|
const prompt = composePlayerPrompt(input);
|
|
548
|
-
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
|
+
});
|
|
549
621
|
if (result.status !== 'ok') {
|
|
550
|
-
throw new Error(result.error ??
|
|
551
|
-
`captainBridge: callPlayer status "${result.status}"`);
|
|
622
|
+
throw new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
|
|
552
623
|
}
|
|
553
624
|
if (result.finalText === undefined) {
|
|
554
625
|
throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
|
|
555
626
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
+
}
|
|
559
636
|
});
|
|
560
637
|
}
|
|
561
638
|
// Captain pane display — PBRT-3 / PBRT-14.
|
|
@@ -564,7 +641,7 @@ function captainBridge(ports, getActiveSignal) {
|
|
|
564
641
|
// glance:
|
|
565
642
|
// (no glyph) bare FSM event type — host renders as captain speech
|
|
566
643
|
// (e.g., `captain> START_CODING`)
|
|
567
|
-
// ⤷
|
|
644
|
+
// ⤷ player-invoking state entry: `<Player>: <label>`
|
|
568
645
|
// → transition guard outcome (`· field=N` tallies
|
|
569
646
|
// appended); the host presenter owns any visual
|
|
570
647
|
// nesting under the preceding ⤷ entry
|
|
@@ -601,17 +678,13 @@ const stateMetadata = (() => {
|
|
|
601
678
|
for (const s of enumerateCaptainStates(codingMachine)) {
|
|
602
679
|
const label = STATE_LABELS[s.stateId];
|
|
603
680
|
if (!label) {
|
|
604
|
-
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}'`);
|
|
605
682
|
}
|
|
606
683
|
const input = s.getInput({});
|
|
607
684
|
m.set(s.stateId, { player: input.player, sourceItem: s.sourceItem, label });
|
|
608
685
|
}
|
|
609
686
|
return m;
|
|
610
687
|
})();
|
|
611
|
-
const stateIdBySourceItem = new Map([...stateMetadata.entries()].map(([stateId, meta]) => [
|
|
612
|
-
meta.sourceItem,
|
|
613
|
-
stateId,
|
|
614
|
-
]));
|
|
615
688
|
const registeredResumableStateIds = new Set(enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map((transition) => transition.target));
|
|
616
689
|
function validateBossReplyOutput(input, output) {
|
|
617
690
|
if (output.guard !== 'needsBossReply')
|
|
@@ -619,9 +692,9 @@ function validateBossReplyOutput(input, output) {
|
|
|
619
692
|
if (typeof output.question !== 'string') {
|
|
620
693
|
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
621
694
|
}
|
|
622
|
-
const stateId =
|
|
623
|
-
if (
|
|
624
|
-
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));
|
|
625
698
|
}
|
|
626
699
|
}
|
|
627
700
|
const QUIESCENT_STATES = new Set([
|
|
@@ -634,11 +707,8 @@ const QUIESCENT_STATES = new Set([
|
|
|
634
707
|
// pane per PBRT-3: the readline returning to its `boss>` prompt is
|
|
635
708
|
// the implicit "turn over" signal, so a `◆ ready` / `◆ done`
|
|
636
709
|
// tombstone is redundant.
|
|
637
|
-
const SUPPRESSED_ENTRY_STATES = new Set([
|
|
638
|
-
|
|
639
|
-
'done',
|
|
640
|
-
]);
|
|
641
|
-
// 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
|
|
642
712
|
// the quiescent states whose entry still carries information
|
|
643
713
|
// (failure with `lastError`, awaitBossReply with the pending
|
|
644
714
|
// question). `ready` and `done` flow through the inspect handler
|
|
@@ -655,12 +725,14 @@ function pendingBossQuestionFromContext(context) {
|
|
|
655
725
|
}
|
|
656
726
|
const candidate = pending;
|
|
657
727
|
if (typeof candidate.resumeStateId !== 'string' ||
|
|
728
|
+
typeof candidate.questionId !== 'string' ||
|
|
658
729
|
typeof candidate.sourceItem !== 'string' ||
|
|
659
730
|
typeof candidate.player !== 'string' ||
|
|
660
731
|
typeof candidate.question !== 'string') {
|
|
661
732
|
return undefined;
|
|
662
733
|
}
|
|
663
734
|
return {
|
|
735
|
+
questionId: candidate.questionId,
|
|
664
736
|
resumeStateId: candidate.resumeStateId,
|
|
665
737
|
sourceItem: candidate.sourceItem,
|
|
666
738
|
player: candidate.player,
|
|
@@ -725,9 +797,9 @@ function formatClassification(eventType) {
|
|
|
725
797
|
}
|
|
726
798
|
function stateTelemetryPayload(from, to, event, context) {
|
|
727
799
|
const payload = {
|
|
728
|
-
from,
|
|
800
|
+
from: from ?? null,
|
|
729
801
|
to,
|
|
730
|
-
event: normalizeEventForTelemetry(event),
|
|
802
|
+
event: normalizeEventForTelemetry(event) ?? null,
|
|
731
803
|
};
|
|
732
804
|
if (to === 'awaitBossReply') {
|
|
733
805
|
const pendingBossQuestion = pendingBossQuestionFromContext(context);
|
|
@@ -743,6 +815,27 @@ function stateTelemetryPayload(from, to, event, context) {
|
|
|
743
815
|
}
|
|
744
816
|
return payload;
|
|
745
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
|
+
}
|
|
746
839
|
// Internal export surface for tests. Not part of the stable public API;
|
|
747
840
|
// the leading underscore signals "subject to change." Each member is
|
|
748
841
|
// referenced here so `noUnusedLocals` stays clean while later tasks
|
|
@@ -768,163 +861,978 @@ export const _internal = {
|
|
|
768
861
|
VERBATIM_PAYLOAD_FIELDS,
|
|
769
862
|
};
|
|
770
863
|
export default function createPlaybookRuntime(options) {
|
|
864
|
+
const boundOptions = snapshotCodePlaybookOptions(options);
|
|
771
865
|
let actor;
|
|
866
|
+
let session;
|
|
867
|
+
let initialized = false;
|
|
868
|
+
let initInFlight;
|
|
869
|
+
let disposalPromise;
|
|
870
|
+
let disposed = false;
|
|
772
871
|
let savedPorts;
|
|
872
|
+
let runtimePorts;
|
|
773
873
|
// The Boss's per-turn AbortSignal, surfaced to captainBridge so
|
|
774
874
|
// ports.callPlayer / callJudge see the right cancellation source.
|
|
775
875
|
// null between turns; set by handleBossInput.
|
|
776
876
|
let activeSignal;
|
|
877
|
+
let activeTurnId;
|
|
878
|
+
let controlPlaneError;
|
|
777
879
|
// Previous root-machine state for the inspect-driven telemetry /
|
|
778
880
|
// status emitter. undefined before the first inspect firing.
|
|
779
881
|
let priorState;
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
let
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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;
|
|
791
1081
|
try {
|
|
792
|
-
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
|
+
});
|
|
793
1090
|
}
|
|
794
1091
|
catch {
|
|
795
|
-
//
|
|
796
|
-
// surfaces real failures via handleBossInput throws.
|
|
1092
|
+
// The original non-abort port rejection remains authoritative.
|
|
797
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;
|
|
798
1097
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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 };
|
|
802
1244
|
}
|
|
803
|
-
function
|
|
804
|
-
|
|
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;
|
|
805
1281
|
}
|
|
806
|
-
function buildActor(ports) {
|
|
1282
|
+
function buildActor(ports, machineSnapshot) {
|
|
807
1283
|
priorState = undefined;
|
|
808
|
-
|
|
809
|
-
|
|
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
|
+
},
|
|
810
1292
|
}), {
|
|
811
|
-
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
|
+
}),
|
|
812
1301
|
inspect: (inspectionEvent) => {
|
|
813
1302
|
if (inspectionEvent.type !== '@xstate.snapshot')
|
|
814
1303
|
return;
|
|
815
|
-
|
|
816
|
-
// Filter out captain sub-actor (fromPromise) snapshots —
|
|
817
|
-
// only the root codingMachine snapshot has a string value.
|
|
818
|
-
if (typeof snap.value !== 'string')
|
|
1304
|
+
if (inspectionEvent.actorRef !== builtActor)
|
|
819
1305
|
return;
|
|
820
|
-
|
|
821
|
-
if (priorState === to)
|
|
1306
|
+
if (suppressInspectionEmissions)
|
|
822
1307
|
return;
|
|
823
|
-
|
|
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
|
-
|
|
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;
|
|
861
1346
|
}
|
|
862
|
-
|
|
863
|
-
|
|
1347
|
+
catch (error) {
|
|
1348
|
+
latchInspectionError(error);
|
|
864
1349
|
}
|
|
865
1350
|
},
|
|
866
1351
|
});
|
|
1352
|
+
return builtActor;
|
|
867
1353
|
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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();
|
|
873
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
|
+
}
|
|
874
1619
|
},
|
|
875
1620
|
async handleBossInput({ text, signal, }) {
|
|
876
1621
|
if (!actor || !savedPorts) {
|
|
877
1622
|
throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
|
|
878
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;
|
|
879
1632
|
activeSignal = signal;
|
|
1633
|
+
controlPlaneError = undefined;
|
|
1634
|
+
let result;
|
|
1635
|
+
let operationError;
|
|
880
1636
|
try {
|
|
1637
|
+
await emitTrace('boss.input.received', { text }, { turnId });
|
|
881
1638
|
// 1. Classify non-empty text into an FSM event through the judge.
|
|
882
|
-
const event = await classifyBossText(text,
|
|
1639
|
+
const event = await classifyBossText(text, runtimePorts, signal, actor.getSnapshot(), boundary);
|
|
883
1640
|
// Empty input, no-action classifier output, or invalid classifier
|
|
884
1641
|
// output — nothing to send.
|
|
885
1642
|
if (event === undefined) {
|
|
886
|
-
|
|
887
|
-
return;
|
|
1643
|
+
result = runResultFor('no-action');
|
|
888
1644
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
actor
|
|
901
|
-
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));
|
|
902
1663
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
// snapshot and returns (DR-004 §8 natural rejection).
|
|
910
|
-
await driveToQuiescence(actor);
|
|
911
|
-
// Drain transition emissions before returning so the Boss
|
|
912
|
-
// sees the final status line for this turn.
|
|
1664
|
+
}
|
|
1665
|
+
catch (error) {
|
|
1666
|
+
operationError = error;
|
|
1667
|
+
}
|
|
1668
|
+
let drainError;
|
|
1669
|
+
try {
|
|
913
1670
|
await drainEmissions();
|
|
914
1671
|
}
|
|
915
|
-
|
|
916
|
-
|
|
1672
|
+
catch (error) {
|
|
1673
|
+
drainError = error;
|
|
917
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;
|
|
918
1711
|
},
|
|
919
|
-
async
|
|
920
|
-
if (actor) {
|
|
921
|
-
|
|
922
|
-
actor = undefined;
|
|
1712
|
+
async resumePlaybookCall(input) {
|
|
1713
|
+
if (!actor || !savedPorts) {
|
|
1714
|
+
throw new Error('createPlaybookRuntime.resumePlaybookCall: init must be called first');
|
|
923
1715
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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;
|
|
928
1836
|
},
|
|
929
1837
|
// @internal — test-only escape hatch for inspecting the
|
|
930
1838
|
// underlying actor's snapshot. Most state assertions are now
|
|
@@ -935,24 +1843,12 @@ export default function createPlaybookRuntime(options) {
|
|
|
935
1843
|
_getActor() {
|
|
936
1844
|
return actor;
|
|
937
1845
|
},
|
|
1846
|
+
_getBoundary() {
|
|
1847
|
+
return boundary;
|
|
1848
|
+
},
|
|
1849
|
+
_getNestedBridge() {
|
|
1850
|
+
return nestedBridge;
|
|
1851
|
+
},
|
|
938
1852
|
};
|
|
939
1853
|
return runtime;
|
|
940
1854
|
}
|
|
941
|
-
function driveToQuiescence(actor) {
|
|
942
|
-
return new Promise((resolve) => {
|
|
943
|
-
if (isQuiescent(actor.getSnapshot())) {
|
|
944
|
-
resolve();
|
|
945
|
-
return;
|
|
946
|
-
}
|
|
947
|
-
const sub = actor.subscribe((snap) => {
|
|
948
|
-
if (isQuiescent(snap)) {
|
|
949
|
-
sub.unsubscribe();
|
|
950
|
-
resolve();
|
|
951
|
-
}
|
|
952
|
-
});
|
|
953
|
-
});
|
|
954
|
-
}
|
|
955
|
-
function isQuiescent(snap) {
|
|
956
|
-
const v = snap.value;
|
|
957
|
-
return typeof v === 'string' && QUIESCENT_STATES.has(v);
|
|
958
|
-
}
|