@sublang/playbook 4.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 +69 -122
- package/docs/assets/playbook-venn.svg +13 -0
- package/docs/cli.md +43 -26
- package/docs/configuration.md +63 -18
- package/docs/embedding.md +24 -16
- package/package.json +43 -22
- package/reference/sdlc/captain.md +70 -83
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
- package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
- package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
- package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
- package/reference/sdlc/captain.playbook/captain.playbook.js +466 -976
- package/reference/sdlc/captain.playbook/captain.playbook.ts +698 -1001
- 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 -639
- package/reference/sdlc/code.playbook/code.playbook.ts +90 -850
- 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.d.ts +2 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +1877 -251
- package/reference/sdlc/code.playbook/playbook-captain.ts +2385 -352
- 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} +511 -370
- package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +616 -451
- 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 +67 -6
- package/slc/link.md +339 -25
- package/slc/text2gears.md +22 -2
- package/src/runtime.d.ts +36 -1
- package/src/runtime.ts +59 -0
- package/src/xstate-playbook-runtime.d.ts +96 -7
- package/src/xstate-playbook-runtime.js +1018 -49
- package/src/xstate-playbook-runtime.ts +1283 -59
- 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' },
|
|
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
|
-
},
|
|
71
|
+
const PLAYER_STATES = [
|
|
72
|
+
{ stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
|
|
152
73
|
{
|
|
153
|
-
stateId: '
|
|
154
|
-
player: '
|
|
155
|
-
sourceItem: '
|
|
74
|
+
stateId: 'askReviewerProposal',
|
|
75
|
+
player: 'Reviewer',
|
|
76
|
+
sourceItem: 'DECIDE-2',
|
|
156
77
|
},
|
|
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
|
}
|
|
@@ -520,12 +409,15 @@ function normalizeErrorCompact(err) {
|
|
|
520
409
|
function normalizeErrorFull(err) {
|
|
521
410
|
return err === undefined || err === null ? undefined : normalizeError(err);
|
|
522
411
|
}
|
|
412
|
+
// DR-028's unified empty predicate: a missing, empty, or whitespace-only
|
|
413
|
+
// `finalText` on an `ok` player result is the one shape that earns exactly
|
|
414
|
+
// one corrective re-ask before the existing failure path applies. Mirrors
|
|
415
|
+
// the shared engine's predicate (PBRT-9).
|
|
416
|
+
function isEmptyFinalText(finalText) {
|
|
417
|
+
return finalText === undefined || finalText.trim().length === 0;
|
|
418
|
+
}
|
|
523
419
|
function isAbortFailure(error, signal) {
|
|
524
|
-
|
|
525
|
-
return false;
|
|
526
|
-
if (Object.is(error, signal.reason))
|
|
527
|
-
return true;
|
|
528
|
-
return normalizeErrorCompact(error)?.name === 'AbortError';
|
|
420
|
+
return signal.aborted && Object.is(error, signal.reason);
|
|
529
421
|
}
|
|
530
422
|
function pendingQuestionsFromContext(context) {
|
|
531
423
|
const pending = context.pendingBossQuestions;
|
|
@@ -552,17 +444,15 @@ function pendingQuestionsFromContext(context) {
|
|
|
552
444
|
return questions.sort((left, right) => left.questionId.localeCompare(right.questionId));
|
|
553
445
|
}
|
|
554
446
|
const WAIT_STATE_RESUME_IDS = {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
waitHostReconciliationReply: 'hostInitialRound',
|
|
558
|
-
waitParticipantReconciliationReply: 'participantInitialRound',
|
|
447
|
+
waitCoderProposalReply: 'askCoderProposal',
|
|
448
|
+
waitReviewerProposalReply: 'askReviewerProposal',
|
|
559
449
|
};
|
|
560
450
|
const WAIT_STATE_IDS = new Set([
|
|
561
451
|
...Object.keys(WAIT_STATE_RESUME_IDS),
|
|
562
452
|
'awaitBossReply',
|
|
563
453
|
]);
|
|
564
454
|
const STATUS_STATE_IDS = new Set([
|
|
565
|
-
...
|
|
455
|
+
...PLAYER_STATE_IDS,
|
|
566
456
|
...WAIT_STATE_IDS,
|
|
567
457
|
'failed',
|
|
568
458
|
]);
|
|
@@ -575,38 +465,44 @@ function questionForWaitState(stateId, pendingQuestions) {
|
|
|
575
465
|
? pendingQuestions[0]
|
|
576
466
|
: undefined;
|
|
577
467
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
+
}
|
|
585
487
|
}
|
|
586
|
-
if (
|
|
587
|
-
|
|
588
|
-
typeof event === 'boolean' ||
|
|
589
|
-
(typeof event === 'number' && Number.isFinite(event))) {
|
|
590
|
-
return event;
|
|
488
|
+
if (source.output !== undefined) {
|
|
489
|
+
descriptor.output = snapshotJsonValue(source.output, 'FSM event output');
|
|
591
490
|
}
|
|
592
|
-
|
|
491
|
+
if (source.error !== undefined) {
|
|
492
|
+
descriptor.error = snapshotJsonValue(normalizeError(source.error), 'FSM event error');
|
|
493
|
+
}
|
|
494
|
+
return snapshotJsonValue(descriptor, 'FSM event');
|
|
593
495
|
}
|
|
594
496
|
function telemetryPayload(previousState, state, event, context) {
|
|
595
|
-
const eventError = event !== null &&
|
|
596
|
-
typeof event === 'object' &&
|
|
597
|
-
!Array.isArray(event) &&
|
|
598
|
-
'error' in event
|
|
599
|
-
? normalizeErrorFull(event.error)
|
|
600
|
-
: undefined;
|
|
601
497
|
const pendingBossQuestions = pendingQuestionsFromContext(context);
|
|
498
|
+
const prior = previousState ?? state;
|
|
602
499
|
const payload = {
|
|
603
|
-
from:
|
|
500
|
+
from: prior.value,
|
|
604
501
|
to: state.value,
|
|
605
|
-
event:
|
|
606
|
-
previousState:
|
|
502
|
+
event: normalizedTransitionEvent(event),
|
|
503
|
+
previousState: prior,
|
|
607
504
|
state,
|
|
608
505
|
...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
|
|
609
|
-
...(eventError !== undefined ? { error: eventError } : {}),
|
|
610
506
|
...(state.activeStateIds.includes('failed')
|
|
611
507
|
? { lastError: normalizeErrorFull(context.lastError) ?? null }
|
|
612
508
|
: {}),
|
|
@@ -615,15 +511,13 @@ function telemetryPayload(previousState, state, event, context) {
|
|
|
615
511
|
return payload;
|
|
616
512
|
}
|
|
617
513
|
export const createPlaybookRuntime = (options) => {
|
|
618
|
-
const boundOptions =
|
|
514
|
+
const boundOptions = snapshotDecideRuntimeOptions(options);
|
|
619
515
|
const binding = {
|
|
620
516
|
...DEFAULT_PLAYER_BINDING,
|
|
621
517
|
...(boundOptions.playerBinding ?? {}),
|
|
622
518
|
};
|
|
623
519
|
const fsmInput = {
|
|
624
|
-
|
|
625
|
-
participant: boundOptions.participant,
|
|
626
|
-
committer: boundOptions.committer,
|
|
520
|
+
coderLlm: boundOptions.coderLlm,
|
|
627
521
|
};
|
|
628
522
|
let ports;
|
|
629
523
|
let sessionIdentity;
|
|
@@ -637,12 +531,15 @@ export const createPlaybookRuntime = (options) => {
|
|
|
637
531
|
let turnSequence = 0;
|
|
638
532
|
let judgeCallSequence = 0;
|
|
639
533
|
let playerCallSequence = 0;
|
|
534
|
+
let playbookCallSequence = 0;
|
|
640
535
|
let lifecycleStarted = false;
|
|
641
536
|
let initInFlight;
|
|
642
537
|
let disposed = false;
|
|
643
538
|
let disposalPromise;
|
|
644
539
|
let controlPlaneError;
|
|
540
|
+
let nestedBridge;
|
|
645
541
|
const playerResumeTokens = new Map();
|
|
542
|
+
const playbookCallTurnIds = new Map();
|
|
646
543
|
const inFlightPlayerIds = new Set();
|
|
647
544
|
const activeBoundaryCalls = new Set();
|
|
648
545
|
const activeEmissionCalls = new Set();
|
|
@@ -662,6 +559,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
662
559
|
if (!isAbortFailure(error, signal))
|
|
663
560
|
controlPlaneError ??= error;
|
|
664
561
|
};
|
|
562
|
+
const latchInspectionError = (error) => {
|
|
563
|
+
if (currentSignal !== undefined) {
|
|
564
|
+
latchControlPlaneError(error, currentSignal);
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
collectFailure(emissionFailures, error);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
665
570
|
const enqueue = (fn) => {
|
|
666
571
|
const queued = emissionQueue.add(fn);
|
|
667
572
|
activeEmissionCalls.add(queued);
|
|
@@ -689,7 +594,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
689
594
|
emissionFailures = [];
|
|
690
595
|
if (failures.length === 1)
|
|
691
596
|
throw failures[0];
|
|
692
|
-
throw new AggregateError(failures, '
|
|
597
|
+
throw new AggregateError(failures, 'decide runtime emissions failed');
|
|
693
598
|
};
|
|
694
599
|
const drainBoundaryCallsAndEmissions = async () => {
|
|
695
600
|
while (true) {
|
|
@@ -708,22 +613,78 @@ export const createPlaybookRuntime = (options) => {
|
|
|
708
613
|
};
|
|
709
614
|
const requirePorts = () => {
|
|
710
615
|
if (!ports) {
|
|
711
|
-
throw new Error('
|
|
616
|
+
throw new Error('decide runtime: init(session) must be called first');
|
|
712
617
|
}
|
|
713
618
|
return ports;
|
|
714
619
|
};
|
|
715
620
|
const requireSessionIdentity = () => {
|
|
716
621
|
if (!sessionIdentity) {
|
|
717
|
-
throw new Error('
|
|
622
|
+
throw new Error('decide runtime: init(session) must be called first');
|
|
718
623
|
}
|
|
719
624
|
return sessionIdentity;
|
|
720
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
|
+
};
|
|
721
680
|
const currentState = () => {
|
|
722
681
|
const live = actor;
|
|
723
682
|
if (!live) {
|
|
724
|
-
throw new Error('
|
|
683
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
725
684
|
}
|
|
726
|
-
return normalizePlaybookSnapshot(live.getSnapshot()
|
|
685
|
+
return normalizePlaybookSnapshot(live.getSnapshot(), {
|
|
686
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
687
|
+
});
|
|
727
688
|
};
|
|
728
689
|
const stateIdentity = (state) => {
|
|
729
690
|
return state.stateId === undefined ? {} : { stateId: state.stateId };
|
|
@@ -795,7 +756,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
795
756
|
...(callStateId !== undefined ? { stateId: callStateId } : {}),
|
|
796
757
|
};
|
|
797
758
|
const queued = await judgeQueue.add(async () => {
|
|
798
|
-
// Keep the complete queue task pending until an active
|
|
759
|
+
// Keep the complete queue task pending until an active coder promise
|
|
799
760
|
// settles. PQueue's signal option may reject add() while that task is
|
|
800
761
|
// still running, which would let the turn drain race a late finish.
|
|
801
762
|
signal.throwIfAborted();
|
|
@@ -839,9 +800,16 @@ export const createPlaybookRuntime = (options) => {
|
|
|
839
800
|
if (inFlightPlayerIds.has(playerId)) {
|
|
840
801
|
throw new Error(`resolved player "${playerId}" already has an in-flight call`);
|
|
841
802
|
}
|
|
842
|
-
inFlightPlayerIds.add(playerId);
|
|
843
803
|
const prompt = composePlayerPrompt(input);
|
|
844
|
-
|
|
804
|
+
let resume;
|
|
805
|
+
try {
|
|
806
|
+
signal.throwIfAborted();
|
|
807
|
+
resume = selectPlayerResume(playerId);
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
latchControlPlaneError(error, signal);
|
|
811
|
+
throw error;
|
|
812
|
+
}
|
|
845
813
|
const callId = `player-${++playerCallSequence}`;
|
|
846
814
|
const identity = {
|
|
847
815
|
purpose: 'captain',
|
|
@@ -858,6 +826,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
858
826
|
message: String(error),
|
|
859
827
|
},
|
|
860
828
|
}, { turnId: currentTurnId, callId });
|
|
829
|
+
inFlightPlayerIds.add(playerId);
|
|
861
830
|
try {
|
|
862
831
|
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
|
|
863
832
|
let rawResult;
|
|
@@ -865,14 +834,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
865
834
|
signal.throwIfAborted();
|
|
866
835
|
const boundary = Promise.resolve(requirePorts().callPlayer(playerId, prompt, signal, { resume }));
|
|
867
836
|
rawResult = await boundary;
|
|
868
|
-
// An XState sibling cancellation does not cancel an arbitrary
|
|
837
|
+
// An XState sibling cancellation does not cancel an arbitrary coder
|
|
869
838
|
// promise. Re-check before a late resolution can mutate continuity or
|
|
870
839
|
// masquerade as a successful boundary finish.
|
|
871
840
|
signal.throwIfAborted();
|
|
872
841
|
}
|
|
873
842
|
catch (error) {
|
|
874
843
|
// A rejected call produced no authoritative result, so the previous
|
|
875
|
-
// token remains untouched. This also covers a
|
|
844
|
+
// token remains untouched. This also covers a coder promise that
|
|
876
845
|
// resolves after its invocation signal was cancelled.
|
|
877
846
|
latchControlPlaneError(error, signal);
|
|
878
847
|
try {
|
|
@@ -893,18 +862,27 @@ export const createPlaybookRuntime = (options) => {
|
|
|
893
862
|
await emitFailure(error);
|
|
894
863
|
}
|
|
895
864
|
catch {
|
|
896
|
-
// The malformed
|
|
865
|
+
// The malformed coder result remains authoritative.
|
|
897
866
|
}
|
|
898
867
|
throw error;
|
|
899
868
|
}
|
|
900
869
|
// The resolved result is authoritative even on aborted/error status.
|
|
901
870
|
// Update continuation state before interpreting that status.
|
|
902
|
-
|
|
903
|
-
result.resumeToken
|
|
904
|
-
|
|
871
|
+
try {
|
|
872
|
+
updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
|
|
873
|
+
result.resumeToken.trim().length > 0
|
|
874
|
+
? result.resumeToken
|
|
875
|
+
: undefined);
|
|
905
876
|
}
|
|
906
|
-
|
|
907
|
-
|
|
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;
|
|
908
886
|
}
|
|
909
887
|
// Keep this finish outside the boundary catch. A trace sink can record
|
|
910
888
|
// the event and then reject; retrying from that catch would duplicate the
|
|
@@ -944,24 +922,85 @@ export const createPlaybookRuntime = (options) => {
|
|
|
944
922
|
throw error;
|
|
945
923
|
}
|
|
946
924
|
combined.throwIfAborted();
|
|
947
|
-
|
|
925
|
+
let { playerId, result } = await callPlayer(input, combined);
|
|
926
|
+
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
927
|
+
// DR-028: an `ok` result whose finalText is missing, empty, or
|
|
928
|
+
// whitespace-only earns exactly one corrective re-ask — the same
|
|
929
|
+
// composed call repeated, traced by runPlayerCall as its own
|
|
930
|
+
// player-call pair, with the resume selection re-read from the
|
|
931
|
+
// token map the first result left (PBRT-38). An abort that lands
|
|
932
|
+
// between the two calls ends the turn without the re-ask (aborts
|
|
933
|
+
// are never retried), and a rejecting finish emission rejects
|
|
934
|
+
// `callPlayer` itself, so it never reaches this branch (PBRT-47).
|
|
935
|
+
combined.throwIfAborted();
|
|
936
|
+
({ playerId, result } = await callPlayer(input, combined));
|
|
937
|
+
}
|
|
948
938
|
if (result.status !== 'ok') {
|
|
949
939
|
throw new Error(`player "${playerId}" returned status "${result.status}"${result.error ? `: ${result.error}` : ''}`);
|
|
950
940
|
}
|
|
951
|
-
|
|
941
|
+
const finalText = result.finalText ?? '';
|
|
942
|
+
if (isEmptyFinalText(finalText)) {
|
|
952
943
|
throw new Error(`player "${playerId}" returned status "ok" with no finalText`);
|
|
953
944
|
}
|
|
954
945
|
combined.throwIfAborted();
|
|
955
946
|
try {
|
|
956
|
-
const prompt = buildAdjudicatorPrompt(input,
|
|
957
|
-
return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input);
|
|
947
|
+
const prompt = buildAdjudicatorPrompt(input, finalText);
|
|
948
|
+
return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
|
|
958
949
|
}
|
|
959
950
|
catch (error) {
|
|
960
951
|
latchControlPlaneError(error, combined);
|
|
961
952
|
throw error;
|
|
962
953
|
}
|
|
963
954
|
});
|
|
964
|
-
|
|
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
|
+
});
|
|
965
1004
|
const inspect = (event) => {
|
|
966
1005
|
if (event.type !== '@xstate.snapshot')
|
|
967
1006
|
return;
|
|
@@ -969,49 +1008,58 @@ export const createPlaybookRuntime = (options) => {
|
|
|
969
1008
|
return;
|
|
970
1009
|
if (suppressInspectionEmissions)
|
|
971
1010
|
return;
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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);
|
|
995
1035
|
};
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
if (priorIds.has(activeStateId) || !STATUS_STATE_IDS.has(activeStateId)) {
|
|
1001
|
-
continue;
|
|
1002
|
-
}
|
|
1003
|
-
if (WAIT_STATE_IDS.has(activeStateId)) {
|
|
1004
|
-
const pending = questionForWaitState(activeStateId, pendingQuestions);
|
|
1005
|
-
if (pending) {
|
|
1006
|
-
scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
|
|
1007
|
-
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;
|
|
1008
1040
|
}
|
|
1009
|
-
|
|
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 });
|
|
1010
1059
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
scheduleStatus(STATE_DESCRIPTIONS[activeStateId] ?? activeStateId, activeStateId, lastError === undefined ? undefined : { lastError });
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
latchInspectionError(error);
|
|
1015
1063
|
}
|
|
1016
1064
|
};
|
|
1017
1065
|
const createRuntimeActor = (machineSnapshot) => {
|
|
@@ -1028,37 +1076,63 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1028
1076
|
inspect,
|
|
1029
1077
|
});
|
|
1030
1078
|
};
|
|
1079
|
+
// PBRT-6: the single seam that stops this runtime's actor. Stopping a
|
|
1080
|
+
// still-running actor fires one more `@xstate.snapshot` for the *unchanged*
|
|
1081
|
+
// state value with `status: 'stopped'`; `inspect` cannot tell that disposal
|
|
1082
|
+
// artifact from a state entry, so unsuppressed it re-emits the parked
|
|
1083
|
+
// state's telemetry and a phantom self-loop transition. Suppression is a
|
|
1084
|
+
// property of stopping, not a rule each caller must remember — every stop
|
|
1085
|
+
// goes through here so no later site can reintroduce the omission.
|
|
1086
|
+
const stopActor = () => {
|
|
1087
|
+
if (!actor)
|
|
1088
|
+
return;
|
|
1089
|
+
suppressInspectionEmissions = true;
|
|
1090
|
+
actor.stop();
|
|
1091
|
+
};
|
|
1031
1092
|
const startActor = () => {
|
|
1032
1093
|
createRuntimeActor();
|
|
1094
|
+
// A fresh actor's emissions are real state entries again.
|
|
1095
|
+
suppressInspectionEmissions = false;
|
|
1033
1096
|
actor?.start();
|
|
1034
1097
|
};
|
|
1035
1098
|
const driveToQuiescence = async () => {
|
|
1036
1099
|
const live = actor;
|
|
1037
1100
|
if (!live)
|
|
1038
|
-
throw new Error('
|
|
1039
|
-
await waitForPlaybookQuiescence(live);
|
|
1101
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
1102
|
+
await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
|
|
1040
1103
|
};
|
|
1041
1104
|
const classify = async (text, signal) => {
|
|
1042
1105
|
const live = actor;
|
|
1043
1106
|
if (!live)
|
|
1044
|
-
throw new Error('
|
|
1107
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
1045
1108
|
const snapshot = live.getSnapshot();
|
|
1046
1109
|
const context = snapshot.context;
|
|
1047
|
-
const state = normalizePlaybookSnapshot(snapshot
|
|
1110
|
+
const state = normalizePlaybookSnapshot(snapshot, {
|
|
1111
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
1112
|
+
});
|
|
1048
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;
|
|
1049
1122
|
const prompt = buildClassifierPrompt(text, {
|
|
1050
1123
|
state,
|
|
1051
1124
|
pendingQuestions,
|
|
1052
1125
|
});
|
|
1053
1126
|
const raw = await callJudge(prompt, signal, 'boss-input-classification', state.stateId);
|
|
1054
|
-
return parseClassification(raw, pendingQuestions.map(({ questionId }) => questionId));
|
|
1127
|
+
return parseClassification(raw, text, pendingQuestions.map(({ questionId }) => questionId));
|
|
1055
1128
|
};
|
|
1056
1129
|
const resultForSnapshot = (signal) => {
|
|
1057
1130
|
const live = actor;
|
|
1058
1131
|
if (!live)
|
|
1059
|
-
throw new Error('
|
|
1132
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
1060
1133
|
const snapshot = live.getSnapshot();
|
|
1061
|
-
const
|
|
1134
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1135
|
+
const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
|
|
1062
1136
|
const context = snapshot.context;
|
|
1063
1137
|
if (signal?.aborted) {
|
|
1064
1138
|
return {
|
|
@@ -1086,7 +1160,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1086
1160
|
}
|
|
1087
1161
|
if (snapshot.status === 'error') {
|
|
1088
1162
|
throw (snapshot.error ??
|
|
1089
|
-
new Error('
|
|
1163
|
+
new Error('decide runtime actor entered error status'));
|
|
1090
1164
|
}
|
|
1091
1165
|
if (state.activeStateIds.includes('failed')) {
|
|
1092
1166
|
const error = normalizeErrorFull(context.lastError);
|
|
@@ -1096,6 +1170,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1096
1170
|
...(error === undefined ? {} : { error }),
|
|
1097
1171
|
};
|
|
1098
1172
|
}
|
|
1173
|
+
if (pendingCall) {
|
|
1174
|
+
return { outcome: 'suspended', state, pendingCall };
|
|
1175
|
+
}
|
|
1099
1176
|
return { outcome: 'quiescent', state };
|
|
1100
1177
|
};
|
|
1101
1178
|
// Shared failed-start cleanup for init and restore: stop the actor,
|
|
@@ -1114,9 +1191,8 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1114
1191
|
// A state that cannot even normalize has no disposal descriptor.
|
|
1115
1192
|
}
|
|
1116
1193
|
}
|
|
1117
|
-
suppressInspectionEmissions = true;
|
|
1118
1194
|
try {
|
|
1119
|
-
|
|
1195
|
+
stopActor();
|
|
1120
1196
|
}
|
|
1121
1197
|
catch {
|
|
1122
1198
|
// Preserve the original startup failure.
|
|
@@ -1160,6 +1236,8 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1160
1236
|
turnSequence = 0;
|
|
1161
1237
|
judgeCallSequence = 0;
|
|
1162
1238
|
playerCallSequence = 0;
|
|
1239
|
+
playbookCallSequence = 0;
|
|
1240
|
+
playbookCallTurnIds.clear();
|
|
1163
1241
|
lifecycleStarted = false;
|
|
1164
1242
|
};
|
|
1165
1243
|
return {
|
|
@@ -1168,7 +1246,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1168
1246
|
initInFlight !== undefined ||
|
|
1169
1247
|
disposed ||
|
|
1170
1248
|
disposalPromise !== undefined) {
|
|
1171
|
-
throw new Error('
|
|
1249
|
+
throw new Error('decide runtime: init(session) may only be called once');
|
|
1172
1250
|
}
|
|
1173
1251
|
const identity = snapshotPlaybookSession(session);
|
|
1174
1252
|
let finishInitialization;
|
|
@@ -1203,8 +1281,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1203
1281
|
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
|
|
1204
1282
|
// Defined only at a safe capture point — initialized, not disposing
|
|
1205
1283
|
// or disposed, no active public boundary, and the actor quiescent
|
|
1206
|
-
// with status `active
|
|
1207
|
-
// so no pending-call guard applies.
|
|
1284
|
+
// with status `active` and no pending nested REVIEW call.
|
|
1208
1285
|
exportSnapshot() {
|
|
1209
1286
|
if (!actor ||
|
|
1210
1287
|
!sessionIdentity ||
|
|
@@ -1215,6 +1292,8 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1215
1292
|
if (currentTurnId !== undefined || currentSignal !== undefined) {
|
|
1216
1293
|
return undefined;
|
|
1217
1294
|
}
|
|
1295
|
+
if (nestedBridge.getPendingCall())
|
|
1296
|
+
return undefined;
|
|
1218
1297
|
const state = currentState();
|
|
1219
1298
|
if (state.status !== 'active' || !state.quiescent)
|
|
1220
1299
|
return undefined;
|
|
@@ -1224,13 +1303,13 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1224
1303
|
schemaVersion: 1,
|
|
1225
1304
|
playbookId: sessionIdentity.playbookId,
|
|
1226
1305
|
machine,
|
|
1227
|
-
playerResumeTokens:
|
|
1306
|
+
playerResumeTokens: snapshotPlayerResumeTokens(),
|
|
1228
1307
|
sequences: {
|
|
1229
1308
|
trace: traceSequence,
|
|
1230
1309
|
turn: turnSequence,
|
|
1231
1310
|
judgeCall: judgeCallSequence,
|
|
1232
1311
|
playerCall: playerCallSequence,
|
|
1233
|
-
playbookCall:
|
|
1312
|
+
playbookCall: playbookCallSequence,
|
|
1234
1313
|
},
|
|
1235
1314
|
state,
|
|
1236
1315
|
pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
|
|
@@ -1251,7 +1330,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1251
1330
|
initInFlight !== undefined ||
|
|
1252
1331
|
disposed ||
|
|
1253
1332
|
disposalPromise !== undefined) {
|
|
1254
|
-
throw new Error('
|
|
1333
|
+
throw new Error('decide runtime: restore(session, snapshot) may only be called once');
|
|
1255
1334
|
}
|
|
1256
1335
|
const identity = snapshotPlaybookSession(session);
|
|
1257
1336
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId);
|
|
@@ -1263,29 +1342,40 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1263
1342
|
lifecycleStarted = true;
|
|
1264
1343
|
ports = identity.ports;
|
|
1265
1344
|
sessionIdentity = identity;
|
|
1345
|
+
let priorExternalPlayerTokens;
|
|
1266
1346
|
try {
|
|
1267
1347
|
traceSequence = boundSnapshot.sequences.trace;
|
|
1268
1348
|
turnSequence = boundSnapshot.sequences.turn;
|
|
1269
1349
|
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
1270
1350
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1351
|
+
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
1352
|
+
if (identity.playerSessions) {
|
|
1353
|
+
priorExternalPlayerTokens = snapshotPlayerResumeTokens();
|
|
1274
1354
|
}
|
|
1355
|
+
restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
|
|
1275
1356
|
suppressInspectionEmissions = true;
|
|
1276
1357
|
createRuntimeActor(boundSnapshot.machine);
|
|
1277
1358
|
actor?.start();
|
|
1278
1359
|
const restoredState = currentState();
|
|
1279
1360
|
if (restoredState.status !== 'active') {
|
|
1280
|
-
throw new Error(`
|
|
1361
|
+
throw new Error(`decide runtime: restored actor status is ${restoredState.status}, expected active`);
|
|
1281
1362
|
}
|
|
1282
1363
|
suppressInspectionEmissions = false;
|
|
1283
1364
|
previousState = restoredState;
|
|
1284
1365
|
await flush();
|
|
1285
1366
|
}
|
|
1286
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
|
+
}
|
|
1287
1377
|
await cleanupFailedStart({ emitDisposal: false });
|
|
1288
|
-
throw
|
|
1378
|
+
throw failure;
|
|
1289
1379
|
}
|
|
1290
1380
|
finally {
|
|
1291
1381
|
finishInitialization();
|
|
@@ -1295,14 +1385,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1295
1385
|
},
|
|
1296
1386
|
async handleBossInput(turn) {
|
|
1297
1387
|
if (disposalPromise !== undefined) {
|
|
1298
|
-
throw new Error('
|
|
1388
|
+
throw new Error('decide runtime: runtime is disposing or disposed');
|
|
1299
1389
|
}
|
|
1300
1390
|
requirePorts();
|
|
1301
1391
|
if (!actor) {
|
|
1302
|
-
throw new Error('
|
|
1392
|
+
throw new Error('decide runtime: init(session) must be called before handleBossInput');
|
|
1303
1393
|
}
|
|
1304
|
-
if (
|
|
1305
|
-
throw new Error('
|
|
1394
|
+
if (currentSignal !== undefined) {
|
|
1395
|
+
throw new Error('decide runtime: another runtime turn is active');
|
|
1306
1396
|
}
|
|
1307
1397
|
const turnId = ++turnSequence;
|
|
1308
1398
|
currentTurnId = turnId;
|
|
@@ -1324,10 +1414,13 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1324
1414
|
await emitBoundaryStatus('No playbook action classified.', state);
|
|
1325
1415
|
result = { outcome: 'no-action', state };
|
|
1326
1416
|
}
|
|
1417
|
+
else if (event.type === 'NO_ACTION') {
|
|
1418
|
+
result = { outcome: 'no-action', state: currentState() };
|
|
1419
|
+
}
|
|
1327
1420
|
else {
|
|
1328
1421
|
await emitBoundaryStatus(event.type, currentState());
|
|
1329
1422
|
if (actor.getSnapshot().status === 'done') {
|
|
1330
|
-
|
|
1423
|
+
stopActor();
|
|
1331
1424
|
startActor();
|
|
1332
1425
|
}
|
|
1333
1426
|
actor.send(event);
|
|
@@ -1412,25 +1505,68 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1412
1505
|
if (failures.length === 1)
|
|
1413
1506
|
throw failures[0];
|
|
1414
1507
|
if (failures.length > 1) {
|
|
1415
|
-
throw new AggregateError(failures, '
|
|
1508
|
+
throw new AggregateError(failures, 'decide runtime turn failed');
|
|
1416
1509
|
}
|
|
1417
1510
|
return result;
|
|
1418
1511
|
},
|
|
1419
|
-
async resumePlaybookCall({ callId, }) {
|
|
1512
|
+
async resumePlaybookCall({ callId, result: childResult, signal, }) {
|
|
1420
1513
|
if (disposalPromise !== undefined) {
|
|
1421
|
-
throw new Error('
|
|
1514
|
+
throw new Error('decide runtime: runtime is disposing or disposed');
|
|
1422
1515
|
}
|
|
1423
1516
|
requirePorts();
|
|
1424
1517
|
if (!actor) {
|
|
1425
|
-
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
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
catch (error) {
|
|
1536
|
+
operationError = error;
|
|
1426
1537
|
}
|
|
1427
|
-
|
|
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;
|
|
1428
1564
|
},
|
|
1429
1565
|
dispose() {
|
|
1430
1566
|
if (disposalPromise !== undefined)
|
|
1431
1567
|
return disposalPromise;
|
|
1432
|
-
if (
|
|
1433
|
-
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'));
|
|
1434
1570
|
}
|
|
1435
1571
|
disposalPromise = (async () => {
|
|
1436
1572
|
const initialization = initInFlight;
|
|
@@ -1442,8 +1578,12 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1442
1578
|
}
|
|
1443
1579
|
const finalState = currentState();
|
|
1444
1580
|
const failures = [];
|
|
1445
|
-
|
|
1446
|
-
|
|
1581
|
+
stopActor();
|
|
1582
|
+
try {
|
|
1583
|
+
await nestedBridge.dispose();
|
|
1584
|
+
}
|
|
1585
|
+
catch (error) {
|
|
1586
|
+
collectFailure(failures, error);
|
|
1447
1587
|
}
|
|
1448
1588
|
try {
|
|
1449
1589
|
await drainBoundaryCallsAndEmissions();
|
|
@@ -1468,6 +1608,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1468
1608
|
}
|
|
1469
1609
|
finally {
|
|
1470
1610
|
playerResumeTokens.clear();
|
|
1611
|
+
playbookCallTurnIds.clear();
|
|
1471
1612
|
inFlightPlayerIds.clear();
|
|
1472
1613
|
activeBoundaryCalls.clear();
|
|
1473
1614
|
activeEmissionCalls.clear();
|
|
@@ -1485,7 +1626,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1485
1626
|
if (failures.length === 1)
|
|
1486
1627
|
throw failures[0];
|
|
1487
1628
|
if (failures.length > 1) {
|
|
1488
|
-
throw new AggregateError(failures, '
|
|
1629
|
+
throw new AggregateError(failures, 'decide runtime disposal failed');
|
|
1489
1630
|
}
|
|
1490
1631
|
})();
|
|
1491
1632
|
return disposalPromise;
|
|
@@ -1506,10 +1647,10 @@ export const _internal = {
|
|
|
1506
1647
|
normalizeErrorCompact,
|
|
1507
1648
|
normalizeErrorFull,
|
|
1508
1649
|
DEFAULT_PLAYER_BINDING,
|
|
1509
|
-
ALIAS_RESOLUTION,
|
|
1510
1650
|
STATE_DESCRIPTIONS,
|
|
1511
|
-
|
|
1512
|
-
|
|
1651
|
+
PLAYER_STATES,
|
|
1652
|
+
PLAYER_STATE_IDS,
|
|
1653
|
+
VERBATIM_PAYLOAD_FIELDS,
|
|
1513
1654
|
BOSS_INTERRUPT_TARGETS,
|
|
1514
1655
|
CONTINUATION_PREAMBLE,
|
|
1515
1656
|
TELEMETRY_TOPIC,
|