@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,15 +11,30 @@
|
|
|
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)
|
|
17
18
|
|
|
19
|
+
import PQueue from 'p-queue';
|
|
18
20
|
import { createActor, fromPromise } from 'xstate';
|
|
21
|
+
import type { InspectionEvent, SnapshotFrom } from 'xstate';
|
|
22
|
+
import {
|
|
23
|
+
assertPlaybookRuntimeSnapshot,
|
|
24
|
+
combineAbortSignals,
|
|
25
|
+
createNestedPlaybookBridge,
|
|
26
|
+
detachPersistedMachineSnapshot,
|
|
27
|
+
normalizeError,
|
|
28
|
+
normalizePlaybookSnapshot,
|
|
29
|
+
snapshotJsonValue,
|
|
30
|
+
snapshotPlaybookSession,
|
|
31
|
+
validatePlayerResult,
|
|
32
|
+
waitForPlaybookQuiescence,
|
|
33
|
+
} from '../../../src/xstate-runtime.js';
|
|
19
34
|
import {
|
|
20
35
|
codingMachine,
|
|
21
|
-
type
|
|
22
|
-
type
|
|
36
|
+
type PlayerInput,
|
|
37
|
+
type PlayerOutput,
|
|
23
38
|
type CodingEvent,
|
|
24
39
|
type CodingInput,
|
|
25
40
|
} from './code.fsm.js';
|
|
@@ -29,22 +44,88 @@ import {
|
|
|
29
44
|
enumerateRootEvents,
|
|
30
45
|
} from './code.fsm.introspect.js';
|
|
31
46
|
import type {
|
|
47
|
+
CaptainCallOptions,
|
|
48
|
+
CaptainResult,
|
|
49
|
+
JsonValue,
|
|
50
|
+
NormalizedError,
|
|
51
|
+
PlayerCallOptions,
|
|
52
|
+
PlaybookCallRequest,
|
|
53
|
+
PlaybookCallResult,
|
|
54
|
+
PlaybookCallStart,
|
|
55
|
+
PlaybookPendingCall,
|
|
56
|
+
PlaybookRunResult,
|
|
57
|
+
PlaybookRuntimeSnapshot,
|
|
58
|
+
PlaybookSession,
|
|
59
|
+
PlaybookState,
|
|
60
|
+
PlaybookStateValue,
|
|
61
|
+
PlaybookTraceEvent,
|
|
62
|
+
PlaybookTraceType,
|
|
32
63
|
PlaybookPorts,
|
|
33
64
|
PlaybookRuntime,
|
|
65
|
+
PlaybookRuntimeFactory,
|
|
34
66
|
PlayerResult,
|
|
35
67
|
} from '@sublang/playbook/runtime';
|
|
36
68
|
|
|
37
|
-
// Public contract. `PlayerResult`, `PlaybookPorts`, and
|
|
38
|
-
// are re-exported from the shared
|
|
69
|
+
// Public contract. `PlayerResult`, `PlaybookPorts`, `PlaybookSession`, and
|
|
70
|
+
// `PlaybookRuntime` are re-exported from the shared runtime module
|
|
39
71
|
// (slc/link.md §Output, DR-004 Addendum A4) so this playbook and any
|
|
40
72
|
// future one resolve one contract definition rather than redefining it.
|
|
41
73
|
// `CodePlaybookOptions` and the default `createPlaybookRuntime` factory
|
|
42
74
|
// (typed `PlaybookRuntimeFactory<CodePlaybookOptions>`) stay
|
|
43
75
|
// CODE-specific.
|
|
44
|
-
export type {
|
|
76
|
+
export type {
|
|
77
|
+
CaptainCallOptions,
|
|
78
|
+
CaptainResult,
|
|
79
|
+
JsonValue,
|
|
80
|
+
NormalizedError,
|
|
81
|
+
PlayerCallOptions,
|
|
82
|
+
PlaybookCallRequest,
|
|
83
|
+
PlaybookCallResult,
|
|
84
|
+
PlaybookCallStart,
|
|
85
|
+
PlaybookPendingCall,
|
|
86
|
+
PlaybookRunResult,
|
|
87
|
+
PlayerResult,
|
|
88
|
+
PlaybookPorts,
|
|
89
|
+
PlaybookSession,
|
|
90
|
+
PlaybookState,
|
|
91
|
+
PlaybookStateValue,
|
|
92
|
+
PlaybookTraceEvent,
|
|
93
|
+
PlaybookTraceType,
|
|
94
|
+
PlaybookRuntime,
|
|
95
|
+
PlaybookRuntimeFactory,
|
|
96
|
+
PlaybookRuntimeSnapshot,
|
|
97
|
+
};
|
|
45
98
|
|
|
46
99
|
export type CodePlaybookOptions = CodingInput;
|
|
47
100
|
|
|
101
|
+
function snapshotCodePlaybookOptions(value: unknown): CodePlaybookOptions {
|
|
102
|
+
const captured = snapshotJsonValue(value, 'CODE runtime options');
|
|
103
|
+
if (
|
|
104
|
+
captured === null ||
|
|
105
|
+
typeof captured !== 'object' ||
|
|
106
|
+
Array.isArray(captured)
|
|
107
|
+
) {
|
|
108
|
+
throw new TypeError('CODE runtime options must be an object');
|
|
109
|
+
}
|
|
110
|
+
const record = captured as Readonly<Record<string, JsonValue>>;
|
|
111
|
+
const allowed = new Set([
|
|
112
|
+
'intent',
|
|
113
|
+
'irNumber',
|
|
114
|
+
'coderPlayer',
|
|
115
|
+
'reviewerPlayer',
|
|
116
|
+
'committerPlayer',
|
|
117
|
+
]);
|
|
118
|
+
for (const [key, option] of Object.entries(record)) {
|
|
119
|
+
if (!allowed.has(key)) {
|
|
120
|
+
throw new TypeError(`CODE runtime options.${key} is not declared`);
|
|
121
|
+
}
|
|
122
|
+
if (typeof option !== 'string') {
|
|
123
|
+
throw new TypeError(`CODE runtime options.${key} must be a string`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return captured as unknown as CodePlaybookOptions;
|
|
127
|
+
}
|
|
128
|
+
|
|
48
129
|
const BOSS_REPLY_ERRORS = {
|
|
49
130
|
missingQuestion: "needsBossReply outcome missing 'question' field",
|
|
50
131
|
unregisteredState: (stateId: string) =>
|
|
@@ -68,19 +149,8 @@ function normalizeErrorCompact(
|
|
|
68
149
|
err: unknown,
|
|
69
150
|
): { name: string; message: string } | undefined {
|
|
70
151
|
if (err === undefined || err === null) return undefined;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
if (typeof err === 'object') {
|
|
75
|
-
const o = err as Record<string, unknown>;
|
|
76
|
-
if (typeof o.message === 'string') {
|
|
77
|
-
return {
|
|
78
|
-
name: typeof o.name === 'string' ? o.name : 'Error',
|
|
79
|
-
message: o.message,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return { name: 'Error', message: String(err) };
|
|
152
|
+
const normalized = normalizeError(err);
|
|
153
|
+
return { name: normalized.name, message: normalized.message };
|
|
84
154
|
}
|
|
85
155
|
|
|
86
156
|
// Normalize an unknown error value to the full `{ name, message, stack }`
|
|
@@ -89,30 +159,65 @@ function normalizeErrorCompact(
|
|
|
89
159
|
function normalizeErrorFull(
|
|
90
160
|
err: unknown,
|
|
91
161
|
): { name: string; message: string; stack?: string } | undefined {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return compact;
|
|
162
|
+
if (err === undefined || err === null) return undefined;
|
|
163
|
+
return normalizeError(err);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
|
|
167
|
+
return (
|
|
168
|
+
signal.aborted &&
|
|
169
|
+
(error === signal.reason || normalizeError(error).name === 'AbortError')
|
|
170
|
+
);
|
|
104
171
|
}
|
|
105
172
|
|
|
106
173
|
// Normalize any `error` field inside a telemetry event so failed
|
|
107
174
|
// transitions don't leak raw Error instances through the channel.
|
|
108
175
|
function normalizeEventForTelemetry(event: unknown): unknown {
|
|
109
|
-
if (event ===
|
|
110
|
-
|
|
176
|
+
if (event === undefined) return undefined;
|
|
177
|
+
return normalizeEventValue(event, 'FSM event', new Set());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizeEventValue(
|
|
181
|
+
value: unknown,
|
|
182
|
+
path: string,
|
|
183
|
+
ancestors: ReadonlySet<object>,
|
|
184
|
+
): JsonValue {
|
|
185
|
+
if (Array.isArray(value)) return snapshotJsonValue(value, path);
|
|
186
|
+
if (value === null || typeof value !== 'object') {
|
|
187
|
+
return snapshotJsonValue(value, path);
|
|
188
|
+
}
|
|
189
|
+
if (ancestors.has(value)) {
|
|
190
|
+
throw new TypeError(`${path} must not contain a JSON cycle`);
|
|
111
191
|
}
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
192
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
193
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
194
|
+
return snapshotJsonValue(value, path);
|
|
195
|
+
}
|
|
196
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
197
|
+
return snapshotJsonValue(value, path);
|
|
198
|
+
}
|
|
199
|
+
const nextAncestors = new Set(ancestors).add(value);
|
|
200
|
+
const normalized: Record<string, JsonValue> = {};
|
|
201
|
+
for (const [key, descriptor] of Object.entries(
|
|
202
|
+
Object.getOwnPropertyDescriptors(value),
|
|
203
|
+
)) {
|
|
204
|
+
if (!descriptor.enumerable) {
|
|
205
|
+
throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
|
|
206
|
+
}
|
|
207
|
+
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
208
|
+
throw new TypeError(`${path}.${key} must be a JSON data property`);
|
|
209
|
+
}
|
|
210
|
+
if (descriptor.value === undefined) continue;
|
|
211
|
+
normalized[key] =
|
|
212
|
+
key === 'error'
|
|
213
|
+
? snapshotJsonValue(normalizeError(descriptor.value), `${path}.error`)
|
|
214
|
+
: normalizeEventValue(
|
|
215
|
+
descriptor.value,
|
|
216
|
+
`${path}.${key}`,
|
|
217
|
+
nextAncestors,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return snapshotJsonValue(normalized, path);
|
|
116
221
|
}
|
|
117
222
|
|
|
118
223
|
// Internal capabilities (DR-004 §10). Each ships with its final
|
|
@@ -131,7 +236,7 @@ function normalizeEventForTelemetry(event: unknown): unknown {
|
|
|
131
236
|
// reply, the continuation preamble and Q/A blocks precede every
|
|
132
237
|
// ordinary block. The FSM's prompt body is never re-flowed.
|
|
133
238
|
|
|
134
|
-
function composePlayerPrompt(input:
|
|
239
|
+
function composePlayerPrompt(input: PlayerInput): string {
|
|
135
240
|
const blocks: string[] = [];
|
|
136
241
|
if (
|
|
137
242
|
input.pendingBossQuestion !== undefined &&
|
|
@@ -189,7 +294,7 @@ function composePlayerPrompt(input: CaptainInput): string {
|
|
|
189
294
|
// set. The alias selects only the host pane; it is not a PBRT-4
|
|
190
295
|
// identity string, so it leaves <coder-llm> / <reviewer-llm>
|
|
191
296
|
// untouched and `input.player` stays `Committer` (PLAYBOOK-3).
|
|
192
|
-
function resolvePlayerId(input:
|
|
297
|
+
function resolvePlayerId(input: PlayerInput): string {
|
|
193
298
|
switch (input.player) {
|
|
194
299
|
case 'Coder':
|
|
195
300
|
return 'coder';
|
|
@@ -202,13 +307,28 @@ function resolvePlayerId(input: CaptainInput): string {
|
|
|
202
307
|
return 'coder';
|
|
203
308
|
default: {
|
|
204
309
|
const exhaustive: never = input.player;
|
|
205
|
-
throw new Error(
|
|
206
|
-
`resolvePlayerId: unknown player ${String(exhaustive)}`,
|
|
207
|
-
);
|
|
310
|
+
throw new Error(`resolvePlayerId: unknown player ${String(exhaustive)}`);
|
|
208
311
|
}
|
|
209
312
|
}
|
|
210
313
|
}
|
|
211
314
|
|
|
315
|
+
type JudgePurpose = 'boss-input-classification' | 'player-output-adjudication';
|
|
316
|
+
|
|
317
|
+
interface RuntimeBoundaryCalls {
|
|
318
|
+
callPlayer(
|
|
319
|
+
input: PlayerInput,
|
|
320
|
+
playerId: string,
|
|
321
|
+
prompt: string,
|
|
322
|
+
signal: AbortSignal,
|
|
323
|
+
): Promise<PlayerResult>;
|
|
324
|
+
callJudge(
|
|
325
|
+
purpose: JudgePurpose,
|
|
326
|
+
stateId: string | undefined,
|
|
327
|
+
prompt: string,
|
|
328
|
+
signal: AbortSignal,
|
|
329
|
+
): Promise<string>;
|
|
330
|
+
}
|
|
331
|
+
|
|
212
332
|
// LLM judge — DR-004 §4. Builds a prompt that lists each declared
|
|
213
333
|
// outcome verbatim, asks ports.callJudge for a JSON
|
|
214
334
|
// `{ guard, …payloadFields }` response, and returns the parsed
|
|
@@ -216,13 +336,21 @@ function resolvePlayerId(input: CaptainInput): string {
|
|
|
216
336
|
// Adjudicator failures (malformed JSON, missing/unknown guard) are
|
|
217
337
|
// control-plane errors and propagate via throw per slc/link.md.
|
|
218
338
|
async function adjudicate(
|
|
219
|
-
input:
|
|
339
|
+
input: PlayerInput,
|
|
220
340
|
finalText: string,
|
|
221
341
|
ports: PlaybookPorts,
|
|
222
342
|
signal: AbortSignal,
|
|
223
|
-
|
|
343
|
+
boundary?: RuntimeBoundaryCalls,
|
|
344
|
+
): Promise<PlayerOutput> {
|
|
224
345
|
const prompt = buildJudgePrompt(input, finalText);
|
|
225
|
-
const raw =
|
|
346
|
+
const raw = boundary
|
|
347
|
+
? await boundary.callJudge(
|
|
348
|
+
'player-output-adjudication',
|
|
349
|
+
input.stateId,
|
|
350
|
+
prompt,
|
|
351
|
+
signal,
|
|
352
|
+
)
|
|
353
|
+
: await ports.callJudge(prompt, signal);
|
|
226
354
|
const parsed = parseJudgeJson(raw);
|
|
227
355
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
228
356
|
throw new Error('adjudicate: judge response is not a JSON object');
|
|
@@ -264,7 +392,7 @@ async function adjudicate(
|
|
|
264
392
|
);
|
|
265
393
|
}
|
|
266
394
|
}
|
|
267
|
-
return obj as
|
|
395
|
+
return obj as PlayerOutput;
|
|
268
396
|
}
|
|
269
397
|
|
|
270
398
|
function extractRequiredFields(description: string): string[] {
|
|
@@ -276,7 +404,7 @@ function extractRequiredFields(description: string): string[] {
|
|
|
276
404
|
return fields;
|
|
277
405
|
}
|
|
278
406
|
|
|
279
|
-
function buildJudgePrompt(input:
|
|
407
|
+
function buildJudgePrompt(input: PlayerInput, finalText: string): string {
|
|
280
408
|
const lines: string[] = [];
|
|
281
409
|
lines.push(`The ${input.player} just produced this output:`);
|
|
282
410
|
lines.push('');
|
|
@@ -444,11 +572,12 @@ async function classifyBossText(
|
|
|
444
572
|
ports: PlaybookPorts,
|
|
445
573
|
signal: AbortSignal,
|
|
446
574
|
snapshotOrState?: unknown,
|
|
575
|
+
boundary?: RuntimeBoundaryCalls,
|
|
447
576
|
): Promise<CodingEvent | undefined> {
|
|
448
577
|
const trimmed = text.trim();
|
|
449
578
|
if (trimmed === '') return undefined;
|
|
450
579
|
|
|
451
|
-
return classifyWithLlm(text, ports, signal, snapshotOrState);
|
|
580
|
+
return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
|
|
452
581
|
}
|
|
453
582
|
|
|
454
583
|
// JumpableStateId is internal to code.fsm.ts (not exported), so
|
|
@@ -469,10 +598,19 @@ async function classifyWithLlm(
|
|
|
469
598
|
ports: PlaybookPorts,
|
|
470
599
|
signal: AbortSignal,
|
|
471
600
|
snapshotOrState?: unknown,
|
|
601
|
+
boundary?: RuntimeBoundaryCalls,
|
|
472
602
|
): Promise<CodingEvent | undefined> {
|
|
473
603
|
const state = classifierState(snapshotOrState);
|
|
474
604
|
const prompt = buildClassifierPrompt(text, state);
|
|
475
|
-
const
|
|
605
|
+
const stateId = typeof state.value === 'string' ? state.value : undefined;
|
|
606
|
+
const raw = boundary
|
|
607
|
+
? await boundary.callJudge(
|
|
608
|
+
'boss-input-classification',
|
|
609
|
+
stateId,
|
|
610
|
+
prompt,
|
|
611
|
+
signal,
|
|
612
|
+
)
|
|
613
|
+
: await ports.callJudge(prompt, signal);
|
|
476
614
|
let parsed: unknown;
|
|
477
615
|
try {
|
|
478
616
|
parsed = parseJudgeJson(raw);
|
|
@@ -562,7 +700,36 @@ async function classifyWithLlm(
|
|
|
562
700
|
await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
|
|
563
701
|
return undefined;
|
|
564
702
|
}
|
|
565
|
-
|
|
703
|
+
const pending = pendingBossQuestionFromContext(state.context);
|
|
704
|
+
if (!pending) {
|
|
705
|
+
await ports.emitStatus(
|
|
706
|
+
'Classifier returned BOSS_REPLY without a pending question',
|
|
707
|
+
);
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
if (
|
|
711
|
+
payload.questionId !== undefined &&
|
|
712
|
+
typeof payload.questionId !== 'string'
|
|
713
|
+
) {
|
|
714
|
+
await ports.emitStatus(
|
|
715
|
+
'Classifier supplied a non-string questionId for BOSS_REPLY',
|
|
716
|
+
);
|
|
717
|
+
return undefined;
|
|
718
|
+
}
|
|
719
|
+
if (
|
|
720
|
+
typeof payload.questionId === 'string' &&
|
|
721
|
+
payload.questionId !== pending.questionId
|
|
722
|
+
) {
|
|
723
|
+
await ports.emitStatus(
|
|
724
|
+
`Classifier supplied unknown questionId for BOSS_REPLY: ${payload.questionId}`,
|
|
725
|
+
);
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
type: 'BOSS_REPLY',
|
|
730
|
+
answer: payload.answer,
|
|
731
|
+
questionId: pending.questionId,
|
|
732
|
+
};
|
|
566
733
|
}
|
|
567
734
|
default:
|
|
568
735
|
await ports.emitStatus(
|
|
@@ -602,7 +769,9 @@ function classifierState(snapshotOrState: unknown): ClassifierState {
|
|
|
602
769
|
|
|
603
770
|
function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
604
771
|
const currentState =
|
|
605
|
-
typeof state.value === 'string'
|
|
772
|
+
typeof state.value === 'string'
|
|
773
|
+
? state.value
|
|
774
|
+
: JSON.stringify(state.value ?? null);
|
|
606
775
|
const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
|
|
607
776
|
const lines = [
|
|
608
777
|
'Classify the following Boss message into exactly one of these events.',
|
|
@@ -613,8 +782,9 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
|
613
782
|
];
|
|
614
783
|
if (pendingBossQuestion !== undefined) {
|
|
615
784
|
lines.push(
|
|
785
|
+
`Pending question id: ${pendingBossQuestion.questionId}`,
|
|
786
|
+
`Pending asking player: ${pendingBossQuestion.player}`,
|
|
616
787
|
`Pending Boss question: ${pendingBossQuestion.question}`,
|
|
617
|
-
`Pending resume state: ${pendingBossQuestion.resumeStateId}`,
|
|
618
788
|
);
|
|
619
789
|
}
|
|
620
790
|
lines.push(
|
|
@@ -630,27 +800,24 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
|
630
800
|
lines.push(` - ${target.stateId}: ${target.description}`);
|
|
631
801
|
}
|
|
632
802
|
if (currentState === 'awaitBossReply') {
|
|
633
|
-
lines.push(
|
|
803
|
+
lines.push(
|
|
804
|
+
'- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }',
|
|
805
|
+
);
|
|
634
806
|
} else {
|
|
635
807
|
lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
|
|
636
808
|
}
|
|
637
|
-
lines.push(
|
|
638
|
-
'',
|
|
639
|
-
'Boss message:',
|
|
640
|
-
'```',
|
|
641
|
-
text,
|
|
642
|
-
'```',
|
|
643
|
-
);
|
|
809
|
+
lines.push('', 'Boss message:', '```', text, '```');
|
|
644
810
|
return lines.join('\n');
|
|
645
811
|
}
|
|
646
812
|
|
|
647
|
-
//
|
|
648
|
-
// codingMachine invokes from every
|
|
813
|
+
// Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
|
|
814
|
+
// codingMachine invokes from every player-invoking state. Per turn:
|
|
649
815
|
// resolve playerId, compose the player prompt, await
|
|
650
816
|
// ports.callPlayer, adjudicate the finalText. PlayerResult status of
|
|
651
817
|
// 'aborted' or 'error' throws so XState routes via onError → #failed
|
|
652
818
|
// (the single fail-stop sink for both Captain errors and player
|
|
653
|
-
// failures).
|
|
819
|
+
// failures). Captain remains the orchestrator and adjudicator; it is not
|
|
820
|
+
// encoded as the delegated FSM actor.
|
|
654
821
|
//
|
|
655
822
|
// `getActiveSignal` is the runtime's hook for flowing the Boss's
|
|
656
823
|
// `handleBossInput.signal` into the host port calls — fromPromise
|
|
@@ -660,34 +827,43 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
|
660
827
|
function captainBridge(
|
|
661
828
|
ports: PlaybookPorts,
|
|
662
829
|
getActiveSignal?: () => AbortSignal | undefined,
|
|
830
|
+
boundary?: RuntimeBoundaryCalls,
|
|
831
|
+
onControlPlaneError?: (error: unknown) => void,
|
|
663
832
|
) {
|
|
664
|
-
return fromPromise<
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
833
|
+
return fromPromise<PlayerOutput, PlayerInput>(async ({ input, signal }) => {
|
|
834
|
+
const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
|
|
835
|
+
const playerId = resolvePlayerId(input);
|
|
836
|
+
const prompt = composePlayerPrompt(input);
|
|
837
|
+
const result = boundary
|
|
838
|
+
? await boundary.callPlayer(input, playerId, prompt, activeSignal)
|
|
839
|
+
: await ports.callPlayer(playerId, prompt, activeSignal, {
|
|
840
|
+
resume: false,
|
|
841
|
+
});
|
|
842
|
+
if (result.status !== 'ok') {
|
|
843
|
+
throw new Error(
|
|
844
|
+
result.error ?? `captainBridge: callPlayer status "${result.status}"`,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
if (result.finalText === undefined) {
|
|
848
|
+
throw new Error(
|
|
849
|
+
'captainBridge: callPlayer returned status=ok with no finalText',
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
681
853
|
const output = await adjudicate(
|
|
682
854
|
input,
|
|
683
855
|
result.finalText,
|
|
684
856
|
ports,
|
|
685
857
|
activeSignal,
|
|
858
|
+
boundary,
|
|
686
859
|
);
|
|
687
860
|
validateBossReplyOutput(input, output);
|
|
688
861
|
return output;
|
|
689
|
-
}
|
|
690
|
-
|
|
862
|
+
} catch (error) {
|
|
863
|
+
onControlPlaneError?.(error);
|
|
864
|
+
throw error;
|
|
865
|
+
}
|
|
866
|
+
});
|
|
691
867
|
}
|
|
692
868
|
|
|
693
869
|
// Captain pane display — PBRT-3 / PBRT-14.
|
|
@@ -696,7 +872,7 @@ function captainBridge(
|
|
|
696
872
|
// glance:
|
|
697
873
|
// (no glyph) bare FSM event type — host renders as captain speech
|
|
698
874
|
// (e.g., `captain> START_CODING`)
|
|
699
|
-
// ⤷
|
|
875
|
+
// ⤷ player-invoking state entry: `<Player>: <label>`
|
|
700
876
|
// → transition guard outcome (`· field=N` tallies
|
|
701
877
|
// appended); the host presenter owns any visual
|
|
702
878
|
// nesting under the preceding ⤷ entry
|
|
@@ -735,7 +911,7 @@ const STATE_LABELS: Readonly<Record<string, string>> = {
|
|
|
735
911
|
};
|
|
736
912
|
|
|
737
913
|
interface StateMetadata {
|
|
738
|
-
player:
|
|
914
|
+
player: PlayerInput['player'];
|
|
739
915
|
sourceItem: string;
|
|
740
916
|
label: string;
|
|
741
917
|
}
|
|
@@ -746,7 +922,7 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
|
|
|
746
922
|
const label = STATE_LABELS[s.stateId];
|
|
747
923
|
if (!label) {
|
|
748
924
|
throw new Error(
|
|
749
|
-
`code.playbook.ts: STATE_LABELS missing entry for
|
|
925
|
+
`code.playbook.ts: STATE_LABELS missing entry for player-invoking state '${s.stateId}'`,
|
|
750
926
|
);
|
|
751
927
|
}
|
|
752
928
|
const input = s.getInput({});
|
|
@@ -755,13 +931,6 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
|
|
|
755
931
|
return m;
|
|
756
932
|
})();
|
|
757
933
|
|
|
758
|
-
const stateIdBySourceItem: ReadonlyMap<string, string> = new Map(
|
|
759
|
-
[...stateMetadata.entries()].map(([stateId, meta]) => [
|
|
760
|
-
meta.sourceItem,
|
|
761
|
-
stateId,
|
|
762
|
-
]),
|
|
763
|
-
);
|
|
764
|
-
|
|
765
934
|
const registeredResumableStateIds: ReadonlySet<string> = new Set(
|
|
766
935
|
enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map(
|
|
767
936
|
(transition) => transition.target,
|
|
@@ -769,18 +938,16 @@ const registeredResumableStateIds: ReadonlySet<string> = new Set(
|
|
|
769
938
|
);
|
|
770
939
|
|
|
771
940
|
function validateBossReplyOutput(
|
|
772
|
-
input:
|
|
773
|
-
output:
|
|
941
|
+
input: PlayerInput,
|
|
942
|
+
output: PlayerOutput,
|
|
774
943
|
): void {
|
|
775
944
|
if (output.guard !== 'needsBossReply') return;
|
|
776
945
|
if (typeof output.question !== 'string') {
|
|
777
946
|
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
778
947
|
}
|
|
779
|
-
const stateId =
|
|
780
|
-
if (
|
|
781
|
-
throw new Error(
|
|
782
|
-
BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem),
|
|
783
|
-
);
|
|
948
|
+
const stateId = input.stateId;
|
|
949
|
+
if (!registeredResumableStateIds.has(stateId)) {
|
|
950
|
+
throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId));
|
|
784
951
|
}
|
|
785
952
|
}
|
|
786
953
|
|
|
@@ -795,12 +962,9 @@ const QUIESCENT_STATES: ReadonlySet<string> = new Set([
|
|
|
795
962
|
// pane per PBRT-3: the readline returning to its `boss>` prompt is
|
|
796
963
|
// the implicit "turn over" signal, so a `◆ ready` / `◆ done`
|
|
797
964
|
// tombstone is redundant.
|
|
798
|
-
const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set([
|
|
799
|
-
'ready',
|
|
800
|
-
'done',
|
|
801
|
-
]);
|
|
965
|
+
const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
|
|
802
966
|
|
|
803
|
-
// Captain-pane surface (PBRT-3): every
|
|
967
|
+
// Captain-pane surface (PBRT-3): every player-invoking state plus
|
|
804
968
|
// the quiescent states whose entry still carries information
|
|
805
969
|
// (failure with `lastError`, awaitBossReply with the pending
|
|
806
970
|
// question). `ready` and `done` flow through the inspect handler
|
|
@@ -811,7 +975,12 @@ const CAPTAIN_PANE_STATES: ReadonlySet<string> = new Set([
|
|
|
811
975
|
...QUIESCENT_STATES,
|
|
812
976
|
]);
|
|
813
977
|
|
|
978
|
+
type BossReplyQuestionId = NonNullable<
|
|
979
|
+
Extract<CodingEvent, { type: 'BOSS_REPLY' }>['questionId']
|
|
980
|
+
>;
|
|
981
|
+
|
|
814
982
|
interface PendingBossQuestionForStatus {
|
|
983
|
+
questionId: BossReplyQuestionId;
|
|
815
984
|
resumeStateId: string;
|
|
816
985
|
sourceItem: string;
|
|
817
986
|
player: string;
|
|
@@ -830,6 +999,7 @@ function pendingBossQuestionFromContext(
|
|
|
830
999
|
>;
|
|
831
1000
|
if (
|
|
832
1001
|
typeof candidate.resumeStateId !== 'string' ||
|
|
1002
|
+
typeof candidate.questionId !== 'string' ||
|
|
833
1003
|
typeof candidate.sourceItem !== 'string' ||
|
|
834
1004
|
typeof candidate.player !== 'string' ||
|
|
835
1005
|
typeof candidate.question !== 'string'
|
|
@@ -837,6 +1007,7 @@ function pendingBossQuestionFromContext(
|
|
|
837
1007
|
return undefined;
|
|
838
1008
|
}
|
|
839
1009
|
return {
|
|
1010
|
+
questionId: candidate.questionId as BossReplyQuestionId,
|
|
840
1011
|
resumeStateId: candidate.resumeStateId,
|
|
841
1012
|
sourceItem: candidate.sourceItem,
|
|
842
1013
|
player: candidate.player,
|
|
@@ -864,9 +1035,7 @@ function formatAwaitBossReplyQuestion(
|
|
|
864
1035
|
// It carries only the resume target, asking player, and source item;
|
|
865
1036
|
// the former `q="<first 80 chars>"` excerpt rider is dropped now that
|
|
866
1037
|
// the full question rides the captain-speech line above.
|
|
867
|
-
function formatAwaitBossReplyMarker(
|
|
868
|
-
context: Record<string, unknown>,
|
|
869
|
-
): string {
|
|
1038
|
+
function formatAwaitBossReplyMarker(context: Record<string, unknown>): string {
|
|
870
1039
|
const pending = pendingBossQuestionFromContext(context);
|
|
871
1040
|
const resumeStateId = pending?.resumeStateId ?? 'unknown';
|
|
872
1041
|
const player = pending?.player ?? 'unknown';
|
|
@@ -912,9 +1081,9 @@ function stateTelemetryPayload(
|
|
|
912
1081
|
context: Record<string, unknown>,
|
|
913
1082
|
): Record<string, unknown> {
|
|
914
1083
|
const payload: Record<string, unknown> = {
|
|
915
|
-
from,
|
|
1084
|
+
from: from ?? null,
|
|
916
1085
|
to,
|
|
917
|
-
event: normalizeEventForTelemetry(event),
|
|
1086
|
+
event: normalizeEventForTelemetry(event) ?? null,
|
|
918
1087
|
};
|
|
919
1088
|
if (to === 'awaitBossReply') {
|
|
920
1089
|
const pendingBossQuestion = pendingBossQuestionFromContext(context);
|
|
@@ -931,6 +1100,32 @@ function stateTelemetryPayload(
|
|
|
931
1100
|
return payload;
|
|
932
1101
|
}
|
|
933
1102
|
|
|
1103
|
+
function structuredStateTelemetryPayload(
|
|
1104
|
+
previousState: PlaybookState | undefined,
|
|
1105
|
+
state: PlaybookState,
|
|
1106
|
+
event: unknown,
|
|
1107
|
+
context: Record<string, unknown>,
|
|
1108
|
+
): JsonValue {
|
|
1109
|
+
const payload: Record<string, unknown> = {
|
|
1110
|
+
from: previousState?.value ?? null,
|
|
1111
|
+
to: state.value,
|
|
1112
|
+
event: normalizeEventForTelemetry(event) ?? null,
|
|
1113
|
+
previousState: previousState ?? null,
|
|
1114
|
+
state,
|
|
1115
|
+
};
|
|
1116
|
+
if (state.stateId === 'awaitBossReply') {
|
|
1117
|
+
const pendingBossQuestion = pendingBossQuestionFromContext(context);
|
|
1118
|
+
if (pendingBossQuestion !== undefined) {
|
|
1119
|
+
payload.pendingBossQuestion = pendingBossQuestion;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
if (state.stateId === 'failed') {
|
|
1123
|
+
const lastError = normalizeErrorFull(context.lastError);
|
|
1124
|
+
if (lastError !== undefined) payload.lastError = lastError;
|
|
1125
|
+
}
|
|
1126
|
+
return snapshotJsonValue(payload, 'FSM telemetry payload');
|
|
1127
|
+
}
|
|
1128
|
+
|
|
934
1129
|
// Internal export surface for tests. Not part of the stable public API;
|
|
935
1130
|
// the leading underscore signals "subject to change." Each member is
|
|
936
1131
|
// referenced here so `noUnusedLocals` stays clean while later tasks
|
|
@@ -956,125 +1151,946 @@ export const _internal = {
|
|
|
956
1151
|
VERBATIM_PAYLOAD_FIELDS,
|
|
957
1152
|
};
|
|
958
1153
|
|
|
1154
|
+
type BossSettlementOutcome =
|
|
1155
|
+
| 'no-action'
|
|
1156
|
+
| 'quiescent'
|
|
1157
|
+
| 'failed'
|
|
1158
|
+
| 'terminal'
|
|
1159
|
+
| 'aborted'
|
|
1160
|
+
| 'suspended';
|
|
1161
|
+
|
|
1162
|
+
interface TracePosition {
|
|
1163
|
+
turnId?: number;
|
|
1164
|
+
callId?: string;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
959
1167
|
export default function createPlaybookRuntime(
|
|
960
1168
|
options: CodePlaybookOptions,
|
|
961
1169
|
): PlaybookRuntime {
|
|
1170
|
+
const boundOptions = snapshotCodePlaybookOptions(options);
|
|
962
1171
|
let actor: ReturnType<typeof createActor> | undefined;
|
|
1172
|
+
let session: PlaybookSession | undefined;
|
|
1173
|
+
let initialized = false;
|
|
1174
|
+
let initInFlight: Promise<void> | undefined;
|
|
1175
|
+
let disposalPromise: Promise<void> | undefined;
|
|
1176
|
+
let disposed = false;
|
|
963
1177
|
let savedPorts: PlaybookPorts | undefined;
|
|
1178
|
+
let runtimePorts: PlaybookPorts | undefined;
|
|
964
1179
|
// The Boss's per-turn AbortSignal, surfaced to captainBridge so
|
|
965
1180
|
// ports.callPlayer / callJudge see the right cancellation source.
|
|
966
1181
|
// null between turns; set by handleBossInput.
|
|
967
1182
|
let activeSignal: AbortSignal | undefined;
|
|
1183
|
+
let activeTurnId: number | undefined;
|
|
1184
|
+
let controlPlaneError: unknown;
|
|
968
1185
|
// Previous root-machine state for the inspect-driven telemetry /
|
|
969
1186
|
// status emitter. undefined before the first inspect firing.
|
|
970
|
-
let priorState:
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
let
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1187
|
+
let priorState: PlaybookState | undefined;
|
|
1188
|
+
let suppressInspectionEmissions = false;
|
|
1189
|
+
|
|
1190
|
+
let traceSequence = 0;
|
|
1191
|
+
let turnSequence = 0;
|
|
1192
|
+
let judgeCallSequence = 0;
|
|
1193
|
+
let playerCallSequence = 0;
|
|
1194
|
+
let playbookCallSequence = 0;
|
|
1195
|
+
const playerResumeTokens = new Map<string, string>();
|
|
1196
|
+
const activePlayerIds = new Set<string>();
|
|
1197
|
+
const playbookCallTurnIds = new Map<string, number | undefined>();
|
|
1198
|
+
const judgeQueue = new PQueue({ concurrency: 1 });
|
|
1199
|
+
const emissionQueue = new PQueue({ concurrency: 1 });
|
|
1200
|
+
const activeEmissionCalls = new Set<Promise<void>>();
|
|
1201
|
+
|
|
1202
|
+
// All trace, state-telemetry, and status work shares this one queue.
|
|
1203
|
+
// Inspection callbacks enqueue a complete ordered batch synchronously;
|
|
1204
|
+
// imperative boundaries await their queued work directly.
|
|
1205
|
+
let emissionFailure: unknown;
|
|
1206
|
+
|
|
1207
|
+
function enqueueEmission(fn: () => Promise<void>): Promise<void> {
|
|
1208
|
+
const queued = emissionQueue.add(fn).then(() => undefined);
|
|
1209
|
+
activeEmissionCalls.add(queued);
|
|
1210
|
+
void queued.then(
|
|
1211
|
+
() => activeEmissionCalls.delete(queued),
|
|
1212
|
+
(error: unknown) => {
|
|
1213
|
+
activeEmissionCalls.delete(queued);
|
|
1214
|
+
emissionFailure ??= error;
|
|
1215
|
+
},
|
|
1216
|
+
);
|
|
1217
|
+
return queued;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
async function drainEmissions(): Promise<void> {
|
|
1221
|
+
while (true) {
|
|
1222
|
+
const active = [...activeEmissionCalls];
|
|
1223
|
+
if (active.length > 0) await Promise.allSettled(active);
|
|
1224
|
+
await emissionQueue.onIdle();
|
|
1225
|
+
if (
|
|
1226
|
+
activeEmissionCalls.size === 0 &&
|
|
1227
|
+
emissionQueue.size === 0 &&
|
|
1228
|
+
emissionQueue.pending === 0
|
|
1229
|
+
) {
|
|
1230
|
+
break;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (emissionFailure !== undefined) {
|
|
1234
|
+
const error = emissionFailure;
|
|
1235
|
+
emissionFailure = undefined;
|
|
1236
|
+
throw error;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function requireSession(): PlaybookSession {
|
|
1241
|
+
if (!session) {
|
|
1242
|
+
throw new Error('createPlaybookRuntime: init must be called first');
|
|
1243
|
+
}
|
|
1244
|
+
return session;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function requireHostPorts(): PlaybookPorts {
|
|
1248
|
+
if (!savedPorts) {
|
|
1249
|
+
throw new Error('createPlaybookRuntime: init must be called first');
|
|
1250
|
+
}
|
|
1251
|
+
return savedPorts;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function createTraceEvent(
|
|
1255
|
+
type: PlaybookTraceType,
|
|
1256
|
+
payload: unknown,
|
|
1257
|
+
position: TracePosition = {},
|
|
1258
|
+
): PlaybookTraceEvent {
|
|
1259
|
+
const currentSession = requireSession();
|
|
1260
|
+
const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
1261
|
+
return {
|
|
1262
|
+
schemaVersion: 2,
|
|
1263
|
+
sessionId: currentSession.sessionId,
|
|
1264
|
+
playbookId: currentSession.playbookId,
|
|
1265
|
+
rootSessionId: currentSession.rootSessionId,
|
|
1266
|
+
...(currentSession.parentSessionId !== undefined
|
|
1267
|
+
? { parentSessionId: currentSession.parentSessionId }
|
|
1268
|
+
: {}),
|
|
1269
|
+
...(currentSession.parentCallId !== undefined
|
|
1270
|
+
? { parentCallId: currentSession.parentCallId }
|
|
1271
|
+
: {}),
|
|
1272
|
+
depth: currentSession.depth,
|
|
1273
|
+
sequence: ++traceSequence,
|
|
1274
|
+
timestamp: Date.now(),
|
|
1275
|
+
type,
|
|
1276
|
+
...(position.turnId !== undefined ? { turnId: position.turnId } : {}),
|
|
1277
|
+
...(position.callId !== undefined ? { callId: position.callId } : {}),
|
|
1278
|
+
payload: safePayload,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function emitTrace(
|
|
1283
|
+
type: PlaybookTraceType,
|
|
1284
|
+
payload: unknown,
|
|
1285
|
+
position: TracePosition = {},
|
|
1286
|
+
): Promise<void> {
|
|
1287
|
+
const currentSession = requireSession();
|
|
1288
|
+
const event = createTraceEvent(type, payload, position);
|
|
1289
|
+
return enqueueEmission(() =>
|
|
1290
|
+
currentSession.ports.emitTelemetry({
|
|
1291
|
+
topic: 'playbook.trace',
|
|
1292
|
+
payload: event,
|
|
1293
|
+
}),
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function stateIdentity(stateId: string | undefined): { stateId?: string } {
|
|
1298
|
+
return stateId === undefined ? {} : { stateId };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function currentState(): PlaybookState {
|
|
1302
|
+
if (!actor) {
|
|
1303
|
+
throw new Error('createPlaybookRuntime: actor is not initialized');
|
|
1304
|
+
}
|
|
1305
|
+
return normalizePlaybookSnapshot(actor.getSnapshot(), {
|
|
1306
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function stateTracePayload(state = currentState()): Record<string, unknown> {
|
|
1311
|
+
return {
|
|
1312
|
+
state,
|
|
1313
|
+
...stateIdentity(state.stateId),
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function createRuntimePorts(hostPorts: PlaybookPorts): PlaybookPorts {
|
|
1318
|
+
return {
|
|
1319
|
+
callPlayer: (playerId, prompt, signal, callOptions) =>
|
|
1320
|
+
hostPorts.callPlayer(playerId, prompt, signal, callOptions),
|
|
1321
|
+
callCaptain: (prompt, signal, callOptions) =>
|
|
1322
|
+
hostPorts.callCaptain(prompt, signal, callOptions),
|
|
1323
|
+
callJudge: (prompt, signal) => hostPorts.callJudge(prompt, signal),
|
|
1324
|
+
callPlaybook: (request, signal) =>
|
|
1325
|
+
hostPorts.callPlaybook(request, signal),
|
|
1326
|
+
emitStatus: (message, data) => {
|
|
1327
|
+
const descriptor = actor ? currentState() : undefined;
|
|
1328
|
+
const safeData =
|
|
1329
|
+
data === undefined
|
|
1330
|
+
? undefined
|
|
1331
|
+
: snapshotJsonValue(data, 'status data');
|
|
1332
|
+
const trace = createTraceEvent(
|
|
1333
|
+
'status.emitted',
|
|
1334
|
+
{
|
|
1335
|
+
message,
|
|
1336
|
+
...(safeData !== undefined ? { data: safeData } : {}),
|
|
1337
|
+
...(descriptor !== undefined
|
|
1338
|
+
? {
|
|
1339
|
+
state: descriptor,
|
|
1340
|
+
...stateIdentity(descriptor.stateId),
|
|
1341
|
+
}
|
|
1342
|
+
: {}),
|
|
1343
|
+
},
|
|
1344
|
+
activeTurnId !== undefined ? { turnId: activeTurnId } : {},
|
|
1345
|
+
);
|
|
1346
|
+
return enqueueEmission(async () => {
|
|
1347
|
+
await hostPorts.emitTelemetry({
|
|
1348
|
+
topic: 'playbook.trace',
|
|
1349
|
+
payload: trace,
|
|
1350
|
+
});
|
|
1351
|
+
await hostPorts.emitStatus(message, safeData);
|
|
1352
|
+
});
|
|
1353
|
+
},
|
|
1354
|
+
emitTelemetry: (event) => {
|
|
1355
|
+
if (typeof event.topic !== 'string' || event.topic.length === 0) {
|
|
1356
|
+
throw new TypeError('telemetry topic must be a non-empty string');
|
|
1357
|
+
}
|
|
1358
|
+
const payload = snapshotJsonValue(event.payload, 'telemetry payload');
|
|
1359
|
+
return enqueueEmission(() =>
|
|
1360
|
+
hostPorts.emitTelemetry({ topic: event.topic, payload }),
|
|
1361
|
+
);
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function emitCallStarted(
|
|
1367
|
+
startedType: 'player.call.started' | 'judge.call.started',
|
|
1368
|
+
finishedType: 'player.call.finished' | 'judge.call.finished',
|
|
1369
|
+
identity: Record<string, unknown>,
|
|
1370
|
+
position: TracePosition,
|
|
1371
|
+
): Promise<void> {
|
|
1372
|
+
try {
|
|
1373
|
+
await emitTrace(startedType, identity, position);
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
controlPlaneError ??= error;
|
|
1376
|
+
try {
|
|
1377
|
+
await emitTrace(
|
|
1378
|
+
finishedType,
|
|
1379
|
+
{ ...identity, status: 'error', error: normalizeError(error) },
|
|
1380
|
+
position,
|
|
1381
|
+
);
|
|
1382
|
+
} catch {
|
|
1383
|
+
// Preserve the start failure after one best-effort finish attempt.
|
|
1384
|
+
}
|
|
1385
|
+
throw error;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const boundary: RuntimeBoundaryCalls = {
|
|
1390
|
+
async callPlayer(input, playerId, prompt, signal): Promise<PlayerResult> {
|
|
1391
|
+
// State-entry telemetry/status must precede the call they describe.
|
|
1392
|
+
await drainEmissions();
|
|
1393
|
+
const turnId = activeTurnId;
|
|
1394
|
+
const callId = `player-${++playerCallSequence}`;
|
|
1395
|
+
const stateId = input.stateId;
|
|
1396
|
+
const resume = playerResumeTokens.get(playerId) ?? false;
|
|
1397
|
+
const identity = {
|
|
1398
|
+
purpose: 'captain' as const,
|
|
1399
|
+
...stateIdentity(stateId),
|
|
1400
|
+
sourceItem: input.sourceItem,
|
|
1401
|
+
playerId,
|
|
1402
|
+
resume,
|
|
1403
|
+
};
|
|
1404
|
+
|
|
1405
|
+
if (activePlayerIds.has(playerId)) {
|
|
1406
|
+
const error = new Error(
|
|
1407
|
+
`simultaneous calls to resolved player ${playerId} are not allowed`,
|
|
1408
|
+
);
|
|
1409
|
+
await emitCallStarted(
|
|
1410
|
+
'player.call.started',
|
|
1411
|
+
'player.call.finished',
|
|
1412
|
+
{ ...identity, prompt },
|
|
1413
|
+
{
|
|
1414
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1415
|
+
callId,
|
|
1416
|
+
},
|
|
1417
|
+
);
|
|
1418
|
+
await emitTrace(
|
|
1419
|
+
'player.call.finished',
|
|
1420
|
+
{ ...identity, status: 'error', error: normalizeError(error) },
|
|
1421
|
+
{
|
|
1422
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1423
|
+
callId,
|
|
1424
|
+
},
|
|
1425
|
+
);
|
|
1426
|
+
throw error;
|
|
1427
|
+
}
|
|
1428
|
+
activePlayerIds.add(playerId);
|
|
1429
|
+
|
|
1430
|
+
try {
|
|
1431
|
+
await emitTrace(
|
|
1432
|
+
'player.call.started',
|
|
1433
|
+
{ ...identity, prompt },
|
|
1434
|
+
{
|
|
1435
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1436
|
+
callId,
|
|
1437
|
+
},
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
let rawResult: unknown;
|
|
1441
|
+
try {
|
|
1442
|
+
rawResult = await requireHostPorts().callPlayer(
|
|
1443
|
+
playerId,
|
|
1444
|
+
prompt,
|
|
1445
|
+
signal,
|
|
1446
|
+
{ resume },
|
|
1447
|
+
);
|
|
1448
|
+
// A host promise is not required to honor cancellation. Do not let
|
|
1449
|
+
// a late result mutate continuity or publish a successful finish.
|
|
1450
|
+
signal.throwIfAborted();
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
if (!signal.aborted) controlPlaneError ??= error;
|
|
984
1453
|
try {
|
|
985
|
-
await
|
|
1454
|
+
await emitTrace(
|
|
1455
|
+
'player.call.finished',
|
|
1456
|
+
{
|
|
1457
|
+
...identity,
|
|
1458
|
+
status: signal.aborted ? 'aborted' : 'error',
|
|
1459
|
+
error: normalizeError(error),
|
|
1460
|
+
},
|
|
1461
|
+
{
|
|
1462
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1463
|
+
callId,
|
|
1464
|
+
},
|
|
1465
|
+
);
|
|
986
1466
|
} catch {
|
|
987
|
-
//
|
|
988
|
-
// surfaces real failures via handleBossInput throws.
|
|
1467
|
+
// The original non-abort port rejection remains authoritative.
|
|
989
1468
|
}
|
|
1469
|
+
// A thrown port call carries no authoritative result, so the
|
|
1470
|
+
// prior token remains available for a later explicit resume.
|
|
1471
|
+
throw error;
|
|
990
1472
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1473
|
+
|
|
1474
|
+
let result: PlayerResult;
|
|
1475
|
+
try {
|
|
1476
|
+
result = validatePlayerResult(rawResult);
|
|
1477
|
+
} catch (error) {
|
|
1478
|
+
if (!signal.aborted) controlPlaneError ??= error;
|
|
1479
|
+
try {
|
|
1480
|
+
await emitTrace(
|
|
1481
|
+
'player.call.finished',
|
|
1482
|
+
{ ...identity, status: 'error', error: normalizeError(error) },
|
|
1483
|
+
{
|
|
1484
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1485
|
+
callId,
|
|
1486
|
+
},
|
|
1487
|
+
);
|
|
1488
|
+
} catch {
|
|
1489
|
+
// The malformed host result remains authoritative.
|
|
1490
|
+
}
|
|
1491
|
+
throw error;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
if (
|
|
1495
|
+
typeof result.resumeToken === 'string' &&
|
|
1496
|
+
result.resumeToken.trim().length > 0
|
|
1497
|
+
) {
|
|
1498
|
+
playerResumeTokens.set(playerId, result.resumeToken);
|
|
1499
|
+
} else {
|
|
1500
|
+
playerResumeTokens.delete(playerId);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
await emitTrace(
|
|
1504
|
+
'player.call.finished',
|
|
1505
|
+
{
|
|
1506
|
+
...identity,
|
|
1507
|
+
status: result.status,
|
|
1508
|
+
...(result.finalText !== undefined
|
|
1509
|
+
? { finalText: result.finalText }
|
|
1510
|
+
: {}),
|
|
1511
|
+
...(result.error !== undefined
|
|
1512
|
+
? { error: normalizeError(result.error) }
|
|
1513
|
+
: {}),
|
|
1514
|
+
...(result.resumeToken !== undefined
|
|
1515
|
+
? { resumeToken: result.resumeToken }
|
|
1516
|
+
: {}),
|
|
1517
|
+
},
|
|
1518
|
+
{
|
|
1519
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1520
|
+
callId,
|
|
1521
|
+
},
|
|
1522
|
+
);
|
|
1523
|
+
return result;
|
|
1524
|
+
} finally {
|
|
1525
|
+
activePlayerIds.delete(playerId);
|
|
1526
|
+
}
|
|
1527
|
+
},
|
|
1528
|
+
|
|
1529
|
+
async callJudge(purpose, stateId, prompt, signal): Promise<string> {
|
|
1530
|
+
return judgeQueue.add(async () => {
|
|
1531
|
+
signal.throwIfAborted();
|
|
1532
|
+
// A transition/status queued synchronously by XState must reach
|
|
1533
|
+
// the host before the judge call that follows it.
|
|
1534
|
+
await drainEmissions();
|
|
1535
|
+
signal.throwIfAborted();
|
|
1536
|
+
const turnId = activeTurnId;
|
|
1537
|
+
const callId = `judge-${++judgeCallSequence}`;
|
|
1538
|
+
const identity = { purpose, ...stateIdentity(stateId) };
|
|
1539
|
+
|
|
1540
|
+
await emitCallStarted(
|
|
1541
|
+
'judge.call.started',
|
|
1542
|
+
'judge.call.finished',
|
|
1543
|
+
{ ...identity, prompt },
|
|
1544
|
+
{
|
|
1545
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1546
|
+
callId,
|
|
1547
|
+
},
|
|
1548
|
+
);
|
|
1549
|
+
let reply: unknown;
|
|
1550
|
+
try {
|
|
1551
|
+
reply = await requireHostPorts().callJudge(prompt, signal);
|
|
1552
|
+
signal.throwIfAborted();
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
if (!isAbortFailure(error, signal)) {
|
|
1555
|
+
controlPlaneError ??= error;
|
|
1556
|
+
}
|
|
1557
|
+
await emitTrace(
|
|
1558
|
+
'judge.call.finished',
|
|
1559
|
+
{
|
|
1560
|
+
...identity,
|
|
1561
|
+
status: signal.aborted ? 'aborted' : 'error',
|
|
1562
|
+
error: normalizeError(error),
|
|
1563
|
+
},
|
|
1564
|
+
{
|
|
1565
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1566
|
+
callId,
|
|
1567
|
+
},
|
|
1568
|
+
);
|
|
1569
|
+
throw error;
|
|
1570
|
+
}
|
|
1571
|
+
if (typeof reply !== 'string') {
|
|
1572
|
+
const error = new TypeError('judge reply must be a string');
|
|
1573
|
+
controlPlaneError ??= error;
|
|
1574
|
+
await emitTrace(
|
|
1575
|
+
'judge.call.finished',
|
|
1576
|
+
{ ...identity, status: 'error', error: normalizeError(error) },
|
|
1577
|
+
{
|
|
1578
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1579
|
+
callId,
|
|
1580
|
+
},
|
|
1581
|
+
);
|
|
1582
|
+
throw error;
|
|
1583
|
+
}
|
|
1584
|
+
// Keep the success finish outside the port-call catch. If a
|
|
1585
|
+
// telemetry sink records this boundary and then rejects, that sink
|
|
1586
|
+
// failure must not synthesize a second, contradictory finish.
|
|
1587
|
+
await emitTrace(
|
|
1588
|
+
'judge.call.finished',
|
|
1589
|
+
{ ...identity, status: 'ok', reply },
|
|
1590
|
+
{
|
|
1591
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1592
|
+
callId,
|
|
1593
|
+
},
|
|
1594
|
+
);
|
|
1595
|
+
return reply;
|
|
1596
|
+
});
|
|
1597
|
+
},
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
const nestedBridge = createNestedPlaybookBridge({
|
|
1601
|
+
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
1602
|
+
getBoundarySignal: () => activeSignal,
|
|
1603
|
+
callPlaybook: (request, signal) =>
|
|
1604
|
+
requireHostPorts().callPlaybook(request, signal),
|
|
1605
|
+
emitStarted: async (event) => {
|
|
1606
|
+
playbookCallTurnIds.set(event.callId, activeTurnId);
|
|
1607
|
+
await emitTrace(
|
|
1608
|
+
'playbook.call.started',
|
|
1609
|
+
{
|
|
1610
|
+
stateId: event.stateId,
|
|
1611
|
+
playbookId: event.playbookId,
|
|
1612
|
+
text: event.text,
|
|
1613
|
+
},
|
|
1614
|
+
{
|
|
1615
|
+
...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
|
|
1616
|
+
callId: event.callId,
|
|
1617
|
+
},
|
|
1618
|
+
);
|
|
1619
|
+
},
|
|
1620
|
+
emitFinished: async (event) => {
|
|
1621
|
+
const turnId = playbookCallTurnIds.get(event.callId);
|
|
1622
|
+
try {
|
|
1623
|
+
await emitTrace(
|
|
1624
|
+
'playbook.call.finished',
|
|
1625
|
+
{
|
|
1626
|
+
stateId: event.stateId,
|
|
1627
|
+
playbookId: event.playbookId,
|
|
1628
|
+
text: event.text,
|
|
1629
|
+
result: event.result,
|
|
1630
|
+
},
|
|
1631
|
+
{
|
|
1632
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1633
|
+
callId: event.callId,
|
|
1634
|
+
},
|
|
1635
|
+
);
|
|
1636
|
+
} finally {
|
|
1637
|
+
playbookCallTurnIds.delete(event.callId);
|
|
1638
|
+
}
|
|
1639
|
+
},
|
|
1640
|
+
drain: drainEmissions,
|
|
1641
|
+
bindResumeSignal: (signal) => {
|
|
1642
|
+
activeSignal = signal;
|
|
1643
|
+
},
|
|
1644
|
+
onControlPlaneError: (error) => {
|
|
1645
|
+
if (!activeSignal?.aborted) controlPlaneError ??= error;
|
|
1646
|
+
},
|
|
1647
|
+
onBackgroundError: (error) => {
|
|
1648
|
+
emissionFailure ??= error;
|
|
1649
|
+
},
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
function tracePositionForActiveTurn(): TracePosition {
|
|
1653
|
+
return activeTurnId === undefined ? {} : { turnId: activeTurnId };
|
|
994
1654
|
}
|
|
995
1655
|
|
|
996
|
-
|
|
997
|
-
|
|
1656
|
+
interface ScheduledStatus {
|
|
1657
|
+
message: string;
|
|
1658
|
+
data?: JsonValue;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function enqueueTransitionEmission(
|
|
1662
|
+
payload: JsonValue,
|
|
1663
|
+
state: PlaybookState,
|
|
1664
|
+
statuses: readonly ScheduledStatus[],
|
|
1665
|
+
position: TracePosition,
|
|
1666
|
+
): void {
|
|
1667
|
+
const currentSession = requireSession();
|
|
1668
|
+
const transitionTrace = createTraceEvent(
|
|
1669
|
+
'fsm.transition',
|
|
1670
|
+
payload,
|
|
1671
|
+
position,
|
|
1672
|
+
);
|
|
1673
|
+
const statusEmissions = statuses.map(({ message, data }) => ({
|
|
1674
|
+
message,
|
|
1675
|
+
data,
|
|
1676
|
+
trace: createTraceEvent(
|
|
1677
|
+
'status.emitted',
|
|
1678
|
+
{
|
|
1679
|
+
message,
|
|
1680
|
+
...(data === undefined ? {} : { data }),
|
|
1681
|
+
state,
|
|
1682
|
+
...stateIdentity(state.stateId),
|
|
1683
|
+
},
|
|
1684
|
+
position,
|
|
1685
|
+
),
|
|
1686
|
+
}));
|
|
1687
|
+
void enqueueEmission(async () => {
|
|
1688
|
+
await currentSession.ports.emitTelemetry({
|
|
1689
|
+
topic: 'playbook.trace',
|
|
1690
|
+
payload: transitionTrace,
|
|
1691
|
+
});
|
|
1692
|
+
await currentSession.ports.emitTelemetry({
|
|
1693
|
+
topic: 'playbook.fsm.state',
|
|
1694
|
+
payload,
|
|
1695
|
+
});
|
|
1696
|
+
for (const status of statusEmissions) {
|
|
1697
|
+
await currentSession.ports.emitTelemetry({
|
|
1698
|
+
topic: 'playbook.trace',
|
|
1699
|
+
payload: status.trace,
|
|
1700
|
+
});
|
|
1701
|
+
await currentSession.ports.emitStatus(status.message, status.data);
|
|
1702
|
+
}
|
|
1703
|
+
}).catch(() => undefined);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function latchInspectionError(error: unknown): void {
|
|
1707
|
+
if (activeSignal !== undefined) controlPlaneError ??= error;
|
|
1708
|
+
else emissionFailure ??= error;
|
|
998
1709
|
}
|
|
999
1710
|
|
|
1000
1711
|
function buildActor(
|
|
1001
1712
|
ports: PlaybookPorts,
|
|
1713
|
+
machineSnapshot?: JsonValue,
|
|
1002
1714
|
): ReturnType<typeof createActor> {
|
|
1003
1715
|
priorState = undefined;
|
|
1004
|
-
|
|
1716
|
+
let builtActor: ReturnType<typeof createActor>;
|
|
1717
|
+
builtActor = createActor(
|
|
1005
1718
|
codingMachine.provide({
|
|
1006
|
-
actors: {
|
|
1719
|
+
actors: {
|
|
1720
|
+
player: captainBridge(
|
|
1721
|
+
ports,
|
|
1722
|
+
() => activeSignal,
|
|
1723
|
+
boundary,
|
|
1724
|
+
(error) => {
|
|
1725
|
+
if (!activeSignal?.aborted) controlPlaneError ??= error;
|
|
1726
|
+
},
|
|
1727
|
+
),
|
|
1728
|
+
},
|
|
1007
1729
|
}),
|
|
1008
1730
|
{
|
|
1009
|
-
input:
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
if (typeof snap.value !== 'string') return;
|
|
1019
|
-
const to = snap.value;
|
|
1020
|
-
if (priorState === to) return;
|
|
1021
|
-
const from = priorState;
|
|
1022
|
-
priorState = to;
|
|
1023
|
-
// Telemetry on every transition (PBRT-14).
|
|
1024
|
-
const context = snap.context ?? {};
|
|
1025
|
-
enqueueEmit(() =>
|
|
1026
|
-
ports.emitTelemetry({
|
|
1027
|
-
topic: 'playbook.fsm.state',
|
|
1028
|
-
payload: stateTelemetryPayload(
|
|
1029
|
-
from,
|
|
1030
|
-
to,
|
|
1031
|
-
inspectionEvent.event,
|
|
1032
|
-
context,
|
|
1033
|
-
),
|
|
1731
|
+
input: boundOptions,
|
|
1732
|
+
// DR-014 §1: a restore rehydrates the persisted machine snapshot;
|
|
1733
|
+
// XState derives context/value from it and ignores `input` then.
|
|
1734
|
+
...(machineSnapshot === undefined
|
|
1735
|
+
? {}
|
|
1736
|
+
: {
|
|
1737
|
+
snapshot: machineSnapshot as unknown as SnapshotFrom<
|
|
1738
|
+
typeof codingMachine
|
|
1739
|
+
>,
|
|
1034
1740
|
}),
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
const
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1741
|
+
inspect: (inspectionEvent: InspectionEvent) => {
|
|
1742
|
+
if (inspectionEvent.type !== '@xstate.snapshot') return;
|
|
1743
|
+
if (inspectionEvent.actorRef !== builtActor) return;
|
|
1744
|
+
if (suppressInspectionEmissions) return;
|
|
1745
|
+
try {
|
|
1746
|
+
const snap = inspectionEvent.snapshot as SnapshotFrom<
|
|
1747
|
+
typeof codingMachine
|
|
1748
|
+
>;
|
|
1749
|
+
const state = normalizePlaybookSnapshot(snap);
|
|
1750
|
+
const to = state.stateId;
|
|
1751
|
+
if (to === undefined) {
|
|
1752
|
+
throw new Error(
|
|
1753
|
+
'CODE root snapshot must expose exactly one playbook state id',
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
const previousState = priorState;
|
|
1757
|
+
const context = snap.context as Record<string, unknown>;
|
|
1758
|
+
const payload = structuredStateTelemetryPayload(
|
|
1759
|
+
previousState,
|
|
1760
|
+
state,
|
|
1761
|
+
inspectionEvent.event,
|
|
1762
|
+
context,
|
|
1763
|
+
);
|
|
1764
|
+
const statuses: ScheduledStatus[] = [];
|
|
1765
|
+
if (CAPTAIN_PANE_STATES.has(to)) {
|
|
1766
|
+
const transitionLine = formatTransition(inspectionEvent.event);
|
|
1767
|
+
if (transitionLine !== undefined) {
|
|
1768
|
+
statuses.push({ message: transitionLine });
|
|
1769
|
+
}
|
|
1770
|
+
if (to === 'awaitBossReply') {
|
|
1771
|
+
statuses.push(
|
|
1772
|
+
{ message: formatAwaitBossReplyQuestion(context) },
|
|
1773
|
+
{ message: formatAwaitBossReplyMarker(context) },
|
|
1774
|
+
);
|
|
1775
|
+
} else {
|
|
1776
|
+
const entryLine = formatStateEntry(to);
|
|
1777
|
+
if (entryLine !== undefined) {
|
|
1778
|
+
const lastError =
|
|
1779
|
+
to === 'failed'
|
|
1780
|
+
? normalizeErrorCompact(
|
|
1781
|
+
(snap.context as { lastError?: unknown }).lastError,
|
|
1782
|
+
)
|
|
1783
|
+
: undefined;
|
|
1784
|
+
statuses.push({
|
|
1785
|
+
message: entryLine,
|
|
1786
|
+
...(lastError === undefined
|
|
1787
|
+
? {}
|
|
1788
|
+
: {
|
|
1789
|
+
data: snapshotJsonValue(
|
|
1790
|
+
{ lastError },
|
|
1791
|
+
'failed status data',
|
|
1792
|
+
),
|
|
1793
|
+
}),
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
enqueueTransitionEmission(
|
|
1799
|
+
payload,
|
|
1800
|
+
state,
|
|
1801
|
+
statuses,
|
|
1802
|
+
tracePositionForActiveTurn(),
|
|
1803
|
+
);
|
|
1804
|
+
priorState = state;
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
latchInspectionError(error);
|
|
1066
1807
|
}
|
|
1067
1808
|
},
|
|
1068
1809
|
},
|
|
1069
1810
|
);
|
|
1811
|
+
return builtActor;
|
|
1070
1812
|
}
|
|
1071
1813
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1814
|
+
function runResultFor(
|
|
1815
|
+
outcome: BossSettlementOutcome,
|
|
1816
|
+
error?: unknown,
|
|
1817
|
+
): PlaybookRunResult {
|
|
1818
|
+
const state = currentState();
|
|
1819
|
+
if (outcome === 'quiescent' || outcome === 'no-action') {
|
|
1820
|
+
return { outcome, state };
|
|
1821
|
+
}
|
|
1822
|
+
if (outcome === 'suspended') {
|
|
1823
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1824
|
+
if (!pendingCall) {
|
|
1825
|
+
throw new Error('suspended runtime has no pending playbook call');
|
|
1826
|
+
}
|
|
1827
|
+
return { outcome, state, pendingCall };
|
|
1828
|
+
}
|
|
1829
|
+
if (outcome === 'terminal') {
|
|
1830
|
+
const output = (actor?.getSnapshot() as { output?: unknown } | undefined)
|
|
1831
|
+
?.output;
|
|
1832
|
+
if (output !== undefined) {
|
|
1833
|
+
return {
|
|
1834
|
+
outcome,
|
|
1835
|
+
state,
|
|
1836
|
+
output: snapshotJsonValue(output, 'terminal playbook output'),
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
return { outcome, state };
|
|
1840
|
+
}
|
|
1841
|
+
const failure =
|
|
1842
|
+
error ??
|
|
1843
|
+
(outcome === 'failed'
|
|
1844
|
+
? (actor?.getSnapshot() as { context?: { lastError?: unknown } })
|
|
1845
|
+
?.context?.lastError
|
|
1846
|
+
: outcome === 'aborted'
|
|
1847
|
+
? activeSignal?.reason
|
|
1848
|
+
: undefined);
|
|
1849
|
+
return {
|
|
1850
|
+
outcome,
|
|
1851
|
+
state,
|
|
1852
|
+
...(failure !== undefined ? { error: normalizeError(failure) } : {}),
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
function settledOutcome(signal: AbortSignal): BossSettlementOutcome {
|
|
1857
|
+
if (nestedBridge.getPendingCall()) return 'suspended';
|
|
1858
|
+
if (signal.aborted) return 'aborted';
|
|
1859
|
+
const state = currentState();
|
|
1860
|
+
if (state.status === 'error') {
|
|
1861
|
+
const actorError = (
|
|
1862
|
+
actor?.getSnapshot() as { error?: unknown } | undefined
|
|
1863
|
+
)?.error;
|
|
1864
|
+
throw actorError ?? new Error('CODE actor entered error status');
|
|
1865
|
+
}
|
|
1866
|
+
if (state.status === 'done') return 'terminal';
|
|
1867
|
+
if (state.stateId === 'failed') return 'failed';
|
|
1868
|
+
return 'quiescent';
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function settlementTracePayload(
|
|
1872
|
+
result: PlaybookRunResult,
|
|
1873
|
+
): Record<string, unknown> {
|
|
1874
|
+
return {
|
|
1875
|
+
...result,
|
|
1876
|
+
...stateIdentity(result.state.stateId),
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// Shared failed-start cleanup for init and restore: stop the actor,
|
|
1881
|
+
// abort/drain nested and host work, optionally emit one best-effort
|
|
1882
|
+
// session.disposed boundary, and unbind every closure field so dispose
|
|
1883
|
+
// stays callable. The caller rethrows its original failure. A restore
|
|
1884
|
+
// failure skips the disposal trace — the parked session was never
|
|
1885
|
+
// re-bound in this process, so its persisted snapshot stays
|
|
1886
|
+
// authoritative (DR-014 §2).
|
|
1887
|
+
async function cleanupFailedStart(
|
|
1888
|
+
cause: unknown,
|
|
1889
|
+
options: { emitDisposal: boolean },
|
|
1890
|
+
): Promise<void> {
|
|
1891
|
+
let finalState: PlaybookState | undefined;
|
|
1892
|
+
if (options.emitDisposal && actor) {
|
|
1893
|
+
try {
|
|
1894
|
+
finalState = currentState();
|
|
1895
|
+
} catch {
|
|
1896
|
+
// A state that cannot even normalize has no disposal descriptor.
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
suppressInspectionEmissions = true;
|
|
1900
|
+
try {
|
|
1901
|
+
actor?.stop();
|
|
1902
|
+
} catch {
|
|
1903
|
+
// Preserve the original startup failure.
|
|
1904
|
+
}
|
|
1905
|
+
try {
|
|
1906
|
+
await nestedBridge.abortPending(cause);
|
|
1907
|
+
} catch {
|
|
1908
|
+
// Preserve the original startup failure.
|
|
1909
|
+
}
|
|
1910
|
+
try {
|
|
1911
|
+
await judgeQueue.onIdle();
|
|
1077
1912
|
await drainEmissions();
|
|
1913
|
+
} catch {
|
|
1914
|
+
// Preserve the original startup failure.
|
|
1915
|
+
}
|
|
1916
|
+
if (options.emitDisposal) {
|
|
1917
|
+
try {
|
|
1918
|
+
await emitTrace(
|
|
1919
|
+
'session.disposed',
|
|
1920
|
+
finalState === undefined
|
|
1921
|
+
? {}
|
|
1922
|
+
: {
|
|
1923
|
+
state: finalState,
|
|
1924
|
+
...stateIdentity(finalState.stateId),
|
|
1925
|
+
},
|
|
1926
|
+
);
|
|
1927
|
+
await drainEmissions();
|
|
1928
|
+
} catch {
|
|
1929
|
+
// The session-start error remains authoritative.
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
playerResumeTokens.clear();
|
|
1933
|
+
activePlayerIds.clear();
|
|
1934
|
+
playbookCallTurnIds.clear();
|
|
1935
|
+
activeEmissionCalls.clear();
|
|
1936
|
+
emissionQueue.clear();
|
|
1937
|
+
judgeQueue.clear();
|
|
1938
|
+
actor = undefined;
|
|
1939
|
+
session = undefined;
|
|
1940
|
+
savedPorts = undefined;
|
|
1941
|
+
runtimePorts = undefined;
|
|
1942
|
+
activeSignal = undefined;
|
|
1943
|
+
activeTurnId = undefined;
|
|
1944
|
+
controlPlaneError = undefined;
|
|
1945
|
+
emissionFailure = undefined;
|
|
1946
|
+
priorState = undefined;
|
|
1947
|
+
suppressInspectionEmissions = false;
|
|
1948
|
+
initialized = false;
|
|
1949
|
+
traceSequence = 0;
|
|
1950
|
+
turnSequence = 0;
|
|
1951
|
+
judgeCallSequence = 0;
|
|
1952
|
+
playerCallSequence = 0;
|
|
1953
|
+
playbookCallSequence = 0;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
const runtime = {
|
|
1957
|
+
async init(nextSession: PlaybookSession): Promise<void> {
|
|
1958
|
+
if (initialized || disposed || disposalPromise !== undefined) {
|
|
1959
|
+
throw new Error('createPlaybookRuntime.init: already initialized');
|
|
1960
|
+
}
|
|
1961
|
+
const boundSession = snapshotPlaybookSession(nextSession);
|
|
1962
|
+
initialized = true;
|
|
1963
|
+
let finishInitialization!: () => void;
|
|
1964
|
+
const initialization = new Promise<void>((resolve) => {
|
|
1965
|
+
finishInitialization = resolve;
|
|
1966
|
+
});
|
|
1967
|
+
initInFlight = initialization;
|
|
1968
|
+
const initTask = (async () => {
|
|
1969
|
+
session = boundSession;
|
|
1970
|
+
savedPorts = boundSession.ports;
|
|
1971
|
+
runtimePorts = createRuntimePorts(boundSession.ports);
|
|
1972
|
+
suppressInspectionEmissions = false;
|
|
1973
|
+
actor = buildActor(runtimePorts);
|
|
1974
|
+
await emitTrace('session.started', stateTracePayload());
|
|
1975
|
+
actor.start();
|
|
1976
|
+
await drainEmissions();
|
|
1977
|
+
})();
|
|
1978
|
+
try {
|
|
1979
|
+
await initTask;
|
|
1980
|
+
} catch (error) {
|
|
1981
|
+
await cleanupFailedStart(error, { emitDisposal: true });
|
|
1982
|
+
throw error;
|
|
1983
|
+
} finally {
|
|
1984
|
+
finishInitialization();
|
|
1985
|
+
if (initInFlight === initialization) initInFlight = undefined;
|
|
1986
|
+
}
|
|
1987
|
+
},
|
|
1988
|
+
|
|
1989
|
+
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
|
|
1990
|
+
// Defined only at a safe capture point — initialized, not disposing
|
|
1991
|
+
// or disposed, no active public boundary, no pending nested call,
|
|
1992
|
+
// and the actor quiescent with status `active`.
|
|
1993
|
+
exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
|
|
1994
|
+
if (!actor || !session || disposed || disposalPromise !== undefined) {
|
|
1995
|
+
return undefined;
|
|
1996
|
+
}
|
|
1997
|
+
if (activeSignal !== undefined) return undefined;
|
|
1998
|
+
if (nestedBridge.getPendingCall()) return undefined;
|
|
1999
|
+
const state = currentState();
|
|
2000
|
+
if (state.status !== 'active' || !state.quiescent) return undefined;
|
|
2001
|
+
const machine = detachPersistedMachineSnapshot(
|
|
2002
|
+
actor.getPersistedSnapshot(),
|
|
2003
|
+
);
|
|
2004
|
+
const context = (actor.getSnapshot() as { context?: unknown })
|
|
2005
|
+
.context as Record<string, unknown>;
|
|
2006
|
+
const pending = pendingBossQuestionFromContext(context ?? {});
|
|
2007
|
+
return {
|
|
2008
|
+
schemaVersion: 1,
|
|
2009
|
+
playbookId: session.playbookId,
|
|
2010
|
+
machine,
|
|
2011
|
+
playerResumeTokens: Object.fromEntries(playerResumeTokens),
|
|
2012
|
+
sequences: {
|
|
2013
|
+
trace: traceSequence,
|
|
2014
|
+
turn: turnSequence,
|
|
2015
|
+
judgeCall: judgeCallSequence,
|
|
2016
|
+
playerCall: playerCallSequence,
|
|
2017
|
+
playbookCall: playbookCallSequence,
|
|
2018
|
+
},
|
|
2019
|
+
state,
|
|
2020
|
+
pendingBossQuestions:
|
|
2021
|
+
pending === undefined
|
|
2022
|
+
? []
|
|
2023
|
+
: [
|
|
2024
|
+
{
|
|
2025
|
+
questionId: pending.questionId,
|
|
2026
|
+
player: pending.player,
|
|
2027
|
+
question: pending.question,
|
|
2028
|
+
sourceItem: pending.sourceItem,
|
|
2029
|
+
},
|
|
2030
|
+
],
|
|
2031
|
+
};
|
|
2032
|
+
},
|
|
2033
|
+
|
|
2034
|
+
// DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
|
|
2035
|
+
// exported snapshot under the same immutable session identity.
|
|
2036
|
+
// Emits no `session.started`, transition trace, or human status —
|
|
2037
|
+
// the session already started; the next public boundary continues
|
|
2038
|
+
// the contiguous trace sequence.
|
|
2039
|
+
async restore(
|
|
2040
|
+
nextSession: PlaybookSession,
|
|
2041
|
+
snapshot: PlaybookRuntimeSnapshot,
|
|
2042
|
+
): Promise<void> {
|
|
2043
|
+
if (initialized || disposed || disposalPromise !== undefined) {
|
|
2044
|
+
throw new Error('createPlaybookRuntime.restore: already initialized');
|
|
2045
|
+
}
|
|
2046
|
+
const boundSession = snapshotPlaybookSession(nextSession);
|
|
2047
|
+
const boundSnapshot = assertPlaybookRuntimeSnapshot(
|
|
2048
|
+
snapshot,
|
|
2049
|
+
boundSession.playbookId,
|
|
2050
|
+
);
|
|
2051
|
+
initialized = true;
|
|
2052
|
+
let finishInitialization!: () => void;
|
|
2053
|
+
const initialization = new Promise<void>((resolve) => {
|
|
2054
|
+
finishInitialization = resolve;
|
|
2055
|
+
});
|
|
2056
|
+
initInFlight = initialization;
|
|
2057
|
+
const initTask = (async () => {
|
|
2058
|
+
session = boundSession;
|
|
2059
|
+
savedPorts = boundSession.ports;
|
|
2060
|
+
runtimePorts = createRuntimePorts(boundSession.ports);
|
|
2061
|
+
traceSequence = boundSnapshot.sequences.trace;
|
|
2062
|
+
turnSequence = boundSnapshot.sequences.turn;
|
|
2063
|
+
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
2064
|
+
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
2065
|
+
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
2066
|
+
playerResumeTokens.clear();
|
|
2067
|
+
for (const [playerId, token] of Object.entries(
|
|
2068
|
+
boundSnapshot.playerResumeTokens,
|
|
2069
|
+
)) {
|
|
2070
|
+
playerResumeTokens.set(playerId, token);
|
|
2071
|
+
}
|
|
2072
|
+
suppressInspectionEmissions = true;
|
|
2073
|
+
actor = buildActor(runtimePorts, boundSnapshot.machine);
|
|
2074
|
+
actor.start();
|
|
2075
|
+
const restoredState = currentState();
|
|
2076
|
+
if (restoredState.status !== 'active') {
|
|
2077
|
+
throw new Error(
|
|
2078
|
+
`createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`,
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
suppressInspectionEmissions = false;
|
|
2082
|
+
priorState = restoredState;
|
|
2083
|
+
await drainEmissions();
|
|
2084
|
+
})();
|
|
2085
|
+
try {
|
|
2086
|
+
await initTask;
|
|
2087
|
+
} catch (error) {
|
|
2088
|
+
await cleanupFailedStart(error, { emitDisposal: false });
|
|
2089
|
+
throw error;
|
|
2090
|
+
} finally {
|
|
2091
|
+
finishInitialization();
|
|
2092
|
+
if (initInFlight === initialization) initInFlight = undefined;
|
|
2093
|
+
}
|
|
1078
2094
|
},
|
|
1079
2095
|
|
|
1080
2096
|
async handleBossInput({
|
|
@@ -1083,68 +2099,249 @@ export default function createPlaybookRuntime(
|
|
|
1083
2099
|
}: {
|
|
1084
2100
|
text: string;
|
|
1085
2101
|
signal: AbortSignal;
|
|
1086
|
-
}): Promise<
|
|
2102
|
+
}): Promise<PlaybookRunResult> {
|
|
1087
2103
|
if (!actor || !savedPorts) {
|
|
1088
2104
|
throw new Error(
|
|
1089
2105
|
'createPlaybookRuntime.handleBossInput: init must be called first',
|
|
1090
2106
|
);
|
|
1091
2107
|
}
|
|
2108
|
+
if (disposed || disposalPromise !== undefined) {
|
|
2109
|
+
throw new Error(
|
|
2110
|
+
'createPlaybookRuntime.handleBossInput: runtime is disposing or disposed',
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
if (activeSignal !== undefined) {
|
|
2114
|
+
throw new Error(
|
|
2115
|
+
'createPlaybookRuntime.handleBossInput: another runtime turn is active',
|
|
2116
|
+
);
|
|
2117
|
+
}
|
|
2118
|
+
const turnId = ++turnSequence;
|
|
2119
|
+
activeTurnId = turnId;
|
|
1092
2120
|
activeSignal = signal;
|
|
2121
|
+
controlPlaneError = undefined;
|
|
2122
|
+
let result: PlaybookRunResult | undefined;
|
|
2123
|
+
let operationError: unknown;
|
|
1093
2124
|
try {
|
|
2125
|
+
await emitTrace('boss.input.received', { text }, { turnId });
|
|
1094
2126
|
// 1. Classify non-empty text into an FSM event through the judge.
|
|
1095
2127
|
const event = await classifyBossText(
|
|
1096
2128
|
text,
|
|
1097
|
-
|
|
2129
|
+
runtimePorts!,
|
|
1098
2130
|
signal,
|
|
1099
2131
|
actor.getSnapshot(),
|
|
2132
|
+
boundary,
|
|
1100
2133
|
);
|
|
1101
2134
|
// Empty input, no-action classifier output, or invalid classifier
|
|
1102
2135
|
// output — nothing to send.
|
|
1103
2136
|
if (event === undefined) {
|
|
1104
|
-
|
|
1105
|
-
|
|
2137
|
+
result = runResultFor('no-action');
|
|
2138
|
+
} else {
|
|
2139
|
+
// 2. Captain-pane classification line (PBRT-14): the bare
|
|
2140
|
+
// FSM event type, emitted before the FSM advances.
|
|
2141
|
+
await runtimePorts!.emitStatus(formatClassification(event.type));
|
|
2142
|
+
// 3. A final actor cannot accept new events; reconstruct only after
|
|
2143
|
+
// classification produced a real event.
|
|
2144
|
+
if (actor.getSnapshot().status === 'done') {
|
|
2145
|
+
actor.stop();
|
|
2146
|
+
actor = buildActor(runtimePorts!);
|
|
2147
|
+
actor.start();
|
|
2148
|
+
}
|
|
2149
|
+
actor.send(event);
|
|
2150
|
+
await waitForPlaybookQuiescence(actor, {
|
|
2151
|
+
pendingCalls: nestedBridge,
|
|
2152
|
+
});
|
|
2153
|
+
if (controlPlaneError !== undefined) throw controlPlaneError;
|
|
2154
|
+
result = runResultFor(settledOutcome(signal));
|
|
1106
2155
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
2156
|
+
} catch (error) {
|
|
2157
|
+
operationError = error;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
let drainError: unknown;
|
|
2161
|
+
try {
|
|
2162
|
+
await drainEmissions();
|
|
2163
|
+
} catch (error) {
|
|
2164
|
+
drainError = error;
|
|
2165
|
+
}
|
|
2166
|
+
const latchedControlError = controlPlaneError;
|
|
2167
|
+
const primaryError = latchedControlError ?? drainError ?? operationError;
|
|
2168
|
+
const abortError =
|
|
2169
|
+
latchedControlError === undefined &&
|
|
2170
|
+
drainError === undefined &&
|
|
2171
|
+
operationError !== undefined &&
|
|
2172
|
+
isAbortFailure(operationError, signal);
|
|
2173
|
+
const settlementResult =
|
|
2174
|
+
primaryError === undefined
|
|
2175
|
+
? (result ?? runResultFor('no-action'))
|
|
2176
|
+
: runResultFor(abortError ? 'aborted' : 'failed', primaryError);
|
|
2177
|
+
|
|
2178
|
+
let settlementEmissionError: unknown;
|
|
2179
|
+
try {
|
|
2180
|
+
await emitTrace(
|
|
2181
|
+
'boss.input.settled',
|
|
2182
|
+
settlementTracePayload(settlementResult),
|
|
2183
|
+
{ turnId },
|
|
1115
2184
|
);
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
actor = buildActor(savedPorts);
|
|
1121
|
-
actor.start();
|
|
1122
|
-
}
|
|
1123
|
-
// 4. Send the event.
|
|
1124
|
-
actor.send(event);
|
|
1125
|
-
// 5. Drive to quiescence. On signal-abort we take no FSM
|
|
1126
|
-
// action: the captain bridge's awaited callPlayer rejects
|
|
1127
|
-
// naturally, the bridge throws, XState routes through
|
|
1128
|
-
// onError → #failed, and this loop sees the quiescent
|
|
1129
|
-
// snapshot and returns (DR-004 §8 natural rejection).
|
|
1130
|
-
await driveToQuiescence(actor);
|
|
1131
|
-
// Drain transition emissions before returning so the Boss
|
|
1132
|
-
// sees the final status line for this turn.
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
settlementEmissionError = error;
|
|
2187
|
+
}
|
|
2188
|
+
try {
|
|
1133
2189
|
await drainEmissions();
|
|
1134
|
-
}
|
|
1135
|
-
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
settlementEmissionError ??= error;
|
|
2192
|
+
}
|
|
2193
|
+
const failure =
|
|
2194
|
+
controlPlaneError ??
|
|
2195
|
+
latchedControlError ??
|
|
2196
|
+
drainError ??
|
|
2197
|
+
(abortError
|
|
2198
|
+
? (settlementEmissionError ?? operationError)
|
|
2199
|
+
: (operationError ?? settlementEmissionError));
|
|
2200
|
+
activeSignal = undefined;
|
|
2201
|
+
activeTurnId = undefined;
|
|
2202
|
+
controlPlaneError = undefined;
|
|
2203
|
+
|
|
2204
|
+
if (
|
|
2205
|
+
failure !== undefined &&
|
|
2206
|
+
!(abortError && settlementEmissionError === undefined)
|
|
2207
|
+
) {
|
|
2208
|
+
throw failure;
|
|
1136
2209
|
}
|
|
2210
|
+
return settlementResult;
|
|
1137
2211
|
},
|
|
1138
2212
|
|
|
1139
|
-
async
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
2213
|
+
async resumePlaybookCall(input: {
|
|
2214
|
+
callId: string;
|
|
2215
|
+
result: PlaybookCallResult;
|
|
2216
|
+
signal: AbortSignal;
|
|
2217
|
+
}): Promise<PlaybookRunResult> {
|
|
2218
|
+
if (!actor || !savedPorts) {
|
|
2219
|
+
throw new Error(
|
|
2220
|
+
'createPlaybookRuntime.resumePlaybookCall: init must be called first',
|
|
2221
|
+
);
|
|
1143
2222
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
2223
|
+
if (disposed || disposalPromise !== undefined) {
|
|
2224
|
+
throw new Error(
|
|
2225
|
+
'createPlaybookRuntime.resumePlaybookCall: runtime is disposing or disposed',
|
|
2226
|
+
);
|
|
2227
|
+
}
|
|
2228
|
+
if (activeSignal !== undefined) {
|
|
2229
|
+
throw new Error(
|
|
2230
|
+
'createPlaybookRuntime.resumePlaybookCall: another runtime turn is active',
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
activeTurnId = playbookCallTurnIds.get(input.callId);
|
|
2234
|
+
activeSignal = input.signal;
|
|
2235
|
+
controlPlaneError = undefined;
|
|
2236
|
+
let result: PlaybookRunResult | undefined;
|
|
2237
|
+
let operationError: unknown;
|
|
2238
|
+
try {
|
|
2239
|
+
await nestedBridge.resume(input);
|
|
2240
|
+
} catch (error) {
|
|
2241
|
+
operationError = error;
|
|
2242
|
+
}
|
|
2243
|
+
try {
|
|
2244
|
+
await waitForPlaybookQuiescence(actor, {
|
|
2245
|
+
pendingCalls: nestedBridge,
|
|
2246
|
+
});
|
|
2247
|
+
result = runResultFor(settledOutcome(input.signal));
|
|
2248
|
+
} catch (error) {
|
|
2249
|
+
operationError ??= error;
|
|
2250
|
+
}
|
|
2251
|
+
let drainError: unknown;
|
|
2252
|
+
try {
|
|
2253
|
+
await drainEmissions();
|
|
2254
|
+
} catch (error) {
|
|
2255
|
+
drainError = error;
|
|
2256
|
+
}
|
|
2257
|
+
const failure = controlPlaneError ?? drainError ?? operationError;
|
|
2258
|
+
activeSignal = undefined;
|
|
2259
|
+
activeTurnId = undefined;
|
|
2260
|
+
controlPlaneError = undefined;
|
|
2261
|
+
if (failure !== undefined) throw failure;
|
|
2262
|
+
if (result === undefined) {
|
|
2263
|
+
throw new Error('playbook resume produced no runtime result');
|
|
2264
|
+
}
|
|
2265
|
+
return result;
|
|
2266
|
+
},
|
|
2267
|
+
|
|
2268
|
+
dispose(): Promise<void> {
|
|
2269
|
+
if (disposalPromise !== undefined) return disposalPromise;
|
|
2270
|
+
if (disposed) return Promise.resolve();
|
|
2271
|
+
if (activeSignal !== undefined) {
|
|
2272
|
+
return Promise.reject(
|
|
2273
|
+
new Error(
|
|
2274
|
+
'createPlaybookRuntime.dispose: cannot dispose during an active runtime boundary',
|
|
2275
|
+
),
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
const task = (async (): Promise<void> => {
|
|
2279
|
+
const failures: unknown[] = [];
|
|
2280
|
+
try {
|
|
2281
|
+
if (initInFlight !== undefined) {
|
|
2282
|
+
try {
|
|
2283
|
+
await initInFlight;
|
|
2284
|
+
} catch {
|
|
2285
|
+
// Dispose still releases whatever an unsuccessful init bound.
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
const finalState = actor ? currentState() : undefined;
|
|
2289
|
+
// Stop the root before settling a suspended child. Its rejection
|
|
2290
|
+
// must not re-enter CODE and start fresh work during disposal.
|
|
2291
|
+
if (actor) actor.stop();
|
|
2292
|
+
try {
|
|
2293
|
+
await nestedBridge.dispose();
|
|
2294
|
+
} catch (error) {
|
|
2295
|
+
failures.push(error);
|
|
2296
|
+
}
|
|
2297
|
+
try {
|
|
2298
|
+
await drainEmissions();
|
|
2299
|
+
} catch (error) {
|
|
2300
|
+
failures.push(error);
|
|
2301
|
+
}
|
|
2302
|
+
if (session !== undefined) {
|
|
2303
|
+
try {
|
|
2304
|
+
await emitTrace(
|
|
2305
|
+
'session.disposed',
|
|
2306
|
+
finalState === undefined
|
|
2307
|
+
? {}
|
|
2308
|
+
: {
|
|
2309
|
+
state: finalState,
|
|
2310
|
+
...stateIdentity(finalState.stateId),
|
|
2311
|
+
},
|
|
2312
|
+
);
|
|
2313
|
+
await drainEmissions();
|
|
2314
|
+
} catch (error) {
|
|
2315
|
+
failures.push(error);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
} finally {
|
|
2319
|
+
playerResumeTokens.clear();
|
|
2320
|
+
activePlayerIds.clear();
|
|
2321
|
+
playbookCallTurnIds.clear();
|
|
2322
|
+
activeEmissionCalls.clear();
|
|
2323
|
+
emissionQueue.clear();
|
|
2324
|
+
judgeQueue.clear();
|
|
2325
|
+
actor = undefined;
|
|
2326
|
+
activeSignal = undefined;
|
|
2327
|
+
activeTurnId = undefined;
|
|
2328
|
+
controlPlaneError = undefined;
|
|
2329
|
+
emissionFailure = undefined;
|
|
2330
|
+
savedPorts = undefined;
|
|
2331
|
+
runtimePorts = undefined;
|
|
2332
|
+
session = undefined;
|
|
2333
|
+
disposed = true;
|
|
2334
|
+
}
|
|
2335
|
+
if (failures.length === 1) throw failures[0];
|
|
2336
|
+
if (failures.length > 1) {
|
|
2337
|
+
throw new AggregateError(
|
|
2338
|
+
failures,
|
|
2339
|
+
'playbook runtime disposal failed',
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
})();
|
|
2343
|
+
disposalPromise = task;
|
|
2344
|
+
return task;
|
|
1148
2345
|
},
|
|
1149
2346
|
|
|
1150
2347
|
// @internal — test-only escape hatch for inspecting the
|
|
@@ -1156,28 +2353,12 @@ export default function createPlaybookRuntime(
|
|
|
1156
2353
|
_getActor() {
|
|
1157
2354
|
return actor;
|
|
1158
2355
|
},
|
|
2356
|
+
_getBoundary() {
|
|
2357
|
+
return boundary;
|
|
2358
|
+
},
|
|
2359
|
+
_getNestedBridge() {
|
|
2360
|
+
return nestedBridge;
|
|
2361
|
+
},
|
|
1159
2362
|
};
|
|
1160
2363
|
return runtime as PlaybookRuntime;
|
|
1161
2364
|
}
|
|
1162
|
-
|
|
1163
|
-
function driveToQuiescence(
|
|
1164
|
-
actor: ReturnType<typeof createActor>,
|
|
1165
|
-
): Promise<void> {
|
|
1166
|
-
return new Promise<void>((resolve) => {
|
|
1167
|
-
if (isQuiescent(actor.getSnapshot())) {
|
|
1168
|
-
resolve();
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
const sub = actor.subscribe((snap) => {
|
|
1172
|
-
if (isQuiescent(snap)) {
|
|
1173
|
-
sub.unsubscribe();
|
|
1174
|
-
resolve();
|
|
1175
|
-
}
|
|
1176
|
-
});
|
|
1177
|
-
});
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
function isQuiescent(snap: { value: unknown }): boolean {
|
|
1181
|
-
const v = snap.value;
|
|
1182
|
-
return typeof v === 'string' && QUIESCENT_STATES.has(v);
|
|
1183
|
-
}
|