@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
|
@@ -0,0 +1,2099 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
//
|
|
4
|
+
// Generic linked-playbook runtime factory (DR-019). The FSM-interpreter
|
|
5
|
+
// machinery that slc/link.md previously regenerated inside every linked
|
|
6
|
+
// `<name>.playbook.ts` artifact — actor wiring, boundary tracing, judge
|
|
7
|
+
// classification/adjudication, script execution, nested-playbook bridging,
|
|
8
|
+
// Boss-reply suspension, snapshot/restore, and disposal — lives here once.
|
|
9
|
+
// A linked artifact supplies only its per-workflow `spec` (options
|
|
10
|
+
// validation and any strategy overrides) and its own FSM; the factory
|
|
11
|
+
// interprets the FSM data the artifact already carries.
|
|
12
|
+
//
|
|
13
|
+
// The machinery is hoisted from the reference CODE artifact
|
|
14
|
+
// (reference/sdlc/code.playbook/code.playbook.ts) verbatim where possible;
|
|
15
|
+
// its behavior tests are the equivalence proof. Do not change observable
|
|
16
|
+
// behavior here without consulting those suites.
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import PQueue from 'p-queue';
|
|
19
|
+
import { createActor, fromPromise } from 'xstate';
|
|
20
|
+
import { assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validateCaptainResult, validatePlayerResult, waitForPlaybookQuiescence, } from './xstate-runtime.js';
|
|
21
|
+
export const BOSS_REPLY_ERRORS = {
|
|
22
|
+
missingQuestion: "needsBossReply outcome missing 'question' field",
|
|
23
|
+
unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
|
|
24
|
+
};
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// A host agent result that is not `ok` (or is `ok` with no final text) is a
|
|
27
|
+
// recoverable FSM failure, not a control-plane error: it travels the invoked
|
|
28
|
+
// actor's XState error path to the failure state and the public boundary
|
|
29
|
+
// resolves `failed` (PBRT-47, matching the player boundary's PBRT-9). The
|
|
30
|
+
// direct-Captain boundary has to emit its paired finish trace before
|
|
31
|
+
// rethrowing, so it needs to tell that failure apart from the control-plane
|
|
32
|
+
// errors it does latch — a thrown port, a malformed result, a rejecting sink.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
const fsmResultFailures = new WeakSet();
|
|
35
|
+
function markFsmResultFailure(error) {
|
|
36
|
+
fsmResultFailures.add(error);
|
|
37
|
+
return error;
|
|
38
|
+
}
|
|
39
|
+
function isFsmResultFailure(error) {
|
|
40
|
+
return (typeof error === 'object' &&
|
|
41
|
+
error !== null &&
|
|
42
|
+
fsmResultFailures.has(error));
|
|
43
|
+
}
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Tolerant judge-JSON recovery (slc/link.md §Boss-event mapping).
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
function isPlainObject(value) {
|
|
48
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
/** Strip a single Markdown code fence that wraps the whole string. */
|
|
51
|
+
export function stripCodeFence(text) {
|
|
52
|
+
const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
|
53
|
+
return fence ? fence[1].trim() : text;
|
|
54
|
+
}
|
|
55
|
+
function dropTrailingComma(out) {
|
|
56
|
+
return out.replace(/,(\s*)$/, '$1');
|
|
57
|
+
}
|
|
58
|
+
// Scan from `start` (a `{`/`[` index), tracking string and bracket-nesting
|
|
59
|
+
// state, and emit the balanced JSON value rooted there. With `repair` false
|
|
60
|
+
// the span is returned only if it actually closes; with `repair` true a
|
|
61
|
+
// trailing comma, an unterminated string, and unclosed brackets are fixed.
|
|
62
|
+
export function extractJsonValue(text, start, repair) {
|
|
63
|
+
const stack = [];
|
|
64
|
+
let out = '';
|
|
65
|
+
let inString = false;
|
|
66
|
+
let escaped = false;
|
|
67
|
+
for (let i = start; i < text.length; i++) {
|
|
68
|
+
const ch = text[i];
|
|
69
|
+
if (inString) {
|
|
70
|
+
out += ch;
|
|
71
|
+
if (escaped)
|
|
72
|
+
escaped = false;
|
|
73
|
+
else if (ch === '\\')
|
|
74
|
+
escaped = true;
|
|
75
|
+
else if (ch === '"')
|
|
76
|
+
inString = false;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === '"') {
|
|
80
|
+
inString = true;
|
|
81
|
+
out += ch;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (ch === '{' || ch === '[') {
|
|
85
|
+
stack.push(ch === '{' ? '}' : ']');
|
|
86
|
+
out += ch;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (ch === '}' || ch === ']') {
|
|
90
|
+
if (repair)
|
|
91
|
+
out = dropTrailingComma(out);
|
|
92
|
+
out += ch;
|
|
93
|
+
stack.pop();
|
|
94
|
+
if (stack.length === 0)
|
|
95
|
+
return out; // top-level value complete
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
out += ch;
|
|
99
|
+
}
|
|
100
|
+
// End of input before the top-level value closed.
|
|
101
|
+
if (!repair)
|
|
102
|
+
return undefined; // strict pass: no balanced span here
|
|
103
|
+
if (inString)
|
|
104
|
+
out += '"';
|
|
105
|
+
out = dropTrailingComma(out);
|
|
106
|
+
while (stack.length > 0)
|
|
107
|
+
out += stack.pop();
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
// Tolerant recovery shared by the classifier and adjudicator: prefer a strict
|
|
111
|
+
// balanced span at the earliest opening brace, then its repair, before
|
|
112
|
+
// advancing to a later candidate. The first plain object wins; the first
|
|
113
|
+
// value of any shape is remembered so a legitimately array/scalar reply still
|
|
114
|
+
// surfaces to the caller's own object check.
|
|
115
|
+
export function parseJudgeJson(raw) {
|
|
116
|
+
const fenced = stripCodeFence(raw.trim());
|
|
117
|
+
// Fast path: a well-formed (optionally fenced) JSON body.
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(fenced);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Fall through to lenient extraction + repair.
|
|
123
|
+
}
|
|
124
|
+
const starts = [];
|
|
125
|
+
for (let i = 0; i < fenced.length; i++) {
|
|
126
|
+
const ch = fenced[i];
|
|
127
|
+
if (ch === '{' || ch === '[')
|
|
128
|
+
starts.push(i);
|
|
129
|
+
}
|
|
130
|
+
let firstValue;
|
|
131
|
+
for (const start of starts) {
|
|
132
|
+
let parsedHere;
|
|
133
|
+
for (const repair of [false, true]) {
|
|
134
|
+
const candidate = extractJsonValue(fenced, start, repair);
|
|
135
|
+
if (candidate === undefined)
|
|
136
|
+
continue;
|
|
137
|
+
try {
|
|
138
|
+
parsedHere = { value: JSON.parse(candidate) };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
continue; // not parseable this way — try repair, then next start
|
|
142
|
+
}
|
|
143
|
+
break; // prefer the strict span at this start over its repair
|
|
144
|
+
}
|
|
145
|
+
if (parsedHere === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
if (isPlainObject(parsedHere.value))
|
|
148
|
+
return parsedHere.value;
|
|
149
|
+
if (firstValue === undefined)
|
|
150
|
+
firstValue = parsedHere;
|
|
151
|
+
}
|
|
152
|
+
if (firstValue !== undefined)
|
|
153
|
+
return firstValue.value;
|
|
154
|
+
throw new Error('adjudicate: judge response is not valid JSON');
|
|
155
|
+
}
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Shared error/context helpers.
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
export function normalizeErrorCompact(err) {
|
|
160
|
+
if (err === undefined || err === null)
|
|
161
|
+
return undefined;
|
|
162
|
+
const normalized = normalizeError(err);
|
|
163
|
+
return { name: normalized.name, message: normalized.message };
|
|
164
|
+
}
|
|
165
|
+
export function normalizeErrorFull(err) {
|
|
166
|
+
if (err === undefined || err === null)
|
|
167
|
+
return undefined;
|
|
168
|
+
return normalizeError(err);
|
|
169
|
+
}
|
|
170
|
+
function isAbortFailure(error, signal) {
|
|
171
|
+
return (signal.aborted &&
|
|
172
|
+
(error === signal.reason || normalizeError(error).name === 'AbortError'));
|
|
173
|
+
}
|
|
174
|
+
/** Read the FSM context's single pending Boss question, when well-formed. */
|
|
175
|
+
export function pendingBossQuestionFromContext(context) {
|
|
176
|
+
const pending = context.pendingBossQuestion;
|
|
177
|
+
if (pending === undefined ||
|
|
178
|
+
pending === null ||
|
|
179
|
+
typeof pending !== 'object') {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
const candidate = pending;
|
|
183
|
+
if (typeof candidate.questionId !== 'string' ||
|
|
184
|
+
typeof candidate.resumeStateId !== 'string' ||
|
|
185
|
+
typeof candidate.sourceItem !== 'string' ||
|
|
186
|
+
typeof candidate.player !== 'string' ||
|
|
187
|
+
typeof candidate.question !== 'string') {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
questionId: candidate.questionId,
|
|
192
|
+
resumeStateId: candidate.resumeStateId,
|
|
193
|
+
sourceItem: candidate.sourceItem,
|
|
194
|
+
player: candidate.player,
|
|
195
|
+
question: candidate.question,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Generic strategy defaults.
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
const CONTINUATION_PREAMBLE = 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
|
|
202
|
+
function continuationBlocks(input) {
|
|
203
|
+
if (input.pendingBossQuestion === undefined || input.bossReply === undefined) {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
return [
|
|
207
|
+
CONTINUATION_PREAMBLE,
|
|
208
|
+
`Boss question:\n${input.pendingBossQuestion.question}`,
|
|
209
|
+
`Boss reply:\n${input.bossReply}`,
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
const PLACEHOLDER_PATTERN = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g;
|
|
213
|
+
function placeholderFieldName(token, fields) {
|
|
214
|
+
const explicit = fields[token];
|
|
215
|
+
if (explicit !== undefined)
|
|
216
|
+
return explicit;
|
|
217
|
+
if (token === '#')
|
|
218
|
+
return 'irNumber';
|
|
219
|
+
return token.replace(/-([A-Za-z0-9])/g, (_match, next) => next.toUpperCase());
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Default player-prompt composer (slc/link.md §Player prompt composition).
|
|
223
|
+
* One callback-based pass substitutes each `<fieldName>` placeholder whose
|
|
224
|
+
* typed input field is a string; replacement text is literal, and
|
|
225
|
+
* placeholder-looking text inside a value is never re-substituted. The
|
|
226
|
+
* continuation preamble and Q/A blocks precede the domain body on resume.
|
|
227
|
+
*/
|
|
228
|
+
export function defaultComposePlayerPrompt(input, placeholderFields = {}) {
|
|
229
|
+
const blocks = continuationBlocks(input);
|
|
230
|
+
const fields = input;
|
|
231
|
+
const body = input.prompt.replace(PLACEHOLDER_PATTERN, (match, token) => {
|
|
232
|
+
const value = fields[placeholderFieldName(token, placeholderFields)];
|
|
233
|
+
return typeof value === 'string' ? value : match;
|
|
234
|
+
});
|
|
235
|
+
blocks.push(body);
|
|
236
|
+
return blocks.join('\n\n');
|
|
237
|
+
}
|
|
238
|
+
function sortJson(value) {
|
|
239
|
+
if (Array.isArray(value))
|
|
240
|
+
return value.map((entry) => sortJson(entry));
|
|
241
|
+
if (value !== null && typeof value === 'object') {
|
|
242
|
+
const record = value;
|
|
243
|
+
const sorted = {};
|
|
244
|
+
for (const key of Object.keys(record).sort()) {
|
|
245
|
+
sorted[key] = sortJson(record[key]);
|
|
246
|
+
}
|
|
247
|
+
return sorted;
|
|
248
|
+
}
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
function stableJson(value, path) {
|
|
252
|
+
return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Default direct-Captain prompt composer (slc/link.md §Captain prompt
|
|
256
|
+
* composition). Placeholder substitution is presence-based: string fields
|
|
257
|
+
* substitute verbatim; JSON-safe arrays/objects render as deterministic JSON
|
|
258
|
+
* with lexicographically sorted keys at every depth.
|
|
259
|
+
*/
|
|
260
|
+
export function defaultComposeCaptainPrompt(input, placeholderFields = {}) {
|
|
261
|
+
const blocks = continuationBlocks(input);
|
|
262
|
+
const fields = input;
|
|
263
|
+
const body = input.prompt.replace(PLACEHOLDER_PATTERN, (match, token) => {
|
|
264
|
+
const field = placeholderFieldName(token, placeholderFields);
|
|
265
|
+
const value = fields[field];
|
|
266
|
+
if (typeof value === 'string')
|
|
267
|
+
return value;
|
|
268
|
+
if (value !== null && typeof value === 'object') {
|
|
269
|
+
return stableJson(value, `CaptainInput.${field}`);
|
|
270
|
+
}
|
|
271
|
+
return match;
|
|
272
|
+
});
|
|
273
|
+
blocks.push(body);
|
|
274
|
+
return blocks.join('\n\n');
|
|
275
|
+
}
|
|
276
|
+
/** Default player binding: each player to its lowercased name. */
|
|
277
|
+
export function defaultResolvePlayerId(input) {
|
|
278
|
+
return input.player.toLowerCase();
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Default required-field extraction (slc/link.md §Captain adjudication).
|
|
282
|
+
* Limited to the description's `Output shall include` / `输出应包含` clause;
|
|
283
|
+
* recognizes both the bare backticked name and the annotated `name: <...>`
|
|
284
|
+
* form.
|
|
285
|
+
*/
|
|
286
|
+
export function defaultExtractRequiredFields(description) {
|
|
287
|
+
const markers = ['Output shall include', '输出应包含'];
|
|
288
|
+
let clauseStart = -1;
|
|
289
|
+
for (const marker of markers) {
|
|
290
|
+
const idx = description.indexOf(marker);
|
|
291
|
+
if (idx !== -1) {
|
|
292
|
+
clauseStart = idx + marker.length;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (clauseStart === -1)
|
|
297
|
+
return [];
|
|
298
|
+
const clause = description.slice(clauseStart);
|
|
299
|
+
const fields = [];
|
|
300
|
+
const re = /`([A-Za-z_$][A-Za-z0-9_$]*)(?::[^`]*)?`/g;
|
|
301
|
+
for (const m of clause.matchAll(re))
|
|
302
|
+
fields.push(m[1]);
|
|
303
|
+
return fields;
|
|
304
|
+
}
|
|
305
|
+
/** Default delegated-player adjudicator prompt. */
|
|
306
|
+
export function defaultBuildJudgePrompt(input, finalText) {
|
|
307
|
+
const lines = [];
|
|
308
|
+
lines.push(`The ${input.player} just produced this output:`);
|
|
309
|
+
lines.push('');
|
|
310
|
+
lines.push('```');
|
|
311
|
+
lines.push(finalText);
|
|
312
|
+
lines.push('```');
|
|
313
|
+
lines.push('');
|
|
314
|
+
lines.push('Pick exactly one outcome by `guard` and return JSON ' +
|
|
315
|
+
'`{ guard, …payloadFields }`. Required payload fields are named in the ' +
|
|
316
|
+
'outcome description after "Output shall include" / "输出应包含".');
|
|
317
|
+
lines.push('');
|
|
318
|
+
for (const [key, description] of Object.entries(input.result)) {
|
|
319
|
+
lines.push(`- \`${key}\` — ${description}`);
|
|
320
|
+
}
|
|
321
|
+
return lines.join('\n');
|
|
322
|
+
}
|
|
323
|
+
const NO_VERBATIM_FIELDS = new Set();
|
|
324
|
+
/**
|
|
325
|
+
* LLM-judge adjudicator for delegated players. Coerces the player's
|
|
326
|
+
* finalText into one of the state's declared guards, extracts every required
|
|
327
|
+
* payload field from the judge reply, and fails loudly (throws) on a missing
|
|
328
|
+
* JSON object, an undeclared guard, or a missing required field. Fields in
|
|
329
|
+
* `verbatimPayloadFields` carry `finalText.trim()` rather than round-tripping
|
|
330
|
+
* long-form prose through judge JSON.
|
|
331
|
+
*/
|
|
332
|
+
export async function adjudicatePlayerOutput(spec, input, finalText, ports, signal, boundary) {
|
|
333
|
+
const buildPrompt = spec.buildJudgePrompt ?? defaultBuildJudgePrompt;
|
|
334
|
+
const extractFields = spec.extractRequiredFields ?? defaultExtractRequiredFields;
|
|
335
|
+
const verbatimFields = spec.verbatimPayloadFields ?? NO_VERBATIM_FIELDS;
|
|
336
|
+
const prompt = buildPrompt(input, finalText);
|
|
337
|
+
const raw = boundary
|
|
338
|
+
? await boundary.callJudge('player-output-adjudication', input.stateId, prompt, signal)
|
|
339
|
+
: await ports.callJudge(prompt, signal);
|
|
340
|
+
const parsed = parseJudgeJson(raw);
|
|
341
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
342
|
+
throw new Error('adjudicate: judge response is not a JSON object');
|
|
343
|
+
}
|
|
344
|
+
const obj = parsed;
|
|
345
|
+
const guard = obj.guard;
|
|
346
|
+
if (typeof guard !== 'string') {
|
|
347
|
+
throw new Error('adjudicate: judge response missing string "guard" field');
|
|
348
|
+
}
|
|
349
|
+
if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
|
|
350
|
+
throw new Error(`adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(input.result).join(', ')}`);
|
|
351
|
+
}
|
|
352
|
+
const verbatim = finalText.trim();
|
|
353
|
+
for (const field of extractFields(input.result[guard])) {
|
|
354
|
+
if (verbatimFields.has(field)) {
|
|
355
|
+
obj[field] = verbatim;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (typeof obj[field] !== 'string') {
|
|
359
|
+
if (guard === 'needsBossReply' && field === 'question') {
|
|
360
|
+
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
361
|
+
}
|
|
362
|
+
throw new Error(`adjudicate: judge response missing required field "${field}" for guard "${guard}"`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return obj;
|
|
366
|
+
}
|
|
367
|
+
function validateBossReplyOutput(input, output, resumableStateIds) {
|
|
368
|
+
if (output.guard !== 'needsBossReply')
|
|
369
|
+
return;
|
|
370
|
+
if (typeof output.question !== 'string') {
|
|
371
|
+
throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
|
|
372
|
+
}
|
|
373
|
+
if (!resumableStateIds.has(input.stateId)) {
|
|
374
|
+
throw new Error(BOSS_REPLY_ERRORS.unregisteredState(input.stateId));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onControlPlaneError) {
|
|
378
|
+
return fromPromise(async ({ input, signal }) => {
|
|
379
|
+
const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
|
|
380
|
+
const playerId = spec.resolvePlayerId(input);
|
|
381
|
+
const prompt = spec.composePlayerPrompt(input);
|
|
382
|
+
const result = boundary
|
|
383
|
+
? await boundary.callPlayer(input, playerId, prompt, activeSignal)
|
|
384
|
+
: await ports.callPlayer(playerId, prompt, activeSignal, {
|
|
385
|
+
resume: false,
|
|
386
|
+
});
|
|
387
|
+
if (result.status !== 'ok') {
|
|
388
|
+
throw new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
|
|
389
|
+
}
|
|
390
|
+
if (result.finalText === undefined) {
|
|
391
|
+
throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
const output = await adjudicatePlayerOutput(spec.adjudication, input, result.finalText, ports, activeSignal, boundary);
|
|
395
|
+
validateBossReplyOutput(input, output, spec.resumableStateIds);
|
|
396
|
+
return output;
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
onControlPlaneError?.(error);
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Direct-Captain adjudication (slc/link.md §Captain adjudication). The judge
|
|
406
|
+
// selects the guard and supplies only other structural fields; the runtime
|
|
407
|
+
// injects the exact visible finalText as the selected output's `question` or
|
|
408
|
+
// `response` and rejects a judge reply that supplies either presentation
|
|
409
|
+
// field as an undeclared extra key.
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
function buildCaptainJudgePrompt(input, finalText) {
|
|
412
|
+
const lines = [];
|
|
413
|
+
lines.push('Adjudicate the direct Captain output for this FSM state.');
|
|
414
|
+
lines.push(`State id: ${input.stateId}`);
|
|
415
|
+
lines.push(`Source item: ${input.sourceItem}`);
|
|
416
|
+
lines.push('');
|
|
417
|
+
lines.push('Visible Captain output:');
|
|
418
|
+
lines.push('```');
|
|
419
|
+
lines.push(finalText);
|
|
420
|
+
lines.push('```');
|
|
421
|
+
lines.push('');
|
|
422
|
+
lines.push('Result keys and descriptions:');
|
|
423
|
+
for (const [key, description] of Object.entries(input.result)) {
|
|
424
|
+
lines.push(`- \`${key}\` — ${description}`);
|
|
425
|
+
}
|
|
426
|
+
lines.push('');
|
|
427
|
+
lines.push('Pick exactly one outcome by `guard` and return JSON ' +
|
|
428
|
+
'`{ guard, …structuralPayloadFields }`. Do not include `question` or ' +
|
|
429
|
+
'`response`; the runtime injects the visible text.');
|
|
430
|
+
return lines.join('\n');
|
|
431
|
+
}
|
|
432
|
+
function adjudicateCaptainOutput(extractFields, input, finalText, judgeText) {
|
|
433
|
+
const parsed = parseJudgeJson(judgeText);
|
|
434
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
435
|
+
throw new Error('adjudicate: judge response is not a JSON object');
|
|
436
|
+
}
|
|
437
|
+
const obj = parsed;
|
|
438
|
+
const guard = obj.guard;
|
|
439
|
+
if (typeof guard !== 'string') {
|
|
440
|
+
throw new Error('adjudicate: judge response missing string "guard" field');
|
|
441
|
+
}
|
|
442
|
+
if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
|
|
443
|
+
throw new Error(`adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(input.result).join(', ')}`);
|
|
444
|
+
}
|
|
445
|
+
const required = extractFields(input.result[guard]);
|
|
446
|
+
const allowed = new Set(['guard']);
|
|
447
|
+
for (const field of required) {
|
|
448
|
+
if (field !== 'question' && field !== 'response')
|
|
449
|
+
allowed.add(field);
|
|
450
|
+
}
|
|
451
|
+
for (const key of Object.keys(obj)) {
|
|
452
|
+
if (!allowed.has(key)) {
|
|
453
|
+
throw new Error(`adjudicate: judge response supplied undeclared field "${key}" for guard "${guard}"`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
for (const field of required) {
|
|
457
|
+
if (field === 'question' || field === 'response')
|
|
458
|
+
continue;
|
|
459
|
+
if (obj[field] === undefined || obj[field] === null) {
|
|
460
|
+
throw new Error(`adjudicate: judge response missing required field "${field}" for guard "${guard}"`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const output = { ...obj, guard };
|
|
464
|
+
if (required.includes('question'))
|
|
465
|
+
output.question = finalText;
|
|
466
|
+
if (required.includes('response'))
|
|
467
|
+
output.response = finalText;
|
|
468
|
+
return output;
|
|
469
|
+
}
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// FSM-artifact introspection over `machine.config` — internal but stable in
|
|
472
|
+
// XState v5: it preserves the literal `createMachine` argument.
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
function stripIdPrefix(target) {
|
|
475
|
+
return target.startsWith('#') ? target.slice(1) : target;
|
|
476
|
+
}
|
|
477
|
+
function collectInvokeSources(machine) {
|
|
478
|
+
const sources = new Set();
|
|
479
|
+
const visit = (stateDef) => {
|
|
480
|
+
if (!isPlainObject(stateDef))
|
|
481
|
+
return;
|
|
482
|
+
const invoke = stateDef.invoke;
|
|
483
|
+
const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : [];
|
|
484
|
+
for (const entry of invokes) {
|
|
485
|
+
if (isPlainObject(entry) && typeof entry.src === 'string') {
|
|
486
|
+
sources.add(entry.src);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (isPlainObject(stateDef.states)) {
|
|
490
|
+
for (const child of Object.values(stateDef.states))
|
|
491
|
+
visit(child);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
visit(machine.config);
|
|
495
|
+
return sources;
|
|
496
|
+
}
|
|
497
|
+
function transitionTargets(transition) {
|
|
498
|
+
const arms = Array.isArray(transition) ? transition : [transition];
|
|
499
|
+
const targets = [];
|
|
500
|
+
for (const arm of arms) {
|
|
501
|
+
if (typeof arm === 'string') {
|
|
502
|
+
targets.push(stripIdPrefix(arm));
|
|
503
|
+
}
|
|
504
|
+
else if (isPlainObject(arm) && typeof arm.target === 'string') {
|
|
505
|
+
targets.push(stripIdPrefix(arm.target));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return targets;
|
|
509
|
+
}
|
|
510
|
+
/** Targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
|
|
511
|
+
export function resumableStateIdsFromMachine(machine) {
|
|
512
|
+
const config = machine.config;
|
|
513
|
+
if (!isPlainObject(config) || !isPlainObject(config.states)) {
|
|
514
|
+
return new Set();
|
|
515
|
+
}
|
|
516
|
+
const awaitState = config.states.awaitBossReply;
|
|
517
|
+
if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
|
|
518
|
+
return new Set();
|
|
519
|
+
}
|
|
520
|
+
const bossReply = awaitState.on.BOSS_REPLY;
|
|
521
|
+
if (bossReply === undefined)
|
|
522
|
+
return new Set();
|
|
523
|
+
return new Set(transitionTargets(bossReply));
|
|
524
|
+
}
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// Default transition/status derivation.
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
|
|
529
|
+
function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
|
|
530
|
+
return (event) => {
|
|
531
|
+
if (event === null || typeof event !== 'object') {
|
|
532
|
+
return snapshotJsonValue(event ?? null, 'FSM event');
|
|
533
|
+
}
|
|
534
|
+
const e = event;
|
|
535
|
+
const out = {};
|
|
536
|
+
if (typeof e.type === 'string')
|
|
537
|
+
out.type = e.type;
|
|
538
|
+
for (const field of transitionEventFields) {
|
|
539
|
+
if (typeof e[field] === 'string')
|
|
540
|
+
out[field] = e[field];
|
|
541
|
+
}
|
|
542
|
+
if (e.output !== undefined) {
|
|
543
|
+
out.output = snapshotJsonValue(e.output, 'FSM event output');
|
|
544
|
+
}
|
|
545
|
+
if (e.error !== undefined) {
|
|
546
|
+
out.error = snapshotJsonValue(normalizeError(e.error), 'FSM event error');
|
|
547
|
+
}
|
|
548
|
+
return snapshotJsonValue(out, 'FSM event');
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function defaultStatusesForState(state, context) {
|
|
552
|
+
const stateId = state.stateId;
|
|
553
|
+
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId))
|
|
554
|
+
return [];
|
|
555
|
+
if (stateId === 'awaitBossReply') {
|
|
556
|
+
const pending = pendingBossQuestionFromContext(context);
|
|
557
|
+
const message = pending === undefined
|
|
558
|
+
? 'Awaiting Boss reply.'
|
|
559
|
+
: `${pending.player} asks: ${pending.question}`;
|
|
560
|
+
return [{ message }];
|
|
561
|
+
}
|
|
562
|
+
if (stateId === 'failed') {
|
|
563
|
+
const lastError = normalizeErrorFull(context.lastError);
|
|
564
|
+
return [
|
|
565
|
+
{
|
|
566
|
+
message: 'Workflow failed; awaiting Boss recovery.',
|
|
567
|
+
...(lastError === undefined
|
|
568
|
+
? {}
|
|
569
|
+
: { data: snapshotJsonValue({ lastError }, 'failed status data') }),
|
|
570
|
+
},
|
|
571
|
+
];
|
|
572
|
+
}
|
|
573
|
+
return [{ message: `Entered ${stateId}.` }];
|
|
574
|
+
}
|
|
575
|
+
function classifierState(snapshotOrState) {
|
|
576
|
+
if (snapshotOrState !== null &&
|
|
577
|
+
typeof snapshotOrState === 'object' &&
|
|
578
|
+
'value' in snapshotOrState) {
|
|
579
|
+
const candidate = snapshotOrState;
|
|
580
|
+
return {
|
|
581
|
+
value: candidate.value,
|
|
582
|
+
context: candidate.context !== null &&
|
|
583
|
+
typeof candidate.context === 'object' &&
|
|
584
|
+
!Array.isArray(candidate.context)
|
|
585
|
+
? candidate.context
|
|
586
|
+
: {},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
return { value: snapshotOrState, context: {} };
|
|
590
|
+
}
|
|
591
|
+
function configuredEventTypesForState(machine, stateId) {
|
|
592
|
+
const configured = new Set();
|
|
593
|
+
const config = machine.config;
|
|
594
|
+
if (!isPlainObject(config))
|
|
595
|
+
return configured;
|
|
596
|
+
if (isPlainObject(config.on)) {
|
|
597
|
+
for (const type of Object.keys(config.on))
|
|
598
|
+
configured.add(type);
|
|
599
|
+
}
|
|
600
|
+
if (stateId !== undefined && isPlainObject(config.states)) {
|
|
601
|
+
const state = config.states[stateId];
|
|
602
|
+
if (isPlainObject(state) && isPlainObject(state.on)) {
|
|
603
|
+
for (const type of Object.keys(state.on))
|
|
604
|
+
configured.add(type);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return configured;
|
|
608
|
+
}
|
|
609
|
+
// Derived contracts merge into whatever the machine already yielded for the
|
|
610
|
+
// same type, so a deterministic entry event that shares a type with another
|
|
611
|
+
// derived contract keeps its exact-text ownership instead of being replaced.
|
|
612
|
+
function mergeDerivedContract(contracts, contract) {
|
|
613
|
+
const existing = contracts.get(contract.type);
|
|
614
|
+
contracts.set(contract.type, {
|
|
615
|
+
type: contract.type,
|
|
616
|
+
fields: { ...(existing?.fields ?? {}), ...(contract.fields ?? {}) },
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
function defaultBossEventSpecs(machine, entryEvent, supplied) {
|
|
620
|
+
const contracts = new Map();
|
|
621
|
+
if (entryEvent !== undefined) {
|
|
622
|
+
mergeDerivedContract(contracts, {
|
|
623
|
+
type: entryEvent.type,
|
|
624
|
+
fields: { [entryEvent.textField]: { source: 'text', required: true } },
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
const config = machine.config;
|
|
628
|
+
const rootInterrupt = isPlainObject(config) && isPlainObject(config.on)
|
|
629
|
+
? config.on.BOSS_INTERRUPT
|
|
630
|
+
: undefined;
|
|
631
|
+
const interruptTargets = rootInterrupt === undefined ? [] : transitionTargets(rootInterrupt);
|
|
632
|
+
if (interruptTargets.length > 0) {
|
|
633
|
+
mergeDerivedContract(contracts, {
|
|
634
|
+
type: 'BOSS_INTERRUPT',
|
|
635
|
+
fields: {
|
|
636
|
+
targetId: {
|
|
637
|
+
source: 'judge',
|
|
638
|
+
required: true,
|
|
639
|
+
values: [...new Set(interruptTargets)],
|
|
640
|
+
},
|
|
641
|
+
// slc/link.md §Boss-event mapping: for BOSS_INTENT and
|
|
642
|
+
// BOSS_INTERRUPT the runtime, never the judge, attaches the exact
|
|
643
|
+
// original Boss text as `bossIntent`.
|
|
644
|
+
bossIntent: { source: 'text', required: true },
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
for (const contract of supplied) {
|
|
649
|
+
if (typeof contract.type !== 'string' ||
|
|
650
|
+
contract.type.trim().length === 0) {
|
|
651
|
+
throw new TypeError('Boss event contract type must be a non-empty string');
|
|
652
|
+
}
|
|
653
|
+
if (contract.type === 'NO_ACTION' || contract.type === 'BOSS_REPLY') {
|
|
654
|
+
throw new TypeError(`Boss event contract ${contract.type} is runtime-owned`);
|
|
655
|
+
}
|
|
656
|
+
const existing = contracts.get(contract.type);
|
|
657
|
+
const fields = {
|
|
658
|
+
...(existing?.fields ?? {}),
|
|
659
|
+
};
|
|
660
|
+
for (const [field, fieldSpec] of Object.entries(contract.fields ?? {})) {
|
|
661
|
+
if (field.length === 0 || field === 'type') {
|
|
662
|
+
throw new TypeError(`Boss event contract ${contract.type} has invalid field ${JSON.stringify(field)}`);
|
|
663
|
+
}
|
|
664
|
+
if (fieldSpec.source !== 'judge' && fieldSpec.source !== 'text') {
|
|
665
|
+
throw new TypeError(`Boss event contract ${contract.type}.${field} has invalid source`);
|
|
666
|
+
}
|
|
667
|
+
if (fieldSpec.values !== undefined) {
|
|
668
|
+
if (fieldSpec.source !== 'judge' ||
|
|
669
|
+
fieldSpec.values.length === 0 ||
|
|
670
|
+
fieldSpec.values.some((value) => typeof value !== 'string' || value.length === 0)) {
|
|
671
|
+
throw new TypeError(`Boss event contract ${contract.type}.${field} has invalid values`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const normalized = {
|
|
675
|
+
source: fieldSpec.source,
|
|
676
|
+
...(fieldSpec.required === true ? { required: true } : {}),
|
|
677
|
+
...(fieldSpec.values === undefined
|
|
678
|
+
? {}
|
|
679
|
+
: { values: [...new Set(fieldSpec.values)] }),
|
|
680
|
+
};
|
|
681
|
+
const derived = fields[field];
|
|
682
|
+
if (derived !== undefined) {
|
|
683
|
+
const derivedValues = derived.values === undefined
|
|
684
|
+
? undefined
|
|
685
|
+
: new Set(derived.values);
|
|
686
|
+
const normalizedValues = normalized.values === undefined
|
|
687
|
+
? undefined
|
|
688
|
+
: new Set(normalized.values);
|
|
689
|
+
const sameValues = derivedValues === undefined || normalizedValues === undefined
|
|
690
|
+
? derivedValues === normalizedValues
|
|
691
|
+
: derivedValues.size === normalizedValues.size &&
|
|
692
|
+
[...derivedValues].every((value) => normalizedValues.has(value));
|
|
693
|
+
if (derived.source !== normalized.source ||
|
|
694
|
+
(derived.required === true) !== (normalized.required === true) ||
|
|
695
|
+
!sameValues) {
|
|
696
|
+
throw new TypeError(`Boss event contract ${contract.type}.${field} conflicts with the runtime-derived contract`);
|
|
697
|
+
}
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
fields[field] = normalized;
|
|
701
|
+
}
|
|
702
|
+
contracts.set(contract.type, { type: contract.type, fields });
|
|
703
|
+
}
|
|
704
|
+
contracts.set('BOSS_REPLY', {
|
|
705
|
+
type: 'BOSS_REPLY',
|
|
706
|
+
fields: {
|
|
707
|
+
questionId: { source: 'judge' },
|
|
708
|
+
answer: { source: 'text', required: true },
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
return contracts;
|
|
712
|
+
}
|
|
713
|
+
function eventContractPrompt(contract) {
|
|
714
|
+
const fields = Object.entries(contract.fields ?? {}).filter(([, field]) => field.source === 'judge');
|
|
715
|
+
const members = [
|
|
716
|
+
`"type": ${JSON.stringify(contract.type)}`,
|
|
717
|
+
...fields.map(([name, field]) => `${JSON.stringify(name)}: ${JSON.stringify(field.values?.[0] ?? '<string>')}`),
|
|
718
|
+
];
|
|
719
|
+
const notes = fields.flatMap(([name, field]) => [
|
|
720
|
+
...(field.required === true ? [] : [`${name} optional`]),
|
|
721
|
+
...(field.values === undefined
|
|
722
|
+
? []
|
|
723
|
+
: [
|
|
724
|
+
`${name} one of ${field.values
|
|
725
|
+
.map((value) => JSON.stringify(value))
|
|
726
|
+
.join(', ')}`,
|
|
727
|
+
]),
|
|
728
|
+
]);
|
|
729
|
+
return `{ ${members.join(', ')} }${notes.length === 0 ? '' : ` (${notes.join('; ')})`}`;
|
|
730
|
+
}
|
|
731
|
+
function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
|
|
732
|
+
const contracts = defaultBossEventSpecs(machine, entryEvent, bossEvents);
|
|
733
|
+
return async (text, ports, signal, snapshotOrState, boundary) => {
|
|
734
|
+
const trimmed = text.trim();
|
|
735
|
+
if (trimmed === '')
|
|
736
|
+
return undefined;
|
|
737
|
+
const state = classifierState(snapshotOrState);
|
|
738
|
+
const stateId = typeof state.value === 'string' ? state.value : undefined;
|
|
739
|
+
const currentState = stateId ?? JSON.stringify(state.value ?? null);
|
|
740
|
+
const pending = pendingBossQuestionFromContext(state.context);
|
|
741
|
+
const configuredTypes = configuredEventTypesForState(machine, stateId);
|
|
742
|
+
const applicable = [...contracts.values()].filter((contract) => configuredTypes.has(contract.type) &&
|
|
743
|
+
(contract.type !== 'BOSS_REPLY' || pending !== undefined));
|
|
744
|
+
const lines = [
|
|
745
|
+
'Classify the following Boss message into exactly one event.',
|
|
746
|
+
'Respond with one exact flat JSON object. Do not add fields that are not shown.',
|
|
747
|
+
'The runtime, not the judge, attaches the exact Boss text to textual event fields.',
|
|
748
|
+
'',
|
|
749
|
+
`Current state: ${currentState}`,
|
|
750
|
+
];
|
|
751
|
+
if (pending !== undefined) {
|
|
752
|
+
lines.push(`Pending question id: ${pending.questionId}`, `Pending asking player: ${pending.player}`, `Pending Boss question: ${pending.question}`);
|
|
753
|
+
}
|
|
754
|
+
lines.push('', 'Allowed JSON objects:', '- { "type": "NO_ACTION" }');
|
|
755
|
+
for (const contract of applicable) {
|
|
756
|
+
lines.push(`- ${eventContractPrompt(contract)}`);
|
|
757
|
+
}
|
|
758
|
+
lines.push('', 'Boss message:', '```', text, '```');
|
|
759
|
+
const prompt = lines.join('\n');
|
|
760
|
+
const raw = boundary
|
|
761
|
+
? await boundary.callJudge('boss-input-classification', stateId, prompt, signal)
|
|
762
|
+
: await ports.callJudge(prompt, signal);
|
|
763
|
+
let parsed;
|
|
764
|
+
try {
|
|
765
|
+
parsed = parseJudgeJson(raw);
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
await ports.emitStatus('Classifier reply was not recoverable JSON');
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
if (typeof parsed !== 'object' ||
|
|
772
|
+
parsed === null ||
|
|
773
|
+
Array.isArray(parsed)) {
|
|
774
|
+
await ports.emitStatus('Classifier returned a non-object JSON response');
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
777
|
+
const obj = parsed;
|
|
778
|
+
const eventType = obj.type;
|
|
779
|
+
if (typeof eventType !== 'string') {
|
|
780
|
+
await ports.emitStatus('Classifier did not name an event type');
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
783
|
+
if (eventType === 'NO_ACTION') {
|
|
784
|
+
if (Object.keys(obj).length !== 1) {
|
|
785
|
+
await ports.emitStatus('Classifier supplied extra fields for NO_ACTION');
|
|
786
|
+
return undefined;
|
|
787
|
+
}
|
|
788
|
+
return undefined;
|
|
789
|
+
}
|
|
790
|
+
const contract = applicable.find((candidate) => candidate.type === eventType);
|
|
791
|
+
if (contract === undefined) {
|
|
792
|
+
await ports.emitStatus(`Classifier returned unknown or inapplicable event type: ${eventType}`);
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
795
|
+
const fields = contract.fields ?? {};
|
|
796
|
+
const judgeFields = new Set(Object.entries(fields)
|
|
797
|
+
.filter(([, field]) => field.source === 'judge')
|
|
798
|
+
.map(([field]) => field));
|
|
799
|
+
const extras = Object.keys(obj).filter((field) => field !== 'type' && !judgeFields.has(field));
|
|
800
|
+
if (extras.length > 0) {
|
|
801
|
+
await ports.emitStatus(`Classifier supplied undeclared field for ${eventType}: ${extras[0]}`);
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
const event = { type: eventType };
|
|
805
|
+
for (const [field, fieldSpec] of Object.entries(fields)) {
|
|
806
|
+
if (fieldSpec.source === 'text') {
|
|
807
|
+
event[field] = text;
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const value = obj[field];
|
|
811
|
+
if (value === undefined && fieldSpec.required !== true)
|
|
812
|
+
continue;
|
|
813
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
814
|
+
await ports.emitStatus(`Classifier omitted or invalidated ${field} for ${eventType}`);
|
|
815
|
+
return undefined;
|
|
816
|
+
}
|
|
817
|
+
if (fieldSpec.values !== undefined && !fieldSpec.values.includes(value)) {
|
|
818
|
+
await ports.emitStatus(`Classifier supplied unknown ${field} for ${eventType}: ${value}`);
|
|
819
|
+
return undefined;
|
|
820
|
+
}
|
|
821
|
+
event[field] = value;
|
|
822
|
+
}
|
|
823
|
+
if (eventType === 'BOSS_REPLY') {
|
|
824
|
+
if (pending === undefined) {
|
|
825
|
+
await ports.emitStatus('Classifier returned BOSS_REPLY without a pending question');
|
|
826
|
+
return undefined;
|
|
827
|
+
}
|
|
828
|
+
const questionId = event.questionId;
|
|
829
|
+
if (questionId !== undefined && questionId !== pending.questionId) {
|
|
830
|
+
await ports.emitStatus(`Classifier supplied unknown questionId for BOSS_REPLY: ${String(questionId)}`);
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
833
|
+
event.questionId = pending.questionId;
|
|
834
|
+
}
|
|
835
|
+
const can = snapshotOrState?.can;
|
|
836
|
+
if (typeof can === 'function' &&
|
|
837
|
+
!can.call(snapshotOrState, event)) {
|
|
838
|
+
await ports.emitStatus(`Classifier selected ${eventType}, but its state guards rejected the event`);
|
|
839
|
+
return undefined;
|
|
840
|
+
}
|
|
841
|
+
return event;
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
|
|
846
|
+
* under the slc/link.md contract. The factory provides every actor kind the
|
|
847
|
+
* machine declares — `player`, `script`, `captain`, and nested `playbook`
|
|
848
|
+
* (literal and dynamic) — and implements the full runtime lifecycle including
|
|
849
|
+
* the optional parked-session snapshot capability (DR-014).
|
|
850
|
+
*
|
|
851
|
+
* Scope: single-region root machines (each snapshot exposes exactly one
|
|
852
|
+
* playbook state id). Parallel-region FSMs keep their own linked runtimes.
|
|
853
|
+
*/
|
|
854
|
+
export function createXStatePlaybookRuntime(machine, spec) {
|
|
855
|
+
const label = spec.label ?? 'playbook';
|
|
856
|
+
const declaredActors = collectInvokeSources(machine);
|
|
857
|
+
const resumableStateIds = spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
|
|
858
|
+
const resolvePlayerIdSpec = spec.resolvePlayerId;
|
|
859
|
+
const composePlayerPrompt = spec.composePlayerPrompt ??
|
|
860
|
+
((input) => defaultComposePlayerPrompt(input, spec.placeholderFields));
|
|
861
|
+
const composeCaptainPrompt = spec.composeCaptainPrompt ??
|
|
862
|
+
((input) => defaultComposeCaptainPrompt(input, spec.placeholderFields));
|
|
863
|
+
const adjudication = {
|
|
864
|
+
...(spec.buildJudgePrompt !== undefined
|
|
865
|
+
? { buildJudgePrompt: spec.buildJudgePrompt }
|
|
866
|
+
: {}),
|
|
867
|
+
...(spec.extractRequiredFields !== undefined
|
|
868
|
+
? { extractRequiredFields: spec.extractRequiredFields }
|
|
869
|
+
: {}),
|
|
870
|
+
...(spec.verbatimPayloadFields !== undefined
|
|
871
|
+
? { verbatimPayloadFields: spec.verbatimPayloadFields }
|
|
872
|
+
: {}),
|
|
873
|
+
};
|
|
874
|
+
const extractFields = spec.extractRequiredFields ?? defaultExtractRequiredFields;
|
|
875
|
+
// Build the derived classifier unconditionally: it is the sole validator of
|
|
876
|
+
// supplied `bossEvents`, and DR-019 §2 requires a conflicting duplicate to
|
|
877
|
+
// fail factory construction whether or not this spec overrides the
|
|
878
|
+
// classifier that would have consumed the contracts.
|
|
879
|
+
const derivedClassifyBossText = makeDefaultClassifyBossText(machine, spec.entryEvent, spec.bossEvents ?? []);
|
|
880
|
+
const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
|
|
881
|
+
const normalizeTransitionEvent = spec.normalizeTransitionEvent ??
|
|
882
|
+
makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
|
|
883
|
+
const statusesForState = spec.statusesForState ?? defaultStatusesForState;
|
|
884
|
+
const machineInput = spec.machineInput ?? ((options) => options);
|
|
885
|
+
const scriptCwd = spec.scriptCwd ??
|
|
886
|
+
((options) => {
|
|
887
|
+
const cwd = options?.cwd;
|
|
888
|
+
return typeof cwd === 'string' ? cwd : undefined;
|
|
889
|
+
});
|
|
890
|
+
return function createPlaybookRuntime(options) {
|
|
891
|
+
const boundOptions = spec.snapshotOptions(options);
|
|
892
|
+
const boundScriptCwd = scriptCwd(boundOptions);
|
|
893
|
+
let actor;
|
|
894
|
+
let session;
|
|
895
|
+
let initialized = false;
|
|
896
|
+
let initInFlight;
|
|
897
|
+
let disposalPromise;
|
|
898
|
+
let disposed = false;
|
|
899
|
+
let savedPorts;
|
|
900
|
+
let runtimePorts;
|
|
901
|
+
// The Boss's per-turn AbortSignal, surfaced to the provided actors so
|
|
902
|
+
// ports.callPlayer / callCaptain / callJudge see the right cancellation
|
|
903
|
+
// source. undefined between turns; set by the public boundaries.
|
|
904
|
+
let activeSignal;
|
|
905
|
+
let activeTurnId;
|
|
906
|
+
let controlPlaneError;
|
|
907
|
+
// Previous root-machine state for the inspect-driven telemetry /
|
|
908
|
+
// status emitter. undefined before the first inspect firing.
|
|
909
|
+
let priorState;
|
|
910
|
+
let suppressInspectionEmissions = false;
|
|
911
|
+
let traceSequence = 0;
|
|
912
|
+
let turnSequence = 0;
|
|
913
|
+
let judgeCallSequence = 0;
|
|
914
|
+
let playerCallSequence = 0;
|
|
915
|
+
let playbookCallSequence = 0;
|
|
916
|
+
let captainCallSequence = 0;
|
|
917
|
+
const playerResumeTokens = new Map();
|
|
918
|
+
const activePlayerIds = new Set();
|
|
919
|
+
const playbookCallTurnIds = new Map();
|
|
920
|
+
// Captain and judge work share one serialized lane (slc/link.md
|
|
921
|
+
// §Session lifecycle).
|
|
922
|
+
const judgeQueue = new PQueue({ concurrency: 1 });
|
|
923
|
+
const emissionQueue = new PQueue({ concurrency: 1 });
|
|
924
|
+
const activeEmissionCalls = new Set();
|
|
925
|
+
// All trace, state-telemetry, and status work shares this one queue.
|
|
926
|
+
// Inspection callbacks enqueue a complete ordered batch synchronously;
|
|
927
|
+
// imperative boundaries await their queued work directly.
|
|
928
|
+
let emissionFailure;
|
|
929
|
+
function enqueueEmission(fn) {
|
|
930
|
+
const queued = emissionQueue.add(fn).then(() => undefined);
|
|
931
|
+
activeEmissionCalls.add(queued);
|
|
932
|
+
void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
|
|
933
|
+
activeEmissionCalls.delete(queued);
|
|
934
|
+
emissionFailure ??= error;
|
|
935
|
+
});
|
|
936
|
+
return queued;
|
|
937
|
+
}
|
|
938
|
+
async function drainEmissions() {
|
|
939
|
+
while (true) {
|
|
940
|
+
const active = [...activeEmissionCalls];
|
|
941
|
+
if (active.length > 0)
|
|
942
|
+
await Promise.allSettled(active);
|
|
943
|
+
await emissionQueue.onIdle();
|
|
944
|
+
if (activeEmissionCalls.size === 0 &&
|
|
945
|
+
emissionQueue.size === 0 &&
|
|
946
|
+
emissionQueue.pending === 0) {
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (emissionFailure !== undefined) {
|
|
951
|
+
const error = emissionFailure;
|
|
952
|
+
emissionFailure = undefined;
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function requireSession() {
|
|
957
|
+
if (!session) {
|
|
958
|
+
throw new Error('createPlaybookRuntime: init must be called first');
|
|
959
|
+
}
|
|
960
|
+
return session;
|
|
961
|
+
}
|
|
962
|
+
function requireHostPorts() {
|
|
963
|
+
if (!savedPorts) {
|
|
964
|
+
throw new Error('createPlaybookRuntime: init must be called first');
|
|
965
|
+
}
|
|
966
|
+
return savedPorts;
|
|
967
|
+
}
|
|
968
|
+
function createTraceEvent(type, payload, position = {}) {
|
|
969
|
+
const currentSession = requireSession();
|
|
970
|
+
const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
971
|
+
return {
|
|
972
|
+
schemaVersion: 2,
|
|
973
|
+
sessionId: currentSession.sessionId,
|
|
974
|
+
playbookId: currentSession.playbookId,
|
|
975
|
+
rootSessionId: currentSession.rootSessionId,
|
|
976
|
+
...(currentSession.parentSessionId !== undefined
|
|
977
|
+
? { parentSessionId: currentSession.parentSessionId }
|
|
978
|
+
: {}),
|
|
979
|
+
...(currentSession.parentCallId !== undefined
|
|
980
|
+
? { parentCallId: currentSession.parentCallId }
|
|
981
|
+
: {}),
|
|
982
|
+
depth: currentSession.depth,
|
|
983
|
+
sequence: ++traceSequence,
|
|
984
|
+
timestamp: Date.now(),
|
|
985
|
+
type,
|
|
986
|
+
...(position.turnId !== undefined ? { turnId: position.turnId } : {}),
|
|
987
|
+
...(position.callId !== undefined ? { callId: position.callId } : {}),
|
|
988
|
+
payload: safePayload,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
function emitTrace(type, payload, position = {}) {
|
|
992
|
+
const currentSession = requireSession();
|
|
993
|
+
const event = createTraceEvent(type, payload, position);
|
|
994
|
+
return enqueueEmission(() => currentSession.ports.emitTelemetry({
|
|
995
|
+
topic: 'playbook.trace',
|
|
996
|
+
payload: event,
|
|
997
|
+
}));
|
|
998
|
+
}
|
|
999
|
+
function stateIdentity(stateId) {
|
|
1000
|
+
return stateId === undefined ? {} : { stateId };
|
|
1001
|
+
}
|
|
1002
|
+
function currentState() {
|
|
1003
|
+
if (!actor) {
|
|
1004
|
+
throw new Error('createPlaybookRuntime: actor is not initialized');
|
|
1005
|
+
}
|
|
1006
|
+
return normalizePlaybookSnapshot(actor.getSnapshot(), {
|
|
1007
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
function stateTracePayload(state = currentState()) {
|
|
1011
|
+
return {
|
|
1012
|
+
state,
|
|
1013
|
+
...stateIdentity(state.stateId),
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
function createRuntimePorts(hostPorts) {
|
|
1017
|
+
return {
|
|
1018
|
+
callPlayer: (playerId, prompt, signal, callOptions) => hostPorts.callPlayer(playerId, prompt, signal, callOptions),
|
|
1019
|
+
callCaptain: (prompt, signal, callOptions) => hostPorts.callCaptain(prompt, signal, callOptions),
|
|
1020
|
+
callJudge: (prompt, signal) => hostPorts.callJudge(prompt, signal),
|
|
1021
|
+
callPlaybook: (request, signal) => hostPorts.callPlaybook(request, signal),
|
|
1022
|
+
emitStatus: (message, data) => {
|
|
1023
|
+
const descriptor = actor ? currentState() : undefined;
|
|
1024
|
+
const safeData = data === undefined
|
|
1025
|
+
? undefined
|
|
1026
|
+
: snapshotJsonValue(data, 'status data');
|
|
1027
|
+
const trace = createTraceEvent('status.emitted', {
|
|
1028
|
+
message,
|
|
1029
|
+
...(safeData !== undefined ? { data: safeData } : {}),
|
|
1030
|
+
...(descriptor !== undefined
|
|
1031
|
+
? {
|
|
1032
|
+
state: descriptor,
|
|
1033
|
+
...stateIdentity(descriptor.stateId),
|
|
1034
|
+
}
|
|
1035
|
+
: {}),
|
|
1036
|
+
}, activeTurnId !== undefined ? { turnId: activeTurnId } : {});
|
|
1037
|
+
return enqueueEmission(async () => {
|
|
1038
|
+
await hostPorts.emitTelemetry({
|
|
1039
|
+
topic: 'playbook.trace',
|
|
1040
|
+
payload: trace,
|
|
1041
|
+
});
|
|
1042
|
+
await hostPorts.emitStatus(message, safeData);
|
|
1043
|
+
});
|
|
1044
|
+
},
|
|
1045
|
+
emitTelemetry: (event) => {
|
|
1046
|
+
if (typeof event.topic !== 'string' || event.topic.length === 0) {
|
|
1047
|
+
throw new TypeError('telemetry topic must be a non-empty string');
|
|
1048
|
+
}
|
|
1049
|
+
const payload = snapshotJsonValue(event.payload, 'telemetry payload');
|
|
1050
|
+
return enqueueEmission(() => hostPorts.emitTelemetry({ topic: event.topic, payload }));
|
|
1051
|
+
},
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
async function emitCallStarted(startedType, finishedType, identity, position) {
|
|
1055
|
+
try {
|
|
1056
|
+
await emitTrace(startedType, identity, position);
|
|
1057
|
+
}
|
|
1058
|
+
catch (error) {
|
|
1059
|
+
controlPlaneError ??= error;
|
|
1060
|
+
try {
|
|
1061
|
+
await emitTrace(finishedType, { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
1062
|
+
}
|
|
1063
|
+
catch {
|
|
1064
|
+
// Preserve the start failure after one best-effort finish attempt.
|
|
1065
|
+
}
|
|
1066
|
+
throw error;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const boundary = {
|
|
1070
|
+
async callPlayer(input, playerId, prompt, signal) {
|
|
1071
|
+
// State-entry telemetry/status must precede the call they describe.
|
|
1072
|
+
await drainEmissions();
|
|
1073
|
+
const turnId = activeTurnId;
|
|
1074
|
+
const callId = `player-${++playerCallSequence}`;
|
|
1075
|
+
const stateId = input.stateId;
|
|
1076
|
+
const resume = playerResumeTokens.get(playerId) ?? false;
|
|
1077
|
+
const identity = {
|
|
1078
|
+
purpose: 'captain',
|
|
1079
|
+
...stateIdentity(stateId),
|
|
1080
|
+
sourceItem: input.sourceItem,
|
|
1081
|
+
playerId,
|
|
1082
|
+
resume,
|
|
1083
|
+
};
|
|
1084
|
+
const position = {
|
|
1085
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1086
|
+
callId,
|
|
1087
|
+
};
|
|
1088
|
+
if (activePlayerIds.has(playerId)) {
|
|
1089
|
+
const error = new Error(`simultaneous calls to resolved player ${playerId} are not allowed`);
|
|
1090
|
+
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position);
|
|
1091
|
+
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
1092
|
+
throw error;
|
|
1093
|
+
}
|
|
1094
|
+
activePlayerIds.add(playerId);
|
|
1095
|
+
try {
|
|
1096
|
+
await emitTrace('player.call.started', { ...identity, prompt }, position);
|
|
1097
|
+
let rawResult;
|
|
1098
|
+
try {
|
|
1099
|
+
rawResult = await requireHostPorts().callPlayer(playerId, prompt, signal, { resume });
|
|
1100
|
+
// A host promise is not required to honor cancellation. Do not let
|
|
1101
|
+
// a late result mutate continuity or publish a successful finish.
|
|
1102
|
+
signal.throwIfAborted();
|
|
1103
|
+
}
|
|
1104
|
+
catch (error) {
|
|
1105
|
+
if (!signal.aborted)
|
|
1106
|
+
controlPlaneError ??= error;
|
|
1107
|
+
try {
|
|
1108
|
+
await emitTrace('player.call.finished', {
|
|
1109
|
+
...identity,
|
|
1110
|
+
status: signal.aborted ? 'aborted' : 'error',
|
|
1111
|
+
error: normalizeError(error),
|
|
1112
|
+
}, position);
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
// The original non-abort port rejection remains authoritative.
|
|
1116
|
+
}
|
|
1117
|
+
// A thrown port call carries no authoritative result, so the
|
|
1118
|
+
// prior token remains available for a later explicit resume.
|
|
1119
|
+
throw error;
|
|
1120
|
+
}
|
|
1121
|
+
let result;
|
|
1122
|
+
try {
|
|
1123
|
+
result = validatePlayerResult(rawResult);
|
|
1124
|
+
}
|
|
1125
|
+
catch (error) {
|
|
1126
|
+
if (!signal.aborted)
|
|
1127
|
+
controlPlaneError ??= error;
|
|
1128
|
+
try {
|
|
1129
|
+
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
1130
|
+
}
|
|
1131
|
+
catch {
|
|
1132
|
+
// The malformed host result remains authoritative.
|
|
1133
|
+
}
|
|
1134
|
+
throw error;
|
|
1135
|
+
}
|
|
1136
|
+
if (typeof result.resumeToken === 'string' &&
|
|
1137
|
+
result.resumeToken.trim().length > 0) {
|
|
1138
|
+
playerResumeTokens.set(playerId, result.resumeToken);
|
|
1139
|
+
}
|
|
1140
|
+
else {
|
|
1141
|
+
playerResumeTokens.delete(playerId);
|
|
1142
|
+
}
|
|
1143
|
+
await emitTrace('player.call.finished', {
|
|
1144
|
+
...identity,
|
|
1145
|
+
status: result.status,
|
|
1146
|
+
...(result.finalText !== undefined
|
|
1147
|
+
? { finalText: result.finalText }
|
|
1148
|
+
: {}),
|
|
1149
|
+
...(result.error !== undefined
|
|
1150
|
+
? { error: normalizeError(result.error) }
|
|
1151
|
+
: {}),
|
|
1152
|
+
...(result.resumeToken !== undefined
|
|
1153
|
+
? { resumeToken: result.resumeToken }
|
|
1154
|
+
: {}),
|
|
1155
|
+
}, position);
|
|
1156
|
+
return result;
|
|
1157
|
+
}
|
|
1158
|
+
finally {
|
|
1159
|
+
activePlayerIds.delete(playerId);
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
async callJudge(purpose, stateId, prompt, signal) {
|
|
1163
|
+
return judgeQueue.add(async () => {
|
|
1164
|
+
signal.throwIfAborted();
|
|
1165
|
+
// A transition/status queued synchronously by XState must reach
|
|
1166
|
+
// the host before the judge call that follows it.
|
|
1167
|
+
await drainEmissions();
|
|
1168
|
+
signal.throwIfAborted();
|
|
1169
|
+
const turnId = activeTurnId;
|
|
1170
|
+
const callId = `judge-${++judgeCallSequence}`;
|
|
1171
|
+
const identity = { purpose, ...stateIdentity(stateId) };
|
|
1172
|
+
const position = {
|
|
1173
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1174
|
+
callId,
|
|
1175
|
+
};
|
|
1176
|
+
await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position);
|
|
1177
|
+
let reply;
|
|
1178
|
+
try {
|
|
1179
|
+
reply = await requireHostPorts().callJudge(prompt, signal);
|
|
1180
|
+
signal.throwIfAborted();
|
|
1181
|
+
}
|
|
1182
|
+
catch (error) {
|
|
1183
|
+
if (!isAbortFailure(error, signal)) {
|
|
1184
|
+
controlPlaneError ??= error;
|
|
1185
|
+
}
|
|
1186
|
+
await emitTrace('judge.call.finished', {
|
|
1187
|
+
...identity,
|
|
1188
|
+
status: signal.aborted ? 'aborted' : 'error',
|
|
1189
|
+
error: normalizeError(error),
|
|
1190
|
+
}, position);
|
|
1191
|
+
throw error;
|
|
1192
|
+
}
|
|
1193
|
+
if (typeof reply !== 'string') {
|
|
1194
|
+
const error = new TypeError('judge reply must be a string');
|
|
1195
|
+
controlPlaneError ??= error;
|
|
1196
|
+
await emitTrace('judge.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
1197
|
+
throw error;
|
|
1198
|
+
}
|
|
1199
|
+
// Keep the success finish outside the port-call catch. If a
|
|
1200
|
+
// telemetry sink records this boundary and then rejects, that sink
|
|
1201
|
+
// failure must not synthesize a second, contradictory finish.
|
|
1202
|
+
await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply }, position);
|
|
1203
|
+
// The finish sink is part of the classifier boundary. A signal may
|
|
1204
|
+
// abort while that ordered emission drains; never let the already
|
|
1205
|
+
// classified event mutate the machine afterward.
|
|
1206
|
+
signal.throwIfAborted();
|
|
1207
|
+
return reply;
|
|
1208
|
+
});
|
|
1209
|
+
},
|
|
1210
|
+
async callCaptain(input, prompt, signal) {
|
|
1211
|
+
return judgeQueue.add(async () => {
|
|
1212
|
+
signal.throwIfAborted();
|
|
1213
|
+
await drainEmissions();
|
|
1214
|
+
signal.throwIfAborted();
|
|
1215
|
+
const turnId = activeTurnId;
|
|
1216
|
+
const callId = `captain-${++captainCallSequence}`;
|
|
1217
|
+
const identity = {
|
|
1218
|
+
...stateIdentity(input.stateId),
|
|
1219
|
+
sourceItem: input.sourceItem,
|
|
1220
|
+
visibility: 'visible',
|
|
1221
|
+
resume: false,
|
|
1222
|
+
...(input.allowedTools === undefined
|
|
1223
|
+
? {}
|
|
1224
|
+
: { allowedTools: [...input.allowedTools] }),
|
|
1225
|
+
};
|
|
1226
|
+
const position = {
|
|
1227
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1228
|
+
callId,
|
|
1229
|
+
};
|
|
1230
|
+
await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position);
|
|
1231
|
+
let rawResult;
|
|
1232
|
+
try {
|
|
1233
|
+
rawResult = await requireHostPorts().callCaptain(prompt, signal, {
|
|
1234
|
+
visibility: 'visible',
|
|
1235
|
+
resume: false,
|
|
1236
|
+
...(input.allowedTools !== undefined
|
|
1237
|
+
? { allowedTools: input.allowedTools }
|
|
1238
|
+
: {}),
|
|
1239
|
+
});
|
|
1240
|
+
signal.throwIfAborted();
|
|
1241
|
+
}
|
|
1242
|
+
catch (error) {
|
|
1243
|
+
if (!isAbortFailure(error, signal))
|
|
1244
|
+
controlPlaneError ??= error;
|
|
1245
|
+
await emitTrace('captain.call.finished', {
|
|
1246
|
+
...identity,
|
|
1247
|
+
status: signal.aborted ? 'aborted' : 'error',
|
|
1248
|
+
error: normalizeError(error),
|
|
1249
|
+
}, position);
|
|
1250
|
+
throw error;
|
|
1251
|
+
}
|
|
1252
|
+
let result;
|
|
1253
|
+
try {
|
|
1254
|
+
result = validateCaptainResult(rawResult);
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
controlPlaneError ??= error;
|
|
1258
|
+
await emitTrace('captain.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
1259
|
+
throw error;
|
|
1260
|
+
}
|
|
1261
|
+
// A non-`ok` host result is a recoverable FSM failure (PBRT-47), so
|
|
1262
|
+
// it is never latched as a control-plane error; it is still
|
|
1263
|
+
// authoritative for the actor's error path even when the required
|
|
1264
|
+
// finish emission fails or a coincident boundary abort lands.
|
|
1265
|
+
let resultFailure;
|
|
1266
|
+
if (result.status !== 'ok') {
|
|
1267
|
+
resultFailure = markFsmResultFailure(new Error(result.error ??
|
|
1268
|
+
`captainActor: callCaptain status "${result.status}"`));
|
|
1269
|
+
}
|
|
1270
|
+
else if (result.finalText === undefined || result.finalText === '') {
|
|
1271
|
+
resultFailure = markFsmResultFailure(new Error('captainActor: callCaptain returned status=ok with no finalText'));
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
await emitTrace('captain.call.finished', {
|
|
1275
|
+
...identity,
|
|
1276
|
+
status: result.status,
|
|
1277
|
+
...(result.finalText !== undefined
|
|
1278
|
+
? { finalText: result.finalText }
|
|
1279
|
+
: {}),
|
|
1280
|
+
...(result.error !== undefined
|
|
1281
|
+
? { error: normalizeError(result.error) }
|
|
1282
|
+
: resultFailure !== undefined
|
|
1283
|
+
? { error: normalizeError(resultFailure) }
|
|
1284
|
+
: {}),
|
|
1285
|
+
}, position);
|
|
1286
|
+
}
|
|
1287
|
+
catch (error) {
|
|
1288
|
+
// Keep the finish-sink failure in the emission queue for public
|
|
1289
|
+
// cleanup evidence, but do not replace an authoritative result
|
|
1290
|
+
// failure on the invoked actor's XState onError path.
|
|
1291
|
+
if (resultFailure !== undefined)
|
|
1292
|
+
throw resultFailure;
|
|
1293
|
+
throw error;
|
|
1294
|
+
}
|
|
1295
|
+
if (resultFailure !== undefined) {
|
|
1296
|
+
throw resultFailure;
|
|
1297
|
+
}
|
|
1298
|
+
return result;
|
|
1299
|
+
});
|
|
1300
|
+
},
|
|
1301
|
+
};
|
|
1302
|
+
function resolvePlayerId(input) {
|
|
1303
|
+
return resolvePlayerIdSpec
|
|
1304
|
+
? resolvePlayerIdSpec(input, boundOptions)
|
|
1305
|
+
: defaultResolvePlayerId(input);
|
|
1306
|
+
}
|
|
1307
|
+
function playerActor(ports) {
|
|
1308
|
+
return createPlayerBridge({
|
|
1309
|
+
resolvePlayerId,
|
|
1310
|
+
composePlayerPrompt,
|
|
1311
|
+
adjudication,
|
|
1312
|
+
resumableStateIds,
|
|
1313
|
+
}, ports, () => activeSignal, boundary, (error) => {
|
|
1314
|
+
if (!activeSignal?.aborted)
|
|
1315
|
+
controlPlaneError ??= error;
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
// Direct-Captain actor (slc/link.md §Captain prompt composition,
|
|
1319
|
+
// §Captain adjudication): one visible callCaptain, then hidden judge
|
|
1320
|
+
// adjudication that injects the exact visible finalText as the selected
|
|
1321
|
+
// output's question/response.
|
|
1322
|
+
function captainActor() {
|
|
1323
|
+
return fromPromise(async ({ input, signal }) => {
|
|
1324
|
+
const active = combineAbortSignals(signal, activeSignal);
|
|
1325
|
+
try {
|
|
1326
|
+
await drainEmissions();
|
|
1327
|
+
const prompt = composeCaptainPrompt(input);
|
|
1328
|
+
const result = await boundary.callCaptain(input, prompt, active);
|
|
1329
|
+
// The boundary owns result validation (PBRT-47) and throws the
|
|
1330
|
+
// authoritative failure itself, so a returned result is always
|
|
1331
|
+
// `ok` with visible text. Assert that invariant rather than
|
|
1332
|
+
// restating the failure semantics, which would drift.
|
|
1333
|
+
if (result.status !== 'ok' || !result.finalText) {
|
|
1334
|
+
throw new Error('captainActor: boundary returned an unvalidated Captain result');
|
|
1335
|
+
}
|
|
1336
|
+
const judgePrompt = buildCaptainJudgePrompt(input, result.finalText);
|
|
1337
|
+
const raw = await boundary.callJudge('captain-output-adjudication', input.stateId, judgePrompt, active);
|
|
1338
|
+
const output = adjudicateCaptainOutput(extractFields, input, result.finalText, raw);
|
|
1339
|
+
validateBossReplyOutput(input, output, resumableStateIds);
|
|
1340
|
+
return output;
|
|
1341
|
+
}
|
|
1342
|
+
catch (error) {
|
|
1343
|
+
// A host-reported Captain result failure routes to the FSM's
|
|
1344
|
+
// failure state (PBRT-47); everything else here — a drained
|
|
1345
|
+
// emission failure, prompt composition, the port itself,
|
|
1346
|
+
// adjudication — is control plane.
|
|
1347
|
+
if (!active.aborted && !isFsmResultFailure(error)) {
|
|
1348
|
+
controlPlaneError ??= error;
|
|
1349
|
+
}
|
|
1350
|
+
throw error;
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
// Deterministic script actor (slc/link.md §Script execution). Runs
|
|
1355
|
+
// `input.command` through `sh -c`, resolves the declared guard
|
|
1356
|
+
// mechanically from the exit status, and emits one status + one
|
|
1357
|
+
// `playbook.script` telemetry event. No agent call, no adjudication,
|
|
1358
|
+
// no `*.call.*` trace.
|
|
1359
|
+
function scriptActor() {
|
|
1360
|
+
return fromPromise(async ({ input, signal }) => {
|
|
1361
|
+
await drainEmissions();
|
|
1362
|
+
const active = combineAbortSignals(signal, activeSignal);
|
|
1363
|
+
const guards = Object.keys(input.result);
|
|
1364
|
+
const okGuard = guards[0];
|
|
1365
|
+
const failedGuard = guards[1] ?? guards[0];
|
|
1366
|
+
const cwd = boundScriptCwd ?? process.cwd();
|
|
1367
|
+
const ports = runtimePorts ?? requireHostPorts();
|
|
1368
|
+
const exitStatus = await new Promise((resolve, reject) => {
|
|
1369
|
+
let child;
|
|
1370
|
+
try {
|
|
1371
|
+
child = spawn('sh', ['-c', input.command], {
|
|
1372
|
+
cwd,
|
|
1373
|
+
stdio: 'ignore',
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
catch (error) {
|
|
1377
|
+
reject(error);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
const onAbort = () => {
|
|
1381
|
+
child.kill('SIGTERM');
|
|
1382
|
+
reject(active.reason ?? new Error('script aborted'));
|
|
1383
|
+
};
|
|
1384
|
+
if (active.aborted) {
|
|
1385
|
+
onAbort();
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
active.addEventListener('abort', onAbort, { once: true });
|
|
1389
|
+
child.on('error', (error) => {
|
|
1390
|
+
active.removeEventListener('abort', onAbort);
|
|
1391
|
+
reject(error);
|
|
1392
|
+
});
|
|
1393
|
+
child.on('close', (code) => {
|
|
1394
|
+
active.removeEventListener('abort', onAbort);
|
|
1395
|
+
resolve(typeof code === 'number' ? code : 1);
|
|
1396
|
+
});
|
|
1397
|
+
});
|
|
1398
|
+
await ports.emitStatus(`Executed script for ${input.stateId} (exit ${exitStatus}).`);
|
|
1399
|
+
await ports.emitTelemetry({
|
|
1400
|
+
topic: 'playbook.script',
|
|
1401
|
+
payload: {
|
|
1402
|
+
stateId: input.stateId,
|
|
1403
|
+
sourceItem: input.sourceItem,
|
|
1404
|
+
exitStatus,
|
|
1405
|
+
},
|
|
1406
|
+
});
|
|
1407
|
+
if (exitStatus === 0) {
|
|
1408
|
+
return { guard: okGuard, exitStatus: 0 };
|
|
1409
|
+
}
|
|
1410
|
+
return { guard: failedGuard, exitStatus };
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
const nestedBridge = createNestedPlaybookBridge({
|
|
1414
|
+
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
1415
|
+
getBoundarySignal: () => activeSignal,
|
|
1416
|
+
callPlaybook: (request, signal) => requireHostPorts().callPlaybook(request, signal),
|
|
1417
|
+
emitStarted: async (event) => {
|
|
1418
|
+
playbookCallTurnIds.set(event.callId, activeTurnId);
|
|
1419
|
+
await emitTrace('playbook.call.started', {
|
|
1420
|
+
stateId: event.stateId,
|
|
1421
|
+
playbookId: event.playbookId,
|
|
1422
|
+
text: event.text,
|
|
1423
|
+
}, {
|
|
1424
|
+
...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
|
|
1425
|
+
callId: event.callId,
|
|
1426
|
+
});
|
|
1427
|
+
},
|
|
1428
|
+
emitFinished: async (event) => {
|
|
1429
|
+
const turnId = playbookCallTurnIds.get(event.callId);
|
|
1430
|
+
try {
|
|
1431
|
+
await emitTrace('playbook.call.finished', {
|
|
1432
|
+
stateId: event.stateId,
|
|
1433
|
+
playbookId: event.playbookId,
|
|
1434
|
+
text: event.text,
|
|
1435
|
+
result: event.result,
|
|
1436
|
+
}, {
|
|
1437
|
+
...(turnId !== undefined ? { turnId } : {}),
|
|
1438
|
+
callId: event.callId,
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
finally {
|
|
1442
|
+
playbookCallTurnIds.delete(event.callId);
|
|
1443
|
+
}
|
|
1444
|
+
},
|
|
1445
|
+
drain: drainEmissions,
|
|
1446
|
+
bindResumeSignal: (signal) => {
|
|
1447
|
+
activeSignal = signal;
|
|
1448
|
+
},
|
|
1449
|
+
onControlPlaneError: (error) => {
|
|
1450
|
+
if (!activeSignal?.aborted)
|
|
1451
|
+
controlPlaneError ??= error;
|
|
1452
|
+
},
|
|
1453
|
+
onBackgroundError: (error) => {
|
|
1454
|
+
emissionFailure ??= error;
|
|
1455
|
+
},
|
|
1456
|
+
});
|
|
1457
|
+
function tracePositionForActiveTurn() {
|
|
1458
|
+
return activeTurnId === undefined ? {} : { turnId: activeTurnId };
|
|
1459
|
+
}
|
|
1460
|
+
function structuredStateTelemetryPayload(previousState, state, event, context) {
|
|
1461
|
+
const payload = {
|
|
1462
|
+
from: previousState?.value ?? null,
|
|
1463
|
+
to: state.value,
|
|
1464
|
+
event: normalizeTransitionEvent(event) ?? null,
|
|
1465
|
+
previousState: previousState ?? null,
|
|
1466
|
+
state,
|
|
1467
|
+
};
|
|
1468
|
+
if (state.stateId === 'awaitBossReply') {
|
|
1469
|
+
const pendingBossQuestion = pendingBossQuestionFromContext(context);
|
|
1470
|
+
if (pendingBossQuestion !== undefined) {
|
|
1471
|
+
payload.pendingBossQuestion = pendingBossQuestion;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (state.stateId === 'failed') {
|
|
1475
|
+
const lastError = normalizeErrorFull(context.lastError);
|
|
1476
|
+
if (lastError !== undefined)
|
|
1477
|
+
payload.lastError = lastError;
|
|
1478
|
+
}
|
|
1479
|
+
return snapshotJsonValue(payload, 'FSM telemetry payload');
|
|
1480
|
+
}
|
|
1481
|
+
function enqueueTransitionEmission(payload, state, statuses, position) {
|
|
1482
|
+
const currentSession = requireSession();
|
|
1483
|
+
const transitionTrace = createTraceEvent('fsm.transition', payload, position);
|
|
1484
|
+
const statusEmissions = statuses.map(({ message, data }) => ({
|
|
1485
|
+
message,
|
|
1486
|
+
data,
|
|
1487
|
+
trace: createTraceEvent('status.emitted', {
|
|
1488
|
+
message,
|
|
1489
|
+
...(data === undefined ? {} : { data }),
|
|
1490
|
+
state,
|
|
1491
|
+
...stateIdentity(state.stateId),
|
|
1492
|
+
}, position),
|
|
1493
|
+
}));
|
|
1494
|
+
void enqueueEmission(async () => {
|
|
1495
|
+
await currentSession.ports.emitTelemetry({
|
|
1496
|
+
topic: 'playbook.trace',
|
|
1497
|
+
payload: transitionTrace,
|
|
1498
|
+
});
|
|
1499
|
+
await currentSession.ports.emitTelemetry({
|
|
1500
|
+
topic: 'playbook.fsm.state',
|
|
1501
|
+
payload,
|
|
1502
|
+
});
|
|
1503
|
+
for (const status of statusEmissions) {
|
|
1504
|
+
await currentSession.ports.emitTelemetry({
|
|
1505
|
+
topic: 'playbook.trace',
|
|
1506
|
+
payload: status.trace,
|
|
1507
|
+
});
|
|
1508
|
+
await currentSession.ports.emitStatus(status.message, status.data);
|
|
1509
|
+
}
|
|
1510
|
+
}).catch(() => undefined);
|
|
1511
|
+
}
|
|
1512
|
+
function latchInspectionError(error) {
|
|
1513
|
+
if (activeSignal !== undefined)
|
|
1514
|
+
controlPlaneError ??= error;
|
|
1515
|
+
else
|
|
1516
|
+
emissionFailure ??= error;
|
|
1517
|
+
}
|
|
1518
|
+
function buildActor(ports, machineSnapshot) {
|
|
1519
|
+
priorState = undefined;
|
|
1520
|
+
const actors = {};
|
|
1521
|
+
if (declaredActors.has('player'))
|
|
1522
|
+
actors.player = playerActor(ports);
|
|
1523
|
+
if (declaredActors.has('captain'))
|
|
1524
|
+
actors.captain = captainActor();
|
|
1525
|
+
if (declaredActors.has('script'))
|
|
1526
|
+
actors.script = scriptActor();
|
|
1527
|
+
if (declaredActors.has('playbook')) {
|
|
1528
|
+
actors.playbook = nestedBridge.actorLogic;
|
|
1529
|
+
}
|
|
1530
|
+
const provided = machine.provide({
|
|
1531
|
+
actors: actors,
|
|
1532
|
+
});
|
|
1533
|
+
let builtActor;
|
|
1534
|
+
builtActor = createActor(provided, {
|
|
1535
|
+
input: machineInput(boundOptions, requireSession()),
|
|
1536
|
+
// DR-014 §1: a restore rehydrates the persisted machine snapshot;
|
|
1537
|
+
// XState derives context/value from it and ignores `input` then.
|
|
1538
|
+
...(machineSnapshot === undefined
|
|
1539
|
+
? {}
|
|
1540
|
+
: { snapshot: machineSnapshot }),
|
|
1541
|
+
inspect: (inspectionEvent) => {
|
|
1542
|
+
if (inspectionEvent.type !== '@xstate.snapshot')
|
|
1543
|
+
return;
|
|
1544
|
+
if (inspectionEvent.actorRef !== builtActor)
|
|
1545
|
+
return;
|
|
1546
|
+
if (suppressInspectionEmissions)
|
|
1547
|
+
return;
|
|
1548
|
+
try {
|
|
1549
|
+
const snap = inspectionEvent.snapshot;
|
|
1550
|
+
const state = normalizePlaybookSnapshot(snap);
|
|
1551
|
+
if (state.stateId === undefined) {
|
|
1552
|
+
throw new Error(`${label} root snapshot must expose exactly one playbook state id`);
|
|
1553
|
+
}
|
|
1554
|
+
const previousState = priorState;
|
|
1555
|
+
const context = (snap.context ??
|
|
1556
|
+
{});
|
|
1557
|
+
const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
|
|
1558
|
+
const statuses = statusesForState(state, context, inspectionEvent.event);
|
|
1559
|
+
enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn());
|
|
1560
|
+
priorState = state;
|
|
1561
|
+
}
|
|
1562
|
+
catch (error) {
|
|
1563
|
+
latchInspectionError(error);
|
|
1564
|
+
}
|
|
1565
|
+
},
|
|
1566
|
+
});
|
|
1567
|
+
return builtActor;
|
|
1568
|
+
}
|
|
1569
|
+
function runResultFor(outcome, error) {
|
|
1570
|
+
const state = currentState();
|
|
1571
|
+
if (outcome === 'quiescent' || outcome === 'no-action') {
|
|
1572
|
+
return { outcome, state };
|
|
1573
|
+
}
|
|
1574
|
+
if (outcome === 'suspended') {
|
|
1575
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1576
|
+
if (!pendingCall) {
|
|
1577
|
+
throw new Error('suspended runtime has no pending playbook call');
|
|
1578
|
+
}
|
|
1579
|
+
return { outcome, state, pendingCall };
|
|
1580
|
+
}
|
|
1581
|
+
if (outcome === 'terminal') {
|
|
1582
|
+
const output = actor?.getSnapshot()?.output;
|
|
1583
|
+
if (output !== undefined) {
|
|
1584
|
+
return {
|
|
1585
|
+
outcome,
|
|
1586
|
+
state,
|
|
1587
|
+
output: snapshotJsonValue(output, 'terminal playbook output'),
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
return { outcome, state };
|
|
1591
|
+
}
|
|
1592
|
+
const failure = error ??
|
|
1593
|
+
(outcome === 'failed'
|
|
1594
|
+
? actor?.getSnapshot()
|
|
1595
|
+
?.context?.lastError
|
|
1596
|
+
: outcome === 'aborted'
|
|
1597
|
+
? activeSignal?.reason
|
|
1598
|
+
: undefined);
|
|
1599
|
+
return {
|
|
1600
|
+
outcome,
|
|
1601
|
+
state,
|
|
1602
|
+
...(failure !== undefined ? { error: normalizeError(failure) } : {}),
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
function settledOutcome(signal) {
|
|
1606
|
+
if (nestedBridge.getPendingCall())
|
|
1607
|
+
return 'suspended';
|
|
1608
|
+
if (signal.aborted)
|
|
1609
|
+
return 'aborted';
|
|
1610
|
+
const state = currentState();
|
|
1611
|
+
if (state.status === 'error') {
|
|
1612
|
+
const actorError = actor?.getSnapshot()?.error;
|
|
1613
|
+
throw actorError ?? new Error(`${label} actor entered error status`);
|
|
1614
|
+
}
|
|
1615
|
+
if (state.status === 'done')
|
|
1616
|
+
return 'terminal';
|
|
1617
|
+
if (state.stateId === 'failed')
|
|
1618
|
+
return 'failed';
|
|
1619
|
+
return 'quiescent';
|
|
1620
|
+
}
|
|
1621
|
+
function settlementTracePayload(result) {
|
|
1622
|
+
return {
|
|
1623
|
+
...result,
|
|
1624
|
+
...stateIdentity(result.state.stateId),
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
// Shared failed-start cleanup for init and restore: stop the actor,
|
|
1628
|
+
// abort/drain nested and host work, optionally emit one best-effort
|
|
1629
|
+
// session.disposed boundary, and unbind every closure field so dispose
|
|
1630
|
+
// stays callable. The caller rethrows its original failure. A restore
|
|
1631
|
+
// failure skips the disposal trace — the parked session was never
|
|
1632
|
+
// re-bound in this process, so its persisted snapshot stays
|
|
1633
|
+
// authoritative (DR-014 §2).
|
|
1634
|
+
async function cleanupFailedStart(cause, options) {
|
|
1635
|
+
let finalState;
|
|
1636
|
+
if (options.emitDisposal && actor) {
|
|
1637
|
+
try {
|
|
1638
|
+
finalState = currentState();
|
|
1639
|
+
}
|
|
1640
|
+
catch {
|
|
1641
|
+
// A state that cannot even normalize has no disposal descriptor.
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
suppressInspectionEmissions = true;
|
|
1645
|
+
try {
|
|
1646
|
+
actor?.stop();
|
|
1647
|
+
}
|
|
1648
|
+
catch {
|
|
1649
|
+
// Preserve the original startup failure.
|
|
1650
|
+
}
|
|
1651
|
+
try {
|
|
1652
|
+
await nestedBridge.abortPending(cause);
|
|
1653
|
+
}
|
|
1654
|
+
catch {
|
|
1655
|
+
// Preserve the original startup failure.
|
|
1656
|
+
}
|
|
1657
|
+
try {
|
|
1658
|
+
await judgeQueue.onIdle();
|
|
1659
|
+
await drainEmissions();
|
|
1660
|
+
}
|
|
1661
|
+
catch {
|
|
1662
|
+
// Preserve the original startup failure.
|
|
1663
|
+
}
|
|
1664
|
+
if (options.emitDisposal) {
|
|
1665
|
+
try {
|
|
1666
|
+
await emitTrace('session.disposed', finalState === undefined
|
|
1667
|
+
? {}
|
|
1668
|
+
: {
|
|
1669
|
+
state: finalState,
|
|
1670
|
+
...stateIdentity(finalState.stateId),
|
|
1671
|
+
});
|
|
1672
|
+
await drainEmissions();
|
|
1673
|
+
}
|
|
1674
|
+
catch {
|
|
1675
|
+
// The session-start error remains authoritative.
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
playerResumeTokens.clear();
|
|
1679
|
+
activePlayerIds.clear();
|
|
1680
|
+
playbookCallTurnIds.clear();
|
|
1681
|
+
activeEmissionCalls.clear();
|
|
1682
|
+
emissionQueue.clear();
|
|
1683
|
+
judgeQueue.clear();
|
|
1684
|
+
actor = undefined;
|
|
1685
|
+
session = undefined;
|
|
1686
|
+
savedPorts = undefined;
|
|
1687
|
+
runtimePorts = undefined;
|
|
1688
|
+
activeSignal = undefined;
|
|
1689
|
+
activeTurnId = undefined;
|
|
1690
|
+
controlPlaneError = undefined;
|
|
1691
|
+
emissionFailure = undefined;
|
|
1692
|
+
priorState = undefined;
|
|
1693
|
+
suppressInspectionEmissions = false;
|
|
1694
|
+
initialized = false;
|
|
1695
|
+
traceSequence = 0;
|
|
1696
|
+
turnSequence = 0;
|
|
1697
|
+
judgeCallSequence = 0;
|
|
1698
|
+
playerCallSequence = 0;
|
|
1699
|
+
playbookCallSequence = 0;
|
|
1700
|
+
captainCallSequence = 0;
|
|
1701
|
+
}
|
|
1702
|
+
const runtime = {
|
|
1703
|
+
async init(nextSession) {
|
|
1704
|
+
if (initialized || disposed || disposalPromise !== undefined) {
|
|
1705
|
+
throw new Error('createPlaybookRuntime.init: already initialized');
|
|
1706
|
+
}
|
|
1707
|
+
const boundSession = snapshotPlaybookSession(nextSession);
|
|
1708
|
+
initialized = true;
|
|
1709
|
+
let finishInitialization;
|
|
1710
|
+
const initialization = new Promise((resolve) => {
|
|
1711
|
+
finishInitialization = resolve;
|
|
1712
|
+
});
|
|
1713
|
+
initInFlight = initialization;
|
|
1714
|
+
const initTask = (async () => {
|
|
1715
|
+
session = boundSession;
|
|
1716
|
+
savedPorts = boundSession.ports;
|
|
1717
|
+
runtimePorts = createRuntimePorts(boundSession.ports);
|
|
1718
|
+
suppressInspectionEmissions = false;
|
|
1719
|
+
actor = buildActor(runtimePorts);
|
|
1720
|
+
await emitTrace('session.started', stateTracePayload());
|
|
1721
|
+
actor.start();
|
|
1722
|
+
await drainEmissions();
|
|
1723
|
+
})();
|
|
1724
|
+
try {
|
|
1725
|
+
await initTask;
|
|
1726
|
+
}
|
|
1727
|
+
catch (error) {
|
|
1728
|
+
await cleanupFailedStart(error, { emitDisposal: true });
|
|
1729
|
+
throw error;
|
|
1730
|
+
}
|
|
1731
|
+
finally {
|
|
1732
|
+
finishInitialization();
|
|
1733
|
+
if (initInFlight === initialization)
|
|
1734
|
+
initInFlight = undefined;
|
|
1735
|
+
}
|
|
1736
|
+
},
|
|
1737
|
+
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
|
|
1738
|
+
// Defined only at a safe capture point — initialized, not disposing
|
|
1739
|
+
// or disposed, no active public boundary, no pending nested call,
|
|
1740
|
+
// and the actor quiescent with status `active`.
|
|
1741
|
+
exportSnapshot() {
|
|
1742
|
+
if (!actor || !session || disposed || disposalPromise !== undefined) {
|
|
1743
|
+
return undefined;
|
|
1744
|
+
}
|
|
1745
|
+
if (activeSignal !== undefined)
|
|
1746
|
+
return undefined;
|
|
1747
|
+
if (nestedBridge.getPendingCall())
|
|
1748
|
+
return undefined;
|
|
1749
|
+
const state = currentState();
|
|
1750
|
+
if (state.status !== 'active' || !state.quiescent)
|
|
1751
|
+
return undefined;
|
|
1752
|
+
const machineSnapshot = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
|
|
1753
|
+
const context = actor.getSnapshot()
|
|
1754
|
+
.context;
|
|
1755
|
+
const pending = pendingBossQuestionFromContext(context ?? {});
|
|
1756
|
+
return {
|
|
1757
|
+
schemaVersion: 1,
|
|
1758
|
+
playbookId: session.playbookId,
|
|
1759
|
+
machine: machineSnapshot,
|
|
1760
|
+
playerResumeTokens: Object.fromEntries(playerResumeTokens),
|
|
1761
|
+
sequences: {
|
|
1762
|
+
trace: traceSequence,
|
|
1763
|
+
turn: turnSequence,
|
|
1764
|
+
judgeCall: judgeCallSequence,
|
|
1765
|
+
playerCall: playerCallSequence,
|
|
1766
|
+
playbookCall: playbookCallSequence,
|
|
1767
|
+
...(declaredActors.has('captain')
|
|
1768
|
+
? { captainCall: captainCallSequence }
|
|
1769
|
+
: {}),
|
|
1770
|
+
},
|
|
1771
|
+
state,
|
|
1772
|
+
pendingBossQuestions: pending === undefined
|
|
1773
|
+
? []
|
|
1774
|
+
: [
|
|
1775
|
+
{
|
|
1776
|
+
questionId: pending.questionId,
|
|
1777
|
+
player: pending.player,
|
|
1778
|
+
question: pending.question,
|
|
1779
|
+
sourceItem: pending.sourceItem,
|
|
1780
|
+
},
|
|
1781
|
+
],
|
|
1782
|
+
};
|
|
1783
|
+
},
|
|
1784
|
+
// DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
|
|
1785
|
+
// exported snapshot under the same immutable session identity.
|
|
1786
|
+
// Emits no `session.started`, transition trace, or human status —
|
|
1787
|
+
// the session already started; the next public boundary continues
|
|
1788
|
+
// the contiguous trace sequence.
|
|
1789
|
+
async restore(nextSession, snapshot) {
|
|
1790
|
+
if (initialized || disposed || disposalPromise !== undefined) {
|
|
1791
|
+
throw new Error('createPlaybookRuntime.restore: already initialized');
|
|
1792
|
+
}
|
|
1793
|
+
const boundSession = snapshotPlaybookSession(nextSession);
|
|
1794
|
+
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId);
|
|
1795
|
+
initialized = true;
|
|
1796
|
+
let finishInitialization;
|
|
1797
|
+
const initialization = new Promise((resolve) => {
|
|
1798
|
+
finishInitialization = resolve;
|
|
1799
|
+
});
|
|
1800
|
+
initInFlight = initialization;
|
|
1801
|
+
const initTask = (async () => {
|
|
1802
|
+
session = boundSession;
|
|
1803
|
+
savedPorts = boundSession.ports;
|
|
1804
|
+
runtimePorts = createRuntimePorts(boundSession.ports);
|
|
1805
|
+
traceSequence = boundSnapshot.sequences.trace;
|
|
1806
|
+
turnSequence = boundSnapshot.sequences.turn;
|
|
1807
|
+
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
1808
|
+
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1809
|
+
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
1810
|
+
captainCallSequence =
|
|
1811
|
+
boundSnapshot.sequences.captainCall ??
|
|
1812
|
+
// Legacy schema-v1 snapshots predate this dedicated counter.
|
|
1813
|
+
// Every Captain call already consumed at least one trace number,
|
|
1814
|
+
// so the global trace counter is a collision-safe id floor.
|
|
1815
|
+
boundSnapshot.sequences.trace;
|
|
1816
|
+
playerResumeTokens.clear();
|
|
1817
|
+
for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
|
|
1818
|
+
playerResumeTokens.set(playerId, token);
|
|
1819
|
+
}
|
|
1820
|
+
suppressInspectionEmissions = true;
|
|
1821
|
+
actor = buildActor(runtimePorts, boundSnapshot.machine);
|
|
1822
|
+
actor.start();
|
|
1823
|
+
const restoredState = currentState();
|
|
1824
|
+
if (restoredState.status !== 'active') {
|
|
1825
|
+
throw new Error(`createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`);
|
|
1826
|
+
}
|
|
1827
|
+
suppressInspectionEmissions = false;
|
|
1828
|
+
priorState = restoredState;
|
|
1829
|
+
await drainEmissions();
|
|
1830
|
+
})();
|
|
1831
|
+
try {
|
|
1832
|
+
await initTask;
|
|
1833
|
+
}
|
|
1834
|
+
catch (error) {
|
|
1835
|
+
await cleanupFailedStart(error, { emitDisposal: false });
|
|
1836
|
+
throw error;
|
|
1837
|
+
}
|
|
1838
|
+
finally {
|
|
1839
|
+
finishInitialization();
|
|
1840
|
+
if (initInFlight === initialization)
|
|
1841
|
+
initInFlight = undefined;
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
async handleBossInput({ text, signal, }) {
|
|
1845
|
+
if (!actor || !savedPorts) {
|
|
1846
|
+
throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
|
|
1847
|
+
}
|
|
1848
|
+
if (disposed || disposalPromise !== undefined) {
|
|
1849
|
+
throw new Error('createPlaybookRuntime.handleBossInput: runtime is disposing or disposed');
|
|
1850
|
+
}
|
|
1851
|
+
if (activeSignal !== undefined) {
|
|
1852
|
+
throw new Error('createPlaybookRuntime.handleBossInput: another runtime turn is active');
|
|
1853
|
+
}
|
|
1854
|
+
const turnId = ++turnSequence;
|
|
1855
|
+
activeTurnId = turnId;
|
|
1856
|
+
activeSignal = signal;
|
|
1857
|
+
controlPlaneError = undefined;
|
|
1858
|
+
let result;
|
|
1859
|
+
let operationError;
|
|
1860
|
+
try {
|
|
1861
|
+
await emitTrace('boss.input.received', { text }, { turnId });
|
|
1862
|
+
// 1. Map the Boss text to an FSM event: deterministic exact entry
|
|
1863
|
+
// where applicable (slc/link.md §Boss-event mapping), judge
|
|
1864
|
+
// classification otherwise.
|
|
1865
|
+
let event;
|
|
1866
|
+
const trimmed = text.trim();
|
|
1867
|
+
if (trimmed !== '') {
|
|
1868
|
+
const snapshot = actor.getSnapshot();
|
|
1869
|
+
const terminal = snapshot.status === 'done';
|
|
1870
|
+
const stateId = normalizePlaybookSnapshot(snapshot).stateId;
|
|
1871
|
+
if (spec.entryEvent !== undefined &&
|
|
1872
|
+
(stateId === 'ready' || terminal)) {
|
|
1873
|
+
event = {
|
|
1874
|
+
type: spec.entryEvent.type,
|
|
1875
|
+
[spec.entryEvent.textField]: text,
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
else {
|
|
1879
|
+
event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary);
|
|
1880
|
+
}
|
|
1881
|
+
signal.throwIfAborted();
|
|
1882
|
+
}
|
|
1883
|
+
// Empty input, no-action classifier output, or invalid classifier
|
|
1884
|
+
// output — nothing to send.
|
|
1885
|
+
if (event === undefined) {
|
|
1886
|
+
result = runResultFor('no-action');
|
|
1887
|
+
}
|
|
1888
|
+
else {
|
|
1889
|
+
// 2. Optional Captain-pane classification line: the bare FSM
|
|
1890
|
+
// event type, emitted before the FSM advances.
|
|
1891
|
+
const statusLine = spec.classificationStatus?.(event);
|
|
1892
|
+
if (statusLine !== undefined) {
|
|
1893
|
+
await runtimePorts.emitStatus(statusLine);
|
|
1894
|
+
}
|
|
1895
|
+
signal.throwIfAborted();
|
|
1896
|
+
// 3. A final actor cannot accept new events; reconstruct only
|
|
1897
|
+
// after classification produced a real event.
|
|
1898
|
+
if (actor.getSnapshot().status === 'done') {
|
|
1899
|
+
actor.stop();
|
|
1900
|
+
actor = buildActor(runtimePorts);
|
|
1901
|
+
actor.start();
|
|
1902
|
+
}
|
|
1903
|
+
actor.send(event);
|
|
1904
|
+
await waitForPlaybookQuiescence(actor, {
|
|
1905
|
+
pendingCalls: nestedBridge,
|
|
1906
|
+
});
|
|
1907
|
+
if (controlPlaneError !== undefined)
|
|
1908
|
+
throw controlPlaneError;
|
|
1909
|
+
result = runResultFor(settledOutcome(signal));
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
catch (error) {
|
|
1913
|
+
operationError = error;
|
|
1914
|
+
}
|
|
1915
|
+
let drainError;
|
|
1916
|
+
try {
|
|
1917
|
+
await drainEmissions();
|
|
1918
|
+
}
|
|
1919
|
+
catch (error) {
|
|
1920
|
+
drainError = error;
|
|
1921
|
+
}
|
|
1922
|
+
const latchedControlError = controlPlaneError;
|
|
1923
|
+
const primaryError = latchedControlError ?? drainError ?? operationError;
|
|
1924
|
+
const abortError = latchedControlError === undefined &&
|
|
1925
|
+
drainError === undefined &&
|
|
1926
|
+
operationError !== undefined &&
|
|
1927
|
+
isAbortFailure(operationError, signal);
|
|
1928
|
+
const settlementResult = primaryError === undefined
|
|
1929
|
+
? (result ?? runResultFor('no-action'))
|
|
1930
|
+
: runResultFor(abortError ? 'aborted' : 'failed', primaryError);
|
|
1931
|
+
let settlementEmissionError;
|
|
1932
|
+
try {
|
|
1933
|
+
await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
|
|
1934
|
+
}
|
|
1935
|
+
catch (error) {
|
|
1936
|
+
settlementEmissionError = error;
|
|
1937
|
+
}
|
|
1938
|
+
try {
|
|
1939
|
+
await drainEmissions();
|
|
1940
|
+
}
|
|
1941
|
+
catch (error) {
|
|
1942
|
+
settlementEmissionError ??= error;
|
|
1943
|
+
}
|
|
1944
|
+
const failure = controlPlaneError ??
|
|
1945
|
+
latchedControlError ??
|
|
1946
|
+
drainError ??
|
|
1947
|
+
(abortError
|
|
1948
|
+
? (settlementEmissionError ?? operationError)
|
|
1949
|
+
: (operationError ?? settlementEmissionError));
|
|
1950
|
+
activeSignal = undefined;
|
|
1951
|
+
activeTurnId = undefined;
|
|
1952
|
+
controlPlaneError = undefined;
|
|
1953
|
+
if (failure !== undefined &&
|
|
1954
|
+
!(abortError && settlementEmissionError === undefined)) {
|
|
1955
|
+
throw failure;
|
|
1956
|
+
}
|
|
1957
|
+
return settlementResult;
|
|
1958
|
+
},
|
|
1959
|
+
async resumePlaybookCall(input) {
|
|
1960
|
+
if (!actor || !savedPorts) {
|
|
1961
|
+
throw new Error('createPlaybookRuntime.resumePlaybookCall: init must be called first');
|
|
1962
|
+
}
|
|
1963
|
+
if (disposed || disposalPromise !== undefined) {
|
|
1964
|
+
throw new Error('createPlaybookRuntime.resumePlaybookCall: runtime is disposing or disposed');
|
|
1965
|
+
}
|
|
1966
|
+
if (activeSignal !== undefined) {
|
|
1967
|
+
throw new Error('createPlaybookRuntime.resumePlaybookCall: another runtime turn is active');
|
|
1968
|
+
}
|
|
1969
|
+
activeTurnId = playbookCallTurnIds.get(input.callId);
|
|
1970
|
+
activeSignal = input.signal;
|
|
1971
|
+
controlPlaneError = undefined;
|
|
1972
|
+
let result;
|
|
1973
|
+
let operationError;
|
|
1974
|
+
try {
|
|
1975
|
+
await nestedBridge.resume(input);
|
|
1976
|
+
}
|
|
1977
|
+
catch (error) {
|
|
1978
|
+
operationError = error;
|
|
1979
|
+
}
|
|
1980
|
+
try {
|
|
1981
|
+
await waitForPlaybookQuiescence(actor, {
|
|
1982
|
+
pendingCalls: nestedBridge,
|
|
1983
|
+
});
|
|
1984
|
+
result = runResultFor(settledOutcome(input.signal));
|
|
1985
|
+
}
|
|
1986
|
+
catch (error) {
|
|
1987
|
+
operationError ??= error;
|
|
1988
|
+
}
|
|
1989
|
+
let drainError;
|
|
1990
|
+
try {
|
|
1991
|
+
await drainEmissions();
|
|
1992
|
+
}
|
|
1993
|
+
catch (error) {
|
|
1994
|
+
drainError = error;
|
|
1995
|
+
}
|
|
1996
|
+
const failure = controlPlaneError ?? drainError ?? operationError;
|
|
1997
|
+
activeSignal = undefined;
|
|
1998
|
+
activeTurnId = undefined;
|
|
1999
|
+
controlPlaneError = undefined;
|
|
2000
|
+
if (failure !== undefined)
|
|
2001
|
+
throw failure;
|
|
2002
|
+
if (result === undefined) {
|
|
2003
|
+
throw new Error('playbook resume produced no runtime result');
|
|
2004
|
+
}
|
|
2005
|
+
return result;
|
|
2006
|
+
},
|
|
2007
|
+
dispose() {
|
|
2008
|
+
if (disposalPromise !== undefined)
|
|
2009
|
+
return disposalPromise;
|
|
2010
|
+
if (disposed)
|
|
2011
|
+
return Promise.resolve();
|
|
2012
|
+
if (activeSignal !== undefined) {
|
|
2013
|
+
return Promise.reject(new Error('createPlaybookRuntime.dispose: cannot dispose during an active runtime boundary'));
|
|
2014
|
+
}
|
|
2015
|
+
const task = (async () => {
|
|
2016
|
+
const failures = [];
|
|
2017
|
+
try {
|
|
2018
|
+
if (initInFlight !== undefined) {
|
|
2019
|
+
try {
|
|
2020
|
+
await initInFlight;
|
|
2021
|
+
}
|
|
2022
|
+
catch {
|
|
2023
|
+
// Dispose still releases whatever an unsuccessful init bound.
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
const finalState = actor ? currentState() : undefined;
|
|
2027
|
+
// Stop the root before settling a suspended child. Its rejection
|
|
2028
|
+
// must not re-enter the FSM and start fresh work during disposal.
|
|
2029
|
+
if (actor)
|
|
2030
|
+
actor.stop();
|
|
2031
|
+
try {
|
|
2032
|
+
await nestedBridge.dispose();
|
|
2033
|
+
}
|
|
2034
|
+
catch (error) {
|
|
2035
|
+
failures.push(error);
|
|
2036
|
+
}
|
|
2037
|
+
try {
|
|
2038
|
+
await drainEmissions();
|
|
2039
|
+
}
|
|
2040
|
+
catch (error) {
|
|
2041
|
+
failures.push(error);
|
|
2042
|
+
}
|
|
2043
|
+
if (session !== undefined) {
|
|
2044
|
+
try {
|
|
2045
|
+
await emitTrace('session.disposed', finalState === undefined
|
|
2046
|
+
? {}
|
|
2047
|
+
: {
|
|
2048
|
+
state: finalState,
|
|
2049
|
+
...stateIdentity(finalState.stateId),
|
|
2050
|
+
});
|
|
2051
|
+
await drainEmissions();
|
|
2052
|
+
}
|
|
2053
|
+
catch (error) {
|
|
2054
|
+
failures.push(error);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
finally {
|
|
2059
|
+
playerResumeTokens.clear();
|
|
2060
|
+
activePlayerIds.clear();
|
|
2061
|
+
playbookCallTurnIds.clear();
|
|
2062
|
+
activeEmissionCalls.clear();
|
|
2063
|
+
emissionQueue.clear();
|
|
2064
|
+
judgeQueue.clear();
|
|
2065
|
+
actor = undefined;
|
|
2066
|
+
activeSignal = undefined;
|
|
2067
|
+
activeTurnId = undefined;
|
|
2068
|
+
controlPlaneError = undefined;
|
|
2069
|
+
emissionFailure = undefined;
|
|
2070
|
+
savedPorts = undefined;
|
|
2071
|
+
runtimePorts = undefined;
|
|
2072
|
+
session = undefined;
|
|
2073
|
+
disposed = true;
|
|
2074
|
+
}
|
|
2075
|
+
if (failures.length === 1)
|
|
2076
|
+
throw failures[0];
|
|
2077
|
+
if (failures.length > 1) {
|
|
2078
|
+
throw new AggregateError(failures, 'playbook runtime disposal failed');
|
|
2079
|
+
}
|
|
2080
|
+
})();
|
|
2081
|
+
disposalPromise = task;
|
|
2082
|
+
return task;
|
|
2083
|
+
},
|
|
2084
|
+
// @internal — test-only escape hatches for inspecting the underlying
|
|
2085
|
+
// actor, traced boundary, and nested bridge. Not part of the stable
|
|
2086
|
+
// public runtime contract.
|
|
2087
|
+
_getActor() {
|
|
2088
|
+
return actor;
|
|
2089
|
+
},
|
|
2090
|
+
_getBoundary() {
|
|
2091
|
+
return boundary;
|
|
2092
|
+
},
|
|
2093
|
+
_getNestedBridge() {
|
|
2094
|
+
return nestedBridge;
|
|
2095
|
+
},
|
|
2096
|
+
};
|
|
2097
|
+
return runtime;
|
|
2098
|
+
};
|
|
2099
|
+
}
|