@sublang/playbook 1.0.0 → 2.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 +8 -1
- package/package.json +6 -2
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +1 -1
- package/reference/sdlc/code.playbook/bin/playbook.js +6 -2
- package/reference/sdlc/code.playbook/bin/run.js +110 -4
- package/reference/sdlc/code.playbook/code.playbook.d.ts +4 -16
- package/reference/sdlc/code.playbook/code.playbook.js +88 -1272
- package/reference/sdlc/code.playbook/code.playbook.ts +127 -1547
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +11 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +1 -1
- package/slc/link.md +122 -47
- package/slc/optimize.md +7 -3
- package/slc/text2gears.md +8 -2
- package/src/runtime.d.ts +1 -0
- package/src/runtime.ts +1 -0
- package/src/xstate-playbook-runtime.d.ts +201 -0
- package/src/xstate-playbook-runtime.js +2099 -0
- package/src/xstate-playbook-runtime.ts +2849 -0
- package/src/xstate-runtime.d.ts +1 -0
- package/src/xstate-runtime.js +11 -0
- package/src/xstate-runtime.ts +14 -0
|
@@ -15,21 +15,26 @@
|
|
|
15
15
|
// PlaybookRuntime imported and re-exported from
|
|
16
16
|
// @sublang/playbook/runtime
|
|
17
17
|
// (slc/link.md §Output, DR-004 Addendum A4)
|
|
18
|
+
// Runtime: the shared createXStatePlaybookRuntime factory from
|
|
19
|
+
// @sublang/playbook/xstate-runtime interprets the FSM
|
|
20
|
+
// (slc/link.md §Output, DR-019); this module carries only
|
|
21
|
+
// the CODE-specific spec — options validation, player
|
|
22
|
+
// binding, prompt composition, Boss-event classification,
|
|
23
|
+
// and Captain-pane status formatting.
|
|
18
24
|
|
|
19
|
-
import PQueue from 'p-queue';
|
|
20
|
-
import { createActor, fromPromise } from 'xstate';
|
|
21
|
-
import type { InspectionEvent, SnapshotFrom } from 'xstate';
|
|
22
25
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
detachPersistedMachineSnapshot,
|
|
26
|
+
createPlayerBridge,
|
|
27
|
+
createXStatePlaybookRuntime,
|
|
28
|
+
adjudicatePlayerOutput,
|
|
27
29
|
normalizeError,
|
|
28
|
-
|
|
30
|
+
normalizeErrorCompact,
|
|
31
|
+
normalizeErrorFull,
|
|
32
|
+
parseJudgeJson,
|
|
29
33
|
snapshotJsonValue,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
type PlaybookPlayerInput,
|
|
35
|
+
type RuntimeBoundaryCalls,
|
|
36
|
+
type ScheduledStatus,
|
|
37
|
+
type XStatePlaybookRuntimeSpec,
|
|
33
38
|
} from '../../../src/xstate-runtime.js';
|
|
34
39
|
import {
|
|
35
40
|
codingMachine,
|
|
@@ -126,12 +131,6 @@ function snapshotCodePlaybookOptions(value: unknown): CodePlaybookOptions {
|
|
|
126
131
|
return captured as unknown as CodePlaybookOptions;
|
|
127
132
|
}
|
|
128
133
|
|
|
129
|
-
const BOSS_REPLY_ERRORS = {
|
|
130
|
-
missingQuestion: "needsBossReply outcome missing 'question' field",
|
|
131
|
-
unregisteredState: (stateId: string) =>
|
|
132
|
-
`state ${stateId} declared needsBossReply but is not registered as resumable`,
|
|
133
|
-
} as const;
|
|
134
|
-
|
|
135
134
|
// Required-payload fields whose value is the player's verbatim long-form
|
|
136
135
|
// prose. The runtime carries `finalText.trim()` into these fields rather
|
|
137
136
|
// than asking the judge to round-trip the text through JSON. Short
|
|
@@ -142,34 +141,6 @@ const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
|
|
|
142
141
|
'challenges',
|
|
143
142
|
]);
|
|
144
143
|
|
|
145
|
-
// Normalize an unknown error value to the compact `{ name, message }`
|
|
146
|
-
// shape used by Captain-pane / status emissions. Returns `undefined`
|
|
147
|
-
// for nullish input so callers can omit absent errors.
|
|
148
|
-
function normalizeErrorCompact(
|
|
149
|
-
err: unknown,
|
|
150
|
-
): { name: string; message: string } | undefined {
|
|
151
|
-
if (err === undefined || err === null) return undefined;
|
|
152
|
-
const normalized = normalizeError(err);
|
|
153
|
-
return { name: normalized.name, message: normalized.message };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Normalize an unknown error value to the full `{ name, message, stack }`
|
|
157
|
-
// shape used by telemetry emissions. Returns `undefined` for nullish
|
|
158
|
-
// input. `stack` is omitted when not available on the source value.
|
|
159
|
-
function normalizeErrorFull(
|
|
160
|
-
err: unknown,
|
|
161
|
-
): { name: string; message: string; stack?: string } | undefined {
|
|
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
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
144
|
// Normalize any `error` field inside a telemetry event so failed
|
|
174
145
|
// transitions don't leak raw Error instances through the channel.
|
|
175
146
|
function normalizeEventForTelemetry(event: unknown): unknown {
|
|
@@ -220,10 +191,6 @@ function normalizeEventValue(
|
|
|
220
191
|
return snapshotJsonValue(normalized, path);
|
|
221
192
|
}
|
|
222
193
|
|
|
223
|
-
// Internal capabilities (DR-004 §10). Each ships with its final
|
|
224
|
-
// signature; behavior lands in the per-capability task noted by the
|
|
225
|
-
// TODO marker.
|
|
226
|
-
|
|
227
194
|
// Player-prompt composer — DR-004 §6.
|
|
228
195
|
// Substitutes the three placeholder tokens in `input.prompt` (literal
|
|
229
196
|
// string replace, no escaping) and arranges labelled blocks around
|
|
@@ -312,89 +279,6 @@ function resolvePlayerId(input: PlayerInput): string {
|
|
|
312
279
|
}
|
|
313
280
|
}
|
|
314
281
|
|
|
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
|
-
|
|
332
|
-
// LLM judge — DR-004 §4. Builds a prompt that lists each declared
|
|
333
|
-
// outcome verbatim, asks ports.callJudge for a JSON
|
|
334
|
-
// `{ guard, …payloadFields }` response, and returns the parsed
|
|
335
|
-
// object once the chosen guard is one of the input.result keys.
|
|
336
|
-
// Adjudicator failures (malformed JSON, missing/unknown guard) are
|
|
337
|
-
// control-plane errors and propagate via throw per slc/link.md.
|
|
338
|
-
async function adjudicate(
|
|
339
|
-
input: PlayerInput,
|
|
340
|
-
finalText: string,
|
|
341
|
-
ports: PlaybookPorts,
|
|
342
|
-
signal: AbortSignal,
|
|
343
|
-
boundary?: RuntimeBoundaryCalls,
|
|
344
|
-
): Promise<PlayerOutput> {
|
|
345
|
-
const prompt = buildJudgePrompt(input, finalText);
|
|
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);
|
|
354
|
-
const parsed = parseJudgeJson(raw);
|
|
355
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
356
|
-
throw new Error('adjudicate: judge response is not a JSON object');
|
|
357
|
-
}
|
|
358
|
-
const obj = parsed as Record<string, unknown>;
|
|
359
|
-
const guard = obj.guard;
|
|
360
|
-
if (typeof guard !== 'string') {
|
|
361
|
-
throw new Error('adjudicate: judge response missing string "guard" field');
|
|
362
|
-
}
|
|
363
|
-
if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
|
|
364
|
-
throw new Error(
|
|
365
|
-
`adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(
|
|
366
|
-
input.result,
|
|
367
|
-
).join(', ')}`,
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
// Per slc/link.md, a missing payload field the state's `result`
|
|
371
|
-
// description requires is a control-plane error. The FSM names
|
|
372
|
-
// required fields with the literal phrase
|
|
373
|
-
// Output shall include `<fieldName>: <...>`
|
|
374
|
-
// so we extract those tokens and require each to be a string in
|
|
375
|
-
// the judge response — except for VERBATIM_PAYLOAD_FIELDS
|
|
376
|
-
// (`reviews`, `challenges`), where the runtime substitutes
|
|
377
|
-
// `finalText.trim()` so the long-form prose is not round-tripped
|
|
378
|
-
// through judge JSON. Short extracted fields like `question` and
|
|
379
|
-
// `taskDescription` keep the existing extract-and-validate path.
|
|
380
|
-
const verbatim = finalText.trim();
|
|
381
|
-
for (const field of extractRequiredFields(input.result[guard])) {
|
|
382
|
-
if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
|
|
383
|
-
obj[field] = verbatim;
|
|
384
|
-
continue;
|
|
385
|
-
}
|
|
386
|
-
if (typeof obj[field] !== 'string') {
|
|
387
|
-
if (guard === 'needsBossReply' && field === 'question') {
|
|
388
|
-
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
389
|
-
}
|
|
390
|
-
throw new Error(
|
|
391
|
-
`adjudicate: judge response missing required field "${field}" for guard "${guard}"`,
|
|
392
|
-
);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
return obj as PlayerOutput;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
282
|
function extractRequiredFields(description: string): string[] {
|
|
399
283
|
const fields: string[] = [];
|
|
400
284
|
const re = /Output shall include `([A-Za-z_][A-Za-z0-9_]*):/g;
|
|
@@ -427,137 +311,38 @@ function buildJudgePrompt(input: PlayerInput, finalText: string): string {
|
|
|
427
311
|
return lines.join('\n');
|
|
428
312
|
}
|
|
429
313
|
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
// truncation) span, so a damaged object earlier in the prose is not
|
|
440
|
-
// overridden by a cleaner one later. Scanning every start (not just
|
|
441
|
-
// the first bracket) keeps a bracketed fragment in surrounding prose —
|
|
442
|
-
// e.g. an aside like `see [1]` or `{n/a}` before the real object —
|
|
443
|
-
// from masking a later, genuinely valid object. Both callers
|
|
444
|
-
// (classification and adjudication) expect an object, so plain objects
|
|
445
|
-
// win over arrays/scalars; the first value of any shape is remembered
|
|
446
|
-
// so a legitimately array/scalar reply still surfaces to the caller's
|
|
447
|
-
// own object check. Only a reply from which no JSON value can be
|
|
448
|
-
// recovered is treated as malformed and throws, preserving the
|
|
449
|
-
// control-plane error contract (PBRT-7, PBRT-10).
|
|
450
|
-
function parseJudgeJson(raw: string): unknown {
|
|
451
|
-
const fenced = stripCodeFence(raw.trim());
|
|
452
|
-
// Fast path: a well-formed (optionally fenced) JSON body.
|
|
453
|
-
try {
|
|
454
|
-
return JSON.parse(fenced);
|
|
455
|
-
} catch {
|
|
456
|
-
// Fall through to lenient extraction + repair.
|
|
457
|
-
}
|
|
458
|
-
const starts: number[] = [];
|
|
459
|
-
for (let i = 0; i < fenced.length; i++) {
|
|
460
|
-
const ch = fenced[i];
|
|
461
|
-
if (ch === '{' || ch === '[') starts.push(i);
|
|
462
|
-
}
|
|
463
|
-
// Walk starts in document order. At each start prefer a strict
|
|
464
|
-
// balanced span (most trustworthy) and fall back to a repaired one
|
|
465
|
-
// for a trailing-comma / truncated tail, so the earliest intended
|
|
466
|
-
// object wins even when it needs repair. Return the first plain
|
|
467
|
-
// object; remember the first value of any shape so a legitimately
|
|
468
|
-
// array/scalar reply still surfaces to the caller's own object check.
|
|
469
|
-
let firstValue: { value: unknown } | undefined;
|
|
470
|
-
for (const start of starts) {
|
|
471
|
-
let parsedHere: { value: unknown } | undefined;
|
|
472
|
-
for (const repair of [false, true]) {
|
|
473
|
-
const candidate = extractJsonValue(fenced, start, repair);
|
|
474
|
-
if (candidate === undefined) continue;
|
|
475
|
-
try {
|
|
476
|
-
parsedHere = { value: JSON.parse(candidate) };
|
|
477
|
-
} catch {
|
|
478
|
-
continue; // not parseable this way — try repair, then next start
|
|
479
|
-
}
|
|
480
|
-
break; // prefer the strict span at this start over its repair
|
|
481
|
-
}
|
|
482
|
-
if (parsedHere === undefined) continue;
|
|
483
|
-
if (isPlainObject(parsedHere.value)) return parsedHere.value;
|
|
484
|
-
if (firstValue === undefined) firstValue = parsedHere;
|
|
485
|
-
}
|
|
486
|
-
if (firstValue !== undefined) return firstValue.value;
|
|
487
|
-
throw new Error('adjudicate: judge response is not valid JSON');
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function isPlainObject(value: unknown): boolean {
|
|
491
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Strip a single Markdown code fence that wraps the whole string.
|
|
495
|
-
function stripCodeFence(text: string): string {
|
|
496
|
-
const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
|
497
|
-
return fence ? fence[1].trim() : text;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Scan from `start` (a `{`/`[` index), tracking string and
|
|
501
|
-
// bracket-nesting state, and emit the balanced JSON value rooted
|
|
502
|
-
// there. Anything after the top-level value closes is ignored (so a
|
|
503
|
-
// trailing code fence or commentary does not matter). With
|
|
504
|
-
// `repair === false` the span is returned only if it actually closes,
|
|
505
|
-
// and trailing commas are left intact — so the caller can prefer a
|
|
506
|
-
// cleanly-balanced span before attempting repair; if input ends
|
|
507
|
-
// before the value closes, undefined is returned. With
|
|
508
|
-
// `repair === true` common damage is fixed: a trailing comma before a
|
|
509
|
-
// close is removed, an unterminated string is closed, and any
|
|
510
|
-
// brackets still open at end-of-input are closed in order.
|
|
511
|
-
function extractJsonValue(
|
|
512
|
-
text: string,
|
|
513
|
-
start: number,
|
|
514
|
-
repair: boolean,
|
|
515
|
-
): string | undefined {
|
|
516
|
-
const stack: string[] = [];
|
|
517
|
-
let out = '';
|
|
518
|
-
let inString = false;
|
|
519
|
-
let escaped = false;
|
|
520
|
-
for (let i = start; i < text.length; i++) {
|
|
521
|
-
const ch = text[i];
|
|
522
|
-
if (inString) {
|
|
523
|
-
out += ch;
|
|
524
|
-
if (escaped) escaped = false;
|
|
525
|
-
else if (ch === '\\') escaped = true;
|
|
526
|
-
else if (ch === '"') inString = false;
|
|
527
|
-
continue;
|
|
528
|
-
}
|
|
529
|
-
if (ch === '"') {
|
|
530
|
-
inString = true;
|
|
531
|
-
out += ch;
|
|
532
|
-
continue;
|
|
533
|
-
}
|
|
534
|
-
if (ch === '{' || ch === '[') {
|
|
535
|
-
stack.push(ch === '{' ? '}' : ']');
|
|
536
|
-
out += ch;
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
539
|
-
if (ch === '}' || ch === ']') {
|
|
540
|
-
if (repair) out = dropTrailingComma(out);
|
|
541
|
-
out += ch;
|
|
542
|
-
stack.pop();
|
|
543
|
-
if (stack.length === 0) return out; // top-level value complete
|
|
544
|
-
continue;
|
|
545
|
-
}
|
|
546
|
-
out += ch;
|
|
547
|
-
}
|
|
548
|
-
// End of input before the top-level value closed.
|
|
549
|
-
if (!repair) return undefined; // strict pass: no balanced span here
|
|
550
|
-
if (inString) out += '"';
|
|
551
|
-
out = dropTrailingComma(out);
|
|
552
|
-
while (stack.length > 0) out += stack.pop();
|
|
553
|
-
return out;
|
|
554
|
-
}
|
|
314
|
+
// CODE-specific adjudication strategy: the CODE judge prompt above, the
|
|
315
|
+
// DR-004 `Output shall include` required-field extraction, and the
|
|
316
|
+
// verbatim long-form payload fields.
|
|
317
|
+
const CODE_ADJUDICATION = {
|
|
318
|
+
buildJudgePrompt: (input: PlaybookPlayerInput, finalText: string) =>
|
|
319
|
+
buildJudgePrompt(input as unknown as PlayerInput, finalText),
|
|
320
|
+
extractRequiredFields,
|
|
321
|
+
verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
|
|
322
|
+
};
|
|
555
323
|
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
// `{
|
|
559
|
-
|
|
560
|
-
|
|
324
|
+
// LLM judge — DR-004 §4. Delegates to the shared adjudicator with the
|
|
325
|
+
// CODE strategy: it lists each declared outcome verbatim, asks
|
|
326
|
+
// ports.callJudge for a JSON `{ guard, …payloadFields }` response, and
|
|
327
|
+
// returns the parsed object once the chosen guard is one of the
|
|
328
|
+
// input.result keys. Adjudicator failures (malformed JSON,
|
|
329
|
+
// missing/unknown guard) are control-plane errors and propagate via
|
|
330
|
+
// throw per slc/link.md.
|
|
331
|
+
async function adjudicate(
|
|
332
|
+
input: PlayerInput,
|
|
333
|
+
finalText: string,
|
|
334
|
+
ports: PlaybookPorts,
|
|
335
|
+
signal: AbortSignal,
|
|
336
|
+
boundary?: RuntimeBoundaryCalls,
|
|
337
|
+
): Promise<PlayerOutput> {
|
|
338
|
+
return (await adjudicatePlayerOutput(
|
|
339
|
+
CODE_ADJUDICATION,
|
|
340
|
+
input,
|
|
341
|
+
finalText,
|
|
342
|
+
ports,
|
|
343
|
+
signal,
|
|
344
|
+
boundary,
|
|
345
|
+
)) as unknown as PlayerOutput;
|
|
561
346
|
}
|
|
562
347
|
|
|
563
348
|
// Boss-event classifier — DR-004 §3.
|
|
@@ -811,11 +596,12 @@ function buildClassifierPrompt(text: string, state: ClassifierState): string {
|
|
|
811
596
|
}
|
|
812
597
|
|
|
813
598
|
// Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
|
|
814
|
-
// codingMachine invokes from every player-invoking state
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
599
|
+
// codingMachine invokes from every player-invoking state, built by the
|
|
600
|
+
// shared createPlayerBridge with the CODE binding, composer, and
|
|
601
|
+
// adjudication strategy. Per turn: resolve playerId, compose the player
|
|
602
|
+
// prompt, await ports.callPlayer, adjudicate the finalText. PlayerResult
|
|
603
|
+
// status of 'aborted' or 'error' throws so XState routes via onError →
|
|
604
|
+
// #failed (the single fail-stop sink for both Captain errors and player
|
|
819
605
|
// failures). Captain remains the orchestrator and adjudicator; it is not
|
|
820
606
|
// encoded as the delegated FSM actor.
|
|
821
607
|
//
|
|
@@ -830,40 +616,20 @@ function captainBridge(
|
|
|
830
616
|
boundary?: RuntimeBoundaryCalls,
|
|
831
617
|
onControlPlaneError?: (error: unknown) => void,
|
|
832
618
|
) {
|
|
833
|
-
return
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
:
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
if (result.finalText === undefined) {
|
|
848
|
-
throw new Error(
|
|
849
|
-
'captainBridge: callPlayer returned status=ok with no finalText',
|
|
850
|
-
);
|
|
851
|
-
}
|
|
852
|
-
try {
|
|
853
|
-
const output = await adjudicate(
|
|
854
|
-
input,
|
|
855
|
-
result.finalText,
|
|
856
|
-
ports,
|
|
857
|
-
activeSignal,
|
|
858
|
-
boundary,
|
|
859
|
-
);
|
|
860
|
-
validateBossReplyOutput(input, output);
|
|
861
|
-
return output;
|
|
862
|
-
} catch (error) {
|
|
863
|
-
onControlPlaneError?.(error);
|
|
864
|
-
throw error;
|
|
865
|
-
}
|
|
866
|
-
});
|
|
619
|
+
return createPlayerBridge(
|
|
620
|
+
{
|
|
621
|
+
resolvePlayerId: (input) =>
|
|
622
|
+
resolvePlayerId(input as unknown as PlayerInput),
|
|
623
|
+
composePlayerPrompt: (input) =>
|
|
624
|
+
composePlayerPrompt(input as unknown as PlayerInput),
|
|
625
|
+
adjudication: CODE_ADJUDICATION,
|
|
626
|
+
resumableStateIds: registeredResumableStateIds,
|
|
627
|
+
},
|
|
628
|
+
ports,
|
|
629
|
+
getActiveSignal,
|
|
630
|
+
boundary,
|
|
631
|
+
onControlPlaneError,
|
|
632
|
+
);
|
|
867
633
|
}
|
|
868
634
|
|
|
869
635
|
// Captain pane display — PBRT-3 / PBRT-14.
|
|
@@ -937,20 +703,6 @@ const registeredResumableStateIds: ReadonlySet<string> = new Set(
|
|
|
937
703
|
),
|
|
938
704
|
);
|
|
939
705
|
|
|
940
|
-
function validateBossReplyOutput(
|
|
941
|
-
input: PlayerInput,
|
|
942
|
-
output: PlayerOutput,
|
|
943
|
-
): void {
|
|
944
|
-
if (output.guard !== 'needsBossReply') return;
|
|
945
|
-
if (typeof output.question !== 'string') {
|
|
946
|
-
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
947
|
-
}
|
|
948
|
-
const stateId = input.stateId;
|
|
949
|
-
if (!registeredResumableStateIds.has(stateId)) {
|
|
950
|
-
throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId));
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
|
|
954
706
|
const QUIESCENT_STATES: ReadonlySet<string> = new Set([
|
|
955
707
|
'ready',
|
|
956
708
|
'awaitBossReply',
|
|
@@ -1100,36 +852,47 @@ function stateTelemetryPayload(
|
|
|
1100
852
|
return payload;
|
|
1101
853
|
}
|
|
1102
854
|
|
|
1103
|
-
|
|
1104
|
-
|
|
855
|
+
// Captain-pane status lines for a root transition (PBRT-3 / PBRT-14):
|
|
856
|
+
// the `→ guard` outcome line for the settling transition, then either
|
|
857
|
+
// the awaitBossReply question + rider-less marker pair or the state's
|
|
858
|
+
// entry line (with `lastError` data on `failed`).
|
|
859
|
+
function statusesForState(
|
|
1105
860
|
state: PlaybookState,
|
|
1106
|
-
event: unknown,
|
|
1107
861
|
context: Record<string, unknown>,
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
if (state.stateId === 'awaitBossReply') {
|
|
1117
|
-
const pendingBossQuestion = pendingBossQuestionFromContext(context);
|
|
1118
|
-
if (pendingBossQuestion !== undefined) {
|
|
1119
|
-
payload.pendingBossQuestion = pendingBossQuestion;
|
|
1120
|
-
}
|
|
862
|
+
event: unknown,
|
|
863
|
+
): ScheduledStatus[] {
|
|
864
|
+
const to = state.stateId;
|
|
865
|
+
if (to === undefined || !CAPTAIN_PANE_STATES.has(to)) return [];
|
|
866
|
+
const statuses: ScheduledStatus[] = [];
|
|
867
|
+
const transitionLine = formatTransition(event);
|
|
868
|
+
if (transitionLine !== undefined) {
|
|
869
|
+
statuses.push({ message: transitionLine });
|
|
1121
870
|
}
|
|
1122
|
-
if (
|
|
1123
|
-
|
|
1124
|
-
|
|
871
|
+
if (to === 'awaitBossReply') {
|
|
872
|
+
statuses.push(
|
|
873
|
+
{ message: formatAwaitBossReplyQuestion(context) },
|
|
874
|
+
{ message: formatAwaitBossReplyMarker(context) },
|
|
875
|
+
);
|
|
876
|
+
} else {
|
|
877
|
+
const entryLine = formatStateEntry(to);
|
|
878
|
+
if (entryLine !== undefined) {
|
|
879
|
+
const lastError =
|
|
880
|
+
to === 'failed' ? normalizeErrorCompact(context.lastError) : undefined;
|
|
881
|
+
statuses.push({
|
|
882
|
+
message: entryLine,
|
|
883
|
+
...(lastError === undefined
|
|
884
|
+
? {}
|
|
885
|
+
: {
|
|
886
|
+
data: snapshotJsonValue({ lastError }, 'failed status data'),
|
|
887
|
+
}),
|
|
888
|
+
});
|
|
889
|
+
}
|
|
1125
890
|
}
|
|
1126
|
-
return
|
|
891
|
+
return statuses;
|
|
1127
892
|
}
|
|
1128
893
|
|
|
1129
894
|
// Internal export surface for tests. Not part of the stable public API;
|
|
1130
|
-
// the leading underscore signals "subject to change."
|
|
1131
|
-
// referenced here so `noUnusedLocals` stays clean while later tasks
|
|
1132
|
-
// wire the factory body to use them.
|
|
895
|
+
// the leading underscore signals "subject to change."
|
|
1133
896
|
export const _internal = {
|
|
1134
897
|
composePlayerPrompt,
|
|
1135
898
|
resolvePlayerId,
|
|
@@ -1151,1214 +914,31 @@ export const _internal = {
|
|
|
1151
914
|
VERBATIM_PAYLOAD_FIELDS,
|
|
1152
915
|
};
|
|
1153
916
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
let savedPorts: PlaybookPorts | undefined;
|
|
1178
|
-
let runtimePorts: PlaybookPorts | undefined;
|
|
1179
|
-
// The Boss's per-turn AbortSignal, surfaced to captainBridge so
|
|
1180
|
-
// ports.callPlayer / callJudge see the right cancellation source.
|
|
1181
|
-
// null between turns; set by handleBossInput.
|
|
1182
|
-
let activeSignal: AbortSignal | undefined;
|
|
1183
|
-
let activeTurnId: number | undefined;
|
|
1184
|
-
let controlPlaneError: unknown;
|
|
1185
|
-
// Previous root-machine state for the inspect-driven telemetry /
|
|
1186
|
-
// status emitter. undefined before the first inspect firing.
|
|
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;
|
|
1453
|
-
try {
|
|
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
|
-
);
|
|
1466
|
-
} catch {
|
|
1467
|
-
// The original non-abort port rejection remains authoritative.
|
|
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;
|
|
1472
|
-
}
|
|
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 };
|
|
1654
|
-
}
|
|
1655
|
-
|
|
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;
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
function buildActor(
|
|
1712
|
-
ports: PlaybookPorts,
|
|
1713
|
-
machineSnapshot?: JsonValue,
|
|
1714
|
-
): ReturnType<typeof createActor> {
|
|
1715
|
-
priorState = undefined;
|
|
1716
|
-
let builtActor: ReturnType<typeof createActor>;
|
|
1717
|
-
builtActor = createActor(
|
|
1718
|
-
codingMachine.provide({
|
|
1719
|
-
actors: {
|
|
1720
|
-
player: captainBridge(
|
|
1721
|
-
ports,
|
|
1722
|
-
() => activeSignal,
|
|
1723
|
-
boundary,
|
|
1724
|
-
(error) => {
|
|
1725
|
-
if (!activeSignal?.aborted) controlPlaneError ??= error;
|
|
1726
|
-
},
|
|
1727
|
-
),
|
|
1728
|
-
},
|
|
1729
|
-
}),
|
|
1730
|
-
{
|
|
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
|
-
>,
|
|
1740
|
-
}),
|
|
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);
|
|
1807
|
-
}
|
|
1808
|
-
},
|
|
1809
|
-
},
|
|
1810
|
-
);
|
|
1811
|
-
return builtActor;
|
|
1812
|
-
}
|
|
1813
|
-
|
|
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();
|
|
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
|
-
}
|
|
2094
|
-
},
|
|
2095
|
-
|
|
2096
|
-
async handleBossInput({
|
|
2097
|
-
text,
|
|
2098
|
-
signal,
|
|
2099
|
-
}: {
|
|
2100
|
-
text: string;
|
|
2101
|
-
signal: AbortSignal;
|
|
2102
|
-
}): Promise<PlaybookRunResult> {
|
|
2103
|
-
if (!actor || !savedPorts) {
|
|
2104
|
-
throw new Error(
|
|
2105
|
-
'createPlaybookRuntime.handleBossInput: init must be called first',
|
|
2106
|
-
);
|
|
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;
|
|
2120
|
-
activeSignal = signal;
|
|
2121
|
-
controlPlaneError = undefined;
|
|
2122
|
-
let result: PlaybookRunResult | undefined;
|
|
2123
|
-
let operationError: unknown;
|
|
2124
|
-
try {
|
|
2125
|
-
await emitTrace('boss.input.received', { text }, { turnId });
|
|
2126
|
-
// 1. Classify non-empty text into an FSM event through the judge.
|
|
2127
|
-
const event = await classifyBossText(
|
|
2128
|
-
text,
|
|
2129
|
-
runtimePorts!,
|
|
2130
|
-
signal,
|
|
2131
|
-
actor.getSnapshot(),
|
|
2132
|
-
boundary,
|
|
2133
|
-
);
|
|
2134
|
-
// Empty input, no-action classifier output, or invalid classifier
|
|
2135
|
-
// output — nothing to send.
|
|
2136
|
-
if (event === undefined) {
|
|
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));
|
|
2155
|
-
}
|
|
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 },
|
|
2184
|
-
);
|
|
2185
|
-
} catch (error) {
|
|
2186
|
-
settlementEmissionError = error;
|
|
2187
|
-
}
|
|
2188
|
-
try {
|
|
2189
|
-
await drainEmissions();
|
|
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;
|
|
2209
|
-
}
|
|
2210
|
-
return settlementResult;
|
|
2211
|
-
},
|
|
2212
|
-
|
|
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
|
-
);
|
|
2222
|
-
}
|
|
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
|
-
},
|
|
917
|
+
// The CODE-specific spec handed to the shared runtime factory
|
|
918
|
+
// (slc/link.md §Output, DR-019). The generic machinery — actor wiring,
|
|
919
|
+
// boundary tracing, Boss-turn lifecycle, nested-playbook bridge, and the
|
|
920
|
+
// DR-014 parked-session snapshot capability — lives in
|
|
921
|
+
// @sublang/playbook/xstate-runtime; this spec carries only what is
|
|
922
|
+
// CODE-specific.
|
|
923
|
+
const runtimeSpec: XStatePlaybookRuntimeSpec<CodePlaybookOptions> = {
|
|
924
|
+
label: 'CODE',
|
|
925
|
+
snapshotOptions: snapshotCodePlaybookOptions,
|
|
926
|
+
resolvePlayerId: (input) => resolvePlayerId(input as unknown as PlayerInput),
|
|
927
|
+
composePlayerPrompt: (input) =>
|
|
928
|
+
composePlayerPrompt(input as unknown as PlayerInput),
|
|
929
|
+
buildJudgePrompt: CODE_ADJUDICATION.buildJudgePrompt,
|
|
930
|
+
extractRequiredFields,
|
|
931
|
+
verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
|
|
932
|
+
resumableStateIds: registeredResumableStateIds,
|
|
933
|
+
classifyBossText: (text, ports, signal, snapshotOrState, boundary) =>
|
|
934
|
+
classifyBossText(text, ports, signal, snapshotOrState, boundary),
|
|
935
|
+
classificationStatus: (event) => formatClassification(event.type),
|
|
936
|
+
statusesForState,
|
|
937
|
+
normalizeTransitionEvent: (event) =>
|
|
938
|
+
normalizeEventForTelemetry(event) as JsonValue | undefined,
|
|
939
|
+
};
|
|
2267
940
|
|
|
2268
|
-
|
|
2269
|
-
|
|
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;
|
|
2345
|
-
},
|
|
941
|
+
const createPlaybookRuntime: PlaybookRuntimeFactory<CodePlaybookOptions> =
|
|
942
|
+
createXStatePlaybookRuntime(codingMachine, runtimeSpec);
|
|
2346
943
|
|
|
2347
|
-
|
|
2348
|
-
// underlying actor's snapshot. Most state assertions are now
|
|
2349
|
-
// expressible via the recorded emitStatus / emitTelemetry
|
|
2350
|
-
// calls (DR-004 §9); the hatch stays for the few cases where
|
|
2351
|
-
// direct context inspection is clearer (e.g., the dispose
|
|
2352
|
-
// teardown test).
|
|
2353
|
-
_getActor() {
|
|
2354
|
-
return actor;
|
|
2355
|
-
},
|
|
2356
|
-
_getBoundary() {
|
|
2357
|
-
return boundary;
|
|
2358
|
-
},
|
|
2359
|
-
_getNestedBridge() {
|
|
2360
|
-
return nestedBridge;
|
|
2361
|
-
},
|
|
2362
|
-
};
|
|
2363
|
-
return runtime as PlaybookRuntime;
|
|
2364
|
-
}
|
|
944
|
+
export default createPlaybookRuntime;
|