@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,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 &&
|
|
@@ -177,8 +282,9 @@ function composePlayerPrompt(input: CaptainInput): string {
|
|
|
177
282
|
// Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
|
|
178
283
|
// Committer (= Coder | Reviewer per code.gears.md) resolves to the
|
|
179
284
|
// configured alias when present: `input.committerPlayer`, the
|
|
180
|
-
// validated `captain.options.code.committer`
|
|
181
|
-
// already a baked player id ('coder' / 'reviewer').
|
|
285
|
+
// validated `captain.options.playbooks.code.options.committer`
|
|
286
|
+
// (PBRT-8 / PBRT-30), already a baked player id ('coder' / 'reviewer').
|
|
287
|
+
// Absent a
|
|
182
288
|
// configured alias it falls back to the DR-004 §2 baked binding by
|
|
183
289
|
// populated <playerName>Player field: prefer `coderPlayer` (CODE-18
|
|
184
290
|
// wires only coderPlayer; CODE-19 wires both so coderPlayer still wins
|
|
@@ -188,7 +294,7 @@ function composePlayerPrompt(input: CaptainInput): string {
|
|
|
188
294
|
// set. The alias selects only the host pane; it is not a PBRT-4
|
|
189
295
|
// identity string, so it leaves <coder-llm> / <reviewer-llm>
|
|
190
296
|
// untouched and `input.player` stays `Committer` (PLAYBOOK-3).
|
|
191
|
-
function resolvePlayerId(input:
|
|
297
|
+
function resolvePlayerId(input: PlayerInput): string {
|
|
192
298
|
switch (input.player) {
|
|
193
299
|
case 'Coder':
|
|
194
300
|
return 'coder';
|
|
@@ -201,13 +307,28 @@ function resolvePlayerId(input: CaptainInput): string {
|
|
|
201
307
|
return 'coder';
|
|
202
308
|
default: {
|
|
203
309
|
const exhaustive: never = input.player;
|
|
204
|
-
throw new Error(
|
|
205
|
-
`resolvePlayerId: unknown player ${String(exhaustive)}`,
|
|
206
|
-
);
|
|
310
|
+
throw new Error(`resolvePlayerId: unknown player ${String(exhaustive)}`);
|
|
207
311
|
}
|
|
208
312
|
}
|
|
209
313
|
}
|
|
210
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
|
+
|
|
211
332
|
// LLM judge — DR-004 §4. Builds a prompt that lists each declared
|
|
212
333
|
// outcome verbatim, asks ports.callJudge for a JSON
|
|
213
334
|
// `{ guard, …payloadFields }` response, and returns the parsed
|
|
@@ -215,13 +336,21 @@ function resolvePlayerId(input: CaptainInput): string {
|
|
|
215
336
|
// Adjudicator failures (malformed JSON, missing/unknown guard) are
|
|
216
337
|
// control-plane errors and propagate via throw per slc/link.md.
|
|
217
338
|
async function adjudicate(
|
|
218
|
-
input:
|
|
339
|
+
input: PlayerInput,
|
|
219
340
|
finalText: string,
|
|
220
341
|
ports: PlaybookPorts,
|
|
221
342
|
signal: AbortSignal,
|
|
222
|
-
|
|
343
|
+
boundary?: RuntimeBoundaryCalls,
|
|
344
|
+
): Promise<PlayerOutput> {
|
|
223
345
|
const prompt = buildJudgePrompt(input, finalText);
|
|
224
|
-
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);
|
|
225
354
|
const parsed = parseJudgeJson(raw);
|
|
226
355
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
227
356
|
throw new Error('adjudicate: judge response is not a JSON object');
|
|
@@ -263,7 +392,7 @@ async function adjudicate(
|
|
|
263
392
|
);
|
|
264
393
|
}
|
|
265
394
|
}
|
|
266
|
-
return obj as
|
|
395
|
+
return obj as PlayerOutput;
|
|
267
396
|
}
|
|
268
397
|
|
|
269
398
|
function extractRequiredFields(description: string): string[] {
|
|
@@ -275,7 +404,7 @@ function extractRequiredFields(description: string): string[] {
|
|
|
275
404
|
return fields;
|
|
276
405
|
}
|
|
277
406
|
|
|
278
|
-
function buildJudgePrompt(input:
|
|
407
|
+
function buildJudgePrompt(input: PlayerInput, finalText: string): string {
|
|
279
408
|
const lines: string[] = [];
|
|
280
409
|
lines.push(`The ${input.player} just produced this output:`);
|
|
281
410
|
lines.push('');
|
|
@@ -443,11 +572,12 @@ async function classifyBossText(
|
|
|
443
572
|
ports: PlaybookPorts,
|
|
444
573
|
signal: AbortSignal,
|
|
445
574
|
snapshotOrState?: unknown,
|
|
575
|
+
boundary?: RuntimeBoundaryCalls,
|
|
446
576
|
): Promise<CodingEvent | undefined> {
|
|
447
577
|
const trimmed = text.trim();
|
|
448
578
|
if (trimmed === '') return undefined;
|
|
449
579
|
|
|
450
|
-
return classifyWithLlm(text, ports, signal, snapshotOrState);
|
|
580
|
+
return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
|
|
451
581
|
}
|
|
452
582
|
|
|
453
583
|
// JumpableStateId is internal to code.fsm.ts (not exported), so
|
|
@@ -468,10 +598,19 @@ async function classifyWithLlm(
|
|
|
468
598
|
ports: PlaybookPorts,
|
|
469
599
|
signal: AbortSignal,
|
|
470
600
|
snapshotOrState?: unknown,
|
|
601
|
+
boundary?: RuntimeBoundaryCalls,
|
|
471
602
|
): Promise<CodingEvent | undefined> {
|
|
472
603
|
const state = classifierState(snapshotOrState);
|
|
473
604
|
const prompt = buildClassifierPrompt(text, state);
|
|
474
|
-
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);
|
|
475
614
|
let parsed: unknown;
|
|
476
615
|
try {
|
|
477
616
|
parsed = parseJudgeJson(raw);
|
|
@@ -561,7 +700,36 @@ async function classifyWithLlm(
|
|
|
561
700
|
await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
|
|
562
701
|
return undefined;
|
|
563
702
|
}
|
|
564
|
-
|
|
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
|
+
};
|
|
565
733
|
}
|
|
566
734
|
default:
|
|
567
735
|
await ports.emitStatus(
|
|
@@ -601,7 +769,9 @@ function classifierState(snapshotOrState: unknown): ClassifierState {
|
|
|
601
769
|
|
|
602
770
|
function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
603
771
|
const currentState =
|
|
604
|
-
typeof state.value === 'string'
|
|
772
|
+
typeof state.value === 'string'
|
|
773
|
+
? state.value
|
|
774
|
+
: JSON.stringify(state.value ?? null);
|
|
605
775
|
const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
|
|
606
776
|
const lines = [
|
|
607
777
|
'Classify the following Boss message into exactly one of these events.',
|
|
@@ -612,8 +782,9 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
|
612
782
|
];
|
|
613
783
|
if (pendingBossQuestion !== undefined) {
|
|
614
784
|
lines.push(
|
|
785
|
+
`Pending question id: ${pendingBossQuestion.questionId}`,
|
|
786
|
+
`Pending asking player: ${pendingBossQuestion.player}`,
|
|
615
787
|
`Pending Boss question: ${pendingBossQuestion.question}`,
|
|
616
|
-
`Pending resume state: ${pendingBossQuestion.resumeStateId}`,
|
|
617
788
|
);
|
|
618
789
|
}
|
|
619
790
|
lines.push(
|
|
@@ -629,27 +800,24 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
|
629
800
|
lines.push(` - ${target.stateId}: ${target.description}`);
|
|
630
801
|
}
|
|
631
802
|
if (currentState === 'awaitBossReply') {
|
|
632
|
-
lines.push(
|
|
803
|
+
lines.push(
|
|
804
|
+
'- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }',
|
|
805
|
+
);
|
|
633
806
|
} else {
|
|
634
807
|
lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
|
|
635
808
|
}
|
|
636
|
-
lines.push(
|
|
637
|
-
'',
|
|
638
|
-
'Boss message:',
|
|
639
|
-
'```',
|
|
640
|
-
text,
|
|
641
|
-
'```',
|
|
642
|
-
);
|
|
809
|
+
lines.push('', 'Boss message:', '```', text, '```');
|
|
643
810
|
return lines.join('\n');
|
|
644
811
|
}
|
|
645
812
|
|
|
646
|
-
//
|
|
647
|
-
// 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:
|
|
648
815
|
// resolve playerId, compose the player prompt, await
|
|
649
816
|
// ports.callPlayer, adjudicate the finalText. PlayerResult status of
|
|
650
817
|
// 'aborted' or 'error' throws so XState routes via onError → #failed
|
|
651
818
|
// (the single fail-stop sink for both Captain errors and player
|
|
652
|
-
// failures).
|
|
819
|
+
// failures). Captain remains the orchestrator and adjudicator; it is not
|
|
820
|
+
// encoded as the delegated FSM actor.
|
|
653
821
|
//
|
|
654
822
|
// `getActiveSignal` is the runtime's hook for flowing the Boss's
|
|
655
823
|
// `handleBossInput.signal` into the host port calls — fromPromise
|
|
@@ -659,34 +827,43 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
|
659
827
|
function captainBridge(
|
|
660
828
|
ports: PlaybookPorts,
|
|
661
829
|
getActiveSignal?: () => AbortSignal | undefined,
|
|
830
|
+
boundary?: RuntimeBoundaryCalls,
|
|
831
|
+
onControlPlaneError?: (error: unknown) => void,
|
|
662
832
|
) {
|
|
663
|
-
return fromPromise<
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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 {
|
|
680
853
|
const output = await adjudicate(
|
|
681
854
|
input,
|
|
682
855
|
result.finalText,
|
|
683
856
|
ports,
|
|
684
857
|
activeSignal,
|
|
858
|
+
boundary,
|
|
685
859
|
);
|
|
686
860
|
validateBossReplyOutput(input, output);
|
|
687
861
|
return output;
|
|
688
|
-
}
|
|
689
|
-
|
|
862
|
+
} catch (error) {
|
|
863
|
+
onControlPlaneError?.(error);
|
|
864
|
+
throw error;
|
|
865
|
+
}
|
|
866
|
+
});
|
|
690
867
|
}
|
|
691
868
|
|
|
692
869
|
// Captain pane display — PBRT-3 / PBRT-14.
|
|
@@ -695,7 +872,7 @@ function captainBridge(
|
|
|
695
872
|
// glance:
|
|
696
873
|
// (no glyph) bare FSM event type — host renders as captain speech
|
|
697
874
|
// (e.g., `captain> START_CODING`)
|
|
698
|
-
// ⤷
|
|
875
|
+
// ⤷ player-invoking state entry: `<Player>: <label>`
|
|
699
876
|
// → transition guard outcome (`· field=N` tallies
|
|
700
877
|
// appended); the host presenter owns any visual
|
|
701
878
|
// nesting under the preceding ⤷ entry
|
|
@@ -734,7 +911,7 @@ const STATE_LABELS: Readonly<Record<string, string>> = {
|
|
|
734
911
|
};
|
|
735
912
|
|
|
736
913
|
interface StateMetadata {
|
|
737
|
-
player:
|
|
914
|
+
player: PlayerInput['player'];
|
|
738
915
|
sourceItem: string;
|
|
739
916
|
label: string;
|
|
740
917
|
}
|
|
@@ -745,7 +922,7 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
|
|
|
745
922
|
const label = STATE_LABELS[s.stateId];
|
|
746
923
|
if (!label) {
|
|
747
924
|
throw new Error(
|
|
748
|
-
`code.playbook.ts: STATE_LABELS missing entry for
|
|
925
|
+
`code.playbook.ts: STATE_LABELS missing entry for player-invoking state '${s.stateId}'`,
|
|
749
926
|
);
|
|
750
927
|
}
|
|
751
928
|
const input = s.getInput({});
|
|
@@ -754,13 +931,6 @@ const stateMetadata: ReadonlyMap<string, StateMetadata> = (() => {
|
|
|
754
931
|
return m;
|
|
755
932
|
})();
|
|
756
933
|
|
|
757
|
-
const stateIdBySourceItem: ReadonlyMap<string, string> = new Map(
|
|
758
|
-
[...stateMetadata.entries()].map(([stateId, meta]) => [
|
|
759
|
-
meta.sourceItem,
|
|
760
|
-
stateId,
|
|
761
|
-
]),
|
|
762
|
-
);
|
|
763
|
-
|
|
764
934
|
const registeredResumableStateIds: ReadonlySet<string> = new Set(
|
|
765
935
|
enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map(
|
|
766
936
|
(transition) => transition.target,
|
|
@@ -768,18 +938,16 @@ const registeredResumableStateIds: ReadonlySet<string> = new Set(
|
|
|
768
938
|
);
|
|
769
939
|
|
|
770
940
|
function validateBossReplyOutput(
|
|
771
|
-
input:
|
|
772
|
-
output:
|
|
941
|
+
input: PlayerInput,
|
|
942
|
+
output: PlayerOutput,
|
|
773
943
|
): void {
|
|
774
944
|
if (output.guard !== 'needsBossReply') return;
|
|
775
945
|
if (typeof output.question !== 'string') {
|
|
776
946
|
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
777
947
|
}
|
|
778
|
-
const stateId =
|
|
779
|
-
if (
|
|
780
|
-
throw new Error(
|
|
781
|
-
BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem),
|
|
782
|
-
);
|
|
948
|
+
const stateId = input.stateId;
|
|
949
|
+
if (!registeredResumableStateIds.has(stateId)) {
|
|
950
|
+
throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId));
|
|
783
951
|
}
|
|
784
952
|
}
|
|
785
953
|
|
|
@@ -794,12 +962,9 @@ const QUIESCENT_STATES: ReadonlySet<string> = new Set([
|
|
|
794
962
|
// pane per PBRT-3: the readline returning to its `boss>` prompt is
|
|
795
963
|
// the implicit "turn over" signal, so a `◆ ready` / `◆ done`
|
|
796
964
|
// tombstone is redundant.
|
|
797
|
-
const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set([
|
|
798
|
-
'ready',
|
|
799
|
-
'done',
|
|
800
|
-
]);
|
|
965
|
+
const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
|
|
801
966
|
|
|
802
|
-
// Captain-pane surface (PBRT-3): every
|
|
967
|
+
// Captain-pane surface (PBRT-3): every player-invoking state plus
|
|
803
968
|
// the quiescent states whose entry still carries information
|
|
804
969
|
// (failure with `lastError`, awaitBossReply with the pending
|
|
805
970
|
// question). `ready` and `done` flow through the inspect handler
|
|
@@ -810,7 +975,12 @@ const CAPTAIN_PANE_STATES: ReadonlySet<string> = new Set([
|
|
|
810
975
|
...QUIESCENT_STATES,
|
|
811
976
|
]);
|
|
812
977
|
|
|
978
|
+
type BossReplyQuestionId = NonNullable<
|
|
979
|
+
Extract<CodingEvent, { type: 'BOSS_REPLY' }>['questionId']
|
|
980
|
+
>;
|
|
981
|
+
|
|
813
982
|
interface PendingBossQuestionForStatus {
|
|
983
|
+
questionId: BossReplyQuestionId;
|
|
814
984
|
resumeStateId: string;
|
|
815
985
|
sourceItem: string;
|
|
816
986
|
player: string;
|
|
@@ -829,6 +999,7 @@ function pendingBossQuestionFromContext(
|
|
|
829
999
|
>;
|
|
830
1000
|
if (
|
|
831
1001
|
typeof candidate.resumeStateId !== 'string' ||
|
|
1002
|
+
typeof candidate.questionId !== 'string' ||
|
|
832
1003
|
typeof candidate.sourceItem !== 'string' ||
|
|
833
1004
|
typeof candidate.player !== 'string' ||
|
|
834
1005
|
typeof candidate.question !== 'string'
|
|
@@ -836,6 +1007,7 @@ function pendingBossQuestionFromContext(
|
|
|
836
1007
|
return undefined;
|
|
837
1008
|
}
|
|
838
1009
|
return {
|
|
1010
|
+
questionId: candidate.questionId as BossReplyQuestionId,
|
|
839
1011
|
resumeStateId: candidate.resumeStateId,
|
|
840
1012
|
sourceItem: candidate.sourceItem,
|
|
841
1013
|
player: candidate.player,
|
|
@@ -863,9 +1035,7 @@ function formatAwaitBossReplyQuestion(
|
|
|
863
1035
|
// It carries only the resume target, asking player, and source item;
|
|
864
1036
|
// the former `q="<first 80 chars>"` excerpt rider is dropped now that
|
|
865
1037
|
// the full question rides the captain-speech line above.
|
|
866
|
-
function formatAwaitBossReplyMarker(
|
|
867
|
-
context: Record<string, unknown>,
|
|
868
|
-
): string {
|
|
1038
|
+
function formatAwaitBossReplyMarker(context: Record<string, unknown>): string {
|
|
869
1039
|
const pending = pendingBossQuestionFromContext(context);
|
|
870
1040
|
const resumeStateId = pending?.resumeStateId ?? 'unknown';
|
|
871
1041
|
const player = pending?.player ?? 'unknown';
|
|
@@ -911,9 +1081,9 @@ function stateTelemetryPayload(
|
|
|
911
1081
|
context: Record<string, unknown>,
|
|
912
1082
|
): Record<string, unknown> {
|
|
913
1083
|
const payload: Record<string, unknown> = {
|
|
914
|
-
from,
|
|
1084
|
+
from: from ?? null,
|
|
915
1085
|
to,
|
|
916
|
-
event: normalizeEventForTelemetry(event),
|
|
1086
|
+
event: normalizeEventForTelemetry(event) ?? null,
|
|
917
1087
|
};
|
|
918
1088
|
if (to === 'awaitBossReply') {
|
|
919
1089
|
const pendingBossQuestion = pendingBossQuestionFromContext(context);
|
|
@@ -930,6 +1100,32 @@ function stateTelemetryPayload(
|
|
|
930
1100
|
return payload;
|
|
931
1101
|
}
|
|
932
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
|
+
|
|
933
1129
|
// Internal export surface for tests. Not part of the stable public API;
|
|
934
1130
|
// the leading underscore signals "subject to change." Each member is
|
|
935
1131
|
// referenced here so `noUnusedLocals` stays clean while later tasks
|
|
@@ -955,125 +1151,946 @@ export const _internal = {
|
|
|
955
1151
|
VERBATIM_PAYLOAD_FIELDS,
|
|
956
1152
|
};
|
|
957
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
|
+
|
|
958
1167
|
export default function createPlaybookRuntime(
|
|
959
1168
|
options: CodePlaybookOptions,
|
|
960
1169
|
): PlaybookRuntime {
|
|
1170
|
+
const boundOptions = snapshotCodePlaybookOptions(options);
|
|
961
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;
|
|
962
1177
|
let savedPorts: PlaybookPorts | undefined;
|
|
1178
|
+
let runtimePorts: PlaybookPorts | undefined;
|
|
963
1179
|
// The Boss's per-turn AbortSignal, surfaced to captainBridge so
|
|
964
1180
|
// ports.callPlayer / callJudge see the right cancellation source.
|
|
965
1181
|
// null between turns; set by handleBossInput.
|
|
966
1182
|
let activeSignal: AbortSignal | undefined;
|
|
1183
|
+
let activeTurnId: number | undefined;
|
|
1184
|
+
let controlPlaneError: unknown;
|
|
967
1185
|
// Previous root-machine state for the inspect-driven telemetry /
|
|
968
1186
|
// status emitter. undefined before the first inspect firing.
|
|
969
|
-
let priorState:
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
let
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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;
|
|
983
1453
|
try {
|
|
984
|
-
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
|
+
);
|
|
985
1466
|
} catch {
|
|
986
|
-
//
|
|
987
|
-
// surfaces real failures via handleBossInput throws.
|
|
1467
|
+
// The original non-abort port rejection remains authoritative.
|
|
988
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;
|
|
989
1472
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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 };
|
|
993
1654
|
}
|
|
994
1655
|
|
|
995
|
-
|
|
996
|
-
|
|
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;
|
|
997
1709
|
}
|
|
998
1710
|
|
|
999
1711
|
function buildActor(
|
|
1000
1712
|
ports: PlaybookPorts,
|
|
1713
|
+
machineSnapshot?: JsonValue,
|
|
1001
1714
|
): ReturnType<typeof createActor> {
|
|
1002
1715
|
priorState = undefined;
|
|
1003
|
-
|
|
1716
|
+
let builtActor: ReturnType<typeof createActor>;
|
|
1717
|
+
builtActor = createActor(
|
|
1004
1718
|
codingMachine.provide({
|
|
1005
|
-
actors: {
|
|
1719
|
+
actors: {
|
|
1720
|
+
player: captainBridge(
|
|
1721
|
+
ports,
|
|
1722
|
+
() => activeSignal,
|
|
1723
|
+
boundary,
|
|
1724
|
+
(error) => {
|
|
1725
|
+
if (!activeSignal?.aborted) controlPlaneError ??= error;
|
|
1726
|
+
},
|
|
1727
|
+
),
|
|
1728
|
+
},
|
|
1006
1729
|
}),
|
|
1007
1730
|
{
|
|
1008
|
-
input:
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
if (typeof snap.value !== 'string') return;
|
|
1018
|
-
const to = snap.value;
|
|
1019
|
-
if (priorState === to) return;
|
|
1020
|
-
const from = priorState;
|
|
1021
|
-
priorState = to;
|
|
1022
|
-
// Telemetry on every transition (PBRT-14).
|
|
1023
|
-
const context = snap.context ?? {};
|
|
1024
|
-
enqueueEmit(() =>
|
|
1025
|
-
ports.emitTelemetry({
|
|
1026
|
-
topic: 'playbook.fsm.state',
|
|
1027
|
-
payload: stateTelemetryPayload(
|
|
1028
|
-
from,
|
|
1029
|
-
to,
|
|
1030
|
-
inspectionEvent.event,
|
|
1031
|
-
context,
|
|
1032
|
-
),
|
|
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
|
+
>,
|
|
1033
1740
|
}),
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
const
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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);
|
|
1065
1807
|
}
|
|
1066
1808
|
},
|
|
1067
1809
|
},
|
|
1068
1810
|
);
|
|
1811
|
+
return builtActor;
|
|
1069
1812
|
}
|
|
1070
1813
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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();
|
|
1076
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
|
+
}
|
|
1077
2094
|
},
|
|
1078
2095
|
|
|
1079
2096
|
async handleBossInput({
|
|
@@ -1082,68 +2099,249 @@ export default function createPlaybookRuntime(
|
|
|
1082
2099
|
}: {
|
|
1083
2100
|
text: string;
|
|
1084
2101
|
signal: AbortSignal;
|
|
1085
|
-
}): Promise<
|
|
2102
|
+
}): Promise<PlaybookRunResult> {
|
|
1086
2103
|
if (!actor || !savedPorts) {
|
|
1087
2104
|
throw new Error(
|
|
1088
2105
|
'createPlaybookRuntime.handleBossInput: init must be called first',
|
|
1089
2106
|
);
|
|
1090
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;
|
|
1091
2120
|
activeSignal = signal;
|
|
2121
|
+
controlPlaneError = undefined;
|
|
2122
|
+
let result: PlaybookRunResult | undefined;
|
|
2123
|
+
let operationError: unknown;
|
|
1092
2124
|
try {
|
|
2125
|
+
await emitTrace('boss.input.received', { text }, { turnId });
|
|
1093
2126
|
// 1. Classify non-empty text into an FSM event through the judge.
|
|
1094
2127
|
const event = await classifyBossText(
|
|
1095
2128
|
text,
|
|
1096
|
-
|
|
2129
|
+
runtimePorts!,
|
|
1097
2130
|
signal,
|
|
1098
2131
|
actor.getSnapshot(),
|
|
2132
|
+
boundary,
|
|
1099
2133
|
);
|
|
1100
2134
|
// Empty input, no-action classifier output, or invalid classifier
|
|
1101
2135
|
// output — nothing to send.
|
|
1102
2136
|
if (event === undefined) {
|
|
1103
|
-
|
|
1104
|
-
|
|
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));
|
|
1105
2155
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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 },
|
|
1114
2184
|
);
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
actor = buildActor(savedPorts);
|
|
1120
|
-
actor.start();
|
|
1121
|
-
}
|
|
1122
|
-
// 4. Send the event.
|
|
1123
|
-
actor.send(event);
|
|
1124
|
-
// 5. Drive to quiescence. On signal-abort we take no FSM
|
|
1125
|
-
// action: the captain bridge's awaited callPlayer rejects
|
|
1126
|
-
// naturally, the bridge throws, XState routes through
|
|
1127
|
-
// onError → #failed, and this loop sees the quiescent
|
|
1128
|
-
// snapshot and returns (DR-004 §8 natural rejection).
|
|
1129
|
-
await driveToQuiescence(actor);
|
|
1130
|
-
// Drain transition emissions before returning so the Boss
|
|
1131
|
-
// sees the final status line for this turn.
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
settlementEmissionError = error;
|
|
2187
|
+
}
|
|
2188
|
+
try {
|
|
1132
2189
|
await drainEmissions();
|
|
1133
|
-
}
|
|
1134
|
-
|
|
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;
|
|
1135
2209
|
}
|
|
2210
|
+
return settlementResult;
|
|
1136
2211
|
},
|
|
1137
2212
|
|
|
1138
|
-
async
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
+
);
|
|
1142
2222
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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;
|
|
1147
2345
|
},
|
|
1148
2346
|
|
|
1149
2347
|
// @internal — test-only escape hatch for inspecting the
|
|
@@ -1155,28 +2353,12 @@ export default function createPlaybookRuntime(
|
|
|
1155
2353
|
_getActor() {
|
|
1156
2354
|
return actor;
|
|
1157
2355
|
},
|
|
2356
|
+
_getBoundary() {
|
|
2357
|
+
return boundary;
|
|
2358
|
+
},
|
|
2359
|
+
_getNestedBridge() {
|
|
2360
|
+
return nestedBridge;
|
|
2361
|
+
},
|
|
1158
2362
|
};
|
|
1159
2363
|
return runtime as PlaybookRuntime;
|
|
1160
2364
|
}
|
|
1161
|
-
|
|
1162
|
-
function driveToQuiescence(
|
|
1163
|
-
actor: ReturnType<typeof createActor>,
|
|
1164
|
-
): Promise<void> {
|
|
1165
|
-
return new Promise<void>((resolve) => {
|
|
1166
|
-
if (isQuiescent(actor.getSnapshot())) {
|
|
1167
|
-
resolve();
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
const sub = actor.subscribe((snap) => {
|
|
1171
|
-
if (isQuiescent(snap)) {
|
|
1172
|
-
sub.unsubscribe();
|
|
1173
|
-
resolve();
|
|
1174
|
-
}
|
|
1175
|
-
});
|
|
1176
|
-
});
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
function isQuiescent(snap: { value: unknown }): boolean {
|
|
1180
|
-
const v = snap.value;
|
|
1181
|
-
return typeof v === 'string' && QUIESCENT_STATES.has(v);
|
|
1182
|
-
}
|