@sublang/playbook 7.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/docs/cli.md +88 -43
- package/docs/configuration.md +221 -119
- package/docs/embedding.md +78 -27
- package/package.json +4 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
- package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
- package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
- package/reference/sdlc/code.playbook/bin/run.js +283 -298
- package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
- package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +18 -15
- package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +25 -15
- package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
- package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
- package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
- package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +29 -23
- package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +45 -24
- package/slc/link.md +297 -135
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +21 -16
- package/src/runtime.ts +20 -23
- package/src/xstate-playbook-runtime.d.ts +34 -20
- package/src/xstate-playbook-runtime.js +973 -400
- package/src/xstate-playbook-runtime.ts +1203 -457
- package/src/xstate-runtime.d.ts +17 -7
- package/src/xstate-runtime.js +198 -81
- package/src/xstate-runtime.ts +339 -112
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// Linker inputs:
|
|
8
8
|
// FSM artifact: ./decide.fsm.ts
|
|
9
9
|
// Link target: @sublang/playbook/runtime
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// Role binding: canonical coder and reviewer roles; concrete players
|
|
11
|
+
// and prompt identities are supplied by the host session
|
|
12
12
|
// Adjudication: LLM-judge per state (default)
|
|
13
13
|
// Boss-event mapping: free-text judge classification (default)
|
|
14
14
|
// Abort strategy: natural rejection; every player-invoking state's
|
|
@@ -19,80 +19,64 @@ import PQueue from 'p-queue';
|
|
|
19
19
|
import { createActor, fromPromise } from 'xstate';
|
|
20
20
|
import { assertJsonSafe, assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
|
|
21
21
|
import decideMachine from './decide.fsm.js';
|
|
22
|
-
const DEFAULT_PLAYER_BINDING = {
|
|
23
|
-
Coder: 'coder',
|
|
24
|
-
Reviewer: 'reviewer',
|
|
25
|
-
};
|
|
26
22
|
function snapshotDecideRuntimeOptions(value) {
|
|
27
23
|
const captured = snapshotJsonValue(value, 'DECIDE runtime options');
|
|
28
24
|
if (!isPlainObject(captured)) {
|
|
29
25
|
throw new TypeError('DECIDE runtime options must be an object');
|
|
30
26
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
throw new TypeError(`DECIDE runtime options.${key} is not declared`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
if (typeof captured.coderLlm !== 'string' ||
|
|
38
|
-
captured.coderLlm.trim().length === 0) {
|
|
39
|
-
throw new TypeError('DECIDE runtime options.coderLlm must be a non-empty string');
|
|
27
|
+
const [unknown] = Object.keys(captured);
|
|
28
|
+
if (unknown !== undefined) {
|
|
29
|
+
throw new TypeError(`DECIDE runtime options.${unknown} is not declared`);
|
|
40
30
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
31
|
+
return Object.freeze({});
|
|
32
|
+
}
|
|
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;
|
|
53
47
|
}
|
|
48
|
+
visit(state.states);
|
|
54
49
|
}
|
|
55
|
-
}
|
|
56
|
-
|
|
50
|
+
};
|
|
51
|
+
visit(states);
|
|
52
|
+
return Object.freeze(descriptions);
|
|
57
53
|
}
|
|
58
|
-
const STATE_DESCRIPTIONS =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
askReviewerProposal: 'Reviewer independently proposes a spec design.',
|
|
62
|
-
waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
|
|
63
|
-
waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
|
|
64
|
-
commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
|
|
65
|
-
awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
|
|
66
|
-
reviewCommit: 'REVIEW examines the committed proposal.',
|
|
67
|
-
failed: 'DECIDE failed and is waiting for a new topic.',
|
|
68
|
-
reportedReviewFailure: 'DECIDE reports REVIEW’s failure and its last commit.',
|
|
69
|
-
done: 'DECIDE completed with an approved commit.',
|
|
70
|
-
};
|
|
71
|
-
const PLAYER_STATES = [
|
|
72
|
-
{ stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
|
|
54
|
+
const STATE_DESCRIPTIONS = authoredStateDescriptions(decideMachine.config.states);
|
|
55
|
+
const ROLE_STATES = [
|
|
56
|
+
{ stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
|
|
73
57
|
{
|
|
74
58
|
stateId: 'askReviewerProposal',
|
|
75
|
-
|
|
59
|
+
role: 'reviewer',
|
|
76
60
|
sourceItem: 'DECIDE-2',
|
|
77
61
|
},
|
|
78
|
-
{ stateId: 'commitCoderProposal',
|
|
62
|
+
{ stateId: 'commitCoderProposal', role: 'coder', sourceItem: 'DECIDE-3' },
|
|
79
63
|
];
|
|
80
|
-
const
|
|
64
|
+
const ROLE_STATE_IDS = new Set(ROLE_STATES.map((state) => state.stateId));
|
|
65
|
+
const ROLE_IDS = ['coder', 'reviewer'];
|
|
66
|
+
const ROLE_ID_SET = new Set(ROLE_IDS);
|
|
67
|
+
const roleLabel = (roleId) => roleId === 'coder' ? 'Coder' : 'Reviewer';
|
|
81
68
|
const BOSS_INTERRUPT_TARGETS = ['independentProposals'];
|
|
82
69
|
const BOSS_INTERRUPT_TARGET_IDS = new Set(BOSS_INTERRUPT_TARGETS);
|
|
83
70
|
const TELEMETRY_TOPIC = 'playbook.fsm.state';
|
|
84
71
|
const TRACE_TOPIC = 'playbook.trace';
|
|
85
72
|
const CONTINUATION_PREAMBLE = 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
|
|
86
|
-
const PLACEHOLDER_FIELDS = [
|
|
87
|
-
['<caller-topic>', 'callerTopic'],
|
|
88
|
-
['<coder-llm>', 'coderLlm'],
|
|
89
|
-
];
|
|
73
|
+
const PLACEHOLDER_FIELDS = [['<caller-topic>', 'callerTopic']];
|
|
90
74
|
const VERBATIM_PAYLOAD_FIELDS = new Set([
|
|
91
75
|
'coderProposal',
|
|
92
76
|
'reviewerProposal',
|
|
93
77
|
'coderOutput',
|
|
94
78
|
]);
|
|
95
|
-
function composePlayerPrompt(input) {
|
|
79
|
+
function composePlayerPrompt(input, promptIdentity) {
|
|
96
80
|
const blocks = [];
|
|
97
81
|
if (input.pendingBossQuestion && input.bossReply !== undefined) {
|
|
98
82
|
blocks.push([
|
|
@@ -111,6 +95,9 @@ function composePlayerPrompt(input) {
|
|
|
111
95
|
if (typeof value === 'string')
|
|
112
96
|
replacements.set(placeholder, value);
|
|
113
97
|
}
|
|
98
|
+
if (input.prompt.includes('<coder-llm>')) {
|
|
99
|
+
replacements.set('<coder-llm>', promptIdentity('coder'));
|
|
100
|
+
}
|
|
114
101
|
const body = input.prompt.replace(/<caller-topic>|<coder-llm>/g, (placeholder, offset, source) => {
|
|
115
102
|
const value = replacements.get(placeholder);
|
|
116
103
|
if (value === undefined)
|
|
@@ -123,18 +110,6 @@ function composePlayerPrompt(input) {
|
|
|
123
110
|
blocks.push(body);
|
|
124
111
|
return blocks.join('\n\n');
|
|
125
112
|
}
|
|
126
|
-
function resolvePlayerId(input, binding) {
|
|
127
|
-
switch (input.player) {
|
|
128
|
-
case 'Coder':
|
|
129
|
-
return binding.Coder;
|
|
130
|
-
case 'Reviewer':
|
|
131
|
-
return binding.Reviewer;
|
|
132
|
-
default: {
|
|
133
|
-
const exhaustive = input.player;
|
|
134
|
-
throw new Error(`unknown player ${String(exhaustive)}`);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
113
|
// A `result` description names required payload fields in its
|
|
139
114
|
// "Output shall include ..." sentence.
|
|
140
115
|
function requiredFieldsFor(description) {
|
|
@@ -283,7 +258,7 @@ function buildClassifierPrompt(text, ctx) {
|
|
|
283
258
|
if (ctx.pendingQuestions.length > 0) {
|
|
284
259
|
lines.push('Pending Boss questions:');
|
|
285
260
|
for (const pending of ctx.pendingQuestions) {
|
|
286
|
-
lines.push(`- ${pending.questionId} (${pending.
|
|
261
|
+
lines.push(`- ${pending.questionId} (${pending.asker.roleId}): ${pending.question}`);
|
|
287
262
|
}
|
|
288
263
|
lines.push('If the Boss message answers a pending question, classify it as BOSS_REPLY; if it is a fresh directive, classify it accordingly.');
|
|
289
264
|
}
|
|
@@ -354,7 +329,7 @@ function buildAdjudicatorPrompt(input, playerOutput) {
|
|
|
354
329
|
lines.push('This is hidden control work. Do not call tools, inspect files, or ' +
|
|
355
330
|
'seek external evidence. Decide only from the supplied player output ' +
|
|
356
331
|
'and guard descriptions. Reply with exactly one JSON object and no prose.');
|
|
357
|
-
lines.push(`The
|
|
332
|
+
lines.push(`The role "${roleLabel(input.role)}" produced the output below for source item ${input.sourceItem}.`);
|
|
358
333
|
lines.push('Choose exactly one guard whose description matches that output.');
|
|
359
334
|
lines.push('');
|
|
360
335
|
lines.push('Player output (verbatim):');
|
|
@@ -435,6 +410,14 @@ function isEmptyFinalText(finalText) {
|
|
|
435
410
|
function isAbortFailure(error, signal) {
|
|
436
411
|
return signal.aborted && Object.is(error, signal.reason);
|
|
437
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
|
+
}
|
|
438
421
|
function pendingQuestionsFromContext(context) {
|
|
439
422
|
const pending = context.pendingBossQuestions;
|
|
440
423
|
if (pending === undefined ||
|
|
@@ -452,7 +435,9 @@ function pendingQuestionsFromContext(context) {
|
|
|
452
435
|
obj.questionId === key &&
|
|
453
436
|
typeof obj.resumeStateId === 'string' &&
|
|
454
437
|
typeof obj.sourceItem === 'string' &&
|
|
455
|
-
|
|
438
|
+
isPlainObject(obj.asker) &&
|
|
439
|
+
obj.asker.kind === 'role' &&
|
|
440
|
+
ROLE_ID_SET.has(String(obj.asker.roleId)) &&
|
|
456
441
|
typeof obj.question === 'string') {
|
|
457
442
|
questions.push(obj);
|
|
458
443
|
}
|
|
@@ -468,10 +453,26 @@ const WAIT_STATE_IDS = new Set([
|
|
|
468
453
|
'awaitBossReply',
|
|
469
454
|
]);
|
|
470
455
|
const STATUS_STATE_IDS = new Set([
|
|
471
|
-
...
|
|
456
|
+
...ROLE_STATE_IDS,
|
|
472
457
|
...WAIT_STATE_IDS,
|
|
473
458
|
'failed',
|
|
474
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
|
+
}
|
|
475
476
|
function questionForWaitState(stateId, pendingQuestions) {
|
|
476
477
|
const resumeStateId = WAIT_STATE_RESUME_IDS[stateId];
|
|
477
478
|
if (resumeStateId !== undefined) {
|
|
@@ -510,7 +511,7 @@ function normalizedTransitionEvent(event) {
|
|
|
510
511
|
return snapshotJsonValue(descriptor, 'FSM event');
|
|
511
512
|
}
|
|
512
513
|
function telemetryPayload(previousState, state, event, context) {
|
|
513
|
-
const pendingBossQuestions =
|
|
514
|
+
const pendingBossQuestions = pendingQuestionsForState(state, context);
|
|
514
515
|
const prior = previousState ?? state;
|
|
515
516
|
const payload = {
|
|
516
517
|
from: prior.value,
|
|
@@ -527,18 +528,14 @@ function telemetryPayload(previousState, state, event, context) {
|
|
|
527
528
|
return payload;
|
|
528
529
|
}
|
|
529
530
|
export const createPlaybookRuntime = (options) => {
|
|
530
|
-
const
|
|
531
|
-
const binding = {
|
|
532
|
-
...DEFAULT_PLAYER_BINDING,
|
|
533
|
-
...(boundOptions.playerBinding ?? {}),
|
|
534
|
-
};
|
|
535
|
-
const fsmInput = {
|
|
536
|
-
coderLlm: boundOptions.coderLlm,
|
|
537
|
-
};
|
|
531
|
+
const fsmInput = snapshotDecideRuntimeOptions(options);
|
|
538
532
|
let ports;
|
|
539
533
|
let sessionIdentity;
|
|
540
534
|
let actor;
|
|
541
535
|
let currentSignal;
|
|
536
|
+
let currentAborts;
|
|
537
|
+
const actorSettlementAborts = [];
|
|
538
|
+
let actorSettlementErrorAborts;
|
|
542
539
|
let currentTurnId;
|
|
543
540
|
let previousState;
|
|
544
541
|
let suppressInspectionEmissions = false;
|
|
@@ -554,9 +551,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
554
551
|
let disposalPromise;
|
|
555
552
|
let controlPlaneError;
|
|
556
553
|
let nestedBridge;
|
|
557
|
-
const
|
|
554
|
+
const privateResumeTokens = new Map();
|
|
558
555
|
const playbookCallTurnIds = new Map();
|
|
559
|
-
const
|
|
556
|
+
const inFlightPlayerKeys = new Set();
|
|
560
557
|
const activeBoundaryCalls = new Set();
|
|
561
558
|
const activeEmissionCalls = new Set();
|
|
562
559
|
const emissionQueue = new PQueue({ concurrency: 1 });
|
|
@@ -575,24 +572,27 @@ export const createPlaybookRuntime = (options) => {
|
|
|
575
572
|
if (!isAbortFailure(error, signal))
|
|
576
573
|
controlPlaneError ??= error;
|
|
577
574
|
};
|
|
578
|
-
const latchInspectionError = (error) => {
|
|
579
|
-
if (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
575
|
+
const latchInspectionError = (error, aborts = currentAborts) => {
|
|
576
|
+
if (aborts?.isAbortReason(error))
|
|
577
|
+
return;
|
|
578
|
+
if (currentSignal !== undefined)
|
|
579
|
+
controlPlaneError ??= error;
|
|
580
|
+
else
|
|
583
581
|
collectFailure(emissionFailures, error);
|
|
584
|
-
}
|
|
585
582
|
};
|
|
586
|
-
const enqueue = (fn) => {
|
|
583
|
+
const enqueue = (fn, aborts = currentAborts) => {
|
|
584
|
+
const enqueueAborts = aborts;
|
|
587
585
|
const queued = emissionQueue.add(fn);
|
|
588
586
|
activeEmissionCalls.add(queued);
|
|
589
587
|
void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
|
|
590
588
|
activeEmissionCalls.delete(queued);
|
|
591
|
-
|
|
589
|
+
if (!enqueueAborts?.isAbortReason(error)) {
|
|
590
|
+
collectFailure(emissionFailures, error);
|
|
591
|
+
}
|
|
592
592
|
});
|
|
593
593
|
return queued;
|
|
594
594
|
};
|
|
595
|
-
const flush = async () => {
|
|
595
|
+
const flush = async (_aborts = currentAborts) => {
|
|
596
596
|
while (true) {
|
|
597
597
|
const active = [...activeEmissionCalls];
|
|
598
598
|
if (active.length > 0)
|
|
@@ -608,9 +608,15 @@ export const createPlaybookRuntime = (options) => {
|
|
|
608
608
|
return;
|
|
609
609
|
const failures = emissionFailures;
|
|
610
610
|
emissionFailures = [];
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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;
|
|
614
620
|
};
|
|
615
621
|
const drainBoundaryCallsAndEmissions = async () => {
|
|
616
622
|
while (true) {
|
|
@@ -639,59 +645,130 @@ export const createPlaybookRuntime = (options) => {
|
|
|
639
645
|
}
|
|
640
646
|
return sessionIdentity;
|
|
641
647
|
};
|
|
642
|
-
const
|
|
648
|
+
const bindSession = (nextSession) => {
|
|
649
|
+
const bound = snapshotPlaybookSession(nextSession);
|
|
650
|
+
if (bound.roleBindings === undefined)
|
|
651
|
+
return bound;
|
|
652
|
+
const actual = Object.keys(bound.roleBindings).sort();
|
|
653
|
+
const expected = [...ROLE_IDS].sort();
|
|
654
|
+
const missing = expected.filter((roleId) => !actual.includes(roleId));
|
|
655
|
+
const extra = actual.filter((roleId) => !ROLE_ID_SET.has(roleId));
|
|
656
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
657
|
+
throw new TypeError(`DECIDE session roleBindings must cover exactly [${expected.join(', ')}]` +
|
|
658
|
+
`${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
|
|
659
|
+
`${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`);
|
|
660
|
+
}
|
|
661
|
+
return bound;
|
|
662
|
+
};
|
|
663
|
+
const resolvedPlayerId = (roleId) => requireSessionIdentity().roleBindings?.[roleId]?.playerId;
|
|
664
|
+
const promptIdentity = (roleId) => requireSessionIdentity().roleBindings?.[roleId]?.promptIdentity ?? roleId;
|
|
665
|
+
const composeInvocationPrompt = (input) => {
|
|
666
|
+
let active = true;
|
|
667
|
+
const lookup = (roleId) => {
|
|
668
|
+
if (!active) {
|
|
669
|
+
throw new Error('DECIDE prompt identity lookup is no longer active for this invocation');
|
|
670
|
+
}
|
|
671
|
+
if (!ROLE_ID_SET.has(roleId)) {
|
|
672
|
+
throw new TypeError(`DECIDE prompt identity lookup rejected undeclared role ${String(roleId)}`);
|
|
673
|
+
}
|
|
674
|
+
return promptIdentity(roleId);
|
|
675
|
+
};
|
|
676
|
+
try {
|
|
677
|
+
return composePlayerPrompt(input, lookup);
|
|
678
|
+
}
|
|
679
|
+
finally {
|
|
680
|
+
active = false;
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
const continuationKey = (roleId, playerId) => playerId ?? roleId;
|
|
684
|
+
const tokensByContinuationKey = (tokens) => {
|
|
685
|
+
const byKey = new Map();
|
|
686
|
+
for (const [roleId, token] of Object.entries(tokens)) {
|
|
687
|
+
if (!ROLE_ID_SET.has(roleId)) {
|
|
688
|
+
throw new TypeError(`DECIDE role tokens contain unknown role ${roleId}`);
|
|
689
|
+
}
|
|
690
|
+
const typedRole = roleId;
|
|
691
|
+
const key = continuationKey(typedRole, resolvedPlayerId(typedRole));
|
|
692
|
+
const prior = byKey.get(key);
|
|
693
|
+
if (prior !== undefined && prior !== token) {
|
|
694
|
+
throw new TypeError(`DECIDE runtime snapshot assigns conflicting tokens to roles bound to player ${key}`);
|
|
695
|
+
}
|
|
696
|
+
byKey.set(key, token);
|
|
697
|
+
}
|
|
698
|
+
const rolesByKey = new Map();
|
|
699
|
+
for (const roleId of ROLE_IDS) {
|
|
700
|
+
const key = continuationKey(roleId, resolvedPlayerId(roleId));
|
|
701
|
+
rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
|
|
702
|
+
}
|
|
703
|
+
for (const [key, roles] of rolesByKey) {
|
|
704
|
+
if (roles.length < 2)
|
|
705
|
+
continue;
|
|
706
|
+
const present = roles.filter((roleId) => tokens[roleId] !== undefined);
|
|
707
|
+
if (present.length !== 0 && present.length !== roles.length) {
|
|
708
|
+
throw new TypeError(`DECIDE role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return byKey;
|
|
712
|
+
};
|
|
713
|
+
const selectPlayerResume = (roleId, playerId) => {
|
|
643
714
|
const session = requireSessionIdentity();
|
|
644
715
|
const selected = session.playerSessions
|
|
645
|
-
? session.playerSessions.select(
|
|
646
|
-
:
|
|
716
|
+
? session.playerSessions.select(roleId)
|
|
717
|
+
: privateResumeTokens.get(continuationKey(roleId, playerId)) ?? false;
|
|
647
718
|
if (selected !== false &&
|
|
648
719
|
(typeof selected !== 'string' || selected.trim().length === 0)) {
|
|
649
|
-
throw new TypeError(`player session store returned an invalid resume token for ${
|
|
720
|
+
throw new TypeError(`player session store returned an invalid resume token for role ${roleId}`);
|
|
650
721
|
}
|
|
651
722
|
return selected;
|
|
652
723
|
};
|
|
653
|
-
const updatePlayerResume = (playerId,
|
|
724
|
+
const updatePlayerResume = (roleId, playerId, result) => {
|
|
725
|
+
if (result.resumeToken === undefined && result.status !== 'ok')
|
|
726
|
+
return;
|
|
654
727
|
const session = requireSessionIdentity();
|
|
655
728
|
if (session.playerSessions) {
|
|
656
|
-
session.playerSessions.update(
|
|
729
|
+
session.playerSessions.update(roleId, result.resumeToken);
|
|
657
730
|
}
|
|
658
|
-
else if (resumeToken !== undefined
|
|
659
|
-
|
|
731
|
+
else if (result.resumeToken !== undefined) {
|
|
732
|
+
privateResumeTokens.set(continuationKey(roleId, playerId), result.resumeToken);
|
|
660
733
|
}
|
|
661
734
|
else {
|
|
662
|
-
|
|
735
|
+
privateResumeTokens.delete(continuationKey(roleId, playerId));
|
|
663
736
|
}
|
|
664
737
|
};
|
|
665
|
-
const
|
|
738
|
+
const snapshotRoleResumeTokens = () => {
|
|
666
739
|
const session = requireSessionIdentity();
|
|
667
740
|
const captured = snapshotJsonValue(session.playerSessions
|
|
668
741
|
? session.playerSessions.snapshot()
|
|
669
|
-
: Object.fromEntries(
|
|
742
|
+
: Object.fromEntries(ROLE_IDS.flatMap((roleId) => {
|
|
743
|
+
const token = privateResumeTokens.get(continuationKey(roleId, resolvedPlayerId(roleId)));
|
|
744
|
+
return token === undefined ? [] : [[roleId, token]];
|
|
745
|
+
})), 'player session store snapshot');
|
|
670
746
|
if (!isPlainObject(captured)) {
|
|
671
747
|
throw new TypeError('player session store snapshot must be an object');
|
|
672
748
|
}
|
|
673
749
|
const tokens = {};
|
|
674
|
-
for (const [
|
|
675
|
-
if (
|
|
676
|
-
throw new TypeError(
|
|
750
|
+
for (const [roleId, token] of Object.entries(captured)) {
|
|
751
|
+
if (!ROLE_ID_SET.has(roleId)) {
|
|
752
|
+
throw new TypeError(`player session store snapshot contains unknown role ${roleId}`);
|
|
677
753
|
}
|
|
678
754
|
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
679
|
-
throw new TypeError(`player session store snapshot token for ${
|
|
755
|
+
throw new TypeError(`player session store snapshot token for ${roleId} must be a non-empty string`);
|
|
680
756
|
}
|
|
681
|
-
tokens[
|
|
757
|
+
tokens[roleId] = token;
|
|
682
758
|
}
|
|
759
|
+
tokensByContinuationKey(tokens);
|
|
683
760
|
return tokens;
|
|
684
761
|
};
|
|
685
|
-
const
|
|
762
|
+
const restoreRoleResumeTokens = (tokens) => {
|
|
763
|
+
const byKey = tokensByContinuationKey(tokens);
|
|
686
764
|
const session = requireSessionIdentity();
|
|
687
765
|
if (session.playerSessions) {
|
|
688
766
|
session.playerSessions.restore(tokens);
|
|
689
767
|
return;
|
|
690
768
|
}
|
|
691
|
-
|
|
692
|
-
for (const [
|
|
693
|
-
|
|
694
|
-
}
|
|
769
|
+
privateResumeTokens.clear();
|
|
770
|
+
for (const [key, token] of byKey)
|
|
771
|
+
privateResumeTokens.set(key, token);
|
|
695
772
|
};
|
|
696
773
|
const currentState = (pendingCall = nestedBridge.getPendingCall()) => {
|
|
697
774
|
const live = actor;
|
|
@@ -705,12 +782,12 @@ export const createPlaybookRuntime = (options) => {
|
|
|
705
782
|
const stateIdentity = (state) => {
|
|
706
783
|
return state.stateId === undefined ? {} : { stateId: state.stateId };
|
|
707
784
|
};
|
|
708
|
-
const enqueueTracedEmission = (type, payload, meta = {}, describedEmission) => {
|
|
785
|
+
const enqueueTracedEmission = (type, payload, meta = {}, describedEmission, aborts) => {
|
|
709
786
|
const runtimePorts = requirePorts();
|
|
710
787
|
const identity = requireSessionIdentity();
|
|
711
788
|
const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
|
|
712
789
|
const trace = Object.freeze({
|
|
713
|
-
schemaVersion:
|
|
790
|
+
schemaVersion: 3,
|
|
714
791
|
sessionId: identity.sessionId,
|
|
715
792
|
playbookId: identity.playbookId,
|
|
716
793
|
rootSessionId: identity.rootSessionId,
|
|
@@ -731,9 +808,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
731
808
|
return enqueue(async () => {
|
|
732
809
|
await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
|
|
733
810
|
await describedEmission?.(runtimePorts);
|
|
734
|
-
});
|
|
811
|
+
}, aborts);
|
|
735
812
|
};
|
|
736
|
-
const emitTrace = (type, payload, meta = {}) => enqueueTracedEmission(type, payload, meta);
|
|
813
|
+
const emitTrace = (type, payload, meta = {}, aborts) => enqueueTracedEmission(type, payload, meta, undefined, aborts);
|
|
737
814
|
const emitBoundaryStatus = async (message, state) => {
|
|
738
815
|
const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
|
|
739
816
|
await enqueueTracedEmission('status.emitted', {
|
|
@@ -745,20 +822,24 @@ export const createPlaybookRuntime = (options) => {
|
|
|
745
822
|
}, { turnId: currentTurnId }, (runtimePorts) => runtimePorts.emitStatus(message));
|
|
746
823
|
};
|
|
747
824
|
const emitCallStarted = async (startedType, finishedType, identity, meta, signal) => {
|
|
825
|
+
const aborts = abortReasonClassifier(signal);
|
|
748
826
|
try {
|
|
749
|
-
await emitTrace(startedType, identity, meta);
|
|
827
|
+
await emitTrace(startedType, identity, meta, aborts);
|
|
750
828
|
}
|
|
751
829
|
catch (error) {
|
|
752
830
|
latchControlPlaneError(error, signal);
|
|
753
831
|
try {
|
|
754
832
|
await emitTrace(finishedType, {
|
|
755
833
|
...identity,
|
|
756
|
-
|
|
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',
|
|
757
838
|
error: normalizeErrorFull(error) ?? {
|
|
758
839
|
name: 'Error',
|
|
759
840
|
message: String(error),
|
|
760
841
|
},
|
|
761
|
-
}, meta);
|
|
842
|
+
}, meta, aborts);
|
|
762
843
|
}
|
|
763
844
|
catch {
|
|
764
845
|
// Preserve the start failure after one best-effort finish attempt.
|
|
@@ -767,6 +848,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
767
848
|
}
|
|
768
849
|
};
|
|
769
850
|
const runJudgeCall = async (prompt, signal, purpose, callStateId) => {
|
|
851
|
+
const aborts = abortReasonClassifier(signal);
|
|
770
852
|
const identity = {
|
|
771
853
|
purpose,
|
|
772
854
|
...(callStateId !== undefined ? { stateId: callStateId } : {}),
|
|
@@ -794,15 +876,18 @@ export const createPlaybookRuntime = (options) => {
|
|
|
794
876
|
latchControlPlaneError(error, signal);
|
|
795
877
|
await emitTrace('judge.call.finished', {
|
|
796
878
|
...identity,
|
|
797
|
-
|
|
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',
|
|
798
883
|
error: normalizeErrorFull(error) ?? {
|
|
799
884
|
name: 'Error',
|
|
800
885
|
message: String(error),
|
|
801
886
|
},
|
|
802
|
-
}, { turnId: currentTurnId, callId });
|
|
887
|
+
}, { turnId: currentTurnId, callId }, aborts);
|
|
803
888
|
throw error;
|
|
804
889
|
}
|
|
805
|
-
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);
|
|
806
891
|
return finalText;
|
|
807
892
|
});
|
|
808
893
|
if (queued === undefined) {
|
|
@@ -812,15 +897,18 @@ export const createPlaybookRuntime = (options) => {
|
|
|
812
897
|
};
|
|
813
898
|
const callJudge = (prompt, signal, purpose, callStateId) => trackBoundaryCall(runJudgeCall(prompt, signal, purpose, callStateId));
|
|
814
899
|
const runPlayerCall = async (input, signal) => {
|
|
815
|
-
const
|
|
816
|
-
if (
|
|
817
|
-
throw new
|
|
818
|
-
}
|
|
819
|
-
const
|
|
900
|
+
const aborts = abortReasonClassifier(signal);
|
|
901
|
+
if (!ROLE_ID_SET.has(input.role)) {
|
|
902
|
+
throw new TypeError(`DECIDE player input role must name a declared local role`);
|
|
903
|
+
}
|
|
904
|
+
const roleId = input.role;
|
|
905
|
+
const playerId = resolvedPlayerId(roleId);
|
|
906
|
+
const playerKey = continuationKey(roleId, playerId);
|
|
907
|
+
const prompt = composeInvocationPrompt(input);
|
|
820
908
|
let resume;
|
|
821
909
|
try {
|
|
822
910
|
signal.throwIfAborted();
|
|
823
|
-
resume = selectPlayerResume(playerId);
|
|
911
|
+
resume = selectPlayerResume(roleId, playerId);
|
|
824
912
|
}
|
|
825
913
|
catch (error) {
|
|
826
914
|
latchControlPlaneError(error, signal);
|
|
@@ -828,27 +916,36 @@ export const createPlaybookRuntime = (options) => {
|
|
|
828
916
|
}
|
|
829
917
|
const callId = `player-${++playerCallSequence}`;
|
|
830
918
|
const identity = {
|
|
831
|
-
purpose: 'captain',
|
|
832
919
|
stateId: input.stateId,
|
|
833
920
|
sourceItem: input.sourceItem,
|
|
834
|
-
|
|
921
|
+
roleId,
|
|
922
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
835
923
|
resume,
|
|
836
924
|
};
|
|
837
925
|
const emitFailure = (error) => emitTrace('player.call.finished', {
|
|
838
926
|
...identity,
|
|
839
|
-
|
|
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',
|
|
840
931
|
error: normalizeErrorFull(error) ?? {
|
|
841
932
|
name: 'Error',
|
|
842
933
|
message: String(error),
|
|
843
934
|
},
|
|
844
|
-
}, { turnId: currentTurnId, callId });
|
|
845
|
-
|
|
935
|
+
}, { turnId: currentTurnId, callId }, aborts);
|
|
936
|
+
if (inFlightPlayerKeys.has(playerKey)) {
|
|
937
|
+
const error = new Error(`resolved player key "${playerKey}" already has an in-flight call`);
|
|
938
|
+
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
|
|
939
|
+
await emitFailure(error);
|
|
940
|
+
throw error;
|
|
941
|
+
}
|
|
942
|
+
inFlightPlayerKeys.add(playerKey);
|
|
846
943
|
try {
|
|
847
944
|
await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
|
|
848
945
|
let rawResult;
|
|
849
946
|
try {
|
|
850
947
|
signal.throwIfAborted();
|
|
851
|
-
const boundary = Promise.resolve(requirePorts().callPlayer(
|
|
948
|
+
const boundary = Promise.resolve(requirePorts().callPlayer(roleId, prompt, signal, { resume }));
|
|
852
949
|
rawResult = await boundary;
|
|
853
950
|
// An XState sibling cancellation does not cancel an arbitrary coder
|
|
854
951
|
// promise. Re-check before a late resolution can mutate continuity or
|
|
@@ -885,10 +982,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
885
982
|
// The resolved result is authoritative even on aborted/error status.
|
|
886
983
|
// Update continuation state before interpreting that status.
|
|
887
984
|
try {
|
|
888
|
-
updatePlayerResume(playerId,
|
|
889
|
-
result.resumeToken.trim().length > 0
|
|
890
|
-
? result.resumeToken
|
|
891
|
-
: undefined);
|
|
985
|
+
updatePlayerResume(roleId, playerId, result);
|
|
892
986
|
}
|
|
893
987
|
catch (error) {
|
|
894
988
|
latchControlPlaneError(error, signal);
|
|
@@ -915,11 +1009,15 @@ export const createPlaybookRuntime = (options) => {
|
|
|
915
1009
|
...(result.error !== undefined
|
|
916
1010
|
? { error: normalizeErrorFull(result.error) }
|
|
917
1011
|
: {}),
|
|
918
|
-
}, { turnId: currentTurnId, callId });
|
|
919
|
-
return {
|
|
1012
|
+
}, { turnId: currentTurnId, callId }, aborts);
|
|
1013
|
+
return {
|
|
1014
|
+
roleId,
|
|
1015
|
+
...(playerId === undefined ? {} : { playerId }),
|
|
1016
|
+
result,
|
|
1017
|
+
};
|
|
920
1018
|
}
|
|
921
1019
|
finally {
|
|
922
|
-
|
|
1020
|
+
inFlightPlayerKeys.delete(playerKey);
|
|
923
1021
|
}
|
|
924
1022
|
};
|
|
925
1023
|
const callPlayer = (input, signal) => {
|
|
@@ -927,52 +1025,59 @@ export const createPlaybookRuntime = (options) => {
|
|
|
927
1025
|
};
|
|
928
1026
|
const player = fromPromise(async ({ input, signal }) => {
|
|
929
1027
|
const combined = combineSignals(signal, currentSignal);
|
|
930
|
-
|
|
931
|
-
// Yield through the runtime emission queue before crossing the player
|
|
932
|
-
// boundary so state trace/status always precede its call-start trace.
|
|
1028
|
+
const settlementAborts = abortReasonClassifier(combined);
|
|
933
1029
|
try {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
latchControlPlaneError(error, combined);
|
|
938
|
-
throw error;
|
|
939
|
-
}
|
|
940
|
-
combined.throwIfAborted();
|
|
941
|
-
let { playerId, result } = await callPlayer(input, combined);
|
|
942
|
-
if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
|
|
943
|
-
// DR-028: an `ok` result whose finalText is missing, empty, or
|
|
944
|
-
// whitespace-only earns exactly one corrective re-ask — the same
|
|
945
|
-
// composed call repeated, traced by runPlayerCall as its own
|
|
946
|
-
// player-call pair, with the resume selection re-read from the
|
|
947
|
-
// token map the first result left (PBRT-38). An abort that lands
|
|
948
|
-
// between the two calls ends the turn without the re-ask (aborts
|
|
949
|
-
// are never retried), and a rejecting finish emission rejects
|
|
950
|
-
// `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.
|
|
951
1033
|
combined.throwIfAborted();
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
+
}
|
|
965
1071
|
}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
throw error;
|
|
1072
|
+
finally {
|
|
1073
|
+
actorSettlementAborts.push(settlementAborts);
|
|
969
1074
|
}
|
|
970
1075
|
});
|
|
971
1076
|
nestedBridge = createNestedPlaybookBridge({
|
|
972
1077
|
nextCallId: () => `playbook-${++playbookCallSequence}`,
|
|
973
1078
|
getBoundarySignal: () => currentSignal,
|
|
974
1079
|
callPlaybook: (request, signal) => trackBoundaryCall(Promise.resolve(requirePorts().callPlaybook(request, signal))),
|
|
975
|
-
emitStarted: async (event) => {
|
|
1080
|
+
emitStarted: async (event, aborts) => {
|
|
976
1081
|
playbookCallTurnIds.set(event.callId, currentTurnId);
|
|
977
1082
|
await emitTrace('playbook.call.started', {
|
|
978
1083
|
stateId: event.stateId,
|
|
@@ -981,9 +1086,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
981
1086
|
}, {
|
|
982
1087
|
...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
|
|
983
1088
|
callId: event.callId,
|
|
984
|
-
});
|
|
1089
|
+
}, aborts);
|
|
985
1090
|
},
|
|
986
|
-
emitFinished: async (event) => {
|
|
1091
|
+
emitFinished: async (event, aborts) => {
|
|
987
1092
|
const turnId = playbookCallTurnIds.get(event.callId);
|
|
988
1093
|
try {
|
|
989
1094
|
await emitTrace('playbook.call.finished', {
|
|
@@ -994,29 +1099,48 @@ export const createPlaybookRuntime = (options) => {
|
|
|
994
1099
|
}, {
|
|
995
1100
|
...(turnId === undefined ? {} : { turnId }),
|
|
996
1101
|
callId: event.callId,
|
|
997
|
-
});
|
|
1102
|
+
}, aborts);
|
|
998
1103
|
}
|
|
999
1104
|
finally {
|
|
1000
1105
|
playbookCallTurnIds.delete(event.callId);
|
|
1001
1106
|
}
|
|
1002
1107
|
},
|
|
1003
1108
|
drain: flush,
|
|
1004
|
-
bindResumeSignal: (signal) => {
|
|
1109
|
+
bindResumeSignal: (signal, aborts) => {
|
|
1005
1110
|
currentSignal = signal;
|
|
1111
|
+
currentAborts = aborts ?? abortReasonClassifier(signal);
|
|
1006
1112
|
},
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1113
|
+
bindActorSettlement: (aborts) => {
|
|
1114
|
+
actorSettlementAborts.push(aborts);
|
|
1115
|
+
},
|
|
1116
|
+
onControlPlaneError: (error, aborts) => {
|
|
1117
|
+
if (!aborts?.isAbortReason(error) &&
|
|
1118
|
+
!currentAborts?.isAbortReason(error)) {
|
|
1010
1119
|
controlPlaneError ??= error;
|
|
1011
1120
|
}
|
|
1012
1121
|
},
|
|
1013
|
-
onBackgroundError: (error) => {
|
|
1014
|
-
|
|
1122
|
+
onBackgroundError: (error, aborts) => {
|
|
1123
|
+
if (!aborts?.isAbortReason(error)) {
|
|
1124
|
+
collectFailure(emissionFailures, error);
|
|
1125
|
+
}
|
|
1015
1126
|
},
|
|
1016
1127
|
});
|
|
1017
1128
|
const providedMachine = decideMachine.provide({
|
|
1018
1129
|
actors: { player, playbook: nestedBridge.actorLogic },
|
|
1019
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
|
+
};
|
|
1020
1144
|
const inspect = (event) => {
|
|
1021
1145
|
if (event.type !== '@xstate.snapshot')
|
|
1022
1146
|
return;
|
|
@@ -1024,6 +1148,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1024
1148
|
return;
|
|
1025
1149
|
if (suppressInspectionEmissions)
|
|
1026
1150
|
return;
|
|
1151
|
+
const settlementAborts = consumeActorSettlementAborts(true);
|
|
1027
1152
|
try {
|
|
1028
1153
|
const snapshot = event.snapshot;
|
|
1029
1154
|
const state = normalizePlaybookSnapshot(snapshot);
|
|
@@ -1034,7 +1159,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1034
1159
|
void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
|
|
1035
1160
|
topic: TELEMETRY_TOPIC,
|
|
1036
1161
|
payload: describedFsmPayload,
|
|
1037
|
-
})).catch(() => undefined);
|
|
1162
|
+
}), settlementAborts).catch(() => undefined);
|
|
1038
1163
|
const priorIds = new Set(previousState?.activeStateIds ?? []);
|
|
1039
1164
|
previousState = state;
|
|
1040
1165
|
const pendingQuestions = pendingQuestionsFromContext(context);
|
|
@@ -1047,7 +1172,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1047
1172
|
...(data !== undefined ? { data } : {}),
|
|
1048
1173
|
};
|
|
1049
1174
|
assertJsonSafe(tracePayload);
|
|
1050
|
-
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);
|
|
1051
1176
|
};
|
|
1052
1177
|
for (const activeStateId of state.activeStateIds) {
|
|
1053
1178
|
if (priorIds.has(activeStateId) ||
|
|
@@ -1057,8 +1182,8 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1057
1182
|
if (WAIT_STATE_IDS.has(activeStateId)) {
|
|
1058
1183
|
const pending = questionForWaitState(activeStateId, pendingQuestions);
|
|
1059
1184
|
if (pending) {
|
|
1060
|
-
scheduleStatus(`${pending.
|
|
1061
|
-
scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.
|
|
1185
|
+
scheduleStatus(`${pending.asker.roleId} asks: ${pending.question}`, activeStateId);
|
|
1186
|
+
scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.asker.roleId} · ${pending.sourceItem}`, activeStateId);
|
|
1062
1187
|
}
|
|
1063
1188
|
continue;
|
|
1064
1189
|
}
|
|
@@ -1068,14 +1193,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1068
1193
|
const description = STATE_DESCRIPTIONS[activeStateId];
|
|
1069
1194
|
if (description === undefined)
|
|
1070
1195
|
continue;
|
|
1071
|
-
const
|
|
1072
|
-
scheduleStatus(
|
|
1196
|
+
const roleState = ROLE_STATES.find((candidate) => candidate.stateId === activeStateId);
|
|
1197
|
+
scheduleStatus(roleState === undefined
|
|
1073
1198
|
? '◆ workflow failed; awaiting Boss recovery.'
|
|
1074
|
-
: `⤷ ${
|
|
1199
|
+
: `⤷ ${roleLabel(roleState.role)}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
|
|
1075
1200
|
}
|
|
1076
1201
|
}
|
|
1077
1202
|
catch (error) {
|
|
1078
|
-
latchInspectionError(error);
|
|
1203
|
+
latchInspectionError(error, settlementAborts);
|
|
1079
1204
|
}
|
|
1080
1205
|
};
|
|
1081
1206
|
const createRuntimeActor = (machineSnapshot) => {
|
|
@@ -1091,6 +1216,15 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1091
1216
|
}),
|
|
1092
1217
|
inspect,
|
|
1093
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
|
+
});
|
|
1094
1228
|
};
|
|
1095
1229
|
// PBRT-6: the single seam that stops this runtime's actor. Stopping a
|
|
1096
1230
|
// still-running actor fires one more `@xstate.snapshot` for the *unchanged*
|
|
@@ -1126,7 +1260,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1126
1260
|
const state = normalizePlaybookSnapshot(snapshot, {
|
|
1127
1261
|
pendingCall: nestedBridge.getPendingCall(),
|
|
1128
1262
|
});
|
|
1129
|
-
const pendingQuestions =
|
|
1263
|
+
const pendingQuestions = pendingQuestionsForState(state, context);
|
|
1130
1264
|
if (pendingQuestions.length === 0 &&
|
|
1131
1265
|
(snapshot.status === 'done' ||
|
|
1132
1266
|
state.activeStateIds.includes('ready') ||
|
|
@@ -1150,33 +1284,53 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1150
1284
|
const pendingCall = nestedBridge.getPendingCall();
|
|
1151
1285
|
const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
|
|
1152
1286
|
const context = snapshot.context;
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
: {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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.
|
|
1167
1313
|
if (snapshot.status === 'done') {
|
|
1168
1314
|
const output = snapshot.output;
|
|
1169
1315
|
if (output !== undefined)
|
|
1170
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
|
+
}
|
|
1171
1325
|
return {
|
|
1172
1326
|
outcome: 'terminal',
|
|
1173
1327
|
state,
|
|
1328
|
+
stateDescription,
|
|
1174
1329
|
...(output === undefined ? {} : { output }),
|
|
1175
1330
|
};
|
|
1176
1331
|
}
|
|
1177
|
-
if (
|
|
1178
|
-
|
|
1179
|
-
new Error('decide runtime actor entered error status'));
|
|
1332
|
+
if (signal?.aborted) {
|
|
1333
|
+
return abortedResult(signal);
|
|
1180
1334
|
}
|
|
1181
1335
|
if (state.activeStateIds.includes('failed')) {
|
|
1182
1336
|
const error = normalizeErrorFull(context.lastError);
|
|
@@ -1239,14 +1393,17 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1239
1393
|
// The session-start error remains authoritative.
|
|
1240
1394
|
}
|
|
1241
1395
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1396
|
+
privateResumeTokens.clear();
|
|
1397
|
+
inFlightPlayerKeys.clear();
|
|
1244
1398
|
activeBoundaryCalls.clear();
|
|
1245
1399
|
activeEmissionCalls.clear();
|
|
1246
1400
|
emissionQueue.clear();
|
|
1247
1401
|
judgeQueue.clear();
|
|
1248
1402
|
actor = undefined;
|
|
1249
1403
|
currentSignal = undefined;
|
|
1404
|
+
currentAborts = undefined;
|
|
1405
|
+
actorSettlementAborts.length = 0;
|
|
1406
|
+
actorSettlementErrorAborts = undefined;
|
|
1250
1407
|
currentTurnId = undefined;
|
|
1251
1408
|
ports = undefined;
|
|
1252
1409
|
sessionIdentity = undefined;
|
|
@@ -1270,7 +1427,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1270
1427
|
disposalPromise !== undefined) {
|
|
1271
1428
|
throw new Error('decide runtime: init(session) may only be called once');
|
|
1272
1429
|
}
|
|
1273
|
-
const identity =
|
|
1430
|
+
const identity = bindSession(session);
|
|
1274
1431
|
let finishInitialization;
|
|
1275
1432
|
const initialization = new Promise((resolve) => {
|
|
1276
1433
|
finishInitialization = resolve;
|
|
@@ -1348,10 +1505,10 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1348
1505
|
const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
|
|
1349
1506
|
const context = actor.getSnapshot().context;
|
|
1350
1507
|
return {
|
|
1351
|
-
schemaVersion:
|
|
1508
|
+
schemaVersion: 3,
|
|
1352
1509
|
playbookId: sessionIdentity.playbookId,
|
|
1353
1510
|
machine,
|
|
1354
|
-
|
|
1511
|
+
roleResumeTokens: snapshotRoleResumeTokens(),
|
|
1355
1512
|
sequences: {
|
|
1356
1513
|
trace: traceSequence,
|
|
1357
1514
|
turn: turnSequence,
|
|
@@ -1360,9 +1517,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1360
1517
|
playbookCall: playbookCallSequence,
|
|
1361
1518
|
},
|
|
1362
1519
|
state,
|
|
1363
|
-
pendingBossQuestions:
|
|
1520
|
+
pendingBossQuestions: pendingQuestionsForState(state, context).map((pending) => ({
|
|
1364
1521
|
questionId: pending.questionId,
|
|
1365
|
-
|
|
1522
|
+
asker: pending.asker,
|
|
1366
1523
|
question: pending.question,
|
|
1367
1524
|
sourceItem: pending.sourceItem,
|
|
1368
1525
|
})),
|
|
@@ -1381,11 +1538,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1381
1538
|
disposalPromise !== undefined) {
|
|
1382
1539
|
throw new Error('decide runtime: restore(session, snapshot) may only be called once');
|
|
1383
1540
|
}
|
|
1384
|
-
const identity =
|
|
1541
|
+
const identity = bindSession(session);
|
|
1385
1542
|
const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId, { allowSuspendedCall: true });
|
|
1386
|
-
const suspendedCall = boundSnapshot.
|
|
1387
|
-
? boundSnapshot.suspendedCall
|
|
1388
|
-
: undefined;
|
|
1543
|
+
const suspendedCall = boundSnapshot.suspendedCall;
|
|
1389
1544
|
let finishInitialization;
|
|
1390
1545
|
const initialization = new Promise((resolve) => {
|
|
1391
1546
|
finishInitialization = resolve;
|
|
@@ -1394,7 +1549,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1394
1549
|
lifecycleStarted = true;
|
|
1395
1550
|
ports = identity.ports;
|
|
1396
1551
|
sessionIdentity = identity;
|
|
1397
|
-
let
|
|
1552
|
+
let priorExternalRoleTokens;
|
|
1398
1553
|
try {
|
|
1399
1554
|
traceSequence = boundSnapshot.sequences.trace;
|
|
1400
1555
|
turnSequence = boundSnapshot.sequences.turn;
|
|
@@ -1402,9 +1557,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1402
1557
|
playerCallSequence = boundSnapshot.sequences.playerCall;
|
|
1403
1558
|
playbookCallSequence = boundSnapshot.sequences.playbookCall;
|
|
1404
1559
|
if (identity.playerSessions) {
|
|
1405
|
-
|
|
1560
|
+
priorExternalRoleTokens = snapshotRoleResumeTokens();
|
|
1406
1561
|
}
|
|
1407
|
-
|
|
1562
|
+
restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
|
|
1408
1563
|
nestedBridge.prepareRestore(suspendedCall);
|
|
1409
1564
|
if (suspendedCall !== undefined) {
|
|
1410
1565
|
playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
|
|
@@ -1430,9 +1585,9 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1430
1585
|
}
|
|
1431
1586
|
catch (error) {
|
|
1432
1587
|
let failure = error;
|
|
1433
|
-
if (
|
|
1588
|
+
if (priorExternalRoleTokens !== undefined) {
|
|
1434
1589
|
try {
|
|
1435
|
-
identity.playerSessions.restore(
|
|
1590
|
+
identity.playerSessions.restore(priorExternalRoleTokens);
|
|
1436
1591
|
}
|
|
1437
1592
|
catch (rollbackError) {
|
|
1438
1593
|
failure = new AggregateError([error, rollbackError], 'DECIDE restore and player continuation rollback failed');
|
|
@@ -1461,12 +1616,17 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1461
1616
|
const turnId = ++turnSequence;
|
|
1462
1617
|
currentTurnId = turnId;
|
|
1463
1618
|
currentSignal = turn.signal;
|
|
1619
|
+
currentAborts = abortReasonClassifier(turn.signal);
|
|
1464
1620
|
controlPlaneError = undefined;
|
|
1465
1621
|
let result = resultForSnapshot(turn.signal);
|
|
1466
1622
|
let settlement = result;
|
|
1467
1623
|
const failures = [];
|
|
1468
1624
|
try {
|
|
1469
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();
|
|
1470
1630
|
if (turn.text.trim().length === 0) {
|
|
1471
1631
|
const state = currentState();
|
|
1472
1632
|
result = { outcome: 'no-action', state };
|
|
@@ -1502,16 +1662,19 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1502
1662
|
}
|
|
1503
1663
|
catch (error) {
|
|
1504
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).
|
|
1505
1668
|
if (primaryError !== undefined) {
|
|
1506
1669
|
collectFailure(failures, primaryError);
|
|
1507
1670
|
}
|
|
1508
|
-
else if (!turn.signal
|
|
1671
|
+
else if (!isAbortFailure(error, turn.signal)) {
|
|
1509
1672
|
collectFailure(failures, error);
|
|
1510
1673
|
}
|
|
1511
1674
|
const state = currentState();
|
|
1512
1675
|
const effectiveError = primaryError ?? error;
|
|
1513
1676
|
result =
|
|
1514
|
-
turn.signal
|
|
1677
|
+
isAbortFailure(error, turn.signal) && primaryError === undefined
|
|
1515
1678
|
? resultForSnapshot(turn.signal)
|
|
1516
1679
|
: {
|
|
1517
1680
|
outcome: 'failed',
|
|
@@ -1532,12 +1695,14 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1532
1695
|
catch (error) {
|
|
1533
1696
|
const primaryError = controlPlaneError;
|
|
1534
1697
|
const effectiveError = primaryError ?? error;
|
|
1535
|
-
|
|
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);
|
|
1536
1703
|
const state = currentState();
|
|
1537
1704
|
result = {
|
|
1538
|
-
outcome:
|
|
1539
|
-
? 'aborted'
|
|
1540
|
-
: 'failed',
|
|
1705
|
+
outcome: drainAborted ? 'aborted' : 'failed',
|
|
1541
1706
|
state,
|
|
1542
1707
|
error: normalizeErrorFull(effectiveError) ?? {
|
|
1543
1708
|
name: 'Error',
|
|
@@ -1546,21 +1711,31 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1546
1711
|
};
|
|
1547
1712
|
settlement = { ...result, ...stateIdentity(state) };
|
|
1548
1713
|
}
|
|
1549
|
-
currentSignal = undefined;
|
|
1550
1714
|
try {
|
|
1551
1715
|
await emitTrace('boss.input.settled', settlement, { turnId });
|
|
1552
1716
|
}
|
|
1553
1717
|
catch (error) {
|
|
1554
|
-
|
|
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
|
+
}
|
|
1555
1723
|
}
|
|
1556
1724
|
try {
|
|
1557
1725
|
await flush();
|
|
1558
1726
|
}
|
|
1559
1727
|
catch (error) {
|
|
1560
|
-
|
|
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
|
+
}
|
|
1561
1734
|
}
|
|
1562
1735
|
finally {
|
|
1563
1736
|
const primaryError = controlPlaneError;
|
|
1737
|
+
currentSignal = undefined;
|
|
1738
|
+
currentAborts = undefined;
|
|
1564
1739
|
currentTurnId = undefined;
|
|
1565
1740
|
controlPlaneError = undefined;
|
|
1566
1741
|
if (primaryError !== undefined)
|
|
@@ -1586,6 +1761,7 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1586
1761
|
}
|
|
1587
1762
|
currentTurnId = playbookCallTurnIds.get(callId);
|
|
1588
1763
|
currentSignal = signal;
|
|
1764
|
+
currentAborts = abortReasonClassifier(signal);
|
|
1589
1765
|
controlPlaneError = undefined;
|
|
1590
1766
|
let runResult;
|
|
1591
1767
|
let operationError;
|
|
@@ -1615,14 +1791,50 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1615
1791
|
catch (error) {
|
|
1616
1792
|
drainError = error;
|
|
1617
1793
|
}
|
|
1618
|
-
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);
|
|
1619
1810
|
currentSignal = undefined;
|
|
1811
|
+
currentAborts = undefined;
|
|
1620
1812
|
currentTurnId = undefined;
|
|
1621
1813
|
controlPlaneError = undefined;
|
|
1622
1814
|
if (failure !== undefined)
|
|
1623
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
|
+
}
|
|
1624
1829
|
if (runResult === undefined) {
|
|
1625
|
-
|
|
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
|
+
}
|
|
1626
1838
|
}
|
|
1627
1839
|
return runResult;
|
|
1628
1840
|
},
|
|
@@ -1671,15 +1883,18 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1671
1883
|
collectFailure(failures, error);
|
|
1672
1884
|
}
|
|
1673
1885
|
finally {
|
|
1674
|
-
|
|
1886
|
+
privateResumeTokens.clear();
|
|
1675
1887
|
playbookCallTurnIds.clear();
|
|
1676
|
-
|
|
1888
|
+
inFlightPlayerKeys.clear();
|
|
1677
1889
|
activeBoundaryCalls.clear();
|
|
1678
1890
|
activeEmissionCalls.clear();
|
|
1679
1891
|
emissionQueue.clear();
|
|
1680
1892
|
judgeQueue.clear();
|
|
1681
1893
|
actor = undefined;
|
|
1682
1894
|
currentSignal = undefined;
|
|
1895
|
+
currentAborts = undefined;
|
|
1896
|
+
actorSettlementAborts.length = 0;
|
|
1897
|
+
actorSettlementErrorAborts = undefined;
|
|
1683
1898
|
currentTurnId = undefined;
|
|
1684
1899
|
ports = undefined;
|
|
1685
1900
|
sessionIdentity = undefined;
|
|
@@ -1695,11 +1910,15 @@ export const createPlaybookRuntime = (options) => {
|
|
|
1695
1910
|
})();
|
|
1696
1911
|
return disposalPromise;
|
|
1697
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
|
+
},
|
|
1698
1918
|
};
|
|
1699
1919
|
};
|
|
1700
1920
|
export const _internal = {
|
|
1701
1921
|
composePlayerPrompt,
|
|
1702
|
-
resolvePlayerId,
|
|
1703
1922
|
requiredFieldsFor,
|
|
1704
1923
|
extractJson,
|
|
1705
1924
|
buildClassifierPrompt,
|
|
@@ -1708,12 +1927,12 @@ export const _internal = {
|
|
|
1708
1927
|
parseAdjudication,
|
|
1709
1928
|
combineSignals,
|
|
1710
1929
|
pendingQuestionsFromContext,
|
|
1930
|
+
pendingQuestionsForState,
|
|
1711
1931
|
normalizeErrorCompact,
|
|
1712
1932
|
normalizeErrorFull,
|
|
1713
|
-
DEFAULT_PLAYER_BINDING,
|
|
1714
1933
|
STATE_DESCRIPTIONS,
|
|
1715
|
-
|
|
1716
|
-
|
|
1934
|
+
ROLE_STATES,
|
|
1935
|
+
ROLE_STATE_IDS,
|
|
1717
1936
|
VERBATIM_PAYLOAD_FIELDS,
|
|
1718
1937
|
BOSS_INTERRUPT_TARGETS,
|
|
1719
1938
|
CONTINUATION_PREAMBLE,
|