@sublang/playbook 5.0.0 → 7.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 +22 -14
- package/docs/cli.md +138 -90
- package/docs/configuration.md +113 -29
- package/docs/embedding.md +24 -16
- package/package.json +42 -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/bin/launch-config.js +938 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +145 -562
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1171 -983
- package/reference/sdlc/code.playbook/bin/session-store.js +1169 -0
- 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.d.ts +70 -3
- package/reference/sdlc/code.playbook/playbook-captain.js +954 -80
- package/reference/sdlc/code.playbook/playbook-captain.ts +1408 -80
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +37 -36
- 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} +545 -372
- package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +665 -454
- 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 +83 -14
- package/slc/text2gears.md +22 -2
- package/src/runtime.d.ts +21 -2
- package/src/runtime.ts +38 -6
- package/src/xstate-playbook-runtime.d.ts +9 -2
- package/src/xstate-playbook-runtime.js +319 -35
- package/src/xstate-playbook-runtime.ts +412 -41
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +384 -57
- package/src/xstate-runtime.ts +542 -71
- 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
|
-
},
|
|
152
|
-
{
|
|
153
|
-
stateId: 'participantAddressesRebuttals',
|
|
154
|
-
player: 'Participant',
|
|
155
|
-
sourceItem: 'DISCUSS-13',
|
|
156
|
-
},
|
|
71
|
+
const PLAYER_STATES = [
|
|
72
|
+
{ stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
|
|
157
73
|
{
|
|
158
|
-
stateId: '
|
|
159
|
-
player: '
|
|
160
|
-
sourceItem: '
|
|
74
|
+
stateId: 'askReviewerProposal',
|
|
75
|
+
player: 'Reviewer',
|
|
76
|
+
sourceItem: 'DECIDE-2',
|
|
161
77
|
},
|
|
78
|
+
{ stateId: 'commitCoderProposal', player: 'Coder', sourceItem: 'DECIDE-3' },
|
|
162
79
|
];
|
|
163
|
-
const
|
|
164
|
-
const BOSS_INTERRUPT_TARGETS = [
|
|
165
|
-
'ready',
|
|
166
|
-
'initialProposalRound',
|
|
167
|
-
'reconciliationRound',
|
|
168
|
-
'hostWritesAgreement',
|
|
169
|
-
'commitInitialChanges',
|
|
170
|
-
'reviewSpecInitialCommit',
|
|
171
|
-
'reviewSpecHostChanges',
|
|
172
|
-
'reviewDrInitialCommit',
|
|
173
|
-
'reviewDrHostChanges',
|
|
174
|
-
'reviewMixedInitialCommit',
|
|
175
|
-
'reviewMixedHostChanges',
|
|
176
|
-
'hostAddressesFindings',
|
|
177
|
-
'participantAddressesRebuttals',
|
|
178
|
-
'commitReviewedChanges',
|
|
179
|
-
'failed',
|
|
180
|
-
];
|
|
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;
|
|
@@ -308,6 +203,22 @@ function parseJudgeJson(raw) {
|
|
|
308
203
|
function isPlainObject(value) {
|
|
309
204
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
310
205
|
}
|
|
206
|
+
function sortJson(value) {
|
|
207
|
+
if (Array.isArray(value))
|
|
208
|
+
return value.map((entry) => sortJson(entry));
|
|
209
|
+
if (value !== null && typeof value === 'object') {
|
|
210
|
+
const record = value;
|
|
211
|
+
const sorted = {};
|
|
212
|
+
for (const key of Object.keys(record).sort()) {
|
|
213
|
+
sorted[key] = sortJson(record[key]);
|
|
214
|
+
}
|
|
215
|
+
return sorted;
|
|
216
|
+
}
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
function stableJson(value, path) {
|
|
220
|
+
return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
|
|
221
|
+
}
|
|
311
222
|
function stripCodeFence(text) {
|
|
312
223
|
const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
|
|
313
224
|
return fence ? fence[1].trim() : text;
|
|
@@ -364,7 +275,7 @@ function dropTrailingComma(value) {
|
|
|
364
275
|
}
|
|
365
276
|
function buildClassifierPrompt(text, ctx) {
|
|
366
277
|
const lines = [];
|
|
367
|
-
lines.push('You are the Boss-input classifier for the
|
|
278
|
+
lines.push('You are the Boss-input classifier for the decide playbook.');
|
|
368
279
|
lines.push('Classify the Boss message into exactly one FSM event, or into no event.');
|
|
369
280
|
lines.push('');
|
|
370
281
|
lines.push(`Current FSM state: ${JSON.stringify(ctx.state.value)}`);
|
|
@@ -372,84 +283,66 @@ function buildClassifierPrompt(text, ctx) {
|
|
|
372
283
|
if (ctx.pendingQuestions.length > 0) {
|
|
373
284
|
lines.push('Pending Boss questions:');
|
|
374
285
|
for (const pending of ctx.pendingQuestions) {
|
|
375
|
-
lines.push(`- ${pending.questionId} (${pending.player}
|
|
286
|
+
lines.push(`- ${pending.questionId} (${pending.player}): ${pending.question}`);
|
|
376
287
|
}
|
|
377
288
|
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
289
|
}
|
|
379
290
|
lines.push('');
|
|
380
291
|
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.');
|
|
292
|
+
lines.push(`- BOSS_INTERRUPT: required targetId string, exactly ${BOSS_INTERRUPT_TARGETS[0]}; the runtime attaches the exact Boss text as bossIntent.`);
|
|
293
|
+
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.');
|
|
294
|
+
lines.push('- NO_ACTION: no fields.');
|
|
385
295
|
lines.push('');
|
|
386
296
|
lines.push('Boss message:');
|
|
387
297
|
lines.push(text);
|
|
388
298
|
lines.push('');
|
|
389
|
-
lines.push('Reply with a single JSON object: { "
|
|
390
|
-
lines.push('Use
|
|
299
|
+
lines.push('Reply with a single JSON object: { "type": "<EVENT_TYPE>", ...declared fields }.');
|
|
300
|
+
lines.push('Use NO_ACTION when no FSM action applies.');
|
|
391
301
|
return lines.join('\n');
|
|
392
302
|
}
|
|
393
|
-
function parseClassification(raw, pendingQuestionIds = []) {
|
|
303
|
+
function parseClassification(raw, text, pendingQuestionIds = []) {
|
|
394
304
|
const obj = extractJson(raw);
|
|
395
305
|
if (!obj)
|
|
396
306
|
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;
|
|
307
|
+
const exactKeys = (...keys) => {
|
|
308
|
+
const ownKeys = Reflect.ownKeys(obj);
|
|
309
|
+
return (ownKeys.length === keys.length &&
|
|
310
|
+
ownKeys.every((key) => typeof key === 'string' && keys.includes(key)));
|
|
311
|
+
};
|
|
312
|
+
const eventType = obj.type;
|
|
313
|
+
if (eventType === 'NO_ACTION') {
|
|
314
|
+
return exactKeys('type') ? { type: 'NO_ACTION' } : null;
|
|
427
315
|
}
|
|
428
316
|
if (eventType === 'BOSS_INTERRUPT') {
|
|
429
|
-
if (
|
|
317
|
+
if (exactKeys('type', 'targetId') &&
|
|
318
|
+
typeof obj.targetId === 'string' &&
|
|
430
319
|
BOSS_INTERRUPT_TARGET_IDS.has(obj.targetId)) {
|
|
431
|
-
return {
|
|
320
|
+
return {
|
|
321
|
+
type: 'BOSS_INTERRUPT',
|
|
322
|
+
targetId: obj.targetId,
|
|
323
|
+
bossIntent: text,
|
|
324
|
+
};
|
|
432
325
|
}
|
|
433
326
|
return null;
|
|
434
327
|
}
|
|
435
328
|
if (eventType === 'BOSS_REPLY') {
|
|
436
|
-
if (
|
|
329
|
+
if (pendingQuestionIds.length === 0)
|
|
437
330
|
return null;
|
|
438
|
-
}
|
|
439
331
|
if (typeof obj.questionId === 'string') {
|
|
440
|
-
return
|
|
332
|
+
return exactKeys('type', 'questionId') &&
|
|
333
|
+
pendingQuestionIds.includes(obj.questionId)
|
|
441
334
|
? {
|
|
442
335
|
type: 'BOSS_REPLY',
|
|
443
336
|
questionId: obj.questionId,
|
|
444
|
-
answer:
|
|
337
|
+
answer: text,
|
|
445
338
|
}
|
|
446
339
|
: null;
|
|
447
340
|
}
|
|
448
|
-
return pendingQuestionIds.length === 1
|
|
341
|
+
return exactKeys('type') && pendingQuestionIds.length === 1
|
|
449
342
|
? {
|
|
450
343
|
type: 'BOSS_REPLY',
|
|
451
344
|
questionId: pendingQuestionIds[0],
|
|
452
|
-
answer:
|
|
345
|
+
answer: text,
|
|
453
346
|
}
|
|
454
347
|
: null;
|
|
455
348
|
}
|
|
@@ -473,11 +366,22 @@ function buildAdjudicatorPrompt(input, playerOutput) {
|
|
|
473
366
|
for (const [guard, description] of Object.entries(input.result)) {
|
|
474
367
|
lines.push(`- ${guard}: ${description}`);
|
|
475
368
|
}
|
|
369
|
+
const runtimeOwnedFields = new Set();
|
|
370
|
+
for (const description of Object.values(input.result)) {
|
|
371
|
+
for (const field of requiredFieldsFor(description)) {
|
|
372
|
+
if (VERBATIM_PAYLOAD_FIELDS.has(field))
|
|
373
|
+
runtimeOwnedFields.add(field);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (runtimeOwnedFields.size > 0) {
|
|
377
|
+
lines.push('');
|
|
378
|
+
lines.push(`The runtime owns these verbatim fields; do not include them in your JSON: ${[...runtimeOwnedFields].join(', ')}.`);
|
|
379
|
+
}
|
|
476
380
|
lines.push('');
|
|
477
381
|
lines.push('Reply with a single JSON object: { "guard": "<one of the guard names above>", ...any payload fields the chosen guard description requires }.');
|
|
478
382
|
return lines.join('\n');
|
|
479
383
|
}
|
|
480
|
-
function parseAdjudication(raw, input) {
|
|
384
|
+
function parseAdjudication(raw, input, finalText) {
|
|
481
385
|
const obj = extractJson(raw);
|
|
482
386
|
if (!obj || typeof obj.guard !== 'string' || obj.guard.trim() === '') {
|
|
483
387
|
throw new Error('adjudicator returned empty or malformed JSON');
|
|
@@ -486,25 +390,26 @@ function parseAdjudication(raw, input) {
|
|
|
486
390
|
if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
|
|
487
391
|
throw new Error(`adjudicator returned undeclared guard "${guard}" for ${input.sourceItem}`);
|
|
488
392
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
393
|
+
const requiredFields = requiredFieldsFor(input.result[guard]);
|
|
394
|
+
const allowedFields = new Set(['guard', ...requiredFields]);
|
|
395
|
+
for (const key of Reflect.ownKeys(obj)) {
|
|
396
|
+
if (typeof key !== 'string' || !allowedFields.has(key)) {
|
|
397
|
+
throw new Error(`adjudicator response for guard "${guard}" included undeclared field "${String(key)}"`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const output = { guard };
|
|
401
|
+
for (const field of requiredFields) {
|
|
402
|
+
if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
|
|
403
|
+
output[field] = finalText;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const value = obj[field];
|
|
498
407
|
if (value === undefined ||
|
|
499
408
|
value === null ||
|
|
500
409
|
(typeof value === 'string' && value.trim() === '')) {
|
|
501
410
|
throw new Error(`adjudicator response for guard "${guard}" missing required field "${field}"`);
|
|
502
411
|
}
|
|
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}"`);
|
|
412
|
+
output[field] = value;
|
|
508
413
|
}
|
|
509
414
|
return output;
|
|
510
415
|
}
|
|
@@ -528,11 +433,7 @@ function isEmptyFinalText(finalText) {
|
|
|
528
433
|
return finalText === undefined || finalText.trim().length === 0;
|
|
529
434
|
}
|
|
530
435
|
function isAbortFailure(error, signal) {
|
|
531
|
-
|
|
532
|
-
return false;
|
|
533
|
-
if (Object.is(error, signal.reason))
|
|
534
|
-
return true;
|
|
535
|
-
return normalizeErrorCompact(error)?.name === 'AbortError';
|
|
436
|
+
return signal.aborted && Object.is(error, signal.reason);
|
|
536
437
|
}
|
|
537
438
|
function pendingQuestionsFromContext(context) {
|
|
538
439
|
const pending = context.pendingBossQuestions;
|
|
@@ -559,17 +460,15 @@ function pendingQuestionsFromContext(context) {
|
|
|
559
460
|
return questions.sort((left, right) => left.questionId.localeCompare(right.questionId));
|
|
560
461
|
}
|
|
561
462
|
const WAIT_STATE_RESUME_IDS = {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
waitHostReconciliationReply: 'hostInitialRound',
|
|
565
|
-
waitParticipantReconciliationReply: 'participantInitialRound',
|
|
463
|
+
waitCoderProposalReply: 'askCoderProposal',
|
|
464
|
+
waitReviewerProposalReply: 'askReviewerProposal',
|
|
566
465
|
};
|
|
567
466
|
const WAIT_STATE_IDS = new Set([
|
|
568
467
|
...Object.keys(WAIT_STATE_RESUME_IDS),
|
|
569
468
|
'awaitBossReply',
|
|
570
469
|
]);
|
|
571
470
|
const STATUS_STATE_IDS = new Set([
|
|
572
|
-
...
|
|
471
|
+
...PLAYER_STATE_IDS,
|
|
573
472
|
...WAIT_STATE_IDS,
|
|
574
473
|
'failed',
|
|
575
474
|
]);
|
|
@@ -582,38 +481,44 @@ function questionForWaitState(stateId, pendingQuestions) {
|
|
|
582
481
|
? pendingQuestions[0]
|
|
583
482
|
: undefined;
|
|
584
483
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
484
|
+
const TRANSITION_EVENT_FIELDS = [
|
|
485
|
+
'callerTopic',
|
|
486
|
+
'targetId',
|
|
487
|
+
'bossIntent',
|
|
488
|
+
'questionId',
|
|
489
|
+
'answer',
|
|
490
|
+
];
|
|
491
|
+
function normalizedTransitionEvent(event) {
|
|
492
|
+
if (event === null || typeof event !== 'object') {
|
|
493
|
+
return snapshotJsonValue(event ?? null, 'FSM event');
|
|
592
494
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
495
|
+
const source = event;
|
|
496
|
+
const descriptor = {};
|
|
497
|
+
if (typeof source.type === 'string')
|
|
498
|
+
descriptor.type = source.type;
|
|
499
|
+
for (const field of TRANSITION_EVENT_FIELDS) {
|
|
500
|
+
if (typeof source[field] === 'string') {
|
|
501
|
+
descriptor[field] = source[field];
|
|
502
|
+
}
|
|
598
503
|
}
|
|
599
|
-
|
|
504
|
+
if (source.output !== undefined) {
|
|
505
|
+
descriptor.output = snapshotJsonValue(source.output, 'FSM event output');
|
|
506
|
+
}
|
|
507
|
+
if (source.error !== undefined) {
|
|
508
|
+
descriptor.error = snapshotJsonValue(normalizeError(source.error), 'FSM event error');
|
|
509
|
+
}
|
|
510
|
+
return snapshotJsonValue(descriptor, 'FSM event');
|
|
600
511
|
}
|
|
601
512
|
function telemetryPayload(previousState, state, event, context) {
|
|
602
|
-
const eventError = event !== null &&
|
|
603
|
-
typeof event === 'object' &&
|
|
604
|
-
!Array.isArray(event) &&
|
|
605
|
-
'error' in event
|
|
606
|
-
? normalizeErrorFull(event.error)
|
|
607
|
-
: undefined;
|
|
608
513
|
const pendingBossQuestions = pendingQuestionsFromContext(context);
|
|
514
|
+
const prior = previousState ?? state;
|
|
609
515
|
const payload = {
|
|
610
|
-
from:
|
|
516
|
+
from: prior.value,
|
|
611
517
|
to: state.value,
|
|
612
|
-
event:
|
|
613
|
-
previousState:
|
|
518
|
+
event: normalizedTransitionEvent(event),
|
|
519
|
+
previousState: prior,
|
|
614
520
|
state,
|
|
615
521
|
...(pendingBossQuestions.length > 0 ? { pendingBossQuestions } : {}),
|
|
616
|
-
...(eventError !== undefined ? { error: eventError } : {}),
|
|
617
522
|
...(state.activeStateIds.includes('failed')
|
|
618
523
|
? { lastError: normalizeErrorFull(context.lastError) ?? null }
|
|
619
524
|
: {}),
|
|
@@ -622,15 +527,13 @@ function telemetryPayload(previousState, state, event, context) {
|
|
|
622
527
|
return payload;
|
|
623
528
|
}
|
|
624
529
|
export const createPlaybookRuntime = (options) => {
|
|
625
|
-
const boundOptions =
|
|
530
|
+
const boundOptions = snapshotDecideRuntimeOptions(options);
|
|
626
531
|
const binding = {
|
|
627
532
|
...DEFAULT_PLAYER_BINDING,
|
|
628
533
|
...(boundOptions.playerBinding ?? {}),
|
|
629
534
|
};
|
|
630
535
|
const fsmInput = {
|
|
631
|
-
|
|
632
|
-
participant: boundOptions.participant,
|
|
633
|
-
committer: boundOptions.committer,
|
|
536
|
+
coderLlm: boundOptions.coderLlm,
|
|
634
537
|
};
|
|
635
538
|
let ports;
|
|
636
539
|
let sessionIdentity;
|
|
@@ -644,12 +547,15 @@ export const createPlaybookRuntime = (options) => {
|
|
|
644
547
|
let turnSequence = 0;
|
|
645
548
|
let judgeCallSequence = 0;
|
|
646
549
|
let playerCallSequence = 0;
|
|
550
|
+
let playbookCallSequence = 0;
|
|
647
551
|
let lifecycleStarted = false;
|
|
648
552
|
let initInFlight;
|
|
649
553
|
let disposed = false;
|
|
650
554
|
let disposalPromise;
|
|
651
555
|
let controlPlaneError;
|
|
556
|
+
let nestedBridge;
|
|
652
557
|
const playerResumeTokens = new Map();
|
|
558
|
+
const playbookCallTurnIds = new Map();
|
|
653
559
|
const inFlightPlayerIds = new Set();
|
|
654
560
|
const activeBoundaryCalls = new Set();
|
|
655
561
|
const activeEmissionCalls = new Set();
|
|
@@ -669,6 +575,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
669
575
|
if (!isAbortFailure(error, signal))
|
|
670
576
|
controlPlaneError ??= error;
|
|
671
577
|
};
|
|
578
|
+
const latchInspectionError = (error) => {
|
|
579
|
+
if (currentSignal !== undefined) {
|
|
580
|
+
latchControlPlaneError(error, currentSignal);
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
collectFailure(emissionFailures, error);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
672
586
|
const enqueue = (fn) => {
|
|
673
587
|
const queued = emissionQueue.add(fn);
|
|
674
588
|
activeEmissionCalls.add(queued);
|
|
@@ -696,7 +610,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
696
610
|
emissionFailures = [];
|
|
697
611
|
if (failures.length === 1)
|
|
698
612
|
throw failures[0];
|
|
699
|
-
throw new AggregateError(failures, '
|
|
613
|
+
throw new AggregateError(failures, 'decide runtime emissions failed');
|
|
700
614
|
};
|
|
701
615
|
const drainBoundaryCallsAndEmissions = async () => {
|
|
702
616
|
while (true) {
|
|
@@ -715,22 +629,78 @@ export const createPlaybookRuntime = (options) => {
|
|
|
715
629
|
};
|
|
716
630
|
const requirePorts = () => {
|
|
717
631
|
if (!ports) {
|
|
718
|
-
throw new Error('
|
|
632
|
+
throw new Error('decide runtime: init(session) must be called first');
|
|
719
633
|
}
|
|
720
634
|
return ports;
|
|
721
635
|
};
|
|
722
636
|
const requireSessionIdentity = () => {
|
|
723
637
|
if (!sessionIdentity) {
|
|
724
|
-
throw new Error('
|
|
638
|
+
throw new Error('decide runtime: init(session) must be called first');
|
|
725
639
|
}
|
|
726
640
|
return sessionIdentity;
|
|
727
641
|
};
|
|
728
|
-
const
|
|
642
|
+
const selectPlayerResume = (playerId) => {
|
|
643
|
+
const session = requireSessionIdentity();
|
|
644
|
+
const selected = session.playerSessions
|
|
645
|
+
? session.playerSessions.select(playerId)
|
|
646
|
+
: playerResumeTokens.get(playerId) ?? false;
|
|
647
|
+
if (selected !== false &&
|
|
648
|
+
(typeof selected !== 'string' || selected.trim().length === 0)) {
|
|
649
|
+
throw new TypeError(`player session store returned an invalid resume token for ${playerId}`);
|
|
650
|
+
}
|
|
651
|
+
return selected;
|
|
652
|
+
};
|
|
653
|
+
const updatePlayerResume = (playerId, resumeToken) => {
|
|
654
|
+
const session = requireSessionIdentity();
|
|
655
|
+
if (session.playerSessions) {
|
|
656
|
+
session.playerSessions.update(playerId, resumeToken);
|
|
657
|
+
}
|
|
658
|
+
else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
|
|
659
|
+
playerResumeTokens.set(playerId, resumeToken);
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
playerResumeTokens.delete(playerId);
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
const snapshotPlayerResumeTokens = () => {
|
|
666
|
+
const session = requireSessionIdentity();
|
|
667
|
+
const captured = snapshotJsonValue(session.playerSessions
|
|
668
|
+
? session.playerSessions.snapshot()
|
|
669
|
+
: Object.fromEntries(playerResumeTokens), 'player session store snapshot');
|
|
670
|
+
if (!isPlainObject(captured)) {
|
|
671
|
+
throw new TypeError('player session store snapshot must be an object');
|
|
672
|
+
}
|
|
673
|
+
const tokens = {};
|
|
674
|
+
for (const [playerId, token] of Object.entries(captured)) {
|
|
675
|
+
if (playerId.trim().length === 0) {
|
|
676
|
+
throw new TypeError('player session store snapshot player ids must be non-empty');
|
|
677
|
+
}
|
|
678
|
+
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
679
|
+
throw new TypeError(`player session store snapshot token for ${playerId} must be a non-empty string`);
|
|
680
|
+
}
|
|
681
|
+
tokens[playerId] = token;
|
|
682
|
+
}
|
|
683
|
+
return tokens;
|
|
684
|
+
};
|
|
685
|
+
const restorePlayerResumeTokens = (tokens) => {
|
|
686
|
+
const session = requireSessionIdentity();
|
|
687
|
+
if (session.playerSessions) {
|
|
688
|
+
session.playerSessions.restore(tokens);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
playerResumeTokens.clear();
|
|
692
|
+
for (const [playerId, token] of Object.entries(tokens)) {
|
|
693
|
+
playerResumeTokens.set(playerId, token);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
const currentState = (pendingCall = nestedBridge.getPendingCall()) => {
|
|
729
697
|
const live = actor;
|
|
730
698
|
if (!live) {
|
|
731
|
-
throw new Error('
|
|
699
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
732
700
|
}
|
|
733
|
-
return normalizePlaybookSnapshot(live.getSnapshot()
|
|
701
|
+
return normalizePlaybookSnapshot(live.getSnapshot(), {
|
|
702
|
+
pendingCall,
|
|
703
|
+
});
|
|
734
704
|
};
|
|
735
705
|
const stateIdentity = (state) => {
|
|
736
706
|
return state.stateId === undefined ? {} : { stateId: state.stateId };
|
|
@@ -802,7 +772,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
802
772
|
...(callStateId !== undefined ? { stateId: callStateId } : {}),
|
|
803
773
|
};
|
|
804
774
|
const queued = await judgeQueue.add(async () => {
|
|
805
|
-
// Keep the complete queue task pending until an active
|
|
775
|
+
// Keep the complete queue task pending until an active coder promise
|
|
806
776
|
// settles. PQueue's signal option may reject add() while that task is
|
|
807
777
|
// still running, which would let the turn drain race a late finish.
|
|
808
778
|
signal.throwIfAborted();
|
|
@@ -846,9 +816,16 @@ export const createPlaybookRuntime = (options) => {
|
|
|
846
816
|
if (inFlightPlayerIds.has(playerId)) {
|
|
847
817
|
throw new Error(`resolved player "${playerId}" already has an in-flight call`);
|
|
848
818
|
}
|
|
849
|
-
inFlightPlayerIds.add(playerId);
|
|
850
819
|
const prompt = composePlayerPrompt(input);
|
|
851
|
-
|
|
820
|
+
let resume;
|
|
821
|
+
try {
|
|
822
|
+
signal.throwIfAborted();
|
|
823
|
+
resume = selectPlayerResume(playerId);
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
latchControlPlaneError(error, signal);
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
852
829
|
const callId = `player-${++playerCallSequence}`;
|
|
853
830
|
const identity = {
|
|
854
831
|
purpose: 'captain',
|
|
@@ -865,6 +842,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
865
842
|
message: String(error),
|
|
866
843
|
},
|
|
867
844
|
}, { turnId: currentTurnId, callId });
|
|
845
|
+
inFlightPlayerIds.add(playerId);
|
|
868
846
|
try {
|
|
869
847
|
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
|
|
870
848
|
let rawResult;
|
|
@@ -872,14 +850,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
872
850
|
signal.throwIfAborted();
|
|
873
851
|
const boundary = Promise.resolve(requirePorts().callPlayer(playerId, prompt, signal, { resume }));
|
|
874
852
|
rawResult = await boundary;
|
|
875
|
-
// An XState sibling cancellation does not cancel an arbitrary
|
|
853
|
+
// An XState sibling cancellation does not cancel an arbitrary coder
|
|
876
854
|
// promise. Re-check before a late resolution can mutate continuity or
|
|
877
855
|
// masquerade as a successful boundary finish.
|
|
878
856
|
signal.throwIfAborted();
|
|
879
857
|
}
|
|
880
858
|
catch (error) {
|
|
881
859
|
// A rejected call produced no authoritative result, so the previous
|
|
882
|
-
// token remains untouched. This also covers a
|
|
860
|
+
// token remains untouched. This also covers a coder promise that
|
|
883
861
|
// resolves after its invocation signal was cancelled.
|
|
884
862
|
latchControlPlaneError(error, signal);
|
|
885
863
|
try {
|
|
@@ -900,18 +878,27 @@ export const createPlaybookRuntime = (options) => {
|
|
|
900
878
|
await emitFailure(error);
|
|
901
879
|
}
|
|
902
880
|
catch {
|
|
903
|
-
// The malformed
|
|
881
|
+
// The malformed coder result remains authoritative.
|
|
904
882
|
}
|
|
905
883
|
throw error;
|
|
906
884
|
}
|
|
907
885
|
// The resolved result is authoritative even on aborted/error status.
|
|
908
886
|
// Update continuation state before interpreting that status.
|
|
909
|
-
|
|
910
|
-
result.resumeToken
|
|
911
|
-
|
|
887
|
+
try {
|
|
888
|
+
updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
|
|
889
|
+
result.resumeToken.trim().length > 0
|
|
890
|
+
? result.resumeToken
|
|
891
|
+
: undefined);
|
|
912
892
|
}
|
|
913
|
-
|
|
914
|
-
|
|
893
|
+
catch (error) {
|
|
894
|
+
latchControlPlaneError(error, signal);
|
|
895
|
+
try {
|
|
896
|
+
await emitFailure(error);
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
// The continuation-store failure remains authoritative.
|
|
900
|
+
}
|
|
901
|
+
throw error;
|
|
915
902
|
}
|
|
916
903
|
// Keep this finish outside the boundary catch. A trace sink can record
|
|
917
904
|
// the event and then reject; retrying from that catch would duplicate the
|
|
@@ -974,14 +961,62 @@ export const createPlaybookRuntime = (options) => {
|
|
|
974
961
|
combined.throwIfAborted();
|
|
975
962
|
try {
|
|
976
963
|
const prompt = buildAdjudicatorPrompt(input, finalText);
|
|
977
|
-
return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input);
|
|
964
|
+
return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
|
|
978
965
|
}
|
|
979
966
|
catch (error) {
|
|
980
967
|
latchControlPlaneError(error, combined);
|
|
981
968
|
throw error;
|
|
982
969
|
}
|
|
983
970
|
});
|
|
984
|
-
|
|
971
|
+
nestedBridge = createNestedPlaybookBridge({
|
|
972
|
+
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
973
|
+
getBoundarySignal: () => currentSignal,
|
|
974
|
+
callPlaybook: (request, signal) => trackBoundaryCall(Promise.resolve(requirePorts().callPlaybook(request, signal))),
|
|
975
|
+
emitStarted: async (event) => {
|
|
976
|
+
playbookCallTurnIds.set(event.callId, currentTurnId);
|
|
977
|
+
await emitTrace('playbook.call.started', {
|
|
978
|
+
stateId: event.stateId,
|
|
979
|
+
playbookId: event.playbookId,
|
|
980
|
+
text: event.text,
|
|
981
|
+
}, {
|
|
982
|
+
...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
|
|
983
|
+
callId: event.callId,
|
|
984
|
+
});
|
|
985
|
+
},
|
|
986
|
+
emitFinished: async (event) => {
|
|
987
|
+
const turnId = playbookCallTurnIds.get(event.callId);
|
|
988
|
+
try {
|
|
989
|
+
await emitTrace('playbook.call.finished', {
|
|
990
|
+
stateId: event.stateId,
|
|
991
|
+
playbookId: event.playbookId,
|
|
992
|
+
text: event.text,
|
|
993
|
+
result: event.result,
|
|
994
|
+
}, {
|
|
995
|
+
...(turnId === undefined ? {} : { turnId }),
|
|
996
|
+
callId: event.callId,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
finally {
|
|
1000
|
+
playbookCallTurnIds.delete(event.callId);
|
|
1001
|
+
}
|
|
1002
|
+
},
|
|
1003
|
+
drain: flush,
|
|
1004
|
+
bindResumeSignal: (signal) => {
|
|
1005
|
+
currentSignal = signal;
|
|
1006
|
+
},
|
|
1007
|
+
onControlPlaneError: (error) => {
|
|
1008
|
+
const signal = currentSignal;
|
|
1009
|
+
if (!signal || !isAbortFailure(error, signal)) {
|
|
1010
|
+
controlPlaneError ??= error;
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
onBackgroundError: (error) => {
|
|
1014
|
+
collectFailure(emissionFailures, error);
|
|
1015
|
+
},
|
|
1016
|
+
});
|
|
1017
|
+
const providedMachine = decideMachine.provide({
|
|
1018
|
+
actors: { player, playbook: nestedBridge.actorLogic },
|
|
1019
|
+
});
|
|
985
1020
|
const inspect = (event) => {
|
|
986
1021
|
if (event.type !== '@xstate.snapshot')
|
|
987
1022
|
return;
|
|
@@ -989,49 +1024,58 @@ export const createPlaybookRuntime = (options) => {
|
|
|
989
1024
|
return;
|
|
990
1025
|
if (suppressInspectionEmissions)
|
|
991
1026
|
return;
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1027
|
+
try {
|
|
1028
|
+
const snapshot = event.snapshot;
|
|
1029
|
+
const state = normalizePlaybookSnapshot(snapshot);
|
|
1030
|
+
const prior = previousState ?? state;
|
|
1031
|
+
const context = snapshot.context;
|
|
1032
|
+
const fsmPayload = telemetryPayload(prior, state, event.event, context);
|
|
1033
|
+
const describedFsmPayload = snapshotJsonValue(fsmPayload, 'described FSM telemetry');
|
|
1034
|
+
void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
|
|
1035
|
+
topic: TELEMETRY_TOPIC,
|
|
1036
|
+
payload: describedFsmPayload,
|
|
1037
|
+
})).catch(() => undefined);
|
|
1038
|
+
const priorIds = new Set(previousState?.activeStateIds ?? []);
|
|
1039
|
+
previousState = state;
|
|
1040
|
+
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
1041
|
+
const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
|
|
1042
|
+
const scheduleStatus = (message, stateId, data) => {
|
|
1043
|
+
const tracePayload = {
|
|
1044
|
+
...(bossRelevantStateIds.length === 1 ? { stateId } : {}),
|
|
1045
|
+
message,
|
|
1046
|
+
state,
|
|
1047
|
+
...(data !== undefined ? { data } : {}),
|
|
1048
|
+
};
|
|
1049
|
+
assertJsonSafe(tracePayload);
|
|
1050
|
+
void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data)).catch(() => undefined);
|
|
1015
1051
|
};
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
if (priorIds.has(activeStateId) || !STATUS_STATE_IDS.has(activeStateId)) {
|
|
1021
|
-
continue;
|
|
1022
|
-
}
|
|
1023
|
-
if (WAIT_STATE_IDS.has(activeStateId)) {
|
|
1024
|
-
const pending = questionForWaitState(activeStateId, pendingQuestions);
|
|
1025
|
-
if (pending) {
|
|
1026
|
-
scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
|
|
1027
|
-
scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`, activeStateId);
|
|
1052
|
+
for (const activeStateId of state.activeStateIds) {
|
|
1053
|
+
if (priorIds.has(activeStateId) ||
|
|
1054
|
+
!STATUS_STATE_IDS.has(activeStateId)) {
|
|
1055
|
+
continue;
|
|
1028
1056
|
}
|
|
1029
|
-
|
|
1057
|
+
if (WAIT_STATE_IDS.has(activeStateId)) {
|
|
1058
|
+
const pending = questionForWaitState(activeStateId, pendingQuestions);
|
|
1059
|
+
if (pending) {
|
|
1060
|
+
scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
|
|
1061
|
+
scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`, activeStateId);
|
|
1062
|
+
}
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
const lastError = activeStateId === 'failed'
|
|
1066
|
+
? normalizeErrorCompact(context.lastError)
|
|
1067
|
+
: undefined;
|
|
1068
|
+
const description = STATE_DESCRIPTIONS[activeStateId];
|
|
1069
|
+
if (description === undefined)
|
|
1070
|
+
continue;
|
|
1071
|
+
const playerState = PLAYER_STATES.find((candidate) => candidate.stateId === activeStateId);
|
|
1072
|
+
scheduleStatus(playerState === undefined
|
|
1073
|
+
? '◆ workflow failed; awaiting Boss recovery.'
|
|
1074
|
+
: `⤷ ${playerState.player}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
|
|
1030
1075
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
scheduleStatus(STATE_DESCRIPTIONS[activeStateId] ?? activeStateId, activeStateId, lastError === undefined ? undefined : { lastError });
|
|
1076
|
+
}
|
|
1077
|
+
catch (error) {
|
|
1078
|
+
latchInspectionError(error);
|
|
1035
1079
|
}
|
|
1036
1080
|
};
|
|
1037
1081
|
const createRuntimeActor = (machineSnapshot) => {
|
|
@@ -1070,30 +1114,41 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1070
1114
|
const driveToQuiescence = async () => {
|
|
1071
1115
|
const live = actor;
|
|
1072
1116
|
if (!live)
|
|
1073
|
-
throw new Error('
|
|
1074
|
-
await waitForPlaybookQuiescence(live);
|
|
1117
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
1118
|
+
await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
|
|
1075
1119
|
};
|
|
1076
1120
|
const classify = async (text, signal) => {
|
|
1077
1121
|
const live = actor;
|
|
1078
1122
|
if (!live)
|
|
1079
|
-
throw new Error('
|
|
1123
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
1080
1124
|
const snapshot = live.getSnapshot();
|
|
1081
1125
|
const context = snapshot.context;
|
|
1082
|
-
const state = normalizePlaybookSnapshot(snapshot
|
|
1126
|
+
const state = normalizePlaybookSnapshot(snapshot, {
|
|
1127
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
1128
|
+
});
|
|
1083
1129
|
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
1130
|
+
if (pendingQuestions.length === 0 &&
|
|
1131
|
+
(snapshot.status === 'done' ||
|
|
1132
|
+
state.activeStateIds.includes('ready') ||
|
|
1133
|
+
state.activeStateIds.includes('failed'))) {
|
|
1134
|
+
return { type: 'START_DECIDE', callerTopic: text };
|
|
1135
|
+
}
|
|
1136
|
+
if (pendingQuestions.length === 0)
|
|
1137
|
+
return null;
|
|
1084
1138
|
const prompt = buildClassifierPrompt(text, {
|
|
1085
1139
|
state,
|
|
1086
1140
|
pendingQuestions,
|
|
1087
1141
|
});
|
|
1088
1142
|
const raw = await callJudge(prompt, signal, 'boss-input-classification', state.stateId);
|
|
1089
|
-
return parseClassification(raw, pendingQuestions.map(({ questionId }) => questionId));
|
|
1143
|
+
return parseClassification(raw, text, pendingQuestions.map(({ questionId }) => questionId));
|
|
1090
1144
|
};
|
|
1091
1145
|
const resultForSnapshot = (signal) => {
|
|
1092
1146
|
const live = actor;
|
|
1093
1147
|
if (!live)
|
|
1094
|
-
throw new Error('
|
|
1148
|
+
throw new Error('decide runtime: actor is not initialized');
|
|
1095
1149
|
const snapshot = live.getSnapshot();
|
|
1096
|
-
const
|
|
1150
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1151
|
+
const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
|
|
1097
1152
|
const context = snapshot.context;
|
|
1098
1153
|
if (signal?.aborted) {
|
|
1099
1154
|
return {
|
|
@@ -1121,7 +1176,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1121
1176
|
}
|
|
1122
1177
|
if (snapshot.status === 'error') {
|
|
1123
1178
|
throw (snapshot.error ??
|
|
1124
|
-
new Error('
|
|
1179
|
+
new Error('decide runtime actor entered error status'));
|
|
1125
1180
|
}
|
|
1126
1181
|
if (state.activeStateIds.includes('failed')) {
|
|
1127
1182
|
const error = normalizeErrorFull(context.lastError);
|
|
@@ -1131,6 +1186,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1131
1186
|
...(error === undefined ? {} : { error }),
|
|
1132
1187
|
};
|
|
1133
1188
|
}
|
|
1189
|
+
if (pendingCall) {
|
|
1190
|
+
return { outcome: 'suspended', state, pendingCall };
|
|
1191
|
+
}
|
|
1134
1192
|
return { outcome: 'quiescent', state };
|
|
1135
1193
|
};
|
|
1136
1194
|
// Shared failed-start cleanup for init and restore: stop the actor,
|
|
@@ -1139,7 +1197,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1139
1197
|
// The caller rethrows its original failure. A restore failure skips
|
|
1140
1198
|
// the disposal trace — the parked session was never re-bound in this
|
|
1141
1199
|
// process, so its persisted snapshot stays authoritative (DR-014 §2).
|
|
1142
|
-
const cleanupFailedStart = async (options) => {
|
|
1200
|
+
const cleanupFailedStart = async (cause, options) => {
|
|
1143
1201
|
let finalState;
|
|
1144
1202
|
if (options.emitDisposal && actor) {
|
|
1145
1203
|
try {
|
|
@@ -1155,6 +1213,12 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1155
1213
|
catch {
|
|
1156
1214
|
// Preserve the original startup failure.
|
|
1157
1215
|
}
|
|
1216
|
+
try {
|
|
1217
|
+
await nestedBridge.abortPending(cause);
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
// Preserve the original startup failure.
|
|
1221
|
+
}
|
|
1158
1222
|
try {
|
|
1159
1223
|
await judgeQueue.onIdle();
|
|
1160
1224
|
await drainBoundaryCallsAndEmissions();
|
|
@@ -1194,6 +1258,8 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1194
1258
|
turnSequence = 0;
|
|
1195
1259
|
judgeCallSequence = 0;
|
|
1196
1260
|
playerCallSequence = 0;
|
|
1261
|
+
playbookCallSequence = 0;
|
|
1262
|
+
playbookCallTurnIds.clear();
|
|
1197
1263
|
lifecycleStarted = false;
|
|
1198
1264
|
};
|
|
1199
1265
|
return {
|
|
@@ -1202,7 +1268,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1202
1268
|
initInFlight !== undefined ||
|
|
1203
1269
|
disposed ||
|
|
1204
1270
|
disposalPromise !== undefined) {
|
|
1205
|
-
throw new Error('
|
|
1271
|
+
throw new Error('decide runtime: init(session) may only be called once');
|
|
1206
1272
|
}
|
|
1207
1273
|
const identity = snapshotPlaybookSession(session);
|
|
1208
1274
|
let finishInitialization;
|
|
@@ -1225,7 +1291,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1225
1291
|
await flush();
|
|
1226
1292
|
}
|
|
1227
1293
|
catch (error) {
|
|
1228
|
-
await cleanupFailedStart({ emitDisposal: true });
|
|
1294
|
+
await cleanupFailedStart(error, { emitDisposal: true });
|
|
1229
1295
|
throw error;
|
|
1230
1296
|
}
|
|
1231
1297
|
finally {
|
|
@@ -1234,11 +1300,11 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1234
1300
|
initInFlight = undefined;
|
|
1235
1301
|
}
|
|
1236
1302
|
},
|
|
1237
|
-
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked
|
|
1303
|
+
// DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
|
|
1304
|
+
// session, including one already-started suspended REVIEW call.
|
|
1238
1305
|
// Defined only at a safe capture point — initialized, not disposing
|
|
1239
|
-
// or disposed, no active public boundary, and the actor quiescent
|
|
1240
|
-
//
|
|
1241
|
-
// so no pending-call guard applies.
|
|
1306
|
+
// or disposed, no active public boundary, and the actor quiescent with
|
|
1307
|
+
// status `active`.
|
|
1242
1308
|
exportSnapshot() {
|
|
1243
1309
|
if (!actor ||
|
|
1244
1310
|
!sessionIdentity ||
|
|
@@ -1249,22 +1315,49 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1249
1315
|
if (currentTurnId !== undefined || currentSignal !== undefined) {
|
|
1250
1316
|
return undefined;
|
|
1251
1317
|
}
|
|
1318
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1319
|
+
const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
|
|
1320
|
+
if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
|
|
1321
|
+
return undefined;
|
|
1322
|
+
}
|
|
1323
|
+
if (pendingCall !== undefined &&
|
|
1324
|
+
bridgeSuspendedCall !== undefined &&
|
|
1325
|
+
(pendingCall.callId !== bridgeSuspendedCall.callId ||
|
|
1326
|
+
pendingCall.playbookId !== bridgeSuspendedCall.playbookId ||
|
|
1327
|
+
pendingCall.childSessionId !== bridgeSuspendedCall.childSessionId)) {
|
|
1328
|
+
return undefined;
|
|
1329
|
+
}
|
|
1330
|
+
let suspendedCall;
|
|
1331
|
+
if (bridgeSuspendedCall !== undefined) {
|
|
1332
|
+
if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
|
|
1333
|
+
return undefined;
|
|
1334
|
+
}
|
|
1335
|
+
const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
|
|
1336
|
+
if (bridgeSuspendedCall.turnId !== undefined &&
|
|
1337
|
+
bridgeSuspendedCall.turnId !== turnId) {
|
|
1338
|
+
return undefined;
|
|
1339
|
+
}
|
|
1340
|
+
suspendedCall = {
|
|
1341
|
+
...bridgeSuspendedCall,
|
|
1342
|
+
...(turnId === undefined ? {} : { turnId }),
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1252
1345
|
const state = currentState();
|
|
1253
1346
|
if (state.status !== 'active' || !state.quiescent)
|
|
1254
1347
|
return undefined;
|
|
1255
1348
|
const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
|
|
1256
1349
|
const context = actor.getSnapshot().context;
|
|
1257
1350
|
return {
|
|
1258
|
-
schemaVersion:
|
|
1351
|
+
schemaVersion: 2,
|
|
1259
1352
|
playbookId: sessionIdentity.playbookId,
|
|
1260
1353
|
machine,
|
|
1261
|
-
playerResumeTokens:
|
|
1354
|
+
playerResumeTokens: snapshotPlayerResumeTokens(),
|
|
1262
1355
|
sequences: {
|
|
1263
1356
|
trace: traceSequence,
|
|
1264
1357
|
turn: turnSequence,
|
|
1265
1358
|
judgeCall: judgeCallSequence,
|
|
1266
1359
|
playerCall: playerCallSequence,
|
|
1267
|
-
playbookCall:
|
|
1360
|
+
playbookCall: playbookCallSequence,
|
|
1268
1361
|
},
|
|
1269
1362
|
state,
|
|
1270
1363
|
pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
|
|
@@ -1273,6 +1366,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1273
1366
|
question: pending.question,
|
|
1274
1367
|
sourceItem: pending.sourceItem,
|
|
1275
1368
|
})),
|
|
1369
|
+
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
1276
1370
|
};
|
|
1277
1371
|
},
|
|
1278
1372
|
// DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
|
|
@@ -1285,10 +1379,13 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1285
1379
|
initInFlight !== undefined ||
|
|
1286
1380
|
disposed ||
|
|
1287
1381
|
disposalPromise !== undefined) {
|
|
1288
|
-
throw new Error('
|
|
1382
|
+
throw new Error('decide runtime: restore(session, snapshot) may only be called once');
|
|
1289
1383
|
}
|
|
1290
1384
|
const identity = snapshotPlaybookSession(session);
|
|
1291
|
-
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId);
|
|
1385
|
+
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId, { allowSuspendedCall: true });
|
|
1386
|
+
const suspendedCall = boundSnapshot.schemaVersion === 2
|
|
1387
|
+
? boundSnapshot.suspendedCall
|
|
1388
|
+
: undefined;
|
|
1292
1389
|
let finishInitialization;
|
|
1293
1390
|
const initialization = new Promise((resolve) => {
|
|
1294
1391
|
finishInitialization = resolve;
|
|
@@ -1297,29 +1394,52 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1297
1394
|
lifecycleStarted = true;
|
|
1298
1395
|
ports = identity.ports;
|
|
1299
1396
|
sessionIdentity = identity;
|
|
1397
|
+
let priorExternalPlayerTokens;
|
|
1300
1398
|
try {
|
|
1301
1399
|
traceSequence = boundSnapshot.sequences.trace;
|
|
1302
1400
|
turnSequence = boundSnapshot.sequences.turn;
|
|
1303
1401
|
judgeCallSequence = boundSnapshot.sequences.judgeCall;
|
|
1304
1402
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1403
|
+
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
1404
|
+
if (identity.playerSessions) {
|
|
1405
|
+
priorExternalPlayerTokens = snapshotPlayerResumeTokens();
|
|
1406
|
+
}
|
|
1407
|
+
restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
|
|
1408
|
+
nestedBridge.prepareRestore(suspendedCall);
|
|
1409
|
+
if (suspendedCall !== undefined) {
|
|
1410
|
+
playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
|
|
1308
1411
|
}
|
|
1309
1412
|
suppressInspectionEmissions = true;
|
|
1310
1413
|
createRuntimeActor(boundSnapshot.machine);
|
|
1311
1414
|
actor?.start();
|
|
1312
|
-
const restoredState = currentState();
|
|
1415
|
+
const restoredState = currentState(suspendedCall);
|
|
1313
1416
|
if (restoredState.status !== 'active') {
|
|
1314
|
-
throw new Error(`
|
|
1417
|
+
throw new Error(`decide runtime: restored actor status is ${restoredState.status}, expected active`);
|
|
1418
|
+
}
|
|
1419
|
+
if (stableJson(restoredState, 'restored runtime state') !==
|
|
1420
|
+
stableJson(boundSnapshot.state, 'runtime snapshot state')) {
|
|
1421
|
+
throw new Error('decide runtime: restored actor state does not match snapshot state');
|
|
1315
1422
|
}
|
|
1316
|
-
suppressInspectionEmissions = false;
|
|
1317
1423
|
previousState = restoredState;
|
|
1318
1424
|
await flush();
|
|
1425
|
+
suppressInspectionEmissions = false;
|
|
1426
|
+
// Final fallible step: after this publication the authoritative
|
|
1427
|
+
// child has rejoined ordinary resume/abort ownership, so no later
|
|
1428
|
+
// restore validation may trigger failed-start rollback.
|
|
1429
|
+
nestedBridge.confirmRestore();
|
|
1319
1430
|
}
|
|
1320
1431
|
catch (error) {
|
|
1321
|
-
|
|
1322
|
-
|
|
1432
|
+
let failure = error;
|
|
1433
|
+
if (priorExternalPlayerTokens !== undefined) {
|
|
1434
|
+
try {
|
|
1435
|
+
identity.playerSessions.restore(priorExternalPlayerTokens);
|
|
1436
|
+
}
|
|
1437
|
+
catch (rollbackError) {
|
|
1438
|
+
failure = new AggregateError([error, rollbackError], 'DECIDE restore and player continuation rollback failed');
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
await cleanupFailedStart(failure, { emitDisposal: false });
|
|
1442
|
+
throw failure;
|
|
1323
1443
|
}
|
|
1324
1444
|
finally {
|
|
1325
1445
|
finishInitialization();
|
|
@@ -1329,14 +1449,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1329
1449
|
},
|
|
1330
1450
|
async handleBossInput(turn) {
|
|
1331
1451
|
if (disposalPromise !== undefined) {
|
|
1332
|
-
throw new Error('
|
|
1452
|
+
throw new Error('decide runtime: runtime is disposing or disposed');
|
|
1333
1453
|
}
|
|
1334
1454
|
requirePorts();
|
|
1335
1455
|
if (!actor) {
|
|
1336
|
-
throw new Error('
|
|
1456
|
+
throw new Error('decide runtime: init(session) must be called before handleBossInput');
|
|
1337
1457
|
}
|
|
1338
|
-
if (
|
|
1339
|
-
throw new Error('
|
|
1458
|
+
if (currentSignal !== undefined) {
|
|
1459
|
+
throw new Error('decide runtime: another runtime turn is active');
|
|
1340
1460
|
}
|
|
1341
1461
|
const turnId = ++turnSequence;
|
|
1342
1462
|
currentTurnId = turnId;
|
|
@@ -1358,6 +1478,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1358
1478
|
await emitBoundaryStatus('No playbook action classified.', state);
|
|
1359
1479
|
result = { outcome: 'no-action', state };
|
|
1360
1480
|
}
|
|
1481
|
+
else if (event.type === 'NO_ACTION') {
|
|
1482
|
+
result = { outcome: 'no-action', state: currentState() };
|
|
1483
|
+
}
|
|
1361
1484
|
else {
|
|
1362
1485
|
await emitBoundaryStatus(event.type, currentState());
|
|
1363
1486
|
if (actor.getSnapshot().status === 'done') {
|
|
@@ -1446,25 +1569,68 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1446
1569
|
if (failures.length === 1)
|
|
1447
1570
|
throw failures[0];
|
|
1448
1571
|
if (failures.length > 1) {
|
|
1449
|
-
throw new AggregateError(failures, '
|
|
1572
|
+
throw new AggregateError(failures, 'decide runtime turn failed');
|
|
1450
1573
|
}
|
|
1451
1574
|
return result;
|
|
1452
1575
|
},
|
|
1453
|
-
async resumePlaybookCall({ callId, }) {
|
|
1576
|
+
async resumePlaybookCall({ callId, result: childResult, signal, }) {
|
|
1454
1577
|
if (disposalPromise !== undefined) {
|
|
1455
|
-
throw new Error('
|
|
1578
|
+
throw new Error('decide runtime: runtime is disposing or disposed');
|
|
1456
1579
|
}
|
|
1457
1580
|
requirePorts();
|
|
1458
1581
|
if (!actor) {
|
|
1459
|
-
throw new Error('
|
|
1582
|
+
throw new Error('decide runtime: init(session) must be called before resumePlaybookCall');
|
|
1583
|
+
}
|
|
1584
|
+
if (currentSignal !== undefined) {
|
|
1585
|
+
throw new Error('decide runtime: another runtime turn is active');
|
|
1586
|
+
}
|
|
1587
|
+
currentTurnId = playbookCallTurnIds.get(callId);
|
|
1588
|
+
currentSignal = signal;
|
|
1589
|
+
controlPlaneError = undefined;
|
|
1590
|
+
let runResult;
|
|
1591
|
+
let operationError;
|
|
1592
|
+
try {
|
|
1593
|
+
await nestedBridge.resume({
|
|
1594
|
+
callId,
|
|
1595
|
+
result: childResult,
|
|
1596
|
+
signal,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
catch (error) {
|
|
1600
|
+
operationError = error;
|
|
1601
|
+
}
|
|
1602
|
+
try {
|
|
1603
|
+
await waitForPlaybookQuiescence(actor, {
|
|
1604
|
+
pendingCalls: nestedBridge,
|
|
1605
|
+
});
|
|
1606
|
+
runResult = resultForSnapshot(signal);
|
|
1607
|
+
}
|
|
1608
|
+
catch (error) {
|
|
1609
|
+
operationError ??= error;
|
|
1460
1610
|
}
|
|
1461
|
-
|
|
1611
|
+
let drainError;
|
|
1612
|
+
try {
|
|
1613
|
+
await drainBoundaryCallsAndEmissions();
|
|
1614
|
+
}
|
|
1615
|
+
catch (error) {
|
|
1616
|
+
drainError = error;
|
|
1617
|
+
}
|
|
1618
|
+
const failure = controlPlaneError ?? drainError ?? operationError;
|
|
1619
|
+
currentSignal = undefined;
|
|
1620
|
+
currentTurnId = undefined;
|
|
1621
|
+
controlPlaneError = undefined;
|
|
1622
|
+
if (failure !== undefined)
|
|
1623
|
+
throw failure;
|
|
1624
|
+
if (runResult === undefined) {
|
|
1625
|
+
throw new Error('decide runtime: playbook resume produced no result');
|
|
1626
|
+
}
|
|
1627
|
+
return runResult;
|
|
1462
1628
|
},
|
|
1463
1629
|
dispose() {
|
|
1464
1630
|
if (disposalPromise !== undefined)
|
|
1465
1631
|
return disposalPromise;
|
|
1466
|
-
if (
|
|
1467
|
-
return Promise.reject(new Error('
|
|
1632
|
+
if (currentSignal !== undefined) {
|
|
1633
|
+
return Promise.reject(new Error('decide runtime: cannot dispose while a runtime turn is active'));
|
|
1468
1634
|
}
|
|
1469
1635
|
disposalPromise = (async () => {
|
|
1470
1636
|
const initialization = initInFlight;
|
|
@@ -1477,6 +1643,12 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1477
1643
|
const finalState = currentState();
|
|
1478
1644
|
const failures = [];
|
|
1479
1645
|
stopActor();
|
|
1646
|
+
try {
|
|
1647
|
+
await nestedBridge.dispose();
|
|
1648
|
+
}
|
|
1649
|
+
catch (error) {
|
|
1650
|
+
collectFailure(failures, error);
|
|
1651
|
+
}
|
|
1480
1652
|
try {
|
|
1481
1653
|
await drainBoundaryCallsAndEmissions();
|
|
1482
1654
|
}
|
|
@@ -1500,6 +1672,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1500
1672
|
}
|
|
1501
1673
|
finally {
|
|
1502
1674
|
playerResumeTokens.clear();
|
|
1675
|
+
playbookCallTurnIds.clear();
|
|
1503
1676
|
inFlightPlayerIds.clear();
|
|
1504
1677
|
activeBoundaryCalls.clear();
|
|
1505
1678
|
activeEmissionCalls.clear();
|
|
@@ -1517,7 +1690,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1517
1690
|
if (failures.length === 1)
|
|
1518
1691
|
throw failures[0];
|
|
1519
1692
|
if (failures.length > 1) {
|
|
1520
|
-
throw new AggregateError(failures, '
|
|
1693
|
+
throw new AggregateError(failures, 'decide runtime disposal failed');
|
|
1521
1694
|
}
|
|
1522
1695
|
})();
|
|
1523
1696
|
return disposalPromise;
|
|
@@ -1538,10 +1711,10 @@ export const _internal = {
|
|
|
1538
1711
|
normalizeErrorCompact,
|
|
1539
1712
|
normalizeErrorFull,
|
|
1540
1713
|
DEFAULT_PLAYER_BINDING,
|
|
1541
|
-
ALIAS_RESOLUTION,
|
|
1542
1714
|
STATE_DESCRIPTIONS,
|
|
1543
|
-
|
|
1544
|
-
|
|
1715
|
+
PLAYER_STATES,
|
|
1716
|
+
PLAYER_STATE_IDS,
|
|
1717
|
+
VERBATIM_PAYLOAD_FIELDS,
|
|
1545
1718
|
BOSS_INTERRUPT_TARGETS,
|
|
1546
1719
|
CONTINUATION_PREAMBLE,
|
|
1547
1720
|
TELEMETRY_TOPIC,
|