@sublang/playbook 5.0.0 → 6.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 +11 -7
- package/docs/cli.md +38 -35
- package/docs/configuration.md +58 -15
- package/docs/embedding.md +24 -16
- package/package.json +40 -21
- package/reference/sdlc/captain.playbook/captain.playbook.js +2 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +2 -0
- package/reference/sdlc/code.md +55 -97
- package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
- package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
- package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
- package/reference/sdlc/code.playbook/code.gears.md +51 -263
- package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
- package/reference/sdlc/code.playbook/code.playbook.js +69 -656
- package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
- package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
- package/reference/sdlc/code.playbook/code.registry.js +20 -78
- package/reference/sdlc/code.playbook/code.registry.ts +58 -122
- package/reference/sdlc/code.playbook/playbook-captain.js +93 -15
- package/reference/sdlc/code.playbook/playbook-captain.ts +115 -19
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
- package/reference/sdlc/decide.md +54 -0
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
- package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
- package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
- package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
- package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +471 -362
- package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +575 -443
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
- package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
- package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
- package/reference/sdlc/review.md +81 -0
- package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
- package/reference/sdlc/review.playbook/review.fsm.js +524 -0
- package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
- package/reference/sdlc/review.playbook/review.gears.md +112 -0
- package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
- package/reference/sdlc/review.playbook/review.playbook.js +112 -0
- package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
- package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
- package/reference/sdlc/review.playbook/review.registry.js +73 -0
- package/reference/sdlc/review.playbook/review.registry.ts +138 -0
- package/slc/gears2fsm.md +13 -4
- package/slc/link.md +48 -2
- package/slc/text2gears.md +22 -2
- package/src/runtime.d.ts +7 -0
- package/src/runtime.ts +12 -0
- package/src/xstate-playbook-runtime.d.ts +9 -2
- package/src/xstate-playbook-runtime.js +255 -21
- package/src/xstate-playbook-runtime.ts +333 -28
- package/src/xstate-runtime.js +25 -0
- package/src/xstate-runtime.ts +51 -0
- package/reference/sdlc/discuss.md +0 -93
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
- package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
- package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js}
RENAMED
|
@@ -1,205 +1,97 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
//
|
|
4
|
-
// PlaybookRuntime for the
|
|
4
|
+
// PlaybookRuntime for the decide playbook, linked from the FSM artifact by
|
|
5
5
|
// the slc FSM-to-runtime link phase.
|
|
6
6
|
//
|
|
7
7
|
// Linker inputs:
|
|
8
|
-
// FSM artifact: ./
|
|
9
|
-
// Link target: @sublang/playbook/
|
|
10
|
-
// Player binding:
|
|
11
|
-
// Committer -> committer
|
|
8
|
+
// FSM artifact: ./decide.fsm.ts
|
|
9
|
+
// Link target: @sublang/playbook/runtime
|
|
10
|
+
// Player binding: Coder -> coder, Reviewer -> reviewer
|
|
12
11
|
// (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
12
|
// Adjudication: LLM-judge per state (default)
|
|
19
13
|
// Boss-event mapping: free-text judge classification (default)
|
|
20
14
|
// Abort strategy: natural rejection; every player-invoking state's
|
|
21
15
|
// onError routes to the quiescent failed state.
|
|
16
|
+
// Output profile: bespoke parallel runtime with the shared nested-call
|
|
17
|
+
// bridge (slc/link.md §Output)
|
|
22
18
|
import PQueue from 'p-queue';
|
|
23
19
|
import { createActor, fromPromise } from 'xstate';
|
|
24
|
-
import { assertJsonSafe, assertPlaybookRuntimeSnapshot, combineAbortSignals, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
|
|
25
|
-
import
|
|
20
|
+
import { assertJsonSafe, assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
|
|
21
|
+
import decideMachine from './decide.fsm.js';
|
|
26
22
|
const DEFAULT_PLAYER_BINDING = {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
Committer: 'committer',
|
|
23
|
+
Coder: 'coder',
|
|
24
|
+
Reviewer: 'reviewer',
|
|
30
25
|
};
|
|
31
|
-
|
|
32
|
-
|
|
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');
|
|
26
|
+
function snapshotDecideRuntimeOptions(value) {
|
|
27
|
+
const captured = snapshotJsonValue(value, 'DECIDE runtime options');
|
|
37
28
|
if (!isPlainObject(captured)) {
|
|
38
|
-
throw new TypeError('
|
|
29
|
+
throw new TypeError('DECIDE runtime options must be an object');
|
|
39
30
|
}
|
|
40
|
-
const allowed = new Set([
|
|
41
|
-
'host',
|
|
42
|
-
'participant',
|
|
43
|
-
'committer',
|
|
44
|
-
'playerBinding',
|
|
45
|
-
]);
|
|
31
|
+
const allowed = new Set(['coderLlm', 'playerBinding']);
|
|
46
32
|
for (const key of Object.keys(captured)) {
|
|
47
33
|
if (!allowed.has(key)) {
|
|
48
|
-
throw new TypeError(`
|
|
34
|
+
throw new TypeError(`DECIDE runtime options.${key} is not declared`);
|
|
49
35
|
}
|
|
50
36
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
37
|
+
if (typeof captured.coderLlm !== 'string' ||
|
|
38
|
+
captured.coderLlm.trim().length === 0) {
|
|
39
|
+
throw new TypeError('DECIDE runtime options.coderLlm must be a non-empty string');
|
|
55
40
|
}
|
|
56
41
|
if ('playerBinding' in captured) {
|
|
57
42
|
const playerBinding = captured.playerBinding;
|
|
58
43
|
if (!isPlainObject(playerBinding)) {
|
|
59
|
-
throw new TypeError('
|
|
44
|
+
throw new TypeError('DECIDE runtime options.playerBinding must be an object');
|
|
60
45
|
}
|
|
61
|
-
const playerNames = new Set([
|
|
62
|
-
'Host',
|
|
63
|
-
'Participant',
|
|
64
|
-
'Committer',
|
|
65
|
-
]);
|
|
46
|
+
const playerNames = new Set(['Coder', 'Reviewer']);
|
|
66
47
|
for (const [player, playerId] of Object.entries(playerBinding)) {
|
|
67
48
|
if (!playerNames.has(player)) {
|
|
68
|
-
throw new TypeError(`
|
|
49
|
+
throw new TypeError(`DECIDE runtime options.playerBinding.${player} is not declared`);
|
|
69
50
|
}
|
|
70
51
|
if (typeof playerId !== 'string' || playerId.trim().length === 0) {
|
|
71
|
-
throw new TypeError(`
|
|
52
|
+
throw new TypeError(`DECIDE runtime options.playerBinding.${player} must be a non-empty string`);
|
|
72
53
|
}
|
|
73
54
|
}
|
|
74
55
|
}
|
|
75
56
|
return captured;
|
|
76
57
|
}
|
|
77
58
|
const STATE_DESCRIPTIONS = {
|
|
78
|
-
ready: '
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.',
|
|
59
|
+
ready: 'Waiting for a topic to decide.',
|
|
60
|
+
askCoderProposal: 'Coder independently proposes a spec design.',
|
|
61
|
+
askReviewerProposal: 'Reviewer independently proposes a spec design.',
|
|
62
|
+
waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
|
|
63
|
+
waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
|
|
64
|
+
commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
|
|
65
|
+
awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
|
|
66
|
+
reviewCommit: 'REVIEW examines the committed proposal.',
|
|
67
|
+
failed: 'DECIDE failed and is waiting for a new topic.',
|
|
68
|
+
reportedReviewFailure: 'DECIDE reports REVIEW’s failure and its last commit.',
|
|
69
|
+
done: 'DECIDE completed with an approved commit.',
|
|
97
70
|
};
|
|
98
|
-
const
|
|
99
|
-
{ stateId: '
|
|
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' },
|
|
71
|
+
const PLAYER_STATES = [
|
|
72
|
+
{ stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
|
|
112
73
|
{
|
|
113
|
-
stateId: '
|
|
114
|
-
player: '
|
|
115
|
-
sourceItem: '
|
|
74
|
+
stateId: 'askReviewerProposal',
|
|
75
|
+
player: 'Reviewer',
|
|
76
|
+
sourceItem: 'DECIDE-2',
|
|
116
77
|
},
|
|
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',
|
|
78
|
+
{ stateId: 'commitCoderProposal', player: 'Coder', sourceItem: 'DECIDE-3' },
|
|
180
79
|
];
|
|
80
|
+
const PLAYER_STATE_IDS = new Set(PLAYER_STATES.map((state) => state.stateId));
|
|
81
|
+
const BOSS_INTERRUPT_TARGETS = ['independentProposals'];
|
|
181
82
|
const BOSS_INTERRUPT_TARGET_IDS = new Set(BOSS_INTERRUPT_TARGETS);
|
|
182
|
-
const REVIEW_SCOPES = new Set([
|
|
183
|
-
'specItems',
|
|
184
|
-
'decisionRecords',
|
|
185
|
-
'mixed',
|
|
186
|
-
]);
|
|
187
83
|
const TELEMETRY_TOPIC = 'playbook.fsm.state';
|
|
188
84
|
const TRACE_TOPIC = 'playbook.trace';
|
|
189
85
|
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
86
|
const PLACEHOLDER_FIELDS = [
|
|
191
|
-
['<topic>', '
|
|
192
|
-
['<
|
|
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'],
|
|
87
|
+
['<caller-topic>', 'callerTopic'],
|
|
88
|
+
['<coder-llm>', 'coderLlm'],
|
|
202
89
|
];
|
|
90
|
+
const VERBATIM_PAYLOAD_FIELDS = new Set([
|
|
91
|
+
'coderProposal',
|
|
92
|
+
'reviewerProposal',
|
|
93
|
+
'coderOutput',
|
|
94
|
+
]);
|
|
203
95
|
function composePlayerPrompt(input) {
|
|
204
96
|
const blocks = [];
|
|
205
97
|
if (input.pendingBossQuestion && input.bossReply !== undefined) {
|
|
@@ -213,35 +105,38 @@ function composePlayerPrompt(input) {
|
|
|
213
105
|
input.bossReply,
|
|
214
106
|
].join('\n'));
|
|
215
107
|
}
|
|
216
|
-
|
|
108
|
+
const replacements = new Map();
|
|
217
109
|
for (const [placeholder, field] of PLACEHOLDER_FIELDS) {
|
|
218
110
|
const value = input[field];
|
|
219
|
-
if (typeof value === 'string')
|
|
220
|
-
|
|
221
|
-
}
|
|
111
|
+
if (typeof value === 'string')
|
|
112
|
+
replacements.set(placeholder, value);
|
|
222
113
|
}
|
|
114
|
+
const body = input.prompt.replace(/<caller-topic>|<coder-llm>/g, (placeholder, offset, source) => {
|
|
115
|
+
const value = replacements.get(placeholder);
|
|
116
|
+
if (value === undefined)
|
|
117
|
+
return placeholder;
|
|
118
|
+
const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
|
|
119
|
+
return source.slice(lineStart, offset) === '> '
|
|
120
|
+
? value.replaceAll('\n', '\n> ')
|
|
121
|
+
: value;
|
|
122
|
+
});
|
|
223
123
|
blocks.push(body);
|
|
224
124
|
return blocks.join('\n\n');
|
|
225
125
|
}
|
|
226
126
|
function resolvePlayerId(input, binding) {
|
|
227
127
|
switch (input.player) {
|
|
228
|
-
case '
|
|
229
|
-
return binding.
|
|
230
|
-
case '
|
|
231
|
-
return binding.
|
|
232
|
-
case 'Committer':
|
|
233
|
-
return input.committerPlayer ?? binding.Host;
|
|
128
|
+
case 'Coder':
|
|
129
|
+
return binding.Coder;
|
|
130
|
+
case 'Reviewer':
|
|
131
|
+
return binding.Reviewer;
|
|
234
132
|
default: {
|
|
235
133
|
const exhaustive = input.player;
|
|
236
134
|
throw new Error(`unknown player ${String(exhaustive)}`);
|
|
237
135
|
}
|
|
238
136
|
}
|
|
239
137
|
}
|
|
240
|
-
// A `result` description names required payload fields in
|
|
241
|
-
// "Output shall include ..." sentence.
|
|
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.
|
|
138
|
+
// A `result` description names required payload fields in its
|
|
139
|
+
// "Output shall include ..." sentence.
|
|
245
140
|
function requiredFieldsFor(description) {
|
|
246
141
|
const fields = [];
|
|
247
142
|
const sentence = /Output shall include([^.]*)/g;
|
|
@@ -364,7 +259,7 @@ function dropTrailingComma(value) {
|
|
|
364
259
|
}
|
|
365
260
|
function buildClassifierPrompt(text, ctx) {
|
|
366
261
|
const lines = [];
|
|
367
|
-
lines.push('You are the Boss-input classifier for the
|
|
262
|
+
lines.push('You are the Boss-input classifier for the decide playbook.');
|
|
368
263
|
lines.push('Classify the Boss message into exactly one FSM event, or into no event.');
|
|
369
264
|
lines.push('');
|
|
370
265
|
lines.push(`Current FSM state: ${JSON.stringify(ctx.state.value)}`);
|
|
@@ -372,84 +267,66 @@ function buildClassifierPrompt(text, ctx) {
|
|
|
372
267
|
if (ctx.pendingQuestions.length > 0) {
|
|
373
268
|
lines.push('Pending Boss questions:');
|
|
374
269
|
for (const pending of ctx.pendingQuestions) {
|
|
375
|
-
lines.push(`- ${pending.questionId} (${pending.player}
|
|
270
|
+
lines.push(`- ${pending.questionId} (${pending.player}): ${pending.question}`);
|
|
376
271
|
}
|
|
377
272
|
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
273
|
}
|
|
379
274
|
lines.push('');
|
|
380
275
|
lines.push('Events and payload contracts:');
|
|
381
|
-
lines.push(
|
|
382
|
-
lines.push('-
|
|
383
|
-
lines.push(
|
|
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.');
|
|
276
|
+
lines.push(`- BOSS_INTERRUPT: required targetId string, exactly ${BOSS_INTERRUPT_TARGETS[0]}; the runtime attaches the exact Boss text as bossIntent.`);
|
|
277
|
+
lines.push('- BOSS_REPLY: optional questionId when exactly one question is pending and required when several are pending; the runtime attaches the exact Boss text as answer.');
|
|
278
|
+
lines.push('- NO_ACTION: no fields.');
|
|
385
279
|
lines.push('');
|
|
386
280
|
lines.push('Boss message:');
|
|
387
281
|
lines.push(text);
|
|
388
282
|
lines.push('');
|
|
389
|
-
lines.push('Reply with a single JSON object: { "
|
|
390
|
-
lines.push('Use
|
|
283
|
+
lines.push('Reply with a single JSON object: { "type": "<EVENT_TYPE>", ...declared fields }.');
|
|
284
|
+
lines.push('Use NO_ACTION when no FSM action applies.');
|
|
391
285
|
return lines.join('\n');
|
|
392
286
|
}
|
|
393
|
-
function parseClassification(raw, pendingQuestionIds = []) {
|
|
287
|
+
function parseClassification(raw, text, pendingQuestionIds = []) {
|
|
394
288
|
const obj = extractJson(raw);
|
|
395
289
|
if (!obj)
|
|
396
290
|
return null;
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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;
|
|
291
|
+
const exactKeys = (...keys) => {
|
|
292
|
+
const ownKeys = Reflect.ownKeys(obj);
|
|
293
|
+
return (ownKeys.length === keys.length &&
|
|
294
|
+
ownKeys.every((key) => typeof key === 'string' && keys.includes(key)));
|
|
295
|
+
};
|
|
296
|
+
const eventType = obj.type;
|
|
297
|
+
if (eventType === 'NO_ACTION') {
|
|
298
|
+
return exactKeys('type') ? { type: 'NO_ACTION' } : null;
|
|
427
299
|
}
|
|
428
300
|
if (eventType === 'BOSS_INTERRUPT') {
|
|
429
|
-
if (
|
|
301
|
+
if (exactKeys('type', 'targetId') &&
|
|
302
|
+
typeof obj.targetId === 'string' &&
|
|
430
303
|
BOSS_INTERRUPT_TARGET_IDS.has(obj.targetId)) {
|
|
431
|
-
return {
|
|
304
|
+
return {
|
|
305
|
+
type: 'BOSS_INTERRUPT',
|
|
306
|
+
targetId: obj.targetId,
|
|
307
|
+
bossIntent: text,
|
|
308
|
+
};
|
|
432
309
|
}
|
|
433
310
|
return null;
|
|
434
311
|
}
|
|
435
312
|
if (eventType === 'BOSS_REPLY') {
|
|
436
|
-
if (
|
|
313
|
+
if (pendingQuestionIds.length === 0)
|
|
437
314
|
return null;
|
|
438
|
-
}
|
|
439
315
|
if (typeof obj.questionId === 'string') {
|
|
440
|
-
return
|
|
316
|
+
return exactKeys('type', 'questionId') &&
|
|
317
|
+
pendingQuestionIds.includes(obj.questionId)
|
|
441
318
|
? {
|
|
442
319
|
type: 'BOSS_REPLY',
|
|
443
320
|
questionId: obj.questionId,
|
|
444
|
-
answer:
|
|
321
|
+
answer: text,
|
|
445
322
|
}
|
|
446
323
|
: null;
|
|
447
324
|
}
|
|
448
|
-
return pendingQuestionIds.length === 1
|
|
325
|
+
return exactKeys('type') && pendingQuestionIds.length === 1
|
|
449
326
|
? {
|
|
450
327
|
type: 'BOSS_REPLY',
|
|
451
328
|
questionId: pendingQuestionIds[0],
|
|
452
|
-
answer:
|
|
329
|
+
answer: text,
|
|
453
330
|
}
|
|
454
331
|
: null;
|
|
455
332
|
}
|
|
@@ -473,11 +350,22 @@ function buildAdjudicatorPrompt(input, playerOutput) {
|
|
|
473
350
|
for (const [guard, description] of Object.entries(input.result)) {
|
|
474
351
|
lines.push(`- ${guard}: ${description}`);
|
|
475
352
|
}
|
|
353
|
+
const runtimeOwnedFields = new Set();
|
|
354
|
+
for (const description of Object.values(input.result)) {
|
|
355
|
+
for (const field of requiredFieldsFor(description)) {
|
|
356
|
+
if (VERBATIM_PAYLOAD_FIELDS.has(field))
|
|
357
|
+
runtimeOwnedFields.add(field);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (runtimeOwnedFields.size > 0) {
|
|
361
|
+
lines.push('');
|
|
362
|
+
lines.push(`The runtime owns these verbatim fields; do not include them in your JSON: ${[...runtimeOwnedFields].join(', ')}.`);
|
|
363
|
+
}
|
|
476
364
|
lines.push('');
|
|
477
365
|
lines.push('Reply with a single JSON object: { "guard": "<one of the guard names above>", ...any payload fields the chosen guard description requires }.');
|
|
478
366
|
return lines.join('\n');
|
|
479
367
|
}
|
|
480
|
-
function parseAdjudication(raw, input) {
|
|
368
|
+
function parseAdjudication(raw, input, finalText) {
|
|
481
369
|
const obj = extractJson(raw);
|
|
482
370
|
if (!obj || typeof obj.guard !== 'string' || obj.guard.trim() === '') {
|
|
483
371
|
throw new Error('adjudicator returned empty or malformed JSON');
|
|
@@ -486,25 +374,26 @@ function parseAdjudication(raw, input) {
|
|
|
486
374
|
if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
|
|
487
375
|
throw new Error(`adjudicator returned undeclared guard "${guard}" for ${input.sourceItem}`);
|
|
488
376
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
377
|
+
const requiredFields = requiredFieldsFor(input.result[guard]);
|
|
378
|
+
const allowedFields = new Set(['guard', ...requiredFields]);
|
|
379
|
+
for (const key of Reflect.ownKeys(obj)) {
|
|
380
|
+
if (typeof key !== 'string' || !allowedFields.has(key)) {
|
|
381
|
+
throw new Error(`adjudicator response for guard "${guard}" included undeclared field "${String(key)}"`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const output = { guard };
|
|
385
|
+
for (const field of requiredFields) {
|
|
386
|
+
if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
|
|
387
|
+
output[field] = finalText;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const value = obj[field];
|
|
498
391
|
if (value === undefined ||
|
|
499
392
|
value === null ||
|
|
500
393
|
(typeof value === 'string' && value.trim() === '')) {
|
|
501
394
|
throw new Error(`adjudicator response for guard "${guard}" missing required field "${field}"`);
|
|
502
395
|
}
|
|
503
|
-
|
|
504
|
-
if ('reviewScope' in output &&
|
|
505
|
-
typeof output.reviewScope === 'string' &&
|
|
506
|
-
!REVIEW_SCOPES.has(output.reviewScope)) {
|
|
507
|
-
throw new Error(`adjudicator returned invalid reviewScope "${output.reviewScope}"`);
|
|
396
|
+
output[field] = value;
|
|
508
397
|
}
|
|
509
398
|
return output;
|
|
510
399
|
}
|
|
@@ -528,11 +417,7 @@ function isEmptyFinalText(finalText) {
|
|
|
528
417
|
return finalText === undefined || finalText.trim().length === 0;
|
|
529
418
|
}
|
|
530
419
|
function isAbortFailure(error, signal) {
|
|
531
|
-
|
|
532
|
-
return false;
|
|
533
|
-
if (Object.is(error, signal.reason))
|
|
534
|
-
return true;
|
|
535
|
-
return normalizeErrorCompact(error)?.name === 'AbortError';
|
|
420
|
+
return signal.aborted && Object.is(error, signal.reason);
|
|
536
421
|
}
|
|
537
422
|
function pendingQuestionsFromContext(context) {
|
|
538
423
|
const pending = context.pendingBossQuestions;
|
|
@@ -559,17 +444,15 @@ function pendingQuestionsFromContext(context) {
|
|
|
559
444
|
return questions.sort((left, right) => left.questionId.localeCompare(right.questionId));
|
|
560
445
|
}
|
|
561
446
|
const WAIT_STATE_RESUME_IDS = {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
waitHostReconciliationReply: 'hostInitialRound',
|
|
565
|
-
waitParticipantReconciliationReply: 'participantInitialRound',
|
|
447
|
+
waitCoderProposalReply: 'askCoderProposal',
|
|
448
|
+
waitReviewerProposalReply: 'askReviewerProposal',
|
|
566
449
|
};
|
|
567
450
|
const WAIT_STATE_IDS = new Set([
|
|
568
451
|
...Object.keys(WAIT_STATE_RESUME_IDS),
|
|
569
452
|
'awaitBossReply',
|
|
570
453
|
]);
|
|
571
454
|
const STATUS_STATE_IDS = new Set([
|
|
572
|
-
...
|
|
455
|
+
...PLAYER_STATE_IDS,
|
|
573
456
|
...WAIT_STATE_IDS,
|
|
574
457
|
'failed',
|
|
575
458
|
]);
|
|
@@ -582,38 +465,44 @@ function questionForWaitState(stateId, pendingQuestions) {
|
|
|
582
465
|
? pendingQuestions[0]
|
|
583
466
|
: undefined;
|
|
584
467
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
468
|
+
const TRANSITION_EVENT_FIELDS = [
|
|
469
|
+
'callerTopic',
|
|
470
|
+
'targetId',
|
|
471
|
+
'bossIntent',
|
|
472
|
+
'questionId',
|
|
473
|
+
'answer',
|
|
474
|
+
];
|
|
475
|
+
function normalizedTransitionEvent(event) {
|
|
476
|
+
if (event === null || typeof event !== 'object') {
|
|
477
|
+
return snapshotJsonValue(event ?? null, 'FSM event');
|
|
478
|
+
}
|
|
479
|
+
const source = event;
|
|
480
|
+
const descriptor = {};
|
|
481
|
+
if (typeof source.type === 'string')
|
|
482
|
+
descriptor.type = source.type;
|
|
483
|
+
for (const field of TRANSITION_EVENT_FIELDS) {
|
|
484
|
+
if (typeof source[field] === 'string') {
|
|
485
|
+
descriptor[field] = source[field];
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (source.output !== undefined) {
|
|
489
|
+
descriptor.output = snapshotJsonValue(source.output, 'FSM event output');
|
|
592
490
|
}
|
|
593
|
-
if (
|
|
594
|
-
|
|
595
|
-
typeof event === 'boolean' ||
|
|
596
|
-
(typeof event === 'number' && Number.isFinite(event))) {
|
|
597
|
-
return event;
|
|
491
|
+
if (source.error !== undefined) {
|
|
492
|
+
descriptor.error = snapshotJsonValue(normalizeError(source.error), 'FSM event error');
|
|
598
493
|
}
|
|
599
|
-
return
|
|
494
|
+
return snapshotJsonValue(descriptor, 'FSM event');
|
|
600
495
|
}
|
|
601
496
|
function telemetryPayload(previousState, state, event, context) {
|
|
602
|
-
const eventError = event !== null &&
|
|
603
|
-
typeof event === 'object' &&
|
|
604
|
-
!Array.isArray(event) &&
|
|
605
|
-
'error' in event
|
|
606
|
-
? normalizeErrorFull(event.error)
|
|
607
|
-
: undefined;
|
|
608
497
|
const pendingBossQuestions = pendingQuestionsFromContext(context);
|
|
498
|
+
const prior = previousState ?? state;
|
|
609
499
|
const payload = {
|
|
610
|
-
from:
|
|
500
|
+
from: prior.value,
|
|
611
501
|
to: state.value,
|
|
612
|
-
event:
|
|
613
|
-
previousState:
|
|
502
|
+
event: normalizedTransitionEvent(event),
|
|
503
|
+
previousState: prior,
|
|
614
504
|
state,
|
|
615
505
|
...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
|
|
616
|
-
...(eventError !== undefined ? { error: eventError } : {}),
|
|
617
506
|
...(state.activeStateIds.includes('failed')
|
|
618
507
|
? { lastError: normalizeErrorFull(context.lastError) ?? null }
|
|
619
508
|
: {}),
|
|
@@ -622,15 +511,13 @@ function telemetryPayload(previousState, state, event, context) {
|
|
|
622
511
|
return payload;
|
|
623
512
|
}
|
|
624
513
|
export const createPlaybookRuntime = (options) => {
|
|
625
|
-
const boundOptions =
|
|
514
|
+
const boundOptions = snapshotDecideRuntimeOptions(options);
|
|
626
515
|
const binding = {
|
|
627
516
|
...DEFAULT_PLAYER_BINDING,
|
|
628
517
|
...(boundOptions.playerBinding ?? {}),
|
|
629
518
|
};
|
|
630
519
|
const fsmInput = {
|
|
631
|
-
|
|
632
|
-
participant: boundOptions.participant,
|
|
633
|
-
committer: boundOptions.committer,
|
|
520
|
+
coderLlm: boundOptions.coderLlm,
|
|
634
521
|
};
|
|
635
522
|
let ports;
|
|
636
523
|
let sessionIdentity;
|
|
@@ -644,12 +531,15 @@ export const createPlaybookRuntime = (options) => {
|
|
|
644
531
|
let turnSequence = 0;
|
|
645
532
|
let judgeCallSequence = 0;
|
|
646
533
|
let playerCallSequence = 0;
|
|
534
|
+
let playbookCallSequence = 0;
|
|
647
535
|
let lifecycleStarted = false;
|
|
648
536
|
let initInFlight;
|
|
649
537
|
let disposed = false;
|
|
650
538
|
let disposalPromise;
|
|
651
539
|
let controlPlaneError;
|
|
540
|
+
let nestedBridge;
|
|
652
541
|
const playerResumeTokens = new Map();
|
|
542
|
+
const playbookCallTurnIds = new Map();
|
|
653
543
|
const inFlightPlayerIds = new Set();
|
|
654
544
|
const activeBoundaryCalls = new Set();
|
|
655
545
|
const activeEmissionCalls = new Set();
|
|
@@ -669,6 +559,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
669
559
|
if (!isAbortFailure(error, signal))
|
|
670
560
|
controlPlaneError ??= error;
|
|
671
561
|
};
|
|
562
|
+
const latchInspectionError = (error) => {
|
|
563
|
+
if (currentSignal !== undefined) {
|
|
564
|
+
latchControlPlaneError(error, currentSignal);
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
collectFailure(emissionFailures, error);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
672
570
|
const enqueue = (fn) => {
|
|
673
571
|
const queued = emissionQueue.add(fn);
|
|
674
572
|
activeEmissionCalls.add(queued);
|
|
@@ -696,7 +594,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
696
594
|
emissionFailures = [];
|
|
697
595
|
if (failures.length === 1)
|
|
698
596
|
throw failures[0];
|
|
699
|
-
throw new AggregateError(failures, '
|
|
597
|
+
throw new AggregateError(failures, 'decide runtime emissions failed');
|
|
700
598
|
};
|
|
701
599
|
const drainBoundaryCallsAndEmissions = async () => {
|
|
702
600
|
while (true) {
|
|
@@ -715,22 +613,78 @@ export const createPlaybookRuntime = (options) => {
|
|
|
715
613
|
};
|
|
716
614
|
const requirePorts = () => {
|
|
717
615
|
if (!ports) {
|
|
718
|
-
throw new Error('
|
|
616
|
+
throw new Error('decide runtime: init(session) must be called first');
|
|
719
617
|
}
|
|
720
618
|
return ports;
|
|
721
619
|
};
|
|
722
620
|
const requireSessionIdentity = () => {
|
|
723
621
|
if (!sessionIdentity) {
|
|
724
|
-
throw new Error('
|
|
622
|
+
throw new Error('decide runtime: init(session) must be called first');
|
|
725
623
|
}
|
|
726
624
|
return sessionIdentity;
|
|
727
625
|
};
|
|
626
|
+
const selectPlayerResume = (playerId) => {
|
|
627
|
+
const session = requireSessionIdentity();
|
|
628
|
+
const selected = session.playerSessions
|
|
629
|
+
? session.playerSessions.select(playerId)
|
|
630
|
+
: playerResumeTokens.get(playerId) ?? false;
|
|
631
|
+
if (selected !== false &&
|
|
632
|
+
(typeof selected !== 'string' || selected.trim().length === 0)) {
|
|
633
|
+
throw new TypeError(`player session store returned an invalid resume token for ${playerId}`);
|
|
634
|
+
}
|
|
635
|
+
return selected;
|
|
636
|
+
};
|
|
637
|
+
const updatePlayerResume = (playerId, resumeToken) => {
|
|
638
|
+
const session = requireSessionIdentity();
|
|
639
|
+
if (session.playerSessions) {
|
|
640
|
+
session.playerSessions.update(playerId, resumeToken);
|
|
641
|
+
}
|
|
642
|
+
else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
|
|
643
|
+
playerResumeTokens.set(playerId, resumeToken);
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
playerResumeTokens.delete(playerId);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
const snapshotPlayerResumeTokens = () => {
|
|
650
|
+
const session = requireSessionIdentity();
|
|
651
|
+
const captured = snapshotJsonValue(session.playerSessions
|
|
652
|
+
? session.playerSessions.snapshot()
|
|
653
|
+
: Object.fromEntries(playerResumeTokens), 'player session store snapshot');
|
|
654
|
+
if (!isPlainObject(captured)) {
|
|
655
|
+
throw new TypeError('player session store snapshot must be an object');
|
|
656
|
+
}
|
|
657
|
+
const tokens = {};
|
|
658
|
+
for (const [playerId, token] of Object.entries(captured)) {
|
|
659
|
+
if (playerId.trim().length === 0) {
|
|
660
|
+
throw new TypeError('player session store snapshot player ids must be non-empty');
|
|
661
|
+
}
|
|
662
|
+
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
663
|
+
throw new TypeError(`player session store snapshot token for ${playerId} must be a non-empty string`);
|
|
664
|
+
}
|
|
665
|
+
tokens[playerId] = token;
|
|
666
|
+
}
|
|
667
|
+
return tokens;
|
|
668
|
+
};
|
|
669
|
+
const restorePlayerResumeTokens = (tokens) => {
|
|
670
|
+
const session = requireSessionIdentity();
|
|
671
|
+
if (session.playerSessions) {
|
|
672
|
+
session.playerSessions.restore(tokens);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
playerResumeTokens.clear();
|
|
676
|
+
for (const [playerId, token] of Object.entries(tokens)) {
|
|
677
|
+
playerResumeTokens.set(playerId, token);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
728
680
|
const currentState = () => {
|
|
729
681
|
const live = actor;
|
|
730
682
|
if (!live) {
|
|
731
|
-
throw new Error('
|
|
683
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
732
684
|
}
|
|
733
|
-
return normalizePlaybookSnapshot(live.getSnapshot()
|
|
685
|
+
return normalizePlaybookSnapshot(live.getSnapshot(), {
|
|
686
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
687
|
+
});
|
|
734
688
|
};
|
|
735
689
|
const stateIdentity = (state) => {
|
|
736
690
|
return state.stateId === undefined ? {} : { stateId: state.stateId };
|
|
@@ -802,7 +756,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
802
756
|
...(callStateId !== undefined ? { stateId: callStateId } : {}),
|
|
803
757
|
};
|
|
804
758
|
const queued = await judgeQueue.add(async () => {
|
|
805
|
-
// Keep the complete queue task pending until an active
|
|
759
|
+
// Keep the complete queue task pending until an active coder promise
|
|
806
760
|
// settles. PQueue's signal option may reject add() while that task is
|
|
807
761
|
// still running, which would let the turn drain race a late finish.
|
|
808
762
|
signal.throwIfAborted();
|
|
@@ -846,9 +800,16 @@ export const createPlaybookRuntime = (options) => {
|
|
|
846
800
|
if (inFlightPlayerIds.has(playerId)) {
|
|
847
801
|
throw new Error(`resolved player "${playerId}" already has an in-flight call`);
|
|
848
802
|
}
|
|
849
|
-
inFlightPlayerIds.add(playerId);
|
|
850
803
|
const prompt = composePlayerPrompt(input);
|
|
851
|
-
|
|
804
|
+
let resume;
|
|
805
|
+
try {
|
|
806
|
+
signal.throwIfAborted();
|
|
807
|
+
resume = selectPlayerResume(playerId);
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
latchControlPlaneError(error, signal);
|
|
811
|
+
throw error;
|
|
812
|
+
}
|
|
852
813
|
const callId = `player-${++playerCallSequence}`;
|
|
853
814
|
const identity = {
|
|
854
815
|
purpose: 'captain',
|
|
@@ -865,6 +826,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
865
826
|
message: String(error),
|
|
866
827
|
},
|
|
867
828
|
}, { turnId: currentTurnId, callId });
|
|
829
|
+
inFlightPlayerIds.add(playerId);
|
|
868
830
|
try {
|
|
869
831
|
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
|
|
870
832
|
let rawResult;
|
|
@@ -872,14 +834,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
872
834
|
signal.throwIfAborted();
|
|
873
835
|
const boundary = Promise.resolve(requirePorts().callPlayer(playerId, prompt, signal, { resume }));
|
|
874
836
|
rawResult = await boundary;
|
|
875
|
-
// An XState sibling cancellation does not cancel an arbitrary
|
|
837
|
+
// An XState sibling cancellation does not cancel an arbitrary coder
|
|
876
838
|
// promise. Re-check before a late resolution can mutate continuity or
|
|
877
839
|
// masquerade as a successful boundary finish.
|
|
878
840
|
signal.throwIfAborted();
|
|
879
841
|
}
|
|
880
842
|
catch (error) {
|
|
881
843
|
// A rejected call produced no authoritative result, so the previous
|
|
882
|
-
// token remains untouched. This also covers a
|
|
844
|
+
// token remains untouched. This also covers a coder promise that
|
|
883
845
|
// resolves after its invocation signal was cancelled.
|
|
884
846
|
latchControlPlaneError(error, signal);
|
|
885
847
|
try {
|
|
@@ -900,18 +862,27 @@ export const createPlaybookRuntime = (options) => {
|
|
|
900
862
|
await emitFailure(error);
|
|
901
863
|
}
|
|
902
864
|
catch {
|
|
903
|
-
// The malformed
|
|
865
|
+
// The malformed coder result remains authoritative.
|
|
904
866
|
}
|
|
905
867
|
throw error;
|
|
906
868
|
}
|
|
907
869
|
// The resolved result is authoritative even on aborted/error status.
|
|
908
870
|
// Update continuation state before interpreting that status.
|
|
909
|
-
|
|
910
|
-
result.resumeToken
|
|
911
|
-
|
|
871
|
+
try {
|
|
872
|
+
updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
|
|
873
|
+
result.resumeToken.trim().length > 0
|
|
874
|
+
? result.resumeToken
|
|
875
|
+
: undefined);
|
|
912
876
|
}
|
|
913
|
-
|
|
914
|
-
|
|
877
|
+
catch (error) {
|
|
878
|
+
latchControlPlaneError(error, signal);
|
|
879
|
+
try {
|
|
880
|
+
await emitFailure(error);
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
// The continuation-store failure remains authoritative.
|
|
884
|
+
}
|
|
885
|
+
throw error;
|
|
915
886
|
}
|
|
916
887
|
// Keep this finish outside the boundary catch. A trace sink can record
|
|
917
888
|
// the event and then reject; retrying from that catch would duplicate the
|
|
@@ -974,14 +945,62 @@ export const createPlaybookRuntime = (options) => {
|
|
|
974
945
|
combined.throwIfAborted();
|
|
975
946
|
try {
|
|
976
947
|
const prompt = buildAdjudicatorPrompt(input, finalText);
|
|
977
|
-
return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input);
|
|
948
|
+
return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
|
|
978
949
|
}
|
|
979
950
|
catch (error) {
|
|
980
951
|
latchControlPlaneError(error, combined);
|
|
981
952
|
throw error;
|
|
982
953
|
}
|
|
983
954
|
});
|
|
984
|
-
|
|
955
|
+
nestedBridge = createNestedPlaybookBridge({
|
|
956
|
+
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
957
|
+
getBoundarySignal: () => currentSignal,
|
|
958
|
+
callPlaybook: (request, signal) => trackBoundaryCall(Promise.resolve(requirePorts().callPlaybook(request, signal))),
|
|
959
|
+
emitStarted: async (event) => {
|
|
960
|
+
playbookCallTurnIds.set(event.callId, currentTurnId);
|
|
961
|
+
await emitTrace('playbook.call.started', {
|
|
962
|
+
stateId: event.stateId,
|
|
963
|
+
playbookId: event.playbookId,
|
|
964
|
+
text: event.text,
|
|
965
|
+
}, {
|
|
966
|
+
...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
|
|
967
|
+
callId: event.callId,
|
|
968
|
+
});
|
|
969
|
+
},
|
|
970
|
+
emitFinished: async (event) => {
|
|
971
|
+
const turnId = playbookCallTurnIds.get(event.callId);
|
|
972
|
+
try {
|
|
973
|
+
await emitTrace('playbook.call.finished', {
|
|
974
|
+
stateId: event.stateId,
|
|
975
|
+
playbookId: event.playbookId,
|
|
976
|
+
text: event.text,
|
|
977
|
+
result: event.result,
|
|
978
|
+
}, {
|
|
979
|
+
...(turnId === undefined ? {} : { turnId }),
|
|
980
|
+
callId: event.callId,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
finally {
|
|
984
|
+
playbookCallTurnIds.delete(event.callId);
|
|
985
|
+
}
|
|
986
|
+
},
|
|
987
|
+
drain: flush,
|
|
988
|
+
bindResumeSignal: (signal) => {
|
|
989
|
+
currentSignal = signal;
|
|
990
|
+
},
|
|
991
|
+
onControlPlaneError: (error) => {
|
|
992
|
+
const signal = currentSignal;
|
|
993
|
+
if (!signal || !isAbortFailure(error, signal)) {
|
|
994
|
+
controlPlaneError ??= error;
|
|
995
|
+
}
|
|
996
|
+
},
|
|
997
|
+
onBackgroundError: (error) => {
|
|
998
|
+
collectFailure(emissionFailures, error);
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
const providedMachine = decideMachine.provide({
|
|
1002
|
+
actors: { player, playbook: nestedBridge.actorLogic },
|
|
1003
|
+
});
|
|
985
1004
|
const inspect = (event) => {
|
|
986
1005
|
if (event.type !== '@xstate.snapshot')
|
|
987
1006
|
return;
|
|
@@ -989,49 +1008,58 @@ export const createPlaybookRuntime = (options) => {
|
|
|
989
1008
|
return;
|
|
990
1009
|
if (suppressInspectionEmissions)
|
|
991
1010
|
return;
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1011
|
+
try {
|
|
1012
|
+
const snapshot = event.snapshot;
|
|
1013
|
+
const state = normalizePlaybookSnapshot(snapshot);
|
|
1014
|
+
const prior = previousState ?? state;
|
|
1015
|
+
const context = snapshot.context;
|
|
1016
|
+
const fsmPayload = telemetryPayload(prior, state, event.event, context);
|
|
1017
|
+
const describedFsmPayload = snapshotJsonValue(fsmPayload, 'described FSM telemetry');
|
|
1018
|
+
void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
|
|
1019
|
+
topic: TELEMETRY_TOPIC,
|
|
1020
|
+
payload: describedFsmPayload,
|
|
1021
|
+
})).catch(() => undefined);
|
|
1022
|
+
const priorIds = new Set(previousState?.activeStateIds ?? []);
|
|
1023
|
+
previousState = state;
|
|
1024
|
+
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
1025
|
+
const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
|
|
1026
|
+
const scheduleStatus = (message, stateId, data) => {
|
|
1027
|
+
const tracePayload = {
|
|
1028
|
+
...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
|
|
1029
|
+
message,
|
|
1030
|
+
state,
|
|
1031
|
+
...(data !== undefined ? { data } : {}),
|
|
1032
|
+
};
|
|
1033
|
+
assertJsonSafe(tracePayload);
|
|
1034
|
+
void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data)).catch(() => undefined);
|
|
1015
1035
|
};
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
if (priorIds.has(activeStateId) || !STATUS_STATE_IDS.has(activeStateId)) {
|
|
1021
|
-
continue;
|
|
1022
|
-
}
|
|
1023
|
-
if (WAIT_STATE_IDS.has(activeStateId)) {
|
|
1024
|
-
const pending = questionForWaitState(activeStateId, pendingQuestions);
|
|
1025
|
-
if (pending) {
|
|
1026
|
-
scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
|
|
1027
|
-
scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`, activeStateId);
|
|
1036
|
+
for (const activeStateId of state.activeStateIds) {
|
|
1037
|
+
if (priorIds.has(activeStateId) ||
|
|
1038
|
+
!STATUS_STATE_IDS.has(activeStateId)) {
|
|
1039
|
+
continue;
|
|
1028
1040
|
}
|
|
1029
|
-
|
|
1041
|
+
if (WAIT_STATE_IDS.has(activeStateId)) {
|
|
1042
|
+
const pending = questionForWaitState(activeStateId, pendingQuestions);
|
|
1043
|
+
if (pending) {
|
|
1044
|
+
scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
|
|
1045
|
+
scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`, activeStateId);
|
|
1046
|
+
}
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
const lastError = activeStateId === 'failed'
|
|
1050
|
+
? normalizeErrorCompact(context.lastError)
|
|
1051
|
+
: undefined;
|
|
1052
|
+
const description = STATE_DESCRIPTIONS[activeStateId];
|
|
1053
|
+
if (description === undefined)
|
|
1054
|
+
continue;
|
|
1055
|
+
const playerState = PLAYER_STATES.find((candidate) => candidate.stateId === activeStateId);
|
|
1056
|
+
scheduleStatus(playerState === undefined
|
|
1057
|
+
? '◆ workflow failed; awaiting Boss recovery.'
|
|
1058
|
+
: `⤷ ${playerState.player}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
|
|
1030
1059
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
scheduleStatus(STATE_DESCRIPTIONS[activeStateId] ?? activeStateId, activeStateId, lastError === undefined ? undefined : { lastError });
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
latchInspectionError(error);
|
|
1035
1063
|
}
|
|
1036
1064
|
};
|
|
1037
1065
|
const createRuntimeActor = (machineSnapshot) => {
|
|
@@ -1070,30 +1098,41 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1070
1098
|
const driveToQuiescence = async () => {
|
|
1071
1099
|
const live = actor;
|
|
1072
1100
|
if (!live)
|
|
1073
|
-
throw new Error('
|
|
1074
|
-
await waitForPlaybookQuiescence(live);
|
|
1101
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
1102
|
+
await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
|
|
1075
1103
|
};
|
|
1076
1104
|
const classify = async (text, signal) => {
|
|
1077
1105
|
const live = actor;
|
|
1078
1106
|
if (!live)
|
|
1079
|
-
throw new Error('
|
|
1107
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
1080
1108
|
const snapshot = live.getSnapshot();
|
|
1081
1109
|
const context = snapshot.context;
|
|
1082
|
-
const state = normalizePlaybookSnapshot(snapshot
|
|
1110
|
+
const state = normalizePlaybookSnapshot(snapshot, {
|
|
1111
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
1112
|
+
});
|
|
1083
1113
|
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
1114
|
+
if (pendingQuestions.length === 0 &&
|
|
1115
|
+
(snapshot.status === 'done' ||
|
|
1116
|
+
state.activeStateIds.includes('ready') ||
|
|
1117
|
+
state.activeStateIds.includes('failed'))) {
|
|
1118
|
+
return { type: 'START_DECIDE', callerTopic: text };
|
|
1119
|
+
}
|
|
1120
|
+
if (pendingQuestions.length === 0)
|
|
1121
|
+
return null;
|
|
1084
1122
|
const prompt = buildClassifierPrompt(text, {
|
|
1085
1123
|
state,
|
|
1086
1124
|
pendingQuestions,
|
|
1087
1125
|
});
|
|
1088
1126
|
const raw = await callJudge(prompt, signal, 'boss-input-classification', state.stateId);
|
|
1089
|
-
return parseClassification(raw, pendingQuestions.map(({ questionId }) => questionId));
|
|
1127
|
+
return parseClassification(raw, text, pendingQuestions.map(({ questionId }) => questionId));
|
|
1090
1128
|
};
|
|
1091
1129
|
const resultForSnapshot = (signal) => {
|
|
1092
1130
|
const live = actor;
|
|
1093
1131
|
if (!live)
|
|
1094
|
-
throw new Error('
|
|
1132
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
1095
1133
|
const snapshot = live.getSnapshot();
|
|
1096
|
-
const
|
|
1134
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1135
|
+
const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
|
|
1097
1136
|
const context = snapshot.context;
|
|
1098
1137
|
if (signal?.aborted) {
|
|
1099
1138
|
return {
|
|
@@ -1121,7 +1160,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1121
1160
|
}
|
|
1122
1161
|
if (snapshot.status === 'error') {
|
|
1123
1162
|
throw (snapshot.error ??
|
|
1124
|
-
new Error('
|
|
1163
|
+
new Error('decide runtime actor entered error status'));
|
|
1125
1164
|
}
|
|
1126
1165
|
if (state.activeStateIds.includes('failed')) {
|
|
1127
1166
|
const error = normalizeErrorFull(context.lastError);
|
|
@@ -1131,6 +1170,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1131
1170
|
...(error === undefined ? {} : { error }),
|
|
1132
1171
|
};
|
|
1133
1172
|
}
|
|
1173
|
+
if (pendingCall) {
|
|
1174
|
+
return { outcome: 'suspended', state, pendingCall };
|
|
1175
|
+
}
|
|
1134
1176
|
return { outcome: 'quiescent', state };
|
|
1135
1177
|
};
|
|
1136
1178
|
// Shared failed-start cleanup for init and restore: stop the actor,
|
|
@@ -1194,6 +1236,8 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1194
1236
|
turnSequence = 0;
|
|
1195
1237
|
judgeCallSequence = 0;
|
|
1196
1238
|
playerCallSequence = 0;
|
|
1239
|
+
playbookCallSequence = 0;
|
|
1240
|
+
playbookCallTurnIds.clear();
|
|
1197
1241
|
lifecycleStarted = false;
|
|
1198
1242
|
};
|
|
1199
1243
|
return {
|
|
@@ -1202,7 +1246,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1202
1246
|
initInFlight !== undefined ||
|
|
1203
1247
|
disposed ||
|
|
1204
1248
|
disposalPromise !== undefined) {
|
|
1205
|
-
throw new Error('
|
|
1249
|
+
throw new Error('decide runtime: init(session) may only be called once');
|
|
1206
1250
|
}
|
|
1207
1251
|
const identity = snapshotPlaybookSession(session);
|
|
1208
1252
|
let finishInitialization;
|
|
@@ -1237,8 +1281,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1237
1281
|
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
|
|
1238
1282
|
// Defined only at a safe capture point — initialized, not disposing
|
|
1239
1283
|
// or disposed, no active public boundary, and the actor quiescent
|
|
1240
|
-
// with status `active
|
|
1241
|
-
// so no pending-call guard applies.
|
|
1284
|
+
// with status `active` and no pending nested REVIEW call.
|
|
1242
1285
|
exportSnapshot() {
|
|
1243
1286
|
if (!actor ||
|
|
1244
1287
|
!sessionIdentity ||
|
|
@@ -1249,6 +1292,8 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1249
1292
|
if (currentTurnId !== undefined || currentSignal !== undefined) {
|
|
1250
1293
|
return undefined;
|
|
1251
1294
|
}
|
|
1295
|
+
if (nestedBridge.getPendingCall())
|
|
1296
|
+
return undefined;
|
|
1252
1297
|
const state = currentState();
|
|
1253
1298
|
if (state.status !== 'active' || !state.quiescent)
|
|
1254
1299
|
return undefined;
|
|
@@ -1258,13 +1303,13 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1258
1303
|
schemaVersion: 1,
|
|
1259
1304
|
playbookId: sessionIdentity.playbookId,
|
|
1260
1305
|
machine,
|
|
1261
|
-
playerResumeTokens:
|
|
1306
|
+
playerResumeTokens: snapshotPlayerResumeTokens(),
|
|
1262
1307
|
sequences: {
|
|
1263
1308
|
trace: traceSequence,
|
|
1264
1309
|
turn: turnSequence,
|
|
1265
1310
|
judgeCall: judgeCallSequence,
|
|
1266
1311
|
playerCall: playerCallSequence,
|
|
1267
|
-
playbookCall:
|
|
1312
|
+
playbookCall: playbookCallSequence,
|
|
1268
1313
|
},
|
|
1269
1314
|
state,
|
|
1270
1315
|
pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
|
|
@@ -1285,7 +1330,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1285
1330
|
initInFlight !== undefined ||
|
|
1286
1331
|
disposed ||
|
|
1287
1332
|
disposalPromise !== undefined) {
|
|
1288
|
-
throw new Error('
|
|
1333
|
+
throw new Error('decide runtime: restore(session, snapshot) may only be called once');
|
|
1289
1334
|
}
|
|
1290
1335
|
const identity = snapshotPlaybookSession(session);
|
|
1291
1336
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId);
|
|
@@ -1297,29 +1342,40 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1297
1342
|
lifecycleStarted = true;
|
|
1298
1343
|
ports = identity.ports;
|
|
1299
1344
|
sessionIdentity = identity;
|
|
1345
|
+
let priorExternalPlayerTokens;
|
|
1300
1346
|
try {
|
|
1301
1347
|
traceSequence = boundSnapshot.sequences.trace;
|
|
1302
1348
|
turnSequence = boundSnapshot.sequences.turn;
|
|
1303
1349
|
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
1304
1350
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1351
|
+
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
1352
|
+
if (identity.playerSessions) {
|
|
1353
|
+
priorExternalPlayerTokens = snapshotPlayerResumeTokens();
|
|
1308
1354
|
}
|
|
1355
|
+
restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
|
|
1309
1356
|
suppressInspectionEmissions = true;
|
|
1310
1357
|
createRuntimeActor(boundSnapshot.machine);
|
|
1311
1358
|
actor?.start();
|
|
1312
1359
|
const restoredState = currentState();
|
|
1313
1360
|
if (restoredState.status !== 'active') {
|
|
1314
|
-
throw new Error(`
|
|
1361
|
+
throw new Error(`decide runtime: restored actor status is ${restoredState.status}, expected active`);
|
|
1315
1362
|
}
|
|
1316
1363
|
suppressInspectionEmissions = false;
|
|
1317
1364
|
previousState = restoredState;
|
|
1318
1365
|
await flush();
|
|
1319
1366
|
}
|
|
1320
1367
|
catch (error) {
|
|
1368
|
+
let failure = error;
|
|
1369
|
+
if (priorExternalPlayerTokens !== undefined) {
|
|
1370
|
+
try {
|
|
1371
|
+
identity.playerSessions.restore(priorExternalPlayerTokens);
|
|
1372
|
+
}
|
|
1373
|
+
catch (rollbackError) {
|
|
1374
|
+
failure = new AggregateError([error, rollbackError], 'DECIDE restore and player continuation rollback failed');
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1321
1377
|
await cleanupFailedStart({ emitDisposal: false });
|
|
1322
|
-
throw
|
|
1378
|
+
throw failure;
|
|
1323
1379
|
}
|
|
1324
1380
|
finally {
|
|
1325
1381
|
finishInitialization();
|
|
@@ -1329,14 +1385,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1329
1385
|
},
|
|
1330
1386
|
async handleBossInput(turn) {
|
|
1331
1387
|
if (disposalPromise !== undefined) {
|
|
1332
|
-
throw new Error('
|
|
1388
|
+
throw new Error('decide runtime: runtime is disposing or disposed');
|
|
1333
1389
|
}
|
|
1334
1390
|
requirePorts();
|
|
1335
1391
|
if (!actor) {
|
|
1336
|
-
throw new Error('
|
|
1392
|
+
throw new Error('decide runtime: init(session) must be called before handleBossInput');
|
|
1337
1393
|
}
|
|
1338
|
-
if (
|
|
1339
|
-
throw new Error('
|
|
1394
|
+
if (currentSignal !== undefined) {
|
|
1395
|
+
throw new Error('decide runtime: another runtime turn is active');
|
|
1340
1396
|
}
|
|
1341
1397
|
const turnId = ++turnSequence;
|
|
1342
1398
|
currentTurnId = turnId;
|
|
@@ -1358,6 +1414,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1358
1414
|
await emitBoundaryStatus('No playbook action classified.', state);
|
|
1359
1415
|
result = { outcome: 'no-action', state };
|
|
1360
1416
|
}
|
|
1417
|
+
else if (event.type === 'NO_ACTION') {
|
|
1418
|
+
result = { outcome: 'no-action', state: currentState() };
|
|
1419
|
+
}
|
|
1361
1420
|
else {
|
|
1362
1421
|
await emitBoundaryStatus(event.type, currentState());
|
|
1363
1422
|
if (actor.getSnapshot().status === 'done') {
|
|
@@ -1446,25 +1505,68 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1446
1505
|
if (failures.length === 1)
|
|
1447
1506
|
throw failures[0];
|
|
1448
1507
|
if (failures.length > 1) {
|
|
1449
|
-
throw new AggregateError(failures, '
|
|
1508
|
+
throw new AggregateError(failures, 'decide runtime turn failed');
|
|
1450
1509
|
}
|
|
1451
1510
|
return result;
|
|
1452
1511
|
},
|
|
1453
|
-
async resumePlaybookCall({ callId, }) {
|
|
1512
|
+
async resumePlaybookCall({ callId, result: childResult, signal, }) {
|
|
1454
1513
|
if (disposalPromise !== undefined) {
|
|
1455
|
-
throw new Error('
|
|
1514
|
+
throw new Error('decide runtime: runtime is disposing or disposed');
|
|
1456
1515
|
}
|
|
1457
1516
|
requirePorts();
|
|
1458
1517
|
if (!actor) {
|
|
1459
|
-
throw new Error('
|
|
1518
|
+
throw new Error('decide runtime: init(session) must be called before resumePlaybookCall');
|
|
1519
|
+
}
|
|
1520
|
+
if (currentSignal !== undefined) {
|
|
1521
|
+
throw new Error('decide runtime: another runtime turn is active');
|
|
1522
|
+
}
|
|
1523
|
+
currentTurnId = playbookCallTurnIds.get(callId);
|
|
1524
|
+
currentSignal = signal;
|
|
1525
|
+
controlPlaneError = undefined;
|
|
1526
|
+
let runResult;
|
|
1527
|
+
let operationError;
|
|
1528
|
+
try {
|
|
1529
|
+
await nestedBridge.resume({
|
|
1530
|
+
callId,
|
|
1531
|
+
result: childResult,
|
|
1532
|
+
signal,
|
|
1533
|
+
});
|
|
1460
1534
|
}
|
|
1461
|
-
|
|
1535
|
+
catch (error) {
|
|
1536
|
+
operationError = error;
|
|
1537
|
+
}
|
|
1538
|
+
try {
|
|
1539
|
+
await waitForPlaybookQuiescence(actor, {
|
|
1540
|
+
pendingCalls: nestedBridge,
|
|
1541
|
+
});
|
|
1542
|
+
runResult = resultForSnapshot(signal);
|
|
1543
|
+
}
|
|
1544
|
+
catch (error) {
|
|
1545
|
+
operationError ??= error;
|
|
1546
|
+
}
|
|
1547
|
+
let drainError;
|
|
1548
|
+
try {
|
|
1549
|
+
await drainBoundaryCallsAndEmissions();
|
|
1550
|
+
}
|
|
1551
|
+
catch (error) {
|
|
1552
|
+
drainError = error;
|
|
1553
|
+
}
|
|
1554
|
+
const failure = controlPlaneError ?? drainError ?? operationError;
|
|
1555
|
+
currentSignal = undefined;
|
|
1556
|
+
currentTurnId = undefined;
|
|
1557
|
+
controlPlaneError = undefined;
|
|
1558
|
+
if (failure !== undefined)
|
|
1559
|
+
throw failure;
|
|
1560
|
+
if (runResult === undefined) {
|
|
1561
|
+
throw new Error('decide runtime: playbook resume produced no result');
|
|
1562
|
+
}
|
|
1563
|
+
return runResult;
|
|
1462
1564
|
},
|
|
1463
1565
|
dispose() {
|
|
1464
1566
|
if (disposalPromise !== undefined)
|
|
1465
1567
|
return disposalPromise;
|
|
1466
|
-
if (
|
|
1467
|
-
return Promise.reject(new Error('
|
|
1568
|
+
if (currentSignal !== undefined) {
|
|
1569
|
+
return Promise.reject(new Error('decide runtime: cannot dispose while a runtime turn is active'));
|
|
1468
1570
|
}
|
|
1469
1571
|
disposalPromise = (async () => {
|
|
1470
1572
|
const initialization = initInFlight;
|
|
@@ -1477,6 +1579,12 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1477
1579
|
const finalState = currentState();
|
|
1478
1580
|
const failures = [];
|
|
1479
1581
|
stopActor();
|
|
1582
|
+
try {
|
|
1583
|
+
await nestedBridge.dispose();
|
|
1584
|
+
}
|
|
1585
|
+
catch (error) {
|
|
1586
|
+
collectFailure(failures, error);
|
|
1587
|
+
}
|
|
1480
1588
|
try {
|
|
1481
1589
|
await drainBoundaryCallsAndEmissions();
|
|
1482
1590
|
}
|
|
@@ -1500,6 +1608,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1500
1608
|
}
|
|
1501
1609
|
finally {
|
|
1502
1610
|
playerResumeTokens.clear();
|
|
1611
|
+
playbookCallTurnIds.clear();
|
|
1503
1612
|
inFlightPlayerIds.clear();
|
|
1504
1613
|
activeBoundaryCalls.clear();
|
|
1505
1614
|
activeEmissionCalls.clear();
|
|
@@ -1517,7 +1626,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1517
1626
|
if (failures.length === 1)
|
|
1518
1627
|
throw failures[0];
|
|
1519
1628
|
if (failures.length > 1) {
|
|
1520
|
-
throw new AggregateError(failures, '
|
|
1629
|
+
throw new AggregateError(failures, 'decide runtime disposal failed');
|
|
1521
1630
|
}
|
|
1522
1631
|
})();
|
|
1523
1632
|
return disposalPromise;
|
|
@@ -1538,10 +1647,10 @@ export const _internal = {
|
|
|
1538
1647
|
normalizeErrorCompact,
|
|
1539
1648
|
normalizeErrorFull,
|
|
1540
1649
|
DEFAULT_PLAYER_BINDING,
|
|
1541
|
-
ALIAS_RESOLUTION,
|
|
1542
1650
|
STATE_DESCRIPTIONS,
|
|
1543
|
-
|
|
1544
|
-
|
|
1651
|
+
PLAYER_STATES,
|
|
1652
|
+
PLAYER_STATE_IDS,
|
|
1653
|
+
VERBATIM_PAYLOAD_FIELDS,
|
|
1545
1654
|
BOSS_INTERRUPT_TARGETS,
|
|
1546
1655
|
CONTINUATION_PREAMBLE,
|
|
1547
1656
|
TELEMETRY_TOPIC,
|