@sublang/playbook 6.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 +28 -11
- package/docs/cli.md +158 -68
- package/docs/configuration.md +246 -108
- package/docs/embedding.md +71 -25
- package/package.json +6 -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 +1900 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1164 -991
- package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
- 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 +101 -9
- package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
- package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
- 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 +244 -143
- package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
- 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 +140 -97
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +24 -8
- package/src/runtime.ts +29 -13
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +301 -159
- package/src/xstate-playbook-runtime.ts +405 -186
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +403 -62
- package/src/xstate-runtime.ts +566 -78
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
5
|
import PQueue from 'p-queue';
|
|
5
|
-
import {
|
|
6
|
+
import { isAgentCallSettingsError, } from '@sublang/cligent/tmux-play';
|
|
7
|
+
import { assertPlaybookRuntimeSnapshot, hiddenControlEnvelope, registerPlaybookAbortCleanup, snapshotJsonValue, validatePlayerResult, } from '../../../src/xstate-runtime.js';
|
|
6
8
|
import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
|
|
7
9
|
class VisibilityControlError extends Error {
|
|
8
10
|
constructor(cause) {
|
|
@@ -10,10 +12,33 @@ class VisibilityControlError extends Error {
|
|
|
10
12
|
this.name = 'VisibilityControlError';
|
|
11
13
|
}
|
|
12
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
|
+
}
|
|
13
36
|
const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
|
|
14
37
|
const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
|
|
15
38
|
const INTERNAL_CAPTAIN_ID = 'captain';
|
|
16
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_-]*$/;
|
|
17
42
|
function parseRegisteredCommand(prompt) {
|
|
18
43
|
const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(prompt.trim());
|
|
19
44
|
if (!match)
|
|
@@ -126,7 +151,14 @@ function pendingQuestionLines(pending) {
|
|
|
126
151
|
: typeof record.id === 'string'
|
|
127
152
|
? record.id
|
|
128
153
|
: undefined;
|
|
129
|
-
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;
|
|
130
162
|
const text = typeof record.question === 'string'
|
|
131
163
|
? record.question
|
|
132
164
|
: typeof record.text === 'string'
|
|
@@ -136,9 +168,9 @@ function pendingQuestionLines(pending) {
|
|
|
136
168
|
// fragment is never handed back to the tag as a value: bounding it a
|
|
137
169
|
// second time would cut the line at the seam's limit and drop whatever
|
|
138
170
|
// the shell had already written after the long part.
|
|
139
|
-
const asked =
|
|
171
|
+
const asked = askerLabel === undefined
|
|
140
172
|
? digestLine `${quoteEvidence(text)}`
|
|
141
|
-
: digestLine `${quoteEvidence(
|
|
173
|
+
: digestLine `${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
|
|
142
174
|
const marker = id === undefined ? '' : digestLine `(${quoteEvidence(id)}) `;
|
|
143
175
|
lines.push(`- ${marker}${asked}`);
|
|
144
176
|
}
|
|
@@ -178,14 +210,20 @@ function renderJournalPayload(payload) {
|
|
|
178
210
|
const raw = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
179
211
|
return raw ?? 'null';
|
|
180
212
|
}
|
|
181
|
-
function
|
|
213
|
+
function renderJournalDigest(records, heading) {
|
|
182
214
|
const lines = records.map((record) => `${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(record.payload)}`);
|
|
183
215
|
return [
|
|
184
|
-
|
|
216
|
+
heading,
|
|
185
217
|
'The labeled ControlView and catalog digest blocks outrank conversation memory.',
|
|
186
218
|
...(lines.length === 0 ? ['(no earlier turns)'] : lines),
|
|
187
219
|
].join('\n');
|
|
188
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
|
+
}
|
|
189
227
|
// DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
|
|
190
228
|
// internal control vocabulary.
|
|
191
229
|
const CONTROL_VOCABULARY = [
|
|
@@ -298,22 +336,31 @@ function proseRejection(prose, liveSessionIds = [], liveStateIds = [], suppliedI
|
|
|
298
336
|
return undefined;
|
|
299
337
|
}
|
|
300
338
|
// DR-013 A1: adapters with no provider-enforced tool-restriction surface.
|
|
301
|
-
// Cligent's Codex
|
|
302
|
-
// empty list that expresses tool-free — because
|
|
303
|
-
// cannot enforce one, so requesting it fails
|
|
304
|
-
// model is reached. Omitting the option is the
|
|
305
|
-
// run a control call at all; its isolation then
|
|
306
|
-
// hidden-judge envelope below rather than on provider
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
+
}
|
|
310
357
|
// The tool half of a control call's options. An empty allowlist means "no
|
|
311
358
|
// tools available" and is distinct from omission, which grants the adapter's
|
|
312
359
|
// full native tool surface — so omit only where the empty list would be
|
|
313
360
|
// refused, and keep requesting enforcement whenever the adapter is unknown.
|
|
314
361
|
function controlCallToolOptions(captainAdapter) {
|
|
315
362
|
if (captainAdapter !== undefined &&
|
|
316
|
-
|
|
363
|
+
requiresPromptOnlyToolIsolation(captainAdapter)) {
|
|
317
364
|
return {};
|
|
318
365
|
}
|
|
319
366
|
return { allowedTools: [] };
|
|
@@ -329,14 +376,6 @@ function forwardedToolOptions(requested, captainAdapter) {
|
|
|
329
376
|
return controlCallToolOptions(captainAdapter);
|
|
330
377
|
return { allowedTools: requested };
|
|
331
378
|
}
|
|
332
|
-
function readCaptainAdapter(options) {
|
|
333
|
-
if (typeof options !== 'object' || options === null)
|
|
334
|
-
return undefined;
|
|
335
|
-
const adapter = options.captainAdapter;
|
|
336
|
-
return typeof adapter === 'string' && adapter.length > 0
|
|
337
|
-
? adapter
|
|
338
|
-
: undefined;
|
|
339
|
-
}
|
|
340
379
|
const hiddenJudgeEnvelope = hiddenControlEnvelope;
|
|
341
380
|
// CAPTAIN-20: the result-phase block the shell supplies inside the closing
|
|
342
381
|
// reply call's envelope — the settlement's outcome-report facts verbatim, the
|
|
@@ -407,34 +446,653 @@ function isValidRegistryEntry(value) {
|
|
|
407
446
|
if (typeof value !== 'object' || value === null)
|
|
408
447
|
return false;
|
|
409
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
|
+
}
|
|
410
467
|
return (typeof e.id === 'string' &&
|
|
411
468
|
typeof e.command === 'string' &&
|
|
412
469
|
typeof e.intent === 'string' &&
|
|
413
|
-
|
|
470
|
+
e.artifactSchema === 2 &&
|
|
414
471
|
typeof e.validateOptions === 'function' &&
|
|
415
472
|
typeof e.createRuntime === 'function');
|
|
416
473
|
}
|
|
417
|
-
|
|
418
|
-
|
|
474
|
+
const SNAPSHOT_ACTIONS = new Set([
|
|
475
|
+
'respond',
|
|
476
|
+
'start',
|
|
477
|
+
'switch',
|
|
478
|
+
'dismiss',
|
|
479
|
+
'deliver',
|
|
480
|
+
'runtime',
|
|
481
|
+
]);
|
|
482
|
+
const SNAPSHOT_SETTLEMENT_STATUSES = new Set([
|
|
483
|
+
'ok',
|
|
484
|
+
'rejected',
|
|
485
|
+
'failed',
|
|
486
|
+
]);
|
|
487
|
+
const SNAPSHOT_JOURNAL_KINDS = new Set([
|
|
488
|
+
'boss',
|
|
489
|
+
'reply',
|
|
490
|
+
'handoff',
|
|
491
|
+
'action',
|
|
492
|
+
'outcome',
|
|
493
|
+
]);
|
|
494
|
+
function snapshotRecord(value, path) {
|
|
495
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
496
|
+
throw new TypeError(`${path} must be an object`);
|
|
497
|
+
}
|
|
498
|
+
return value;
|
|
499
|
+
}
|
|
500
|
+
function rejectSnapshotKeys(value, allowed, path) {
|
|
501
|
+
const allowedKeys = new Set(allowed);
|
|
502
|
+
const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
|
|
503
|
+
if (unknown.length > 0) {
|
|
504
|
+
throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown[0])}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function snapshotString(value, path, allowEmpty = false) {
|
|
508
|
+
if (typeof value !== 'string' ||
|
|
509
|
+
(!allowEmpty && value.trim().length === 0)) {
|
|
510
|
+
throw new TypeError(`${path} must be a ${allowEmpty ? '' : 'non-empty '}string`);
|
|
511
|
+
}
|
|
512
|
+
return value;
|
|
513
|
+
}
|
|
514
|
+
function snapshotInteger(value, path, minimum = 0) {
|
|
515
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
516
|
+
throw new TypeError(`${path} must be an integer >= ${minimum}`);
|
|
517
|
+
}
|
|
518
|
+
return value;
|
|
519
|
+
}
|
|
520
|
+
function snapshotUuid(value, path) {
|
|
521
|
+
const id = snapshotString(value, path);
|
|
522
|
+
if (!UUID_PATTERN.test(id)) {
|
|
523
|
+
throw new TypeError(`${path} must be a UUID`);
|
|
524
|
+
}
|
|
525
|
+
return id;
|
|
526
|
+
}
|
|
527
|
+
function snapshotPermissions(value, path) {
|
|
528
|
+
if (value === undefined)
|
|
419
529
|
return undefined;
|
|
420
|
-
const
|
|
421
|
-
|
|
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)
|
|
422
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
|
+
}
|
|
677
|
+
/** Validate, detach, and freeze one untrusted shell snapshot. */
|
|
678
|
+
export function assertPlaybookCaptainShellSnapshot(value) {
|
|
679
|
+
const detached = snapshotJsonValue(value, 'Captain shell snapshot');
|
|
680
|
+
const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
|
|
681
|
+
const mode = snapshot.mode;
|
|
682
|
+
const commonKeys = [
|
|
683
|
+
'schemaVersion',
|
|
684
|
+
'captain',
|
|
685
|
+
'playerSessions',
|
|
686
|
+
'issuedSessionIds',
|
|
687
|
+
'sequences',
|
|
688
|
+
'journal',
|
|
689
|
+
'lastAction',
|
|
690
|
+
'lastSettlementStatus',
|
|
691
|
+
'mode',
|
|
692
|
+
];
|
|
693
|
+
if (mode === 'chat') {
|
|
694
|
+
rejectSnapshotKeys(snapshot, commonKeys, 'Captain shell snapshot');
|
|
695
|
+
}
|
|
696
|
+
else if (mode === 'engaged.parked') {
|
|
697
|
+
rejectSnapshotKeys(snapshot, [
|
|
698
|
+
...commonKeys,
|
|
699
|
+
'frames',
|
|
700
|
+
'pendingBossQuestions',
|
|
701
|
+
'lastError',
|
|
702
|
+
], 'Captain shell snapshot');
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
throw new TypeError('Captain shell snapshot.mode must be "chat" or "engaged.parked"');
|
|
706
|
+
}
|
|
707
|
+
if (snapshot.schemaVersion !== 3) {
|
|
708
|
+
throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
|
|
709
|
+
}
|
|
710
|
+
const captain = snapshotRecord(snapshot.captain, 'Captain shell snapshot.captain');
|
|
711
|
+
rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'agent', 'conversation'], 'Captain shell snapshot.captain');
|
|
712
|
+
const captainSessionId = snapshotUuid(captain.sessionId, 'Captain shell snapshot.captain.sessionId');
|
|
713
|
+
const captainRuntime = assertPlaybookRuntimeSnapshot(captain.runtime, INTERNAL_CAPTAIN_ID);
|
|
714
|
+
const captainAgent = snapshotFixedAgent(captain.agent, 'Captain shell snapshot.captain.agent');
|
|
715
|
+
const conversation = snapshotRecord(captain.conversation, 'Captain shell snapshot.captain.conversation');
|
|
716
|
+
let normalizedConversation;
|
|
717
|
+
if (conversation.kind === 'pinned') {
|
|
718
|
+
rejectSnapshotKeys(conversation, ['kind', 'token'], 'Captain shell snapshot.captain.conversation');
|
|
719
|
+
normalizedConversation = {
|
|
720
|
+
kind: 'pinned',
|
|
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'),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
else if (conversation.kind === 'unopened' ||
|
|
735
|
+
conversation.kind === 'needsSeeding') {
|
|
736
|
+
rejectSnapshotKeys(conversation, ['kind'], 'Captain shell snapshot.captain.conversation');
|
|
737
|
+
normalizedConversation = { kind: conversation.kind };
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
throw new TypeError('Captain shell snapshot.captain.conversation.kind is not supported');
|
|
741
|
+
}
|
|
742
|
+
if (!Array.isArray(snapshot.issuedSessionIds)) {
|
|
743
|
+
throw new TypeError('Captain shell snapshot.issuedSessionIds must be an array');
|
|
744
|
+
}
|
|
745
|
+
const issued = snapshot.issuedSessionIds.map((id, index) => snapshotUuid(id, `Captain shell snapshot.issuedSessionIds[${index}]`));
|
|
746
|
+
if (new Set(issued).size !== issued.length) {
|
|
747
|
+
throw new TypeError('Captain shell snapshot.issuedSessionIds must not contain duplicates');
|
|
748
|
+
}
|
|
749
|
+
if (issued[0] !== captainSessionId) {
|
|
750
|
+
throw new TypeError('Captain shell snapshot Captain session id must be the first issued id');
|
|
751
|
+
}
|
|
752
|
+
const sequences = snapshotRecord(snapshot.sequences, 'Captain shell snapshot.sequences');
|
|
753
|
+
rejectSnapshotKeys(sequences, ['turn', 'journal'], 'Captain shell snapshot.sequences');
|
|
754
|
+
const turnSequence = snapshotInteger(sequences.turn, 'Captain shell snapshot.sequences.turn');
|
|
755
|
+
const journalSequence = snapshotInteger(sequences.journal, 'Captain shell snapshot.sequences.journal');
|
|
756
|
+
if (!Array.isArray(snapshot.journal)) {
|
|
757
|
+
throw new TypeError('Captain shell snapshot.journal must be an array');
|
|
758
|
+
}
|
|
759
|
+
const normalizedJournal = [];
|
|
760
|
+
let previousTurn = 0;
|
|
761
|
+
let bossRecords = 0;
|
|
762
|
+
for (const [index, value] of snapshot.journal.entries()) {
|
|
763
|
+
const record = snapshotRecord(value, `Captain shell snapshot.journal[${index}]`);
|
|
764
|
+
rejectSnapshotKeys(record, ['seq', 'turnId', 'kind', 'payload'], `Captain shell snapshot.journal[${index}]`);
|
|
765
|
+
const seq = snapshotInteger(record.seq, `Captain shell snapshot.journal[${index}].seq`, 1);
|
|
766
|
+
if (seq !== index + 1) {
|
|
767
|
+
throw new TypeError('Captain shell snapshot journal sequence must be contiguous from one');
|
|
768
|
+
}
|
|
769
|
+
const turnId = snapshotInteger(record.turnId, `Captain shell snapshot.journal[${index}].turnId`, 1);
|
|
770
|
+
if (turnId < previousTurn || turnId > turnSequence) {
|
|
771
|
+
throw new TypeError('Captain shell snapshot journal turn ids must be ordered and in range');
|
|
772
|
+
}
|
|
773
|
+
const kind = record.kind;
|
|
774
|
+
if (typeof kind !== 'string' ||
|
|
775
|
+
!SNAPSHOT_JOURNAL_KINDS.has(kind)) {
|
|
776
|
+
throw new TypeError(`Captain shell snapshot.journal[${index}].kind is not supported`);
|
|
777
|
+
}
|
|
778
|
+
if (turnId !== previousTurn) {
|
|
779
|
+
if (turnId !== previousTurn + 1 || kind !== 'boss') {
|
|
780
|
+
throw new TypeError('Captain shell snapshot journal must begin every turn with one boss record');
|
|
781
|
+
}
|
|
782
|
+
bossRecords++;
|
|
783
|
+
previousTurn = turnId;
|
|
784
|
+
}
|
|
785
|
+
else if (kind === 'boss') {
|
|
786
|
+
throw new TypeError('Captain shell snapshot journal must contain one boss record per turn');
|
|
787
|
+
}
|
|
788
|
+
normalizedJournal.push({
|
|
789
|
+
seq,
|
|
790
|
+
turnId,
|
|
791
|
+
kind: kind,
|
|
792
|
+
payload: record.payload,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
if (journalSequence !== normalizedJournal.length ||
|
|
796
|
+
bossRecords !== turnSequence) {
|
|
797
|
+
throw new TypeError('Captain shell snapshot sequences do not match the complete journal');
|
|
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
|
+
}
|
|
812
|
+
let lastAction;
|
|
813
|
+
if (snapshot.lastAction !== undefined) {
|
|
814
|
+
if (typeof snapshot.lastAction !== 'string' ||
|
|
815
|
+
!SNAPSHOT_ACTIONS.has(snapshot.lastAction)) {
|
|
816
|
+
throw new TypeError('Captain shell snapshot.lastAction is not supported');
|
|
817
|
+
}
|
|
818
|
+
lastAction = snapshot.lastAction;
|
|
819
|
+
}
|
|
820
|
+
let lastSettlementStatus;
|
|
821
|
+
if (snapshot.lastSettlementStatus !== undefined) {
|
|
822
|
+
if (typeof snapshot.lastSettlementStatus !== 'string' ||
|
|
823
|
+
!SNAPSHOT_SETTLEMENT_STATUSES.has(snapshot.lastSettlementStatus)) {
|
|
824
|
+
throw new TypeError('Captain shell snapshot.lastSettlementStatus is not supported');
|
|
825
|
+
}
|
|
826
|
+
lastSettlementStatus = snapshot.lastSettlementStatus;
|
|
827
|
+
}
|
|
828
|
+
const playerSessions = snapshotPlayerSessions(snapshot.playerSessions, 'Captain shell snapshot.playerSessions');
|
|
829
|
+
const common = {
|
|
830
|
+
schemaVersion: 3,
|
|
831
|
+
captain: {
|
|
832
|
+
sessionId: captainSessionId,
|
|
833
|
+
runtime: captainRuntime,
|
|
834
|
+
agent: captainAgent,
|
|
835
|
+
conversation: normalizedConversation,
|
|
836
|
+
},
|
|
837
|
+
playerSessions,
|
|
838
|
+
issuedSessionIds: issued,
|
|
839
|
+
sequences: { turn: turnSequence, journal: journalSequence },
|
|
840
|
+
journal: normalizedJournal,
|
|
841
|
+
...(lastAction === undefined ? {} : { lastAction }),
|
|
842
|
+
...(lastSettlementStatus === undefined
|
|
843
|
+
? {}
|
|
844
|
+
: { lastSettlementStatus }),
|
|
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
|
+
}
|
|
857
|
+
if (mode === 'chat') {
|
|
858
|
+
return snapshotJsonValue({ ...common, mode }, 'Captain shell snapshot');
|
|
859
|
+
}
|
|
860
|
+
if (!Array.isArray(snapshot.frames) || snapshot.frames.length === 0) {
|
|
861
|
+
throw new TypeError('Captain shell snapshot.frames must be a non-empty array');
|
|
862
|
+
}
|
|
863
|
+
const normalizedFrames = [];
|
|
864
|
+
for (const [index, value] of snapshot.frames.entries()) {
|
|
865
|
+
const frame = snapshotRecord(value, `Captain shell snapshot.frames[${index}]`);
|
|
866
|
+
rejectSnapshotKeys(frame, [
|
|
867
|
+
'playbookId',
|
|
868
|
+
'sessionId',
|
|
869
|
+
'rootSessionId',
|
|
870
|
+
'depth',
|
|
871
|
+
'parentSessionId',
|
|
872
|
+
'parentCallId',
|
|
873
|
+
'options',
|
|
874
|
+
'roleBindings',
|
|
875
|
+
'runtime',
|
|
876
|
+
], `Captain shell snapshot.frames[${index}]`);
|
|
877
|
+
const playbookId = snapshotString(frame.playbookId, `Captain shell snapshot.frames[${index}].playbookId`);
|
|
878
|
+
const sessionId = snapshotUuid(frame.sessionId, `Captain shell snapshot.frames[${index}].sessionId`);
|
|
879
|
+
const rootSessionId = snapshotUuid(frame.rootSessionId, `Captain shell snapshot.frames[${index}].rootSessionId`);
|
|
880
|
+
const depth = snapshotInteger(frame.depth, `Captain shell snapshot.frames[${index}].depth`);
|
|
881
|
+
const parentSessionId = frame.parentSessionId === undefined
|
|
882
|
+
? undefined
|
|
883
|
+
: snapshotUuid(frame.parentSessionId, `Captain shell snapshot.frames[${index}].parentSessionId`);
|
|
884
|
+
const parentCallId = frame.parentCallId === undefined
|
|
885
|
+
? undefined
|
|
886
|
+
: snapshotString(frame.parentCallId, `Captain shell snapshot.frames[${index}].parentCallId`);
|
|
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`);
|
|
890
|
+
normalizedFrames.push({
|
|
891
|
+
playbookId,
|
|
892
|
+
sessionId,
|
|
893
|
+
rootSessionId,
|
|
894
|
+
depth,
|
|
895
|
+
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
896
|
+
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
897
|
+
options,
|
|
898
|
+
roleBindings,
|
|
899
|
+
runtime,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
let normalizedLastError;
|
|
903
|
+
if (snapshot.lastError !== undefined) {
|
|
904
|
+
const error = snapshotRecord(snapshot.lastError, 'Captain shell snapshot.lastError');
|
|
905
|
+
rejectSnapshotKeys(error, ['name', 'message'], 'Captain shell snapshot.lastError');
|
|
906
|
+
normalizedLastError = {
|
|
907
|
+
name: snapshotString(error.name, 'Captain shell snapshot.lastError.name', true),
|
|
908
|
+
message: snapshotString(error.message, 'Captain shell snapshot.lastError.message', true),
|
|
909
|
+
};
|
|
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
|
+
}
|
|
423
972
|
}
|
|
424
|
-
|
|
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
|
+
}
|
|
981
|
+
return snapshotJsonValue({
|
|
982
|
+
...common,
|
|
983
|
+
mode,
|
|
984
|
+
frames: normalizedFrames,
|
|
985
|
+
...(snapshot.pendingBossQuestions === undefined
|
|
986
|
+
? {}
|
|
987
|
+
: { pendingBossQuestions: snapshot.pendingBossQuestions }),
|
|
988
|
+
...(normalizedLastError === undefined
|
|
989
|
+
? {}
|
|
990
|
+
: { lastError: normalizedLastError }),
|
|
991
|
+
}, 'Captain shell snapshot');
|
|
992
|
+
}
|
|
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' };
|
|
998
|
+
}
|
|
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
|
+
};
|
|
425
1049
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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;
|
|
1062
|
+
}
|
|
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) {
|
|
430
1067
|
const entries = [];
|
|
431
1068
|
const byCommand = new Map();
|
|
432
1069
|
const byId = new Map();
|
|
433
1070
|
const enablementById = new Map();
|
|
434
|
-
const
|
|
435
|
-
|
|
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)) {
|
|
436
1078
|
throw new Error('captain.options.playbooks is required');
|
|
437
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
|
+
}
|
|
438
1096
|
const ids = Object.keys(config);
|
|
439
1097
|
if (ids.length === 0) {
|
|
440
1098
|
throw new Error('captain.options.playbooks must enable at least one playbook');
|
|
@@ -448,6 +1106,7 @@ async function buildEnablements(options, players, loadModule) {
|
|
|
448
1106
|
throw new Error(`captain.options.playbooks.${id} must be an object`);
|
|
449
1107
|
}
|
|
450
1108
|
const record = block;
|
|
1109
|
+
rejectSnapshotKeys(record, ['from', 'command', 'roles', 'options'], `captain.options.playbooks.${id}`);
|
|
451
1110
|
const from = record.from;
|
|
452
1111
|
if (typeof from !== 'string' || from.length === 0) {
|
|
453
1112
|
throw new Error(`captain.options.playbooks.${id}.from must be a module specifier`);
|
|
@@ -478,41 +1137,83 @@ async function buildEnablements(options, players, loadModule) {
|
|
|
478
1137
|
if (byCommand.has(command)) {
|
|
479
1138
|
throw new Error(`captain.options.playbooks has a duplicate effective command "${command}"`);
|
|
480
1139
|
}
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
};
|
|
488
|
-
}
|
|
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`);
|
|
489
1175
|
entries.push(entry);
|
|
490
1176
|
byId.set(entry.id, entry);
|
|
491
1177
|
byCommand.set(command, entry);
|
|
492
1178
|
enablementById.set(entry.id, {
|
|
493
1179
|
entry,
|
|
494
1180
|
command,
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
|
|
1181
|
+
options: validatedOptions,
|
|
1182
|
+
roleBindings,
|
|
498
1183
|
});
|
|
499
1184
|
}
|
|
500
|
-
|
|
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
|
+
};
|
|
501
1198
|
}
|
|
502
1199
|
export function createPlaybookCaptainShell(options, deps = {}) {
|
|
503
1200
|
const loadModule = deps.loadModule ?? ((specifier) => import(specifier));
|
|
504
1201
|
const createSessionId = deps.createSessionId ?? randomUUID;
|
|
505
1202
|
const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const
|
|
1203
|
+
let captainAgent;
|
|
1204
|
+
let captainAdapter;
|
|
1205
|
+
let playerAgents = new Map();
|
|
1206
|
+
const playerLedger = new Map();
|
|
1207
|
+
const playerTransactions = new Map();
|
|
510
1208
|
let entries = [];
|
|
511
1209
|
let byCommand = new Map();
|
|
512
1210
|
let byId = new Map();
|
|
513
1211
|
let enablementById = new Map();
|
|
514
1212
|
let session;
|
|
515
|
-
let
|
|
1213
|
+
let sessionEmissionsOpen = false;
|
|
1214
|
+
let closedGateAttempted = false;
|
|
1215
|
+
let lifecycle = 'fresh';
|
|
1216
|
+
let terminallyDisposed = false;
|
|
516
1217
|
let activeContext;
|
|
517
1218
|
const frames = [];
|
|
518
1219
|
let mode = 'chat';
|
|
@@ -524,18 +1225,57 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
524
1225
|
const pendingChildParents = new Set();
|
|
525
1226
|
const captainQueue = new PQueue({ concurrency: 1 });
|
|
526
1227
|
let disposing = false;
|
|
1228
|
+
const admitHostBoundary = () => {
|
|
1229
|
+
if (sessionEmissionsOpen)
|
|
1230
|
+
return;
|
|
1231
|
+
closedGateAttempted = true;
|
|
1232
|
+
throw new Error('Captain shell host boundaries are closed during restore');
|
|
1233
|
+
};
|
|
1234
|
+
const admitHostEmission = () => {
|
|
1235
|
+
if (sessionEmissionsOpen)
|
|
1236
|
+
return true;
|
|
1237
|
+
closedGateAttempted = true;
|
|
1238
|
+
return false;
|
|
1239
|
+
};
|
|
1240
|
+
const installSession = (initSession, emissionsOpen) => {
|
|
1241
|
+
sessionEmissionsOpen = emissionsOpen;
|
|
1242
|
+
closedGateAttempted = false;
|
|
1243
|
+
session = {
|
|
1244
|
+
signal: initSession.signal,
|
|
1245
|
+
players: initSession.players,
|
|
1246
|
+
emitStatus: async (message, data) => {
|
|
1247
|
+
if (!admitHostEmission())
|
|
1248
|
+
return;
|
|
1249
|
+
await initSession.emitStatus(message, data);
|
|
1250
|
+
},
|
|
1251
|
+
emitTelemetry: async (event) => {
|
|
1252
|
+
if (!admitHostEmission())
|
|
1253
|
+
return;
|
|
1254
|
+
await initSession.emitTelemetry(event);
|
|
1255
|
+
},
|
|
1256
|
+
setVisiblePlayers: async (playerIds) => {
|
|
1257
|
+
if (!admitHostEmission())
|
|
1258
|
+
return;
|
|
1259
|
+
await initSession.setVisiblePlayers(playerIds);
|
|
1260
|
+
},
|
|
1261
|
+
};
|
|
1262
|
+
};
|
|
527
1263
|
// --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
|
|
528
1264
|
let captainRuntime;
|
|
529
1265
|
let captainSessionId;
|
|
530
|
-
// CAPTAIN-35:
|
|
531
|
-
//
|
|
532
|
-
//
|
|
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.
|
|
533
1269
|
let conversation = { kind: 'unopened' };
|
|
534
1270
|
let shuttingDown = false;
|
|
535
1271
|
const journal = [];
|
|
536
1272
|
let journalSeq = 0;
|
|
537
1273
|
let turnSequence = 0;
|
|
538
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;
|
|
539
1279
|
// The durable call the runtime is about to make, taken from the paired
|
|
540
1280
|
// `captain.call.started` boundary the engine emits before the port call
|
|
541
1281
|
// (CAPTAIN-9): the shell never infers a call's kind from its prose.
|
|
@@ -603,7 +1343,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
603
1343
|
// (CAPTAIN-5/CAPTAIN-6).
|
|
604
1344
|
...(captainRuntime
|
|
605
1345
|
? {
|
|
606
|
-
durableConversation: conversation.kind === 'pinned'
|
|
1346
|
+
durableConversation: conversation.kind === 'pinned' ||
|
|
1347
|
+
(conversation.kind === 'needsCatchUp' &&
|
|
1348
|
+
conversation.resume !== false),
|
|
607
1349
|
sessionJournal: true,
|
|
608
1350
|
}
|
|
609
1351
|
: {}),
|
|
@@ -759,40 +1501,133 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
759
1501
|
};
|
|
760
1502
|
let callNestedPlaybook;
|
|
761
1503
|
const createPorts = (frame) => ({
|
|
762
|
-
callPlayer: async (
|
|
763
|
-
|
|
1504
|
+
callPlayer: async (roleId, prompt, signal, options) => {
|
|
1505
|
+
admitHostBoundary();
|
|
1506
|
+
if (!activeContext || !activeTurn || !frame.playerCallScope) {
|
|
764
1507
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
765
1508
|
}
|
|
766
1509
|
const context = activeContext;
|
|
1510
|
+
const admittedTurn = activeTurn;
|
|
1511
|
+
const scope = frame.playerCallScope;
|
|
767
1512
|
signal.throwIfAborted();
|
|
768
|
-
const
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
// CaptainContext is turn-scoped and cannot accept a narrower XState
|
|
773
|
-
// invocation signal. Recheck after the host call so a sibling
|
|
774
|
-
// cancellation is still reported as aborted and cannot rotate a
|
|
775
|
-
// stopped branch's player token in the linked runtime.
|
|
776
|
-
signal.throwIfAborted();
|
|
777
|
-
// CAPTAIN-20: only a player call that actually produced work is an
|
|
778
|
-
// interruption the Boss was spared. A call that errored or aborted
|
|
779
|
-
// saved nothing, so it never feeds the saved-counts gate.
|
|
780
|
-
const summary = activeTurnSummary;
|
|
781
|
-
if (summary && summaryIncludes(frame) && result.status === 'ok') {
|
|
782
|
-
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)}`);
|
|
783
1517
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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,
|
|
793
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;
|
|
794
1628
|
},
|
|
795
1629
|
callCaptain: async (prompt, signal, options) => {
|
|
1630
|
+
admitHostBoundary();
|
|
796
1631
|
if (!activeContext) {
|
|
797
1632
|
throw new Error('callCaptain invoked outside a Boss turn');
|
|
798
1633
|
}
|
|
@@ -810,6 +1645,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
810
1645
|
};
|
|
811
1646
|
},
|
|
812
1647
|
callJudge: async (prompt, signal) => {
|
|
1648
|
+
admitHostBoundary();
|
|
813
1649
|
if (!activeContext) {
|
|
814
1650
|
throw new Error('callJudge invoked outside a Boss turn');
|
|
815
1651
|
}
|
|
@@ -835,6 +1671,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
835
1671
|
return result.finalText;
|
|
836
1672
|
},
|
|
837
1673
|
callPlaybook: (request, signal) => {
|
|
1674
|
+
admitHostBoundary();
|
|
838
1675
|
const opening = callNestedPlaybook(frame, request, signal);
|
|
839
1676
|
let exposed;
|
|
840
1677
|
const registerOpeningCleanup = () => {
|
|
@@ -848,23 +1685,34 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
848
1685
|
registerOpeningCleanup();
|
|
849
1686
|
return exposed;
|
|
850
1687
|
},
|
|
851
|
-
emitStatus:
|
|
852
|
-
|
|
1688
|
+
emitStatus: (message, data) => {
|
|
1689
|
+
if (!admitHostEmission())
|
|
1690
|
+
return Promise.resolve();
|
|
1691
|
+
return trackHostCall(frame, (async () => {
|
|
1692
|
+
await requireSession().emitStatus(message, data);
|
|
1693
|
+
})());
|
|
853
1694
|
},
|
|
854
|
-
emitTelemetry:
|
|
855
|
-
if (
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1695
|
+
emitTelemetry: (event) => {
|
|
1696
|
+
if (!admitHostEmission())
|
|
1697
|
+
return Promise.resolve();
|
|
1698
|
+
const emission = (async () => {
|
|
1699
|
+
if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
|
|
1700
|
+
await mirrorSubRuntimeTelemetry(frame, event.payload);
|
|
1701
|
+
}
|
|
1702
|
+
await requireSession().emitTelemetry(event);
|
|
1703
|
+
})();
|
|
1704
|
+
return trackHostCall(frame, emission);
|
|
859
1705
|
},
|
|
860
1706
|
});
|
|
861
1707
|
// CAPTAIN-22: before dispatching to a playbook, request tmux-play
|
|
862
|
-
// visibility for that playbook's
|
|
1708
|
+
// visibility for that playbook's explicitly bound session players. A pane
|
|
863
1709
|
// reconciliation failure is display-only in tmux-play and does not
|
|
864
1710
|
// reject; the legacy path carries no generated set and skips this.
|
|
865
1711
|
const requestVisibility = async (frame) => {
|
|
866
|
-
const ids = [...new Set([...frame.playerBindings.values()].map(({
|
|
867
|
-
|
|
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)
|
|
868
1716
|
return;
|
|
869
1717
|
try {
|
|
870
1718
|
await activeContext.setVisiblePlayers(ids);
|
|
@@ -896,34 +1744,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
896
1744
|
: undefined;
|
|
897
1745
|
return typeof stack === 'string' ? { ...compact, stack } : compact;
|
|
898
1746
|
};
|
|
1747
|
+
const makePlayerBindings = (enablement) => {
|
|
1748
|
+
return new Map(enablement.roleBindings);
|
|
1749
|
+
};
|
|
899
1750
|
const makeFrame = (enablement, parent) => {
|
|
900
1751
|
const entry = enablement.entry;
|
|
901
1752
|
const sessionId = allocateSessionId();
|
|
902
|
-
const playerBindings =
|
|
903
|
-
|
|
904
|
-
let inherited;
|
|
905
|
-
for (let ancestor = parent?.frame; ancestor && inherited === undefined; ancestor = ancestor.parent?.frame) {
|
|
906
|
-
inherited = ancestor.playerBindings.get(role);
|
|
907
|
-
}
|
|
908
|
-
if (inherited) {
|
|
909
|
-
playerBindings.set(role, inherited);
|
|
910
|
-
continue;
|
|
911
|
-
}
|
|
912
|
-
const configured = enablement.boundPlayers.find((player) => player.id === role) ?? { id: role };
|
|
913
|
-
playerBindings.set(role, {
|
|
914
|
-
hostPlayerId: enablement.hostPlayerId(role),
|
|
915
|
-
player: configured,
|
|
916
|
-
});
|
|
917
|
-
}
|
|
918
|
-
const playerResumeTokens = parent?.frame.playerResumeTokens ?? new Map();
|
|
919
|
-
const runtime = entry.createRuntime({
|
|
920
|
-
captainOptions: enablement.optionInput,
|
|
921
|
-
players: [...playerBindings].map(([role, { player }]) => ({
|
|
922
|
-
id: role,
|
|
923
|
-
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
924
|
-
...(player.model === undefined ? {} : { model: player.model }),
|
|
925
|
-
})),
|
|
926
|
-
});
|
|
1753
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
1754
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
927
1755
|
return {
|
|
928
1756
|
entry,
|
|
929
1757
|
enablement,
|
|
@@ -932,59 +1760,188 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
932
1760
|
rootSessionId: parent?.frame.rootSessionId ?? sessionId,
|
|
933
1761
|
depth: parent ? parent.frame.depth + 1 : 0,
|
|
934
1762
|
playerBindings,
|
|
935
|
-
playerResumeTokens,
|
|
936
1763
|
...(parent ? { parent } : {}),
|
|
937
1764
|
inFlightHostCalls: new Set(),
|
|
938
1765
|
};
|
|
939
1766
|
};
|
|
1767
|
+
const makeRestoredFrame = (enablement, snapshot, parent) => {
|
|
1768
|
+
const entry = enablement.entry;
|
|
1769
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
1770
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
1771
|
+
return {
|
|
1772
|
+
entry,
|
|
1773
|
+
enablement,
|
|
1774
|
+
runtime,
|
|
1775
|
+
sessionId: snapshot.sessionId,
|
|
1776
|
+
rootSessionId: snapshot.rootSessionId,
|
|
1777
|
+
depth: snapshot.depth,
|
|
1778
|
+
playerBindings,
|
|
1779
|
+
...(parent ? { parent } : {}),
|
|
1780
|
+
state: snapshot.runtime.state,
|
|
1781
|
+
inFlightHostCalls: new Set(),
|
|
1782
|
+
};
|
|
1783
|
+
};
|
|
940
1784
|
const playerSessionStore = (frame) => ({
|
|
941
1785
|
select(playerId) {
|
|
942
1786
|
const binding = bindingFor(frame, playerId);
|
|
943
|
-
return
|
|
1787
|
+
return playerLedger.get(binding.playerId)?.resumeToken ?? false;
|
|
944
1788
|
},
|
|
945
1789
|
update(playerId, resumeToken) {
|
|
946
1790
|
const binding = bindingFor(frame, playerId);
|
|
947
|
-
|
|
948
|
-
|
|
1791
|
+
const ledger = playerLedger.get(binding.playerId);
|
|
1792
|
+
if (!ledger) {
|
|
1793
|
+
throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
|
|
949
1794
|
}
|
|
950
|
-
|
|
951
|
-
|
|
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);
|
|
952
1831
|
}
|
|
953
1832
|
},
|
|
954
1833
|
snapshot() {
|
|
955
1834
|
const tokens = {};
|
|
956
1835
|
for (const [playerId, binding] of frame.playerBindings) {
|
|
957
|
-
const token =
|
|
1836
|
+
const token = playerLedger.get(binding.playerId)?.resumeToken;
|
|
958
1837
|
if (token !== undefined)
|
|
959
1838
|
tokens[playerId] = token;
|
|
960
1839
|
}
|
|
961
1840
|
return tokens;
|
|
962
1841
|
},
|
|
963
1842
|
restore(tokens) {
|
|
964
|
-
|
|
965
|
-
frame
|
|
1843
|
+
if (lifecycle !== 'restoring' ||
|
|
1844
|
+
restoringPlayerSessionFrame !== frame) {
|
|
1845
|
+
throw new Error(`${frameLabel(frame)} player-session restore is only available during shell restoration`);
|
|
966
1846
|
}
|
|
1847
|
+
const byPlayer = new Map();
|
|
967
1848
|
for (const [playerId, token] of Object.entries(tokens)) {
|
|
968
1849
|
const binding = bindingFor(frame, playerId);
|
|
969
|
-
|
|
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;
|
|
970
1869
|
}
|
|
971
1870
|
},
|
|
972
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
|
+
};
|
|
1922
|
+
const frameSession = (frame) => ({
|
|
1923
|
+
sessionId: frame.sessionId,
|
|
1924
|
+
playbookId: frame.entry.id,
|
|
1925
|
+
rootSessionId: frame.rootSessionId,
|
|
1926
|
+
...(frame.parent
|
|
1927
|
+
? {
|
|
1928
|
+
parentSessionId: frame.parent.frame.sessionId,
|
|
1929
|
+
parentCallId: frame.parent.callId,
|
|
1930
|
+
}
|
|
1931
|
+
: {}),
|
|
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
|
+
])),
|
|
1940
|
+
playerSessions: playerSessionStore(frame),
|
|
1941
|
+
ports: createPorts(frame),
|
|
1942
|
+
});
|
|
973
1943
|
const initFrame = async (frame) => {
|
|
974
|
-
await frame.runtime.init(
|
|
975
|
-
sessionId: frame.sessionId,
|
|
976
|
-
playbookId: frame.entry.id,
|
|
977
|
-
rootSessionId: frame.rootSessionId,
|
|
978
|
-
...(frame.parent
|
|
979
|
-
? {
|
|
980
|
-
parentSessionId: frame.parent.frame.sessionId,
|
|
981
|
-
parentCallId: frame.parent.callId,
|
|
982
|
-
}
|
|
983
|
-
: {}),
|
|
984
|
-
depth: frame.depth,
|
|
985
|
-
playerSessions: playerSessionStore(frame),
|
|
986
|
-
ports: createPorts(frame),
|
|
987
|
-
});
|
|
1944
|
+
await frame.runtime.init(frameSession(frame));
|
|
988
1945
|
};
|
|
989
1946
|
const clearLeafLedger = () => {
|
|
990
1947
|
pendingBossQuestions = undefined;
|
|
@@ -1264,7 +2221,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1264
2221
|
// exception filed against an effect that never ran.
|
|
1265
2222
|
await requestVisibility(frame);
|
|
1266
2223
|
await setMode('engaged.driving', 'submit');
|
|
1267
|
-
const result = await runEffect(() => frame.runtime.handleBossInput({ text, signal }));
|
|
2224
|
+
const result = await runFrameOperation(frame, () => runEffect(() => frame.runtime.handleBossInput({ text, signal })));
|
|
1268
2225
|
frame.state = result.state;
|
|
1269
2226
|
return result;
|
|
1270
2227
|
};
|
|
@@ -1310,11 +2267,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1310
2267
|
}
|
|
1311
2268
|
let result;
|
|
1312
2269
|
try {
|
|
1313
|
-
result = await runEffect(() => parent.runtime.resumePlaybookCall({
|
|
2270
|
+
result = await runFrameOperation(parent, () => runEffect(() => parent.runtime.resumePlaybookCall({
|
|
1314
2271
|
callId: parentLink.callId,
|
|
1315
2272
|
result: effectiveResult,
|
|
1316
2273
|
signal: context.signal,
|
|
1317
|
-
}));
|
|
2274
|
+
})));
|
|
1318
2275
|
}
|
|
1319
2276
|
catch (error) {
|
|
1320
2277
|
if (disposing || invocationSignal?.aborted)
|
|
@@ -1346,6 +2303,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1346
2303
|
await resumeParent(frame, callResultFor(frame, result), context);
|
|
1347
2304
|
}
|
|
1348
2305
|
else {
|
|
2306
|
+
// CAPTAIN-20: the root is still alive here, so this is the one
|
|
2307
|
+
// authoritative boundary that can retain the Boss-facing meaning its
|
|
2308
|
+
// runtime publishes before disposal removes the frame. The opaque run
|
|
2309
|
+
// output remains runtime-to-runtime data and never becomes Captain
|
|
2310
|
+
// evidence (CAPPLAY-10).
|
|
2311
|
+
activeTurn?.settlementFacts.push(rootCompletionFact(frame));
|
|
1349
2312
|
await runEffect(() => disposeStack('final'));
|
|
1350
2313
|
}
|
|
1351
2314
|
return;
|
|
@@ -1664,7 +2627,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1664
2627
|
stateDigestLine(view.state, view.stateDescription),
|
|
1665
2628
|
].join(': '));
|
|
1666
2629
|
lines.push(...leafContextLines(view.context));
|
|
1667
|
-
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)}`);
|
|
1668
2633
|
lines.push(pending.length === 0
|
|
1669
2634
|
? 'Pending Boss questions: none.'
|
|
1670
2635
|
: ['Pending Boss questions:', ...pending].join('\n'));
|
|
@@ -1760,6 +2725,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1760
2725
|
await trackTurnCall(settlement.context.emitReply(settlement.text));
|
|
1761
2726
|
}
|
|
1762
2727
|
catch (error) {
|
|
2728
|
+
conversation = { kind: 'needsSeeding' };
|
|
1763
2729
|
const normalized = normalizeErrorCompact(error) ?? {
|
|
1764
2730
|
name: 'Error',
|
|
1765
2731
|
message: String(error),
|
|
@@ -1856,8 +2822,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1856
2822
|
* text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
|
|
1857
2823
|
* the Captain may need to repeat.
|
|
1858
2824
|
*/
|
|
1859
|
-
const
|
|
1860
|
-
for (const record of
|
|
2825
|
+
const conversationDigest = (records, render) => {
|
|
2826
|
+
for (const record of records) {
|
|
1861
2827
|
if (record.kind === 'action' &&
|
|
1862
2828
|
typeof record.payload === 'object' &&
|
|
1863
2829
|
record.payload !== null &&
|
|
@@ -1867,13 +2833,34 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1867
2833
|
recordSuppliedIdentifier(actionId);
|
|
1868
2834
|
}
|
|
1869
2835
|
}
|
|
1870
|
-
return
|
|
2836
|
+
return render(records);
|
|
1871
2837
|
};
|
|
2838
|
+
const reseedDigest = () => conversationDigest(journal, renderReseedDigest);
|
|
2839
|
+
const catchUpDigest = (afterJournalSeq) => conversationDigest(journal.filter((record) => record.seq > afterJournalSeq), renderCatchUpDigest);
|
|
1872
2840
|
const markControlFailure = (error) => {
|
|
1873
|
-
|
|
1874
|
-
activeTurn.controlFailure = true;
|
|
2841
|
+
activeTurn?.controlFailures.add(error);
|
|
1875
2842
|
return error;
|
|
1876
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
|
+
};
|
|
1877
2864
|
/**
|
|
1878
2865
|
* CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
|
|
1879
2866
|
* engagement constructed, a stack disposed, an advertised action applied.
|
|
@@ -1919,14 +2906,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1919
2906
|
this.name = 'CaptainProseError';
|
|
1920
2907
|
}
|
|
1921
2908
|
}
|
|
1922
|
-
const rawDurableCall = async (context, prompt, resume) => {
|
|
2909
|
+
const rawDurableCall = async (context, prompt, resume, attempt) => {
|
|
1923
2910
|
const queued = captainQueue.add(async () => {
|
|
1924
2911
|
context.signal.throwIfAborted();
|
|
1925
|
-
|
|
2912
|
+
attempt.providerBoundaryEntered = true;
|
|
2913
|
+
const result = await classifySettingsCall(() => context.callCaptain(prompt, {
|
|
1926
2914
|
visibility: 'hidden',
|
|
1927
2915
|
resume,
|
|
1928
2916
|
...controlCallToolOptions(captainAdapter),
|
|
1929
|
-
|
|
2917
|
+
settings: callSettings(captainAgent),
|
|
2918
|
+
}));
|
|
1930
2919
|
context.signal.throwIfAborted();
|
|
1931
2920
|
return result;
|
|
1932
2921
|
});
|
|
@@ -1938,17 +2927,47 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1938
2927
|
// conversation that is owed a reseed carries the digest on its very next
|
|
1939
2928
|
// call, so the turn after a failed reseed starts seeded rather than blank.
|
|
1940
2929
|
const durableCall = async (context, compose) => {
|
|
1941
|
-
const
|
|
2930
|
+
const startingConversation = conversation;
|
|
2931
|
+
const resume = startingConversation.kind === 'pinned'
|
|
2932
|
+
? startingConversation.token
|
|
2933
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
2934
|
+
? startingConversation.resume
|
|
2935
|
+
: false;
|
|
1942
2936
|
const seedFirstCall = conversation.kind === 'needsSeeding';
|
|
2937
|
+
const catchUpFirstCall = conversation.kind === 'needsCatchUp';
|
|
2938
|
+
const representedJournalSeq = journalSeq;
|
|
2939
|
+
const firstAttempt = { providerBoundaryEntered: false };
|
|
1943
2940
|
let result;
|
|
1944
2941
|
let failure;
|
|
1945
2942
|
try {
|
|
1946
|
-
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);
|
|
1947
2950
|
}
|
|
1948
2951
|
catch (error) {
|
|
1949
2952
|
if (context.signal.aborted) {
|
|
1950
|
-
|
|
1951
|
-
|
|
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);
|
|
1952
2971
|
}
|
|
1953
2972
|
failure = error;
|
|
1954
2973
|
}
|
|
@@ -1958,11 +2977,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1958
2977
|
result.resumeToken === undefined;
|
|
1959
2978
|
if (!unsynchronized) {
|
|
1960
2979
|
conversation = { kind: 'pinned', token: result.resumeToken };
|
|
2980
|
+
if (activeTurn) {
|
|
2981
|
+
activeTurn.captainSyncedJournalSeq = representedJournalSeq;
|
|
2982
|
+
}
|
|
1961
2983
|
return {
|
|
1962
2984
|
...(result.finalText !== undefined
|
|
1963
2985
|
? { finalText: result.finalText }
|
|
1964
2986
|
: {}),
|
|
1965
|
-
correctiveSpent: seedFirstCall,
|
|
2987
|
+
correctiveSpent: seedFirstCall || catchUpFirstCall,
|
|
1966
2988
|
};
|
|
1967
2989
|
}
|
|
1968
2990
|
// Only the model-side conversation is replaced: the stack, player
|
|
@@ -1972,13 +2994,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1972
2994
|
conversation = { kind: 'needsSeeding' };
|
|
1973
2995
|
const recap = reseedDigest();
|
|
1974
2996
|
let reissued;
|
|
2997
|
+
const reissueAttempt = { providerBoundaryEntered: false };
|
|
1975
2998
|
try {
|
|
1976
|
-
reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false);
|
|
2999
|
+
reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false, reissueAttempt);
|
|
1977
3000
|
}
|
|
1978
3001
|
catch (error) {
|
|
1979
3002
|
if (context.signal.aborted) {
|
|
1980
3003
|
conversation = { kind: 'needsSeeding' };
|
|
1981
|
-
throw error;
|
|
3004
|
+
throw context.signal.reason ?? error;
|
|
3005
|
+
}
|
|
3006
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
3007
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
1982
3008
|
}
|
|
1983
3009
|
throw markControlFailure(new CaptainContinuityError(error));
|
|
1984
3010
|
}
|
|
@@ -1987,6 +3013,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1987
3013
|
`callCaptain status "${reissued.status}" without a resume token`));
|
|
1988
3014
|
}
|
|
1989
3015
|
conversation = { kind: 'pinned', token: reissued.resumeToken };
|
|
3016
|
+
if (activeTurn)
|
|
3017
|
+
activeTurn.captainSyncedJournalSeq = journalSeq;
|
|
1990
3018
|
return {
|
|
1991
3019
|
...(reissued.finalText !== undefined
|
|
1992
3020
|
? { finalText: reissued.finalText }
|
|
@@ -2025,9 +3053,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2025
3053
|
// -------------------------------------------------------------------------
|
|
2026
3054
|
const captainPorts = () => ({
|
|
2027
3055
|
callPlayer: async () => {
|
|
3056
|
+
admitHostBoundary();
|
|
2028
3057
|
throw new Error('the session Captain has no players');
|
|
2029
3058
|
},
|
|
2030
3059
|
callCaptain: async (prompt, signal) => {
|
|
3060
|
+
admitHostBoundary();
|
|
2031
3061
|
if (!activeContext) {
|
|
2032
3062
|
throw new Error('the session Captain called out of a Boss turn');
|
|
2033
3063
|
}
|
|
@@ -2085,29 +3115,38 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2085
3115
|
return { status: 'ok', finalText: 'ok' };
|
|
2086
3116
|
},
|
|
2087
3117
|
callJudge: async () => {
|
|
3118
|
+
admitHostBoundary();
|
|
2088
3119
|
throw new Error('the session Captain makes no judge call');
|
|
2089
3120
|
},
|
|
2090
3121
|
callPlaybook: async () => {
|
|
3122
|
+
admitHostBoundary();
|
|
2091
3123
|
throw new Error('the session Captain never calls a playbook');
|
|
2092
3124
|
},
|
|
2093
3125
|
// CAPTAIN-9: the session Captain's human status stream is suppressed while
|
|
2094
3126
|
// its structured telemetry is forwarded.
|
|
2095
|
-
emitStatus: async () => {
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
3127
|
+
emitStatus: async () => {
|
|
3128
|
+
admitHostEmission();
|
|
3129
|
+
},
|
|
3130
|
+
emitTelemetry: (event) => {
|
|
3131
|
+
if (!admitHostEmission())
|
|
3132
|
+
return Promise.resolve();
|
|
3133
|
+
const emission = (async () => {
|
|
3134
|
+
if (event.topic === 'playbook.trace') {
|
|
3135
|
+
const payload = payloadRecord(event.payload);
|
|
3136
|
+
if (payload?.type === 'captain.call.started') {
|
|
3137
|
+
const identity = payloadRecord(payload.payload);
|
|
3138
|
+
const stateId = identity?.stateId;
|
|
3139
|
+
servingCall =
|
|
3140
|
+
stateId === 'reporting'
|
|
3141
|
+
? 'closingReply'
|
|
3142
|
+
: stateId === 'answeringCommand'
|
|
3143
|
+
? 'commandReply'
|
|
3144
|
+
: 'decision';
|
|
3145
|
+
}
|
|
2108
3146
|
}
|
|
2109
|
-
|
|
2110
|
-
|
|
3147
|
+
await requireSession().emitTelemetry(event);
|
|
3148
|
+
})();
|
|
3149
|
+
return trackTurnCall(emission);
|
|
2111
3150
|
},
|
|
2112
3151
|
});
|
|
2113
3152
|
const resolveCommandTurn = (text) => {
|
|
@@ -2175,6 +3214,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2175
3214
|
return undefined;
|
|
2176
3215
|
}
|
|
2177
3216
|
};
|
|
3217
|
+
const rootCompletionFact = (frame) => {
|
|
3218
|
+
const published = leafStateDescription(frame);
|
|
3219
|
+
const description = published === undefined ? '' : compactEvidence(published);
|
|
3220
|
+
return description === ''
|
|
3221
|
+
? `${frameLabel(frame)} completed; its runtime published no result description.`
|
|
3222
|
+
: `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
|
|
3223
|
+
};
|
|
2178
3224
|
const leafStateSummary = () => {
|
|
2179
3225
|
const leaf = leafFrame();
|
|
2180
3226
|
if (!leaf)
|
|
@@ -2312,7 +3358,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2312
3358
|
message: String(error),
|
|
2313
3359
|
};
|
|
2314
3360
|
if (aborted) {
|
|
2315
|
-
|
|
3361
|
+
markConversationUnsynchronized();
|
|
2316
3362
|
if (turn?.outcomePending) {
|
|
2317
3363
|
turn.settlementFacts.push(`The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`);
|
|
2318
3364
|
journalOutcome(journalOutcomeEvidence(turn.settlementFacts, 'failed', turn.report));
|
|
@@ -2573,9 +3619,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2573
3619
|
};
|
|
2574
3620
|
throw outcome.error;
|
|
2575
3621
|
}
|
|
2576
|
-
if (!frames.includes(leaf)) {
|
|
2577
|
-
facts.push(`${frameLabel(leaf)} finished and was disposed.`);
|
|
2578
|
-
}
|
|
2579
3622
|
const runFailed = drainRunFailureFacts(facts);
|
|
2580
3623
|
const summary = leafStateSummary();
|
|
2581
3624
|
turn.report = {
|
|
@@ -2645,7 +3688,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2645
3688
|
// guard against re-execution — a repeated selection returns the recorded
|
|
2646
3689
|
// receipt rather than acting twice.
|
|
2647
3690
|
const key = `turn-${turn.id}-apply-${actionId}`;
|
|
2648
|
-
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 }))));
|
|
2649
3692
|
if (outcome.error !== undefined)
|
|
2650
3693
|
throw outcome.error;
|
|
2651
3694
|
const receipt = outcome.result;
|
|
@@ -2730,8 +3773,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2730
3773
|
};
|
|
2731
3774
|
};
|
|
2732
3775
|
const controller = {
|
|
2733
|
-
submit: (selection, signal) =>
|
|
2734
|
-
|
|
3776
|
+
submit: (selection, signal) => {
|
|
3777
|
+
admitHostBoundary();
|
|
3778
|
+
return settleSelection(selection, signal);
|
|
3779
|
+
},
|
|
3780
|
+
resolveParsedTurn: () => {
|
|
3781
|
+
admitHostBoundary();
|
|
3782
|
+
return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
|
|
3783
|
+
},
|
|
2735
3784
|
};
|
|
2736
3785
|
// -------------------------------------------------------------------------
|
|
2737
3786
|
// Failure surface (CAPTAIN-34): a Boss-appropriate reply naming a concrete
|
|
@@ -2787,7 +3836,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2787
3836
|
// The durable Captain conversation did not receive the shell-authored
|
|
2788
3837
|
// fallback. Force its next call through the journal so it cannot interpret
|
|
2789
3838
|
// the Boss's follow-up without the reply the Boss was given this turn.
|
|
2790
|
-
|
|
3839
|
+
if (activeTurn?.settingsPreflightFailures.has(error)) {
|
|
3840
|
+
markConversationCatchUp();
|
|
3841
|
+
}
|
|
3842
|
+
else {
|
|
3843
|
+
markConversationUnsynchronized();
|
|
3844
|
+
}
|
|
2791
3845
|
// A rejected presentation may already have emitted bytes. It is therefore
|
|
2792
3846
|
// final for this turn even though the Promise did not prove it was shown.
|
|
2793
3847
|
if (activeTurn?.presentationAttempted === true)
|
|
@@ -2800,40 +3854,445 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2800
3854
|
text: failureReplyText(),
|
|
2801
3855
|
});
|
|
2802
3856
|
};
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
3857
|
+
const enabledCatalog = () => Object.freeze(entries.map((entry) => Object.freeze({
|
|
3858
|
+
id: entry.id,
|
|
3859
|
+
command: enablementById.get(entry.id).command,
|
|
3860
|
+
intent: entry.intent,
|
|
3861
|
+
})));
|
|
3862
|
+
const captainPlaybookSession = (id) => ({
|
|
3863
|
+
sessionId: id,
|
|
3864
|
+
playbookId: INTERNAL_CAPTAIN_ID,
|
|
3865
|
+
rootSessionId: id,
|
|
3866
|
+
depth: 0,
|
|
3867
|
+
roleBindings: {},
|
|
3868
|
+
ports: captainPorts(),
|
|
3869
|
+
});
|
|
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
|
+
]));
|
|
3885
|
+
const assertSnapshotMatchesEnablements = (snapshot, enabled) => {
|
|
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
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
if (snapshot.mode === 'chat')
|
|
3912
|
+
return;
|
|
3913
|
+
for (const frame of snapshot.frames) {
|
|
3914
|
+
const enablement = enabled.get(frame.playbookId);
|
|
3915
|
+
if (!enablement) {
|
|
3916
|
+
throw new TypeError(`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`);
|
|
3917
|
+
}
|
|
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`);
|
|
3924
|
+
}
|
|
3925
|
+
if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
|
|
3926
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`);
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
};
|
|
3930
|
+
const safeCapturePoint = () => {
|
|
3931
|
+
if (lifecycle !== 'ready' ||
|
|
3932
|
+
terminallyDisposed ||
|
|
3933
|
+
!sessionEmissionsOpen ||
|
|
3934
|
+
!session ||
|
|
3935
|
+
session.signal.aborted ||
|
|
3936
|
+
!captainRuntime ||
|
|
3937
|
+
disposing ||
|
|
3938
|
+
shuttingDown ||
|
|
3939
|
+
activeContext !== undefined ||
|
|
3940
|
+
activeTurn !== undefined ||
|
|
3941
|
+
activeTurnHostCalls !== undefined ||
|
|
3942
|
+
activeTurnSummary !== undefined ||
|
|
3943
|
+
runFailureFacts !== undefined ||
|
|
3944
|
+
servingCall !== undefined ||
|
|
3945
|
+
decisionCall !== undefined ||
|
|
3946
|
+
playerTransactions.size !== 0 ||
|
|
3947
|
+
captainQueue.pending !== 0 ||
|
|
3948
|
+
captainQueue.size !== 0 ||
|
|
3949
|
+
(mode !== 'chat' && mode !== 'engaged.parked')) {
|
|
3950
|
+
return false;
|
|
3951
|
+
}
|
|
3952
|
+
if ((mode === 'chat' &&
|
|
3953
|
+
(frames.length !== 0 ||
|
|
3954
|
+
pendingChildParents.size !== 0 ||
|
|
3955
|
+
pendingBossQuestions !== undefined ||
|
|
3956
|
+
lastError !== undefined)) ||
|
|
3957
|
+
(mode === 'engaged.parked' && frames.length === 0)) {
|
|
3958
|
+
return false;
|
|
3959
|
+
}
|
|
3960
|
+
const expectedParents = new Set(frames.slice(0, -1));
|
|
3961
|
+
if (pendingChildParents.size !== expectedParents.size ||
|
|
3962
|
+
[...pendingChildParents].some((frame) => !expectedParents.has(frame))) {
|
|
3963
|
+
return false;
|
|
3964
|
+
}
|
|
3965
|
+
return frames.every((frame, index) => {
|
|
3966
|
+
const liveInvocation = frame.invocationSignal !== undefined || frame.abortListener !== undefined;
|
|
3967
|
+
return (frame.state !== undefined &&
|
|
3968
|
+
frame.state.status === 'active' &&
|
|
3969
|
+
frame.state.quiescent &&
|
|
3970
|
+
!frame.disposing &&
|
|
3971
|
+
frame.disposePromise === undefined &&
|
|
3972
|
+
frame.removal === undefined &&
|
|
3973
|
+
frame.inFlightHostCalls.size === 0 &&
|
|
3974
|
+
(index === 0
|
|
3975
|
+
? !liveInvocation
|
|
3976
|
+
: !liveInvocation ||
|
|
3977
|
+
(frame.invocationSignal !== undefined &&
|
|
3978
|
+
!frame.invocationSignal.aborted &&
|
|
3979
|
+
frame.abortListener !== undefined)));
|
|
3980
|
+
});
|
|
3981
|
+
};
|
|
3982
|
+
const exportShellSnapshot = () => {
|
|
3983
|
+
if (!safeCapturePoint() ||
|
|
3984
|
+
!captainRuntime ||
|
|
3985
|
+
!captainSessionId ||
|
|
3986
|
+
!captainAgent) {
|
|
3987
|
+
return undefined;
|
|
3988
|
+
}
|
|
3989
|
+
try {
|
|
3990
|
+
if (typeof captainRuntime.exportSnapshot !== 'function' ||
|
|
3991
|
+
typeof captainRuntime.restore !== 'function') {
|
|
3992
|
+
return undefined;
|
|
3993
|
+
}
|
|
3994
|
+
const captainSnapshot = captainRuntime.exportSnapshot();
|
|
3995
|
+
if (captainSnapshot === undefined)
|
|
3996
|
+
return undefined;
|
|
3997
|
+
const frameSnapshots = [];
|
|
3998
|
+
for (const frame of frames) {
|
|
3999
|
+
if (typeof frame.runtime.exportSnapshot !== 'function' ||
|
|
4000
|
+
typeof frame.runtime.restore !== 'function') {
|
|
4001
|
+
return undefined;
|
|
4002
|
+
}
|
|
4003
|
+
const runtime = frame.runtime.exportSnapshot();
|
|
4004
|
+
if (runtime === undefined ||
|
|
4005
|
+
!isDeepStrictEqual(frame.state, runtime.state)) {
|
|
4006
|
+
return undefined;
|
|
4007
|
+
}
|
|
4008
|
+
frameSnapshots.push({
|
|
4009
|
+
playbookId: frame.entry.id,
|
|
4010
|
+
sessionId: frame.sessionId,
|
|
4011
|
+
rootSessionId: frame.rootSessionId,
|
|
4012
|
+
depth: frame.depth,
|
|
4013
|
+
...(frame.parent
|
|
4014
|
+
? {
|
|
4015
|
+
parentSessionId: frame.parent.frame.sessionId,
|
|
4016
|
+
parentCallId: frame.parent.callId,
|
|
4017
|
+
}
|
|
4018
|
+
: {}),
|
|
4019
|
+
options: frame.enablement.options,
|
|
4020
|
+
roleBindings: Object.fromEntries([...frame.playerBindings].map(([role, binding]) => [
|
|
4021
|
+
role,
|
|
4022
|
+
binding.playerId,
|
|
4023
|
+
])),
|
|
4024
|
+
runtime,
|
|
4025
|
+
});
|
|
4026
|
+
}
|
|
4027
|
+
const common = {
|
|
4028
|
+
schemaVersion: 3,
|
|
4029
|
+
captain: {
|
|
4030
|
+
sessionId: captainSessionId,
|
|
4031
|
+
runtime: captainSnapshot,
|
|
4032
|
+
agent: fixedAgent(captainAgent),
|
|
4033
|
+
conversation,
|
|
4034
|
+
},
|
|
4035
|
+
playerSessions: playerLedgerRecord(),
|
|
4036
|
+
issuedSessionIds: [...issuedSessionIds],
|
|
4037
|
+
sequences: { turn: turnSequence, journal: journalSeq },
|
|
4038
|
+
journal,
|
|
4039
|
+
...(lastAction === undefined ? {} : { lastAction }),
|
|
4040
|
+
...(lastSettlementStatus === undefined
|
|
4041
|
+
? {}
|
|
4042
|
+
: { lastSettlementStatus }),
|
|
4043
|
+
};
|
|
4044
|
+
const candidate = mode === 'chat'
|
|
4045
|
+
? { ...common, mode }
|
|
4046
|
+
: {
|
|
4047
|
+
...common,
|
|
4048
|
+
mode: 'engaged.parked',
|
|
4049
|
+
frames: frameSnapshots,
|
|
4050
|
+
...(pendingBossQuestions === undefined
|
|
4051
|
+
? {}
|
|
4052
|
+
: { pendingBossQuestions: pendingBossQuestions }),
|
|
4053
|
+
...(lastError === undefined ? {} : { lastError }),
|
|
4054
|
+
};
|
|
4055
|
+
const normalized = assertPlaybookCaptainShellSnapshot(candidate);
|
|
4056
|
+
assertSnapshotMatchesEnablements(normalized, enablementById);
|
|
4057
|
+
return normalized;
|
|
4058
|
+
}
|
|
4059
|
+
catch {
|
|
4060
|
+
return undefined;
|
|
4061
|
+
}
|
|
4062
|
+
};
|
|
4063
|
+
const verifyRestoredRuntime = (runtime, expected, playbookId, allowSuspendedCall) => {
|
|
4064
|
+
const actual = runtime.exportSnapshot?.();
|
|
4065
|
+
if (actual === undefined) {
|
|
4066
|
+
throw new Error(`restored ${playbookId} runtime did not reach a safe snapshot boundary`);
|
|
4067
|
+
}
|
|
4068
|
+
const normalized = assertPlaybookRuntimeSnapshot(actual, playbookId, allowSuspendedCall ? { allowSuspendedCall: true } : {});
|
|
4069
|
+
for (const key of [
|
|
4070
|
+
'state',
|
|
4071
|
+
'roleResumeTokens',
|
|
4072
|
+
'sequences',
|
|
4073
|
+
'pendingBossQuestions',
|
|
4074
|
+
'suspendedCall',
|
|
4075
|
+
]) {
|
|
4076
|
+
if (!isDeepStrictEqual(normalized[key], expected[key])) {
|
|
4077
|
+
throw new Error(`restored ${playbookId} runtime changed snapshot field ${key}`);
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
4080
|
+
};
|
|
4081
|
+
const resetFailedRestore = async () => {
|
|
4082
|
+
const cleanupFailures = [];
|
|
4083
|
+
for (const frame of [...frames].reverse()) {
|
|
4084
|
+
frame.disposing = true;
|
|
4085
|
+
try {
|
|
4086
|
+
await frame.runtime.dispose();
|
|
4087
|
+
}
|
|
4088
|
+
catch (error) {
|
|
4089
|
+
cleanupFailures.push(error);
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
if (captainRuntime) {
|
|
4093
|
+
shuttingDown = true;
|
|
4094
|
+
try {
|
|
4095
|
+
await captainRuntime.dispose();
|
|
4096
|
+
}
|
|
4097
|
+
catch (error) {
|
|
4098
|
+
cleanupFailures.push(error);
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
frames.splice(0);
|
|
4102
|
+
pendingChildParents.clear();
|
|
4103
|
+
issuedSessionIds.clear();
|
|
4104
|
+
journal.splice(0);
|
|
4105
|
+
entries = [];
|
|
4106
|
+
byCommand = new Map();
|
|
4107
|
+
byId = new Map();
|
|
4108
|
+
enablementById = new Map();
|
|
4109
|
+
captainAgent = undefined;
|
|
4110
|
+
captainAdapter = undefined;
|
|
4111
|
+
playerAgents = new Map();
|
|
4112
|
+
playerLedger.clear();
|
|
4113
|
+
playerTransactions.clear();
|
|
4114
|
+
session = undefined;
|
|
4115
|
+
sessionEmissionsOpen = false;
|
|
4116
|
+
closedGateAttempted = false;
|
|
4117
|
+
captainRuntime = undefined;
|
|
4118
|
+
captainSessionId = undefined;
|
|
4119
|
+
conversation = { kind: 'unopened' };
|
|
4120
|
+
mode = 'chat';
|
|
4121
|
+
pendingBossQuestions = undefined;
|
|
4122
|
+
lastError = undefined;
|
|
4123
|
+
journalSeq = 0;
|
|
4124
|
+
turnSequence = 0;
|
|
4125
|
+
lastAction = undefined;
|
|
4126
|
+
lastSettlementStatus = undefined;
|
|
4127
|
+
shuttingDown = false;
|
|
4128
|
+
if (cleanupFailures.length > 0) {
|
|
4129
|
+
terminallyDisposed = true;
|
|
4130
|
+
lifecycle = 'closed';
|
|
4131
|
+
}
|
|
4132
|
+
else {
|
|
4133
|
+
lifecycle = 'fresh';
|
|
4134
|
+
}
|
|
4135
|
+
return cleanupFailures;
|
|
4136
|
+
};
|
|
4137
|
+
const restoreShellSnapshot = async (initSession, untrusted) => {
|
|
4138
|
+
if (lifecycle !== 'fresh' || terminallyDisposed) {
|
|
4139
|
+
throw new Error('Captain shell restore requires a fresh shell');
|
|
4140
|
+
}
|
|
4141
|
+
if (initSession.signal.aborted) {
|
|
4142
|
+
throw new Error('cannot restore an aborted Captain session');
|
|
4143
|
+
}
|
|
4144
|
+
lifecycle = 'restoring';
|
|
4145
|
+
try {
|
|
4146
|
+
const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
|
|
4147
|
+
const built = await buildEnablements(options, loadModule);
|
|
4148
|
+
captainAgent = built.captainAgent;
|
|
4149
|
+
captainAdapter = captainAgent.adapter;
|
|
4150
|
+
playerAgents = built.playerAgents;
|
|
4151
|
+
assertSnapshotMatchesEnablements(snapshot, built.enablementById);
|
|
4152
|
+
installSession(initSession, false);
|
|
2808
4153
|
entries = built.entries;
|
|
2809
4154
|
byCommand = built.byCommand;
|
|
2810
4155
|
byId = built.byId;
|
|
2811
4156
|
enablementById = built.enablementById;
|
|
2812
|
-
for (const
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
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
|
+
}
|
|
2824
4171
|
captainRuntime = createCaptainRuntime({
|
|
2825
|
-
enabledPlaybooks:
|
|
4172
|
+
enabledPlaybooks: enabledCatalog(),
|
|
2826
4173
|
controller,
|
|
2827
4174
|
});
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
4175
|
+
if (typeof captainRuntime.restore !== 'function') {
|
|
4176
|
+
throw new Error('session Captain runtime does not support restore');
|
|
4177
|
+
}
|
|
4178
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
4179
|
+
for (const [index, frameSnapshot] of snapshot.frames.entries()) {
|
|
4180
|
+
const parentFrame = frames.at(-1);
|
|
4181
|
+
const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, index === 0
|
|
4182
|
+
? undefined
|
|
4183
|
+
: {
|
|
4184
|
+
frame: parentFrame,
|
|
4185
|
+
callId: frameSnapshot.parentCallId,
|
|
4186
|
+
});
|
|
4187
|
+
if (typeof frame.runtime.restore !== 'function') {
|
|
4188
|
+
throw new Error(`playbook ${frame.entry.id} runtime does not support restore`);
|
|
4189
|
+
}
|
|
4190
|
+
frames.push(frame);
|
|
4191
|
+
if (parentFrame)
|
|
4192
|
+
pendingChildParents.add(parentFrame);
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
await captainRuntime.restore(captainPlaybookSession(snapshot.captain.sessionId), snapshot.captain.runtime);
|
|
4196
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
4197
|
+
for (const [index, frame] of frames.entries()) {
|
|
4198
|
+
restoringPlayerSessionFrame = frame;
|
|
4199
|
+
try {
|
|
4200
|
+
await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
|
|
4201
|
+
}
|
|
4202
|
+
finally {
|
|
4203
|
+
restoringPlayerSessionFrame = undefined;
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
if (closedGateAttempted) {
|
|
4208
|
+
throw new Error('a runtime attempted a host emission during restore');
|
|
4209
|
+
}
|
|
4210
|
+
verifyRestoredRuntime(captainRuntime, snapshot.captain.runtime, INTERNAL_CAPTAIN_ID, false);
|
|
4211
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
4212
|
+
for (const [index, frame] of frames.entries()) {
|
|
4213
|
+
verifyRestoredRuntime(frame.runtime, snapshot.frames[index].runtime, frame.entry.id, true);
|
|
4214
|
+
}
|
|
4215
|
+
if (!isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)) {
|
|
4216
|
+
throw new Error('restored Captain-session player continuation changed during restore');
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
if (closedGateAttempted) {
|
|
4220
|
+
throw new Error('a runtime attempted a host emission during restore');
|
|
4221
|
+
}
|
|
4222
|
+
if (requireSession().signal.aborted) {
|
|
4223
|
+
throw new Error('Captain session aborted during restore');
|
|
4224
|
+
}
|
|
4225
|
+
for (const id of snapshot.issuedSessionIds)
|
|
4226
|
+
issuedSessionIds.add(id);
|
|
4227
|
+
journal.push(...snapshot.journal);
|
|
4228
|
+
journalSeq = snapshot.sequences.journal;
|
|
4229
|
+
turnSequence = snapshot.sequences.turn;
|
|
4230
|
+
conversation = snapshot.captain.conversation;
|
|
4231
|
+
captainSessionId = snapshot.captain.sessionId;
|
|
4232
|
+
lastAction = snapshot.lastAction;
|
|
4233
|
+
lastSettlementStatus = snapshot.lastSettlementStatus;
|
|
4234
|
+
mode = snapshot.mode;
|
|
4235
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
4236
|
+
pendingBossQuestions = snapshot.pendingBossQuestions;
|
|
4237
|
+
lastError = snapshot.lastError;
|
|
4238
|
+
}
|
|
4239
|
+
lifecycle = 'ready';
|
|
4240
|
+
// The final commit is deliberately one non-throwing assignment.
|
|
4241
|
+
sessionEmissionsOpen = true;
|
|
4242
|
+
}
|
|
4243
|
+
catch (error) {
|
|
4244
|
+
const cleanupFailures = await resetFailedRestore();
|
|
4245
|
+
if (cleanupFailures.length > 0) {
|
|
4246
|
+
throw new AggregateError([error, ...cleanupFailures], 'Captain shell restore and cleanup failed');
|
|
4247
|
+
}
|
|
4248
|
+
throw error;
|
|
4249
|
+
}
|
|
4250
|
+
};
|
|
4251
|
+
return {
|
|
4252
|
+
async init(initSession) {
|
|
4253
|
+
if (lifecycle !== 'fresh' || terminallyDisposed) {
|
|
4254
|
+
throw new Error('Captain shell requires a fresh instance for init');
|
|
4255
|
+
}
|
|
4256
|
+
if (initSession.signal.aborted) {
|
|
4257
|
+
throw new Error('cannot initialize an aborted Captain session');
|
|
4258
|
+
}
|
|
4259
|
+
lifecycle = 'initializing';
|
|
4260
|
+
try {
|
|
4261
|
+
installSession(initSession, true);
|
|
4262
|
+
const built = await buildEnablements(options, loadModule);
|
|
4263
|
+
entries = built.entries;
|
|
4264
|
+
byCommand = built.byCommand;
|
|
4265
|
+
byId = built.byId;
|
|
4266
|
+
enablementById = built.enablementById;
|
|
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));
|
|
4272
|
+
}
|
|
4273
|
+
await setMode('chat', 'init');
|
|
4274
|
+
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
4275
|
+
// engagement stack, with its own playbook session id.
|
|
4276
|
+
captainSessionId = allocateSessionId();
|
|
4277
|
+
captainRuntime = createCaptainRuntime({
|
|
4278
|
+
enabledPlaybooks: enabledCatalog(),
|
|
4279
|
+
controller,
|
|
4280
|
+
});
|
|
4281
|
+
await captainRuntime.init(captainPlaybookSession(captainSessionId));
|
|
4282
|
+
lifecycle = 'ready';
|
|
4283
|
+
}
|
|
4284
|
+
catch (error) {
|
|
4285
|
+
terminallyDisposed = true;
|
|
4286
|
+
lifecycle = 'closed';
|
|
4287
|
+
throw error;
|
|
4288
|
+
}
|
|
2835
4289
|
},
|
|
4290
|
+
exportSnapshot: exportShellSnapshot,
|
|
4291
|
+
restore: restoreShellSnapshot,
|
|
2836
4292
|
async handleBossTurn(turn, context) {
|
|
4293
|
+
if (lifecycle !== 'ready' || terminallyDisposed) {
|
|
4294
|
+
throw new Error('init must be called first, or restore must complete before handling a Boss turn');
|
|
4295
|
+
}
|
|
2837
4296
|
requireSession();
|
|
2838
4297
|
if (!captainRuntime) {
|
|
2839
4298
|
throw new Error('init must be called first');
|
|
@@ -2851,6 +4310,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2851
4310
|
const parsed = resolveCommandTurn(turn.prompt);
|
|
2852
4311
|
activeTurn = {
|
|
2853
4312
|
id: ++turnSequence,
|
|
4313
|
+
captainSyncedJournalSeq: journalSeq,
|
|
2854
4314
|
bossText: turn.prompt,
|
|
2855
4315
|
authoritativeText: parsed?.authoritativeText ?? turn.prompt,
|
|
2856
4316
|
...(parsed ? { resolution: parsed.resolution } : {}),
|
|
@@ -2858,6 +4318,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2858
4318
|
presentationAttempted: false,
|
|
2859
4319
|
settlementFacts: [],
|
|
2860
4320
|
effectThrows: new Set(),
|
|
4321
|
+
controlFailures: new Set(),
|
|
4322
|
+
settingsPreflightFailures: new Set(),
|
|
2861
4323
|
suppliedIdentifiers: new Set(),
|
|
2862
4324
|
outcomeRecorded: false,
|
|
2863
4325
|
};
|
|
@@ -2876,11 +4338,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2876
4338
|
new Error('the session Captain turn failed at its boundary'));
|
|
2877
4339
|
}
|
|
2878
4340
|
else if (result.outcome === 'aborted') {
|
|
2879
|
-
|
|
4341
|
+
markConversationUnsynchronized();
|
|
2880
4342
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
2881
4343
|
activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
|
|
2882
4344
|
journalOutcome([...activeTurn.settlementFacts]);
|
|
2883
4345
|
}
|
|
4346
|
+
if (context.signal.aborted) {
|
|
4347
|
+
throw context.signal.reason;
|
|
4348
|
+
}
|
|
2884
4349
|
}
|
|
2885
4350
|
else if (result.outcome !== 'suspended' &&
|
|
2886
4351
|
!context.signal.aborted &&
|
|
@@ -2894,10 +4359,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2894
4359
|
}
|
|
2895
4360
|
catch (error) {
|
|
2896
4361
|
if (context.signal.aborted) {
|
|
2897
|
-
|
|
4362
|
+
markConversationUnsynchronized();
|
|
2898
4363
|
throw error;
|
|
2899
4364
|
}
|
|
2900
|
-
const controlFailure = activeTurn?.
|
|
4365
|
+
const controlFailure = activeTurn?.controlFailures.has(error) === true;
|
|
2901
4366
|
await settleTurnFailure(context, error);
|
|
2902
4367
|
if (activeTurn?.presentationError !== undefined) {
|
|
2903
4368
|
throw activeTurn.presentationError;
|
|
@@ -2909,7 +4374,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2909
4374
|
}
|
|
2910
4375
|
finally {
|
|
2911
4376
|
if (context.signal.aborted) {
|
|
2912
|
-
|
|
4377
|
+
markConversationUnsynchronized();
|
|
2913
4378
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
2914
4379
|
activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
|
|
2915
4380
|
journalOutcome([...activeTurn.settlementFacts]);
|
|
@@ -2926,10 +4391,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2926
4391
|
}
|
|
2927
4392
|
},
|
|
2928
4393
|
async prepareDispose() {
|
|
4394
|
+
if (lifecycle === 'initializing' || lifecycle === 'restoring') {
|
|
4395
|
+
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
4396
|
+
}
|
|
2929
4397
|
activeContext = undefined;
|
|
2930
4398
|
await teardown();
|
|
2931
4399
|
},
|
|
2932
4400
|
async dispose() {
|
|
4401
|
+
if (lifecycle === 'initializing' || lifecycle === 'restoring') {
|
|
4402
|
+
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
4403
|
+
}
|
|
2933
4404
|
activeContext = undefined;
|
|
2934
4405
|
await teardown();
|
|
2935
4406
|
},
|
|
@@ -2937,6 +4408,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2937
4408
|
// CAPTAIN-16: dispose every active frame from leaf to root, then the
|
|
2938
4409
|
// session Captain last.
|
|
2939
4410
|
async function teardown() {
|
|
4411
|
+
terminallyDisposed = true;
|
|
4412
|
+
lifecycle = 'disposing';
|
|
2940
4413
|
let failure;
|
|
2941
4414
|
try {
|
|
2942
4415
|
await disposeStack('dispose');
|
|
@@ -2955,6 +4428,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2955
4428
|
failure ??= error;
|
|
2956
4429
|
}
|
|
2957
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();
|
|
4434
|
+
lifecycle = 'closed';
|
|
2958
4435
|
if (failure !== undefined)
|
|
2959
4436
|
throw failure;
|
|
2960
4437
|
}
|