@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
|
@@ -30,19 +30,28 @@ function snapshotDecideRuntimeOptions(value) {
|
|
|
30
30
|
}
|
|
31
31
|
return Object.freeze({});
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
33
|
+
function authoredStateDescriptions(states) {
|
|
34
|
+
const descriptions = {};
|
|
35
|
+
const visit = (children) => {
|
|
36
|
+
for (const state of Object.values(children ?? {})) {
|
|
37
|
+
const stateId = state.meta?.playbook?.stateId;
|
|
38
|
+
const description = state.meta?.playbook?.description;
|
|
39
|
+
if (typeof stateId === 'string' &&
|
|
40
|
+
typeof description === 'string' &&
|
|
41
|
+
description.trim().length > 0) {
|
|
42
|
+
const existing = descriptions[stateId];
|
|
43
|
+
if (existing !== undefined && existing !== description) {
|
|
44
|
+
throw new Error(`DECIDE state ${stateId} declares conflicting descriptions`);
|
|
45
|
+
}
|
|
46
|
+
descriptions[stateId] = description;
|
|
47
|
+
}
|
|
48
|
+
visit(state.states);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
visit(states);
|
|
52
|
+
return Object.freeze(descriptions);
|
|
53
|
+
}
|
|
54
|
+
const STATE_DESCRIPTIONS = authoredStateDescriptions(decideMachine.config.states);
|
|
46
55
|
const ROLE_STATES = [
|
|
47
56
|
{ stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
|
|
48
57
|
{
|
|
@@ -401,6 +410,14 @@ function isEmptyFinalText(finalText) {
|
|
|
401
410
|
function isAbortFailure(error, signal) {
|
|
402
411
|
return signal.aborted && Object.is(error, signal.reason);
|
|
403
412
|
}
|
|
413
|
+
function abortReasonClassifier(...sources) {
|
|
414
|
+
const captured = sources.filter((source) => source !== undefined);
|
|
415
|
+
return Object.freeze({
|
|
416
|
+
isAbortReason: (error) => captured.some((source) => source instanceof AbortSignal
|
|
417
|
+
? isAbortFailure(error, source)
|
|
418
|
+
: source.isAbortReason(error)),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
404
421
|
function pendingQuestionsFromContext(context) {
|
|
405
422
|
const pending = context.pendingBossQuestions;
|
|
406
423
|
if (pending === undefined ||
|
|
@@ -440,6 +457,22 @@ const STATUS_STATE_IDS = new Set([
|
|
|
440
457
|
...WAIT_STATE_IDS,
|
|
441
458
|
'failed',
|
|
442
459
|
]);
|
|
460
|
+
// PBRT-45: a question is pending only while its authored reply-wait state
|
|
461
|
+
// is active. The context retains an answered question through the resumed
|
|
462
|
+
// player call so the Q+A continuation prompt can quote it, and each branch
|
|
463
|
+
// keeps its own entry through the parallel region — so an unfiltered
|
|
464
|
+
// projection would report the answered question as still awaiting during
|
|
465
|
+
// the resume, and both branch questions after only one remains pending.
|
|
466
|
+
const RESUME_WAIT_STATE_IDS = {
|
|
467
|
+
...Object.fromEntries(Object.entries(WAIT_STATE_RESUME_IDS).map(([waitStateId, resumeStateId]) => [
|
|
468
|
+
resumeStateId,
|
|
469
|
+
waitStateId,
|
|
470
|
+
])),
|
|
471
|
+
commitCoderProposal: 'awaitBossReply',
|
|
472
|
+
};
|
|
473
|
+
function pendingQuestionsForState(state, context) {
|
|
474
|
+
return pendingQuestionsFromContext(context).filter((pending) => state.activeStateIds.includes(RESUME_WAIT_STATE_IDS[pending.resumeStateId] ?? ''));
|
|
475
|
+
}
|
|
443
476
|
function questionForWaitState(stateId, pendingQuestions) {
|
|
444
477
|
const resumeStateId = WAIT_STATE_RESUME_IDS[stateId];
|
|
445
478
|
if (resumeStateId !== undefined) {
|
|
@@ -478,7 +511,7 @@ function normalizedTransitionEvent(event) {
|
|
|
478
511
|
return snapshotJsonValue(descriptor, 'FSM event');
|
|
479
512
|
}
|
|
480
513
|
function telemetryPayload(previousState, state, event, context) {
|
|
481
|
-
const pendingBossQuestions =
|
|
514
|
+
const pendingBossQuestions = pendingQuestionsForState(state, context);
|
|
482
515
|
const prior = previousState ?? state;
|
|
483
516
|
const payload = {
|
|
484
517
|
from: prior.value,
|
|
@@ -500,6 +533,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
500
533
|
let sessionIdentity;
|
|
501
534
|
let actor;
|
|
502
535
|
let currentSignal;
|
|
536
|
+
let currentAborts;
|
|
537
|
+
const actorSettlementAborts = [];
|
|
538
|
+
let actorSettlementErrorAborts;
|
|
503
539
|
let currentTurnId;
|
|
504
540
|
let previousState;
|
|
505
541
|
let suppressInspectionEmissions = false;
|
|
@@ -536,24 +572,27 @@ export const createPlaybookRuntime = (options) => {
|
|
|
536
572
|
if (!isAbortFailure(error, signal))
|
|
537
573
|
controlPlaneError ??= error;
|
|
538
574
|
};
|
|
539
|
-
const latchInspectionError = (error) => {
|
|
540
|
-
if (
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
575
|
+
const latchInspectionError = (error, aborts = currentAborts) => {
|
|
576
|
+
if (aborts?.isAbortReason(error))
|
|
577
|
+
return;
|
|
578
|
+
if (currentSignal !== undefined)
|
|
579
|
+
controlPlaneError ??= error;
|
|
580
|
+
else
|
|
544
581
|
collectFailure(emissionFailures, error);
|
|
545
|
-
}
|
|
546
582
|
};
|
|
547
|
-
const enqueue = (fn) => {
|
|
583
|
+
const enqueue = (fn, aborts = currentAborts) => {
|
|
584
|
+
const enqueueAborts = aborts;
|
|
548
585
|
const queued = emissionQueue.add(fn);
|
|
549
586
|
activeEmissionCalls.add(queued);
|
|
550
587
|
void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
|
|
551
588
|
activeEmissionCalls.delete(queued);
|
|
552
|
-
|
|
589
|
+
if (!enqueueAborts?.isAbortReason(error)) {
|
|
590
|
+
collectFailure(emissionFailures, error);
|
|
591
|
+
}
|
|
553
592
|
});
|
|
554
593
|
return queued;
|
|
555
594
|
};
|
|
556
|
-
const flush = async () => {
|
|
595
|
+
const flush = async (_aborts = currentAborts) => {
|
|
557
596
|
while (true) {
|
|
558
597
|
const active = [...activeEmissionCalls];
|
|
559
598
|
if (active.length > 0)
|
|
@@ -569,9 +608,15 @@ export const createPlaybookRuntime = (options) => {
|
|
|
569
608
|
return;
|
|
570
609
|
const failures = emissionFailures;
|
|
571
610
|
emissionFailures = [];
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
611
|
+
const failure = failures.length === 1
|
|
612
|
+
? failures[0]
|
|
613
|
+
: new AggregateError(failures, 'decide runtime emissions failed');
|
|
614
|
+
// Enqueue ownership already classified every stored failure as distinct.
|
|
615
|
+
// Preserve that classification if an unrelated public boundary drains
|
|
616
|
+
// it with a signal whose reason happens to be the same object.
|
|
617
|
+
if (currentSignal !== undefined)
|
|
618
|
+
controlPlaneError ??= failure;
|
|
619
|
+
throw failure;
|
|
575
620
|
};
|
|
576
621
|
const drainBoundaryCallsAndEmissions = async () => {
|
|
577
622
|
while (true) {
|
|
@@ -737,7 +782,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
737
782
|
const stateIdentity = (state) => {
|
|
738
783
|
return state.stateId === undefined ? {} : { stateId: state.stateId };
|
|
739
784
|
};
|
|
740
|
-
const enqueueTracedEmission = (type, payload, meta = {}, describedEmission) => {
|
|
785
|
+
const enqueueTracedEmission = (type, payload, meta = {}, describedEmission, aborts) => {
|
|
741
786
|
const runtimePorts = requirePorts();
|
|
742
787
|
const identity = requireSessionIdentity();
|
|
743
788
|
const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
@@ -763,9 +808,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
763
808
|
return enqueue(async () => {
|
|
764
809
|
await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
|
|
765
810
|
await describedEmission?.(runtimePorts);
|
|
766
|
-
});
|
|
811
|
+
}, aborts);
|
|
767
812
|
};
|
|
768
|
-
const emitTrace = (type, payload, meta = {}) => enqueueTracedEmission(type, payload, meta);
|
|
813
|
+
const emitTrace = (type, payload, meta = {}, aborts) => enqueueTracedEmission(type, payload, meta, undefined, aborts);
|
|
769
814
|
const emitBoundaryStatus = async (message, state) => {
|
|
770
815
|
const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
|
|
771
816
|
await enqueueTracedEmission('status.emitted', {
|
|
@@ -777,20 +822,24 @@ export const createPlaybookRuntime = (options) => {
|
|
|
777
822
|
}, { turnId: currentTurnId }, (runtimePorts) => runtimePorts.emitStatus(message));
|
|
778
823
|
};
|
|
779
824
|
const emitCallStarted = async (startedType, finishedType, identity, meta, signal) => {
|
|
825
|
+
const aborts = abortReasonClassifier(signal);
|
|
780
826
|
try {
|
|
781
|
-
await emitTrace(startedType, identity, meta);
|
|
827
|
+
await emitTrace(startedType, identity, meta, aborts);
|
|
782
828
|
}
|
|
783
829
|
catch (error) {
|
|
784
830
|
latchControlPlaneError(error, signal);
|
|
785
831
|
try {
|
|
786
832
|
await emitTrace(finishedType, {
|
|
787
833
|
...identity,
|
|
788
|
-
|
|
834
|
+
// A started-trace sink rejection causally identical to the
|
|
835
|
+
// boundary reason is the abort's own evidence: the pair
|
|
836
|
+
// finishes 'aborted', not 'error' (DR-036 §4).
|
|
837
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
789
838
|
error: normalizeErrorFull(error) ?? {
|
|
790
839
|
name: 'Error',
|
|
791
840
|
message: String(error),
|
|
792
841
|
},
|
|
793
|
-
}, meta);
|
|
842
|
+
}, meta, aborts);
|
|
794
843
|
}
|
|
795
844
|
catch {
|
|
796
845
|
// Preserve the start failure after one best-effort finish attempt.
|
|
@@ -799,6 +848,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
799
848
|
}
|
|
800
849
|
};
|
|
801
850
|
const runJudgeCall = async (prompt, signal, purpose, callStateId) => {
|
|
851
|
+
const aborts = abortReasonClassifier(signal);
|
|
802
852
|
const identity = {
|
|
803
853
|
purpose,
|
|
804
854
|
...(callStateId !== undefined ? { stateId: callStateId } : {}),
|
|
@@ -826,15 +876,18 @@ export const createPlaybookRuntime = (options) => {
|
|
|
826
876
|
latchControlPlaneError(error, signal);
|
|
827
877
|
await emitTrace('judge.call.finished', {
|
|
828
878
|
...identity,
|
|
829
|
-
|
|
879
|
+
// Only the exact abort reason is cancellation; a distinct
|
|
880
|
+
// failure under an aborted signal stays an error
|
|
881
|
+
// (slc/link.md §Abort).
|
|
882
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
830
883
|
error: normalizeErrorFull(error) ?? {
|
|
831
884
|
name: 'Error',
|
|
832
885
|
message: String(error),
|
|
833
886
|
},
|
|
834
|
-
}, { turnId: currentTurnId, callId });
|
|
887
|
+
}, { turnId: currentTurnId, callId }, aborts);
|
|
835
888
|
throw error;
|
|
836
889
|
}
|
|
837
|
-
await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply: finalText }, { turnId: currentTurnId, callId });
|
|
890
|
+
await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply: finalText }, { turnId: currentTurnId, callId }, aborts);
|
|
838
891
|
return finalText;
|
|
839
892
|
});
|
|
840
893
|
if (queued === undefined) {
|
|
@@ -844,6 +897,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
844
897
|
};
|
|
845
898
|
const callJudge = (prompt, signal, purpose, callStateId) => trackBoundaryCall(runJudgeCall(prompt, signal, purpose, callStateId));
|
|
846
899
|
const runPlayerCall = async (input, signal) => {
|
|
900
|
+
const aborts = abortReasonClassifier(signal);
|
|
847
901
|
if (!ROLE_ID_SET.has(input.role)) {
|
|
848
902
|
throw new TypeError(`DECIDE player input role must name a declared local role`);
|
|
849
903
|
}
|
|
@@ -870,12 +924,15 @@ export const createPlaybookRuntime = (options) => {
|
|
|
870
924
|
};
|
|
871
925
|
const emitFailure = (error) => emitTrace('player.call.finished', {
|
|
872
926
|
...identity,
|
|
873
|
-
|
|
927
|
+
// Only the exact abort reason is cancellation; a distinct
|
|
928
|
+
// failure under an aborted signal stays an error
|
|
929
|
+
// (slc/link.md §Abort).
|
|
930
|
+
status: isAbortFailure(error, signal) ? 'aborted' : 'error',
|
|
874
931
|
error: normalizeErrorFull(error) ?? {
|
|
875
932
|
name: 'Error',
|
|
876
933
|
message: String(error),
|
|
877
934
|
},
|
|
878
|
-
}, { turnId: currentTurnId, callId });
|
|
935
|
+
}, { turnId: currentTurnId, callId }, aborts);
|
|
879
936
|
if (inFlightPlayerKeys.has(playerKey)) {
|
|
880
937
|
const error = new Error(`resolved player key "${playerKey}" already has an in-flight call`);
|
|
881
938
|
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
|
|
@@ -952,7 +1009,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
952
1009
|
...(result.error !== undefined
|
|
953
1010
|
? { error: normalizeErrorFull(result.error) }
|
|
954
1011
|
: {}),
|
|
955
|
-
}, { turnId: currentTurnId, callId });
|
|
1012
|
+
}, { turnId: currentTurnId, callId }, aborts);
|
|
956
1013
|
return {
|
|
957
1014
|
roleId,
|
|
958
1015
|
...(playerId === undefined ? {} : { playerId }),
|
|
@@ -968,52 +1025,59 @@ export const createPlaybookRuntime = (options) => {
|
|
|
968
1025
|
};
|
|
969
1026
|
const player = fromPromise(async ({ input, signal }) => {
|
|
970
1027
|
const combined = combineSignals(signal, currentSignal);
|
|
971
|
-
|
|
972
|
-
// Yield through the runtime emission queue before crossing the player
|
|
973
|
-
// boundary so state trace/status always precede its call-start trace.
|
|
1028
|
+
const settlementAborts = abortReasonClassifier(combined);
|
|
974
1029
|
try {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
latchControlPlaneError(error, combined);
|
|
979
|
-
throw error;
|
|
980
|
-
}
|
|
981
|
-
combined.throwIfAborted();
|
|
982
|
-
let { roleId, playerId, result } = await callPlayer(input, combined);
|
|
983
|
-
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
984
|
-
// DR-028: an `ok` result whose finalText is missing, empty, or
|
|
985
|
-
// whitespace-only earns exactly one corrective re-ask — the same
|
|
986
|
-
// composed call repeated, traced by runPlayerCall as its own
|
|
987
|
-
// player-call pair, with the resume selection re-read from the
|
|
988
|
-
// token map the first result left (PBRT-38). An abort that lands
|
|
989
|
-
// between the two calls ends the turn without the re-ask (aborts
|
|
990
|
-
// are never retried), and a rejecting finish emission rejects
|
|
991
|
-
// `callPlayer` itself, so it never reaches this branch (PBRT-47).
|
|
1030
|
+
// XState starts invoked actors while publishing the entering snapshot.
|
|
1031
|
+
// Yield through the runtime emission queue before crossing the player
|
|
1032
|
+
// boundary so state trace/status always precede its call-start trace.
|
|
992
1033
|
combined.throwIfAborted();
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1034
|
+
try {
|
|
1035
|
+
await flush(settlementAborts);
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
latchControlPlaneError(error, combined);
|
|
1039
|
+
throw error;
|
|
1040
|
+
}
|
|
1041
|
+
combined.throwIfAborted();
|
|
1042
|
+
let { roleId, playerId, result } = await callPlayer(input, combined);
|
|
1043
|
+
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
1044
|
+
// DR-028: an `ok` result whose finalText is missing, empty, or
|
|
1045
|
+
// whitespace-only earns exactly one corrective re-ask — the same
|
|
1046
|
+
// composed call repeated, traced by runPlayerCall as its own
|
|
1047
|
+
// player-call pair, with the resume selection re-read from the
|
|
1048
|
+
// token map the first result left (PBRT-38). An abort that lands
|
|
1049
|
+
// between the two calls ends the turn without the re-ask (aborts
|
|
1050
|
+
// are never retried), and a rejecting finish emission rejects
|
|
1051
|
+
// `callPlayer` itself, so it never reaches this branch (PBRT-47).
|
|
1052
|
+
combined.throwIfAborted();
|
|
1053
|
+
({ roleId, playerId, result } = await callPlayer(input, combined));
|
|
1054
|
+
}
|
|
1055
|
+
if (result.status !== 'ok') {
|
|
1056
|
+
throw new Error(`${roleLabel(roleId)}${playerId === undefined ? '' : ` (${playerId})`} returned status "${result.status}"${result.error ? `: ${result.error}` : ''}`);
|
|
1057
|
+
}
|
|
1058
|
+
const finalText = result.finalText ?? '';
|
|
1059
|
+
if (isEmptyFinalText(finalText)) {
|
|
1060
|
+
throw new Error(`${roleLabel(roleId)}${playerId === undefined ? '' : ` (${playerId})`} returned status "ok" with no finalText`);
|
|
1061
|
+
}
|
|
1062
|
+
combined.throwIfAborted();
|
|
1063
|
+
try {
|
|
1064
|
+
const prompt = buildAdjudicatorPrompt(input, finalText);
|
|
1065
|
+
return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
|
|
1066
|
+
}
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
latchControlPlaneError(error, combined);
|
|
1069
|
+
throw error;
|
|
1070
|
+
}
|
|
1006
1071
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
throw error;
|
|
1072
|
+
finally {
|
|
1073
|
+
actorSettlementAborts.push(settlementAborts);
|
|
1010
1074
|
}
|
|
1011
1075
|
});
|
|
1012
1076
|
nestedBridge = createNestedPlaybookBridge({
|
|
1013
1077
|
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
1014
1078
|
getBoundarySignal: () => currentSignal,
|
|
1015
1079
|
callPlaybook: (request, signal) => trackBoundaryCall(Promise.resolve(requirePorts().callPlaybook(request, signal))),
|
|
1016
|
-
emitStarted: async (event) => {
|
|
1080
|
+
emitStarted: async (event, aborts) => {
|
|
1017
1081
|
playbookCallTurnIds.set(event.callId, currentTurnId);
|
|
1018
1082
|
await emitTrace('playbook.call.started', {
|
|
1019
1083
|
stateId: event.stateId,
|
|
@@ -1022,9 +1086,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1022
1086
|
}, {
|
|
1023
1087
|
...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
|
|
1024
1088
|
callId: event.callId,
|
|
1025
|
-
});
|
|
1089
|
+
}, aborts);
|
|
1026
1090
|
},
|
|
1027
|
-
emitFinished: async (event) => {
|
|
1091
|
+
emitFinished: async (event, aborts) => {
|
|
1028
1092
|
const turnId = playbookCallTurnIds.get(event.callId);
|
|
1029
1093
|
try {
|
|
1030
1094
|
await emitTrace('playbook.call.finished', {
|
|
@@ -1035,29 +1099,48 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1035
1099
|
}, {
|
|
1036
1100
|
...(turnId === undefined ? {} : { turnId }),
|
|
1037
1101
|
callId: event.callId,
|
|
1038
|
-
});
|
|
1102
|
+
}, aborts);
|
|
1039
1103
|
}
|
|
1040
1104
|
finally {
|
|
1041
1105
|
playbookCallTurnIds.delete(event.callId);
|
|
1042
1106
|
}
|
|
1043
1107
|
},
|
|
1044
1108
|
drain: flush,
|
|
1045
|
-
bindResumeSignal: (signal) => {
|
|
1109
|
+
bindResumeSignal: (signal, aborts) => {
|
|
1046
1110
|
currentSignal = signal;
|
|
1111
|
+
currentAborts = aborts ?? abortReasonClassifier(signal);
|
|
1047
1112
|
},
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1113
|
+
bindActorSettlement: (aborts) => {
|
|
1114
|
+
actorSettlementAborts.push(aborts);
|
|
1115
|
+
},
|
|
1116
|
+
onControlPlaneError: (error, aborts) => {
|
|
1117
|
+
if (!aborts?.isAbortReason(error) &&
|
|
1118
|
+
!currentAborts?.isAbortReason(error)) {
|
|
1051
1119
|
controlPlaneError ??= error;
|
|
1052
1120
|
}
|
|
1053
1121
|
},
|
|
1054
|
-
onBackgroundError: (error) => {
|
|
1055
|
-
|
|
1122
|
+
onBackgroundError: (error, aborts) => {
|
|
1123
|
+
if (!aborts?.isAbortReason(error)) {
|
|
1124
|
+
collectFailure(emissionFailures, error);
|
|
1125
|
+
}
|
|
1056
1126
|
},
|
|
1057
1127
|
});
|
|
1058
1128
|
const providedMachine = decideMachine.provide({
|
|
1059
1129
|
actors: { player, playbook: nestedBridge.actorLogic },
|
|
1060
1130
|
});
|
|
1131
|
+
const consumeActorSettlementAborts = (forSnapshot = false) => {
|
|
1132
|
+
const aborts = actorSettlementAborts.shift() ?? actorSettlementErrorAborts;
|
|
1133
|
+
actorSettlementErrorAborts = undefined;
|
|
1134
|
+
if (forSnapshot && aborts !== undefined) {
|
|
1135
|
+
actorSettlementErrorAborts = aborts;
|
|
1136
|
+
queueMicrotask(() => {
|
|
1137
|
+
if (actorSettlementErrorAborts === aborts) {
|
|
1138
|
+
actorSettlementErrorAborts = undefined;
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
return aborts;
|
|
1143
|
+
};
|
|
1061
1144
|
const inspect = (event) => {
|
|
1062
1145
|
if (event.type !== '@xstate.snapshot')
|
|
1063
1146
|
return;
|
|
@@ -1065,6 +1148,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1065
1148
|
return;
|
|
1066
1149
|
if (suppressInspectionEmissions)
|
|
1067
1150
|
return;
|
|
1151
|
+
const settlementAborts = consumeActorSettlementAborts(true);
|
|
1068
1152
|
try {
|
|
1069
1153
|
const snapshot = event.snapshot;
|
|
1070
1154
|
const state = normalizePlaybookSnapshot(snapshot);
|
|
@@ -1075,7 +1159,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1075
1159
|
void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
|
|
1076
1160
|
topic: TELEMETRY_TOPIC,
|
|
1077
1161
|
payload: describedFsmPayload,
|
|
1078
|
-
})).catch(() => undefined);
|
|
1162
|
+
}), settlementAborts).catch(() => undefined);
|
|
1079
1163
|
const priorIds = new Set(previousState?.activeStateIds ?? []);
|
|
1080
1164
|
previousState = state;
|
|
1081
1165
|
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
@@ -1088,7 +1172,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1088
1172
|
...(data !== undefined ? { data } : {}),
|
|
1089
1173
|
};
|
|
1090
1174
|
assertJsonSafe(tracePayload);
|
|
1091
|
-
void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data)).catch(() => undefined);
|
|
1175
|
+
void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data), settlementAborts).catch(() => undefined);
|
|
1092
1176
|
};
|
|
1093
1177
|
for (const activeStateId of state.activeStateIds) {
|
|
1094
1178
|
if (priorIds.has(activeStateId) ||
|
|
@@ -1116,7 +1200,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1116
1200
|
}
|
|
1117
1201
|
}
|
|
1118
1202
|
catch (error) {
|
|
1119
|
-
latchInspectionError(error);
|
|
1203
|
+
latchInspectionError(error, settlementAborts);
|
|
1120
1204
|
}
|
|
1121
1205
|
};
|
|
1122
1206
|
const createRuntimeActor = (machineSnapshot) => {
|
|
@@ -1132,6 +1216,15 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1132
1216
|
}),
|
|
1133
1217
|
inspect,
|
|
1134
1218
|
});
|
|
1219
|
+
// A synchronous FSM action throw errors the actor without any pending
|
|
1220
|
+
// boundary await to observe it; unobserved, XState would surface it via
|
|
1221
|
+
// reportUnhandledError as an uncaughtException. Observe it here: latch
|
|
1222
|
+
// it as a control error while a turn signal is active (unless it is
|
|
1223
|
+
// the abort reason itself), otherwise collect it with the emission
|
|
1224
|
+
// failures (slc/link.md §Abort).
|
|
1225
|
+
actor.subscribe({
|
|
1226
|
+
error: (error) => latchInspectionError(error, consumeActorSettlementAborts()),
|
|
1227
|
+
});
|
|
1135
1228
|
};
|
|
1136
1229
|
// PBRT-6: the single seam that stops this runtime's actor. Stopping a
|
|
1137
1230
|
// still-running actor fires one more `@xstate.snapshot` for the *unchanged*
|
|
@@ -1167,7 +1260,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1167
1260
|
const state = normalizePlaybookSnapshot(snapshot, {
|
|
1168
1261
|
pendingCall: nestedBridge.getPendingCall(),
|
|
1169
1262
|
});
|
|
1170
|
-
const pendingQuestions =
|
|
1263
|
+
const pendingQuestions = pendingQuestionsForState(state, context);
|
|
1171
1264
|
if (pendingQuestions.length === 0 &&
|
|
1172
1265
|
(snapshot.status === 'done' ||
|
|
1173
1266
|
state.activeStateIds.includes('ready') ||
|
|
@@ -1191,33 +1284,53 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1191
1284
|
const pendingCall = nestedBridge.getPendingCall();
|
|
1192
1285
|
const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
|
|
1193
1286
|
const context = snapshot.context;
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
: {
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1287
|
+
const abortedResult = (abortSignal) => ({
|
|
1288
|
+
outcome: 'aborted',
|
|
1289
|
+
state,
|
|
1290
|
+
...(abortSignal.reason === undefined
|
|
1291
|
+
? {}
|
|
1292
|
+
: {
|
|
1293
|
+
error: normalizeErrorFull(abortSignal.reason) ?? {
|
|
1294
|
+
name: 'AbortError',
|
|
1295
|
+
message: String(abortSignal.reason),
|
|
1296
|
+
},
|
|
1297
|
+
}),
|
|
1298
|
+
});
|
|
1299
|
+
if (snapshot.status === 'error') {
|
|
1300
|
+
// An errored actor outranks a coincident abort unless the actor's
|
|
1301
|
+
// error is the abort reason itself (slc/link.md §Abort).
|
|
1302
|
+
const actorError = snapshot.error;
|
|
1303
|
+
if (actorError !== undefined &&
|
|
1304
|
+
signal !== undefined &&
|
|
1305
|
+
isAbortFailure(actorError, signal)) {
|
|
1306
|
+
return abortedResult(signal);
|
|
1307
|
+
}
|
|
1308
|
+
throw (actorError ?? new Error('decide runtime actor entered error status'));
|
|
1309
|
+
}
|
|
1310
|
+
// Terminal completion outranks a coincident abort (DR-036 §3): reporting
|
|
1311
|
+
// 'aborted' over a completed machine would hide a terminal state that the
|
|
1312
|
+
// next turn silently restarts, duplicating the workflow's side effects.
|
|
1208
1313
|
if (snapshot.status === 'done') {
|
|
1209
1314
|
const output = snapshot.output;
|
|
1210
1315
|
if (output !== undefined)
|
|
1211
1316
|
assertJsonSafe(output, 'terminal output');
|
|
1317
|
+
const stateDescription = state.activeStateIds.includes('done')
|
|
1318
|
+
? STATE_DESCRIPTIONS.done
|
|
1319
|
+
: state.activeStateIds.includes('reportedReviewFailure')
|
|
1320
|
+
? STATE_DESCRIPTIONS.reportedReviewFailure
|
|
1321
|
+
: undefined;
|
|
1322
|
+
if (stateDescription === undefined) {
|
|
1323
|
+
throw new Error('decide runtime: completed actor has no authored final-state description');
|
|
1324
|
+
}
|
|
1212
1325
|
return {
|
|
1213
1326
|
outcome: 'terminal',
|
|
1214
1327
|
state,
|
|
1328
|
+
stateDescription,
|
|
1215
1329
|
...(output === undefined ? {} : { output }),
|
|
1216
1330
|
};
|
|
1217
1331
|
}
|
|
1218
|
-
if (
|
|
1219
|
-
|
|
1220
|
-
new Error('decide runtime actor entered error status'));
|
|
1332
|
+
if (signal?.aborted) {
|
|
1333
|
+
return abortedResult(signal);
|
|
1221
1334
|
}
|
|
1222
1335
|
if (state.activeStateIds.includes('failed')) {
|
|
1223
1336
|
const error = normalizeErrorFull(context.lastError);
|
|
@@ -1288,6 +1401,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1288
1401
|
judgeQueue.clear();
|
|
1289
1402
|
actor = undefined;
|
|
1290
1403
|
currentSignal = undefined;
|
|
1404
|
+
currentAborts = undefined;
|
|
1405
|
+
actorSettlementAborts.length = 0;
|
|
1406
|
+
actorSettlementErrorAborts = undefined;
|
|
1291
1407
|
currentTurnId = undefined;
|
|
1292
1408
|
ports = undefined;
|
|
1293
1409
|
sessionIdentity = undefined;
|
|
@@ -1401,7 +1517,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1401
1517
|
playbookCall: playbookCallSequence,
|
|
1402
1518
|
},
|
|
1403
1519
|
state,
|
|
1404
|
-
pendingBossQuestions:
|
|
1520
|
+
pendingBossQuestions: pendingQuestionsForState(state, context).map((pending) => ({
|
|
1405
1521
|
questionId: pending.questionId,
|
|
1406
1522
|
asker: pending.asker,
|
|
1407
1523
|
question: pending.question,
|
|
@@ -1500,12 +1616,17 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1500
1616
|
const turnId = ++turnSequence;
|
|
1501
1617
|
currentTurnId = turnId;
|
|
1502
1618
|
currentSignal = turn.signal;
|
|
1619
|
+
currentAborts = abortReasonClassifier(turn.signal);
|
|
1503
1620
|
controlPlaneError = undefined;
|
|
1504
1621
|
let result = resultForSnapshot(turn.signal);
|
|
1505
1622
|
let settlement = result;
|
|
1506
1623
|
const failures = [];
|
|
1507
1624
|
try {
|
|
1508
1625
|
await emitTrace('boss.input.received', { text: turn.text }, { turnId });
|
|
1626
|
+
// A boundary entered aborted records the attempted input, then refuses
|
|
1627
|
+
// delivery before deterministic mapping or the classifier can perform
|
|
1628
|
+
// any host-visible work (DR-036 §5).
|
|
1629
|
+
turn.signal.throwIfAborted();
|
|
1509
1630
|
if (turn.text.trim().length === 0) {
|
|
1510
1631
|
const state = currentState();
|
|
1511
1632
|
result = { outcome: 'no-action', state };
|
|
@@ -1541,16 +1662,19 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1541
1662
|
}
|
|
1542
1663
|
catch (error) {
|
|
1543
1664
|
const primaryError = controlPlaneError;
|
|
1665
|
+
// Only a rejection that is the exact abort reason settles as the
|
|
1666
|
+
// cancellation; a distinct failure observed while the signal is
|
|
1667
|
+
// aborted remains a control error (slc/link.md §Abort).
|
|
1544
1668
|
if (primaryError !== undefined) {
|
|
1545
1669
|
collectFailure(failures, primaryError);
|
|
1546
1670
|
}
|
|
1547
|
-
else if (!turn.signal
|
|
1671
|
+
else if (!isAbortFailure(error, turn.signal)) {
|
|
1548
1672
|
collectFailure(failures, error);
|
|
1549
1673
|
}
|
|
1550
1674
|
const state = currentState();
|
|
1551
1675
|
const effectiveError = primaryError ?? error;
|
|
1552
1676
|
result =
|
|
1553
|
-
turn.signal
|
|
1677
|
+
isAbortFailure(error, turn.signal) && primaryError === undefined
|
|
1554
1678
|
? resultForSnapshot(turn.signal)
|
|
1555
1679
|
: {
|
|
1556
1680
|
outcome: 'failed',
|
|
@@ -1571,12 +1695,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1571
1695
|
catch (error) {
|
|
1572
1696
|
const primaryError = controlPlaneError;
|
|
1573
1697
|
const effectiveError = primaryError ?? error;
|
|
1574
|
-
|
|
1698
|
+
// A drain rejection that is the exact abort reason evidences the
|
|
1699
|
+
// cancellation, not a control-plane failure (slc/link.md §Abort).
|
|
1700
|
+
const drainAborted = isAbortFailure(effectiveError, turn.signal);
|
|
1701
|
+
if (!drainAborted)
|
|
1702
|
+
collectFailure(failures, effectiveError);
|
|
1575
1703
|
const state = currentState();
|
|
1576
1704
|
result = {
|
|
1577
|
-
outcome:
|
|
1578
|
-
? 'aborted'
|
|
1579
|
-
: 'failed',
|
|
1705
|
+
outcome: drainAborted ? 'aborted' : 'failed',
|
|
1580
1706
|
state,
|
|
1581
1707
|
error: normalizeErrorFull(effectiveError) ?? {
|
|
1582
1708
|
name: 'Error',
|
|
@@ -1585,21 +1711,31 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1585
1711
|
};
|
|
1586
1712
|
settlement = { ...result, ...stateIdentity(state) };
|
|
1587
1713
|
}
|
|
1588
|
-
currentSignal = undefined;
|
|
1589
1714
|
try {
|
|
1590
1715
|
await emitTrace('boss.input.settled', settlement, { turnId });
|
|
1591
1716
|
}
|
|
1592
1717
|
catch (error) {
|
|
1593
|
-
|
|
1718
|
+
// A settlement-trace rejection that is the exact abort reason also
|
|
1719
|
+
// evidences the cancellation (slc/link.md §Abort).
|
|
1720
|
+
if (!isAbortFailure(error, turn.signal)) {
|
|
1721
|
+
collectFailure(failures, error);
|
|
1722
|
+
}
|
|
1594
1723
|
}
|
|
1595
1724
|
try {
|
|
1596
1725
|
await flush();
|
|
1597
1726
|
}
|
|
1598
1727
|
catch (error) {
|
|
1599
|
-
|
|
1728
|
+
// A late flush rejection that is the exact abort reason likewise
|
|
1729
|
+
// evidences the cancellation; the settled result already labels
|
|
1730
|
+
// the turn aborted then (slc/link.md §Abort).
|
|
1731
|
+
if (!isAbortFailure(error, turn.signal)) {
|
|
1732
|
+
collectFailure(failures, error);
|
|
1733
|
+
}
|
|
1600
1734
|
}
|
|
1601
1735
|
finally {
|
|
1602
1736
|
const primaryError = controlPlaneError;
|
|
1737
|
+
currentSignal = undefined;
|
|
1738
|
+
currentAborts = undefined;
|
|
1603
1739
|
currentTurnId = undefined;
|
|
1604
1740
|
controlPlaneError = undefined;
|
|
1605
1741
|
if (primaryError !== undefined)
|
|
@@ -1625,6 +1761,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1625
1761
|
}
|
|
1626
1762
|
currentTurnId = playbookCallTurnIds.get(callId);
|
|
1627
1763
|
currentSignal = signal;
|
|
1764
|
+
currentAborts = abortReasonClassifier(signal);
|
|
1628
1765
|
controlPlaneError = undefined;
|
|
1629
1766
|
let runResult;
|
|
1630
1767
|
let operationError;
|
|
@@ -1654,14 +1791,50 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1654
1791
|
catch (error) {
|
|
1655
1792
|
drainError = error;
|
|
1656
1793
|
}
|
|
1657
|
-
const
|
|
1794
|
+
const aborts = currentAborts ?? abortReasonClassifier(signal);
|
|
1795
|
+
// The control latch has already classified its failure as distinct
|
|
1796
|
+
// under the operation that owned it. Only still-unclassified drain and
|
|
1797
|
+
// operation candidates may be cancellation evidence for this resume.
|
|
1798
|
+
const controlFailure = controlPlaneError;
|
|
1799
|
+
const drainAbort = controlFailure === undefined &&
|
|
1800
|
+
drainError !== undefined &&
|
|
1801
|
+
aborts.isAbortReason(drainError);
|
|
1802
|
+
const operationAbort = controlFailure === undefined &&
|
|
1803
|
+
operationError !== undefined &&
|
|
1804
|
+
aborts.isAbortReason(operationError);
|
|
1805
|
+
const abortEvidence = (drainAbort ? drainError : undefined) ??
|
|
1806
|
+
(operationAbort ? operationError : undefined);
|
|
1807
|
+
const failure = controlFailure ??
|
|
1808
|
+
(drainAbort ? undefined : drainError) ??
|
|
1809
|
+
(operationAbort ? undefined : operationError);
|
|
1658
1810
|
currentSignal = undefined;
|
|
1811
|
+
currentAborts = undefined;
|
|
1659
1812
|
currentTurnId = undefined;
|
|
1660
1813
|
controlPlaneError = undefined;
|
|
1661
1814
|
if (failure !== undefined)
|
|
1662
1815
|
throw failure;
|
|
1816
|
+
if (abortEvidence !== undefined &&
|
|
1817
|
+
runResult?.outcome !== 'terminal' &&
|
|
1818
|
+
runResult?.outcome !== 'suspended') {
|
|
1819
|
+
const state = currentState();
|
|
1820
|
+
runResult = {
|
|
1821
|
+
outcome: 'aborted',
|
|
1822
|
+
state,
|
|
1823
|
+
error: normalizeErrorFull(abortEvidence) ?? {
|
|
1824
|
+
name: 'AbortError',
|
|
1825
|
+
message: String(abortEvidence),
|
|
1826
|
+
},
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1663
1829
|
if (runResult === undefined) {
|
|
1664
|
-
|
|
1830
|
+
if (signal.aborted) {
|
|
1831
|
+
// Every candidate was the abort's own evidence: settle on the
|
|
1832
|
+
// machine's state under the aborted boundary signal (DR-036 §4).
|
|
1833
|
+
runResult = resultForSnapshot(signal);
|
|
1834
|
+
}
|
|
1835
|
+
else {
|
|
1836
|
+
throw new Error('decide runtime: playbook resume produced no result');
|
|
1837
|
+
}
|
|
1665
1838
|
}
|
|
1666
1839
|
return runResult;
|
|
1667
1840
|
},
|
|
@@ -1719,6 +1892,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1719
1892
|
judgeQueue.clear();
|
|
1720
1893
|
actor = undefined;
|
|
1721
1894
|
currentSignal = undefined;
|
|
1895
|
+
currentAborts = undefined;
|
|
1896
|
+
actorSettlementAborts.length = 0;
|
|
1897
|
+
actorSettlementErrorAborts = undefined;
|
|
1722
1898
|
currentTurnId = undefined;
|
|
1723
1899
|
ports = undefined;
|
|
1724
1900
|
sessionIdentity = undefined;
|
|
@@ -1734,6 +1910,11 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1734
1910
|
})();
|
|
1735
1911
|
return disposalPromise;
|
|
1736
1912
|
},
|
|
1913
|
+
// @internal — test-only parity with the shared factory's bridge escape
|
|
1914
|
+
// hatch. This is hidden by the PlaybookRuntime return type.
|
|
1915
|
+
_getNestedBridge() {
|
|
1916
|
+
return nestedBridge;
|
|
1917
|
+
},
|
|
1737
1918
|
};
|
|
1738
1919
|
};
|
|
1739
1920
|
export const _internal = {
|
|
@@ -1746,6 +1927,7 @@ export const _internal = {
|
|
|
1746
1927
|
parseAdjudication,
|
|
1747
1928
|
combineSignals,
|
|
1748
1929
|
pendingQuestionsFromContext,
|
|
1930
|
+
pendingQuestionsForState,
|
|
1749
1931
|
normalizeErrorCompact,
|
|
1750
1932
|
normalizeErrorFull,
|
|
1751
1933
|
STATE_DESCRIPTIONS,
|