@sublang/playbook 6.0.0 → 8.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 +28 -11
- package/docs/cli.md +158 -68
- package/docs/configuration.md +246 -108
- package/docs/embedding.md +71 -25
- package/package.json +6 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1164 -991
- package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +7 -11
- package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +12 -13
- package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
- package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
- package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
- package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +16 -21
- package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +27 -23
- package/slc/link.md +140 -97
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +24 -8
- package/src/runtime.ts +29 -13
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +301 -159
- package/src/xstate-playbook-runtime.ts +405 -186
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +403 -62
- package/src/xstate-runtime.ts +566 -78
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// Linker inputs:
|
|
8
8
|
// FSM artifact: ./decide.fsm.ts
|
|
9
9
|
// Link target: @sublang/playbook/runtime
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// Role binding: canonical coder and reviewer roles; concrete players
|
|
11
|
+
// and prompt identities are supplied by the host session
|
|
12
12
|
// Adjudication: LLM-judge per state (default)
|
|
13
13
|
// Boss-event mapping: free-text judge classification (default)
|
|
14
14
|
// Abort strategy: natural rejection; every player-invoking state's
|
|
@@ -98,58 +98,20 @@ export type {
|
|
|
98
98
|
PlaybookTraceType,
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
-
type
|
|
101
|
+
type RoleId = 'coder' | 'reviewer';
|
|
102
102
|
|
|
103
|
-
export
|
|
104
|
-
playerBinding?: Partial<Record<PlayerName, string>>;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const DEFAULT_PLAYER_BINDING: Readonly<Record<PlayerName, string>> = {
|
|
108
|
-
Coder: 'coder',
|
|
109
|
-
Reviewer: 'reviewer',
|
|
110
|
-
};
|
|
103
|
+
export type PlaybookRuntimeOptions = DecideInput;
|
|
111
104
|
|
|
112
105
|
function snapshotDecideRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
|
|
113
106
|
const captured = snapshotJsonValue(value, 'DECIDE runtime options');
|
|
114
107
|
if (!isPlainObject(captured)) {
|
|
115
108
|
throw new TypeError('DECIDE runtime options must be an object');
|
|
116
109
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
throw new TypeError(`DECIDE runtime options.${key} is not declared`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
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
|
-
);
|
|
130
|
-
}
|
|
131
|
-
if ('playerBinding' in captured) {
|
|
132
|
-
const playerBinding = captured.playerBinding;
|
|
133
|
-
if (!isPlainObject(playerBinding)) {
|
|
134
|
-
throw new TypeError(
|
|
135
|
-
'DECIDE runtime options.playerBinding must be an object',
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
const playerNames = new Set<PlayerName>(['Coder', 'Reviewer']);
|
|
139
|
-
for (const [player, playerId] of Object.entries(playerBinding)) {
|
|
140
|
-
if (!playerNames.has(player as PlayerName)) {
|
|
141
|
-
throw new TypeError(
|
|
142
|
-
`DECIDE runtime options.playerBinding.${player} is not declared`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
if (typeof playerId !== 'string' || playerId.trim().length === 0) {
|
|
146
|
-
throw new TypeError(
|
|
147
|
-
`DECIDE runtime options.playerBinding.${player} must be a non-empty string`,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
110
|
+
const [unknown] = Object.keys(captured);
|
|
111
|
+
if (unknown !== undefined) {
|
|
112
|
+
throw new TypeError(`DECIDE runtime options.${unknown} is not declared`);
|
|
151
113
|
}
|
|
152
|
-
return
|
|
114
|
+
return Object.freeze({});
|
|
153
115
|
}
|
|
154
116
|
|
|
155
117
|
const STATE_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
|
@@ -167,20 +129,26 @@ const STATE_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
|
|
167
129
|
done: 'DECIDE completed with an approved commit.',
|
|
168
130
|
};
|
|
169
131
|
|
|
170
|
-
const
|
|
171
|
-
{ stateId: 'askCoderProposal',
|
|
132
|
+
const ROLE_STATES = [
|
|
133
|
+
{ stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
|
|
172
134
|
{
|
|
173
135
|
stateId: 'askReviewerProposal',
|
|
174
|
-
|
|
136
|
+
role: 'reviewer',
|
|
175
137
|
sourceItem: 'DECIDE-2',
|
|
176
138
|
},
|
|
177
|
-
{ stateId: 'commitCoderProposal',
|
|
139
|
+
{ stateId: 'commitCoderProposal', role: 'coder', sourceItem: 'DECIDE-3' },
|
|
178
140
|
] as const;
|
|
179
141
|
|
|
180
|
-
const
|
|
181
|
-
|
|
142
|
+
const ROLE_STATE_IDS: ReadonlySet<string> = new Set(
|
|
143
|
+
ROLE_STATES.map((state) => state.stateId),
|
|
182
144
|
);
|
|
183
145
|
|
|
146
|
+
const ROLE_IDS = ['coder', 'reviewer'] as const;
|
|
147
|
+
const ROLE_ID_SET: ReadonlySet<string> = new Set(ROLE_IDS);
|
|
148
|
+
|
|
149
|
+
const roleLabel = (roleId: RoleId): string =>
|
|
150
|
+
roleId === 'coder' ? 'Coder' : 'Reviewer';
|
|
151
|
+
|
|
184
152
|
const BOSS_INTERRUPT_TARGETS = ['independentProposals'] as const;
|
|
185
153
|
|
|
186
154
|
const BOSS_INTERRUPT_TARGET_IDS: ReadonlySet<string> = new Set(
|
|
@@ -194,10 +162,7 @@ const CONTINUATION_PREAMBLE =
|
|
|
194
162
|
'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
|
|
195
163
|
|
|
196
164
|
const PLACEHOLDER_FIELDS: ReadonlyArray<readonly [string, keyof PlayerInput]> =
|
|
197
|
-
[
|
|
198
|
-
['<caller-topic>', 'callerTopic'],
|
|
199
|
-
['<coder-llm>', 'coderLlm'],
|
|
200
|
-
];
|
|
165
|
+
[['<caller-topic>', 'callerTopic']];
|
|
201
166
|
|
|
202
167
|
const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
|
|
203
168
|
'coderProposal',
|
|
@@ -205,7 +170,12 @@ const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
|
|
|
205
170
|
'coderOutput',
|
|
206
171
|
]);
|
|
207
172
|
|
|
208
|
-
|
|
173
|
+
type PromptIdentity = (roleId: RoleId) => string;
|
|
174
|
+
|
|
175
|
+
function composePlayerPrompt(
|
|
176
|
+
input: PlayerInput,
|
|
177
|
+
promptIdentity: PromptIdentity,
|
|
178
|
+
): string {
|
|
209
179
|
const blocks: string[] = [];
|
|
210
180
|
|
|
211
181
|
if (input.pendingBossQuestion && input.bossReply !== undefined) {
|
|
@@ -227,6 +197,9 @@ function composePlayerPrompt(input: PlayerInput): string {
|
|
|
227
197
|
const value = input[field];
|
|
228
198
|
if (typeof value === 'string') replacements.set(placeholder, value);
|
|
229
199
|
}
|
|
200
|
+
if (input.prompt.includes('<coder-llm>')) {
|
|
201
|
+
replacements.set('<coder-llm>', promptIdentity('coder'));
|
|
202
|
+
}
|
|
230
203
|
const body = input.prompt.replace(
|
|
231
204
|
/<caller-topic>|<coder-llm>/g,
|
|
232
205
|
(placeholder, offset: number, source: string) => {
|
|
@@ -243,22 +216,6 @@ function composePlayerPrompt(input: PlayerInput): string {
|
|
|
243
216
|
return blocks.join('\n\n');
|
|
244
217
|
}
|
|
245
218
|
|
|
246
|
-
function resolvePlayerId(
|
|
247
|
-
input: PlayerInput,
|
|
248
|
-
binding: Record<PlayerName, string>,
|
|
249
|
-
): string {
|
|
250
|
-
switch (input.player) {
|
|
251
|
-
case 'Coder':
|
|
252
|
-
return binding.Coder;
|
|
253
|
-
case 'Reviewer':
|
|
254
|
-
return binding.Reviewer;
|
|
255
|
-
default: {
|
|
256
|
-
const exhaustive: never = input.player;
|
|
257
|
-
throw new Error(`unknown player ${String(exhaustive)}`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
219
|
// A `result` description names required payload fields in its
|
|
263
220
|
// "Output shall include ..." sentence.
|
|
264
221
|
function requiredFieldsFor(description: string): string[] {
|
|
@@ -324,6 +281,23 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
324
281
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
325
282
|
}
|
|
326
283
|
|
|
284
|
+
function sortJson(value: JsonValue): JsonValue {
|
|
285
|
+
if (Array.isArray(value)) return value.map((entry) => sortJson(entry));
|
|
286
|
+
if (value !== null && typeof value === 'object') {
|
|
287
|
+
const record = value as { readonly [key: string]: JsonValue };
|
|
288
|
+
const sorted: Record<string, JsonValue> = {};
|
|
289
|
+
for (const key of Object.keys(record).sort()) {
|
|
290
|
+
sorted[key] = sortJson(record[key]);
|
|
291
|
+
}
|
|
292
|
+
return sorted;
|
|
293
|
+
}
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function stableJson(value: unknown, path: string): string {
|
|
298
|
+
return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
|
|
299
|
+
}
|
|
300
|
+
|
|
327
301
|
function stripCodeFence(text: string): string {
|
|
328
302
|
const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
|
|
329
303
|
return fence ? fence[1].trim() : text;
|
|
@@ -396,7 +370,7 @@ function buildClassifierPrompt(
|
|
|
396
370
|
lines.push('Pending Boss questions:');
|
|
397
371
|
for (const pending of ctx.pendingQuestions) {
|
|
398
372
|
lines.push(
|
|
399
|
-
`- ${pending.questionId} (${pending.
|
|
373
|
+
`- ${pending.questionId} (${pending.asker.roleId}): ${pending.question}`,
|
|
400
374
|
);
|
|
401
375
|
}
|
|
402
376
|
lines.push(
|
|
@@ -496,7 +470,7 @@ function buildAdjudicatorPrompt(
|
|
|
496
470
|
'and guard descriptions. Reply with exactly one JSON object and no prose.',
|
|
497
471
|
);
|
|
498
472
|
lines.push(
|
|
499
|
-
`The
|
|
473
|
+
`The role "${roleLabel(input.role)}" produced the output below for source item ${input.sourceItem}.`,
|
|
500
474
|
);
|
|
501
475
|
lines.push('Choose exactly one guard whose description matches that output.');
|
|
502
476
|
lines.push('');
|
|
@@ -632,7 +606,9 @@ function pendingQuestionsFromContext(
|
|
|
632
606
|
obj.questionId === key &&
|
|
633
607
|
typeof obj.resumeStateId === 'string' &&
|
|
634
608
|
typeof obj.sourceItem === 'string' &&
|
|
635
|
-
|
|
609
|
+
isPlainObject(obj.asker) &&
|
|
610
|
+
obj.asker.kind === 'role' &&
|
|
611
|
+
ROLE_ID_SET.has(String(obj.asker.roleId)) &&
|
|
636
612
|
typeof obj.question === 'string'
|
|
637
613
|
) {
|
|
638
614
|
questions.push(obj as unknown as PendingBossQuestion);
|
|
@@ -654,7 +630,7 @@ const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
|
|
|
654
630
|
]);
|
|
655
631
|
|
|
656
632
|
const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
|
|
657
|
-
...
|
|
633
|
+
...ROLE_STATE_IDS,
|
|
658
634
|
...WAIT_STATE_IDS,
|
|
659
635
|
'failed',
|
|
660
636
|
]);
|
|
@@ -732,14 +708,7 @@ function telemetryPayload(
|
|
|
732
708
|
export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
733
709
|
PlaybookRuntimeOptions
|
|
734
710
|
> = (options) => {
|
|
735
|
-
const
|
|
736
|
-
const binding: Record<PlayerName, string> = {
|
|
737
|
-
...DEFAULT_PLAYER_BINDING,
|
|
738
|
-
...(boundOptions.playerBinding ?? {}),
|
|
739
|
-
};
|
|
740
|
-
const fsmInput: DecideInput = {
|
|
741
|
-
coderLlm: boundOptions.coderLlm,
|
|
742
|
-
};
|
|
711
|
+
const fsmInput = snapshotDecideRuntimeOptions(options);
|
|
743
712
|
|
|
744
713
|
type SessionIdentity = Readonly<PlaybookSession>;
|
|
745
714
|
|
|
@@ -762,9 +731,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
762
731
|
let disposalPromise: Promise<void> | undefined;
|
|
763
732
|
let controlPlaneError: unknown;
|
|
764
733
|
let nestedBridge: NestedPlaybookBridge<PlaybookInput>;
|
|
765
|
-
const
|
|
734
|
+
const privateResumeTokens = new Map<string, string>();
|
|
766
735
|
const playbookCallTurnIds = new Map<string, number | undefined>();
|
|
767
|
-
const
|
|
736
|
+
const inFlightPlayerKeys = new Set<string>();
|
|
768
737
|
const activeBoundaryCalls = new Set<Promise<unknown>>();
|
|
769
738
|
const activeEmissionCalls = new Set<Promise<void>>();
|
|
770
739
|
const emissionQueue = new PQueue({ concurrency: 1 });
|
|
@@ -852,81 +821,187 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
852
821
|
}
|
|
853
822
|
return sessionIdentity;
|
|
854
823
|
};
|
|
855
|
-
|
|
824
|
+
|
|
825
|
+
const bindSession = (nextSession: PlaybookSession): SessionIdentity => {
|
|
826
|
+
const bound = snapshotPlaybookSession(nextSession);
|
|
827
|
+
if (bound.roleBindings === undefined) return bound;
|
|
828
|
+
const actual = Object.keys(bound.roleBindings).sort();
|
|
829
|
+
const expected = [...ROLE_IDS].sort();
|
|
830
|
+
const missing = expected.filter((roleId) => !actual.includes(roleId));
|
|
831
|
+
const extra = actual.filter((roleId) => !ROLE_ID_SET.has(roleId));
|
|
832
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
833
|
+
throw new TypeError(
|
|
834
|
+
`DECIDE session roleBindings must cover exactly [${expected.join(', ')}]` +
|
|
835
|
+
`${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
|
|
836
|
+
`${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`,
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
return bound;
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const resolvedPlayerId = (roleId: RoleId): string | undefined =>
|
|
843
|
+
requireSessionIdentity().roleBindings?.[roleId]?.playerId;
|
|
844
|
+
|
|
845
|
+
const promptIdentity = (roleId: RoleId): string =>
|
|
846
|
+
requireSessionIdentity().roleBindings?.[roleId]?.promptIdentity ?? roleId;
|
|
847
|
+
|
|
848
|
+
const composeInvocationPrompt = (input: PlayerInput): string => {
|
|
849
|
+
let active = true;
|
|
850
|
+
const lookup: PromptIdentity = (roleId) => {
|
|
851
|
+
if (!active) {
|
|
852
|
+
throw new Error(
|
|
853
|
+
'DECIDE prompt identity lookup is no longer active for this invocation',
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
if (!ROLE_ID_SET.has(roleId)) {
|
|
857
|
+
throw new TypeError(
|
|
858
|
+
`DECIDE prompt identity lookup rejected undeclared role ${String(roleId)}`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
return promptIdentity(roleId);
|
|
862
|
+
};
|
|
863
|
+
try {
|
|
864
|
+
return composePlayerPrompt(input, lookup);
|
|
865
|
+
} finally {
|
|
866
|
+
active = false;
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const continuationKey = (
|
|
871
|
+
roleId: RoleId,
|
|
872
|
+
playerId: string | undefined,
|
|
873
|
+
): string => playerId ?? roleId;
|
|
874
|
+
|
|
875
|
+
const tokensByContinuationKey = (
|
|
876
|
+
tokens: Readonly<Record<string, string>>,
|
|
877
|
+
): Map<string, string> => {
|
|
878
|
+
const byKey = new Map<string, string>();
|
|
879
|
+
for (const [roleId, token] of Object.entries(tokens)) {
|
|
880
|
+
if (!ROLE_ID_SET.has(roleId)) {
|
|
881
|
+
throw new TypeError(
|
|
882
|
+
`DECIDE role tokens contain unknown role ${roleId}`,
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
const typedRole = roleId as RoleId;
|
|
886
|
+
const key = continuationKey(typedRole, resolvedPlayerId(typedRole));
|
|
887
|
+
const prior = byKey.get(key);
|
|
888
|
+
if (prior !== undefined && prior !== token) {
|
|
889
|
+
throw new TypeError(
|
|
890
|
+
`DECIDE runtime snapshot assigns conflicting tokens to roles bound to player ${key}`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
byKey.set(key, token);
|
|
894
|
+
}
|
|
895
|
+
const rolesByKey = new Map<string, RoleId[]>();
|
|
896
|
+
for (const roleId of ROLE_IDS) {
|
|
897
|
+
const key = continuationKey(roleId, resolvedPlayerId(roleId));
|
|
898
|
+
rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
|
|
899
|
+
}
|
|
900
|
+
for (const [key, roles] of rolesByKey) {
|
|
901
|
+
if (roles.length < 2) continue;
|
|
902
|
+
const present = roles.filter((roleId) => tokens[roleId] !== undefined);
|
|
903
|
+
if (present.length !== 0 && present.length !== roles.length) {
|
|
904
|
+
throw new TypeError(
|
|
905
|
+
`DECIDE role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return byKey;
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
const selectPlayerResume = (
|
|
913
|
+
roleId: RoleId,
|
|
914
|
+
playerId: string | undefined,
|
|
915
|
+
): string | false => {
|
|
856
916
|
const session = requireSessionIdentity();
|
|
857
917
|
const selected = session.playerSessions
|
|
858
|
-
? session.playerSessions.select(
|
|
859
|
-
:
|
|
918
|
+
? session.playerSessions.select(roleId)
|
|
919
|
+
: privateResumeTokens.get(continuationKey(roleId, playerId)) ?? false;
|
|
860
920
|
if (
|
|
861
921
|
selected !== false &&
|
|
862
922
|
(typeof selected !== 'string' || selected.trim().length === 0)
|
|
863
923
|
) {
|
|
864
924
|
throw new TypeError(
|
|
865
|
-
`player session store returned an invalid resume token for ${
|
|
925
|
+
`player session store returned an invalid resume token for role ${roleId}`,
|
|
866
926
|
);
|
|
867
927
|
}
|
|
868
928
|
return selected;
|
|
869
929
|
};
|
|
870
930
|
const updatePlayerResume = (
|
|
871
|
-
|
|
872
|
-
|
|
931
|
+
roleId: RoleId,
|
|
932
|
+
playerId: string | undefined,
|
|
933
|
+
result: PlayerResult,
|
|
873
934
|
): void => {
|
|
935
|
+
if (result.resumeToken === undefined && result.status !== 'ok') return;
|
|
874
936
|
const session = requireSessionIdentity();
|
|
875
937
|
if (session.playerSessions) {
|
|
876
|
-
session.playerSessions.update(
|
|
877
|
-
} else if (resumeToken !== undefined
|
|
878
|
-
|
|
938
|
+
session.playerSessions.update(roleId, result.resumeToken);
|
|
939
|
+
} else if (result.resumeToken !== undefined) {
|
|
940
|
+
privateResumeTokens.set(
|
|
941
|
+
continuationKey(roleId, playerId),
|
|
942
|
+
result.resumeToken,
|
|
943
|
+
);
|
|
879
944
|
} else {
|
|
880
|
-
|
|
945
|
+
privateResumeTokens.delete(continuationKey(roleId, playerId));
|
|
881
946
|
}
|
|
882
947
|
};
|
|
883
|
-
const
|
|
948
|
+
const snapshotRoleResumeTokens = (): Record<string, string> => {
|
|
884
949
|
const session = requireSessionIdentity();
|
|
885
950
|
const captured = snapshotJsonValue(
|
|
886
951
|
session.playerSessions
|
|
887
952
|
? session.playerSessions.snapshot()
|
|
888
|
-
: Object.fromEntries(
|
|
953
|
+
: Object.fromEntries(
|
|
954
|
+
ROLE_IDS.flatMap((roleId) => {
|
|
955
|
+
const token = privateResumeTokens.get(
|
|
956
|
+
continuationKey(roleId, resolvedPlayerId(roleId)),
|
|
957
|
+
);
|
|
958
|
+
return token === undefined ? [] : [[roleId, token]];
|
|
959
|
+
}),
|
|
960
|
+
),
|
|
889
961
|
'player session store snapshot',
|
|
890
962
|
);
|
|
891
963
|
if (!isPlainObject(captured)) {
|
|
892
964
|
throw new TypeError('player session store snapshot must be an object');
|
|
893
965
|
}
|
|
894
966
|
const tokens: Record<string, string> = {};
|
|
895
|
-
for (const [
|
|
896
|
-
if (
|
|
967
|
+
for (const [roleId, token] of Object.entries(captured)) {
|
|
968
|
+
if (!ROLE_ID_SET.has(roleId)) {
|
|
897
969
|
throw new TypeError(
|
|
898
|
-
|
|
970
|
+
`player session store snapshot contains unknown role ${roleId}`,
|
|
899
971
|
);
|
|
900
972
|
}
|
|
901
973
|
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
902
974
|
throw new TypeError(
|
|
903
|
-
`player session store snapshot token for ${
|
|
975
|
+
`player session store snapshot token for ${roleId} must be a non-empty string`,
|
|
904
976
|
);
|
|
905
977
|
}
|
|
906
|
-
tokens[
|
|
978
|
+
tokens[roleId] = token;
|
|
907
979
|
}
|
|
980
|
+
tokensByContinuationKey(tokens);
|
|
908
981
|
return tokens;
|
|
909
982
|
};
|
|
910
|
-
const
|
|
983
|
+
const restoreRoleResumeTokens = (
|
|
911
984
|
tokens: Readonly<Record<string, string>>,
|
|
912
985
|
): void => {
|
|
986
|
+
const byKey = tokensByContinuationKey(tokens);
|
|
913
987
|
const session = requireSessionIdentity();
|
|
914
988
|
if (session.playerSessions) {
|
|
915
989
|
session.playerSessions.restore(tokens);
|
|
916
990
|
return;
|
|
917
991
|
}
|
|
918
|
-
|
|
919
|
-
for (const [
|
|
920
|
-
playerResumeTokens.set(playerId, token);
|
|
921
|
-
}
|
|
992
|
+
privateResumeTokens.clear();
|
|
993
|
+
for (const [key, token] of byKey) privateResumeTokens.set(key, token);
|
|
922
994
|
};
|
|
923
|
-
const currentState = (
|
|
995
|
+
const currentState = (
|
|
996
|
+
pendingCall: PlaybookPendingCall | undefined =
|
|
997
|
+
nestedBridge.getPendingCall(),
|
|
998
|
+
): PlaybookState => {
|
|
924
999
|
const live = actor;
|
|
925
1000
|
if (!live) {
|
|
926
1001
|
throw new Error('decide runtime: actor is not initialized');
|
|
927
1002
|
}
|
|
928
1003
|
return normalizePlaybookSnapshot(live.getSnapshot(), {
|
|
929
|
-
pendingCall
|
|
1004
|
+
pendingCall,
|
|
930
1005
|
});
|
|
931
1006
|
};
|
|
932
1007
|
const stateIdentity = (state: PlaybookState): { stateId?: string } => {
|
|
@@ -942,7 +1017,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
942
1017
|
const identity = requireSessionIdentity();
|
|
943
1018
|
const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
944
1019
|
const trace: PlaybookTraceEvent = Object.freeze({
|
|
945
|
-
schemaVersion:
|
|
1020
|
+
schemaVersion: 3,
|
|
946
1021
|
sessionId: identity.sessionId,
|
|
947
1022
|
playbookId: identity.playbookId,
|
|
948
1023
|
rootSessionId: identity.rootSessionId,
|
|
@@ -1098,28 +1173,34 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1098
1173
|
const runPlayerCall = async (
|
|
1099
1174
|
input: PlayerInput,
|
|
1100
1175
|
signal: AbortSignal,
|
|
1101
|
-
): Promise<{
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1176
|
+
): Promise<{
|
|
1177
|
+
roleId: RoleId;
|
|
1178
|
+
playerId?: string;
|
|
1179
|
+
result: PlayerResult;
|
|
1180
|
+
}> => {
|
|
1181
|
+
if (!ROLE_ID_SET.has(input.role)) {
|
|
1182
|
+
throw new TypeError(
|
|
1183
|
+
`DECIDE player input role must name a declared local role`,
|
|
1106
1184
|
);
|
|
1107
1185
|
}
|
|
1108
|
-
const
|
|
1186
|
+
const roleId = input.role;
|
|
1187
|
+
const playerId = resolvedPlayerId(roleId);
|
|
1188
|
+
const playerKey = continuationKey(roleId, playerId);
|
|
1189
|
+
const prompt = composeInvocationPrompt(input);
|
|
1109
1190
|
let resume: PlayerCallOptions['resume'];
|
|
1110
1191
|
try {
|
|
1111
1192
|
signal.throwIfAborted();
|
|
1112
|
-
resume = selectPlayerResume(playerId);
|
|
1193
|
+
resume = selectPlayerResume(roleId, playerId);
|
|
1113
1194
|
} catch (error) {
|
|
1114
1195
|
latchControlPlaneError(error, signal);
|
|
1115
1196
|
throw error;
|
|
1116
1197
|
}
|
|
1117
1198
|
const callId = `player-${++playerCallSequence}`;
|
|
1118
1199
|
const identity = {
|
|
1119
|
-
purpose: 'captain',
|
|
1120
1200
|
stateId: input.stateId,
|
|
1121
1201
|
sourceItem: input.sourceItem,
|
|
1122
|
-
|
|
1202
|
+
roleId,
|
|
1203
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
1123
1204
|
resume,
|
|
1124
1205
|
};
|
|
1125
1206
|
const emitFailure = (error: unknown): Promise<void> =>
|
|
@@ -1135,7 +1216,21 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1135
1216
|
},
|
|
1136
1217
|
{ turnId: currentTurnId, callId },
|
|
1137
1218
|
);
|
|
1138
|
-
|
|
1219
|
+
if (inFlightPlayerKeys.has(playerKey)) {
|
|
1220
|
+
const error = new Error(
|
|
1221
|
+
`resolved player key "${playerKey}" already has an in-flight call`,
|
|
1222
|
+
);
|
|
1223
|
+
await emitCallStarted(
|
|
1224
|
+
'player.call.started',
|
|
1225
|
+
'player.call.finished',
|
|
1226
|
+
{ ...identity, prompt },
|
|
1227
|
+
{ turnId: currentTurnId, callId },
|
|
1228
|
+
signal,
|
|
1229
|
+
);
|
|
1230
|
+
await emitFailure(error);
|
|
1231
|
+
throw error;
|
|
1232
|
+
}
|
|
1233
|
+
inFlightPlayerKeys.add(playerKey);
|
|
1139
1234
|
try {
|
|
1140
1235
|
await emitCallStarted(
|
|
1141
1236
|
'player.call.started',
|
|
@@ -1149,7 +1244,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1149
1244
|
try {
|
|
1150
1245
|
signal.throwIfAborted();
|
|
1151
1246
|
const boundary = Promise.resolve(
|
|
1152
|
-
requirePorts().callPlayer(
|
|
1247
|
+
requirePorts().callPlayer(roleId, prompt, signal, { resume }),
|
|
1153
1248
|
);
|
|
1154
1249
|
rawResult = await boundary;
|
|
1155
1250
|
// An XState sibling cancellation does not cancel an arbitrary coder
|
|
@@ -1185,13 +1280,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1185
1280
|
// The resolved result is authoritative even on aborted/error status.
|
|
1186
1281
|
// Update continuation state before interpreting that status.
|
|
1187
1282
|
try {
|
|
1188
|
-
updatePlayerResume(
|
|
1189
|
-
playerId,
|
|
1190
|
-
typeof result.resumeToken === 'string' &&
|
|
1191
|
-
result.resumeToken.trim().length > 0
|
|
1192
|
-
? result.resumeToken
|
|
1193
|
-
: undefined,
|
|
1194
|
-
);
|
|
1283
|
+
updatePlayerResume(roleId, playerId, result);
|
|
1195
1284
|
} catch (error) {
|
|
1196
1285
|
latchControlPlaneError(error, signal);
|
|
1197
1286
|
try {
|
|
@@ -1222,16 +1311,24 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1222
1311
|
},
|
|
1223
1312
|
{ turnId: currentTurnId, callId },
|
|
1224
1313
|
);
|
|
1225
|
-
return {
|
|
1314
|
+
return {
|
|
1315
|
+
roleId,
|
|
1316
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
1317
|
+
result,
|
|
1318
|
+
};
|
|
1226
1319
|
} finally {
|
|
1227
|
-
|
|
1320
|
+
inFlightPlayerKeys.delete(playerKey);
|
|
1228
1321
|
}
|
|
1229
1322
|
};
|
|
1230
1323
|
|
|
1231
1324
|
const callPlayer = (
|
|
1232
1325
|
input: PlayerInput,
|
|
1233
1326
|
signal: AbortSignal,
|
|
1234
|
-
): Promise<{
|
|
1327
|
+
): Promise<{
|
|
1328
|
+
roleId: RoleId;
|
|
1329
|
+
playerId?: string;
|
|
1330
|
+
result: PlayerResult;
|
|
1331
|
+
}> => {
|
|
1235
1332
|
return trackBoundaryCall(runPlayerCall(input, signal));
|
|
1236
1333
|
};
|
|
1237
1334
|
|
|
@@ -1249,7 +1346,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1249
1346
|
}
|
|
1250
1347
|
combined.throwIfAborted();
|
|
1251
1348
|
|
|
1252
|
-
let { playerId, result } = await callPlayer(input, combined);
|
|
1349
|
+
let { roleId, playerId, result } = await callPlayer(input, combined);
|
|
1253
1350
|
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
1254
1351
|
// DR-028: an `ok` result whose finalText is missing, empty, or
|
|
1255
1352
|
// whitespace-only earns exactly one corrective re-ask — the same
|
|
@@ -1260,11 +1357,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1260
1357
|
// are never retried), and a rejecting finish emission rejects
|
|
1261
1358
|
// `callPlayer` itself, so it never reaches this branch (PBRT-47).
|
|
1262
1359
|
combined.throwIfAborted();
|
|
1263
|
-
({ playerId, result } = await callPlayer(input, combined));
|
|
1360
|
+
({ roleId, playerId, result } = await callPlayer(input, combined));
|
|
1264
1361
|
}
|
|
1265
1362
|
if (result.status !== 'ok') {
|
|
1266
1363
|
throw new Error(
|
|
1267
|
-
|
|
1364
|
+
`${roleLabel(roleId)}${
|
|
1365
|
+
playerId === undefined ? '' : ` (${playerId})`
|
|
1366
|
+
} returned status "${result.status}"${
|
|
1268
1367
|
result.error ? `: ${result.error}` : ''
|
|
1269
1368
|
}`,
|
|
1270
1369
|
);
|
|
@@ -1272,7 +1371,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1272
1371
|
const finalText = result.finalText ?? '';
|
|
1273
1372
|
if (isEmptyFinalText(finalText)) {
|
|
1274
1373
|
throw new Error(
|
|
1275
|
-
|
|
1374
|
+
`${roleLabel(roleId)}${
|
|
1375
|
+
playerId === undefined ? '' : ` (${playerId})`
|
|
1376
|
+
} returned status "ok" with no finalText`,
|
|
1276
1377
|
);
|
|
1277
1378
|
}
|
|
1278
1379
|
combined.throwIfAborted();
|
|
@@ -1423,11 +1524,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1423
1524
|
);
|
|
1424
1525
|
if (pending) {
|
|
1425
1526
|
scheduleStatus(
|
|
1426
|
-
`${pending.
|
|
1527
|
+
`${pending.asker.roleId} asks: ${pending.question}`,
|
|
1427
1528
|
activeStateId,
|
|
1428
1529
|
);
|
|
1429
1530
|
scheduleStatus(
|
|
1430
|
-
`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.
|
|
1531
|
+
`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.asker.roleId} · ${pending.sourceItem}`,
|
|
1431
1532
|
activeStateId,
|
|
1432
1533
|
);
|
|
1433
1534
|
}
|
|
@@ -1439,13 +1540,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1439
1540
|
: undefined;
|
|
1440
1541
|
const description = STATE_DESCRIPTIONS[activeStateId];
|
|
1441
1542
|
if (description === undefined) continue;
|
|
1442
|
-
const
|
|
1543
|
+
const roleState = ROLE_STATES.find(
|
|
1443
1544
|
(candidate) => candidate.stateId === activeStateId,
|
|
1444
1545
|
);
|
|
1445
1546
|
scheduleStatus(
|
|
1446
|
-
|
|
1547
|
+
roleState === undefined
|
|
1447
1548
|
? '◆ workflow failed; awaiting Boss recovery.'
|
|
1448
|
-
: `⤷ ${
|
|
1549
|
+
: `⤷ ${roleLabel(roleState.role)}: ${description}`,
|
|
1449
1550
|
activeStateId,
|
|
1450
1551
|
lastError === undefined ? undefined : { lastError },
|
|
1451
1552
|
);
|
|
@@ -1592,9 +1693,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1592
1693
|
// The caller rethrows its original failure. A restore failure skips
|
|
1593
1694
|
// the disposal trace — the parked session was never re-bound in this
|
|
1594
1695
|
// process, so its persisted snapshot stays authoritative (DR-014 §2).
|
|
1595
|
-
const cleanupFailedStart = async (
|
|
1596
|
-
|
|
1597
|
-
|
|
1696
|
+
const cleanupFailedStart = async (
|
|
1697
|
+
cause: unknown,
|
|
1698
|
+
options: { emitDisposal: boolean },
|
|
1699
|
+
): Promise<void> => {
|
|
1598
1700
|
let finalState: PlaybookState | undefined;
|
|
1599
1701
|
if (options.emitDisposal && actor) {
|
|
1600
1702
|
try {
|
|
@@ -1608,6 +1710,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1608
1710
|
} catch {
|
|
1609
1711
|
// Preserve the original startup failure.
|
|
1610
1712
|
}
|
|
1713
|
+
try {
|
|
1714
|
+
await nestedBridge.abortPending(cause);
|
|
1715
|
+
} catch {
|
|
1716
|
+
// Preserve the original startup failure.
|
|
1717
|
+
}
|
|
1611
1718
|
try {
|
|
1612
1719
|
await judgeQueue.onIdle();
|
|
1613
1720
|
await drainBoundaryCallsAndEmissions();
|
|
@@ -1626,8 +1733,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1626
1733
|
// The session-start error remains authoritative.
|
|
1627
1734
|
}
|
|
1628
1735
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1736
|
+
privateResumeTokens.clear();
|
|
1737
|
+
inFlightPlayerKeys.clear();
|
|
1631
1738
|
activeBoundaryCalls.clear();
|
|
1632
1739
|
activeEmissionCalls.clear();
|
|
1633
1740
|
emissionQueue.clear();
|
|
@@ -1662,7 +1769,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1662
1769
|
'decide runtime: init(session) may only be called once',
|
|
1663
1770
|
);
|
|
1664
1771
|
}
|
|
1665
|
-
const identity =
|
|
1772
|
+
const identity = bindSession(session);
|
|
1666
1773
|
let finishInitialization!: () => void;
|
|
1667
1774
|
const initialization = new Promise<void>((resolve) => {
|
|
1668
1775
|
finishInitialization = resolve;
|
|
@@ -1682,7 +1789,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1682
1789
|
actor?.start();
|
|
1683
1790
|
await flush();
|
|
1684
1791
|
} catch (error) {
|
|
1685
|
-
await cleanupFailedStart({ emitDisposal: true });
|
|
1792
|
+
await cleanupFailedStart(error, { emitDisposal: true });
|
|
1686
1793
|
throw error;
|
|
1687
1794
|
} finally {
|
|
1688
1795
|
finishInitialization();
|
|
@@ -1690,10 +1797,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1690
1797
|
}
|
|
1691
1798
|
},
|
|
1692
1799
|
|
|
1693
|
-
// DR-014 §1 / PBRT-45: JSON-safe capture of a parked
|
|
1800
|
+
// DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
|
|
1801
|
+
// session, including one already-started suspended REVIEW call.
|
|
1694
1802
|
// Defined only at a safe capture point — initialized, not disposing
|
|
1695
|
-
// or disposed, no active public boundary, and the actor quiescent
|
|
1696
|
-
//
|
|
1803
|
+
// or disposed, no active public boundary, and the actor quiescent with
|
|
1804
|
+
// status `active`.
|
|
1697
1805
|
exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
|
|
1698
1806
|
if (
|
|
1699
1807
|
!actor ||
|
|
@@ -1706,7 +1814,37 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1706
1814
|
if (currentTurnId !== undefined || currentSignal !== undefined) {
|
|
1707
1815
|
return undefined;
|
|
1708
1816
|
}
|
|
1709
|
-
|
|
1817
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
1818
|
+
const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
|
|
1819
|
+
if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
|
|
1820
|
+
return undefined;
|
|
1821
|
+
}
|
|
1822
|
+
if (
|
|
1823
|
+
pendingCall !== undefined &&
|
|
1824
|
+
bridgeSuspendedCall !== undefined &&
|
|
1825
|
+
(pendingCall.callId !== bridgeSuspendedCall.callId ||
|
|
1826
|
+
pendingCall.playbookId !== bridgeSuspendedCall.playbookId ||
|
|
1827
|
+
pendingCall.childSessionId !== bridgeSuspendedCall.childSessionId)
|
|
1828
|
+
) {
|
|
1829
|
+
return undefined;
|
|
1830
|
+
}
|
|
1831
|
+
let suspendedCall: typeof bridgeSuspendedCall;
|
|
1832
|
+
if (bridgeSuspendedCall !== undefined) {
|
|
1833
|
+
if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
|
|
1834
|
+
return undefined;
|
|
1835
|
+
}
|
|
1836
|
+
const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
|
|
1837
|
+
if (
|
|
1838
|
+
bridgeSuspendedCall.turnId !== undefined &&
|
|
1839
|
+
bridgeSuspendedCall.turnId !== turnId
|
|
1840
|
+
) {
|
|
1841
|
+
return undefined;
|
|
1842
|
+
}
|
|
1843
|
+
suspendedCall = {
|
|
1844
|
+
...bridgeSuspendedCall,
|
|
1845
|
+
...(turnId === undefined ? {} : { turnId }),
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1710
1848
|
const state = currentState();
|
|
1711
1849
|
if (state.status !== 'active' || !state.quiescent) return undefined;
|
|
1712
1850
|
const machine = detachPersistedMachineSnapshot(
|
|
@@ -1716,10 +1854,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1716
1854
|
actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
|
|
1717
1855
|
).context as unknown as Record<string, unknown>;
|
|
1718
1856
|
return {
|
|
1719
|
-
schemaVersion:
|
|
1857
|
+
schemaVersion: 3,
|
|
1720
1858
|
playbookId: sessionIdentity.playbookId,
|
|
1721
1859
|
machine,
|
|
1722
|
-
|
|
1860
|
+
roleResumeTokens: snapshotRoleResumeTokens(),
|
|
1723
1861
|
sequences: {
|
|
1724
1862
|
trace: traceSequence,
|
|
1725
1863
|
turn: turnSequence,
|
|
@@ -1731,11 +1869,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1731
1869
|
pendingBossQuestions: pendingQuestionsFromContext(context).map(
|
|
1732
1870
|
(pending) => ({
|
|
1733
1871
|
questionId: pending.questionId,
|
|
1734
|
-
|
|
1872
|
+
asker: pending.asker,
|
|
1735
1873
|
question: pending.question,
|
|
1736
1874
|
sourceItem: pending.sourceItem,
|
|
1737
1875
|
}),
|
|
1738
1876
|
),
|
|
1877
|
+
...(suspendedCall === undefined ? {} : { suspendedCall }),
|
|
1739
1878
|
};
|
|
1740
1879
|
},
|
|
1741
1880
|
|
|
@@ -1758,11 +1897,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1758
1897
|
'decide runtime: restore(session, snapshot) may only be called once',
|
|
1759
1898
|
);
|
|
1760
1899
|
}
|
|
1761
|
-
const identity =
|
|
1900
|
+
const identity = bindSession(session);
|
|
1762
1901
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(
|
|
1763
1902
|
snapshot,
|
|
1764
1903
|
identity.playbookId,
|
|
1904
|
+
{ allowSuspendedCall: true },
|
|
1765
1905
|
);
|
|
1906
|
+
const suspendedCall = boundSnapshot.suspendedCall;
|
|
1766
1907
|
let finishInitialization!: () => void;
|
|
1767
1908
|
const initialization = new Promise<void>((resolve) => {
|
|
1768
1909
|
finishInitialization = resolve;
|
|
@@ -1771,7 +1912,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1771
1912
|
lifecycleStarted = true;
|
|
1772
1913
|
ports = identity.ports;
|
|
1773
1914
|
sessionIdentity = identity;
|
|
1774
|
-
let
|
|
1915
|
+
let priorExternalRoleTokens:
|
|
1775
1916
|
| Readonly<Record<string, string>>
|
|
1776
1917
|
| undefined;
|
|
1777
1918
|
try {
|
|
@@ -1781,26 +1922,42 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1781
1922
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1782
1923
|
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
1783
1924
|
if (identity.playerSessions) {
|
|
1784
|
-
|
|
1925
|
+
priorExternalRoleTokens = snapshotRoleResumeTokens();
|
|
1926
|
+
}
|
|
1927
|
+
restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
|
|
1928
|
+
nestedBridge.prepareRestore(suspendedCall);
|
|
1929
|
+
if (suspendedCall !== undefined) {
|
|
1930
|
+
playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
|
|
1785
1931
|
}
|
|
1786
|
-
restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
|
|
1787
1932
|
suppressInspectionEmissions = true;
|
|
1788
1933
|
createRuntimeActor(boundSnapshot.machine);
|
|
1789
1934
|
actor?.start();
|
|
1790
|
-
const restoredState = currentState();
|
|
1935
|
+
const restoredState = currentState(suspendedCall);
|
|
1791
1936
|
if (restoredState.status !== 'active') {
|
|
1792
1937
|
throw new Error(
|
|
1793
1938
|
`decide runtime: restored actor status is ${restoredState.status}, expected active`,
|
|
1794
1939
|
);
|
|
1795
1940
|
}
|
|
1796
|
-
|
|
1941
|
+
if (
|
|
1942
|
+
stableJson(restoredState, 'restored runtime state') !==
|
|
1943
|
+
stableJson(boundSnapshot.state, 'runtime snapshot state')
|
|
1944
|
+
) {
|
|
1945
|
+
throw new Error(
|
|
1946
|
+
'decide runtime: restored actor state does not match snapshot state',
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1797
1949
|
previousState = restoredState;
|
|
1798
1950
|
await flush();
|
|
1951
|
+
suppressInspectionEmissions = false;
|
|
1952
|
+
// Final fallible step: after this publication the authoritative
|
|
1953
|
+
// child has rejoined ordinary resume/abort ownership, so no later
|
|
1954
|
+
// restore validation may trigger failed-start rollback.
|
|
1955
|
+
nestedBridge.confirmRestore();
|
|
1799
1956
|
} catch (error) {
|
|
1800
1957
|
let failure = error;
|
|
1801
|
-
if (
|
|
1958
|
+
if (priorExternalRoleTokens !== undefined) {
|
|
1802
1959
|
try {
|
|
1803
|
-
identity.playerSessions!.restore(
|
|
1960
|
+
identity.playerSessions!.restore(priorExternalRoleTokens);
|
|
1804
1961
|
} catch (rollbackError) {
|
|
1805
1962
|
failure = new AggregateError(
|
|
1806
1963
|
[error, rollbackError],
|
|
@@ -1808,7 +1965,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1808
1965
|
);
|
|
1809
1966
|
}
|
|
1810
1967
|
}
|
|
1811
|
-
await cleanupFailedStart({ emitDisposal: false });
|
|
1968
|
+
await cleanupFailedStart(failure, { emitDisposal: false });
|
|
1812
1969
|
throw failure;
|
|
1813
1970
|
} finally {
|
|
1814
1971
|
finishInitialization();
|
|
@@ -2044,9 +2201,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
2044
2201
|
} catch (error) {
|
|
2045
2202
|
collectFailure(failures, error);
|
|
2046
2203
|
} finally {
|
|
2047
|
-
|
|
2204
|
+
privateResumeTokens.clear();
|
|
2048
2205
|
playbookCallTurnIds.clear();
|
|
2049
|
-
|
|
2206
|
+
inFlightPlayerKeys.clear();
|
|
2050
2207
|
activeBoundaryCalls.clear();
|
|
2051
2208
|
activeEmissionCalls.clear();
|
|
2052
2209
|
emissionQueue.clear();
|
|
@@ -2072,7 +2229,6 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
2072
2229
|
|
|
2073
2230
|
export const _internal = {
|
|
2074
2231
|
composePlayerPrompt,
|
|
2075
|
-
resolvePlayerId,
|
|
2076
2232
|
requiredFieldsFor,
|
|
2077
2233
|
extractJson,
|
|
2078
2234
|
buildClassifierPrompt,
|
|
@@ -2083,10 +2239,9 @@ export const _internal = {
|
|
|
2083
2239
|
pendingQuestionsFromContext,
|
|
2084
2240
|
normalizeErrorCompact,
|
|
2085
2241
|
normalizeErrorFull,
|
|
2086
|
-
DEFAULT_PLAYER_BINDING,
|
|
2087
2242
|
STATE_DESCRIPTIONS,
|
|
2088
|
-
|
|
2089
|
-
|
|
2243
|
+
ROLE_STATES,
|
|
2244
|
+
ROLE_STATE_IDS,
|
|
2090
2245
|
VERBATIM_PAYLOAD_FIELDS,
|
|
2091
2246
|
BOSS_INTERRUPT_TARGETS,
|
|
2092
2247
|
CONTINUATION_PREAMBLE,
|