@sublang/playbook 7.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/docs/cli.md +88 -43
- package/docs/configuration.md +221 -119
- package/docs/embedding.md +78 -27
- package/package.json +4 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
- package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
- package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
- package/reference/sdlc/code.playbook/bin/run.js +283 -298
- package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
- package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +18 -15
- package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +25 -15
- package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
- package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
- package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
- package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +29 -23
- package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +45 -24
- package/slc/link.md +297 -135
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +21 -16
- package/src/runtime.ts +20 -23
- package/src/xstate-playbook-runtime.d.ts +34 -20
- package/src/xstate-playbook-runtime.js +973 -400
- package/src/xstate-playbook-runtime.ts +1203 -457
- package/src/xstate-runtime.d.ts +17 -7
- package/src/xstate-runtime.js +198 -81
- package/src/xstate-runtime.ts +339 -112
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
5
|
import PQueue from 'p-queue';
|
|
6
|
-
import {
|
|
6
|
+
import { isAgentCallSettingsError, } from '@sublang/cligent/tmux-play';
|
|
7
|
+
import { assertPlaybookRuntimeSnapshot, hiddenControlEnvelope, registerPlaybookAbortCleanup, snapshotJsonValue, validatePlayerResult, } from '../../../src/xstate-runtime.js';
|
|
7
8
|
import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
|
|
8
9
|
class VisibilityControlError extends Error {
|
|
9
10
|
constructor(cause) {
|
|
@@ -11,10 +12,33 @@ class VisibilityControlError extends Error {
|
|
|
11
12
|
this.name = 'VisibilityControlError';
|
|
12
13
|
}
|
|
13
14
|
}
|
|
15
|
+
class AgentSettingsPreflightError extends Error {
|
|
16
|
+
rejection;
|
|
17
|
+
constructor(rejection) {
|
|
18
|
+
super('agent rejected supplied complete call settings', {
|
|
19
|
+
cause: rejection,
|
|
20
|
+
});
|
|
21
|
+
this.rejection = rejection;
|
|
22
|
+
this.name = 'AgentSettingsPreflightError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function classifySettingsCall(call) {
|
|
26
|
+
try {
|
|
27
|
+
return await call();
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (isAgentCallSettingsError(error)) {
|
|
31
|
+
throw new AgentSettingsPreflightError(error);
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
14
36
|
const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
|
|
15
37
|
const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
|
|
16
38
|
const INTERNAL_CAPTAIN_ID = 'captain';
|
|
17
39
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
40
|
+
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
41
|
+
const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
18
42
|
function parseRegisteredCommand(prompt) {
|
|
19
43
|
const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(prompt.trim());
|
|
20
44
|
if (!match)
|
|
@@ -102,6 +126,34 @@ function stateDigestLine(state, description) {
|
|
|
102
126
|
digestLine `status ${state.status}`,
|
|
103
127
|
].join('; ');
|
|
104
128
|
}
|
|
129
|
+
// CAPTAIN-5's mirrored ledger member holds the runtime snapshot's
|
|
130
|
+
// pending-question projection — entries of `{ questionId, asker, question,
|
|
131
|
+
// sourceItem }` — never a raw telemetry payload: the linked runtime's state
|
|
132
|
+
// telemetry carries the singular full-context question, whose extra
|
|
133
|
+
// runtime-internal fields (`resumeStateId`) fail the durable snapshot's
|
|
134
|
+
// leaf-projection equality (CAPTAIN-41) and with it headless settlement.
|
|
135
|
+
function mirroredBossQuestions(value) {
|
|
136
|
+
if (value === undefined || value === null)
|
|
137
|
+
return undefined;
|
|
138
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
139
|
+
return entries.map((entry) => {
|
|
140
|
+
if (typeof entry !== 'object' || entry === null)
|
|
141
|
+
return entry;
|
|
142
|
+
const record = entry;
|
|
143
|
+
const projected = {};
|
|
144
|
+
if (record.questionId !== undefined) {
|
|
145
|
+
projected.questionId = record.questionId;
|
|
146
|
+
}
|
|
147
|
+
if (record.asker !== undefined)
|
|
148
|
+
projected.asker = record.asker;
|
|
149
|
+
if (record.question !== undefined)
|
|
150
|
+
projected.question = record.question;
|
|
151
|
+
if (record.sourceItem !== undefined) {
|
|
152
|
+
projected.sourceItem = record.sourceItem;
|
|
153
|
+
}
|
|
154
|
+
return projected;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
105
157
|
function pendingQuestionLines(pending) {
|
|
106
158
|
const list = Array.isArray(pending)
|
|
107
159
|
? pending
|
|
@@ -127,7 +179,14 @@ function pendingQuestionLines(pending) {
|
|
|
127
179
|
: typeof record.id === 'string'
|
|
128
180
|
? record.id
|
|
129
181
|
: undefined;
|
|
130
|
-
const
|
|
182
|
+
const asker = typeof record.asker === 'object' && record.asker !== null
|
|
183
|
+
? record.asker
|
|
184
|
+
: undefined;
|
|
185
|
+
const askerLabel = asker?.kind === 'captain'
|
|
186
|
+
? 'Captain'
|
|
187
|
+
: asker?.kind === 'role' && typeof asker.roleId === 'string'
|
|
188
|
+
? asker.roleId
|
|
189
|
+
: undefined;
|
|
131
190
|
const text = typeof record.question === 'string'
|
|
132
191
|
? record.question
|
|
133
192
|
: typeof record.text === 'string'
|
|
@@ -137,9 +196,9 @@ function pendingQuestionLines(pending) {
|
|
|
137
196
|
// fragment is never handed back to the tag as a value: bounding it a
|
|
138
197
|
// second time would cut the line at the seam's limit and drop whatever
|
|
139
198
|
// the shell had already written after the long part.
|
|
140
|
-
const asked =
|
|
199
|
+
const asked = askerLabel === undefined
|
|
141
200
|
? digestLine `${quoteEvidence(text)}`
|
|
142
|
-
: digestLine `${quoteEvidence(
|
|
201
|
+
: digestLine `${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
|
|
143
202
|
const marker = id === undefined ? '' : digestLine `(${quoteEvidence(id)}) `;
|
|
144
203
|
lines.push(`- ${marker}${asked}`);
|
|
145
204
|
}
|
|
@@ -179,14 +238,20 @@ function renderJournalPayload(payload) {
|
|
|
179
238
|
const raw = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
180
239
|
return raw ?? 'null';
|
|
181
240
|
}
|
|
182
|
-
function
|
|
241
|
+
function renderJournalDigest(records, heading) {
|
|
183
242
|
const lines = records.map((record) => `${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(record.payload)}`);
|
|
184
243
|
return [
|
|
185
|
-
|
|
244
|
+
heading,
|
|
186
245
|
'The labeled ControlView and catalog digest blocks outrank conversation memory.',
|
|
187
246
|
...(lines.length === 0 ? ['(no earlier turns)'] : lines),
|
|
188
247
|
].join('\n');
|
|
189
248
|
}
|
|
249
|
+
function renderReseedDigest(records) {
|
|
250
|
+
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.');
|
|
251
|
+
}
|
|
252
|
+
function renderCatchUpDigest(records) {
|
|
253
|
+
return renderJournalDigest(records, 'This retained conversation missed the host journal records below. Treat this deterministic journal suffix as authoritative.');
|
|
254
|
+
}
|
|
190
255
|
// DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
|
|
191
256
|
// internal control vocabulary.
|
|
192
257
|
const CONTROL_VOCABULARY = [
|
|
@@ -299,22 +364,31 @@ function proseRejection(prose, liveSessionIds = [], liveStateIds = [], suppliedI
|
|
|
299
364
|
return undefined;
|
|
300
365
|
}
|
|
301
366
|
// DR-013 A1: adapters with no provider-enforced tool-restriction surface.
|
|
302
|
-
// Cligent's Codex
|
|
303
|
-
// empty list that expresses tool-free — because
|
|
304
|
-
// cannot enforce one, so requesting it fails
|
|
305
|
-
// model is reached. Omitting the option is the
|
|
306
|
-
// run a control call at all; its isolation then
|
|
307
|
-
// hidden-judge envelope below rather than on provider
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
367
|
+
// Cligent's Codex, Kimi, and OpenCode adapters reject any `allowedTools`
|
|
368
|
+
// value — including the empty list that expresses tool-free — because their
|
|
369
|
+
// supported provider surfaces cannot enforce one, so requesting it fails
|
|
370
|
+
// every control call before the model is reached. Omitting the option is the
|
|
371
|
+
// only way such an adapter can run a control call at all; its isolation then
|
|
372
|
+
// rests on the authored hidden-judge envelope below rather than on provider
|
|
373
|
+
// enforcement.
|
|
374
|
+
const CAPTAIN_TOOL_ISOLATION_BY_ADAPTER = {
|
|
375
|
+
claude: 'provider-enforced',
|
|
376
|
+
codex: 'prompt-only',
|
|
377
|
+
gemini: 'provider-enforced',
|
|
378
|
+
kimi: 'prompt-only',
|
|
379
|
+
opencode: 'prompt-only',
|
|
380
|
+
};
|
|
381
|
+
function requiresPromptOnlyToolIsolation(captainAdapter) {
|
|
382
|
+
return (Object.hasOwn(CAPTAIN_TOOL_ISOLATION_BY_ADAPTER, captainAdapter) &&
|
|
383
|
+
CAPTAIN_TOOL_ISOLATION_BY_ADAPTER[captainAdapter] === 'prompt-only');
|
|
384
|
+
}
|
|
311
385
|
// The tool half of a control call's options. An empty allowlist means "no
|
|
312
386
|
// tools available" and is distinct from omission, which grants the adapter's
|
|
313
387
|
// full native tool surface — so omit only where the empty list would be
|
|
314
388
|
// refused, and keep requesting enforcement whenever the adapter is unknown.
|
|
315
389
|
function controlCallToolOptions(captainAdapter) {
|
|
316
390
|
if (captainAdapter !== undefined &&
|
|
317
|
-
|
|
391
|
+
requiresPromptOnlyToolIsolation(captainAdapter)) {
|
|
318
392
|
return {};
|
|
319
393
|
}
|
|
320
394
|
return { allowedTools: [] };
|
|
@@ -330,14 +404,6 @@ function forwardedToolOptions(requested, captainAdapter) {
|
|
|
330
404
|
return controlCallToolOptions(captainAdapter);
|
|
331
405
|
return { allowedTools: requested };
|
|
332
406
|
}
|
|
333
|
-
function readCaptainAdapter(options) {
|
|
334
|
-
if (typeof options !== 'object' || options === null)
|
|
335
|
-
return undefined;
|
|
336
|
-
const adapter = options.captainAdapter;
|
|
337
|
-
return typeof adapter === 'string' && adapter.length > 0
|
|
338
|
-
? adapter
|
|
339
|
-
: undefined;
|
|
340
|
-
}
|
|
341
407
|
const hiddenJudgeEnvelope = hiddenControlEnvelope;
|
|
342
408
|
// CAPTAIN-20: the result-phase block the shell supplies inside the closing
|
|
343
409
|
// reply call's envelope — the settlement's outcome-report facts verbatim, the
|
|
@@ -408,10 +474,28 @@ function isValidRegistryEntry(value) {
|
|
|
408
474
|
if (typeof value !== 'object' || value === null)
|
|
409
475
|
return false;
|
|
410
476
|
const e = value;
|
|
477
|
+
if (!Array.isArray(e.requiredRoleIds) ||
|
|
478
|
+
e.requiredRoleIds.some((role) => typeof role !== 'string' ||
|
|
479
|
+
!ROLE_ID_PATTERN.test(role) ||
|
|
480
|
+
role === INTERNAL_CAPTAIN_ID) ||
|
|
481
|
+
new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
|
|
482
|
+
!Array.isArray(e.concurrentRoleSets)) {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
const roles = new Set(e.requiredRoleIds);
|
|
486
|
+
const concurrency = e.concurrentRoleSets;
|
|
487
|
+
if (concurrency.some((set) => !Array.isArray(set) ||
|
|
488
|
+
set.length < 2 ||
|
|
489
|
+
set.some((role) => typeof role !== 'string' || !roles.has(role)) ||
|
|
490
|
+
new Set(set).size !== set.length) ||
|
|
491
|
+
new Set(concurrency.map((set) => JSON.stringify(set))).size !==
|
|
492
|
+
concurrency.length) {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
411
495
|
return (typeof e.id === 'string' &&
|
|
412
496
|
typeof e.command === 'string' &&
|
|
413
497
|
typeof e.intent === 'string' &&
|
|
414
|
-
|
|
498
|
+
e.artifactSchema === 2 &&
|
|
415
499
|
typeof e.validateOptions === 'function' &&
|
|
416
500
|
typeof e.createRuntime === 'function');
|
|
417
501
|
}
|
|
@@ -468,14 +552,165 @@ function snapshotUuid(value, path) {
|
|
|
468
552
|
}
|
|
469
553
|
return id;
|
|
470
554
|
}
|
|
555
|
+
function snapshotPermissions(value, path) {
|
|
556
|
+
if (value === undefined)
|
|
557
|
+
return undefined;
|
|
558
|
+
const record = snapshotRecord(value, path);
|
|
559
|
+
rejectSnapshotKeys(record, ['mode', 'fileWrite', 'shellExecute', 'networkAccess', 'writablePaths'], path);
|
|
560
|
+
const normalized = {};
|
|
561
|
+
if (record.mode !== undefined) {
|
|
562
|
+
if (record.mode !== 'auto' && record.mode !== 'bypass') {
|
|
563
|
+
throw new TypeError(`${path}.mode must be "auto" or "bypass"`);
|
|
564
|
+
}
|
|
565
|
+
normalized.mode = record.mode;
|
|
566
|
+
}
|
|
567
|
+
for (const key of [
|
|
568
|
+
'fileWrite',
|
|
569
|
+
'shellExecute',
|
|
570
|
+
'networkAccess',
|
|
571
|
+
]) {
|
|
572
|
+
const level = record[key];
|
|
573
|
+
if (level === undefined)
|
|
574
|
+
continue;
|
|
575
|
+
if (level !== 'allow' && level !== 'ask' && level !== 'deny') {
|
|
576
|
+
throw new TypeError(`${path}.${key} must be "allow", "ask", or "deny"`);
|
|
577
|
+
}
|
|
578
|
+
normalized[key] = level;
|
|
579
|
+
}
|
|
580
|
+
if (record.writablePaths !== undefined) {
|
|
581
|
+
if (!Array.isArray(record.writablePaths) ||
|
|
582
|
+
record.writablePaths.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
|
|
583
|
+
throw new TypeError(`${path}.writablePaths must be an array of non-empty strings`);
|
|
584
|
+
}
|
|
585
|
+
normalized.writablePaths = [...record.writablePaths];
|
|
586
|
+
}
|
|
587
|
+
return normalized;
|
|
588
|
+
}
|
|
589
|
+
function livePermissions(value) {
|
|
590
|
+
if (value === undefined)
|
|
591
|
+
return undefined;
|
|
592
|
+
return {
|
|
593
|
+
...(value.mode === undefined ? {} : { mode: value.mode }),
|
|
594
|
+
...(value.fileWrite === undefined ? {} : { fileWrite: value.fileWrite }),
|
|
595
|
+
...(value.shellExecute === undefined
|
|
596
|
+
? {}
|
|
597
|
+
: { shellExecute: value.shellExecute }),
|
|
598
|
+
...(value.networkAccess === undefined
|
|
599
|
+
? {}
|
|
600
|
+
: { networkAccess: value.networkAccess }),
|
|
601
|
+
...(value.writablePaths === undefined
|
|
602
|
+
? {}
|
|
603
|
+
: { writablePaths: [...value.writablePaths] }),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function snapshotFixedAgent(value, path) {
|
|
607
|
+
const record = snapshotRecord(value, path);
|
|
608
|
+
rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions'], path);
|
|
609
|
+
const adapter = snapshotString(record.adapter, `${path}.adapter`);
|
|
610
|
+
const instruction = record.instruction === undefined
|
|
611
|
+
? undefined
|
|
612
|
+
: snapshotString(record.instruction, `${path}.instruction`, true);
|
|
613
|
+
const permissions = snapshotPermissions(record.permissions, `${path}.permissions`);
|
|
614
|
+
return {
|
|
615
|
+
adapter,
|
|
616
|
+
...(instruction === undefined ? {} : { instruction }),
|
|
617
|
+
...(permissions === undefined ? {} : { permissions }),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
function snapshotPlayerSessions(value, path) {
|
|
621
|
+
const sessions = snapshotRecord(value, path);
|
|
622
|
+
return Object.fromEntries(Object.entries(sessions).map(([playerId, raw]) => {
|
|
623
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
624
|
+
throw new TypeError(`${path} has invalid player id ${JSON.stringify(playerId)}`);
|
|
625
|
+
}
|
|
626
|
+
const record = snapshotRecord(raw, `${path}.${playerId}`);
|
|
627
|
+
rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions', 'resumeToken'], `${path}.${playerId}`);
|
|
628
|
+
const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'resumeToken')), `${path}.${playerId}`);
|
|
629
|
+
const resumeToken = record.resumeToken === undefined
|
|
630
|
+
? undefined
|
|
631
|
+
: snapshotString(record.resumeToken, `${path}.${playerId}.resumeToken`);
|
|
632
|
+
return [
|
|
633
|
+
playerId,
|
|
634
|
+
{ ...fixed, ...(resumeToken === undefined ? {} : { resumeToken }) },
|
|
635
|
+
];
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
function snapshotFrameRoleBindings(value, path) {
|
|
639
|
+
const bindings = snapshotRecord(value, path);
|
|
640
|
+
return Object.fromEntries(Object.entries(bindings).map(([roleId, raw]) => {
|
|
641
|
+
if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
|
|
642
|
+
throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
|
|
643
|
+
}
|
|
644
|
+
const playerId = snapshotString(raw, `${path}.${roleId}`);
|
|
645
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
646
|
+
throw new TypeError(`${path}.${roleId} has invalid player id`);
|
|
647
|
+
}
|
|
648
|
+
return [roleId, playerId];
|
|
649
|
+
}));
|
|
650
|
+
}
|
|
651
|
+
function normalizeHostPlayerResult(value, expectedPlayerId) {
|
|
652
|
+
const path = 'tmux-play delegated-player result';
|
|
653
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
654
|
+
throw new TypeError(`${path} must be an object`);
|
|
655
|
+
}
|
|
656
|
+
const prototype = Object.getPrototypeOf(value);
|
|
657
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
658
|
+
throw new TypeError(`${path} must be a plain JSON object`);
|
|
659
|
+
}
|
|
660
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
661
|
+
const allowedKeys = new Set([
|
|
662
|
+
'status',
|
|
663
|
+
'playerId',
|
|
664
|
+
'turnId',
|
|
665
|
+
'resumeToken',
|
|
666
|
+
'finalText',
|
|
667
|
+
'error',
|
|
668
|
+
]);
|
|
669
|
+
const normalized = {};
|
|
670
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
671
|
+
if (typeof key === 'symbol') {
|
|
672
|
+
throw new TypeError(`${path} must not contain symbol-keyed properties`);
|
|
673
|
+
}
|
|
674
|
+
const descriptor = descriptors[key];
|
|
675
|
+
if (!allowedKeys.has(key)) {
|
|
676
|
+
throw new TypeError(`${path} has unknown field ${JSON.stringify(key)}`);
|
|
677
|
+
}
|
|
678
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
|
|
679
|
+
throw new TypeError(`${path}.${key} must be an enumerable data property`);
|
|
680
|
+
}
|
|
681
|
+
// Cligent deliberately exposes optional result members as own
|
|
682
|
+
// `undefined` data properties. Omit only those members before taking the
|
|
683
|
+
// immutable JSON snapshot; every other value still passes the strict
|
|
684
|
+
// JSON validator below.
|
|
685
|
+
if (descriptor.value !== undefined)
|
|
686
|
+
normalized[key] = descriptor.value;
|
|
687
|
+
}
|
|
688
|
+
const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
|
|
689
|
+
rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'], path);
|
|
690
|
+
if (record.playerId !== expectedPlayerId) {
|
|
691
|
+
throw new TypeError(`${path}.playerId does not match the requested player`);
|
|
692
|
+
}
|
|
693
|
+
snapshotInteger(record.turnId, `${path}.turnId`, 1);
|
|
694
|
+
return validatePlayerResult({
|
|
695
|
+
status: record.status,
|
|
696
|
+
...(record.resumeToken === undefined
|
|
697
|
+
? {}
|
|
698
|
+
: { resumeToken: record.resumeToken }),
|
|
699
|
+
...(record.finalText === undefined
|
|
700
|
+
? {}
|
|
701
|
+
: { finalText: record.finalText }),
|
|
702
|
+
...(record.error === undefined ? {} : { error: record.error }),
|
|
703
|
+
}, path);
|
|
704
|
+
}
|
|
471
705
|
/** Validate, detach, and freeze one untrusted shell snapshot. */
|
|
472
|
-
function assertPlaybookCaptainShellSnapshot(value) {
|
|
706
|
+
export function assertPlaybookCaptainShellSnapshot(value) {
|
|
473
707
|
const detached = snapshotJsonValue(value, 'Captain shell snapshot');
|
|
474
708
|
const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
|
|
475
709
|
const mode = snapshot.mode;
|
|
476
710
|
const commonKeys = [
|
|
477
711
|
'schemaVersion',
|
|
478
712
|
'captain',
|
|
713
|
+
'playerSessions',
|
|
479
714
|
'issuedSessionIds',
|
|
480
715
|
'sequences',
|
|
481
716
|
'journal',
|
|
@@ -490,7 +725,6 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
490
725
|
rejectSnapshotKeys(snapshot, [
|
|
491
726
|
...commonKeys,
|
|
492
727
|
'frames',
|
|
493
|
-
'rootPlayerResumeTokens',
|
|
494
728
|
'pendingBossQuestions',
|
|
495
729
|
'lastError',
|
|
496
730
|
], 'Captain shell snapshot');
|
|
@@ -498,20 +732,31 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
498
732
|
else {
|
|
499
733
|
throw new TypeError('Captain shell snapshot.mode must be "chat" or "engaged.parked"');
|
|
500
734
|
}
|
|
501
|
-
if (snapshot.schemaVersion !==
|
|
502
|
-
throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected
|
|
735
|
+
if (snapshot.schemaVersion !== 3) {
|
|
736
|
+
throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
|
|
503
737
|
}
|
|
504
738
|
const captain = snapshotRecord(snapshot.captain, 'Captain shell snapshot.captain');
|
|
505
|
-
rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'conversation'], 'Captain shell snapshot.captain');
|
|
739
|
+
rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'agent', 'conversation'], 'Captain shell snapshot.captain');
|
|
506
740
|
const captainSessionId = snapshotUuid(captain.sessionId, 'Captain shell snapshot.captain.sessionId');
|
|
507
741
|
const captainRuntime = assertPlaybookRuntimeSnapshot(captain.runtime, INTERNAL_CAPTAIN_ID);
|
|
742
|
+
const captainAgent = snapshotFixedAgent(captain.agent, 'Captain shell snapshot.captain.agent');
|
|
508
743
|
const conversation = snapshotRecord(captain.conversation, 'Captain shell snapshot.captain.conversation');
|
|
509
744
|
let normalizedConversation;
|
|
510
745
|
if (conversation.kind === 'pinned') {
|
|
511
746
|
rejectSnapshotKeys(conversation, ['kind', 'token'], 'Captain shell snapshot.captain.conversation');
|
|
512
747
|
normalizedConversation = {
|
|
513
748
|
kind: 'pinned',
|
|
514
|
-
token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token'
|
|
749
|
+
token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token'),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
else if (conversation.kind === 'needsCatchUp') {
|
|
753
|
+
rejectSnapshotKeys(conversation, ['kind', 'resume', 'afterJournalSeq'], 'Captain shell snapshot.captain.conversation');
|
|
754
|
+
normalizedConversation = {
|
|
755
|
+
kind: 'needsCatchUp',
|
|
756
|
+
resume: conversation.resume === false
|
|
757
|
+
? false
|
|
758
|
+
: snapshotString(conversation.resume, 'Captain shell snapshot.captain.conversation.resume'),
|
|
759
|
+
afterJournalSeq: snapshotInteger(conversation.afterJournalSeq, 'Captain shell snapshot.captain.conversation.afterJournalSeq'),
|
|
515
760
|
};
|
|
516
761
|
}
|
|
517
762
|
else if (conversation.kind === 'unopened' ||
|
|
@@ -579,6 +824,19 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
579
824
|
bossRecords !== turnSequence) {
|
|
580
825
|
throw new TypeError('Captain shell snapshot sequences do not match the complete journal');
|
|
581
826
|
}
|
|
827
|
+
const emptyHistory = turnSequence === 0 && normalizedJournal.length === 0;
|
|
828
|
+
if ((normalizedConversation.kind === 'unopened') !== emptyHistory) {
|
|
829
|
+
throw new TypeError('Captain shell snapshot history is empty exactly when its conversation is unopened');
|
|
830
|
+
}
|
|
831
|
+
if (normalizedConversation.kind === 'needsCatchUp' &&
|
|
832
|
+
normalizedConversation.afterJournalSeq >= journalSequence) {
|
|
833
|
+
throw new TypeError('Captain shell snapshot catch-up watermark must precede the current journal sequence');
|
|
834
|
+
}
|
|
835
|
+
if (normalizedConversation.kind === 'needsCatchUp' &&
|
|
836
|
+
((normalizedConversation.resume === false) !==
|
|
837
|
+
(normalizedConversation.afterJournalSeq === 0))) {
|
|
838
|
+
throw new TypeError('Captain shell snapshot catch-up resume is fresh exactly at journal watermark zero');
|
|
839
|
+
}
|
|
582
840
|
let lastAction;
|
|
583
841
|
if (snapshot.lastAction !== undefined) {
|
|
584
842
|
if (typeof snapshot.lastAction !== 'string' ||
|
|
@@ -595,13 +853,16 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
595
853
|
}
|
|
596
854
|
lastSettlementStatus = snapshot.lastSettlementStatus;
|
|
597
855
|
}
|
|
856
|
+
const playerSessions = snapshotPlayerSessions(snapshot.playerSessions, 'Captain shell snapshot.playerSessions');
|
|
598
857
|
const common = {
|
|
599
|
-
schemaVersion:
|
|
858
|
+
schemaVersion: 3,
|
|
600
859
|
captain: {
|
|
601
860
|
sessionId: captainSessionId,
|
|
602
861
|
runtime: captainRuntime,
|
|
862
|
+
agent: captainAgent,
|
|
603
863
|
conversation: normalizedConversation,
|
|
604
864
|
},
|
|
865
|
+
playerSessions,
|
|
605
866
|
issuedSessionIds: issued,
|
|
606
867
|
sequences: { turn: turnSequence, journal: journalSequence },
|
|
607
868
|
journal: normalizedJournal,
|
|
@@ -610,6 +871,17 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
610
871
|
? {}
|
|
611
872
|
: { lastSettlementStatus }),
|
|
612
873
|
};
|
|
874
|
+
if (captainRuntime.state.status !== 'active' ||
|
|
875
|
+
!captainRuntime.state.quiescent ||
|
|
876
|
+
!captainRuntime.state.tags.includes('playbook.parked') ||
|
|
877
|
+
captainRuntime.suspendedCall !== undefined ||
|
|
878
|
+
Object.keys(captainRuntime.roleResumeTokens).length > 0 ||
|
|
879
|
+
captainRuntime.pendingBossQuestions.length > 0) {
|
|
880
|
+
throw new TypeError('Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended');
|
|
881
|
+
}
|
|
882
|
+
if (captainRuntime.sequences.turn !== turnSequence) {
|
|
883
|
+
throw new TypeError('Captain shell snapshot Captain and shell turn sequences must match');
|
|
884
|
+
}
|
|
613
885
|
if (mode === 'chat') {
|
|
614
886
|
return snapshotJsonValue({ ...common, mode }, 'Captain shell snapshot');
|
|
615
887
|
}
|
|
@@ -626,6 +898,8 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
626
898
|
'depth',
|
|
627
899
|
'parentSessionId',
|
|
628
900
|
'parentCallId',
|
|
901
|
+
'options',
|
|
902
|
+
'roleBindings',
|
|
629
903
|
'runtime',
|
|
630
904
|
], `Captain shell snapshot.frames[${index}]`);
|
|
631
905
|
const playbookId = snapshotString(frame.playbookId, `Captain shell snapshot.frames[${index}].playbookId`);
|
|
@@ -639,6 +913,8 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
639
913
|
? undefined
|
|
640
914
|
: snapshotString(frame.parentCallId, `Captain shell snapshot.frames[${index}].parentCallId`);
|
|
641
915
|
const runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, { allowSuspendedCall: true });
|
|
916
|
+
const options = frame.options;
|
|
917
|
+
const roleBindings = snapshotFrameRoleBindings(frame.roleBindings, `Captain shell snapshot.frames[${index}].roleBindings`);
|
|
642
918
|
normalizedFrames.push({
|
|
643
919
|
playbookId,
|
|
644
920
|
sessionId,
|
|
@@ -646,14 +922,11 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
646
922
|
depth,
|
|
647
923
|
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
648
924
|
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
925
|
+
options,
|
|
926
|
+
roleBindings,
|
|
649
927
|
runtime,
|
|
650
928
|
});
|
|
651
929
|
}
|
|
652
|
-
const rootTokens = snapshotRecord(snapshot.rootPlayerResumeTokens, 'Captain shell snapshot.rootPlayerResumeTokens');
|
|
653
|
-
const normalizedRootTokens = Object.fromEntries(Object.entries(rootTokens).map(([playerId, token]) => [
|
|
654
|
-
playerId,
|
|
655
|
-
snapshotString(token, `Captain shell snapshot.rootPlayerResumeTokens.${playerId}`),
|
|
656
|
-
]));
|
|
657
930
|
let normalizedLastError;
|
|
658
931
|
if (snapshot.lastError !== undefined) {
|
|
659
932
|
const error = snapshotRecord(snapshot.lastError, 'Captain shell snapshot.lastError');
|
|
@@ -663,11 +936,80 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
663
936
|
message: snapshotString(error.message, 'Captain shell snapshot.lastError.message', true),
|
|
664
937
|
};
|
|
665
938
|
}
|
|
939
|
+
const activePlaybooks = new Set();
|
|
940
|
+
const activeSessionIds = new Set([captainSessionId]);
|
|
941
|
+
const issuedIds = new Set(issued);
|
|
942
|
+
const rootSessionId = normalizedFrames[0].sessionId;
|
|
943
|
+
for (const [index, frame] of normalizedFrames.entries()) {
|
|
944
|
+
if (activePlaybooks.has(frame.playbookId)) {
|
|
945
|
+
throw new TypeError('Captain shell snapshot engagement path must not contain a playbook cycle');
|
|
946
|
+
}
|
|
947
|
+
activePlaybooks.add(frame.playbookId);
|
|
948
|
+
if (activeSessionIds.has(frame.sessionId)) {
|
|
949
|
+
throw new TypeError('Captain shell snapshot frame session ids must be unique');
|
|
950
|
+
}
|
|
951
|
+
activeSessionIds.add(frame.sessionId);
|
|
952
|
+
if (!issuedIds.has(frame.sessionId)) {
|
|
953
|
+
throw new TypeError('Captain shell snapshot frame session id was not historically issued');
|
|
954
|
+
}
|
|
955
|
+
if (frame.depth !== index ||
|
|
956
|
+
frame.rootSessionId !== rootSessionId ||
|
|
957
|
+
frame.runtime.state.status !== 'active' ||
|
|
958
|
+
!frame.runtime.state.quiescent) {
|
|
959
|
+
throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
|
|
960
|
+
}
|
|
961
|
+
if (index === 0) {
|
|
962
|
+
if (frame.sessionId !== frame.rootSessionId ||
|
|
963
|
+
frame.parentSessionId !== undefined ||
|
|
964
|
+
frame.parentCallId !== undefined) {
|
|
965
|
+
throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
const parent = normalizedFrames[index - 1];
|
|
970
|
+
const pending = parent.runtime.suspendedCall;
|
|
971
|
+
if (frame.parentSessionId !== parent.sessionId ||
|
|
972
|
+
frame.parentCallId === undefined) {
|
|
973
|
+
throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
|
|
974
|
+
}
|
|
975
|
+
if (!pending ||
|
|
976
|
+
pending.callId !== frame.parentCallId ||
|
|
977
|
+
pending.playbookId !== frame.playbookId ||
|
|
978
|
+
pending.childSessionId !== frame.sessionId) {
|
|
979
|
+
throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
for (const playerId of Object.values(frame.roleBindings)) {
|
|
983
|
+
if (playerSessions[playerId] === undefined) {
|
|
984
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} binds an absent session player`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
for (const question of frame.runtime.pendingBossQuestions) {
|
|
988
|
+
if (question.asker.kind === 'role' &&
|
|
989
|
+
frame.roleBindings[question.asker.roleId] === undefined) {
|
|
990
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} has a pending question from an unbound role`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
const projectedTokens = Object.fromEntries(Object.entries(frame.roleBindings).flatMap(([role, playerId]) => {
|
|
994
|
+
const token = playerSessions[playerId]?.resumeToken;
|
|
995
|
+
return token === undefined ? [] : [[role, token]];
|
|
996
|
+
}));
|
|
997
|
+
if (!isDeepStrictEqual(projectedTokens, frame.runtime.roleResumeTokens)) {
|
|
998
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match session continuation`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const leafRuntime = normalizedFrames.at(-1).runtime;
|
|
1002
|
+
if (leafRuntime.suspendedCall !== undefined ||
|
|
1003
|
+
!leafRuntime.state.tags.includes('playbook.parked')) {
|
|
1004
|
+
throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
|
|
1005
|
+
}
|
|
1006
|
+
if (!isDeepStrictEqual(snapshot.pendingBossQuestions ?? [], leafRuntime.pendingBossQuestions)) {
|
|
1007
|
+
throw new TypeError('Captain shell snapshot pending Boss questions must equal the leaf runtime projection');
|
|
1008
|
+
}
|
|
666
1009
|
return snapshotJsonValue({
|
|
667
1010
|
...common,
|
|
668
1011
|
mode,
|
|
669
1012
|
frames: normalizedFrames,
|
|
670
|
-
rootPlayerResumeTokens: normalizedRootTokens,
|
|
671
1013
|
...(snapshot.pendingBossQuestions === undefined
|
|
672
1014
|
? {}
|
|
673
1015
|
: { pendingBossQuestions: snapshot.pendingBossQuestions }),
|
|
@@ -676,27 +1018,109 @@ function assertPlaybookCaptainShellSnapshot(value) {
|
|
|
676
1018
|
: { lastError: normalizedLastError }),
|
|
677
1019
|
}, 'Captain shell snapshot');
|
|
678
1020
|
}
|
|
679
|
-
function
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
return undefined;
|
|
1021
|
+
function snapshotTuningSelection(value, path) {
|
|
1022
|
+
const selection = snapshotRecord(value, path);
|
|
1023
|
+
if (selection.kind === 'provider-default') {
|
|
1024
|
+
rejectSnapshotKeys(selection, ['kind'], path);
|
|
1025
|
+
return { kind: 'provider-default' };
|
|
685
1026
|
}
|
|
686
|
-
|
|
1027
|
+
if (selection.kind === 'value') {
|
|
1028
|
+
rejectSnapshotKeys(selection, ['kind', 'value'], path);
|
|
1029
|
+
return {
|
|
1030
|
+
kind: 'value',
|
|
1031
|
+
value: snapshotString(selection.value, `${path}.value`),
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
throw new TypeError(`${path}.kind must be "value" or "provider-default"`);
|
|
1035
|
+
}
|
|
1036
|
+
const EFFORT_VALUES = new Set([
|
|
1037
|
+
'on',
|
|
1038
|
+
'minimal',
|
|
1039
|
+
'low',
|
|
1040
|
+
'medium',
|
|
1041
|
+
'high',
|
|
1042
|
+
'xhigh',
|
|
1043
|
+
'max',
|
|
1044
|
+
'ultra',
|
|
1045
|
+
'ultracode',
|
|
1046
|
+
'off',
|
|
1047
|
+
]);
|
|
1048
|
+
function snapshotEffortSelection(value, path) {
|
|
1049
|
+
const selection = snapshotTuningSelection(value, path);
|
|
1050
|
+
if (selection.kind === 'value' && !EFFORT_VALUES.has(selection.value)) {
|
|
1051
|
+
throw new TypeError(`${path}.value is not a supported effort selection`);
|
|
1052
|
+
}
|
|
1053
|
+
return selection;
|
|
1054
|
+
}
|
|
1055
|
+
function snapshotSessionAgent(value, path) {
|
|
1056
|
+
const agent = snapshotRecord(value, path);
|
|
1057
|
+
rejectSnapshotKeys(agent, ['adapter', 'model', 'effort', 'instruction', 'permissions'], path);
|
|
1058
|
+
const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(agent).filter(([key]) => key !== 'model' && key !== 'effort')), path);
|
|
1059
|
+
return {
|
|
1060
|
+
adapter: fixed.adapter,
|
|
1061
|
+
...(fixed.instruction === undefined
|
|
1062
|
+
? {}
|
|
1063
|
+
: { instruction: fixed.instruction }),
|
|
1064
|
+
...(fixed.permissions === undefined
|
|
1065
|
+
? {}
|
|
1066
|
+
: { permissions: livePermissions(fixed.permissions) }),
|
|
1067
|
+
model: snapshotTuningSelection(agent.model, `${path}.model`),
|
|
1068
|
+
effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
function fixedAgent(agent) {
|
|
1072
|
+
return {
|
|
1073
|
+
adapter: agent.adapter,
|
|
1074
|
+
...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
|
|
1075
|
+
...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
function callSettings(agent, tuning = agent) {
|
|
1079
|
+
return {
|
|
1080
|
+
model: tuning.model,
|
|
1081
|
+
effort: tuning.effort,
|
|
1082
|
+
...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
|
|
1083
|
+
...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
function promptIdentity(binding) {
|
|
1087
|
+
return binding.model.kind === 'value'
|
|
1088
|
+
? binding.model.value
|
|
1089
|
+
: binding.agent.adapter;
|
|
687
1090
|
}
|
|
688
|
-
// Resolve the active registry at init from
|
|
689
|
-
// (CAPTAIN-16)
|
|
690
|
-
//
|
|
691
|
-
async function buildEnablements(options,
|
|
1091
|
+
// Resolve the active registry at init from exact normalized role and session
|
|
1092
|
+
// agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
|
|
1093
|
+
// exists at this boundary.
|
|
1094
|
+
async function buildEnablements(options, loadModule) {
|
|
692
1095
|
const entries = [];
|
|
693
1096
|
const byCommand = new Map();
|
|
694
1097
|
const byId = new Map();
|
|
695
1098
|
const enablementById = new Map();
|
|
696
|
-
const
|
|
697
|
-
|
|
1099
|
+
const detached = snapshotJsonValue(options, 'captain.options');
|
|
1100
|
+
const top = snapshotRecord(detached, 'captain.options');
|
|
1101
|
+
rejectSnapshotKeys(top, ['playbooks', 'sessionAgents', 'captainAdapter'], 'captain.options');
|
|
1102
|
+
const configValue = top.playbooks;
|
|
1103
|
+
if (typeof configValue !== 'object' ||
|
|
1104
|
+
configValue === null ||
|
|
1105
|
+
Array.isArray(configValue)) {
|
|
698
1106
|
throw new Error('captain.options.playbooks is required');
|
|
699
1107
|
}
|
|
1108
|
+
const config = configValue;
|
|
1109
|
+
const sessionAgents = snapshotRecord(top.sessionAgents, 'captain.options.sessionAgents');
|
|
1110
|
+
rejectSnapshotKeys(sessionAgents, ['captain', 'players'], 'captain.options.sessionAgents');
|
|
1111
|
+
const captainAgent = snapshotSessionAgent(sessionAgents.captain, 'captain.options.sessionAgents.captain');
|
|
1112
|
+
if (top.captainAdapter !== undefined &&
|
|
1113
|
+
top.captainAdapter !== captainAgent.adapter) {
|
|
1114
|
+
throw new Error('captain.options.captainAdapter must equal sessionAgents.captain.adapter');
|
|
1115
|
+
}
|
|
1116
|
+
const playerAgentRecord = snapshotRecord(sessionAgents.players, 'captain.options.sessionAgents.players');
|
|
1117
|
+
const playerAgents = new Map();
|
|
1118
|
+
for (const [playerId, agent] of Object.entries(playerAgentRecord)) {
|
|
1119
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
1120
|
+
throw new Error(`captain.options.sessionAgents.players has invalid player id ${JSON.stringify(playerId)}`);
|
|
1121
|
+
}
|
|
1122
|
+
playerAgents.set(playerId, snapshotSessionAgent(agent, `captain.options.sessionAgents.players.${playerId}`));
|
|
1123
|
+
}
|
|
700
1124
|
const ids = Object.keys(config);
|
|
701
1125
|
if (ids.length === 0) {
|
|
702
1126
|
throw new Error('captain.options.playbooks must enable at least one playbook');
|
|
@@ -710,6 +1134,7 @@ async function buildEnablements(options, players, loadModule) {
|
|
|
710
1134
|
throw new Error(`captain.options.playbooks.${id} must be an object`);
|
|
711
1135
|
}
|
|
712
1136
|
const record = block;
|
|
1137
|
+
rejectSnapshotKeys(record, ['from', 'command', 'roles', 'options'], `captain.options.playbooks.${id}`);
|
|
713
1138
|
const from = record.from;
|
|
714
1139
|
if (typeof from !== 'string' || from.length === 0) {
|
|
715
1140
|
throw new Error(`captain.options.playbooks.${id}.from must be a module specifier`);
|
|
@@ -740,35 +1165,74 @@ async function buildEnablements(options, players, loadModule) {
|
|
|
740
1165
|
if (byCommand.has(command)) {
|
|
741
1166
|
throw new Error(`captain.options.playbooks has a duplicate effective command "${command}"`);
|
|
742
1167
|
}
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
};
|
|
750
|
-
}
|
|
1168
|
+
const roleRecord = snapshotRecord(record.roles, `captain.options.playbooks.${id}.roles`);
|
|
1169
|
+
const required = new Set(entry.requiredRoleIds);
|
|
1170
|
+
const configuredRoles = Object.keys(roleRecord);
|
|
1171
|
+
const missing = entry.requiredRoleIds.filter((role) => !Object.hasOwn(roleRecord, role));
|
|
1172
|
+
const extra = configuredRoles.filter((role) => !required.has(role));
|
|
1173
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
1174
|
+
throw new Error(`captain.options.playbooks.${id}.roles must exactly cover requiredRoleIds`);
|
|
1175
|
+
}
|
|
1176
|
+
const roleBindings = new Map();
|
|
1177
|
+
for (const role of entry.requiredRoleIds) {
|
|
1178
|
+
const path = `captain.options.playbooks.${id}.roles.${role}`;
|
|
1179
|
+
const rawBinding = snapshotRecord(roleRecord[role], path);
|
|
1180
|
+
rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort'], path);
|
|
1181
|
+
const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
|
|
1182
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
1183
|
+
throw new Error(`${path}.playerId is not a canonical player id`);
|
|
1184
|
+
}
|
|
1185
|
+
const agent = playerAgents.get(playerId);
|
|
1186
|
+
if (!agent) {
|
|
1187
|
+
throw new Error(`${path}.playerId names absent session player ${JSON.stringify(playerId)}`);
|
|
1188
|
+
}
|
|
1189
|
+
roleBindings.set(role, {
|
|
1190
|
+
playerId,
|
|
1191
|
+
model: snapshotTuningSelection(rawBinding.model, `${path}.model`),
|
|
1192
|
+
effort: snapshotEffortSelection(rawBinding.effort, `${path}.effort`),
|
|
1193
|
+
agent,
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
for (const concurrentRoles of entry.concurrentRoleSets) {
|
|
1197
|
+
const playerIds = concurrentRoles.map((role) => roleBindings.get(role).playerId);
|
|
1198
|
+
if (new Set(playerIds).size !== playerIds.length) {
|
|
1199
|
+
throw new Error(`captain.options.playbooks.${id}.roles aliases concurrent roles ${JSON.stringify(concurrentRoles)}`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
const validatedOptions = snapshotJsonValue(entry.validateOptions(record.options), `captain.options.playbooks.${id}.options`);
|
|
751
1203
|
entries.push(entry);
|
|
752
1204
|
byId.set(entry.id, entry);
|
|
753
1205
|
byCommand.set(command, entry);
|
|
754
1206
|
enablementById.set(entry.id, {
|
|
755
1207
|
entry,
|
|
756
1208
|
command,
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
|
|
1209
|
+
options: validatedOptions,
|
|
1210
|
+
roleBindings,
|
|
760
1211
|
});
|
|
761
1212
|
}
|
|
762
|
-
|
|
1213
|
+
const referenced = new Set([...enablementById.values()].flatMap((enablement) => [...enablement.roleBindings.values()].map((binding) => binding.playerId)));
|
|
1214
|
+
const unreferenced = [...playerAgents.keys()].find((id) => !referenced.has(id));
|
|
1215
|
+
if (unreferenced !== undefined) {
|
|
1216
|
+
throw new Error(`captain.options.sessionAgents.players has unreferenced player ${JSON.stringify(unreferenced)}`);
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
entries,
|
|
1220
|
+
byCommand,
|
|
1221
|
+
byId,
|
|
1222
|
+
enablementById,
|
|
1223
|
+
captainAgent,
|
|
1224
|
+
playerAgents,
|
|
1225
|
+
};
|
|
763
1226
|
}
|
|
764
1227
|
export function createPlaybookCaptainShell(options, deps = {}) {
|
|
765
1228
|
const loadModule = deps.loadModule ?? ((specifier) => import(specifier));
|
|
766
1229
|
const createSessionId = deps.createSessionId ?? randomUUID;
|
|
767
1230
|
const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
const
|
|
1231
|
+
let captainAgent;
|
|
1232
|
+
let captainAdapter;
|
|
1233
|
+
let playerAgents = new Map();
|
|
1234
|
+
const playerLedger = new Map();
|
|
1235
|
+
const playerTransactions = new Map();
|
|
772
1236
|
let entries = [];
|
|
773
1237
|
let byCommand = new Map();
|
|
774
1238
|
let byId = new Map();
|
|
@@ -778,7 +1242,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
778
1242
|
let closedGateAttempted = false;
|
|
779
1243
|
let lifecycle = 'fresh';
|
|
780
1244
|
let terminallyDisposed = false;
|
|
781
|
-
let players = [];
|
|
782
1245
|
let activeContext;
|
|
783
1246
|
const frames = [];
|
|
784
1247
|
let mode = 'chat';
|
|
@@ -828,15 +1291,19 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
828
1291
|
// --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
|
|
829
1292
|
let captainRuntime;
|
|
830
1293
|
let captainSessionId;
|
|
831
|
-
// CAPTAIN-35:
|
|
832
|
-
//
|
|
833
|
-
//
|
|
1294
|
+
// CAPTAIN-35: a preflight settings rejection retains proven continuity but
|
|
1295
|
+
// records the exact journal suffix still owed; other continuity failures
|
|
1296
|
+
// require a fresh, full reseed.
|
|
834
1297
|
let conversation = { kind: 'unopened' };
|
|
835
1298
|
let shuttingDown = false;
|
|
836
1299
|
const journal = [];
|
|
837
1300
|
let journalSeq = 0;
|
|
838
1301
|
let turnSequence = 0;
|
|
839
1302
|
let activeTurn;
|
|
1303
|
+
// `PlayerSessionStore.restore` is authoritative only while the shell is
|
|
1304
|
+
// awaiting the exact owning runtime's restore during a closed-gate shell
|
|
1305
|
+
// restoration. A runtime cannot use the store as a general ledger writer.
|
|
1306
|
+
let restoringPlayerSessionFrame;
|
|
840
1307
|
// The durable call the runtime is about to make, taken from the paired
|
|
841
1308
|
// `captain.call.started` boundary the engine emits before the port call
|
|
842
1309
|
// (CAPTAIN-9): the shell never infers a call's kind from its prose.
|
|
@@ -904,7 +1371,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
904
1371
|
// (CAPTAIN-5/CAPTAIN-6).
|
|
905
1372
|
...(captainRuntime
|
|
906
1373
|
? {
|
|
907
|
-
durableConversation: conversation.kind === 'pinned'
|
|
1374
|
+
durableConversation: conversation.kind === 'pinned' ||
|
|
1375
|
+
(conversation.kind === 'needsCatchUp' &&
|
|
1376
|
+
conversation.resume !== false),
|
|
908
1377
|
sessionJournal: true,
|
|
909
1378
|
}
|
|
910
1379
|
: {}),
|
|
@@ -1050,8 +1519,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1050
1519
|
}
|
|
1051
1520
|
}
|
|
1052
1521
|
if (leafFrame() === frame) {
|
|
1053
|
-
pendingBossQuestions =
|
|
1054
|
-
record.pendingBossQuestions ?? record.pendingBossQuestion;
|
|
1522
|
+
pendingBossQuestions = mirroredBossQuestions(record.pendingBossQuestions ?? record.pendingBossQuestion);
|
|
1055
1523
|
lastError = normalizeErrorCompact(record.lastError);
|
|
1056
1524
|
if (state.quiescent && state.tags.includes('playbook.parked')) {
|
|
1057
1525
|
await setMode('engaged.parked', `sub-runtime:${state.stateId ?? 'structured'}`);
|
|
@@ -1060,39 +1528,130 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1060
1528
|
};
|
|
1061
1529
|
let callNestedPlaybook;
|
|
1062
1530
|
const createPorts = (frame) => ({
|
|
1063
|
-
callPlayer: async (
|
|
1531
|
+
callPlayer: async (roleId, prompt, signal, options) => {
|
|
1064
1532
|
admitHostBoundary();
|
|
1065
|
-
if (!activeContext) {
|
|
1533
|
+
if (!activeContext || !activeTurn || !frame.playerCallScope) {
|
|
1066
1534
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
1067
1535
|
}
|
|
1068
1536
|
const context = activeContext;
|
|
1537
|
+
const admittedTurn = activeTurn;
|
|
1538
|
+
const scope = frame.playerCallScope;
|
|
1069
1539
|
signal.throwIfAborted();
|
|
1070
|
-
const
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
// CaptainContext is turn-scoped and cannot accept a narrower XState
|
|
1075
|
-
// invocation signal. Recheck after the host call so a sibling
|
|
1076
|
-
// cancellation is still reported as aborted and cannot rotate a
|
|
1077
|
-
// stopped branch's player token in the linked runtime.
|
|
1078
|
-
signal.throwIfAborted();
|
|
1079
|
-
// CAPTAIN-20: only a player call that actually produced work is an
|
|
1080
|
-
// interruption the Boss was spared. A call that errored or aborted
|
|
1081
|
-
// saved nothing, so it never feeds the saved-counts gate.
|
|
1082
|
-
const summary = activeTurnSummary;
|
|
1083
|
-
if (summary && summaryIncludes(frame) && result.status === 'ok') {
|
|
1084
|
-
summary.counts.interruptions++;
|
|
1540
|
+
const binding = bindingFor(frame, roleId);
|
|
1541
|
+
const ledger = playerLedger.get(binding.playerId);
|
|
1542
|
+
if (!ledger) {
|
|
1543
|
+
throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
|
|
1085
1544
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1545
|
+
const expectedResume = ledger.resumeToken ?? false;
|
|
1546
|
+
if (options.resume !== expectedResume) {
|
|
1547
|
+
throw new Error(`${frameLabel(frame)} player continuation changed before dispatch`);
|
|
1548
|
+
}
|
|
1549
|
+
if (playerTransactions.has(binding.playerId)) {
|
|
1550
|
+
throw new Error(`session player ${JSON.stringify(binding.playerId)} already has a call in flight`);
|
|
1551
|
+
}
|
|
1552
|
+
const settings = callSettings(binding.agent, binding);
|
|
1553
|
+
const calling = {
|
|
1554
|
+
phase: 'calling',
|
|
1555
|
+
frame,
|
|
1556
|
+
roleId,
|
|
1557
|
+
turnId: admittedTurn.id,
|
|
1558
|
+
signal,
|
|
1559
|
+
scope,
|
|
1560
|
+
abandoned: false,
|
|
1095
1561
|
};
|
|
1562
|
+
playerTransactions.set(binding.playerId, calling);
|
|
1563
|
+
let result;
|
|
1564
|
+
let hostResolved = false;
|
|
1565
|
+
try {
|
|
1566
|
+
let rawResult;
|
|
1567
|
+
try {
|
|
1568
|
+
rawResult = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, prompt, {
|
|
1569
|
+
resume: options.resume,
|
|
1570
|
+
settings,
|
|
1571
|
+
})));
|
|
1572
|
+
hostResolved = true;
|
|
1573
|
+
}
|
|
1574
|
+
catch (error) {
|
|
1575
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
1576
|
+
if (playerTransactions.get(binding.playerId) !== calling ||
|
|
1577
|
+
calling.abandoned ||
|
|
1578
|
+
signal.aborted ||
|
|
1579
|
+
activeTurn !== admittedTurn ||
|
|
1580
|
+
frame.playerCallScope !== scope ||
|
|
1581
|
+
!frames.includes(frame)) {
|
|
1582
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
1583
|
+
playerTransactions.delete(binding.playerId);
|
|
1584
|
+
}
|
|
1585
|
+
signal.throwIfAborted();
|
|
1586
|
+
throw new Error(`${frameLabel(frame)} player settings rejection arrived after its runtime operation ended`);
|
|
1587
|
+
}
|
|
1588
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
1589
|
+
}
|
|
1590
|
+
throw error;
|
|
1591
|
+
}
|
|
1592
|
+
result = normalizeHostPlayerResult(rawResult, binding.playerId);
|
|
1593
|
+
const transitionRequired = result.resumeToken !== undefined || result.status === 'ok';
|
|
1594
|
+
if (playerTransactions.get(binding.playerId) !== calling ||
|
|
1595
|
+
calling.abandoned ||
|
|
1596
|
+
signal.aborted ||
|
|
1597
|
+
activeTurn !== admittedTurn ||
|
|
1598
|
+
frame.playerCallScope !== scope ||
|
|
1599
|
+
!frames.includes(frame)) {
|
|
1600
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
1601
|
+
if (transitionRequired) {
|
|
1602
|
+
playerTransactions.set(binding.playerId, {
|
|
1603
|
+
phase: 'quarantined',
|
|
1604
|
+
frame,
|
|
1605
|
+
roleId,
|
|
1606
|
+
turnId: admittedTurn.id,
|
|
1607
|
+
signal,
|
|
1608
|
+
scope,
|
|
1609
|
+
reason: 'a transition-worthy result arrived after its runtime operation ended',
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
else {
|
|
1613
|
+
playerTransactions.delete(binding.playerId);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
signal.throwIfAborted();
|
|
1617
|
+
throw new Error(`${frameLabel(frame)} player result arrived after its runtime operation ended`);
|
|
1618
|
+
}
|
|
1619
|
+
if (transitionRequired) {
|
|
1620
|
+
playerTransactions.set(binding.playerId, {
|
|
1621
|
+
phase: 'awaitingCommit',
|
|
1622
|
+
frame,
|
|
1623
|
+
roleId,
|
|
1624
|
+
turnId: admittedTurn.id,
|
|
1625
|
+
signal,
|
|
1626
|
+
scope,
|
|
1627
|
+
status: result.status,
|
|
1628
|
+
expectedToken: result.resumeToken,
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
else {
|
|
1632
|
+
playerTransactions.delete(binding.playerId);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
catch (error) {
|
|
1636
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
1637
|
+
if (hostResolved) {
|
|
1638
|
+
playerTransactions.set(binding.playerId, {
|
|
1639
|
+
phase: 'quarantined',
|
|
1640
|
+
frame,
|
|
1641
|
+
roleId,
|
|
1642
|
+
turnId: admittedTurn.id,
|
|
1643
|
+
signal,
|
|
1644
|
+
scope,
|
|
1645
|
+
reason: 'a late player result could not be validated',
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
else {
|
|
1649
|
+
playerTransactions.delete(binding.playerId);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
throw error;
|
|
1653
|
+
}
|
|
1654
|
+
return result;
|
|
1096
1655
|
},
|
|
1097
1656
|
callCaptain: async (prompt, signal, options) => {
|
|
1098
1657
|
admitHostBoundary();
|
|
@@ -1173,12 +1732,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1173
1732
|
},
|
|
1174
1733
|
});
|
|
1175
1734
|
// CAPTAIN-22: before dispatching to a playbook, request tmux-play
|
|
1176
|
-
// visibility for that playbook's
|
|
1735
|
+
// visibility for that playbook's explicitly bound session players. A pane
|
|
1177
1736
|
// reconciliation failure is display-only in tmux-play and does not
|
|
1178
1737
|
// reject; the legacy path carries no generated set and skips this.
|
|
1179
1738
|
const requestVisibility = async (frame) => {
|
|
1180
|
-
const ids = [...new Set([...frame.playerBindings.values()].map(({
|
|
1181
|
-
|
|
1739
|
+
const ids = [...new Set([...frame.playerBindings.values()].map(({ playerId }) => playerId))];
|
|
1740
|
+
// A roleless frame does not ask a non-empty host roster to show `[]`:
|
|
1741
|
+
// tmux-play reserves that value for a genuinely empty configured roster.
|
|
1742
|
+
if (ids.length === 0 || !activeContext)
|
|
1182
1743
|
return;
|
|
1183
1744
|
try {
|
|
1184
1745
|
await activeContext.setVisiblePlayers(ids);
|
|
@@ -1210,39 +1771,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1210
1771
|
: undefined;
|
|
1211
1772
|
return typeof stack === 'string' ? { ...compact, stack } : compact;
|
|
1212
1773
|
};
|
|
1213
|
-
const makePlayerBindings = (enablement
|
|
1214
|
-
|
|
1215
|
-
const playerBindings = new Map();
|
|
1216
|
-
for (const role of entry.requiredRoleIds) {
|
|
1217
|
-
let inherited;
|
|
1218
|
-
for (let ancestor = parent?.frame; ancestor && inherited === undefined; ancestor = ancestor.parent?.frame) {
|
|
1219
|
-
inherited = ancestor.playerBindings.get(role);
|
|
1220
|
-
}
|
|
1221
|
-
if (inherited) {
|
|
1222
|
-
playerBindings.set(role, inherited);
|
|
1223
|
-
continue;
|
|
1224
|
-
}
|
|
1225
|
-
const configured = enablement.boundPlayers.find((player) => player.id === role) ?? { id: role };
|
|
1226
|
-
playerBindings.set(role, {
|
|
1227
|
-
hostPlayerId: enablement.hostPlayerId(role),
|
|
1228
|
-
player: configured,
|
|
1229
|
-
});
|
|
1230
|
-
}
|
|
1231
|
-
return playerBindings;
|
|
1774
|
+
const makePlayerBindings = (enablement) => {
|
|
1775
|
+
return new Map(enablement.roleBindings);
|
|
1232
1776
|
};
|
|
1233
1777
|
const makeFrame = (enablement, parent) => {
|
|
1234
1778
|
const entry = enablement.entry;
|
|
1235
1779
|
const sessionId = allocateSessionId();
|
|
1236
|
-
const playerBindings = makePlayerBindings(enablement
|
|
1237
|
-
const
|
|
1238
|
-
const runtime = entry.createRuntime({
|
|
1239
|
-
captainOptions: enablement.optionInput,
|
|
1240
|
-
players: [...playerBindings].map(([role, { player }]) => ({
|
|
1241
|
-
id: role,
|
|
1242
|
-
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
1243
|
-
...(player.model === undefined ? {} : { model: player.model }),
|
|
1244
|
-
})),
|
|
1245
|
-
});
|
|
1780
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
1781
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
1246
1782
|
return {
|
|
1247
1783
|
entry,
|
|
1248
1784
|
enablement,
|
|
@@ -1251,22 +1787,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1251
1787
|
rootSessionId: parent?.frame.rootSessionId ?? sessionId,
|
|
1252
1788
|
depth: parent ? parent.frame.depth + 1 : 0,
|
|
1253
1789
|
playerBindings,
|
|
1254
|
-
playerResumeTokens,
|
|
1255
1790
|
...(parent ? { parent } : {}),
|
|
1256
1791
|
inFlightHostCalls: new Set(),
|
|
1257
1792
|
};
|
|
1258
1793
|
};
|
|
1259
|
-
const makeRestoredFrame = (enablement, snapshot,
|
|
1794
|
+
const makeRestoredFrame = (enablement, snapshot, parent) => {
|
|
1260
1795
|
const entry = enablement.entry;
|
|
1261
|
-
const playerBindings = makePlayerBindings(enablement
|
|
1262
|
-
const runtime = entry.createRuntime(
|
|
1263
|
-
captainOptions: enablement.optionInput,
|
|
1264
|
-
players: [...playerBindings].map(([role, { player }]) => ({
|
|
1265
|
-
id: role,
|
|
1266
|
-
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
1267
|
-
...(player.model === undefined ? {} : { model: player.model }),
|
|
1268
|
-
})),
|
|
1269
|
-
});
|
|
1796
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
1797
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
1270
1798
|
return {
|
|
1271
1799
|
entry,
|
|
1272
1800
|
enablement,
|
|
@@ -1275,7 +1803,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1275
1803
|
rootSessionId: snapshot.rootSessionId,
|
|
1276
1804
|
depth: snapshot.depth,
|
|
1277
1805
|
playerBindings,
|
|
1278
|
-
playerResumeTokens: rootPlayerResumeTokens,
|
|
1279
1806
|
...(parent ? { parent } : {}),
|
|
1280
1807
|
state: snapshot.runtime.state,
|
|
1281
1808
|
inFlightHostCalls: new Set(),
|
|
@@ -1284,36 +1811,141 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1284
1811
|
const playerSessionStore = (frame) => ({
|
|
1285
1812
|
select(playerId) {
|
|
1286
1813
|
const binding = bindingFor(frame, playerId);
|
|
1287
|
-
return
|
|
1814
|
+
return playerLedger.get(binding.playerId)?.resumeToken ?? false;
|
|
1288
1815
|
},
|
|
1289
1816
|
update(playerId, resumeToken) {
|
|
1290
1817
|
const binding = bindingFor(frame, playerId);
|
|
1291
|
-
|
|
1292
|
-
|
|
1818
|
+
const ledger = playerLedger.get(binding.playerId);
|
|
1819
|
+
if (!ledger) {
|
|
1820
|
+
throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
|
|
1293
1821
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1822
|
+
const pending = playerTransactions.get(binding.playerId);
|
|
1823
|
+
if (pending?.phase !== 'awaitingCommit' ||
|
|
1824
|
+
pending.frame !== frame ||
|
|
1825
|
+
pending.roleId !== playerId ||
|
|
1826
|
+
pending.scope !== frame.playerCallScope ||
|
|
1827
|
+
pending.expectedToken !== resumeToken) {
|
|
1828
|
+
throw new Error(`${frameLabel(frame)} player update does not acknowledge a validated host result`);
|
|
1829
|
+
}
|
|
1830
|
+
if (pending.signal.aborted || activeTurn?.id !== pending.turnId) {
|
|
1831
|
+
playerTransactions.set(binding.playerId, {
|
|
1832
|
+
phase: 'quarantined',
|
|
1833
|
+
frame: pending.frame,
|
|
1834
|
+
roleId: pending.roleId,
|
|
1835
|
+
turnId: pending.turnId,
|
|
1836
|
+
signal: pending.signal,
|
|
1837
|
+
scope: pending.scope,
|
|
1838
|
+
reason: 'the runtime aborted before committing a validated result',
|
|
1839
|
+
});
|
|
1840
|
+
throw new Error(`${frameLabel(frame)} rejected a late or aborted player continuation update`);
|
|
1841
|
+
}
|
|
1842
|
+
try {
|
|
1843
|
+
if (resumeToken === undefined)
|
|
1844
|
+
delete ledger.resumeToken;
|
|
1845
|
+
else
|
|
1846
|
+
ledger.resumeToken = resumeToken;
|
|
1847
|
+
// CAPTAIN-20: a result counts only after the runtime validated it and
|
|
1848
|
+
// atomically published its authorized continuation transition.
|
|
1849
|
+
const summary = activeTurnSummary;
|
|
1850
|
+
if (pending.status === 'ok' &&
|
|
1851
|
+
summary &&
|
|
1852
|
+
summaryIncludes(frame)) {
|
|
1853
|
+
summary.counts.interruptions++;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
finally {
|
|
1857
|
+
playerTransactions.delete(binding.playerId);
|
|
1296
1858
|
}
|
|
1297
1859
|
},
|
|
1298
1860
|
snapshot() {
|
|
1299
1861
|
const tokens = {};
|
|
1300
1862
|
for (const [playerId, binding] of frame.playerBindings) {
|
|
1301
|
-
const token =
|
|
1863
|
+
const token = playerLedger.get(binding.playerId)?.resumeToken;
|
|
1302
1864
|
if (token !== undefined)
|
|
1303
1865
|
tokens[playerId] = token;
|
|
1304
1866
|
}
|
|
1305
1867
|
return tokens;
|
|
1306
1868
|
},
|
|
1307
1869
|
restore(tokens) {
|
|
1308
|
-
|
|
1309
|
-
frame
|
|
1870
|
+
if (lifecycle !== 'restoring' ||
|
|
1871
|
+
restoringPlayerSessionFrame !== frame) {
|
|
1872
|
+
throw new Error(`${frameLabel(frame)} player-session restore is only available during shell restoration`);
|
|
1310
1873
|
}
|
|
1874
|
+
const byPlayer = new Map();
|
|
1311
1875
|
for (const [playerId, token] of Object.entries(tokens)) {
|
|
1312
1876
|
const binding = bindingFor(frame, playerId);
|
|
1313
|
-
|
|
1877
|
+
const previous = byPlayer.get(binding.playerId);
|
|
1878
|
+
if (previous !== undefined && previous !== token) {
|
|
1879
|
+
throw new Error(`${frameLabel(frame)} restored conflicting tokens for shared player ${JSON.stringify(binding.playerId)}`);
|
|
1880
|
+
}
|
|
1881
|
+
byPlayer.set(binding.playerId, token);
|
|
1882
|
+
}
|
|
1883
|
+
for (const binding of frame.playerBindings.values()) {
|
|
1884
|
+
if (!byPlayer.has(binding.playerId))
|
|
1885
|
+
byPlayer.set(binding.playerId, undefined);
|
|
1886
|
+
}
|
|
1887
|
+
for (const [playerId, token] of byPlayer) {
|
|
1888
|
+
const ledger = playerLedger.get(playerId);
|
|
1889
|
+
if (!ledger) {
|
|
1890
|
+
throw new Error(`${frameLabel(frame)} restored absent session player ${JSON.stringify(playerId)}`);
|
|
1891
|
+
}
|
|
1892
|
+
if (token === undefined)
|
|
1893
|
+
delete ledger.resumeToken;
|
|
1894
|
+
else
|
|
1895
|
+
ledger.resumeToken = token;
|
|
1314
1896
|
}
|
|
1315
1897
|
},
|
|
1316
1898
|
});
|
|
1899
|
+
const closePlayerCallScope = (frame, scope) => {
|
|
1900
|
+
if (frame.playerCallScope === scope)
|
|
1901
|
+
frame.playerCallScope = undefined;
|
|
1902
|
+
const missing = [];
|
|
1903
|
+
for (const [playerId, transaction] of playerTransactions) {
|
|
1904
|
+
if (transaction.frame !== frame || transaction.scope !== scope)
|
|
1905
|
+
continue;
|
|
1906
|
+
if (transaction.phase === 'calling') {
|
|
1907
|
+
transaction.abandoned = true;
|
|
1908
|
+
}
|
|
1909
|
+
else {
|
|
1910
|
+
playerTransactions.set(playerId, {
|
|
1911
|
+
phase: 'quarantined',
|
|
1912
|
+
frame: transaction.frame,
|
|
1913
|
+
roleId: transaction.roleId,
|
|
1914
|
+
turnId: transaction.turnId,
|
|
1915
|
+
signal: transaction.signal,
|
|
1916
|
+
scope: transaction.scope,
|
|
1917
|
+
reason: transaction.phase === 'awaitingCommit'
|
|
1918
|
+
? 'the runtime returned without committing a validated result'
|
|
1919
|
+
: transaction.reason,
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
if (!transaction.signal.aborted)
|
|
1923
|
+
missing.push(playerId);
|
|
1924
|
+
}
|
|
1925
|
+
return missing.length === 0
|
|
1926
|
+
? undefined
|
|
1927
|
+
: new Error(`${frameLabel(frame)} runtime returned without committing validated player result for ${missing.map((id) => JSON.stringify(id)).join(', ')}`);
|
|
1928
|
+
};
|
|
1929
|
+
const runFrameOperation = async (frame, operation) => {
|
|
1930
|
+
if (frame.playerCallScope !== undefined) {
|
|
1931
|
+
throw new Error(`${frameLabel(frame)} runtime operations must not overlap`);
|
|
1932
|
+
}
|
|
1933
|
+
const scope = {};
|
|
1934
|
+
frame.playerCallScope = scope;
|
|
1935
|
+
let outcome;
|
|
1936
|
+
try {
|
|
1937
|
+
outcome = { ok: true, value: await operation() };
|
|
1938
|
+
}
|
|
1939
|
+
catch (error) {
|
|
1940
|
+
outcome = { ok: false, error };
|
|
1941
|
+
}
|
|
1942
|
+
const cleanupError = closePlayerCallScope(frame, scope);
|
|
1943
|
+
if (!outcome.ok)
|
|
1944
|
+
throw outcome.error;
|
|
1945
|
+
if (cleanupError !== undefined)
|
|
1946
|
+
throw cleanupError;
|
|
1947
|
+
return outcome.value;
|
|
1948
|
+
};
|
|
1317
1949
|
const frameSession = (frame) => ({
|
|
1318
1950
|
sessionId: frame.sessionId,
|
|
1319
1951
|
playbookId: frame.entry.id,
|
|
@@ -1325,6 +1957,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1325
1957
|
}
|
|
1326
1958
|
: {}),
|
|
1327
1959
|
depth: frame.depth,
|
|
1960
|
+
roleBindings: Object.fromEntries([...frame.playerBindings].map(([roleId, binding]) => [
|
|
1961
|
+
roleId,
|
|
1962
|
+
{
|
|
1963
|
+
playerId: binding.playerId,
|
|
1964
|
+
promptIdentity: promptIdentity(binding),
|
|
1965
|
+
},
|
|
1966
|
+
])),
|
|
1328
1967
|
playerSessions: playerSessionStore(frame),
|
|
1329
1968
|
ports: createPorts(frame),
|
|
1330
1969
|
});
|
|
@@ -1609,7 +2248,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1609
2248
|
// exception filed against an effect that never ran.
|
|
1610
2249
|
await requestVisibility(frame);
|
|
1611
2250
|
await setMode('engaged.driving', 'submit');
|
|
1612
|
-
const result = await runEffect(() => frame.runtime.handleBossInput({ text, signal }));
|
|
2251
|
+
const result = await runFrameOperation(frame, () => runEffect(() => frame.runtime.handleBossInput({ text, signal })));
|
|
1613
2252
|
frame.state = result.state;
|
|
1614
2253
|
return result;
|
|
1615
2254
|
};
|
|
@@ -1655,11 +2294,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1655
2294
|
}
|
|
1656
2295
|
let result;
|
|
1657
2296
|
try {
|
|
1658
|
-
result = await runEffect(() => parent.runtime.resumePlaybookCall({
|
|
2297
|
+
result = await runFrameOperation(parent, () => runEffect(() => parent.runtime.resumePlaybookCall({
|
|
1659
2298
|
callId: parentLink.callId,
|
|
1660
2299
|
result: effectiveResult,
|
|
1661
2300
|
signal: context.signal,
|
|
1662
|
-
}));
|
|
2301
|
+
})));
|
|
1663
2302
|
}
|
|
1664
2303
|
catch (error) {
|
|
1665
2304
|
if (disposing || invocationSignal?.aborted)
|
|
@@ -1696,7 +2335,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1696
2335
|
// runtime publishes before disposal removes the frame. The opaque run
|
|
1697
2336
|
// output remains runtime-to-runtime data and never becomes Captain
|
|
1698
2337
|
// evidence (CAPPLAY-10).
|
|
1699
|
-
activeTurn?.settlementFacts.push(rootCompletionFact(frame));
|
|
2338
|
+
activeTurn?.settlementFacts.push(rootCompletionFact(frame, result));
|
|
1700
2339
|
await runEffect(() => disposeStack('final'));
|
|
1701
2340
|
}
|
|
1702
2341
|
return;
|
|
@@ -2015,7 +2654,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2015
2654
|
stateDigestLine(view.state, view.stateDescription),
|
|
2016
2655
|
].join(': '));
|
|
2017
2656
|
lines.push(...leafContextLines(view.context));
|
|
2018
|
-
const pending = view.pendingQuestions.map((question) => digestLine `- (${quoteEvidence(question.questionId)}) ${quoteEvidence(question.
|
|
2657
|
+
const pending = view.pendingQuestions.map((question) => digestLine `- (${quoteEvidence(question.questionId)}) ${quoteEvidence(question.asker.kind === 'captain'
|
|
2658
|
+
? 'Captain'
|
|
2659
|
+
: question.asker.roleId)} asks: ${quoteEvidence(question.question)}`);
|
|
2019
2660
|
lines.push(pending.length === 0
|
|
2020
2661
|
? 'Pending Boss questions: none.'
|
|
2021
2662
|
: ['Pending Boss questions:', ...pending].join('\n'));
|
|
@@ -2111,6 +2752,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2111
2752
|
await trackTurnCall(settlement.context.emitReply(settlement.text));
|
|
2112
2753
|
}
|
|
2113
2754
|
catch (error) {
|
|
2755
|
+
conversation = { kind: 'needsSeeding' };
|
|
2114
2756
|
const normalized = normalizeErrorCompact(error) ?? {
|
|
2115
2757
|
name: 'Error',
|
|
2116
2758
|
message: String(error),
|
|
@@ -2207,8 +2849,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2207
2849
|
* text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
|
|
2208
2850
|
* the Captain may need to repeat.
|
|
2209
2851
|
*/
|
|
2210
|
-
const
|
|
2211
|
-
for (const record of
|
|
2852
|
+
const conversationDigest = (records, render) => {
|
|
2853
|
+
for (const record of records) {
|
|
2212
2854
|
if (record.kind === 'action' &&
|
|
2213
2855
|
typeof record.payload === 'object' &&
|
|
2214
2856
|
record.payload !== null &&
|
|
@@ -2218,13 +2860,34 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2218
2860
|
recordSuppliedIdentifier(actionId);
|
|
2219
2861
|
}
|
|
2220
2862
|
}
|
|
2221
|
-
return
|
|
2863
|
+
return render(records);
|
|
2222
2864
|
};
|
|
2865
|
+
const reseedDigest = () => conversationDigest(journal, renderReseedDigest);
|
|
2866
|
+
const catchUpDigest = (afterJournalSeq) => conversationDigest(journal.filter((record) => record.seq > afterJournalSeq), renderCatchUpDigest);
|
|
2223
2867
|
const markControlFailure = (error) => {
|
|
2224
|
-
|
|
2225
|
-
activeTurn.controlFailure = true;
|
|
2868
|
+
activeTurn?.controlFailures.add(error);
|
|
2226
2869
|
return error;
|
|
2227
2870
|
};
|
|
2871
|
+
const markSettingsRejection = (error) => markControlFailure(error);
|
|
2872
|
+
const rememberSettingsPreflight = (error) => {
|
|
2873
|
+
activeTurn?.settingsPreflightFailures.add(error);
|
|
2874
|
+
return markSettingsRejection(error);
|
|
2875
|
+
};
|
|
2876
|
+
const markConversationCatchUp = () => {
|
|
2877
|
+
if (conversation.kind === 'needsSeeding' || conversation.kind === 'needsCatchUp') {
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
conversation = {
|
|
2881
|
+
kind: 'needsCatchUp',
|
|
2882
|
+
resume: conversation.kind === 'pinned' ? conversation.token : false,
|
|
2883
|
+
afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
|
|
2884
|
+
};
|
|
2885
|
+
};
|
|
2886
|
+
const markConversationUnsynchronized = () => {
|
|
2887
|
+
if (conversation.kind !== 'needsCatchUp') {
|
|
2888
|
+
conversation = { kind: 'needsSeeding' };
|
|
2889
|
+
}
|
|
2890
|
+
};
|
|
2228
2891
|
/**
|
|
2229
2892
|
* CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
|
|
2230
2893
|
* engagement constructed, a stack disposed, an advertised action applied.
|
|
@@ -2270,14 +2933,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2270
2933
|
this.name = 'CaptainProseError';
|
|
2271
2934
|
}
|
|
2272
2935
|
}
|
|
2273
|
-
const rawDurableCall = async (context, prompt, resume) => {
|
|
2936
|
+
const rawDurableCall = async (context, prompt, resume, attempt) => {
|
|
2274
2937
|
const queued = captainQueue.add(async () => {
|
|
2275
2938
|
context.signal.throwIfAborted();
|
|
2276
|
-
|
|
2939
|
+
attempt.providerBoundaryEntered = true;
|
|
2940
|
+
const result = await classifySettingsCall(() => context.callCaptain(prompt, {
|
|
2277
2941
|
visibility: 'hidden',
|
|
2278
2942
|
resume,
|
|
2279
2943
|
...controlCallToolOptions(captainAdapter),
|
|
2280
|
-
|
|
2944
|
+
settings: callSettings(captainAgent),
|
|
2945
|
+
}));
|
|
2281
2946
|
context.signal.throwIfAborted();
|
|
2282
2947
|
return result;
|
|
2283
2948
|
});
|
|
@@ -2289,17 +2954,47 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2289
2954
|
// conversation that is owed a reseed carries the digest on its very next
|
|
2290
2955
|
// call, so the turn after a failed reseed starts seeded rather than blank.
|
|
2291
2956
|
const durableCall = async (context, compose) => {
|
|
2292
|
-
const
|
|
2957
|
+
const startingConversation = conversation;
|
|
2958
|
+
const resume = startingConversation.kind === 'pinned'
|
|
2959
|
+
? startingConversation.token
|
|
2960
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
2961
|
+
? startingConversation.resume
|
|
2962
|
+
: false;
|
|
2293
2963
|
const seedFirstCall = conversation.kind === 'needsSeeding';
|
|
2964
|
+
const catchUpFirstCall = conversation.kind === 'needsCatchUp';
|
|
2965
|
+
const representedJournalSeq = journalSeq;
|
|
2966
|
+
const firstAttempt = { providerBoundaryEntered: false };
|
|
2294
2967
|
let result;
|
|
2295
2968
|
let failure;
|
|
2296
2969
|
try {
|
|
2297
|
-
result = await rawDurableCall(context, compose(seedFirstCall
|
|
2970
|
+
result = await rawDurableCall(context, compose(seedFirstCall
|
|
2971
|
+
? { reseedDigest: reseedDigest() }
|
|
2972
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
2973
|
+
? {
|
|
2974
|
+
reseedDigest: catchUpDigest(startingConversation.afterJournalSeq),
|
|
2975
|
+
}
|
|
2976
|
+
: {}), resume, firstAttempt);
|
|
2298
2977
|
}
|
|
2299
2978
|
catch (error) {
|
|
2300
2979
|
if (context.signal.aborted) {
|
|
2301
|
-
|
|
2302
|
-
|
|
2980
|
+
if (firstAttempt.providerBoundaryEntered) {
|
|
2981
|
+
conversation = { kind: 'needsSeeding' };
|
|
2982
|
+
}
|
|
2983
|
+
else {
|
|
2984
|
+
markConversationUnsynchronized();
|
|
2985
|
+
}
|
|
2986
|
+
throw context.signal.reason ?? error;
|
|
2987
|
+
}
|
|
2988
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
2989
|
+
if (conversation.kind !== 'needsCatchUp' &&
|
|
2990
|
+
conversation.kind !== 'needsSeeding') {
|
|
2991
|
+
conversation = {
|
|
2992
|
+
kind: 'needsCatchUp',
|
|
2993
|
+
resume: conversation.kind === 'pinned' ? conversation.token : false,
|
|
2994
|
+
afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
|
|
2995
|
+
};
|
|
2996
|
+
}
|
|
2997
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
2303
2998
|
}
|
|
2304
2999
|
failure = error;
|
|
2305
3000
|
}
|
|
@@ -2309,11 +3004,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2309
3004
|
result.resumeToken === undefined;
|
|
2310
3005
|
if (!unsynchronized) {
|
|
2311
3006
|
conversation = { kind: 'pinned', token: result.resumeToken };
|
|
3007
|
+
if (activeTurn) {
|
|
3008
|
+
activeTurn.captainSyncedJournalSeq = representedJournalSeq;
|
|
3009
|
+
}
|
|
2312
3010
|
return {
|
|
2313
3011
|
...(result.finalText !== undefined
|
|
2314
3012
|
? { finalText: result.finalText }
|
|
2315
3013
|
: {}),
|
|
2316
|
-
correctiveSpent: seedFirstCall,
|
|
3014
|
+
correctiveSpent: seedFirstCall || catchUpFirstCall,
|
|
2317
3015
|
};
|
|
2318
3016
|
}
|
|
2319
3017
|
// Only the model-side conversation is replaced: the stack, player
|
|
@@ -2323,13 +3021,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2323
3021
|
conversation = { kind: 'needsSeeding' };
|
|
2324
3022
|
const recap = reseedDigest();
|
|
2325
3023
|
let reissued;
|
|
3024
|
+
const reissueAttempt = { providerBoundaryEntered: false };
|
|
2326
3025
|
try {
|
|
2327
|
-
reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false);
|
|
3026
|
+
reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false, reissueAttempt);
|
|
2328
3027
|
}
|
|
2329
3028
|
catch (error) {
|
|
2330
3029
|
if (context.signal.aborted) {
|
|
2331
3030
|
conversation = { kind: 'needsSeeding' };
|
|
2332
|
-
throw error;
|
|
3031
|
+
throw context.signal.reason ?? error;
|
|
3032
|
+
}
|
|
3033
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
3034
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
2333
3035
|
}
|
|
2334
3036
|
throw markControlFailure(new CaptainContinuityError(error));
|
|
2335
3037
|
}
|
|
@@ -2338,6 +3040,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2338
3040
|
`callCaptain status "${reissued.status}" without a resume token`));
|
|
2339
3041
|
}
|
|
2340
3042
|
conversation = { kind: 'pinned', token: reissued.resumeToken };
|
|
3043
|
+
if (activeTurn)
|
|
3044
|
+
activeTurn.captainSyncedJournalSeq = journalSeq;
|
|
2341
3045
|
return {
|
|
2342
3046
|
...(reissued.finalText !== undefined
|
|
2343
3047
|
? { finalText: reissued.finalText }
|
|
@@ -2523,10 +3227,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2523
3227
|
// -------------------------------------------------------------------------
|
|
2524
3228
|
// The controller port (DR-029): host validation is the sole effector.
|
|
2525
3229
|
// -------------------------------------------------------------------------
|
|
2526
|
-
// The
|
|
2527
|
-
// same way the digest reads it.
|
|
2528
|
-
//
|
|
2529
|
-
//
|
|
3230
|
+
// The legacy state-description channel, read from the live control view the
|
|
3231
|
+
// same way the digest reads it. DR-037 makes the terminal result authoritative
|
|
3232
|
+
// for completion; this remains only for an older runtime that omits the new
|
|
3233
|
+
// optional member.
|
|
2530
3234
|
const leafStateDescription = (frame) => {
|
|
2531
3235
|
if (typeof frame.runtime.describe !== 'function')
|
|
2532
3236
|
return undefined;
|
|
@@ -2537,9 +3241,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2537
3241
|
return undefined;
|
|
2538
3242
|
}
|
|
2539
3243
|
};
|
|
2540
|
-
const rootCompletionFact = (frame) => {
|
|
2541
|
-
const
|
|
2542
|
-
|
|
3244
|
+
const rootCompletionFact = (frame, result) => {
|
|
3245
|
+
const returned = result.stateDescription === undefined
|
|
3246
|
+
? ''
|
|
3247
|
+
: compactEvidence(result.stateDescription);
|
|
3248
|
+
const legacy = returned === '' ? leafStateDescription(frame) : undefined;
|
|
3249
|
+
const description = returned !== ''
|
|
3250
|
+
? returned
|
|
3251
|
+
: legacy === undefined
|
|
3252
|
+
? ''
|
|
3253
|
+
: compactEvidence(legacy);
|
|
2543
3254
|
return description === ''
|
|
2544
3255
|
? `${frameLabel(frame)} completed; its runtime published no result description.`
|
|
2545
3256
|
: `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
|
|
@@ -2681,7 +3392,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2681
3392
|
message: String(error),
|
|
2682
3393
|
};
|
|
2683
3394
|
if (aborted) {
|
|
2684
|
-
|
|
3395
|
+
markConversationUnsynchronized();
|
|
2685
3396
|
if (turn?.outcomePending) {
|
|
2686
3397
|
turn.settlementFacts.push(`The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`);
|
|
2687
3398
|
journalOutcome(journalOutcomeEvidence(turn.settlementFacts, 'failed', turn.report));
|
|
@@ -3011,7 +3722,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3011
3722
|
// guard against re-execution — a repeated selection returns the recorded
|
|
3012
3723
|
// receipt rather than acting twice.
|
|
3013
3724
|
const key = `turn-${turn.id}-apply-${actionId}`;
|
|
3014
|
-
const outcome = await withCounting(leaf, async () => runEffect(() => leaf.runtime.apply({ actionId, key, signal })));
|
|
3725
|
+
const outcome = await withCounting(leaf, async () => runFrameOperation(leaf, () => runEffect(() => leaf.runtime.apply({ actionId, key, signal }))));
|
|
3015
3726
|
if (outcome.error !== undefined)
|
|
3016
3727
|
throw outcome.error;
|
|
3017
3728
|
const receipt = outcome.result;
|
|
@@ -3159,7 +3870,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3159
3870
|
// The durable Captain conversation did not receive the shell-authored
|
|
3160
3871
|
// fallback. Force its next call through the journal so it cannot interpret
|
|
3161
3872
|
// the Boss's follow-up without the reply the Boss was given this turn.
|
|
3162
|
-
|
|
3873
|
+
if (activeTurn?.settingsPreflightFailures.has(error)) {
|
|
3874
|
+
markConversationCatchUp();
|
|
3875
|
+
}
|
|
3876
|
+
else {
|
|
3877
|
+
markConversationUnsynchronized();
|
|
3878
|
+
}
|
|
3163
3879
|
// A rejected presentation may already have emitted bytes. It is therefore
|
|
3164
3880
|
// final for this turn even though the Promise did not prove it was shown.
|
|
3165
3881
|
if (activeTurn?.presentationAttempted === true)
|
|
@@ -3182,112 +3898,68 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3182
3898
|
playbookId: INTERNAL_CAPTAIN_ID,
|
|
3183
3899
|
rootSessionId: id,
|
|
3184
3900
|
depth: 0,
|
|
3901
|
+
roleBindings: {},
|
|
3185
3902
|
ports: captainPorts(),
|
|
3186
3903
|
});
|
|
3187
|
-
const
|
|
3904
|
+
const playerLedgerRecord = () => Object.fromEntries([...playerLedger].map(([playerId, entry]) => [
|
|
3905
|
+
playerId,
|
|
3906
|
+
{
|
|
3907
|
+
adapter: entry.adapter,
|
|
3908
|
+
...(entry.instruction === undefined
|
|
3909
|
+
? {}
|
|
3910
|
+
: { instruction: entry.instruction }),
|
|
3911
|
+
...(entry.permissions === undefined
|
|
3912
|
+
? {}
|
|
3913
|
+
: { permissions: entry.permissions }),
|
|
3914
|
+
...(entry.resumeToken === undefined
|
|
3915
|
+
? {}
|
|
3916
|
+
: { resumeToken: entry.resumeToken }),
|
|
3917
|
+
},
|
|
3918
|
+
]));
|
|
3188
3919
|
const assertSnapshotMatchesEnablements = (snapshot, enabled) => {
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3920
|
+
if (captainAgent === undefined ||
|
|
3921
|
+
!isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))) {
|
|
3922
|
+
throw new TypeError('Captain shell snapshot Captain agent is incompatible with current config');
|
|
3923
|
+
}
|
|
3924
|
+
const configuredPlayerIds = [...playerAgents.keys()].sort();
|
|
3925
|
+
const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
|
|
3926
|
+
if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
|
|
3927
|
+
throw new TypeError('Captain shell snapshot player ledger does not match current referenced players');
|
|
3928
|
+
}
|
|
3929
|
+
for (const playerId of configuredPlayerIds) {
|
|
3930
|
+
const saved = snapshot.playerSessions[playerId];
|
|
3931
|
+
const configured = playerAgents.get(playerId);
|
|
3932
|
+
const savedFixed = {
|
|
3933
|
+
adapter: saved.adapter,
|
|
3934
|
+
...(saved.instruction === undefined
|
|
3935
|
+
? {}
|
|
3936
|
+
: { instruction: saved.instruction }),
|
|
3937
|
+
...(saved.permissions === undefined
|
|
3938
|
+
? {}
|
|
3939
|
+
: { permissions: saved.permissions }),
|
|
3940
|
+
};
|
|
3941
|
+
if (!isDeepStrictEqual(savedFixed, fixedAgent(configured))) {
|
|
3942
|
+
throw new TypeError(`Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`);
|
|
3943
|
+
}
|
|
3205
3944
|
}
|
|
3206
3945
|
if (snapshot.mode === 'chat')
|
|
3207
3946
|
return;
|
|
3208
|
-
const
|
|
3209
|
-
const activeSessionIds = new Set([snapshot.captain.sessionId]);
|
|
3210
|
-
const issuedIds = new Set(snapshot.issuedSessionIds);
|
|
3211
|
-
const allowedHostPlayerIds = new Set();
|
|
3212
|
-
for (const enablement of enabled.values()) {
|
|
3213
|
-
for (const role of enablement.entry.requiredRoleIds) {
|
|
3214
|
-
allowedHostPlayerIds.add(enablement.hostPlayerId(role));
|
|
3215
|
-
}
|
|
3216
|
-
}
|
|
3217
|
-
for (const playerId of Object.keys(snapshot.rootPlayerResumeTokens)) {
|
|
3218
|
-
if (!allowedHostPlayerIds.has(playerId)) {
|
|
3219
|
-
throw new TypeError(`Captain shell snapshot root token names unknown host player ${JSON.stringify(playerId)}`);
|
|
3220
|
-
}
|
|
3221
|
-
}
|
|
3222
|
-
const bindingMaps = [];
|
|
3223
|
-
const rootSessionId = snapshot.frames[0].sessionId;
|
|
3224
|
-
for (const [index, frame] of snapshot.frames.entries()) {
|
|
3947
|
+
for (const frame of snapshot.frames) {
|
|
3225
3948
|
const enablement = enabled.get(frame.playbookId);
|
|
3226
3949
|
if (!enablement) {
|
|
3227
3950
|
throw new TypeError(`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`);
|
|
3228
3951
|
}
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
if (
|
|
3234
|
-
throw new TypeError(
|
|
3235
|
-
}
|
|
3236
|
-
activeSessionIds.add(frame.sessionId);
|
|
3237
|
-
if (!issuedIds.has(frame.sessionId)) {
|
|
3238
|
-
throw new TypeError('Captain shell snapshot frame session id was not historically issued');
|
|
3239
|
-
}
|
|
3240
|
-
if (frame.depth !== index ||
|
|
3241
|
-
frame.rootSessionId !== rootSessionId ||
|
|
3242
|
-
frame.runtime.state.status !== 'active' ||
|
|
3243
|
-
!frame.runtime.state.quiescent) {
|
|
3244
|
-
throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
|
|
3245
|
-
}
|
|
3246
|
-
if (index === 0) {
|
|
3247
|
-
if (frame.sessionId !== frame.rootSessionId ||
|
|
3248
|
-
frame.parentSessionId !== undefined ||
|
|
3249
|
-
frame.parentCallId !== undefined) {
|
|
3250
|
-
throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
|
|
3251
|
-
}
|
|
3252
|
-
}
|
|
3253
|
-
else {
|
|
3254
|
-
const parent = snapshot.frames[index - 1];
|
|
3255
|
-
if (frame.parentSessionId !== parent.sessionId ||
|
|
3256
|
-
frame.parentCallId === undefined) {
|
|
3257
|
-
throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
|
|
3258
|
-
}
|
|
3259
|
-
const pending = parent.runtime.suspendedCall;
|
|
3260
|
-
if (!pending ||
|
|
3261
|
-
pending.callId !== frame.parentCallId ||
|
|
3262
|
-
pending.playbookId !== frame.playbookId ||
|
|
3263
|
-
pending.childSessionId !== frame.sessionId) {
|
|
3264
|
-
throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
|
|
3265
|
-
}
|
|
3266
|
-
}
|
|
3267
|
-
const roleBindings = new Map();
|
|
3268
|
-
for (const role of enablement.entry.requiredRoleIds) {
|
|
3269
|
-
let inherited;
|
|
3270
|
-
for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
|
|
3271
|
-
inherited = bindingMaps[ancestor]?.get(role);
|
|
3272
|
-
if (inherited !== undefined)
|
|
3273
|
-
break;
|
|
3274
|
-
}
|
|
3275
|
-
roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
|
|
3952
|
+
const configuredBindings = Object.fromEntries([...enablement.roleBindings].map(([role, binding]) => [
|
|
3953
|
+
role,
|
|
3954
|
+
binding.playerId,
|
|
3955
|
+
]));
|
|
3956
|
+
if (!isDeepStrictEqual(frame.options, enablement.options)) {
|
|
3957
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`);
|
|
3276
3958
|
}
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
|
|
3280
|
-
return token === undefined ? [] : [[role, token]];
|
|
3281
|
-
}));
|
|
3282
|
-
if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
|
|
3283
|
-
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match root-owned continuation`);
|
|
3959
|
+
if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
|
|
3960
|
+
throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`);
|
|
3284
3961
|
}
|
|
3285
3962
|
}
|
|
3286
|
-
const leafRuntime = snapshot.frames.at(-1).runtime;
|
|
3287
|
-
if (leafRuntime.suspendedCall !== undefined ||
|
|
3288
|
-
!leafRuntime.state.tags.includes('playbook.parked')) {
|
|
3289
|
-
throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
|
|
3290
|
-
}
|
|
3291
3963
|
};
|
|
3292
3964
|
const safeCapturePoint = () => {
|
|
3293
3965
|
if (lifecycle !== 'ready' ||
|
|
@@ -3305,6 +3977,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3305
3977
|
runFailureFacts !== undefined ||
|
|
3306
3978
|
servingCall !== undefined ||
|
|
3307
3979
|
decisionCall !== undefined ||
|
|
3980
|
+
playerTransactions.size !== 0 ||
|
|
3308
3981
|
captainQueue.pending !== 0 ||
|
|
3309
3982
|
captainQueue.size !== 0 ||
|
|
3310
3983
|
(mode !== 'chat' && mode !== 'engaged.parked')) {
|
|
@@ -3341,7 +4014,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3341
4014
|
});
|
|
3342
4015
|
};
|
|
3343
4016
|
const exportShellSnapshot = () => {
|
|
3344
|
-
if (!safeCapturePoint() ||
|
|
4017
|
+
if (!safeCapturePoint() ||
|
|
4018
|
+
!captainRuntime ||
|
|
4019
|
+
!captainSessionId ||
|
|
4020
|
+
!captainAgent) {
|
|
3345
4021
|
return undefined;
|
|
3346
4022
|
}
|
|
3347
4023
|
try {
|
|
@@ -3374,16 +4050,23 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3374
4050
|
parentCallId: frame.parent.callId,
|
|
3375
4051
|
}
|
|
3376
4052
|
: {}),
|
|
4053
|
+
options: frame.enablement.options,
|
|
4054
|
+
roleBindings: Object.fromEntries([...frame.playerBindings].map(([role, binding]) => [
|
|
4055
|
+
role,
|
|
4056
|
+
binding.playerId,
|
|
4057
|
+
])),
|
|
3377
4058
|
runtime,
|
|
3378
4059
|
});
|
|
3379
4060
|
}
|
|
3380
4061
|
const common = {
|
|
3381
|
-
schemaVersion:
|
|
4062
|
+
schemaVersion: 3,
|
|
3382
4063
|
captain: {
|
|
3383
4064
|
sessionId: captainSessionId,
|
|
3384
4065
|
runtime: captainSnapshot,
|
|
4066
|
+
agent: fixedAgent(captainAgent),
|
|
3385
4067
|
conversation,
|
|
3386
4068
|
},
|
|
4069
|
+
playerSessions: playerLedgerRecord(),
|
|
3387
4070
|
issuedSessionIds: [...issuedSessionIds],
|
|
3388
4071
|
sequences: { turn: turnSequence, journal: journalSeq },
|
|
3389
4072
|
journal,
|
|
@@ -3398,7 +4081,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3398
4081
|
...common,
|
|
3399
4082
|
mode: 'engaged.parked',
|
|
3400
4083
|
frames: frameSnapshots,
|
|
3401
|
-
rootPlayerResumeTokens: tokenRecord(rootFrame().playerResumeTokens),
|
|
3402
4084
|
...(pendingBossQuestions === undefined
|
|
3403
4085
|
? {}
|
|
3404
4086
|
: { pendingBossQuestions: pendingBossQuestions }),
|
|
@@ -3420,7 +4102,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3420
4102
|
const normalized = assertPlaybookRuntimeSnapshot(actual, playbookId, allowSuspendedCall ? { allowSuspendedCall: true } : {});
|
|
3421
4103
|
for (const key of [
|
|
3422
4104
|
'state',
|
|
3423
|
-
'
|
|
4105
|
+
'roleResumeTokens',
|
|
3424
4106
|
'sequences',
|
|
3425
4107
|
'pendingBossQuestions',
|
|
3426
4108
|
'suspendedCall',
|
|
@@ -3458,7 +4140,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3458
4140
|
byCommand = new Map();
|
|
3459
4141
|
byId = new Map();
|
|
3460
4142
|
enablementById = new Map();
|
|
3461
|
-
|
|
4143
|
+
captainAgent = undefined;
|
|
4144
|
+
captainAdapter = undefined;
|
|
4145
|
+
playerAgents = new Map();
|
|
4146
|
+
playerLedger.clear();
|
|
4147
|
+
playerTransactions.clear();
|
|
3462
4148
|
session = undefined;
|
|
3463
4149
|
sessionEmissionsOpen = false;
|
|
3464
4150
|
closedGateAttempted = false;
|
|
@@ -3492,17 +4178,30 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3492
4178
|
lifecycle = 'restoring';
|
|
3493
4179
|
try {
|
|
3494
4180
|
const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
|
|
3495
|
-
const built = await buildEnablements(options,
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
4181
|
+
const built = await buildEnablements(options, loadModule);
|
|
4182
|
+
captainAgent = built.captainAgent;
|
|
4183
|
+
captainAdapter = captainAgent.adapter;
|
|
4184
|
+
playerAgents = built.playerAgents;
|
|
3499
4185
|
assertSnapshotMatchesEnablements(snapshot, built.enablementById);
|
|
3500
4186
|
installSession(initSession, false);
|
|
3501
|
-
players = initSession.players;
|
|
3502
4187
|
entries = built.entries;
|
|
3503
4188
|
byCommand = built.byCommand;
|
|
3504
4189
|
byId = built.byId;
|
|
3505
4190
|
enablementById = built.enablementById;
|
|
4191
|
+
for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
|
|
4192
|
+
playerLedger.set(playerId, {
|
|
4193
|
+
adapter: saved.adapter,
|
|
4194
|
+
...(saved.instruction === undefined
|
|
4195
|
+
? {}
|
|
4196
|
+
: { instruction: saved.instruction }),
|
|
4197
|
+
...(saved.permissions === undefined
|
|
4198
|
+
? {}
|
|
4199
|
+
: { permissions: livePermissions(saved.permissions) }),
|
|
4200
|
+
...(saved.resumeToken === undefined
|
|
4201
|
+
? {}
|
|
4202
|
+
: { resumeToken: saved.resumeToken }),
|
|
4203
|
+
});
|
|
4204
|
+
}
|
|
3506
4205
|
captainRuntime = createCaptainRuntime({
|
|
3507
4206
|
enabledPlaybooks: enabledCatalog(),
|
|
3508
4207
|
controller,
|
|
@@ -3511,10 +4210,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3511
4210
|
throw new Error('session Captain runtime does not support restore');
|
|
3512
4211
|
}
|
|
3513
4212
|
if (snapshot.mode === 'engaged.parked') {
|
|
3514
|
-
const rootTokens = new Map(Object.entries(snapshot.rootPlayerResumeTokens));
|
|
3515
4213
|
for (const [index, frameSnapshot] of snapshot.frames.entries()) {
|
|
3516
4214
|
const parentFrame = frames.at(-1);
|
|
3517
|
-
const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot,
|
|
4215
|
+
const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, index === 0
|
|
3518
4216
|
? undefined
|
|
3519
4217
|
: {
|
|
3520
4218
|
frame: parentFrame,
|
|
@@ -3531,7 +4229,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3531
4229
|
await captainRuntime.restore(captainPlaybookSession(snapshot.captain.sessionId), snapshot.captain.runtime);
|
|
3532
4230
|
if (snapshot.mode === 'engaged.parked') {
|
|
3533
4231
|
for (const [index, frame] of frames.entries()) {
|
|
3534
|
-
|
|
4232
|
+
restoringPlayerSessionFrame = frame;
|
|
4233
|
+
try {
|
|
4234
|
+
await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
|
|
4235
|
+
}
|
|
4236
|
+
finally {
|
|
4237
|
+
restoringPlayerSessionFrame = undefined;
|
|
4238
|
+
}
|
|
3535
4239
|
}
|
|
3536
4240
|
}
|
|
3537
4241
|
if (closedGateAttempted) {
|
|
@@ -3542,8 +4246,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3542
4246
|
for (const [index, frame] of frames.entries()) {
|
|
3543
4247
|
verifyRestoredRuntime(frame.runtime, snapshot.frames[index].runtime, frame.entry.id, true);
|
|
3544
4248
|
}
|
|
3545
|
-
if (!isDeepStrictEqual(
|
|
3546
|
-
throw new Error('restored
|
|
4249
|
+
if (!isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)) {
|
|
4250
|
+
throw new Error('restored Captain-session player continuation changed during restore');
|
|
3547
4251
|
}
|
|
3548
4252
|
}
|
|
3549
4253
|
if (closedGateAttempted) {
|
|
@@ -3589,14 +4293,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3589
4293
|
lifecycle = 'initializing';
|
|
3590
4294
|
try {
|
|
3591
4295
|
installSession(initSession, true);
|
|
3592
|
-
|
|
3593
|
-
const built = await buildEnablements(options, players, loadModule);
|
|
4296
|
+
const built = await buildEnablements(options, loadModule);
|
|
3594
4297
|
entries = built.entries;
|
|
3595
4298
|
byCommand = built.byCommand;
|
|
3596
4299
|
byId = built.byId;
|
|
3597
4300
|
enablementById = built.enablementById;
|
|
3598
|
-
|
|
3599
|
-
|
|
4301
|
+
captainAgent = built.captainAgent;
|
|
4302
|
+
captainAdapter = captainAgent.adapter;
|
|
4303
|
+
playerAgents = built.playerAgents;
|
|
4304
|
+
for (const [playerId, agent] of playerAgents) {
|
|
4305
|
+
playerLedger.set(playerId, fixedAgent(agent));
|
|
3600
4306
|
}
|
|
3601
4307
|
await setMode('chat', 'init');
|
|
3602
4308
|
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
@@ -3638,6 +4344,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3638
4344
|
const parsed = resolveCommandTurn(turn.prompt);
|
|
3639
4345
|
activeTurn = {
|
|
3640
4346
|
id: ++turnSequence,
|
|
4347
|
+
captainSyncedJournalSeq: journalSeq,
|
|
3641
4348
|
bossText: turn.prompt,
|
|
3642
4349
|
authoritativeText: parsed?.authoritativeText ?? turn.prompt,
|
|
3643
4350
|
...(parsed ? { resolution: parsed.resolution } : {}),
|
|
@@ -3645,6 +4352,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3645
4352
|
presentationAttempted: false,
|
|
3646
4353
|
settlementFacts: [],
|
|
3647
4354
|
effectThrows: new Set(),
|
|
4355
|
+
controlFailures: new Set(),
|
|
4356
|
+
settingsPreflightFailures: new Set(),
|
|
3648
4357
|
suppliedIdentifiers: new Set(),
|
|
3649
4358
|
outcomeRecorded: false,
|
|
3650
4359
|
};
|
|
@@ -3663,11 +4372,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3663
4372
|
new Error('the session Captain turn failed at its boundary'));
|
|
3664
4373
|
}
|
|
3665
4374
|
else if (result.outcome === 'aborted') {
|
|
3666
|
-
|
|
4375
|
+
markConversationUnsynchronized();
|
|
3667
4376
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
3668
4377
|
activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
|
|
3669
4378
|
journalOutcome([...activeTurn.settlementFacts]);
|
|
3670
4379
|
}
|
|
4380
|
+
if (context.signal.aborted) {
|
|
4381
|
+
throw context.signal.reason;
|
|
4382
|
+
}
|
|
3671
4383
|
}
|
|
3672
4384
|
else if (result.outcome !== 'suspended' &&
|
|
3673
4385
|
!context.signal.aborted &&
|
|
@@ -3681,10 +4393,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3681
4393
|
}
|
|
3682
4394
|
catch (error) {
|
|
3683
4395
|
if (context.signal.aborted) {
|
|
3684
|
-
|
|
4396
|
+
markConversationUnsynchronized();
|
|
3685
4397
|
throw error;
|
|
3686
4398
|
}
|
|
3687
|
-
const controlFailure = activeTurn?.
|
|
4399
|
+
const controlFailure = activeTurn?.controlFailures.has(error) === true;
|
|
3688
4400
|
await settleTurnFailure(context, error);
|
|
3689
4401
|
if (activeTurn?.presentationError !== undefined) {
|
|
3690
4402
|
throw activeTurn.presentationError;
|
|
@@ -3696,7 +4408,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3696
4408
|
}
|
|
3697
4409
|
finally {
|
|
3698
4410
|
if (context.signal.aborted) {
|
|
3699
|
-
|
|
4411
|
+
markConversationUnsynchronized();
|
|
3700
4412
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
3701
4413
|
activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
|
|
3702
4414
|
journalOutcome([...activeTurn.settlementFacts]);
|
|
@@ -3750,6 +4462,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3750
4462
|
failure ??= error;
|
|
3751
4463
|
}
|
|
3752
4464
|
}
|
|
4465
|
+
// Quarantine is session-wide by design. Only terminal teardown may drop
|
|
4466
|
+
// its ownership after every frame host call and the Captain are drained.
|
|
4467
|
+
playerTransactions.clear();
|
|
3753
4468
|
lifecycle = 'closed';
|
|
3754
4469
|
if (failure !== undefined)
|
|
3755
4470
|
throw failure;
|