@sublang/playbook 7.0.0 → 9.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 +20 -7
- package/docs/cli.md +88 -43
- package/docs/configuration.md +221 -119
- package/docs/embedding.md +78 -27
- package/package.json +4 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
- package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
- 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 +9 -6
- 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 +18 -15
- package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
- 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 +25 -15
- package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
- 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 +1014 -299
- package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
- 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 +10 -10
- 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 +11 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
- package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
- 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 +29 -23
- package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
- 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 +45 -24
- package/slc/link.md +297 -135
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +21 -16
- package/src/runtime.ts +20 -23
- package/src/xstate-playbook-runtime.d.ts +34 -20
- package/src/xstate-playbook-runtime.js +973 -400
- package/src/xstate-playbook-runtime.ts +1203 -457
- package/src/xstate-runtime.d.ts +17 -7
- package/src/xstate-runtime.js +198 -81
- package/src/xstate-runtime.ts +339 -112
|
@@ -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,89 +98,88 @@ 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
|
-
);
|
|
110
|
+
const [unknown] = Object.keys(captured);
|
|
111
|
+
if (unknown !== undefined) {
|
|
112
|
+
throw new TypeError(`DECIDE runtime options.${unknown} is not declared`);
|
|
130
113
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
114
|
+
return Object.freeze({});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface AuthoredStateConfig {
|
|
118
|
+
readonly meta?: {
|
|
119
|
+
readonly playbook?: {
|
|
120
|
+
readonly stateId?: unknown;
|
|
121
|
+
readonly description?: unknown;
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
readonly states?: Readonly<Record<string, AuthoredStateConfig>>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function authoredStateDescriptions(
|
|
128
|
+
states: Readonly<Record<string, AuthoredStateConfig>> | undefined,
|
|
129
|
+
): Readonly<Record<string, string>> {
|
|
130
|
+
const descriptions: Record<string, string> = {};
|
|
131
|
+
const visit = (
|
|
132
|
+
children: Readonly<Record<string, AuthoredStateConfig>> | undefined,
|
|
133
|
+
): void => {
|
|
134
|
+
for (const state of Object.values(children ?? {})) {
|
|
135
|
+
const stateId = state.meta?.playbook?.stateId;
|
|
136
|
+
const description = state.meta?.playbook?.description;
|
|
137
|
+
if (
|
|
138
|
+
typeof stateId === 'string' &&
|
|
139
|
+
typeof description === 'string' &&
|
|
140
|
+
description.trim().length > 0
|
|
141
|
+
) {
|
|
142
|
+
const existing = descriptions[stateId];
|
|
143
|
+
if (existing !== undefined && existing !== description) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`DECIDE state ${stateId} declares conflicting descriptions`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
descriptions[stateId] = description;
|
|
149
149
|
}
|
|
150
|
+
visit(state.states);
|
|
150
151
|
}
|
|
151
|
-
}
|
|
152
|
-
|
|
152
|
+
};
|
|
153
|
+
visit(states);
|
|
154
|
+
return Object.freeze(descriptions);
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
const STATE_DESCRIPTIONS
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
|
|
161
|
-
commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
|
|
162
|
-
awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
|
|
163
|
-
reviewCommit: 'REVIEW examines the committed proposal.',
|
|
164
|
-
failed: 'DECIDE failed and is waiting for a new topic.',
|
|
165
|
-
reportedReviewFailure:
|
|
166
|
-
'DECIDE reports REVIEW’s failure and its last commit.',
|
|
167
|
-
done: 'DECIDE completed with an approved commit.',
|
|
168
|
-
};
|
|
157
|
+
const STATE_DESCRIPTIONS = authoredStateDescriptions(
|
|
158
|
+
decideMachine.config.states as
|
|
159
|
+
| Readonly<Record<string, AuthoredStateConfig>>
|
|
160
|
+
| undefined,
|
|
161
|
+
);
|
|
169
162
|
|
|
170
|
-
const
|
|
171
|
-
{ stateId: 'askCoderProposal',
|
|
163
|
+
const ROLE_STATES = [
|
|
164
|
+
{ stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
|
|
172
165
|
{
|
|
173
166
|
stateId: 'askReviewerProposal',
|
|
174
|
-
|
|
167
|
+
role: 'reviewer',
|
|
175
168
|
sourceItem: 'DECIDE-2',
|
|
176
169
|
},
|
|
177
|
-
{ stateId: 'commitCoderProposal',
|
|
170
|
+
{ stateId: 'commitCoderProposal', role: 'coder', sourceItem: 'DECIDE-3' },
|
|
178
171
|
] as const;
|
|
179
172
|
|
|
180
|
-
const
|
|
181
|
-
|
|
173
|
+
const ROLE_STATE_IDS: ReadonlySet<string> = new Set(
|
|
174
|
+
ROLE_STATES.map((state) => state.stateId),
|
|
182
175
|
);
|
|
183
176
|
|
|
177
|
+
const ROLE_IDS = ['coder', 'reviewer'] as const;
|
|
178
|
+
const ROLE_ID_SET: ReadonlySet<string> = new Set(ROLE_IDS);
|
|
179
|
+
|
|
180
|
+
const roleLabel = (roleId: RoleId): string =>
|
|
181
|
+
roleId === 'coder' ? 'Coder' : 'Reviewer';
|
|
182
|
+
|
|
184
183
|
const BOSS_INTERRUPT_TARGETS = ['independentProposals'] as const;
|
|
185
184
|
|
|
186
185
|
const BOSS_INTERRUPT_TARGET_IDS: ReadonlySet<string> = new Set(
|
|
@@ -194,10 +193,7 @@ const CONTINUATION_PREAMBLE =
|
|
|
194
193
|
'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
|
|
195
194
|
|
|
196
195
|
const PLACEHOLDER_FIELDS: ReadonlyArray<readonly [string, keyof PlayerInput]> =
|
|
197
|
-
[
|
|
198
|
-
['<caller-topic>', 'callerTopic'],
|
|
199
|
-
['<coder-llm>', 'coderLlm'],
|
|
200
|
-
];
|
|
196
|
+
[['<caller-topic>', 'callerTopic']];
|
|
201
197
|
|
|
202
198
|
const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
|
|
203
199
|
'coderProposal',
|
|
@@ -205,7 +201,12 @@ const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
|
|
|
205
201
|
'coderOutput',
|
|
206
202
|
]);
|
|
207
203
|
|
|
208
|
-
|
|
204
|
+
type PromptIdentity = (roleId: RoleId) => string;
|
|
205
|
+
|
|
206
|
+
function composePlayerPrompt(
|
|
207
|
+
input: PlayerInput,
|
|
208
|
+
promptIdentity: PromptIdentity,
|
|
209
|
+
): string {
|
|
209
210
|
const blocks: string[] = [];
|
|
210
211
|
|
|
211
212
|
if (input.pendingBossQuestion && input.bossReply !== undefined) {
|
|
@@ -227,6 +228,9 @@ function composePlayerPrompt(input: PlayerInput): string {
|
|
|
227
228
|
const value = input[field];
|
|
228
229
|
if (typeof value === 'string') replacements.set(placeholder, value);
|
|
229
230
|
}
|
|
231
|
+
if (input.prompt.includes('<coder-llm>')) {
|
|
232
|
+
replacements.set('<coder-llm>', promptIdentity('coder'));
|
|
233
|
+
}
|
|
230
234
|
const body = input.prompt.replace(
|
|
231
235
|
/<caller-topic>|<coder-llm>/g,
|
|
232
236
|
(placeholder, offset: number, source: string) => {
|
|
@@ -243,22 +247,6 @@ function composePlayerPrompt(input: PlayerInput): string {
|
|
|
243
247
|
return blocks.join('\n\n');
|
|
244
248
|
}
|
|
245
249
|
|
|
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
250
|
// A `result` description names required payload fields in its
|
|
263
251
|
// "Output shall include ..." sentence.
|
|
264
252
|
function requiredFieldsFor(description: string): string[] {
|
|
@@ -413,7 +401,7 @@ function buildClassifierPrompt(
|
|
|
413
401
|
lines.push('Pending Boss questions:');
|
|
414
402
|
for (const pending of ctx.pendingQuestions) {
|
|
415
403
|
lines.push(
|
|
416
|
-
`- ${pending.questionId} (${pending.
|
|
404
|
+
`- ${pending.questionId} (${pending.asker.roleId}): ${pending.question}`,
|
|
417
405
|
);
|
|
418
406
|
}
|
|
419
407
|
lines.push(
|
|
@@ -513,7 +501,7 @@ function buildAdjudicatorPrompt(
|
|
|
513
501
|
'and guard descriptions. Reply with exactly one JSON object and no prose.',
|
|
514
502
|
);
|
|
515
503
|
lines.push(
|
|
516
|
-
`The
|
|
504
|
+
`The role "${roleLabel(input.role)}" produced the output below for source item ${input.sourceItem}.`,
|
|
517
505
|
);
|
|
518
506
|
lines.push('Choose exactly one guard whose description matches that output.');
|
|
519
507
|
lines.push('');
|
|
@@ -627,6 +615,27 @@ function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
|
|
|
627
615
|
return signal.aborted && Object.is(error, signal.reason);
|
|
628
616
|
}
|
|
629
617
|
|
|
618
|
+
interface AbortReasonClassifier {
|
|
619
|
+
isAbortReason(error: unknown): boolean;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function abortReasonClassifier(
|
|
623
|
+
...sources: readonly (AbortSignal | AbortReasonClassifier | undefined)[]
|
|
624
|
+
): AbortReasonClassifier {
|
|
625
|
+
const captured = sources.filter(
|
|
626
|
+
(source): source is AbortSignal | AbortReasonClassifier =>
|
|
627
|
+
source !== undefined,
|
|
628
|
+
);
|
|
629
|
+
return Object.freeze({
|
|
630
|
+
isAbortReason: (error: unknown): boolean =>
|
|
631
|
+
captured.some((source) =>
|
|
632
|
+
source instanceof AbortSignal
|
|
633
|
+
? isAbortFailure(error, source)
|
|
634
|
+
: source.isAbortReason(error),
|
|
635
|
+
),
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
630
639
|
function pendingQuestionsFromContext(
|
|
631
640
|
context: Record<string, unknown>,
|
|
632
641
|
): PendingBossQuestion[] {
|
|
@@ -649,7 +658,9 @@ function pendingQuestionsFromContext(
|
|
|
649
658
|
obj.questionId === key &&
|
|
650
659
|
typeof obj.resumeStateId === 'string' &&
|
|
651
660
|
typeof obj.sourceItem === 'string' &&
|
|
652
|
-
|
|
661
|
+
isPlainObject(obj.asker) &&
|
|
662
|
+
obj.asker.kind === 'role' &&
|
|
663
|
+
ROLE_ID_SET.has(String(obj.asker.roleId)) &&
|
|
653
664
|
typeof obj.question === 'string'
|
|
654
665
|
) {
|
|
655
666
|
questions.push(obj as unknown as PendingBossQuestion);
|
|
@@ -671,11 +682,38 @@ const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
|
|
|
671
682
|
]);
|
|
672
683
|
|
|
673
684
|
const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
|
|
674
|
-
...
|
|
685
|
+
...ROLE_STATE_IDS,
|
|
675
686
|
...WAIT_STATE_IDS,
|
|
676
687
|
'failed',
|
|
677
688
|
]);
|
|
678
689
|
|
|
690
|
+
// PBRT-45: a question is pending only while its authored reply-wait state
|
|
691
|
+
// is active. The context retains an answered question through the resumed
|
|
692
|
+
// player call so the Q+A continuation prompt can quote it, and each branch
|
|
693
|
+
// keeps its own entry through the parallel region — so an unfiltered
|
|
694
|
+
// projection would report the answered question as still awaiting during
|
|
695
|
+
// the resume, and both branch questions after only one remains pending.
|
|
696
|
+
const RESUME_WAIT_STATE_IDS: Readonly<Record<string, string>> = {
|
|
697
|
+
...Object.fromEntries(
|
|
698
|
+
Object.entries(WAIT_STATE_RESUME_IDS).map(([waitStateId, resumeStateId]) => [
|
|
699
|
+
resumeStateId,
|
|
700
|
+
waitStateId,
|
|
701
|
+
]),
|
|
702
|
+
),
|
|
703
|
+
commitCoderProposal: 'awaitBossReply',
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
function pendingQuestionsForState(
|
|
707
|
+
state: PlaybookState,
|
|
708
|
+
context: Record<string, unknown>,
|
|
709
|
+
): PendingBossQuestion[] {
|
|
710
|
+
return pendingQuestionsFromContext(context).filter((pending) =>
|
|
711
|
+
state.activeStateIds.includes(
|
|
712
|
+
RESUME_WAIT_STATE_IDS[pending.resumeStateId] ?? '',
|
|
713
|
+
),
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
679
717
|
function questionForWaitState(
|
|
680
718
|
stateId: string,
|
|
681
719
|
pendingQuestions: readonly PendingBossQuestion[],
|
|
@@ -729,7 +767,7 @@ function telemetryPayload(
|
|
|
729
767
|
event: unknown,
|
|
730
768
|
context: Record<string, unknown>,
|
|
731
769
|
): JsonValue {
|
|
732
|
-
const pendingBossQuestions =
|
|
770
|
+
const pendingBossQuestions = pendingQuestionsForState(state, context);
|
|
733
771
|
const prior = previousState ?? state;
|
|
734
772
|
const payload = {
|
|
735
773
|
from: prior.value,
|
|
@@ -749,14 +787,7 @@ function telemetryPayload(
|
|
|
749
787
|
export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
750
788
|
PlaybookRuntimeOptions
|
|
751
789
|
> = (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
|
-
};
|
|
790
|
+
const fsmInput = snapshotDecideRuntimeOptions(options);
|
|
760
791
|
|
|
761
792
|
type SessionIdentity = Readonly<PlaybookSession>;
|
|
762
793
|
|
|
@@ -764,6 +795,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
764
795
|
let sessionIdentity: SessionIdentity | undefined;
|
|
765
796
|
let actor: ReturnType<typeof createActor> | undefined;
|
|
766
797
|
let currentSignal: AbortSignal | undefined;
|
|
798
|
+
let currentAborts: AbortReasonClassifier | undefined;
|
|
799
|
+
const actorSettlementAborts: AbortReasonClassifier[] = [];
|
|
800
|
+
let actorSettlementErrorAborts: AbortReasonClassifier | undefined;
|
|
767
801
|
let currentTurnId: number | undefined;
|
|
768
802
|
let previousState: PlaybookState | undefined;
|
|
769
803
|
let suppressInspectionEmissions = false;
|
|
@@ -779,9 +813,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
779
813
|
let disposalPromise: Promise<void> | undefined;
|
|
780
814
|
let controlPlaneError: unknown;
|
|
781
815
|
let nestedBridge: NestedPlaybookBridge<PlaybookInput>;
|
|
782
|
-
const
|
|
816
|
+
const privateResumeTokens = new Map<string, string>();
|
|
783
817
|
const playbookCallTurnIds = new Map<string, number | undefined>();
|
|
784
|
-
const
|
|
818
|
+
const inFlightPlayerKeys = new Set<string>();
|
|
785
819
|
const activeBoundaryCalls = new Set<Promise<unknown>>();
|
|
786
820
|
const activeEmissionCalls = new Set<Promise<void>>();
|
|
787
821
|
const emissionQueue = new PQueue({ concurrency: 1 });
|
|
@@ -802,26 +836,35 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
802
836
|
): void => {
|
|
803
837
|
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
804
838
|
};
|
|
805
|
-
const latchInspectionError = (
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
839
|
+
const latchInspectionError = (
|
|
840
|
+
error: unknown,
|
|
841
|
+
aborts: AbortReasonClassifier | undefined = currentAborts,
|
|
842
|
+
): void => {
|
|
843
|
+
if (aborts?.isAbortReason(error)) return;
|
|
844
|
+
if (currentSignal !== undefined) controlPlaneError ??= error;
|
|
845
|
+
else collectFailure(emissionFailures, error);
|
|
811
846
|
};
|
|
812
|
-
const enqueue = (
|
|
847
|
+
const enqueue = (
|
|
848
|
+
fn: () => Promise<void>,
|
|
849
|
+
aborts: AbortReasonClassifier | undefined = currentAborts,
|
|
850
|
+
): Promise<void> => {
|
|
851
|
+
const enqueueAborts = aborts;
|
|
813
852
|
const queued = emissionQueue.add(fn);
|
|
814
853
|
activeEmissionCalls.add(queued);
|
|
815
854
|
void queued.then(
|
|
816
855
|
() => activeEmissionCalls.delete(queued),
|
|
817
856
|
(error: unknown) => {
|
|
818
857
|
activeEmissionCalls.delete(queued);
|
|
819
|
-
|
|
858
|
+
if (!enqueueAborts?.isAbortReason(error)) {
|
|
859
|
+
collectFailure(emissionFailures, error);
|
|
860
|
+
}
|
|
820
861
|
},
|
|
821
862
|
);
|
|
822
863
|
return queued;
|
|
823
864
|
};
|
|
824
|
-
const flush = async (
|
|
865
|
+
const flush = async (
|
|
866
|
+
_aborts: AbortReasonClassifier | undefined = currentAborts,
|
|
867
|
+
): Promise<void> => {
|
|
825
868
|
while (true) {
|
|
826
869
|
const active = [...activeEmissionCalls];
|
|
827
870
|
if (active.length > 0) await Promise.allSettled(active);
|
|
@@ -837,8 +880,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
837
880
|
if (emissionFailures.length === 0) return;
|
|
838
881
|
const failures = emissionFailures;
|
|
839
882
|
emissionFailures = [];
|
|
840
|
-
|
|
841
|
-
|
|
883
|
+
const failure =
|
|
884
|
+
failures.length === 1
|
|
885
|
+
? failures[0]
|
|
886
|
+
: new AggregateError(failures, 'decide runtime emissions failed');
|
|
887
|
+
// Enqueue ownership already classified every stored failure as distinct.
|
|
888
|
+
// Preserve that classification if an unrelated public boundary drains
|
|
889
|
+
// it with a signal whose reason happens to be the same object.
|
|
890
|
+
if (currentSignal !== undefined) controlPlaneError ??= failure;
|
|
891
|
+
throw failure;
|
|
842
892
|
};
|
|
843
893
|
const drainBoundaryCallsAndEmissions = async (): Promise<void> => {
|
|
844
894
|
while (true) {
|
|
@@ -869,73 +919,176 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
869
919
|
}
|
|
870
920
|
return sessionIdentity;
|
|
871
921
|
};
|
|
872
|
-
|
|
922
|
+
|
|
923
|
+
const bindSession = (nextSession: PlaybookSession): SessionIdentity => {
|
|
924
|
+
const bound = snapshotPlaybookSession(nextSession);
|
|
925
|
+
if (bound.roleBindings === undefined) return bound;
|
|
926
|
+
const actual = Object.keys(bound.roleBindings).sort();
|
|
927
|
+
const expected = [...ROLE_IDS].sort();
|
|
928
|
+
const missing = expected.filter((roleId) => !actual.includes(roleId));
|
|
929
|
+
const extra = actual.filter((roleId) => !ROLE_ID_SET.has(roleId));
|
|
930
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
931
|
+
throw new TypeError(
|
|
932
|
+
`DECIDE session roleBindings must cover exactly [${expected.join(', ')}]` +
|
|
933
|
+
`${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
|
|
934
|
+
`${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`,
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
return bound;
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
const resolvedPlayerId = (roleId: RoleId): string | undefined =>
|
|
941
|
+
requireSessionIdentity().roleBindings?.[roleId]?.playerId;
|
|
942
|
+
|
|
943
|
+
const promptIdentity = (roleId: RoleId): string =>
|
|
944
|
+
requireSessionIdentity().roleBindings?.[roleId]?.promptIdentity ?? roleId;
|
|
945
|
+
|
|
946
|
+
const composeInvocationPrompt = (input: PlayerInput): string => {
|
|
947
|
+
let active = true;
|
|
948
|
+
const lookup: PromptIdentity = (roleId) => {
|
|
949
|
+
if (!active) {
|
|
950
|
+
throw new Error(
|
|
951
|
+
'DECIDE prompt identity lookup is no longer active for this invocation',
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
if (!ROLE_ID_SET.has(roleId)) {
|
|
955
|
+
throw new TypeError(
|
|
956
|
+
`DECIDE prompt identity lookup rejected undeclared role ${String(roleId)}`,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
return promptIdentity(roleId);
|
|
960
|
+
};
|
|
961
|
+
try {
|
|
962
|
+
return composePlayerPrompt(input, lookup);
|
|
963
|
+
} finally {
|
|
964
|
+
active = false;
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const continuationKey = (
|
|
969
|
+
roleId: RoleId,
|
|
970
|
+
playerId: string | undefined,
|
|
971
|
+
): string => playerId ?? roleId;
|
|
972
|
+
|
|
973
|
+
const tokensByContinuationKey = (
|
|
974
|
+
tokens: Readonly<Record<string, string>>,
|
|
975
|
+
): Map<string, string> => {
|
|
976
|
+
const byKey = new Map<string, string>();
|
|
977
|
+
for (const [roleId, token] of Object.entries(tokens)) {
|
|
978
|
+
if (!ROLE_ID_SET.has(roleId)) {
|
|
979
|
+
throw new TypeError(
|
|
980
|
+
`DECIDE role tokens contain unknown role ${roleId}`,
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
const typedRole = roleId as RoleId;
|
|
984
|
+
const key = continuationKey(typedRole, resolvedPlayerId(typedRole));
|
|
985
|
+
const prior = byKey.get(key);
|
|
986
|
+
if (prior !== undefined && prior !== token) {
|
|
987
|
+
throw new TypeError(
|
|
988
|
+
`DECIDE runtime snapshot assigns conflicting tokens to roles bound to player ${key}`,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
byKey.set(key, token);
|
|
992
|
+
}
|
|
993
|
+
const rolesByKey = new Map<string, RoleId[]>();
|
|
994
|
+
for (const roleId of ROLE_IDS) {
|
|
995
|
+
const key = continuationKey(roleId, resolvedPlayerId(roleId));
|
|
996
|
+
rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
|
|
997
|
+
}
|
|
998
|
+
for (const [key, roles] of rolesByKey) {
|
|
999
|
+
if (roles.length < 2) continue;
|
|
1000
|
+
const present = roles.filter((roleId) => tokens[roleId] !== undefined);
|
|
1001
|
+
if (present.length !== 0 && present.length !== roles.length) {
|
|
1002
|
+
throw new TypeError(
|
|
1003
|
+
`DECIDE role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`,
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return byKey;
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
const selectPlayerResume = (
|
|
1011
|
+
roleId: RoleId,
|
|
1012
|
+
playerId: string | undefined,
|
|
1013
|
+
): string | false => {
|
|
873
1014
|
const session = requireSessionIdentity();
|
|
874
1015
|
const selected = session.playerSessions
|
|
875
|
-
? session.playerSessions.select(
|
|
876
|
-
:
|
|
1016
|
+
? session.playerSessions.select(roleId)
|
|
1017
|
+
: privateResumeTokens.get(continuationKey(roleId, playerId)) ?? false;
|
|
877
1018
|
if (
|
|
878
1019
|
selected !== false &&
|
|
879
1020
|
(typeof selected !== 'string' || selected.trim().length === 0)
|
|
880
1021
|
) {
|
|
881
1022
|
throw new TypeError(
|
|
882
|
-
`player session store returned an invalid resume token for ${
|
|
1023
|
+
`player session store returned an invalid resume token for role ${roleId}`,
|
|
883
1024
|
);
|
|
884
1025
|
}
|
|
885
1026
|
return selected;
|
|
886
1027
|
};
|
|
887
1028
|
const updatePlayerResume = (
|
|
888
|
-
|
|
889
|
-
|
|
1029
|
+
roleId: RoleId,
|
|
1030
|
+
playerId: string | undefined,
|
|
1031
|
+
result: PlayerResult,
|
|
890
1032
|
): void => {
|
|
1033
|
+
if (result.resumeToken === undefined && result.status !== 'ok') return;
|
|
891
1034
|
const session = requireSessionIdentity();
|
|
892
1035
|
if (session.playerSessions) {
|
|
893
|
-
session.playerSessions.update(
|
|
894
|
-
} else if (resumeToken !== undefined
|
|
895
|
-
|
|
1036
|
+
session.playerSessions.update(roleId, result.resumeToken);
|
|
1037
|
+
} else if (result.resumeToken !== undefined) {
|
|
1038
|
+
privateResumeTokens.set(
|
|
1039
|
+
continuationKey(roleId, playerId),
|
|
1040
|
+
result.resumeToken,
|
|
1041
|
+
);
|
|
896
1042
|
} else {
|
|
897
|
-
|
|
1043
|
+
privateResumeTokens.delete(continuationKey(roleId, playerId));
|
|
898
1044
|
}
|
|
899
1045
|
};
|
|
900
|
-
const
|
|
1046
|
+
const snapshotRoleResumeTokens = (): Record<string, string> => {
|
|
901
1047
|
const session = requireSessionIdentity();
|
|
902
1048
|
const captured = snapshotJsonValue(
|
|
903
1049
|
session.playerSessions
|
|
904
1050
|
? session.playerSessions.snapshot()
|
|
905
|
-
: Object.fromEntries(
|
|
1051
|
+
: Object.fromEntries(
|
|
1052
|
+
ROLE_IDS.flatMap((roleId) => {
|
|
1053
|
+
const token = privateResumeTokens.get(
|
|
1054
|
+
continuationKey(roleId, resolvedPlayerId(roleId)),
|
|
1055
|
+
);
|
|
1056
|
+
return token === undefined ? [] : [[roleId, token]];
|
|
1057
|
+
}),
|
|
1058
|
+
),
|
|
906
1059
|
'player session store snapshot',
|
|
907
1060
|
);
|
|
908
1061
|
if (!isPlainObject(captured)) {
|
|
909
1062
|
throw new TypeError('player session store snapshot must be an object');
|
|
910
1063
|
}
|
|
911
1064
|
const tokens: Record<string, string> = {};
|
|
912
|
-
for (const [
|
|
913
|
-
if (
|
|
1065
|
+
for (const [roleId, token] of Object.entries(captured)) {
|
|
1066
|
+
if (!ROLE_ID_SET.has(roleId)) {
|
|
914
1067
|
throw new TypeError(
|
|
915
|
-
|
|
1068
|
+
`player session store snapshot contains unknown role ${roleId}`,
|
|
916
1069
|
);
|
|
917
1070
|
}
|
|
918
1071
|
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
919
1072
|
throw new TypeError(
|
|
920
|
-
`player session store snapshot token for ${
|
|
1073
|
+
`player session store snapshot token for ${roleId} must be a non-empty string`,
|
|
921
1074
|
);
|
|
922
1075
|
}
|
|
923
|
-
tokens[
|
|
1076
|
+
tokens[roleId] = token;
|
|
924
1077
|
}
|
|
1078
|
+
tokensByContinuationKey(tokens);
|
|
925
1079
|
return tokens;
|
|
926
1080
|
};
|
|
927
|
-
const
|
|
1081
|
+
const restoreRoleResumeTokens = (
|
|
928
1082
|
tokens: Readonly<Record<string, string>>,
|
|
929
1083
|
): void => {
|
|
1084
|
+
const byKey = tokensByContinuationKey(tokens);
|
|
930
1085
|
const session = requireSessionIdentity();
|
|
931
1086
|
if (session.playerSessions) {
|
|
932
1087
|
session.playerSessions.restore(tokens);
|
|
933
1088
|
return;
|
|
934
1089
|
}
|
|
935
|
-
|
|
936
|
-
for (const [
|
|
937
|
-
playerResumeTokens.set(playerId, token);
|
|
938
|
-
}
|
|
1090
|
+
privateResumeTokens.clear();
|
|
1091
|
+
for (const [key, token] of byKey) privateResumeTokens.set(key, token);
|
|
939
1092
|
};
|
|
940
1093
|
const currentState = (
|
|
941
1094
|
pendingCall: PlaybookPendingCall | undefined =
|
|
@@ -957,12 +1110,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
957
1110
|
payload: unknown,
|
|
958
1111
|
meta: { turnId?: number; callId?: string } = {},
|
|
959
1112
|
describedEmission?: (runtimePorts: PlaybookPorts) => Promise<void>,
|
|
1113
|
+
aborts?: AbortReasonClassifier,
|
|
960
1114
|
): Promise<void> => {
|
|
961
1115
|
const runtimePorts = requirePorts();
|
|
962
1116
|
const identity = requireSessionIdentity();
|
|
963
1117
|
const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
964
1118
|
const trace: PlaybookTraceEvent = Object.freeze({
|
|
965
|
-
schemaVersion:
|
|
1119
|
+
schemaVersion: 3,
|
|
966
1120
|
sessionId: identity.sessionId,
|
|
967
1121
|
playbookId: identity.playbookId,
|
|
968
1122
|
rootSessionId: identity.rootSessionId,
|
|
@@ -980,16 +1134,20 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
980
1134
|
...(meta.callId !== undefined ? { callId: meta.callId } : {}),
|
|
981
1135
|
payload: jsonPayload,
|
|
982
1136
|
});
|
|
983
|
-
return enqueue(
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1137
|
+
return enqueue(
|
|
1138
|
+
async () => {
|
|
1139
|
+
await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
|
|
1140
|
+
await describedEmission?.(runtimePorts);
|
|
1141
|
+
},
|
|
1142
|
+
aborts,
|
|
1143
|
+
);
|
|
987
1144
|
};
|
|
988
1145
|
const emitTrace = (
|
|
989
1146
|
type: PlaybookTraceType,
|
|
990
1147
|
payload: unknown,
|
|
991
1148
|
meta: { turnId?: number; callId?: string } = {},
|
|
992
|
-
|
|
1149
|
+
aborts?: AbortReasonClassifier,
|
|
1150
|
+
): Promise<void> => enqueueTracedEmission(type, payload, meta, undefined, aborts);
|
|
993
1151
|
const emitBoundaryStatus = async (
|
|
994
1152
|
message: string,
|
|
995
1153
|
state: PlaybookState,
|
|
@@ -1018,8 +1176,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1018
1176
|
meta: { turnId?: number; callId?: string },
|
|
1019
1177
|
signal: AbortSignal,
|
|
1020
1178
|
): Promise<void> => {
|
|
1179
|
+
const aborts = abortReasonClassifier(signal);
|
|
1021
1180
|
try {
|
|
1022
|
-
await emitTrace(startedType, identity, meta);
|
|
1181
|
+
await emitTrace(startedType, identity, meta, aborts);
|
|
1023
1182
|
} catch (error) {
|
|
1024
1183
|
latchControlPlaneError(error, signal);
|
|
1025
1184
|
try {
|
|
@@ -1027,13 +1186,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1027
1186
|
finishedType,
|
|
1028
1187
|
{
|
|
1029
1188
|
...identity,
|
|
1030
|
-
|
|
1189
|
+
// A started-trace sink rejection causally identical to the
|
|
1190
|
+
// boundary reason is the abort's own evidence: the pair
|
|
1191
|
+
// finishes 'aborted', not 'error' (DR-036 §4).
|
|
1192
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1031
1193
|
error: normalizeErrorFull(error) ?? {
|
|
1032
1194
|
name: 'Error',
|
|
1033
1195
|
message: String(error),
|
|
1034
1196
|
},
|
|
1035
1197
|
},
|
|
1036
1198
|
meta,
|
|
1199
|
+
aborts,
|
|
1037
1200
|
);
|
|
1038
1201
|
} catch {
|
|
1039
1202
|
// Preserve the start failure after one best-effort finish attempt.
|
|
@@ -1048,6 +1211,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1048
1211
|
purpose: 'boss-input-classification' | 'player-output-adjudication',
|
|
1049
1212
|
callStateId: string | undefined,
|
|
1050
1213
|
): Promise<string> => {
|
|
1214
|
+
const aborts = abortReasonClassifier(signal);
|
|
1051
1215
|
const identity = {
|
|
1052
1216
|
purpose,
|
|
1053
1217
|
...(callStateId !== undefined ? { stateId: callStateId } : {}),
|
|
@@ -1083,13 +1247,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1083
1247
|
'judge.call.finished',
|
|
1084
1248
|
{
|
|
1085
1249
|
...identity,
|
|
1086
|
-
|
|
1250
|
+
// Only the exact abort reason is cancellation; a distinct
|
|
1251
|
+
// failure under an aborted signal stays an error
|
|
1252
|
+
// (slc/link.md §Abort).
|
|
1253
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1087
1254
|
error: normalizeErrorFull(error) ?? {
|
|
1088
1255
|
name: 'Error',
|
|
1089
1256
|
message: String(error),
|
|
1090
1257
|
},
|
|
1091
1258
|
},
|
|
1092
1259
|
{ turnId: currentTurnId, callId },
|
|
1260
|
+
aborts,
|
|
1093
1261
|
);
|
|
1094
1262
|
throw error;
|
|
1095
1263
|
}
|
|
@@ -1098,6 +1266,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1098
1266
|
'judge.call.finished',
|
|
1099
1267
|
{ ...identity, status: 'ok', reply: finalText },
|
|
1100
1268
|
{ turnId: currentTurnId, callId },
|
|
1269
|
+
aborts,
|
|
1101
1270
|
);
|
|
1102
1271
|
return finalText;
|
|
1103
1272
|
});
|
|
@@ -1118,28 +1287,35 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1118
1287
|
const runPlayerCall = async (
|
|
1119
1288
|
input: PlayerInput,
|
|
1120
1289
|
signal: AbortSignal,
|
|
1121
|
-
): Promise<{
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1290
|
+
): Promise<{
|
|
1291
|
+
roleId: RoleId;
|
|
1292
|
+
playerId?: string;
|
|
1293
|
+
result: PlayerResult;
|
|
1294
|
+
}> => {
|
|
1295
|
+
const aborts = abortReasonClassifier(signal);
|
|
1296
|
+
if (!ROLE_ID_SET.has(input.role)) {
|
|
1297
|
+
throw new TypeError(
|
|
1298
|
+
`DECIDE player input role must name a declared local role`,
|
|
1126
1299
|
);
|
|
1127
1300
|
}
|
|
1128
|
-
const
|
|
1301
|
+
const roleId = input.role;
|
|
1302
|
+
const playerId = resolvedPlayerId(roleId);
|
|
1303
|
+
const playerKey = continuationKey(roleId, playerId);
|
|
1304
|
+
const prompt = composeInvocationPrompt(input);
|
|
1129
1305
|
let resume: PlayerCallOptions['resume'];
|
|
1130
1306
|
try {
|
|
1131
1307
|
signal.throwIfAborted();
|
|
1132
|
-
resume = selectPlayerResume(playerId);
|
|
1308
|
+
resume = selectPlayerResume(roleId, playerId);
|
|
1133
1309
|
} catch (error) {
|
|
1134
1310
|
latchControlPlaneError(error, signal);
|
|
1135
1311
|
throw error;
|
|
1136
1312
|
}
|
|
1137
1313
|
const callId = `player-${++playerCallSequence}`;
|
|
1138
1314
|
const identity = {
|
|
1139
|
-
purpose: 'captain',
|
|
1140
1315
|
stateId: input.stateId,
|
|
1141
1316
|
sourceItem: input.sourceItem,
|
|
1142
|
-
|
|
1317
|
+
roleId,
|
|
1318
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
1143
1319
|
resume,
|
|
1144
1320
|
};
|
|
1145
1321
|
const emitFailure = (error: unknown): Promise<void> =>
|
|
@@ -1147,15 +1323,33 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1147
1323
|
'player.call.finished',
|
|
1148
1324
|
{
|
|
1149
1325
|
...identity,
|
|
1150
|
-
|
|
1326
|
+
// Only the exact abort reason is cancellation; a distinct
|
|
1327
|
+
// failure under an aborted signal stays an error
|
|
1328
|
+
// (slc/link.md §Abort).
|
|
1329
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1151
1330
|
error: normalizeErrorFull(error) ?? {
|
|
1152
1331
|
name: 'Error',
|
|
1153
1332
|
message: String(error),
|
|
1154
1333
|
},
|
|
1155
1334
|
},
|
|
1156
1335
|
{ turnId: currentTurnId, callId },
|
|
1336
|
+
aborts,
|
|
1337
|
+
);
|
|
1338
|
+
if (inFlightPlayerKeys.has(playerKey)) {
|
|
1339
|
+
const error = new Error(
|
|
1340
|
+
`resolved player key "${playerKey}" already has an in-flight call`,
|
|
1341
|
+
);
|
|
1342
|
+
await emitCallStarted(
|
|
1343
|
+
'player.call.started',
|
|
1344
|
+
'player.call.finished',
|
|
1345
|
+
{ ...identity, prompt },
|
|
1346
|
+
{ turnId: currentTurnId, callId },
|
|
1347
|
+
signal,
|
|
1157
1348
|
);
|
|
1158
|
-
|
|
1349
|
+
await emitFailure(error);
|
|
1350
|
+
throw error;
|
|
1351
|
+
}
|
|
1352
|
+
inFlightPlayerKeys.add(playerKey);
|
|
1159
1353
|
try {
|
|
1160
1354
|
await emitCallStarted(
|
|
1161
1355
|
'player.call.started',
|
|
@@ -1169,7 +1363,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1169
1363
|
try {
|
|
1170
1364
|
signal.throwIfAborted();
|
|
1171
1365
|
const boundary = Promise.resolve(
|
|
1172
|
-
requirePorts().callPlayer(
|
|
1366
|
+
requirePorts().callPlayer(roleId, prompt, signal, { resume }),
|
|
1173
1367
|
);
|
|
1174
1368
|
rawResult = await boundary;
|
|
1175
1369
|
// An XState sibling cancellation does not cancel an arbitrary coder
|
|
@@ -1205,13 +1399,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1205
1399
|
// The resolved result is authoritative even on aborted/error status.
|
|
1206
1400
|
// Update continuation state before interpreting that status.
|
|
1207
1401
|
try {
|
|
1208
|
-
updatePlayerResume(
|
|
1209
|
-
playerId,
|
|
1210
|
-
typeof result.resumeToken === 'string' &&
|
|
1211
|
-
result.resumeToken.trim().length > 0
|
|
1212
|
-
? result.resumeToken
|
|
1213
|
-
: undefined,
|
|
1214
|
-
);
|
|
1402
|
+
updatePlayerResume(roleId, playerId, result);
|
|
1215
1403
|
} catch (error) {
|
|
1216
1404
|
latchControlPlaneError(error, signal);
|
|
1217
1405
|
try {
|
|
@@ -1241,77 +1429,96 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1241
1429
|
: {}),
|
|
1242
1430
|
},
|
|
1243
1431
|
{ turnId: currentTurnId, callId },
|
|
1432
|
+
aborts,
|
|
1244
1433
|
);
|
|
1245
|
-
return {
|
|
1434
|
+
return {
|
|
1435
|
+
roleId,
|
|
1436
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
1437
|
+
result,
|
|
1438
|
+
};
|
|
1246
1439
|
} finally {
|
|
1247
|
-
|
|
1440
|
+
inFlightPlayerKeys.delete(playerKey);
|
|
1248
1441
|
}
|
|
1249
1442
|
};
|
|
1250
1443
|
|
|
1251
1444
|
const callPlayer = (
|
|
1252
1445
|
input: PlayerInput,
|
|
1253
1446
|
signal: AbortSignal,
|
|
1254
|
-
): Promise<{
|
|
1447
|
+
): Promise<{
|
|
1448
|
+
roleId: RoleId;
|
|
1449
|
+
playerId?: string;
|
|
1450
|
+
result: PlayerResult;
|
|
1451
|
+
}> => {
|
|
1255
1452
|
return trackBoundaryCall(runPlayerCall(input, signal));
|
|
1256
1453
|
};
|
|
1257
1454
|
|
|
1258
1455
|
const player = fromPromise<PlayerOutput, PlayerInput>(
|
|
1259
1456
|
async ({ input, signal }) => {
|
|
1260
1457
|
const combined = combineSignals(signal, currentSignal);
|
|
1261
|
-
|
|
1262
|
-
// Yield through the runtime emission queue before crossing the player
|
|
1263
|
-
// boundary so state trace/status always precede its call-start trace.
|
|
1458
|
+
const settlementAborts = abortReasonClassifier(combined);
|
|
1264
1459
|
try {
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
// whitespace-only earns exactly one corrective re-ask — the same
|
|
1276
|
-
// composed call repeated, traced by runPlayerCall as its own
|
|
1277
|
-
// player-call pair, with the resume selection re-read from the
|
|
1278
|
-
// token map the first result left (PBRT-38). An abort that lands
|
|
1279
|
-
// between the two calls ends the turn without the re-ask (aborts
|
|
1280
|
-
// are never retried), and a rejecting finish emission rejects
|
|
1281
|
-
// `callPlayer` itself, so it never reaches this branch (PBRT-47).
|
|
1460
|
+
// XState starts invoked actors while publishing the entering snapshot.
|
|
1461
|
+
// Yield through the runtime emission queue before crossing the player
|
|
1462
|
+
// boundary so state trace/status always precede its call-start trace.
|
|
1463
|
+
combined.throwIfAborted();
|
|
1464
|
+
try {
|
|
1465
|
+
await flush(settlementAborts);
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
latchControlPlaneError(error, combined);
|
|
1468
|
+
throw error;
|
|
1469
|
+
}
|
|
1282
1470
|
combined.throwIfAborted();
|
|
1283
|
-
({ playerId, result } = await callPlayer(input, combined));
|
|
1284
|
-
}
|
|
1285
|
-
if (result.status !== 'ok') {
|
|
1286
|
-
throw new Error(
|
|
1287
|
-
`player "${playerId}" returned status "${result.status}"${
|
|
1288
|
-
result.error ? `: ${result.error}` : ''
|
|
1289
|
-
}`,
|
|
1290
|
-
);
|
|
1291
|
-
}
|
|
1292
|
-
const finalText = result.finalText ?? '';
|
|
1293
|
-
if (isEmptyFinalText(finalText)) {
|
|
1294
|
-
throw new Error(
|
|
1295
|
-
`player "${playerId}" returned status "ok" with no finalText`,
|
|
1296
|
-
);
|
|
1297
|
-
}
|
|
1298
|
-
combined.throwIfAborted();
|
|
1299
1471
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
),
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1472
|
+
let { roleId, playerId, result } = await callPlayer(input, combined);
|
|
1473
|
+
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
1474
|
+
// DR-028: an `ok` result whose finalText is missing, empty, or
|
|
1475
|
+
// whitespace-only earns exactly one corrective re-ask — the same
|
|
1476
|
+
// composed call repeated, traced by runPlayerCall as its own
|
|
1477
|
+
// player-call pair, with the resume selection re-read from the
|
|
1478
|
+
// token map the first result left (PBRT-38). An abort that lands
|
|
1479
|
+
// between the two calls ends the turn without the re-ask (aborts
|
|
1480
|
+
// are never retried), and a rejecting finish emission rejects
|
|
1481
|
+
// `callPlayer` itself, so it never reaches this branch (PBRT-47).
|
|
1482
|
+
combined.throwIfAborted();
|
|
1483
|
+
({ roleId, playerId, result } = await callPlayer(input, combined));
|
|
1484
|
+
}
|
|
1485
|
+
if (result.status !== 'ok') {
|
|
1486
|
+
throw new Error(
|
|
1487
|
+
`${roleLabel(roleId)}${
|
|
1488
|
+
playerId === undefined ? '' : ` (${playerId})`
|
|
1489
|
+
} returned status "${result.status}"${
|
|
1490
|
+
result.error ? `: ${result.error}` : ''
|
|
1491
|
+
}`,
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
const finalText = result.finalText ?? '';
|
|
1495
|
+
if (isEmptyFinalText(finalText)) {
|
|
1496
|
+
throw new Error(
|
|
1497
|
+
`${roleLabel(roleId)}${
|
|
1498
|
+
playerId === undefined ? '' : ` (${playerId})`
|
|
1499
|
+
} returned status "ok" with no finalText`,
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
combined.throwIfAborted();
|
|
1503
|
+
|
|
1504
|
+
try {
|
|
1505
|
+
const prompt = buildAdjudicatorPrompt(input, finalText);
|
|
1506
|
+
return parseAdjudication(
|
|
1507
|
+
await callJudge(
|
|
1508
|
+
prompt,
|
|
1509
|
+
combined,
|
|
1510
|
+
'player-output-adjudication',
|
|
1511
|
+
input.stateId,
|
|
1512
|
+
),
|
|
1513
|
+
input,
|
|
1514
|
+
finalText,
|
|
1515
|
+
);
|
|
1516
|
+
} catch (error) {
|
|
1517
|
+
latchControlPlaneError(error, combined);
|
|
1518
|
+
throw error;
|
|
1519
|
+
}
|
|
1520
|
+
} finally {
|
|
1521
|
+
actorSettlementAborts.push(settlementAborts);
|
|
1315
1522
|
}
|
|
1316
1523
|
},
|
|
1317
1524
|
);
|
|
@@ -1323,7 +1530,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1323
1530
|
trackBoundaryCall(
|
|
1324
1531
|
Promise.resolve(requirePorts().callPlaybook(request, signal)),
|
|
1325
1532
|
),
|
|
1326
|
-
emitStarted: async (event) => {
|
|
1533
|
+
emitStarted: async (event, aborts) => {
|
|
1327
1534
|
playbookCallTurnIds.set(event.callId, currentTurnId);
|
|
1328
1535
|
await emitTrace(
|
|
1329
1536
|
'playbook.call.started',
|
|
@@ -1336,9 +1543,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1336
1543
|
...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
|
|
1337
1544
|
callId: event.callId,
|
|
1338
1545
|
},
|
|
1546
|
+
aborts,
|
|
1339
1547
|
);
|
|
1340
1548
|
},
|
|
1341
|
-
emitFinished: async (event) => {
|
|
1549
|
+
emitFinished: async (event, aborts) => {
|
|
1342
1550
|
const turnId = playbookCallTurnIds.get(event.callId);
|
|
1343
1551
|
try {
|
|
1344
1552
|
await emitTrace(
|
|
@@ -1353,23 +1561,32 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1353
1561
|
...(turnId === undefined ? {} : { turnId }),
|
|
1354
1562
|
callId: event.callId,
|
|
1355
1563
|
},
|
|
1564
|
+
aborts,
|
|
1356
1565
|
);
|
|
1357
1566
|
} finally {
|
|
1358
1567
|
playbookCallTurnIds.delete(event.callId);
|
|
1359
1568
|
}
|
|
1360
1569
|
},
|
|
1361
1570
|
drain: flush,
|
|
1362
|
-
bindResumeSignal: (signal) => {
|
|
1571
|
+
bindResumeSignal: (signal, aborts) => {
|
|
1363
1572
|
currentSignal = signal;
|
|
1573
|
+
currentAborts = aborts ?? abortReasonClassifier(signal);
|
|
1574
|
+
},
|
|
1575
|
+
bindActorSettlement: (aborts) => {
|
|
1576
|
+
actorSettlementAborts.push(aborts);
|
|
1364
1577
|
},
|
|
1365
|
-
onControlPlaneError: (error) => {
|
|
1366
|
-
|
|
1367
|
-
|
|
1578
|
+
onControlPlaneError: (error, aborts) => {
|
|
1579
|
+
if (
|
|
1580
|
+
!aborts?.isAbortReason(error) &&
|
|
1581
|
+
!currentAborts?.isAbortReason(error)
|
|
1582
|
+
) {
|
|
1368
1583
|
controlPlaneError ??= error;
|
|
1369
1584
|
}
|
|
1370
1585
|
},
|
|
1371
|
-
onBackgroundError: (error) => {
|
|
1372
|
-
|
|
1586
|
+
onBackgroundError: (error, aborts) => {
|
|
1587
|
+
if (!aborts?.isAbortReason(error)) {
|
|
1588
|
+
collectFailure(emissionFailures, error);
|
|
1589
|
+
}
|
|
1373
1590
|
},
|
|
1374
1591
|
});
|
|
1375
1592
|
|
|
@@ -1377,10 +1594,27 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1377
1594
|
actors: { player, playbook: nestedBridge.actorLogic },
|
|
1378
1595
|
});
|
|
1379
1596
|
|
|
1597
|
+
const consumeActorSettlementAborts = (
|
|
1598
|
+
forSnapshot = false,
|
|
1599
|
+
): AbortReasonClassifier | undefined => {
|
|
1600
|
+
const aborts = actorSettlementAborts.shift() ?? actorSettlementErrorAborts;
|
|
1601
|
+
actorSettlementErrorAborts = undefined;
|
|
1602
|
+
if (forSnapshot && aborts !== undefined) {
|
|
1603
|
+
actorSettlementErrorAborts = aborts;
|
|
1604
|
+
queueMicrotask(() => {
|
|
1605
|
+
if (actorSettlementErrorAborts === aborts) {
|
|
1606
|
+
actorSettlementErrorAborts = undefined;
|
|
1607
|
+
}
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
return aborts;
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1380
1613
|
const inspect = (event: InspectionEvent): void => {
|
|
1381
1614
|
if (event.type !== '@xstate.snapshot') return;
|
|
1382
1615
|
if (actor === undefined || event.actorRef !== actor) return;
|
|
1383
1616
|
if (suppressInspectionEmissions) return;
|
|
1617
|
+
const settlementAborts = consumeActorSettlementAborts(true);
|
|
1384
1618
|
try {
|
|
1385
1619
|
const snapshot = event.snapshot as SnapshotFrom<typeof decideMachine>;
|
|
1386
1620
|
const state = normalizePlaybookSnapshot(snapshot);
|
|
@@ -1401,6 +1635,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1401
1635
|
topic: TELEMETRY_TOPIC,
|
|
1402
1636
|
payload: describedFsmPayload,
|
|
1403
1637
|
}),
|
|
1638
|
+
settlementAborts,
|
|
1404
1639
|
).catch(() => undefined);
|
|
1405
1640
|
|
|
1406
1641
|
const priorIds = new Set(previousState?.activeStateIds ?? []);
|
|
@@ -1426,6 +1661,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1426
1661
|
tracePayload,
|
|
1427
1662
|
{ turnId: currentTurnId },
|
|
1428
1663
|
(emissionPorts) => emissionPorts.emitStatus(message, data),
|
|
1664
|
+
settlementAborts,
|
|
1429
1665
|
).catch(() => undefined);
|
|
1430
1666
|
};
|
|
1431
1667
|
|
|
@@ -1443,11 +1679,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1443
1679
|
);
|
|
1444
1680
|
if (pending) {
|
|
1445
1681
|
scheduleStatus(
|
|
1446
|
-
`${pending.
|
|
1682
|
+
`${pending.asker.roleId} asks: ${pending.question}`,
|
|
1447
1683
|
activeStateId,
|
|
1448
1684
|
);
|
|
1449
1685
|
scheduleStatus(
|
|
1450
|
-
`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.
|
|
1686
|
+
`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.asker.roleId} · ${pending.sourceItem}`,
|
|
1451
1687
|
activeStateId,
|
|
1452
1688
|
);
|
|
1453
1689
|
}
|
|
@@ -1459,19 +1695,19 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1459
1695
|
: undefined;
|
|
1460
1696
|
const description = STATE_DESCRIPTIONS[activeStateId];
|
|
1461
1697
|
if (description === undefined) continue;
|
|
1462
|
-
const
|
|
1698
|
+
const roleState = ROLE_STATES.find(
|
|
1463
1699
|
(candidate) => candidate.stateId === activeStateId,
|
|
1464
1700
|
);
|
|
1465
1701
|
scheduleStatus(
|
|
1466
|
-
|
|
1702
|
+
roleState === undefined
|
|
1467
1703
|
? '◆ workflow failed; awaiting Boss recovery.'
|
|
1468
|
-
: `⤷ ${
|
|
1704
|
+
: `⤷ ${roleLabel(roleState.role)}: ${description}`,
|
|
1469
1705
|
activeStateId,
|
|
1470
1706
|
lastError === undefined ? undefined : { lastError },
|
|
1471
1707
|
);
|
|
1472
1708
|
}
|
|
1473
1709
|
} catch (error) {
|
|
1474
|
-
latchInspectionError(error);
|
|
1710
|
+
latchInspectionError(error, settlementAborts);
|
|
1475
1711
|
}
|
|
1476
1712
|
};
|
|
1477
1713
|
|
|
@@ -1490,6 +1726,16 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1490
1726
|
}),
|
|
1491
1727
|
inspect,
|
|
1492
1728
|
});
|
|
1729
|
+
// A synchronous FSM action throw errors the actor without any pending
|
|
1730
|
+
// boundary await to observe it; unobserved, XState would surface it via
|
|
1731
|
+
// reportUnhandledError as an uncaughtException. Observe it here: latch
|
|
1732
|
+
// it as a control error while a turn signal is active (unless it is
|
|
1733
|
+
// the abort reason itself), otherwise collect it with the emission
|
|
1734
|
+
// failures (slc/link.md §Abort).
|
|
1735
|
+
actor.subscribe({
|
|
1736
|
+
error: (error) =>
|
|
1737
|
+
latchInspectionError(error, consumeActorSettlementAborts()),
|
|
1738
|
+
});
|
|
1493
1739
|
};
|
|
1494
1740
|
|
|
1495
1741
|
// PBRT-6: the single seam that stops this runtime's actor. Stopping a
|
|
@@ -1529,7 +1775,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1529
1775
|
const state = normalizePlaybookSnapshot(snapshot, {
|
|
1530
1776
|
pendingCall: nestedBridge.getPendingCall(),
|
|
1531
1777
|
});
|
|
1532
|
-
const pendingQuestions =
|
|
1778
|
+
const pendingQuestions = pendingQuestionsForState(state, context);
|
|
1533
1779
|
if (
|
|
1534
1780
|
pendingQuestions.length === 0 &&
|
|
1535
1781
|
(snapshot.status === 'done' ||
|
|
@@ -1563,34 +1809,58 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1563
1809
|
const pendingCall = nestedBridge.getPendingCall();
|
|
1564
1810
|
const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
|
|
1565
1811
|
const context = snapshot.context as unknown as Record<string, unknown>;
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1812
|
+
const abortedResult = (abortSignal: AbortSignal): PlaybookRunResult => ({
|
|
1813
|
+
outcome: 'aborted',
|
|
1814
|
+
state,
|
|
1815
|
+
...(abortSignal.reason === undefined
|
|
1816
|
+
? {}
|
|
1817
|
+
: {
|
|
1818
|
+
error: normalizeErrorFull(abortSignal.reason) ?? {
|
|
1819
|
+
name: 'AbortError',
|
|
1820
|
+
message: String(abortSignal.reason),
|
|
1821
|
+
},
|
|
1822
|
+
}),
|
|
1823
|
+
});
|
|
1824
|
+
if (snapshot.status === 'error') {
|
|
1825
|
+
// An errored actor outranks a coincident abort unless the actor's
|
|
1826
|
+
// error is the abort reason itself (slc/link.md §Abort).
|
|
1827
|
+
const actorError = (snapshot as { error?: unknown }).error;
|
|
1828
|
+
if (
|
|
1829
|
+
actorError !== undefined &&
|
|
1830
|
+
signal !== undefined &&
|
|
1831
|
+
isAbortFailure(actorError, signal)
|
|
1832
|
+
) {
|
|
1833
|
+
return abortedResult(signal);
|
|
1834
|
+
}
|
|
1835
|
+
throw (
|
|
1836
|
+
actorError ?? new Error('decide runtime actor entered error status')
|
|
1837
|
+
);
|
|
1579
1838
|
}
|
|
1839
|
+
// Terminal completion outranks a coincident abort (DR-036 §3): reporting
|
|
1840
|
+
// 'aborted' over a completed machine would hide a terminal state that the
|
|
1841
|
+
// next turn silently restarts, duplicating the workflow's side effects.
|
|
1580
1842
|
if (snapshot.status === 'done') {
|
|
1581
1843
|
const output = (snapshot as { output?: unknown }).output;
|
|
1582
1844
|
if (output !== undefined) assertJsonSafe(output, 'terminal output');
|
|
1845
|
+
const stateDescription = state.activeStateIds.includes('done')
|
|
1846
|
+
? STATE_DESCRIPTIONS.done
|
|
1847
|
+
: state.activeStateIds.includes('reportedReviewFailure')
|
|
1848
|
+
? STATE_DESCRIPTIONS.reportedReviewFailure
|
|
1849
|
+
: undefined;
|
|
1850
|
+
if (stateDescription === undefined) {
|
|
1851
|
+
throw new Error(
|
|
1852
|
+
'decide runtime: completed actor has no authored final-state description',
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1583
1855
|
return {
|
|
1584
1856
|
outcome: 'terminal',
|
|
1585
1857
|
state,
|
|
1858
|
+
stateDescription,
|
|
1586
1859
|
...(output === undefined ? {} : { output }),
|
|
1587
1860
|
};
|
|
1588
1861
|
}
|
|
1589
|
-
if (
|
|
1590
|
-
|
|
1591
|
-
(snapshot as { error?: unknown }).error ??
|
|
1592
|
-
new Error('decide runtime actor entered error status')
|
|
1593
|
-
);
|
|
1862
|
+
if (signal?.aborted) {
|
|
1863
|
+
return abortedResult(signal);
|
|
1594
1864
|
}
|
|
1595
1865
|
if (state.activeStateIds.includes('failed')) {
|
|
1596
1866
|
const error = normalizeErrorFull(context.lastError);
|
|
@@ -1652,14 +1922,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1652
1922
|
// The session-start error remains authoritative.
|
|
1653
1923
|
}
|
|
1654
1924
|
}
|
|
1655
|
-
|
|
1656
|
-
|
|
1925
|
+
privateResumeTokens.clear();
|
|
1926
|
+
inFlightPlayerKeys.clear();
|
|
1657
1927
|
activeBoundaryCalls.clear();
|
|
1658
1928
|
activeEmissionCalls.clear();
|
|
1659
1929
|
emissionQueue.clear();
|
|
1660
1930
|
judgeQueue.clear();
|
|
1661
1931
|
actor = undefined;
|
|
1662
1932
|
currentSignal = undefined;
|
|
1933
|
+
currentAborts = undefined;
|
|
1934
|
+
actorSettlementAborts.length = 0;
|
|
1935
|
+
actorSettlementErrorAborts = undefined;
|
|
1663
1936
|
currentTurnId = undefined;
|
|
1664
1937
|
ports = undefined;
|
|
1665
1938
|
sessionIdentity = undefined;
|
|
@@ -1688,7 +1961,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1688
1961
|
'decide runtime: init(session) may only be called once',
|
|
1689
1962
|
);
|
|
1690
1963
|
}
|
|
1691
|
-
const identity =
|
|
1964
|
+
const identity = bindSession(session);
|
|
1692
1965
|
let finishInitialization!: () => void;
|
|
1693
1966
|
const initialization = new Promise<void>((resolve) => {
|
|
1694
1967
|
finishInitialization = resolve;
|
|
@@ -1773,10 +2046,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1773
2046
|
actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
|
|
1774
2047
|
).context as unknown as Record<string, unknown>;
|
|
1775
2048
|
return {
|
|
1776
|
-
schemaVersion:
|
|
2049
|
+
schemaVersion: 3,
|
|
1777
2050
|
playbookId: sessionIdentity.playbookId,
|
|
1778
2051
|
machine,
|
|
1779
|
-
|
|
2052
|
+
roleResumeTokens: snapshotRoleResumeTokens(),
|
|
1780
2053
|
sequences: {
|
|
1781
2054
|
trace: traceSequence,
|
|
1782
2055
|
turn: turnSequence,
|
|
@@ -1785,10 +2058,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1785
2058
|
playbookCall: playbookCallSequence,
|
|
1786
2059
|
},
|
|
1787
2060
|
state,
|
|
1788
|
-
pendingBossQuestions:
|
|
2061
|
+
pendingBossQuestions: pendingQuestionsForState(state, context).map(
|
|
1789
2062
|
(pending) => ({
|
|
1790
2063
|
questionId: pending.questionId,
|
|
1791
|
-
|
|
2064
|
+
asker: pending.asker,
|
|
1792
2065
|
question: pending.question,
|
|
1793
2066
|
sourceItem: pending.sourceItem,
|
|
1794
2067
|
}),
|
|
@@ -1816,16 +2089,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1816
2089
|
'decide runtime: restore(session, snapshot) may only be called once',
|
|
1817
2090
|
);
|
|
1818
2091
|
}
|
|
1819
|
-
const identity =
|
|
2092
|
+
const identity = bindSession(session);
|
|
1820
2093
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(
|
|
1821
2094
|
snapshot,
|
|
1822
2095
|
identity.playbookId,
|
|
1823
2096
|
{ allowSuspendedCall: true },
|
|
1824
2097
|
);
|
|
1825
|
-
const suspendedCall =
|
|
1826
|
-
boundSnapshot.schemaVersion === 2
|
|
1827
|
-
? boundSnapshot.suspendedCall
|
|
1828
|
-
: undefined;
|
|
2098
|
+
const suspendedCall = boundSnapshot.suspendedCall;
|
|
1829
2099
|
let finishInitialization!: () => void;
|
|
1830
2100
|
const initialization = new Promise<void>((resolve) => {
|
|
1831
2101
|
finishInitialization = resolve;
|
|
@@ -1834,7 +2104,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1834
2104
|
lifecycleStarted = true;
|
|
1835
2105
|
ports = identity.ports;
|
|
1836
2106
|
sessionIdentity = identity;
|
|
1837
|
-
let
|
|
2107
|
+
let priorExternalRoleTokens:
|
|
1838
2108
|
| Readonly<Record<string, string>>
|
|
1839
2109
|
| undefined;
|
|
1840
2110
|
try {
|
|
@@ -1844,9 +2114,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1844
2114
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1845
2115
|
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
1846
2116
|
if (identity.playerSessions) {
|
|
1847
|
-
|
|
2117
|
+
priorExternalRoleTokens = snapshotRoleResumeTokens();
|
|
1848
2118
|
}
|
|
1849
|
-
|
|
2119
|
+
restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
|
|
1850
2120
|
nestedBridge.prepareRestore(suspendedCall);
|
|
1851
2121
|
if (suspendedCall !== undefined) {
|
|
1852
2122
|
playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
|
|
@@ -1877,9 +2147,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1877
2147
|
nestedBridge.confirmRestore();
|
|
1878
2148
|
} catch (error) {
|
|
1879
2149
|
let failure = error;
|
|
1880
|
-
if (
|
|
2150
|
+
if (priorExternalRoleTokens !== undefined) {
|
|
1881
2151
|
try {
|
|
1882
|
-
identity.playerSessions!.restore(
|
|
2152
|
+
identity.playerSessions!.restore(priorExternalRoleTokens);
|
|
1883
2153
|
} catch (rollbackError) {
|
|
1884
2154
|
failure = new AggregateError(
|
|
1885
2155
|
[error, rollbackError],
|
|
@@ -1915,12 +2185,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1915
2185
|
const turnId = ++turnSequence;
|
|
1916
2186
|
currentTurnId = turnId;
|
|
1917
2187
|
currentSignal = turn.signal;
|
|
2188
|
+
currentAborts = abortReasonClassifier(turn.signal);
|
|
1918
2189
|
controlPlaneError = undefined;
|
|
1919
2190
|
let result: PlaybookRunResult = resultForSnapshot(turn.signal);
|
|
1920
2191
|
let settlement: unknown = result;
|
|
1921
2192
|
const failures: unknown[] = [];
|
|
1922
2193
|
try {
|
|
1923
2194
|
await emitTrace('boss.input.received', { text: turn.text }, { turnId });
|
|
2195
|
+
// A boundary entered aborted records the attempted input, then refuses
|
|
2196
|
+
// delivery before deterministic mapping or the classifier can perform
|
|
2197
|
+
// any host-visible work (DR-036 §5).
|
|
2198
|
+
turn.signal.throwIfAborted();
|
|
1924
2199
|
if (turn.text.trim().length === 0) {
|
|
1925
2200
|
const state = currentState();
|
|
1926
2201
|
result = { outcome: 'no-action', state };
|
|
@@ -1953,15 +2228,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1953
2228
|
};
|
|
1954
2229
|
} catch (error) {
|
|
1955
2230
|
const primaryError = controlPlaneError;
|
|
2231
|
+
// Only a rejection that is the exact abort reason settles as the
|
|
2232
|
+
// cancellation; a distinct failure observed while the signal is
|
|
2233
|
+
// aborted remains a control error (slc/link.md §Abort).
|
|
1956
2234
|
if (primaryError !== undefined) {
|
|
1957
2235
|
collectFailure(failures, primaryError);
|
|
1958
|
-
} else if (!turn.signal
|
|
2236
|
+
} else if (!isAbortFailure(error, turn.signal)) {
|
|
1959
2237
|
collectFailure(failures, error);
|
|
1960
2238
|
}
|
|
1961
2239
|
const state = currentState();
|
|
1962
2240
|
const effectiveError = primaryError ?? error;
|
|
1963
2241
|
result =
|
|
1964
|
-
turn.signal
|
|
2242
|
+
isAbortFailure(error, turn.signal) && primaryError === undefined
|
|
1965
2243
|
? resultForSnapshot(turn.signal)
|
|
1966
2244
|
: {
|
|
1967
2245
|
outcome: 'failed',
|
|
@@ -1982,13 +2260,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1982
2260
|
} catch (error) {
|
|
1983
2261
|
const primaryError = controlPlaneError;
|
|
1984
2262
|
const effectiveError = primaryError ?? error;
|
|
1985
|
-
|
|
2263
|
+
// A drain rejection that is the exact abort reason evidences the
|
|
2264
|
+
// cancellation, not a control-plane failure (slc/link.md §Abort).
|
|
2265
|
+
const drainAborted = isAbortFailure(effectiveError, turn.signal);
|
|
2266
|
+
if (!drainAborted) collectFailure(failures, effectiveError);
|
|
1986
2267
|
const state = currentState();
|
|
1987
2268
|
result = {
|
|
1988
|
-
outcome:
|
|
1989
|
-
turn.signal.aborted && primaryError === undefined
|
|
1990
|
-
? 'aborted'
|
|
1991
|
-
: 'failed',
|
|
2269
|
+
outcome: drainAborted ? 'aborted' : 'failed',
|
|
1992
2270
|
state,
|
|
1993
2271
|
error: normalizeErrorFull(effectiveError) ?? {
|
|
1994
2272
|
name: 'Error',
|
|
@@ -1997,18 +2275,28 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
1997
2275
|
};
|
|
1998
2276
|
settlement = { ...result, ...stateIdentity(state) };
|
|
1999
2277
|
}
|
|
2000
|
-
currentSignal = undefined;
|
|
2001
2278
|
try {
|
|
2002
2279
|
await emitTrace('boss.input.settled', settlement, { turnId });
|
|
2003
2280
|
} catch (error) {
|
|
2004
|
-
|
|
2281
|
+
// A settlement-trace rejection that is the exact abort reason also
|
|
2282
|
+
// evidences the cancellation (slc/link.md §Abort).
|
|
2283
|
+
if (!isAbortFailure(error, turn.signal)) {
|
|
2284
|
+
collectFailure(failures, error);
|
|
2285
|
+
}
|
|
2005
2286
|
}
|
|
2006
2287
|
try {
|
|
2007
2288
|
await flush();
|
|
2008
2289
|
} catch (error) {
|
|
2009
|
-
|
|
2290
|
+
// A late flush rejection that is the exact abort reason likewise
|
|
2291
|
+
// evidences the cancellation; the settled result already labels
|
|
2292
|
+
// the turn aborted then (slc/link.md §Abort).
|
|
2293
|
+
if (!isAbortFailure(error, turn.signal)) {
|
|
2294
|
+
collectFailure(failures, error);
|
|
2295
|
+
}
|
|
2010
2296
|
} finally {
|
|
2011
2297
|
const primaryError = controlPlaneError;
|
|
2298
|
+
currentSignal = undefined;
|
|
2299
|
+
currentAborts = undefined;
|
|
2012
2300
|
currentTurnId = undefined;
|
|
2013
2301
|
controlPlaneError = undefined;
|
|
2014
2302
|
if (primaryError !== undefined) throw primaryError;
|
|
@@ -2044,6 +2332,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
2044
2332
|
}
|
|
2045
2333
|
currentTurnId = playbookCallTurnIds.get(callId);
|
|
2046
2334
|
currentSignal = signal;
|
|
2335
|
+
currentAborts = abortReasonClassifier(signal);
|
|
2047
2336
|
controlPlaneError = undefined;
|
|
2048
2337
|
let runResult: PlaybookRunResult | undefined;
|
|
2049
2338
|
let operationError: unknown;
|
|
@@ -2070,13 +2359,56 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
2070
2359
|
} catch (error) {
|
|
2071
2360
|
drainError = error;
|
|
2072
2361
|
}
|
|
2073
|
-
const
|
|
2362
|
+
const aborts = currentAborts ?? abortReasonClassifier(signal);
|
|
2363
|
+
// The control latch has already classified its failure as distinct
|
|
2364
|
+
// under the operation that owned it. Only still-unclassified drain and
|
|
2365
|
+
// operation candidates may be cancellation evidence for this resume.
|
|
2366
|
+
const controlFailure = controlPlaneError;
|
|
2367
|
+
const drainAbort =
|
|
2368
|
+
controlFailure === undefined &&
|
|
2369
|
+
drainError !== undefined &&
|
|
2370
|
+
aborts.isAbortReason(drainError);
|
|
2371
|
+
const operationAbort =
|
|
2372
|
+
controlFailure === undefined &&
|
|
2373
|
+
operationError !== undefined &&
|
|
2374
|
+
aborts.isAbortReason(operationError);
|
|
2375
|
+
const abortEvidence =
|
|
2376
|
+
(drainAbort ? drainError : undefined) ??
|
|
2377
|
+
(operationAbort ? operationError : undefined);
|
|
2378
|
+
const failure =
|
|
2379
|
+
controlFailure ??
|
|
2380
|
+
(drainAbort ? undefined : drainError) ??
|
|
2381
|
+
(operationAbort ? undefined : operationError);
|
|
2074
2382
|
currentSignal = undefined;
|
|
2383
|
+
currentAborts = undefined;
|
|
2075
2384
|
currentTurnId = undefined;
|
|
2076
2385
|
controlPlaneError = undefined;
|
|
2077
2386
|
if (failure !== undefined) throw failure;
|
|
2387
|
+
if (
|
|
2388
|
+
abortEvidence !== undefined &&
|
|
2389
|
+
runResult?.outcome !== 'terminal' &&
|
|
2390
|
+
runResult?.outcome !== 'suspended'
|
|
2391
|
+
) {
|
|
2392
|
+
const state = currentState();
|
|
2393
|
+
runResult = {
|
|
2394
|
+
outcome: 'aborted',
|
|
2395
|
+
state,
|
|
2396
|
+
error: normalizeErrorFull(abortEvidence) ?? {
|
|
2397
|
+
name: 'AbortError',
|
|
2398
|
+
message: String(abortEvidence),
|
|
2399
|
+
},
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2078
2402
|
if (runResult === undefined) {
|
|
2079
|
-
|
|
2403
|
+
if (signal.aborted) {
|
|
2404
|
+
// Every candidate was the abort's own evidence: settle on the
|
|
2405
|
+
// machine's state under the aborted boundary signal (DR-036 §4).
|
|
2406
|
+
runResult = resultForSnapshot(signal);
|
|
2407
|
+
} else {
|
|
2408
|
+
throw new Error(
|
|
2409
|
+
'decide runtime: playbook resume produced no result',
|
|
2410
|
+
);
|
|
2411
|
+
}
|
|
2080
2412
|
}
|
|
2081
2413
|
return runResult;
|
|
2082
2414
|
},
|
|
@@ -2123,15 +2455,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
2123
2455
|
} catch (error) {
|
|
2124
2456
|
collectFailure(failures, error);
|
|
2125
2457
|
} finally {
|
|
2126
|
-
|
|
2458
|
+
privateResumeTokens.clear();
|
|
2127
2459
|
playbookCallTurnIds.clear();
|
|
2128
|
-
|
|
2460
|
+
inFlightPlayerKeys.clear();
|
|
2129
2461
|
activeBoundaryCalls.clear();
|
|
2130
2462
|
activeEmissionCalls.clear();
|
|
2131
2463
|
emissionQueue.clear();
|
|
2132
2464
|
judgeQueue.clear();
|
|
2133
2465
|
actor = undefined;
|
|
2134
2466
|
currentSignal = undefined;
|
|
2467
|
+
currentAborts = undefined;
|
|
2468
|
+
actorSettlementAborts.length = 0;
|
|
2469
|
+
actorSettlementErrorAborts = undefined;
|
|
2135
2470
|
currentTurnId = undefined;
|
|
2136
2471
|
ports = undefined;
|
|
2137
2472
|
sessionIdentity = undefined;
|
|
@@ -2146,12 +2481,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
|
|
|
2146
2481
|
})();
|
|
2147
2482
|
return disposalPromise;
|
|
2148
2483
|
},
|
|
2484
|
+
|
|
2485
|
+
// @internal — test-only parity with the shared factory's bridge escape
|
|
2486
|
+
// hatch. This is hidden by the PlaybookRuntime return type.
|
|
2487
|
+
_getNestedBridge() {
|
|
2488
|
+
return nestedBridge;
|
|
2489
|
+
},
|
|
2149
2490
|
};
|
|
2150
2491
|
};
|
|
2151
2492
|
|
|
2152
2493
|
export const _internal = {
|
|
2153
2494
|
composePlayerPrompt,
|
|
2154
|
-
resolvePlayerId,
|
|
2155
2495
|
requiredFieldsFor,
|
|
2156
2496
|
extractJson,
|
|
2157
2497
|
buildClassifierPrompt,
|
|
@@ -2160,12 +2500,12 @@ export const _internal = {
|
|
|
2160
2500
|
parseAdjudication,
|
|
2161
2501
|
combineSignals,
|
|
2162
2502
|
pendingQuestionsFromContext,
|
|
2503
|
+
pendingQuestionsForState,
|
|
2163
2504
|
normalizeErrorCompact,
|
|
2164
2505
|
normalizeErrorFull,
|
|
2165
|
-
DEFAULT_PLAYER_BINDING,
|
|
2166
2506
|
STATE_DESCRIPTIONS,
|
|
2167
|
-
|
|
2168
|
-
|
|
2507
|
+
ROLE_STATES,
|
|
2508
|
+
ROLE_STATE_IDS,
|
|
2169
2509
|
VERBATIM_PAYLOAD_FIELDS,
|
|
2170
2510
|
BOSS_INTERRUPT_TARGETS,
|
|
2171
2511
|
CONTINUATION_PREAMBLE,
|