@sublang/playbook 0.9.0 → 1.3.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 +190 -151
- package/package.json +50 -6
- package/reference/sdlc/captain.md +102 -0
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
- package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
- package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
- package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
- package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +158 -12
- package/reference/sdlc/code.playbook/bin/run.js +999 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
- package/reference/sdlc/code.playbook/code.fsm.js +334 -102
- package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
- package/reference/sdlc/code.playbook/code.gears.md +11 -10
- package/reference/sdlc/code.playbook/code.playbook.d.ts +16 -19
- package/reference/sdlc/code.playbook/code.playbook.js +199 -488
- package/reference/sdlc/code.playbook/code.playbook.ts +327 -566
- package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
- package/reference/sdlc/code.playbook/code.registry.js +0 -3
- package/reference/sdlc/code.playbook/code.registry.ts +0 -6
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
- package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
- package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +21 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
- package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
- package/slc/gears2fsm.md +557 -57
- package/slc/link.md +1165 -89
- package/slc/optimize.md +92 -0
- package/slc/text2gears.md +255 -7
- package/src/runtime.d.ts +146 -3
- package/src/runtime.ts +201 -2
- package/src/xstate-playbook-runtime.d.ts +201 -0
- package/src/xstate-playbook-runtime.js +2058 -0
- package/src/xstate-playbook-runtime.ts +2792 -0
- package/src/xstate-runtime.d.ts +95 -0
- package/src/xstate-runtime.js +1258 -0
- package/src/xstate-runtime.ts +1816 -0
|
@@ -0,0 +1,1514 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
//
|
|
4
|
+
// PlaybookRuntime for the discuss playbook, linked from the FSM artifact by
|
|
5
|
+
// the slc FSM-to-runtime link phase.
|
|
6
|
+
//
|
|
7
|
+
// Linker inputs:
|
|
8
|
+
// FSM artifact: ./discuss.fsm.ts
|
|
9
|
+
// Link target: @sublang/playbook/src/runtime.ts
|
|
10
|
+
// Player binding: Host -> host, Participant -> participant,
|
|
11
|
+
// Committer -> committer
|
|
12
|
+
// (default binding: lowercased player name)
|
|
13
|
+
// Composite players: Committer = Host | Participant. DISCUSS-14 and
|
|
14
|
+
// DISCUSS-15 keep PlayerInput.player = 'Committer';
|
|
15
|
+
// callPlayer resolution uses options.committer when
|
|
16
|
+
// supplied, otherwise falls back to Host, the first
|
|
17
|
+
// listed alias alternative.
|
|
18
|
+
// Adjudication: LLM-judge per state (default)
|
|
19
|
+
// Boss-event mapping: free-text judge classification (default)
|
|
20
|
+
// Abort strategy: natural rejection; every player-invoking state's
|
|
21
|
+
// onError routes to the quiescent failed state.
|
|
22
|
+
import PQueue from 'p-queue';
|
|
23
|
+
import { createActor, fromPromise } from 'xstate';
|
|
24
|
+
import { assertJsonSafe, assertPlaybookRuntimeSnapshot, combineAbortSignals, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
|
|
25
|
+
import discussMachine from './discuss.fsm.js';
|
|
26
|
+
const DEFAULT_PLAYER_BINDING = {
|
|
27
|
+
Host: 'host',
|
|
28
|
+
Participant: 'participant',
|
|
29
|
+
Committer: 'committer',
|
|
30
|
+
};
|
|
31
|
+
const ALIAS_RESOLUTION = {
|
|
32
|
+
'DISCUSS-14': 'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
|
|
33
|
+
'DISCUSS-15': 'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
|
|
34
|
+
};
|
|
35
|
+
function snapshotDiscussRuntimeOptions(value) {
|
|
36
|
+
const captured = snapshotJsonValue(value, 'DISCUSS runtime options');
|
|
37
|
+
if (!isPlainObject(captured)) {
|
|
38
|
+
throw new TypeError('DISCUSS runtime options must be an object');
|
|
39
|
+
}
|
|
40
|
+
const allowed = new Set([
|
|
41
|
+
'host',
|
|
42
|
+
'participant',
|
|
43
|
+
'committer',
|
|
44
|
+
'playerBinding',
|
|
45
|
+
]);
|
|
46
|
+
for (const key of Object.keys(captured)) {
|
|
47
|
+
if (!allowed.has(key)) {
|
|
48
|
+
throw new TypeError(`DISCUSS runtime options.${key} is not declared`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const key of ['host', 'participant', 'committer']) {
|
|
52
|
+
if (key in captured && typeof captured[key] !== 'string') {
|
|
53
|
+
throw new TypeError(`DISCUSS runtime options.${key} must be a string`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if ('playerBinding' in captured) {
|
|
57
|
+
const playerBinding = captured.playerBinding;
|
|
58
|
+
if (!isPlainObject(playerBinding)) {
|
|
59
|
+
throw new TypeError('DISCUSS runtime options.playerBinding must be an object');
|
|
60
|
+
}
|
|
61
|
+
const playerNames = new Set([
|
|
62
|
+
'Host',
|
|
63
|
+
'Participant',
|
|
64
|
+
'Committer',
|
|
65
|
+
]);
|
|
66
|
+
for (const [player, playerId] of Object.entries(playerBinding)) {
|
|
67
|
+
if (!playerNames.has(player)) {
|
|
68
|
+
throw new TypeError(`DISCUSS runtime options.playerBinding.${player} is not declared`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof playerId !== 'string' || playerId.trim().length === 0) {
|
|
71
|
+
throw new TypeError(`DISCUSS runtime options.playerBinding.${player} must be a non-empty string`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return captured;
|
|
76
|
+
}
|
|
77
|
+
const STATE_DESCRIPTIONS = {
|
|
78
|
+
ready: 'Idle hub awaiting a Boss discussion or review directive.',
|
|
79
|
+
askHostInitial: 'Host proposes whether the Boss topic should become spec items or DRs.',
|
|
80
|
+
askParticipantInitial: 'Participant independently proposes whether the Boss topic should become spec items or DRs.',
|
|
81
|
+
hostInitialRound: 'Host reconciles the Participant proposal during initial discussion.',
|
|
82
|
+
participantInitialRound: 'Participant reconciles the Host proposal during initial discussion.',
|
|
83
|
+
hostWritesAgreement: 'Host writes the agreed spec items or DRs and updates the spec map.',
|
|
84
|
+
commitInitialChanges: 'Committer commits the changes produced at the end of initial discussion.',
|
|
85
|
+
reviewSpecInitialCommit: 'Participant reviews newly committed spec-item changes.',
|
|
86
|
+
reviewSpecHostChanges: 'Participant reviews Host changes to spec items after findings.',
|
|
87
|
+
reviewDrInitialCommit: 'Participant reviews newly committed decision-record changes.',
|
|
88
|
+
reviewDrHostChanges: 'Participant reviews Host changes to decision records after findings.',
|
|
89
|
+
reviewMixedInitialCommit: 'Participant reviews newly committed mixed spec-item and DR changes.',
|
|
90
|
+
reviewMixedHostChanges: 'Participant reviews Host changes to mixed spec items and DRs after findings.',
|
|
91
|
+
hostAddressesFindings: 'Host accepts or challenges review findings and stages repo changes.',
|
|
92
|
+
participantAddressesRebuttals: 'Participant accepts or challenges Host rebuttals.',
|
|
93
|
+
commitReviewedChanges: 'Committer commits reviewed changes once Participant raises no findings.',
|
|
94
|
+
awaitBossReply: 'Waiting for Boss to answer a player question.',
|
|
95
|
+
failed: 'The discussion workflow failed and is waiting for Boss recovery.',
|
|
96
|
+
done: 'The discussion workflow completed with a reviewed commit.',
|
|
97
|
+
};
|
|
98
|
+
const CAPTAIN_STATES = [
|
|
99
|
+
{ stateId: 'askHostInitial', player: 'Host', sourceItem: 'DISCUSS-1' },
|
|
100
|
+
{
|
|
101
|
+
stateId: 'askParticipantInitial',
|
|
102
|
+
player: 'Participant',
|
|
103
|
+
sourceItem: 'DISCUSS-2',
|
|
104
|
+
},
|
|
105
|
+
{ stateId: 'hostInitialRound', player: 'Host', sourceItem: 'DISCUSS-3' },
|
|
106
|
+
{
|
|
107
|
+
stateId: 'participantInitialRound',
|
|
108
|
+
player: 'Participant',
|
|
109
|
+
sourceItem: 'DISCUSS-4',
|
|
110
|
+
},
|
|
111
|
+
{ stateId: 'hostWritesAgreement', player: 'Host', sourceItem: 'DISCUSS-5' },
|
|
112
|
+
{
|
|
113
|
+
stateId: 'commitInitialChanges',
|
|
114
|
+
player: 'Committer',
|
|
115
|
+
sourceItem: 'DISCUSS-14',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
stateId: 'reviewSpecInitialCommit',
|
|
119
|
+
player: 'Participant',
|
|
120
|
+
sourceItem: 'DISCUSS-6',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
stateId: 'reviewSpecHostChanges',
|
|
124
|
+
player: 'Participant',
|
|
125
|
+
sourceItem: 'DISCUSS-7',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
stateId: 'reviewDrInitialCommit',
|
|
129
|
+
player: 'Participant',
|
|
130
|
+
sourceItem: 'DISCUSS-8',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
stateId: 'reviewDrHostChanges',
|
|
134
|
+
player: 'Participant',
|
|
135
|
+
sourceItem: 'DISCUSS-9',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
stateId: 'reviewMixedInitialCommit',
|
|
139
|
+
player: 'Participant',
|
|
140
|
+
sourceItem: 'DISCUSS-10',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
stateId: 'reviewMixedHostChanges',
|
|
144
|
+
player: 'Participant',
|
|
145
|
+
sourceItem: 'DISCUSS-11',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
stateId: 'hostAddressesFindings',
|
|
149
|
+
player: 'Host',
|
|
150
|
+
sourceItem: 'DISCUSS-12',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
stateId: 'participantAddressesRebuttals',
|
|
154
|
+
player: 'Participant',
|
|
155
|
+
sourceItem: 'DISCUSS-13',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
stateId: 'commitReviewedChanges',
|
|
159
|
+
player: 'Committer',
|
|
160
|
+
sourceItem: 'DISCUSS-15',
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
const CAPTAIN_STATE_IDS = new Set(CAPTAIN_STATES.map((state) => state.stateId));
|
|
164
|
+
const BOSS_INTERRUPT_TARGETS = [
|
|
165
|
+
'ready',
|
|
166
|
+
'initialProposalRound',
|
|
167
|
+
'reconciliationRound',
|
|
168
|
+
'hostWritesAgreement',
|
|
169
|
+
'commitInitialChanges',
|
|
170
|
+
'reviewSpecInitialCommit',
|
|
171
|
+
'reviewSpecHostChanges',
|
|
172
|
+
'reviewDrInitialCommit',
|
|
173
|
+
'reviewDrHostChanges',
|
|
174
|
+
'reviewMixedInitialCommit',
|
|
175
|
+
'reviewMixedHostChanges',
|
|
176
|
+
'hostAddressesFindings',
|
|
177
|
+
'participantAddressesRebuttals',
|
|
178
|
+
'commitReviewedChanges',
|
|
179
|
+
'failed',
|
|
180
|
+
];
|
|
181
|
+
const BOSS_INTERRUPT_TARGET_IDS = new Set(BOSS_INTERRUPT_TARGETS);
|
|
182
|
+
const REVIEW_SCOPES = new Set([
|
|
183
|
+
'specItems',
|
|
184
|
+
'decisionRecords',
|
|
185
|
+
'mixed',
|
|
186
|
+
]);
|
|
187
|
+
const TELEMETRY_TOPIC = 'playbook.fsm.state';
|
|
188
|
+
const TRACE_TOPIC = 'playbook.trace';
|
|
189
|
+
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.';
|
|
190
|
+
const PLACEHOLDER_FIELDS = [
|
|
191
|
+
['<topic>', 'topic'],
|
|
192
|
+
['<participant-proposal>', 'participantProposal'],
|
|
193
|
+
['<host-previous-proposal>', 'hostProposal'],
|
|
194
|
+
['<host-proposal>', 'hostProposal'],
|
|
195
|
+
['<participant-previous-proposal>', 'participantProposal'],
|
|
196
|
+
['<agreement>', 'agreement'],
|
|
197
|
+
['<changes>', 'latestChanges'],
|
|
198
|
+
['<review-items>', 'reviewItems'],
|
|
199
|
+
['<rebuttals>', 'rebuttals'],
|
|
200
|
+
['<host-llm>', 'hostLlm'],
|
|
201
|
+
['<participant-llm>', 'participantLlm'],
|
|
202
|
+
];
|
|
203
|
+
function composePlayerPrompt(input) {
|
|
204
|
+
const blocks = [];
|
|
205
|
+
if (input.pendingBossQuestion && input.bossReply !== undefined) {
|
|
206
|
+
blocks.push([
|
|
207
|
+
CONTINUATION_PREAMBLE,
|
|
208
|
+
'',
|
|
209
|
+
'Boss question:',
|
|
210
|
+
input.pendingBossQuestion.question,
|
|
211
|
+
'',
|
|
212
|
+
'Boss reply:',
|
|
213
|
+
input.bossReply,
|
|
214
|
+
].join('\n'));
|
|
215
|
+
}
|
|
216
|
+
let body = input.prompt;
|
|
217
|
+
for (const [placeholder, field] of PLACEHOLDER_FIELDS) {
|
|
218
|
+
const value = input[field];
|
|
219
|
+
if (typeof value === 'string') {
|
|
220
|
+
body = body.replaceAll(placeholder, value);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
blocks.push(body);
|
|
224
|
+
return blocks.join('\n\n');
|
|
225
|
+
}
|
|
226
|
+
function resolvePlayerId(input, binding) {
|
|
227
|
+
switch (input.player) {
|
|
228
|
+
case 'Host':
|
|
229
|
+
return binding.Host;
|
|
230
|
+
case 'Participant':
|
|
231
|
+
return binding.Participant;
|
|
232
|
+
case 'Committer':
|
|
233
|
+
return input.committerPlayer ?? binding.Host;
|
|
234
|
+
default: {
|
|
235
|
+
const exhaustive = input.player;
|
|
236
|
+
throw new Error(`unknown player ${String(exhaustive)}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// A `result` description names required payload fields in an
|
|
241
|
+
// "Output shall include ..." sentence. One sentence can name several
|
|
242
|
+
// fields — DISCUSS-5's wroteChanges names both `latestChanges` and
|
|
243
|
+
// `reviewScope` — so every backticked `field:` token in the sentence
|
|
244
|
+
// span is required, not just the one adjacent to the phrase.
|
|
245
|
+
function requiredFieldsFor(description) {
|
|
246
|
+
const fields = [];
|
|
247
|
+
const sentence = /Output shall include([^.]*)/g;
|
|
248
|
+
let span;
|
|
249
|
+
while ((span = sentence.exec(description)) !== null) {
|
|
250
|
+
for (const field of span[1].matchAll(/`([A-Za-z_][A-Za-z0-9_]*)\s*:/g)) {
|
|
251
|
+
fields.push(field[1]);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return fields;
|
|
255
|
+
}
|
|
256
|
+
// LLM judges routinely wrap JSON in prose/fences or damage its tail. Match
|
|
257
|
+
// CODE's recovery contract: scan candidate starts in document order, prefer a
|
|
258
|
+
// strict balanced value at each position, then repair trailing commas and
|
|
259
|
+
// truncation before considering a later candidate.
|
|
260
|
+
function extractJson(raw) {
|
|
261
|
+
try {
|
|
262
|
+
const parsed = parseJudgeJson(raw);
|
|
263
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function parseJudgeJson(raw) {
|
|
270
|
+
const text = stripCodeFence(raw.trim());
|
|
271
|
+
try {
|
|
272
|
+
return JSON.parse(text);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Fall through to candidate extraction and repair.
|
|
276
|
+
}
|
|
277
|
+
const starts = [];
|
|
278
|
+
for (let index = 0; index < text.length; index++) {
|
|
279
|
+
if (text[index] === '{' || text[index] === '[')
|
|
280
|
+
starts.push(index);
|
|
281
|
+
}
|
|
282
|
+
let firstValue;
|
|
283
|
+
for (const start of starts) {
|
|
284
|
+
let parsedHere;
|
|
285
|
+
for (const repair of [false, true]) {
|
|
286
|
+
const candidate = extractJsonValue(text, start, repair);
|
|
287
|
+
if (candidate === undefined)
|
|
288
|
+
continue;
|
|
289
|
+
try {
|
|
290
|
+
parsedHere = { value: JSON.parse(candidate) };
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Try repair at this position, then continue in document order.
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
if (parsedHere === undefined)
|
|
299
|
+
continue;
|
|
300
|
+
if (isPlainObject(parsedHere.value))
|
|
301
|
+
return parsedHere.value;
|
|
302
|
+
firstValue ??= parsedHere;
|
|
303
|
+
}
|
|
304
|
+
if (firstValue !== undefined)
|
|
305
|
+
return firstValue.value;
|
|
306
|
+
throw new Error('judge response is not valid JSON');
|
|
307
|
+
}
|
|
308
|
+
function isPlainObject(value) {
|
|
309
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
310
|
+
}
|
|
311
|
+
function stripCodeFence(text) {
|
|
312
|
+
const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
|
|
313
|
+
return fence ? fence[1].trim() : text;
|
|
314
|
+
}
|
|
315
|
+
function extractJsonValue(text, start, repair) {
|
|
316
|
+
const stack = [];
|
|
317
|
+
let output = '';
|
|
318
|
+
let inString = false;
|
|
319
|
+
let escaped = false;
|
|
320
|
+
for (let index = start; index < text.length; index++) {
|
|
321
|
+
const character = text[index];
|
|
322
|
+
if (inString) {
|
|
323
|
+
output += character;
|
|
324
|
+
if (escaped)
|
|
325
|
+
escaped = false;
|
|
326
|
+
else if (character === '\\')
|
|
327
|
+
escaped = true;
|
|
328
|
+
else if (character === '"')
|
|
329
|
+
inString = false;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (character === '"') {
|
|
333
|
+
inString = true;
|
|
334
|
+
output += character;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (character === '{' || character === '[') {
|
|
338
|
+
stack.push(character === '{' ? '}' : ']');
|
|
339
|
+
output += character;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (character === '}' || character === ']') {
|
|
343
|
+
if (repair)
|
|
344
|
+
output = dropTrailingComma(output);
|
|
345
|
+
output += character;
|
|
346
|
+
stack.pop();
|
|
347
|
+
if (stack.length === 0)
|
|
348
|
+
return output;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
output += character;
|
|
352
|
+
}
|
|
353
|
+
if (!repair)
|
|
354
|
+
return undefined;
|
|
355
|
+
if (inString)
|
|
356
|
+
output += '"';
|
|
357
|
+
output = dropTrailingComma(output);
|
|
358
|
+
while (stack.length > 0)
|
|
359
|
+
output += stack.pop();
|
|
360
|
+
return output;
|
|
361
|
+
}
|
|
362
|
+
function dropTrailingComma(value) {
|
|
363
|
+
return value.replace(/,(\s*)$/, '$1');
|
|
364
|
+
}
|
|
365
|
+
function buildClassifierPrompt(text, ctx) {
|
|
366
|
+
const lines = [];
|
|
367
|
+
lines.push('You are the Boss-input classifier for the discuss playbook.');
|
|
368
|
+
lines.push('Classify the Boss message into exactly one FSM event, or into no event.');
|
|
369
|
+
lines.push('');
|
|
370
|
+
lines.push(`Current FSM state: ${JSON.stringify(ctx.state.value)}`);
|
|
371
|
+
lines.push(`Active state ids: ${ctx.state.activeStateIds.join(', ')}`);
|
|
372
|
+
if (ctx.pendingQuestions.length > 0) {
|
|
373
|
+
lines.push('Pending Boss questions:');
|
|
374
|
+
for (const pending of ctx.pendingQuestions) {
|
|
375
|
+
lines.push(`- ${pending.questionId} (${pending.player}, ${pending.sourceItem}): ${pending.question}`);
|
|
376
|
+
}
|
|
377
|
+
lines.push('If the Boss message answers a pending question, classify it as BOSS_REPLY; if it is a fresh directive, classify it accordingly.');
|
|
378
|
+
}
|
|
379
|
+
lines.push('');
|
|
380
|
+
lines.push('Events and payload contracts:');
|
|
381
|
+
lines.push('- START_DISCUSSION: required topic string; optional hostLlm, participantLlm strings (identities normally come from run options).');
|
|
382
|
+
lines.push('- START_REVIEW: required latestChanges string, required reviewScope string ("specItems" | "decisionRecords" | "mixed"), optional rebuttals string.');
|
|
383
|
+
lines.push(`- BOSS_INTERRUPT: required targetId string, one of ${BOSS_INTERRUPT_TARGETS.join(', ')}.`);
|
|
384
|
+
lines.push('- BOSS_REPLY: required answer string and questionId when several questions are pending; valid only while at least one Boss question is pending.');
|
|
385
|
+
lines.push('');
|
|
386
|
+
lines.push('Boss message:');
|
|
387
|
+
lines.push(text);
|
|
388
|
+
lines.push('');
|
|
389
|
+
lines.push('Reply with a single JSON object: { "event": "<EVENT_TYPE or null>", ...payload fields }.');
|
|
390
|
+
lines.push('Use null when no FSM action applies.');
|
|
391
|
+
return lines.join('\n');
|
|
392
|
+
}
|
|
393
|
+
function parseClassification(raw, pendingQuestionIds = []) {
|
|
394
|
+
const obj = extractJson(raw);
|
|
395
|
+
if (!obj)
|
|
396
|
+
return null;
|
|
397
|
+
const eventType = obj.event ?? obj.type;
|
|
398
|
+
if (eventType === 'START_DISCUSSION') {
|
|
399
|
+
// hostLlm/participantLlm are optional on the FSM event: the identities
|
|
400
|
+
// normally flow in via the machine input (run options).
|
|
401
|
+
if (typeof obj.topic === 'string') {
|
|
402
|
+
return {
|
|
403
|
+
type: 'START_DISCUSSION',
|
|
404
|
+
topic: obj.topic,
|
|
405
|
+
...(typeof obj.hostLlm === 'string' ? { hostLlm: obj.hostLlm } : {}),
|
|
406
|
+
...(typeof obj.participantLlm === 'string'
|
|
407
|
+
? { participantLlm: obj.participantLlm }
|
|
408
|
+
: {}),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
if (eventType === 'START_REVIEW') {
|
|
414
|
+
if (typeof obj.latestChanges === 'string' &&
|
|
415
|
+
typeof obj.reviewScope === 'string' &&
|
|
416
|
+
REVIEW_SCOPES.has(obj.reviewScope)) {
|
|
417
|
+
return {
|
|
418
|
+
type: 'START_REVIEW',
|
|
419
|
+
latestChanges: obj.latestChanges,
|
|
420
|
+
reviewScope: obj.reviewScope,
|
|
421
|
+
...(typeof obj.rebuttals === 'string'
|
|
422
|
+
? { rebuttals: obj.rebuttals }
|
|
423
|
+
: {}),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
if (eventType === 'BOSS_INTERRUPT') {
|
|
429
|
+
if (typeof obj.targetId === 'string' &&
|
|
430
|
+
BOSS_INTERRUPT_TARGET_IDS.has(obj.targetId)) {
|
|
431
|
+
return { type: 'BOSS_INTERRUPT', targetId: obj.targetId };
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
if (eventType === 'BOSS_REPLY') {
|
|
436
|
+
if (typeof obj.answer !== 'string' || pendingQuestionIds.length === 0) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
if (typeof obj.questionId === 'string') {
|
|
440
|
+
return pendingQuestionIds.includes(obj.questionId)
|
|
441
|
+
? {
|
|
442
|
+
type: 'BOSS_REPLY',
|
|
443
|
+
questionId: obj.questionId,
|
|
444
|
+
answer: obj.answer,
|
|
445
|
+
}
|
|
446
|
+
: null;
|
|
447
|
+
}
|
|
448
|
+
return pendingQuestionIds.length === 1
|
|
449
|
+
? {
|
|
450
|
+
type: 'BOSS_REPLY',
|
|
451
|
+
questionId: pendingQuestionIds[0],
|
|
452
|
+
answer: obj.answer,
|
|
453
|
+
}
|
|
454
|
+
: null;
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
function buildAdjudicatorPrompt(input, playerOutput) {
|
|
459
|
+
const lines = [];
|
|
460
|
+
lines.push('You are the guard adjudicator for a playbook state machine.');
|
|
461
|
+
lines.push(`The player "${input.player}" produced the output below for source item ${input.sourceItem}.`);
|
|
462
|
+
lines.push('Choose exactly one guard whose description matches that output.');
|
|
463
|
+
lines.push('');
|
|
464
|
+
lines.push('Player output (verbatim):');
|
|
465
|
+
lines.push('"""');
|
|
466
|
+
lines.push(playerOutput);
|
|
467
|
+
lines.push('"""');
|
|
468
|
+
lines.push('');
|
|
469
|
+
lines.push('Guards (choose exactly one; the descriptions are authoritative and must be applied as written):');
|
|
470
|
+
for (const [guard, description] of Object.entries(input.result)) {
|
|
471
|
+
lines.push(`- ${guard}: ${description}`);
|
|
472
|
+
}
|
|
473
|
+
lines.push('');
|
|
474
|
+
lines.push('Reply with a single JSON object: { "guard": "<one of the guard names above>", ...any payload fields the chosen guard description requires }.');
|
|
475
|
+
return lines.join('\n');
|
|
476
|
+
}
|
|
477
|
+
function parseAdjudication(raw, input) {
|
|
478
|
+
const obj = extractJson(raw);
|
|
479
|
+
if (!obj || typeof obj.guard !== 'string' || obj.guard.trim() === '') {
|
|
480
|
+
throw new Error('adjudicator returned empty or malformed JSON');
|
|
481
|
+
}
|
|
482
|
+
const guard = obj.guard;
|
|
483
|
+
if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
|
|
484
|
+
throw new Error(`adjudicator returned undeclared guard "${guard}" for ${input.sourceItem}`);
|
|
485
|
+
}
|
|
486
|
+
// Per slc/link.md §Captain adjudication the judge answers
|
|
487
|
+
// `{ guard, …payloadFields }` and the runtime validates the required
|
|
488
|
+
// fields. Every payload field is carried through — dropping the
|
|
489
|
+
// non-required ones would blind FSM fallbacks such as
|
|
490
|
+
// `outputOf(event).reviewScope ?? context.reviewScope` on guards whose
|
|
491
|
+
// description says "may include".
|
|
492
|
+
const output = { ...obj, guard };
|
|
493
|
+
for (const field of requiredFieldsFor(input.result[guard])) {
|
|
494
|
+
const value = output[field];
|
|
495
|
+
if (value === undefined ||
|
|
496
|
+
value === null ||
|
|
497
|
+
(typeof value === 'string' && value.trim() === '')) {
|
|
498
|
+
throw new Error(`adjudicator response for guard "${guard}" missing required field "${field}"`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if ('reviewScope' in output &&
|
|
502
|
+
typeof output.reviewScope === 'string' &&
|
|
503
|
+
!REVIEW_SCOPES.has(output.reviewScope)) {
|
|
504
|
+
throw new Error(`adjudicator returned invalid reviewScope "${output.reviewScope}"`);
|
|
505
|
+
}
|
|
506
|
+
return output;
|
|
507
|
+
}
|
|
508
|
+
function combineSignals(a, b) {
|
|
509
|
+
return combineAbortSignals(a, b);
|
|
510
|
+
}
|
|
511
|
+
function normalizeErrorCompact(err) {
|
|
512
|
+
if (err === undefined || err === null)
|
|
513
|
+
return undefined;
|
|
514
|
+
const normalized = normalizeError(err);
|
|
515
|
+
return { name: normalized.name, message: normalized.message };
|
|
516
|
+
}
|
|
517
|
+
function normalizeErrorFull(err) {
|
|
518
|
+
return err === undefined || err === null ? undefined : normalizeError(err);
|
|
519
|
+
}
|
|
520
|
+
function isAbortFailure(error, signal) {
|
|
521
|
+
if (!signal.aborted)
|
|
522
|
+
return false;
|
|
523
|
+
if (Object.is(error, signal.reason))
|
|
524
|
+
return true;
|
|
525
|
+
return normalizeErrorCompact(error)?.name === 'AbortError';
|
|
526
|
+
}
|
|
527
|
+
function pendingQuestionsFromContext(context) {
|
|
528
|
+
const pending = context.pendingBossQuestions;
|
|
529
|
+
if (pending === undefined ||
|
|
530
|
+
pending === null ||
|
|
531
|
+
typeof pending !== 'object') {
|
|
532
|
+
return [];
|
|
533
|
+
}
|
|
534
|
+
const questions = [];
|
|
535
|
+
for (const [key, value] of Object.entries(pending)) {
|
|
536
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const obj = value;
|
|
540
|
+
if (typeof obj.questionId === 'string' &&
|
|
541
|
+
obj.questionId === key &&
|
|
542
|
+
typeof obj.resumeStateId === 'string' &&
|
|
543
|
+
typeof obj.sourceItem === 'string' &&
|
|
544
|
+
typeof obj.player === 'string' &&
|
|
545
|
+
typeof obj.question === 'string') {
|
|
546
|
+
questions.push(obj);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return questions.sort((left, right) => left.questionId.localeCompare(right.questionId));
|
|
550
|
+
}
|
|
551
|
+
const WAIT_STATE_RESUME_IDS = {
|
|
552
|
+
waitHostInitialReply: 'askHostInitial',
|
|
553
|
+
waitParticipantInitialReply: 'askParticipantInitial',
|
|
554
|
+
waitHostReconciliationReply: 'hostInitialRound',
|
|
555
|
+
waitParticipantReconciliationReply: 'participantInitialRound',
|
|
556
|
+
};
|
|
557
|
+
const WAIT_STATE_IDS = new Set([
|
|
558
|
+
...Object.keys(WAIT_STATE_RESUME_IDS),
|
|
559
|
+
'awaitBossReply',
|
|
560
|
+
]);
|
|
561
|
+
const STATUS_STATE_IDS = new Set([
|
|
562
|
+
...CAPTAIN_STATE_IDS,
|
|
563
|
+
...WAIT_STATE_IDS,
|
|
564
|
+
'failed',
|
|
565
|
+
]);
|
|
566
|
+
function questionForWaitState(stateId, pendingQuestions) {
|
|
567
|
+
const resumeStateId = WAIT_STATE_RESUME_IDS[stateId];
|
|
568
|
+
if (resumeStateId !== undefined) {
|
|
569
|
+
return pendingQuestions.find((pending) => pending.resumeStateId === resumeStateId);
|
|
570
|
+
}
|
|
571
|
+
return stateId === 'awaitBossReply' && pendingQuestions.length === 1
|
|
572
|
+
? pendingQuestions[0]
|
|
573
|
+
: undefined;
|
|
574
|
+
}
|
|
575
|
+
function normalizedEventType(event) {
|
|
576
|
+
if (event !== null &&
|
|
577
|
+
typeof event === 'object' &&
|
|
578
|
+
!Array.isArray(event) &&
|
|
579
|
+
'type' in event) {
|
|
580
|
+
const type = event.type;
|
|
581
|
+
return typeof type === 'string' ? type : String(type);
|
|
582
|
+
}
|
|
583
|
+
if (event === null ||
|
|
584
|
+
typeof event === 'string' ||
|
|
585
|
+
typeof event === 'boolean' ||
|
|
586
|
+
(typeof event === 'number' && Number.isFinite(event))) {
|
|
587
|
+
return event;
|
|
588
|
+
}
|
|
589
|
+
return String(event);
|
|
590
|
+
}
|
|
591
|
+
function telemetryPayload(previousState, state, event, context) {
|
|
592
|
+
const eventError = event !== null &&
|
|
593
|
+
typeof event === 'object' &&
|
|
594
|
+
!Array.isArray(event) &&
|
|
595
|
+
'error' in event
|
|
596
|
+
? normalizeErrorFull(event.error)
|
|
597
|
+
: undefined;
|
|
598
|
+
const pendingBossQuestions = pendingQuestionsFromContext(context);
|
|
599
|
+
const payload = {
|
|
600
|
+
from: previousState?.value ?? null,
|
|
601
|
+
to: state.value,
|
|
602
|
+
event: normalizedEventType(event),
|
|
603
|
+
previousState: previousState ?? null,
|
|
604
|
+
state,
|
|
605
|
+
...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
|
|
606
|
+
...(eventError !== undefined ? { error: eventError } : {}),
|
|
607
|
+
...(state.activeStateIds.includes('failed')
|
|
608
|
+
? { lastError: normalizeErrorFull(context.lastError) ?? null }
|
|
609
|
+
: {}),
|
|
610
|
+
};
|
|
611
|
+
assertJsonSafe(payload);
|
|
612
|
+
return payload;
|
|
613
|
+
}
|
|
614
|
+
export const createPlaybookRuntime = (options) => {
|
|
615
|
+
const boundOptions = snapshotDiscussRuntimeOptions(options);
|
|
616
|
+
const binding = {
|
|
617
|
+
...DEFAULT_PLAYER_BINDING,
|
|
618
|
+
...(boundOptions.playerBinding ?? {}),
|
|
619
|
+
};
|
|
620
|
+
const fsmInput = {
|
|
621
|
+
host: boundOptions.host,
|
|
622
|
+
participant: boundOptions.participant,
|
|
623
|
+
committer: boundOptions.committer,
|
|
624
|
+
};
|
|
625
|
+
let ports;
|
|
626
|
+
let sessionIdentity;
|
|
627
|
+
let actor;
|
|
628
|
+
let currentSignal;
|
|
629
|
+
let currentTurnId;
|
|
630
|
+
let previousState;
|
|
631
|
+
let suppressInspectionEmissions = false;
|
|
632
|
+
let emissionFailures = [];
|
|
633
|
+
let traceSequence = 0;
|
|
634
|
+
let turnSequence = 0;
|
|
635
|
+
let judgeCallSequence = 0;
|
|
636
|
+
let playerCallSequence = 0;
|
|
637
|
+
let lifecycleStarted = false;
|
|
638
|
+
let initInFlight;
|
|
639
|
+
let disposed = false;
|
|
640
|
+
let disposalPromise;
|
|
641
|
+
let controlPlaneError;
|
|
642
|
+
const playerResumeTokens = new Map();
|
|
643
|
+
const inFlightPlayerIds = new Set();
|
|
644
|
+
const activeBoundaryCalls = new Set();
|
|
645
|
+
const activeEmissionCalls = new Set();
|
|
646
|
+
const emissionQueue = new PQueue({ concurrency: 1 });
|
|
647
|
+
const judgeQueue = new PQueue({ concurrency: 1 });
|
|
648
|
+
const collectFailure = (failures, error) => {
|
|
649
|
+
if (error instanceof AggregateError) {
|
|
650
|
+
for (const nested of error.errors)
|
|
651
|
+
collectFailure(failures, nested);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (!failures.some((failure) => Object.is(failure, error))) {
|
|
655
|
+
failures.push(error);
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
const latchControlPlaneError = (error, signal) => {
|
|
659
|
+
if (!isAbortFailure(error, signal))
|
|
660
|
+
controlPlaneError ??= error;
|
|
661
|
+
};
|
|
662
|
+
const enqueue = (fn) => {
|
|
663
|
+
const queued = emissionQueue.add(fn);
|
|
664
|
+
activeEmissionCalls.add(queued);
|
|
665
|
+
void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
|
|
666
|
+
activeEmissionCalls.delete(queued);
|
|
667
|
+
collectFailure(emissionFailures, error);
|
|
668
|
+
});
|
|
669
|
+
return queued;
|
|
670
|
+
};
|
|
671
|
+
const flush = async () => {
|
|
672
|
+
while (true) {
|
|
673
|
+
const active = [...activeEmissionCalls];
|
|
674
|
+
if (active.length > 0)
|
|
675
|
+
await Promise.allSettled(active);
|
|
676
|
+
await emissionQueue.onIdle();
|
|
677
|
+
if (activeEmissionCalls.size === 0 &&
|
|
678
|
+
emissionQueue.size === 0 &&
|
|
679
|
+
emissionQueue.pending === 0) {
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (emissionFailures.length === 0)
|
|
684
|
+
return;
|
|
685
|
+
const failures = emissionFailures;
|
|
686
|
+
emissionFailures = [];
|
|
687
|
+
if (failures.length === 1)
|
|
688
|
+
throw failures[0];
|
|
689
|
+
throw new AggregateError(failures, 'discuss runtime emissions failed');
|
|
690
|
+
};
|
|
691
|
+
const drainBoundaryCallsAndEmissions = async () => {
|
|
692
|
+
while (true) {
|
|
693
|
+
if (activeBoundaryCalls.size > 0) {
|
|
694
|
+
await Promise.allSettled([...activeBoundaryCalls]);
|
|
695
|
+
}
|
|
696
|
+
await flush();
|
|
697
|
+
if (activeBoundaryCalls.size === 0)
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
const trackBoundaryCall = (call) => {
|
|
702
|
+
activeBoundaryCalls.add(call);
|
|
703
|
+
void call.then(() => activeBoundaryCalls.delete(call), () => activeBoundaryCalls.delete(call));
|
|
704
|
+
return call;
|
|
705
|
+
};
|
|
706
|
+
const requirePorts = () => {
|
|
707
|
+
if (!ports) {
|
|
708
|
+
throw new Error('discuss runtime: init(session) must be called first');
|
|
709
|
+
}
|
|
710
|
+
return ports;
|
|
711
|
+
};
|
|
712
|
+
const requireSessionIdentity = () => {
|
|
713
|
+
if (!sessionIdentity) {
|
|
714
|
+
throw new Error('discuss runtime: init(session) must be called first');
|
|
715
|
+
}
|
|
716
|
+
return sessionIdentity;
|
|
717
|
+
};
|
|
718
|
+
const currentState = () => {
|
|
719
|
+
const live = actor;
|
|
720
|
+
if (!live) {
|
|
721
|
+
throw new Error('discuss runtime: actor is not initialized');
|
|
722
|
+
}
|
|
723
|
+
return normalizePlaybookSnapshot(live.getSnapshot());
|
|
724
|
+
};
|
|
725
|
+
const stateIdentity = (state) => {
|
|
726
|
+
return state.stateId === undefined ? {} : { stateId: state.stateId };
|
|
727
|
+
};
|
|
728
|
+
const enqueueTracedEmission = (type, payload, meta = {}, describedEmission) => {
|
|
729
|
+
const runtimePorts = requirePorts();
|
|
730
|
+
const identity = requireSessionIdentity();
|
|
731
|
+
const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
732
|
+
const trace = Object.freeze({
|
|
733
|
+
schemaVersion: 2,
|
|
734
|
+
sessionId: identity.sessionId,
|
|
735
|
+
playbookId: identity.playbookId,
|
|
736
|
+
rootSessionId: identity.rootSessionId,
|
|
737
|
+
...(identity.parentSessionId !== undefined
|
|
738
|
+
? { parentSessionId: identity.parentSessionId }
|
|
739
|
+
: {}),
|
|
740
|
+
...(identity.parentCallId !== undefined
|
|
741
|
+
? { parentCallId: identity.parentCallId }
|
|
742
|
+
: {}),
|
|
743
|
+
depth: identity.depth,
|
|
744
|
+
sequence: ++traceSequence,
|
|
745
|
+
timestamp: Date.now(),
|
|
746
|
+
type,
|
|
747
|
+
...(meta.turnId !== undefined ? { turnId: meta.turnId } : {}),
|
|
748
|
+
...(meta.callId !== undefined ? { callId: meta.callId } : {}),
|
|
749
|
+
payload: jsonPayload,
|
|
750
|
+
});
|
|
751
|
+
return enqueue(async () => {
|
|
752
|
+
await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
|
|
753
|
+
await describedEmission?.(runtimePorts);
|
|
754
|
+
});
|
|
755
|
+
};
|
|
756
|
+
const emitTrace = (type, payload, meta = {}) => enqueueTracedEmission(type, payload, meta);
|
|
757
|
+
const emitBoundaryStatus = async (message, state) => {
|
|
758
|
+
const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
|
|
759
|
+
await enqueueTracedEmission('status.emitted', {
|
|
760
|
+
...(bossRelevantStateIds.length === 1
|
|
761
|
+
? { stateId: bossRelevantStateIds[0] }
|
|
762
|
+
: {}),
|
|
763
|
+
message,
|
|
764
|
+
state,
|
|
765
|
+
}, { turnId: currentTurnId }, (runtimePorts) => runtimePorts.emitStatus(message));
|
|
766
|
+
};
|
|
767
|
+
const emitCallStarted = async (startedType, finishedType, identity, meta, signal) => {
|
|
768
|
+
try {
|
|
769
|
+
await emitTrace(startedType, identity, meta);
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
latchControlPlaneError(error, signal);
|
|
773
|
+
try {
|
|
774
|
+
await emitTrace(finishedType, {
|
|
775
|
+
...identity,
|
|
776
|
+
status: 'error',
|
|
777
|
+
error: normalizeErrorFull(error) ?? {
|
|
778
|
+
name: 'Error',
|
|
779
|
+
message: String(error),
|
|
780
|
+
},
|
|
781
|
+
}, meta);
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
// Preserve the start failure after one best-effort finish attempt.
|
|
785
|
+
}
|
|
786
|
+
throw error;
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
const runJudgeCall = async (prompt, signal, purpose, callStateId) => {
|
|
790
|
+
const identity = {
|
|
791
|
+
purpose,
|
|
792
|
+
...(callStateId !== undefined ? { stateId: callStateId } : {}),
|
|
793
|
+
};
|
|
794
|
+
const queued = await judgeQueue.add(async () => {
|
|
795
|
+
// Keep the complete queue task pending until an active host promise
|
|
796
|
+
// settles. PQueue's signal option may reject add() while that task is
|
|
797
|
+
// still running, which would let the turn drain race a late finish.
|
|
798
|
+
signal.throwIfAborted();
|
|
799
|
+
const callId = `judge-${++judgeCallSequence}`;
|
|
800
|
+
await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
|
|
801
|
+
let finalText;
|
|
802
|
+
try {
|
|
803
|
+
signal.throwIfAborted();
|
|
804
|
+
const reply = await requirePorts().callJudge(prompt, signal);
|
|
805
|
+
if (typeof reply !== 'string') {
|
|
806
|
+
throw new TypeError('judge result must be a string');
|
|
807
|
+
}
|
|
808
|
+
finalText = reply;
|
|
809
|
+
// A cancelled parallel actor can outlive a judge port that ignores
|
|
810
|
+
// its signal. Do not report that late resolution as success.
|
|
811
|
+
signal.throwIfAborted();
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
latchControlPlaneError(error, signal);
|
|
815
|
+
await emitTrace('judge.call.finished', {
|
|
816
|
+
...identity,
|
|
817
|
+
status: signal.aborted ? 'aborted' : 'error',
|
|
818
|
+
error: normalizeErrorFull(error) ?? {
|
|
819
|
+
name: 'Error',
|
|
820
|
+
message: String(error),
|
|
821
|
+
},
|
|
822
|
+
}, { turnId: currentTurnId, callId });
|
|
823
|
+
throw error;
|
|
824
|
+
}
|
|
825
|
+
await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply: finalText }, { turnId: currentTurnId, callId });
|
|
826
|
+
return finalText;
|
|
827
|
+
});
|
|
828
|
+
if (queued === undefined) {
|
|
829
|
+
throw new Error('judge call completed without a reply');
|
|
830
|
+
}
|
|
831
|
+
return queued;
|
|
832
|
+
};
|
|
833
|
+
const callJudge = (prompt, signal, purpose, callStateId) => trackBoundaryCall(runJudgeCall(prompt, signal, purpose, callStateId));
|
|
834
|
+
const runPlayerCall = async (input, signal) => {
|
|
835
|
+
const playerId = resolvePlayerId(input, binding);
|
|
836
|
+
if (inFlightPlayerIds.has(playerId)) {
|
|
837
|
+
throw new Error(`resolved player "${playerId}" already has an in-flight call`);
|
|
838
|
+
}
|
|
839
|
+
inFlightPlayerIds.add(playerId);
|
|
840
|
+
const prompt = composePlayerPrompt(input);
|
|
841
|
+
const resume = playerResumeTokens.get(playerId) ?? false;
|
|
842
|
+
const callId = `player-${++playerCallSequence}`;
|
|
843
|
+
const identity = {
|
|
844
|
+
purpose: 'captain',
|
|
845
|
+
stateId: input.stateId,
|
|
846
|
+
sourceItem: input.sourceItem,
|
|
847
|
+
playerId,
|
|
848
|
+
resume,
|
|
849
|
+
};
|
|
850
|
+
const emitFailure = (error) => emitTrace('player.call.finished', {
|
|
851
|
+
...identity,
|
|
852
|
+
status: signal.aborted ? 'aborted' : 'error',
|
|
853
|
+
error: normalizeErrorFull(error) ?? {
|
|
854
|
+
name: 'Error',
|
|
855
|
+
message: String(error),
|
|
856
|
+
},
|
|
857
|
+
}, { turnId: currentTurnId, callId });
|
|
858
|
+
try {
|
|
859
|
+
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
|
|
860
|
+
let rawResult;
|
|
861
|
+
try {
|
|
862
|
+
signal.throwIfAborted();
|
|
863
|
+
const boundary = Promise.resolve(requirePorts().callPlayer(playerId, prompt, signal, { resume }));
|
|
864
|
+
rawResult = await boundary;
|
|
865
|
+
// An XState sibling cancellation does not cancel an arbitrary host
|
|
866
|
+
// promise. Re-check before a late resolution can mutate continuity or
|
|
867
|
+
// masquerade as a successful boundary finish.
|
|
868
|
+
signal.throwIfAborted();
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
// A rejected call produced no authoritative result, so the previous
|
|
872
|
+
// token remains untouched. This also covers a host promise that
|
|
873
|
+
// resolves after its invocation signal was cancelled.
|
|
874
|
+
latchControlPlaneError(error, signal);
|
|
875
|
+
try {
|
|
876
|
+
await emitFailure(error);
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
// The original non-abort port rejection remains authoritative.
|
|
880
|
+
}
|
|
881
|
+
throw error;
|
|
882
|
+
}
|
|
883
|
+
let result;
|
|
884
|
+
try {
|
|
885
|
+
result = validatePlayerResult(rawResult);
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
latchControlPlaneError(error, signal);
|
|
889
|
+
try {
|
|
890
|
+
await emitFailure(error);
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
// The malformed host result remains authoritative.
|
|
894
|
+
}
|
|
895
|
+
throw error;
|
|
896
|
+
}
|
|
897
|
+
// The resolved result is authoritative even on aborted/error status.
|
|
898
|
+
// Update continuation state before interpreting that status.
|
|
899
|
+
if (typeof result.resumeToken === 'string' &&
|
|
900
|
+
result.resumeToken.trim().length > 0) {
|
|
901
|
+
playerResumeTokens.set(playerId, result.resumeToken);
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
playerResumeTokens.delete(playerId);
|
|
905
|
+
}
|
|
906
|
+
// Keep this finish outside the boundary catch. A trace sink can record
|
|
907
|
+
// the event and then reject; retrying from that catch would duplicate the
|
|
908
|
+
// same call id and falsely recast an emission failure as a player error.
|
|
909
|
+
await emitTrace('player.call.finished', {
|
|
910
|
+
...identity,
|
|
911
|
+
status: result.status,
|
|
912
|
+
...(result.resumeToken !== undefined
|
|
913
|
+
? { resumeToken: result.resumeToken }
|
|
914
|
+
: {}),
|
|
915
|
+
...(result.finalText !== undefined
|
|
916
|
+
? { finalText: result.finalText }
|
|
917
|
+
: {}),
|
|
918
|
+
...(result.error !== undefined
|
|
919
|
+
? { error: normalizeErrorFull(result.error) }
|
|
920
|
+
: {}),
|
|
921
|
+
}, { turnId: currentTurnId, callId });
|
|
922
|
+
return { playerId, result };
|
|
923
|
+
}
|
|
924
|
+
finally {
|
|
925
|
+
inFlightPlayerIds.delete(playerId);
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
const callPlayer = (input, signal) => {
|
|
929
|
+
return trackBoundaryCall(runPlayerCall(input, signal));
|
|
930
|
+
};
|
|
931
|
+
const player = fromPromise(async ({ input, signal }) => {
|
|
932
|
+
const combined = combineSignals(signal, currentSignal);
|
|
933
|
+
// XState starts invoked actors while publishing the entering snapshot.
|
|
934
|
+
// Yield through the runtime emission queue before crossing the player
|
|
935
|
+
// boundary so state trace/status always precede its call-start trace.
|
|
936
|
+
try {
|
|
937
|
+
await flush();
|
|
938
|
+
}
|
|
939
|
+
catch (error) {
|
|
940
|
+
latchControlPlaneError(error, combined);
|
|
941
|
+
throw error;
|
|
942
|
+
}
|
|
943
|
+
combined.throwIfAborted();
|
|
944
|
+
const { playerId, result } = await callPlayer(input, combined);
|
|
945
|
+
if (result.status !== 'ok') {
|
|
946
|
+
throw new Error(`player "${playerId}" returned status "${result.status}"${result.error ? `: ${result.error}` : ''}`);
|
|
947
|
+
}
|
|
948
|
+
if (result.finalText === undefined) {
|
|
949
|
+
throw new Error(`player "${playerId}" returned status "ok" with no finalText`);
|
|
950
|
+
}
|
|
951
|
+
combined.throwIfAborted();
|
|
952
|
+
try {
|
|
953
|
+
const prompt = buildAdjudicatorPrompt(input, result.finalText);
|
|
954
|
+
return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input);
|
|
955
|
+
}
|
|
956
|
+
catch (error) {
|
|
957
|
+
latchControlPlaneError(error, combined);
|
|
958
|
+
throw error;
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
const providedMachine = discussMachine.provide({ actors: { player } });
|
|
962
|
+
const inspect = (event) => {
|
|
963
|
+
if (event.type !== '@xstate.snapshot')
|
|
964
|
+
return;
|
|
965
|
+
if (actor === undefined || event.actorRef !== actor)
|
|
966
|
+
return;
|
|
967
|
+
if (suppressInspectionEmissions)
|
|
968
|
+
return;
|
|
969
|
+
const snapshot = event.snapshot;
|
|
970
|
+
const state = normalizePlaybookSnapshot(snapshot);
|
|
971
|
+
const prior = previousState;
|
|
972
|
+
previousState = state;
|
|
973
|
+
const runtimePorts = ports;
|
|
974
|
+
if (!runtimePorts)
|
|
975
|
+
return;
|
|
976
|
+
const context = snapshot.context;
|
|
977
|
+
const fsmPayload = telemetryPayload(prior, state, event.event, context);
|
|
978
|
+
const describedFsmPayload = snapshotJsonValue(fsmPayload, 'described FSM telemetry');
|
|
979
|
+
void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
|
|
980
|
+
topic: TELEMETRY_TOPIC,
|
|
981
|
+
payload: describedFsmPayload,
|
|
982
|
+
})).catch(() => undefined);
|
|
983
|
+
const priorIds = new Set(prior?.activeStateIds ?? []);
|
|
984
|
+
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
985
|
+
const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
|
|
986
|
+
const scheduleStatus = (message, stateId, data) => {
|
|
987
|
+
const tracePayload = {
|
|
988
|
+
...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
|
|
989
|
+
message,
|
|
990
|
+
state,
|
|
991
|
+
...(data !== undefined ? { data } : {}),
|
|
992
|
+
};
|
|
993
|
+
assertJsonSafe(tracePayload);
|
|
994
|
+
void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data)).catch(() => undefined);
|
|
995
|
+
};
|
|
996
|
+
for (const activeStateId of state.activeStateIds) {
|
|
997
|
+
if (priorIds.has(activeStateId) || !STATUS_STATE_IDS.has(activeStateId)) {
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (WAIT_STATE_IDS.has(activeStateId)) {
|
|
1001
|
+
const pending = questionForWaitState(activeStateId, pendingQuestions);
|
|
1002
|
+
if (pending) {
|
|
1003
|
+
scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
|
|
1004
|
+
scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`, activeStateId);
|
|
1005
|
+
}
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
const lastError = activeStateId === 'failed'
|
|
1009
|
+
? normalizeErrorCompact(context.lastError)
|
|
1010
|
+
: undefined;
|
|
1011
|
+
scheduleStatus(STATE_DESCRIPTIONS[activeStateId] ?? activeStateId, activeStateId, lastError === undefined ? undefined : { lastError });
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
const createRuntimeActor = (machineSnapshot) => {
|
|
1015
|
+
previousState = undefined;
|
|
1016
|
+
// DR-014 §1: a restore rehydrates the persisted machine snapshot;
|
|
1017
|
+
// XState derives context/value from it and ignores `input` then.
|
|
1018
|
+
actor = createActor(providedMachine, {
|
|
1019
|
+
input: fsmInput,
|
|
1020
|
+
...(machineSnapshot === undefined
|
|
1021
|
+
? {}
|
|
1022
|
+
: {
|
|
1023
|
+
snapshot: machineSnapshot,
|
|
1024
|
+
}),
|
|
1025
|
+
inspect,
|
|
1026
|
+
});
|
|
1027
|
+
};
|
|
1028
|
+
const startActor = () => {
|
|
1029
|
+
createRuntimeActor();
|
|
1030
|
+
actor?.start();
|
|
1031
|
+
};
|
|
1032
|
+
const driveToQuiescence = async () => {
|
|
1033
|
+
const live = actor;
|
|
1034
|
+
if (!live)
|
|
1035
|
+
throw new Error('discuss runtime: actor is not initialized');
|
|
1036
|
+
await waitForPlaybookQuiescence(live);
|
|
1037
|
+
};
|
|
1038
|
+
const classify = async (text, signal) => {
|
|
1039
|
+
const live = actor;
|
|
1040
|
+
if (!live)
|
|
1041
|
+
throw new Error('discuss runtime: actor is not initialized');
|
|
1042
|
+
const snapshot = live.getSnapshot();
|
|
1043
|
+
const context = snapshot.context;
|
|
1044
|
+
const state = normalizePlaybookSnapshot(snapshot);
|
|
1045
|
+
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
1046
|
+
const prompt = buildClassifierPrompt(text, {
|
|
1047
|
+
state,
|
|
1048
|
+
pendingQuestions,
|
|
1049
|
+
});
|
|
1050
|
+
const raw = await callJudge(prompt, signal, 'boss-input-classification', state.stateId);
|
|
1051
|
+
return parseClassification(raw, pendingQuestions.map(({ questionId }) => questionId));
|
|
1052
|
+
};
|
|
1053
|
+
const resultForSnapshot = (signal) => {
|
|
1054
|
+
const live = actor;
|
|
1055
|
+
if (!live)
|
|
1056
|
+
throw new Error('discuss runtime: actor is not initialized');
|
|
1057
|
+
const snapshot = live.getSnapshot();
|
|
1058
|
+
const state = normalizePlaybookSnapshot(snapshot);
|
|
1059
|
+
const context = snapshot.context;
|
|
1060
|
+
if (signal?.aborted) {
|
|
1061
|
+
return {
|
|
1062
|
+
outcome: 'aborted',
|
|
1063
|
+
state,
|
|
1064
|
+
...(signal.reason === undefined
|
|
1065
|
+
? {}
|
|
1066
|
+
: {
|
|
1067
|
+
error: normalizeErrorFull(signal.reason) ?? {
|
|
1068
|
+
name: 'AbortError',
|
|
1069
|
+
message: String(signal.reason),
|
|
1070
|
+
},
|
|
1071
|
+
}),
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
if (snapshot.status === 'done') {
|
|
1075
|
+
const output = snapshot.output;
|
|
1076
|
+
if (output !== undefined)
|
|
1077
|
+
assertJsonSafe(output, 'terminal output');
|
|
1078
|
+
return {
|
|
1079
|
+
outcome: 'terminal',
|
|
1080
|
+
state,
|
|
1081
|
+
...(output === undefined ? {} : { output }),
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
if (snapshot.status === 'error') {
|
|
1085
|
+
throw (snapshot.error ??
|
|
1086
|
+
new Error('discuss runtime actor entered error status'));
|
|
1087
|
+
}
|
|
1088
|
+
if (state.activeStateIds.includes('failed')) {
|
|
1089
|
+
const error = normalizeErrorFull(context.lastError);
|
|
1090
|
+
return {
|
|
1091
|
+
outcome: 'failed',
|
|
1092
|
+
state,
|
|
1093
|
+
...(error === undefined ? {} : { error }),
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
return { outcome: 'quiescent', state };
|
|
1097
|
+
};
|
|
1098
|
+
// Shared failed-start cleanup for init and restore: stop the actor,
|
|
1099
|
+
// drain queued work, optionally emit one best-effort session.disposed
|
|
1100
|
+
// boundary, and unbind every closure field so dispose stays callable.
|
|
1101
|
+
// The caller rethrows its original failure. A restore failure skips
|
|
1102
|
+
// the disposal trace — the parked session was never re-bound in this
|
|
1103
|
+
// process, so its persisted snapshot stays authoritative (DR-014 §2).
|
|
1104
|
+
const cleanupFailedStart = async (options) => {
|
|
1105
|
+
let finalState;
|
|
1106
|
+
if (options.emitDisposal && actor) {
|
|
1107
|
+
try {
|
|
1108
|
+
finalState = currentState();
|
|
1109
|
+
}
|
|
1110
|
+
catch {
|
|
1111
|
+
// A state that cannot even normalize has no disposal descriptor.
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
suppressInspectionEmissions = true;
|
|
1115
|
+
try {
|
|
1116
|
+
actor?.stop();
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
// Preserve the original startup failure.
|
|
1120
|
+
}
|
|
1121
|
+
try {
|
|
1122
|
+
await judgeQueue.onIdle();
|
|
1123
|
+
await drainBoundaryCallsAndEmissions();
|
|
1124
|
+
}
|
|
1125
|
+
catch {
|
|
1126
|
+
// Preserve the original startup failure.
|
|
1127
|
+
}
|
|
1128
|
+
if (options.emitDisposal) {
|
|
1129
|
+
try {
|
|
1130
|
+
await emitTrace('session.disposed', {
|
|
1131
|
+
...(finalState === undefined
|
|
1132
|
+
? {}
|
|
1133
|
+
: { state: finalState, ...stateIdentity(finalState) }),
|
|
1134
|
+
});
|
|
1135
|
+
await flush();
|
|
1136
|
+
}
|
|
1137
|
+
catch {
|
|
1138
|
+
// The session-start error remains authoritative.
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
playerResumeTokens.clear();
|
|
1142
|
+
inFlightPlayerIds.clear();
|
|
1143
|
+
activeBoundaryCalls.clear();
|
|
1144
|
+
activeEmissionCalls.clear();
|
|
1145
|
+
emissionQueue.clear();
|
|
1146
|
+
judgeQueue.clear();
|
|
1147
|
+
actor = undefined;
|
|
1148
|
+
currentSignal = undefined;
|
|
1149
|
+
currentTurnId = undefined;
|
|
1150
|
+
ports = undefined;
|
|
1151
|
+
sessionIdentity = undefined;
|
|
1152
|
+
previousState = undefined;
|
|
1153
|
+
suppressInspectionEmissions = false;
|
|
1154
|
+
controlPlaneError = undefined;
|
|
1155
|
+
emissionFailures = [];
|
|
1156
|
+
traceSequence = 0;
|
|
1157
|
+
turnSequence = 0;
|
|
1158
|
+
judgeCallSequence = 0;
|
|
1159
|
+
playerCallSequence = 0;
|
|
1160
|
+
lifecycleStarted = false;
|
|
1161
|
+
};
|
|
1162
|
+
return {
|
|
1163
|
+
async init(session) {
|
|
1164
|
+
if (lifecycleStarted ||
|
|
1165
|
+
initInFlight !== undefined ||
|
|
1166
|
+
disposed ||
|
|
1167
|
+
disposalPromise !== undefined) {
|
|
1168
|
+
throw new Error('discuss runtime: init(session) may only be called once');
|
|
1169
|
+
}
|
|
1170
|
+
const identity = snapshotPlaybookSession(session);
|
|
1171
|
+
let finishInitialization;
|
|
1172
|
+
const initialization = new Promise((resolve) => {
|
|
1173
|
+
finishInitialization = resolve;
|
|
1174
|
+
});
|
|
1175
|
+
initInFlight = initialization;
|
|
1176
|
+
lifecycleStarted = true;
|
|
1177
|
+
ports = identity.ports;
|
|
1178
|
+
sessionIdentity = identity;
|
|
1179
|
+
try {
|
|
1180
|
+
suppressInspectionEmissions = false;
|
|
1181
|
+
createRuntimeActor();
|
|
1182
|
+
const state = currentState();
|
|
1183
|
+
await emitTrace('session.started', {
|
|
1184
|
+
state,
|
|
1185
|
+
...stateIdentity(state),
|
|
1186
|
+
});
|
|
1187
|
+
actor?.start();
|
|
1188
|
+
await flush();
|
|
1189
|
+
}
|
|
1190
|
+
catch (error) {
|
|
1191
|
+
await cleanupFailedStart({ emitDisposal: true });
|
|
1192
|
+
throw error;
|
|
1193
|
+
}
|
|
1194
|
+
finally {
|
|
1195
|
+
finishInitialization();
|
|
1196
|
+
if (initInFlight === initialization)
|
|
1197
|
+
initInFlight = undefined;
|
|
1198
|
+
}
|
|
1199
|
+
},
|
|
1200
|
+
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
|
|
1201
|
+
// Defined only at a safe capture point — initialized, not disposing
|
|
1202
|
+
// or disposed, no active public boundary, and the actor quiescent
|
|
1203
|
+
// with status `active`. DISCUSS never opens nested playbook calls,
|
|
1204
|
+
// so no pending-call guard applies.
|
|
1205
|
+
exportSnapshot() {
|
|
1206
|
+
if (!actor ||
|
|
1207
|
+
!sessionIdentity ||
|
|
1208
|
+
disposed ||
|
|
1209
|
+
disposalPromise !== undefined) {
|
|
1210
|
+
return undefined;
|
|
1211
|
+
}
|
|
1212
|
+
if (currentTurnId !== undefined || currentSignal !== undefined) {
|
|
1213
|
+
return undefined;
|
|
1214
|
+
}
|
|
1215
|
+
const state = currentState();
|
|
1216
|
+
if (state.status !== 'active' || !state.quiescent)
|
|
1217
|
+
return undefined;
|
|
1218
|
+
const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
|
|
1219
|
+
const context = actor.getSnapshot().context;
|
|
1220
|
+
return {
|
|
1221
|
+
schemaVersion: 1,
|
|
1222
|
+
playbookId: sessionIdentity.playbookId,
|
|
1223
|
+
machine,
|
|
1224
|
+
playerResumeTokens: Object.fromEntries(playerResumeTokens),
|
|
1225
|
+
sequences: {
|
|
1226
|
+
trace: traceSequence,
|
|
1227
|
+
turn: turnSequence,
|
|
1228
|
+
judgeCall: judgeCallSequence,
|
|
1229
|
+
playerCall: playerCallSequence,
|
|
1230
|
+
playbookCall: 0,
|
|
1231
|
+
},
|
|
1232
|
+
state,
|
|
1233
|
+
pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
|
|
1234
|
+
questionId: pending.questionId,
|
|
1235
|
+
player: pending.player,
|
|
1236
|
+
question: pending.question,
|
|
1237
|
+
sourceItem: pending.sourceItem,
|
|
1238
|
+
})),
|
|
1239
|
+
};
|
|
1240
|
+
},
|
|
1241
|
+
// DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
|
|
1242
|
+
// exported snapshot under the same immutable session identity.
|
|
1243
|
+
// Emits no `session.started`, transition trace, or human status —
|
|
1244
|
+
// the session already started; the next public boundary continues
|
|
1245
|
+
// the contiguous trace sequence.
|
|
1246
|
+
async restore(session, snapshot) {
|
|
1247
|
+
if (lifecycleStarted ||
|
|
1248
|
+
initInFlight !== undefined ||
|
|
1249
|
+
disposed ||
|
|
1250
|
+
disposalPromise !== undefined) {
|
|
1251
|
+
throw new Error('discuss runtime: restore(session, snapshot) may only be called once');
|
|
1252
|
+
}
|
|
1253
|
+
const identity = snapshotPlaybookSession(session);
|
|
1254
|
+
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId);
|
|
1255
|
+
let finishInitialization;
|
|
1256
|
+
const initialization = new Promise((resolve) => {
|
|
1257
|
+
finishInitialization = resolve;
|
|
1258
|
+
});
|
|
1259
|
+
initInFlight = initialization;
|
|
1260
|
+
lifecycleStarted = true;
|
|
1261
|
+
ports = identity.ports;
|
|
1262
|
+
sessionIdentity = identity;
|
|
1263
|
+
try {
|
|
1264
|
+
traceSequence = boundSnapshot.sequences.trace;
|
|
1265
|
+
turnSequence = boundSnapshot.sequences.turn;
|
|
1266
|
+
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
1267
|
+
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1268
|
+
playerResumeTokens.clear();
|
|
1269
|
+
for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
|
|
1270
|
+
playerResumeTokens.set(playerId, token);
|
|
1271
|
+
}
|
|
1272
|
+
suppressInspectionEmissions = true;
|
|
1273
|
+
createRuntimeActor(boundSnapshot.machine);
|
|
1274
|
+
actor?.start();
|
|
1275
|
+
const restoredState = currentState();
|
|
1276
|
+
if (restoredState.status !== 'active') {
|
|
1277
|
+
throw new Error(`discuss runtime: restored actor status is ${restoredState.status}, expected active`);
|
|
1278
|
+
}
|
|
1279
|
+
suppressInspectionEmissions = false;
|
|
1280
|
+
previousState = restoredState;
|
|
1281
|
+
await flush();
|
|
1282
|
+
}
|
|
1283
|
+
catch (error) {
|
|
1284
|
+
await cleanupFailedStart({ emitDisposal: false });
|
|
1285
|
+
throw error;
|
|
1286
|
+
}
|
|
1287
|
+
finally {
|
|
1288
|
+
finishInitialization();
|
|
1289
|
+
if (initInFlight === initialization)
|
|
1290
|
+
initInFlight = undefined;
|
|
1291
|
+
}
|
|
1292
|
+
},
|
|
1293
|
+
async handleBossInput(turn) {
|
|
1294
|
+
if (disposalPromise !== undefined) {
|
|
1295
|
+
throw new Error('discuss runtime: runtime is disposing or disposed');
|
|
1296
|
+
}
|
|
1297
|
+
requirePorts();
|
|
1298
|
+
if (!actor) {
|
|
1299
|
+
throw new Error('discuss runtime: init(session) must be called before handleBossInput');
|
|
1300
|
+
}
|
|
1301
|
+
if (currentTurnId !== undefined) {
|
|
1302
|
+
throw new Error('discuss runtime: another runtime turn is active');
|
|
1303
|
+
}
|
|
1304
|
+
const turnId = ++turnSequence;
|
|
1305
|
+
currentTurnId = turnId;
|
|
1306
|
+
currentSignal = turn.signal;
|
|
1307
|
+
controlPlaneError = undefined;
|
|
1308
|
+
let result = resultForSnapshot(turn.signal);
|
|
1309
|
+
let settlement = result;
|
|
1310
|
+
const failures = [];
|
|
1311
|
+
try {
|
|
1312
|
+
await emitTrace('boss.input.received', { text: turn.text }, { turnId });
|
|
1313
|
+
if (turn.text.trim().length === 0) {
|
|
1314
|
+
const state = currentState();
|
|
1315
|
+
result = { outcome: 'no-action', state };
|
|
1316
|
+
}
|
|
1317
|
+
else {
|
|
1318
|
+
const event = await classify(turn.text, turn.signal);
|
|
1319
|
+
if (!event) {
|
|
1320
|
+
const state = currentState();
|
|
1321
|
+
await emitBoundaryStatus('No playbook action classified.', state);
|
|
1322
|
+
result = { outcome: 'no-action', state };
|
|
1323
|
+
}
|
|
1324
|
+
else {
|
|
1325
|
+
await emitBoundaryStatus(event.type, currentState());
|
|
1326
|
+
if (actor.getSnapshot().status === 'done') {
|
|
1327
|
+
actor.stop();
|
|
1328
|
+
startActor();
|
|
1329
|
+
}
|
|
1330
|
+
actor.send(event);
|
|
1331
|
+
await driveToQuiescence();
|
|
1332
|
+
await drainBoundaryCallsAndEmissions();
|
|
1333
|
+
if (controlPlaneError !== undefined)
|
|
1334
|
+
throw controlPlaneError;
|
|
1335
|
+
result = resultForSnapshot(turn.signal);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
settlement = {
|
|
1339
|
+
...result,
|
|
1340
|
+
...stateIdentity(result.state),
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
catch (error) {
|
|
1344
|
+
const primaryError = controlPlaneError;
|
|
1345
|
+
if (primaryError !== undefined) {
|
|
1346
|
+
collectFailure(failures, primaryError);
|
|
1347
|
+
}
|
|
1348
|
+
else if (!turn.signal.aborted) {
|
|
1349
|
+
collectFailure(failures, error);
|
|
1350
|
+
}
|
|
1351
|
+
const state = currentState();
|
|
1352
|
+
const effectiveError = primaryError ?? error;
|
|
1353
|
+
result =
|
|
1354
|
+
turn.signal.aborted && primaryError === undefined
|
|
1355
|
+
? resultForSnapshot(turn.signal)
|
|
1356
|
+
: {
|
|
1357
|
+
outcome: 'failed',
|
|
1358
|
+
state,
|
|
1359
|
+
error: normalizeErrorFull(effectiveError) ?? {
|
|
1360
|
+
name: 'Error',
|
|
1361
|
+
message: String(effectiveError),
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
settlement = {
|
|
1365
|
+
...result,
|
|
1366
|
+
...stateIdentity(state),
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
try {
|
|
1370
|
+
await drainBoundaryCallsAndEmissions();
|
|
1371
|
+
}
|
|
1372
|
+
catch (error) {
|
|
1373
|
+
const primaryError = controlPlaneError;
|
|
1374
|
+
const effectiveError = primaryError ?? error;
|
|
1375
|
+
collectFailure(failures, effectiveError);
|
|
1376
|
+
const state = currentState();
|
|
1377
|
+
result = {
|
|
1378
|
+
outcome: turn.signal.aborted && primaryError === undefined
|
|
1379
|
+
? 'aborted'
|
|
1380
|
+
: 'failed',
|
|
1381
|
+
state,
|
|
1382
|
+
error: normalizeErrorFull(effectiveError) ?? {
|
|
1383
|
+
name: 'Error',
|
|
1384
|
+
message: String(effectiveError),
|
|
1385
|
+
},
|
|
1386
|
+
};
|
|
1387
|
+
settlement = { ...result, ...stateIdentity(state) };
|
|
1388
|
+
}
|
|
1389
|
+
currentSignal = undefined;
|
|
1390
|
+
try {
|
|
1391
|
+
await emitTrace('boss.input.settled', settlement, { turnId });
|
|
1392
|
+
}
|
|
1393
|
+
catch (error) {
|
|
1394
|
+
collectFailure(failures, error);
|
|
1395
|
+
}
|
|
1396
|
+
try {
|
|
1397
|
+
await flush();
|
|
1398
|
+
}
|
|
1399
|
+
catch (error) {
|
|
1400
|
+
collectFailure(failures, error);
|
|
1401
|
+
}
|
|
1402
|
+
finally {
|
|
1403
|
+
const primaryError = controlPlaneError;
|
|
1404
|
+
currentTurnId = undefined;
|
|
1405
|
+
controlPlaneError = undefined;
|
|
1406
|
+
if (primaryError !== undefined)
|
|
1407
|
+
throw primaryError;
|
|
1408
|
+
}
|
|
1409
|
+
if (failures.length === 1)
|
|
1410
|
+
throw failures[0];
|
|
1411
|
+
if (failures.length > 1) {
|
|
1412
|
+
throw new AggregateError(failures, 'discuss runtime turn failed');
|
|
1413
|
+
}
|
|
1414
|
+
return result;
|
|
1415
|
+
},
|
|
1416
|
+
async resumePlaybookCall({ callId, }) {
|
|
1417
|
+
if (disposalPromise !== undefined) {
|
|
1418
|
+
throw new Error('discuss runtime: runtime is disposing or disposed');
|
|
1419
|
+
}
|
|
1420
|
+
requirePorts();
|
|
1421
|
+
if (!actor) {
|
|
1422
|
+
throw new Error('discuss runtime: init(session) must be called before resumePlaybookCall');
|
|
1423
|
+
}
|
|
1424
|
+
throw new Error(`unknown or stale playbook call id ${callId}`);
|
|
1425
|
+
},
|
|
1426
|
+
dispose() {
|
|
1427
|
+
if (disposalPromise !== undefined)
|
|
1428
|
+
return disposalPromise;
|
|
1429
|
+
if (currentTurnId !== undefined) {
|
|
1430
|
+
return Promise.reject(new Error('discuss runtime: cannot dispose while a runtime turn is active'));
|
|
1431
|
+
}
|
|
1432
|
+
disposalPromise = (async () => {
|
|
1433
|
+
const initialization = initInFlight;
|
|
1434
|
+
if (initialization)
|
|
1435
|
+
await initialization;
|
|
1436
|
+
if (!sessionIdentity || disposed) {
|
|
1437
|
+
disposed = true;
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
const finalState = currentState();
|
|
1441
|
+
const failures = [];
|
|
1442
|
+
if (actor) {
|
|
1443
|
+
actor.stop();
|
|
1444
|
+
}
|
|
1445
|
+
try {
|
|
1446
|
+
await drainBoundaryCallsAndEmissions();
|
|
1447
|
+
}
|
|
1448
|
+
catch (error) {
|
|
1449
|
+
collectFailure(failures, error);
|
|
1450
|
+
}
|
|
1451
|
+
try {
|
|
1452
|
+
await emitTrace('session.disposed', {
|
|
1453
|
+
state: finalState,
|
|
1454
|
+
...stateIdentity(finalState),
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
catch (error) {
|
|
1458
|
+
collectFailure(failures, error);
|
|
1459
|
+
}
|
|
1460
|
+
try {
|
|
1461
|
+
await flush();
|
|
1462
|
+
}
|
|
1463
|
+
catch (error) {
|
|
1464
|
+
collectFailure(failures, error);
|
|
1465
|
+
}
|
|
1466
|
+
finally {
|
|
1467
|
+
playerResumeTokens.clear();
|
|
1468
|
+
inFlightPlayerIds.clear();
|
|
1469
|
+
activeBoundaryCalls.clear();
|
|
1470
|
+
activeEmissionCalls.clear();
|
|
1471
|
+
emissionQueue.clear();
|
|
1472
|
+
judgeQueue.clear();
|
|
1473
|
+
actor = undefined;
|
|
1474
|
+
currentSignal = undefined;
|
|
1475
|
+
currentTurnId = undefined;
|
|
1476
|
+
ports = undefined;
|
|
1477
|
+
sessionIdentity = undefined;
|
|
1478
|
+
previousState = undefined;
|
|
1479
|
+
controlPlaneError = undefined;
|
|
1480
|
+
disposed = true;
|
|
1481
|
+
}
|
|
1482
|
+
if (failures.length === 1)
|
|
1483
|
+
throw failures[0];
|
|
1484
|
+
if (failures.length > 1) {
|
|
1485
|
+
throw new AggregateError(failures, 'discuss runtime disposal failed');
|
|
1486
|
+
}
|
|
1487
|
+
})();
|
|
1488
|
+
return disposalPromise;
|
|
1489
|
+
},
|
|
1490
|
+
};
|
|
1491
|
+
};
|
|
1492
|
+
export const _internal = {
|
|
1493
|
+
composePlayerPrompt,
|
|
1494
|
+
resolvePlayerId,
|
|
1495
|
+
requiredFieldsFor,
|
|
1496
|
+
extractJson,
|
|
1497
|
+
buildClassifierPrompt,
|
|
1498
|
+
parseClassification,
|
|
1499
|
+
buildAdjudicatorPrompt,
|
|
1500
|
+
parseAdjudication,
|
|
1501
|
+
combineSignals,
|
|
1502
|
+
pendingQuestionsFromContext,
|
|
1503
|
+
normalizeErrorCompact,
|
|
1504
|
+
normalizeErrorFull,
|
|
1505
|
+
DEFAULT_PLAYER_BINDING,
|
|
1506
|
+
ALIAS_RESOLUTION,
|
|
1507
|
+
STATE_DESCRIPTIONS,
|
|
1508
|
+
CAPTAIN_STATES,
|
|
1509
|
+
CAPTAIN_STATE_IDS,
|
|
1510
|
+
BOSS_INTERRUPT_TARGETS,
|
|
1511
|
+
CONTINUATION_PREAMBLE,
|
|
1512
|
+
TELEMETRY_TOPIC,
|
|
1513
|
+
};
|
|
1514
|
+
export default createPlaybookRuntime;
|