@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
|
@@ -5,12 +5,17 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
5
|
import { isDeepStrictEqual } from 'node:util';
|
|
6
6
|
import PQueue from 'p-queue';
|
|
7
7
|
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
import {
|
|
9
|
+
isAgentCallSettingsError,
|
|
10
|
+
type AgentCallSettings,
|
|
11
|
+
type BossTurn,
|
|
12
|
+
type Captain,
|
|
13
|
+
type CaptainContext,
|
|
14
|
+
type CaptainSession,
|
|
15
|
+
type PlayerAdapterName,
|
|
16
|
+
type TuningSelection,
|
|
13
17
|
} from '@sublang/cligent/tmux-play';
|
|
18
|
+
import type { Effort, PermissionPolicy } from '@sublang/cligent';
|
|
14
19
|
import type {
|
|
15
20
|
JsonValue,
|
|
16
21
|
NormalizedError,
|
|
@@ -22,6 +27,7 @@ import type {
|
|
|
22
27
|
PlaybookRunResult,
|
|
23
28
|
PlaybookRuntime,
|
|
24
29
|
PlaybookRuntimeSnapshot,
|
|
30
|
+
PlayerResult,
|
|
25
31
|
PlayerSessionStore,
|
|
26
32
|
PlaybookState,
|
|
27
33
|
} from '@sublang/playbook/runtime';
|
|
@@ -30,6 +36,7 @@ import {
|
|
|
30
36
|
hiddenControlEnvelope,
|
|
31
37
|
registerPlaybookAbortCleanup,
|
|
32
38
|
snapshotJsonValue,
|
|
39
|
+
validatePlayerResult,
|
|
33
40
|
} from '../../../src/xstate-runtime.js';
|
|
34
41
|
import createDefaultCaptainRuntime, {
|
|
35
42
|
type CaptainControllerPort,
|
|
@@ -37,13 +44,37 @@ import createDefaultCaptainRuntime, {
|
|
|
37
44
|
type CaptainParsedResolution,
|
|
38
45
|
type SettlementEvidence,
|
|
39
46
|
} from '../captain.playbook/captain.playbook.js';
|
|
40
|
-
import type { PlaybookSummaryPolicy
|
|
47
|
+
import type { PlaybookSummaryPolicy } from './code.registry.js';
|
|
48
|
+
|
|
49
|
+
interface SessionAgent {
|
|
50
|
+
readonly adapter: string;
|
|
51
|
+
readonly model: TuningSelection;
|
|
52
|
+
readonly effort: TuningSelection<Effort>;
|
|
53
|
+
readonly instruction?: string;
|
|
54
|
+
readonly permissions?: PermissionPolicy;
|
|
55
|
+
}
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
interface PlayerLedgerEntry {
|
|
58
|
+
readonly adapter: string;
|
|
59
|
+
readonly instruction?: string;
|
|
60
|
+
readonly permissions?: PermissionPolicy;
|
|
61
|
+
resumeToken?: string;
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
type DeepReadonly<T> = T extends (...args: never[]) => unknown
|
|
65
|
+
? T
|
|
66
|
+
: T extends readonly (infer Element)[]
|
|
67
|
+
? readonly DeepReadonly<Element>[]
|
|
68
|
+
: T extends object
|
|
69
|
+
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
|
70
|
+
: T;
|
|
71
|
+
|
|
72
|
+
type SnapshotAgentEnvelope = DeepReadonly<
|
|
73
|
+
Omit<SessionAgent, 'model' | 'effort'>
|
|
74
|
+
>;
|
|
75
|
+
|
|
76
|
+
type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
|
|
77
|
+
|
|
47
78
|
export interface PlaybookCaptainDeps {
|
|
48
79
|
loadModule?: (specifier: string) => Promise<unknown>;
|
|
49
80
|
createSessionId?: () => string;
|
|
@@ -61,15 +92,22 @@ export interface PlaybookCaptainRegistryEntry {
|
|
|
61
92
|
id: string;
|
|
62
93
|
command: string;
|
|
63
94
|
intent: string;
|
|
95
|
+
artifactSchema: 2;
|
|
64
96
|
requiredRoleIds: readonly string[];
|
|
97
|
+
concurrentRoleSets: readonly (readonly string[])[];
|
|
65
98
|
summaryPolicy?: PlaybookSummaryPolicy;
|
|
66
|
-
validateOptions(
|
|
67
|
-
createRuntime(options:
|
|
99
|
+
validateOptions(optionSlice: unknown): unknown;
|
|
100
|
+
createRuntime(options: unknown): PlaybookRuntime;
|
|
68
101
|
}
|
|
69
102
|
|
|
70
103
|
type PlaybookCaptainConversationSnapshot =
|
|
71
104
|
| { readonly kind: 'unopened' }
|
|
72
105
|
| { readonly kind: 'pinned'; readonly token: string }
|
|
106
|
+
| {
|
|
107
|
+
readonly kind: 'needsCatchUp';
|
|
108
|
+
readonly resume: string | false;
|
|
109
|
+
readonly afterJournalSeq: number;
|
|
110
|
+
}
|
|
73
111
|
| { readonly kind: 'needsSeeding' };
|
|
74
112
|
|
|
75
113
|
interface PlaybookCaptainJournalRecord {
|
|
@@ -86,16 +124,20 @@ interface PlaybookCaptainFrameSnapshot {
|
|
|
86
124
|
readonly depth: number;
|
|
87
125
|
readonly parentSessionId?: string;
|
|
88
126
|
readonly parentCallId?: string;
|
|
89
|
-
readonly
|
|
127
|
+
readonly options: JsonValue;
|
|
128
|
+
readonly roleBindings: Readonly<Record<string, string>>;
|
|
129
|
+
readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
|
|
90
130
|
}
|
|
91
131
|
|
|
92
132
|
interface PlaybookCaptainShellSnapshotFields {
|
|
93
|
-
readonly schemaVersion:
|
|
133
|
+
readonly schemaVersion: 3;
|
|
94
134
|
readonly captain: {
|
|
95
135
|
readonly sessionId: string;
|
|
96
|
-
readonly runtime: PlaybookRuntimeSnapshot
|
|
136
|
+
readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
|
|
137
|
+
readonly agent: SnapshotAgentEnvelope;
|
|
97
138
|
readonly conversation: PlaybookCaptainConversationSnapshot;
|
|
98
139
|
};
|
|
140
|
+
readonly playerSessions: Readonly<Record<string, PlayerLedgerSnapshotEntry>>;
|
|
99
141
|
/** Every Captain and engagement UUID issued during this logical session. */
|
|
100
142
|
readonly issuedSessionIds: readonly string[];
|
|
101
143
|
readonly sequences: {
|
|
@@ -117,13 +159,12 @@ interface PlaybookCaptainShellSnapshotFields {
|
|
|
117
159
|
* Complete JSON-safe durable state for one Captain shell between Boss turns.
|
|
118
160
|
* The discriminated mode keeps chat snapshots free of stale engagement data.
|
|
119
161
|
*/
|
|
120
|
-
|
|
162
|
+
type PlaybookCaptainShellSnapshotValue =
|
|
121
163
|
PlaybookCaptainShellSnapshotFields &
|
|
122
164
|
(
|
|
123
165
|
| {
|
|
124
166
|
readonly mode: 'chat';
|
|
125
167
|
readonly frames?: never;
|
|
126
|
-
readonly rootPlayerResumeTokens?: never;
|
|
127
168
|
readonly pendingBossQuestions?: never;
|
|
128
169
|
readonly lastError?: never;
|
|
129
170
|
}
|
|
@@ -131,13 +172,14 @@ export type PlaybookCaptainShellSnapshot =
|
|
|
131
172
|
readonly mode: 'engaged.parked';
|
|
132
173
|
/** Root-to-leaf engagement order. */
|
|
133
174
|
readonly frames: readonly PlaybookCaptainFrameSnapshot[];
|
|
134
|
-
/** Root-owned continuation, keyed by effective host-player id. */
|
|
135
|
-
readonly rootPlayerResumeTokens: Readonly<Record<string, string>>;
|
|
136
175
|
readonly pendingBossQuestions?: JsonValue;
|
|
137
176
|
readonly lastError?: { readonly name: string; readonly message: string };
|
|
138
177
|
}
|
|
139
178
|
);
|
|
140
179
|
|
|
180
|
+
export type PlaybookCaptainShellSnapshot =
|
|
181
|
+
DeepReadonly<PlaybookCaptainShellSnapshotValue>;
|
|
182
|
+
|
|
141
183
|
/** tmux and headless front ends share this one durable Captain shell API. */
|
|
142
184
|
export interface PlaybookCaptainShell extends Captain {
|
|
143
185
|
exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
|
|
@@ -147,22 +189,45 @@ export interface PlaybookCaptainShell extends Captain {
|
|
|
147
189
|
): Promise<void>;
|
|
148
190
|
}
|
|
149
191
|
|
|
150
|
-
// Per-enabled-playbook binding the shell resolves at init from
|
|
151
|
-
// `captain.options.playbooks
|
|
152
|
-
// `<id>-<role>` host players and carries the generated visible set.
|
|
192
|
+
// Per-enabled-playbook binding the shell resolves at init from the exact
|
|
193
|
+
// normalized `captain.options.playbooks.<id>` role map.
|
|
153
194
|
interface Enablement {
|
|
154
195
|
entry: PlaybookCaptainRegistryEntry;
|
|
155
196
|
command: string;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
hostPlayerId: (localRole: string) => string;
|
|
197
|
+
options: JsonValue;
|
|
198
|
+
roleBindings: ReadonlyMap<string, EffectivePlayerBinding>;
|
|
159
199
|
}
|
|
160
200
|
|
|
161
201
|
interface EffectivePlayerBinding {
|
|
162
|
-
readonly
|
|
163
|
-
readonly
|
|
202
|
+
readonly playerId: string;
|
|
203
|
+
readonly model: TuningSelection;
|
|
204
|
+
readonly effort: TuningSelection<Effort>;
|
|
205
|
+
readonly agent: SessionAgent;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface PlayerTransactionOwner {
|
|
209
|
+
readonly frame: EngagementFrame;
|
|
210
|
+
readonly roleId: string;
|
|
211
|
+
readonly turnId: number;
|
|
212
|
+
readonly signal: AbortSignal;
|
|
213
|
+
readonly scope: object;
|
|
164
214
|
}
|
|
165
215
|
|
|
216
|
+
type PlayerTransaction =
|
|
217
|
+
| (PlayerTransactionOwner & {
|
|
218
|
+
readonly phase: 'calling';
|
|
219
|
+
abandoned: boolean;
|
|
220
|
+
})
|
|
221
|
+
| (PlayerTransactionOwner & {
|
|
222
|
+
readonly phase: 'awaitingCommit';
|
|
223
|
+
readonly status: PlayerResult['status'];
|
|
224
|
+
readonly expectedToken: string | undefined;
|
|
225
|
+
})
|
|
226
|
+
| (PlayerTransactionOwner & {
|
|
227
|
+
readonly phase: 'quarantined';
|
|
228
|
+
readonly reason: string;
|
|
229
|
+
});
|
|
230
|
+
|
|
166
231
|
interface EngagementFrame {
|
|
167
232
|
entry: PlaybookCaptainRegistryEntry;
|
|
168
233
|
enablement: Enablement;
|
|
@@ -171,7 +236,6 @@ interface EngagementFrame {
|
|
|
171
236
|
rootSessionId: string;
|
|
172
237
|
depth: number;
|
|
173
238
|
playerBindings: ReadonlyMap<string, EffectivePlayerBinding>;
|
|
174
|
-
playerResumeTokens: Map<string, string>;
|
|
175
239
|
parent?: {
|
|
176
240
|
frame: EngagementFrame;
|
|
177
241
|
callId: string;
|
|
@@ -180,6 +244,7 @@ interface EngagementFrame {
|
|
|
180
244
|
abortListener?: () => void;
|
|
181
245
|
invocationSignal?: AbortSignal;
|
|
182
246
|
inFlightHostCalls: Set<Promise<unknown>>;
|
|
247
|
+
playerCallScope?: object;
|
|
183
248
|
// Set synchronously before this frame's runtime is asked to dispose, so a
|
|
184
249
|
// telemetry payload emitted during disposal is never mistaken for evidence
|
|
185
250
|
// about a live leaf. `disposePromise` cannot serve: it is assigned after
|
|
@@ -204,6 +269,26 @@ class VisibilityControlError extends Error {
|
|
|
204
269
|
}
|
|
205
270
|
}
|
|
206
271
|
|
|
272
|
+
class AgentSettingsPreflightError extends Error {
|
|
273
|
+
constructor(readonly rejection: unknown) {
|
|
274
|
+
super('agent rejected supplied complete call settings', {
|
|
275
|
+
cause: rejection,
|
|
276
|
+
});
|
|
277
|
+
this.name = 'AgentSettingsPreflightError';
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function classifySettingsCall<T>(call: () => Promise<T>): Promise<T> {
|
|
282
|
+
try {
|
|
283
|
+
return await call();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (isAgentCallSettingsError(error)) {
|
|
286
|
+
throw new AgentSettingsPreflightError(error);
|
|
287
|
+
}
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
207
292
|
type DisposalReason = 'dismiss' | 'final' | 'dispose' | 'failure';
|
|
208
293
|
|
|
209
294
|
interface ControlLedger {
|
|
@@ -245,15 +330,19 @@ interface JournalRecord {
|
|
|
245
330
|
type DurableCallKind = 'decision' | 'commandReply' | 'closingReply';
|
|
246
331
|
|
|
247
332
|
/**
|
|
248
|
-
* CAPTAIN-35: the
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* failed reseed starting a bare conversation with no session memory at all.
|
|
333
|
+
* CAPTAIN-35: the durable conversation distinguishes first use, healthy
|
|
334
|
+
* continuity, a settings-preflight catch-up on retained continuity, and a
|
|
335
|
+
* full reseed after continuity becomes suspect. Modeling those states keeps
|
|
336
|
+
* each recovery path explicit instead of overloading one presence boolean.
|
|
253
337
|
*/
|
|
254
338
|
type DurableConversation =
|
|
255
339
|
| { readonly kind: 'unopened' }
|
|
256
340
|
| { readonly kind: 'pinned'; readonly token: string }
|
|
341
|
+
| {
|
|
342
|
+
readonly kind: 'needsCatchUp';
|
|
343
|
+
readonly resume: string | false;
|
|
344
|
+
readonly afterJournalSeq: number;
|
|
345
|
+
}
|
|
257
346
|
| { readonly kind: 'needsSeeding' };
|
|
258
347
|
|
|
259
348
|
/**
|
|
@@ -275,6 +364,8 @@ const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
|
|
|
275
364
|
const INTERNAL_CAPTAIN_ID = 'captain';
|
|
276
365
|
const UUID_PATTERN =
|
|
277
366
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
367
|
+
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
368
|
+
const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
278
369
|
|
|
279
370
|
interface TurnSummaryCounts {
|
|
280
371
|
interruptions: number;
|
|
@@ -290,6 +381,8 @@ interface ActiveTurnSummary {
|
|
|
290
381
|
/** The shell state of one Boss turn (DR-029). */
|
|
291
382
|
interface ActiveTurn {
|
|
292
383
|
readonly id: number;
|
|
384
|
+
/** Latest journal record represented in a successful Captain call. */
|
|
385
|
+
captainSyncedJournalSeq: number;
|
|
293
386
|
/** The exact Boss text of the turn; never rewritten (CAPTAIN-31). */
|
|
294
387
|
readonly bossText: string;
|
|
295
388
|
/**
|
|
@@ -318,7 +411,9 @@ interface ActiveTurn {
|
|
|
318
411
|
* with the Boss-appropriate failure reply instead of propagating, so the
|
|
319
412
|
* Boss's next message settles normally.
|
|
320
413
|
*/
|
|
321
|
-
|
|
414
|
+
readonly controlFailures: Set<unknown>;
|
|
415
|
+
/** Exact settings-preflight rejections that performed no provider work. */
|
|
416
|
+
readonly settingsPreflightFailures: Set<unknown>;
|
|
322
417
|
/**
|
|
323
418
|
* Every value that escaped an effect invocation this turn — a runtime
|
|
324
419
|
* driven, an engagement constructed, a stack disposed, an advertised action
|
|
@@ -466,6 +561,31 @@ function stateDigestLine(
|
|
|
466
561
|
].join('; ');
|
|
467
562
|
}
|
|
468
563
|
|
|
564
|
+
// CAPTAIN-5's mirrored ledger member holds the runtime snapshot's
|
|
565
|
+
// pending-question projection — entries of `{ questionId, asker, question,
|
|
566
|
+
// sourceItem }` — never a raw telemetry payload: the linked runtime's state
|
|
567
|
+
// telemetry carries the singular full-context question, whose extra
|
|
568
|
+
// runtime-internal fields (`resumeStateId`) fail the durable snapshot's
|
|
569
|
+
// leaf-projection equality (CAPTAIN-41) and with it headless settlement.
|
|
570
|
+
function mirroredBossQuestions(value: unknown): unknown {
|
|
571
|
+
if (value === undefined || value === null) return undefined;
|
|
572
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
573
|
+
return entries.map((entry) => {
|
|
574
|
+
if (typeof entry !== 'object' || entry === null) return entry;
|
|
575
|
+
const record = entry as Record<string, unknown>;
|
|
576
|
+
const projected: Record<string, unknown> = {};
|
|
577
|
+
if (record.questionId !== undefined) {
|
|
578
|
+
projected.questionId = record.questionId;
|
|
579
|
+
}
|
|
580
|
+
if (record.asker !== undefined) projected.asker = record.asker;
|
|
581
|
+
if (record.question !== undefined) projected.question = record.question;
|
|
582
|
+
if (record.sourceItem !== undefined) {
|
|
583
|
+
projected.sourceItem = record.sourceItem;
|
|
584
|
+
}
|
|
585
|
+
return projected;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
469
589
|
function pendingQuestionLines(pending: unknown): string[] {
|
|
470
590
|
const list = Array.isArray(pending)
|
|
471
591
|
? pending
|
|
@@ -492,7 +612,16 @@ function pendingQuestionLines(pending: unknown): string[] {
|
|
|
492
612
|
: typeof record.id === 'string'
|
|
493
613
|
? record.id
|
|
494
614
|
: undefined;
|
|
495
|
-
const
|
|
615
|
+
const asker =
|
|
616
|
+
typeof record.asker === 'object' && record.asker !== null
|
|
617
|
+
? (record.asker as Record<string, unknown>)
|
|
618
|
+
: undefined;
|
|
619
|
+
const askerLabel =
|
|
620
|
+
asker?.kind === 'captain'
|
|
621
|
+
? 'Captain'
|
|
622
|
+
: asker?.kind === 'role' && typeof asker.roleId === 'string'
|
|
623
|
+
? asker.roleId
|
|
624
|
+
: undefined;
|
|
496
625
|
const text =
|
|
497
626
|
typeof record.question === 'string'
|
|
498
627
|
? record.question
|
|
@@ -504,9 +633,9 @@ function pendingQuestionLines(pending: unknown): string[] {
|
|
|
504
633
|
// second time would cut the line at the seam's limit and drop whatever
|
|
505
634
|
// the shell had already written after the long part.
|
|
506
635
|
const asked =
|
|
507
|
-
|
|
636
|
+
askerLabel === undefined
|
|
508
637
|
? digestLine`${quoteEvidence(text)}`
|
|
509
|
-
: digestLine`${quoteEvidence(
|
|
638
|
+
: digestLine`${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
|
|
510
639
|
const marker = id === undefined ? '' : digestLine`(${quoteEvidence(id)}) `;
|
|
511
640
|
lines.push(`- ${marker}${asked}`);
|
|
512
641
|
}
|
|
@@ -548,7 +677,10 @@ function renderJournalPayload(payload: JsonValue): string {
|
|
|
548
677
|
return raw ?? 'null';
|
|
549
678
|
}
|
|
550
679
|
|
|
551
|
-
function
|
|
680
|
+
function renderJournalDigest(
|
|
681
|
+
records: readonly JournalRecord[],
|
|
682
|
+
heading: string,
|
|
683
|
+
): string {
|
|
552
684
|
const lines = records.map(
|
|
553
685
|
(record) =>
|
|
554
686
|
`${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(
|
|
@@ -556,12 +688,26 @@ function renderReseedDigest(records: readonly JournalRecord[]): string {
|
|
|
556
688
|
)}`,
|
|
557
689
|
);
|
|
558
690
|
return [
|
|
559
|
-
|
|
691
|
+
heading,
|
|
560
692
|
'The labeled ControlView and catalog digest blocks outrank conversation memory.',
|
|
561
693
|
...(lines.length === 0 ? ['(no earlier turns)'] : lines),
|
|
562
694
|
].join('\n');
|
|
563
695
|
}
|
|
564
696
|
|
|
697
|
+
function renderReseedDigest(records: readonly JournalRecord[]): string {
|
|
698
|
+
return renderJournalDigest(
|
|
699
|
+
records,
|
|
700
|
+
'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.',
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function renderCatchUpDigest(records: readonly JournalRecord[]): string {
|
|
705
|
+
return renderJournalDigest(
|
|
706
|
+
records,
|
|
707
|
+
'This retained conversation missed the host journal records below. Treat this deterministic journal suffix as authoritative.',
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
565
711
|
// DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
|
|
566
712
|
// internal control vocabulary.
|
|
567
713
|
const CONTROL_VOCABULARY: readonly RegExp[] = [
|
|
@@ -681,16 +827,32 @@ function proseRejection(
|
|
|
681
827
|
return undefined;
|
|
682
828
|
}
|
|
683
829
|
|
|
830
|
+
type CaptainToolIsolation = 'provider-enforced' | 'prompt-only';
|
|
831
|
+
|
|
684
832
|
// DR-013 A1: adapters with no provider-enforced tool-restriction surface.
|
|
685
|
-
// Cligent's Codex
|
|
686
|
-
// empty list that expresses tool-free — because
|
|
687
|
-
// cannot enforce one, so requesting it fails
|
|
688
|
-
// model is reached. Omitting the option is the
|
|
689
|
-
// run a control call at all; its isolation then
|
|
690
|
-
// hidden-judge envelope below rather than on provider
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
833
|
+
// Cligent's Codex, Kimi, and OpenCode adapters reject any `allowedTools`
|
|
834
|
+
// value — including the empty list that expresses tool-free — because their
|
|
835
|
+
// supported provider surfaces cannot enforce one, so requesting it fails
|
|
836
|
+
// every control call before the model is reached. Omitting the option is the
|
|
837
|
+
// only way such an adapter can run a control call at all; its isolation then
|
|
838
|
+
// rests on the authored hidden-judge envelope below rather than on provider
|
|
839
|
+
// enforcement.
|
|
840
|
+
const CAPTAIN_TOOL_ISOLATION_BY_ADAPTER = {
|
|
841
|
+
claude: 'provider-enforced',
|
|
842
|
+
codex: 'prompt-only',
|
|
843
|
+
gemini: 'provider-enforced',
|
|
844
|
+
kimi: 'prompt-only',
|
|
845
|
+
opencode: 'prompt-only',
|
|
846
|
+
} as const satisfies Readonly<Record<PlayerAdapterName, CaptainToolIsolation>>;
|
|
847
|
+
|
|
848
|
+
function requiresPromptOnlyToolIsolation(captainAdapter: string): boolean {
|
|
849
|
+
return (
|
|
850
|
+
Object.hasOwn(CAPTAIN_TOOL_ISOLATION_BY_ADAPTER, captainAdapter) &&
|
|
851
|
+
CAPTAIN_TOOL_ISOLATION_BY_ADAPTER[
|
|
852
|
+
captainAdapter as PlayerAdapterName
|
|
853
|
+
] === 'prompt-only'
|
|
854
|
+
);
|
|
855
|
+
}
|
|
694
856
|
|
|
695
857
|
// The tool half of a control call's options. An empty allowlist means "no
|
|
696
858
|
// tools available" and is distinct from omission, which grants the adapter's
|
|
@@ -701,7 +863,7 @@ function controlCallToolOptions(
|
|
|
701
863
|
): { allowedTools?: readonly string[] } {
|
|
702
864
|
if (
|
|
703
865
|
captainAdapter !== undefined &&
|
|
704
|
-
|
|
866
|
+
requiresPromptOnlyToolIsolation(captainAdapter)
|
|
705
867
|
) {
|
|
706
868
|
return {};
|
|
707
869
|
}
|
|
@@ -721,14 +883,6 @@ function forwardedToolOptions(
|
|
|
721
883
|
return { allowedTools: requested };
|
|
722
884
|
}
|
|
723
885
|
|
|
724
|
-
function readCaptainAdapter(options: unknown): string | undefined {
|
|
725
|
-
if (typeof options !== 'object' || options === null) return undefined;
|
|
726
|
-
const adapter = (options as Record<string, unknown>).captainAdapter;
|
|
727
|
-
return typeof adapter === 'string' && adapter.length > 0
|
|
728
|
-
? adapter
|
|
729
|
-
: undefined;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
886
|
const hiddenJudgeEnvelope = hiddenControlEnvelope;
|
|
733
887
|
|
|
734
888
|
interface OutcomeReport {
|
|
@@ -839,11 +993,39 @@ function isValidRegistryEntry(
|
|
|
839
993
|
): value is PlaybookCaptainRegistryEntry {
|
|
840
994
|
if (typeof value !== 'object' || value === null) return false;
|
|
841
995
|
const e = value as Record<string, unknown>;
|
|
996
|
+
if (
|
|
997
|
+
!Array.isArray(e.requiredRoleIds) ||
|
|
998
|
+
e.requiredRoleIds.some(
|
|
999
|
+
(role) =>
|
|
1000
|
+
typeof role !== 'string' ||
|
|
1001
|
+
!ROLE_ID_PATTERN.test(role) ||
|
|
1002
|
+
role === INTERNAL_CAPTAIN_ID,
|
|
1003
|
+
) ||
|
|
1004
|
+
new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
|
|
1005
|
+
!Array.isArray(e.concurrentRoleSets)
|
|
1006
|
+
) {
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
const roles = new Set(e.requiredRoleIds);
|
|
1010
|
+
const concurrency = e.concurrentRoleSets as unknown[];
|
|
1011
|
+
if (
|
|
1012
|
+
concurrency.some(
|
|
1013
|
+
(set) =>
|
|
1014
|
+
!Array.isArray(set) ||
|
|
1015
|
+
set.length < 2 ||
|
|
1016
|
+
set.some((role) => typeof role !== 'string' || !roles.has(role)) ||
|
|
1017
|
+
new Set(set).size !== set.length,
|
|
1018
|
+
) ||
|
|
1019
|
+
new Set(concurrency.map((set) => JSON.stringify(set))).size !==
|
|
1020
|
+
concurrency.length
|
|
1021
|
+
) {
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
842
1024
|
return (
|
|
843
1025
|
typeof e.id === 'string' &&
|
|
844
1026
|
typeof e.command === 'string' &&
|
|
845
1027
|
typeof e.intent === 'string' &&
|
|
846
|
-
|
|
1028
|
+
e.artifactSchema === 2 &&
|
|
847
1029
|
typeof e.validateOptions === 'function' &&
|
|
848
1030
|
typeof e.createRuntime === 'function'
|
|
849
1031
|
);
|
|
@@ -925,8 +1107,207 @@ function snapshotUuid(value: JsonValue | undefined, path: string): string {
|
|
|
925
1107
|
return id;
|
|
926
1108
|
}
|
|
927
1109
|
|
|
1110
|
+
function snapshotPermissions(
|
|
1111
|
+
value: JsonValue | undefined,
|
|
1112
|
+
path: string,
|
|
1113
|
+
): PermissionPolicy | undefined {
|
|
1114
|
+
if (value === undefined) return undefined;
|
|
1115
|
+
const record = snapshotRecord(value, path);
|
|
1116
|
+
rejectSnapshotKeys(
|
|
1117
|
+
record,
|
|
1118
|
+
['mode', 'fileWrite', 'shellExecute', 'networkAccess', 'writablePaths'],
|
|
1119
|
+
path,
|
|
1120
|
+
);
|
|
1121
|
+
const normalized: PermissionPolicy = {};
|
|
1122
|
+
if (record.mode !== undefined) {
|
|
1123
|
+
if (record.mode !== 'auto' && record.mode !== 'bypass') {
|
|
1124
|
+
throw new TypeError(`${path}.mode must be "auto" or "bypass"`);
|
|
1125
|
+
}
|
|
1126
|
+
normalized.mode = record.mode;
|
|
1127
|
+
}
|
|
1128
|
+
for (const key of [
|
|
1129
|
+
'fileWrite',
|
|
1130
|
+
'shellExecute',
|
|
1131
|
+
'networkAccess',
|
|
1132
|
+
] as const) {
|
|
1133
|
+
const level = record[key];
|
|
1134
|
+
if (level === undefined) continue;
|
|
1135
|
+
if (level !== 'allow' && level !== 'ask' && level !== 'deny') {
|
|
1136
|
+
throw new TypeError(`${path}.${key} must be "allow", "ask", or "deny"`);
|
|
1137
|
+
}
|
|
1138
|
+
normalized[key] = level;
|
|
1139
|
+
}
|
|
1140
|
+
if (record.writablePaths !== undefined) {
|
|
1141
|
+
if (
|
|
1142
|
+
!Array.isArray(record.writablePaths) ||
|
|
1143
|
+
record.writablePaths.some(
|
|
1144
|
+
(entry) => typeof entry !== 'string' || entry.length === 0,
|
|
1145
|
+
)
|
|
1146
|
+
) {
|
|
1147
|
+
throw new TypeError(`${path}.writablePaths must be an array of non-empty strings`);
|
|
1148
|
+
}
|
|
1149
|
+
normalized.writablePaths = [...record.writablePaths];
|
|
1150
|
+
}
|
|
1151
|
+
return normalized;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function livePermissions(
|
|
1155
|
+
value: DeepReadonly<PermissionPolicy> | undefined,
|
|
1156
|
+
): PermissionPolicy | undefined {
|
|
1157
|
+
if (value === undefined) return undefined;
|
|
1158
|
+
return {
|
|
1159
|
+
...(value.mode === undefined ? {} : { mode: value.mode }),
|
|
1160
|
+
...(value.fileWrite === undefined ? {} : { fileWrite: value.fileWrite }),
|
|
1161
|
+
...(value.shellExecute === undefined
|
|
1162
|
+
? {}
|
|
1163
|
+
: { shellExecute: value.shellExecute }),
|
|
1164
|
+
...(value.networkAccess === undefined
|
|
1165
|
+
? {}
|
|
1166
|
+
: { networkAccess: value.networkAccess }),
|
|
1167
|
+
...(value.writablePaths === undefined
|
|
1168
|
+
? {}
|
|
1169
|
+
: { writablePaths: [...value.writablePaths] }),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function snapshotFixedAgent(
|
|
1174
|
+
value: JsonValue | undefined,
|
|
1175
|
+
path: string,
|
|
1176
|
+
): SnapshotAgentEnvelope {
|
|
1177
|
+
const record = snapshotRecord(value, path);
|
|
1178
|
+
rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions'], path);
|
|
1179
|
+
const adapter = snapshotString(record.adapter, `${path}.adapter`);
|
|
1180
|
+
const instruction =
|
|
1181
|
+
record.instruction === undefined
|
|
1182
|
+
? undefined
|
|
1183
|
+
: snapshotString(record.instruction, `${path}.instruction`, true);
|
|
1184
|
+
const permissions = snapshotPermissions(record.permissions, `${path}.permissions`);
|
|
1185
|
+
return {
|
|
1186
|
+
adapter,
|
|
1187
|
+
...(instruction === undefined ? {} : { instruction }),
|
|
1188
|
+
...(permissions === undefined ? {} : { permissions }),
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function snapshotPlayerSessions(
|
|
1193
|
+
value: JsonValue | undefined,
|
|
1194
|
+
path: string,
|
|
1195
|
+
): Readonly<Record<string, PlayerLedgerSnapshotEntry>> {
|
|
1196
|
+
const sessions = snapshotRecord(value, path);
|
|
1197
|
+
return Object.fromEntries(
|
|
1198
|
+
Object.entries(sessions).map(([playerId, raw]) => {
|
|
1199
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
1200
|
+
throw new TypeError(`${path} has invalid player id ${JSON.stringify(playerId)}`);
|
|
1201
|
+
}
|
|
1202
|
+
const record = snapshotRecord(raw, `${path}.${playerId}`);
|
|
1203
|
+
rejectSnapshotKeys(
|
|
1204
|
+
record,
|
|
1205
|
+
['adapter', 'instruction', 'permissions', 'resumeToken'],
|
|
1206
|
+
`${path}.${playerId}`,
|
|
1207
|
+
);
|
|
1208
|
+
const fixed = snapshotFixedAgent(
|
|
1209
|
+
Object.fromEntries(
|
|
1210
|
+
Object.entries(record).filter(([key]) => key !== 'resumeToken'),
|
|
1211
|
+
) as JsonValue,
|
|
1212
|
+
`${path}.${playerId}`,
|
|
1213
|
+
);
|
|
1214
|
+
const resumeToken =
|
|
1215
|
+
record.resumeToken === undefined
|
|
1216
|
+
? undefined
|
|
1217
|
+
: snapshotString(record.resumeToken, `${path}.${playerId}.resumeToken`);
|
|
1218
|
+
return [
|
|
1219
|
+
playerId,
|
|
1220
|
+
{ ...fixed, ...(resumeToken === undefined ? {} : { resumeToken }) },
|
|
1221
|
+
];
|
|
1222
|
+
}),
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function snapshotFrameRoleBindings(
|
|
1227
|
+
value: JsonValue | undefined,
|
|
1228
|
+
path: string,
|
|
1229
|
+
): Readonly<Record<string, string>> {
|
|
1230
|
+
const bindings = snapshotRecord(value, path);
|
|
1231
|
+
return Object.fromEntries(
|
|
1232
|
+
Object.entries(bindings).map(([roleId, raw]) => {
|
|
1233
|
+
if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
|
|
1234
|
+
throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
|
|
1235
|
+
}
|
|
1236
|
+
const playerId = snapshotString(raw, `${path}.${roleId}`);
|
|
1237
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
1238
|
+
throw new TypeError(`${path}.${roleId} has invalid player id`);
|
|
1239
|
+
}
|
|
1240
|
+
return [roleId, playerId];
|
|
1241
|
+
}),
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function normalizeHostPlayerResult(
|
|
1246
|
+
value: unknown,
|
|
1247
|
+
expectedPlayerId: string,
|
|
1248
|
+
): PlayerResult {
|
|
1249
|
+
const path = 'tmux-play delegated-player result';
|
|
1250
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
1251
|
+
throw new TypeError(`${path} must be an object`);
|
|
1252
|
+
}
|
|
1253
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
1254
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1255
|
+
throw new TypeError(`${path} must be a plain JSON object`);
|
|
1256
|
+
}
|
|
1257
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1258
|
+
const allowedKeys = new Set([
|
|
1259
|
+
'status',
|
|
1260
|
+
'playerId',
|
|
1261
|
+
'turnId',
|
|
1262
|
+
'resumeToken',
|
|
1263
|
+
'finalText',
|
|
1264
|
+
'error',
|
|
1265
|
+
]);
|
|
1266
|
+
const normalized: Record<string, unknown> = {};
|
|
1267
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
1268
|
+
if (typeof key === 'symbol') {
|
|
1269
|
+
throw new TypeError(`${path} must not contain symbol-keyed properties`);
|
|
1270
|
+
}
|
|
1271
|
+
const descriptor = descriptors[key];
|
|
1272
|
+
if (!allowedKeys.has(key)) {
|
|
1273
|
+
throw new TypeError(`${path} has unknown field ${JSON.stringify(key)}`);
|
|
1274
|
+
}
|
|
1275
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
|
|
1276
|
+
throw new TypeError(`${path}.${key} must be an enumerable data property`);
|
|
1277
|
+
}
|
|
1278
|
+
// Cligent deliberately exposes optional result members as own
|
|
1279
|
+
// `undefined` data properties. Omit only those members before taking the
|
|
1280
|
+
// immutable JSON snapshot; every other value still passes the strict
|
|
1281
|
+
// JSON validator below.
|
|
1282
|
+
if (descriptor.value !== undefined) normalized[key] = descriptor.value;
|
|
1283
|
+
}
|
|
1284
|
+
const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
|
|
1285
|
+
rejectSnapshotKeys(
|
|
1286
|
+
record,
|
|
1287
|
+
['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'],
|
|
1288
|
+
path,
|
|
1289
|
+
);
|
|
1290
|
+
if (record.playerId !== expectedPlayerId) {
|
|
1291
|
+
throw new TypeError(`${path}.playerId does not match the requested player`);
|
|
1292
|
+
}
|
|
1293
|
+
snapshotInteger(record.turnId, `${path}.turnId`, 1);
|
|
1294
|
+
return validatePlayerResult(
|
|
1295
|
+
{
|
|
1296
|
+
status: record.status,
|
|
1297
|
+
...(record.resumeToken === undefined
|
|
1298
|
+
? {}
|
|
1299
|
+
: { resumeToken: record.resumeToken }),
|
|
1300
|
+
...(record.finalText === undefined
|
|
1301
|
+
? {}
|
|
1302
|
+
: { finalText: record.finalText }),
|
|
1303
|
+
...(record.error === undefined ? {} : { error: record.error }),
|
|
1304
|
+
},
|
|
1305
|
+
path,
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
928
1309
|
/** Validate, detach, and freeze one untrusted shell snapshot. */
|
|
929
|
-
function assertPlaybookCaptainShellSnapshot(
|
|
1310
|
+
export function assertPlaybookCaptainShellSnapshot(
|
|
930
1311
|
value: unknown,
|
|
931
1312
|
): PlaybookCaptainShellSnapshot {
|
|
932
1313
|
const detached = snapshotJsonValue(value, 'Captain shell snapshot');
|
|
@@ -935,6 +1316,7 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
935
1316
|
const commonKeys = [
|
|
936
1317
|
'schemaVersion',
|
|
937
1318
|
'captain',
|
|
1319
|
+
'playerSessions',
|
|
938
1320
|
'issuedSessionIds',
|
|
939
1321
|
'sequences',
|
|
940
1322
|
'journal',
|
|
@@ -950,7 +1332,6 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
950
1332
|
[
|
|
951
1333
|
...commonKeys,
|
|
952
1334
|
'frames',
|
|
953
|
-
'rootPlayerResumeTokens',
|
|
954
1335
|
'pendingBossQuestions',
|
|
955
1336
|
'lastError',
|
|
956
1337
|
],
|
|
@@ -961,9 +1342,9 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
961
1342
|
'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
|
|
962
1343
|
);
|
|
963
1344
|
}
|
|
964
|
-
if (snapshot.schemaVersion !==
|
|
1345
|
+
if (snapshot.schemaVersion !== 3) {
|
|
965
1346
|
throw new TypeError(
|
|
966
|
-
`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected
|
|
1347
|
+
`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
|
|
967
1348
|
);
|
|
968
1349
|
}
|
|
969
1350
|
|
|
@@ -973,7 +1354,7 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
973
1354
|
);
|
|
974
1355
|
rejectSnapshotKeys(
|
|
975
1356
|
captain,
|
|
976
|
-
['sessionId', 'runtime', 'conversation'],
|
|
1357
|
+
['sessionId', 'runtime', 'agent', 'conversation'],
|
|
977
1358
|
'Captain shell snapshot.captain',
|
|
978
1359
|
);
|
|
979
1360
|
const captainSessionId = snapshotUuid(
|
|
@@ -984,6 +1365,10 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
984
1365
|
captain.runtime,
|
|
985
1366
|
INTERNAL_CAPTAIN_ID,
|
|
986
1367
|
);
|
|
1368
|
+
const captainAgent = snapshotFixedAgent(
|
|
1369
|
+
captain.agent,
|
|
1370
|
+
'Captain shell snapshot.captain.agent',
|
|
1371
|
+
);
|
|
987
1372
|
const conversation = snapshotRecord(
|
|
988
1373
|
captain.conversation,
|
|
989
1374
|
'Captain shell snapshot.captain.conversation',
|
|
@@ -1000,7 +1385,26 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1000
1385
|
token: snapshotString(
|
|
1001
1386
|
conversation.token,
|
|
1002
1387
|
'Captain shell snapshot.captain.conversation.token',
|
|
1003
|
-
|
|
1388
|
+
),
|
|
1389
|
+
};
|
|
1390
|
+
} else if (conversation.kind === 'needsCatchUp') {
|
|
1391
|
+
rejectSnapshotKeys(
|
|
1392
|
+
conversation,
|
|
1393
|
+
['kind', 'resume', 'afterJournalSeq'],
|
|
1394
|
+
'Captain shell snapshot.captain.conversation',
|
|
1395
|
+
);
|
|
1396
|
+
normalizedConversation = {
|
|
1397
|
+
kind: 'needsCatchUp',
|
|
1398
|
+
resume:
|
|
1399
|
+
conversation.resume === false
|
|
1400
|
+
? false
|
|
1401
|
+
: snapshotString(
|
|
1402
|
+
conversation.resume,
|
|
1403
|
+
'Captain shell snapshot.captain.conversation.resume',
|
|
1404
|
+
),
|
|
1405
|
+
afterJournalSeq: snapshotInteger(
|
|
1406
|
+
conversation.afterJournalSeq,
|
|
1407
|
+
'Captain shell snapshot.captain.conversation.afterJournalSeq',
|
|
1004
1408
|
),
|
|
1005
1409
|
};
|
|
1006
1410
|
} else if (
|
|
@@ -1131,6 +1535,29 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1131
1535
|
'Captain shell snapshot sequences do not match the complete journal',
|
|
1132
1536
|
);
|
|
1133
1537
|
}
|
|
1538
|
+
const emptyHistory = turnSequence === 0 && normalizedJournal.length === 0;
|
|
1539
|
+
if ((normalizedConversation.kind === 'unopened') !== emptyHistory) {
|
|
1540
|
+
throw new TypeError(
|
|
1541
|
+
'Captain shell snapshot history is empty exactly when its conversation is unopened',
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
if (
|
|
1545
|
+
normalizedConversation.kind === 'needsCatchUp' &&
|
|
1546
|
+
normalizedConversation.afterJournalSeq >= journalSequence
|
|
1547
|
+
) {
|
|
1548
|
+
throw new TypeError(
|
|
1549
|
+
'Captain shell snapshot catch-up watermark must precede the current journal sequence',
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
if (
|
|
1553
|
+
normalizedConversation.kind === 'needsCatchUp' &&
|
|
1554
|
+
((normalizedConversation.resume === false) !==
|
|
1555
|
+
(normalizedConversation.afterJournalSeq === 0))
|
|
1556
|
+
) {
|
|
1557
|
+
throw new TypeError(
|
|
1558
|
+
'Captain shell snapshot catch-up resume is fresh exactly at journal watermark zero',
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1134
1561
|
|
|
1135
1562
|
let lastAction: PlaybookCaptainShellSnapshotFields['lastAction'];
|
|
1136
1563
|
if (snapshot.lastAction !== undefined) {
|
|
@@ -1162,13 +1589,19 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1162
1589
|
typeof lastSettlementStatus
|
|
1163
1590
|
>;
|
|
1164
1591
|
}
|
|
1592
|
+
const playerSessions = snapshotPlayerSessions(
|
|
1593
|
+
snapshot.playerSessions,
|
|
1594
|
+
'Captain shell snapshot.playerSessions',
|
|
1595
|
+
);
|
|
1165
1596
|
const common: PlaybookCaptainShellSnapshotFields = {
|
|
1166
|
-
schemaVersion:
|
|
1597
|
+
schemaVersion: 3,
|
|
1167
1598
|
captain: {
|
|
1168
1599
|
sessionId: captainSessionId,
|
|
1169
1600
|
runtime: captainRuntime,
|
|
1601
|
+
agent: captainAgent,
|
|
1170
1602
|
conversation: normalizedConversation,
|
|
1171
1603
|
},
|
|
1604
|
+
playerSessions,
|
|
1172
1605
|
issuedSessionIds: issued,
|
|
1173
1606
|
sequences: { turn: turnSequence, journal: journalSequence },
|
|
1174
1607
|
journal: normalizedJournal,
|
|
@@ -1177,6 +1610,23 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1177
1610
|
? {}
|
|
1178
1611
|
: { lastSettlementStatus }),
|
|
1179
1612
|
};
|
|
1613
|
+
if (
|
|
1614
|
+
captainRuntime.state.status !== 'active' ||
|
|
1615
|
+
!captainRuntime.state.quiescent ||
|
|
1616
|
+
!captainRuntime.state.tags.includes('playbook.parked') ||
|
|
1617
|
+
captainRuntime.suspendedCall !== undefined ||
|
|
1618
|
+
Object.keys(captainRuntime.roleResumeTokens).length > 0 ||
|
|
1619
|
+
captainRuntime.pendingBossQuestions.length > 0
|
|
1620
|
+
) {
|
|
1621
|
+
throw new TypeError(
|
|
1622
|
+
'Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended',
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
if (captainRuntime.sequences.turn !== turnSequence) {
|
|
1626
|
+
throw new TypeError(
|
|
1627
|
+
'Captain shell snapshot Captain and shell turn sequences must match',
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1180
1630
|
if (mode === 'chat') {
|
|
1181
1631
|
return snapshotJsonValue(
|
|
1182
1632
|
{ ...common, mode },
|
|
@@ -1204,6 +1654,8 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1204
1654
|
'depth',
|
|
1205
1655
|
'parentSessionId',
|
|
1206
1656
|
'parentCallId',
|
|
1657
|
+
'options',
|
|
1658
|
+
'roleBindings',
|
|
1207
1659
|
'runtime',
|
|
1208
1660
|
],
|
|
1209
1661
|
`Captain shell snapshot.frames[${index}]`,
|
|
@@ -1243,6 +1695,11 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1243
1695
|
playbookId,
|
|
1244
1696
|
{ allowSuspendedCall: true },
|
|
1245
1697
|
);
|
|
1698
|
+
const options = frame.options as JsonValue;
|
|
1699
|
+
const roleBindings = snapshotFrameRoleBindings(
|
|
1700
|
+
frame.roleBindings,
|
|
1701
|
+
`Captain shell snapshot.frames[${index}].roleBindings`,
|
|
1702
|
+
);
|
|
1246
1703
|
normalizedFrames.push({
|
|
1247
1704
|
playbookId,
|
|
1248
1705
|
sessionId,
|
|
@@ -1250,23 +1707,11 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1250
1707
|
depth,
|
|
1251
1708
|
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
1252
1709
|
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
1710
|
+
options,
|
|
1711
|
+
roleBindings,
|
|
1253
1712
|
runtime,
|
|
1254
1713
|
});
|
|
1255
1714
|
}
|
|
1256
|
-
|
|
1257
|
-
const rootTokens = snapshotRecord(
|
|
1258
|
-
snapshot.rootPlayerResumeTokens,
|
|
1259
|
-
'Captain shell snapshot.rootPlayerResumeTokens',
|
|
1260
|
-
);
|
|
1261
|
-
const normalizedRootTokens = Object.fromEntries(
|
|
1262
|
-
Object.entries(rootTokens).map(([playerId, token]) => [
|
|
1263
|
-
playerId,
|
|
1264
|
-
snapshotString(
|
|
1265
|
-
token,
|
|
1266
|
-
`Captain shell snapshot.rootPlayerResumeTokens.${playerId}`,
|
|
1267
|
-
),
|
|
1268
|
-
]),
|
|
1269
|
-
);
|
|
1270
1715
|
let normalizedLastError:
|
|
1271
1716
|
| { readonly name: string; readonly message: string }
|
|
1272
1717
|
| undefined;
|
|
@@ -1293,12 +1738,123 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1293
1738
|
),
|
|
1294
1739
|
};
|
|
1295
1740
|
}
|
|
1741
|
+
const activePlaybooks = new Set<string>();
|
|
1742
|
+
const activeSessionIds = new Set<string>([captainSessionId]);
|
|
1743
|
+
const issuedIds = new Set(issued);
|
|
1744
|
+
const rootSessionId = normalizedFrames[0]!.sessionId;
|
|
1745
|
+
for (const [index, frame] of normalizedFrames.entries()) {
|
|
1746
|
+
if (activePlaybooks.has(frame.playbookId)) {
|
|
1747
|
+
throw new TypeError(
|
|
1748
|
+
'Captain shell snapshot engagement path must not contain a playbook cycle',
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
activePlaybooks.add(frame.playbookId);
|
|
1752
|
+
if (activeSessionIds.has(frame.sessionId)) {
|
|
1753
|
+
throw new TypeError(
|
|
1754
|
+
'Captain shell snapshot frame session ids must be unique',
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
activeSessionIds.add(frame.sessionId);
|
|
1758
|
+
if (!issuedIds.has(frame.sessionId)) {
|
|
1759
|
+
throw new TypeError(
|
|
1760
|
+
'Captain shell snapshot frame session id was not historically issued',
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
if (
|
|
1764
|
+
frame.depth !== index ||
|
|
1765
|
+
frame.rootSessionId !== rootSessionId ||
|
|
1766
|
+
frame.runtime.state.status !== 'active' ||
|
|
1767
|
+
!frame.runtime.state.quiescent
|
|
1768
|
+
) {
|
|
1769
|
+
throw new TypeError(
|
|
1770
|
+
'Captain shell snapshot frame depth, root, or parked runtime state is inconsistent',
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
if (index === 0) {
|
|
1774
|
+
if (
|
|
1775
|
+
frame.sessionId !== frame.rootSessionId ||
|
|
1776
|
+
frame.parentSessionId !== undefined ||
|
|
1777
|
+
frame.parentCallId !== undefined
|
|
1778
|
+
) {
|
|
1779
|
+
throw new TypeError(
|
|
1780
|
+
'Captain shell snapshot root frame has child-only identity fields',
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
} else {
|
|
1784
|
+
const parent = normalizedFrames[index - 1]!;
|
|
1785
|
+
const pending = parent.runtime.suspendedCall;
|
|
1786
|
+
if (
|
|
1787
|
+
frame.parentSessionId !== parent.sessionId ||
|
|
1788
|
+
frame.parentCallId === undefined
|
|
1789
|
+
) {
|
|
1790
|
+
throw new TypeError(
|
|
1791
|
+
'Captain shell snapshot child frame does not identify its immediate parent',
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
if (
|
|
1795
|
+
!pending ||
|
|
1796
|
+
pending.callId !== frame.parentCallId ||
|
|
1797
|
+
pending.playbookId !== frame.playbookId ||
|
|
1798
|
+
pending.childSessionId !== frame.sessionId
|
|
1799
|
+
) {
|
|
1800
|
+
throw new TypeError(
|
|
1801
|
+
'Captain shell snapshot parent suspended call does not match its child edge',
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
for (const playerId of Object.values(frame.roleBindings)) {
|
|
1806
|
+
if (playerSessions[playerId] === undefined) {
|
|
1807
|
+
throw new TypeError(
|
|
1808
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} binds an absent session player`,
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
for (const question of frame.runtime.pendingBossQuestions) {
|
|
1813
|
+
if (
|
|
1814
|
+
question.asker.kind === 'role' &&
|
|
1815
|
+
frame.roleBindings[question.asker.roleId] === undefined
|
|
1816
|
+
) {
|
|
1817
|
+
throw new TypeError(
|
|
1818
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} has a pending question from an unbound role`,
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
const projectedTokens = Object.fromEntries(
|
|
1823
|
+
Object.entries(frame.roleBindings).flatMap(([role, playerId]) => {
|
|
1824
|
+
const token = playerSessions[playerId]?.resumeToken;
|
|
1825
|
+
return token === undefined ? [] : [[role, token] as const];
|
|
1826
|
+
}),
|
|
1827
|
+
);
|
|
1828
|
+
if (!isDeepStrictEqual(projectedTokens, frame.runtime.roleResumeTokens)) {
|
|
1829
|
+
throw new TypeError(
|
|
1830
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match session continuation`,
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
const leafRuntime = normalizedFrames.at(-1)!.runtime;
|
|
1835
|
+
if (
|
|
1836
|
+
leafRuntime.suspendedCall !== undefined ||
|
|
1837
|
+
!leafRuntime.state.tags.includes('playbook.parked')
|
|
1838
|
+
) {
|
|
1839
|
+
throw new TypeError(
|
|
1840
|
+
'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
if (
|
|
1844
|
+
!isDeepStrictEqual(
|
|
1845
|
+
snapshot.pendingBossQuestions ?? [],
|
|
1846
|
+
leafRuntime.pendingBossQuestions,
|
|
1847
|
+
)
|
|
1848
|
+
) {
|
|
1849
|
+
throw new TypeError(
|
|
1850
|
+
'Captain shell snapshot pending Boss questions must equal the leaf runtime projection',
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1296
1853
|
return snapshotJsonValue(
|
|
1297
1854
|
{
|
|
1298
1855
|
...common,
|
|
1299
1856
|
mode,
|
|
1300
1857
|
frames: normalizedFrames,
|
|
1301
|
-
rootPlayerResumeTokens: normalizedRootTokens,
|
|
1302
1858
|
...(snapshot.pendingBossQuestions === undefined
|
|
1303
1859
|
? {}
|
|
1304
1860
|
: { pendingBossQuestions: snapshot.pendingBossQuestions }),
|
|
@@ -1310,30 +1866,120 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1310
1866
|
) as unknown as PlaybookCaptainShellSnapshot;
|
|
1311
1867
|
}
|
|
1312
1868
|
|
|
1313
|
-
function readPlaybooksConfig(
|
|
1314
|
-
options: unknown,
|
|
1315
|
-
): Record<string, unknown> | undefined {
|
|
1316
|
-
if (typeof options !== 'object' || options === null) return undefined;
|
|
1317
|
-
const pb = (options as Record<string, unknown>).playbooks;
|
|
1318
|
-
if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
|
|
1319
|
-
return undefined;
|
|
1320
|
-
}
|
|
1321
|
-
return pb as Record<string, unknown>;
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
1869
|
interface BuiltRegistry {
|
|
1325
1870
|
entries: readonly PlaybookCaptainRegistryEntry[];
|
|
1326
1871
|
byCommand: Map<string, PlaybookCaptainRegistryEntry>;
|
|
1327
1872
|
byId: Map<string, PlaybookCaptainRegistryEntry>;
|
|
1328
1873
|
enablementById: Map<string, Enablement>;
|
|
1874
|
+
captainAgent: SessionAgent;
|
|
1875
|
+
playerAgents: Map<string, SessionAgent>;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function snapshotTuningSelection(
|
|
1879
|
+
value: JsonValue | undefined,
|
|
1880
|
+
path: string,
|
|
1881
|
+
): TuningSelection {
|
|
1882
|
+
const selection = snapshotRecord(value, path);
|
|
1883
|
+
if (selection.kind === 'provider-default') {
|
|
1884
|
+
rejectSnapshotKeys(selection, ['kind'], path);
|
|
1885
|
+
return { kind: 'provider-default' };
|
|
1886
|
+
}
|
|
1887
|
+
if (selection.kind === 'value') {
|
|
1888
|
+
rejectSnapshotKeys(selection, ['kind', 'value'], path);
|
|
1889
|
+
return {
|
|
1890
|
+
kind: 'value',
|
|
1891
|
+
value: snapshotString(selection.value, `${path}.value`),
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
throw new TypeError(`${path}.kind must be "value" or "provider-default"`);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
const EFFORT_VALUES: ReadonlySet<string> = new Set([
|
|
1898
|
+
'on',
|
|
1899
|
+
'minimal',
|
|
1900
|
+
'low',
|
|
1901
|
+
'medium',
|
|
1902
|
+
'high',
|
|
1903
|
+
'xhigh',
|
|
1904
|
+
'max',
|
|
1905
|
+
'ultra',
|
|
1906
|
+
'ultracode',
|
|
1907
|
+
'off',
|
|
1908
|
+
]);
|
|
1909
|
+
|
|
1910
|
+
function snapshotEffortSelection(
|
|
1911
|
+
value: JsonValue | undefined,
|
|
1912
|
+
path: string,
|
|
1913
|
+
): TuningSelection<Effort> {
|
|
1914
|
+
const selection = snapshotTuningSelection(value, path);
|
|
1915
|
+
if (selection.kind === 'value' && !EFFORT_VALUES.has(selection.value)) {
|
|
1916
|
+
throw new TypeError(`${path}.value is not a supported effort selection`);
|
|
1917
|
+
}
|
|
1918
|
+
return selection as TuningSelection<Effort>;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function snapshotSessionAgent(
|
|
1922
|
+
value: JsonValue | undefined,
|
|
1923
|
+
path: string,
|
|
1924
|
+
): SessionAgent {
|
|
1925
|
+
const agent = snapshotRecord(value, path);
|
|
1926
|
+
rejectSnapshotKeys(
|
|
1927
|
+
agent,
|
|
1928
|
+
['adapter', 'model', 'effort', 'instruction', 'permissions'],
|
|
1929
|
+
path,
|
|
1930
|
+
);
|
|
1931
|
+
const fixed = snapshotFixedAgent(
|
|
1932
|
+
Object.fromEntries(
|
|
1933
|
+
Object.entries(agent).filter(
|
|
1934
|
+
([key]) => key !== 'model' && key !== 'effort',
|
|
1935
|
+
),
|
|
1936
|
+
) as JsonValue,
|
|
1937
|
+
path,
|
|
1938
|
+
);
|
|
1939
|
+
return {
|
|
1940
|
+
adapter: fixed.adapter,
|
|
1941
|
+
...(fixed.instruction === undefined
|
|
1942
|
+
? {}
|
|
1943
|
+
: { instruction: fixed.instruction }),
|
|
1944
|
+
...(fixed.permissions === undefined
|
|
1945
|
+
? {}
|
|
1946
|
+
: { permissions: livePermissions(fixed.permissions) }),
|
|
1947
|
+
model: snapshotTuningSelection(agent.model, `${path}.model`),
|
|
1948
|
+
effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
|
|
1949
|
+
};
|
|
1329
1950
|
}
|
|
1330
1951
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1952
|
+
function fixedAgent(agent: SessionAgent): Omit<SessionAgent, 'model' | 'effort'> {
|
|
1953
|
+
return {
|
|
1954
|
+
adapter: agent.adapter,
|
|
1955
|
+
...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
|
|
1956
|
+
...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
function callSettings(
|
|
1961
|
+
agent: SessionAgent,
|
|
1962
|
+
tuning: Pick<SessionAgent, 'model' | 'effort'> = agent,
|
|
1963
|
+
): AgentCallSettings {
|
|
1964
|
+
return {
|
|
1965
|
+
model: tuning.model,
|
|
1966
|
+
effort: tuning.effort,
|
|
1967
|
+
...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
|
|
1968
|
+
...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function promptIdentity(binding: EffectivePlayerBinding): string {
|
|
1973
|
+
return binding.model.kind === 'value'
|
|
1974
|
+
? binding.model.value
|
|
1975
|
+
: binding.agent.adapter;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// Resolve the active registry at init from exact normalized role and session
|
|
1979
|
+
// agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
|
|
1980
|
+
// exists at this boundary.
|
|
1334
1981
|
async function buildEnablements(
|
|
1335
1982
|
options: unknown,
|
|
1336
|
-
players: readonly RegistryPlayer[],
|
|
1337
1983
|
loadModule: (specifier: string) => Promise<unknown>,
|
|
1338
1984
|
): Promise<BuiltRegistry> {
|
|
1339
1985
|
const entries: PlaybookCaptainRegistryEntry[] = [];
|
|
@@ -1341,10 +1987,63 @@ async function buildEnablements(
|
|
|
1341
1987
|
const byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
1342
1988
|
const enablementById = new Map<string, Enablement>();
|
|
1343
1989
|
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1990
|
+
const detached = snapshotJsonValue(options, 'captain.options');
|
|
1991
|
+
const top = snapshotRecord(detached, 'captain.options');
|
|
1992
|
+
rejectSnapshotKeys(
|
|
1993
|
+
top,
|
|
1994
|
+
['playbooks', 'sessionAgents', 'captainAdapter'],
|
|
1995
|
+
'captain.options',
|
|
1996
|
+
);
|
|
1997
|
+
const configValue = top.playbooks;
|
|
1998
|
+
if (
|
|
1999
|
+
typeof configValue !== 'object' ||
|
|
2000
|
+
configValue === null ||
|
|
2001
|
+
Array.isArray(configValue)
|
|
2002
|
+
) {
|
|
1346
2003
|
throw new Error('captain.options.playbooks is required');
|
|
1347
2004
|
}
|
|
2005
|
+
const config = configValue as Record<string, JsonValue>;
|
|
2006
|
+
|
|
2007
|
+
const sessionAgents = snapshotRecord(
|
|
2008
|
+
top.sessionAgents,
|
|
2009
|
+
'captain.options.sessionAgents',
|
|
2010
|
+
);
|
|
2011
|
+
rejectSnapshotKeys(
|
|
2012
|
+
sessionAgents,
|
|
2013
|
+
['captain', 'players'],
|
|
2014
|
+
'captain.options.sessionAgents',
|
|
2015
|
+
);
|
|
2016
|
+
const captainAgent = snapshotSessionAgent(
|
|
2017
|
+
sessionAgents.captain,
|
|
2018
|
+
'captain.options.sessionAgents.captain',
|
|
2019
|
+
);
|
|
2020
|
+
if (
|
|
2021
|
+
top.captainAdapter !== undefined &&
|
|
2022
|
+
top.captainAdapter !== captainAgent.adapter
|
|
2023
|
+
) {
|
|
2024
|
+
throw new Error(
|
|
2025
|
+
'captain.options.captainAdapter must equal sessionAgents.captain.adapter',
|
|
2026
|
+
);
|
|
2027
|
+
}
|
|
2028
|
+
const playerAgentRecord = snapshotRecord(
|
|
2029
|
+
sessionAgents.players,
|
|
2030
|
+
'captain.options.sessionAgents.players',
|
|
2031
|
+
);
|
|
2032
|
+
const playerAgents = new Map<string, SessionAgent>();
|
|
2033
|
+
for (const [playerId, agent] of Object.entries(playerAgentRecord)) {
|
|
2034
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
2035
|
+
throw new Error(
|
|
2036
|
+
`captain.options.sessionAgents.players has invalid player id ${JSON.stringify(playerId)}`,
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
playerAgents.set(
|
|
2040
|
+
playerId,
|
|
2041
|
+
snapshotSessionAgent(
|
|
2042
|
+
agent,
|
|
2043
|
+
`captain.options.sessionAgents.players.${playerId}`,
|
|
2044
|
+
),
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
1348
2047
|
|
|
1349
2048
|
const ids = Object.keys(config);
|
|
1350
2049
|
if (ids.length === 0) {
|
|
@@ -1362,7 +2061,12 @@ async function buildEnablements(
|
|
|
1362
2061
|
if (typeof block !== 'object' || block === null || Array.isArray(block)) {
|
|
1363
2062
|
throw new Error(`captain.options.playbooks.${id} must be an object`);
|
|
1364
2063
|
}
|
|
1365
|
-
const record = block as Record<string,
|
|
2064
|
+
const record = block as Record<string, JsonValue>;
|
|
2065
|
+
rejectSnapshotKeys(
|
|
2066
|
+
record,
|
|
2067
|
+
['from', 'command', 'roles', 'options'],
|
|
2068
|
+
`captain.options.playbooks.${id}`,
|
|
2069
|
+
);
|
|
1366
2070
|
const from = record.from;
|
|
1367
2071
|
if (typeof from !== 'string' || from.length === 0) {
|
|
1368
2072
|
throw new Error(
|
|
@@ -1409,26 +2113,86 @@ async function buildEnablements(
|
|
|
1409
2113
|
`captain.options.playbooks has a duplicate effective command "${command}"`,
|
|
1410
2114
|
);
|
|
1411
2115
|
}
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
2116
|
+
const roleRecord = snapshotRecord(
|
|
2117
|
+
record.roles,
|
|
2118
|
+
`captain.options.playbooks.${id}.roles`,
|
|
2119
|
+
);
|
|
2120
|
+
const required = new Set(entry.requiredRoleIds);
|
|
2121
|
+
const configuredRoles = Object.keys(roleRecord);
|
|
2122
|
+
const missing = entry.requiredRoleIds.filter(
|
|
2123
|
+
(role) => !Object.hasOwn(roleRecord, role),
|
|
2124
|
+
);
|
|
2125
|
+
const extra = configuredRoles.filter((role) => !required.has(role));
|
|
2126
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
2127
|
+
throw new Error(
|
|
2128
|
+
`captain.options.playbooks.${id}.roles must exactly cover requiredRoleIds`,
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
2131
|
+
const roleBindings = new Map<string, EffectivePlayerBinding>();
|
|
2132
|
+
for (const role of entry.requiredRoleIds) {
|
|
2133
|
+
const path = `captain.options.playbooks.${id}.roles.${role}`;
|
|
2134
|
+
const rawBinding = snapshotRecord(roleRecord[role], path);
|
|
2135
|
+
rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort'], path);
|
|
2136
|
+
const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
|
|
2137
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
2138
|
+
throw new Error(`${path}.playerId is not a canonical player id`);
|
|
2139
|
+
}
|
|
2140
|
+
const agent = playerAgents.get(playerId);
|
|
2141
|
+
if (!agent) {
|
|
2142
|
+
throw new Error(
|
|
2143
|
+
`${path}.playerId names absent session player ${JSON.stringify(playerId)}`,
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
roleBindings.set(role, {
|
|
2147
|
+
playerId,
|
|
2148
|
+
model: snapshotTuningSelection(rawBinding.model, `${path}.model`),
|
|
2149
|
+
effort: snapshotEffortSelection(rawBinding.effort, `${path}.effort`),
|
|
2150
|
+
agent,
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
for (const concurrentRoles of entry.concurrentRoleSets) {
|
|
2154
|
+
const playerIds = concurrentRoles.map(
|
|
2155
|
+
(role) => roleBindings.get(role)!.playerId,
|
|
2156
|
+
);
|
|
2157
|
+
if (new Set(playerIds).size !== playerIds.length) {
|
|
2158
|
+
throw new Error(
|
|
2159
|
+
`captain.options.playbooks.${id}.roles aliases concurrent roles ${JSON.stringify(concurrentRoles)}`,
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
const validatedOptions = snapshotJsonValue(
|
|
2164
|
+
entry.validateOptions(record.options),
|
|
2165
|
+
`captain.options.playbooks.${id}.options`,
|
|
2166
|
+
);
|
|
1420
2167
|
entries.push(entry);
|
|
1421
2168
|
byId.set(entry.id, entry);
|
|
1422
2169
|
byCommand.set(command, entry);
|
|
1423
2170
|
enablementById.set(entry.id, {
|
|
1424
2171
|
entry,
|
|
1425
2172
|
command,
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
|
|
2173
|
+
options: validatedOptions,
|
|
2174
|
+
roleBindings,
|
|
1429
2175
|
});
|
|
1430
2176
|
}
|
|
1431
|
-
|
|
2177
|
+
const referenced = new Set(
|
|
2178
|
+
[...enablementById.values()].flatMap((enablement) =>
|
|
2179
|
+
[...enablement.roleBindings.values()].map((binding) => binding.playerId),
|
|
2180
|
+
),
|
|
2181
|
+
);
|
|
2182
|
+
const unreferenced = [...playerAgents.keys()].find((id) => !referenced.has(id));
|
|
2183
|
+
if (unreferenced !== undefined) {
|
|
2184
|
+
throw new Error(
|
|
2185
|
+
`captain.options.sessionAgents.players has unreferenced player ${JSON.stringify(unreferenced)}`,
|
|
2186
|
+
);
|
|
2187
|
+
}
|
|
2188
|
+
return {
|
|
2189
|
+
entries,
|
|
2190
|
+
byCommand,
|
|
2191
|
+
byId,
|
|
2192
|
+
enablementById,
|
|
2193
|
+
captainAgent,
|
|
2194
|
+
playerAgents,
|
|
2195
|
+
};
|
|
1432
2196
|
}
|
|
1433
2197
|
|
|
1434
2198
|
export function createPlaybookCaptainShell(
|
|
@@ -1441,10 +2205,11 @@ export function createPlaybookCaptainShell(
|
|
|
1441
2205
|
const createCaptainRuntime: NonNullable<
|
|
1442
2206
|
PlaybookCaptainDeps['createCaptainRuntime']
|
|
1443
2207
|
> = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
const
|
|
2208
|
+
let captainAgent: SessionAgent | undefined;
|
|
2209
|
+
let captainAdapter: string | undefined;
|
|
2210
|
+
let playerAgents = new Map<string, SessionAgent>();
|
|
2211
|
+
const playerLedger = new Map<string, PlayerLedgerEntry>();
|
|
2212
|
+
const playerTransactions = new Map<string, PlayerTransaction>();
|
|
1448
2213
|
let entries: readonly PlaybookCaptainRegistryEntry[] = [];
|
|
1449
2214
|
let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
1450
2215
|
let byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
@@ -1460,7 +2225,6 @@ export function createPlaybookCaptainShell(
|
|
|
1460
2225
|
| 'disposing'
|
|
1461
2226
|
| 'closed' = 'fresh';
|
|
1462
2227
|
let terminallyDisposed = false;
|
|
1463
|
-
let players: readonly RegistryPlayer[] = [];
|
|
1464
2228
|
let activeContext: CaptainContext | undefined;
|
|
1465
2229
|
const frames: EngagementFrame[] = [];
|
|
1466
2230
|
let mode: ShellMode = 'chat';
|
|
@@ -1512,15 +2276,19 @@ export function createPlaybookCaptainShell(
|
|
|
1512
2276
|
// --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
|
|
1513
2277
|
let captainRuntime: PlaybookRuntime | undefined;
|
|
1514
2278
|
let captainSessionId: string | undefined;
|
|
1515
|
-
// CAPTAIN-35:
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
2279
|
+
// CAPTAIN-35: a preflight settings rejection retains proven continuity but
|
|
2280
|
+
// records the exact journal suffix still owed; other continuity failures
|
|
2281
|
+
// require a fresh, full reseed.
|
|
1518
2282
|
let conversation: DurableConversation = { kind: 'unopened' };
|
|
1519
2283
|
let shuttingDown = false;
|
|
1520
2284
|
const journal: JournalRecord[] = [];
|
|
1521
2285
|
let journalSeq = 0;
|
|
1522
2286
|
let turnSequence = 0;
|
|
1523
2287
|
let activeTurn: ActiveTurn | undefined;
|
|
2288
|
+
// `PlayerSessionStore.restore` is authoritative only while the shell is
|
|
2289
|
+
// awaiting the exact owning runtime's restore during a closed-gate shell
|
|
2290
|
+
// restoration. A runtime cannot use the store as a general ledger writer.
|
|
2291
|
+
let restoringPlayerSessionFrame: EngagementFrame | undefined;
|
|
1524
2292
|
// The durable call the runtime is about to make, taken from the paired
|
|
1525
2293
|
// `captain.call.started` boundary the engine emits before the port call
|
|
1526
2294
|
// (CAPTAIN-9): the shell never infers a call's kind from its prose.
|
|
@@ -1609,7 +2377,10 @@ export function createPlaybookCaptainShell(
|
|
|
1609
2377
|
// (CAPTAIN-5/CAPTAIN-6).
|
|
1610
2378
|
...(captainRuntime
|
|
1611
2379
|
? {
|
|
1612
|
-
durableConversation:
|
|
2380
|
+
durableConversation:
|
|
2381
|
+
conversation.kind === 'pinned' ||
|
|
2382
|
+
(conversation.kind === 'needsCatchUp' &&
|
|
2383
|
+
conversation.resume !== false),
|
|
1613
2384
|
sessionJournal: true,
|
|
1614
2385
|
}
|
|
1615
2386
|
: {}),
|
|
@@ -1807,8 +2578,9 @@ export function createPlaybookCaptainShell(
|
|
|
1807
2578
|
}
|
|
1808
2579
|
|
|
1809
2580
|
if (leafFrame() === frame) {
|
|
1810
|
-
pendingBossQuestions =
|
|
1811
|
-
record.pendingBossQuestions ?? record.pendingBossQuestion
|
|
2581
|
+
pendingBossQuestions = mirroredBossQuestions(
|
|
2582
|
+
record.pendingBossQuestions ?? record.pendingBossQuestion,
|
|
2583
|
+
);
|
|
1812
2584
|
lastError = normalizeErrorCompact(record.lastError);
|
|
1813
2585
|
if (state.quiescent && state.tags.includes('playbook.parked')) {
|
|
1814
2586
|
await setMode(
|
|
@@ -1826,42 +2598,146 @@ export function createPlaybookCaptainShell(
|
|
|
1826
2598
|
) => Promise<PlaybookCallStart>;
|
|
1827
2599
|
|
|
1828
2600
|
const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
|
|
1829
|
-
callPlayer: async (
|
|
2601
|
+
callPlayer: async (roleId, prompt, signal, options) => {
|
|
1830
2602
|
admitHostBoundary();
|
|
1831
|
-
if (!activeContext) {
|
|
2603
|
+
if (!activeContext || !activeTurn || !frame.playerCallScope) {
|
|
1832
2604
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
1833
2605
|
}
|
|
1834
2606
|
const context = activeContext;
|
|
2607
|
+
const admittedTurn = activeTurn;
|
|
2608
|
+
const scope = frame.playerCallScope;
|
|
1835
2609
|
signal.throwIfAborted();
|
|
1836
|
-
const
|
|
1837
|
-
const
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
);
|
|
1843
|
-
// CaptainContext is turn-scoped and cannot accept a narrower XState
|
|
1844
|
-
// invocation signal. Recheck after the host call so a sibling
|
|
1845
|
-
// cancellation is still reported as aborted and cannot rotate a
|
|
1846
|
-
// stopped branch's player token in the linked runtime.
|
|
1847
|
-
signal.throwIfAborted();
|
|
1848
|
-
// CAPTAIN-20: only a player call that actually produced work is an
|
|
1849
|
-
// interruption the Boss was spared. A call that errored or aborted
|
|
1850
|
-
// saved nothing, so it never feeds the saved-counts gate.
|
|
1851
|
-
const summary = activeTurnSummary;
|
|
1852
|
-
if (summary && summaryIncludes(frame) && result.status === 'ok') {
|
|
1853
|
-
summary.counts.interruptions++;
|
|
2610
|
+
const binding = bindingFor(frame, roleId);
|
|
2611
|
+
const ledger = playerLedger.get(binding.playerId);
|
|
2612
|
+
if (!ledger) {
|
|
2613
|
+
throw new Error(
|
|
2614
|
+
`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`,
|
|
2615
|
+
);
|
|
1854
2616
|
}
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
2617
|
+
const expectedResume = ledger.resumeToken ?? false;
|
|
2618
|
+
if (options.resume !== expectedResume) {
|
|
2619
|
+
throw new Error(
|
|
2620
|
+
`${frameLabel(frame)} player continuation changed before dispatch`,
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
if (playerTransactions.has(binding.playerId)) {
|
|
2624
|
+
throw new Error(
|
|
2625
|
+
`session player ${JSON.stringify(binding.playerId)} already has a call in flight`,
|
|
2626
|
+
);
|
|
2627
|
+
}
|
|
2628
|
+
const settings = callSettings(binding.agent, binding);
|
|
2629
|
+
const calling: PlayerTransaction = {
|
|
2630
|
+
phase: 'calling',
|
|
2631
|
+
frame,
|
|
2632
|
+
roleId,
|
|
2633
|
+
turnId: admittedTurn.id,
|
|
2634
|
+
signal,
|
|
2635
|
+
scope,
|
|
2636
|
+
abandoned: false,
|
|
1864
2637
|
};
|
|
2638
|
+
playerTransactions.set(binding.playerId, calling);
|
|
2639
|
+
let result: PlayerResult;
|
|
2640
|
+
let hostResolved = false;
|
|
2641
|
+
try {
|
|
2642
|
+
let rawResult: unknown;
|
|
2643
|
+
try {
|
|
2644
|
+
rawResult = await trackHostCall(
|
|
2645
|
+
frame,
|
|
2646
|
+
classifySettingsCall(() =>
|
|
2647
|
+
context.callPlayer(binding.playerId, prompt, {
|
|
2648
|
+
resume: options.resume,
|
|
2649
|
+
settings,
|
|
2650
|
+
}),
|
|
2651
|
+
),
|
|
2652
|
+
);
|
|
2653
|
+
hostResolved = true;
|
|
2654
|
+
} catch (error) {
|
|
2655
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
2656
|
+
if (
|
|
2657
|
+
playerTransactions.get(binding.playerId) !== calling ||
|
|
2658
|
+
calling.abandoned ||
|
|
2659
|
+
signal.aborted ||
|
|
2660
|
+
activeTurn !== admittedTurn ||
|
|
2661
|
+
frame.playerCallScope !== scope ||
|
|
2662
|
+
!frames.includes(frame)
|
|
2663
|
+
) {
|
|
2664
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
2665
|
+
playerTransactions.delete(binding.playerId);
|
|
2666
|
+
}
|
|
2667
|
+
signal.throwIfAborted();
|
|
2668
|
+
throw new Error(
|
|
2669
|
+
`${frameLabel(frame)} player settings rejection arrived after its runtime operation ended`,
|
|
2670
|
+
);
|
|
2671
|
+
}
|
|
2672
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
2673
|
+
}
|
|
2674
|
+
throw error;
|
|
2675
|
+
}
|
|
2676
|
+
result = normalizeHostPlayerResult(rawResult, binding.playerId);
|
|
2677
|
+
const transitionRequired =
|
|
2678
|
+
result.resumeToken !== undefined || result.status === 'ok';
|
|
2679
|
+
if (
|
|
2680
|
+
playerTransactions.get(binding.playerId) !== calling ||
|
|
2681
|
+
calling.abandoned ||
|
|
2682
|
+
signal.aborted ||
|
|
2683
|
+
activeTurn !== admittedTurn ||
|
|
2684
|
+
frame.playerCallScope !== scope ||
|
|
2685
|
+
!frames.includes(frame)
|
|
2686
|
+
) {
|
|
2687
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
2688
|
+
if (transitionRequired) {
|
|
2689
|
+
playerTransactions.set(binding.playerId, {
|
|
2690
|
+
phase: 'quarantined',
|
|
2691
|
+
frame,
|
|
2692
|
+
roleId,
|
|
2693
|
+
turnId: admittedTurn.id,
|
|
2694
|
+
signal,
|
|
2695
|
+
scope,
|
|
2696
|
+
reason:
|
|
2697
|
+
'a transition-worthy result arrived after its runtime operation ended',
|
|
2698
|
+
});
|
|
2699
|
+
} else {
|
|
2700
|
+
playerTransactions.delete(binding.playerId);
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
signal.throwIfAborted();
|
|
2704
|
+
throw new Error(
|
|
2705
|
+
`${frameLabel(frame)} player result arrived after its runtime operation ended`,
|
|
2706
|
+
);
|
|
2707
|
+
}
|
|
2708
|
+
if (transitionRequired) {
|
|
2709
|
+
playerTransactions.set(binding.playerId, {
|
|
2710
|
+
phase: 'awaitingCommit',
|
|
2711
|
+
frame,
|
|
2712
|
+
roleId,
|
|
2713
|
+
turnId: admittedTurn.id,
|
|
2714
|
+
signal,
|
|
2715
|
+
scope,
|
|
2716
|
+
status: result.status,
|
|
2717
|
+
expectedToken: result.resumeToken,
|
|
2718
|
+
});
|
|
2719
|
+
} else {
|
|
2720
|
+
playerTransactions.delete(binding.playerId);
|
|
2721
|
+
}
|
|
2722
|
+
} catch (error) {
|
|
2723
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
2724
|
+
if (hostResolved) {
|
|
2725
|
+
playerTransactions.set(binding.playerId, {
|
|
2726
|
+
phase: 'quarantined',
|
|
2727
|
+
frame,
|
|
2728
|
+
roleId,
|
|
2729
|
+
turnId: admittedTurn.id,
|
|
2730
|
+
signal,
|
|
2731
|
+
scope,
|
|
2732
|
+
reason: 'a late player result could not be validated',
|
|
2733
|
+
});
|
|
2734
|
+
} else {
|
|
2735
|
+
playerTransactions.delete(binding.playerId);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
throw error;
|
|
2739
|
+
}
|
|
2740
|
+
return result;
|
|
1865
2741
|
},
|
|
1866
2742
|
callCaptain: async (prompt, signal, options) => {
|
|
1867
2743
|
admitHostBoundary();
|
|
@@ -1962,14 +2838,16 @@ export function createPlaybookCaptainShell(
|
|
|
1962
2838
|
});
|
|
1963
2839
|
|
|
1964
2840
|
// CAPTAIN-22: before dispatching to a playbook, request tmux-play
|
|
1965
|
-
// visibility for that playbook's
|
|
2841
|
+
// visibility for that playbook's explicitly bound session players. A pane
|
|
1966
2842
|
// reconciliation failure is display-only in tmux-play and does not
|
|
1967
2843
|
// reject; the legacy path carries no generated set and skips this.
|
|
1968
2844
|
const requestVisibility = async (frame: EngagementFrame): Promise<void> => {
|
|
1969
2845
|
const ids = [...new Set(
|
|
1970
|
-
[...frame.playerBindings.values()].map(({
|
|
2846
|
+
[...frame.playerBindings.values()].map(({ playerId }) => playerId),
|
|
1971
2847
|
)];
|
|
1972
|
-
|
|
2848
|
+
// A roleless frame does not ask a non-empty host roster to show `[]`:
|
|
2849
|
+
// tmux-play reserves that value for a genuinely empty configured roster.
|
|
2850
|
+
if (ids.length === 0 || !activeContext) return;
|
|
1973
2851
|
try {
|
|
1974
2852
|
await activeContext.setVisiblePlayers(ids);
|
|
1975
2853
|
} catch (error) {
|
|
@@ -2009,32 +2887,8 @@ export function createPlaybookCaptainShell(
|
|
|
2009
2887
|
|
|
2010
2888
|
const makePlayerBindings = (
|
|
2011
2889
|
enablement: Enablement,
|
|
2012
|
-
parent?: { frame: EngagementFrame; callId: string },
|
|
2013
2890
|
): ReadonlyMap<string, EffectivePlayerBinding> => {
|
|
2014
|
-
|
|
2015
|
-
const playerBindings = new Map<string, EffectivePlayerBinding>();
|
|
2016
|
-
for (const role of entry.requiredRoleIds) {
|
|
2017
|
-
let inherited: EffectivePlayerBinding | undefined;
|
|
2018
|
-
for (
|
|
2019
|
-
let ancestor = parent?.frame;
|
|
2020
|
-
ancestor && inherited === undefined;
|
|
2021
|
-
ancestor = ancestor.parent?.frame
|
|
2022
|
-
) {
|
|
2023
|
-
inherited = ancestor.playerBindings.get(role);
|
|
2024
|
-
}
|
|
2025
|
-
if (inherited) {
|
|
2026
|
-
playerBindings.set(role, inherited);
|
|
2027
|
-
continue;
|
|
2028
|
-
}
|
|
2029
|
-
const configured = enablement.boundPlayers.find(
|
|
2030
|
-
(player) => player.id === role,
|
|
2031
|
-
) ?? { id: role };
|
|
2032
|
-
playerBindings.set(role, {
|
|
2033
|
-
hostPlayerId: enablement.hostPlayerId(role),
|
|
2034
|
-
player: configured,
|
|
2035
|
-
});
|
|
2036
|
-
}
|
|
2037
|
-
return playerBindings;
|
|
2891
|
+
return new Map(enablement.roleBindings);
|
|
2038
2892
|
};
|
|
2039
2893
|
|
|
2040
2894
|
const makeFrame = (
|
|
@@ -2043,17 +2897,8 @@ export function createPlaybookCaptainShell(
|
|
|
2043
2897
|
): EngagementFrame => {
|
|
2044
2898
|
const entry = enablement.entry;
|
|
2045
2899
|
const sessionId = allocateSessionId();
|
|
2046
|
-
const playerBindings = makePlayerBindings(enablement
|
|
2047
|
-
const
|
|
2048
|
-
parent?.frame.playerResumeTokens ?? new Map<string, string>();
|
|
2049
|
-
const runtime = entry.createRuntime({
|
|
2050
|
-
captainOptions: enablement.optionInput,
|
|
2051
|
-
players: [...playerBindings].map(([role, { player }]) => ({
|
|
2052
|
-
id: role,
|
|
2053
|
-
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
2054
|
-
...(player.model === undefined ? {} : { model: player.model }),
|
|
2055
|
-
})),
|
|
2056
|
-
});
|
|
2900
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
2901
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
2057
2902
|
return {
|
|
2058
2903
|
entry,
|
|
2059
2904
|
enablement,
|
|
@@ -2062,7 +2907,6 @@ export function createPlaybookCaptainShell(
|
|
|
2062
2907
|
rootSessionId: parent?.frame.rootSessionId ?? sessionId,
|
|
2063
2908
|
depth: parent ? parent.frame.depth + 1 : 0,
|
|
2064
2909
|
playerBindings,
|
|
2065
|
-
playerResumeTokens,
|
|
2066
2910
|
...(parent ? { parent } : {}),
|
|
2067
2911
|
inFlightHostCalls: new Set(),
|
|
2068
2912
|
};
|
|
@@ -2071,19 +2915,11 @@ export function createPlaybookCaptainShell(
|
|
|
2071
2915
|
const makeRestoredFrame = (
|
|
2072
2916
|
enablement: Enablement,
|
|
2073
2917
|
snapshot: PlaybookCaptainFrameSnapshot,
|
|
2074
|
-
rootPlayerResumeTokens: Map<string, string>,
|
|
2075
2918
|
parent?: { frame: EngagementFrame; callId: string },
|
|
2076
2919
|
): EngagementFrame => {
|
|
2077
2920
|
const entry = enablement.entry;
|
|
2078
|
-
const playerBindings = makePlayerBindings(enablement
|
|
2079
|
-
const runtime = entry.createRuntime(
|
|
2080
|
-
captainOptions: enablement.optionInput,
|
|
2081
|
-
players: [...playerBindings].map(([role, { player }]) => ({
|
|
2082
|
-
id: role,
|
|
2083
|
-
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
2084
|
-
...(player.model === undefined ? {} : { model: player.model }),
|
|
2085
|
-
})),
|
|
2086
|
-
});
|
|
2921
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
2922
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
2087
2923
|
return {
|
|
2088
2924
|
entry,
|
|
2089
2925
|
enablement,
|
|
@@ -2092,7 +2928,6 @@ export function createPlaybookCaptainShell(
|
|
|
2092
2928
|
rootSessionId: snapshot.rootSessionId,
|
|
2093
2929
|
depth: snapshot.depth,
|
|
2094
2930
|
playerBindings,
|
|
2095
|
-
playerResumeTokens: rootPlayerResumeTokens,
|
|
2096
2931
|
...(parent ? { parent } : {}),
|
|
2097
2932
|
state: snapshot.runtime.state,
|
|
2098
2933
|
inFlightHostCalls: new Set(),
|
|
@@ -2102,35 +2937,159 @@ export function createPlaybookCaptainShell(
|
|
|
2102
2937
|
const playerSessionStore = (frame: EngagementFrame): PlayerSessionStore => ({
|
|
2103
2938
|
select(playerId) {
|
|
2104
2939
|
const binding = bindingFor(frame, playerId);
|
|
2105
|
-
return
|
|
2940
|
+
return playerLedger.get(binding.playerId)?.resumeToken ?? false;
|
|
2106
2941
|
},
|
|
2107
2942
|
update(playerId, resumeToken) {
|
|
2108
2943
|
const binding = bindingFor(frame, playerId);
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2944
|
+
const ledger = playerLedger.get(binding.playerId);
|
|
2945
|
+
if (!ledger) {
|
|
2946
|
+
throw new Error(
|
|
2947
|
+
`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`,
|
|
2948
|
+
);
|
|
2949
|
+
}
|
|
2950
|
+
const pending = playerTransactions.get(binding.playerId);
|
|
2951
|
+
if (
|
|
2952
|
+
pending?.phase !== 'awaitingCommit' ||
|
|
2953
|
+
pending.frame !== frame ||
|
|
2954
|
+
pending.roleId !== playerId ||
|
|
2955
|
+
pending.scope !== frame.playerCallScope ||
|
|
2956
|
+
pending.expectedToken !== resumeToken
|
|
2957
|
+
) {
|
|
2958
|
+
throw new Error(
|
|
2959
|
+
`${frameLabel(frame)} player update does not acknowledge a validated host result`,
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
if (pending.signal.aborted || activeTurn?.id !== pending.turnId) {
|
|
2963
|
+
playerTransactions.set(binding.playerId, {
|
|
2964
|
+
phase: 'quarantined',
|
|
2965
|
+
frame: pending.frame,
|
|
2966
|
+
roleId: pending.roleId,
|
|
2967
|
+
turnId: pending.turnId,
|
|
2968
|
+
signal: pending.signal,
|
|
2969
|
+
scope: pending.scope,
|
|
2970
|
+
reason: 'the runtime aborted before committing a validated result',
|
|
2971
|
+
});
|
|
2972
|
+
throw new Error(
|
|
2973
|
+
`${frameLabel(frame)} rejected a late or aborted player continuation update`,
|
|
2974
|
+
);
|
|
2975
|
+
}
|
|
2976
|
+
try {
|
|
2977
|
+
if (resumeToken === undefined) delete ledger.resumeToken;
|
|
2978
|
+
else ledger.resumeToken = resumeToken;
|
|
2979
|
+
// CAPTAIN-20: a result counts only after the runtime validated it and
|
|
2980
|
+
// atomically published its authorized continuation transition.
|
|
2981
|
+
const summary = activeTurnSummary;
|
|
2982
|
+
if (
|
|
2983
|
+
pending.status === 'ok' &&
|
|
2984
|
+
summary &&
|
|
2985
|
+
summaryIncludes(frame)
|
|
2986
|
+
) {
|
|
2987
|
+
summary.counts.interruptions++;
|
|
2988
|
+
}
|
|
2989
|
+
} finally {
|
|
2990
|
+
playerTransactions.delete(binding.playerId);
|
|
2113
2991
|
}
|
|
2114
2992
|
},
|
|
2115
2993
|
snapshot() {
|
|
2116
2994
|
const tokens: Record<string, string> = {};
|
|
2117
2995
|
for (const [playerId, binding] of frame.playerBindings) {
|
|
2118
|
-
const token =
|
|
2996
|
+
const token = playerLedger.get(binding.playerId)?.resumeToken;
|
|
2119
2997
|
if (token !== undefined) tokens[playerId] = token;
|
|
2120
2998
|
}
|
|
2121
2999
|
return tokens;
|
|
2122
3000
|
},
|
|
2123
3001
|
restore(tokens) {
|
|
2124
|
-
|
|
2125
|
-
|
|
3002
|
+
if (
|
|
3003
|
+
lifecycle !== 'restoring' ||
|
|
3004
|
+
restoringPlayerSessionFrame !== frame
|
|
3005
|
+
) {
|
|
3006
|
+
throw new Error(
|
|
3007
|
+
`${frameLabel(frame)} player-session restore is only available during shell restoration`,
|
|
3008
|
+
);
|
|
2126
3009
|
}
|
|
3010
|
+
const byPlayer = new Map<string, string | undefined>();
|
|
2127
3011
|
for (const [playerId, token] of Object.entries(tokens)) {
|
|
2128
3012
|
const binding = bindingFor(frame, playerId);
|
|
2129
|
-
|
|
3013
|
+
const previous = byPlayer.get(binding.playerId);
|
|
3014
|
+
if (previous !== undefined && previous !== token) {
|
|
3015
|
+
throw new Error(
|
|
3016
|
+
`${frameLabel(frame)} restored conflicting tokens for shared player ${JSON.stringify(binding.playerId)}`,
|
|
3017
|
+
);
|
|
3018
|
+
}
|
|
3019
|
+
byPlayer.set(binding.playerId, token);
|
|
3020
|
+
}
|
|
3021
|
+
for (const binding of frame.playerBindings.values()) {
|
|
3022
|
+
if (!byPlayer.has(binding.playerId)) byPlayer.set(binding.playerId, undefined);
|
|
3023
|
+
}
|
|
3024
|
+
for (const [playerId, token] of byPlayer) {
|
|
3025
|
+
const ledger = playerLedger.get(playerId);
|
|
3026
|
+
if (!ledger) {
|
|
3027
|
+
throw new Error(
|
|
3028
|
+
`${frameLabel(frame)} restored absent session player ${JSON.stringify(playerId)}`,
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
if (token === undefined) delete ledger.resumeToken;
|
|
3032
|
+
else ledger.resumeToken = token;
|
|
2130
3033
|
}
|
|
2131
3034
|
},
|
|
2132
3035
|
});
|
|
2133
3036
|
|
|
3037
|
+
const closePlayerCallScope = (
|
|
3038
|
+
frame: EngagementFrame,
|
|
3039
|
+
scope: object,
|
|
3040
|
+
): Error | undefined => {
|
|
3041
|
+
if (frame.playerCallScope === scope) frame.playerCallScope = undefined;
|
|
3042
|
+
const missing: string[] = [];
|
|
3043
|
+
for (const [playerId, transaction] of playerTransactions) {
|
|
3044
|
+
if (transaction.frame !== frame || transaction.scope !== scope) continue;
|
|
3045
|
+
if (transaction.phase === 'calling') {
|
|
3046
|
+
transaction.abandoned = true;
|
|
3047
|
+
} else {
|
|
3048
|
+
playerTransactions.set(playerId, {
|
|
3049
|
+
phase: 'quarantined',
|
|
3050
|
+
frame: transaction.frame,
|
|
3051
|
+
roleId: transaction.roleId,
|
|
3052
|
+
turnId: transaction.turnId,
|
|
3053
|
+
signal: transaction.signal,
|
|
3054
|
+
scope: transaction.scope,
|
|
3055
|
+
reason:
|
|
3056
|
+
transaction.phase === 'awaitingCommit'
|
|
3057
|
+
? 'the runtime returned without committing a validated result'
|
|
3058
|
+
: transaction.reason,
|
|
3059
|
+
});
|
|
3060
|
+
}
|
|
3061
|
+
if (!transaction.signal.aborted) missing.push(playerId);
|
|
3062
|
+
}
|
|
3063
|
+
return missing.length === 0
|
|
3064
|
+
? undefined
|
|
3065
|
+
: new Error(
|
|
3066
|
+
`${frameLabel(frame)} runtime returned without committing validated player result for ${missing.map((id) => JSON.stringify(id)).join(', ')}`,
|
|
3067
|
+
);
|
|
3068
|
+
};
|
|
3069
|
+
|
|
3070
|
+
const runFrameOperation = async <T>(
|
|
3071
|
+
frame: EngagementFrame,
|
|
3072
|
+
operation: () => Promise<T>,
|
|
3073
|
+
): Promise<T> => {
|
|
3074
|
+
if (frame.playerCallScope !== undefined) {
|
|
3075
|
+
throw new Error(`${frameLabel(frame)} runtime operations must not overlap`);
|
|
3076
|
+
}
|
|
3077
|
+
const scope = {};
|
|
3078
|
+
frame.playerCallScope = scope;
|
|
3079
|
+
let outcome:
|
|
3080
|
+
| { readonly ok: true; readonly value: T }
|
|
3081
|
+
| { readonly ok: false; readonly error: unknown };
|
|
3082
|
+
try {
|
|
3083
|
+
outcome = { ok: true, value: await operation() };
|
|
3084
|
+
} catch (error) {
|
|
3085
|
+
outcome = { ok: false, error };
|
|
3086
|
+
}
|
|
3087
|
+
const cleanupError = closePlayerCallScope(frame, scope);
|
|
3088
|
+
if (!outcome.ok) throw outcome.error;
|
|
3089
|
+
if (cleanupError !== undefined) throw cleanupError;
|
|
3090
|
+
return outcome.value;
|
|
3091
|
+
};
|
|
3092
|
+
|
|
2134
3093
|
const frameSession = (frame: EngagementFrame) => ({
|
|
2135
3094
|
sessionId: frame.sessionId,
|
|
2136
3095
|
playbookId: frame.entry.id,
|
|
@@ -2142,6 +3101,15 @@ export function createPlaybookCaptainShell(
|
|
|
2142
3101
|
}
|
|
2143
3102
|
: {}),
|
|
2144
3103
|
depth: frame.depth,
|
|
3104
|
+
roleBindings: Object.fromEntries(
|
|
3105
|
+
[...frame.playerBindings].map(([roleId, binding]) => [
|
|
3106
|
+
roleId,
|
|
3107
|
+
{
|
|
3108
|
+
playerId: binding.playerId,
|
|
3109
|
+
promptIdentity: promptIdentity(binding),
|
|
3110
|
+
},
|
|
3111
|
+
]),
|
|
3112
|
+
),
|
|
2145
3113
|
playerSessions: playerSessionStore(frame),
|
|
2146
3114
|
ports: createPorts(frame),
|
|
2147
3115
|
});
|
|
@@ -2465,8 +3433,10 @@ export function createPlaybookCaptainShell(
|
|
|
2465
3433
|
// exception filed against an effect that never ran.
|
|
2466
3434
|
await requestVisibility(frame);
|
|
2467
3435
|
await setMode('engaged.driving', 'submit');
|
|
2468
|
-
const result = await
|
|
2469
|
-
|
|
3436
|
+
const result = await runFrameOperation(frame, () =>
|
|
3437
|
+
runEffect(() =>
|
|
3438
|
+
frame.runtime.handleBossInput({ text, signal }),
|
|
3439
|
+
),
|
|
2470
3440
|
);
|
|
2471
3441
|
frame.state = result.state;
|
|
2472
3442
|
return result;
|
|
@@ -2520,12 +3490,14 @@ export function createPlaybookCaptainShell(
|
|
|
2520
3490
|
}
|
|
2521
3491
|
let result: PlaybookRunResult;
|
|
2522
3492
|
try {
|
|
2523
|
-
result = await
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
3493
|
+
result = await runFrameOperation(parent, () =>
|
|
3494
|
+
runEffect(() =>
|
|
3495
|
+
parent.runtime.resumePlaybookCall({
|
|
3496
|
+
callId: parentLink.callId,
|
|
3497
|
+
result: effectiveResult,
|
|
3498
|
+
signal: context.signal,
|
|
3499
|
+
}),
|
|
3500
|
+
),
|
|
2529
3501
|
);
|
|
2530
3502
|
} catch (error) {
|
|
2531
3503
|
if (disposing || invocationSignal?.aborted) return;
|
|
@@ -2572,7 +3544,7 @@ export function createPlaybookCaptainShell(
|
|
|
2572
3544
|
// runtime publishes before disposal removes the frame. The opaque run
|
|
2573
3545
|
// output remains runtime-to-runtime data and never becomes Captain
|
|
2574
3546
|
// evidence (CAPPLAY-10).
|
|
2575
|
-
activeTurn?.settlementFacts.push(rootCompletionFact(frame));
|
|
3547
|
+
activeTurn?.settlementFacts.push(rootCompletionFact(frame, result));
|
|
2576
3548
|
await runEffect(() => disposeStack('final'));
|
|
2577
3549
|
}
|
|
2578
3550
|
return;
|
|
@@ -2949,7 +3921,9 @@ export function createPlaybookCaptainShell(
|
|
|
2949
3921
|
const pending = view.pendingQuestions.map(
|
|
2950
3922
|
(question) =>
|
|
2951
3923
|
digestLine`- (${quoteEvidence(question.questionId)}) ${quoteEvidence(
|
|
2952
|
-
question.
|
|
3924
|
+
question.asker.kind === 'captain'
|
|
3925
|
+
? 'Captain'
|
|
3926
|
+
: question.asker.roleId,
|
|
2953
3927
|
)} asks: ${quoteEvidence(question.question)}`,
|
|
2954
3928
|
);
|
|
2955
3929
|
lines.push(
|
|
@@ -3073,6 +4047,7 @@ export function createPlaybookCaptainShell(
|
|
|
3073
4047
|
try {
|
|
3074
4048
|
await trackTurnCall(settlement.context.emitReply(settlement.text));
|
|
3075
4049
|
} catch (error) {
|
|
4050
|
+
conversation = { kind: 'needsSeeding' };
|
|
3076
4051
|
const normalized = normalizeErrorCompact(error) ?? {
|
|
3077
4052
|
name: 'Error',
|
|
3078
4053
|
message: String(error),
|
|
@@ -3179,8 +4154,11 @@ export function createPlaybookCaptainShell(
|
|
|
3179
4154
|
* text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
|
|
3180
4155
|
* the Captain may need to repeat.
|
|
3181
4156
|
*/
|
|
3182
|
-
const
|
|
3183
|
-
|
|
4157
|
+
const conversationDigest = (
|
|
4158
|
+
records: readonly JournalRecord[],
|
|
4159
|
+
render: (records: readonly JournalRecord[]) => string,
|
|
4160
|
+
): string => {
|
|
4161
|
+
for (const record of records) {
|
|
3184
4162
|
if (
|
|
3185
4163
|
record.kind === 'action' &&
|
|
3186
4164
|
typeof record.payload === 'object' &&
|
|
@@ -3191,14 +4169,47 @@ export function createPlaybookCaptainShell(
|
|
|
3191
4169
|
if (typeof actionId === 'string') recordSuppliedIdentifier(actionId);
|
|
3192
4170
|
}
|
|
3193
4171
|
}
|
|
3194
|
-
return
|
|
4172
|
+
return render(records);
|
|
3195
4173
|
};
|
|
3196
4174
|
|
|
4175
|
+
const reseedDigest = (): string =>
|
|
4176
|
+
conversationDigest(journal, renderReseedDigest);
|
|
4177
|
+
|
|
4178
|
+
const catchUpDigest = (afterJournalSeq: number): string =>
|
|
4179
|
+
conversationDigest(
|
|
4180
|
+
journal.filter((record) => record.seq > afterJournalSeq),
|
|
4181
|
+
renderCatchUpDigest,
|
|
4182
|
+
);
|
|
4183
|
+
|
|
3197
4184
|
const markControlFailure = <E>(error: E): E => {
|
|
3198
|
-
|
|
4185
|
+
activeTurn?.controlFailures.add(error);
|
|
3199
4186
|
return error;
|
|
3200
4187
|
};
|
|
3201
4188
|
|
|
4189
|
+
const markSettingsRejection = <E>(error: E): E => markControlFailure(error);
|
|
4190
|
+
|
|
4191
|
+
const rememberSettingsPreflight = <E>(error: E): E => {
|
|
4192
|
+
activeTurn?.settingsPreflightFailures.add(error);
|
|
4193
|
+
return markSettingsRejection(error);
|
|
4194
|
+
};
|
|
4195
|
+
|
|
4196
|
+
const markConversationCatchUp = (): void => {
|
|
4197
|
+
if (conversation.kind === 'needsSeeding' || conversation.kind === 'needsCatchUp') {
|
|
4198
|
+
return;
|
|
4199
|
+
}
|
|
4200
|
+
conversation = {
|
|
4201
|
+
kind: 'needsCatchUp',
|
|
4202
|
+
resume: conversation.kind === 'pinned' ? conversation.token : false,
|
|
4203
|
+
afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
|
|
4204
|
+
};
|
|
4205
|
+
};
|
|
4206
|
+
|
|
4207
|
+
const markConversationUnsynchronized = (): void => {
|
|
4208
|
+
if (conversation.kind !== 'needsCatchUp') {
|
|
4209
|
+
conversation = { kind: 'needsSeeding' };
|
|
4210
|
+
}
|
|
4211
|
+
};
|
|
4212
|
+
|
|
3202
4213
|
/**
|
|
3203
4214
|
* CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
|
|
3204
4215
|
* engagement constructed, a stack disposed, an advertised action applied.
|
|
@@ -3253,6 +4264,7 @@ export function createPlaybookCaptainShell(
|
|
|
3253
4264
|
context: CaptainContext,
|
|
3254
4265
|
prompt: string,
|
|
3255
4266
|
resume: string | false,
|
|
4267
|
+
attempt: { providerBoundaryEntered: boolean },
|
|
3256
4268
|
): Promise<{
|
|
3257
4269
|
status: string;
|
|
3258
4270
|
finalText?: string;
|
|
@@ -3261,11 +4273,15 @@ export function createPlaybookCaptainShell(
|
|
|
3261
4273
|
}> => {
|
|
3262
4274
|
const queued = captainQueue.add(async () => {
|
|
3263
4275
|
context.signal.throwIfAborted();
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
4276
|
+
attempt.providerBoundaryEntered = true;
|
|
4277
|
+
const result = await classifySettingsCall(() =>
|
|
4278
|
+
context.callCaptain(prompt, {
|
|
4279
|
+
visibility: 'hidden',
|
|
4280
|
+
resume,
|
|
4281
|
+
...controlCallToolOptions(captainAdapter),
|
|
4282
|
+
settings: callSettings(captainAgent!),
|
|
4283
|
+
}),
|
|
4284
|
+
);
|
|
3269
4285
|
context.signal.throwIfAborted();
|
|
3270
4286
|
return result;
|
|
3271
4287
|
});
|
|
@@ -3286,8 +4302,17 @@ export function createPlaybookCaptainShell(
|
|
|
3286
4302
|
context: CaptainContext,
|
|
3287
4303
|
compose: (options: { reseedDigest?: string }) => string,
|
|
3288
4304
|
): Promise<DurableCallOutcome> => {
|
|
3289
|
-
const
|
|
4305
|
+
const startingConversation = conversation;
|
|
4306
|
+
const resume =
|
|
4307
|
+
startingConversation.kind === 'pinned'
|
|
4308
|
+
? startingConversation.token
|
|
4309
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
4310
|
+
? startingConversation.resume
|
|
4311
|
+
: false;
|
|
3290
4312
|
const seedFirstCall = conversation.kind === 'needsSeeding';
|
|
4313
|
+
const catchUpFirstCall = conversation.kind === 'needsCatchUp';
|
|
4314
|
+
const representedJournalSeq = journalSeq;
|
|
4315
|
+
const firstAttempt = { providerBoundaryEntered: false };
|
|
3291
4316
|
let result:
|
|
3292
4317
|
| { status: string; finalText?: string; resumeToken?: string; error?: string }
|
|
3293
4318
|
| undefined;
|
|
@@ -3296,14 +4321,42 @@ export function createPlaybookCaptainShell(
|
|
|
3296
4321
|
result = await rawDurableCall(
|
|
3297
4322
|
context,
|
|
3298
4323
|
compose(
|
|
3299
|
-
seedFirstCall
|
|
4324
|
+
seedFirstCall
|
|
4325
|
+
? { reseedDigest: reseedDigest() }
|
|
4326
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
4327
|
+
? {
|
|
4328
|
+
reseedDigest: catchUpDigest(
|
|
4329
|
+
startingConversation.afterJournalSeq,
|
|
4330
|
+
),
|
|
4331
|
+
}
|
|
4332
|
+
: {},
|
|
3300
4333
|
),
|
|
3301
4334
|
resume,
|
|
4335
|
+
firstAttempt,
|
|
3302
4336
|
);
|
|
3303
4337
|
} catch (error) {
|
|
3304
4338
|
if (context.signal.aborted) {
|
|
3305
|
-
|
|
3306
|
-
|
|
4339
|
+
if (firstAttempt.providerBoundaryEntered) {
|
|
4340
|
+
conversation = { kind: 'needsSeeding' };
|
|
4341
|
+
} else {
|
|
4342
|
+
markConversationUnsynchronized();
|
|
4343
|
+
}
|
|
4344
|
+
throw context.signal.reason ?? error;
|
|
4345
|
+
}
|
|
4346
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
4347
|
+
if (
|
|
4348
|
+
conversation.kind !== 'needsCatchUp' &&
|
|
4349
|
+
conversation.kind !== 'needsSeeding'
|
|
4350
|
+
) {
|
|
4351
|
+
conversation = {
|
|
4352
|
+
kind: 'needsCatchUp',
|
|
4353
|
+
resume:
|
|
4354
|
+
conversation.kind === 'pinned' ? conversation.token : false,
|
|
4355
|
+
afterJournalSeq:
|
|
4356
|
+
activeTurn?.captainSyncedJournalSeq ?? 0,
|
|
4357
|
+
};
|
|
4358
|
+
}
|
|
4359
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
3307
4360
|
}
|
|
3308
4361
|
failure = error;
|
|
3309
4362
|
}
|
|
@@ -3314,11 +4367,14 @@ export function createPlaybookCaptainShell(
|
|
|
3314
4367
|
result.resumeToken === undefined;
|
|
3315
4368
|
if (!unsynchronized) {
|
|
3316
4369
|
conversation = { kind: 'pinned', token: result!.resumeToken! };
|
|
4370
|
+
if (activeTurn) {
|
|
4371
|
+
activeTurn.captainSyncedJournalSeq = representedJournalSeq;
|
|
4372
|
+
}
|
|
3317
4373
|
return {
|
|
3318
4374
|
...(result!.finalText !== undefined
|
|
3319
4375
|
? { finalText: result!.finalText }
|
|
3320
4376
|
: {}),
|
|
3321
|
-
correctiveSpent: seedFirstCall,
|
|
4377
|
+
correctiveSpent: seedFirstCall || catchUpFirstCall,
|
|
3322
4378
|
};
|
|
3323
4379
|
}
|
|
3324
4380
|
// Only the model-side conversation is replaced: the stack, player
|
|
@@ -3330,16 +4386,21 @@ export function createPlaybookCaptainShell(
|
|
|
3330
4386
|
let reissued:
|
|
3331
4387
|
| { status: string; finalText?: string; resumeToken?: string; error?: string }
|
|
3332
4388
|
| undefined;
|
|
4389
|
+
const reissueAttempt = { providerBoundaryEntered: false };
|
|
3333
4390
|
try {
|
|
3334
4391
|
reissued = await rawDurableCall(
|
|
3335
4392
|
context,
|
|
3336
4393
|
compose({ reseedDigest: recap }),
|
|
3337
4394
|
false,
|
|
4395
|
+
reissueAttempt,
|
|
3338
4396
|
);
|
|
3339
4397
|
} catch (error) {
|
|
3340
4398
|
if (context.signal.aborted) {
|
|
3341
4399
|
conversation = { kind: 'needsSeeding' };
|
|
3342
|
-
throw error;
|
|
4400
|
+
throw context.signal.reason ?? error;
|
|
4401
|
+
}
|
|
4402
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
4403
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
3343
4404
|
}
|
|
3344
4405
|
throw markControlFailure(new CaptainContinuityError(error));
|
|
3345
4406
|
}
|
|
@@ -3352,6 +4413,7 @@ export function createPlaybookCaptainShell(
|
|
|
3352
4413
|
);
|
|
3353
4414
|
}
|
|
3354
4415
|
conversation = { kind: 'pinned', token: reissued.resumeToken };
|
|
4416
|
+
if (activeTurn) activeTurn.captainSyncedJournalSeq = journalSeq;
|
|
3355
4417
|
return {
|
|
3356
4418
|
...(reissued.finalText !== undefined
|
|
3357
4419
|
? { finalText: reissued.finalText }
|
|
@@ -3570,10 +4632,10 @@ export function createPlaybookCaptainShell(
|
|
|
3570
4632
|
// The controller port (DR-029): host validation is the sole effector.
|
|
3571
4633
|
// -------------------------------------------------------------------------
|
|
3572
4634
|
|
|
3573
|
-
// The
|
|
3574
|
-
// same way the digest reads it.
|
|
3575
|
-
//
|
|
3576
|
-
//
|
|
4635
|
+
// The legacy state-description channel, read from the live control view the
|
|
4636
|
+
// same way the digest reads it. DR-037 makes the terminal result authoritative
|
|
4637
|
+
// for completion; this remains only for an older runtime that omits the new
|
|
4638
|
+
// optional member.
|
|
3577
4639
|
const leafStateDescription = (
|
|
3578
4640
|
frame: EngagementFrame,
|
|
3579
4641
|
): string | undefined => {
|
|
@@ -3585,10 +4647,21 @@ export function createPlaybookCaptainShell(
|
|
|
3585
4647
|
}
|
|
3586
4648
|
};
|
|
3587
4649
|
|
|
3588
|
-
const rootCompletionFact = (
|
|
3589
|
-
|
|
4650
|
+
const rootCompletionFact = (
|
|
4651
|
+
frame: EngagementFrame,
|
|
4652
|
+
result: Extract<PlaybookRunResult, { outcome: 'terminal' }>,
|
|
4653
|
+
): string => {
|
|
4654
|
+
const returned =
|
|
4655
|
+
result.stateDescription === undefined
|
|
4656
|
+
? ''
|
|
4657
|
+
: compactEvidence(result.stateDescription);
|
|
4658
|
+
const legacy = returned === '' ? leafStateDescription(frame) : undefined;
|
|
3590
4659
|
const description =
|
|
3591
|
-
|
|
4660
|
+
returned !== ''
|
|
4661
|
+
? returned
|
|
4662
|
+
: legacy === undefined
|
|
4663
|
+
? ''
|
|
4664
|
+
: compactEvidence(legacy);
|
|
3592
4665
|
return description === ''
|
|
3593
4666
|
? `${frameLabel(frame)} completed; its runtime published no result description.`
|
|
3594
4667
|
: `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
|
|
@@ -3758,7 +4831,7 @@ export function createPlaybookCaptainShell(
|
|
|
3758
4831
|
message: String(error),
|
|
3759
4832
|
};
|
|
3760
4833
|
if (aborted) {
|
|
3761
|
-
|
|
4834
|
+
markConversationUnsynchronized();
|
|
3762
4835
|
if (turn?.outcomePending) {
|
|
3763
4836
|
turn.settlementFacts.push(
|
|
3764
4837
|
`The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`,
|
|
@@ -4158,7 +5231,11 @@ export function createPlaybookCaptainShell(
|
|
|
4158
5231
|
// receipt rather than acting twice.
|
|
4159
5232
|
const key = `turn-${turn.id}-apply-${actionId}`;
|
|
4160
5233
|
const outcome = await withCounting(leaf, async () =>
|
|
4161
|
-
|
|
5234
|
+
runFrameOperation(leaf, () =>
|
|
5235
|
+
runEffect(() =>
|
|
5236
|
+
leaf.runtime.apply!({ actionId, key, signal }),
|
|
5237
|
+
),
|
|
5238
|
+
),
|
|
4162
5239
|
);
|
|
4163
5240
|
if (outcome.error !== undefined) throw outcome.error;
|
|
4164
5241
|
const receipt = outcome.result!;
|
|
@@ -4322,7 +5399,11 @@ export function createPlaybookCaptainShell(
|
|
|
4322
5399
|
// The durable Captain conversation did not receive the shell-authored
|
|
4323
5400
|
// fallback. Force its next call through the journal so it cannot interpret
|
|
4324
5401
|
// the Boss's follow-up without the reply the Boss was given this turn.
|
|
4325
|
-
|
|
5402
|
+
if (activeTurn?.settingsPreflightFailures.has(error)) {
|
|
5403
|
+
markConversationCatchUp();
|
|
5404
|
+
} else {
|
|
5405
|
+
markConversationUnsynchronized();
|
|
5406
|
+
}
|
|
4326
5407
|
// A rejected presentation may already have emitted bytes. It is therefore
|
|
4327
5408
|
// final for this turn even though the Promise did not prove it was shown.
|
|
4328
5409
|
if (activeTurn?.presentationAttempted === true) return;
|
|
@@ -4353,164 +5434,93 @@ export function createPlaybookCaptainShell(
|
|
|
4353
5434
|
playbookId: INTERNAL_CAPTAIN_ID,
|
|
4354
5435
|
rootSessionId: id,
|
|
4355
5436
|
depth: 0,
|
|
5437
|
+
roleBindings: {},
|
|
4356
5438
|
ports: captainPorts(),
|
|
4357
5439
|
});
|
|
4358
5440
|
|
|
4359
|
-
const
|
|
4360
|
-
|
|
4361
|
-
|
|
5441
|
+
const playerLedgerRecord = (): Readonly<
|
|
5442
|
+
Record<string, PlayerLedgerSnapshotEntry>
|
|
5443
|
+
> =>
|
|
5444
|
+
Object.fromEntries(
|
|
5445
|
+
[...playerLedger].map(([playerId, entry]) => [
|
|
5446
|
+
playerId,
|
|
5447
|
+
{
|
|
5448
|
+
adapter: entry.adapter,
|
|
5449
|
+
...(entry.instruction === undefined
|
|
5450
|
+
? {}
|
|
5451
|
+
: { instruction: entry.instruction }),
|
|
5452
|
+
...(entry.permissions === undefined
|
|
5453
|
+
? {}
|
|
5454
|
+
: { permissions: entry.permissions }),
|
|
5455
|
+
...(entry.resumeToken === undefined
|
|
5456
|
+
? {}
|
|
5457
|
+
: { resumeToken: entry.resumeToken }),
|
|
5458
|
+
},
|
|
5459
|
+
]),
|
|
5460
|
+
);
|
|
4362
5461
|
|
|
4363
5462
|
const assertSnapshotMatchesEnablements = (
|
|
4364
5463
|
snapshot: PlaybookCaptainShellSnapshot,
|
|
4365
5464
|
enabled: ReadonlyMap<string, Enablement>,
|
|
4366
5465
|
): void => {
|
|
4367
|
-
const captain = snapshot.captain.runtime;
|
|
4368
5466
|
if (
|
|
4369
|
-
|
|
4370
|
-
captain.
|
|
4371
|
-
!captain.state.quiescent ||
|
|
4372
|
-
!captain.state.tags.includes('playbook.parked') ||
|
|
4373
|
-
captain.suspendedCall !== undefined ||
|
|
4374
|
-
Object.keys(captain.playerResumeTokens).length > 0 ||
|
|
4375
|
-
captain.pendingBossQuestions.length > 0
|
|
5467
|
+
captainAgent === undefined ||
|
|
5468
|
+
!isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))
|
|
4376
5469
|
) {
|
|
4377
5470
|
throw new TypeError(
|
|
4378
|
-
'Captain shell snapshot Captain
|
|
4379
|
-
);
|
|
4380
|
-
}
|
|
4381
|
-
if (captain.sequences.turn !== snapshot.sequences.turn) {
|
|
4382
|
-
throw new TypeError(
|
|
4383
|
-
'Captain shell snapshot Captain and shell turn sequences must match',
|
|
5471
|
+
'Captain shell snapshot Captain agent is incompatible with current config',
|
|
4384
5472
|
);
|
|
4385
5473
|
}
|
|
4386
|
-
const
|
|
4387
|
-
|
|
4388
|
-
if (
|
|
4389
|
-
(snapshot.captain.conversation.kind === 'unopened') !== emptyHistory
|
|
4390
|
-
) {
|
|
5474
|
+
const configuredPlayerIds = [...playerAgents.keys()].sort();
|
|
5475
|
+
const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
|
|
5476
|
+
if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
|
|
4391
5477
|
throw new TypeError(
|
|
4392
|
-
'Captain shell snapshot
|
|
5478
|
+
'Captain shell snapshot player ledger does not match current referenced players',
|
|
4393
5479
|
);
|
|
4394
5480
|
}
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
if (!
|
|
5481
|
+
for (const playerId of configuredPlayerIds) {
|
|
5482
|
+
const saved = snapshot.playerSessions[playerId]!;
|
|
5483
|
+
const configured = playerAgents.get(playerId)!;
|
|
5484
|
+
const savedFixed = {
|
|
5485
|
+
adapter: saved.adapter,
|
|
5486
|
+
...(saved.instruction === undefined
|
|
5487
|
+
? {}
|
|
5488
|
+
: { instruction: saved.instruction }),
|
|
5489
|
+
...(saved.permissions === undefined
|
|
5490
|
+
? {}
|
|
5491
|
+
: { permissions: saved.permissions }),
|
|
5492
|
+
};
|
|
5493
|
+
if (!isDeepStrictEqual(savedFixed, fixedAgent(configured))) {
|
|
4408
5494
|
throw new TypeError(
|
|
4409
|
-
`Captain shell snapshot
|
|
5495
|
+
`Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`,
|
|
4410
5496
|
);
|
|
4411
5497
|
}
|
|
4412
5498
|
}
|
|
4413
|
-
|
|
4414
|
-
const
|
|
4415
|
-
const rootSessionId = snapshot.frames[0]!.sessionId;
|
|
4416
|
-
for (const [index, frame] of snapshot.frames.entries()) {
|
|
5499
|
+
if (snapshot.mode === 'chat') return;
|
|
5500
|
+
for (const frame of snapshot.frames) {
|
|
4417
5501
|
const enablement = enabled.get(frame.playbookId);
|
|
4418
5502
|
if (!enablement) {
|
|
4419
5503
|
throw new TypeError(
|
|
4420
5504
|
`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`,
|
|
4421
5505
|
);
|
|
4422
5506
|
}
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
if (
|
|
4430
|
-
throw new TypeError(
|
|
4431
|
-
'Captain shell snapshot frame session ids must be unique',
|
|
4432
|
-
);
|
|
4433
|
-
}
|
|
4434
|
-
activeSessionIds.add(frame.sessionId);
|
|
4435
|
-
if (!issuedIds.has(frame.sessionId)) {
|
|
4436
|
-
throw new TypeError(
|
|
4437
|
-
'Captain shell snapshot frame session id was not historically issued',
|
|
4438
|
-
);
|
|
4439
|
-
}
|
|
4440
|
-
if (
|
|
4441
|
-
frame.depth !== index ||
|
|
4442
|
-
frame.rootSessionId !== rootSessionId ||
|
|
4443
|
-
frame.runtime.state.status !== 'active' ||
|
|
4444
|
-
!frame.runtime.state.quiescent
|
|
4445
|
-
) {
|
|
5507
|
+
const configuredBindings = Object.fromEntries(
|
|
5508
|
+
[...enablement.roleBindings].map(([role, binding]) => [
|
|
5509
|
+
role,
|
|
5510
|
+
binding.playerId,
|
|
5511
|
+
]),
|
|
5512
|
+
);
|
|
5513
|
+
if (!isDeepStrictEqual(frame.options, enablement.options)) {
|
|
4446
5514
|
throw new TypeError(
|
|
4447
|
-
|
|
5515
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`,
|
|
4448
5516
|
);
|
|
4449
5517
|
}
|
|
4450
|
-
if (
|
|
4451
|
-
if (
|
|
4452
|
-
frame.sessionId !== frame.rootSessionId ||
|
|
4453
|
-
frame.parentSessionId !== undefined ||
|
|
4454
|
-
frame.parentCallId !== undefined
|
|
4455
|
-
) {
|
|
4456
|
-
throw new TypeError(
|
|
4457
|
-
'Captain shell snapshot root frame has child-only identity fields',
|
|
4458
|
-
);
|
|
4459
|
-
}
|
|
4460
|
-
} else {
|
|
4461
|
-
const parent = snapshot.frames[index - 1]!;
|
|
4462
|
-
if (
|
|
4463
|
-
frame.parentSessionId !== parent.sessionId ||
|
|
4464
|
-
frame.parentCallId === undefined
|
|
4465
|
-
) {
|
|
4466
|
-
throw new TypeError(
|
|
4467
|
-
'Captain shell snapshot child frame does not identify its immediate parent',
|
|
4468
|
-
);
|
|
4469
|
-
}
|
|
4470
|
-
const pending = parent.runtime.suspendedCall;
|
|
4471
|
-
if (
|
|
4472
|
-
!pending ||
|
|
4473
|
-
pending.callId !== frame.parentCallId ||
|
|
4474
|
-
pending.playbookId !== frame.playbookId ||
|
|
4475
|
-
pending.childSessionId !== frame.sessionId
|
|
4476
|
-
) {
|
|
4477
|
-
throw new TypeError(
|
|
4478
|
-
'Captain shell snapshot parent suspended call does not match its child edge',
|
|
4479
|
-
);
|
|
4480
|
-
}
|
|
4481
|
-
}
|
|
4482
|
-
|
|
4483
|
-
const roleBindings = new Map<string, string>();
|
|
4484
|
-
for (const role of enablement.entry.requiredRoleIds) {
|
|
4485
|
-
let inherited: string | undefined;
|
|
4486
|
-
for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
|
|
4487
|
-
inherited = bindingMaps[ancestor]?.get(role);
|
|
4488
|
-
if (inherited !== undefined) break;
|
|
4489
|
-
}
|
|
4490
|
-
roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
|
|
4491
|
-
}
|
|
4492
|
-
bindingMaps.push(roleBindings);
|
|
4493
|
-
const projectedTokens = Object.fromEntries(
|
|
4494
|
-
[...roleBindings].flatMap(([role, hostPlayerId]) => {
|
|
4495
|
-
const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
|
|
4496
|
-
return token === undefined ? [] : [[role, token] as const];
|
|
4497
|
-
}),
|
|
4498
|
-
);
|
|
4499
|
-
if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
|
|
5518
|
+
if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
|
|
4500
5519
|
throw new TypeError(
|
|
4501
|
-
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)}
|
|
5520
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`,
|
|
4502
5521
|
);
|
|
4503
5522
|
}
|
|
4504
5523
|
}
|
|
4505
|
-
const leafRuntime = snapshot.frames.at(-1)!.runtime;
|
|
4506
|
-
if (
|
|
4507
|
-
leafRuntime.suspendedCall !== undefined ||
|
|
4508
|
-
!leafRuntime.state.tags.includes('playbook.parked')
|
|
4509
|
-
) {
|
|
4510
|
-
throw new TypeError(
|
|
4511
|
-
'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
|
|
4512
|
-
);
|
|
4513
|
-
}
|
|
4514
5524
|
};
|
|
4515
5525
|
|
|
4516
5526
|
const safeCapturePoint = (): boolean => {
|
|
@@ -4530,6 +5540,7 @@ export function createPlaybookCaptainShell(
|
|
|
4530
5540
|
runFailureFacts !== undefined ||
|
|
4531
5541
|
servingCall !== undefined ||
|
|
4532
5542
|
decisionCall !== undefined ||
|
|
5543
|
+
playerTransactions.size !== 0 ||
|
|
4533
5544
|
captainQueue.pending !== 0 ||
|
|
4534
5545
|
captainQueue.size !== 0 ||
|
|
4535
5546
|
(mode !== 'chat' && mode !== 'engaged.parked')
|
|
@@ -4575,7 +5586,12 @@ export function createPlaybookCaptainShell(
|
|
|
4575
5586
|
};
|
|
4576
5587
|
|
|
4577
5588
|
const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
|
|
4578
|
-
if (
|
|
5589
|
+
if (
|
|
5590
|
+
!safeCapturePoint() ||
|
|
5591
|
+
!captainRuntime ||
|
|
5592
|
+
!captainSessionId ||
|
|
5593
|
+
!captainAgent
|
|
5594
|
+
) {
|
|
4579
5595
|
return undefined;
|
|
4580
5596
|
}
|
|
4581
5597
|
try {
|
|
@@ -4613,16 +5629,25 @@ export function createPlaybookCaptainShell(
|
|
|
4613
5629
|
parentCallId: frame.parent.callId,
|
|
4614
5630
|
}
|
|
4615
5631
|
: {}),
|
|
5632
|
+
options: frame.enablement.options,
|
|
5633
|
+
roleBindings: Object.fromEntries(
|
|
5634
|
+
[...frame.playerBindings].map(([role, binding]) => [
|
|
5635
|
+
role,
|
|
5636
|
+
binding.playerId,
|
|
5637
|
+
]),
|
|
5638
|
+
),
|
|
4616
5639
|
runtime,
|
|
4617
5640
|
});
|
|
4618
5641
|
}
|
|
4619
5642
|
const common = {
|
|
4620
|
-
schemaVersion:
|
|
5643
|
+
schemaVersion: 3 as const,
|
|
4621
5644
|
captain: {
|
|
4622
5645
|
sessionId: captainSessionId,
|
|
4623
5646
|
runtime: captainSnapshot,
|
|
5647
|
+
agent: fixedAgent(captainAgent),
|
|
4624
5648
|
conversation,
|
|
4625
5649
|
},
|
|
5650
|
+
playerSessions: playerLedgerRecord(),
|
|
4626
5651
|
issuedSessionIds: [...issuedSessionIds],
|
|
4627
5652
|
sequences: { turn: turnSequence, journal: journalSeq },
|
|
4628
5653
|
journal,
|
|
@@ -4638,9 +5663,6 @@ export function createPlaybookCaptainShell(
|
|
|
4638
5663
|
...common,
|
|
4639
5664
|
mode: 'engaged.parked',
|
|
4640
5665
|
frames: frameSnapshots,
|
|
4641
|
-
rootPlayerResumeTokens: tokenRecord(
|
|
4642
|
-
rootFrame()!.playerResumeTokens,
|
|
4643
|
-
),
|
|
4644
5666
|
...(pendingBossQuestions === undefined
|
|
4645
5667
|
? {}
|
|
4646
5668
|
: { pendingBossQuestions: pendingBossQuestions as JsonValue }),
|
|
@@ -4673,7 +5695,7 @@ export function createPlaybookCaptainShell(
|
|
|
4673
5695
|
);
|
|
4674
5696
|
for (const key of [
|
|
4675
5697
|
'state',
|
|
4676
|
-
'
|
|
5698
|
+
'roleResumeTokens',
|
|
4677
5699
|
'sequences',
|
|
4678
5700
|
'pendingBossQuestions',
|
|
4679
5701
|
'suspendedCall',
|
|
@@ -4712,7 +5734,11 @@ export function createPlaybookCaptainShell(
|
|
|
4712
5734
|
byCommand = new Map();
|
|
4713
5735
|
byId = new Map();
|
|
4714
5736
|
enablementById = new Map();
|
|
4715
|
-
|
|
5737
|
+
captainAgent = undefined;
|
|
5738
|
+
captainAdapter = undefined;
|
|
5739
|
+
playerAgents = new Map();
|
|
5740
|
+
playerLedger.clear();
|
|
5741
|
+
playerTransactions.clear();
|
|
4716
5742
|
session = undefined;
|
|
4717
5743
|
sessionEmissionsOpen = false;
|
|
4718
5744
|
closedGateAttempted = false;
|
|
@@ -4749,22 +5775,31 @@ export function createPlaybookCaptainShell(
|
|
|
4749
5775
|
lifecycle = 'restoring';
|
|
4750
5776
|
try {
|
|
4751
5777
|
const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
|
|
4752
|
-
const built = await buildEnablements(
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
);
|
|
4757
|
-
for (const enablement of built.enablementById.values()) {
|
|
4758
|
-
enablement.entry.validateOptions(enablement.optionInput);
|
|
4759
|
-
}
|
|
5778
|
+
const built = await buildEnablements(options, loadModule);
|
|
5779
|
+
captainAgent = built.captainAgent;
|
|
5780
|
+
captainAdapter = captainAgent.adapter;
|
|
5781
|
+
playerAgents = built.playerAgents;
|
|
4760
5782
|
assertSnapshotMatchesEnablements(snapshot, built.enablementById);
|
|
4761
5783
|
|
|
4762
5784
|
installSession(initSession, false);
|
|
4763
|
-
players = initSession.players;
|
|
4764
5785
|
entries = built.entries;
|
|
4765
5786
|
byCommand = built.byCommand;
|
|
4766
5787
|
byId = built.byId;
|
|
4767
5788
|
enablementById = built.enablementById;
|
|
5789
|
+
for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
|
|
5790
|
+
playerLedger.set(playerId, {
|
|
5791
|
+
adapter: saved.adapter,
|
|
5792
|
+
...(saved.instruction === undefined
|
|
5793
|
+
? {}
|
|
5794
|
+
: { instruction: saved.instruction }),
|
|
5795
|
+
...(saved.permissions === undefined
|
|
5796
|
+
? {}
|
|
5797
|
+
: { permissions: livePermissions(saved.permissions) }),
|
|
5798
|
+
...(saved.resumeToken === undefined
|
|
5799
|
+
? {}
|
|
5800
|
+
: { resumeToken: saved.resumeToken }),
|
|
5801
|
+
});
|
|
5802
|
+
}
|
|
4768
5803
|
|
|
4769
5804
|
captainRuntime = createCaptainRuntime({
|
|
4770
5805
|
enabledPlaybooks: enabledCatalog(),
|
|
@@ -4775,15 +5810,11 @@ export function createPlaybookCaptainShell(
|
|
|
4775
5810
|
}
|
|
4776
5811
|
|
|
4777
5812
|
if (snapshot.mode === 'engaged.parked') {
|
|
4778
|
-
const rootTokens = new Map(
|
|
4779
|
-
Object.entries(snapshot.rootPlayerResumeTokens),
|
|
4780
|
-
);
|
|
4781
5813
|
for (const [index, frameSnapshot] of snapshot.frames.entries()) {
|
|
4782
5814
|
const parentFrame = frames.at(-1);
|
|
4783
5815
|
const frame = makeRestoredFrame(
|
|
4784
5816
|
enablementById.get(frameSnapshot.playbookId)!,
|
|
4785
5817
|
frameSnapshot,
|
|
4786
|
-
rootTokens,
|
|
4787
5818
|
index === 0
|
|
4788
5819
|
? undefined
|
|
4789
5820
|
: {
|
|
@@ -4807,10 +5838,15 @@ export function createPlaybookCaptainShell(
|
|
|
4807
5838
|
);
|
|
4808
5839
|
if (snapshot.mode === 'engaged.parked') {
|
|
4809
5840
|
for (const [index, frame] of frames.entries()) {
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
5841
|
+
restoringPlayerSessionFrame = frame;
|
|
5842
|
+
try {
|
|
5843
|
+
await frame.runtime.restore!(
|
|
5844
|
+
frameSession(frame),
|
|
5845
|
+
snapshot.frames[index]!.runtime,
|
|
5846
|
+
);
|
|
5847
|
+
} finally {
|
|
5848
|
+
restoringPlayerSessionFrame = undefined;
|
|
5849
|
+
}
|
|
4814
5850
|
}
|
|
4815
5851
|
}
|
|
4816
5852
|
if (closedGateAttempted) {
|
|
@@ -4832,13 +5868,10 @@ export function createPlaybookCaptainShell(
|
|
|
4832
5868
|
);
|
|
4833
5869
|
}
|
|
4834
5870
|
if (
|
|
4835
|
-
!isDeepStrictEqual(
|
|
4836
|
-
tokenRecord(rootFrame()!.playerResumeTokens),
|
|
4837
|
-
snapshot.rootPlayerResumeTokens,
|
|
4838
|
-
)
|
|
5871
|
+
!isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)
|
|
4839
5872
|
) {
|
|
4840
5873
|
throw new Error(
|
|
4841
|
-
'restored
|
|
5874
|
+
'restored Captain-session player continuation changed during restore',
|
|
4842
5875
|
);
|
|
4843
5876
|
}
|
|
4844
5877
|
}
|
|
@@ -4887,14 +5920,16 @@ export function createPlaybookCaptainShell(
|
|
|
4887
5920
|
lifecycle = 'initializing';
|
|
4888
5921
|
try {
|
|
4889
5922
|
installSession(initSession, true);
|
|
4890
|
-
|
|
4891
|
-
const built = await buildEnablements(options, players, loadModule);
|
|
5923
|
+
const built = await buildEnablements(options, loadModule);
|
|
4892
5924
|
entries = built.entries;
|
|
4893
5925
|
byCommand = built.byCommand;
|
|
4894
5926
|
byId = built.byId;
|
|
4895
5927
|
enablementById = built.enablementById;
|
|
4896
|
-
|
|
4897
|
-
|
|
5928
|
+
captainAgent = built.captainAgent;
|
|
5929
|
+
captainAdapter = captainAgent.adapter;
|
|
5930
|
+
playerAgents = built.playerAgents;
|
|
5931
|
+
for (const [playerId, agent] of playerAgents) {
|
|
5932
|
+
playerLedger.set(playerId, fixedAgent(agent));
|
|
4898
5933
|
}
|
|
4899
5934
|
await setMode('chat', 'init');
|
|
4900
5935
|
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
@@ -4942,6 +5977,7 @@ export function createPlaybookCaptainShell(
|
|
|
4942
5977
|
const parsed = resolveCommandTurn(turn.prompt);
|
|
4943
5978
|
activeTurn = {
|
|
4944
5979
|
id: ++turnSequence,
|
|
5980
|
+
captainSyncedJournalSeq: journalSeq,
|
|
4945
5981
|
bossText: turn.prompt,
|
|
4946
5982
|
authoritativeText: parsed?.authoritativeText ?? turn.prompt,
|
|
4947
5983
|
...(parsed ? { resolution: parsed.resolution } : {}),
|
|
@@ -4949,6 +5985,8 @@ export function createPlaybookCaptainShell(
|
|
|
4949
5985
|
presentationAttempted: false,
|
|
4950
5986
|
settlementFacts: [],
|
|
4951
5987
|
effectThrows: new Set<unknown>(),
|
|
5988
|
+
controlFailures: new Set<unknown>(),
|
|
5989
|
+
settingsPreflightFailures: new Set<unknown>(),
|
|
4952
5990
|
suppliedIdentifiers: new Set<string>(),
|
|
4953
5991
|
outcomeRecorded: false,
|
|
4954
5992
|
};
|
|
@@ -4969,13 +6007,16 @@ export function createPlaybookCaptainShell(
|
|
|
4969
6007
|
new Error('the session Captain turn failed at its boundary'),
|
|
4970
6008
|
);
|
|
4971
6009
|
} else if (result.outcome === 'aborted') {
|
|
4972
|
-
|
|
6010
|
+
markConversationUnsynchronized();
|
|
4973
6011
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
4974
6012
|
activeTurn.settlementFacts.push(
|
|
4975
6013
|
'The Boss turn was aborted before it settled; no action was repeated automatically.',
|
|
4976
6014
|
);
|
|
4977
6015
|
journalOutcome([...activeTurn.settlementFacts]);
|
|
4978
6016
|
}
|
|
6017
|
+
if (context.signal.aborted) {
|
|
6018
|
+
throw context.signal.reason;
|
|
6019
|
+
}
|
|
4979
6020
|
} else if (
|
|
4980
6021
|
result.outcome !== 'suspended' &&
|
|
4981
6022
|
!context.signal.aborted &&
|
|
@@ -4994,10 +6035,10 @@ export function createPlaybookCaptainShell(
|
|
|
4994
6035
|
}
|
|
4995
6036
|
} catch (error) {
|
|
4996
6037
|
if (context.signal.aborted) {
|
|
4997
|
-
|
|
6038
|
+
markConversationUnsynchronized();
|
|
4998
6039
|
throw error;
|
|
4999
6040
|
}
|
|
5000
|
-
const controlFailure = activeTurn?.
|
|
6041
|
+
const controlFailure = activeTurn?.controlFailures.has(error) === true;
|
|
5001
6042
|
await settleTurnFailure(context, error);
|
|
5002
6043
|
if (activeTurn?.presentationError !== undefined) {
|
|
5003
6044
|
throw activeTurn.presentationError;
|
|
@@ -5007,7 +6048,7 @@ export function createPlaybookCaptainShell(
|
|
|
5007
6048
|
if (!controlFailure) throw error;
|
|
5008
6049
|
} finally {
|
|
5009
6050
|
if (context.signal.aborted) {
|
|
5010
|
-
|
|
6051
|
+
markConversationUnsynchronized();
|
|
5011
6052
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
5012
6053
|
activeTurn.settlementFacts.push(
|
|
5013
6054
|
'The Boss turn was aborted before it settled; no action was repeated automatically.',
|
|
@@ -5064,6 +6105,9 @@ export function createPlaybookCaptainShell(
|
|
|
5064
6105
|
failure ??= error;
|
|
5065
6106
|
}
|
|
5066
6107
|
}
|
|
6108
|
+
// Quarantine is session-wide by design. Only terminal teardown may drop
|
|
6109
|
+
// its ownership after every frame host call and the Captain are drained.
|
|
6110
|
+
playerTransactions.clear();
|
|
5067
6111
|
lifecycle = 'closed';
|
|
5068
6112
|
if (failure !== undefined) throw failure;
|
|
5069
6113
|
}
|