@sublang/playbook 7.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 +17 -4
- package/docs/cli.md +74 -29
- package/docs/configuration.md +209 -112
- package/docs/embedding.md +71 -25
- package/package.json +4 -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 +1078 -116
- package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
- package/reference/sdlc/code.playbook/bin/run.js +283 -298
- package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
- 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 +39 -14
- package/reference/sdlc/code.playbook/playbook-captain.js +970 -289
- package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
- 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 +171 -134
- package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
- 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 +113 -93
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +20 -16
- package/src/runtime.ts +19 -23
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +241 -149
- package/src/xstate-playbook-runtime.ts +331 -178
- package/src/xstate-runtime.js +63 -24
- package/src/xstate-runtime.ts +96 -28
|
@@ -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[] {
|
|
@@ -413,7 +370,7 @@ function buildClassifierPrompt(
|
|
|
413
370
|
lines.push('Pending Boss questions:');
|
|
414
371
|
for (const pending of ctx.pendingQuestions) {
|
|
415
372
|
lines.push(
|
|
416
|
-
`- ${pending.questionId} (${pending.
|
|
373
|
+
`- ${pending.questionId} (${pending.asker.roleId}): ${pending.question}`,
|
|
417
374
|
);
|
|
418
375
|
}
|
|
419
376
|
lines.push(
|
|
@@ -513,7 +470,7 @@ function buildAdjudicatorPrompt(
|
|
|
513
470
|
'and guard descriptions. Reply with exactly one JSON object and no prose.',
|
|
514
471
|
);
|
|
515
472
|
lines.push(
|
|
516
|
-
`The
|
|
473
|
+
`The role "${roleLabel(input.role)}" produced the output below for source item ${input.sourceItem}.`,
|
|
517
474
|
);
|
|
518
475
|
lines.push('Choose exactly one guard whose description matches that output.');
|
|
519
476
|
lines.push('');
|
|
@@ -649,7 +606,9 @@ function pendingQuestionsFromContext(
|
|
|
649
606
|
obj.questionId === key &&
|
|
650
607
|
typeof obj.resumeStateId === 'string' &&
|
|
651
608
|
typeof obj.sourceItem === 'string' &&
|
|
652
|
-
|
|
609
|
+
isPlainObject(obj.asker) &&
|
|
610
|
+
obj.asker.kind === 'role' &&
|
|
611
|
+
ROLE_ID_SET.has(String(obj.asker.roleId)) &&
|
|
653
612
|
typeof obj.question === 'string'
|
|
654
613
|
) {
|
|
655
614
|
questions.push(obj as unknown as PendingBossQuestion);
|
|
@@ -671,7 +630,7 @@ const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
|
|
|
671
630
|
]);
|
|
672
631
|
|
|
673
632
|
const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
|
|
674
|
-
...
|
|
633
|
+
...ROLE_STATE_IDS,
|
|
675
634
|
...WAIT_STATE_IDS,
|
|
676
635
|
'failed',
|
|
677
636
|
]);
|
|
@@ -749,14 +708,7 @@ function telemetryPayload(
|
|
|
749
708
|
export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
750
709
|
PlaybookRuntimeOptions
|
|
751
710
|
> = (options) => {
|
|
752
|
-
const
|
|
753
|
-
const binding: Record<PlayerName, string> = {
|
|
754
|
-
...DEFAULT_PLAYER_BINDING,
|
|
755
|
-
...(boundOptions.playerBinding ?? {}),
|
|
756
|
-
};
|
|
757
|
-
const fsmInput: DecideInput = {
|
|
758
|
-
coderLlm: boundOptions.coderLlm,
|
|
759
|
-
};
|
|
711
|
+
const fsmInput = snapshotDecideRuntimeOptions(options);
|
|
760
712
|
|
|
761
713
|
type SessionIdentity = Readonly<PlaybookSession>;
|
|
762
714
|
|
|
@@ -779,9 +731,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
779
731
|
let disposalPromise: Promise<void> | undefined;
|
|
780
732
|
let controlPlaneError: unknown;
|
|
781
733
|
let nestedBridge: NestedPlaybookBridge<PlaybookInput>;
|
|
782
|
-
const
|
|
734
|
+
const privateResumeTokens = new Map<string, string>();
|
|
783
735
|
const playbookCallTurnIds = new Map<string, number | undefined>();
|
|
784
|
-
const
|
|
736
|
+
const inFlightPlayerKeys = new Set<string>();
|
|
785
737
|
const activeBoundaryCalls = new Set<Promise<unknown>>();
|
|
786
738
|
const activeEmissionCalls = new Set<Promise<void>>();
|
|
787
739
|
const emissionQueue = new PQueue({ concurrency: 1 });
|
|
@@ -869,73 +821,176 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
869
821
|
}
|
|
870
822
|
return sessionIdentity;
|
|
871
823
|
};
|
|
872
|
-
|
|
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 => {
|
|
873
916
|
const session = requireSessionIdentity();
|
|
874
917
|
const selected = session.playerSessions
|
|
875
|
-
? session.playerSessions.select(
|
|
876
|
-
:
|
|
918
|
+
? session.playerSessions.select(roleId)
|
|
919
|
+
: privateResumeTokens.get(continuationKey(roleId, playerId)) ?? false;
|
|
877
920
|
if (
|
|
878
921
|
selected !== false &&
|
|
879
922
|
(typeof selected !== 'string' || selected.trim().length === 0)
|
|
880
923
|
) {
|
|
881
924
|
throw new TypeError(
|
|
882
|
-
`player session store returned an invalid resume token for ${
|
|
925
|
+
`player session store returned an invalid resume token for role ${roleId}`,
|
|
883
926
|
);
|
|
884
927
|
}
|
|
885
928
|
return selected;
|
|
886
929
|
};
|
|
887
930
|
const updatePlayerResume = (
|
|
888
|
-
|
|
889
|
-
|
|
931
|
+
roleId: RoleId,
|
|
932
|
+
playerId: string | undefined,
|
|
933
|
+
result: PlayerResult,
|
|
890
934
|
): void => {
|
|
935
|
+
if (result.resumeToken === undefined && result.status !== 'ok') return;
|
|
891
936
|
const session = requireSessionIdentity();
|
|
892
937
|
if (session.playerSessions) {
|
|
893
|
-
session.playerSessions.update(
|
|
894
|
-
} else if (resumeToken !== undefined
|
|
895
|
-
|
|
938
|
+
session.playerSessions.update(roleId, result.resumeToken);
|
|
939
|
+
} else if (result.resumeToken !== undefined) {
|
|
940
|
+
privateResumeTokens.set(
|
|
941
|
+
continuationKey(roleId, playerId),
|
|
942
|
+
result.resumeToken,
|
|
943
|
+
);
|
|
896
944
|
} else {
|
|
897
|
-
|
|
945
|
+
privateResumeTokens.delete(continuationKey(roleId, playerId));
|
|
898
946
|
}
|
|
899
947
|
};
|
|
900
|
-
const
|
|
948
|
+
const snapshotRoleResumeTokens = (): Record<string, string> => {
|
|
901
949
|
const session = requireSessionIdentity();
|
|
902
950
|
const captured = snapshotJsonValue(
|
|
903
951
|
session.playerSessions
|
|
904
952
|
? session.playerSessions.snapshot()
|
|
905
|
-
: 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
|
+
),
|
|
906
961
|
'player session store snapshot',
|
|
907
962
|
);
|
|
908
963
|
if (!isPlainObject(captured)) {
|
|
909
964
|
throw new TypeError('player session store snapshot must be an object');
|
|
910
965
|
}
|
|
911
966
|
const tokens: Record<string, string> = {};
|
|
912
|
-
for (const [
|
|
913
|
-
if (
|
|
967
|
+
for (const [roleId, token] of Object.entries(captured)) {
|
|
968
|
+
if (!ROLE_ID_SET.has(roleId)) {
|
|
914
969
|
throw new TypeError(
|
|
915
|
-
|
|
970
|
+
`player session store snapshot contains unknown role ${roleId}`,
|
|
916
971
|
);
|
|
917
972
|
}
|
|
918
973
|
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
919
974
|
throw new TypeError(
|
|
920
|
-
`player session store snapshot token for ${
|
|
975
|
+
`player session store snapshot token for ${roleId} must be a non-empty string`,
|
|
921
976
|
);
|
|
922
977
|
}
|
|
923
|
-
tokens[
|
|
978
|
+
tokens[roleId] = token;
|
|
924
979
|
}
|
|
980
|
+
tokensByContinuationKey(tokens);
|
|
925
981
|
return tokens;
|
|
926
982
|
};
|
|
927
|
-
const
|
|
983
|
+
const restoreRoleResumeTokens = (
|
|
928
984
|
tokens: Readonly<Record<string, string>>,
|
|
929
985
|
): void => {
|
|
986
|
+
const byKey = tokensByContinuationKey(tokens);
|
|
930
987
|
const session = requireSessionIdentity();
|
|
931
988
|
if (session.playerSessions) {
|
|
932
989
|
session.playerSessions.restore(tokens);
|
|
933
990
|
return;
|
|
934
991
|
}
|
|
935
|
-
|
|
936
|
-
for (const [
|
|
937
|
-
playerResumeTokens.set(playerId, token);
|
|
938
|
-
}
|
|
992
|
+
privateResumeTokens.clear();
|
|
993
|
+
for (const [key, token] of byKey) privateResumeTokens.set(key, token);
|
|
939
994
|
};
|
|
940
995
|
const currentState = (
|
|
941
996
|
pendingCall: PlaybookPendingCall | undefined =
|
|
@@ -962,7 +1017,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
962
1017
|
const identity = requireSessionIdentity();
|
|
963
1018
|
const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
964
1019
|
const trace: PlaybookTraceEvent = Object.freeze({
|
|
965
|
-
schemaVersion:
|
|
1020
|
+
schemaVersion: 3,
|
|
966
1021
|
sessionId: identity.sessionId,
|
|
967
1022
|
playbookId: identity.playbookId,
|
|
968
1023
|
rootSessionId: identity.rootSessionId,
|
|
@@ -1118,28 +1173,34 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1118
1173
|
const runPlayerCall = async (
|
|
1119
1174
|
input: PlayerInput,
|
|
1120
1175
|
signal: AbortSignal,
|
|
1121
|
-
): Promise<{
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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`,
|
|
1126
1184
|
);
|
|
1127
1185
|
}
|
|
1128
|
-
const
|
|
1186
|
+
const roleId = input.role;
|
|
1187
|
+
const playerId = resolvedPlayerId(roleId);
|
|
1188
|
+
const playerKey = continuationKey(roleId, playerId);
|
|
1189
|
+
const prompt = composeInvocationPrompt(input);
|
|
1129
1190
|
let resume: PlayerCallOptions['resume'];
|
|
1130
1191
|
try {
|
|
1131
1192
|
signal.throwIfAborted();
|
|
1132
|
-
resume = selectPlayerResume(playerId);
|
|
1193
|
+
resume = selectPlayerResume(roleId, playerId);
|
|
1133
1194
|
} catch (error) {
|
|
1134
1195
|
latchControlPlaneError(error, signal);
|
|
1135
1196
|
throw error;
|
|
1136
1197
|
}
|
|
1137
1198
|
const callId = `player-${++playerCallSequence}`;
|
|
1138
1199
|
const identity = {
|
|
1139
|
-
purpose: 'captain',
|
|
1140
1200
|
stateId: input.stateId,
|
|
1141
1201
|
sourceItem: input.sourceItem,
|
|
1142
|
-
|
|
1202
|
+
roleId,
|
|
1203
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
1143
1204
|
resume,
|
|
1144
1205
|
};
|
|
1145
1206
|
const emitFailure = (error: unknown): Promise<void> =>
|
|
@@ -1155,7 +1216,21 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1155
1216
|
},
|
|
1156
1217
|
{ turnId: currentTurnId, callId },
|
|
1157
1218
|
);
|
|
1158
|
-
|
|
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);
|
|
1159
1234
|
try {
|
|
1160
1235
|
await emitCallStarted(
|
|
1161
1236
|
'player.call.started',
|
|
@@ -1169,7 +1244,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1169
1244
|
try {
|
|
1170
1245
|
signal.throwIfAborted();
|
|
1171
1246
|
const boundary = Promise.resolve(
|
|
1172
|
-
requirePorts().callPlayer(
|
|
1247
|
+
requirePorts().callPlayer(roleId, prompt, signal, { resume }),
|
|
1173
1248
|
);
|
|
1174
1249
|
rawResult = await boundary;
|
|
1175
1250
|
// An XState sibling cancellation does not cancel an arbitrary coder
|
|
@@ -1205,13 +1280,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1205
1280
|
// The resolved result is authoritative even on aborted/error status.
|
|
1206
1281
|
// Update continuation state before interpreting that status.
|
|
1207
1282
|
try {
|
|
1208
|
-
updatePlayerResume(
|
|
1209
|
-
playerId,
|
|
1210
|
-
typeof result.resumeToken === 'string' &&
|
|
1211
|
-
result.resumeToken.trim().length > 0
|
|
1212
|
-
? result.resumeToken
|
|
1213
|
-
: undefined,
|
|
1214
|
-
);
|
|
1283
|
+
updatePlayerResume(roleId, playerId, result);
|
|
1215
1284
|
} catch (error) {
|
|
1216
1285
|
latchControlPlaneError(error, signal);
|
|
1217
1286
|
try {
|
|
@@ -1242,16 +1311,24 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1242
1311
|
},
|
|
1243
1312
|
{ turnId: currentTurnId, callId },
|
|
1244
1313
|
);
|
|
1245
|
-
return {
|
|
1314
|
+
return {
|
|
1315
|
+
roleId,
|
|
1316
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
1317
|
+
result,
|
|
1318
|
+
};
|
|
1246
1319
|
} finally {
|
|
1247
|
-
|
|
1320
|
+
inFlightPlayerKeys.delete(playerKey);
|
|
1248
1321
|
}
|
|
1249
1322
|
};
|
|
1250
1323
|
|
|
1251
1324
|
const callPlayer = (
|
|
1252
1325
|
input: PlayerInput,
|
|
1253
1326
|
signal: AbortSignal,
|
|
1254
|
-
): Promise<{
|
|
1327
|
+
): Promise<{
|
|
1328
|
+
roleId: RoleId;
|
|
1329
|
+
playerId?: string;
|
|
1330
|
+
result: PlayerResult;
|
|
1331
|
+
}> => {
|
|
1255
1332
|
return trackBoundaryCall(runPlayerCall(input, signal));
|
|
1256
1333
|
};
|
|
1257
1334
|
|
|
@@ -1269,7 +1346,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1269
1346
|
}
|
|
1270
1347
|
combined.throwIfAborted();
|
|
1271
1348
|
|
|
1272
|
-
let { playerId, result } = await callPlayer(input, combined);
|
|
1349
|
+
let { roleId, playerId, result } = await callPlayer(input, combined);
|
|
1273
1350
|
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
1274
1351
|
// DR-028: an `ok` result whose finalText is missing, empty, or
|
|
1275
1352
|
// whitespace-only earns exactly one corrective re-ask — the same
|
|
@@ -1280,11 +1357,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1280
1357
|
// are never retried), and a rejecting finish emission rejects
|
|
1281
1358
|
// `callPlayer` itself, so it never reaches this branch (PBRT-47).
|
|
1282
1359
|
combined.throwIfAborted();
|
|
1283
|
-
({ playerId, result } = await callPlayer(input, combined));
|
|
1360
|
+
({ roleId, playerId, result } = await callPlayer(input, combined));
|
|
1284
1361
|
}
|
|
1285
1362
|
if (result.status !== 'ok') {
|
|
1286
1363
|
throw new Error(
|
|
1287
|
-
|
|
1364
|
+
`${roleLabel(roleId)}${
|
|
1365
|
+
playerId === undefined ? '' : ` (${playerId})`
|
|
1366
|
+
} returned status "${result.status}"${
|
|
1288
1367
|
result.error ? `: ${result.error}` : ''
|
|
1289
1368
|
}`,
|
|
1290
1369
|
);
|
|
@@ -1292,7 +1371,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1292
1371
|
const finalText = result.finalText ?? '';
|
|
1293
1372
|
if (isEmptyFinalText(finalText)) {
|
|
1294
1373
|
throw new Error(
|
|
1295
|
-
|
|
1374
|
+
`${roleLabel(roleId)}${
|
|
1375
|
+
playerId === undefined ? '' : ` (${playerId})`
|
|
1376
|
+
} returned status "ok" with no finalText`,
|
|
1296
1377
|
);
|
|
1297
1378
|
}
|
|
1298
1379
|
combined.throwIfAborted();
|
|
@@ -1443,11 +1524,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1443
1524
|
);
|
|
1444
1525
|
if (pending) {
|
|
1445
1526
|
scheduleStatus(
|
|
1446
|
-
`${pending.
|
|
1527
|
+
`${pending.asker.roleId} asks: ${pending.question}`,
|
|
1447
1528
|
activeStateId,
|
|
1448
1529
|
);
|
|
1449
1530
|
scheduleStatus(
|
|
1450
|
-
`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.
|
|
1531
|
+
`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.asker.roleId} · ${pending.sourceItem}`,
|
|
1451
1532
|
activeStateId,
|
|
1452
1533
|
);
|
|
1453
1534
|
}
|
|
@@ -1459,13 +1540,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1459
1540
|
: undefined;
|
|
1460
1541
|
const description = STATE_DESCRIPTIONS[activeStateId];
|
|
1461
1542
|
if (description === undefined) continue;
|
|
1462
|
-
const
|
|
1543
|
+
const roleState = ROLE_STATES.find(
|
|
1463
1544
|
(candidate) => candidate.stateId === activeStateId,
|
|
1464
1545
|
);
|
|
1465
1546
|
scheduleStatus(
|
|
1466
|
-
|
|
1547
|
+
roleState === undefined
|
|
1467
1548
|
? '◆ workflow failed; awaiting Boss recovery.'
|
|
1468
|
-
: `⤷ ${
|
|
1549
|
+
: `⤷ ${roleLabel(roleState.role)}: ${description}`,
|
|
1469
1550
|
activeStateId,
|
|
1470
1551
|
lastError === undefined ? undefined : { lastError },
|
|
1471
1552
|
);
|
|
@@ -1652,8 +1733,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1652
1733
|
// The session-start error remains authoritative.
|
|
1653
1734
|
}
|
|
1654
1735
|
}
|
|
1655
|
-
|
|
1656
|
-
|
|
1736
|
+
privateResumeTokens.clear();
|
|
1737
|
+
inFlightPlayerKeys.clear();
|
|
1657
1738
|
activeBoundaryCalls.clear();
|
|
1658
1739
|
activeEmissionCalls.clear();
|
|
1659
1740
|
emissionQueue.clear();
|
|
@@ -1688,7 +1769,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1688
1769
|
'decide runtime: init(session) may only be called once',
|
|
1689
1770
|
);
|
|
1690
1771
|
}
|
|
1691
|
-
const identity =
|
|
1772
|
+
const identity = bindSession(session);
|
|
1692
1773
|
let finishInitialization!: () => void;
|
|
1693
1774
|
const initialization = new Promise<void>((resolve) => {
|
|
1694
1775
|
finishInitialization = resolve;
|
|
@@ -1773,10 +1854,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1773
1854
|
actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
|
|
1774
1855
|
).context as unknown as Record<string, unknown>;
|
|
1775
1856
|
return {
|
|
1776
|
-
schemaVersion:
|
|
1857
|
+
schemaVersion: 3,
|
|
1777
1858
|
playbookId: sessionIdentity.playbookId,
|
|
1778
1859
|
machine,
|
|
1779
|
-
|
|
1860
|
+
roleResumeTokens: snapshotRoleResumeTokens(),
|
|
1780
1861
|
sequences: {
|
|
1781
1862
|
trace: traceSequence,
|
|
1782
1863
|
turn: turnSequence,
|
|
@@ -1788,7 +1869,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1788
1869
|
pendingBossQuestions: pendingQuestionsFromContext(context).map(
|
|
1789
1870
|
(pending) => ({
|
|
1790
1871
|
questionId: pending.questionId,
|
|
1791
|
-
|
|
1872
|
+
asker: pending.asker,
|
|
1792
1873
|
question: pending.question,
|
|
1793
1874
|
sourceItem: pending.sourceItem,
|
|
1794
1875
|
}),
|
|
@@ -1816,16 +1897,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1816
1897
|
'decide runtime: restore(session, snapshot) may only be called once',
|
|
1817
1898
|
);
|
|
1818
1899
|
}
|
|
1819
|
-
const identity =
|
|
1900
|
+
const identity = bindSession(session);
|
|
1820
1901
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(
|
|
1821
1902
|
snapshot,
|
|
1822
1903
|
identity.playbookId,
|
|
1823
1904
|
{ allowSuspendedCall: true },
|
|
1824
1905
|
);
|
|
1825
|
-
const suspendedCall =
|
|
1826
|
-
boundSnapshot.schemaVersion === 2
|
|
1827
|
-
? boundSnapshot.suspendedCall
|
|
1828
|
-
: undefined;
|
|
1906
|
+
const suspendedCall = boundSnapshot.suspendedCall;
|
|
1829
1907
|
let finishInitialization!: () => void;
|
|
1830
1908
|
const initialization = new Promise<void>((resolve) => {
|
|
1831
1909
|
finishInitialization = resolve;
|
|
@@ -1834,7 +1912,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1834
1912
|
lifecycleStarted = true;
|
|
1835
1913
|
ports = identity.ports;
|
|
1836
1914
|
sessionIdentity = identity;
|
|
1837
|
-
let
|
|
1915
|
+
let priorExternalRoleTokens:
|
|
1838
1916
|
| Readonly<Record<string, string>>
|
|
1839
1917
|
| undefined;
|
|
1840
1918
|
try {
|
|
@@ -1844,9 +1922,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1844
1922
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1845
1923
|
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
1846
1924
|
if (identity.playerSessions) {
|
|
1847
|
-
|
|
1925
|
+
priorExternalRoleTokens = snapshotRoleResumeTokens();
|
|
1848
1926
|
}
|
|
1849
|
-
|
|
1927
|
+
restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
|
|
1850
1928
|
nestedBridge.prepareRestore(suspendedCall);
|
|
1851
1929
|
if (suspendedCall !== undefined) {
|
|
1852
1930
|
playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
|
|
@@ -1877,9 +1955,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1877
1955
|
nestedBridge.confirmRestore();
|
|
1878
1956
|
} catch (error) {
|
|
1879
1957
|
let failure = error;
|
|
1880
|
-
if (
|
|
1958
|
+
if (priorExternalRoleTokens !== undefined) {
|
|
1881
1959
|
try {
|
|
1882
|
-
identity.playerSessions!.restore(
|
|
1960
|
+
identity.playerSessions!.restore(priorExternalRoleTokens);
|
|
1883
1961
|
} catch (rollbackError) {
|
|
1884
1962
|
failure = new AggregateError(
|
|
1885
1963
|
[error, rollbackError],
|
|
@@ -2123,9 +2201,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
2123
2201
|
} catch (error) {
|
|
2124
2202
|
collectFailure(failures, error);
|
|
2125
2203
|
} finally {
|
|
2126
|
-
|
|
2204
|
+
privateResumeTokens.clear();
|
|
2127
2205
|
playbookCallTurnIds.clear();
|
|
2128
|
-
|
|
2206
|
+
inFlightPlayerKeys.clear();
|
|
2129
2207
|
activeBoundaryCalls.clear();
|
|
2130
2208
|
activeEmissionCalls.clear();
|
|
2131
2209
|
emissionQueue.clear();
|
|
@@ -2151,7 +2229,6 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
2151
2229
|
|
|
2152
2230
|
export const _internal = {
|
|
2153
2231
|
composePlayerPrompt,
|
|
2154
|
-
resolvePlayerId,
|
|
2155
2232
|
requiredFieldsFor,
|
|
2156
2233
|
extractJson,
|
|
2157
2234
|
buildClassifierPrompt,
|
|
@@ -2162,10 +2239,9 @@ export const _internal = {
|
|
|
2162
2239
|
pendingQuestionsFromContext,
|
|
2163
2240
|
normalizeErrorCompact,
|
|
2164
2241
|
normalizeErrorFull,
|
|
2165
|
-
DEFAULT_PLAYER_BINDING,
|
|
2166
2242
|
STATE_DESCRIPTIONS,
|
|
2167
|
-
|
|
2168
|
-
|
|
2243
|
+
ROLE_STATES,
|
|
2244
|
+
ROLE_STATE_IDS,
|
|
2169
2245
|
VERBATIM_PAYLOAD_FIELDS,
|
|
2170
2246
|
BOSS_INTERRUPT_TARGETS,
|
|
2171
2247
|
CONTINUATION_PREAMBLE,
|