@sublang/playbook 8.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 +3 -3
- package/docs/cli.md +15 -15
- package/docs/configuration.md +13 -8
- package/docs/embedding.md +7 -2
- package/package.json +1 -1
- package/reference/sdlc/captain.playbook/captain.playbook.js +14 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +18 -4
- package/reference/sdlc/code.playbook/code.fsm.d.ts +4 -1
- package/reference/sdlc/code.playbook/code.fsm.js +11 -4
- package/reference/sdlc/code.playbook/code.fsm.ts +12 -4
- package/reference/sdlc/code.playbook/code.playbook.js +14 -3
- package/reference/sdlc/code.playbook/code.playbook.ts +13 -3
- package/reference/sdlc/code.playbook/playbook-captain.js +44 -10
- package/reference/sdlc/code.playbook/playbook-captain.ts +47 -10
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +2 -0
- package/reference/sdlc/decide.playbook/decide.playbook.js +299 -117
- package/reference/sdlc/decide.playbook/decide.playbook.ts +395 -131
- package/reference/sdlc/review.playbook/review.playbook.js +14 -3
- package/reference/sdlc/review.playbook/review.playbook.ts +13 -3
- package/slc/gears2fsm.md +19 -2
- package/slc/link.md +184 -42
- package/src/runtime.d.ts +1 -0
- package/src/runtime.ts +1 -0
- package/src/xstate-playbook-runtime.d.ts +13 -3
- package/src/xstate-playbook-runtime.js +732 -251
- package/src/xstate-playbook-runtime.ts +873 -280
- package/src/xstate-runtime.d.ts +17 -7
- package/src/xstate-runtime.js +135 -57
- package/src/xstate-runtime.ts +243 -84
|
@@ -375,7 +375,19 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
|
|
|
375
375
|
* ordinary textual entry event, send it without a judge call, carrying the
|
|
376
376
|
* exact Boss text in `textField`. Absent: every non-empty turn classifies.
|
|
377
377
|
*/
|
|
378
|
-
entryEvent?: {
|
|
378
|
+
entryEvent?: {
|
|
379
|
+
type: string;
|
|
380
|
+
textField: string;
|
|
381
|
+
/**
|
|
382
|
+
* DR-034: the FSM context member this machine's entry action copies the
|
|
383
|
+
* exact Boss text into. Where it is named, the failure-state retry
|
|
384
|
+
* builds its payload from that member of the live snapshot instead of
|
|
385
|
+
* from the process-local recorded event, so the action derives the same
|
|
386
|
+
* before and after `restore`. Absent: the recorded event stays the
|
|
387
|
+
* source and the action lives only as long as the process.
|
|
388
|
+
*/
|
|
389
|
+
contextField?: string;
|
|
390
|
+
};
|
|
379
391
|
/**
|
|
380
392
|
* Exact flat Boss-event contracts whose non-text fields the judge may
|
|
381
393
|
* select. `entryEvent` and scalar `BOSS_REPLY` contracts are supplied by
|
|
@@ -575,11 +587,54 @@ export function normalizeErrorFull(
|
|
|
575
587
|
return normalizeError(err);
|
|
576
588
|
}
|
|
577
589
|
|
|
590
|
+
// slc/link.md §Abort: cancellation is causal identity with the applicable
|
|
591
|
+
// signal's reason — never an `AbortError` name, never bare signal state. A
|
|
592
|
+
// distinct failure observed while the signal is aborted stays a non-abort
|
|
593
|
+
// control error and takes precedence (mirrors DECIDE's bespoke reference).
|
|
578
594
|
function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
|
|
579
|
-
return (
|
|
580
|
-
|
|
581
|
-
|
|
595
|
+
return signal.aborted && Object.is(error, signal.reason);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
interface AbortReasonClassifier {
|
|
599
|
+
isAbortReason(error: unknown): boolean;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function abortReasonClassifier(
|
|
603
|
+
...sources: readonly (AbortSignal | AbortReasonClassifier | undefined)[]
|
|
604
|
+
): AbortReasonClassifier {
|
|
605
|
+
const captured = sources.filter(
|
|
606
|
+
(source): source is AbortSignal | AbortReasonClassifier =>
|
|
607
|
+
source !== undefined,
|
|
582
608
|
);
|
|
609
|
+
return Object.freeze({
|
|
610
|
+
isAbortReason: (error: unknown): boolean =>
|
|
611
|
+
captured.some((source) =>
|
|
612
|
+
source instanceof AbortSignal
|
|
613
|
+
? isAbortFailure(error, source)
|
|
614
|
+
: source.isAbortReason(error),
|
|
615
|
+
),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* gears2fsm's canonical Boss-reply wait state. On the runtime's Boss-facing
|
|
621
|
+
* surfaces — state telemetry, status lines, the exported snapshot, and the
|
|
622
|
+
* control view — a context question counts as *pending* only while the
|
|
623
|
+
* machine sits in this state awaiting the reply. Later states retain the
|
|
624
|
+
* answered question in context (the resumed player prompt is composed from
|
|
625
|
+
* it), so an unconditional projection would resurrect it: a failure the
|
|
626
|
+
* resumed player reached would export a question nobody is waiting on,
|
|
627
|
+
* disagreeing with the gated telemetry a mirroring host's ledger follows
|
|
628
|
+
* and failing the shell's snapshot-equality settlement check.
|
|
629
|
+
*/
|
|
630
|
+
const BOSS_REPLY_WAIT_STATE_ID = 'awaitBossReply';
|
|
631
|
+
|
|
632
|
+
function pendingBossQuestionForState(
|
|
633
|
+
state: PlaybookState,
|
|
634
|
+
context: Record<string, unknown>,
|
|
635
|
+
): PlaybookPendingBossQuestionContext | undefined {
|
|
636
|
+
if (state.stateId !== BOSS_REPLY_WAIT_STATE_ID) return undefined;
|
|
637
|
+
return pendingBossQuestionFromContext(context);
|
|
583
638
|
}
|
|
584
639
|
|
|
585
640
|
/** Read the FSM context's single pending Boss question, when well-formed. */
|
|
@@ -911,7 +966,9 @@ export function createPlayerBridge(
|
|
|
911
966
|
roleId = spec.resolveRoleId(input);
|
|
912
967
|
prompt = spec.composePlayerPrompt(input);
|
|
913
968
|
} catch (error) {
|
|
914
|
-
|
|
969
|
+
if (!isAbortFailure(error, activeSignal)) {
|
|
970
|
+
onControlPlaneError?.(error);
|
|
971
|
+
}
|
|
915
972
|
throw error;
|
|
916
973
|
}
|
|
917
974
|
const callPlayer = (resume: string | false) =>
|
|
@@ -969,7 +1026,9 @@ export function createPlayerBridge(
|
|
|
969
1026
|
validateBossReplyOutput(input, output, spec.resumableStateIds);
|
|
970
1027
|
return output;
|
|
971
1028
|
} catch (error) {
|
|
972
|
-
|
|
1029
|
+
if (!isAbortFailure(error, activeSignal)) {
|
|
1030
|
+
onControlPlaneError?.(error);
|
|
1031
|
+
}
|
|
973
1032
|
throw error;
|
|
974
1033
|
}
|
|
975
1034
|
},
|
|
@@ -1171,7 +1230,7 @@ export function resumableStateIdsFromMachine(
|
|
|
1171
1230
|
if (!isPlainObject(config) || !isPlainObject(config.states)) {
|
|
1172
1231
|
return new Set();
|
|
1173
1232
|
}
|
|
1174
|
-
const awaitState = config.states
|
|
1233
|
+
const awaitState = config.states[BOSS_REPLY_WAIT_STATE_ID];
|
|
1175
1234
|
if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
|
|
1176
1235
|
return new Set();
|
|
1177
1236
|
}
|
|
@@ -1278,6 +1337,43 @@ function deepFreeze<T>(value: T): T {
|
|
|
1278
1337
|
|
|
1279
1338
|
const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
|
|
1280
1339
|
|
|
1340
|
+
// Bounded escalation for aborted script process groups: SIGTERM first, then
|
|
1341
|
+
// SIGKILL after this grace, so settlement (gated on the shell's own exit)
|
|
1342
|
+
// stays bounded even for TERM-immune commands.
|
|
1343
|
+
const SCRIPT_ABORT_KILL_GRACE_MS = 2000;
|
|
1344
|
+
|
|
1345
|
+
class ScriptProcessGroupTeardownError extends Error {
|
|
1346
|
+
constructor(
|
|
1347
|
+
pid: number,
|
|
1348
|
+
message: string,
|
|
1349
|
+
cause?: unknown,
|
|
1350
|
+
) {
|
|
1351
|
+
super(
|
|
1352
|
+
`script process group ${pid} teardown could not be confirmed: ${message}`,
|
|
1353
|
+
cause === undefined ? undefined : { cause },
|
|
1354
|
+
);
|
|
1355
|
+
this.name = 'ScriptProcessGroupTeardownError';
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function isNoSuchProcess(error: unknown): boolean {
|
|
1360
|
+
return (
|
|
1361
|
+
typeof error === 'object' &&
|
|
1362
|
+
error !== null &&
|
|
1363
|
+
'code' in error &&
|
|
1364
|
+
(error as { code?: unknown }).code === 'ESRCH'
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function isProcessPermissionDenied(error: unknown): boolean {
|
|
1369
|
+
return (
|
|
1370
|
+
typeof error === 'object' &&
|
|
1371
|
+
error !== null &&
|
|
1372
|
+
'code' in error &&
|
|
1373
|
+
(error as { code?: unknown }).code === 'EPERM'
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1281
1377
|
function makeDefaultNormalizeTransitionEvent(
|
|
1282
1378
|
transitionEventFields: readonly string[],
|
|
1283
1379
|
): (event: unknown) => JsonValue {
|
|
@@ -1395,7 +1491,7 @@ function makeDefaultStatusesForState(
|
|
|
1395
1491
|
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
|
|
1396
1492
|
return statuses;
|
|
1397
1493
|
}
|
|
1398
|
-
if (stateId ===
|
|
1494
|
+
if (stateId === BOSS_REPLY_WAIT_STATE_ID) {
|
|
1399
1495
|
const pending = pendingBossQuestionFromContext(context);
|
|
1400
1496
|
if (pending === undefined) {
|
|
1401
1497
|
return [...statuses, { message: 'Awaiting Boss reply.' }];
|
|
@@ -1675,7 +1771,15 @@ function makeDefaultClassifyBossText(
|
|
|
1675
1771
|
const state = classifierState(snapshotOrState);
|
|
1676
1772
|
const stateId = typeof state.value === 'string' ? state.value : undefined;
|
|
1677
1773
|
const currentState = stateId ?? JSON.stringify(state.value ?? null);
|
|
1678
|
-
|
|
1774
|
+
// The classifier shares the reply-wait pendingness of every other
|
|
1775
|
+
// surface: outside the wait, a context question a later state retains
|
|
1776
|
+
// is answered history, so the prompt must not present it as pending —
|
|
1777
|
+
// a judge told a question awaits at the failure state is steered toward
|
|
1778
|
+
// a reply it cannot select or toward no action at all.
|
|
1779
|
+
const pending =
|
|
1780
|
+
stateId === BOSS_REPLY_WAIT_STATE_ID
|
|
1781
|
+
? pendingBossQuestionFromContext(state.context)
|
|
1782
|
+
: undefined;
|
|
1679
1783
|
const configuredTypes = configuredEventTypesForState(machine, stateId);
|
|
1680
1784
|
const applicable = [...contracts.values()].filter(
|
|
1681
1785
|
(contract) =>
|
|
@@ -1848,6 +1952,74 @@ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
|
|
|
1848
1952
|
return visit((machine as unknown as { config?: unknown }).config);
|
|
1849
1953
|
}
|
|
1850
1954
|
|
|
1955
|
+
// PBRT-52: the factory's domain is FLAT single-region machines — every
|
|
1956
|
+
// state a direct child of the root, so each snapshot exposes exactly one
|
|
1957
|
+
// playbook state id and every state-keyed lookup (deterministic entries,
|
|
1958
|
+
// retry, reply-wait pendingness, configured events, descriptions) indexes
|
|
1959
|
+
// one unambiguous identity. A compound child would be accepted and then
|
|
1960
|
+
// silently misbehave on all of those gates, so it is rejected up front
|
|
1961
|
+
// exactly like a parallel region.
|
|
1962
|
+
function machineDeclaresNestedState(machine: AnyStateMachine): boolean {
|
|
1963
|
+
const config = (machine as unknown as { config?: unknown }).config;
|
|
1964
|
+
if (!isPlainObject(config) || !isPlainObject(config.states)) return false;
|
|
1965
|
+
return Object.values(config.states).some(
|
|
1966
|
+
(stateDef) =>
|
|
1967
|
+
isPlainObject(stateDef) &&
|
|
1968
|
+
isPlainObject(stateDef.states) &&
|
|
1969
|
+
Object.keys(stateDef.states).length > 0,
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// PBRT-52: the factory's lookups index states by their root key, and the
|
|
1974
|
+
// published playbook identity is `meta.playbook.stateId` — the two must
|
|
1975
|
+
// coincide or a machine can advertise a pending question or retry under an
|
|
1976
|
+
// identity no lookup resolves. A state with no string stateId is just as
|
|
1977
|
+
// dead: every snapshot identity derives from that member, so the first
|
|
1978
|
+
// entry would fail the exactly-one-state-id inspection at runtime.
|
|
1979
|
+
// gears2fsm keeps identity and key equal by construction; a hand-authored
|
|
1980
|
+
// artifact that splits or omits them fails here instead of at a silently
|
|
1981
|
+
// dead gate.
|
|
1982
|
+
function assertFlatStateIdentity(
|
|
1983
|
+
machine: AnyStateMachine,
|
|
1984
|
+
label: string,
|
|
1985
|
+
): void {
|
|
1986
|
+
const config = (machine as unknown as { config?: unknown }).config;
|
|
1987
|
+
const states =
|
|
1988
|
+
isPlainObject(config) && isPlainObject(config.states)
|
|
1989
|
+
? config.states
|
|
1990
|
+
: undefined;
|
|
1991
|
+
// A machine with no root states has no playbook identity to expose; its
|
|
1992
|
+
// first snapshot would fail the exactly-one-state-id inspection, so it
|
|
1993
|
+
// fails construction with the defect named instead.
|
|
1994
|
+
if (states === undefined || Object.keys(states).length === 0) {
|
|
1995
|
+
throw new Error(
|
|
1996
|
+
`${label} declares no root states; the shared runtime requires at ` +
|
|
1997
|
+
'least one flat playbook state',
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
for (const [key, stateDef] of Object.entries(states)) {
|
|
2001
|
+
if (!isPlainObject(stateDef)) continue;
|
|
2002
|
+
const meta = isPlainObject(stateDef.meta) ? stateDef.meta : undefined;
|
|
2003
|
+
const playbook =
|
|
2004
|
+
meta !== undefined && isPlainObject(meta.playbook)
|
|
2005
|
+
? meta.playbook
|
|
2006
|
+
: undefined;
|
|
2007
|
+
const stateId = playbook?.stateId;
|
|
2008
|
+
if (typeof stateId !== 'string') {
|
|
2009
|
+
throw new Error(
|
|
2010
|
+
`${label} state ${key} declares no string meta.playbook.stateId; ` +
|
|
2011
|
+
'the shared runtime derives every playbook state identity from it',
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
if (stateId !== key) {
|
|
2015
|
+
throw new Error(
|
|
2016
|
+
`${label} state ${key} declares meta.playbook.stateId ${stateId}; ` +
|
|
2017
|
+
'the shared runtime requires the playbook state id to equal the state key',
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
|
|
1851
2023
|
/**
|
|
1852
2024
|
* Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
|
|
1853
2025
|
* under the slc/link.md contract. The factory provides every actor kind the
|
|
@@ -1855,9 +2027,10 @@ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
|
|
|
1855
2027
|
* (literal and dynamic) — and implements the full runtime lifecycle including
|
|
1856
2028
|
* the optional parked-session snapshot capability (DR-014).
|
|
1857
2029
|
*
|
|
1858
|
-
* Scope: machines
|
|
1859
|
-
*
|
|
1860
|
-
*
|
|
2030
|
+
* Scope: flat single-region machines — no parallel state, no compound
|
|
2031
|
+
* child states, and every root state's `meta.playbook.stateId` equal to its
|
|
2032
|
+
* state key — so each snapshot exposes exactly one playbook state id.
|
|
2033
|
+
* Parallel-region FSMs keep their own linked runtimes.
|
|
1861
2034
|
*/
|
|
1862
2035
|
export function createXStatePlaybookRuntime<TOptions>(
|
|
1863
2036
|
machine: AnyStateMachine,
|
|
@@ -1883,6 +2056,12 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1883
2056
|
`${label} uses a parallel state; the shared runtime supports only single-region FSMs`,
|
|
1884
2057
|
);
|
|
1885
2058
|
}
|
|
2059
|
+
if (machineDeclaresNestedState(machine)) {
|
|
2060
|
+
throw new Error(
|
|
2061
|
+
`${label} declares a compound state; the shared runtime supports only flat single-region FSMs`,
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
assertFlatStateIdentity(machine, label);
|
|
1886
2065
|
const declaredActors = collectInvokeSources(machine);
|
|
1887
2066
|
const resumableStateIds =
|
|
1888
2067
|
spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
|
|
@@ -1987,6 +2166,20 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
1987
2166
|
// ports.callPlayer / callCaptain / callJudge see the right cancellation
|
|
1988
2167
|
// source. undefined between turns; set by the public boundaries.
|
|
1989
2168
|
let activeSignal: AbortSignal | undefined;
|
|
2169
|
+
// Immutable cancellation provenance for the active public boundary. A
|
|
2170
|
+
// nested resume widens it to include both invocation and resume signals;
|
|
2171
|
+
// mutable `activeSignal` alone cannot classify a late invocation reason.
|
|
2172
|
+
let activeAborts: AbortReasonClassifier | undefined;
|
|
2173
|
+
// The bridge binds the provenance of a child result immediately before
|
|
2174
|
+
// its promise actor settles. The next root snapshot/error consumes this
|
|
2175
|
+
// one-shot so background settlement emissions retain their owner.
|
|
2176
|
+
let actorSettlementAborts: AbortReasonClassifier | undefined;
|
|
2177
|
+
let actorSettlementErrorAborts: AbortReasonClassifier | undefined;
|
|
2178
|
+
// Exact cancellation observed by an emission owned by the active
|
|
2179
|
+
// boundary. Ordinary runs settle from their signal/state; apply also
|
|
2180
|
+
// needs this phase-local evidence to fold a pre-publication failure into
|
|
2181
|
+
// its accepted receipt.
|
|
2182
|
+
let activeAbortEmission: unknown;
|
|
1990
2183
|
let activeTurnId: number | undefined;
|
|
1991
2184
|
let controlPlaneError: unknown;
|
|
1992
2185
|
// Previous root-machine state for the inspect-driven telemetry /
|
|
@@ -2025,7 +2218,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2025
2218
|
// All trace, state-telemetry, and status work shares this one queue.
|
|
2026
2219
|
// Inspection callbacks enqueue a complete ordered batch synchronously;
|
|
2027
2220
|
// imperative boundaries await their queued work directly.
|
|
2028
|
-
let emissionFailure: unknown;
|
|
2221
|
+
let emissionFailure: { readonly error: unknown } | undefined;
|
|
2029
2222
|
|
|
2030
2223
|
function bindSession(nextSession: PlaybookSession): PlaybookSession {
|
|
2031
2224
|
const bound = snapshotPlaybookSession(nextSession);
|
|
@@ -2214,20 +2407,40 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2214
2407
|
for (const [key, token] of byKey) privateResumeTokens.set(key, token);
|
|
2215
2408
|
}
|
|
2216
2409
|
|
|
2217
|
-
function enqueueEmission(
|
|
2410
|
+
function enqueueEmission(
|
|
2411
|
+
fn: () => Promise<void>,
|
|
2412
|
+
aborts: AbortReasonClassifier | undefined = activeAborts,
|
|
2413
|
+
): Promise<void> {
|
|
2414
|
+
// The emission belongs to the boundary enqueueing it: a rejection
|
|
2415
|
+
// causally identical to that boundary's abort reason is the
|
|
2416
|
+
// cancellation's own evidence — never latched, so it cannot poison a
|
|
2417
|
+
// later unrelated boundary (DR-036).
|
|
2418
|
+
const enqueueAborts = aborts;
|
|
2218
2419
|
const queued = emissionQueue.add(fn).then(() => undefined);
|
|
2219
2420
|
activeEmissionCalls.add(queued);
|
|
2220
2421
|
void queued.then(
|
|
2221
2422
|
() => activeEmissionCalls.delete(queued),
|
|
2222
2423
|
(error: unknown) => {
|
|
2223
2424
|
activeEmissionCalls.delete(queued);
|
|
2224
|
-
|
|
2425
|
+
if (enqueueAborts?.isAbortReason(error)) {
|
|
2426
|
+
// Record evidence only when it also belongs to the public
|
|
2427
|
+
// boundary that is still active. A background A cancellation
|
|
2428
|
+
// racing an unrelated B boundary is forgiven under A and must
|
|
2429
|
+
// not change B's settlement.
|
|
2430
|
+
if (activeAborts?.isAbortReason(error)) {
|
|
2431
|
+
activeAbortEmission ??= error;
|
|
2432
|
+
}
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
emissionFailure ??= { error };
|
|
2225
2436
|
},
|
|
2226
2437
|
);
|
|
2227
2438
|
return queued;
|
|
2228
2439
|
}
|
|
2229
2440
|
|
|
2230
|
-
async function drainEmissions(
|
|
2441
|
+
async function drainEmissions(
|
|
2442
|
+
_aborts: AbortReasonClassifier | undefined = activeAborts,
|
|
2443
|
+
): Promise<void> {
|
|
2231
2444
|
while (true) {
|
|
2232
2445
|
const active = [...activeEmissionCalls];
|
|
2233
2446
|
if (active.length > 0) await Promise.allSettled(active);
|
|
@@ -2241,8 +2454,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2241
2454
|
}
|
|
2242
2455
|
}
|
|
2243
2456
|
if (emissionFailure !== undefined) {
|
|
2244
|
-
const error = emissionFailure;
|
|
2457
|
+
const { error } = emissionFailure;
|
|
2245
2458
|
emissionFailure = undefined;
|
|
2459
|
+
// The failure was classified as distinct by its enqueue owner. If a
|
|
2460
|
+
// later public boundary drains it, retain that classification in the
|
|
2461
|
+
// boundary latch before throwing; its signal must not reinterpret
|
|
2462
|
+
// the same object as cancellation (DR-036 decision 2).
|
|
2463
|
+
if (activeSignal !== undefined) controlPlaneError ??= error;
|
|
2246
2464
|
throw error;
|
|
2247
2465
|
}
|
|
2248
2466
|
}
|
|
@@ -2293,14 +2511,17 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2293
2511
|
type: PlaybookTraceType,
|
|
2294
2512
|
payload: unknown,
|
|
2295
2513
|
position: TracePosition = {},
|
|
2514
|
+
aborts?: AbortReasonClassifier,
|
|
2296
2515
|
): Promise<void> {
|
|
2297
2516
|
const currentSession = requireSession();
|
|
2298
2517
|
const event = createTraceEvent(type, payload, position);
|
|
2299
|
-
return enqueueEmission(
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2518
|
+
return enqueueEmission(
|
|
2519
|
+
() =>
|
|
2520
|
+
currentSession.ports.emitTelemetry({
|
|
2521
|
+
topic: 'playbook.trace',
|
|
2522
|
+
payload: event,
|
|
2523
|
+
}),
|
|
2524
|
+
aborts,
|
|
2304
2525
|
);
|
|
2305
2526
|
}
|
|
2306
2527
|
|
|
@@ -2388,6 +2609,11 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2388
2609
|
| 'apply.finished',
|
|
2389
2610
|
identity: Record<string, unknown>,
|
|
2390
2611
|
position: TracePosition,
|
|
2612
|
+
// The applicable combined signal: a start-sink rejection causally
|
|
2613
|
+
// identical to its reason is the cancellation itself, not a control
|
|
2614
|
+
// error — the pair finishes `aborted` and nothing latches
|
|
2615
|
+
// (slc/link.md §Abort).
|
|
2616
|
+
signal: AbortSignal,
|
|
2391
2617
|
// Base payload of the best-effort finish emitted when the start sink
|
|
2392
2618
|
// rejects; it defaults to the payload the start carried, which the
|
|
2393
2619
|
// player, judge, and captain pairs take as-is. The apply pair cannot:
|
|
@@ -2399,13 +2625,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2399
2625
|
try {
|
|
2400
2626
|
await emitTrace(startedType, identity, position);
|
|
2401
2627
|
} catch (error) {
|
|
2402
|
-
controlPlaneError ??= error;
|
|
2628
|
+
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
2403
2629
|
try {
|
|
2404
2630
|
await emitTrace(
|
|
2405
2631
|
finishedType,
|
|
2406
2632
|
{
|
|
2407
2633
|
...finishIdentity,
|
|
2408
|
-
status: 'error',
|
|
2634
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
2409
2635
|
error: normalizeError(error),
|
|
2410
2636
|
},
|
|
2411
2637
|
position,
|
|
@@ -2434,7 +2660,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2434
2660
|
signal.throwIfAborted();
|
|
2435
2661
|
resume = selectPlayerResume(roleId, playerId);
|
|
2436
2662
|
} catch (error) {
|
|
2437
|
-
if (!signal
|
|
2663
|
+
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
2438
2664
|
throw error;
|
|
2439
2665
|
}
|
|
2440
2666
|
const callId = `player-${++playerCallSequence}`;
|
|
@@ -2460,6 +2686,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2460
2686
|
'player.call.finished',
|
|
2461
2687
|
{ ...identity, prompt },
|
|
2462
2688
|
position,
|
|
2689
|
+
signal,
|
|
2463
2690
|
);
|
|
2464
2691
|
await emitTrace(
|
|
2465
2692
|
'player.call.finished',
|
|
@@ -2471,10 +2698,12 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2471
2698
|
activePlayerKeys.add(playerKey);
|
|
2472
2699
|
|
|
2473
2700
|
try {
|
|
2474
|
-
await
|
|
2701
|
+
await emitCallStarted(
|
|
2475
2702
|
'player.call.started',
|
|
2703
|
+
'player.call.finished',
|
|
2476
2704
|
{ ...identity, prompt },
|
|
2477
2705
|
position,
|
|
2706
|
+
signal,
|
|
2478
2707
|
);
|
|
2479
2708
|
|
|
2480
2709
|
let rawResult: unknown;
|
|
@@ -2494,13 +2723,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2494
2723
|
// a late result mutate continuity or publish a successful finish.
|
|
2495
2724
|
signal.throwIfAborted();
|
|
2496
2725
|
} catch (error) {
|
|
2497
|
-
if (!signal
|
|
2726
|
+
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
2498
2727
|
try {
|
|
2499
2728
|
await emitTrace(
|
|
2500
2729
|
'player.call.finished',
|
|
2501
2730
|
{
|
|
2502
2731
|
...identity,
|
|
2503
|
-
status: signal
|
|
2732
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
2504
2733
|
error: normalizeError(error),
|
|
2505
2734
|
},
|
|
2506
2735
|
position,
|
|
@@ -2517,7 +2746,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2517
2746
|
try {
|
|
2518
2747
|
result = validatePlayerResult(rawResult);
|
|
2519
2748
|
} catch (error) {
|
|
2520
|
-
if (!signal
|
|
2749
|
+
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
2521
2750
|
try {
|
|
2522
2751
|
await emitTrace(
|
|
2523
2752
|
'player.call.finished',
|
|
@@ -2533,7 +2762,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2533
2762
|
try {
|
|
2534
2763
|
updatePlayerResume(roleId, playerId, result);
|
|
2535
2764
|
} catch (error) {
|
|
2536
|
-
if (!signal
|
|
2765
|
+
if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
|
|
2537
2766
|
try {
|
|
2538
2767
|
await emitTrace(
|
|
2539
2768
|
'player.call.finished',
|
|
@@ -2589,6 +2818,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2589
2818
|
'judge.call.finished',
|
|
2590
2819
|
{ ...identity, prompt },
|
|
2591
2820
|
position,
|
|
2821
|
+
signal,
|
|
2592
2822
|
);
|
|
2593
2823
|
let reply: unknown;
|
|
2594
2824
|
try {
|
|
@@ -2607,7 +2837,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2607
2837
|
'judge.call.finished',
|
|
2608
2838
|
{
|
|
2609
2839
|
...identity,
|
|
2610
|
-
status: signal
|
|
2840
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
2611
2841
|
error: normalizeError(error),
|
|
2612
2842
|
},
|
|
2613
2843
|
position,
|
|
@@ -2671,6 +2901,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2671
2901
|
'captain.call.finished',
|
|
2672
2902
|
{ ...identity, prompt },
|
|
2673
2903
|
position,
|
|
2904
|
+
signal,
|
|
2674
2905
|
);
|
|
2675
2906
|
let rawResult: unknown;
|
|
2676
2907
|
try {
|
|
@@ -2693,7 +2924,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2693
2924
|
'captain.call.finished',
|
|
2694
2925
|
{
|
|
2695
2926
|
...identity,
|
|
2696
|
-
status: signal
|
|
2927
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
2697
2928
|
error: normalizeError(error),
|
|
2698
2929
|
},
|
|
2699
2930
|
position,
|
|
@@ -2784,7 +3015,9 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2784
3015
|
() => activeSignal,
|
|
2785
3016
|
boundary,
|
|
2786
3017
|
(error) => {
|
|
2787
|
-
if (
|
|
3018
|
+
if (activeSignal === undefined || !isAbortFailure(error, activeSignal)) {
|
|
3019
|
+
controlPlaneError ??= error;
|
|
3020
|
+
}
|
|
2788
3021
|
},
|
|
2789
3022
|
);
|
|
2790
3023
|
}
|
|
@@ -2870,7 +3103,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2870
3103
|
// failure state (PBRT-47); everything else here — a drained
|
|
2871
3104
|
// emission failure, prompt composition, the port itself,
|
|
2872
3105
|
// adjudication — is control plane.
|
|
2873
|
-
if (!active
|
|
3106
|
+
if (!isAbortFailure(error, active) && !isFsmResultFailure(error)) {
|
|
2874
3107
|
controlPlaneError ??= error;
|
|
2875
3108
|
}
|
|
2876
3109
|
throw error;
|
|
@@ -2897,53 +3130,187 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2897
3130
|
const failedGuard = guards[1] ?? guards[0];
|
|
2898
3131
|
const cwd = boundScriptCwd ?? process.cwd();
|
|
2899
3132
|
const ports = runtimePorts ?? requireHostPorts();
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
3133
|
+
// slc/link.md §Script execution: an already-aborted turn spawns
|
|
3134
|
+
// nothing, and the thrown signal reason keeps the rejection
|
|
3135
|
+
// causally classified as the abort it is.
|
|
3136
|
+
active.throwIfAborted();
|
|
3137
|
+
|
|
3138
|
+
// Abort ownership — the listener that terminates the group and
|
|
3139
|
+
// the escalation timer — spans the whole invocation body, not
|
|
3140
|
+
// just the spawn-to-close window: an abort landing during the
|
|
3141
|
+
// post-exit emission tail must still kill surviving group
|
|
3142
|
+
// members before the actor settles (slc/link.md §Script
|
|
3143
|
+
// execution). One finally releases both.
|
|
3144
|
+
let child: ReturnType<typeof spawn> | undefined;
|
|
3145
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
3146
|
+
const signalGroup = (sig: NodeJS.Signals): void => {
|
|
3147
|
+
if (child?.pid !== undefined) {
|
|
3148
|
+
try {
|
|
3149
|
+
process.kill(-child.pid, sig);
|
|
3150
|
+
} catch {
|
|
3151
|
+
// Confirmation belongs to the bounded liveness probe below:
|
|
3152
|
+
// a failed signal can mean ESRCH, EPERM, or another fault.
|
|
3153
|
+
}
|
|
2919
3154
|
}
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
3155
|
+
};
|
|
3156
|
+
// After a SIGKILL is posted, settlement waits for the group to
|
|
3157
|
+
// stop being signalable — bounded by the same grace so an
|
|
3158
|
+
// unreapable member outside the runtime's control cannot stall
|
|
3159
|
+
// the turn forever. Observed teardown is milliseconds.
|
|
3160
|
+
let groupGonePromise: Promise<void> | undefined;
|
|
3161
|
+
const awaitGroupGone = (): Promise<void> => {
|
|
3162
|
+
const pid = child?.pid;
|
|
3163
|
+
if (pid === undefined) return Promise.resolve();
|
|
3164
|
+
groupGonePromise ??= (async () => {
|
|
3165
|
+
const teardownFailure = (
|
|
3166
|
+
message: string,
|
|
3167
|
+
cause?: unknown,
|
|
3168
|
+
): ScriptProcessGroupTeardownError => {
|
|
3169
|
+
const failure = new ScriptProcessGroupTeardownError(
|
|
3170
|
+
pid,
|
|
3171
|
+
message,
|
|
3172
|
+
cause,
|
|
3173
|
+
);
|
|
3174
|
+
// A teardown failure is not an authored script result.
|
|
3175
|
+
// Surface it at the active public boundary even though
|
|
3176
|
+
// XState also routes the rejected actor through onError.
|
|
3177
|
+
controlPlaneError ??= failure;
|
|
3178
|
+
return failure;
|
|
3179
|
+
};
|
|
3180
|
+
const deadline = Date.now() + SCRIPT_ABORT_KILL_GRACE_MS;
|
|
3181
|
+
let lastProbeError: unknown;
|
|
3182
|
+
for (;;) {
|
|
3183
|
+
try {
|
|
3184
|
+
process.kill(-pid, 0);
|
|
3185
|
+
} catch (error) {
|
|
3186
|
+
if (isNoSuchProcess(error)) return;
|
|
3187
|
+
// EPERM confirms that at least one process in the group
|
|
3188
|
+
// still exists but is not signalable by this process. Keep
|
|
3189
|
+
// waiting for ESRCH within the bound; every other probe
|
|
3190
|
+
// error makes confirmation itself unreliable immediately.
|
|
3191
|
+
if (!isProcessPermissionDenied(error)) {
|
|
3192
|
+
throw teardownFailure(
|
|
3193
|
+
'the liveness probe failed',
|
|
3194
|
+
error,
|
|
3195
|
+
);
|
|
3196
|
+
}
|
|
3197
|
+
lastProbeError = error;
|
|
3198
|
+
}
|
|
3199
|
+
if (Date.now() >= deadline) {
|
|
3200
|
+
throw teardownFailure(
|
|
3201
|
+
`the group remained signalable after ${SCRIPT_ABORT_KILL_GRACE_MS}ms`,
|
|
3202
|
+
lastProbeError,
|
|
3203
|
+
);
|
|
3204
|
+
}
|
|
3205
|
+
await new Promise((tick) => setTimeout(tick, 5));
|
|
3206
|
+
}
|
|
3207
|
+
})();
|
|
3208
|
+
return groupGonePromise;
|
|
3209
|
+
};
|
|
3210
|
+
const onAbort = (): void => {
|
|
3211
|
+
signalGroup('SIGTERM');
|
|
3212
|
+
killTimer = setTimeout(
|
|
3213
|
+
() => signalGroup('SIGKILL'),
|
|
3214
|
+
SCRIPT_ABORT_KILL_GRACE_MS,
|
|
3215
|
+
);
|
|
3216
|
+
};
|
|
3217
|
+
// An abort observed once the shell has already exited rejects
|
|
3218
|
+
// with the signal's reason before guard resolution and before
|
|
3219
|
+
// starting any further script emission — after killing whatever
|
|
3220
|
+
// group members outlived the shell. The shell's own exit ended
|
|
3221
|
+
// the TERM grace's purpose, so escalation is immediate here.
|
|
3222
|
+
const settleIfAborted = async (): Promise<void> => {
|
|
3223
|
+
if (!active.aborted) return;
|
|
3224
|
+
signalGroup('SIGKILL');
|
|
3225
|
+
await awaitGroupGone();
|
|
3226
|
+
active.throwIfAborted();
|
|
3227
|
+
};
|
|
3228
|
+
let invocationFailed = false;
|
|
3229
|
+
try {
|
|
3230
|
+
const exitStatus = await new Promise<number>(
|
|
3231
|
+
(resolve, reject) => {
|
|
3232
|
+
try {
|
|
3233
|
+
// detached: the shell leads its own POSIX process group,
|
|
3234
|
+
// so an abort can terminate the command's whole group — a
|
|
3235
|
+
// lone SIGTERM to the wrapper never reaches backgrounded
|
|
3236
|
+
// members.
|
|
3237
|
+
child = spawn('sh', ['-c', input.command], {
|
|
3238
|
+
cwd,
|
|
3239
|
+
stdio: 'ignore',
|
|
3240
|
+
detached: true,
|
|
3241
|
+
});
|
|
3242
|
+
} catch (error) {
|
|
3243
|
+
reject(error);
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
// On abort, terminate the group and escalate — but settle
|
|
3247
|
+
// only from 'close', after the shell itself has exited, so
|
|
3248
|
+
// the turn never reports quiescence while the script still
|
|
3249
|
+
// runs (slc/link.md §Abort). SIGKILL is untrappable, so
|
|
3250
|
+
// 'close' is bounded by the grace.
|
|
3251
|
+
active.addEventListener('abort', onAbort, { once: true });
|
|
3252
|
+
child.on('error', (error) => {
|
|
3253
|
+
reject(error);
|
|
3254
|
+
});
|
|
3255
|
+
child.on('close', (code) => {
|
|
3256
|
+
if (active.aborted) {
|
|
3257
|
+
// The shell may exit cooperatively on the group SIGTERM
|
|
3258
|
+
// while a TERM-immune same-group descendant survives;
|
|
3259
|
+
// the group stays addressable while any member lives,
|
|
3260
|
+
// so kill it and await its disappearance before
|
|
3261
|
+
// settling (slc/link.md §Script execution).
|
|
3262
|
+
signalGroup('SIGKILL');
|
|
3263
|
+
void awaitGroupGone().then(
|
|
3264
|
+
() =>
|
|
3265
|
+
reject(active.reason),
|
|
3266
|
+
reject,
|
|
3267
|
+
);
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
resolve(typeof code === 'number' ? code : 1);
|
|
3271
|
+
});
|
|
3272
|
+
},
|
|
3273
|
+
);
|
|
2930
3274
|
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
3275
|
+
await settleIfAborted();
|
|
3276
|
+
|
|
3277
|
+
await ports.emitStatus(
|
|
3278
|
+
`Executed script for ${input.stateId} (exit ${exitStatus}).`,
|
|
3279
|
+
);
|
|
3280
|
+
await settleIfAborted();
|
|
3281
|
+
await ports.emitTelemetry({
|
|
3282
|
+
topic: 'playbook.script',
|
|
3283
|
+
payload: {
|
|
3284
|
+
stateId: input.stateId,
|
|
3285
|
+
sourceItem: input.sourceItem,
|
|
3286
|
+
exitStatus,
|
|
3287
|
+
},
|
|
3288
|
+
});
|
|
3289
|
+
await settleIfAborted();
|
|
2942
3290
|
|
|
2943
|
-
|
|
2944
|
-
|
|
3291
|
+
if (exitStatus === 0) {
|
|
3292
|
+
return { guard: okGuard, exitStatus: 0 };
|
|
3293
|
+
}
|
|
3294
|
+
return { guard: failedGuard, exitStatus };
|
|
3295
|
+
} catch (error) {
|
|
3296
|
+
// Preserve the invocation's authoritative exact cancellation or
|
|
3297
|
+
// distinct sink failure after teardown succeeds. The finally
|
|
3298
|
+
// block may replace it only with a distinct teardown failure
|
|
3299
|
+
// when the process group cannot be confirmed gone.
|
|
3300
|
+
invocationFailed = true;
|
|
3301
|
+
throw error;
|
|
3302
|
+
} finally {
|
|
3303
|
+
try {
|
|
3304
|
+
if (active.aborted) {
|
|
3305
|
+
signalGroup('SIGKILL');
|
|
3306
|
+
await awaitGroupGone();
|
|
3307
|
+
if (!invocationFailed) active.throwIfAborted();
|
|
3308
|
+
}
|
|
3309
|
+
} finally {
|
|
3310
|
+
active.removeEventListener('abort', onAbort);
|
|
3311
|
+
if (killTimer !== undefined) clearTimeout(killTimer);
|
|
3312
|
+
}
|
|
2945
3313
|
}
|
|
2946
|
-
return { guard: failedGuard, exitStatus };
|
|
2947
3314
|
},
|
|
2948
3315
|
);
|
|
2949
3316
|
}
|
|
@@ -2953,7 +3320,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2953
3320
|
getBoundarySignal: () => activeSignal,
|
|
2954
3321
|
callPlaybook: (request, signal) =>
|
|
2955
3322
|
requireHostPorts().callPlaybook(request, signal),
|
|
2956
|
-
emitStarted: async (event) => {
|
|
3323
|
+
emitStarted: async (event, aborts) => {
|
|
2957
3324
|
playbookCallTurnIds.set(event.callId, activeTurnId);
|
|
2958
3325
|
await emitTrace(
|
|
2959
3326
|
'playbook.call.started',
|
|
@@ -2966,9 +3333,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2966
3333
|
...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
|
|
2967
3334
|
callId: event.callId,
|
|
2968
3335
|
},
|
|
3336
|
+
aborts,
|
|
2969
3337
|
);
|
|
2970
3338
|
},
|
|
2971
|
-
emitFinished: async (event) => {
|
|
3339
|
+
emitFinished: async (event, aborts) => {
|
|
2972
3340
|
const turnId = playbookCallTurnIds.get(event.callId);
|
|
2973
3341
|
try {
|
|
2974
3342
|
await emitTrace(
|
|
@@ -2983,20 +3351,35 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
2983
3351
|
...(turnId !== undefined ? { turnId } : {}),
|
|
2984
3352
|
callId: event.callId,
|
|
2985
3353
|
},
|
|
3354
|
+
aborts,
|
|
2986
3355
|
);
|
|
2987
3356
|
} finally {
|
|
2988
3357
|
playbookCallTurnIds.delete(event.callId);
|
|
2989
3358
|
}
|
|
2990
3359
|
},
|
|
2991
3360
|
drain: drainEmissions,
|
|
2992
|
-
bindResumeSignal: (signal) => {
|
|
3361
|
+
bindResumeSignal: (signal, aborts) => {
|
|
2993
3362
|
activeSignal = signal;
|
|
3363
|
+
activeAborts = aborts ?? abortReasonClassifier(signal);
|
|
2994
3364
|
},
|
|
2995
|
-
|
|
2996
|
-
|
|
3365
|
+
bindActorSettlement: (aborts) => {
|
|
3366
|
+
actorSettlementAborts = aborts;
|
|
3367
|
+
},
|
|
3368
|
+
onControlPlaneError: (error, aborts) => {
|
|
3369
|
+
// The shared bridge classifies before reporting against its own
|
|
3370
|
+
// invocation-and-resume signals; classify once more here against
|
|
3371
|
+
// the boundary signal so a report that is the active boundary's
|
|
3372
|
+
// exact abort reason can never masquerade as a control error
|
|
3373
|
+
// (slc/link.md §Abort).
|
|
3374
|
+
if (
|
|
3375
|
+
!aborts?.isAbortReason(error) &&
|
|
3376
|
+
!activeAborts?.isAbortReason(error)
|
|
3377
|
+
) {
|
|
3378
|
+
controlPlaneError ??= error;
|
|
3379
|
+
}
|
|
2997
3380
|
},
|
|
2998
|
-
onBackgroundError: (error) => {
|
|
2999
|
-
emissionFailure ??= error;
|
|
3381
|
+
onBackgroundError: (error, aborts) => {
|
|
3382
|
+
if (!aborts?.isAbortReason(error)) emissionFailure ??= { error };
|
|
3000
3383
|
},
|
|
3001
3384
|
});
|
|
3002
3385
|
|
|
@@ -3017,11 +3400,9 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3017
3400
|
previousState: previousState ?? null,
|
|
3018
3401
|
state,
|
|
3019
3402
|
};
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
payload.pendingBossQuestion = pendingBossQuestion;
|
|
3024
|
-
}
|
|
3403
|
+
const pendingBossQuestion = pendingBossQuestionForState(state, context);
|
|
3404
|
+
if (pendingBossQuestion !== undefined) {
|
|
3405
|
+
payload.pendingBossQuestion = pendingBossQuestion;
|
|
3025
3406
|
}
|
|
3026
3407
|
if (state.stateId === 'failed') {
|
|
3027
3408
|
const lastError = normalizeErrorFull(context.lastError);
|
|
@@ -3035,6 +3416,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3035
3416
|
state: PlaybookState,
|
|
3036
3417
|
statuses: readonly ScheduledStatus[],
|
|
3037
3418
|
position: TracePosition,
|
|
3419
|
+
aborts?: AbortReasonClassifier,
|
|
3038
3420
|
): void {
|
|
3039
3421
|
const currentSession = requireSession();
|
|
3040
3422
|
const transitionTrace = createTraceEvent(
|
|
@@ -3056,28 +3438,61 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3056
3438
|
position,
|
|
3057
3439
|
),
|
|
3058
3440
|
}));
|
|
3059
|
-
void enqueueEmission(
|
|
3060
|
-
|
|
3061
|
-
topic: 'playbook.trace',
|
|
3062
|
-
payload: transitionTrace,
|
|
3063
|
-
});
|
|
3064
|
-
await currentSession.ports.emitTelemetry({
|
|
3065
|
-
topic: 'playbook.fsm.state',
|
|
3066
|
-
payload,
|
|
3067
|
-
});
|
|
3068
|
-
for (const status of statusEmissions) {
|
|
3441
|
+
void enqueueEmission(
|
|
3442
|
+
async () => {
|
|
3069
3443
|
await currentSession.ports.emitTelemetry({
|
|
3070
3444
|
topic: 'playbook.trace',
|
|
3071
|
-
payload:
|
|
3445
|
+
payload: transitionTrace,
|
|
3072
3446
|
});
|
|
3073
|
-
await currentSession.ports.
|
|
3074
|
-
|
|
3075
|
-
|
|
3447
|
+
await currentSession.ports.emitTelemetry({
|
|
3448
|
+
topic: 'playbook.fsm.state',
|
|
3449
|
+
payload,
|
|
3450
|
+
});
|
|
3451
|
+
for (const status of statusEmissions) {
|
|
3452
|
+
await currentSession.ports.emitTelemetry({
|
|
3453
|
+
topic: 'playbook.trace',
|
|
3454
|
+
payload: status.trace,
|
|
3455
|
+
});
|
|
3456
|
+
await currentSession.ports.emitStatus(status.message, status.data);
|
|
3457
|
+
}
|
|
3458
|
+
},
|
|
3459
|
+
aborts,
|
|
3460
|
+
).catch(() => undefined);
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
// One classifying latch for every runtime-observed error — inspection
|
|
3464
|
+
// failures and root-actor errors alike. Outside a boundary the error
|
|
3465
|
+
// rides the emission channel, which the next boundary's (or init's)
|
|
3466
|
+
// drain throws; inside a boundary it is a control-plane error unless it
|
|
3467
|
+
// is the boundary signal's own abort reason (slc/link.md §Abort).
|
|
3468
|
+
function latchRuntimeError(
|
|
3469
|
+
error: unknown,
|
|
3470
|
+
aborts: AbortReasonClassifier | undefined = activeAborts,
|
|
3471
|
+
): void {
|
|
3472
|
+
if (aborts?.isAbortReason(error)) return;
|
|
3473
|
+
if (activeSignal === undefined) emissionFailure ??= { error };
|
|
3474
|
+
else controlPlaneError ??= error;
|
|
3076
3475
|
}
|
|
3077
3476
|
|
|
3078
|
-
function
|
|
3079
|
-
|
|
3080
|
-
|
|
3477
|
+
function consumeActorSettlementAborts(
|
|
3478
|
+
forSnapshot = false,
|
|
3479
|
+
): AbortReasonClassifier | undefined {
|
|
3480
|
+
const aborts = actorSettlementAborts ?? actorSettlementErrorAborts;
|
|
3481
|
+
actorSettlementAborts = undefined;
|
|
3482
|
+
actorSettlementErrorAborts = undefined;
|
|
3483
|
+
if (forSnapshot && aborts !== undefined) {
|
|
3484
|
+
// XState can report an errored root through both its inspection
|
|
3485
|
+
// snapshot and subscriber. Keep the same provenance through that
|
|
3486
|
+
// synchronous notification only; an ordinary transition must not
|
|
3487
|
+
// lend it to a later unrelated actor error.
|
|
3488
|
+
actorSettlementErrorAborts = aborts;
|
|
3489
|
+
queueMicrotask(() => {
|
|
3490
|
+
if (actorSettlementErrorAborts === aborts) {
|
|
3491
|
+
actorSettlementErrorAborts = undefined;
|
|
3492
|
+
}
|
|
3493
|
+
});
|
|
3494
|
+
}
|
|
3495
|
+
return aborts;
|
|
3081
3496
|
}
|
|
3082
3497
|
|
|
3083
3498
|
// PBRT-6: the single seam that stops this runtime's actor. Stopping a
|
|
@@ -3121,6 +3536,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3121
3536
|
if (inspectionEvent.type !== '@xstate.snapshot') return;
|
|
3122
3537
|
if (inspectionEvent.actorRef !== builtActor) return;
|
|
3123
3538
|
if (suppressInspectionEmissions) return;
|
|
3539
|
+
const settlementAborts = consumeActorSettlementAborts(true);
|
|
3124
3540
|
try {
|
|
3125
3541
|
const snap = inspectionEvent.snapshot;
|
|
3126
3542
|
const state = normalizePlaybookSnapshot(snap);
|
|
@@ -3148,13 +3564,25 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3148
3564
|
state,
|
|
3149
3565
|
statuses,
|
|
3150
3566
|
tracePositionForActiveTurn(),
|
|
3567
|
+
settlementAborts,
|
|
3151
3568
|
);
|
|
3152
3569
|
priorState = state;
|
|
3153
3570
|
} catch (error) {
|
|
3154
|
-
|
|
3571
|
+
latchRuntimeError(error, settlementAborts);
|
|
3155
3572
|
}
|
|
3156
3573
|
},
|
|
3157
3574
|
});
|
|
3575
|
+
// A synchronously-errored actor is already quiescent, so the turn's
|
|
3576
|
+
// quiescence wait never subscribes and XState would report the error
|
|
3577
|
+
// as unhandled after the boundary returns. Observe it through the
|
|
3578
|
+
// classifying latch: mid-boundary it is the control-plane error
|
|
3579
|
+
// unless it is the abort reason itself; at startup it rides the
|
|
3580
|
+
// emission channel so `init`'s own drain rejects with it and the
|
|
3581
|
+
// failed-start cleanup runs (slc/link.md §Session lifecycle).
|
|
3582
|
+
builtActor.subscribe({
|
|
3583
|
+
error: (error) =>
|
|
3584
|
+
latchRuntimeError(error, consumeActorSettlementAborts()),
|
|
3585
|
+
});
|
|
3158
3586
|
return builtActor;
|
|
3159
3587
|
}
|
|
3160
3588
|
|
|
@@ -3177,14 +3605,20 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3177
3605
|
const output = (
|
|
3178
3606
|
actor?.getSnapshot() as { output?: unknown } | undefined
|
|
3179
3607
|
)?.output;
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3608
|
+
const stateDescription = stateDescriptionFor(state);
|
|
3609
|
+
return {
|
|
3610
|
+
outcome,
|
|
3611
|
+
state,
|
|
3612
|
+
...(stateDescription === undefined ? {} : { stateDescription }),
|
|
3613
|
+
...(output === undefined
|
|
3614
|
+
? {}
|
|
3615
|
+
: {
|
|
3616
|
+
output: snapshotJsonValue(
|
|
3617
|
+
output,
|
|
3618
|
+
'terminal playbook output',
|
|
3619
|
+
),
|
|
3620
|
+
}),
|
|
3621
|
+
};
|
|
3188
3622
|
}
|
|
3189
3623
|
const failure =
|
|
3190
3624
|
error ??
|
|
@@ -3203,15 +3637,23 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3203
3637
|
|
|
3204
3638
|
function settledOutcome(signal: AbortSignal): BossSettlementOutcome {
|
|
3205
3639
|
if (nestedBridge.getPendingCall()) return 'suspended';
|
|
3206
|
-
if (signal.aborted) return 'aborted';
|
|
3207
3640
|
const state = currentState();
|
|
3208
3641
|
if (state.status === 'error') {
|
|
3642
|
+
// An errored actor outranks a coincident abort unless the actor's
|
|
3643
|
+
// error is the abort reason itself (slc/link.md §Abort).
|
|
3209
3644
|
const actorError = (
|
|
3210
3645
|
actor?.getSnapshot() as { error?: unknown } | undefined
|
|
3211
3646
|
)?.error;
|
|
3647
|
+
if (actorError !== undefined && isAbortFailure(actorError, signal)) {
|
|
3648
|
+
return 'aborted';
|
|
3649
|
+
}
|
|
3212
3650
|
throw actorError ?? new Error(`${label} actor entered error status`);
|
|
3213
3651
|
}
|
|
3652
|
+
// Terminal completion outranks a coincident abort: the work finished,
|
|
3653
|
+
// and reporting `aborted` would hide a terminal machine behind a
|
|
3654
|
+
// settlement a later turn silently restarts (slc/link.md §Abort).
|
|
3214
3655
|
if (state.status === 'done') return 'terminal';
|
|
3656
|
+
if (signal.aborted) return 'aborted';
|
|
3215
3657
|
if (state.stateId === 'failed') return 'failed';
|
|
3216
3658
|
return 'quiescent';
|
|
3217
3659
|
}
|
|
@@ -3288,6 +3730,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3288
3730
|
savedPorts = undefined;
|
|
3289
3731
|
runtimePorts = undefined;
|
|
3290
3732
|
activeSignal = undefined;
|
|
3733
|
+
activeAborts = undefined;
|
|
3734
|
+
actorSettlementAborts = undefined;
|
|
3735
|
+
actorSettlementErrorAborts = undefined;
|
|
3736
|
+
activeAbortEmission = undefined;
|
|
3291
3737
|
activeTurnId = undefined;
|
|
3292
3738
|
controlPlaneError = undefined;
|
|
3293
3739
|
emissionFailure = undefined;
|
|
@@ -3323,32 +3769,57 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3323
3769
|
);
|
|
3324
3770
|
}
|
|
3325
3771
|
|
|
3772
|
+
// DR-034: where the artifact names the FSM context member its entry
|
|
3773
|
+
// action copies the exact Boss text into, that member of the live
|
|
3774
|
+
// snapshot is the retry payload's source. The persisted machine snapshot
|
|
3775
|
+
// carries it, so the candidate derives identically in the process that
|
|
3776
|
+
// exported the snapshot and in one that restored it, and a failure
|
|
3777
|
+
// reached after a Boss reply — whose recorded event the failure state
|
|
3778
|
+
// refuses — is recoverable too. Naming the member is the artifact's
|
|
3779
|
+
// statement that it holds the entry text: a same-named member is never
|
|
3780
|
+
// assumed, since inferring one would turn any matching context member
|
|
3781
|
+
// into a replay payload without its author saying so.
|
|
3782
|
+
// Declared and absent or empty excludes the candidate rather than
|
|
3783
|
+
// falling back to the record, which would make the action depend on the
|
|
3784
|
+
// process again — the very thing this source exists to end.
|
|
3785
|
+
function retryEventFrom(snapshot: unknown): EventObject | undefined {
|
|
3786
|
+
const entryEvent = spec.entryEvent;
|
|
3787
|
+
if (entryEvent?.contextField === undefined) return lastBossEvent;
|
|
3788
|
+
const context = (snapshot as { context?: unknown } | null)?.context;
|
|
3789
|
+
const text = isPlainObject(context)
|
|
3790
|
+
? context[entryEvent.contextField]
|
|
3791
|
+
: undefined;
|
|
3792
|
+
if (typeof text !== 'string' || text.trim() === '') return undefined;
|
|
3793
|
+
return { type: entryEvent.type, [entryEvent.textField]: text };
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3326
3796
|
// The failure-state retry entry replays the recorded last classified
|
|
3327
|
-
// event with its recorded payload
|
|
3328
|
-
//
|
|
3329
|
-
//
|
|
3797
|
+
// event with its recorded payload, or the entry event the declared
|
|
3798
|
+
// context member above sources. A candidate whose event the live
|
|
3799
|
+
// snapshot does not accept — or whose payload the runtime can source
|
|
3800
|
+
// from neither — is excluded rather than completed with invented text.
|
|
3330
3801
|
function retryActionFor(
|
|
3331
3802
|
snapshot: unknown,
|
|
3332
3803
|
stateId: string | undefined,
|
|
3333
3804
|
): DerivedControlAction | undefined {
|
|
3334
|
-
if (stateId !== 'failed'
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
if (!snapshotCan(snapshot,
|
|
3805
|
+
if (stateId !== 'failed') return undefined;
|
|
3806
|
+
const retryEvent = retryEventFrom(snapshot);
|
|
3807
|
+
if (retryEvent === undefined) return undefined;
|
|
3808
|
+
if (!snapshotCan(snapshot, retryEvent)) return undefined;
|
|
3338
3809
|
// A recorded explicit-state-jump event names the exact state its
|
|
3339
3810
|
// replay re-enters: the root BOSS_INTERRUPT shape is a guarded
|
|
3340
3811
|
// multi-arm list keyed on `targetId`, so the first configured arm
|
|
3341
3812
|
// may label a different state than the one the recorded event
|
|
3342
3813
|
// actually resumes.
|
|
3343
3814
|
const recordedTargetId =
|
|
3344
|
-
|
|
3345
|
-
? (
|
|
3815
|
+
retryEvent.type === JUMP_EVENT_TYPE
|
|
3816
|
+
? (retryEvent as { targetId?: unknown }).targetId
|
|
3346
3817
|
: undefined;
|
|
3347
3818
|
const target =
|
|
3348
3819
|
typeof recordedTargetId === 'string' &&
|
|
3349
3820
|
recordedTargetId.trim().length > 0
|
|
3350
3821
|
? recordedTargetId
|
|
3351
|
-
: firstTransitionTarget(machine, stateId,
|
|
3822
|
+
: firstTransitionTarget(machine, stateId, retryEvent.type);
|
|
3352
3823
|
// PBRT-52: a label is written from a source state description, never
|
|
3353
3824
|
// from an identifier. Falling back to the target id — or, with no
|
|
3354
3825
|
// resolvable target, to the FSM event type — makes the label *be* the
|
|
@@ -3362,10 +3833,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3362
3833
|
if (description === undefined) return undefined;
|
|
3363
3834
|
return {
|
|
3364
3835
|
action: {
|
|
3365
|
-
id: `retry:${
|
|
3836
|
+
id: `retry:${retryEvent.type}`,
|
|
3366
3837
|
label: `Retry: ${description}`,
|
|
3367
3838
|
},
|
|
3368
|
-
event:
|
|
3839
|
+
event: retryEvent,
|
|
3369
3840
|
};
|
|
3370
3841
|
}
|
|
3371
3842
|
|
|
@@ -3559,7 +4030,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3559
4030
|
);
|
|
3560
4031
|
const context = (actor.getSnapshot() as { context?: unknown })
|
|
3561
4032
|
.context as Record<string, unknown>;
|
|
3562
|
-
const pending =
|
|
4033
|
+
const pending = pendingBossQuestionForState(state, context ?? {});
|
|
3563
4034
|
return {
|
|
3564
4035
|
schemaVersion: 3,
|
|
3565
4036
|
playbookId: session.playbookId,
|
|
@@ -3658,7 +4129,26 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3658
4129
|
suppressInspectionEmissions = true;
|
|
3659
4130
|
actor = buildActor(runtimePorts, boundSnapshot.machine);
|
|
3660
4131
|
actor.start();
|
|
3661
|
-
|
|
4132
|
+
// A start-time actor error rides the startup emission channel
|
|
4133
|
+
// (latchRuntimeError); consume both latches here so the original
|
|
4134
|
+
// error outranks the derived status check below.
|
|
4135
|
+
{
|
|
4136
|
+
const startupFailure = emissionFailure;
|
|
4137
|
+
if (
|
|
4138
|
+
controlPlaneError !== undefined ||
|
|
4139
|
+
startupFailure !== undefined
|
|
4140
|
+
) {
|
|
4141
|
+
const startupError =
|
|
4142
|
+
controlPlaneError !== undefined
|
|
4143
|
+
? controlPlaneError
|
|
4144
|
+
: startupFailure!.error;
|
|
4145
|
+
controlPlaneError = undefined;
|
|
4146
|
+
if (emissionFailure === startupFailure) {
|
|
4147
|
+
emissionFailure = undefined;
|
|
4148
|
+
}
|
|
4149
|
+
throw startupError;
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
3662
4152
|
const restoredState = normalizePlaybookSnapshot(
|
|
3663
4153
|
actor.getSnapshot(),
|
|
3664
4154
|
suspendedCall === undefined
|
|
@@ -3741,7 +4231,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3741
4231
|
const state = currentState();
|
|
3742
4232
|
const context = ((snapshot as { context?: unknown }).context ??
|
|
3743
4233
|
{}) as Record<string, unknown>;
|
|
3744
|
-
const pending =
|
|
4234
|
+
const pending = pendingBossQuestionForState(state, context);
|
|
3745
4235
|
const lastError = normalizeErrorFull(context.lastError);
|
|
3746
4236
|
const projectedContext = projectControlContext(context);
|
|
3747
4237
|
const stateDescription = stateDescriptionFor(state);
|
|
@@ -3829,6 +4319,8 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3829
4319
|
const position: TracePosition = { turnId, callId };
|
|
3830
4320
|
activeTurnId = turnId;
|
|
3831
4321
|
activeSignal = signal;
|
|
4322
|
+
activeAborts = abortReasonClassifier(signal);
|
|
4323
|
+
activeAbortEmission = undefined;
|
|
3832
4324
|
controlPlaneError = undefined;
|
|
3833
4325
|
// Every receipt variant is normalized and frozen where it is built,
|
|
3834
4326
|
// inside the guarded region, so the recording step below cannot
|
|
@@ -3880,9 +4372,13 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3880
4372
|
// final for their key. Past publication such a failure is therefore
|
|
3881
4373
|
// re-latched onto the emission channel, surfacing from the next
|
|
3882
4374
|
// public boundary's drain, and `apply` still does not throw past
|
|
3883
|
-
// acceptance (PBRT-52).
|
|
4375
|
+
// acceptance (PBRT-52). A delivery rejection causally identical to
|
|
4376
|
+
// this call's own abort reason evidences the cancellation and is
|
|
4377
|
+
// dropped — never carried to a later unrelated boundary
|
|
4378
|
+
// (slc/link.md §Abort).
|
|
3884
4379
|
const latchDeliveryFailure = (error: unknown): void => {
|
|
3885
|
-
|
|
4380
|
+
if (isAbortFailure(error, signal)) return;
|
|
4381
|
+
emissionFailure ??= { error };
|
|
3886
4382
|
};
|
|
3887
4383
|
try {
|
|
3888
4384
|
try {
|
|
@@ -3909,6 +4405,7 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3909
4405
|
'apply.finished',
|
|
3910
4406
|
identity,
|
|
3911
4407
|
position,
|
|
4408
|
+
signal,
|
|
3912
4409
|
preAcceptanceFinish('apply.started trace sink rejected'),
|
|
3913
4410
|
);
|
|
3914
4411
|
// An abort may land while the awaited started emission drains
|
|
@@ -3999,6 +4496,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
3999
4496
|
} catch (error) {
|
|
4000
4497
|
settlementError = error;
|
|
4001
4498
|
}
|
|
4499
|
+
// Exact cancellation is not a control-plane latch, but after apply
|
|
4500
|
+
// acceptance and before publication it is still settlement evidence
|
|
4501
|
+
// and therefore folds into the owed failed receipt (DR-036 §4).
|
|
4502
|
+
settlementError ??= activeAbortEmission;
|
|
4002
4503
|
// Fold before the finish emission, the last point at which the
|
|
4003
4504
|
// traced disposition and the returned one can still be made the
|
|
4004
4505
|
// same value.
|
|
@@ -4037,6 +4538,8 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
4037
4538
|
// wedge every later public boundary behind "another runtime turn
|
|
4038
4539
|
// is active".
|
|
4039
4540
|
activeSignal = undefined;
|
|
4541
|
+
activeAborts = undefined;
|
|
4542
|
+
activeAbortEmission = undefined;
|
|
4040
4543
|
activeTurnId = undefined;
|
|
4041
4544
|
controlPlaneError = undefined;
|
|
4042
4545
|
}
|
|
@@ -4085,136 +4588,171 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
4085
4588
|
const turnId = ++turnSequence;
|
|
4086
4589
|
activeTurnId = turnId;
|
|
4087
4590
|
activeSignal = signal;
|
|
4591
|
+
activeAborts = abortReasonClassifier(signal);
|
|
4592
|
+
activeAbortEmission = undefined;
|
|
4088
4593
|
controlPlaneError = undefined;
|
|
4089
4594
|
let result: PlaybookRunResult | undefined;
|
|
4090
4595
|
let operationError: unknown;
|
|
4596
|
+
// The boundary sentinel releases on every exit: a settlement defect
|
|
4597
|
+
// past the drain — a snapshot normalization throw inside
|
|
4598
|
+
// `runResultFor` included — must never wedge every later public
|
|
4599
|
+
// boundary and `dispose` itself behind "another runtime turn is
|
|
4600
|
+
// active". Mirrors the apply boundary's finally.
|
|
4091
4601
|
try {
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
const trimmed = text.trim();
|
|
4098
|
-
if (trimmed !== '') {
|
|
4099
|
-
const snapshot = actor.getSnapshot();
|
|
4100
|
-
const terminal = snapshot.status === 'done';
|
|
4101
|
-
const stateId = normalizePlaybookSnapshot(snapshot).stateId;
|
|
4102
|
-
if (
|
|
4103
|
-
spec.entryEvent !== undefined &&
|
|
4104
|
-
(stateId === 'ready' || terminal)
|
|
4105
|
-
) {
|
|
4106
|
-
event = {
|
|
4107
|
-
type: spec.entryEvent.type,
|
|
4108
|
-
[spec.entryEvent.textField]: text,
|
|
4109
|
-
};
|
|
4110
|
-
} else {
|
|
4111
|
-
event = await classifyBossText(
|
|
4112
|
-
text,
|
|
4113
|
-
runtimePorts!,
|
|
4114
|
-
signal,
|
|
4115
|
-
snapshot,
|
|
4116
|
-
boundary,
|
|
4117
|
-
boundOptions,
|
|
4118
|
-
);
|
|
4119
|
-
}
|
|
4120
|
-
signal.throwIfAborted();
|
|
4121
|
-
}
|
|
4122
|
-
// Empty input, no-action classifier output, or invalid classifier
|
|
4123
|
-
// output — nothing to send.
|
|
4124
|
-
if (event === undefined) {
|
|
4125
|
-
result = runResultFor('no-action');
|
|
4126
|
-
} else {
|
|
4127
|
-
// 2. Optional Captain-pane classification line: the bare FSM
|
|
4128
|
-
// event type, emitted before the FSM advances.
|
|
4129
|
-
const statusLine = classificationStatus(event);
|
|
4130
|
-
if (statusLine !== undefined) {
|
|
4131
|
-
await runtimePorts!.emitStatus(statusLine);
|
|
4132
|
-
}
|
|
4602
|
+
try {
|
|
4603
|
+
await emitTrace('boss.input.received', { text }, { turnId });
|
|
4604
|
+
// Record the attempted input, then refuse a boundary that entered
|
|
4605
|
+
// aborted before deterministic mapping or the classifier can
|
|
4606
|
+
// perform host-visible work (DR-036 §5).
|
|
4133
4607
|
signal.throwIfAborted();
|
|
4134
|
-
//
|
|
4135
|
-
//
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4608
|
+
// 1. Map the Boss text to an FSM event: deterministic exact entry
|
|
4609
|
+
// where applicable (slc/link.md §Boss-event mapping), judge
|
|
4610
|
+
// classification otherwise.
|
|
4611
|
+
let event: EventObject | undefined;
|
|
4612
|
+
const trimmed = text.trim();
|
|
4613
|
+
if (trimmed !== '') {
|
|
4614
|
+
const snapshot = actor.getSnapshot();
|
|
4615
|
+
const terminal = snapshot.status === 'done';
|
|
4616
|
+
const stateId = normalizePlaybookSnapshot(snapshot).stateId;
|
|
4617
|
+
// PBRT-1 / slc/link.md §Boss-event mapping: the idle entry, the
|
|
4618
|
+
// recoverable failure state, and the reconstructed terminal all
|
|
4619
|
+
// accept exactly one ordinary textual entry event, so delivered
|
|
4620
|
+
// text enters deterministically — no judge call to spend and no
|
|
4621
|
+
// classifier whim to settle a restart as no action. Every other
|
|
4622
|
+
// parked state — a reply wait or an authored mid-workflow
|
|
4623
|
+
// checkpoint — classifies under its own Boss-event contracts.
|
|
4624
|
+
if (
|
|
4625
|
+
spec.entryEvent !== undefined &&
|
|
4626
|
+
(stateId === 'ready' || stateId === 'failed' || terminal)
|
|
4627
|
+
) {
|
|
4628
|
+
event = {
|
|
4629
|
+
type: spec.entryEvent.type,
|
|
4630
|
+
[spec.entryEvent.textField]: text,
|
|
4631
|
+
};
|
|
4632
|
+
} else {
|
|
4633
|
+
event = await classifyBossText(
|
|
4634
|
+
text,
|
|
4635
|
+
runtimePorts!,
|
|
4636
|
+
signal,
|
|
4637
|
+
snapshot,
|
|
4638
|
+
boundary,
|
|
4639
|
+
boundOptions,
|
|
4640
|
+
);
|
|
4641
|
+
}
|
|
4642
|
+
signal.throwIfAborted();
|
|
4142
4643
|
}
|
|
4143
|
-
//
|
|
4144
|
-
//
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4644
|
+
// Empty input, no-action classifier output, or invalid classifier
|
|
4645
|
+
// output — nothing to send.
|
|
4646
|
+
if (event === undefined) {
|
|
4647
|
+
result = runResultFor('no-action');
|
|
4648
|
+
} else {
|
|
4649
|
+
// 2. Optional Captain-pane classification line: the bare FSM
|
|
4650
|
+
// event type, emitted before the FSM advances.
|
|
4651
|
+
const statusLine = classificationStatus(event);
|
|
4652
|
+
if (statusLine !== undefined) {
|
|
4653
|
+
await runtimePorts!.emitStatus(statusLine);
|
|
4654
|
+
}
|
|
4655
|
+
signal.throwIfAborted();
|
|
4656
|
+
// 3. A final actor cannot accept new events; reconstruct only
|
|
4657
|
+
// after classification produced a real event.
|
|
4658
|
+
if (actor.getSnapshot().status === 'done') {
|
|
4659
|
+
stopActor();
|
|
4660
|
+
actor = buildActor(runtimePorts!);
|
|
4661
|
+
// The replacement actor's snapshots are real state entries.
|
|
4662
|
+
suppressInspectionEmissions = false;
|
|
4663
|
+
actor.start();
|
|
4664
|
+
}
|
|
4665
|
+
// DR-029: keep the classified event with its recorded payload
|
|
4666
|
+
// as the retry-replay source. Recording is sanitizing, not
|
|
4667
|
+
// load-bearing: an override classifier's non-JSON-safe event is
|
|
4668
|
+
// simply not recorded, and the turn proceeds unchanged.
|
|
4669
|
+
try {
|
|
4670
|
+
lastBossEvent = snapshotJsonValue(
|
|
4671
|
+
event,
|
|
4672
|
+
'recorded Boss event',
|
|
4673
|
+
) as unknown as EventObject;
|
|
4674
|
+
} catch {
|
|
4675
|
+
lastBossEvent = undefined;
|
|
4676
|
+
}
|
|
4677
|
+
actor.send(event);
|
|
4678
|
+
await waitForPlaybookQuiescence(actor, {
|
|
4679
|
+
pendingCalls: nestedBridge,
|
|
4680
|
+
});
|
|
4681
|
+
if (controlPlaneError !== undefined) throw controlPlaneError;
|
|
4682
|
+
result = runResultFor(settledOutcome(signal));
|
|
4154
4683
|
}
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
pendingCalls: nestedBridge,
|
|
4158
|
-
});
|
|
4159
|
-
if (controlPlaneError !== undefined) throw controlPlaneError;
|
|
4160
|
-
result = runResultFor(settledOutcome(signal));
|
|
4684
|
+
} catch (error) {
|
|
4685
|
+
operationError = error;
|
|
4161
4686
|
}
|
|
4162
|
-
} catch (error) {
|
|
4163
|
-
operationError = error;
|
|
4164
|
-
}
|
|
4165
4687
|
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4688
|
+
let drainError: unknown;
|
|
4689
|
+
try {
|
|
4690
|
+
await drainEmissions();
|
|
4691
|
+
} catch (error) {
|
|
4692
|
+
drainError = error;
|
|
4693
|
+
}
|
|
4694
|
+
const latchedControlError = controlPlaneError;
|
|
4695
|
+
// A drain rejection that is the exact abort reason evidences the
|
|
4696
|
+
// cancellation, not a control-plane failure (slc/link.md §Abort).
|
|
4697
|
+
const drainAbort =
|
|
4698
|
+
drainError !== undefined && isAbortFailure(drainError, signal);
|
|
4699
|
+
const effectiveDrainError = drainAbort ? undefined : drainError;
|
|
4700
|
+
const primaryError =
|
|
4701
|
+
latchedControlError ?? effectiveDrainError ?? operationError;
|
|
4702
|
+
const abortError =
|
|
4703
|
+
latchedControlError === undefined &&
|
|
4704
|
+
effectiveDrainError === undefined &&
|
|
4705
|
+
((operationError !== undefined &&
|
|
4706
|
+
isAbortFailure(operationError, signal)) ||
|
|
4707
|
+
(drainAbort && operationError === undefined));
|
|
4708
|
+
const settlementResult =
|
|
4709
|
+
primaryError === undefined
|
|
4710
|
+
? (result ?? runResultFor('no-action'))
|
|
4711
|
+
: runResultFor(abortError ? 'aborted' : 'failed', primaryError);
|
|
4712
|
+
|
|
4713
|
+
let settlementEmissionError: unknown;
|
|
4714
|
+
try {
|
|
4715
|
+
await emitTrace(
|
|
4716
|
+
'boss.input.settled',
|
|
4717
|
+
settlementTracePayload(settlementResult),
|
|
4718
|
+
{ turnId },
|
|
4719
|
+
);
|
|
4720
|
+
} catch (error) {
|
|
4721
|
+
settlementEmissionError = error;
|
|
4722
|
+
}
|
|
4723
|
+
try {
|
|
4724
|
+
await drainEmissions();
|
|
4725
|
+
} catch (error) {
|
|
4726
|
+
settlementEmissionError ??= error;
|
|
4727
|
+
}
|
|
4728
|
+
if (
|
|
4729
|
+
settlementEmissionError !== undefined &&
|
|
4730
|
+
isAbortFailure(settlementEmissionError, signal)
|
|
4731
|
+
) {
|
|
4732
|
+
settlementEmissionError = undefined;
|
|
4733
|
+
}
|
|
4734
|
+
const failure =
|
|
4735
|
+
controlPlaneError ??
|
|
4736
|
+
latchedControlError ??
|
|
4737
|
+
effectiveDrainError ??
|
|
4738
|
+
(abortError
|
|
4739
|
+
? (settlementEmissionError ?? operationError)
|
|
4740
|
+
: (operationError ?? settlementEmissionError));
|
|
4210
4741
|
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4742
|
+
if (
|
|
4743
|
+
failure !== undefined &&
|
|
4744
|
+
!(abortError && settlementEmissionError === undefined)
|
|
4745
|
+
) {
|
|
4746
|
+
throw failure;
|
|
4747
|
+
}
|
|
4748
|
+
return settlementResult;
|
|
4749
|
+
} finally {
|
|
4750
|
+
activeSignal = undefined;
|
|
4751
|
+
activeAborts = undefined;
|
|
4752
|
+
activeAbortEmission = undefined;
|
|
4753
|
+
activeTurnId = undefined;
|
|
4754
|
+
controlPlaneError = undefined;
|
|
4216
4755
|
}
|
|
4217
|
-
return settlementResult;
|
|
4218
4756
|
},
|
|
4219
4757
|
|
|
4220
4758
|
async resumePlaybookCall(input: {
|
|
@@ -4239,37 +4777,88 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
4239
4777
|
}
|
|
4240
4778
|
activeTurnId = playbookCallTurnIds.get(input.callId);
|
|
4241
4779
|
activeSignal = input.signal;
|
|
4780
|
+
activeAborts = abortReasonClassifier(input.signal);
|
|
4781
|
+
activeAbortEmission = undefined;
|
|
4242
4782
|
controlPlaneError = undefined;
|
|
4243
|
-
|
|
4244
|
-
|
|
4783
|
+
// The boundary sentinel releases on every exit, mirroring
|
|
4784
|
+
// `handleBossInput` and the apply boundary.
|
|
4245
4785
|
try {
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4786
|
+
let result: PlaybookRunResult | undefined;
|
|
4787
|
+
let operationError: unknown;
|
|
4788
|
+
try {
|
|
4789
|
+
await nestedBridge.resume(input);
|
|
4790
|
+
} catch (error) {
|
|
4791
|
+
operationError = error;
|
|
4792
|
+
}
|
|
4793
|
+
try {
|
|
4794
|
+
await waitForPlaybookQuiescence(actor, {
|
|
4795
|
+
pendingCalls: nestedBridge,
|
|
4796
|
+
});
|
|
4797
|
+
result = runResultFor(settledOutcome(input.signal));
|
|
4798
|
+
} catch (error) {
|
|
4799
|
+
operationError ??= error;
|
|
4800
|
+
}
|
|
4801
|
+
// A resume refused because its signal was already aborted
|
|
4802
|
+
// delivers nothing: the pending call survives for a later
|
|
4803
|
+
// resume, and the boundary settles `aborted` rather than
|
|
4804
|
+
// advertising `suspended` (slc/link.md §Nested playbook bridge).
|
|
4805
|
+
if (
|
|
4806
|
+
operationError !== undefined &&
|
|
4807
|
+
isAbortFailure(operationError, input.signal) &&
|
|
4808
|
+
nestedBridge.getPendingCall()?.callId === input.callId
|
|
4809
|
+
) {
|
|
4810
|
+
result = {
|
|
4811
|
+
outcome: 'aborted',
|
|
4812
|
+
state: currentState(),
|
|
4813
|
+
error: normalizeError(input.signal.reason),
|
|
4814
|
+
};
|
|
4815
|
+
}
|
|
4816
|
+
let drainError: unknown;
|
|
4817
|
+
try {
|
|
4818
|
+
await drainEmissions();
|
|
4819
|
+
} catch (error) {
|
|
4820
|
+
drainError = error;
|
|
4821
|
+
}
|
|
4822
|
+
const aborts = activeAborts ?? abortReasonClassifier(input.signal);
|
|
4823
|
+
// A control-plane latch has already classified its failure as
|
|
4824
|
+
// distinct under the owning operation. Never reinterpret it
|
|
4825
|
+
// against this later resume signal (DR-036 decision 2).
|
|
4826
|
+
const controlFailure = controlPlaneError;
|
|
4827
|
+
const drainAbort =
|
|
4828
|
+
controlFailure === undefined &&
|
|
4829
|
+
drainError !== undefined &&
|
|
4830
|
+
aborts.isAbortReason(drainError);
|
|
4831
|
+
const operationAbort =
|
|
4832
|
+
controlFailure === undefined &&
|
|
4833
|
+
operationError !== undefined &&
|
|
4834
|
+
aborts.isAbortReason(operationError);
|
|
4835
|
+
const abortEvidence =
|
|
4836
|
+
activeAbortEmission ??
|
|
4837
|
+
(drainAbort ? drainError : undefined) ??
|
|
4838
|
+
(operationAbort ? operationError : undefined);
|
|
4839
|
+
const failure =
|
|
4840
|
+
controlFailure ??
|
|
4841
|
+
(drainAbort ? undefined : drainError) ??
|
|
4842
|
+
(operationAbort ? undefined : operationError);
|
|
4843
|
+
if (failure !== undefined) throw failure;
|
|
4844
|
+
if (
|
|
4845
|
+
abortEvidence !== undefined &&
|
|
4846
|
+
result?.outcome !== 'terminal' &&
|
|
4847
|
+
result?.outcome !== 'suspended'
|
|
4848
|
+
) {
|
|
4849
|
+
result = runResultFor('aborted', abortEvidence);
|
|
4850
|
+
}
|
|
4851
|
+
if (result === undefined) {
|
|
4852
|
+
throw new Error('playbook resume produced no runtime result');
|
|
4853
|
+
}
|
|
4854
|
+
return result;
|
|
4855
|
+
} finally {
|
|
4856
|
+
activeSignal = undefined;
|
|
4857
|
+
activeAborts = undefined;
|
|
4858
|
+
activeAbortEmission = undefined;
|
|
4859
|
+
activeTurnId = undefined;
|
|
4860
|
+
controlPlaneError = undefined;
|
|
4271
4861
|
}
|
|
4272
|
-
return result;
|
|
4273
4862
|
},
|
|
4274
4863
|
|
|
4275
4864
|
dispose(): Promise<void> {
|
|
@@ -4339,6 +4928,10 @@ export function createXStatePlaybookRuntime<TOptions>(
|
|
|
4339
4928
|
appliedReceipts.clear();
|
|
4340
4929
|
actor = undefined;
|
|
4341
4930
|
activeSignal = undefined;
|
|
4931
|
+
activeAborts = undefined;
|
|
4932
|
+
actorSettlementAborts = undefined;
|
|
4933
|
+
actorSettlementErrorAborts = undefined;
|
|
4934
|
+
activeAbortEmission = undefined;
|
|
4342
4935
|
activeTurnId = undefined;
|
|
4343
4936
|
controlPlaneError = undefined;
|
|
4344
4937
|
emissionFailure = undefined;
|