@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
|
@@ -233,9 +233,37 @@ export function normalizeErrorFull(err) {
|
|
|
233
233
|
return undefined;
|
|
234
234
|
return normalizeError(err);
|
|
235
235
|
}
|
|
236
|
+
// slc/link.md §Abort: cancellation is causal identity with the applicable
|
|
237
|
+
// signal's reason — never an `AbortError` name, never bare signal state. A
|
|
238
|
+
// distinct failure observed while the signal is aborted stays a non-abort
|
|
239
|
+
// control error and takes precedence (mirrors DECIDE's bespoke reference).
|
|
236
240
|
function isAbortFailure(error, signal) {
|
|
237
|
-
return
|
|
238
|
-
|
|
241
|
+
return signal.aborted && Object.is(error, signal.reason);
|
|
242
|
+
}
|
|
243
|
+
function abortReasonClassifier(...sources) {
|
|
244
|
+
const captured = sources.filter((source) => source !== undefined);
|
|
245
|
+
return Object.freeze({
|
|
246
|
+
isAbortReason: (error) => captured.some((source) => source instanceof AbortSignal
|
|
247
|
+
? isAbortFailure(error, source)
|
|
248
|
+
: source.isAbortReason(error)),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* gears2fsm's canonical Boss-reply wait state. On the runtime's Boss-facing
|
|
253
|
+
* surfaces — state telemetry, status lines, the exported snapshot, and the
|
|
254
|
+
* control view — a context question counts as *pending* only while the
|
|
255
|
+
* machine sits in this state awaiting the reply. Later states retain the
|
|
256
|
+
* answered question in context (the resumed player prompt is composed from
|
|
257
|
+
* it), so an unconditional projection would resurrect it: a failure the
|
|
258
|
+
* resumed player reached would export a question nobody is waiting on,
|
|
259
|
+
* disagreeing with the gated telemetry a mirroring host's ledger follows
|
|
260
|
+
* and failing the shell's snapshot-equality settlement check.
|
|
261
|
+
*/
|
|
262
|
+
const BOSS_REPLY_WAIT_STATE_ID = 'awaitBossReply';
|
|
263
|
+
function pendingBossQuestionForState(state, context) {
|
|
264
|
+
if (state.stateId !== BOSS_REPLY_WAIT_STATE_ID)
|
|
265
|
+
return undefined;
|
|
266
|
+
return pendingBossQuestionFromContext(context);
|
|
239
267
|
}
|
|
240
268
|
/** Read the FSM context's single pending Boss question, when well-formed. */
|
|
241
269
|
export function pendingBossQuestionFromContext(context) {
|
|
@@ -466,7 +494,9 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
|
|
|
466
494
|
prompt = spec.composePlayerPrompt(input);
|
|
467
495
|
}
|
|
468
496
|
catch (error) {
|
|
469
|
-
|
|
497
|
+
if (!isAbortFailure(error, activeSignal)) {
|
|
498
|
+
onControlPlaneError?.(error);
|
|
499
|
+
}
|
|
470
500
|
throw error;
|
|
471
501
|
}
|
|
472
502
|
const callPlayer = (resume) => boundary
|
|
@@ -506,7 +536,9 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
|
|
|
506
536
|
return output;
|
|
507
537
|
}
|
|
508
538
|
catch (error) {
|
|
509
|
-
|
|
539
|
+
if (!isAbortFailure(error, activeSignal)) {
|
|
540
|
+
onControlPlaneError?.(error);
|
|
541
|
+
}
|
|
510
542
|
throw error;
|
|
511
543
|
}
|
|
512
544
|
});
|
|
@@ -670,7 +702,7 @@ export function resumableStateIdsFromMachine(machine) {
|
|
|
670
702
|
if (!isPlainObject(config) || !isPlainObject(config.states)) {
|
|
671
703
|
return new Set();
|
|
672
704
|
}
|
|
673
|
-
const awaitState = config.states
|
|
705
|
+
const awaitState = config.states[BOSS_REPLY_WAIT_STATE_ID];
|
|
674
706
|
if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
|
|
675
707
|
return new Set();
|
|
676
708
|
}
|
|
@@ -772,6 +804,28 @@ function deepFreeze(value) {
|
|
|
772
804
|
// Default transition/status derivation.
|
|
773
805
|
// ---------------------------------------------------------------------------
|
|
774
806
|
const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
|
|
807
|
+
// Bounded escalation for aborted script process groups: SIGTERM first, then
|
|
808
|
+
// SIGKILL after this grace, so settlement (gated on the shell's own exit)
|
|
809
|
+
// stays bounded even for TERM-immune commands.
|
|
810
|
+
const SCRIPT_ABORT_KILL_GRACE_MS = 2000;
|
|
811
|
+
class ScriptProcessGroupTeardownError extends Error {
|
|
812
|
+
constructor(pid, message, cause) {
|
|
813
|
+
super(`script process group ${pid} teardown could not be confirmed: ${message}`, cause === undefined ? undefined : { cause });
|
|
814
|
+
this.name = 'ScriptProcessGroupTeardownError';
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function isNoSuchProcess(error) {
|
|
818
|
+
return (typeof error === 'object' &&
|
|
819
|
+
error !== null &&
|
|
820
|
+
'code' in error &&
|
|
821
|
+
error.code === 'ESRCH');
|
|
822
|
+
}
|
|
823
|
+
function isProcessPermissionDenied(error) {
|
|
824
|
+
return (typeof error === 'object' &&
|
|
825
|
+
error !== null &&
|
|
826
|
+
'code' in error &&
|
|
827
|
+
error.code === 'EPERM');
|
|
828
|
+
}
|
|
775
829
|
function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
|
|
776
830
|
return (event) => {
|
|
777
831
|
if (event === null || typeof event !== 'object') {
|
|
@@ -861,7 +915,7 @@ function makeDefaultStatusesForState(roleStates) {
|
|
|
861
915
|
if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
|
|
862
916
|
return statuses;
|
|
863
917
|
}
|
|
864
|
-
if (stateId ===
|
|
918
|
+
if (stateId === BOSS_REPLY_WAIT_STATE_ID) {
|
|
865
919
|
const pending = pendingBossQuestionFromContext(context);
|
|
866
920
|
if (pending === undefined) {
|
|
867
921
|
return [...statuses, { message: 'Awaiting Boss reply.' }];
|
|
@@ -1063,7 +1117,14 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
|
|
|
1063
1117
|
const state = classifierState(snapshotOrState);
|
|
1064
1118
|
const stateId = typeof state.value === 'string' ? state.value : undefined;
|
|
1065
1119
|
const currentState = stateId ?? JSON.stringify(state.value ?? null);
|
|
1066
|
-
|
|
1120
|
+
// The classifier shares the reply-wait pendingness of every other
|
|
1121
|
+
// surface: outside the wait, a context question a later state retains
|
|
1122
|
+
// is answered history, so the prompt must not present it as pending —
|
|
1123
|
+
// a judge told a question awaits at the failure state is steered toward
|
|
1124
|
+
// a reply it cannot select or toward no action at all.
|
|
1125
|
+
const pending = stateId === BOSS_REPLY_WAIT_STATE_ID
|
|
1126
|
+
? pendingBossQuestionFromContext(state.context)
|
|
1127
|
+
: undefined;
|
|
1067
1128
|
const configuredTypes = configuredEventTypesForState(machine, stateId);
|
|
1068
1129
|
const applicable = [...contracts.values()].filter((contract) => configuredTypes.has(contract.type) &&
|
|
1069
1130
|
(contract.type !== 'BOSS_REPLY' || pending !== undefined));
|
|
@@ -1179,6 +1240,60 @@ function machineDeclaresParallelState(machine) {
|
|
|
1179
1240
|
};
|
|
1180
1241
|
return visit(machine.config);
|
|
1181
1242
|
}
|
|
1243
|
+
// PBRT-52: the factory's domain is FLAT single-region machines — every
|
|
1244
|
+
// state a direct child of the root, so each snapshot exposes exactly one
|
|
1245
|
+
// playbook state id and every state-keyed lookup (deterministic entries,
|
|
1246
|
+
// retry, reply-wait pendingness, configured events, descriptions) indexes
|
|
1247
|
+
// one unambiguous identity. A compound child would be accepted and then
|
|
1248
|
+
// silently misbehave on all of those gates, so it is rejected up front
|
|
1249
|
+
// exactly like a parallel region.
|
|
1250
|
+
function machineDeclaresNestedState(machine) {
|
|
1251
|
+
const config = machine.config;
|
|
1252
|
+
if (!isPlainObject(config) || !isPlainObject(config.states))
|
|
1253
|
+
return false;
|
|
1254
|
+
return Object.values(config.states).some((stateDef) => isPlainObject(stateDef) &&
|
|
1255
|
+
isPlainObject(stateDef.states) &&
|
|
1256
|
+
Object.keys(stateDef.states).length > 0);
|
|
1257
|
+
}
|
|
1258
|
+
// PBRT-52: the factory's lookups index states by their root key, and the
|
|
1259
|
+
// published playbook identity is `meta.playbook.stateId` — the two must
|
|
1260
|
+
// coincide or a machine can advertise a pending question or retry under an
|
|
1261
|
+
// identity no lookup resolves. A state with no string stateId is just as
|
|
1262
|
+
// dead: every snapshot identity derives from that member, so the first
|
|
1263
|
+
// entry would fail the exactly-one-state-id inspection at runtime.
|
|
1264
|
+
// gears2fsm keeps identity and key equal by construction; a hand-authored
|
|
1265
|
+
// artifact that splits or omits them fails here instead of at a silently
|
|
1266
|
+
// dead gate.
|
|
1267
|
+
function assertFlatStateIdentity(machine, label) {
|
|
1268
|
+
const config = machine.config;
|
|
1269
|
+
const states = isPlainObject(config) && isPlainObject(config.states)
|
|
1270
|
+
? config.states
|
|
1271
|
+
: undefined;
|
|
1272
|
+
// A machine with no root states has no playbook identity to expose; its
|
|
1273
|
+
// first snapshot would fail the exactly-one-state-id inspection, so it
|
|
1274
|
+
// fails construction with the defect named instead.
|
|
1275
|
+
if (states === undefined || Object.keys(states).length === 0) {
|
|
1276
|
+
throw new Error(`${label} declares no root states; the shared runtime requires at ` +
|
|
1277
|
+
'least one flat playbook state');
|
|
1278
|
+
}
|
|
1279
|
+
for (const [key, stateDef] of Object.entries(states)) {
|
|
1280
|
+
if (!isPlainObject(stateDef))
|
|
1281
|
+
continue;
|
|
1282
|
+
const meta = isPlainObject(stateDef.meta) ? stateDef.meta : undefined;
|
|
1283
|
+
const playbook = meta !== undefined && isPlainObject(meta.playbook)
|
|
1284
|
+
? meta.playbook
|
|
1285
|
+
: undefined;
|
|
1286
|
+
const stateId = playbook?.stateId;
|
|
1287
|
+
if (typeof stateId !== 'string') {
|
|
1288
|
+
throw new Error(`${label} state ${key} declares no string meta.playbook.stateId; ` +
|
|
1289
|
+
'the shared runtime derives every playbook state identity from it');
|
|
1290
|
+
}
|
|
1291
|
+
if (stateId !== key) {
|
|
1292
|
+
throw new Error(`${label} state ${key} declares meta.playbook.stateId ${stateId}; ` +
|
|
1293
|
+
'the shared runtime requires the playbook state id to equal the state key');
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1182
1297
|
/**
|
|
1183
1298
|
* Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
|
|
1184
1299
|
* under the slc/link.md contract. The factory provides every actor kind the
|
|
@@ -1186,9 +1301,10 @@ function machineDeclaresParallelState(machine) {
|
|
|
1186
1301
|
* (literal and dynamic) — and implements the full runtime lifecycle including
|
|
1187
1302
|
* the optional parked-session snapshot capability (DR-014).
|
|
1188
1303
|
*
|
|
1189
|
-
* Scope: machines
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1304
|
+
* Scope: flat single-region machines — no parallel state, no compound
|
|
1305
|
+
* child states, and every root state's `meta.playbook.stateId` equal to its
|
|
1306
|
+
* state key — so each snapshot exposes exactly one playbook state id.
|
|
1307
|
+
* Parallel-region FSMs keep their own linked runtimes.
|
|
1192
1308
|
*/
|
|
1193
1309
|
export function createXStatePlaybookRuntime(machine, spec) {
|
|
1194
1310
|
const label = spec.label ?? 'playbook';
|
|
@@ -1205,6 +1321,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1205
1321
|
if (machineDeclaresParallelState(machine)) {
|
|
1206
1322
|
throw new Error(`${label} uses a parallel state; the shared runtime supports only single-region FSMs`);
|
|
1207
1323
|
}
|
|
1324
|
+
if (machineDeclaresNestedState(machine)) {
|
|
1325
|
+
throw new Error(`${label} declares a compound state; the shared runtime supports only flat single-region FSMs`);
|
|
1326
|
+
}
|
|
1327
|
+
assertFlatStateIdentity(machine, label);
|
|
1208
1328
|
const declaredActors = collectInvokeSources(machine);
|
|
1209
1329
|
const resumableStateIds = spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
|
|
1210
1330
|
// DR-029: source state descriptions label the control actions the
|
|
@@ -1282,6 +1402,20 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1282
1402
|
// ports.callPlayer / callCaptain / callJudge see the right cancellation
|
|
1283
1403
|
// source. undefined between turns; set by the public boundaries.
|
|
1284
1404
|
let activeSignal;
|
|
1405
|
+
// Immutable cancellation provenance for the active public boundary. A
|
|
1406
|
+
// nested resume widens it to include both invocation and resume signals;
|
|
1407
|
+
// mutable `activeSignal` alone cannot classify a late invocation reason.
|
|
1408
|
+
let activeAborts;
|
|
1409
|
+
// The bridge binds the provenance of a child result immediately before
|
|
1410
|
+
// its promise actor settles. The next root snapshot/error consumes this
|
|
1411
|
+
// one-shot so background settlement emissions retain their owner.
|
|
1412
|
+
let actorSettlementAborts;
|
|
1413
|
+
let actorSettlementErrorAborts;
|
|
1414
|
+
// Exact cancellation observed by an emission owned by the active
|
|
1415
|
+
// boundary. Ordinary runs settle from their signal/state; apply also
|
|
1416
|
+
// needs this phase-local evidence to fold a pre-publication failure into
|
|
1417
|
+
// its accepted receipt.
|
|
1418
|
+
let activeAbortEmission;
|
|
1285
1419
|
let activeTurnId;
|
|
1286
1420
|
let controlPlaneError;
|
|
1287
1421
|
// Previous root-machine state for the inspect-driven telemetry /
|
|
@@ -1457,16 +1591,31 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1457
1591
|
for (const [key, token] of byKey)
|
|
1458
1592
|
privateResumeTokens.set(key, token);
|
|
1459
1593
|
}
|
|
1460
|
-
function enqueueEmission(fn) {
|
|
1594
|
+
function enqueueEmission(fn, aborts = activeAborts) {
|
|
1595
|
+
// The emission belongs to the boundary enqueueing it: a rejection
|
|
1596
|
+
// causally identical to that boundary's abort reason is the
|
|
1597
|
+
// cancellation's own evidence — never latched, so it cannot poison a
|
|
1598
|
+
// later unrelated boundary (DR-036).
|
|
1599
|
+
const enqueueAborts = aborts;
|
|
1461
1600
|
const queued = emissionQueue.add(fn).then(() => undefined);
|
|
1462
1601
|
activeEmissionCalls.add(queued);
|
|
1463
1602
|
void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
|
|
1464
1603
|
activeEmissionCalls.delete(queued);
|
|
1465
|
-
|
|
1604
|
+
if (enqueueAborts?.isAbortReason(error)) {
|
|
1605
|
+
// Record evidence only when it also belongs to the public
|
|
1606
|
+
// boundary that is still active. A background A cancellation
|
|
1607
|
+
// racing an unrelated B boundary is forgiven under A and must
|
|
1608
|
+
// not change B's settlement.
|
|
1609
|
+
if (activeAborts?.isAbortReason(error)) {
|
|
1610
|
+
activeAbortEmission ??= error;
|
|
1611
|
+
}
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
emissionFailure ??= { error };
|
|
1466
1615
|
});
|
|
1467
1616
|
return queued;
|
|
1468
1617
|
}
|
|
1469
|
-
async function drainEmissions() {
|
|
1618
|
+
async function drainEmissions(_aborts = activeAborts) {
|
|
1470
1619
|
while (true) {
|
|
1471
1620
|
const active = [...activeEmissionCalls];
|
|
1472
1621
|
if (active.length > 0)
|
|
@@ -1479,8 +1628,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1479
1628
|
}
|
|
1480
1629
|
}
|
|
1481
1630
|
if (emissionFailure !== undefined) {
|
|
1482
|
-
const error = emissionFailure;
|
|
1631
|
+
const { error } = emissionFailure;
|
|
1483
1632
|
emissionFailure = undefined;
|
|
1633
|
+
// The failure was classified as distinct by its enqueue owner. If a
|
|
1634
|
+
// later public boundary drains it, retain that classification in the
|
|
1635
|
+
// boundary latch before throwing; its signal must not reinterpret
|
|
1636
|
+
// the same object as cancellation (DR-036 decision 2).
|
|
1637
|
+
if (activeSignal !== undefined)
|
|
1638
|
+
controlPlaneError ??= error;
|
|
1484
1639
|
throw error;
|
|
1485
1640
|
}
|
|
1486
1641
|
}
|
|
@@ -1519,13 +1674,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1519
1674
|
payload: safePayload,
|
|
1520
1675
|
};
|
|
1521
1676
|
}
|
|
1522
|
-
function emitTrace(type, payload, position = {}) {
|
|
1677
|
+
function emitTrace(type, payload, position = {}, aborts) {
|
|
1523
1678
|
const currentSession = requireSession();
|
|
1524
1679
|
const event = createTraceEvent(type, payload, position);
|
|
1525
1680
|
return enqueueEmission(() => currentSession.ports.emitTelemetry({
|
|
1526
1681
|
topic: 'playbook.trace',
|
|
1527
1682
|
payload: event,
|
|
1528
|
-
}));
|
|
1683
|
+
}), aborts);
|
|
1529
1684
|
}
|
|
1530
1685
|
function stateIdentity(stateId) {
|
|
1531
1686
|
return stateId === undefined ? {} : { stateId };
|
|
@@ -1583,6 +1738,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1583
1738
|
};
|
|
1584
1739
|
}
|
|
1585
1740
|
async function emitCallStarted(startedType, finishedType, identity, position,
|
|
1741
|
+
// The applicable combined signal: a start-sink rejection causally
|
|
1742
|
+
// identical to its reason is the cancellation itself, not a control
|
|
1743
|
+
// error — the pair finishes `aborted` and nothing latches
|
|
1744
|
+
// (slc/link.md §Abort).
|
|
1745
|
+
signal,
|
|
1586
1746
|
// Base payload of the best-effort finish emitted when the start sink
|
|
1587
1747
|
// rejects; it defaults to the payload the start carried, which the
|
|
1588
1748
|
// player, judge, and captain pairs take as-is. The apply pair cannot:
|
|
@@ -1594,11 +1754,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1594
1754
|
await emitTrace(startedType, identity, position);
|
|
1595
1755
|
}
|
|
1596
1756
|
catch (error) {
|
|
1597
|
-
|
|
1757
|
+
if (!isAbortFailure(error, signal))
|
|
1758
|
+
controlPlaneError ??= error;
|
|
1598
1759
|
try {
|
|
1599
1760
|
await emitTrace(finishedType, {
|
|
1600
1761
|
...finishIdentity,
|
|
1601
|
-
status: 'error',
|
|
1762
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1602
1763
|
error: normalizeError(error),
|
|
1603
1764
|
}, position);
|
|
1604
1765
|
}
|
|
@@ -1621,7 +1782,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1621
1782
|
resume = selectPlayerResume(roleId, playerId);
|
|
1622
1783
|
}
|
|
1623
1784
|
catch (error) {
|
|
1624
|
-
if (!signal
|
|
1785
|
+
if (!isAbortFailure(error, signal))
|
|
1625
1786
|
controlPlaneError ??= error;
|
|
1626
1787
|
throw error;
|
|
1627
1788
|
}
|
|
@@ -1640,13 +1801,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1640
1801
|
const playerKey = continuationKey(roleId, playerId);
|
|
1641
1802
|
if (activePlayerKeys.has(playerKey)) {
|
|
1642
1803
|
const error = new Error(`simultaneous calls to player key ${playerKey} are not allowed`);
|
|
1643
|
-
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position);
|
|
1804
|
+
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position, signal);
|
|
1644
1805
|
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
1645
1806
|
throw error;
|
|
1646
1807
|
}
|
|
1647
1808
|
activePlayerKeys.add(playerKey);
|
|
1648
1809
|
try {
|
|
1649
|
-
await
|
|
1810
|
+
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position, signal);
|
|
1650
1811
|
let rawResult;
|
|
1651
1812
|
try {
|
|
1652
1813
|
// An abort may land while the awaited started emission drains
|
|
@@ -1660,12 +1821,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1660
1821
|
signal.throwIfAborted();
|
|
1661
1822
|
}
|
|
1662
1823
|
catch (error) {
|
|
1663
|
-
if (!signal
|
|
1824
|
+
if (!isAbortFailure(error, signal))
|
|
1664
1825
|
controlPlaneError ??= error;
|
|
1665
1826
|
try {
|
|
1666
1827
|
await emitTrace('player.call.finished', {
|
|
1667
1828
|
...identity,
|
|
1668
|
-
status: signal
|
|
1829
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1669
1830
|
error: normalizeError(error),
|
|
1670
1831
|
}, position);
|
|
1671
1832
|
}
|
|
@@ -1681,7 +1842,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1681
1842
|
result = validatePlayerResult(rawResult);
|
|
1682
1843
|
}
|
|
1683
1844
|
catch (error) {
|
|
1684
|
-
if (!signal
|
|
1845
|
+
if (!isAbortFailure(error, signal))
|
|
1685
1846
|
controlPlaneError ??= error;
|
|
1686
1847
|
try {
|
|
1687
1848
|
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
@@ -1695,7 +1856,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1695
1856
|
updatePlayerResume(roleId, playerId, result);
|
|
1696
1857
|
}
|
|
1697
1858
|
catch (error) {
|
|
1698
|
-
if (!signal
|
|
1859
|
+
if (!isAbortFailure(error, signal))
|
|
1699
1860
|
controlPlaneError ??= error;
|
|
1700
1861
|
try {
|
|
1701
1862
|
await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
|
|
@@ -1738,7 +1899,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1738
1899
|
...(turnId !== undefined ? { turnId } : {}),
|
|
1739
1900
|
callId,
|
|
1740
1901
|
};
|
|
1741
|
-
await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position);
|
|
1902
|
+
await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position, signal);
|
|
1742
1903
|
let reply;
|
|
1743
1904
|
try {
|
|
1744
1905
|
// An abort may land while the awaited started emission drains
|
|
@@ -1755,7 +1916,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1755
1916
|
}
|
|
1756
1917
|
await emitTrace('judge.call.finished', {
|
|
1757
1918
|
...identity,
|
|
1758
|
-
status: signal
|
|
1919
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1759
1920
|
error: normalizeError(error),
|
|
1760
1921
|
}, position);
|
|
1761
1922
|
throw error;
|
|
@@ -1802,7 +1963,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1802
1963
|
...(turnId !== undefined ? { turnId } : {}),
|
|
1803
1964
|
callId,
|
|
1804
1965
|
};
|
|
1805
|
-
await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position);
|
|
1966
|
+
await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position, signal);
|
|
1806
1967
|
let rawResult;
|
|
1807
1968
|
try {
|
|
1808
1969
|
// An abort may land while the awaited started emission drains
|
|
@@ -1824,7 +1985,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1824
1985
|
controlPlaneError ??= error;
|
|
1825
1986
|
await emitTrace('captain.call.finished', {
|
|
1826
1987
|
...identity,
|
|
1827
|
-
status: signal
|
|
1988
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
1828
1989
|
error: normalizeError(error),
|
|
1829
1990
|
}, position);
|
|
1830
1991
|
throw error;
|
|
@@ -1893,8 +2054,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1893
2054
|
adjudication,
|
|
1894
2055
|
resumableStateIds,
|
|
1895
2056
|
}, ports, () => activeSignal, boundary, (error) => {
|
|
1896
|
-
if (!activeSignal
|
|
2057
|
+
if (activeSignal === undefined || !isAbortFailure(error, activeSignal)) {
|
|
1897
2058
|
controlPlaneError ??= error;
|
|
2059
|
+
}
|
|
1898
2060
|
});
|
|
1899
2061
|
}
|
|
1900
2062
|
// Direct-Captain actor (slc/link.md §Captain prompt composition,
|
|
@@ -1961,7 +2123,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1961
2123
|
// failure state (PBRT-47); everything else here — a drained
|
|
1962
2124
|
// emission failure, prompt composition, the port itself,
|
|
1963
2125
|
// adjudication — is control plane.
|
|
1964
|
-
if (!active
|
|
2126
|
+
if (!isAbortFailure(error, active) && !isFsmResultFailure(error)) {
|
|
1965
2127
|
controlPlaneError ??= error;
|
|
1966
2128
|
}
|
|
1967
2129
|
throw error;
|
|
@@ -1982,56 +2144,177 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
1982
2144
|
const failedGuard = guards[1] ?? guards[0];
|
|
1983
2145
|
const cwd = boundScriptCwd ?? process.cwd();
|
|
1984
2146
|
const ports = runtimePorts ?? requireHostPorts();
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2147
|
+
// slc/link.md §Script execution: an already-aborted turn spawns
|
|
2148
|
+
// nothing, and the thrown signal reason keeps the rejection
|
|
2149
|
+
// causally classified as the abort it is.
|
|
2150
|
+
active.throwIfAborted();
|
|
2151
|
+
// Abort ownership — the listener that terminates the group and
|
|
2152
|
+
// the escalation timer — spans the whole invocation body, not
|
|
2153
|
+
// just the spawn-to-close window: an abort landing during the
|
|
2154
|
+
// post-exit emission tail must still kill surviving group
|
|
2155
|
+
// members before the actor settles (slc/link.md §Script
|
|
2156
|
+
// execution). One finally releases both.
|
|
2157
|
+
let child;
|
|
2158
|
+
let killTimer;
|
|
2159
|
+
const signalGroup = (sig) => {
|
|
2160
|
+
if (child?.pid !== undefined) {
|
|
2161
|
+
try {
|
|
2162
|
+
process.kill(-child.pid, sig);
|
|
2163
|
+
}
|
|
2164
|
+
catch {
|
|
2165
|
+
// Confirmation belongs to the bounded liveness probe below:
|
|
2166
|
+
// a failed signal can mean ESRCH, EPERM, or another fault.
|
|
2167
|
+
}
|
|
1992
2168
|
}
|
|
1993
|
-
|
|
1994
|
-
|
|
2169
|
+
};
|
|
2170
|
+
// After a SIGKILL is posted, settlement waits for the group to
|
|
2171
|
+
// stop being signalable — bounded by the same grace so an
|
|
2172
|
+
// unreapable member outside the runtime's control cannot stall
|
|
2173
|
+
// the turn forever. Observed teardown is milliseconds.
|
|
2174
|
+
let groupGonePromise;
|
|
2175
|
+
const awaitGroupGone = () => {
|
|
2176
|
+
const pid = child?.pid;
|
|
2177
|
+
if (pid === undefined)
|
|
2178
|
+
return Promise.resolve();
|
|
2179
|
+
groupGonePromise ??= (async () => {
|
|
2180
|
+
const teardownFailure = (message, cause) => {
|
|
2181
|
+
const failure = new ScriptProcessGroupTeardownError(pid, message, cause);
|
|
2182
|
+
// A teardown failure is not an authored script result.
|
|
2183
|
+
// Surface it at the active public boundary even though
|
|
2184
|
+
// XState also routes the rejected actor through onError.
|
|
2185
|
+
controlPlaneError ??= failure;
|
|
2186
|
+
return failure;
|
|
2187
|
+
};
|
|
2188
|
+
const deadline = Date.now() + SCRIPT_ABORT_KILL_GRACE_MS;
|
|
2189
|
+
let lastProbeError;
|
|
2190
|
+
for (;;) {
|
|
2191
|
+
try {
|
|
2192
|
+
process.kill(-pid, 0);
|
|
2193
|
+
}
|
|
2194
|
+
catch (error) {
|
|
2195
|
+
if (isNoSuchProcess(error))
|
|
2196
|
+
return;
|
|
2197
|
+
// EPERM confirms that at least one process in the group
|
|
2198
|
+
// still exists but is not signalable by this process. Keep
|
|
2199
|
+
// waiting for ESRCH within the bound; every other probe
|
|
2200
|
+
// error makes confirmation itself unreliable immediately.
|
|
2201
|
+
if (!isProcessPermissionDenied(error)) {
|
|
2202
|
+
throw teardownFailure('the liveness probe failed', error);
|
|
2203
|
+
}
|
|
2204
|
+
lastProbeError = error;
|
|
2205
|
+
}
|
|
2206
|
+
if (Date.now() >= deadline) {
|
|
2207
|
+
throw teardownFailure(`the group remained signalable after ${SCRIPT_ABORT_KILL_GRACE_MS}ms`, lastProbeError);
|
|
2208
|
+
}
|
|
2209
|
+
await new Promise((tick) => setTimeout(tick, 5));
|
|
2210
|
+
}
|
|
2211
|
+
})();
|
|
2212
|
+
return groupGonePromise;
|
|
2213
|
+
};
|
|
2214
|
+
const onAbort = () => {
|
|
2215
|
+
signalGroup('SIGTERM');
|
|
2216
|
+
killTimer = setTimeout(() => signalGroup('SIGKILL'), SCRIPT_ABORT_KILL_GRACE_MS);
|
|
2217
|
+
};
|
|
2218
|
+
// An abort observed once the shell has already exited rejects
|
|
2219
|
+
// with the signal's reason before guard resolution and before
|
|
2220
|
+
// starting any further script emission — after killing whatever
|
|
2221
|
+
// group members outlived the shell. The shell's own exit ended
|
|
2222
|
+
// the TERM grace's purpose, so escalation is immediate here.
|
|
2223
|
+
const settleIfAborted = async () => {
|
|
2224
|
+
if (!active.aborted)
|
|
1995
2225
|
return;
|
|
2226
|
+
signalGroup('SIGKILL');
|
|
2227
|
+
await awaitGroupGone();
|
|
2228
|
+
active.throwIfAborted();
|
|
2229
|
+
};
|
|
2230
|
+
let invocationFailed = false;
|
|
2231
|
+
try {
|
|
2232
|
+
const exitStatus = await new Promise((resolve, reject) => {
|
|
2233
|
+
try {
|
|
2234
|
+
// detached: the shell leads its own POSIX process group,
|
|
2235
|
+
// so an abort can terminate the command's whole group — a
|
|
2236
|
+
// lone SIGTERM to the wrapper never reaches backgrounded
|
|
2237
|
+
// members.
|
|
2238
|
+
child = spawn('sh', ['-c', input.command], {
|
|
2239
|
+
cwd,
|
|
2240
|
+
stdio: 'ignore',
|
|
2241
|
+
detached: true,
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
catch (error) {
|
|
2245
|
+
reject(error);
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
// On abort, terminate the group and escalate — but settle
|
|
2249
|
+
// only from 'close', after the shell itself has exited, so
|
|
2250
|
+
// the turn never reports quiescence while the script still
|
|
2251
|
+
// runs (slc/link.md §Abort). SIGKILL is untrappable, so
|
|
2252
|
+
// 'close' is bounded by the grace.
|
|
2253
|
+
active.addEventListener('abort', onAbort, { once: true });
|
|
2254
|
+
child.on('error', (error) => {
|
|
2255
|
+
reject(error);
|
|
2256
|
+
});
|
|
2257
|
+
child.on('close', (code) => {
|
|
2258
|
+
if (active.aborted) {
|
|
2259
|
+
// The shell may exit cooperatively on the group SIGTERM
|
|
2260
|
+
// while a TERM-immune same-group descendant survives;
|
|
2261
|
+
// the group stays addressable while any member lives,
|
|
2262
|
+
// so kill it and await its disappearance before
|
|
2263
|
+
// settling (slc/link.md §Script execution).
|
|
2264
|
+
signalGroup('SIGKILL');
|
|
2265
|
+
void awaitGroupGone().then(() => reject(active.reason), reject);
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
resolve(typeof code === 'number' ? code : 1);
|
|
2269
|
+
});
|
|
2270
|
+
});
|
|
2271
|
+
await settleIfAborted();
|
|
2272
|
+
await ports.emitStatus(`Executed script for ${input.stateId} (exit ${exitStatus}).`);
|
|
2273
|
+
await settleIfAborted();
|
|
2274
|
+
await ports.emitTelemetry({
|
|
2275
|
+
topic: 'playbook.script',
|
|
2276
|
+
payload: {
|
|
2277
|
+
stateId: input.stateId,
|
|
2278
|
+
sourceItem: input.sourceItem,
|
|
2279
|
+
exitStatus,
|
|
2280
|
+
},
|
|
2281
|
+
});
|
|
2282
|
+
await settleIfAborted();
|
|
2283
|
+
if (exitStatus === 0) {
|
|
2284
|
+
return { guard: okGuard, exitStatus: 0 };
|
|
1996
2285
|
}
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2286
|
+
return { guard: failedGuard, exitStatus };
|
|
2287
|
+
}
|
|
2288
|
+
catch (error) {
|
|
2289
|
+
// Preserve the invocation's authoritative exact cancellation or
|
|
2290
|
+
// distinct sink failure after teardown succeeds. The finally
|
|
2291
|
+
// block may replace it only with a distinct teardown failure
|
|
2292
|
+
// when the process group cannot be confirmed gone.
|
|
2293
|
+
invocationFailed = true;
|
|
2294
|
+
throw error;
|
|
2295
|
+
}
|
|
2296
|
+
finally {
|
|
2297
|
+
try {
|
|
2298
|
+
if (active.aborted) {
|
|
2299
|
+
signalGroup('SIGKILL');
|
|
2300
|
+
await awaitGroupGone();
|
|
2301
|
+
if (!invocationFailed)
|
|
2302
|
+
active.throwIfAborted();
|
|
2303
|
+
}
|
|
2004
2304
|
}
|
|
2005
|
-
|
|
2006
|
-
child.on('error', (error) => {
|
|
2007
|
-
active.removeEventListener('abort', onAbort);
|
|
2008
|
-
reject(error);
|
|
2009
|
-
});
|
|
2010
|
-
child.on('close', (code) => {
|
|
2305
|
+
finally {
|
|
2011
2306
|
active.removeEventListener('abort', onAbort);
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
await ports.emitStatus(`Executed script for ${input.stateId} (exit ${exitStatus}).`);
|
|
2016
|
-
await ports.emitTelemetry({
|
|
2017
|
-
topic: 'playbook.script',
|
|
2018
|
-
payload: {
|
|
2019
|
-
stateId: input.stateId,
|
|
2020
|
-
sourceItem: input.sourceItem,
|
|
2021
|
-
exitStatus,
|
|
2022
|
-
},
|
|
2023
|
-
});
|
|
2024
|
-
if (exitStatus === 0) {
|
|
2025
|
-
return { guard: okGuard, exitStatus: 0 };
|
|
2307
|
+
if (killTimer !== undefined)
|
|
2308
|
+
clearTimeout(killTimer);
|
|
2309
|
+
}
|
|
2026
2310
|
}
|
|
2027
|
-
return { guard: failedGuard, exitStatus };
|
|
2028
2311
|
});
|
|
2029
2312
|
}
|
|
2030
2313
|
const nestedBridge = createNestedPlaybookBridge({
|
|
2031
2314
|
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
2032
2315
|
getBoundarySignal: () => activeSignal,
|
|
2033
2316
|
callPlaybook: (request, signal) => requireHostPorts().callPlaybook(request, signal),
|
|
2034
|
-
emitStarted: async (event) => {
|
|
2317
|
+
emitStarted: async (event, aborts) => {
|
|
2035
2318
|
playbookCallTurnIds.set(event.callId, activeTurnId);
|
|
2036
2319
|
await emitTrace('playbook.call.started', {
|
|
2037
2320
|
stateId: event.stateId,
|
|
@@ -2040,9 +2323,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2040
2323
|
}, {
|
|
2041
2324
|
...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
|
|
2042
2325
|
callId: event.callId,
|
|
2043
|
-
});
|
|
2326
|
+
}, aborts);
|
|
2044
2327
|
},
|
|
2045
|
-
emitFinished: async (event) => {
|
|
2328
|
+
emitFinished: async (event, aborts) => {
|
|
2046
2329
|
const turnId = playbookCallTurnIds.get(event.callId);
|
|
2047
2330
|
try {
|
|
2048
2331
|
await emitTrace('playbook.call.finished', {
|
|
@@ -2053,22 +2336,34 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2053
2336
|
}, {
|
|
2054
2337
|
...(turnId !== undefined ? { turnId } : {}),
|
|
2055
2338
|
callId: event.callId,
|
|
2056
|
-
});
|
|
2339
|
+
}, aborts);
|
|
2057
2340
|
}
|
|
2058
2341
|
finally {
|
|
2059
2342
|
playbookCallTurnIds.delete(event.callId);
|
|
2060
2343
|
}
|
|
2061
2344
|
},
|
|
2062
2345
|
drain: drainEmissions,
|
|
2063
|
-
bindResumeSignal: (signal) => {
|
|
2346
|
+
bindResumeSignal: (signal, aborts) => {
|
|
2064
2347
|
activeSignal = signal;
|
|
2348
|
+
activeAborts = aborts ?? abortReasonClassifier(signal);
|
|
2349
|
+
},
|
|
2350
|
+
bindActorSettlement: (aborts) => {
|
|
2351
|
+
actorSettlementAborts = aborts;
|
|
2065
2352
|
},
|
|
2066
|
-
onControlPlaneError: (error) => {
|
|
2067
|
-
|
|
2353
|
+
onControlPlaneError: (error, aborts) => {
|
|
2354
|
+
// The shared bridge classifies before reporting against its own
|
|
2355
|
+
// invocation-and-resume signals; classify once more here against
|
|
2356
|
+
// the boundary signal so a report that is the active boundary's
|
|
2357
|
+
// exact abort reason can never masquerade as a control error
|
|
2358
|
+
// (slc/link.md §Abort).
|
|
2359
|
+
if (!aborts?.isAbortReason(error) &&
|
|
2360
|
+
!activeAborts?.isAbortReason(error)) {
|
|
2068
2361
|
controlPlaneError ??= error;
|
|
2362
|
+
}
|
|
2069
2363
|
},
|
|
2070
|
-
onBackgroundError: (error) => {
|
|
2071
|
-
|
|
2364
|
+
onBackgroundError: (error, aborts) => {
|
|
2365
|
+
if (!aborts?.isAbortReason(error))
|
|
2366
|
+
emissionFailure ??= { error };
|
|
2072
2367
|
},
|
|
2073
2368
|
});
|
|
2074
2369
|
function tracePositionForActiveTurn() {
|
|
@@ -2082,11 +2377,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2082
2377
|
previousState: previousState ?? null,
|
|
2083
2378
|
state,
|
|
2084
2379
|
};
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
payload.pendingBossQuestion = pendingBossQuestion;
|
|
2089
|
-
}
|
|
2380
|
+
const pendingBossQuestion = pendingBossQuestionForState(state, context);
|
|
2381
|
+
if (pendingBossQuestion !== undefined) {
|
|
2382
|
+
payload.pendingBossQuestion = pendingBossQuestion;
|
|
2090
2383
|
}
|
|
2091
2384
|
if (state.stateId === 'failed') {
|
|
2092
2385
|
const lastError = normalizeErrorFull(context.lastError);
|
|
@@ -2095,7 +2388,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2095
2388
|
}
|
|
2096
2389
|
return snapshotJsonValue(payload, 'FSM telemetry payload');
|
|
2097
2390
|
}
|
|
2098
|
-
function enqueueTransitionEmission(payload, state, statuses, position) {
|
|
2391
|
+
function enqueueTransitionEmission(payload, state, statuses, position, aborts) {
|
|
2099
2392
|
const currentSession = requireSession();
|
|
2100
2393
|
const transitionTrace = createTraceEvent('fsm.transition', payload, position);
|
|
2101
2394
|
const statusEmissions = statuses.map(({ message, data }) => ({
|
|
@@ -2124,13 +2417,38 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2124
2417
|
});
|
|
2125
2418
|
await currentSession.ports.emitStatus(status.message, status.data);
|
|
2126
2419
|
}
|
|
2127
|
-
}).catch(() => undefined);
|
|
2420
|
+
}, aborts).catch(() => undefined);
|
|
2128
2421
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2422
|
+
// One classifying latch for every runtime-observed error — inspection
|
|
2423
|
+
// failures and root-actor errors alike. Outside a boundary the error
|
|
2424
|
+
// rides the emission channel, which the next boundary's (or init's)
|
|
2425
|
+
// drain throws; inside a boundary it is a control-plane error unless it
|
|
2426
|
+
// is the boundary signal's own abort reason (slc/link.md §Abort).
|
|
2427
|
+
function latchRuntimeError(error, aborts = activeAborts) {
|
|
2428
|
+
if (aborts?.isAbortReason(error))
|
|
2429
|
+
return;
|
|
2430
|
+
if (activeSignal === undefined)
|
|
2431
|
+
emissionFailure ??= { error };
|
|
2132
2432
|
else
|
|
2133
|
-
|
|
2433
|
+
controlPlaneError ??= error;
|
|
2434
|
+
}
|
|
2435
|
+
function consumeActorSettlementAborts(forSnapshot = false) {
|
|
2436
|
+
const aborts = actorSettlementAborts ?? actorSettlementErrorAborts;
|
|
2437
|
+
actorSettlementAborts = undefined;
|
|
2438
|
+
actorSettlementErrorAborts = undefined;
|
|
2439
|
+
if (forSnapshot && aborts !== undefined) {
|
|
2440
|
+
// XState can report an errored root through both its inspection
|
|
2441
|
+
// snapshot and subscriber. Keep the same provenance through that
|
|
2442
|
+
// synchronous notification only; an ordinary transition must not
|
|
2443
|
+
// lend it to a later unrelated actor error.
|
|
2444
|
+
actorSettlementErrorAborts = aborts;
|
|
2445
|
+
queueMicrotask(() => {
|
|
2446
|
+
if (actorSettlementErrorAborts === aborts) {
|
|
2447
|
+
actorSettlementErrorAborts = undefined;
|
|
2448
|
+
}
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
return aborts;
|
|
2134
2452
|
}
|
|
2135
2453
|
// PBRT-6: the single seam that stops this runtime's actor. Stopping a
|
|
2136
2454
|
// still-running actor fires one more `@xstate.snapshot` for the
|
|
@@ -2176,6 +2494,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2176
2494
|
return;
|
|
2177
2495
|
if (suppressInspectionEmissions)
|
|
2178
2496
|
return;
|
|
2497
|
+
const settlementAborts = consumeActorSettlementAborts(true);
|
|
2179
2498
|
try {
|
|
2180
2499
|
const snap = inspectionEvent.snapshot;
|
|
2181
2500
|
const state = normalizePlaybookSnapshot(snap);
|
|
@@ -2187,14 +2506,24 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2187
2506
|
{});
|
|
2188
2507
|
const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
|
|
2189
2508
|
const statuses = statusesForState(state, context, inspectionEvent.event);
|
|
2190
|
-
enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn());
|
|
2509
|
+
enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn(), settlementAborts);
|
|
2191
2510
|
priorState = state;
|
|
2192
2511
|
}
|
|
2193
2512
|
catch (error) {
|
|
2194
|
-
|
|
2513
|
+
latchRuntimeError(error, settlementAborts);
|
|
2195
2514
|
}
|
|
2196
2515
|
},
|
|
2197
2516
|
});
|
|
2517
|
+
// A synchronously-errored actor is already quiescent, so the turn's
|
|
2518
|
+
// quiescence wait never subscribes and XState would report the error
|
|
2519
|
+
// as unhandled after the boundary returns. Observe it through the
|
|
2520
|
+
// classifying latch: mid-boundary it is the control-plane error
|
|
2521
|
+
// unless it is the abort reason itself; at startup it rides the
|
|
2522
|
+
// emission channel so `init`'s own drain rejects with it and the
|
|
2523
|
+
// failed-start cleanup runs (slc/link.md §Session lifecycle).
|
|
2524
|
+
builtActor.subscribe({
|
|
2525
|
+
error: (error) => latchRuntimeError(error, consumeActorSettlementAborts()),
|
|
2526
|
+
});
|
|
2198
2527
|
return builtActor;
|
|
2199
2528
|
}
|
|
2200
2529
|
function runResultFor(outcome, error) {
|
|
@@ -2211,14 +2540,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2211
2540
|
}
|
|
2212
2541
|
if (outcome === 'terminal') {
|
|
2213
2542
|
const output = actor?.getSnapshot()?.output;
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2543
|
+
const stateDescription = stateDescriptionFor(state);
|
|
2544
|
+
return {
|
|
2545
|
+
outcome,
|
|
2546
|
+
state,
|
|
2547
|
+
...(stateDescription === undefined ? {} : { stateDescription }),
|
|
2548
|
+
...(output === undefined
|
|
2549
|
+
? {}
|
|
2550
|
+
: {
|
|
2551
|
+
output: snapshotJsonValue(output, 'terminal playbook output'),
|
|
2552
|
+
}),
|
|
2553
|
+
};
|
|
2222
2554
|
}
|
|
2223
2555
|
const failure = error ??
|
|
2224
2556
|
(outcome === 'failed'
|
|
@@ -2236,15 +2568,23 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2236
2568
|
function settledOutcome(signal) {
|
|
2237
2569
|
if (nestedBridge.getPendingCall())
|
|
2238
2570
|
return 'suspended';
|
|
2239
|
-
if (signal.aborted)
|
|
2240
|
-
return 'aborted';
|
|
2241
2571
|
const state = currentState();
|
|
2242
2572
|
if (state.status === 'error') {
|
|
2573
|
+
// An errored actor outranks a coincident abort unless the actor's
|
|
2574
|
+
// error is the abort reason itself (slc/link.md §Abort).
|
|
2243
2575
|
const actorError = actor?.getSnapshot()?.error;
|
|
2576
|
+
if (actorError !== undefined && isAbortFailure(actorError, signal)) {
|
|
2577
|
+
return 'aborted';
|
|
2578
|
+
}
|
|
2244
2579
|
throw actorError ?? new Error(`${label} actor entered error status`);
|
|
2245
2580
|
}
|
|
2581
|
+
// Terminal completion outranks a coincident abort: the work finished,
|
|
2582
|
+
// and reporting `aborted` would hide a terminal machine behind a
|
|
2583
|
+
// settlement a later turn silently restarts (slc/link.md §Abort).
|
|
2246
2584
|
if (state.status === 'done')
|
|
2247
2585
|
return 'terminal';
|
|
2586
|
+
if (signal.aborted)
|
|
2587
|
+
return 'aborted';
|
|
2248
2588
|
if (state.stateId === 'failed')
|
|
2249
2589
|
return 'failed';
|
|
2250
2590
|
return 'quiescent';
|
|
@@ -2317,6 +2657,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2317
2657
|
savedPorts = undefined;
|
|
2318
2658
|
runtimePorts = undefined;
|
|
2319
2659
|
activeSignal = undefined;
|
|
2660
|
+
activeAborts = undefined;
|
|
2661
|
+
actorSettlementAborts = undefined;
|
|
2662
|
+
actorSettlementErrorAborts = undefined;
|
|
2663
|
+
activeAbortEmission = undefined;
|
|
2320
2664
|
activeTurnId = undefined;
|
|
2321
2665
|
controlPlaneError = undefined;
|
|
2322
2666
|
emissionFailure = undefined;
|
|
@@ -2338,28 +2682,56 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2338
2682
|
can.call(snapshot, event) ===
|
|
2339
2683
|
true);
|
|
2340
2684
|
}
|
|
2685
|
+
// DR-034: where the artifact names the FSM context member its entry
|
|
2686
|
+
// action copies the exact Boss text into, that member of the live
|
|
2687
|
+
// snapshot is the retry payload's source. The persisted machine snapshot
|
|
2688
|
+
// carries it, so the candidate derives identically in the process that
|
|
2689
|
+
// exported the snapshot and in one that restored it, and a failure
|
|
2690
|
+
// reached after a Boss reply — whose recorded event the failure state
|
|
2691
|
+
// refuses — is recoverable too. Naming the member is the artifact's
|
|
2692
|
+
// statement that it holds the entry text: a same-named member is never
|
|
2693
|
+
// assumed, since inferring one would turn any matching context member
|
|
2694
|
+
// into a replay payload without its author saying so.
|
|
2695
|
+
// Declared and absent or empty excludes the candidate rather than
|
|
2696
|
+
// falling back to the record, which would make the action depend on the
|
|
2697
|
+
// process again — the very thing this source exists to end.
|
|
2698
|
+
function retryEventFrom(snapshot) {
|
|
2699
|
+
const entryEvent = spec.entryEvent;
|
|
2700
|
+
if (entryEvent?.contextField === undefined)
|
|
2701
|
+
return lastBossEvent;
|
|
2702
|
+
const context = snapshot?.context;
|
|
2703
|
+
const text = isPlainObject(context)
|
|
2704
|
+
? context[entryEvent.contextField]
|
|
2705
|
+
: undefined;
|
|
2706
|
+
if (typeof text !== 'string' || text.trim() === '')
|
|
2707
|
+
return undefined;
|
|
2708
|
+
return { type: entryEvent.type, [entryEvent.textField]: text };
|
|
2709
|
+
}
|
|
2341
2710
|
// The failure-state retry entry replays the recorded last classified
|
|
2342
|
-
// event with its recorded payload
|
|
2343
|
-
//
|
|
2344
|
-
//
|
|
2711
|
+
// event with its recorded payload, or the entry event the declared
|
|
2712
|
+
// context member above sources. A candidate whose event the live
|
|
2713
|
+
// snapshot does not accept — or whose payload the runtime can source
|
|
2714
|
+
// from neither — is excluded rather than completed with invented text.
|
|
2345
2715
|
function retryActionFor(snapshot, stateId) {
|
|
2346
|
-
if (stateId !== 'failed'
|
|
2716
|
+
if (stateId !== 'failed')
|
|
2347
2717
|
return undefined;
|
|
2348
|
-
|
|
2349
|
-
if (
|
|
2718
|
+
const retryEvent = retryEventFrom(snapshot);
|
|
2719
|
+
if (retryEvent === undefined)
|
|
2720
|
+
return undefined;
|
|
2721
|
+
if (!snapshotCan(snapshot, retryEvent))
|
|
2350
2722
|
return undefined;
|
|
2351
2723
|
// A recorded explicit-state-jump event names the exact state its
|
|
2352
2724
|
// replay re-enters: the root BOSS_INTERRUPT shape is a guarded
|
|
2353
2725
|
// multi-arm list keyed on `targetId`, so the first configured arm
|
|
2354
2726
|
// may label a different state than the one the recorded event
|
|
2355
2727
|
// actually resumes.
|
|
2356
|
-
const recordedTargetId =
|
|
2357
|
-
?
|
|
2728
|
+
const recordedTargetId = retryEvent.type === JUMP_EVENT_TYPE
|
|
2729
|
+
? retryEvent.targetId
|
|
2358
2730
|
: undefined;
|
|
2359
2731
|
const target = typeof recordedTargetId === 'string' &&
|
|
2360
2732
|
recordedTargetId.trim().length > 0
|
|
2361
2733
|
? recordedTargetId
|
|
2362
|
-
: firstTransitionTarget(machine, stateId,
|
|
2734
|
+
: firstTransitionTarget(machine, stateId, retryEvent.type);
|
|
2363
2735
|
// PBRT-52: a label is written from a source state description, never
|
|
2364
2736
|
// from an identifier. Falling back to the target id — or, with no
|
|
2365
2737
|
// resolvable target, to the FSM event type — makes the label *be* the
|
|
@@ -2373,10 +2745,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2373
2745
|
return undefined;
|
|
2374
2746
|
return {
|
|
2375
2747
|
action: {
|
|
2376
|
-
id: `retry:${
|
|
2748
|
+
id: `retry:${retryEvent.type}`,
|
|
2377
2749
|
label: `Retry: ${description}`,
|
|
2378
2750
|
},
|
|
2379
|
-
event:
|
|
2751
|
+
event: retryEvent,
|
|
2380
2752
|
};
|
|
2381
2753
|
}
|
|
2382
2754
|
function deriveControlActions(snapshot) {
|
|
@@ -2561,7 +2933,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2561
2933
|
const machineSnapshot = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
|
|
2562
2934
|
const context = actor.getSnapshot()
|
|
2563
2935
|
.context;
|
|
2564
|
-
const pending =
|
|
2936
|
+
const pending = pendingBossQuestionForState(state, context ?? {});
|
|
2565
2937
|
return {
|
|
2566
2938
|
schemaVersion: 3,
|
|
2567
2939
|
playbookId: session.playbookId,
|
|
@@ -2642,8 +3014,23 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2642
3014
|
suppressInspectionEmissions = true;
|
|
2643
3015
|
actor = buildActor(runtimePorts, boundSnapshot.machine);
|
|
2644
3016
|
actor.start();
|
|
2645
|
-
|
|
2646
|
-
|
|
3017
|
+
// A start-time actor error rides the startup emission channel
|
|
3018
|
+
// (latchRuntimeError); consume both latches here so the original
|
|
3019
|
+
// error outranks the derived status check below.
|
|
3020
|
+
{
|
|
3021
|
+
const startupFailure = emissionFailure;
|
|
3022
|
+
if (controlPlaneError !== undefined ||
|
|
3023
|
+
startupFailure !== undefined) {
|
|
3024
|
+
const startupError = controlPlaneError !== undefined
|
|
3025
|
+
? controlPlaneError
|
|
3026
|
+
: startupFailure.error;
|
|
3027
|
+
controlPlaneError = undefined;
|
|
3028
|
+
if (emissionFailure === startupFailure) {
|
|
3029
|
+
emissionFailure = undefined;
|
|
3030
|
+
}
|
|
3031
|
+
throw startupError;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
2647
3034
|
const restoredState = normalizePlaybookSnapshot(actor.getSnapshot(), suspendedCall === undefined
|
|
2648
3035
|
? {}
|
|
2649
3036
|
: {
|
|
@@ -2709,7 +3096,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2709
3096
|
const state = currentState();
|
|
2710
3097
|
const context = (snapshot.context ??
|
|
2711
3098
|
{});
|
|
2712
|
-
const pending =
|
|
3099
|
+
const pending = pendingBossQuestionForState(state, context);
|
|
2713
3100
|
const lastError = normalizeErrorFull(context.lastError);
|
|
2714
3101
|
const projectedContext = projectControlContext(context);
|
|
2715
3102
|
const stateDescription = stateDescriptionFor(state);
|
|
@@ -2777,6 +3164,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2777
3164
|
const position = { turnId, callId };
|
|
2778
3165
|
activeTurnId = turnId;
|
|
2779
3166
|
activeSignal = signal;
|
|
3167
|
+
activeAborts = abortReasonClassifier(signal);
|
|
3168
|
+
activeAbortEmission = undefined;
|
|
2780
3169
|
controlPlaneError = undefined;
|
|
2781
3170
|
// Every receipt variant is normalized and frozen where it is built,
|
|
2782
3171
|
// inside the guarded region, so the recording step below cannot
|
|
@@ -2822,9 +3211,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2822
3211
|
// final for their key. Past publication such a failure is therefore
|
|
2823
3212
|
// re-latched onto the emission channel, surfacing from the next
|
|
2824
3213
|
// public boundary's drain, and `apply` still does not throw past
|
|
2825
|
-
// acceptance (PBRT-52).
|
|
3214
|
+
// acceptance (PBRT-52). A delivery rejection causally identical to
|
|
3215
|
+
// this call's own abort reason evidences the cancellation and is
|
|
3216
|
+
// dropped — never carried to a later unrelated boundary
|
|
3217
|
+
// (slc/link.md §Abort).
|
|
2826
3218
|
const latchDeliveryFailure = (error) => {
|
|
2827
|
-
|
|
3219
|
+
if (isAbortFailure(error, signal))
|
|
3220
|
+
return;
|
|
3221
|
+
emissionFailure ??= { error };
|
|
2828
3222
|
};
|
|
2829
3223
|
try {
|
|
2830
3224
|
try {
|
|
@@ -2844,7 +3238,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2844
3238
|
key,
|
|
2845
3239
|
...receiptTracePayload({ disposition: 'rejected', reason }),
|
|
2846
3240
|
});
|
|
2847
|
-
await emitCallStarted('apply.started', 'apply.finished', identity, position, preAcceptanceFinish('apply.started trace sink rejected'));
|
|
3241
|
+
await emitCallStarted('apply.started', 'apply.finished', identity, position, signal, preAcceptanceFinish('apply.started trace sink rejected'));
|
|
2848
3242
|
// An abort may land while the awaited started emission drains
|
|
2849
3243
|
// (e.g. fired from the trace sink itself); the action must
|
|
2850
3244
|
// never execute after abort. Settle the already-started pair
|
|
@@ -2923,6 +3317,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2923
3317
|
catch (error) {
|
|
2924
3318
|
settlementError = error;
|
|
2925
3319
|
}
|
|
3320
|
+
// Exact cancellation is not a control-plane latch, but after apply
|
|
3321
|
+
// acceptance and before publication it is still settlement evidence
|
|
3322
|
+
// and therefore folds into the owed failed receipt (DR-036 §4).
|
|
3323
|
+
settlementError ??= activeAbortEmission;
|
|
2926
3324
|
// Fold before the finish emission, the last point at which the
|
|
2927
3325
|
// traced disposition and the returned one can still be made the
|
|
2928
3326
|
// same value.
|
|
@@ -2964,6 +3362,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2964
3362
|
// wedge every later public boundary behind "another runtime turn
|
|
2965
3363
|
// is active".
|
|
2966
3364
|
activeSignal = undefined;
|
|
3365
|
+
activeAborts = undefined;
|
|
3366
|
+
activeAbortEmission = undefined;
|
|
2967
3367
|
activeTurnId = undefined;
|
|
2968
3368
|
controlPlaneError = undefined;
|
|
2969
3369
|
}
|
|
@@ -2999,119 +3399,152 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
2999
3399
|
const turnId = ++turnSequence;
|
|
3000
3400
|
activeTurnId = turnId;
|
|
3001
3401
|
activeSignal = signal;
|
|
3402
|
+
activeAborts = abortReasonClassifier(signal);
|
|
3403
|
+
activeAbortEmission = undefined;
|
|
3002
3404
|
controlPlaneError = undefined;
|
|
3003
3405
|
let result;
|
|
3004
3406
|
let operationError;
|
|
3407
|
+
// The boundary sentinel releases on every exit: a settlement defect
|
|
3408
|
+
// past the drain — a snapshot normalization throw inside
|
|
3409
|
+
// `runResultFor` included — must never wedge every later public
|
|
3410
|
+
// boundary and `dispose` itself behind "another runtime turn is
|
|
3411
|
+
// active". Mirrors the apply boundary's finally.
|
|
3005
3412
|
try {
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
const trimmed = text.trim();
|
|
3012
|
-
if (trimmed !== '') {
|
|
3013
|
-
const snapshot = actor.getSnapshot();
|
|
3014
|
-
const terminal = snapshot.status === 'done';
|
|
3015
|
-
const stateId = normalizePlaybookSnapshot(snapshot).stateId;
|
|
3016
|
-
if (spec.entryEvent !== undefined &&
|
|
3017
|
-
(stateId === 'ready' || terminal)) {
|
|
3018
|
-
event = {
|
|
3019
|
-
type: spec.entryEvent.type,
|
|
3020
|
-
[spec.entryEvent.textField]: text,
|
|
3021
|
-
};
|
|
3022
|
-
}
|
|
3023
|
-
else {
|
|
3024
|
-
event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
|
|
3025
|
-
}
|
|
3026
|
-
signal.throwIfAborted();
|
|
3027
|
-
}
|
|
3028
|
-
// Empty input, no-action classifier output, or invalid classifier
|
|
3029
|
-
// output — nothing to send.
|
|
3030
|
-
if (event === undefined) {
|
|
3031
|
-
result = runResultFor('no-action');
|
|
3032
|
-
}
|
|
3033
|
-
else {
|
|
3034
|
-
// 2. Optional Captain-pane classification line: the bare FSM
|
|
3035
|
-
// event type, emitted before the FSM advances.
|
|
3036
|
-
const statusLine = classificationStatus(event);
|
|
3037
|
-
if (statusLine !== undefined) {
|
|
3038
|
-
await runtimePorts.emitStatus(statusLine);
|
|
3039
|
-
}
|
|
3413
|
+
try {
|
|
3414
|
+
await emitTrace('boss.input.received', { text }, { turnId });
|
|
3415
|
+
// Record the attempted input, then refuse a boundary that entered
|
|
3416
|
+
// aborted before deterministic mapping or the classifier can
|
|
3417
|
+
// perform host-visible work (DR-036 §5).
|
|
3040
3418
|
signal.throwIfAborted();
|
|
3041
|
-
//
|
|
3042
|
-
//
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3419
|
+
// 1. Map the Boss text to an FSM event: deterministic exact entry
|
|
3420
|
+
// where applicable (slc/link.md §Boss-event mapping), judge
|
|
3421
|
+
// classification otherwise.
|
|
3422
|
+
let event;
|
|
3423
|
+
const trimmed = text.trim();
|
|
3424
|
+
if (trimmed !== '') {
|
|
3425
|
+
const snapshot = actor.getSnapshot();
|
|
3426
|
+
const terminal = snapshot.status === 'done';
|
|
3427
|
+
const stateId = normalizePlaybookSnapshot(snapshot).stateId;
|
|
3428
|
+
// PBRT-1 / slc/link.md §Boss-event mapping: the idle entry, the
|
|
3429
|
+
// recoverable failure state, and the reconstructed terminal all
|
|
3430
|
+
// accept exactly one ordinary textual entry event, so delivered
|
|
3431
|
+
// text enters deterministically — no judge call to spend and no
|
|
3432
|
+
// classifier whim to settle a restart as no action. Every other
|
|
3433
|
+
// parked state — a reply wait or an authored mid-workflow
|
|
3434
|
+
// checkpoint — classifies under its own Boss-event contracts.
|
|
3435
|
+
if (spec.entryEvent !== undefined &&
|
|
3436
|
+
(stateId === 'ready' || stateId === 'failed' || terminal)) {
|
|
3437
|
+
event = {
|
|
3438
|
+
type: spec.entryEvent.type,
|
|
3439
|
+
[spec.entryEvent.textField]: text,
|
|
3440
|
+
};
|
|
3441
|
+
}
|
|
3442
|
+
else {
|
|
3443
|
+
event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
|
|
3444
|
+
}
|
|
3445
|
+
signal.throwIfAborted();
|
|
3049
3446
|
}
|
|
3050
|
-
//
|
|
3051
|
-
//
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
try {
|
|
3055
|
-
lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
|
|
3447
|
+
// Empty input, no-action classifier output, or invalid classifier
|
|
3448
|
+
// output — nothing to send.
|
|
3449
|
+
if (event === undefined) {
|
|
3450
|
+
result = runResultFor('no-action');
|
|
3056
3451
|
}
|
|
3057
|
-
|
|
3058
|
-
|
|
3452
|
+
else {
|
|
3453
|
+
// 2. Optional Captain-pane classification line: the bare FSM
|
|
3454
|
+
// event type, emitted before the FSM advances.
|
|
3455
|
+
const statusLine = classificationStatus(event);
|
|
3456
|
+
if (statusLine !== undefined) {
|
|
3457
|
+
await runtimePorts.emitStatus(statusLine);
|
|
3458
|
+
}
|
|
3459
|
+
signal.throwIfAborted();
|
|
3460
|
+
// 3. A final actor cannot accept new events; reconstruct only
|
|
3461
|
+
// after classification produced a real event.
|
|
3462
|
+
if (actor.getSnapshot().status === 'done') {
|
|
3463
|
+
stopActor();
|
|
3464
|
+
actor = buildActor(runtimePorts);
|
|
3465
|
+
// The replacement actor's snapshots are real state entries.
|
|
3466
|
+
suppressInspectionEmissions = false;
|
|
3467
|
+
actor.start();
|
|
3468
|
+
}
|
|
3469
|
+
// DR-029: keep the classified event with its recorded payload
|
|
3470
|
+
// as the retry-replay source. Recording is sanitizing, not
|
|
3471
|
+
// load-bearing: an override classifier's non-JSON-safe event is
|
|
3472
|
+
// simply not recorded, and the turn proceeds unchanged.
|
|
3473
|
+
try {
|
|
3474
|
+
lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
|
|
3475
|
+
}
|
|
3476
|
+
catch {
|
|
3477
|
+
lastBossEvent = undefined;
|
|
3478
|
+
}
|
|
3479
|
+
actor.send(event);
|
|
3480
|
+
await waitForPlaybookQuiescence(actor, {
|
|
3481
|
+
pendingCalls: nestedBridge,
|
|
3482
|
+
});
|
|
3483
|
+
if (controlPlaneError !== undefined)
|
|
3484
|
+
throw controlPlaneError;
|
|
3485
|
+
result = runResultFor(settledOutcome(signal));
|
|
3059
3486
|
}
|
|
3060
|
-
actor.send(event);
|
|
3061
|
-
await waitForPlaybookQuiescence(actor, {
|
|
3062
|
-
pendingCalls: nestedBridge,
|
|
3063
|
-
});
|
|
3064
|
-
if (controlPlaneError !== undefined)
|
|
3065
|
-
throw controlPlaneError;
|
|
3066
|
-
result = runResultFor(settledOutcome(signal));
|
|
3067
3487
|
}
|
|
3488
|
+
catch (error) {
|
|
3489
|
+
operationError = error;
|
|
3490
|
+
}
|
|
3491
|
+
let drainError;
|
|
3492
|
+
try {
|
|
3493
|
+
await drainEmissions();
|
|
3494
|
+
}
|
|
3495
|
+
catch (error) {
|
|
3496
|
+
drainError = error;
|
|
3497
|
+
}
|
|
3498
|
+
const latchedControlError = controlPlaneError;
|
|
3499
|
+
// A drain rejection that is the exact abort reason evidences the
|
|
3500
|
+
// cancellation, not a control-plane failure (slc/link.md §Abort).
|
|
3501
|
+
const drainAbort = drainError !== undefined && isAbortFailure(drainError, signal);
|
|
3502
|
+
const effectiveDrainError = drainAbort ? undefined : drainError;
|
|
3503
|
+
const primaryError = latchedControlError ?? effectiveDrainError ?? operationError;
|
|
3504
|
+
const abortError = latchedControlError === undefined &&
|
|
3505
|
+
effectiveDrainError === undefined &&
|
|
3506
|
+
((operationError !== undefined &&
|
|
3507
|
+
isAbortFailure(operationError, signal)) ||
|
|
3508
|
+
(drainAbort && operationError === undefined));
|
|
3509
|
+
const settlementResult = primaryError === undefined
|
|
3510
|
+
? (result ?? runResultFor('no-action'))
|
|
3511
|
+
: runResultFor(abortError ? 'aborted' : 'failed', primaryError);
|
|
3512
|
+
let settlementEmissionError;
|
|
3513
|
+
try {
|
|
3514
|
+
await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
|
|
3515
|
+
}
|
|
3516
|
+
catch (error) {
|
|
3517
|
+
settlementEmissionError = error;
|
|
3518
|
+
}
|
|
3519
|
+
try {
|
|
3520
|
+
await drainEmissions();
|
|
3521
|
+
}
|
|
3522
|
+
catch (error) {
|
|
3523
|
+
settlementEmissionError ??= error;
|
|
3524
|
+
}
|
|
3525
|
+
if (settlementEmissionError !== undefined &&
|
|
3526
|
+
isAbortFailure(settlementEmissionError, signal)) {
|
|
3527
|
+
settlementEmissionError = undefined;
|
|
3528
|
+
}
|
|
3529
|
+
const failure = controlPlaneError ??
|
|
3530
|
+
latchedControlError ??
|
|
3531
|
+
effectiveDrainError ??
|
|
3532
|
+
(abortError
|
|
3533
|
+
? (settlementEmissionError ?? operationError)
|
|
3534
|
+
: (operationError ?? settlementEmissionError));
|
|
3535
|
+
if (failure !== undefined &&
|
|
3536
|
+
!(abortError && settlementEmissionError === undefined)) {
|
|
3537
|
+
throw failure;
|
|
3538
|
+
}
|
|
3539
|
+
return settlementResult;
|
|
3068
3540
|
}
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
}
|
|
3076
|
-
catch (error) {
|
|
3077
|
-
drainError = error;
|
|
3078
|
-
}
|
|
3079
|
-
const latchedControlError = controlPlaneError;
|
|
3080
|
-
const primaryError = latchedControlError ?? drainError ?? operationError;
|
|
3081
|
-
const abortError = latchedControlError === undefined &&
|
|
3082
|
-
drainError === undefined &&
|
|
3083
|
-
operationError !== undefined &&
|
|
3084
|
-
isAbortFailure(operationError, signal);
|
|
3085
|
-
const settlementResult = primaryError === undefined
|
|
3086
|
-
? (result ?? runResultFor('no-action'))
|
|
3087
|
-
: runResultFor(abortError ? 'aborted' : 'failed', primaryError);
|
|
3088
|
-
let settlementEmissionError;
|
|
3089
|
-
try {
|
|
3090
|
-
await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
|
|
3091
|
-
}
|
|
3092
|
-
catch (error) {
|
|
3093
|
-
settlementEmissionError = error;
|
|
3094
|
-
}
|
|
3095
|
-
try {
|
|
3096
|
-
await drainEmissions();
|
|
3097
|
-
}
|
|
3098
|
-
catch (error) {
|
|
3099
|
-
settlementEmissionError ??= error;
|
|
3100
|
-
}
|
|
3101
|
-
const failure = controlPlaneError ??
|
|
3102
|
-
latchedControlError ??
|
|
3103
|
-
drainError ??
|
|
3104
|
-
(abortError
|
|
3105
|
-
? (settlementEmissionError ?? operationError)
|
|
3106
|
-
: (operationError ?? settlementEmissionError));
|
|
3107
|
-
activeSignal = undefined;
|
|
3108
|
-
activeTurnId = undefined;
|
|
3109
|
-
controlPlaneError = undefined;
|
|
3110
|
-
if (failure !== undefined &&
|
|
3111
|
-
!(abortError && settlementEmissionError === undefined)) {
|
|
3112
|
-
throw failure;
|
|
3541
|
+
finally {
|
|
3542
|
+
activeSignal = undefined;
|
|
3543
|
+
activeAborts = undefined;
|
|
3544
|
+
activeAbortEmission = undefined;
|
|
3545
|
+
activeTurnId = undefined;
|
|
3546
|
+
controlPlaneError = undefined;
|
|
3113
3547
|
}
|
|
3114
|
-
return settlementResult;
|
|
3115
3548
|
},
|
|
3116
3549
|
async resumePlaybookCall(input) {
|
|
3117
3550
|
if (!actor || !savedPorts) {
|
|
@@ -3125,41 +3558,85 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
3125
3558
|
}
|
|
3126
3559
|
activeTurnId = playbookCallTurnIds.get(input.callId);
|
|
3127
3560
|
activeSignal = input.signal;
|
|
3561
|
+
activeAborts = abortReasonClassifier(input.signal);
|
|
3562
|
+
activeAbortEmission = undefined;
|
|
3128
3563
|
controlPlaneError = undefined;
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
try {
|
|
3132
|
-
await nestedBridge.resume(input);
|
|
3133
|
-
}
|
|
3134
|
-
catch (error) {
|
|
3135
|
-
operationError = error;
|
|
3136
|
-
}
|
|
3137
|
-
try {
|
|
3138
|
-
await waitForPlaybookQuiescence(actor, {
|
|
3139
|
-
pendingCalls: nestedBridge,
|
|
3140
|
-
});
|
|
3141
|
-
result = runResultFor(settledOutcome(input.signal));
|
|
3142
|
-
}
|
|
3143
|
-
catch (error) {
|
|
3144
|
-
operationError ??= error;
|
|
3145
|
-
}
|
|
3146
|
-
let drainError;
|
|
3564
|
+
// The boundary sentinel releases on every exit, mirroring
|
|
3565
|
+
// `handleBossInput` and the apply boundary.
|
|
3147
3566
|
try {
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3567
|
+
let result;
|
|
3568
|
+
let operationError;
|
|
3569
|
+
try {
|
|
3570
|
+
await nestedBridge.resume(input);
|
|
3571
|
+
}
|
|
3572
|
+
catch (error) {
|
|
3573
|
+
operationError = error;
|
|
3574
|
+
}
|
|
3575
|
+
try {
|
|
3576
|
+
await waitForPlaybookQuiescence(actor, {
|
|
3577
|
+
pendingCalls: nestedBridge,
|
|
3578
|
+
});
|
|
3579
|
+
result = runResultFor(settledOutcome(input.signal));
|
|
3580
|
+
}
|
|
3581
|
+
catch (error) {
|
|
3582
|
+
operationError ??= error;
|
|
3583
|
+
}
|
|
3584
|
+
// A resume refused because its signal was already aborted
|
|
3585
|
+
// delivers nothing: the pending call survives for a later
|
|
3586
|
+
// resume, and the boundary settles `aborted` rather than
|
|
3587
|
+
// advertising `suspended` (slc/link.md §Nested playbook bridge).
|
|
3588
|
+
if (operationError !== undefined &&
|
|
3589
|
+
isAbortFailure(operationError, input.signal) &&
|
|
3590
|
+
nestedBridge.getPendingCall()?.callId === input.callId) {
|
|
3591
|
+
result = {
|
|
3592
|
+
outcome: 'aborted',
|
|
3593
|
+
state: currentState(),
|
|
3594
|
+
error: normalizeError(input.signal.reason),
|
|
3595
|
+
};
|
|
3596
|
+
}
|
|
3597
|
+
let drainError;
|
|
3598
|
+
try {
|
|
3599
|
+
await drainEmissions();
|
|
3600
|
+
}
|
|
3601
|
+
catch (error) {
|
|
3602
|
+
drainError = error;
|
|
3603
|
+
}
|
|
3604
|
+
const aborts = activeAborts ?? abortReasonClassifier(input.signal);
|
|
3605
|
+
// A control-plane latch has already classified its failure as
|
|
3606
|
+
// distinct under the owning operation. Never reinterpret it
|
|
3607
|
+
// against this later resume signal (DR-036 decision 2).
|
|
3608
|
+
const controlFailure = controlPlaneError;
|
|
3609
|
+
const drainAbort = controlFailure === undefined &&
|
|
3610
|
+
drainError !== undefined &&
|
|
3611
|
+
aborts.isAbortReason(drainError);
|
|
3612
|
+
const operationAbort = controlFailure === undefined &&
|
|
3613
|
+
operationError !== undefined &&
|
|
3614
|
+
aborts.isAbortReason(operationError);
|
|
3615
|
+
const abortEvidence = activeAbortEmission ??
|
|
3616
|
+
(drainAbort ? drainError : undefined) ??
|
|
3617
|
+
(operationAbort ? operationError : undefined);
|
|
3618
|
+
const failure = controlFailure ??
|
|
3619
|
+
(drainAbort ? undefined : drainError) ??
|
|
3620
|
+
(operationAbort ? undefined : operationError);
|
|
3621
|
+
if (failure !== undefined)
|
|
3622
|
+
throw failure;
|
|
3623
|
+
if (abortEvidence !== undefined &&
|
|
3624
|
+
result?.outcome !== 'terminal' &&
|
|
3625
|
+
result?.outcome !== 'suspended') {
|
|
3626
|
+
result = runResultFor('aborted', abortEvidence);
|
|
3627
|
+
}
|
|
3628
|
+
if (result === undefined) {
|
|
3629
|
+
throw new Error('playbook resume produced no runtime result');
|
|
3630
|
+
}
|
|
3631
|
+
return result;
|
|
3152
3632
|
}
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
if (result === undefined) {
|
|
3160
|
-
throw new Error('playbook resume produced no runtime result');
|
|
3633
|
+
finally {
|
|
3634
|
+
activeSignal = undefined;
|
|
3635
|
+
activeAborts = undefined;
|
|
3636
|
+
activeAbortEmission = undefined;
|
|
3637
|
+
activeTurnId = undefined;
|
|
3638
|
+
controlPlaneError = undefined;
|
|
3161
3639
|
}
|
|
3162
|
-
return result;
|
|
3163
3640
|
},
|
|
3164
3641
|
dispose() {
|
|
3165
3642
|
if (disposalPromise !== undefined)
|
|
@@ -3228,6 +3705,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
|
|
|
3228
3705
|
appliedReceipts.clear();
|
|
3229
3706
|
actor = undefined;
|
|
3230
3707
|
activeSignal = undefined;
|
|
3708
|
+
activeAborts = undefined;
|
|
3709
|
+
actorSettlementAborts = undefined;
|
|
3710
|
+
actorSettlementErrorAborts = undefined;
|
|
3711
|
+
activeAbortEmission = undefined;
|
|
3231
3712
|
activeTurnId = undefined;
|
|
3232
3713
|
controlPlaneError = undefined;
|
|
3233
3714
|
emissionFailure = undefined;
|