@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.ts → decide.playbook/decide.playbook.ts}
RENAMED
|
@@ -1,24 +1,20 @@
|
|
|
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
|
|
|
23
19
|
import PQueue from 'p-queue';
|
|
24
20
|
import { createActor, fromPromise } from 'xstate';
|
|
@@ -28,6 +24,7 @@ import {
|
|
|
28
24
|
assertJsonSafe,
|
|
29
25
|
assertPlaybookRuntimeSnapshot,
|
|
30
26
|
combineAbortSignals,
|
|
27
|
+
createNestedPlaybookBridge,
|
|
31
28
|
detachPersistedMachineSnapshot,
|
|
32
29
|
normalizeError,
|
|
33
30
|
normalizePlaybookSnapshot,
|
|
@@ -36,14 +33,16 @@ import {
|
|
|
36
33
|
validatePlayerResult,
|
|
37
34
|
waitForPlaybookQuiescence,
|
|
38
35
|
} from '../../../src/xstate-runtime.js';
|
|
36
|
+
import type { NestedPlaybookBridge } from '../../../src/xstate-runtime.js';
|
|
39
37
|
|
|
40
|
-
import
|
|
38
|
+
import decideMachine, {
|
|
41
39
|
type PlayerInput,
|
|
42
40
|
type PlayerOutput,
|
|
43
|
-
type
|
|
44
|
-
type
|
|
41
|
+
type DecideEvent,
|
|
42
|
+
type DecideInput,
|
|
45
43
|
type PendingBossQuestion,
|
|
46
|
-
|
|
44
|
+
type PlaybookInput,
|
|
45
|
+
} from './decide.fsm.js';
|
|
47
46
|
|
|
48
47
|
import type {
|
|
49
48
|
CaptainCallOptions,
|
|
@@ -52,9 +51,13 @@ import type {
|
|
|
52
51
|
NormalizedError,
|
|
53
52
|
PlayerCallOptions,
|
|
54
53
|
PlayerResult,
|
|
54
|
+
PlayerSessionStore,
|
|
55
55
|
PlaybookCallRequest,
|
|
56
56
|
PlaybookCallResult,
|
|
57
57
|
PlaybookCallStart,
|
|
58
|
+
PlaybookControlAction,
|
|
59
|
+
PlaybookControlReceipt,
|
|
60
|
+
PlaybookControlView,
|
|
58
61
|
PlaybookPendingCall,
|
|
59
62
|
PlaybookPorts,
|
|
60
63
|
PlaybookRunResult,
|
|
@@ -75,9 +78,13 @@ export type {
|
|
|
75
78
|
NormalizedError,
|
|
76
79
|
PlayerCallOptions,
|
|
77
80
|
PlayerResult,
|
|
81
|
+
PlayerSessionStore,
|
|
78
82
|
PlaybookCallRequest,
|
|
79
83
|
PlaybookCallResult,
|
|
80
84
|
PlaybookCallStart,
|
|
85
|
+
PlaybookControlAction,
|
|
86
|
+
PlaybookControlReceipt,
|
|
87
|
+
PlaybookControlView,
|
|
81
88
|
PlaybookPendingCall,
|
|
82
89
|
PlaybookPorts,
|
|
83
90
|
PlaybookRunResult,
|
|
@@ -91,68 +98,53 @@ export type {
|
|
|
91
98
|
PlaybookTraceType,
|
|
92
99
|
};
|
|
93
100
|
|
|
94
|
-
type PlayerName = '
|
|
95
|
-
type ReviewScope = 'specItems' | 'decisionRecords' | 'mixed';
|
|
101
|
+
type PlayerName = 'Coder' | 'Reviewer';
|
|
96
102
|
|
|
97
|
-
export interface PlaybookRuntimeOptions extends
|
|
103
|
+
export interface PlaybookRuntimeOptions extends DecideInput {
|
|
98
104
|
playerBinding?: Partial<Record<PlayerName, string>>;
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
const DEFAULT_PLAYER_BINDING: Readonly<Record<PlayerName, string>> = {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
Committer: 'committer',
|
|
108
|
+
Coder: 'coder',
|
|
109
|
+
Reviewer: 'reviewer',
|
|
105
110
|
};
|
|
106
111
|
|
|
107
|
-
|
|
108
|
-
'
|
|
109
|
-
'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
|
|
110
|
-
'DISCUSS-15':
|
|
111
|
-
'Committer = Host | Participant; uses input.committerPlayer when supplied, otherwise Host.',
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
function snapshotDiscussRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
|
|
115
|
-
const captured = snapshotJsonValue(value, 'DISCUSS runtime options');
|
|
112
|
+
function snapshotDecideRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
|
|
113
|
+
const captured = snapshotJsonValue(value, 'DECIDE runtime options');
|
|
116
114
|
if (!isPlainObject(captured)) {
|
|
117
|
-
throw new TypeError('
|
|
115
|
+
throw new TypeError('DECIDE runtime options must be an object');
|
|
118
116
|
}
|
|
119
|
-
const allowed = new Set([
|
|
120
|
-
'host',
|
|
121
|
-
'participant',
|
|
122
|
-
'committer',
|
|
123
|
-
'playerBinding',
|
|
124
|
-
]);
|
|
117
|
+
const allowed = new Set(['coderLlm', 'playerBinding']);
|
|
125
118
|
for (const key of Object.keys(captured)) {
|
|
126
119
|
if (!allowed.has(key)) {
|
|
127
|
-
throw new TypeError(`
|
|
120
|
+
throw new TypeError(`DECIDE runtime options.${key} is not declared`);
|
|
128
121
|
}
|
|
129
122
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
123
|
+
if (
|
|
124
|
+
typeof captured.coderLlm !== 'string' ||
|
|
125
|
+
captured.coderLlm.trim().length === 0
|
|
126
|
+
) {
|
|
127
|
+
throw new TypeError(
|
|
128
|
+
'DECIDE runtime options.coderLlm must be a non-empty string',
|
|
129
|
+
);
|
|
134
130
|
}
|
|
135
131
|
if ('playerBinding' in captured) {
|
|
136
132
|
const playerBinding = captured.playerBinding;
|
|
137
133
|
if (!isPlainObject(playerBinding)) {
|
|
138
134
|
throw new TypeError(
|
|
139
|
-
'
|
|
135
|
+
'DECIDE runtime options.playerBinding must be an object',
|
|
140
136
|
);
|
|
141
137
|
}
|
|
142
|
-
const playerNames = new Set<PlayerName>([
|
|
143
|
-
'Host',
|
|
144
|
-
'Participant',
|
|
145
|
-
'Committer',
|
|
146
|
-
]);
|
|
138
|
+
const playerNames = new Set<PlayerName>(['Coder', 'Reviewer']);
|
|
147
139
|
for (const [player, playerId] of Object.entries(playerBinding)) {
|
|
148
140
|
if (!playerNames.has(player as PlayerName)) {
|
|
149
141
|
throw new TypeError(
|
|
150
|
-
`
|
|
142
|
+
`DECIDE runtime options.playerBinding.${player} is not declared`,
|
|
151
143
|
);
|
|
152
144
|
}
|
|
153
145
|
if (typeof playerId !== 'string' || playerId.trim().length === 0) {
|
|
154
146
|
throw new TypeError(
|
|
155
|
-
`
|
|
147
|
+
`DECIDE runtime options.playerBinding.${player} must be a non-empty string`,
|
|
156
148
|
);
|
|
157
149
|
}
|
|
158
150
|
}
|
|
@@ -161,140 +153,40 @@ function snapshotDiscussRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
|
|
|
161
153
|
}
|
|
162
154
|
|
|
163
155
|
const STATE_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
|
164
|
-
ready: '
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
'
|
|
175
|
-
|
|
176
|
-
'Committer commits the changes produced at the end of initial discussion.',
|
|
177
|
-
reviewSpecInitialCommit:
|
|
178
|
-
'Participant reviews newly committed spec-item changes.',
|
|
179
|
-
reviewSpecHostChanges:
|
|
180
|
-
'Participant reviews Host changes to spec items after findings.',
|
|
181
|
-
reviewDrInitialCommit:
|
|
182
|
-
'Participant reviews newly committed decision-record changes.',
|
|
183
|
-
reviewDrHostChanges:
|
|
184
|
-
'Participant reviews Host changes to decision records after findings.',
|
|
185
|
-
reviewMixedInitialCommit:
|
|
186
|
-
'Participant reviews newly committed mixed spec-item and DR changes.',
|
|
187
|
-
reviewMixedHostChanges:
|
|
188
|
-
'Participant reviews Host changes to mixed spec items and DRs after findings.',
|
|
189
|
-
hostAddressesFindings:
|
|
190
|
-
'Host accepts or challenges review findings and stages repo changes.',
|
|
191
|
-
participantAddressesRebuttals:
|
|
192
|
-
'Participant accepts or challenges Host rebuttals.',
|
|
193
|
-
commitReviewedChanges:
|
|
194
|
-
'Committer commits reviewed changes once Participant raises no findings.',
|
|
195
|
-
awaitBossReply: 'Waiting for Boss to answer a player question.',
|
|
196
|
-
failed: 'The discussion workflow failed and is waiting for Boss recovery.',
|
|
197
|
-
done: 'The discussion workflow completed with a reviewed commit.',
|
|
156
|
+
ready: 'Waiting for a topic to decide.',
|
|
157
|
+
askCoderProposal: 'Coder independently proposes a spec design.',
|
|
158
|
+
askReviewerProposal: 'Reviewer independently proposes a spec design.',
|
|
159
|
+
waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
|
|
160
|
+
waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
|
|
161
|
+
commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
|
|
162
|
+
awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
|
|
163
|
+
reviewCommit: 'REVIEW examines the committed proposal.',
|
|
164
|
+
failed: 'DECIDE failed and is waiting for a new topic.',
|
|
165
|
+
reportedReviewFailure:
|
|
166
|
+
'DECIDE reports REVIEW’s failure and its last commit.',
|
|
167
|
+
done: 'DECIDE completed with an approved commit.',
|
|
198
168
|
};
|
|
199
169
|
|
|
200
|
-
const
|
|
201
|
-
{ stateId: '
|
|
202
|
-
{
|
|
203
|
-
stateId: 'askParticipantInitial',
|
|
204
|
-
player: 'Participant',
|
|
205
|
-
sourceItem: 'DISCUSS-2',
|
|
206
|
-
},
|
|
207
|
-
{ stateId: 'hostInitialRound', player: 'Host', sourceItem: 'DISCUSS-3' },
|
|
208
|
-
{
|
|
209
|
-
stateId: 'participantInitialRound',
|
|
210
|
-
player: 'Participant',
|
|
211
|
-
sourceItem: 'DISCUSS-4',
|
|
212
|
-
},
|
|
213
|
-
{ stateId: 'hostWritesAgreement', player: 'Host', sourceItem: 'DISCUSS-5' },
|
|
214
|
-
{
|
|
215
|
-
stateId: 'commitInitialChanges',
|
|
216
|
-
player: 'Committer',
|
|
217
|
-
sourceItem: 'DISCUSS-14',
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
stateId: 'reviewSpecInitialCommit',
|
|
221
|
-
player: 'Participant',
|
|
222
|
-
sourceItem: 'DISCUSS-6',
|
|
223
|
-
},
|
|
224
|
-
{
|
|
225
|
-
stateId: 'reviewSpecHostChanges',
|
|
226
|
-
player: 'Participant',
|
|
227
|
-
sourceItem: 'DISCUSS-7',
|
|
228
|
-
},
|
|
229
|
-
{
|
|
230
|
-
stateId: 'reviewDrInitialCommit',
|
|
231
|
-
player: 'Participant',
|
|
232
|
-
sourceItem: 'DISCUSS-8',
|
|
233
|
-
},
|
|
234
|
-
{
|
|
235
|
-
stateId: 'reviewDrHostChanges',
|
|
236
|
-
player: 'Participant',
|
|
237
|
-
sourceItem: 'DISCUSS-9',
|
|
238
|
-
},
|
|
239
|
-
{
|
|
240
|
-
stateId: 'reviewMixedInitialCommit',
|
|
241
|
-
player: 'Participant',
|
|
242
|
-
sourceItem: 'DISCUSS-10',
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
stateId: 'reviewMixedHostChanges',
|
|
246
|
-
player: 'Participant',
|
|
247
|
-
sourceItem: 'DISCUSS-11',
|
|
248
|
-
},
|
|
249
|
-
{
|
|
250
|
-
stateId: 'hostAddressesFindings',
|
|
251
|
-
player: 'Host',
|
|
252
|
-
sourceItem: 'DISCUSS-12',
|
|
253
|
-
},
|
|
254
|
-
{
|
|
255
|
-
stateId: 'participantAddressesRebuttals',
|
|
256
|
-
player: 'Participant',
|
|
257
|
-
sourceItem: 'DISCUSS-13',
|
|
258
|
-
},
|
|
170
|
+
const PLAYER_STATES = [
|
|
171
|
+
{ stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
|
|
259
172
|
{
|
|
260
|
-
stateId: '
|
|
261
|
-
player: '
|
|
262
|
-
sourceItem: '
|
|
173
|
+
stateId: 'askReviewerProposal',
|
|
174
|
+
player: 'Reviewer',
|
|
175
|
+
sourceItem: 'DECIDE-2',
|
|
263
176
|
},
|
|
177
|
+
{ stateId: 'commitCoderProposal', player: 'Coder', sourceItem: 'DECIDE-3' },
|
|
264
178
|
] as const;
|
|
265
179
|
|
|
266
|
-
const
|
|
267
|
-
|
|
180
|
+
const PLAYER_STATE_IDS: ReadonlySet<string> = new Set(
|
|
181
|
+
PLAYER_STATES.map((state) => state.stateId),
|
|
268
182
|
);
|
|
269
183
|
|
|
270
|
-
const BOSS_INTERRUPT_TARGETS = [
|
|
271
|
-
'ready',
|
|
272
|
-
'initialProposalRound',
|
|
273
|
-
'reconciliationRound',
|
|
274
|
-
'hostWritesAgreement',
|
|
275
|
-
'commitInitialChanges',
|
|
276
|
-
'reviewSpecInitialCommit',
|
|
277
|
-
'reviewSpecHostChanges',
|
|
278
|
-
'reviewDrInitialCommit',
|
|
279
|
-
'reviewDrHostChanges',
|
|
280
|
-
'reviewMixedInitialCommit',
|
|
281
|
-
'reviewMixedHostChanges',
|
|
282
|
-
'hostAddressesFindings',
|
|
283
|
-
'participantAddressesRebuttals',
|
|
284
|
-
'commitReviewedChanges',
|
|
285
|
-
'failed',
|
|
286
|
-
] as const;
|
|
184
|
+
const BOSS_INTERRUPT_TARGETS = ['independentProposals'] as const;
|
|
287
185
|
|
|
288
186
|
const BOSS_INTERRUPT_TARGET_IDS: ReadonlySet<string> = new Set(
|
|
289
187
|
BOSS_INTERRUPT_TARGETS,
|
|
290
188
|
);
|
|
291
189
|
|
|
292
|
-
const REVIEW_SCOPES: ReadonlySet<string> = new Set([
|
|
293
|
-
'specItems',
|
|
294
|
-
'decisionRecords',
|
|
295
|
-
'mixed',
|
|
296
|
-
]);
|
|
297
|
-
|
|
298
190
|
const TELEMETRY_TOPIC = 'playbook.fsm.state';
|
|
299
191
|
const TRACE_TOPIC = 'playbook.trace';
|
|
300
192
|
|
|
@@ -303,19 +195,16 @@ const CONTINUATION_PREAMBLE =
|
|
|
303
195
|
|
|
304
196
|
const PLACEHOLDER_FIELDS: ReadonlyArray<readonly [string, keyof PlayerInput]> =
|
|
305
197
|
[
|
|
306
|
-
['<topic>', '
|
|
307
|
-
['<
|
|
308
|
-
['<host-previous-proposal>', 'hostProposal'],
|
|
309
|
-
['<host-proposal>', 'hostProposal'],
|
|
310
|
-
['<participant-previous-proposal>', 'participantProposal'],
|
|
311
|
-
['<agreement>', 'agreement'],
|
|
312
|
-
['<changes>', 'latestChanges'],
|
|
313
|
-
['<review-items>', 'reviewItems'],
|
|
314
|
-
['<rebuttals>', 'rebuttals'],
|
|
315
|
-
['<host-llm>', 'hostLlm'],
|
|
316
|
-
['<participant-llm>', 'participantLlm'],
|
|
198
|
+
['<caller-topic>', 'callerTopic'],
|
|
199
|
+
['<coder-llm>', 'coderLlm'],
|
|
317
200
|
];
|
|
318
201
|
|
|
202
|
+
const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
|
|
203
|
+
'coderProposal',
|
|
204
|
+
'reviewerProposal',
|
|
205
|
+
'coderOutput',
|
|
206
|
+
]);
|
|
207
|
+
|
|
319
208
|
function composePlayerPrompt(input: PlayerInput): string {
|
|
320
209
|
const blocks: string[] = [];
|
|
321
210
|
|
|
@@ -333,13 +222,22 @@ function composePlayerPrompt(input: PlayerInput): string {
|
|
|
333
222
|
);
|
|
334
223
|
}
|
|
335
224
|
|
|
336
|
-
|
|
225
|
+
const replacements = new Map<string, string>();
|
|
337
226
|
for (const [placeholder, field] of PLACEHOLDER_FIELDS) {
|
|
338
227
|
const value = input[field];
|
|
339
|
-
if (typeof value === 'string')
|
|
340
|
-
body = body.replaceAll(placeholder, value);
|
|
341
|
-
}
|
|
228
|
+
if (typeof value === 'string') replacements.set(placeholder, value);
|
|
342
229
|
}
|
|
230
|
+
const body = input.prompt.replace(
|
|
231
|
+
/<caller-topic>|<coder-llm>/g,
|
|
232
|
+
(placeholder, offset: number, source: string) => {
|
|
233
|
+
const value = replacements.get(placeholder);
|
|
234
|
+
if (value === undefined) return placeholder;
|
|
235
|
+
const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
|
|
236
|
+
return source.slice(lineStart, offset) === '> '
|
|
237
|
+
? value.replaceAll('\n', '\n> ')
|
|
238
|
+
: value;
|
|
239
|
+
},
|
|
240
|
+
);
|
|
343
241
|
|
|
344
242
|
blocks.push(body);
|
|
345
243
|
return blocks.join('\n\n');
|
|
@@ -350,12 +248,10 @@ function resolvePlayerId(
|
|
|
350
248
|
binding: Record<PlayerName, string>,
|
|
351
249
|
): string {
|
|
352
250
|
switch (input.player) {
|
|
353
|
-
case '
|
|
354
|
-
return binding.
|
|
355
|
-
case '
|
|
356
|
-
return binding.
|
|
357
|
-
case 'Committer':
|
|
358
|
-
return input.committerPlayer ?? binding.Host;
|
|
251
|
+
case 'Coder':
|
|
252
|
+
return binding.Coder;
|
|
253
|
+
case 'Reviewer':
|
|
254
|
+
return binding.Reviewer;
|
|
359
255
|
default: {
|
|
360
256
|
const exhaustive: never = input.player;
|
|
361
257
|
throw new Error(`unknown player ${String(exhaustive)}`);
|
|
@@ -363,11 +259,8 @@ function resolvePlayerId(
|
|
|
363
259
|
}
|
|
364
260
|
}
|
|
365
261
|
|
|
366
|
-
// A `result` description names required payload fields in
|
|
367
|
-
// "Output shall include ..." sentence.
|
|
368
|
-
// fields — DISCUSS-5's wroteChanges names both `latestChanges` and
|
|
369
|
-
// `reviewScope` — so every backticked `field:` token in the sentence
|
|
370
|
-
// span is required, not just the one adjacent to the phrase.
|
|
262
|
+
// A `result` description names required payload fields in its
|
|
263
|
+
// "Output shall include ..." sentence.
|
|
371
264
|
function requiredFieldsFor(description: string): string[] {
|
|
372
265
|
const fields: string[] = [];
|
|
373
266
|
const sentence = /Output shall include([^.]*)/g;
|
|
@@ -492,7 +385,7 @@ function buildClassifierPrompt(
|
|
|
492
385
|
},
|
|
493
386
|
): string {
|
|
494
387
|
const lines: string[] = [];
|
|
495
|
-
lines.push('You are the Boss-input classifier for the
|
|
388
|
+
lines.push('You are the Boss-input classifier for the decide playbook.');
|
|
496
389
|
lines.push(
|
|
497
390
|
'Classify the Boss message into exactly one FSM event, or into no event.',
|
|
498
391
|
);
|
|
@@ -503,7 +396,7 @@ function buildClassifierPrompt(
|
|
|
503
396
|
lines.push('Pending Boss questions:');
|
|
504
397
|
for (const pending of ctx.pendingQuestions) {
|
|
505
398
|
lines.push(
|
|
506
|
-
`- ${pending.questionId} (${pending.player}
|
|
399
|
+
`- ${pending.questionId} (${pending.player}): ${pending.question}`,
|
|
507
400
|
);
|
|
508
401
|
}
|
|
509
402
|
lines.push(
|
|
@@ -513,98 +406,77 @@ function buildClassifierPrompt(
|
|
|
513
406
|
lines.push('');
|
|
514
407
|
lines.push('Events and payload contracts:');
|
|
515
408
|
lines.push(
|
|
516
|
-
|
|
517
|
-
);
|
|
518
|
-
lines.push(
|
|
519
|
-
'- START_REVIEW: required latestChanges string, required reviewScope string ("specItems" | "decisionRecords" | "mixed"), optional rebuttals string.',
|
|
409
|
+
`- BOSS_INTERRUPT: required targetId string, exactly ${BOSS_INTERRUPT_TARGETS[0]}; the runtime attaches the exact Boss text as bossIntent.`,
|
|
520
410
|
);
|
|
521
411
|
lines.push(
|
|
522
|
-
|
|
523
|
-
);
|
|
524
|
-
lines.push(
|
|
525
|
-
'- BOSS_REPLY: required answer string and questionId when several questions are pending; valid only while at least one Boss question is pending.',
|
|
412
|
+
'- 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.',
|
|
526
413
|
);
|
|
414
|
+
lines.push('- NO_ACTION: no fields.');
|
|
527
415
|
lines.push('');
|
|
528
416
|
lines.push('Boss message:');
|
|
529
417
|
lines.push(text);
|
|
530
418
|
lines.push('');
|
|
531
419
|
lines.push(
|
|
532
|
-
'Reply with a single JSON object: { "
|
|
420
|
+
'Reply with a single JSON object: { "type": "<EVENT_TYPE>", ...declared fields }.',
|
|
533
421
|
);
|
|
534
|
-
lines.push('Use
|
|
422
|
+
lines.push('Use NO_ACTION when no FSM action applies.');
|
|
535
423
|
return lines.join('\n');
|
|
536
424
|
}
|
|
537
425
|
|
|
538
426
|
function parseClassification(
|
|
539
427
|
raw: string,
|
|
428
|
+
text: string,
|
|
540
429
|
pendingQuestionIds: readonly string[] = [],
|
|
541
|
-
):
|
|
430
|
+
): DecideEvent | { type: 'NO_ACTION' } | null {
|
|
542
431
|
const obj = extractJson(raw);
|
|
543
432
|
if (!obj) return null;
|
|
544
|
-
const
|
|
433
|
+
const exactKeys = (...keys: string[]): boolean => {
|
|
434
|
+
const ownKeys = Reflect.ownKeys(obj);
|
|
435
|
+
return (
|
|
436
|
+
ownKeys.length === keys.length &&
|
|
437
|
+
ownKeys.every(
|
|
438
|
+
(key) => typeof key === 'string' && keys.includes(key),
|
|
439
|
+
)
|
|
440
|
+
);
|
|
441
|
+
};
|
|
442
|
+
const eventType = obj.type;
|
|
545
443
|
|
|
546
|
-
if (eventType === '
|
|
547
|
-
|
|
548
|
-
// normally flow in via the machine input (run options).
|
|
549
|
-
if (typeof obj.topic === 'string') {
|
|
550
|
-
return {
|
|
551
|
-
type: 'START_DISCUSSION',
|
|
552
|
-
topic: obj.topic,
|
|
553
|
-
...(typeof obj.hostLlm === 'string' ? { hostLlm: obj.hostLlm } : {}),
|
|
554
|
-
...(typeof obj.participantLlm === 'string'
|
|
555
|
-
? { participantLlm: obj.participantLlm }
|
|
556
|
-
: {}),
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
return null;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
if (eventType === 'START_REVIEW') {
|
|
563
|
-
if (
|
|
564
|
-
typeof obj.latestChanges === 'string' &&
|
|
565
|
-
typeof obj.reviewScope === 'string' &&
|
|
566
|
-
REVIEW_SCOPES.has(obj.reviewScope)
|
|
567
|
-
) {
|
|
568
|
-
return {
|
|
569
|
-
type: 'START_REVIEW',
|
|
570
|
-
latestChanges: obj.latestChanges,
|
|
571
|
-
reviewScope: obj.reviewScope as ReviewScope,
|
|
572
|
-
...(typeof obj.rebuttals === 'string'
|
|
573
|
-
? { rebuttals: obj.rebuttals }
|
|
574
|
-
: {}),
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
return null;
|
|
444
|
+
if (eventType === 'NO_ACTION') {
|
|
445
|
+
return exactKeys('type') ? { type: 'NO_ACTION' } : null;
|
|
578
446
|
}
|
|
579
447
|
|
|
580
448
|
if (eventType === 'BOSS_INTERRUPT') {
|
|
581
449
|
if (
|
|
450
|
+
exactKeys('type', 'targetId') &&
|
|
582
451
|
typeof obj.targetId === 'string' &&
|
|
583
452
|
BOSS_INTERRUPT_TARGET_IDS.has(obj.targetId)
|
|
584
453
|
) {
|
|
585
|
-
return {
|
|
454
|
+
return {
|
|
455
|
+
type: 'BOSS_INTERRUPT',
|
|
456
|
+
targetId: obj.targetId as 'independentProposals',
|
|
457
|
+
bossIntent: text,
|
|
458
|
+
};
|
|
586
459
|
}
|
|
587
460
|
return null;
|
|
588
461
|
}
|
|
589
462
|
|
|
590
463
|
if (eventType === 'BOSS_REPLY') {
|
|
591
|
-
if (
|
|
592
|
-
return null;
|
|
593
|
-
}
|
|
464
|
+
if (pendingQuestionIds.length === 0) return null;
|
|
594
465
|
if (typeof obj.questionId === 'string') {
|
|
595
|
-
return
|
|
466
|
+
return exactKeys('type', 'questionId') &&
|
|
467
|
+
pendingQuestionIds.includes(obj.questionId)
|
|
596
468
|
? {
|
|
597
469
|
type: 'BOSS_REPLY',
|
|
598
470
|
questionId: obj.questionId as never,
|
|
599
|
-
answer:
|
|
471
|
+
answer: text,
|
|
600
472
|
}
|
|
601
473
|
: null;
|
|
602
474
|
}
|
|
603
|
-
return pendingQuestionIds.length === 1
|
|
475
|
+
return exactKeys('type') && pendingQuestionIds.length === 1
|
|
604
476
|
? {
|
|
605
477
|
type: 'BOSS_REPLY',
|
|
606
478
|
questionId: pendingQuestionIds[0] as never,
|
|
607
|
-
answer:
|
|
479
|
+
answer: text,
|
|
608
480
|
}
|
|
609
481
|
: null;
|
|
610
482
|
}
|
|
@@ -639,6 +511,18 @@ function buildAdjudicatorPrompt(
|
|
|
639
511
|
for (const [guard, description] of Object.entries(input.result)) {
|
|
640
512
|
lines.push(`- ${guard}: ${description}`);
|
|
641
513
|
}
|
|
514
|
+
const runtimeOwnedFields = new Set<string>();
|
|
515
|
+
for (const description of Object.values(input.result)) {
|
|
516
|
+
for (const field of requiredFieldsFor(description)) {
|
|
517
|
+
if (VERBATIM_PAYLOAD_FIELDS.has(field)) runtimeOwnedFields.add(field);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (runtimeOwnedFields.size > 0) {
|
|
521
|
+
lines.push('');
|
|
522
|
+
lines.push(
|
|
523
|
+
`The runtime owns these verbatim fields; do not include them in your JSON: ${[...runtimeOwnedFields].join(', ')}.`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
642
526
|
lines.push('');
|
|
643
527
|
lines.push(
|
|
644
528
|
'Reply with a single JSON object: { "guard": "<one of the guard names above>", ...any payload fields the chosen guard description requires }.',
|
|
@@ -646,7 +530,11 @@ function buildAdjudicatorPrompt(
|
|
|
646
530
|
return lines.join('\n');
|
|
647
531
|
}
|
|
648
532
|
|
|
649
|
-
function parseAdjudication(
|
|
533
|
+
function parseAdjudication(
|
|
534
|
+
raw: string,
|
|
535
|
+
input: PlayerInput,
|
|
536
|
+
finalText: string,
|
|
537
|
+
): PlayerOutput {
|
|
650
538
|
const obj = extractJson(raw);
|
|
651
539
|
if (!obj || typeof obj.guard !== 'string' || obj.guard.trim() === '') {
|
|
652
540
|
throw new Error('adjudicator returned empty or malformed JSON');
|
|
@@ -659,15 +547,22 @@ function parseAdjudication(raw: string, input: PlayerInput): PlayerOutput {
|
|
|
659
547
|
);
|
|
660
548
|
}
|
|
661
549
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
550
|
+
const requiredFields = requiredFieldsFor(input.result[guard]);
|
|
551
|
+
const allowedFields = new Set(['guard', ...requiredFields]);
|
|
552
|
+
for (const key of Reflect.ownKeys(obj)) {
|
|
553
|
+
if (typeof key !== 'string' || !allowedFields.has(key)) {
|
|
554
|
+
throw new Error(
|
|
555
|
+
`adjudicator response for guard "${guard}" included undeclared field "${String(key)}"`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const output: Record<string, unknown> = { guard };
|
|
560
|
+
for (const field of requiredFields) {
|
|
561
|
+
if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
|
|
562
|
+
output[field] = finalText;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const value = obj[field];
|
|
671
566
|
if (
|
|
672
567
|
value === undefined ||
|
|
673
568
|
value === null ||
|
|
@@ -677,19 +572,9 @@ function parseAdjudication(raw: string, input: PlayerInput): PlayerOutput {
|
|
|
677
572
|
`adjudicator response for guard "${guard}" missing required field "${field}"`,
|
|
678
573
|
);
|
|
679
574
|
}
|
|
575
|
+
output[field] = value;
|
|
680
576
|
}
|
|
681
|
-
|
|
682
|
-
if (
|
|
683
|
-
'reviewScope' in output &&
|
|
684
|
-
typeof output.reviewScope === 'string' &&
|
|
685
|
-
!REVIEW_SCOPES.has(output.reviewScope)
|
|
686
|
-
) {
|
|
687
|
-
throw new Error(
|
|
688
|
-
`adjudicator returned invalid reviewScope "${output.reviewScope}"`,
|
|
689
|
-
);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
return output;
|
|
577
|
+
return output as PlayerOutput;
|
|
693
578
|
}
|
|
694
579
|
|
|
695
580
|
function combineSignals(
|
|
@@ -722,9 +607,7 @@ function isEmptyFinalText(finalText: string | undefined): boolean {
|
|
|
722
607
|
}
|
|
723
608
|
|
|
724
609
|
function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
|
|
725
|
-
|
|
726
|
-
if (Object.is(error, signal.reason)) return true;
|
|
727
|
-
return normalizeErrorCompact(error)?.name === 'AbortError';
|
|
610
|
+
return signal.aborted && Object.is(error, signal.reason);
|
|
728
611
|
}
|
|
729
612
|
|
|
730
613
|
function pendingQuestionsFromContext(
|
|
@@ -761,10 +644,8 @@ function pendingQuestionsFromContext(
|
|
|
761
644
|
}
|
|
762
645
|
|
|
763
646
|
const WAIT_STATE_RESUME_IDS: Readonly<Record<string, string>> = {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
waitHostReconciliationReply: 'hostInitialRound',
|
|
767
|
-
waitParticipantReconciliationReply: 'participantInitialRound',
|
|
647
|
+
waitCoderProposalReply: 'askCoderProposal',
|
|
648
|
+
waitReviewerProposalReply: 'askReviewerProposal',
|
|
768
649
|
};
|
|
769
650
|
|
|
770
651
|
const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
|
|
@@ -773,7 +654,7 @@ const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
|
|
|
773
654
|
]);
|
|
774
655
|
|
|
775
656
|
const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
|
|
776
|
-
...
|
|
657
|
+
...PLAYER_STATE_IDS,
|
|
777
658
|
...WAIT_STATE_IDS,
|
|
778
659
|
'failed',
|
|
779
660
|
]);
|
|
@@ -793,25 +674,36 @@ function questionForWaitState(
|
|
|
793
674
|
: undefined;
|
|
794
675
|
}
|
|
795
676
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
677
|
+
const TRANSITION_EVENT_FIELDS: readonly string[] = [
|
|
678
|
+
'callerTopic',
|
|
679
|
+
'targetId',
|
|
680
|
+
'bossIntent',
|
|
681
|
+
'questionId',
|
|
682
|
+
'answer',
|
|
683
|
+
];
|
|
684
|
+
|
|
685
|
+
function normalizedTransitionEvent(event: unknown): JsonValue {
|
|
686
|
+
if (event === null || typeof event !== 'object') {
|
|
687
|
+
return snapshotJsonValue(event ?? null, 'FSM event');
|
|
805
688
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
(typeof
|
|
811
|
-
|
|
812
|
-
|
|
689
|
+
const source = event as Record<string, unknown>;
|
|
690
|
+
const descriptor: Record<string, JsonValue> = {};
|
|
691
|
+
if (typeof source.type === 'string') descriptor.type = source.type;
|
|
692
|
+
for (const field of TRANSITION_EVENT_FIELDS) {
|
|
693
|
+
if (typeof source[field] === 'string') {
|
|
694
|
+
descriptor[field] = source[field] as string;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (source.output !== undefined) {
|
|
698
|
+
descriptor.output = snapshotJsonValue(source.output, 'FSM event output');
|
|
699
|
+
}
|
|
700
|
+
if (source.error !== undefined) {
|
|
701
|
+
descriptor.error = snapshotJsonValue(
|
|
702
|
+
normalizeError(source.error),
|
|
703
|
+
'FSM event error',
|
|
704
|
+
);
|
|
813
705
|
}
|
|
814
|
-
return
|
|
706
|
+
return snapshotJsonValue(descriptor, 'FSM event');
|
|
815
707
|
}
|
|
816
708
|
|
|
817
709
|
function telemetryPayload(
|
|
@@ -820,22 +712,15 @@ function telemetryPayload(
|
|
|
820
712
|
event: unknown,
|
|
821
713
|
context: Record<string, unknown>,
|
|
822
714
|
): JsonValue {
|
|
823
|
-
const eventError =
|
|
824
|
-
event !== null &&
|
|
825
|
-
typeof event === 'object' &&
|
|
826
|
-
!Array.isArray(event) &&
|
|
827
|
-
'error' in event
|
|
828
|
-
? normalizeErrorFull((event as { error: unknown }).error)
|
|
829
|
-
: undefined;
|
|
830
715
|
const pendingBossQuestions = pendingQuestionsFromContext(context);
|
|
716
|
+
const prior = previousState ?? state;
|
|
831
717
|
const payload = {
|
|
832
|
-
from:
|
|
718
|
+
from: prior.value,
|
|
833
719
|
to: state.value,
|
|
834
|
-
event:
|
|
835
|
-
previousState:
|
|
720
|
+
event: normalizedTransitionEvent(event),
|
|
721
|
+
previousState: prior,
|
|
836
722
|
state,
|
|
837
723
|
...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
|
|
838
|
-
...(eventError !== undefined ? { error: eventError } : {}),
|
|
839
724
|
...(state.activeStateIds.includes('failed')
|
|
840
725
|
? { lastError: normalizeErrorFull(context.lastError) ?? null }
|
|
841
726
|
: {}),
|
|
@@ -847,15 +732,13 @@ function telemetryPayload(
|
|
|
847
732
|
export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
848
733
|
PlaybookRuntimeOptions
|
|
849
734
|
> = (options) => {
|
|
850
|
-
const boundOptions =
|
|
735
|
+
const boundOptions = snapshotDecideRuntimeOptions(options);
|
|
851
736
|
const binding: Record<PlayerName, string> = {
|
|
852
737
|
...DEFAULT_PLAYER_BINDING,
|
|
853
738
|
...(boundOptions.playerBinding ?? {}),
|
|
854
739
|
};
|
|
855
|
-
const fsmInput:
|
|
856
|
-
|
|
857
|
-
participant: boundOptions.participant,
|
|
858
|
-
committer: boundOptions.committer,
|
|
740
|
+
const fsmInput: DecideInput = {
|
|
741
|
+
coderLlm: boundOptions.coderLlm,
|
|
859
742
|
};
|
|
860
743
|
|
|
861
744
|
type SessionIdentity = Readonly<PlaybookSession>;
|
|
@@ -872,12 +755,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
872
755
|
let turnSequence = 0;
|
|
873
756
|
let judgeCallSequence = 0;
|
|
874
757
|
let playerCallSequence = 0;
|
|
758
|
+
let playbookCallSequence = 0;
|
|
875
759
|
let lifecycleStarted = false;
|
|
876
760
|
let initInFlight: Promise<void> | undefined;
|
|
877
761
|
let disposed = false;
|
|
878
762
|
let disposalPromise: Promise<void> | undefined;
|
|
879
763
|
let controlPlaneError: unknown;
|
|
764
|
+
let nestedBridge: NestedPlaybookBridge<PlaybookInput>;
|
|
880
765
|
const playerResumeTokens = new Map<string, string>();
|
|
766
|
+
const playbookCallTurnIds = new Map<string, number | undefined>();
|
|
881
767
|
const inFlightPlayerIds = new Set<string>();
|
|
882
768
|
const activeBoundaryCalls = new Set<Promise<unknown>>();
|
|
883
769
|
const activeEmissionCalls = new Set<Promise<void>>();
|
|
@@ -899,6 +785,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
899
785
|
): void => {
|
|
900
786
|
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
901
787
|
};
|
|
788
|
+
const latchInspectionError = (error: unknown): void => {
|
|
789
|
+
if (currentSignal !== undefined) {
|
|
790
|
+
latchControlPlaneError(error, currentSignal);
|
|
791
|
+
} else {
|
|
792
|
+
collectFailure(emissionFailures, error);
|
|
793
|
+
}
|
|
794
|
+
};
|
|
902
795
|
const enqueue = (fn: () => Promise<void>): Promise<void> => {
|
|
903
796
|
const queued = emissionQueue.add(fn);
|
|
904
797
|
activeEmissionCalls.add(queued);
|
|
@@ -928,7 +821,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
928
821
|
const failures = emissionFailures;
|
|
929
822
|
emissionFailures = [];
|
|
930
823
|
if (failures.length === 1) throw failures[0];
|
|
931
|
-
throw new AggregateError(failures, '
|
|
824
|
+
throw new AggregateError(failures, 'decide runtime emissions failed');
|
|
932
825
|
};
|
|
933
826
|
const drainBoundaryCallsAndEmissions = async (): Promise<void> => {
|
|
934
827
|
while (true) {
|
|
@@ -949,22 +842,92 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
949
842
|
};
|
|
950
843
|
const requirePorts = (): PlaybookPorts => {
|
|
951
844
|
if (!ports) {
|
|
952
|
-
throw new Error('
|
|
845
|
+
throw new Error('decide runtime: init(session) must be called first');
|
|
953
846
|
}
|
|
954
847
|
return ports;
|
|
955
848
|
};
|
|
956
849
|
const requireSessionIdentity = (): SessionIdentity => {
|
|
957
850
|
if (!sessionIdentity) {
|
|
958
|
-
throw new Error('
|
|
851
|
+
throw new Error('decide runtime: init(session) must be called first');
|
|
959
852
|
}
|
|
960
853
|
return sessionIdentity;
|
|
961
854
|
};
|
|
855
|
+
const selectPlayerResume = (playerId: string): string | false => {
|
|
856
|
+
const session = requireSessionIdentity();
|
|
857
|
+
const selected = session.playerSessions
|
|
858
|
+
? session.playerSessions.select(playerId)
|
|
859
|
+
: playerResumeTokens.get(playerId) ?? false;
|
|
860
|
+
if (
|
|
861
|
+
selected !== false &&
|
|
862
|
+
(typeof selected !== 'string' || selected.trim().length === 0)
|
|
863
|
+
) {
|
|
864
|
+
throw new TypeError(
|
|
865
|
+
`player session store returned an invalid resume token for ${playerId}`,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
return selected;
|
|
869
|
+
};
|
|
870
|
+
const updatePlayerResume = (
|
|
871
|
+
playerId: string,
|
|
872
|
+
resumeToken: string | undefined,
|
|
873
|
+
): void => {
|
|
874
|
+
const session = requireSessionIdentity();
|
|
875
|
+
if (session.playerSessions) {
|
|
876
|
+
session.playerSessions.update(playerId, resumeToken);
|
|
877
|
+
} else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
|
|
878
|
+
playerResumeTokens.set(playerId, resumeToken);
|
|
879
|
+
} else {
|
|
880
|
+
playerResumeTokens.delete(playerId);
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
const snapshotPlayerResumeTokens = (): Record<string, string> => {
|
|
884
|
+
const session = requireSessionIdentity();
|
|
885
|
+
const captured = snapshotJsonValue(
|
|
886
|
+
session.playerSessions
|
|
887
|
+
? session.playerSessions.snapshot()
|
|
888
|
+
: Object.fromEntries(playerResumeTokens),
|
|
889
|
+
'player session store snapshot',
|
|
890
|
+
);
|
|
891
|
+
if (!isPlainObject(captured)) {
|
|
892
|
+
throw new TypeError('player session store snapshot must be an object');
|
|
893
|
+
}
|
|
894
|
+
const tokens: Record<string, string> = {};
|
|
895
|
+
for (const [playerId, token] of Object.entries(captured)) {
|
|
896
|
+
if (playerId.trim().length === 0) {
|
|
897
|
+
throw new TypeError(
|
|
898
|
+
'player session store snapshot player ids must be non-empty',
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
902
|
+
throw new TypeError(
|
|
903
|
+
`player session store snapshot token for ${playerId} must be a non-empty string`,
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
tokens[playerId] = token;
|
|
907
|
+
}
|
|
908
|
+
return tokens;
|
|
909
|
+
};
|
|
910
|
+
const restorePlayerResumeTokens = (
|
|
911
|
+
tokens: Readonly<Record<string, string>>,
|
|
912
|
+
): void => {
|
|
913
|
+
const session = requireSessionIdentity();
|
|
914
|
+
if (session.playerSessions) {
|
|
915
|
+
session.playerSessions.restore(tokens);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
playerResumeTokens.clear();
|
|
919
|
+
for (const [playerId, token] of Object.entries(tokens)) {
|
|
920
|
+
playerResumeTokens.set(playerId, token);
|
|
921
|
+
}
|
|
922
|
+
};
|
|
962
923
|
const currentState = (): PlaybookState => {
|
|
963
924
|
const live = actor;
|
|
964
925
|
if (!live) {
|
|
965
|
-
throw new Error('
|
|
926
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
966
927
|
}
|
|
967
|
-
return normalizePlaybookSnapshot(live.getSnapshot()
|
|
928
|
+
return normalizePlaybookSnapshot(live.getSnapshot(), {
|
|
929
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
930
|
+
});
|
|
968
931
|
};
|
|
969
932
|
const stateIdentity = (state: PlaybookState): { stateId?: string } => {
|
|
970
933
|
return state.stateId === undefined ? {} : { stateId: state.stateId };
|
|
@@ -1070,7 +1033,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1070
1033
|
...(callStateId !== undefined ? { stateId: callStateId } : {}),
|
|
1071
1034
|
};
|
|
1072
1035
|
const queued = await judgeQueue.add(async () => {
|
|
1073
|
-
// Keep the complete queue task pending until an active
|
|
1036
|
+
// Keep the complete queue task pending until an active coder promise
|
|
1074
1037
|
// settles. PQueue's signal option may reject add() while that task is
|
|
1075
1038
|
// still running, which would let the turn drain race a late finish.
|
|
1076
1039
|
signal.throwIfAborted();
|
|
@@ -1142,10 +1105,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1142
1105
|
`resolved player "${playerId}" already has an in-flight call`,
|
|
1143
1106
|
);
|
|
1144
1107
|
}
|
|
1145
|
-
inFlightPlayerIds.add(playerId);
|
|
1146
1108
|
const prompt = composePlayerPrompt(input);
|
|
1147
|
-
|
|
1148
|
-
|
|
1109
|
+
let resume: PlayerCallOptions['resume'];
|
|
1110
|
+
try {
|
|
1111
|
+
signal.throwIfAborted();
|
|
1112
|
+
resume = selectPlayerResume(playerId);
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
latchControlPlaneError(error, signal);
|
|
1115
|
+
throw error;
|
|
1116
|
+
}
|
|
1149
1117
|
const callId = `player-${++playerCallSequence}`;
|
|
1150
1118
|
const identity = {
|
|
1151
1119
|
purpose: 'captain',
|
|
@@ -1167,6 +1135,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1167
1135
|
},
|
|
1168
1136
|
{ turnId: currentTurnId, callId },
|
|
1169
1137
|
);
|
|
1138
|
+
inFlightPlayerIds.add(playerId);
|
|
1170
1139
|
try {
|
|
1171
1140
|
await emitCallStarted(
|
|
1172
1141
|
'player.call.started',
|
|
@@ -1183,13 +1152,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1183
1152
|
requirePorts().callPlayer(playerId, prompt, signal, { resume }),
|
|
1184
1153
|
);
|
|
1185
1154
|
rawResult = await boundary;
|
|
1186
|
-
// An XState sibling cancellation does not cancel an arbitrary
|
|
1155
|
+
// An XState sibling cancellation does not cancel an arbitrary coder
|
|
1187
1156
|
// promise. Re-check before a late resolution can mutate continuity or
|
|
1188
1157
|
// masquerade as a successful boundary finish.
|
|
1189
1158
|
signal.throwIfAborted();
|
|
1190
1159
|
} catch (error) {
|
|
1191
1160
|
// A rejected call produced no authoritative result, so the previous
|
|
1192
|
-
// token remains untouched. This also covers a
|
|
1161
|
+
// token remains untouched. This also covers a coder promise that
|
|
1193
1162
|
// resolves after its invocation signal was cancelled.
|
|
1194
1163
|
latchControlPlaneError(error, signal);
|
|
1195
1164
|
try {
|
|
@@ -1208,20 +1177,29 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1208
1177
|
try {
|
|
1209
1178
|
await emitFailure(error);
|
|
1210
1179
|
} catch {
|
|
1211
|
-
// The malformed
|
|
1180
|
+
// The malformed coder result remains authoritative.
|
|
1212
1181
|
}
|
|
1213
1182
|
throw error;
|
|
1214
1183
|
}
|
|
1215
1184
|
|
|
1216
1185
|
// The resolved result is authoritative even on aborted/error status.
|
|
1217
1186
|
// Update continuation state before interpreting that status.
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1187
|
+
try {
|
|
1188
|
+
updatePlayerResume(
|
|
1189
|
+
playerId,
|
|
1190
|
+
typeof result.resumeToken === 'string' &&
|
|
1191
|
+
result.resumeToken.trim().length > 0
|
|
1192
|
+
? result.resumeToken
|
|
1193
|
+
: undefined,
|
|
1194
|
+
);
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
latchControlPlaneError(error, signal);
|
|
1197
|
+
try {
|
|
1198
|
+
await emitFailure(error);
|
|
1199
|
+
} catch {
|
|
1200
|
+
// The continuation-store failure remains authoritative.
|
|
1201
|
+
}
|
|
1202
|
+
throw error;
|
|
1225
1203
|
}
|
|
1226
1204
|
|
|
1227
1205
|
// Keep this finish outside the boundary catch. A trace sink can record
|
|
@@ -1309,6 +1287,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1309
1287
|
input.stateId,
|
|
1310
1288
|
),
|
|
1311
1289
|
input,
|
|
1290
|
+
finalText,
|
|
1312
1291
|
);
|
|
1313
1292
|
} catch (error) {
|
|
1314
1293
|
latchControlPlaneError(error, combined);
|
|
@@ -1317,89 +1296,162 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1317
1296
|
},
|
|
1318
1297
|
);
|
|
1319
1298
|
|
|
1320
|
-
|
|
1299
|
+
nestedBridge = createNestedPlaybookBridge<PlaybookInput>({
|
|
1300
|
+
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
1301
|
+
getBoundarySignal: () => currentSignal,
|
|
1302
|
+
callPlaybook: (request, signal) =>
|
|
1303
|
+
trackBoundaryCall(
|
|
1304
|
+
Promise.resolve(requirePorts().callPlaybook(request, signal)),
|
|
1305
|
+
),
|
|
1306
|
+
emitStarted: async (event) => {
|
|
1307
|
+
playbookCallTurnIds.set(event.callId, currentTurnId);
|
|
1308
|
+
await emitTrace(
|
|
1309
|
+
'playbook.call.started',
|
|
1310
|
+
{
|
|
1311
|
+
stateId: event.stateId,
|
|
1312
|
+
playbookId: event.playbookId,
|
|
1313
|
+
text: event.text,
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
|
|
1317
|
+
callId: event.callId,
|
|
1318
|
+
},
|
|
1319
|
+
);
|
|
1320
|
+
},
|
|
1321
|
+
emitFinished: async (event) => {
|
|
1322
|
+
const turnId = playbookCallTurnIds.get(event.callId);
|
|
1323
|
+
try {
|
|
1324
|
+
await emitTrace(
|
|
1325
|
+
'playbook.call.finished',
|
|
1326
|
+
{
|
|
1327
|
+
stateId: event.stateId,
|
|
1328
|
+
playbookId: event.playbookId,
|
|
1329
|
+
text: event.text,
|
|
1330
|
+
result: event.result,
|
|
1331
|
+
},
|
|
1332
|
+
{
|
|
1333
|
+
...(turnId === undefined ? {} : { turnId }),
|
|
1334
|
+
callId: event.callId,
|
|
1335
|
+
},
|
|
1336
|
+
);
|
|
1337
|
+
} finally {
|
|
1338
|
+
playbookCallTurnIds.delete(event.callId);
|
|
1339
|
+
}
|
|
1340
|
+
},
|
|
1341
|
+
drain: flush,
|
|
1342
|
+
bindResumeSignal: (signal) => {
|
|
1343
|
+
currentSignal = signal;
|
|
1344
|
+
},
|
|
1345
|
+
onControlPlaneError: (error) => {
|
|
1346
|
+
const signal = currentSignal;
|
|
1347
|
+
if (!signal || !isAbortFailure(error, signal)) {
|
|
1348
|
+
controlPlaneError ??= error;
|
|
1349
|
+
}
|
|
1350
|
+
},
|
|
1351
|
+
onBackgroundError: (error) => {
|
|
1352
|
+
collectFailure(emissionFailures, error);
|
|
1353
|
+
},
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
const providedMachine = decideMachine.provide({
|
|
1357
|
+
actors: { player, playbook: nestedBridge.actorLogic },
|
|
1358
|
+
});
|
|
1321
1359
|
|
|
1322
1360
|
const inspect = (event: InspectionEvent): void => {
|
|
1323
1361
|
if (event.type !== '@xstate.snapshot') return;
|
|
1324
1362
|
if (actor === undefined || event.actorRef !== actor) return;
|
|
1325
1363
|
if (suppressInspectionEmissions) return;
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
fsmPayload,
|
|
1337
|
-
'described FSM telemetry',
|
|
1338
|
-
);
|
|
1364
|
+
try {
|
|
1365
|
+
const snapshot = event.snapshot as SnapshotFrom<typeof decideMachine>;
|
|
1366
|
+
const state = normalizePlaybookSnapshot(snapshot);
|
|
1367
|
+
const prior = previousState ?? state;
|
|
1368
|
+
const context = snapshot.context as unknown as Record<string, unknown>;
|
|
1369
|
+
const fsmPayload = telemetryPayload(prior, state, event.event, context);
|
|
1370
|
+
const describedFsmPayload = snapshotJsonValue(
|
|
1371
|
+
fsmPayload,
|
|
1372
|
+
'described FSM telemetry',
|
|
1373
|
+
);
|
|
1339
1374
|
|
|
1340
|
-
void enqueueTracedEmission(
|
|
1341
|
-
'fsm.transition',
|
|
1342
|
-
fsmPayload,
|
|
1343
|
-
{ turnId: currentTurnId },
|
|
1344
|
-
(emissionPorts) =>
|
|
1345
|
-
emissionPorts.emitTelemetry({
|
|
1346
|
-
topic: TELEMETRY_TOPIC,
|
|
1347
|
-
payload: describedFsmPayload,
|
|
1348
|
-
}),
|
|
1349
|
-
).catch(() => undefined);
|
|
1350
|
-
|
|
1351
|
-
const priorIds = new Set(prior?.activeStateIds ?? []);
|
|
1352
|
-
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
1353
|
-
const bossRelevantStateIds = state.activeStateIds.filter((stateId) =>
|
|
1354
|
-
STATUS_STATE_IDS.has(stateId),
|
|
1355
|
-
);
|
|
1356
|
-
const scheduleStatus = (
|
|
1357
|
-
message: string,
|
|
1358
|
-
stateId: string,
|
|
1359
|
-
data?: JsonValue,
|
|
1360
|
-
): void => {
|
|
1361
|
-
const tracePayload = {
|
|
1362
|
-
...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
|
|
1363
|
-
message,
|
|
1364
|
-
state,
|
|
1365
|
-
...(data !== undefined ? { data } : {}),
|
|
1366
|
-
};
|
|
1367
|
-
assertJsonSafe(tracePayload);
|
|
1368
1375
|
void enqueueTracedEmission(
|
|
1369
|
-
'
|
|
1370
|
-
|
|
1376
|
+
'fsm.transition',
|
|
1377
|
+
fsmPayload,
|
|
1371
1378
|
{ turnId: currentTurnId },
|
|
1372
|
-
(emissionPorts) =>
|
|
1379
|
+
(emissionPorts) =>
|
|
1380
|
+
emissionPorts.emitTelemetry({
|
|
1381
|
+
topic: TELEMETRY_TOPIC,
|
|
1382
|
+
payload: describedFsmPayload,
|
|
1383
|
+
}),
|
|
1373
1384
|
).catch(() => undefined);
|
|
1374
|
-
};
|
|
1375
1385
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1386
|
+
const priorIds = new Set(previousState?.activeStateIds ?? []);
|
|
1387
|
+
previousState = state;
|
|
1388
|
+
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
1389
|
+
const bossRelevantStateIds = state.activeStateIds.filter((stateId) =>
|
|
1390
|
+
STATUS_STATE_IDS.has(stateId),
|
|
1391
|
+
);
|
|
1392
|
+
const scheduleStatus = (
|
|
1393
|
+
message: string,
|
|
1394
|
+
stateId: string,
|
|
1395
|
+
data?: JsonValue,
|
|
1396
|
+
): void => {
|
|
1397
|
+
const tracePayload = {
|
|
1398
|
+
...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
|
|
1399
|
+
message,
|
|
1400
|
+
state,
|
|
1401
|
+
...(data !== undefined ? { data } : {}),
|
|
1402
|
+
};
|
|
1403
|
+
assertJsonSafe(tracePayload);
|
|
1404
|
+
void enqueueTracedEmission(
|
|
1405
|
+
'status.emitted',
|
|
1406
|
+
tracePayload,
|
|
1407
|
+
{ turnId: currentTurnId },
|
|
1408
|
+
(emissionPorts) => emissionPorts.emitStatus(message, data),
|
|
1409
|
+
).catch(() => undefined);
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
for (const activeStateId of state.activeStateIds) {
|
|
1413
|
+
if (
|
|
1414
|
+
priorIds.has(activeStateId) ||
|
|
1415
|
+
!STATUS_STATE_IDS.has(activeStateId)
|
|
1416
|
+
) {
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
if (WAIT_STATE_IDS.has(activeStateId)) {
|
|
1420
|
+
const pending = questionForWaitState(
|
|
1389
1421
|
activeStateId,
|
|
1422
|
+
pendingQuestions,
|
|
1390
1423
|
);
|
|
1424
|
+
if (pending) {
|
|
1425
|
+
scheduleStatus(
|
|
1426
|
+
`${pending.player} asks: ${pending.question}`,
|
|
1427
|
+
activeStateId,
|
|
1428
|
+
);
|
|
1429
|
+
scheduleStatus(
|
|
1430
|
+
`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`,
|
|
1431
|
+
activeStateId,
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
continue;
|
|
1391
1435
|
}
|
|
1392
|
-
|
|
1436
|
+
const lastError =
|
|
1437
|
+
activeStateId === 'failed'
|
|
1438
|
+
? normalizeErrorCompact(context.lastError)
|
|
1439
|
+
: undefined;
|
|
1440
|
+
const description = STATE_DESCRIPTIONS[activeStateId];
|
|
1441
|
+
if (description === undefined) continue;
|
|
1442
|
+
const playerState = PLAYER_STATES.find(
|
|
1443
|
+
(candidate) => candidate.stateId === activeStateId,
|
|
1444
|
+
);
|
|
1445
|
+
scheduleStatus(
|
|
1446
|
+
playerState === undefined
|
|
1447
|
+
? '◆ workflow failed; awaiting Boss recovery.'
|
|
1448
|
+
: `⤷ ${playerState.player}: ${description}`,
|
|
1449
|
+
activeStateId,
|
|
1450
|
+
lastError === undefined ? undefined : { lastError },
|
|
1451
|
+
);
|
|
1393
1452
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
? normalizeErrorCompact(context.lastError)
|
|
1397
|
-
: undefined;
|
|
1398
|
-
scheduleStatus(
|
|
1399
|
-
STATE_DESCRIPTIONS[activeStateId] ?? activeStateId,
|
|
1400
|
-
activeStateId,
|
|
1401
|
-
lastError === undefined ? undefined : { lastError },
|
|
1402
|
-
);
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
latchInspectionError(error);
|
|
1403
1455
|
}
|
|
1404
1456
|
};
|
|
1405
1457
|
|
|
@@ -1413,7 +1465,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1413
1465
|
? {}
|
|
1414
1466
|
: {
|
|
1415
1467
|
snapshot: machineSnapshot as unknown as SnapshotFrom<
|
|
1416
|
-
typeof
|
|
1468
|
+
typeof decideMachine
|
|
1417
1469
|
>,
|
|
1418
1470
|
}),
|
|
1419
1471
|
inspect,
|
|
@@ -1442,20 +1494,31 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1442
1494
|
|
|
1443
1495
|
const driveToQuiescence = async (): Promise<void> => {
|
|
1444
1496
|
const live = actor;
|
|
1445
|
-
if (!live) throw new Error('
|
|
1446
|
-
await waitForPlaybookQuiescence(live);
|
|
1497
|
+
if (!live) throw new Error('decide runtime: actor is not initialized');
|
|
1498
|
+
await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
|
|
1447
1499
|
};
|
|
1448
1500
|
|
|
1449
1501
|
const classify = async (
|
|
1450
1502
|
text: string,
|
|
1451
1503
|
signal: AbortSignal,
|
|
1452
|
-
): Promise<
|
|
1504
|
+
): Promise<DecideEvent | { type: 'NO_ACTION' } | null> => {
|
|
1453
1505
|
const live = actor;
|
|
1454
|
-
if (!live) throw new Error('
|
|
1455
|
-
const snapshot = live.getSnapshot() as SnapshotFrom<typeof
|
|
1456
|
-
const context = snapshot.context as Record<string, unknown>;
|
|
1457
|
-
const state = normalizePlaybookSnapshot(snapshot
|
|
1506
|
+
if (!live) throw new Error('decide runtime: actor is not initialized');
|
|
1507
|
+
const snapshot = live.getSnapshot() as SnapshotFrom<typeof decideMachine>;
|
|
1508
|
+
const context = snapshot.context as unknown as Record<string, unknown>;
|
|
1509
|
+
const state = normalizePlaybookSnapshot(snapshot, {
|
|
1510
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
1511
|
+
});
|
|
1458
1512
|
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
1513
|
+
if (
|
|
1514
|
+
pendingQuestions.length === 0 &&
|
|
1515
|
+
(snapshot.status === 'done' ||
|
|
1516
|
+
state.activeStateIds.includes('ready') ||
|
|
1517
|
+
state.activeStateIds.includes('failed'))
|
|
1518
|
+
) {
|
|
1519
|
+
return { type: 'START_DECIDE', callerTopic: text };
|
|
1520
|
+
}
|
|
1521
|
+
if (pendingQuestions.length === 0) return null;
|
|
1459
1522
|
const prompt = buildClassifierPrompt(text, {
|
|
1460
1523
|
state,
|
|
1461
1524
|
pendingQuestions,
|
|
@@ -1468,16 +1531,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1468
1531
|
);
|
|
1469
1532
|
return parseClassification(
|
|
1470
1533
|
raw,
|
|
1534
|
+
text,
|
|
1471
1535
|
pendingQuestions.map(({ questionId }) => questionId),
|
|
1472
1536
|
);
|
|
1473
1537
|
};
|
|
1474
1538
|
|
|
1475
1539
|
const resultForSnapshot = (signal?: AbortSignal): PlaybookRunResult => {
|
|
1476
1540
|
const live = actor;
|
|
1477
|
-
if (!live) throw new Error('
|
|
1478
|
-
const snapshot = live.getSnapshot() as SnapshotFrom<typeof
|
|
1479
|
-
const
|
|
1480
|
-
const
|
|
1541
|
+
if (!live) throw new Error('decide runtime: actor is not initialized');
|
|
1542
|
+
const snapshot = live.getSnapshot() as SnapshotFrom<typeof decideMachine>;
|
|
1543
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1544
|
+
const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
|
|
1545
|
+
const context = snapshot.context as unknown as Record<string, unknown>;
|
|
1481
1546
|
if (signal?.aborted) {
|
|
1482
1547
|
return {
|
|
1483
1548
|
outcome: 'aborted',
|
|
@@ -1504,7 +1569,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1504
1569
|
if (snapshot.status === 'error') {
|
|
1505
1570
|
throw (
|
|
1506
1571
|
(snapshot as { error?: unknown }).error ??
|
|
1507
|
-
new Error('
|
|
1572
|
+
new Error('decide runtime actor entered error status')
|
|
1508
1573
|
);
|
|
1509
1574
|
}
|
|
1510
1575
|
if (state.activeStateIds.includes('failed')) {
|
|
@@ -1515,6 +1580,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1515
1580
|
...(error === undefined ? {} : { error }),
|
|
1516
1581
|
};
|
|
1517
1582
|
}
|
|
1583
|
+
if (pendingCall) {
|
|
1584
|
+
return { outcome: 'suspended', state, pendingCall };
|
|
1585
|
+
}
|
|
1518
1586
|
return { outcome: 'quiescent', state };
|
|
1519
1587
|
};
|
|
1520
1588
|
|
|
@@ -1577,6 +1645,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1577
1645
|
turnSequence = 0;
|
|
1578
1646
|
judgeCallSequence = 0;
|
|
1579
1647
|
playerCallSequence = 0;
|
|
1648
|
+
playbookCallSequence = 0;
|
|
1649
|
+
playbookCallTurnIds.clear();
|
|
1580
1650
|
lifecycleStarted = false;
|
|
1581
1651
|
};
|
|
1582
1652
|
|
|
@@ -1589,7 +1659,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1589
1659
|
disposalPromise !== undefined
|
|
1590
1660
|
) {
|
|
1591
1661
|
throw new Error(
|
|
1592
|
-
'
|
|
1662
|
+
'decide runtime: init(session) may only be called once',
|
|
1593
1663
|
);
|
|
1594
1664
|
}
|
|
1595
1665
|
const identity = snapshotPlaybookSession(session);
|
|
@@ -1623,8 +1693,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1623
1693
|
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
|
|
1624
1694
|
// Defined only at a safe capture point — initialized, not disposing
|
|
1625
1695
|
// or disposed, no active public boundary, and the actor quiescent
|
|
1626
|
-
// with status `active
|
|
1627
|
-
// so no pending-call guard applies.
|
|
1696
|
+
// with status `active` and no pending nested REVIEW call.
|
|
1628
1697
|
exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
|
|
1629
1698
|
if (
|
|
1630
1699
|
!actor ||
|
|
@@ -1637,25 +1706,26 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1637
1706
|
if (currentTurnId !== undefined || currentSignal !== undefined) {
|
|
1638
1707
|
return undefined;
|
|
1639
1708
|
}
|
|
1709
|
+
if (nestedBridge.getPendingCall()) return undefined;
|
|
1640
1710
|
const state = currentState();
|
|
1641
1711
|
if (state.status !== 'active' || !state.quiescent) return undefined;
|
|
1642
1712
|
const machine = detachPersistedMachineSnapshot(
|
|
1643
1713
|
actor.getPersistedSnapshot(),
|
|
1644
1714
|
);
|
|
1645
1715
|
const context = (
|
|
1646
|
-
actor.getSnapshot() as SnapshotFrom<typeof
|
|
1647
|
-
).context as Record<string, unknown>;
|
|
1716
|
+
actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
|
|
1717
|
+
).context as unknown as Record<string, unknown>;
|
|
1648
1718
|
return {
|
|
1649
1719
|
schemaVersion: 1,
|
|
1650
1720
|
playbookId: sessionIdentity.playbookId,
|
|
1651
1721
|
machine,
|
|
1652
|
-
playerResumeTokens:
|
|
1722
|
+
playerResumeTokens: snapshotPlayerResumeTokens(),
|
|
1653
1723
|
sequences: {
|
|
1654
1724
|
trace: traceSequence,
|
|
1655
1725
|
turn: turnSequence,
|
|
1656
1726
|
judgeCall: judgeCallSequence,
|
|
1657
1727
|
playerCall: playerCallSequence,
|
|
1658
|
-
playbookCall:
|
|
1728
|
+
playbookCall: playbookCallSequence,
|
|
1659
1729
|
},
|
|
1660
1730
|
state,
|
|
1661
1731
|
pendingBossQuestions: pendingQuestionsFromContext(context).map(
|
|
@@ -1685,7 +1755,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1685
1755
|
disposalPromise !== undefined
|
|
1686
1756
|
) {
|
|
1687
1757
|
throw new Error(
|
|
1688
|
-
'
|
|
1758
|
+
'decide runtime: restore(session, snapshot) may only be called once',
|
|
1689
1759
|
);
|
|
1690
1760
|
}
|
|
1691
1761
|
const identity = snapshotPlaybookSession(session);
|
|
@@ -1701,32 +1771,45 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1701
1771
|
lifecycleStarted = true;
|
|
1702
1772
|
ports = identity.ports;
|
|
1703
1773
|
sessionIdentity = identity;
|
|
1774
|
+
let priorExternalPlayerTokens:
|
|
1775
|
+
| Readonly<Record<string, string>>
|
|
1776
|
+
| undefined;
|
|
1704
1777
|
try {
|
|
1705
1778
|
traceSequence = boundSnapshot.sequences.trace;
|
|
1706
1779
|
turnSequence = boundSnapshot.sequences.turn;
|
|
1707
1780
|
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
1708
1781
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
)) {
|
|
1713
|
-
playerResumeTokens.set(playerId, token);
|
|
1782
|
+
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
1783
|
+
if (identity.playerSessions) {
|
|
1784
|
+
priorExternalPlayerTokens = snapshotPlayerResumeTokens();
|
|
1714
1785
|
}
|
|
1786
|
+
restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
|
|
1715
1787
|
suppressInspectionEmissions = true;
|
|
1716
1788
|
createRuntimeActor(boundSnapshot.machine);
|
|
1717
1789
|
actor?.start();
|
|
1718
1790
|
const restoredState = currentState();
|
|
1719
1791
|
if (restoredState.status !== 'active') {
|
|
1720
1792
|
throw new Error(
|
|
1721
|
-
`
|
|
1793
|
+
`decide runtime: restored actor status is ${restoredState.status}, expected active`,
|
|
1722
1794
|
);
|
|
1723
1795
|
}
|
|
1724
1796
|
suppressInspectionEmissions = false;
|
|
1725
1797
|
previousState = restoredState;
|
|
1726
1798
|
await flush();
|
|
1727
1799
|
} catch (error) {
|
|
1800
|
+
let failure = error;
|
|
1801
|
+
if (priorExternalPlayerTokens !== undefined) {
|
|
1802
|
+
try {
|
|
1803
|
+
identity.playerSessions!.restore(priorExternalPlayerTokens);
|
|
1804
|
+
} catch (rollbackError) {
|
|
1805
|
+
failure = new AggregateError(
|
|
1806
|
+
[error, rollbackError],
|
|
1807
|
+
'DECIDE restore and player continuation rollback failed',
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1728
1811
|
await cleanupFailedStart({ emitDisposal: false });
|
|
1729
|
-
throw
|
|
1812
|
+
throw failure;
|
|
1730
1813
|
} finally {
|
|
1731
1814
|
finishInitialization();
|
|
1732
1815
|
if (initInFlight === initialization) initInFlight = undefined;
|
|
@@ -1738,16 +1821,16 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1738
1821
|
signal: AbortSignal;
|
|
1739
1822
|
}): Promise<PlaybookRunResult> {
|
|
1740
1823
|
if (disposalPromise !== undefined) {
|
|
1741
|
-
throw new Error('
|
|
1824
|
+
throw new Error('decide runtime: runtime is disposing or disposed');
|
|
1742
1825
|
}
|
|
1743
1826
|
requirePorts();
|
|
1744
1827
|
if (!actor) {
|
|
1745
1828
|
throw new Error(
|
|
1746
|
-
'
|
|
1829
|
+
'decide runtime: init(session) must be called before handleBossInput',
|
|
1747
1830
|
);
|
|
1748
1831
|
}
|
|
1749
|
-
if (
|
|
1750
|
-
throw new Error('
|
|
1832
|
+
if (currentSignal !== undefined) {
|
|
1833
|
+
throw new Error('decide runtime: another runtime turn is active');
|
|
1751
1834
|
}
|
|
1752
1835
|
|
|
1753
1836
|
const turnId = ++turnSequence;
|
|
@@ -1768,6 +1851,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1768
1851
|
const state = currentState();
|
|
1769
1852
|
await emitBoundaryStatus('No playbook action classified.', state);
|
|
1770
1853
|
result = { outcome: 'no-action', state };
|
|
1854
|
+
} else if (event.type === 'NO_ACTION') {
|
|
1855
|
+
result = { outcome: 'no-action', state: currentState() };
|
|
1771
1856
|
} else {
|
|
1772
1857
|
await emitBoundaryStatus(event.type, currentState());
|
|
1773
1858
|
if (actor.getSnapshot().status === 'done') {
|
|
@@ -1852,36 +1937,77 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1852
1937
|
|
|
1853
1938
|
if (failures.length === 1) throw failures[0];
|
|
1854
1939
|
if (failures.length > 1) {
|
|
1855
|
-
throw new AggregateError(failures, '
|
|
1940
|
+
throw new AggregateError(failures, 'decide runtime turn failed');
|
|
1856
1941
|
}
|
|
1857
1942
|
return result;
|
|
1858
1943
|
},
|
|
1859
1944
|
|
|
1860
1945
|
async resumePlaybookCall({
|
|
1861
1946
|
callId,
|
|
1947
|
+
result: childResult,
|
|
1948
|
+
signal,
|
|
1862
1949
|
}: {
|
|
1863
1950
|
callId: string;
|
|
1864
1951
|
result: PlaybookCallResult;
|
|
1865
1952
|
signal: AbortSignal;
|
|
1866
1953
|
}): Promise<PlaybookRunResult> {
|
|
1867
1954
|
if (disposalPromise !== undefined) {
|
|
1868
|
-
throw new Error('
|
|
1955
|
+
throw new Error('decide runtime: runtime is disposing or disposed');
|
|
1869
1956
|
}
|
|
1870
1957
|
requirePorts();
|
|
1871
1958
|
if (!actor) {
|
|
1872
1959
|
throw new Error(
|
|
1873
|
-
'
|
|
1960
|
+
'decide runtime: init(session) must be called before resumePlaybookCall',
|
|
1874
1961
|
);
|
|
1875
1962
|
}
|
|
1876
|
-
|
|
1963
|
+
if (currentSignal !== undefined) {
|
|
1964
|
+
throw new Error('decide runtime: another runtime turn is active');
|
|
1965
|
+
}
|
|
1966
|
+
currentTurnId = playbookCallTurnIds.get(callId);
|
|
1967
|
+
currentSignal = signal;
|
|
1968
|
+
controlPlaneError = undefined;
|
|
1969
|
+
let runResult: PlaybookRunResult | undefined;
|
|
1970
|
+
let operationError: unknown;
|
|
1971
|
+
try {
|
|
1972
|
+
await nestedBridge.resume({
|
|
1973
|
+
callId,
|
|
1974
|
+
result: childResult,
|
|
1975
|
+
signal,
|
|
1976
|
+
});
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
operationError = error;
|
|
1979
|
+
}
|
|
1980
|
+
try {
|
|
1981
|
+
await waitForPlaybookQuiescence(actor, {
|
|
1982
|
+
pendingCalls: nestedBridge,
|
|
1983
|
+
});
|
|
1984
|
+
runResult = resultForSnapshot(signal);
|
|
1985
|
+
} catch (error) {
|
|
1986
|
+
operationError ??= error;
|
|
1987
|
+
}
|
|
1988
|
+
let drainError: unknown;
|
|
1989
|
+
try {
|
|
1990
|
+
await drainBoundaryCallsAndEmissions();
|
|
1991
|
+
} catch (error) {
|
|
1992
|
+
drainError = error;
|
|
1993
|
+
}
|
|
1994
|
+
const failure = controlPlaneError ?? drainError ?? operationError;
|
|
1995
|
+
currentSignal = undefined;
|
|
1996
|
+
currentTurnId = undefined;
|
|
1997
|
+
controlPlaneError = undefined;
|
|
1998
|
+
if (failure !== undefined) throw failure;
|
|
1999
|
+
if (runResult === undefined) {
|
|
2000
|
+
throw new Error('decide runtime: playbook resume produced no result');
|
|
2001
|
+
}
|
|
2002
|
+
return runResult;
|
|
1877
2003
|
},
|
|
1878
2004
|
|
|
1879
2005
|
dispose(): Promise<void> {
|
|
1880
2006
|
if (disposalPromise !== undefined) return disposalPromise;
|
|
1881
|
-
if (
|
|
2007
|
+
if (currentSignal !== undefined) {
|
|
1882
2008
|
return Promise.reject(
|
|
1883
2009
|
new Error(
|
|
1884
|
-
'
|
|
2010
|
+
'decide runtime: cannot dispose while a runtime turn is active',
|
|
1885
2011
|
),
|
|
1886
2012
|
);
|
|
1887
2013
|
}
|
|
@@ -1895,6 +2021,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1895
2021
|
const finalState = currentState();
|
|
1896
2022
|
const failures: unknown[] = [];
|
|
1897
2023
|
stopActor();
|
|
2024
|
+
try {
|
|
2025
|
+
await nestedBridge.dispose();
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
collectFailure(failures, error);
|
|
2028
|
+
}
|
|
1898
2029
|
try {
|
|
1899
2030
|
await drainBoundaryCallsAndEmissions();
|
|
1900
2031
|
} catch (error) {
|
|
@@ -1914,6 +2045,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1914
2045
|
collectFailure(failures, error);
|
|
1915
2046
|
} finally {
|
|
1916
2047
|
playerResumeTokens.clear();
|
|
2048
|
+
playbookCallTurnIds.clear();
|
|
1917
2049
|
inFlightPlayerIds.clear();
|
|
1918
2050
|
activeBoundaryCalls.clear();
|
|
1919
2051
|
activeEmissionCalls.clear();
|
|
@@ -1930,7 +2062,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1930
2062
|
}
|
|
1931
2063
|
if (failures.length === 1) throw failures[0];
|
|
1932
2064
|
if (failures.length > 1) {
|
|
1933
|
-
throw new AggregateError(failures, '
|
|
2065
|
+
throw new AggregateError(failures, 'decide runtime disposal failed');
|
|
1934
2066
|
}
|
|
1935
2067
|
})();
|
|
1936
2068
|
return disposalPromise;
|
|
@@ -1952,10 +2084,10 @@ export const _internal = {
|
|
|
1952
2084
|
normalizeErrorCompact,
|
|
1953
2085
|
normalizeErrorFull,
|
|
1954
2086
|
DEFAULT_PLAYER_BINDING,
|
|
1955
|
-
ALIAS_RESOLUTION,
|
|
1956
2087
|
STATE_DESCRIPTIONS,
|
|
1957
|
-
|
|
1958
|
-
|
|
2088
|
+
PLAYER_STATES,
|
|
2089
|
+
PLAYER_STATE_IDS,
|
|
2090
|
+
VERBATIM_PAYLOAD_FIELDS,
|
|
1959
2091
|
BOSS_INTERRUPT_TARGETS,
|
|
1960
2092
|
CONTINUATION_PREAMBLE,
|
|
1961
2093
|
TELEMETRY_TOPIC,
|