@sublang/playbook 3.1.0 → 5.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 +64 -99
- package/docs/assets/playbook-venn.svg +13 -0
- package/docs/cli.md +83 -9
- package/docs/configuration.md +5 -3
- package/package.json +7 -4
- package/reference/sdlc/captain.md +70 -83
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
- package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
- package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
- package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
- package/reference/sdlc/captain.playbook/captain.playbook.js +464 -968
- package/reference/sdlc/captain.playbook/captain.playbook.ts +696 -993
- package/reference/sdlc/code.playbook/bin/adapter-sdk.js +247 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +54 -9
- package/reference/sdlc/code.playbook/bin/run.js +97 -0
- package/reference/sdlc/code.playbook/code.playbook.js +17 -0
- package/reference/sdlc/code.playbook/code.playbook.ts +17 -0
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +1784 -215
- package/reference/sdlc/code.playbook/playbook-captain.ts +2293 -330
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +7 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +41 -9
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +42 -9
- package/slc/gears2fsm.md +54 -2
- package/slc/link.md +293 -25
- package/src/runtime.d.ts +29 -1
- package/src/runtime.ts +47 -0
- package/src/xstate-playbook-runtime.d.ts +97 -5
- package/src/xstate-playbook-runtime.js +769 -29
- package/src/xstate-playbook-runtime.ts +962 -34
|
@@ -41,6 +41,9 @@ import type {
|
|
|
41
41
|
CaptainResult,
|
|
42
42
|
JsonValue,
|
|
43
43
|
PlaybookCallResult,
|
|
44
|
+
PlaybookControlAction,
|
|
45
|
+
PlaybookControlReceipt,
|
|
46
|
+
PlaybookControlView,
|
|
44
47
|
PlaybookPorts,
|
|
45
48
|
PlaybookRunResult,
|
|
46
49
|
PlaybookRuntime,
|
|
@@ -124,9 +127,24 @@ export interface RuntimeBoundaryCalls {
|
|
|
124
127
|
input: PlaybookCaptainInput,
|
|
125
128
|
prompt: string,
|
|
126
129
|
signal: AbortSignal,
|
|
130
|
+
callOptions?: XStateCaptainCallOptions,
|
|
127
131
|
): Promise<CaptainResult>;
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Presentation selection for one traced direct-Captain call
|
|
136
|
+
* (slc/link.md §Captain adjudication). `'visible'` (the default) is the
|
|
137
|
+
* workflow form: the port receives `{ visibility: 'visible', resume: false }`
|
|
138
|
+
* and the trace pair carries both members. `'hidden'` is the controller form
|
|
139
|
+
* (DR-029): the port receives `{ visibility: 'hidden', resume: false }`
|
|
140
|
+
* while the host's session-Captain wrapper owns the actual durable-conversation
|
|
141
|
+
* resume selection, so the trace pair carries `visibility: 'hidden'` and no
|
|
142
|
+
* `resume` member — the pinned token never enters runtime telemetry.
|
|
143
|
+
*/
|
|
144
|
+
export interface XStateCaptainCallOptions {
|
|
145
|
+
visibility?: 'visible' | 'hidden';
|
|
146
|
+
}
|
|
147
|
+
|
|
130
148
|
export interface ScheduledStatus {
|
|
131
149
|
message: string;
|
|
132
150
|
data?: JsonValue;
|
|
@@ -177,6 +195,37 @@ function isFsmResultFailure(error: unknown): boolean {
|
|
|
177
195
|
);
|
|
178
196
|
}
|
|
179
197
|
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// DR-028: both call boundaries treat an `ok` result whose `finalText` is
|
|
200
|
+
// missing, empty, or whitespace-only under one empty predicate, and that
|
|
201
|
+
// shape earns exactly one corrective re-ask — the same composed call
|
|
202
|
+
// re-issued once through the same boundary — before a second such result
|
|
203
|
+
// follows the existing failure path. The retry marker distinguishes the
|
|
204
|
+
// re-askable empty-`ok` Captain failure from the never-retried non-`ok`
|
|
205
|
+
// statuses; it is applied only when the failure's finish trace emitted
|
|
206
|
+
// cleanly, because a rejecting finish sink is a control-plane error whose
|
|
207
|
+
// turn gets no corrective re-ask (PBRT-47).
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
function isEmptyFinalText(finalText: string | undefined): boolean {
|
|
211
|
+
return finalText === undefined || finalText.trim().length === 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const emptyOkRetryFailures = new WeakSet<object>();
|
|
215
|
+
|
|
216
|
+
function markEmptyOkRetryFailure(error: Error): Error {
|
|
217
|
+
emptyOkRetryFailures.add(error);
|
|
218
|
+
return error;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isEmptyOkRetryFailure(error: unknown): boolean {
|
|
222
|
+
return (
|
|
223
|
+
typeof error === 'object' &&
|
|
224
|
+
error !== null &&
|
|
225
|
+
emptyOkRetryFailures.has(error as object)
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
180
229
|
// ---------------------------------------------------------------------------
|
|
181
230
|
// DR-022: the engine's compatibility self-report. A linked thin module
|
|
182
231
|
// records the values current at link time in `spec.compat`; the factory
|
|
@@ -248,6 +297,53 @@ function assertRuntimeCompat(
|
|
|
248
297
|
// to preserve their existing observable behavior exactly.
|
|
249
298
|
// ---------------------------------------------------------------------------
|
|
250
299
|
|
|
300
|
+
/**
|
|
301
|
+
* One direct-Captain actor invocation handed to a spec's `captainStrategy`
|
|
302
|
+
* (slc/link.md §Captain adjudication, controller form). The engine owns
|
|
303
|
+
* signal combination, emission draining, trace pairing, the shared
|
|
304
|
+
* Captain/judge lane, and control-plane latching; the strategy owns the
|
|
305
|
+
* playbook-specific call pipeline — e.g. the controller's hidden decision
|
|
306
|
+
* call, `{ action, … }` control-JSON validation with its single corrective
|
|
307
|
+
* re-ask, and controller-port submission.
|
|
308
|
+
*/
|
|
309
|
+
export interface XStateCaptainStrategyRun<TOptions> {
|
|
310
|
+
input: PlaybookCaptainInput;
|
|
311
|
+
/** The prompt composed by the spec's Captain composer for `input`. */
|
|
312
|
+
prompt: string;
|
|
313
|
+
/** Combined invocation-lifetime + active-boundary abort signal. */
|
|
314
|
+
signal: AbortSignal;
|
|
315
|
+
/** The immutable validated runtime options. */
|
|
316
|
+
options: TOptions;
|
|
317
|
+
/** The bound immutable playbook session identity. */
|
|
318
|
+
session: PlaybookSession;
|
|
319
|
+
/**
|
|
320
|
+
* One traced Captain call through the shared serialized lane; every call —
|
|
321
|
+
* initial or corrective — emits its own paired `captain.call.started` /
|
|
322
|
+
* `captain.call.finished` boundary. Throws the boundary's authoritative
|
|
323
|
+
* failure for non-`ok` and empty-`ok` results exactly as the default
|
|
324
|
+
* pipeline does.
|
|
325
|
+
*/
|
|
326
|
+
callCaptain(
|
|
327
|
+
prompt: string,
|
|
328
|
+
callOptions?: XStateCaptainCallOptions,
|
|
329
|
+
): Promise<CaptainResult>;
|
|
330
|
+
/**
|
|
331
|
+
* DR-028: true when `error` is the boundary's re-askable empty-`ok`
|
|
332
|
+
* marker; the strategy may re-issue the same composed call exactly once.
|
|
333
|
+
*/
|
|
334
|
+
isEmptyOkRetry(error: unknown): boolean;
|
|
335
|
+
/**
|
|
336
|
+
* Mark `error` as a recoverable FSM-result failure: it travels the invoked
|
|
337
|
+
* actor's XState `onError` path without being latched as a control-plane
|
|
338
|
+
* error, so the machine's authored recovery arms can route it.
|
|
339
|
+
*/
|
|
340
|
+
recoverableFailure<E extends Error>(error: E): E;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export type XStateCaptainStrategy<TOptions> = (
|
|
344
|
+
run: XStateCaptainStrategyRun<TOptions>,
|
|
345
|
+
) => Promise<PlaybookActorOutput>;
|
|
346
|
+
|
|
251
347
|
export interface XStatePlaybookRuntimeSpec<TOptions> {
|
|
252
348
|
/** Diagnostic label used in internal invariant errors. Default 'playbook'. */
|
|
253
349
|
label?: string;
|
|
@@ -276,14 +372,25 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
|
|
|
276
372
|
* the XState machine alone.
|
|
277
373
|
*/
|
|
278
374
|
bossEvents?: readonly XStateBossEventSpec[];
|
|
279
|
-
/** Boss-input classifier override; default: generic parked-state classifier. */
|
|
375
|
+
/** Boss-input classifier override; default: generic parked-state classifier. Receives the bound validated options last so a fully deterministic controller mapping can consult host-supplied option members (slc/link.md §Boss-event mapping). */
|
|
280
376
|
classifyBossText?: (
|
|
281
377
|
text: string,
|
|
282
378
|
ports: PlaybookPorts,
|
|
283
379
|
signal: AbortSignal,
|
|
284
380
|
snapshotOrState: unknown,
|
|
285
381
|
boundary?: RuntimeBoundaryCalls,
|
|
382
|
+
options?: TOptions,
|
|
286
383
|
) => Promise<EventObject | undefined>;
|
|
384
|
+
/**
|
|
385
|
+
* Direct-Captain actor strategy override (slc/link.md §Captain
|
|
386
|
+
* adjudication, controller form): replaces the default visible-call +
|
|
387
|
+
* hidden-judge pipeline for every `captain` state of this machine. The
|
|
388
|
+
* engine still composes the prompt, combines signals, traces each call as
|
|
389
|
+
* its own pair, and latches control-plane errors; failures the strategy
|
|
390
|
+
* marks with `recoverableFailure` travel the actor's `onError` path as
|
|
391
|
+
* recoverable FSM-result failures instead.
|
|
392
|
+
*/
|
|
393
|
+
captainStrategy?: XStateCaptainStrategy<TOptions>;
|
|
287
394
|
/** Status line emitted after classification names an event. Default: none. */
|
|
288
395
|
classificationStatus?: (event: EventObject) => string | undefined;
|
|
289
396
|
/** Map a player-invoking state's input to the host player id. Default: lowercased player name. */
|
|
@@ -300,6 +407,18 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
|
|
|
300
407
|
extractRequiredFields?: (description: string) => string[];
|
|
301
408
|
/** Required fields carried verbatim from the player's finalText instead of judge JSON. Default: none. */
|
|
302
409
|
verbatimPayloadFields?: ReadonlySet<string>;
|
|
410
|
+
/**
|
|
411
|
+
* DR-029 / PBRT-52: the runtime-authored ControlView context
|
|
412
|
+
* projection — the exact FSM context members `describe()` may expose,
|
|
413
|
+
* in the order the view lists them. Only this artifact knows which of
|
|
414
|
+
* its context members are safe and relevant for a controller prompt, so
|
|
415
|
+
* the engine exports what is named here and nothing else: a member the
|
|
416
|
+
* artifact has not named stays private, and a member added to the FSM
|
|
417
|
+
* later stays private until someone names it. Absent or empty: the view
|
|
418
|
+
* carries no context at all. `pendingBossQuestion` and `lastError` are
|
|
419
|
+
* surfaced first-class by the view and shall not be named here.
|
|
420
|
+
*/
|
|
421
|
+
controlContextFields?: readonly string[];
|
|
303
422
|
/** States that may suspend for a Boss reply. Default: targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
|
|
304
423
|
resumableStateIds?: ReadonlySet<string>;
|
|
305
424
|
/** Human status lines for a root transition. Default: entry lines with question/failure surfacing. */
|
|
@@ -730,8 +849,10 @@ function validateBossReplyOutput(
|
|
|
730
849
|
// ---------------------------------------------------------------------------
|
|
731
850
|
// Delegated-player actor bridge. One PromiseActorLogic the machine invokes
|
|
732
851
|
// from every player-invoking state: resolve the playerId, compose the prompt,
|
|
733
|
-
// await callPlayer, adjudicate the finalText.
|
|
734
|
-
//
|
|
852
|
+
// await callPlayer, adjudicate the finalText. An `ok` result with a missing,
|
|
853
|
+
// empty, or whitespace-only finalText earns exactly one corrective re-ask of
|
|
854
|
+
// the same composed call (DR-028); a non-`ok` result, or a second such empty
|
|
855
|
+
// result, throws so XState routes via onError to the FSM's failure sink.
|
|
735
856
|
//
|
|
736
857
|
// `getActiveSignal` flows the Boss's public-boundary signal into the host
|
|
737
858
|
// port calls — fromPromise hands the bridge XState's actor-scoped signal,
|
|
@@ -757,17 +878,40 @@ export function createPlayerBridge(
|
|
|
757
878
|
const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
|
|
758
879
|
const playerId = spec.resolvePlayerId(input);
|
|
759
880
|
const prompt = spec.composePlayerPrompt(input);
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
881
|
+
const callPlayer = (resume: string | false) =>
|
|
882
|
+
boundary
|
|
883
|
+
? boundary.callPlayer(input, playerId, prompt, activeSignal)
|
|
884
|
+
: ports.callPlayer(playerId, prompt, activeSignal, { resume });
|
|
885
|
+
let result = await callPlayer(false);
|
|
886
|
+
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
887
|
+
// An abort that lands between the empty first result and the
|
|
888
|
+
// corrective call ends the turn as ordinary abort settlement with
|
|
889
|
+
// no second host call — aborts are never retried (DR-028 via
|
|
890
|
+
// DR-025's transport exclusion) — matching the direct-Captain
|
|
891
|
+
// boundary, whose queued corrective call re-checks the signal
|
|
892
|
+
// before starting.
|
|
893
|
+
activeSignal.throwIfAborted();
|
|
894
|
+
// DR-028: exactly one corrective re-ask of the same composed call
|
|
895
|
+
// through the same path, traced by the boundary as its own
|
|
896
|
+
// player-call pair. The traced boundary re-reads its token map
|
|
897
|
+
// (PBRT-38), so the corrective call continues the player session
|
|
898
|
+
// when the first result carried a resume token and starts fresh
|
|
899
|
+
// when it cleared one; the portless verification path mirrors that
|
|
900
|
+
// by carrying the first result's token.
|
|
901
|
+
result = await callPlayer(
|
|
902
|
+
typeof result.resumeToken === 'string' &&
|
|
903
|
+
result.resumeToken.trim().length > 0
|
|
904
|
+
? result.resumeToken
|
|
905
|
+
: false,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
765
908
|
if (result.status !== 'ok') {
|
|
766
909
|
throw new Error(
|
|
767
910
|
result.error ?? `captainBridge: callPlayer status "${result.status}"`,
|
|
768
911
|
);
|
|
769
912
|
}
|
|
770
|
-
|
|
913
|
+
const finalText = result.finalText ?? '';
|
|
914
|
+
if (isEmptyFinalText(finalText)) {
|
|
771
915
|
throw new Error(
|
|
772
916
|
'captainBridge: callPlayer returned status=ok with no finalText',
|
|
773
917
|
);
|
|
@@ -776,7 +920,7 @@ export function createPlayerBridge(
|
|
|
776
920
|
const output = await adjudicatePlayerOutput(
|
|
777
921
|
spec.adjudication,
|
|
778
922
|
input,
|
|
779
|
-
|
|
923
|
+
finalText,
|
|
780
924
|
ports,
|
|
781
925
|
activeSignal,
|
|
782
926
|
boundary,
|
|
@@ -799,8 +943,17 @@ export function createPlayerBridge(
|
|
|
799
943
|
// field as an undeclared extra key.
|
|
800
944
|
// ---------------------------------------------------------------------------
|
|
801
945
|
|
|
802
|
-
|
|
803
|
-
|
|
946
|
+
/**
|
|
947
|
+
* Default direct-Captain adjudicator prompt (DR-025). The single statement of
|
|
948
|
+
* the `{ guard, …structuralPayloadFields }` reply contract, shared with the
|
|
949
|
+
* compiled default Captain artifact so the wording cannot drift.
|
|
950
|
+
*/
|
|
951
|
+
export function defaultBuildCaptainJudgePrompt(
|
|
952
|
+
input: {
|
|
953
|
+
readonly stateId: string;
|
|
954
|
+
readonly sourceItem: string;
|
|
955
|
+
readonly result: Readonly<Record<string, string>>;
|
|
956
|
+
},
|
|
804
957
|
finalText: string,
|
|
805
958
|
): string {
|
|
806
959
|
const lines: string[] = [];
|
|
@@ -932,6 +1085,98 @@ export function resumableStateIdsFromMachine(
|
|
|
932
1085
|
return new Set(transitionTargets(bossReply));
|
|
933
1086
|
}
|
|
934
1087
|
|
|
1088
|
+
// ---------------------------------------------------------------------------
|
|
1089
|
+
// DR-029 control surface: the FSM's explicit-state-jump event and the
|
|
1090
|
+
// source state descriptions that label runtime-advertised actions.
|
|
1091
|
+
// ---------------------------------------------------------------------------
|
|
1092
|
+
|
|
1093
|
+
/** The FSM's explicit-state-jump event type (slc/link.md §Boss-event mapping). */
|
|
1094
|
+
const JUMP_EVENT_TYPE = 'BOSS_INTERRUPT';
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Source state descriptions by state key, node id, and `meta.playbook`
|
|
1098
|
+
* state id, read from `machine.config`. Control actions are labeled from
|
|
1099
|
+
* these descriptions (DR-029); a state without one has no entry.
|
|
1100
|
+
*/
|
|
1101
|
+
export function stateDescriptionsFromMachine(
|
|
1102
|
+
machine: AnyStateMachine,
|
|
1103
|
+
): ReadonlyMap<string, string> {
|
|
1104
|
+
const descriptions = new Map<string, string>();
|
|
1105
|
+
const record = (key: unknown, description: string): void => {
|
|
1106
|
+
if (typeof key !== 'string' || key.length === 0) return;
|
|
1107
|
+
if (!descriptions.has(key)) descriptions.set(key, description);
|
|
1108
|
+
};
|
|
1109
|
+
const visit = (key: string, stateDef: unknown): void => {
|
|
1110
|
+
if (!isPlainObject(stateDef)) return;
|
|
1111
|
+
const playbook = isPlainObject(stateDef.meta)
|
|
1112
|
+
? (stateDef.meta as Record<string, unknown>).playbook
|
|
1113
|
+
: undefined;
|
|
1114
|
+
const description =
|
|
1115
|
+
isPlainObject(playbook) && typeof playbook.description === 'string'
|
|
1116
|
+
? playbook.description
|
|
1117
|
+
: typeof stateDef.description === 'string'
|
|
1118
|
+
? stateDef.description
|
|
1119
|
+
: undefined;
|
|
1120
|
+
if (description !== undefined && description.length > 0) {
|
|
1121
|
+
record(key, description);
|
|
1122
|
+
record(stateDef.id, description);
|
|
1123
|
+
if (isPlainObject(playbook)) record(playbook.stateId, description);
|
|
1124
|
+
}
|
|
1125
|
+
if (isPlainObject(stateDef.states)) {
|
|
1126
|
+
for (const [childKey, child] of Object.entries(stateDef.states)) {
|
|
1127
|
+
visit(childKey, child);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
const config = (machine as unknown as { config?: unknown }).config;
|
|
1132
|
+
if (isPlainObject(config) && isPlainObject(config.states)) {
|
|
1133
|
+
for (const [key, stateDef] of Object.entries(config.states)) {
|
|
1134
|
+
visit(key, stateDef);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return descriptions;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* First configured target of `eventType` from the state with `stateId`,
|
|
1142
|
+
* falling back to the machine root's own transitions. Used only to pick the
|
|
1143
|
+
* source description that labels a retry action, and only for events that
|
|
1144
|
+
* carry no recorded `targetId`: a guarded multi-arm list keyed on the
|
|
1145
|
+
* event's `targetId` (the root `BOSS_INTERRUPT` shape) resumes the recorded
|
|
1146
|
+
* target, not the first configured arm, so the recorded event outranks this
|
|
1147
|
+
* fallback.
|
|
1148
|
+
*/
|
|
1149
|
+
function firstTransitionTarget(
|
|
1150
|
+
machine: AnyStateMachine,
|
|
1151
|
+
stateId: string | undefined,
|
|
1152
|
+
eventType: string,
|
|
1153
|
+
): string | undefined {
|
|
1154
|
+
const config = (machine as unknown as { config?: unknown }).config;
|
|
1155
|
+
if (!isPlainObject(config)) return undefined;
|
|
1156
|
+
const candidates: unknown[] = [];
|
|
1157
|
+
if (stateId !== undefined && isPlainObject(config.states)) {
|
|
1158
|
+
const state = config.states[stateId];
|
|
1159
|
+
if (isPlainObject(state) && isPlainObject(state.on)) {
|
|
1160
|
+
candidates.push(state.on[eventType]);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if (isPlainObject(config.on)) candidates.push(config.on[eventType]);
|
|
1164
|
+
for (const candidate of candidates) {
|
|
1165
|
+
if (candidate === undefined) continue;
|
|
1166
|
+
const targets = transitionTargets(candidate);
|
|
1167
|
+
if (targets.length > 0) return targets[0];
|
|
1168
|
+
}
|
|
1169
|
+
return undefined;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function deepFreeze<T>(value: T): T {
|
|
1173
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
1174
|
+
Object.freeze(value);
|
|
1175
|
+
for (const member of Object.values(value)) deepFreeze(member);
|
|
1176
|
+
}
|
|
1177
|
+
return value;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
935
1180
|
// ---------------------------------------------------------------------------
|
|
936
1181
|
// Default transition/status derivation.
|
|
937
1182
|
// ---------------------------------------------------------------------------
|
|
@@ -1390,6 +1635,16 @@ interface TracePosition {
|
|
|
1390
1635
|
callId?: string;
|
|
1391
1636
|
}
|
|
1392
1637
|
|
|
1638
|
+
function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
|
|
1639
|
+
const visit = (stateDef: unknown): boolean => {
|
|
1640
|
+
if (!isPlainObject(stateDef)) return false;
|
|
1641
|
+
if (stateDef.type === 'parallel') return true;
|
|
1642
|
+
if (!isPlainObject(stateDef.states)) return false;
|
|
1643
|
+
return Object.values(stateDef.states).some(visit);
|
|
1644
|
+
};
|
|
1645
|
+
return visit((machine as unknown as { config?: unknown }).config);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1393
1648
|
/**
|
|
1394
1649
|
* Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
|
|
1395
1650
|
* under the slc/link.md contract. The factory provides every actor kind the
|
|
@@ -1397,8 +1652,9 @@ interface TracePosition {
|
|
|
1397
1652
|
* (literal and dynamic) — and implements the full runtime lifecycle including
|
|
1398
1653
|
* the optional parked-session snapshot capability (DR-014).
|
|
1399
1654
|
*
|
|
1400
|
-
* Scope:
|
|
1401
|
-
* playbook state id). Parallel-region FSMs keep their own linked
|
|
1655
|
+
* Scope: machines that declare no parallel state (each snapshot exposes
|
|
1656
|
+
* exactly one playbook state id). Parallel-region FSMs keep their own linked
|
|
1657
|
+
* runtimes.
|
|
1402
1658
|
*/
|
|
1403
1659
|
export function createXStatePlaybookRuntime<TOptions>(
|
|
1404
1660
|
machine: AnyStateMachine,
|
|
@@ -1408,9 +1664,33 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1408
1664
|
// DR-022 / PBRT-50: reject an incompatible artifact declaration before any
|
|
1409
1665
|
// machine interpretation, against this loaded engine's own self-report.
|
|
1410
1666
|
assertRuntimeCompat(spec.compat, label);
|
|
1667
|
+
if (machineDeclaresParallelState(machine)) {
|
|
1668
|
+
throw new Error(
|
|
1669
|
+
`${label} uses a parallel state; the shared runtime supports only single-region FSMs`,
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1411
1672
|
const declaredActors = collectInvokeSources(machine);
|
|
1412
1673
|
const resumableStateIds =
|
|
1413
1674
|
spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
|
|
1675
|
+
// DR-029: source state descriptions label the control actions the
|
|
1676
|
+
// runtime advertises through `describe()`.
|
|
1677
|
+
const stateDescriptions = stateDescriptionsFromMachine(machine);
|
|
1678
|
+
// PBRT-52: the artifact's own ControlView context projection. Nothing is
|
|
1679
|
+
// exported by default, so an FSM context member — including one added
|
|
1680
|
+
// after this artifact was linked — is private until named here. The two
|
|
1681
|
+
// members the view surfaces first-class are rejected at construction
|
|
1682
|
+
// rather than silently ignored, so an artifact cannot believe it is
|
|
1683
|
+
// exporting them through this list.
|
|
1684
|
+
const controlContextFields: readonly string[] = spec.controlContextFields
|
|
1685
|
+
? [...spec.controlContextFields]
|
|
1686
|
+
: [];
|
|
1687
|
+
for (const field of controlContextFields) {
|
|
1688
|
+
if (field === 'pendingBossQuestion' || field === 'lastError') {
|
|
1689
|
+
throw new Error(
|
|
1690
|
+
`${label} controlContextFields must not name ${field}: the control view surfaces it first-class`,
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1414
1694
|
const resolvePlayerIdSpec = spec.resolvePlayerId;
|
|
1415
1695
|
const composePlayerPrompt =
|
|
1416
1696
|
spec.composePlayerPrompt ??
|
|
@@ -1442,7 +1722,9 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1442
1722
|
spec.entryEvent,
|
|
1443
1723
|
spec.bossEvents ?? [],
|
|
1444
1724
|
);
|
|
1445
|
-
const classifyBossText
|
|
1725
|
+
const classifyBossText: NonNullable<
|
|
1726
|
+
XStatePlaybookRuntimeSpec<TOptions>['classifyBossText']
|
|
1727
|
+
> = spec.classifyBossText ?? derivedClassifyBossText;
|
|
1446
1728
|
const normalizeTransitionEvent =
|
|
1447
1729
|
spec.normalizeTransitionEvent ??
|
|
1448
1730
|
makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
|
|
@@ -1484,6 +1766,19 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1484
1766
|
let playerCallSequence = 0;
|
|
1485
1767
|
let playbookCallSequence = 0;
|
|
1486
1768
|
let captainCallSequence = 0;
|
|
1769
|
+
let applyCallSequence = 0;
|
|
1770
|
+
// DR-029: the last event a public Boss boundary sent into the
|
|
1771
|
+
// machine — classified, deterministic entry, or Boss reply — kept with
|
|
1772
|
+
// its recorded payload so a failure-state retry action can replay the
|
|
1773
|
+
// event that drove the run into `failed`. Process-local: the schema-1
|
|
1774
|
+
// parked snapshot does not persist it (PBRT-50: no schema bump).
|
|
1775
|
+
let lastBossEvent: EventObject | undefined;
|
|
1776
|
+
// DR-029: process-local at-most-once `apply` execution — the accepted receipt
|
|
1777
|
+
// recorded for each idempotency key, returned verbatim on a repeated
|
|
1778
|
+
// key. A key whose call settled `rejected` or threw before reaching
|
|
1779
|
+
// acceptance records nothing, so a later call with that key may still
|
|
1780
|
+
// execute.
|
|
1781
|
+
const appliedReceipts = new Map<string, PlaybookControlReceipt>();
|
|
1487
1782
|
const playerResumeTokens = new Map<string, string>();
|
|
1488
1783
|
const activePlayerIds = new Set<string>();
|
|
1489
1784
|
const playbookCallTurnIds = new Map<string, number | undefined>();
|
|
@@ -1663,13 +1958,22 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1663
1958
|
startedType:
|
|
1664
1959
|
| 'player.call.started'
|
|
1665
1960
|
| 'judge.call.started'
|
|
1666
|
-
| 'captain.call.started'
|
|
1961
|
+
| 'captain.call.started'
|
|
1962
|
+
| 'apply.started',
|
|
1667
1963
|
finishedType:
|
|
1668
1964
|
| 'player.call.finished'
|
|
1669
1965
|
| 'judge.call.finished'
|
|
1670
|
-
| 'captain.call.finished'
|
|
1966
|
+
| 'captain.call.finished'
|
|
1967
|
+
| 'apply.finished',
|
|
1671
1968
|
identity: Record<string, unknown>,
|
|
1672
1969
|
position: TracePosition,
|
|
1970
|
+
// Base payload of the best-effort finish emitted when the start sink
|
|
1971
|
+
// rejects; it defaults to the payload the start carried, which the
|
|
1972
|
+
// player, judge, and captain pairs take as-is. The apply pair cannot:
|
|
1973
|
+
// its finish carries the receipt disposition and none of the
|
|
1974
|
+
// start-only fields, so it passes its own canonical pre-acceptance
|
|
1975
|
+
// base (slc/link.md §Playbook trace).
|
|
1976
|
+
finishIdentity: Record<string, unknown> = identity,
|
|
1673
1977
|
): Promise<void> {
|
|
1674
1978
|
try {
|
|
1675
1979
|
await emitTrace(startedType, identity, position);
|
|
@@ -1678,7 +1982,11 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1678
1982
|
try {
|
|
1679
1983
|
await emitTrace(
|
|
1680
1984
|
finishedType,
|
|
1681
|
-
{
|
|
1985
|
+
{
|
|
1986
|
+
...finishIdentity,
|
|
1987
|
+
status: 'error',
|
|
1988
|
+
error: normalizeError(error),
|
|
1989
|
+
},
|
|
1682
1990
|
position,
|
|
1683
1991
|
);
|
|
1684
1992
|
} catch {
|
|
@@ -1736,6 +2044,11 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1736
2044
|
|
|
1737
2045
|
let rawResult: unknown;
|
|
1738
2046
|
try {
|
|
2047
|
+
// An abort may land while the awaited started emission drains
|
|
2048
|
+
// (e.g. fired from the trace sink itself); the host call must
|
|
2049
|
+
// never start after abort, so settle the already-started pair
|
|
2050
|
+
// as `aborted` through the catch below.
|
|
2051
|
+
signal.throwIfAborted();
|
|
1739
2052
|
rawResult = await requireHostPorts().callPlayer(
|
|
1740
2053
|
playerId,
|
|
1741
2054
|
prompt,
|
|
@@ -1837,6 +2150,11 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1837
2150
|
);
|
|
1838
2151
|
let reply: unknown;
|
|
1839
2152
|
try {
|
|
2153
|
+
// An abort may land while the awaited started emission drains
|
|
2154
|
+
// (e.g. fired from the trace sink itself); the host call must
|
|
2155
|
+
// never start after abort, so settle the already-started pair
|
|
2156
|
+
// as `aborted` through the catch below.
|
|
2157
|
+
signal.throwIfAborted();
|
|
1840
2158
|
reply = await requireHostPorts().callJudge(prompt, signal);
|
|
1841
2159
|
signal.throwIfAborted();
|
|
1842
2160
|
} catch (error) {
|
|
@@ -1880,18 +2198,23 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1880
2198
|
}) as Promise<string>;
|
|
1881
2199
|
},
|
|
1882
2200
|
|
|
1883
|
-
async callCaptain(input, prompt, signal): Promise<CaptainResult> {
|
|
2201
|
+
async callCaptain(input, prompt, signal, callOptions): Promise<CaptainResult> {
|
|
1884
2202
|
return judgeQueue.add(async () => {
|
|
1885
2203
|
signal.throwIfAborted();
|
|
1886
2204
|
await drainEmissions();
|
|
1887
2205
|
signal.throwIfAborted();
|
|
1888
2206
|
const turnId = activeTurnId;
|
|
1889
2207
|
const callId = `captain-${++captainCallSequence}`;
|
|
2208
|
+
const visibility = callOptions?.visibility ?? 'visible';
|
|
1890
2209
|
const identity = {
|
|
1891
2210
|
...stateIdentity(input.stateId),
|
|
1892
2211
|
sourceItem: input.sourceItem,
|
|
1893
|
-
visibility
|
|
1894
|
-
resume: false
|
|
2212
|
+
visibility,
|
|
2213
|
+
// The visible workflow form owns its `resume: false` selection;
|
|
2214
|
+
// a hidden controller call's durable-conversation resume
|
|
2215
|
+
// selection is host-owned (DR-029), so its trace pair carries
|
|
2216
|
+
// no resume member and no token.
|
|
2217
|
+
...(visibility === 'visible' ? { resume: false as const } : {}),
|
|
1895
2218
|
...(input.allowedTools === undefined
|
|
1896
2219
|
? {}
|
|
1897
2220
|
: { allowedTools: [...input.allowedTools] }),
|
|
@@ -1909,8 +2232,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1909
2232
|
);
|
|
1910
2233
|
let rawResult: unknown;
|
|
1911
2234
|
try {
|
|
2235
|
+
// An abort may land while the awaited started emission drains
|
|
2236
|
+
// (e.g. fired from the trace sink itself); the host call must
|
|
2237
|
+
// never start after abort, so settle the already-started pair
|
|
2238
|
+
// as `aborted` through the catch below.
|
|
2239
|
+
signal.throwIfAborted();
|
|
1912
2240
|
rawResult = await requireHostPorts().callCaptain(prompt, signal, {
|
|
1913
|
-
visibility
|
|
2241
|
+
visibility,
|
|
1914
2242
|
resume: false,
|
|
1915
2243
|
...(input.allowedTools !== undefined
|
|
1916
2244
|
? { allowedTools: input.allowedTools }
|
|
@@ -1947,6 +2275,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1947
2275
|
// authoritative for the actor's error path even when the required
|
|
1948
2276
|
// finish emission fails or a coincident boundary abort lands.
|
|
1949
2277
|
let resultFailure: Error | undefined;
|
|
2278
|
+
let emptyOkRetry = false;
|
|
1950
2279
|
if (result.status !== 'ok') {
|
|
1951
2280
|
resultFailure = markFsmResultFailure(
|
|
1952
2281
|
new Error(
|
|
@@ -1954,12 +2283,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1954
2283
|
`captainActor: callCaptain status "${result.status}"`,
|
|
1955
2284
|
),
|
|
1956
2285
|
);
|
|
1957
|
-
} else if (result.finalText
|
|
2286
|
+
} else if (isEmptyFinalText(result.finalText)) {
|
|
1958
2287
|
resultFailure = markFsmResultFailure(
|
|
1959
2288
|
new Error(
|
|
1960
2289
|
'captainActor: callCaptain returned status=ok with no finalText',
|
|
1961
2290
|
),
|
|
1962
2291
|
);
|
|
2292
|
+
emptyOkRetry = true;
|
|
1963
2293
|
}
|
|
1964
2294
|
try {
|
|
1965
2295
|
await emitTrace(
|
|
@@ -1981,12 +2311,17 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1981
2311
|
} catch (error) {
|
|
1982
2312
|
// Keep the finish-sink failure in the emission queue for public
|
|
1983
2313
|
// cleanup evidence, but do not replace an authoritative result
|
|
1984
|
-
// failure on the invoked actor's XState onError path.
|
|
2314
|
+
// failure on the invoked actor's XState onError path. A failure
|
|
2315
|
+
// thrown here is never marked re-askable: a rejecting finish
|
|
2316
|
+
// sink stays a control-plane error with no corrective re-ask
|
|
2317
|
+
// (PBRT-47).
|
|
1985
2318
|
if (resultFailure !== undefined) throw resultFailure;
|
|
1986
2319
|
throw error;
|
|
1987
2320
|
}
|
|
1988
2321
|
if (resultFailure !== undefined) {
|
|
1989
|
-
throw
|
|
2322
|
+
throw emptyOkRetry
|
|
2323
|
+
? markEmptyOkRetryFailure(resultFailure)
|
|
2324
|
+
: resultFailure;
|
|
1990
2325
|
}
|
|
1991
2326
|
return result;
|
|
1992
2327
|
}) as Promise<CaptainResult>;
|
|
@@ -2032,19 +2367,53 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2032
2367
|
try {
|
|
2033
2368
|
await drainEmissions();
|
|
2034
2369
|
const prompt = composeCaptainPrompt(input);
|
|
2035
|
-
|
|
2370
|
+
if (spec.captainStrategy !== undefined) {
|
|
2371
|
+
// Controller form (slc/link.md §Captain adjudication): the
|
|
2372
|
+
// spec's strategy owns the call pipeline; the engine still
|
|
2373
|
+
// owns tracing, the shared lane, signal combination, and the
|
|
2374
|
+
// control-plane latch in the catch below.
|
|
2375
|
+
const output = await spec.captainStrategy({
|
|
2376
|
+
input,
|
|
2377
|
+
prompt,
|
|
2378
|
+
signal: active,
|
|
2379
|
+
options: boundOptions,
|
|
2380
|
+
session: requireSession(),
|
|
2381
|
+
callCaptain: (callPrompt, callOptions) =>
|
|
2382
|
+
boundary.callCaptain!(input, callPrompt, active, callOptions),
|
|
2383
|
+
isEmptyOkRetry: isEmptyOkRetryFailure,
|
|
2384
|
+
recoverableFailure: <E extends Error>(error: E): E => {
|
|
2385
|
+
markFsmResultFailure(error);
|
|
2386
|
+
return error;
|
|
2387
|
+
},
|
|
2388
|
+
});
|
|
2389
|
+
validateBossReplyOutput(input, output, resumableStateIds);
|
|
2390
|
+
return output;
|
|
2391
|
+
}
|
|
2392
|
+
let result: CaptainResult;
|
|
2393
|
+
try {
|
|
2394
|
+
result = await boundary.callCaptain!(input, prompt, active);
|
|
2395
|
+
} catch (error) {
|
|
2396
|
+
if (!isEmptyOkRetryFailure(error)) throw error;
|
|
2397
|
+
// DR-028: exactly one corrective re-ask of the same composed
|
|
2398
|
+
// call through the same boundary, traced as its own
|
|
2399
|
+
// started/finished pair, its result read under the unchanged
|
|
2400
|
+
// rules — a second empty `ok` result throws from the boundary
|
|
2401
|
+
// exactly as the first did, with no further re-ask.
|
|
2402
|
+
result = await boundary.callCaptain!(input, prompt, active);
|
|
2403
|
+
}
|
|
2036
2404
|
// The boundary owns result validation (PBRT-47) and throws the
|
|
2037
2405
|
// authoritative failure itself, so a returned result is always
|
|
2038
2406
|
// `ok` with visible text. Assert that invariant rather than
|
|
2039
2407
|
// restating the failure semantics, which would drift.
|
|
2040
|
-
|
|
2408
|
+
const finalText = result.finalText ?? '';
|
|
2409
|
+
if (result.status !== 'ok' || isEmptyFinalText(finalText)) {
|
|
2041
2410
|
throw new Error(
|
|
2042
2411
|
'captainActor: boundary returned an unvalidated Captain result',
|
|
2043
2412
|
);
|
|
2044
2413
|
}
|
|
2045
|
-
const judgePrompt =
|
|
2414
|
+
const judgePrompt = defaultBuildCaptainJudgePrompt(
|
|
2046
2415
|
input,
|
|
2047
|
-
|
|
2416
|
+
finalText,
|
|
2048
2417
|
);
|
|
2049
2418
|
const raw = await boundary.callJudge(
|
|
2050
2419
|
'captain-output-adjudication',
|
|
@@ -2055,7 +2424,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2055
2424
|
const output = adjudicateCaptainOutput(
|
|
2056
2425
|
extractFields,
|
|
2057
2426
|
input,
|
|
2058
|
-
|
|
2427
|
+
finalText,
|
|
2059
2428
|
raw,
|
|
2060
2429
|
);
|
|
2061
2430
|
validateBossReplyOutput(input, output, resumableStateIds);
|
|
@@ -2275,6 +2644,20 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2275
2644
|
else emissionFailure ??= error;
|
|
2276
2645
|
}
|
|
2277
2646
|
|
|
2647
|
+
// PBRT-6: the single seam that stops this runtime's actor. Stopping a
|
|
2648
|
+
// still-running actor fires one more `@xstate.snapshot` for the
|
|
2649
|
+
// *unchanged* state value with `status: 'stopped'`, which the inspect
|
|
2650
|
+
// callback cannot distinguish from a state entry — unsuppressed it
|
|
2651
|
+
// re-emits the parked state's statuses and a phantom self-loop
|
|
2652
|
+
// transition. Suppression is a property of stopping, not a rule each
|
|
2653
|
+
// caller must remember, so every stop goes through here; a caller that
|
|
2654
|
+
// builds a replacement actor clears the flag before starting it.
|
|
2655
|
+
function stopActor(): void {
|
|
2656
|
+
if (!actor) return;
|
|
2657
|
+
suppressInspectionEmissions = true;
|
|
2658
|
+
actor.stop();
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2278
2661
|
function buildActor(
|
|
2279
2662
|
ports: PlaybookPorts,
|
|
2280
2663
|
machineSnapshot?: JsonValue,
|
|
@@ -2425,9 +2808,8 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2425
2808
|
// A state that cannot even normalize has no disposal descriptor.
|
|
2426
2809
|
}
|
|
2427
2810
|
}
|
|
2428
|
-
suppressInspectionEmissions = true;
|
|
2429
2811
|
try {
|
|
2430
|
-
|
|
2812
|
+
stopActor();
|
|
2431
2813
|
} catch {
|
|
2432
2814
|
// Preserve the original startup failure.
|
|
2433
2815
|
}
|
|
@@ -2464,6 +2846,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2464
2846
|
activeEmissionCalls.clear();
|
|
2465
2847
|
emissionQueue.clear();
|
|
2466
2848
|
judgeQueue.clear();
|
|
2849
|
+
appliedReceipts.clear();
|
|
2467
2850
|
actor = undefined;
|
|
2468
2851
|
session = undefined;
|
|
2469
2852
|
savedPorts = undefined;
|
|
@@ -2473,6 +2856,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2473
2856
|
controlPlaneError = undefined;
|
|
2474
2857
|
emissionFailure = undefined;
|
|
2475
2858
|
priorState = undefined;
|
|
2859
|
+
lastBossEvent = undefined;
|
|
2476
2860
|
suppressInspectionEmissions = false;
|
|
2477
2861
|
initialized = false;
|
|
2478
2862
|
traceSequence = 0;
|
|
@@ -2481,6 +2865,183 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2481
2865
|
playerCallSequence = 0;
|
|
2482
2866
|
playbookCallSequence = 0;
|
|
2483
2867
|
captainCallSequence = 0;
|
|
2868
|
+
applyCallSequence = 0;
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// -----------------------------------------------------------------
|
|
2872
|
+
// DR-029 control surface: action derivation shared by `describe`
|
|
2873
|
+
// and by `apply`'s live revalidation.
|
|
2874
|
+
// -----------------------------------------------------------------
|
|
2875
|
+
|
|
2876
|
+
interface DerivedControlAction {
|
|
2877
|
+
action: PlaybookControlAction;
|
|
2878
|
+
event: EventObject;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
function snapshotCan(snapshot: unknown, event: EventObject): boolean {
|
|
2882
|
+
const can = (snapshot as { can?: unknown } | null)?.can;
|
|
2883
|
+
return (
|
|
2884
|
+
typeof can === 'function' &&
|
|
2885
|
+
(can as (candidate: EventObject) => boolean).call(snapshot, event) ===
|
|
2886
|
+
true
|
|
2887
|
+
);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// The failure-state retry entry replays the recorded last classified
|
|
2891
|
+
// event with its recorded payload. A candidate whose event the live
|
|
2892
|
+
// snapshot does not accept — or whose payload the runtime never
|
|
2893
|
+
// recorded — is excluded rather than completed with invented text.
|
|
2894
|
+
function retryActionFor(
|
|
2895
|
+
snapshot: unknown,
|
|
2896
|
+
stateId: string | undefined,
|
|
2897
|
+
): DerivedControlAction | undefined {
|
|
2898
|
+
if (stateId !== 'failed' || lastBossEvent === undefined) {
|
|
2899
|
+
return undefined;
|
|
2900
|
+
}
|
|
2901
|
+
if (!snapshotCan(snapshot, lastBossEvent)) return undefined;
|
|
2902
|
+
// A recorded explicit-state-jump event names the exact state its
|
|
2903
|
+
// replay re-enters: the root BOSS_INTERRUPT shape is a guarded
|
|
2904
|
+
// multi-arm list keyed on `targetId`, so the first configured arm
|
|
2905
|
+
// may label a different state than the one the recorded event
|
|
2906
|
+
// actually resumes.
|
|
2907
|
+
const recordedTargetId =
|
|
2908
|
+
lastBossEvent.type === JUMP_EVENT_TYPE
|
|
2909
|
+
? (lastBossEvent as { targetId?: unknown }).targetId
|
|
2910
|
+
: undefined;
|
|
2911
|
+
const target =
|
|
2912
|
+
typeof recordedTargetId === 'string' &&
|
|
2913
|
+
recordedTargetId.trim().length > 0
|
|
2914
|
+
? recordedTargetId
|
|
2915
|
+
: firstTransitionTarget(machine, stateId, lastBossEvent.type);
|
|
2916
|
+
// PBRT-52: a label is written from a source state description, never
|
|
2917
|
+
// from an identifier. Falling back to the target id — or, with no
|
|
2918
|
+
// resolvable target, to the FSM event type — makes the label *be* the
|
|
2919
|
+
// internal name, which defeats the substitution the label exists for
|
|
2920
|
+
// and puts a machine identifier into Boss-facing text
|
|
2921
|
+
// (CAPPLAY-5). A candidate whose label can only be an id is excluded
|
|
2922
|
+
// exactly like one whose payload cannot be sourced.
|
|
2923
|
+
const description =
|
|
2924
|
+
(target === undefined ? undefined : stateDescriptions.get(target)) ??
|
|
2925
|
+
stateDescriptions.get(stateId);
|
|
2926
|
+
if (description === undefined) return undefined;
|
|
2927
|
+
return {
|
|
2928
|
+
action: {
|
|
2929
|
+
id: `retry:${lastBossEvent.type}`,
|
|
2930
|
+
label: `Retry: ${description}`,
|
|
2931
|
+
},
|
|
2932
|
+
event: lastBossEvent,
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
function deriveControlActions(snapshot: unknown): DerivedControlAction[] {
|
|
2937
|
+
// Actions derive only at the safe point the parked snapshot also
|
|
2938
|
+
// uses — quiescent actor with status `active` and no pending nested
|
|
2939
|
+
// call. Anywhere else the view still describes the state while
|
|
2940
|
+
// advertising nothing.
|
|
2941
|
+
let state: PlaybookState;
|
|
2942
|
+
try {
|
|
2943
|
+
state = normalizePlaybookSnapshot(snapshot, {
|
|
2944
|
+
pendingCall: nestedBridge.getPendingCall(),
|
|
2945
|
+
});
|
|
2946
|
+
} catch {
|
|
2947
|
+
return [];
|
|
2948
|
+
}
|
|
2949
|
+
if (
|
|
2950
|
+
state.status !== 'active' ||
|
|
2951
|
+
!state.quiescent ||
|
|
2952
|
+
nestedBridge.getPendingCall()
|
|
2953
|
+
) {
|
|
2954
|
+
return [];
|
|
2955
|
+
}
|
|
2956
|
+
const derived: DerivedControlAction[] = [];
|
|
2957
|
+
const retry = retryActionFor(snapshot, state.stateId);
|
|
2958
|
+
if (retry !== undefined) derived.push(retry);
|
|
2959
|
+
// Jump entries: resumable targets whose explicit-state-jump event the
|
|
2960
|
+
// live snapshot accepts (state guards included), sent with the
|
|
2961
|
+
// advertised target id and optional textual fields omitted.
|
|
2962
|
+
for (const targetId of [...resumableStateIds].sort()) {
|
|
2963
|
+
const event = { type: JUMP_EVENT_TYPE, targetId } as EventObject;
|
|
2964
|
+
if (!snapshotCan(snapshot, event)) continue;
|
|
2965
|
+
// PBRT-52: no published description for the target, no Boss-appropriate
|
|
2966
|
+
// label. A jump cannot borrow another state's meaning without naming
|
|
2967
|
+
// the wrong state, so the entry is not advertised at all rather than
|
|
2968
|
+
// labeled with its own target id.
|
|
2969
|
+
const description = stateDescriptions.get(targetId);
|
|
2970
|
+
if (description === undefined) continue;
|
|
2971
|
+
derived.push({
|
|
2972
|
+
action: {
|
|
2973
|
+
id: `jump:${targetId}`,
|
|
2974
|
+
label: `Resume from: ${description}`,
|
|
2975
|
+
},
|
|
2976
|
+
event,
|
|
2977
|
+
});
|
|
2978
|
+
}
|
|
2979
|
+
return derived;
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
// PBRT-52: the control view's context is the artifact's declared
|
|
2983
|
+
// projection, not a serialization of whatever the FSM happens to hold.
|
|
2984
|
+
// Only the runtime knows which of its context members are safe and
|
|
2985
|
+
// relevant for a controller prompt — an allow-by-default export cannot
|
|
2986
|
+
// keep player output, resolved player identities, or option values out
|
|
2987
|
+
// of a prompt whose host is required to exclude them
|
|
2988
|
+
// (CAPTAIN-9) — so nothing is exported unless
|
|
2989
|
+
// `controlContextFields` names it, in the order it names them. Each
|
|
2990
|
+
// named member is still sanitized: raw `Error` values are normalized
|
|
2991
|
+
// and a value that cannot be made JSON-safe is dropped, never thrown,
|
|
2992
|
+
// since `describe` must stay side-effect free and total.
|
|
2993
|
+
function projectControlContext(
|
|
2994
|
+
context: Record<string, unknown>,
|
|
2995
|
+
): JsonValue | undefined {
|
|
2996
|
+
const projected: Record<string, JsonValue> = {};
|
|
2997
|
+
for (const key of controlContextFields) {
|
|
2998
|
+
const value = context[key];
|
|
2999
|
+
if (value === undefined) continue;
|
|
3000
|
+
try {
|
|
3001
|
+
projected[key] = snapshotJsonValue(
|
|
3002
|
+
value instanceof Error ? normalizeError(value) : value,
|
|
3003
|
+
`control context ${key}`,
|
|
3004
|
+
);
|
|
3005
|
+
} catch {
|
|
3006
|
+
// Declared but not JSON-safe — dropped.
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
return Object.keys(projected).length === 0 ? undefined : projected;
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
// PBRT-52: the view's Boss-facing state description — the meaning of the
|
|
3013
|
+
// state the runtime is in, written by the artifact's own source, from the
|
|
3014
|
+
// same descriptions its action labels are written from. A control view is
|
|
3015
|
+
// the only grounding a controller host has for a status answer, and an
|
|
3016
|
+
// internal state id is not Boss-appropriate text
|
|
3017
|
+
// (CAPPLAY-5), so the runtime publishes the meaning
|
|
3018
|
+
// rather than leaving the host to substitute the identifier for it. A
|
|
3019
|
+
// state whose source declares no description publishes none: an id is
|
|
3020
|
+
// never promoted into a description by default.
|
|
3021
|
+
function stateDescriptionFor(state: PlaybookState): string | undefined {
|
|
3022
|
+
const keys = [
|
|
3023
|
+
...(state.stateId === undefined ? [] : [state.stateId]),
|
|
3024
|
+
...(typeof state.value === 'string' ? [state.value] : []),
|
|
3025
|
+
...state.activeStateIds,
|
|
3026
|
+
];
|
|
3027
|
+
for (const key of keys) {
|
|
3028
|
+
const description = stateDescriptions.get(key);
|
|
3029
|
+
if (description !== undefined) return description;
|
|
3030
|
+
}
|
|
3031
|
+
return undefined;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
function receiptTracePayload(
|
|
3035
|
+
receipt: PlaybookControlReceipt,
|
|
3036
|
+
): Record<string, unknown> {
|
|
3037
|
+
return {
|
|
3038
|
+
disposition: receipt.disposition,
|
|
3039
|
+
...(receipt.disposition === 'rejected'
|
|
3040
|
+
? { reason: receipt.reason }
|
|
3041
|
+
: {}),
|
|
3042
|
+
...(receipt.disposition === 'failed' ? { error: receipt.error } : {}),
|
|
3043
|
+
...(receipt.disposition === 'executed' ? { run: receipt.run } : {}),
|
|
3044
|
+
};
|
|
2484
3045
|
}
|
|
2485
3046
|
|
|
2486
3047
|
const runtime = {
|
|
@@ -2602,6 +3163,11 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2602
3163
|
// Every Captain call already consumed at least one trace number,
|
|
2603
3164
|
// so the global trace counter is a collision-safe id floor.
|
|
2604
3165
|
boundSnapshot.sequences.trace;
|
|
3166
|
+
// The schema-1 snapshot carries no apply counter (PBRT-50: no
|
|
3167
|
+
// schema bump); every apply boundary consumed trace numbers, so
|
|
3168
|
+
// the persisted trace counter is a collision-safe id floor here
|
|
3169
|
+
// too, keeping `apply-<n>` call ids unique across restore.
|
|
3170
|
+
applyCallSequence = boundSnapshot.sequences.trace;
|
|
2605
3171
|
playerResumeTokens.clear();
|
|
2606
3172
|
for (const [playerId, token] of Object.entries(
|
|
2607
3173
|
boundSnapshot.playerResumeTokens,
|
|
@@ -2632,6 +3198,349 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2632
3198
|
}
|
|
2633
3199
|
},
|
|
2634
3200
|
|
|
3201
|
+
// DR-029 / PBRT-52: side-effect-free control view over the live
|
|
3202
|
+
// snapshot, valid at parked quiescence outside an active boundary.
|
|
3203
|
+
// The view is detached and frozen; producing it emits nothing and
|
|
3204
|
+
// moves nothing.
|
|
3205
|
+
describe(): PlaybookControlView {
|
|
3206
|
+
if (disposed || disposalPromise !== undefined) {
|
|
3207
|
+
throw new Error(
|
|
3208
|
+
'createPlaybookRuntime.describe: runtime is disposing or disposed',
|
|
3209
|
+
);
|
|
3210
|
+
}
|
|
3211
|
+
if (!actor || !savedPorts) {
|
|
3212
|
+
throw new Error(
|
|
3213
|
+
'createPlaybookRuntime.describe: init must be called first',
|
|
3214
|
+
);
|
|
3215
|
+
}
|
|
3216
|
+
if (activeSignal !== undefined) {
|
|
3217
|
+
throw new Error(
|
|
3218
|
+
'createPlaybookRuntime.describe: another runtime turn is active',
|
|
3219
|
+
);
|
|
3220
|
+
}
|
|
3221
|
+
const snapshot = actor.getSnapshot();
|
|
3222
|
+
const state = currentState();
|
|
3223
|
+
const context = ((snapshot as { context?: unknown }).context ??
|
|
3224
|
+
{}) as Record<string, unknown>;
|
|
3225
|
+
const pending = pendingBossQuestionFromContext(context);
|
|
3226
|
+
const lastError = normalizeErrorFull(context.lastError);
|
|
3227
|
+
const projectedContext = projectControlContext(context);
|
|
3228
|
+
const stateDescription = stateDescriptionFor(state);
|
|
3229
|
+
return deepFreeze({
|
|
3230
|
+
state,
|
|
3231
|
+
...(stateDescription === undefined ? {} : { stateDescription }),
|
|
3232
|
+
...(projectedContext !== undefined
|
|
3233
|
+
? { context: projectedContext }
|
|
3234
|
+
: {}),
|
|
3235
|
+
pendingQuestions:
|
|
3236
|
+
pending === undefined
|
|
3237
|
+
? []
|
|
3238
|
+
: [
|
|
3239
|
+
{
|
|
3240
|
+
questionId: pending.questionId,
|
|
3241
|
+
player: pending.player,
|
|
3242
|
+
question: pending.question,
|
|
3243
|
+
sourceItem: pending.sourceItem,
|
|
3244
|
+
},
|
|
3245
|
+
],
|
|
3246
|
+
...(lastError !== undefined ? { lastError } : {}),
|
|
3247
|
+
actions: deriveControlActions(snapshot).map(({ action }) => action),
|
|
3248
|
+
});
|
|
3249
|
+
},
|
|
3250
|
+
|
|
3251
|
+
// DR-029 / PBRT-52: revalidate the named action against the live
|
|
3252
|
+
// state and execute it at most once per idempotency key. The receipt
|
|
3253
|
+
// discriminates rejected-before-any-effect from executed and from
|
|
3254
|
+
// failed-after-effects-may-exist; a repeated key returns the recorded
|
|
3255
|
+
// receipt without re-execution. A rejection settles before acceptance,
|
|
3256
|
+
// so — like a key whose call threw before reaching acceptance — it
|
|
3257
|
+
// records nothing and the key may execute later, once the action is
|
|
3258
|
+
// advertised.
|
|
3259
|
+
async apply(input: {
|
|
3260
|
+
actionId: string;
|
|
3261
|
+
key: string;
|
|
3262
|
+
signal: AbortSignal;
|
|
3263
|
+
}): Promise<PlaybookControlReceipt> {
|
|
3264
|
+
if (input === null || typeof input !== 'object') {
|
|
3265
|
+
throw new TypeError(
|
|
3266
|
+
'createPlaybookRuntime.apply: input must be an object',
|
|
3267
|
+
);
|
|
3268
|
+
}
|
|
3269
|
+
const { actionId, key, signal } = input;
|
|
3270
|
+
if (typeof actionId !== 'string' || actionId.length === 0) {
|
|
3271
|
+
throw new TypeError(
|
|
3272
|
+
'createPlaybookRuntime.apply: actionId must be a non-empty string',
|
|
3273
|
+
);
|
|
3274
|
+
}
|
|
3275
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
3276
|
+
throw new TypeError(
|
|
3277
|
+
'createPlaybookRuntime.apply: key must be a non-empty string',
|
|
3278
|
+
);
|
|
3279
|
+
}
|
|
3280
|
+
if (!(signal instanceof AbortSignal)) {
|
|
3281
|
+
throw new TypeError(
|
|
3282
|
+
'createPlaybookRuntime.apply: signal must be an AbortSignal',
|
|
3283
|
+
);
|
|
3284
|
+
}
|
|
3285
|
+
if (disposed || disposalPromise !== undefined) {
|
|
3286
|
+
throw new Error(
|
|
3287
|
+
'createPlaybookRuntime.apply: runtime is disposing or disposed',
|
|
3288
|
+
);
|
|
3289
|
+
}
|
|
3290
|
+
if (!actor || !savedPorts) {
|
|
3291
|
+
throw new Error(
|
|
3292
|
+
'createPlaybookRuntime.apply: init must be called first',
|
|
3293
|
+
);
|
|
3294
|
+
}
|
|
3295
|
+
if (activeSignal !== undefined) {
|
|
3296
|
+
throw new Error(
|
|
3297
|
+
'createPlaybookRuntime.apply: another runtime turn is active',
|
|
3298
|
+
);
|
|
3299
|
+
}
|
|
3300
|
+
// Settlement is final: a repeated key returns the recorded receipt
|
|
3301
|
+
// with no revalidation, no execution, and no new trace pair.
|
|
3302
|
+
const recorded = appliedReceipts.get(key);
|
|
3303
|
+
if (recorded !== undefined) return recorded;
|
|
3304
|
+
// An abort before acceptance ends the call with no receipt
|
|
3305
|
+
// recorded, like every other pre-acceptance failure.
|
|
3306
|
+
signal.throwIfAborted();
|
|
3307
|
+
|
|
3308
|
+
const turnId = ++turnSequence;
|
|
3309
|
+
const callId = `apply-${++applyCallSequence}`;
|
|
3310
|
+
const position: TracePosition = { turnId, callId };
|
|
3311
|
+
activeTurnId = turnId;
|
|
3312
|
+
activeSignal = signal;
|
|
3313
|
+
controlPlaneError = undefined;
|
|
3314
|
+
// Every receipt variant is normalized and frozen where it is built,
|
|
3315
|
+
// inside the guarded region, so the recording step below cannot
|
|
3316
|
+
// throw after effects exist.
|
|
3317
|
+
const settledReceipt = (
|
|
3318
|
+
value: PlaybookControlReceipt,
|
|
3319
|
+
): PlaybookControlReceipt =>
|
|
3320
|
+
deepFreeze(
|
|
3321
|
+
snapshotJsonValue(
|
|
3322
|
+
value,
|
|
3323
|
+
'apply receipt',
|
|
3324
|
+
) as unknown as PlaybookControlReceipt,
|
|
3325
|
+
);
|
|
3326
|
+
let receipt: PlaybookControlReceipt | undefined;
|
|
3327
|
+
let operationError: unknown;
|
|
3328
|
+
let settlementError: unknown;
|
|
3329
|
+
// Acceptance is the line past which this boundary owes a receipt and
|
|
3330
|
+
// can no longer signal by throwing: the action may have run, and a
|
|
3331
|
+
// caller that gets an exception instead of a receipt is left with an
|
|
3332
|
+
// executed effect it cannot record and a key it will not reuse.
|
|
3333
|
+
let accepted = false;
|
|
3334
|
+
// Publication is the second line this boundary respects. Before it,
|
|
3335
|
+
// nothing has left the runtime: a settlement failure past acceptance
|
|
3336
|
+
// is a post-acceptance control-plane error PBRT-52 settles as the
|
|
3337
|
+
// `failed` receipt, and folding it in replaces the receipt recorded
|
|
3338
|
+
// at acceptance so the finish trace, the returned receipt, and any
|
|
3339
|
+
// replay of the key all report one settlement. Past publication that
|
|
3340
|
+
// agreement is no longer achievable — the disposition is already on
|
|
3341
|
+
// the wire — so the fold refuses to run, by construction rather than
|
|
3342
|
+
// by call ordering. Only the first settlement error is latched, so
|
|
3343
|
+
// one fold is all there is to do.
|
|
3344
|
+
let folded = false;
|
|
3345
|
+
let published = false;
|
|
3346
|
+
const foldSettlementFailure = (): void => {
|
|
3347
|
+
if (published || !accepted || folded) return;
|
|
3348
|
+
if (settlementError === undefined) return;
|
|
3349
|
+
folded = true;
|
|
3350
|
+
receipt = settledReceipt({
|
|
3351
|
+
disposition: 'failed',
|
|
3352
|
+
error: normalizeError(settlementError),
|
|
3353
|
+
});
|
|
3354
|
+
appliedReceipts.set(key, receipt);
|
|
3355
|
+
};
|
|
3356
|
+
// A settlement failure that lands after the receipt is published says
|
|
3357
|
+
// nothing about the effect: the action ran, the caller's receipt is
|
|
3358
|
+
// true, and only the telemetry delivery failed. Rewriting `executed`
|
|
3359
|
+
// to `failed` there would make the runtime lie to its only caller
|
|
3360
|
+
// about work that succeeded, irrecoverably — accepted receipts are
|
|
3361
|
+
// final for their key. Past publication such a failure is therefore
|
|
3362
|
+
// re-latched onto the emission channel, surfacing from the next
|
|
3363
|
+
// public boundary's drain, and `apply` still does not throw past
|
|
3364
|
+
// acceptance (PBRT-52).
|
|
3365
|
+
const latchDeliveryFailure = (error: unknown): void => {
|
|
3366
|
+
emissionFailure ??= error;
|
|
3367
|
+
};
|
|
3368
|
+
try {
|
|
3369
|
+
try {
|
|
3370
|
+
const identity = {
|
|
3371
|
+
actionId,
|
|
3372
|
+
key,
|
|
3373
|
+
...stateIdentity(currentState().stateId),
|
|
3374
|
+
};
|
|
3375
|
+
// Every apply finish carries the receipt disposition and no
|
|
3376
|
+
// start-only field — `stateId` is on the start alone
|
|
3377
|
+
// (slc/link.md §Playbook trace). Both finishes reachable
|
|
3378
|
+
// before acceptance settle with no effect behind them, so both
|
|
3379
|
+
// carry the canonical `rejected` disposition and the reason
|
|
3380
|
+
// that ended the call, alongside the transport marker.
|
|
3381
|
+
const preAcceptanceFinish = (
|
|
3382
|
+
reason: string,
|
|
3383
|
+
): Record<string, unknown> => ({
|
|
3384
|
+
actionId,
|
|
3385
|
+
key,
|
|
3386
|
+
...receiptTracePayload({ disposition: 'rejected', reason }),
|
|
3387
|
+
});
|
|
3388
|
+
await emitCallStarted(
|
|
3389
|
+
'apply.started',
|
|
3390
|
+
'apply.finished',
|
|
3391
|
+
identity,
|
|
3392
|
+
position,
|
|
3393
|
+
preAcceptanceFinish('apply.started trace sink rejected'),
|
|
3394
|
+
);
|
|
3395
|
+
// An abort may land while the awaited started emission drains
|
|
3396
|
+
// (e.g. fired from the trace sink itself); the action must
|
|
3397
|
+
// never execute after abort. Settle the already-started pair
|
|
3398
|
+
// as `aborted` — carrying the canonical rejected-before-any-
|
|
3399
|
+
// effect receipt disposition required of every apply finish —
|
|
3400
|
+
// and end the call pre-acceptance: no receipt is recorded and
|
|
3401
|
+
// the key stays free.
|
|
3402
|
+
if (signal.aborted) {
|
|
3403
|
+
try {
|
|
3404
|
+
await emitTrace(
|
|
3405
|
+
'apply.finished',
|
|
3406
|
+
{
|
|
3407
|
+
...preAcceptanceFinish('aborted before acceptance'),
|
|
3408
|
+
status: 'aborted',
|
|
3409
|
+
error: normalizeError(signal.reason),
|
|
3410
|
+
},
|
|
3411
|
+
position,
|
|
3412
|
+
);
|
|
3413
|
+
} catch (error) {
|
|
3414
|
+
// A rejecting finish sink surfaces at the boundary like
|
|
3415
|
+
// any settlement failure (see the precedence below).
|
|
3416
|
+
settlementError ??= error;
|
|
3417
|
+
}
|
|
3418
|
+
signal.throwIfAborted();
|
|
3419
|
+
}
|
|
3420
|
+
const snapshot = actor.getSnapshot();
|
|
3421
|
+
const candidate = deriveControlActions(snapshot).find(
|
|
3422
|
+
({ action }) => action.id === actionId,
|
|
3423
|
+
);
|
|
3424
|
+
if (candidate === undefined) {
|
|
3425
|
+
receipt = settledReceipt({
|
|
3426
|
+
disposition: 'rejected',
|
|
3427
|
+
reason: `action ${JSON.stringify(
|
|
3428
|
+
actionId,
|
|
3429
|
+
)} is not currently advertised`,
|
|
3430
|
+
});
|
|
3431
|
+
} else {
|
|
3432
|
+
// Acceptance: from here every outcome records a receipt under
|
|
3433
|
+
// the key, so the action can never execute twice.
|
|
3434
|
+
accepted = true;
|
|
3435
|
+
try {
|
|
3436
|
+
actor.send(candidate.event);
|
|
3437
|
+
await waitForPlaybookQuiescence(actor, {
|
|
3438
|
+
pendingCalls: nestedBridge,
|
|
3439
|
+
});
|
|
3440
|
+
if (controlPlaneError !== undefined) throw controlPlaneError;
|
|
3441
|
+
const run = runResultFor(settledOutcome(signal));
|
|
3442
|
+
receipt = settledReceipt(
|
|
3443
|
+
run.outcome === 'failed' || run.outcome === 'aborted'
|
|
3444
|
+
? {
|
|
3445
|
+
disposition: 'failed',
|
|
3446
|
+
error:
|
|
3447
|
+
('error' in run ? run.error : undefined) ??
|
|
3448
|
+
normalizeError(
|
|
3449
|
+
new Error(
|
|
3450
|
+
`apply settled with outcome ${run.outcome}`,
|
|
3451
|
+
),
|
|
3452
|
+
),
|
|
3453
|
+
}
|
|
3454
|
+
: { disposition: 'executed', run },
|
|
3455
|
+
);
|
|
3456
|
+
} catch (error) {
|
|
3457
|
+
// Effects may exist: a post-acceptance failure is the
|
|
3458
|
+
// receipt, not a control-plane rejection (DR-029).
|
|
3459
|
+
receipt = settledReceipt({
|
|
3460
|
+
disposition: 'failed',
|
|
3461
|
+
error: normalizeError(error),
|
|
3462
|
+
});
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
} catch (error) {
|
|
3466
|
+
operationError = error; // pre-acceptance: no receipt is recorded
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3469
|
+
// Record acceptance before the settlement emissions, so a crash
|
|
3470
|
+
// between acceptance and settlement can never re-execute the
|
|
3471
|
+
// action: the recorded receipt survives and a replayed key
|
|
3472
|
+
// returns it. A rejection settled before acceptance: it is
|
|
3473
|
+
// returned and traced but never recorded, so its key stays free
|
|
3474
|
+
// to execute once the action is advertised.
|
|
3475
|
+
if (receipt !== undefined && receipt.disposition !== 'rejected') {
|
|
3476
|
+
appliedReceipts.set(key, receipt);
|
|
3477
|
+
}
|
|
3478
|
+
try {
|
|
3479
|
+
await drainEmissions();
|
|
3480
|
+
} catch (error) {
|
|
3481
|
+
settlementError = error;
|
|
3482
|
+
}
|
|
3483
|
+
// Fold before the finish emission, the last point at which the
|
|
3484
|
+
// traced disposition and the returned one can still be made the
|
|
3485
|
+
// same value.
|
|
3486
|
+
foldSettlementFailure();
|
|
3487
|
+
if (receipt !== undefined) {
|
|
3488
|
+
// Publication: this disposition is now the settlement, for the
|
|
3489
|
+
// trace, for the caller, and for every replay of the key.
|
|
3490
|
+
published = true;
|
|
3491
|
+
try {
|
|
3492
|
+
await emitTrace(
|
|
3493
|
+
'apply.finished',
|
|
3494
|
+
{ actionId, key, ...receiptTracePayload(receipt) },
|
|
3495
|
+
position,
|
|
3496
|
+
);
|
|
3497
|
+
} catch (error) {
|
|
3498
|
+
if (accepted) latchDeliveryFailure(error);
|
|
3499
|
+
else settlementError ??= error;
|
|
3500
|
+
}
|
|
3501
|
+
// Drain even when the finish emission rejected, so this call
|
|
3502
|
+
// leaves no queued emission behind it. Before acceptance the
|
|
3503
|
+
// failure is consumed and thrown, as every pre-acceptance failure
|
|
3504
|
+
// is; past it the failure is re-latched instead — the effect
|
|
3505
|
+
// happened, so the delivery failure travels on the emission
|
|
3506
|
+
// channel to the next boundary rather than rewriting what
|
|
3507
|
+
// happened or vanishing here.
|
|
3508
|
+
try {
|
|
3509
|
+
await drainEmissions();
|
|
3510
|
+
} catch (error) {
|
|
3511
|
+
if (accepted) latchDeliveryFailure(error);
|
|
3512
|
+
else settlementError ??= error;
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
} finally {
|
|
3516
|
+
// Always release the boundary sentinel, even on a path no
|
|
3517
|
+
// constructible input reaches today, so a defect here can never
|
|
3518
|
+
// wedge every later public boundary behind "another runtime turn
|
|
3519
|
+
// is active".
|
|
3520
|
+
activeSignal = undefined;
|
|
3521
|
+
activeTurnId = undefined;
|
|
3522
|
+
controlPlaneError = undefined;
|
|
3523
|
+
}
|
|
3524
|
+
// Past acceptance every settlement failure has been folded into the
|
|
3525
|
+
// receipt, so nothing is left to throw and the caller always leaves
|
|
3526
|
+
// with the settlement of the effect it may have caused (PBRT-52).
|
|
3527
|
+
if (accepted && receipt !== undefined) return receipt;
|
|
3528
|
+
// Before acceptance no effect exists and no receipt is owed, so a
|
|
3529
|
+
// failure still surfaces by throwing. Settlement failures (a
|
|
3530
|
+
// rejecting finish sink, a drain-latched emission failure) outrank
|
|
3531
|
+
// the operation error, matching the `drainError ?? operationError`
|
|
3532
|
+
// precedence of the other public boundaries. A start-sink failure is
|
|
3533
|
+
// unaffected: its latched drain error is the start error itself.
|
|
3534
|
+
const failure = settlementError ?? operationError;
|
|
3535
|
+
if (failure !== undefined) throw failure;
|
|
3536
|
+
if (receipt === undefined) {
|
|
3537
|
+
throw new Error(
|
|
3538
|
+
'createPlaybookRuntime.apply: no receipt was produced',
|
|
3539
|
+
);
|
|
3540
|
+
}
|
|
3541
|
+
return receipt;
|
|
3542
|
+
},
|
|
3543
|
+
|
|
2635
3544
|
async handleBossInput({
|
|
2636
3545
|
text,
|
|
2637
3546
|
signal,
|
|
@@ -2686,6 +3595,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2686
3595
|
signal,
|
|
2687
3596
|
snapshot,
|
|
2688
3597
|
boundary,
|
|
3598
|
+
boundOptions,
|
|
2689
3599
|
);
|
|
2690
3600
|
}
|
|
2691
3601
|
signal.throwIfAborted();
|
|
@@ -2705,10 +3615,24 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2705
3615
|
// 3. A final actor cannot accept new events; reconstruct only
|
|
2706
3616
|
// after classification produced a real event.
|
|
2707
3617
|
if (actor.getSnapshot().status === 'done') {
|
|
2708
|
-
|
|
3618
|
+
stopActor();
|
|
2709
3619
|
actor = buildActor(runtimePorts!);
|
|
3620
|
+
// The replacement actor's snapshots are real state entries.
|
|
3621
|
+
suppressInspectionEmissions = false;
|
|
2710
3622
|
actor.start();
|
|
2711
3623
|
}
|
|
3624
|
+
// DR-029: keep the classified event with its recorded payload
|
|
3625
|
+
// as the retry-replay source. Recording is sanitizing, not
|
|
3626
|
+
// load-bearing: an override classifier's non-JSON-safe event is
|
|
3627
|
+
// simply not recorded, and the turn proceeds unchanged.
|
|
3628
|
+
try {
|
|
3629
|
+
lastBossEvent = snapshotJsonValue(
|
|
3630
|
+
event,
|
|
3631
|
+
'recorded Boss event',
|
|
3632
|
+
) as unknown as EventObject;
|
|
3633
|
+
} catch {
|
|
3634
|
+
lastBossEvent = undefined;
|
|
3635
|
+
}
|
|
2712
3636
|
actor.send(event);
|
|
2713
3637
|
await waitForPlaybookQuiescence(actor, {
|
|
2714
3638
|
pendingCalls: nestedBridge,
|
|
@@ -2852,7 +3776,9 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2852
3776
|
const finalState = actor ? currentState() : undefined;
|
|
2853
3777
|
// Stop the root before settling a suspended child. Its rejection
|
|
2854
3778
|
// must not re-enter the FSM and start fresh work during disposal.
|
|
2855
|
-
|
|
3779
|
+
// `stopActor` suppresses inspection first, so the stop snapshot
|
|
3780
|
+
// adds nothing beside the `session.disposed` trace below (PBRT-6).
|
|
3781
|
+
stopActor();
|
|
2856
3782
|
try {
|
|
2857
3783
|
await nestedBridge.dispose();
|
|
2858
3784
|
} catch (error) {
|
|
@@ -2886,11 +3812,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2886
3812
|
activeEmissionCalls.clear();
|
|
2887
3813
|
emissionQueue.clear();
|
|
2888
3814
|
judgeQueue.clear();
|
|
3815
|
+
appliedReceipts.clear();
|
|
2889
3816
|
actor = undefined;
|
|
2890
3817
|
activeSignal = undefined;
|
|
2891
3818
|
activeTurnId = undefined;
|
|
2892
3819
|
controlPlaneError = undefined;
|
|
2893
3820
|
emissionFailure = undefined;
|
|
3821
|
+
lastBossEvent = undefined;
|
|
2894
3822
|
savedPorts = undefined;
|
|
2895
3823
|
runtimePorts = undefined;
|
|
2896
3824
|
session = undefined;
|