@sublang/playbook 7.0.0 → 8.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 +17 -4
- package/docs/cli.md +74 -29
- package/docs/configuration.md +209 -112
- package/docs/embedding.md +71 -25
- package/package.json +4 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
- 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 +5 -5
- 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 +7 -11
- package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
- 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 +12 -13
- package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
- 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 +970 -289
- package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
- 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 +9 -9
- 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 +9 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +171 -134
- package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
- 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 +16 -21
- package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
- 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 +27 -23
- package/slc/link.md +113 -93
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +20 -16
- package/src/runtime.ts +19 -23
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +241 -149
- package/src/xstate-playbook-runtime.ts +331 -178
- package/src/xstate-runtime.js +63 -24
- package/src/xstate-runtime.ts +96 -28
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
5
|
import PQueue from 'p-queue';
|
|
6
|
-
import {
|
|
6
|
+
import { isAgentCallSettingsError, } from '@sublang/cligent/tmux-play';
|
|
7
|
+
import { assertPlaybookRuntimeSnapshot, hiddenControlEnvelope, registerPlaybookAbortCleanup, snapshotJsonValue, validatePlayerResult, } from '../../../src/xstate-runtime.js';
|
|
7
8
|
import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
|
|
8
9
|
class VisibilityControlError extends Error {
|
|
9
10
|
constructor(cause) {
|
|
@@ -11,10 +12,33 @@ class VisibilityControlError extends Error {
|
|
|
11
12
|
this.name = 'VisibilityControlError';
|
|
12
13
|
}
|
|
13
14
|
}
|
|
15
|
+
class AgentSettingsPreflightError extends Error {
|
|
16
|
+
rejection;
|
|
17
|
+
constructor(rejection) {
|
|
18
|
+
super('agent rejected supplied complete call settings', {
|
|
19
|
+
cause: rejection,
|
|
20
|
+
});
|
|
21
|
+
this.rejection = rejection;
|
|
22
|
+
this.name = 'AgentSettingsPreflightError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function classifySettingsCall(call) {
|
|
26
|
+
try {
|
|
27
|
+
return await call();
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (isAgentCallSettingsError(error)) {
|
|
31
|
+
throw new AgentSettingsPreflightError(error);
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
14
36
|
const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
|
|
15
37
|
const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
|
|
16
38
|
const INTERNAL_CAPTAIN_ID = 'captain';
|
|
17
39
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
40
|
+
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
41
|
+
const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
18
42
|
function parseRegisteredCommand(prompt) {
|
|
19
43
|
const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(prompt.trim());
|
|
20
44
|
if (!match)
|
|
@@ -127,7 +151,14 @@ function pendingQuestionLines(pending) {
|
|
|
127
151
|
: typeof record.id === 'string'
|
|
128
152
|
? record.id
|
|
129
153
|
: undefined;
|
|
130
|
-
const
|
|
154
|
+
const asker = typeof record.asker === 'object' && record.asker !== null
|
|
155
|
+
? record.asker
|
|
156
|
+
: undefined;
|
|
157
|
+
const askerLabel = asker?.kind === 'captain'
|
|
158
|
+
? 'Captain'
|
|
159
|
+
: asker?.kind === 'role' && typeof asker.roleId === 'string'
|
|
160
|
+
? asker.roleId
|
|
161
|
+
: undefined;
|
|
131
162
|
const text = typeof record.question === 'string'
|
|
132
163
|
? record.question
|
|
133
164
|
: typeof record.text === 'string'
|
|
@@ -137,9 +168,9 @@ function pendingQuestionLines(pending) {
|
|
|
137
168
|
// fragment is never handed back to the tag as a value: bounding it a
|
|
138
169
|
// second time would cut the line at the seam's limit and drop whatever
|
|
139
170
|
// the shell had already written after the long part.
|
|
140
|
-
const asked =
|
|
171
|
+
const asked = askerLabel === undefined
|
|
141
172
|
? digestLine `${quoteEvidence(text)}`
|
|
142
|
-
: digestLine `${quoteEvidence(
|
|
173
|
+
: digestLine `${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
|
|
143
174
|
const marker = id === undefined ? '' : digestLine `(${quoteEvidence(id)}) `;
|
|
144
175
|
lines.push(`- ${marker}${asked}`);
|
|
145
176
|
}
|
|
@@ -179,14 +210,20 @@ function renderJournalPayload(payload) {
|
|
|
179
210
|
const raw = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
180
211
|
return raw ?? 'null';
|
|
181
212
|
}
|
|
182
|
-
function
|
|
213
|
+
function renderJournalDigest(records, heading) {
|
|
183
214
|
const lines = records.map((record) => `${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(record.payload)}`);
|
|
184
215
|
return [
|
|
185
|
-
|
|
216
|
+
heading,
|
|
186
217
|
'The labeled ControlView and catalog digest blocks outrank conversation memory.',
|
|
187
218
|
...(lines.length === 0 ? ['(no earlier turns)'] : lines),
|
|
188
219
|
].join('\n');
|
|
189
220
|
}
|
|
221
|
+
function renderReseedDigest(records) {
|
|
222
|
+
return renderJournalDigest(records, 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.');
|
|
223
|
+
}
|
|
224
|
+
function renderCatchUpDigest(records) {
|
|
225
|
+
return renderJournalDigest(records, 'This retained conversation missed the host journal records below. Treat this deterministic journal suffix as authoritative.');
|
|
226
|
+
}
|
|
190
227
|
// DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
|
|
191
228
|
// internal control vocabulary.
|
|
192
229
|
const CONTROL_VOCABULARY = [
|
|
@@ -299,22 +336,31 @@ function proseRejection(prose, liveSessionIds = [], liveStateIds = [], suppliedI
|
|
|
299
336
|
return undefined;
|
|
300
337
|
}
|
|
301
338
|
// DR-013 A1: adapters with no provider-enforced tool-restriction surface.
|
|
302
|
-
// Cligent's Codex
|
|
303
|
-
// empty list that expresses tool-free — because
|
|
304
|
-
// cannot enforce one, so requesting it fails
|
|
305
|
-
// model is reached. Omitting the option is the
|
|
306
|
-
// run a control call at all; its isolation then
|
|
307
|
-
// hidden-judge envelope below rather than on provider
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
339
|
+
// Cligent's Codex, Kimi, and OpenCode adapters reject any `allowedTools`
|
|
340
|
+
// value — including the empty list that expresses tool-free — because their
|
|
341
|
+
// supported provider surfaces cannot enforce one, so requesting it fails
|
|
342
|
+
// every control call before the model is reached. Omitting the option is the
|
|
343
|
+
// only way such an adapter can run a control call at all; its isolation then
|
|
344
|
+
// rests on the authored hidden-judge envelope below rather than on provider
|
|
345
|
+
// enforcement.
|
|
346
|
+
const CAPTAIN_TOOL_ISOLATION_BY_ADAPTER = {
|
|
347
|
+
claude: 'provider-enforced',
|
|
348
|
+
codex: 'prompt-only',
|
|
349
|
+
gemini: 'provider-enforced',
|
|
350
|
+
kimi: 'prompt-only',
|
|
351
|
+
opencode: 'prompt-only',
|
|
352
|
+
};
|
|
353
|
+
function requiresPromptOnlyToolIsolation(captainAdapter) {
|
|
354
|
+
return (Object.hasOwn(CAPTAIN_TOOL_ISOLATION_BY_ADAPTER, captainAdapter) &&
|
|
355
|
+
CAPTAIN_TOOL_ISOLATION_BY_ADAPTER[captainAdapter] === 'prompt-only');
|
|
356
|
+
}
|
|
311
357
|
// The tool half of a control call's options. An empty allowlist means "no
|
|
312
358
|
// tools available" and is distinct from omission, which grants the adapter's
|
|
313
359
|
// full native tool surface — so omit only where the empty list would be
|
|
314
360
|
// refused, and keep requesting enforcement whenever the adapter is unknown.
|
|
315
361
|
function controlCallToolOptions(captainAdapter) {
|
|
316
362
|
if (captainAdapter !== undefined &&
|
|
317
|
-
|
|
363
|
+
requiresPromptOnlyToolIsolation(captainAdapter)) {
|
|
318
364
|
return {};
|
|
319
365
|
}
|
|
320
366
|
return { allowedTools: [] };
|
|
@@ -330,14 +376,6 @@ function forwardedToolOptions(requested, captainAdapter) {
|
|
|
330
376
|
return controlCallToolOptions(captainAdapter);
|
|
331
377
|
return { allowedTools: requested };
|
|
332
378
|
}
|
|
333
|
-
function readCaptainAdapter(options) {
|
|
334
|
-
if (typeof options !== 'object' || options === null)
|
|
335
|
-
return undefined;
|
|
336
|
-
const adapter = options.captainAdapter;
|
|
337
|
-
return typeof adapter === 'string' && adapter.length > 0
|
|
338
|
-
? adapter
|
|
339
|
-
: undefined;
|
|
340
|
-
}
|
|
341
379
|
const hiddenJudgeEnvelope = hiddenControlEnvelope;
|
|
342
380
|
// CAPTAIN-20: the result-phase block the shell supplies inside the closing
|
|
343
381
|
// reply call's envelope — the settlement's outcome-report facts verbatim, the
|
|
@@ -408,10 +446,28 @@ function isValidRegistryEntry(value) {
|
|
|
408
446
|
if (typeof value !== 'object' || value === null)
|
|
409
447
|
return false;
|
|
410
448
|
const e = value;
|
|
449
|
+
if (!Array.isArray(e.requiredRoleIds) ||
|
|
450
|
+
e.requiredRoleIds.some((role) => typeof role !== 'string' ||
|
|
451
|
+
!ROLE_ID_PATTERN.test(role) ||
|
|
452
|
+
role === INTERNAL_CAPTAIN_ID) ||
|
|
453
|
+
new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
|
|
454
|
+
!Array.isArray(e.concurrentRoleSets)) {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
const roles = new Set(e.requiredRoleIds);
|
|
458
|
+
const concurrency = e.concurrentRoleSets;
|
|
459
|
+
if (concurrency.some((set) => !Array.isArray(set) ||
|
|
460
|
+
set.length < 2 ||
|
|
461
|
+
set.some((role) => typeof role !== 'string' || !roles.has(role)) ||
|
|
462
|
+
new Set(set).size !== set.length) ||
|
|
463
|
+
new Set(concurrency.map((set) => JSON.stringify(set))).size !==
|
|
464
|
+
concurrency.length) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
411
467
|
return (typeof e.id === 'string' &&
|
|
412
468
|
typeof e.command === 'string' &&
|
|
413
469
|
typeof e.intent === 'string' &&
|
|
414
|
-
|
|
470
|
+
e.artifactSchema === 2 &&
|
|
415
471
|
typeof e.validateOptions === 'function' &&
|
|
416
472
|
typeof e.createRuntime === 'function');
|
|
417
473
|
}
|
|
@@ -468,14 +524,165 @@ function snapshotUuid(value, path) {
|
|
|
468
524
|
}
|
|
469
525
|
return id;
|
|
470
526
|
}
|
|
527
|
+
function snapshotPermissions(value, path) {
|
|
528
|
+
if (value === undefined)
|
|
529
|
+
return undefined;
|
|
530
|
+
const record = snapshotRecord(value, path);
|
|
531
|
+
rejectSnapshotKeys(record, ['mode', 'fileWrite', 'shellExecute', 'networkAccess', 'writablePaths'], path);
|
|
532
|
+
const normalized = {};
|
|
533
|
+
if (record.mode !== undefined) {
|
|
534
|
+
if (record.mode !== 'auto' && record.mode !== 'bypass') {
|
|
535
|
+
throw new TypeError(`${path}.mode must be "auto" or "bypass"`);
|
|
536
|
+
}
|
|
537
|
+
normalized.mode = record.mode;
|
|
538
|
+
}
|
|
539
|
+
for (const key of [
|
|
540
|
+
'fileWrite',
|
|
541
|
+
'shellExecute',
|
|
542
|
+
'networkAccess',
|
|
543
|
+
]) {
|
|
544
|
+
const level = record[key];
|
|
545
|
+
if (level === undefined)
|
|
546
|
+
continue;
|
|
547
|
+
if (level !== 'allow' && level !== 'ask' && level !== 'deny') {
|
|
548
|
+
throw new TypeError(`${path}.${key} must be "allow", "ask", or "deny"`);
|
|
549
|
+
}
|
|
550
|
+
normalized[key] = level;
|
|
551
|
+
}
|
|
552
|
+
if (record.writablePaths !== undefined) {
|
|
553
|
+
if (!Array.isArray(record.writablePaths) ||
|
|
554
|
+
record.writablePaths.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
|
|
555
|
+
throw new TypeError(`${path}.writablePaths must be an array of non-empty strings`);
|
|
556
|
+
}
|
|
557
|
+
normalized.writablePaths = [...record.writablePaths];
|
|
558
|
+
}
|
|
559
|
+
return normalized;
|
|
560
|
+
}
|
|
561
|
+
function livePermissions(value) {
|
|
562
|
+
if (value === undefined)
|
|
563
|
+
return undefined;
|
|
564
|
+
return {
|
|
565
|
+
...(value.mode === undefined ? {} : { mode: value.mode }),
|
|
566
|
+
...(value.fileWrite === undefined ? {} : { fileWrite: value.fileWrite }),
|
|
567
|
+
...(value.shellExecute === undefined
|
|
568
|
+
? {}
|
|
569
|
+
: { shellExecute: value.shellExecute }),
|
|
570
|
+
...(value.networkAccess === undefined
|
|
571
|
+
? {}
|
|
572
|
+
: { networkAccess: value.networkAccess }),
|
|
573
|
+
...(value.writablePaths === undefined
|
|
574
|
+
? {}
|
|
575
|
+
: { writablePaths: [...value.writablePaths] }),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function snapshotFixedAgent(value, path) {
|
|
579
|
+
const record = snapshotRecord(value, path);
|
|
580
|
+
rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions'], path);
|
|
581
|
+
const adapter = snapshotString(record.adapter, `${path}.adapter`);
|
|
582
|
+
const instruction = record.instruction === undefined
|
|
583
|
+
? undefined
|
|
584
|
+
: snapshotString(record.instruction, `${path}.instruction`, true);
|
|
585
|
+
const permissions = snapshotPermissions(record.permissions, `${path}.permissions`);
|
|
586
|
+
return {
|
|
587
|
+
adapter,
|
|
588
|
+
...(instruction === undefined ? {} : { instruction }),
|
|
589
|
+
...(permissions === undefined ? {} : { permissions }),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function snapshotPlayerSessions(value, path) {
|
|
593
|
+
const sessions = snapshotRecord(value, path);
|
|
594
|
+
return Object.fromEntries(Object.entries(sessions).map(([playerId, raw]) => {
|
|
595
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
596
|
+
throw new TypeError(`${path} has invalid player id ${JSON.stringify(playerId)}`);
|
|
597
|
+
}
|
|
598
|
+
const record = snapshotRecord(raw, `${path}.${playerId}`);
|
|
599
|
+
rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions', 'resumeToken'], `${path}.${playerId}`);
|
|
600
|
+
const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'resumeToken')), `${path}.${playerId}`);
|
|
601
|
+
const resumeToken = record.resumeToken === undefined
|
|
602
|
+
? undefined
|
|
603
|
+
: snapshotString(record.resumeToken, `${path}.${playerId}.resumeToken`);
|
|
604
|
+
return [
|
|
605
|
+
playerId,
|
|
606
|
+
{ ...fixed, ...(resumeToken === undefined ? {} : { resumeToken }) },
|
|
607
|
+
];
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
function snapshotFrameRoleBindings(value, path) {
|
|
611
|
+
const bindings = snapshotRecord(value, path);
|
|
612
|
+
return Object.fromEntries(Object.entries(bindings).map(([roleId, raw]) => {
|
|
613
|
+
if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
|
|
614
|
+
throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
|
|
615
|
+
}
|
|
616
|
+
const playerId = snapshotString(raw, `${path}.${roleId}`);
|
|
617
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
618
|
+
throw new TypeError(`${path}.${roleId} has invalid player id`);
|
|
619
|
+
}
|
|
620
|
+
return [roleId, playerId];
|
|
621
|
+
}));
|
|
622
|
+
}
|
|
623
|
+
function normalizeHostPlayerResult(value, expectedPlayerId) {
|
|
624
|
+
const path = 'tmux-play delegated-player result';
|
|
625
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
626
|
+
throw new TypeError(`${path} must be an object`);
|
|
627
|
+
}
|
|
628
|
+
const prototype = Object.getPrototypeOf(value);
|
|
629
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
630
|
+
throw new TypeError(`${path} must be a plain JSON object`);
|
|
631
|
+
}
|
|
632
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
633
|
+
const allowedKeys = new Set([
|
|
634
|
+
'status',
|
|
635
|
+
'playerId',
|
|
636
|
+
'turnId',
|
|
637
|
+
'resumeToken',
|
|
638
|
+
'finalText',
|
|
639
|
+
'error',
|
|
640
|
+
]);
|
|
641
|
+
const normalized = {};
|
|
642
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
643
|
+
if (typeof key === 'symbol') {
|
|
644
|
+
throw new TypeError(`${path} must not contain symbol-keyed properties`);
|
|
645
|
+
}
|
|
646
|
+
const descriptor = descriptors[key];
|
|
647
|
+
if (!allowedKeys.has(key)) {
|
|
648
|
+
throw new TypeError(`${path} has unknown field ${JSON.stringify(key)}`);
|
|
649
|
+
}
|
|
650
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
|
|
651
|
+
throw new TypeError(`${path}.${key} must be an enumerable data property`);
|
|
652
|
+
}
|
|
653
|
+
// Cligent deliberately exposes optional result members as own
|
|
654
|
+
// `undefined` data properties. Omit only those members before taking the
|
|
655
|
+
// immutable JSON snapshot; every other value still passes the strict
|
|
656
|
+
// JSON validator below.
|
|
657
|
+
if (descriptor.value !== undefined)
|
|
658
|
+
normalized[key] = descriptor.value;
|
|
659
|
+
}
|
|
660
|
+
const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
|
|
661
|
+
rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'], path);
|
|
662
|
+
if (record.playerId !== expectedPlayerId) {
|
|
663
|
+
throw new TypeError(`${path}.playerId does not match the requested player`);
|
|
664
|
+
}
|
|
665
|
+
snapshotInteger(record.turnId, `${path}.turnId`, 1);
|
|
666
|
+
return validatePlayerResult({
|
|
667
|
+
status: record.status,
|
|
668
|
+
...(record.resumeToken === undefined
|
|
669
|
+
? {}
|
|
670
|
+
: { resumeToken: record.resumeToken }),
|
|
671
|
+
...(record.finalText === undefined
|
|
672
|
+
? {}
|
|
673
|
+
: { finalText: record.finalText }),
|
|
674
|
+
...(record.error === undefined ? {} : { error: record.error }),
|
|
675
|
+
}, path);
|
|
676
|
+
}
|
|
471
677
|
/** Validate, detach, and freeze one untrusted shell snapshot. */
|
|
472
|
-
function assertPlaybookCaptainShellSnapshot(value) {
|
|
678
|
+
export function assertPlaybookCaptainShellSnapshot(value) {
|
|
473
679
|
const detached = snapshotJsonValue(value, 'Captain shell snapshot');
|
|
474
680
|
const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
|
|
475
681
|
const mode = snapshot.mode;
|
|
476
682
|
const commonKeys = [
|
|
477
683
|
'schemaVersion',
|
|
478
684
|
'captain',
|
|
685
|
+
'playerSessions',
|
|
479
686
|
'issuedSessionIds',
|
|
480
687
|
'sequences',
|
|
481
688
|
'journal',
|
|
@@ -490,7 +697,6 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
490
697
|
rejectSnapshotKeys(snapshot, [
|
|
491
698
|
...commonKeys,
|
|
492
699
|
'frames',
|
|
493
|
-
'rootPlayerResumeTokens',
|
|
494
700
|
'pendingBossQuestions',
|
|
495
701
|
'lastError',
|
|
496
702
|
], 'Captain shell snapshot');
|
|
@@ -498,20 +704,31 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
498
704
|
else {
|
|
499
705
|
throw new TypeError('Captain shell snapshot.mode must be "chat" or "engaged.parked"');
|
|
500
706
|
}
|
|
501
|
-
if (snapshot.schemaVersion !==
|
|
502
|
-
throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected
|
|
707
|
+
if (snapshot.schemaVersion !== 3) {
|
|
708
|
+
throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
|
|
503
709
|
}
|
|
504
710
|
const captain = snapshotRecord(snapshot.captain, 'Captain shell snapshot.captain');
|
|
505
|
-
rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'conversation'], 'Captain shell snapshot.captain');
|
|
711
|
+
rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'agent', 'conversation'], 'Captain shell snapshot.captain');
|
|
506
712
|
const captainSessionId = snapshotUuid(captain.sessionId, 'Captain shell snapshot.captain.sessionId');
|
|
507
713
|
const captainRuntime = assertPlaybookRuntimeSnapshot(captain.runtime, INTERNAL_CAPTAIN_ID);
|
|
714
|
+
const captainAgent = snapshotFixedAgent(captain.agent, 'Captain shell snapshot.captain.agent');
|
|
508
715
|
const conversation = snapshotRecord(captain.conversation, 'Captain shell snapshot.captain.conversation');
|
|
509
716
|
let normalizedConversation;
|
|
510
717
|
if (conversation.kind === 'pinned') {
|
|
511
718
|
rejectSnapshotKeys(conversation, ['kind', 'token'], 'Captain shell snapshot.captain.conversation');
|
|
512
719
|
normalizedConversation = {
|
|
513
720
|
kind: 'pinned',
|
|
514
|
-
token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token'
|
|
721
|
+
token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token'),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
else if (conversation.kind === 'needsCatchUp') {
|
|
725
|
+
rejectSnapshotKeys(conversation, ['kind', 'resume', 'afterJournalSeq'], 'Captain shell snapshot.captain.conversation');
|
|
726
|
+
normalizedConversation = {
|
|
727
|
+
kind: 'needsCatchUp',
|
|
728
|
+
resume: conversation.resume === false
|
|
729
|
+
? false
|
|
730
|
+
: snapshotString(conversation.resume, 'Captain shell snapshot.captain.conversation.resume'),
|
|
731
|
+
afterJournalSeq: snapshotInteger(conversation.afterJournalSeq, 'Captain shell snapshot.captain.conversation.afterJournalSeq'),
|
|
515
732
|
};
|
|
516
733
|
}
|
|
517
734
|
else if (conversation.kind === 'unopened' ||
|
|
@@ -579,6 +796,19 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
579
796
|
bossRecords !== turnSequence) {
|
|
580
797
|
throw new TypeError('Captain shell snapshot sequences do not match the complete journal');
|
|
581
798
|
}
|
|
799
|
+
const emptyHistory = turnSequence === 0 && normalizedJournal.length === 0;
|
|
800
|
+
if ((normalizedConversation.kind === 'unopened') !== emptyHistory) {
|
|
801
|
+
throw new TypeError('Captain shell snapshot history is empty exactly when its conversation is unopened');
|
|
802
|
+
}
|
|
803
|
+
if (normalizedConversation.kind === 'needsCatchUp' &&
|
|
804
|
+
normalizedConversation.afterJournalSeq >= journalSequence) {
|
|
805
|
+
throw new TypeError('Captain shell snapshot catch-up watermark must precede the current journal sequence');
|
|
806
|
+
}
|
|
807
|
+
if (normalizedConversation.kind === 'needsCatchUp' &&
|
|
808
|
+
((normalizedConversation.resume === false) !==
|
|
809
|
+
(normalizedConversation.afterJournalSeq === 0))) {
|
|
810
|
+
throw new TypeError('Captain shell snapshot catch-up resume is fresh exactly at journal watermark zero');
|
|
811
|
+
}
|
|
582
812
|
let lastAction;
|
|
583
813
|
if (snapshot.lastAction !== undefined) {
|
|
584
814
|
if (typeof snapshot.lastAction !== 'string' ||
|
|
@@ -595,13 +825,16 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
595
825
|
}
|
|
596
826
|
lastSettlementStatus = snapshot.lastSettlementStatus;
|
|
597
827
|
}
|
|
828
|
+
const playerSessions = snapshotPlayerSessions(snapshot.playerSessions, 'Captain shell snapshot.playerSessions');
|
|
598
829
|
const common = {
|
|
599
|
-
schemaVersion:
|
|
830
|
+
schemaVersion: 3,
|
|
600
831
|
captain: {
|
|
601
832
|
sessionId: captainSessionId,
|
|
602
833
|
runtime: captainRuntime,
|
|
834
|
+
agent: captainAgent,
|
|
603
835
|
conversation: normalizedConversation,
|
|
604
836
|
},
|
|
837
|
+
playerSessions,
|
|
605
838
|
issuedSessionIds: issued,
|
|
606
839
|
sequences: { turn: turnSequence, journal: journalSequence },
|
|
607
840
|
journal: normalizedJournal,
|
|
@@ -610,6 +843,17 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
610
843
|
? {}
|
|
611
844
|
: { lastSettlementStatus }),
|
|
612
845
|
};
|
|
846
|
+
if (captainRuntime.state.status !== 'active' ||
|
|
847
|
+
!captainRuntime.state.quiescent ||
|
|
848
|
+
!captainRuntime.state.tags.includes('playbook.parked') ||
|
|
849
|
+
captainRuntime.suspendedCall !== undefined ||
|
|
850
|
+
Object.keys(captainRuntime.roleResumeTokens).length > 0 ||
|
|
851
|
+
captainRuntime.pendingBossQuestions.length > 0) {
|
|
852
|
+
throw new TypeError('Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended');
|
|
853
|
+
}
|
|
854
|
+
if (captainRuntime.sequences.turn !== turnSequence) {
|
|
855
|
+
throw new TypeError('Captain shell snapshot Captain and shell turn sequences must match');
|
|
856
|
+
}
|
|
613
857
|
if (mode === 'chat') {
|
|
614
858
|
return snapshotJsonValue({ ...common, mode }, 'Captain shell snapshot');
|
|
615
859
|
}
|
|
@@ -626,6 +870,8 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
626
870
|
'depth',
|
|
627
871
|
'parentSessionId',
|
|
628
872
|
'parentCallId',
|
|
873
|
+
'options',
|
|
874
|
+
'roleBindings',
|
|
629
875
|
'runtime',
|
|
630
876
|
], `Captain shell snapshot.frames[${index}]`);
|
|
631
877
|
const playbookId = snapshotString(frame.playbookId, `Captain shell snapshot.frames[${index}].playbookId`);
|
|
@@ -639,6 +885,8 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
639
885
|
? undefined
|
|
640
886
|
: snapshotString(frame.parentCallId, `Captain shell snapshot.frames[${index}].parentCallId`);
|
|
641
887
|
const runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, { allowSuspendedCall: true });
|
|
888
|
+
const options = frame.options;
|
|
889
|
+
const roleBindings = snapshotFrameRoleBindings(frame.roleBindings, `Captain shell snapshot.frames[${index}].roleBindings`);
|
|
642
890
|
normalizedFrames.push({
|
|
643
891
|
playbookId,
|
|
644
892
|
sessionId,
|
|
@@ -646,14 +894,11 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
646
894
|
depth,
|
|
647
895
|
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
648
896
|
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
897
|
+
options,
|
|
898
|
+
roleBindings,
|
|
649
899
|
runtime,
|
|
650
900
|
});
|
|
651
901
|
}
|
|
652
|
-
const rootTokens = snapshotRecord(snapshot.rootPlayerResumeTokens, 'Captain shell snapshot.rootPlayerResumeTokens');
|
|
653
|
-
const normalizedRootTokens = Object.fromEntries(Object.entries(rootTokens).map(([playerId, token]) => [
|
|
654
|
-
playerId,
|
|
655
|
-
snapshotString(token, `Captain shell snapshot.rootPlayerResumeTokens.${playerId}`),
|
|
656
|
-
]));
|
|
657
902
|
let normalizedLastError;
|
|
658
903
|
if (snapshot.lastError !== undefined) {
|
|
659
904
|
const error = snapshotRecord(snapshot.lastError, 'Captain shell snapshot.lastError');
|
|
@@ -663,11 +908,80 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
663
908
|
message: snapshotString(error.message, 'Captain shell snapshot.lastError.message', true),
|
|
664
909
|
};
|
|
665
910
|
}
|
|
911
|
+
const activePlaybooks = new Set();
|
|
912
|
+
const activeSessionIds = new Set([captainSessionId]);
|
|
913
|
+
const issuedIds = new Set(issued);
|
|
914
|
+
const rootSessionId = normalizedFrames[0].sessionId;
|
|
915
|
+
for (const [index, frame] of normalizedFrames.entries()) {
|
|
916
|
+
if (activePlaybooks.has(frame.playbookId)) {
|
|
917
|
+
throw new TypeError('Captain shell snapshot engagement path must not contain a playbook cycle');
|
|
918
|
+
}
|
|
919
|
+
activePlaybooks.add(frame.playbookId);
|
|
920
|
+
if (activeSessionIds.has(frame.sessionId)) {
|
|
921
|
+
throw new TypeError('Captain shell snapshot frame session ids must be unique');
|
|
922
|
+
}
|
|
923
|
+
activeSessionIds.add(frame.sessionId);
|
|
924
|
+
if (!issuedIds.has(frame.sessionId)) {
|
|
925
|
+
throw new TypeError('Captain shell snapshot frame session id was not historically issued');
|
|
926
|
+
}
|
|
927
|
+
if (frame.depth !== index ||
|
|
928
|
+
frame.rootSessionId !== rootSessionId ||
|
|
929
|
+
frame.runtime.state.status !== 'active' ||
|
|
930
|
+
!frame.runtime.state.quiescent) {
|
|
931
|
+
throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
|
|
932
|
+
}
|
|
933
|
+
if (index === 0) {
|
|
934
|
+
if (frame.sessionId !== frame.rootSessionId ||
|
|
935
|
+
frame.parentSessionId !== undefined ||
|
|
936
|
+
frame.parentCallId !== undefined) {
|
|
937
|
+
throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
const parent = normalizedFrames[index - 1];
|
|
942
|
+
const pending = parent.runtime.suspendedCall;
|
|
943
|
+
if (frame.parentSessionId !== parent.sessionId ||
|
|
944
|
+
frame.parentCallId === undefined) {
|
|
945
|
+
throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
|
|
946
|
+
}
|
|
947
|
+
if (!pending ||
|
|
948
|
+
pending.callId !== frame.parentCallId ||
|
|
949
|
+
pending.playbookId !== frame.playbookId ||
|
|
950
|
+
pending.childSessionId !== frame.sessionId) {
|
|
951
|
+
throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
for (const playerId of Object.values(frame.roleBindings)) {
|
|
955
|
+
if (playerSessions[playerId] === undefined) {
|
|
956
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} binds an absent session player`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
for (const question of frame.runtime.pendingBossQuestions) {
|
|
960
|
+
if (question.asker.kind === 'role' &&
|
|
961
|
+
frame.roleBindings[question.asker.roleId] === undefined) {
|
|
962
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} has a pending question from an unbound role`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const projectedTokens = Object.fromEntries(Object.entries(frame.roleBindings).flatMap(([role, playerId]) => {
|
|
966
|
+
const token = playerSessions[playerId]?.resumeToken;
|
|
967
|
+
return token === undefined ? [] : [[role, token]];
|
|
968
|
+
}));
|
|
969
|
+
if (!isDeepStrictEqual(projectedTokens, frame.runtime.roleResumeTokens)) {
|
|
970
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match session continuation`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const leafRuntime = normalizedFrames.at(-1).runtime;
|
|
974
|
+
if (leafRuntime.suspendedCall !== undefined ||
|
|
975
|
+
!leafRuntime.state.tags.includes('playbook.parked')) {
|
|
976
|
+
throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
|
|
977
|
+
}
|
|
978
|
+
if (!isDeepStrictEqual(snapshot.pendingBossQuestions ?? [], leafRuntime.pendingBossQuestions)) {
|
|
979
|
+
throw new TypeError('Captain shell snapshot pending Boss questions must equal the leaf runtime projection');
|
|
980
|
+
}
|
|
666
981
|
return snapshotJsonValue({
|
|
667
982
|
...common,
|
|
668
983
|
mode,
|
|
669
984
|
frames: normalizedFrames,
|
|
670
|
-
rootPlayerResumeTokens: normalizedRootTokens,
|
|
671
985
|
...(snapshot.pendingBossQuestions === undefined
|
|
672
986
|
? {}
|
|
673
987
|
: { pendingBossQuestions: snapshot.pendingBossQuestions }),
|
|
@@ -676,27 +990,109 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
676
990
|
: { lastError: normalizedLastError }),
|
|
677
991
|
}, 'Captain shell snapshot');
|
|
678
992
|
}
|
|
679
|
-
function
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
return undefined;
|
|
993
|
+
function snapshotTuningSelection(value, path) {
|
|
994
|
+
const selection = snapshotRecord(value, path);
|
|
995
|
+
if (selection.kind === 'provider-default') {
|
|
996
|
+
rejectSnapshotKeys(selection, ['kind'], path);
|
|
997
|
+
return { kind: 'provider-default' };
|
|
685
998
|
}
|
|
686
|
-
|
|
999
|
+
if (selection.kind === 'value') {
|
|
1000
|
+
rejectSnapshotKeys(selection, ['kind', 'value'], path);
|
|
1001
|
+
return {
|
|
1002
|
+
kind: 'value',
|
|
1003
|
+
value: snapshotString(selection.value, `${path}.value`),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
throw new TypeError(`${path}.kind must be "value" or "provider-default"`);
|
|
1007
|
+
}
|
|
1008
|
+
const EFFORT_VALUES = new Set([
|
|
1009
|
+
'on',
|
|
1010
|
+
'minimal',
|
|
1011
|
+
'low',
|
|
1012
|
+
'medium',
|
|
1013
|
+
'high',
|
|
1014
|
+
'xhigh',
|
|
1015
|
+
'max',
|
|
1016
|
+
'ultra',
|
|
1017
|
+
'ultracode',
|
|
1018
|
+
'off',
|
|
1019
|
+
]);
|
|
1020
|
+
function snapshotEffortSelection(value, path) {
|
|
1021
|
+
const selection = snapshotTuningSelection(value, path);
|
|
1022
|
+
if (selection.kind === 'value' && !EFFORT_VALUES.has(selection.value)) {
|
|
1023
|
+
throw new TypeError(`${path}.value is not a supported effort selection`);
|
|
1024
|
+
}
|
|
1025
|
+
return selection;
|
|
1026
|
+
}
|
|
1027
|
+
function snapshotSessionAgent(value, path) {
|
|
1028
|
+
const agent = snapshotRecord(value, path);
|
|
1029
|
+
rejectSnapshotKeys(agent, ['adapter', 'model', 'effort', 'instruction', 'permissions'], path);
|
|
1030
|
+
const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(agent).filter(([key]) => key !== 'model' && key !== 'effort')), path);
|
|
1031
|
+
return {
|
|
1032
|
+
adapter: fixed.adapter,
|
|
1033
|
+
...(fixed.instruction === undefined
|
|
1034
|
+
? {}
|
|
1035
|
+
: { instruction: fixed.instruction }),
|
|
1036
|
+
...(fixed.permissions === undefined
|
|
1037
|
+
? {}
|
|
1038
|
+
: { permissions: livePermissions(fixed.permissions) }),
|
|
1039
|
+
model: snapshotTuningSelection(agent.model, `${path}.model`),
|
|
1040
|
+
effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
function fixedAgent(agent) {
|
|
1044
|
+
return {
|
|
1045
|
+
adapter: agent.adapter,
|
|
1046
|
+
...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
|
|
1047
|
+
...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
function callSettings(agent, tuning = agent) {
|
|
1051
|
+
return {
|
|
1052
|
+
model: tuning.model,
|
|
1053
|
+
effort: tuning.effort,
|
|
1054
|
+
...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
|
|
1055
|
+
...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
function promptIdentity(binding) {
|
|
1059
|
+
return binding.model.kind === 'value'
|
|
1060
|
+
? binding.model.value
|
|
1061
|
+
: binding.agent.adapter;
|
|
687
1062
|
}
|
|
688
|
-
// Resolve the active registry at init from
|
|
689
|
-
// (CAPTAIN-16)
|
|
690
|
-
//
|
|
691
|
-
async function buildEnablements(options,
|
|
1063
|
+
// Resolve the active registry at init from exact normalized role and session
|
|
1064
|
+
// agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
|
|
1065
|
+
// exists at this boundary.
|
|
1066
|
+
async function buildEnablements(options, loadModule) {
|
|
692
1067
|
const entries = [];
|
|
693
1068
|
const byCommand = new Map();
|
|
694
1069
|
const byId = new Map();
|
|
695
1070
|
const enablementById = new Map();
|
|
696
|
-
const
|
|
697
|
-
|
|
1071
|
+
const detached = snapshotJsonValue(options, 'captain.options');
|
|
1072
|
+
const top = snapshotRecord(detached, 'captain.options');
|
|
1073
|
+
rejectSnapshotKeys(top, ['playbooks', 'sessionAgents', 'captainAdapter'], 'captain.options');
|
|
1074
|
+
const configValue = top.playbooks;
|
|
1075
|
+
if (typeof configValue !== 'object' ||
|
|
1076
|
+
configValue === null ||
|
|
1077
|
+
Array.isArray(configValue)) {
|
|
698
1078
|
throw new Error('captain.options.playbooks is required');
|
|
699
1079
|
}
|
|
1080
|
+
const config = configValue;
|
|
1081
|
+
const sessionAgents = snapshotRecord(top.sessionAgents, 'captain.options.sessionAgents');
|
|
1082
|
+
rejectSnapshotKeys(sessionAgents, ['captain', 'players'], 'captain.options.sessionAgents');
|
|
1083
|
+
const captainAgent = snapshotSessionAgent(sessionAgents.captain, 'captain.options.sessionAgents.captain');
|
|
1084
|
+
if (top.captainAdapter !== undefined &&
|
|
1085
|
+
top.captainAdapter !== captainAgent.adapter) {
|
|
1086
|
+
throw new Error('captain.options.captainAdapter must equal sessionAgents.captain.adapter');
|
|
1087
|
+
}
|
|
1088
|
+
const playerAgentRecord = snapshotRecord(sessionAgents.players, 'captain.options.sessionAgents.players');
|
|
1089
|
+
const playerAgents = new Map();
|
|
1090
|
+
for (const [playerId, agent] of Object.entries(playerAgentRecord)) {
|
|
1091
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
1092
|
+
throw new Error(`captain.options.sessionAgents.players has invalid player id ${JSON.stringify(playerId)}`);
|
|
1093
|
+
}
|
|
1094
|
+
playerAgents.set(playerId, snapshotSessionAgent(agent, `captain.options.sessionAgents.players.${playerId}`));
|
|
1095
|
+
}
|
|
700
1096
|
const ids = Object.keys(config);
|
|
701
1097
|
if (ids.length === 0) {
|
|
702
1098
|
throw new Error('captain.options.playbooks must enable at least one playbook');
|
|
@@ -710,6 +1106,7 @@ async function buildEnablements(options, players, loadModule) {
|
|
|
710
1106
|
throw new Error(`captain.options.playbooks.${id} must be an object`);
|
|
711
1107
|
}
|
|
712
1108
|
const record = block;
|
|
1109
|
+
rejectSnapshotKeys(record, ['from', 'command', 'roles', 'options'], `captain.options.playbooks.${id}`);
|
|
713
1110
|
const from = record.from;
|
|
714
1111
|
if (typeof from !== 'string' || from.length === 0) {
|
|
715
1112
|
throw new Error(`captain.options.playbooks.${id}.from must be a module specifier`);
|
|
@@ -740,35 +1137,74 @@ async function buildEnablements(options, players, loadModule) {
|
|
|
740
1137
|
if (byCommand.has(command)) {
|
|
741
1138
|
throw new Error(`captain.options.playbooks has a duplicate effective command "${command}"`);
|
|
742
1139
|
}
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
};
|
|
750
|
-
}
|
|
1140
|
+
const roleRecord = snapshotRecord(record.roles, `captain.options.playbooks.${id}.roles`);
|
|
1141
|
+
const required = new Set(entry.requiredRoleIds);
|
|
1142
|
+
const configuredRoles = Object.keys(roleRecord);
|
|
1143
|
+
const missing = entry.requiredRoleIds.filter((role) => !Object.hasOwn(roleRecord, role));
|
|
1144
|
+
const extra = configuredRoles.filter((role) => !required.has(role));
|
|
1145
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
1146
|
+
throw new Error(`captain.options.playbooks.${id}.roles must exactly cover requiredRoleIds`);
|
|
1147
|
+
}
|
|
1148
|
+
const roleBindings = new Map();
|
|
1149
|
+
for (const role of entry.requiredRoleIds) {
|
|
1150
|
+
const path = `captain.options.playbooks.${id}.roles.${role}`;
|
|
1151
|
+
const rawBinding = snapshotRecord(roleRecord[role], path);
|
|
1152
|
+
rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort'], path);
|
|
1153
|
+
const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
|
|
1154
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
1155
|
+
throw new Error(`${path}.playerId is not a canonical player id`);
|
|
1156
|
+
}
|
|
1157
|
+
const agent = playerAgents.get(playerId);
|
|
1158
|
+
if (!agent) {
|
|
1159
|
+
throw new Error(`${path}.playerId names absent session player ${JSON.stringify(playerId)}`);
|
|
1160
|
+
}
|
|
1161
|
+
roleBindings.set(role, {
|
|
1162
|
+
playerId,
|
|
1163
|
+
model: snapshotTuningSelection(rawBinding.model, `${path}.model`),
|
|
1164
|
+
effort: snapshotEffortSelection(rawBinding.effort, `${path}.effort`),
|
|
1165
|
+
agent,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
for (const concurrentRoles of entry.concurrentRoleSets) {
|
|
1169
|
+
const playerIds = concurrentRoles.map((role) => roleBindings.get(role).playerId);
|
|
1170
|
+
if (new Set(playerIds).size !== playerIds.length) {
|
|
1171
|
+
throw new Error(`captain.options.playbooks.${id}.roles aliases concurrent roles ${JSON.stringify(concurrentRoles)}`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
const validatedOptions = snapshotJsonValue(entry.validateOptions(record.options), `captain.options.playbooks.${id}.options`);
|
|
751
1175
|
entries.push(entry);
|
|
752
1176
|
byId.set(entry.id, entry);
|
|
753
1177
|
byCommand.set(command, entry);
|
|
754
1178
|
enablementById.set(entry.id, {
|
|
755
1179
|
entry,
|
|
756
1180
|
command,
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
|
|
1181
|
+
options: validatedOptions,
|
|
1182
|
+
roleBindings,
|
|
760
1183
|
});
|
|
761
1184
|
}
|
|
762
|
-
|
|
1185
|
+
const referenced = new Set([...enablementById.values()].flatMap((enablement) => [...enablement.roleBindings.values()].map((binding) => binding.playerId)));
|
|
1186
|
+
const unreferenced = [...playerAgents.keys()].find((id) => !referenced.has(id));
|
|
1187
|
+
if (unreferenced !== undefined) {
|
|
1188
|
+
throw new Error(`captain.options.sessionAgents.players has unreferenced player ${JSON.stringify(unreferenced)}`);
|
|
1189
|
+
}
|
|
1190
|
+
return {
|
|
1191
|
+
entries,
|
|
1192
|
+
byCommand,
|
|
1193
|
+
byId,
|
|
1194
|
+
enablementById,
|
|
1195
|
+
captainAgent,
|
|
1196
|
+
playerAgents,
|
|
1197
|
+
};
|
|
763
1198
|
}
|
|
764
1199
|
export function createPlaybookCaptainShell(options, deps = {}) {
|
|
765
1200
|
const loadModule = deps.loadModule ?? ((specifier) => import(specifier));
|
|
766
1201
|
const createSessionId = deps.createSessionId ?? randomUUID;
|
|
767
1202
|
const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
const
|
|
1203
|
+
let captainAgent;
|
|
1204
|
+
let captainAdapter;
|
|
1205
|
+
let playerAgents = new Map();
|
|
1206
|
+
const playerLedger = new Map();
|
|
1207
|
+
const playerTransactions = new Map();
|
|
772
1208
|
let entries = [];
|
|
773
1209
|
let byCommand = new Map();
|
|
774
1210
|
let byId = new Map();
|
|
@@ -778,7 +1214,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
778
1214
|
let closedGateAttempted = false;
|
|
779
1215
|
let lifecycle = 'fresh';
|
|
780
1216
|
let terminallyDisposed = false;
|
|
781
|
-
let players = [];
|
|
782
1217
|
let activeContext;
|
|
783
1218
|
const frames = [];
|
|
784
1219
|
let mode = 'chat';
|
|
@@ -828,15 +1263,19 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
828
1263
|
// --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
|
|
829
1264
|
let captainRuntime;
|
|
830
1265
|
let captainSessionId;
|
|
831
|
-
// CAPTAIN-35:
|
|
832
|
-
//
|
|
833
|
-
//
|
|
1266
|
+
// CAPTAIN-35: a preflight settings rejection retains proven continuity but
|
|
1267
|
+
// records the exact journal suffix still owed; other continuity failures
|
|
1268
|
+
// require a fresh, full reseed.
|
|
834
1269
|
let conversation = { kind: 'unopened' };
|
|
835
1270
|
let shuttingDown = false;
|
|
836
1271
|
const journal = [];
|
|
837
1272
|
let journalSeq = 0;
|
|
838
1273
|
let turnSequence = 0;
|
|
839
1274
|
let activeTurn;
|
|
1275
|
+
// `PlayerSessionStore.restore` is authoritative only while the shell is
|
|
1276
|
+
// awaiting the exact owning runtime's restore during a closed-gate shell
|
|
1277
|
+
// restoration. A runtime cannot use the store as a general ledger writer.
|
|
1278
|
+
let restoringPlayerSessionFrame;
|
|
840
1279
|
// The durable call the runtime is about to make, taken from the paired
|
|
841
1280
|
// `captain.call.started` boundary the engine emits before the port call
|
|
842
1281
|
// (CAPTAIN-9): the shell never infers a call's kind from its prose.
|
|
@@ -904,7 +1343,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
904
1343
|
// (CAPTAIN-5/CAPTAIN-6).
|
|
905
1344
|
...(captainRuntime
|
|
906
1345
|
? {
|
|
907
|
-
durableConversation: conversation.kind === 'pinned'
|
|
1346
|
+
durableConversation: conversation.kind === 'pinned' ||
|
|
1347
|
+
(conversation.kind === 'needsCatchUp' &&
|
|
1348
|
+
conversation.resume !== false),
|
|
908
1349
|
sessionJournal: true,
|
|
909
1350
|
}
|
|
910
1351
|
: {}),
|
|
@@ -1060,39 +1501,130 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1060
1501
|
};
|
|
1061
1502
|
let callNestedPlaybook;
|
|
1062
1503
|
const createPorts = (frame) => ({
|
|
1063
|
-
callPlayer: async (
|
|
1504
|
+
callPlayer: async (roleId, prompt, signal, options) => {
|
|
1064
1505
|
admitHostBoundary();
|
|
1065
|
-
if (!activeContext) {
|
|
1506
|
+
if (!activeContext || !activeTurn || !frame.playerCallScope) {
|
|
1066
1507
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
1067
1508
|
}
|
|
1068
1509
|
const context = activeContext;
|
|
1510
|
+
const admittedTurn = activeTurn;
|
|
1511
|
+
const scope = frame.playerCallScope;
|
|
1069
1512
|
signal.throwIfAborted();
|
|
1070
|
-
const
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
// CaptainContext is turn-scoped and cannot accept a narrower XState
|
|
1075
|
-
// invocation signal. Recheck after the host call so a sibling
|
|
1076
|
-
// cancellation is still reported as aborted and cannot rotate a
|
|
1077
|
-
// stopped branch's player token in the linked runtime.
|
|
1078
|
-
signal.throwIfAborted();
|
|
1079
|
-
// CAPTAIN-20: only a player call that actually produced work is an
|
|
1080
|
-
// interruption the Boss was spared. A call that errored or aborted
|
|
1081
|
-
// saved nothing, so it never feeds the saved-counts gate.
|
|
1082
|
-
const summary = activeTurnSummary;
|
|
1083
|
-
if (summary && summaryIncludes(frame) && result.status === 'ok') {
|
|
1084
|
-
summary.counts.interruptions++;
|
|
1513
|
+
const binding = bindingFor(frame, roleId);
|
|
1514
|
+
const ledger = playerLedger.get(binding.playerId);
|
|
1515
|
+
if (!ledger) {
|
|
1516
|
+
throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
|
|
1085
1517
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1518
|
+
const expectedResume = ledger.resumeToken ?? false;
|
|
1519
|
+
if (options.resume !== expectedResume) {
|
|
1520
|
+
throw new Error(`${frameLabel(frame)} player continuation changed before dispatch`);
|
|
1521
|
+
}
|
|
1522
|
+
if (playerTransactions.has(binding.playerId)) {
|
|
1523
|
+
throw new Error(`session player ${JSON.stringify(binding.playerId)} already has a call in flight`);
|
|
1524
|
+
}
|
|
1525
|
+
const settings = callSettings(binding.agent, binding);
|
|
1526
|
+
const calling = {
|
|
1527
|
+
phase: 'calling',
|
|
1528
|
+
frame,
|
|
1529
|
+
roleId,
|
|
1530
|
+
turnId: admittedTurn.id,
|
|
1531
|
+
signal,
|
|
1532
|
+
scope,
|
|
1533
|
+
abandoned: false,
|
|
1095
1534
|
};
|
|
1535
|
+
playerTransactions.set(binding.playerId, calling);
|
|
1536
|
+
let result;
|
|
1537
|
+
let hostResolved = false;
|
|
1538
|
+
try {
|
|
1539
|
+
let rawResult;
|
|
1540
|
+
try {
|
|
1541
|
+
rawResult = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, prompt, {
|
|
1542
|
+
resume: options.resume,
|
|
1543
|
+
settings,
|
|
1544
|
+
})));
|
|
1545
|
+
hostResolved = true;
|
|
1546
|
+
}
|
|
1547
|
+
catch (error) {
|
|
1548
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
1549
|
+
if (playerTransactions.get(binding.playerId) !== calling ||
|
|
1550
|
+
calling.abandoned ||
|
|
1551
|
+
signal.aborted ||
|
|
1552
|
+
activeTurn !== admittedTurn ||
|
|
1553
|
+
frame.playerCallScope !== scope ||
|
|
1554
|
+
!frames.includes(frame)) {
|
|
1555
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
1556
|
+
playerTransactions.delete(binding.playerId);
|
|
1557
|
+
}
|
|
1558
|
+
signal.throwIfAborted();
|
|
1559
|
+
throw new Error(`${frameLabel(frame)} player settings rejection arrived after its runtime operation ended`);
|
|
1560
|
+
}
|
|
1561
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
1562
|
+
}
|
|
1563
|
+
throw error;
|
|
1564
|
+
}
|
|
1565
|
+
result = normalizeHostPlayerResult(rawResult, binding.playerId);
|
|
1566
|
+
const transitionRequired = result.resumeToken !== undefined || result.status === 'ok';
|
|
1567
|
+
if (playerTransactions.get(binding.playerId) !== calling ||
|
|
1568
|
+
calling.abandoned ||
|
|
1569
|
+
signal.aborted ||
|
|
1570
|
+
activeTurn !== admittedTurn ||
|
|
1571
|
+
frame.playerCallScope !== scope ||
|
|
1572
|
+
!frames.includes(frame)) {
|
|
1573
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
1574
|
+
if (transitionRequired) {
|
|
1575
|
+
playerTransactions.set(binding.playerId, {
|
|
1576
|
+
phase: 'quarantined',
|
|
1577
|
+
frame,
|
|
1578
|
+
roleId,
|
|
1579
|
+
turnId: admittedTurn.id,
|
|
1580
|
+
signal,
|
|
1581
|
+
scope,
|
|
1582
|
+
reason: 'a transition-worthy result arrived after its runtime operation ended',
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
else {
|
|
1586
|
+
playerTransactions.delete(binding.playerId);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
signal.throwIfAborted();
|
|
1590
|
+
throw new Error(`${frameLabel(frame)} player result arrived after its runtime operation ended`);
|
|
1591
|
+
}
|
|
1592
|
+
if (transitionRequired) {
|
|
1593
|
+
playerTransactions.set(binding.playerId, {
|
|
1594
|
+
phase: 'awaitingCommit',
|
|
1595
|
+
frame,
|
|
1596
|
+
roleId,
|
|
1597
|
+
turnId: admittedTurn.id,
|
|
1598
|
+
signal,
|
|
1599
|
+
scope,
|
|
1600
|
+
status: result.status,
|
|
1601
|
+
expectedToken: result.resumeToken,
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
else {
|
|
1605
|
+
playerTransactions.delete(binding.playerId);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
catch (error) {
|
|
1609
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
1610
|
+
if (hostResolved) {
|
|
1611
|
+
playerTransactions.set(binding.playerId, {
|
|
1612
|
+
phase: 'quarantined',
|
|
1613
|
+
frame,
|
|
1614
|
+
roleId,
|
|
1615
|
+
turnId: admittedTurn.id,
|
|
1616
|
+
signal,
|
|
1617
|
+
scope,
|
|
1618
|
+
reason: 'a late player result could not be validated',
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
else {
|
|
1622
|
+
playerTransactions.delete(binding.playerId);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
throw error;
|
|
1626
|
+
}
|
|
1627
|
+
return result;
|
|
1096
1628
|
},
|
|
1097
1629
|
callCaptain: async (prompt, signal, options) => {
|
|
1098
1630
|
admitHostBoundary();
|
|
@@ -1173,12 +1705,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1173
1705
|
},
|
|
1174
1706
|
});
|
|
1175
1707
|
// CAPTAIN-22: before dispatching to a playbook, request tmux-play
|
|
1176
|
-
// visibility for that playbook's
|
|
1708
|
+
// visibility for that playbook's explicitly bound session players. A pane
|
|
1177
1709
|
// reconciliation failure is display-only in tmux-play and does not
|
|
1178
1710
|
// reject; the legacy path carries no generated set and skips this.
|
|
1179
1711
|
const requestVisibility = async (frame) => {
|
|
1180
|
-
const ids = [...new Set([...frame.playerBindings.values()].map(({
|
|
1181
|
-
|
|
1712
|
+
const ids = [...new Set([...frame.playerBindings.values()].map(({ playerId }) => playerId))];
|
|
1713
|
+
// A roleless frame does not ask a non-empty host roster to show `[]`:
|
|
1714
|
+
// tmux-play reserves that value for a genuinely empty configured roster.
|
|
1715
|
+
if (ids.length === 0 || !activeContext)
|
|
1182
1716
|
return;
|
|
1183
1717
|
try {
|
|
1184
1718
|
await activeContext.setVisiblePlayers(ids);
|
|
@@ -1210,39 +1744,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1210
1744
|
: undefined;
|
|
1211
1745
|
return typeof stack === 'string' ? { ...compact, stack } : compact;
|
|
1212
1746
|
};
|
|
1213
|
-
const makePlayerBindings = (enablement
|
|
1214
|
-
|
|
1215
|
-
const playerBindings = new Map();
|
|
1216
|
-
for (const role of entry.requiredRoleIds) {
|
|
1217
|
-
let inherited;
|
|
1218
|
-
for (let ancestor = parent?.frame; ancestor && inherited === undefined; ancestor = ancestor.parent?.frame) {
|
|
1219
|
-
inherited = ancestor.playerBindings.get(role);
|
|
1220
|
-
}
|
|
1221
|
-
if (inherited) {
|
|
1222
|
-
playerBindings.set(role, inherited);
|
|
1223
|
-
continue;
|
|
1224
|
-
}
|
|
1225
|
-
const configured = enablement.boundPlayers.find((player) => player.id === role) ?? { id: role };
|
|
1226
|
-
playerBindings.set(role, {
|
|
1227
|
-
hostPlayerId: enablement.hostPlayerId(role),
|
|
1228
|
-
player: configured,
|
|
1229
|
-
});
|
|
1230
|
-
}
|
|
1231
|
-
return playerBindings;
|
|
1747
|
+
const makePlayerBindings = (enablement) => {
|
|
1748
|
+
return new Map(enablement.roleBindings);
|
|
1232
1749
|
};
|
|
1233
1750
|
const makeFrame = (enablement, parent) => {
|
|
1234
1751
|
const entry = enablement.entry;
|
|
1235
1752
|
const sessionId = allocateSessionId();
|
|
1236
|
-
const playerBindings = makePlayerBindings(enablement
|
|
1237
|
-
const
|
|
1238
|
-
const runtime = entry.createRuntime({
|
|
1239
|
-
captainOptions: enablement.optionInput,
|
|
1240
|
-
players: [...playerBindings].map(([role, { player }]) => ({
|
|
1241
|
-
id: role,
|
|
1242
|
-
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
1243
|
-
...(player.model === undefined ? {} : { model: player.model }),
|
|
1244
|
-
})),
|
|
1245
|
-
});
|
|
1753
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
1754
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
1246
1755
|
return {
|
|
1247
1756
|
entry,
|
|
1248
1757
|
enablement,
|
|
@@ -1251,22 +1760,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1251
1760
|
rootSessionId: parent?.frame.rootSessionId ?? sessionId,
|
|
1252
1761
|
depth: parent ? parent.frame.depth + 1 : 0,
|
|
1253
1762
|
playerBindings,
|
|
1254
|
-
playerResumeTokens,
|
|
1255
1763
|
...(parent ? { parent } : {}),
|
|
1256
1764
|
inFlightHostCalls: new Set(),
|
|
1257
1765
|
};
|
|
1258
1766
|
};
|
|
1259
|
-
const makeRestoredFrame = (enablement, snapshot,
|
|
1767
|
+
const makeRestoredFrame = (enablement, snapshot, parent) => {
|
|
1260
1768
|
const entry = enablement.entry;
|
|
1261
|
-
const playerBindings = makePlayerBindings(enablement
|
|
1262
|
-
const runtime = entry.createRuntime(
|
|
1263
|
-
captainOptions: enablement.optionInput,
|
|
1264
|
-
players: [...playerBindings].map(([role, { player }]) => ({
|
|
1265
|
-
id: role,
|
|
1266
|
-
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
1267
|
-
...(player.model === undefined ? {} : { model: player.model }),
|
|
1268
|
-
})),
|
|
1269
|
-
});
|
|
1769
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
1770
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
1270
1771
|
return {
|
|
1271
1772
|
entry,
|
|
1272
1773
|
enablement,
|
|
@@ -1275,7 +1776,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1275
1776
|
rootSessionId: snapshot.rootSessionId,
|
|
1276
1777
|
depth: snapshot.depth,
|
|
1277
1778
|
playerBindings,
|
|
1278
|
-
playerResumeTokens: rootPlayerResumeTokens,
|
|
1279
1779
|
...(parent ? { parent } : {}),
|
|
1280
1780
|
state: snapshot.runtime.state,
|
|
1281
1781
|
inFlightHostCalls: new Set(),
|
|
@@ -1284,36 +1784,141 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1284
1784
|
const playerSessionStore = (frame) => ({
|
|
1285
1785
|
select(playerId) {
|
|
1286
1786
|
const binding = bindingFor(frame, playerId);
|
|
1287
|
-
return
|
|
1787
|
+
return playerLedger.get(binding.playerId)?.resumeToken ?? false;
|
|
1288
1788
|
},
|
|
1289
1789
|
update(playerId, resumeToken) {
|
|
1290
1790
|
const binding = bindingFor(frame, playerId);
|
|
1291
|
-
|
|
1292
|
-
|
|
1791
|
+
const ledger = playerLedger.get(binding.playerId);
|
|
1792
|
+
if (!ledger) {
|
|
1793
|
+
throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
|
|
1293
1794
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1795
|
+
const pending = playerTransactions.get(binding.playerId);
|
|
1796
|
+
if (pending?.phase !== 'awaitingCommit' ||
|
|
1797
|
+
pending.frame !== frame ||
|
|
1798
|
+
pending.roleId !== playerId ||
|
|
1799
|
+
pending.scope !== frame.playerCallScope ||
|
|
1800
|
+
pending.expectedToken !== resumeToken) {
|
|
1801
|
+
throw new Error(`${frameLabel(frame)} player update does not acknowledge a validated host result`);
|
|
1802
|
+
}
|
|
1803
|
+
if (pending.signal.aborted || activeTurn?.id !== pending.turnId) {
|
|
1804
|
+
playerTransactions.set(binding.playerId, {
|
|
1805
|
+
phase: 'quarantined',
|
|
1806
|
+
frame: pending.frame,
|
|
1807
|
+
roleId: pending.roleId,
|
|
1808
|
+
turnId: pending.turnId,
|
|
1809
|
+
signal: pending.signal,
|
|
1810
|
+
scope: pending.scope,
|
|
1811
|
+
reason: 'the runtime aborted before committing a validated result',
|
|
1812
|
+
});
|
|
1813
|
+
throw new Error(`${frameLabel(frame)} rejected a late or aborted player continuation update`);
|
|
1814
|
+
}
|
|
1815
|
+
try {
|
|
1816
|
+
if (resumeToken === undefined)
|
|
1817
|
+
delete ledger.resumeToken;
|
|
1818
|
+
else
|
|
1819
|
+
ledger.resumeToken = resumeToken;
|
|
1820
|
+
// CAPTAIN-20: a result counts only after the runtime validated it and
|
|
1821
|
+
// atomically published its authorized continuation transition.
|
|
1822
|
+
const summary = activeTurnSummary;
|
|
1823
|
+
if (pending.status === 'ok' &&
|
|
1824
|
+
summary &&
|
|
1825
|
+
summaryIncludes(frame)) {
|
|
1826
|
+
summary.counts.interruptions++;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
finally {
|
|
1830
|
+
playerTransactions.delete(binding.playerId);
|
|
1296
1831
|
}
|
|
1297
1832
|
},
|
|
1298
1833
|
snapshot() {
|
|
1299
1834
|
const tokens = {};
|
|
1300
1835
|
for (const [playerId, binding] of frame.playerBindings) {
|
|
1301
|
-
const token =
|
|
1836
|
+
const token = playerLedger.get(binding.playerId)?.resumeToken;
|
|
1302
1837
|
if (token !== undefined)
|
|
1303
1838
|
tokens[playerId] = token;
|
|
1304
1839
|
}
|
|
1305
1840
|
return tokens;
|
|
1306
1841
|
},
|
|
1307
1842
|
restore(tokens) {
|
|
1308
|
-
|
|
1309
|
-
frame
|
|
1843
|
+
if (lifecycle !== 'restoring' ||
|
|
1844
|
+
restoringPlayerSessionFrame !== frame) {
|
|
1845
|
+
throw new Error(`${frameLabel(frame)} player-session restore is only available during shell restoration`);
|
|
1310
1846
|
}
|
|
1847
|
+
const byPlayer = new Map();
|
|
1311
1848
|
for (const [playerId, token] of Object.entries(tokens)) {
|
|
1312
1849
|
const binding = bindingFor(frame, playerId);
|
|
1313
|
-
|
|
1850
|
+
const previous = byPlayer.get(binding.playerId);
|
|
1851
|
+
if (previous !== undefined && previous !== token) {
|
|
1852
|
+
throw new Error(`${frameLabel(frame)} restored conflicting tokens for shared player ${JSON.stringify(binding.playerId)}`);
|
|
1853
|
+
}
|
|
1854
|
+
byPlayer.set(binding.playerId, token);
|
|
1855
|
+
}
|
|
1856
|
+
for (const binding of frame.playerBindings.values()) {
|
|
1857
|
+
if (!byPlayer.has(binding.playerId))
|
|
1858
|
+
byPlayer.set(binding.playerId, undefined);
|
|
1859
|
+
}
|
|
1860
|
+
for (const [playerId, token] of byPlayer) {
|
|
1861
|
+
const ledger = playerLedger.get(playerId);
|
|
1862
|
+
if (!ledger) {
|
|
1863
|
+
throw new Error(`${frameLabel(frame)} restored absent session player ${JSON.stringify(playerId)}`);
|
|
1864
|
+
}
|
|
1865
|
+
if (token === undefined)
|
|
1866
|
+
delete ledger.resumeToken;
|
|
1867
|
+
else
|
|
1868
|
+
ledger.resumeToken = token;
|
|
1314
1869
|
}
|
|
1315
1870
|
},
|
|
1316
1871
|
});
|
|
1872
|
+
const closePlayerCallScope = (frame, scope) => {
|
|
1873
|
+
if (frame.playerCallScope === scope)
|
|
1874
|
+
frame.playerCallScope = undefined;
|
|
1875
|
+
const missing = [];
|
|
1876
|
+
for (const [playerId, transaction] of playerTransactions) {
|
|
1877
|
+
if (transaction.frame !== frame || transaction.scope !== scope)
|
|
1878
|
+
continue;
|
|
1879
|
+
if (transaction.phase === 'calling') {
|
|
1880
|
+
transaction.abandoned = true;
|
|
1881
|
+
}
|
|
1882
|
+
else {
|
|
1883
|
+
playerTransactions.set(playerId, {
|
|
1884
|
+
phase: 'quarantined',
|
|
1885
|
+
frame: transaction.frame,
|
|
1886
|
+
roleId: transaction.roleId,
|
|
1887
|
+
turnId: transaction.turnId,
|
|
1888
|
+
signal: transaction.signal,
|
|
1889
|
+
scope: transaction.scope,
|
|
1890
|
+
reason: transaction.phase === 'awaitingCommit'
|
|
1891
|
+
? 'the runtime returned without committing a validated result'
|
|
1892
|
+
: transaction.reason,
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
if (!transaction.signal.aborted)
|
|
1896
|
+
missing.push(playerId);
|
|
1897
|
+
}
|
|
1898
|
+
return missing.length === 0
|
|
1899
|
+
? undefined
|
|
1900
|
+
: new Error(`${frameLabel(frame)} runtime returned without committing validated player result for ${missing.map((id) => JSON.stringify(id)).join(', ')}`);
|
|
1901
|
+
};
|
|
1902
|
+
const runFrameOperation = async (frame, operation) => {
|
|
1903
|
+
if (frame.playerCallScope !== undefined) {
|
|
1904
|
+
throw new Error(`${frameLabel(frame)} runtime operations must not overlap`);
|
|
1905
|
+
}
|
|
1906
|
+
const scope = {};
|
|
1907
|
+
frame.playerCallScope = scope;
|
|
1908
|
+
let outcome;
|
|
1909
|
+
try {
|
|
1910
|
+
outcome = { ok: true, value: await operation() };
|
|
1911
|
+
}
|
|
1912
|
+
catch (error) {
|
|
1913
|
+
outcome = { ok: false, error };
|
|
1914
|
+
}
|
|
1915
|
+
const cleanupError = closePlayerCallScope(frame, scope);
|
|
1916
|
+
if (!outcome.ok)
|
|
1917
|
+
throw outcome.error;
|
|
1918
|
+
if (cleanupError !== undefined)
|
|
1919
|
+
throw cleanupError;
|
|
1920
|
+
return outcome.value;
|
|
1921
|
+
};
|
|
1317
1922
|
const frameSession = (frame) => ({
|
|
1318
1923
|
sessionId: frame.sessionId,
|
|
1319
1924
|
playbookId: frame.entry.id,
|
|
@@ -1325,6 +1930,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1325
1930
|
}
|
|
1326
1931
|
: {}),
|
|
1327
1932
|
depth: frame.depth,
|
|
1933
|
+
roleBindings: Object.fromEntries([...frame.playerBindings].map(([roleId, binding]) => [
|
|
1934
|
+
roleId,
|
|
1935
|
+
{
|
|
1936
|
+
playerId: binding.playerId,
|
|
1937
|
+
promptIdentity: promptIdentity(binding),
|
|
1938
|
+
},
|
|
1939
|
+
])),
|
|
1328
1940
|
playerSessions: playerSessionStore(frame),
|
|
1329
1941
|
ports: createPorts(frame),
|
|
1330
1942
|
});
|
|
@@ -1609,7 +2221,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1609
2221
|
// exception filed against an effect that never ran.
|
|
1610
2222
|
await requestVisibility(frame);
|
|
1611
2223
|
await setMode('engaged.driving', 'submit');
|
|
1612
|
-
const result = await runEffect(() => frame.runtime.handleBossInput({ text, signal }));
|
|
2224
|
+
const result = await runFrameOperation(frame, () => runEffect(() => frame.runtime.handleBossInput({ text, signal })));
|
|
1613
2225
|
frame.state = result.state;
|
|
1614
2226
|
return result;
|
|
1615
2227
|
};
|
|
@@ -1655,11 +2267,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1655
2267
|
}
|
|
1656
2268
|
let result;
|
|
1657
2269
|
try {
|
|
1658
|
-
result = await runEffect(() => parent.runtime.resumePlaybookCall({
|
|
2270
|
+
result = await runFrameOperation(parent, () => runEffect(() => parent.runtime.resumePlaybookCall({
|
|
1659
2271
|
callId: parentLink.callId,
|
|
1660
2272
|
result: effectiveResult,
|
|
1661
2273
|
signal: context.signal,
|
|
1662
|
-
}));
|
|
2274
|
+
})));
|
|
1663
2275
|
}
|
|
1664
2276
|
catch (error) {
|
|
1665
2277
|
if (disposing || invocationSignal?.aborted)
|
|
@@ -2015,7 +2627,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2015
2627
|
stateDigestLine(view.state, view.stateDescription),
|
|
2016
2628
|
].join(': '));
|
|
2017
2629
|
lines.push(...leafContextLines(view.context));
|
|
2018
|
-
const pending = view.pendingQuestions.map((question) => digestLine `- (${quoteEvidence(question.questionId)}) ${quoteEvidence(question.
|
|
2630
|
+
const pending = view.pendingQuestions.map((question) => digestLine `- (${quoteEvidence(question.questionId)}) ${quoteEvidence(question.asker.kind === 'captain'
|
|
2631
|
+
? 'Captain'
|
|
2632
|
+
: question.asker.roleId)} asks: ${quoteEvidence(question.question)}`);
|
|
2019
2633
|
lines.push(pending.length === 0
|
|
2020
2634
|
? 'Pending Boss questions: none.'
|
|
2021
2635
|
: ['Pending Boss questions:', ...pending].join('\n'));
|
|
@@ -2111,6 +2725,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2111
2725
|
await trackTurnCall(settlement.context.emitReply(settlement.text));
|
|
2112
2726
|
}
|
|
2113
2727
|
catch (error) {
|
|
2728
|
+
conversation = { kind: 'needsSeeding' };
|
|
2114
2729
|
const normalized = normalizeErrorCompact(error) ?? {
|
|
2115
2730
|
name: 'Error',
|
|
2116
2731
|
message: String(error),
|
|
@@ -2207,8 +2822,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2207
2822
|
* text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
|
|
2208
2823
|
* the Captain may need to repeat.
|
|
2209
2824
|
*/
|
|
2210
|
-
const
|
|
2211
|
-
for (const record of
|
|
2825
|
+
const conversationDigest = (records, render) => {
|
|
2826
|
+
for (const record of records) {
|
|
2212
2827
|
if (record.kind === 'action' &&
|
|
2213
2828
|
typeof record.payload === 'object' &&
|
|
2214
2829
|
record.payload !== null &&
|
|
@@ -2218,13 +2833,34 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2218
2833
|
recordSuppliedIdentifier(actionId);
|
|
2219
2834
|
}
|
|
2220
2835
|
}
|
|
2221
|
-
return
|
|
2836
|
+
return render(records);
|
|
2222
2837
|
};
|
|
2838
|
+
const reseedDigest = () => conversationDigest(journal, renderReseedDigest);
|
|
2839
|
+
const catchUpDigest = (afterJournalSeq) => conversationDigest(journal.filter((record) => record.seq > afterJournalSeq), renderCatchUpDigest);
|
|
2223
2840
|
const markControlFailure = (error) => {
|
|
2224
|
-
|
|
2225
|
-
activeTurn.controlFailure = true;
|
|
2841
|
+
activeTurn?.controlFailures.add(error);
|
|
2226
2842
|
return error;
|
|
2227
2843
|
};
|
|
2844
|
+
const markSettingsRejection = (error) => markControlFailure(error);
|
|
2845
|
+
const rememberSettingsPreflight = (error) => {
|
|
2846
|
+
activeTurn?.settingsPreflightFailures.add(error);
|
|
2847
|
+
return markSettingsRejection(error);
|
|
2848
|
+
};
|
|
2849
|
+
const markConversationCatchUp = () => {
|
|
2850
|
+
if (conversation.kind === 'needsSeeding' || conversation.kind === 'needsCatchUp') {
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
conversation = {
|
|
2854
|
+
kind: 'needsCatchUp',
|
|
2855
|
+
resume: conversation.kind === 'pinned' ? conversation.token : false,
|
|
2856
|
+
afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
|
|
2857
|
+
};
|
|
2858
|
+
};
|
|
2859
|
+
const markConversationUnsynchronized = () => {
|
|
2860
|
+
if (conversation.kind !== 'needsCatchUp') {
|
|
2861
|
+
conversation = { kind: 'needsSeeding' };
|
|
2862
|
+
}
|
|
2863
|
+
};
|
|
2228
2864
|
/**
|
|
2229
2865
|
* CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
|
|
2230
2866
|
* engagement constructed, a stack disposed, an advertised action applied.
|
|
@@ -2270,14 +2906,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2270
2906
|
this.name = 'CaptainProseError';
|
|
2271
2907
|
}
|
|
2272
2908
|
}
|
|
2273
|
-
const rawDurableCall = async (context, prompt, resume) => {
|
|
2909
|
+
const rawDurableCall = async (context, prompt, resume, attempt) => {
|
|
2274
2910
|
const queued = captainQueue.add(async () => {
|
|
2275
2911
|
context.signal.throwIfAborted();
|
|
2276
|
-
|
|
2912
|
+
attempt.providerBoundaryEntered = true;
|
|
2913
|
+
const result = await classifySettingsCall(() => context.callCaptain(prompt, {
|
|
2277
2914
|
visibility: 'hidden',
|
|
2278
2915
|
resume,
|
|
2279
2916
|
...controlCallToolOptions(captainAdapter),
|
|
2280
|
-
|
|
2917
|
+
settings: callSettings(captainAgent),
|
|
2918
|
+
}));
|
|
2281
2919
|
context.signal.throwIfAborted();
|
|
2282
2920
|
return result;
|
|
2283
2921
|
});
|
|
@@ -2289,17 +2927,47 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2289
2927
|
// conversation that is owed a reseed carries the digest on its very next
|
|
2290
2928
|
// call, so the turn after a failed reseed starts seeded rather than blank.
|
|
2291
2929
|
const durableCall = async (context, compose) => {
|
|
2292
|
-
const
|
|
2930
|
+
const startingConversation = conversation;
|
|
2931
|
+
const resume = startingConversation.kind === 'pinned'
|
|
2932
|
+
? startingConversation.token
|
|
2933
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
2934
|
+
? startingConversation.resume
|
|
2935
|
+
: false;
|
|
2293
2936
|
const seedFirstCall = conversation.kind === 'needsSeeding';
|
|
2937
|
+
const catchUpFirstCall = conversation.kind === 'needsCatchUp';
|
|
2938
|
+
const representedJournalSeq = journalSeq;
|
|
2939
|
+
const firstAttempt = { providerBoundaryEntered: false };
|
|
2294
2940
|
let result;
|
|
2295
2941
|
let failure;
|
|
2296
2942
|
try {
|
|
2297
|
-
result = await rawDurableCall(context, compose(seedFirstCall
|
|
2943
|
+
result = await rawDurableCall(context, compose(seedFirstCall
|
|
2944
|
+
? { reseedDigest: reseedDigest() }
|
|
2945
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
2946
|
+
? {
|
|
2947
|
+
reseedDigest: catchUpDigest(startingConversation.afterJournalSeq),
|
|
2948
|
+
}
|
|
2949
|
+
: {}), resume, firstAttempt);
|
|
2298
2950
|
}
|
|
2299
2951
|
catch (error) {
|
|
2300
2952
|
if (context.signal.aborted) {
|
|
2301
|
-
|
|
2302
|
-
|
|
2953
|
+
if (firstAttempt.providerBoundaryEntered) {
|
|
2954
|
+
conversation = { kind: 'needsSeeding' };
|
|
2955
|
+
}
|
|
2956
|
+
else {
|
|
2957
|
+
markConversationUnsynchronized();
|
|
2958
|
+
}
|
|
2959
|
+
throw context.signal.reason ?? error;
|
|
2960
|
+
}
|
|
2961
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
2962
|
+
if (conversation.kind !== 'needsCatchUp' &&
|
|
2963
|
+
conversation.kind !== 'needsSeeding') {
|
|
2964
|
+
conversation = {
|
|
2965
|
+
kind: 'needsCatchUp',
|
|
2966
|
+
resume: conversation.kind === 'pinned' ? conversation.token : false,
|
|
2967
|
+
afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
2303
2971
|
}
|
|
2304
2972
|
failure = error;
|
|
2305
2973
|
}
|
|
@@ -2309,11 +2977,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2309
2977
|
result.resumeToken === undefined;
|
|
2310
2978
|
if (!unsynchronized) {
|
|
2311
2979
|
conversation = { kind: 'pinned', token: result.resumeToken };
|
|
2980
|
+
if (activeTurn) {
|
|
2981
|
+
activeTurn.captainSyncedJournalSeq = representedJournalSeq;
|
|
2982
|
+
}
|
|
2312
2983
|
return {
|
|
2313
2984
|
...(result.finalText !== undefined
|
|
2314
2985
|
? { finalText: result.finalText }
|
|
2315
2986
|
: {}),
|
|
2316
|
-
correctiveSpent: seedFirstCall,
|
|
2987
|
+
correctiveSpent: seedFirstCall || catchUpFirstCall,
|
|
2317
2988
|
};
|
|
2318
2989
|
}
|
|
2319
2990
|
// Only the model-side conversation is replaced: the stack, player
|
|
@@ -2323,13 +2994,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2323
2994
|
conversation = { kind: 'needsSeeding' };
|
|
2324
2995
|
const recap = reseedDigest();
|
|
2325
2996
|
let reissued;
|
|
2997
|
+
const reissueAttempt = { providerBoundaryEntered: false };
|
|
2326
2998
|
try {
|
|
2327
|
-
reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false);
|
|
2999
|
+
reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false, reissueAttempt);
|
|
2328
3000
|
}
|
|
2329
3001
|
catch (error) {
|
|
2330
3002
|
if (context.signal.aborted) {
|
|
2331
3003
|
conversation = { kind: 'needsSeeding' };
|
|
2332
|
-
throw error;
|
|
3004
|
+
throw context.signal.reason ?? error;
|
|
3005
|
+
}
|
|
3006
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
3007
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
2333
3008
|
}
|
|
2334
3009
|
throw markControlFailure(new CaptainContinuityError(error));
|
|
2335
3010
|
}
|
|
@@ -2338,6 +3013,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2338
3013
|
`callCaptain status "${reissued.status}" without a resume token`));
|
|
2339
3014
|
}
|
|
2340
3015
|
conversation = { kind: 'pinned', token: reissued.resumeToken };
|
|
3016
|
+
if (activeTurn)
|
|
3017
|
+
activeTurn.captainSyncedJournalSeq = journalSeq;
|
|
2341
3018
|
return {
|
|
2342
3019
|
...(reissued.finalText !== undefined
|
|
2343
3020
|
? { finalText: reissued.finalText }
|
|
@@ -2681,7 +3358,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2681
3358
|
message: String(error),
|
|
2682
3359
|
};
|
|
2683
3360
|
if (aborted) {
|
|
2684
|
-
|
|
3361
|
+
markConversationUnsynchronized();
|
|
2685
3362
|
if (turn?.outcomePending) {
|
|
2686
3363
|
turn.settlementFacts.push(`The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`);
|
|
2687
3364
|
journalOutcome(journalOutcomeEvidence(turn.settlementFacts, 'failed', turn.report));
|
|
@@ -3011,7 +3688,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3011
3688
|
// guard against re-execution — a repeated selection returns the recorded
|
|
3012
3689
|
// receipt rather than acting twice.
|
|
3013
3690
|
const key = `turn-${turn.id}-apply-${actionId}`;
|
|
3014
|
-
const outcome = await withCounting(leaf, async () => runEffect(() => leaf.runtime.apply({ actionId, key, signal })));
|
|
3691
|
+
const outcome = await withCounting(leaf, async () => runFrameOperation(leaf, () => runEffect(() => leaf.runtime.apply({ actionId, key, signal }))));
|
|
3015
3692
|
if (outcome.error !== undefined)
|
|
3016
3693
|
throw outcome.error;
|
|
3017
3694
|
const receipt = outcome.result;
|
|
@@ -3159,7 +3836,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3159
3836
|
// The durable Captain conversation did not receive the shell-authored
|
|
3160
3837
|
// fallback. Force its next call through the journal so it cannot interpret
|
|
3161
3838
|
// the Boss's follow-up without the reply the Boss was given this turn.
|
|
3162
|
-
|
|
3839
|
+
if (activeTurn?.settingsPreflightFailures.has(error)) {
|
|
3840
|
+
markConversationCatchUp();
|
|
3841
|
+
}
|
|
3842
|
+
else {
|
|
3843
|
+
markConversationUnsynchronized();
|
|
3844
|
+
}
|
|
3163
3845
|
// A rejected presentation may already have emitted bytes. It is therefore
|
|
3164
3846
|
// final for this turn even though the Promise did not prove it was shown.
|
|
3165
3847
|
if (activeTurn?.presentationAttempted === true)
|
|
@@ -3182,112 +3864,68 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3182
3864
|
playbookId: INTERNAL_CAPTAIN_ID,
|
|
3183
3865
|
rootSessionId: id,
|
|
3184
3866
|
depth: 0,
|
|
3867
|
+
roleBindings: {},
|
|
3185
3868
|
ports: captainPorts(),
|
|
3186
3869
|
});
|
|
3187
|
-
const
|
|
3870
|
+
const playerLedgerRecord = () => Object.fromEntries([...playerLedger].map(([playerId, entry]) => [
|
|
3871
|
+
playerId,
|
|
3872
|
+
{
|
|
3873
|
+
adapter: entry.adapter,
|
|
3874
|
+
...(entry.instruction === undefined
|
|
3875
|
+
? {}
|
|
3876
|
+
: { instruction: entry.instruction }),
|
|
3877
|
+
...(entry.permissions === undefined
|
|
3878
|
+
? {}
|
|
3879
|
+
: { permissions: entry.permissions }),
|
|
3880
|
+
...(entry.resumeToken === undefined
|
|
3881
|
+
? {}
|
|
3882
|
+
: { resumeToken: entry.resumeToken }),
|
|
3883
|
+
},
|
|
3884
|
+
]));
|
|
3188
3885
|
const assertSnapshotMatchesEnablements = (snapshot, enabled) => {
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3886
|
+
if (captainAgent === undefined ||
|
|
3887
|
+
!isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))) {
|
|
3888
|
+
throw new TypeError('Captain shell snapshot Captain agent is incompatible with current config');
|
|
3889
|
+
}
|
|
3890
|
+
const configuredPlayerIds = [...playerAgents.keys()].sort();
|
|
3891
|
+
const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
|
|
3892
|
+
if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
|
|
3893
|
+
throw new TypeError('Captain shell snapshot player ledger does not match current referenced players');
|
|
3894
|
+
}
|
|
3895
|
+
for (const playerId of configuredPlayerIds) {
|
|
3896
|
+
const saved = snapshot.playerSessions[playerId];
|
|
3897
|
+
const configured = playerAgents.get(playerId);
|
|
3898
|
+
const savedFixed = {
|
|
3899
|
+
adapter: saved.adapter,
|
|
3900
|
+
...(saved.instruction === undefined
|
|
3901
|
+
? {}
|
|
3902
|
+
: { instruction: saved.instruction }),
|
|
3903
|
+
...(saved.permissions === undefined
|
|
3904
|
+
? {}
|
|
3905
|
+
: { permissions: saved.permissions }),
|
|
3906
|
+
};
|
|
3907
|
+
if (!isDeepStrictEqual(savedFixed, fixedAgent(configured))) {
|
|
3908
|
+
throw new TypeError(`Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`);
|
|
3909
|
+
}
|
|
3205
3910
|
}
|
|
3206
3911
|
if (snapshot.mode === 'chat')
|
|
3207
3912
|
return;
|
|
3208
|
-
const
|
|
3209
|
-
const activeSessionIds = new Set([snapshot.captain.sessionId]);
|
|
3210
|
-
const issuedIds = new Set(snapshot.issuedSessionIds);
|
|
3211
|
-
const allowedHostPlayerIds = new Set();
|
|
3212
|
-
for (const enablement of enabled.values()) {
|
|
3213
|
-
for (const role of enablement.entry.requiredRoleIds) {
|
|
3214
|
-
allowedHostPlayerIds.add(enablement.hostPlayerId(role));
|
|
3215
|
-
}
|
|
3216
|
-
}
|
|
3217
|
-
for (const playerId of Object.keys(snapshot.rootPlayerResumeTokens)) {
|
|
3218
|
-
if (!allowedHostPlayerIds.has(playerId)) {
|
|
3219
|
-
throw new TypeError(`Captain shell snapshot root token names unknown host player ${JSON.stringify(playerId)}`);
|
|
3220
|
-
}
|
|
3221
|
-
}
|
|
3222
|
-
const bindingMaps = [];
|
|
3223
|
-
const rootSessionId = snapshot.frames[0].sessionId;
|
|
3224
|
-
for (const [index, frame] of snapshot.frames.entries()) {
|
|
3913
|
+
for (const frame of snapshot.frames) {
|
|
3225
3914
|
const enablement = enabled.get(frame.playbookId);
|
|
3226
3915
|
if (!enablement) {
|
|
3227
3916
|
throw new TypeError(`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`);
|
|
3228
3917
|
}
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
if (
|
|
3234
|
-
throw new TypeError(
|
|
3235
|
-
}
|
|
3236
|
-
activeSessionIds.add(frame.sessionId);
|
|
3237
|
-
if (!issuedIds.has(frame.sessionId)) {
|
|
3238
|
-
throw new TypeError('Captain shell snapshot frame session id was not historically issued');
|
|
3239
|
-
}
|
|
3240
|
-
if (frame.depth !== index ||
|
|
3241
|
-
frame.rootSessionId !== rootSessionId ||
|
|
3242
|
-
frame.runtime.state.status !== 'active' ||
|
|
3243
|
-
!frame.runtime.state.quiescent) {
|
|
3244
|
-
throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
|
|
3245
|
-
}
|
|
3246
|
-
if (index === 0) {
|
|
3247
|
-
if (frame.sessionId !== frame.rootSessionId ||
|
|
3248
|
-
frame.parentSessionId !== undefined ||
|
|
3249
|
-
frame.parentCallId !== undefined) {
|
|
3250
|
-
throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
|
|
3251
|
-
}
|
|
3252
|
-
}
|
|
3253
|
-
else {
|
|
3254
|
-
const parent = snapshot.frames[index - 1];
|
|
3255
|
-
if (frame.parentSessionId !== parent.sessionId ||
|
|
3256
|
-
frame.parentCallId === undefined) {
|
|
3257
|
-
throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
|
|
3258
|
-
}
|
|
3259
|
-
const pending = parent.runtime.suspendedCall;
|
|
3260
|
-
if (!pending ||
|
|
3261
|
-
pending.callId !== frame.parentCallId ||
|
|
3262
|
-
pending.playbookId !== frame.playbookId ||
|
|
3263
|
-
pending.childSessionId !== frame.sessionId) {
|
|
3264
|
-
throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
|
|
3265
|
-
}
|
|
3266
|
-
}
|
|
3267
|
-
const roleBindings = new Map();
|
|
3268
|
-
for (const role of enablement.entry.requiredRoleIds) {
|
|
3269
|
-
let inherited;
|
|
3270
|
-
for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
|
|
3271
|
-
inherited = bindingMaps[ancestor]?.get(role);
|
|
3272
|
-
if (inherited !== undefined)
|
|
3273
|
-
break;
|
|
3274
|
-
}
|
|
3275
|
-
roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
|
|
3918
|
+
const configuredBindings = Object.fromEntries([...enablement.roleBindings].map(([role, binding]) => [
|
|
3919
|
+
role,
|
|
3920
|
+
binding.playerId,
|
|
3921
|
+
]));
|
|
3922
|
+
if (!isDeepStrictEqual(frame.options, enablement.options)) {
|
|
3923
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`);
|
|
3276
3924
|
}
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
|
|
3280
|
-
return token === undefined ? [] : [[role, token]];
|
|
3281
|
-
}));
|
|
3282
|
-
if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
|
|
3283
|
-
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match root-owned continuation`);
|
|
3925
|
+
if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
|
|
3926
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`);
|
|
3284
3927
|
}
|
|
3285
3928
|
}
|
|
3286
|
-
const leafRuntime = snapshot.frames.at(-1).runtime;
|
|
3287
|
-
if (leafRuntime.suspendedCall !== undefined ||
|
|
3288
|
-
!leafRuntime.state.tags.includes('playbook.parked')) {
|
|
3289
|
-
throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
|
|
3290
|
-
}
|
|
3291
3929
|
};
|
|
3292
3930
|
const safeCapturePoint = () => {
|
|
3293
3931
|
if (lifecycle !== 'ready' ||
|
|
@@ -3305,6 +3943,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3305
3943
|
runFailureFacts !== undefined ||
|
|
3306
3944
|
servingCall !== undefined ||
|
|
3307
3945
|
decisionCall !== undefined ||
|
|
3946
|
+
playerTransactions.size !== 0 ||
|
|
3308
3947
|
captainQueue.pending !== 0 ||
|
|
3309
3948
|
captainQueue.size !== 0 ||
|
|
3310
3949
|
(mode !== 'chat' && mode !== 'engaged.parked')) {
|
|
@@ -3341,7 +3980,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3341
3980
|
});
|
|
3342
3981
|
};
|
|
3343
3982
|
const exportShellSnapshot = () => {
|
|
3344
|
-
if (!safeCapturePoint() ||
|
|
3983
|
+
if (!safeCapturePoint() ||
|
|
3984
|
+
!captainRuntime ||
|
|
3985
|
+
!captainSessionId ||
|
|
3986
|
+
!captainAgent) {
|
|
3345
3987
|
return undefined;
|
|
3346
3988
|
}
|
|
3347
3989
|
try {
|
|
@@ -3374,16 +4016,23 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3374
4016
|
parentCallId: frame.parent.callId,
|
|
3375
4017
|
}
|
|
3376
4018
|
: {}),
|
|
4019
|
+
options: frame.enablement.options,
|
|
4020
|
+
roleBindings: Object.fromEntries([...frame.playerBindings].map(([role, binding]) => [
|
|
4021
|
+
role,
|
|
4022
|
+
binding.playerId,
|
|
4023
|
+
])),
|
|
3377
4024
|
runtime,
|
|
3378
4025
|
});
|
|
3379
4026
|
}
|
|
3380
4027
|
const common = {
|
|
3381
|
-
schemaVersion:
|
|
4028
|
+
schemaVersion: 3,
|
|
3382
4029
|
captain: {
|
|
3383
4030
|
sessionId: captainSessionId,
|
|
3384
4031
|
runtime: captainSnapshot,
|
|
4032
|
+
agent: fixedAgent(captainAgent),
|
|
3385
4033
|
conversation,
|
|
3386
4034
|
},
|
|
4035
|
+
playerSessions: playerLedgerRecord(),
|
|
3387
4036
|
issuedSessionIds: [...issuedSessionIds],
|
|
3388
4037
|
sequences: { turn: turnSequence, journal: journalSeq },
|
|
3389
4038
|
journal,
|
|
@@ -3398,7 +4047,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3398
4047
|
...common,
|
|
3399
4048
|
mode: 'engaged.parked',
|
|
3400
4049
|
frames: frameSnapshots,
|
|
3401
|
-
rootPlayerResumeTokens: tokenRecord(rootFrame().playerResumeTokens),
|
|
3402
4050
|
...(pendingBossQuestions === undefined
|
|
3403
4051
|
? {}
|
|
3404
4052
|
: { pendingBossQuestions: pendingBossQuestions }),
|
|
@@ -3420,7 +4068,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3420
4068
|
const normalized = assertPlaybookRuntimeSnapshot(actual, playbookId, allowSuspendedCall ? { allowSuspendedCall: true } : {});
|
|
3421
4069
|
for (const key of [
|
|
3422
4070
|
'state',
|
|
3423
|
-
'
|
|
4071
|
+
'roleResumeTokens',
|
|
3424
4072
|
'sequences',
|
|
3425
4073
|
'pendingBossQuestions',
|
|
3426
4074
|
'suspendedCall',
|
|
@@ -3458,7 +4106,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3458
4106
|
byCommand = new Map();
|
|
3459
4107
|
byId = new Map();
|
|
3460
4108
|
enablementById = new Map();
|
|
3461
|
-
|
|
4109
|
+
captainAgent = undefined;
|
|
4110
|
+
captainAdapter = undefined;
|
|
4111
|
+
playerAgents = new Map();
|
|
4112
|
+
playerLedger.clear();
|
|
4113
|
+
playerTransactions.clear();
|
|
3462
4114
|
session = undefined;
|
|
3463
4115
|
sessionEmissionsOpen = false;
|
|
3464
4116
|
closedGateAttempted = false;
|
|
@@ -3492,17 +4144,30 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3492
4144
|
lifecycle = 'restoring';
|
|
3493
4145
|
try {
|
|
3494
4146
|
const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
|
|
3495
|
-
const built = await buildEnablements(options,
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
4147
|
+
const built = await buildEnablements(options, loadModule);
|
|
4148
|
+
captainAgent = built.captainAgent;
|
|
4149
|
+
captainAdapter = captainAgent.adapter;
|
|
4150
|
+
playerAgents = built.playerAgents;
|
|
3499
4151
|
assertSnapshotMatchesEnablements(snapshot, built.enablementById);
|
|
3500
4152
|
installSession(initSession, false);
|
|
3501
|
-
players = initSession.players;
|
|
3502
4153
|
entries = built.entries;
|
|
3503
4154
|
byCommand = built.byCommand;
|
|
3504
4155
|
byId = built.byId;
|
|
3505
4156
|
enablementById = built.enablementById;
|
|
4157
|
+
for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
|
|
4158
|
+
playerLedger.set(playerId, {
|
|
4159
|
+
adapter: saved.adapter,
|
|
4160
|
+
...(saved.instruction === undefined
|
|
4161
|
+
? {}
|
|
4162
|
+
: { instruction: saved.instruction }),
|
|
4163
|
+
...(saved.permissions === undefined
|
|
4164
|
+
? {}
|
|
4165
|
+
: { permissions: livePermissions(saved.permissions) }),
|
|
4166
|
+
...(saved.resumeToken === undefined
|
|
4167
|
+
? {}
|
|
4168
|
+
: { resumeToken: saved.resumeToken }),
|
|
4169
|
+
});
|
|
4170
|
+
}
|
|
3506
4171
|
captainRuntime = createCaptainRuntime({
|
|
3507
4172
|
enabledPlaybooks: enabledCatalog(),
|
|
3508
4173
|
controller,
|
|
@@ -3511,10 +4176,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3511
4176
|
throw new Error('session Captain runtime does not support restore');
|
|
3512
4177
|
}
|
|
3513
4178
|
if (snapshot.mode === 'engaged.parked') {
|
|
3514
|
-
const rootTokens = new Map(Object.entries(snapshot.rootPlayerResumeTokens));
|
|
3515
4179
|
for (const [index, frameSnapshot] of snapshot.frames.entries()) {
|
|
3516
4180
|
const parentFrame = frames.at(-1);
|
|
3517
|
-
const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot,
|
|
4181
|
+
const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, index === 0
|
|
3518
4182
|
? undefined
|
|
3519
4183
|
: {
|
|
3520
4184
|
frame: parentFrame,
|
|
@@ -3531,7 +4195,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3531
4195
|
await captainRuntime.restore(captainPlaybookSession(snapshot.captain.sessionId), snapshot.captain.runtime);
|
|
3532
4196
|
if (snapshot.mode === 'engaged.parked') {
|
|
3533
4197
|
for (const [index, frame] of frames.entries()) {
|
|
3534
|
-
|
|
4198
|
+
restoringPlayerSessionFrame = frame;
|
|
4199
|
+
try {
|
|
4200
|
+
await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
|
|
4201
|
+
}
|
|
4202
|
+
finally {
|
|
4203
|
+
restoringPlayerSessionFrame = undefined;
|
|
4204
|
+
}
|
|
3535
4205
|
}
|
|
3536
4206
|
}
|
|
3537
4207
|
if (closedGateAttempted) {
|
|
@@ -3542,8 +4212,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3542
4212
|
for (const [index, frame] of frames.entries()) {
|
|
3543
4213
|
verifyRestoredRuntime(frame.runtime, snapshot.frames[index].runtime, frame.entry.id, true);
|
|
3544
4214
|
}
|
|
3545
|
-
if (!isDeepStrictEqual(
|
|
3546
|
-
throw new Error('restored
|
|
4215
|
+
if (!isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)) {
|
|
4216
|
+
throw new Error('restored Captain-session player continuation changed during restore');
|
|
3547
4217
|
}
|
|
3548
4218
|
}
|
|
3549
4219
|
if (closedGateAttempted) {
|
|
@@ -3589,14 +4259,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3589
4259
|
lifecycle = 'initializing';
|
|
3590
4260
|
try {
|
|
3591
4261
|
installSession(initSession, true);
|
|
3592
|
-
|
|
3593
|
-
const built = await buildEnablements(options, players, loadModule);
|
|
4262
|
+
const built = await buildEnablements(options, loadModule);
|
|
3594
4263
|
entries = built.entries;
|
|
3595
4264
|
byCommand = built.byCommand;
|
|
3596
4265
|
byId = built.byId;
|
|
3597
4266
|
enablementById = built.enablementById;
|
|
3598
|
-
|
|
3599
|
-
|
|
4267
|
+
captainAgent = built.captainAgent;
|
|
4268
|
+
captainAdapter = captainAgent.adapter;
|
|
4269
|
+
playerAgents = built.playerAgents;
|
|
4270
|
+
for (const [playerId, agent] of playerAgents) {
|
|
4271
|
+
playerLedger.set(playerId, fixedAgent(agent));
|
|
3600
4272
|
}
|
|
3601
4273
|
await setMode('chat', 'init');
|
|
3602
4274
|
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
@@ -3638,6 +4310,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3638
4310
|
const parsed = resolveCommandTurn(turn.prompt);
|
|
3639
4311
|
activeTurn = {
|
|
3640
4312
|
id: ++turnSequence,
|
|
4313
|
+
captainSyncedJournalSeq: journalSeq,
|
|
3641
4314
|
bossText: turn.prompt,
|
|
3642
4315
|
authoritativeText: parsed?.authoritativeText ?? turn.prompt,
|
|
3643
4316
|
...(parsed ? { resolution: parsed.resolution } : {}),
|
|
@@ -3645,6 +4318,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3645
4318
|
presentationAttempted: false,
|
|
3646
4319
|
settlementFacts: [],
|
|
3647
4320
|
effectThrows: new Set(),
|
|
4321
|
+
controlFailures: new Set(),
|
|
4322
|
+
settingsPreflightFailures: new Set(),
|
|
3648
4323
|
suppliedIdentifiers: new Set(),
|
|
3649
4324
|
outcomeRecorded: false,
|
|
3650
4325
|
};
|
|
@@ -3663,11 +4338,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3663
4338
|
new Error('the session Captain turn failed at its boundary'));
|
|
3664
4339
|
}
|
|
3665
4340
|
else if (result.outcome === 'aborted') {
|
|
3666
|
-
|
|
4341
|
+
markConversationUnsynchronized();
|
|
3667
4342
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
3668
4343
|
activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
|
|
3669
4344
|
journalOutcome([...activeTurn.settlementFacts]);
|
|
3670
4345
|
}
|
|
4346
|
+
if (context.signal.aborted) {
|
|
4347
|
+
throw context.signal.reason;
|
|
4348
|
+
}
|
|
3671
4349
|
}
|
|
3672
4350
|
else if (result.outcome !== 'suspended' &&
|
|
3673
4351
|
!context.signal.aborted &&
|
|
@@ -3681,10 +4359,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3681
4359
|
}
|
|
3682
4360
|
catch (error) {
|
|
3683
4361
|
if (context.signal.aborted) {
|
|
3684
|
-
|
|
4362
|
+
markConversationUnsynchronized();
|
|
3685
4363
|
throw error;
|
|
3686
4364
|
}
|
|
3687
|
-
const controlFailure = activeTurn?.
|
|
4365
|
+
const controlFailure = activeTurn?.controlFailures.has(error) === true;
|
|
3688
4366
|
await settleTurnFailure(context, error);
|
|
3689
4367
|
if (activeTurn?.presentationError !== undefined) {
|
|
3690
4368
|
throw activeTurn.presentationError;
|
|
@@ -3696,7 +4374,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3696
4374
|
}
|
|
3697
4375
|
finally {
|
|
3698
4376
|
if (context.signal.aborted) {
|
|
3699
|
-
|
|
4377
|
+
markConversationUnsynchronized();
|
|
3700
4378
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
3701
4379
|
activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
|
|
3702
4380
|
journalOutcome([...activeTurn.settlementFacts]);
|
|
@@ -3750,6 +4428,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3750
4428
|
failure ??= error;
|
|
3751
4429
|
}
|
|
3752
4430
|
}
|
|
4431
|
+
// Quarantine is session-wide by design. Only terminal teardown may drop
|
|
4432
|
+
// its ownership after every frame host call and the Captain are drained.
|
|
4433
|
+
playerTransactions.clear();
|
|
3753
4434
|
lifecycle = 'closed';
|
|
3754
4435
|
if (failure !== undefined)
|
|
3755
4436
|
throw failure;
|