@sublang/playbook 6.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -11
- package/docs/cli.md +158 -68
- package/docs/configuration.md +246 -108
- package/docs/embedding.md +71 -25
- package/package.json +6 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1164 -991
- package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +7 -11
- package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +12 -13
- package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
- package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
- package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
- package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +16 -21
- package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +27 -23
- package/slc/link.md +140 -97
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +24 -8
- package/src/runtime.ts +29 -13
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +301 -159
- package/src/xstate-playbook-runtime.ts +405 -186
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +403 -62
- package/src/xstate-runtime.ts +566 -78
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
6
|
import PQueue from 'p-queue';
|
|
6
7
|
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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,
|
|
12
17
|
} from '@sublang/cligent/tmux-play';
|
|
18
|
+
import type { Effort, PermissionPolicy } from '@sublang/cligent';
|
|
13
19
|
import type {
|
|
14
20
|
JsonValue,
|
|
15
21
|
NormalizedError,
|
|
@@ -20,12 +26,17 @@ import type {
|
|
|
20
26
|
PlaybookPorts,
|
|
21
27
|
PlaybookRunResult,
|
|
22
28
|
PlaybookRuntime,
|
|
29
|
+
PlaybookRuntimeSnapshot,
|
|
30
|
+
PlayerResult,
|
|
23
31
|
PlayerSessionStore,
|
|
24
32
|
PlaybookState,
|
|
25
33
|
} from '@sublang/playbook/runtime';
|
|
26
34
|
import {
|
|
35
|
+
assertPlaybookRuntimeSnapshot,
|
|
27
36
|
hiddenControlEnvelope,
|
|
28
37
|
registerPlaybookAbortCleanup,
|
|
38
|
+
snapshotJsonValue,
|
|
39
|
+
validatePlayerResult,
|
|
29
40
|
} from '../../../src/xstate-runtime.js';
|
|
30
41
|
import createDefaultCaptainRuntime, {
|
|
31
42
|
type CaptainControllerPort,
|
|
@@ -33,13 +44,37 @@ import createDefaultCaptainRuntime, {
|
|
|
33
44
|
type CaptainParsedResolution,
|
|
34
45
|
type SettlementEvidence,
|
|
35
46
|
} from '../captain.playbook/captain.playbook.js';
|
|
36
|
-
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
|
+
}
|
|
37
56
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
interface PlayerLedgerEntry {
|
|
58
|
+
readonly adapter: string;
|
|
59
|
+
readonly instruction?: string;
|
|
60
|
+
readonly permissions?: PermissionPolicy;
|
|
61
|
+
resumeToken?: string;
|
|
41
62
|
}
|
|
42
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
|
+
|
|
43
78
|
export interface PlaybookCaptainDeps {
|
|
44
79
|
loadModule?: (specifier: string) => Promise<unknown>;
|
|
45
80
|
createSessionId?: () => string;
|
|
@@ -57,28 +92,142 @@ export interface PlaybookCaptainRegistryEntry {
|
|
|
57
92
|
id: string;
|
|
58
93
|
command: string;
|
|
59
94
|
intent: string;
|
|
95
|
+
artifactSchema: 2;
|
|
60
96
|
requiredRoleIds: readonly string[];
|
|
97
|
+
concurrentRoleSets: readonly (readonly string[])[];
|
|
61
98
|
summaryPolicy?: PlaybookSummaryPolicy;
|
|
62
|
-
validateOptions(
|
|
63
|
-
createRuntime(options:
|
|
99
|
+
validateOptions(optionSlice: unknown): unknown;
|
|
100
|
+
createRuntime(options: unknown): PlaybookRuntime;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type PlaybookCaptainConversationSnapshot =
|
|
104
|
+
| { readonly kind: 'unopened' }
|
|
105
|
+
| { readonly kind: 'pinned'; readonly token: string }
|
|
106
|
+
| {
|
|
107
|
+
readonly kind: 'needsCatchUp';
|
|
108
|
+
readonly resume: string | false;
|
|
109
|
+
readonly afterJournalSeq: number;
|
|
110
|
+
}
|
|
111
|
+
| { readonly kind: 'needsSeeding' };
|
|
112
|
+
|
|
113
|
+
interface PlaybookCaptainJournalRecord {
|
|
114
|
+
readonly seq: number;
|
|
115
|
+
readonly turnId: number;
|
|
116
|
+
readonly kind: 'boss' | 'reply' | 'handoff' | 'action' | 'outcome';
|
|
117
|
+
readonly payload: JsonValue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface PlaybookCaptainFrameSnapshot {
|
|
121
|
+
readonly playbookId: string;
|
|
122
|
+
readonly sessionId: string;
|
|
123
|
+
readonly rootSessionId: string;
|
|
124
|
+
readonly depth: number;
|
|
125
|
+
readonly parentSessionId?: string;
|
|
126
|
+
readonly parentCallId?: string;
|
|
127
|
+
readonly options: JsonValue;
|
|
128
|
+
readonly roleBindings: Readonly<Record<string, string>>;
|
|
129
|
+
readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface PlaybookCaptainShellSnapshotFields {
|
|
133
|
+
readonly schemaVersion: 3;
|
|
134
|
+
readonly captain: {
|
|
135
|
+
readonly sessionId: string;
|
|
136
|
+
readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
|
|
137
|
+
readonly agent: SnapshotAgentEnvelope;
|
|
138
|
+
readonly conversation: PlaybookCaptainConversationSnapshot;
|
|
139
|
+
};
|
|
140
|
+
readonly playerSessions: Readonly<Record<string, PlayerLedgerSnapshotEntry>>;
|
|
141
|
+
/** Every Captain and engagement UUID issued during this logical session. */
|
|
142
|
+
readonly issuedSessionIds: readonly string[];
|
|
143
|
+
readonly sequences: {
|
|
144
|
+
readonly turn: number;
|
|
145
|
+
readonly journal: number;
|
|
146
|
+
};
|
|
147
|
+
readonly journal: readonly PlaybookCaptainJournalRecord[];
|
|
148
|
+
readonly lastAction?:
|
|
149
|
+
| 'respond'
|
|
150
|
+
| 'start'
|
|
151
|
+
| 'switch'
|
|
152
|
+
| 'dismiss'
|
|
153
|
+
| 'deliver'
|
|
154
|
+
| 'runtime';
|
|
155
|
+
readonly lastSettlementStatus?: 'ok' | 'rejected' | 'failed';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Complete JSON-safe durable state for one Captain shell between Boss turns.
|
|
160
|
+
* The discriminated mode keeps chat snapshots free of stale engagement data.
|
|
161
|
+
*/
|
|
162
|
+
type PlaybookCaptainShellSnapshotValue =
|
|
163
|
+
PlaybookCaptainShellSnapshotFields &
|
|
164
|
+
(
|
|
165
|
+
| {
|
|
166
|
+
readonly mode: 'chat';
|
|
167
|
+
readonly frames?: never;
|
|
168
|
+
readonly pendingBossQuestions?: never;
|
|
169
|
+
readonly lastError?: never;
|
|
170
|
+
}
|
|
171
|
+
| {
|
|
172
|
+
readonly mode: 'engaged.parked';
|
|
173
|
+
/** Root-to-leaf engagement order. */
|
|
174
|
+
readonly frames: readonly PlaybookCaptainFrameSnapshot[];
|
|
175
|
+
readonly pendingBossQuestions?: JsonValue;
|
|
176
|
+
readonly lastError?: { readonly name: string; readonly message: string };
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
export type PlaybookCaptainShellSnapshot =
|
|
181
|
+
DeepReadonly<PlaybookCaptainShellSnapshotValue>;
|
|
182
|
+
|
|
183
|
+
/** tmux and headless front ends share this one durable Captain shell API. */
|
|
184
|
+
export interface PlaybookCaptainShell extends Captain {
|
|
185
|
+
exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
|
|
186
|
+
restore(
|
|
187
|
+
session: CaptainSession,
|
|
188
|
+
snapshot: PlaybookCaptainShellSnapshot,
|
|
189
|
+
): Promise<void>;
|
|
64
190
|
}
|
|
65
191
|
|
|
66
|
-
// Per-enabled-playbook binding the shell resolves at init from
|
|
67
|
-
// `captain.options.playbooks
|
|
68
|
-
// `<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.
|
|
69
194
|
interface Enablement {
|
|
70
195
|
entry: PlaybookCaptainRegistryEntry;
|
|
71
196
|
command: string;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
hostPlayerId: (localRole: string) => string;
|
|
197
|
+
options: JsonValue;
|
|
198
|
+
roleBindings: ReadonlyMap<string, EffectivePlayerBinding>;
|
|
75
199
|
}
|
|
76
200
|
|
|
77
201
|
interface EffectivePlayerBinding {
|
|
78
|
-
readonly
|
|
79
|
-
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;
|
|
80
214
|
}
|
|
81
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
|
+
|
|
82
231
|
interface EngagementFrame {
|
|
83
232
|
entry: PlaybookCaptainRegistryEntry;
|
|
84
233
|
enablement: Enablement;
|
|
@@ -87,7 +236,6 @@ interface EngagementFrame {
|
|
|
87
236
|
rootSessionId: string;
|
|
88
237
|
depth: number;
|
|
89
238
|
playerBindings: ReadonlyMap<string, EffectivePlayerBinding>;
|
|
90
|
-
playerResumeTokens: Map<string, string>;
|
|
91
239
|
parent?: {
|
|
92
240
|
frame: EngagementFrame;
|
|
93
241
|
callId: string;
|
|
@@ -96,6 +244,7 @@ interface EngagementFrame {
|
|
|
96
244
|
abortListener?: () => void;
|
|
97
245
|
invocationSignal?: AbortSignal;
|
|
98
246
|
inFlightHostCalls: Set<Promise<unknown>>;
|
|
247
|
+
playerCallScope?: object;
|
|
99
248
|
// Set synchronously before this frame's runtime is asked to dispose, so a
|
|
100
249
|
// telemetry payload emitted during disposal is never mistaken for evidence
|
|
101
250
|
// about a live leaf. `disposePromise` cannot serve: it is assigned after
|
|
@@ -120,6 +269,26 @@ class VisibilityControlError extends Error {
|
|
|
120
269
|
}
|
|
121
270
|
}
|
|
122
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
|
+
|
|
123
292
|
type DisposalReason = 'dismiss' | 'final' | 'dispose' | 'failure';
|
|
124
293
|
|
|
125
294
|
interface ControlLedger {
|
|
@@ -161,15 +330,19 @@ interface JournalRecord {
|
|
|
161
330
|
type DurableCallKind = 'decision' | 'commandReply' | 'closingReply';
|
|
162
331
|
|
|
163
332
|
/**
|
|
164
|
-
* CAPTAIN-35: the
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* 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.
|
|
169
337
|
*/
|
|
170
338
|
type DurableConversation =
|
|
171
339
|
| { readonly kind: 'unopened' }
|
|
172
340
|
| { readonly kind: 'pinned'; readonly token: string }
|
|
341
|
+
| {
|
|
342
|
+
readonly kind: 'needsCatchUp';
|
|
343
|
+
readonly resume: string | false;
|
|
344
|
+
readonly afterJournalSeq: number;
|
|
345
|
+
}
|
|
173
346
|
| { readonly kind: 'needsSeeding' };
|
|
174
347
|
|
|
175
348
|
/**
|
|
@@ -191,6 +364,8 @@ const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
|
|
|
191
364
|
const INTERNAL_CAPTAIN_ID = 'captain';
|
|
192
365
|
const UUID_PATTERN =
|
|
193
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_-]*$/;
|
|
194
369
|
|
|
195
370
|
interface TurnSummaryCounts {
|
|
196
371
|
interruptions: number;
|
|
@@ -206,6 +381,8 @@ interface ActiveTurnSummary {
|
|
|
206
381
|
/** The shell state of one Boss turn (DR-029). */
|
|
207
382
|
interface ActiveTurn {
|
|
208
383
|
readonly id: number;
|
|
384
|
+
/** Latest journal record represented in a successful Captain call. */
|
|
385
|
+
captainSyncedJournalSeq: number;
|
|
209
386
|
/** The exact Boss text of the turn; never rewritten (CAPTAIN-31). */
|
|
210
387
|
readonly bossText: string;
|
|
211
388
|
/**
|
|
@@ -234,7 +411,9 @@ interface ActiveTurn {
|
|
|
234
411
|
* with the Boss-appropriate failure reply instead of propagating, so the
|
|
235
412
|
* Boss's next message settles normally.
|
|
236
413
|
*/
|
|
237
|
-
|
|
414
|
+
readonly controlFailures: Set<unknown>;
|
|
415
|
+
/** Exact settings-preflight rejections that performed no provider work. */
|
|
416
|
+
readonly settingsPreflightFailures: Set<unknown>;
|
|
238
417
|
/**
|
|
239
418
|
* Every value that escaped an effect invocation this turn — a runtime
|
|
240
419
|
* driven, an engagement constructed, a stack disposed, an advertised action
|
|
@@ -408,7 +587,16 @@ function pendingQuestionLines(pending: unknown): string[] {
|
|
|
408
587
|
: typeof record.id === 'string'
|
|
409
588
|
? record.id
|
|
410
589
|
: undefined;
|
|
411
|
-
const
|
|
590
|
+
const asker =
|
|
591
|
+
typeof record.asker === 'object' && record.asker !== null
|
|
592
|
+
? (record.asker as Record<string, unknown>)
|
|
593
|
+
: undefined;
|
|
594
|
+
const askerLabel =
|
|
595
|
+
asker?.kind === 'captain'
|
|
596
|
+
? 'Captain'
|
|
597
|
+
: asker?.kind === 'role' && typeof asker.roleId === 'string'
|
|
598
|
+
? asker.roleId
|
|
599
|
+
: undefined;
|
|
412
600
|
const text =
|
|
413
601
|
typeof record.question === 'string'
|
|
414
602
|
? record.question
|
|
@@ -420,9 +608,9 @@ function pendingQuestionLines(pending: unknown): string[] {
|
|
|
420
608
|
// second time would cut the line at the seam's limit and drop whatever
|
|
421
609
|
// the shell had already written after the long part.
|
|
422
610
|
const asked =
|
|
423
|
-
|
|
611
|
+
askerLabel === undefined
|
|
424
612
|
? digestLine`${quoteEvidence(text)}`
|
|
425
|
-
: digestLine`${quoteEvidence(
|
|
613
|
+
: digestLine`${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
|
|
426
614
|
const marker = id === undefined ? '' : digestLine`(${quoteEvidence(id)}) `;
|
|
427
615
|
lines.push(`- ${marker}${asked}`);
|
|
428
616
|
}
|
|
@@ -464,7 +652,10 @@ function renderJournalPayload(payload: JsonValue): string {
|
|
|
464
652
|
return raw ?? 'null';
|
|
465
653
|
}
|
|
466
654
|
|
|
467
|
-
function
|
|
655
|
+
function renderJournalDigest(
|
|
656
|
+
records: readonly JournalRecord[],
|
|
657
|
+
heading: string,
|
|
658
|
+
): string {
|
|
468
659
|
const lines = records.map(
|
|
469
660
|
(record) =>
|
|
470
661
|
`${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(
|
|
@@ -472,12 +663,26 @@ function renderReseedDigest(records: readonly JournalRecord[]): string {
|
|
|
472
663
|
)}`,
|
|
473
664
|
);
|
|
474
665
|
return [
|
|
475
|
-
|
|
666
|
+
heading,
|
|
476
667
|
'The labeled ControlView and catalog digest blocks outrank conversation memory.',
|
|
477
668
|
...(lines.length === 0 ? ['(no earlier turns)'] : lines),
|
|
478
669
|
].join('\n');
|
|
479
670
|
}
|
|
480
671
|
|
|
672
|
+
function renderReseedDigest(records: readonly JournalRecord[]): string {
|
|
673
|
+
return renderJournalDigest(
|
|
674
|
+
records,
|
|
675
|
+
'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.',
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function renderCatchUpDigest(records: readonly JournalRecord[]): string {
|
|
680
|
+
return renderJournalDigest(
|
|
681
|
+
records,
|
|
682
|
+
'This retained conversation missed the host journal records below. Treat this deterministic journal suffix as authoritative.',
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
481
686
|
// DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
|
|
482
687
|
// internal control vocabulary.
|
|
483
688
|
const CONTROL_VOCABULARY: readonly RegExp[] = [
|
|
@@ -597,16 +802,32 @@ function proseRejection(
|
|
|
597
802
|
return undefined;
|
|
598
803
|
}
|
|
599
804
|
|
|
805
|
+
type CaptainToolIsolation = 'provider-enforced' | 'prompt-only';
|
|
806
|
+
|
|
600
807
|
// DR-013 A1: adapters with no provider-enforced tool-restriction surface.
|
|
601
|
-
// Cligent's Codex
|
|
602
|
-
// empty list that expresses tool-free — because
|
|
603
|
-
// cannot enforce one, so requesting it fails
|
|
604
|
-
// model is reached. Omitting the option is the
|
|
605
|
-
// run a control call at all; its isolation then
|
|
606
|
-
// hidden-judge envelope below rather than on provider
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
808
|
+
// Cligent's Codex, Kimi, and OpenCode adapters reject any `allowedTools`
|
|
809
|
+
// value — including the empty list that expresses tool-free — because their
|
|
810
|
+
// supported provider surfaces cannot enforce one, so requesting it fails
|
|
811
|
+
// every control call before the model is reached. Omitting the option is the
|
|
812
|
+
// only way such an adapter can run a control call at all; its isolation then
|
|
813
|
+
// rests on the authored hidden-judge envelope below rather than on provider
|
|
814
|
+
// enforcement.
|
|
815
|
+
const CAPTAIN_TOOL_ISOLATION_BY_ADAPTER = {
|
|
816
|
+
claude: 'provider-enforced',
|
|
817
|
+
codex: 'prompt-only',
|
|
818
|
+
gemini: 'provider-enforced',
|
|
819
|
+
kimi: 'prompt-only',
|
|
820
|
+
opencode: 'prompt-only',
|
|
821
|
+
} as const satisfies Readonly<Record<PlayerAdapterName, CaptainToolIsolation>>;
|
|
822
|
+
|
|
823
|
+
function requiresPromptOnlyToolIsolation(captainAdapter: string): boolean {
|
|
824
|
+
return (
|
|
825
|
+
Object.hasOwn(CAPTAIN_TOOL_ISOLATION_BY_ADAPTER, captainAdapter) &&
|
|
826
|
+
CAPTAIN_TOOL_ISOLATION_BY_ADAPTER[
|
|
827
|
+
captainAdapter as PlayerAdapterName
|
|
828
|
+
] === 'prompt-only'
|
|
829
|
+
);
|
|
830
|
+
}
|
|
610
831
|
|
|
611
832
|
// The tool half of a control call's options. An empty allowlist means "no
|
|
612
833
|
// tools available" and is distinct from omission, which grants the adapter's
|
|
@@ -617,7 +838,7 @@ function controlCallToolOptions(
|
|
|
617
838
|
): { allowedTools?: readonly string[] } {
|
|
618
839
|
if (
|
|
619
840
|
captainAdapter !== undefined &&
|
|
620
|
-
|
|
841
|
+
requiresPromptOnlyToolIsolation(captainAdapter)
|
|
621
842
|
) {
|
|
622
843
|
return {};
|
|
623
844
|
}
|
|
@@ -637,14 +858,6 @@ function forwardedToolOptions(
|
|
|
637
858
|
return { allowedTools: requested };
|
|
638
859
|
}
|
|
639
860
|
|
|
640
|
-
function readCaptainAdapter(options: unknown): string | undefined {
|
|
641
|
-
if (typeof options !== 'object' || options === null) return undefined;
|
|
642
|
-
const adapter = (options as Record<string, unknown>).captainAdapter;
|
|
643
|
-
return typeof adapter === 'string' && adapter.length > 0
|
|
644
|
-
? adapter
|
|
645
|
-
: undefined;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
861
|
const hiddenJudgeEnvelope = hiddenControlEnvelope;
|
|
649
862
|
|
|
650
863
|
interface OutcomeReport {
|
|
@@ -755,25 +968,877 @@ function isValidRegistryEntry(
|
|
|
755
968
|
): value is PlaybookCaptainRegistryEntry {
|
|
756
969
|
if (typeof value !== 'object' || value === null) return false;
|
|
757
970
|
const e = value as Record<string, unknown>;
|
|
971
|
+
if (
|
|
972
|
+
!Array.isArray(e.requiredRoleIds) ||
|
|
973
|
+
e.requiredRoleIds.some(
|
|
974
|
+
(role) =>
|
|
975
|
+
typeof role !== 'string' ||
|
|
976
|
+
!ROLE_ID_PATTERN.test(role) ||
|
|
977
|
+
role === INTERNAL_CAPTAIN_ID,
|
|
978
|
+
) ||
|
|
979
|
+
new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
|
|
980
|
+
!Array.isArray(e.concurrentRoleSets)
|
|
981
|
+
) {
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
const roles = new Set(e.requiredRoleIds);
|
|
985
|
+
const concurrency = e.concurrentRoleSets as unknown[];
|
|
986
|
+
if (
|
|
987
|
+
concurrency.some(
|
|
988
|
+
(set) =>
|
|
989
|
+
!Array.isArray(set) ||
|
|
990
|
+
set.length < 2 ||
|
|
991
|
+
set.some((role) => typeof role !== 'string' || !roles.has(role)) ||
|
|
992
|
+
new Set(set).size !== set.length,
|
|
993
|
+
) ||
|
|
994
|
+
new Set(concurrency.map((set) => JSON.stringify(set))).size !==
|
|
995
|
+
concurrency.length
|
|
996
|
+
) {
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
758
999
|
return (
|
|
759
1000
|
typeof e.id === 'string' &&
|
|
760
1001
|
typeof e.command === 'string' &&
|
|
761
1002
|
typeof e.intent === 'string' &&
|
|
762
|
-
|
|
1003
|
+
e.artifactSchema === 2 &&
|
|
763
1004
|
typeof e.validateOptions === 'function' &&
|
|
764
1005
|
typeof e.createRuntime === 'function'
|
|
765
1006
|
);
|
|
766
1007
|
}
|
|
767
1008
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1009
|
+
const SNAPSHOT_ACTIONS = new Set([
|
|
1010
|
+
'respond',
|
|
1011
|
+
'start',
|
|
1012
|
+
'switch',
|
|
1013
|
+
'dismiss',
|
|
1014
|
+
'deliver',
|
|
1015
|
+
'runtime',
|
|
1016
|
+
] as const);
|
|
1017
|
+
const SNAPSHOT_SETTLEMENT_STATUSES = new Set([
|
|
1018
|
+
'ok',
|
|
1019
|
+
'rejected',
|
|
1020
|
+
'failed',
|
|
1021
|
+
] as const);
|
|
1022
|
+
const SNAPSHOT_JOURNAL_KINDS = new Set([
|
|
1023
|
+
'boss',
|
|
1024
|
+
'reply',
|
|
1025
|
+
'handoff',
|
|
1026
|
+
'action',
|
|
1027
|
+
'outcome',
|
|
1028
|
+
] as const);
|
|
1029
|
+
|
|
1030
|
+
function snapshotRecord(
|
|
1031
|
+
value: JsonValue | undefined,
|
|
1032
|
+
path: string,
|
|
1033
|
+
): Record<string, JsonValue> {
|
|
1034
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
1035
|
+
throw new TypeError(`${path} must be an object`);
|
|
1036
|
+
}
|
|
1037
|
+
return value as Record<string, JsonValue>;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function rejectSnapshotKeys(
|
|
1041
|
+
value: Readonly<Record<string, JsonValue>>,
|
|
1042
|
+
allowed: readonly string[],
|
|
1043
|
+
path: string,
|
|
1044
|
+
): void {
|
|
1045
|
+
const allowedKeys = new Set(allowed);
|
|
1046
|
+
const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
|
|
1047
|
+
if (unknown.length > 0) {
|
|
1048
|
+
throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown[0])}`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function snapshotString(
|
|
1053
|
+
value: JsonValue | undefined,
|
|
1054
|
+
path: string,
|
|
1055
|
+
allowEmpty = false,
|
|
1056
|
+
): string {
|
|
1057
|
+
if (
|
|
1058
|
+
typeof value !== 'string' ||
|
|
1059
|
+
(!allowEmpty && value.trim().length === 0)
|
|
1060
|
+
) {
|
|
1061
|
+
throw new TypeError(`${path} must be a ${allowEmpty ? '' : 'non-empty '}string`);
|
|
1062
|
+
}
|
|
1063
|
+
return value;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function snapshotInteger(
|
|
1067
|
+
value: JsonValue | undefined,
|
|
1068
|
+
path: string,
|
|
1069
|
+
minimum = 0,
|
|
1070
|
+
): number {
|
|
1071
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
|
1072
|
+
throw new TypeError(`${path} must be an integer >= ${minimum}`);
|
|
1073
|
+
}
|
|
1074
|
+
return value as number;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function snapshotUuid(value: JsonValue | undefined, path: string): string {
|
|
1078
|
+
const id = snapshotString(value, path);
|
|
1079
|
+
if (!UUID_PATTERN.test(id)) {
|
|
1080
|
+
throw new TypeError(`${path} must be a UUID`);
|
|
1081
|
+
}
|
|
1082
|
+
return id;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function snapshotPermissions(
|
|
1086
|
+
value: JsonValue | undefined,
|
|
1087
|
+
path: string,
|
|
1088
|
+
): PermissionPolicy | undefined {
|
|
1089
|
+
if (value === undefined) return undefined;
|
|
1090
|
+
const record = snapshotRecord(value, path);
|
|
1091
|
+
rejectSnapshotKeys(
|
|
1092
|
+
record,
|
|
1093
|
+
['mode', 'fileWrite', 'shellExecute', 'networkAccess', 'writablePaths'],
|
|
1094
|
+
path,
|
|
1095
|
+
);
|
|
1096
|
+
const normalized: PermissionPolicy = {};
|
|
1097
|
+
if (record.mode !== undefined) {
|
|
1098
|
+
if (record.mode !== 'auto' && record.mode !== 'bypass') {
|
|
1099
|
+
throw new TypeError(`${path}.mode must be "auto" or "bypass"`);
|
|
1100
|
+
}
|
|
1101
|
+
normalized.mode = record.mode;
|
|
1102
|
+
}
|
|
1103
|
+
for (const key of [
|
|
1104
|
+
'fileWrite',
|
|
1105
|
+
'shellExecute',
|
|
1106
|
+
'networkAccess',
|
|
1107
|
+
] as const) {
|
|
1108
|
+
const level = record[key];
|
|
1109
|
+
if (level === undefined) continue;
|
|
1110
|
+
if (level !== 'allow' && level !== 'ask' && level !== 'deny') {
|
|
1111
|
+
throw new TypeError(`${path}.${key} must be "allow", "ask", or "deny"`);
|
|
1112
|
+
}
|
|
1113
|
+
normalized[key] = level;
|
|
1114
|
+
}
|
|
1115
|
+
if (record.writablePaths !== undefined) {
|
|
1116
|
+
if (
|
|
1117
|
+
!Array.isArray(record.writablePaths) ||
|
|
1118
|
+
record.writablePaths.some(
|
|
1119
|
+
(entry) => typeof entry !== 'string' || entry.length === 0,
|
|
1120
|
+
)
|
|
1121
|
+
) {
|
|
1122
|
+
throw new TypeError(`${path}.writablePaths must be an array of non-empty strings`);
|
|
1123
|
+
}
|
|
1124
|
+
normalized.writablePaths = [...record.writablePaths];
|
|
1125
|
+
}
|
|
1126
|
+
return normalized;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function livePermissions(
|
|
1130
|
+
value: DeepReadonly<PermissionPolicy> | undefined,
|
|
1131
|
+
): PermissionPolicy | undefined {
|
|
1132
|
+
if (value === undefined) return undefined;
|
|
1133
|
+
return {
|
|
1134
|
+
...(value.mode === undefined ? {} : { mode: value.mode }),
|
|
1135
|
+
...(value.fileWrite === undefined ? {} : { fileWrite: value.fileWrite }),
|
|
1136
|
+
...(value.shellExecute === undefined
|
|
1137
|
+
? {}
|
|
1138
|
+
: { shellExecute: value.shellExecute }),
|
|
1139
|
+
...(value.networkAccess === undefined
|
|
1140
|
+
? {}
|
|
1141
|
+
: { networkAccess: value.networkAccess }),
|
|
1142
|
+
...(value.writablePaths === undefined
|
|
1143
|
+
? {}
|
|
1144
|
+
: { writablePaths: [...value.writablePaths] }),
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function snapshotFixedAgent(
|
|
1149
|
+
value: JsonValue | undefined,
|
|
1150
|
+
path: string,
|
|
1151
|
+
): SnapshotAgentEnvelope {
|
|
1152
|
+
const record = snapshotRecord(value, path);
|
|
1153
|
+
rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions'], path);
|
|
1154
|
+
const adapter = snapshotString(record.adapter, `${path}.adapter`);
|
|
1155
|
+
const instruction =
|
|
1156
|
+
record.instruction === undefined
|
|
1157
|
+
? undefined
|
|
1158
|
+
: snapshotString(record.instruction, `${path}.instruction`, true);
|
|
1159
|
+
const permissions = snapshotPermissions(record.permissions, `${path}.permissions`);
|
|
1160
|
+
return {
|
|
1161
|
+
adapter,
|
|
1162
|
+
...(instruction === undefined ? {} : { instruction }),
|
|
1163
|
+
...(permissions === undefined ? {} : { permissions }),
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function snapshotPlayerSessions(
|
|
1168
|
+
value: JsonValue | undefined,
|
|
1169
|
+
path: string,
|
|
1170
|
+
): Readonly<Record<string, PlayerLedgerSnapshotEntry>> {
|
|
1171
|
+
const sessions = snapshotRecord(value, path);
|
|
1172
|
+
return Object.fromEntries(
|
|
1173
|
+
Object.entries(sessions).map(([playerId, raw]) => {
|
|
1174
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
1175
|
+
throw new TypeError(`${path} has invalid player id ${JSON.stringify(playerId)}`);
|
|
1176
|
+
}
|
|
1177
|
+
const record = snapshotRecord(raw, `${path}.${playerId}`);
|
|
1178
|
+
rejectSnapshotKeys(
|
|
1179
|
+
record,
|
|
1180
|
+
['adapter', 'instruction', 'permissions', 'resumeToken'],
|
|
1181
|
+
`${path}.${playerId}`,
|
|
1182
|
+
);
|
|
1183
|
+
const fixed = snapshotFixedAgent(
|
|
1184
|
+
Object.fromEntries(
|
|
1185
|
+
Object.entries(record).filter(([key]) => key !== 'resumeToken'),
|
|
1186
|
+
) as JsonValue,
|
|
1187
|
+
`${path}.${playerId}`,
|
|
1188
|
+
);
|
|
1189
|
+
const resumeToken =
|
|
1190
|
+
record.resumeToken === undefined
|
|
1191
|
+
? undefined
|
|
1192
|
+
: snapshotString(record.resumeToken, `${path}.${playerId}.resumeToken`);
|
|
1193
|
+
return [
|
|
1194
|
+
playerId,
|
|
1195
|
+
{ ...fixed, ...(resumeToken === undefined ? {} : { resumeToken }) },
|
|
1196
|
+
];
|
|
1197
|
+
}),
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function snapshotFrameRoleBindings(
|
|
1202
|
+
value: JsonValue | undefined,
|
|
1203
|
+
path: string,
|
|
1204
|
+
): Readonly<Record<string, string>> {
|
|
1205
|
+
const bindings = snapshotRecord(value, path);
|
|
1206
|
+
return Object.fromEntries(
|
|
1207
|
+
Object.entries(bindings).map(([roleId, raw]) => {
|
|
1208
|
+
if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
|
|
1209
|
+
throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
|
|
1210
|
+
}
|
|
1211
|
+
const playerId = snapshotString(raw, `${path}.${roleId}`);
|
|
1212
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
1213
|
+
throw new TypeError(`${path}.${roleId} has invalid player id`);
|
|
1214
|
+
}
|
|
1215
|
+
return [roleId, playerId];
|
|
1216
|
+
}),
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function normalizeHostPlayerResult(
|
|
1221
|
+
value: unknown,
|
|
1222
|
+
expectedPlayerId: string,
|
|
1223
|
+
): PlayerResult {
|
|
1224
|
+
const path = 'tmux-play delegated-player result';
|
|
1225
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
1226
|
+
throw new TypeError(`${path} must be an object`);
|
|
1227
|
+
}
|
|
1228
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
1229
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1230
|
+
throw new TypeError(`${path} must be a plain JSON object`);
|
|
1231
|
+
}
|
|
1232
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1233
|
+
const allowedKeys = new Set([
|
|
1234
|
+
'status',
|
|
1235
|
+
'playerId',
|
|
1236
|
+
'turnId',
|
|
1237
|
+
'resumeToken',
|
|
1238
|
+
'finalText',
|
|
1239
|
+
'error',
|
|
1240
|
+
]);
|
|
1241
|
+
const normalized: Record<string, unknown> = {};
|
|
1242
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
1243
|
+
if (typeof key === 'symbol') {
|
|
1244
|
+
throw new TypeError(`${path} must not contain symbol-keyed properties`);
|
|
1245
|
+
}
|
|
1246
|
+
const descriptor = descriptors[key];
|
|
1247
|
+
if (!allowedKeys.has(key)) {
|
|
1248
|
+
throw new TypeError(`${path} has unknown field ${JSON.stringify(key)}`);
|
|
1249
|
+
}
|
|
1250
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
|
|
1251
|
+
throw new TypeError(`${path}.${key} must be an enumerable data property`);
|
|
1252
|
+
}
|
|
1253
|
+
// Cligent deliberately exposes optional result members as own
|
|
1254
|
+
// `undefined` data properties. Omit only those members before taking the
|
|
1255
|
+
// immutable JSON snapshot; every other value still passes the strict
|
|
1256
|
+
// JSON validator below.
|
|
1257
|
+
if (descriptor.value !== undefined) normalized[key] = descriptor.value;
|
|
1258
|
+
}
|
|
1259
|
+
const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
|
|
1260
|
+
rejectSnapshotKeys(
|
|
1261
|
+
record,
|
|
1262
|
+
['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'],
|
|
1263
|
+
path,
|
|
1264
|
+
);
|
|
1265
|
+
if (record.playerId !== expectedPlayerId) {
|
|
1266
|
+
throw new TypeError(`${path}.playerId does not match the requested player`);
|
|
1267
|
+
}
|
|
1268
|
+
snapshotInteger(record.turnId, `${path}.turnId`, 1);
|
|
1269
|
+
return validatePlayerResult(
|
|
1270
|
+
{
|
|
1271
|
+
status: record.status,
|
|
1272
|
+
...(record.resumeToken === undefined
|
|
1273
|
+
? {}
|
|
1274
|
+
: { resumeToken: record.resumeToken }),
|
|
1275
|
+
...(record.finalText === undefined
|
|
1276
|
+
? {}
|
|
1277
|
+
: { finalText: record.finalText }),
|
|
1278
|
+
...(record.error === undefined ? {} : { error: record.error }),
|
|
1279
|
+
},
|
|
1280
|
+
path,
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/** Validate, detach, and freeze one untrusted shell snapshot. */
|
|
1285
|
+
export function assertPlaybookCaptainShellSnapshot(
|
|
1286
|
+
value: unknown,
|
|
1287
|
+
): PlaybookCaptainShellSnapshot {
|
|
1288
|
+
const detached = snapshotJsonValue(value, 'Captain shell snapshot');
|
|
1289
|
+
const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
|
|
1290
|
+
const mode = snapshot.mode;
|
|
1291
|
+
const commonKeys = [
|
|
1292
|
+
'schemaVersion',
|
|
1293
|
+
'captain',
|
|
1294
|
+
'playerSessions',
|
|
1295
|
+
'issuedSessionIds',
|
|
1296
|
+
'sequences',
|
|
1297
|
+
'journal',
|
|
1298
|
+
'lastAction',
|
|
1299
|
+
'lastSettlementStatus',
|
|
1300
|
+
'mode',
|
|
1301
|
+
];
|
|
1302
|
+
if (mode === 'chat') {
|
|
1303
|
+
rejectSnapshotKeys(snapshot, commonKeys, 'Captain shell snapshot');
|
|
1304
|
+
} else if (mode === 'engaged.parked') {
|
|
1305
|
+
rejectSnapshotKeys(
|
|
1306
|
+
snapshot,
|
|
1307
|
+
[
|
|
1308
|
+
...commonKeys,
|
|
1309
|
+
'frames',
|
|
1310
|
+
'pendingBossQuestions',
|
|
1311
|
+
'lastError',
|
|
1312
|
+
],
|
|
1313
|
+
'Captain shell snapshot',
|
|
1314
|
+
);
|
|
1315
|
+
} else {
|
|
1316
|
+
throw new TypeError(
|
|
1317
|
+
'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
if (snapshot.schemaVersion !== 3) {
|
|
1321
|
+
throw new TypeError(
|
|
1322
|
+
`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const captain = snapshotRecord(
|
|
1327
|
+
snapshot.captain,
|
|
1328
|
+
'Captain shell snapshot.captain',
|
|
1329
|
+
);
|
|
1330
|
+
rejectSnapshotKeys(
|
|
1331
|
+
captain,
|
|
1332
|
+
['sessionId', 'runtime', 'agent', 'conversation'],
|
|
1333
|
+
'Captain shell snapshot.captain',
|
|
1334
|
+
);
|
|
1335
|
+
const captainSessionId = snapshotUuid(
|
|
1336
|
+
captain.sessionId,
|
|
1337
|
+
'Captain shell snapshot.captain.sessionId',
|
|
1338
|
+
);
|
|
1339
|
+
const captainRuntime = assertPlaybookRuntimeSnapshot(
|
|
1340
|
+
captain.runtime,
|
|
1341
|
+
INTERNAL_CAPTAIN_ID,
|
|
1342
|
+
);
|
|
1343
|
+
const captainAgent = snapshotFixedAgent(
|
|
1344
|
+
captain.agent,
|
|
1345
|
+
'Captain shell snapshot.captain.agent',
|
|
1346
|
+
);
|
|
1347
|
+
const conversation = snapshotRecord(
|
|
1348
|
+
captain.conversation,
|
|
1349
|
+
'Captain shell snapshot.captain.conversation',
|
|
1350
|
+
);
|
|
1351
|
+
let normalizedConversation: PlaybookCaptainConversationSnapshot;
|
|
1352
|
+
if (conversation.kind === 'pinned') {
|
|
1353
|
+
rejectSnapshotKeys(
|
|
1354
|
+
conversation,
|
|
1355
|
+
['kind', 'token'],
|
|
1356
|
+
'Captain shell snapshot.captain.conversation',
|
|
1357
|
+
);
|
|
1358
|
+
normalizedConversation = {
|
|
1359
|
+
kind: 'pinned',
|
|
1360
|
+
token: snapshotString(
|
|
1361
|
+
conversation.token,
|
|
1362
|
+
'Captain shell snapshot.captain.conversation.token',
|
|
1363
|
+
),
|
|
1364
|
+
};
|
|
1365
|
+
} else if (conversation.kind === 'needsCatchUp') {
|
|
1366
|
+
rejectSnapshotKeys(
|
|
1367
|
+
conversation,
|
|
1368
|
+
['kind', 'resume', 'afterJournalSeq'],
|
|
1369
|
+
'Captain shell snapshot.captain.conversation',
|
|
1370
|
+
);
|
|
1371
|
+
normalizedConversation = {
|
|
1372
|
+
kind: 'needsCatchUp',
|
|
1373
|
+
resume:
|
|
1374
|
+
conversation.resume === false
|
|
1375
|
+
? false
|
|
1376
|
+
: snapshotString(
|
|
1377
|
+
conversation.resume,
|
|
1378
|
+
'Captain shell snapshot.captain.conversation.resume',
|
|
1379
|
+
),
|
|
1380
|
+
afterJournalSeq: snapshotInteger(
|
|
1381
|
+
conversation.afterJournalSeq,
|
|
1382
|
+
'Captain shell snapshot.captain.conversation.afterJournalSeq',
|
|
1383
|
+
),
|
|
1384
|
+
};
|
|
1385
|
+
} else if (
|
|
1386
|
+
conversation.kind === 'unopened' ||
|
|
1387
|
+
conversation.kind === 'needsSeeding'
|
|
1388
|
+
) {
|
|
1389
|
+
rejectSnapshotKeys(
|
|
1390
|
+
conversation,
|
|
1391
|
+
['kind'],
|
|
1392
|
+
'Captain shell snapshot.captain.conversation',
|
|
1393
|
+
);
|
|
1394
|
+
normalizedConversation = { kind: conversation.kind };
|
|
1395
|
+
} else {
|
|
1396
|
+
throw new TypeError(
|
|
1397
|
+
'Captain shell snapshot.captain.conversation.kind is not supported',
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (!Array.isArray(snapshot.issuedSessionIds)) {
|
|
1402
|
+
throw new TypeError(
|
|
1403
|
+
'Captain shell snapshot.issuedSessionIds must be an array',
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
const issued = snapshot.issuedSessionIds.map((id, index) =>
|
|
1407
|
+
snapshotUuid(id, `Captain shell snapshot.issuedSessionIds[${index}]`),
|
|
1408
|
+
);
|
|
1409
|
+
if (new Set(issued).size !== issued.length) {
|
|
1410
|
+
throw new TypeError(
|
|
1411
|
+
'Captain shell snapshot.issuedSessionIds must not contain duplicates',
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
if (issued[0] !== captainSessionId) {
|
|
1415
|
+
throw new TypeError(
|
|
1416
|
+
'Captain shell snapshot Captain session id must be the first issued id',
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const sequences = snapshotRecord(
|
|
1421
|
+
snapshot.sequences,
|
|
1422
|
+
'Captain shell snapshot.sequences',
|
|
1423
|
+
);
|
|
1424
|
+
rejectSnapshotKeys(
|
|
1425
|
+
sequences,
|
|
1426
|
+
['turn', 'journal'],
|
|
1427
|
+
'Captain shell snapshot.sequences',
|
|
1428
|
+
);
|
|
1429
|
+
const turnSequence = snapshotInteger(
|
|
1430
|
+
sequences.turn,
|
|
1431
|
+
'Captain shell snapshot.sequences.turn',
|
|
1432
|
+
);
|
|
1433
|
+
const journalSequence = snapshotInteger(
|
|
1434
|
+
sequences.journal,
|
|
1435
|
+
'Captain shell snapshot.sequences.journal',
|
|
1436
|
+
);
|
|
1437
|
+
|
|
1438
|
+
if (!Array.isArray(snapshot.journal)) {
|
|
1439
|
+
throw new TypeError('Captain shell snapshot.journal must be an array');
|
|
1440
|
+
}
|
|
1441
|
+
const normalizedJournal: PlaybookCaptainJournalRecord[] = [];
|
|
1442
|
+
let previousTurn = 0;
|
|
1443
|
+
let bossRecords = 0;
|
|
1444
|
+
for (const [index, value] of snapshot.journal.entries()) {
|
|
1445
|
+
const record = snapshotRecord(
|
|
1446
|
+
value,
|
|
1447
|
+
`Captain shell snapshot.journal[${index}]`,
|
|
1448
|
+
);
|
|
1449
|
+
rejectSnapshotKeys(
|
|
1450
|
+
record,
|
|
1451
|
+
['seq', 'turnId', 'kind', 'payload'],
|
|
1452
|
+
`Captain shell snapshot.journal[${index}]`,
|
|
1453
|
+
);
|
|
1454
|
+
const seq = snapshotInteger(
|
|
1455
|
+
record.seq,
|
|
1456
|
+
`Captain shell snapshot.journal[${index}].seq`,
|
|
1457
|
+
1,
|
|
1458
|
+
);
|
|
1459
|
+
if (seq !== index + 1) {
|
|
1460
|
+
throw new TypeError(
|
|
1461
|
+
'Captain shell snapshot journal sequence must be contiguous from one',
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
const turnId = snapshotInteger(
|
|
1465
|
+
record.turnId,
|
|
1466
|
+
`Captain shell snapshot.journal[${index}].turnId`,
|
|
1467
|
+
1,
|
|
1468
|
+
);
|
|
1469
|
+
if (turnId < previousTurn || turnId > turnSequence) {
|
|
1470
|
+
throw new TypeError(
|
|
1471
|
+
'Captain shell snapshot journal turn ids must be ordered and in range',
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
const kind = record.kind;
|
|
1475
|
+
if (
|
|
1476
|
+
typeof kind !== 'string' ||
|
|
1477
|
+
!SNAPSHOT_JOURNAL_KINDS.has(
|
|
1478
|
+
kind as PlaybookCaptainJournalRecord['kind'],
|
|
1479
|
+
)
|
|
1480
|
+
) {
|
|
1481
|
+
throw new TypeError(
|
|
1482
|
+
`Captain shell snapshot.journal[${index}].kind is not supported`,
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
if (turnId !== previousTurn) {
|
|
1486
|
+
if (turnId !== previousTurn + 1 || kind !== 'boss') {
|
|
1487
|
+
throw new TypeError(
|
|
1488
|
+
'Captain shell snapshot journal must begin every turn with one boss record',
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
bossRecords++;
|
|
1492
|
+
previousTurn = turnId;
|
|
1493
|
+
} else if (kind === 'boss') {
|
|
1494
|
+
throw new TypeError(
|
|
1495
|
+
'Captain shell snapshot journal must contain one boss record per turn',
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
normalizedJournal.push({
|
|
1499
|
+
seq,
|
|
1500
|
+
turnId,
|
|
1501
|
+
kind: kind as PlaybookCaptainJournalRecord['kind'],
|
|
1502
|
+
payload: record.payload as JsonValue,
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
if (
|
|
1506
|
+
journalSequence !== normalizedJournal.length ||
|
|
1507
|
+
bossRecords !== turnSequence
|
|
1508
|
+
) {
|
|
1509
|
+
throw new TypeError(
|
|
1510
|
+
'Captain shell snapshot sequences do not match the complete journal',
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
const emptyHistory = turnSequence === 0 && normalizedJournal.length === 0;
|
|
1514
|
+
if ((normalizedConversation.kind === 'unopened') !== emptyHistory) {
|
|
1515
|
+
throw new TypeError(
|
|
1516
|
+
'Captain shell snapshot history is empty exactly when its conversation is unopened',
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
if (
|
|
1520
|
+
normalizedConversation.kind === 'needsCatchUp' &&
|
|
1521
|
+
normalizedConversation.afterJournalSeq >= journalSequence
|
|
1522
|
+
) {
|
|
1523
|
+
throw new TypeError(
|
|
1524
|
+
'Captain shell snapshot catch-up watermark must precede the current journal sequence',
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
if (
|
|
1528
|
+
normalizedConversation.kind === 'needsCatchUp' &&
|
|
1529
|
+
((normalizedConversation.resume === false) !==
|
|
1530
|
+
(normalizedConversation.afterJournalSeq === 0))
|
|
1531
|
+
) {
|
|
1532
|
+
throw new TypeError(
|
|
1533
|
+
'Captain shell snapshot catch-up resume is fresh exactly at journal watermark zero',
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
let lastAction: PlaybookCaptainShellSnapshotFields['lastAction'];
|
|
1538
|
+
if (snapshot.lastAction !== undefined) {
|
|
1539
|
+
if (
|
|
1540
|
+
typeof snapshot.lastAction !== 'string' ||
|
|
1541
|
+
!SNAPSHOT_ACTIONS.has(
|
|
1542
|
+
snapshot.lastAction as NonNullable<typeof lastAction>,
|
|
1543
|
+
)
|
|
1544
|
+
) {
|
|
1545
|
+
throw new TypeError('Captain shell snapshot.lastAction is not supported');
|
|
1546
|
+
}
|
|
1547
|
+
lastAction = snapshot.lastAction as NonNullable<typeof lastAction>;
|
|
1548
|
+
}
|
|
1549
|
+
let lastSettlementStatus: PlaybookCaptainShellSnapshotFields['lastSettlementStatus'];
|
|
1550
|
+
if (snapshot.lastSettlementStatus !== undefined) {
|
|
1551
|
+
if (
|
|
1552
|
+
typeof snapshot.lastSettlementStatus !== 'string' ||
|
|
1553
|
+
!SNAPSHOT_SETTLEMENT_STATUSES.has(
|
|
1554
|
+
snapshot.lastSettlementStatus as NonNullable<
|
|
1555
|
+
typeof lastSettlementStatus
|
|
1556
|
+
>,
|
|
1557
|
+
)
|
|
1558
|
+
) {
|
|
1559
|
+
throw new TypeError(
|
|
1560
|
+
'Captain shell snapshot.lastSettlementStatus is not supported',
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
lastSettlementStatus = snapshot.lastSettlementStatus as NonNullable<
|
|
1564
|
+
typeof lastSettlementStatus
|
|
1565
|
+
>;
|
|
1566
|
+
}
|
|
1567
|
+
const playerSessions = snapshotPlayerSessions(
|
|
1568
|
+
snapshot.playerSessions,
|
|
1569
|
+
'Captain shell snapshot.playerSessions',
|
|
1570
|
+
);
|
|
1571
|
+
const common: PlaybookCaptainShellSnapshotFields = {
|
|
1572
|
+
schemaVersion: 3,
|
|
1573
|
+
captain: {
|
|
1574
|
+
sessionId: captainSessionId,
|
|
1575
|
+
runtime: captainRuntime,
|
|
1576
|
+
agent: captainAgent,
|
|
1577
|
+
conversation: normalizedConversation,
|
|
1578
|
+
},
|
|
1579
|
+
playerSessions,
|
|
1580
|
+
issuedSessionIds: issued,
|
|
1581
|
+
sequences: { turn: turnSequence, journal: journalSequence },
|
|
1582
|
+
journal: normalizedJournal,
|
|
1583
|
+
...(lastAction === undefined ? {} : { lastAction }),
|
|
1584
|
+
...(lastSettlementStatus === undefined
|
|
1585
|
+
? {}
|
|
1586
|
+
: { lastSettlementStatus }),
|
|
1587
|
+
};
|
|
1588
|
+
if (
|
|
1589
|
+
captainRuntime.state.status !== 'active' ||
|
|
1590
|
+
!captainRuntime.state.quiescent ||
|
|
1591
|
+
!captainRuntime.state.tags.includes('playbook.parked') ||
|
|
1592
|
+
captainRuntime.suspendedCall !== undefined ||
|
|
1593
|
+
Object.keys(captainRuntime.roleResumeTokens).length > 0 ||
|
|
1594
|
+
captainRuntime.pendingBossQuestions.length > 0
|
|
1595
|
+
) {
|
|
1596
|
+
throw new TypeError(
|
|
1597
|
+
'Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended',
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
if (captainRuntime.sequences.turn !== turnSequence) {
|
|
1601
|
+
throw new TypeError(
|
|
1602
|
+
'Captain shell snapshot Captain and shell turn sequences must match',
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
if (mode === 'chat') {
|
|
1606
|
+
return snapshotJsonValue(
|
|
1607
|
+
{ ...common, mode },
|
|
1608
|
+
'Captain shell snapshot',
|
|
1609
|
+
) as unknown as PlaybookCaptainShellSnapshot;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
if (!Array.isArray(snapshot.frames) || snapshot.frames.length === 0) {
|
|
1613
|
+
throw new TypeError(
|
|
1614
|
+
'Captain shell snapshot.frames must be a non-empty array',
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
const normalizedFrames: PlaybookCaptainFrameSnapshot[] = [];
|
|
1618
|
+
for (const [index, value] of snapshot.frames.entries()) {
|
|
1619
|
+
const frame = snapshotRecord(
|
|
1620
|
+
value,
|
|
1621
|
+
`Captain shell snapshot.frames[${index}]`,
|
|
1622
|
+
);
|
|
1623
|
+
rejectSnapshotKeys(
|
|
1624
|
+
frame,
|
|
1625
|
+
[
|
|
1626
|
+
'playbookId',
|
|
1627
|
+
'sessionId',
|
|
1628
|
+
'rootSessionId',
|
|
1629
|
+
'depth',
|
|
1630
|
+
'parentSessionId',
|
|
1631
|
+
'parentCallId',
|
|
1632
|
+
'options',
|
|
1633
|
+
'roleBindings',
|
|
1634
|
+
'runtime',
|
|
1635
|
+
],
|
|
1636
|
+
`Captain shell snapshot.frames[${index}]`,
|
|
1637
|
+
);
|
|
1638
|
+
const playbookId = snapshotString(
|
|
1639
|
+
frame.playbookId,
|
|
1640
|
+
`Captain shell snapshot.frames[${index}].playbookId`,
|
|
1641
|
+
);
|
|
1642
|
+
const sessionId = snapshotUuid(
|
|
1643
|
+
frame.sessionId,
|
|
1644
|
+
`Captain shell snapshot.frames[${index}].sessionId`,
|
|
1645
|
+
);
|
|
1646
|
+
const rootSessionId = snapshotUuid(
|
|
1647
|
+
frame.rootSessionId,
|
|
1648
|
+
`Captain shell snapshot.frames[${index}].rootSessionId`,
|
|
1649
|
+
);
|
|
1650
|
+
const depth = snapshotInteger(
|
|
1651
|
+
frame.depth,
|
|
1652
|
+
`Captain shell snapshot.frames[${index}].depth`,
|
|
1653
|
+
);
|
|
1654
|
+
const parentSessionId =
|
|
1655
|
+
frame.parentSessionId === undefined
|
|
1656
|
+
? undefined
|
|
1657
|
+
: snapshotUuid(
|
|
1658
|
+
frame.parentSessionId,
|
|
1659
|
+
`Captain shell snapshot.frames[${index}].parentSessionId`,
|
|
1660
|
+
);
|
|
1661
|
+
const parentCallId =
|
|
1662
|
+
frame.parentCallId === undefined
|
|
1663
|
+
? undefined
|
|
1664
|
+
: snapshotString(
|
|
1665
|
+
frame.parentCallId,
|
|
1666
|
+
`Captain shell snapshot.frames[${index}].parentCallId`,
|
|
1667
|
+
);
|
|
1668
|
+
const runtime = assertPlaybookRuntimeSnapshot(
|
|
1669
|
+
frame.runtime,
|
|
1670
|
+
playbookId,
|
|
1671
|
+
{ allowSuspendedCall: true },
|
|
1672
|
+
);
|
|
1673
|
+
const options = frame.options as JsonValue;
|
|
1674
|
+
const roleBindings = snapshotFrameRoleBindings(
|
|
1675
|
+
frame.roleBindings,
|
|
1676
|
+
`Captain shell snapshot.frames[${index}].roleBindings`,
|
|
1677
|
+
);
|
|
1678
|
+
normalizedFrames.push({
|
|
1679
|
+
playbookId,
|
|
1680
|
+
sessionId,
|
|
1681
|
+
rootSessionId,
|
|
1682
|
+
depth,
|
|
1683
|
+
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
1684
|
+
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
1685
|
+
options,
|
|
1686
|
+
roleBindings,
|
|
1687
|
+
runtime,
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
let normalizedLastError:
|
|
1691
|
+
| { readonly name: string; readonly message: string }
|
|
1692
|
+
| undefined;
|
|
1693
|
+
if (snapshot.lastError !== undefined) {
|
|
1694
|
+
const error = snapshotRecord(
|
|
1695
|
+
snapshot.lastError,
|
|
1696
|
+
'Captain shell snapshot.lastError',
|
|
1697
|
+
);
|
|
1698
|
+
rejectSnapshotKeys(
|
|
1699
|
+
error,
|
|
1700
|
+
['name', 'message'],
|
|
1701
|
+
'Captain shell snapshot.lastError',
|
|
1702
|
+
);
|
|
1703
|
+
normalizedLastError = {
|
|
1704
|
+
name: snapshotString(
|
|
1705
|
+
error.name,
|
|
1706
|
+
'Captain shell snapshot.lastError.name',
|
|
1707
|
+
true,
|
|
1708
|
+
),
|
|
1709
|
+
message: snapshotString(
|
|
1710
|
+
error.message,
|
|
1711
|
+
'Captain shell snapshot.lastError.message',
|
|
1712
|
+
true,
|
|
1713
|
+
),
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
const activePlaybooks = new Set<string>();
|
|
1717
|
+
const activeSessionIds = new Set<string>([captainSessionId]);
|
|
1718
|
+
const issuedIds = new Set(issued);
|
|
1719
|
+
const rootSessionId = normalizedFrames[0]!.sessionId;
|
|
1720
|
+
for (const [index, frame] of normalizedFrames.entries()) {
|
|
1721
|
+
if (activePlaybooks.has(frame.playbookId)) {
|
|
1722
|
+
throw new TypeError(
|
|
1723
|
+
'Captain shell snapshot engagement path must not contain a playbook cycle',
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
activePlaybooks.add(frame.playbookId);
|
|
1727
|
+
if (activeSessionIds.has(frame.sessionId)) {
|
|
1728
|
+
throw new TypeError(
|
|
1729
|
+
'Captain shell snapshot frame session ids must be unique',
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
activeSessionIds.add(frame.sessionId);
|
|
1733
|
+
if (!issuedIds.has(frame.sessionId)) {
|
|
1734
|
+
throw new TypeError(
|
|
1735
|
+
'Captain shell snapshot frame session id was not historically issued',
|
|
1736
|
+
);
|
|
1737
|
+
}
|
|
1738
|
+
if (
|
|
1739
|
+
frame.depth !== index ||
|
|
1740
|
+
frame.rootSessionId !== rootSessionId ||
|
|
1741
|
+
frame.runtime.state.status !== 'active' ||
|
|
1742
|
+
!frame.runtime.state.quiescent
|
|
1743
|
+
) {
|
|
1744
|
+
throw new TypeError(
|
|
1745
|
+
'Captain shell snapshot frame depth, root, or parked runtime state is inconsistent',
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
if (index === 0) {
|
|
1749
|
+
if (
|
|
1750
|
+
frame.sessionId !== frame.rootSessionId ||
|
|
1751
|
+
frame.parentSessionId !== undefined ||
|
|
1752
|
+
frame.parentCallId !== undefined
|
|
1753
|
+
) {
|
|
1754
|
+
throw new TypeError(
|
|
1755
|
+
'Captain shell snapshot root frame has child-only identity fields',
|
|
1756
|
+
);
|
|
1757
|
+
}
|
|
1758
|
+
} else {
|
|
1759
|
+
const parent = normalizedFrames[index - 1]!;
|
|
1760
|
+
const pending = parent.runtime.suspendedCall;
|
|
1761
|
+
if (
|
|
1762
|
+
frame.parentSessionId !== parent.sessionId ||
|
|
1763
|
+
frame.parentCallId === undefined
|
|
1764
|
+
) {
|
|
1765
|
+
throw new TypeError(
|
|
1766
|
+
'Captain shell snapshot child frame does not identify its immediate parent',
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
if (
|
|
1770
|
+
!pending ||
|
|
1771
|
+
pending.callId !== frame.parentCallId ||
|
|
1772
|
+
pending.playbookId !== frame.playbookId ||
|
|
1773
|
+
pending.childSessionId !== frame.sessionId
|
|
1774
|
+
) {
|
|
1775
|
+
throw new TypeError(
|
|
1776
|
+
'Captain shell snapshot parent suspended call does not match its child edge',
|
|
1777
|
+
);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
for (const playerId of Object.values(frame.roleBindings)) {
|
|
1781
|
+
if (playerSessions[playerId] === undefined) {
|
|
1782
|
+
throw new TypeError(
|
|
1783
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} binds an absent session player`,
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
for (const question of frame.runtime.pendingBossQuestions) {
|
|
1788
|
+
if (
|
|
1789
|
+
question.asker.kind === 'role' &&
|
|
1790
|
+
frame.roleBindings[question.asker.roleId] === undefined
|
|
1791
|
+
) {
|
|
1792
|
+
throw new TypeError(
|
|
1793
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} has a pending question from an unbound role`,
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
const projectedTokens = Object.fromEntries(
|
|
1798
|
+
Object.entries(frame.roleBindings).flatMap(([role, playerId]) => {
|
|
1799
|
+
const token = playerSessions[playerId]?.resumeToken;
|
|
1800
|
+
return token === undefined ? [] : [[role, token] as const];
|
|
1801
|
+
}),
|
|
1802
|
+
);
|
|
1803
|
+
if (!isDeepStrictEqual(projectedTokens, frame.runtime.roleResumeTokens)) {
|
|
1804
|
+
throw new TypeError(
|
|
1805
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match session continuation`,
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
775
1808
|
}
|
|
776
|
-
|
|
1809
|
+
const leafRuntime = normalizedFrames.at(-1)!.runtime;
|
|
1810
|
+
if (
|
|
1811
|
+
leafRuntime.suspendedCall !== undefined ||
|
|
1812
|
+
!leafRuntime.state.tags.includes('playbook.parked')
|
|
1813
|
+
) {
|
|
1814
|
+
throw new TypeError(
|
|
1815
|
+
'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
if (
|
|
1819
|
+
!isDeepStrictEqual(
|
|
1820
|
+
snapshot.pendingBossQuestions ?? [],
|
|
1821
|
+
leafRuntime.pendingBossQuestions,
|
|
1822
|
+
)
|
|
1823
|
+
) {
|
|
1824
|
+
throw new TypeError(
|
|
1825
|
+
'Captain shell snapshot pending Boss questions must equal the leaf runtime projection',
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
return snapshotJsonValue(
|
|
1829
|
+
{
|
|
1830
|
+
...common,
|
|
1831
|
+
mode,
|
|
1832
|
+
frames: normalizedFrames,
|
|
1833
|
+
...(snapshot.pendingBossQuestions === undefined
|
|
1834
|
+
? {}
|
|
1835
|
+
: { pendingBossQuestions: snapshot.pendingBossQuestions }),
|
|
1836
|
+
...(normalizedLastError === undefined
|
|
1837
|
+
? {}
|
|
1838
|
+
: { lastError: normalizedLastError }),
|
|
1839
|
+
},
|
|
1840
|
+
'Captain shell snapshot',
|
|
1841
|
+
) as unknown as PlaybookCaptainShellSnapshot;
|
|
777
1842
|
}
|
|
778
1843
|
|
|
779
1844
|
interface BuiltRegistry {
|
|
@@ -781,14 +1846,115 @@ interface BuiltRegistry {
|
|
|
781
1846
|
byCommand: Map<string, PlaybookCaptainRegistryEntry>;
|
|
782
1847
|
byId: Map<string, PlaybookCaptainRegistryEntry>;
|
|
783
1848
|
enablementById: Map<string, Enablement>;
|
|
1849
|
+
captainAgent: SessionAgent;
|
|
1850
|
+
playerAgents: Map<string, SessionAgent>;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function snapshotTuningSelection(
|
|
1854
|
+
value: JsonValue | undefined,
|
|
1855
|
+
path: string,
|
|
1856
|
+
): TuningSelection {
|
|
1857
|
+
const selection = snapshotRecord(value, path);
|
|
1858
|
+
if (selection.kind === 'provider-default') {
|
|
1859
|
+
rejectSnapshotKeys(selection, ['kind'], path);
|
|
1860
|
+
return { kind: 'provider-default' };
|
|
1861
|
+
}
|
|
1862
|
+
if (selection.kind === 'value') {
|
|
1863
|
+
rejectSnapshotKeys(selection, ['kind', 'value'], path);
|
|
1864
|
+
return {
|
|
1865
|
+
kind: 'value',
|
|
1866
|
+
value: snapshotString(selection.value, `${path}.value`),
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
throw new TypeError(`${path}.kind must be "value" or "provider-default"`);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
const EFFORT_VALUES: ReadonlySet<string> = new Set([
|
|
1873
|
+
'on',
|
|
1874
|
+
'minimal',
|
|
1875
|
+
'low',
|
|
1876
|
+
'medium',
|
|
1877
|
+
'high',
|
|
1878
|
+
'xhigh',
|
|
1879
|
+
'max',
|
|
1880
|
+
'ultra',
|
|
1881
|
+
'ultracode',
|
|
1882
|
+
'off',
|
|
1883
|
+
]);
|
|
1884
|
+
|
|
1885
|
+
function snapshotEffortSelection(
|
|
1886
|
+
value: JsonValue | undefined,
|
|
1887
|
+
path: string,
|
|
1888
|
+
): TuningSelection<Effort> {
|
|
1889
|
+
const selection = snapshotTuningSelection(value, path);
|
|
1890
|
+
if (selection.kind === 'value' && !EFFORT_VALUES.has(selection.value)) {
|
|
1891
|
+
throw new TypeError(`${path}.value is not a supported effort selection`);
|
|
1892
|
+
}
|
|
1893
|
+
return selection as TuningSelection<Effort>;
|
|
784
1894
|
}
|
|
785
1895
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1896
|
+
function snapshotSessionAgent(
|
|
1897
|
+
value: JsonValue | undefined,
|
|
1898
|
+
path: string,
|
|
1899
|
+
): SessionAgent {
|
|
1900
|
+
const agent = snapshotRecord(value, path);
|
|
1901
|
+
rejectSnapshotKeys(
|
|
1902
|
+
agent,
|
|
1903
|
+
['adapter', 'model', 'effort', 'instruction', 'permissions'],
|
|
1904
|
+
path,
|
|
1905
|
+
);
|
|
1906
|
+
const fixed = snapshotFixedAgent(
|
|
1907
|
+
Object.fromEntries(
|
|
1908
|
+
Object.entries(agent).filter(
|
|
1909
|
+
([key]) => key !== 'model' && key !== 'effort',
|
|
1910
|
+
),
|
|
1911
|
+
) as JsonValue,
|
|
1912
|
+
path,
|
|
1913
|
+
);
|
|
1914
|
+
return {
|
|
1915
|
+
adapter: fixed.adapter,
|
|
1916
|
+
...(fixed.instruction === undefined
|
|
1917
|
+
? {}
|
|
1918
|
+
: { instruction: fixed.instruction }),
|
|
1919
|
+
...(fixed.permissions === undefined
|
|
1920
|
+
? {}
|
|
1921
|
+
: { permissions: livePermissions(fixed.permissions) }),
|
|
1922
|
+
model: snapshotTuningSelection(agent.model, `${path}.model`),
|
|
1923
|
+
effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
function fixedAgent(agent: SessionAgent): Omit<SessionAgent, 'model' | 'effort'> {
|
|
1928
|
+
return {
|
|
1929
|
+
adapter: agent.adapter,
|
|
1930
|
+
...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
|
|
1931
|
+
...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function callSettings(
|
|
1936
|
+
agent: SessionAgent,
|
|
1937
|
+
tuning: Pick<SessionAgent, 'model' | 'effort'> = agent,
|
|
1938
|
+
): AgentCallSettings {
|
|
1939
|
+
return {
|
|
1940
|
+
model: tuning.model,
|
|
1941
|
+
effort: tuning.effort,
|
|
1942
|
+
...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
|
|
1943
|
+
...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function promptIdentity(binding: EffectivePlayerBinding): string {
|
|
1948
|
+
return binding.model.kind === 'value'
|
|
1949
|
+
? binding.model.value
|
|
1950
|
+
: binding.agent.adapter;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// Resolve the active registry at init from exact normalized role and session
|
|
1954
|
+
// agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
|
|
1955
|
+
// exists at this boundary.
|
|
789
1956
|
async function buildEnablements(
|
|
790
1957
|
options: unknown,
|
|
791
|
-
players: readonly RegistryPlayer[],
|
|
792
1958
|
loadModule: (specifier: string) => Promise<unknown>,
|
|
793
1959
|
): Promise<BuiltRegistry> {
|
|
794
1960
|
const entries: PlaybookCaptainRegistryEntry[] = [];
|
|
@@ -796,10 +1962,63 @@ async function buildEnablements(
|
|
|
796
1962
|
const byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
797
1963
|
const enablementById = new Map<string, Enablement>();
|
|
798
1964
|
|
|
799
|
-
const
|
|
800
|
-
|
|
1965
|
+
const detached = snapshotJsonValue(options, 'captain.options');
|
|
1966
|
+
const top = snapshotRecord(detached, 'captain.options');
|
|
1967
|
+
rejectSnapshotKeys(
|
|
1968
|
+
top,
|
|
1969
|
+
['playbooks', 'sessionAgents', 'captainAdapter'],
|
|
1970
|
+
'captain.options',
|
|
1971
|
+
);
|
|
1972
|
+
const configValue = top.playbooks;
|
|
1973
|
+
if (
|
|
1974
|
+
typeof configValue !== 'object' ||
|
|
1975
|
+
configValue === null ||
|
|
1976
|
+
Array.isArray(configValue)
|
|
1977
|
+
) {
|
|
801
1978
|
throw new Error('captain.options.playbooks is required');
|
|
802
1979
|
}
|
|
1980
|
+
const config = configValue as Record<string, JsonValue>;
|
|
1981
|
+
|
|
1982
|
+
const sessionAgents = snapshotRecord(
|
|
1983
|
+
top.sessionAgents,
|
|
1984
|
+
'captain.options.sessionAgents',
|
|
1985
|
+
);
|
|
1986
|
+
rejectSnapshotKeys(
|
|
1987
|
+
sessionAgents,
|
|
1988
|
+
['captain', 'players'],
|
|
1989
|
+
'captain.options.sessionAgents',
|
|
1990
|
+
);
|
|
1991
|
+
const captainAgent = snapshotSessionAgent(
|
|
1992
|
+
sessionAgents.captain,
|
|
1993
|
+
'captain.options.sessionAgents.captain',
|
|
1994
|
+
);
|
|
1995
|
+
if (
|
|
1996
|
+
top.captainAdapter !== undefined &&
|
|
1997
|
+
top.captainAdapter !== captainAgent.adapter
|
|
1998
|
+
) {
|
|
1999
|
+
throw new Error(
|
|
2000
|
+
'captain.options.captainAdapter must equal sessionAgents.captain.adapter',
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
const playerAgentRecord = snapshotRecord(
|
|
2004
|
+
sessionAgents.players,
|
|
2005
|
+
'captain.options.sessionAgents.players',
|
|
2006
|
+
);
|
|
2007
|
+
const playerAgents = new Map<string, SessionAgent>();
|
|
2008
|
+
for (const [playerId, agent] of Object.entries(playerAgentRecord)) {
|
|
2009
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
2010
|
+
throw new Error(
|
|
2011
|
+
`captain.options.sessionAgents.players has invalid player id ${JSON.stringify(playerId)}`,
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
playerAgents.set(
|
|
2015
|
+
playerId,
|
|
2016
|
+
snapshotSessionAgent(
|
|
2017
|
+
agent,
|
|
2018
|
+
`captain.options.sessionAgents.players.${playerId}`,
|
|
2019
|
+
),
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
803
2022
|
|
|
804
2023
|
const ids = Object.keys(config);
|
|
805
2024
|
if (ids.length === 0) {
|
|
@@ -817,7 +2036,12 @@ async function buildEnablements(
|
|
|
817
2036
|
if (typeof block !== 'object' || block === null || Array.isArray(block)) {
|
|
818
2037
|
throw new Error(`captain.options.playbooks.${id} must be an object`);
|
|
819
2038
|
}
|
|
820
|
-
const record = block as Record<string,
|
|
2039
|
+
const record = block as Record<string, JsonValue>;
|
|
2040
|
+
rejectSnapshotKeys(
|
|
2041
|
+
record,
|
|
2042
|
+
['from', 'command', 'roles', 'options'],
|
|
2043
|
+
`captain.options.playbooks.${id}`,
|
|
2044
|
+
);
|
|
821
2045
|
const from = record.from;
|
|
822
2046
|
if (typeof from !== 'string' || from.length === 0) {
|
|
823
2047
|
throw new Error(
|
|
@@ -864,48 +2088,118 @@ async function buildEnablements(
|
|
|
864
2088
|
`captain.options.playbooks has a duplicate effective command "${command}"`,
|
|
865
2089
|
);
|
|
866
2090
|
}
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
2091
|
+
const roleRecord = snapshotRecord(
|
|
2092
|
+
record.roles,
|
|
2093
|
+
`captain.options.playbooks.${id}.roles`,
|
|
2094
|
+
);
|
|
2095
|
+
const required = new Set(entry.requiredRoleIds);
|
|
2096
|
+
const configuredRoles = Object.keys(roleRecord);
|
|
2097
|
+
const missing = entry.requiredRoleIds.filter(
|
|
2098
|
+
(role) => !Object.hasOwn(roleRecord, role),
|
|
2099
|
+
);
|
|
2100
|
+
const extra = configuredRoles.filter((role) => !required.has(role));
|
|
2101
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
2102
|
+
throw new Error(
|
|
2103
|
+
`captain.options.playbooks.${id}.roles must exactly cover requiredRoleIds`,
|
|
2104
|
+
);
|
|
2105
|
+
}
|
|
2106
|
+
const roleBindings = new Map<string, EffectivePlayerBinding>();
|
|
2107
|
+
for (const role of entry.requiredRoleIds) {
|
|
2108
|
+
const path = `captain.options.playbooks.${id}.roles.${role}`;
|
|
2109
|
+
const rawBinding = snapshotRecord(roleRecord[role], path);
|
|
2110
|
+
rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort'], path);
|
|
2111
|
+
const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
|
|
2112
|
+
if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
|
|
2113
|
+
throw new Error(`${path}.playerId is not a canonical player id`);
|
|
2114
|
+
}
|
|
2115
|
+
const agent = playerAgents.get(playerId);
|
|
2116
|
+
if (!agent) {
|
|
2117
|
+
throw new Error(
|
|
2118
|
+
`${path}.playerId names absent session player ${JSON.stringify(playerId)}`,
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
roleBindings.set(role, {
|
|
2122
|
+
playerId,
|
|
2123
|
+
model: snapshotTuningSelection(rawBinding.model, `${path}.model`),
|
|
2124
|
+
effort: snapshotEffortSelection(rawBinding.effort, `${path}.effort`),
|
|
2125
|
+
agent,
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
for (const concurrentRoles of entry.concurrentRoleSets) {
|
|
2129
|
+
const playerIds = concurrentRoles.map(
|
|
2130
|
+
(role) => roleBindings.get(role)!.playerId,
|
|
2131
|
+
);
|
|
2132
|
+
if (new Set(playerIds).size !== playerIds.length) {
|
|
2133
|
+
throw new Error(
|
|
2134
|
+
`captain.options.playbooks.${id}.roles aliases concurrent roles ${JSON.stringify(concurrentRoles)}`,
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
const validatedOptions = snapshotJsonValue(
|
|
2139
|
+
entry.validateOptions(record.options),
|
|
2140
|
+
`captain.options.playbooks.${id}.options`,
|
|
2141
|
+
);
|
|
875
2142
|
entries.push(entry);
|
|
876
2143
|
byId.set(entry.id, entry);
|
|
877
2144
|
byCommand.set(command, entry);
|
|
878
2145
|
enablementById.set(entry.id, {
|
|
879
2146
|
entry,
|
|
880
2147
|
command,
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
|
|
2148
|
+
options: validatedOptions,
|
|
2149
|
+
roleBindings,
|
|
884
2150
|
});
|
|
885
2151
|
}
|
|
886
|
-
|
|
2152
|
+
const referenced = new Set(
|
|
2153
|
+
[...enablementById.values()].flatMap((enablement) =>
|
|
2154
|
+
[...enablement.roleBindings.values()].map((binding) => binding.playerId),
|
|
2155
|
+
),
|
|
2156
|
+
);
|
|
2157
|
+
const unreferenced = [...playerAgents.keys()].find((id) => !referenced.has(id));
|
|
2158
|
+
if (unreferenced !== undefined) {
|
|
2159
|
+
throw new Error(
|
|
2160
|
+
`captain.options.sessionAgents.players has unreferenced player ${JSON.stringify(unreferenced)}`,
|
|
2161
|
+
);
|
|
2162
|
+
}
|
|
2163
|
+
return {
|
|
2164
|
+
entries,
|
|
2165
|
+
byCommand,
|
|
2166
|
+
byId,
|
|
2167
|
+
enablementById,
|
|
2168
|
+
captainAgent,
|
|
2169
|
+
playerAgents,
|
|
2170
|
+
};
|
|
887
2171
|
}
|
|
888
2172
|
|
|
889
2173
|
export function createPlaybookCaptainShell(
|
|
890
2174
|
options: unknown,
|
|
891
2175
|
deps: PlaybookCaptainDeps = {},
|
|
892
|
-
):
|
|
2176
|
+
): PlaybookCaptainShell {
|
|
893
2177
|
const loadModule =
|
|
894
2178
|
deps.loadModule ?? ((specifier: string) => import(specifier));
|
|
895
2179
|
const createSessionId = deps.createSessionId ?? randomUUID;
|
|
896
2180
|
const createCaptainRuntime: NonNullable<
|
|
897
2181
|
PlaybookCaptainDeps['createCaptainRuntime']
|
|
898
2182
|
> = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
const
|
|
2183
|
+
let captainAgent: SessionAgent | undefined;
|
|
2184
|
+
let captainAdapter: string | undefined;
|
|
2185
|
+
let playerAgents = new Map<string, SessionAgent>();
|
|
2186
|
+
const playerLedger = new Map<string, PlayerLedgerEntry>();
|
|
2187
|
+
const playerTransactions = new Map<string, PlayerTransaction>();
|
|
903
2188
|
let entries: readonly PlaybookCaptainRegistryEntry[] = [];
|
|
904
2189
|
let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
905
2190
|
let byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
906
2191
|
let enablementById = new Map<string, Enablement>();
|
|
907
2192
|
let session: CaptainSession | undefined;
|
|
908
|
-
let
|
|
2193
|
+
let sessionEmissionsOpen = false;
|
|
2194
|
+
let closedGateAttempted = false;
|
|
2195
|
+
let lifecycle:
|
|
2196
|
+
| 'fresh'
|
|
2197
|
+
| 'initializing'
|
|
2198
|
+
| 'restoring'
|
|
2199
|
+
| 'ready'
|
|
2200
|
+
| 'disposing'
|
|
2201
|
+
| 'closed' = 'fresh';
|
|
2202
|
+
let terminallyDisposed = false;
|
|
909
2203
|
let activeContext: CaptainContext | undefined;
|
|
910
2204
|
const frames: EngagementFrame[] = [];
|
|
911
2205
|
let mode: ShellMode = 'chat';
|
|
@@ -918,18 +2212,58 @@ export function createPlaybookCaptainShell(
|
|
|
918
2212
|
const captainQueue = new PQueue({ concurrency: 1 });
|
|
919
2213
|
let disposing = false;
|
|
920
2214
|
|
|
2215
|
+
const admitHostBoundary = (): void => {
|
|
2216
|
+
if (sessionEmissionsOpen) return;
|
|
2217
|
+
closedGateAttempted = true;
|
|
2218
|
+
throw new Error('Captain shell host boundaries are closed during restore');
|
|
2219
|
+
};
|
|
2220
|
+
|
|
2221
|
+
const admitHostEmission = (): boolean => {
|
|
2222
|
+
if (sessionEmissionsOpen) return true;
|
|
2223
|
+
closedGateAttempted = true;
|
|
2224
|
+
return false;
|
|
2225
|
+
};
|
|
2226
|
+
|
|
2227
|
+
const installSession = (
|
|
2228
|
+
initSession: CaptainSession,
|
|
2229
|
+
emissionsOpen: boolean,
|
|
2230
|
+
): void => {
|
|
2231
|
+
sessionEmissionsOpen = emissionsOpen;
|
|
2232
|
+
closedGateAttempted = false;
|
|
2233
|
+
session = {
|
|
2234
|
+
signal: initSession.signal,
|
|
2235
|
+
players: initSession.players,
|
|
2236
|
+
emitStatus: async (message, data) => {
|
|
2237
|
+
if (!admitHostEmission()) return;
|
|
2238
|
+
await initSession.emitStatus(message, data);
|
|
2239
|
+
},
|
|
2240
|
+
emitTelemetry: async (event) => {
|
|
2241
|
+
if (!admitHostEmission()) return;
|
|
2242
|
+
await initSession.emitTelemetry(event);
|
|
2243
|
+
},
|
|
2244
|
+
setVisiblePlayers: async (playerIds) => {
|
|
2245
|
+
if (!admitHostEmission()) return;
|
|
2246
|
+
await initSession.setVisiblePlayers(playerIds);
|
|
2247
|
+
},
|
|
2248
|
+
};
|
|
2249
|
+
};
|
|
2250
|
+
|
|
921
2251
|
// --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
|
|
922
2252
|
let captainRuntime: PlaybookRuntime | undefined;
|
|
923
2253
|
let captainSessionId: string | undefined;
|
|
924
|
-
// CAPTAIN-35:
|
|
925
|
-
//
|
|
926
|
-
//
|
|
2254
|
+
// CAPTAIN-35: a preflight settings rejection retains proven continuity but
|
|
2255
|
+
// records the exact journal suffix still owed; other continuity failures
|
|
2256
|
+
// require a fresh, full reseed.
|
|
927
2257
|
let conversation: DurableConversation = { kind: 'unopened' };
|
|
928
2258
|
let shuttingDown = false;
|
|
929
2259
|
const journal: JournalRecord[] = [];
|
|
930
2260
|
let journalSeq = 0;
|
|
931
2261
|
let turnSequence = 0;
|
|
932
2262
|
let activeTurn: ActiveTurn | undefined;
|
|
2263
|
+
// `PlayerSessionStore.restore` is authoritative only while the shell is
|
|
2264
|
+
// awaiting the exact owning runtime's restore during a closed-gate shell
|
|
2265
|
+
// restoration. A runtime cannot use the store as a general ledger writer.
|
|
2266
|
+
let restoringPlayerSessionFrame: EngagementFrame | undefined;
|
|
933
2267
|
// The durable call the runtime is about to make, taken from the paired
|
|
934
2268
|
// `captain.call.started` boundary the engine emits before the port call
|
|
935
2269
|
// (CAPTAIN-9): the shell never infers a call's kind from its prose.
|
|
@@ -1018,7 +2352,10 @@ export function createPlaybookCaptainShell(
|
|
|
1018
2352
|
// (CAPTAIN-5/CAPTAIN-6).
|
|
1019
2353
|
...(captainRuntime
|
|
1020
2354
|
? {
|
|
1021
|
-
durableConversation:
|
|
2355
|
+
durableConversation:
|
|
2356
|
+
conversation.kind === 'pinned' ||
|
|
2357
|
+
(conversation.kind === 'needsCatchUp' &&
|
|
2358
|
+
conversation.resume !== false),
|
|
1022
2359
|
sessionJournal: true,
|
|
1023
2360
|
}
|
|
1024
2361
|
: {}),
|
|
@@ -1235,43 +2572,149 @@ export function createPlaybookCaptainShell(
|
|
|
1235
2572
|
) => Promise<PlaybookCallStart>;
|
|
1236
2573
|
|
|
1237
2574
|
const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
|
|
1238
|
-
callPlayer: async (
|
|
1239
|
-
|
|
2575
|
+
callPlayer: async (roleId, prompt, signal, options) => {
|
|
2576
|
+
admitHostBoundary();
|
|
2577
|
+
if (!activeContext || !activeTurn || !frame.playerCallScope) {
|
|
1240
2578
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
1241
2579
|
}
|
|
1242
2580
|
const context = activeContext;
|
|
2581
|
+
const admittedTurn = activeTurn;
|
|
2582
|
+
const scope = frame.playerCallScope;
|
|
1243
2583
|
signal.throwIfAborted();
|
|
1244
|
-
const
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
);
|
|
1251
|
-
// CaptainContext is turn-scoped and cannot accept a narrower XState
|
|
1252
|
-
// invocation signal. Recheck after the host call so a sibling
|
|
1253
|
-
// cancellation is still reported as aborted and cannot rotate a
|
|
1254
|
-
// stopped branch's player token in the linked runtime.
|
|
1255
|
-
signal.throwIfAborted();
|
|
1256
|
-
// CAPTAIN-20: only a player call that actually produced work is an
|
|
1257
|
-
// interruption the Boss was spared. A call that errored or aborted
|
|
1258
|
-
// saved nothing, so it never feeds the saved-counts gate.
|
|
1259
|
-
const summary = activeTurnSummary;
|
|
1260
|
-
if (summary && summaryIncludes(frame) && result.status === 'ok') {
|
|
1261
|
-
summary.counts.interruptions++;
|
|
2584
|
+
const binding = bindingFor(frame, roleId);
|
|
2585
|
+
const ledger = playerLedger.get(binding.playerId);
|
|
2586
|
+
if (!ledger) {
|
|
2587
|
+
throw new Error(
|
|
2588
|
+
`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`,
|
|
2589
|
+
);
|
|
1262
2590
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
2591
|
+
const expectedResume = ledger.resumeToken ?? false;
|
|
2592
|
+
if (options.resume !== expectedResume) {
|
|
2593
|
+
throw new Error(
|
|
2594
|
+
`${frameLabel(frame)} player continuation changed before dispatch`,
|
|
2595
|
+
);
|
|
2596
|
+
}
|
|
2597
|
+
if (playerTransactions.has(binding.playerId)) {
|
|
2598
|
+
throw new Error(
|
|
2599
|
+
`session player ${JSON.stringify(binding.playerId)} already has a call in flight`,
|
|
2600
|
+
);
|
|
2601
|
+
}
|
|
2602
|
+
const settings = callSettings(binding.agent, binding);
|
|
2603
|
+
const calling: PlayerTransaction = {
|
|
2604
|
+
phase: 'calling',
|
|
2605
|
+
frame,
|
|
2606
|
+
roleId,
|
|
2607
|
+
turnId: admittedTurn.id,
|
|
2608
|
+
signal,
|
|
2609
|
+
scope,
|
|
2610
|
+
abandoned: false,
|
|
1272
2611
|
};
|
|
2612
|
+
playerTransactions.set(binding.playerId, calling);
|
|
2613
|
+
let result: PlayerResult;
|
|
2614
|
+
let hostResolved = false;
|
|
2615
|
+
try {
|
|
2616
|
+
let rawResult: unknown;
|
|
2617
|
+
try {
|
|
2618
|
+
rawResult = await trackHostCall(
|
|
2619
|
+
frame,
|
|
2620
|
+
classifySettingsCall(() =>
|
|
2621
|
+
context.callPlayer(binding.playerId, prompt, {
|
|
2622
|
+
resume: options.resume,
|
|
2623
|
+
settings,
|
|
2624
|
+
}),
|
|
2625
|
+
),
|
|
2626
|
+
);
|
|
2627
|
+
hostResolved = true;
|
|
2628
|
+
} catch (error) {
|
|
2629
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
2630
|
+
if (
|
|
2631
|
+
playerTransactions.get(binding.playerId) !== calling ||
|
|
2632
|
+
calling.abandoned ||
|
|
2633
|
+
signal.aborted ||
|
|
2634
|
+
activeTurn !== admittedTurn ||
|
|
2635
|
+
frame.playerCallScope !== scope ||
|
|
2636
|
+
!frames.includes(frame)
|
|
2637
|
+
) {
|
|
2638
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
2639
|
+
playerTransactions.delete(binding.playerId);
|
|
2640
|
+
}
|
|
2641
|
+
signal.throwIfAborted();
|
|
2642
|
+
throw new Error(
|
|
2643
|
+
`${frameLabel(frame)} player settings rejection arrived after its runtime operation ended`,
|
|
2644
|
+
);
|
|
2645
|
+
}
|
|
2646
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
2647
|
+
}
|
|
2648
|
+
throw error;
|
|
2649
|
+
}
|
|
2650
|
+
result = normalizeHostPlayerResult(rawResult, binding.playerId);
|
|
2651
|
+
const transitionRequired =
|
|
2652
|
+
result.resumeToken !== undefined || result.status === 'ok';
|
|
2653
|
+
if (
|
|
2654
|
+
playerTransactions.get(binding.playerId) !== calling ||
|
|
2655
|
+
calling.abandoned ||
|
|
2656
|
+
signal.aborted ||
|
|
2657
|
+
activeTurn !== admittedTurn ||
|
|
2658
|
+
frame.playerCallScope !== scope ||
|
|
2659
|
+
!frames.includes(frame)
|
|
2660
|
+
) {
|
|
2661
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
2662
|
+
if (transitionRequired) {
|
|
2663
|
+
playerTransactions.set(binding.playerId, {
|
|
2664
|
+
phase: 'quarantined',
|
|
2665
|
+
frame,
|
|
2666
|
+
roleId,
|
|
2667
|
+
turnId: admittedTurn.id,
|
|
2668
|
+
signal,
|
|
2669
|
+
scope,
|
|
2670
|
+
reason:
|
|
2671
|
+
'a transition-worthy result arrived after its runtime operation ended',
|
|
2672
|
+
});
|
|
2673
|
+
} else {
|
|
2674
|
+
playerTransactions.delete(binding.playerId);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
signal.throwIfAborted();
|
|
2678
|
+
throw new Error(
|
|
2679
|
+
`${frameLabel(frame)} player result arrived after its runtime operation ended`,
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
if (transitionRequired) {
|
|
2683
|
+
playerTransactions.set(binding.playerId, {
|
|
2684
|
+
phase: 'awaitingCommit',
|
|
2685
|
+
frame,
|
|
2686
|
+
roleId,
|
|
2687
|
+
turnId: admittedTurn.id,
|
|
2688
|
+
signal,
|
|
2689
|
+
scope,
|
|
2690
|
+
status: result.status,
|
|
2691
|
+
expectedToken: result.resumeToken,
|
|
2692
|
+
});
|
|
2693
|
+
} else {
|
|
2694
|
+
playerTransactions.delete(binding.playerId);
|
|
2695
|
+
}
|
|
2696
|
+
} catch (error) {
|
|
2697
|
+
if (playerTransactions.get(binding.playerId) === calling) {
|
|
2698
|
+
if (hostResolved) {
|
|
2699
|
+
playerTransactions.set(binding.playerId, {
|
|
2700
|
+
phase: 'quarantined',
|
|
2701
|
+
frame,
|
|
2702
|
+
roleId,
|
|
2703
|
+
turnId: admittedTurn.id,
|
|
2704
|
+
signal,
|
|
2705
|
+
scope,
|
|
2706
|
+
reason: 'a late player result could not be validated',
|
|
2707
|
+
});
|
|
2708
|
+
} else {
|
|
2709
|
+
playerTransactions.delete(binding.playerId);
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
throw error;
|
|
2713
|
+
}
|
|
2714
|
+
return result;
|
|
1273
2715
|
},
|
|
1274
2716
|
callCaptain: async (prompt, signal, options) => {
|
|
2717
|
+
admitHostBoundary();
|
|
1275
2718
|
if (!activeContext) {
|
|
1276
2719
|
throw new Error('callCaptain invoked outside a Boss turn');
|
|
1277
2720
|
}
|
|
@@ -1295,6 +2738,7 @@ export function createPlaybookCaptainShell(
|
|
|
1295
2738
|
};
|
|
1296
2739
|
},
|
|
1297
2740
|
callJudge: async (prompt, signal) => {
|
|
2741
|
+
admitHostBoundary();
|
|
1298
2742
|
if (!activeContext) {
|
|
1299
2743
|
throw new Error('callJudge invoked outside a Boss turn');
|
|
1300
2744
|
}
|
|
@@ -1330,6 +2774,7 @@ export function createPlaybookCaptainShell(
|
|
|
1330
2774
|
return result.finalText;
|
|
1331
2775
|
},
|
|
1332
2776
|
callPlaybook: (request, signal) => {
|
|
2777
|
+
admitHostBoundary();
|
|
1333
2778
|
const opening = callNestedPlaybook(frame, request, signal);
|
|
1334
2779
|
let exposed!: Promise<PlaybookCallStart>;
|
|
1335
2780
|
const registerOpeningCleanup = (): void => {
|
|
@@ -1342,29 +2787,41 @@ export function createPlaybookCaptainShell(
|
|
|
1342
2787
|
if (signal.aborted) registerOpeningCleanup();
|
|
1343
2788
|
return exposed;
|
|
1344
2789
|
},
|
|
1345
|
-
emitStatus:
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
2790
|
+
emitStatus: (message, data) => {
|
|
2791
|
+
if (!admitHostEmission()) return Promise.resolve();
|
|
2792
|
+
return trackHostCall(
|
|
2793
|
+
frame,
|
|
2794
|
+
(async (): Promise<void> => {
|
|
2795
|
+
await requireSession().emitStatus(
|
|
2796
|
+
message,
|
|
2797
|
+
data as Record<string, unknown> | undefined,
|
|
2798
|
+
);
|
|
2799
|
+
})(),
|
|
1349
2800
|
);
|
|
1350
2801
|
},
|
|
1351
|
-
emitTelemetry:
|
|
1352
|
-
if (
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
2802
|
+
emitTelemetry: (event) => {
|
|
2803
|
+
if (!admitHostEmission()) return Promise.resolve();
|
|
2804
|
+
const emission = (async (): Promise<void> => {
|
|
2805
|
+
if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
|
|
2806
|
+
await mirrorSubRuntimeTelemetry(frame, event.payload);
|
|
2807
|
+
}
|
|
2808
|
+
await requireSession().emitTelemetry(event);
|
|
2809
|
+
})();
|
|
2810
|
+
return trackHostCall(frame, emission);
|
|
1356
2811
|
},
|
|
1357
2812
|
});
|
|
1358
2813
|
|
|
1359
2814
|
// CAPTAIN-22: before dispatching to a playbook, request tmux-play
|
|
1360
|
-
// visibility for that playbook's
|
|
2815
|
+
// visibility for that playbook's explicitly bound session players. A pane
|
|
1361
2816
|
// reconciliation failure is display-only in tmux-play and does not
|
|
1362
2817
|
// reject; the legacy path carries no generated set and skips this.
|
|
1363
2818
|
const requestVisibility = async (frame: EngagementFrame): Promise<void> => {
|
|
1364
2819
|
const ids = [...new Set(
|
|
1365
|
-
[...frame.playerBindings.values()].map(({
|
|
2820
|
+
[...frame.playerBindings.values()].map(({ playerId }) => playerId),
|
|
1366
2821
|
)];
|
|
1367
|
-
|
|
2822
|
+
// A roleless frame does not ask a non-empty host roster to show `[]`:
|
|
2823
|
+
// tmux-play reserves that value for a genuinely empty configured roster.
|
|
2824
|
+
if (ids.length === 0 || !activeContext) return;
|
|
1368
2825
|
try {
|
|
1369
2826
|
await activeContext.setVisiblePlayers(ids);
|
|
1370
2827
|
} catch (error) {
|
|
@@ -1402,44 +2859,20 @@ export function createPlaybookCaptainShell(
|
|
|
1402
2859
|
return typeof stack === 'string' ? { ...compact, stack } : compact;
|
|
1403
2860
|
};
|
|
1404
2861
|
|
|
2862
|
+
const makePlayerBindings = (
|
|
2863
|
+
enablement: Enablement,
|
|
2864
|
+
): ReadonlyMap<string, EffectivePlayerBinding> => {
|
|
2865
|
+
return new Map(enablement.roleBindings);
|
|
2866
|
+
};
|
|
2867
|
+
|
|
1405
2868
|
const makeFrame = (
|
|
1406
2869
|
enablement: Enablement,
|
|
1407
2870
|
parent?: { frame: EngagementFrame; callId: string },
|
|
1408
2871
|
): EngagementFrame => {
|
|
1409
2872
|
const entry = enablement.entry;
|
|
1410
2873
|
const sessionId = allocateSessionId();
|
|
1411
|
-
const playerBindings =
|
|
1412
|
-
|
|
1413
|
-
let inherited: EffectivePlayerBinding | undefined;
|
|
1414
|
-
for (
|
|
1415
|
-
let ancestor = parent?.frame;
|
|
1416
|
-
ancestor && inherited === undefined;
|
|
1417
|
-
ancestor = ancestor.parent?.frame
|
|
1418
|
-
) {
|
|
1419
|
-
inherited = ancestor.playerBindings.get(role);
|
|
1420
|
-
}
|
|
1421
|
-
if (inherited) {
|
|
1422
|
-
playerBindings.set(role, inherited);
|
|
1423
|
-
continue;
|
|
1424
|
-
}
|
|
1425
|
-
const configured = enablement.boundPlayers.find(
|
|
1426
|
-
(player) => player.id === role,
|
|
1427
|
-
) ?? { id: role };
|
|
1428
|
-
playerBindings.set(role, {
|
|
1429
|
-
hostPlayerId: enablement.hostPlayerId(role),
|
|
1430
|
-
player: configured,
|
|
1431
|
-
});
|
|
1432
|
-
}
|
|
1433
|
-
const playerResumeTokens =
|
|
1434
|
-
parent?.frame.playerResumeTokens ?? new Map<string, string>();
|
|
1435
|
-
const runtime = entry.createRuntime({
|
|
1436
|
-
captainOptions: enablement.optionInput,
|
|
1437
|
-
players: [...playerBindings].map(([role, { player }]) => ({
|
|
1438
|
-
id: role,
|
|
1439
|
-
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
1440
|
-
...(player.model === undefined ? {} : { model: player.model }),
|
|
1441
|
-
})),
|
|
1442
|
-
});
|
|
2874
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
2875
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
1443
2876
|
return {
|
|
1444
2877
|
entry,
|
|
1445
2878
|
enablement,
|
|
@@ -1448,46 +2881,190 @@ export function createPlaybookCaptainShell(
|
|
|
1448
2881
|
rootSessionId: parent?.frame.rootSessionId ?? sessionId,
|
|
1449
2882
|
depth: parent ? parent.frame.depth + 1 : 0,
|
|
1450
2883
|
playerBindings,
|
|
1451
|
-
playerResumeTokens,
|
|
1452
2884
|
...(parent ? { parent } : {}),
|
|
1453
2885
|
inFlightHostCalls: new Set(),
|
|
1454
2886
|
};
|
|
1455
2887
|
};
|
|
1456
2888
|
|
|
2889
|
+
const makeRestoredFrame = (
|
|
2890
|
+
enablement: Enablement,
|
|
2891
|
+
snapshot: PlaybookCaptainFrameSnapshot,
|
|
2892
|
+
parent?: { frame: EngagementFrame; callId: string },
|
|
2893
|
+
): EngagementFrame => {
|
|
2894
|
+
const entry = enablement.entry;
|
|
2895
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
2896
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
2897
|
+
return {
|
|
2898
|
+
entry,
|
|
2899
|
+
enablement,
|
|
2900
|
+
runtime,
|
|
2901
|
+
sessionId: snapshot.sessionId,
|
|
2902
|
+
rootSessionId: snapshot.rootSessionId,
|
|
2903
|
+
depth: snapshot.depth,
|
|
2904
|
+
playerBindings,
|
|
2905
|
+
...(parent ? { parent } : {}),
|
|
2906
|
+
state: snapshot.runtime.state,
|
|
2907
|
+
inFlightHostCalls: new Set(),
|
|
2908
|
+
};
|
|
2909
|
+
};
|
|
2910
|
+
|
|
1457
2911
|
const playerSessionStore = (frame: EngagementFrame): PlayerSessionStore => ({
|
|
1458
2912
|
select(playerId) {
|
|
1459
2913
|
const binding = bindingFor(frame, playerId);
|
|
1460
|
-
return
|
|
2914
|
+
return playerLedger.get(binding.playerId)?.resumeToken ?? false;
|
|
1461
2915
|
},
|
|
1462
2916
|
update(playerId, resumeToken) {
|
|
1463
2917
|
const binding = bindingFor(frame, playerId);
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2918
|
+
const ledger = playerLedger.get(binding.playerId);
|
|
2919
|
+
if (!ledger) {
|
|
2920
|
+
throw new Error(
|
|
2921
|
+
`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`,
|
|
2922
|
+
);
|
|
2923
|
+
}
|
|
2924
|
+
const pending = playerTransactions.get(binding.playerId);
|
|
2925
|
+
if (
|
|
2926
|
+
pending?.phase !== 'awaitingCommit' ||
|
|
2927
|
+
pending.frame !== frame ||
|
|
2928
|
+
pending.roleId !== playerId ||
|
|
2929
|
+
pending.scope !== frame.playerCallScope ||
|
|
2930
|
+
pending.expectedToken !== resumeToken
|
|
2931
|
+
) {
|
|
2932
|
+
throw new Error(
|
|
2933
|
+
`${frameLabel(frame)} player update does not acknowledge a validated host result`,
|
|
2934
|
+
);
|
|
2935
|
+
}
|
|
2936
|
+
if (pending.signal.aborted || activeTurn?.id !== pending.turnId) {
|
|
2937
|
+
playerTransactions.set(binding.playerId, {
|
|
2938
|
+
phase: 'quarantined',
|
|
2939
|
+
frame: pending.frame,
|
|
2940
|
+
roleId: pending.roleId,
|
|
2941
|
+
turnId: pending.turnId,
|
|
2942
|
+
signal: pending.signal,
|
|
2943
|
+
scope: pending.scope,
|
|
2944
|
+
reason: 'the runtime aborted before committing a validated result',
|
|
2945
|
+
});
|
|
2946
|
+
throw new Error(
|
|
2947
|
+
`${frameLabel(frame)} rejected a late or aborted player continuation update`,
|
|
2948
|
+
);
|
|
2949
|
+
}
|
|
2950
|
+
try {
|
|
2951
|
+
if (resumeToken === undefined) delete ledger.resumeToken;
|
|
2952
|
+
else ledger.resumeToken = resumeToken;
|
|
2953
|
+
// CAPTAIN-20: a result counts only after the runtime validated it and
|
|
2954
|
+
// atomically published its authorized continuation transition.
|
|
2955
|
+
const summary = activeTurnSummary;
|
|
2956
|
+
if (
|
|
2957
|
+
pending.status === 'ok' &&
|
|
2958
|
+
summary &&
|
|
2959
|
+
summaryIncludes(frame)
|
|
2960
|
+
) {
|
|
2961
|
+
summary.counts.interruptions++;
|
|
2962
|
+
}
|
|
2963
|
+
} finally {
|
|
2964
|
+
playerTransactions.delete(binding.playerId);
|
|
1468
2965
|
}
|
|
1469
2966
|
},
|
|
1470
2967
|
snapshot() {
|
|
1471
2968
|
const tokens: Record<string, string> = {};
|
|
1472
2969
|
for (const [playerId, binding] of frame.playerBindings) {
|
|
1473
|
-
const token =
|
|
2970
|
+
const token = playerLedger.get(binding.playerId)?.resumeToken;
|
|
1474
2971
|
if (token !== undefined) tokens[playerId] = token;
|
|
1475
2972
|
}
|
|
1476
2973
|
return tokens;
|
|
1477
2974
|
},
|
|
1478
2975
|
restore(tokens) {
|
|
1479
|
-
|
|
1480
|
-
|
|
2976
|
+
if (
|
|
2977
|
+
lifecycle !== 'restoring' ||
|
|
2978
|
+
restoringPlayerSessionFrame !== frame
|
|
2979
|
+
) {
|
|
2980
|
+
throw new Error(
|
|
2981
|
+
`${frameLabel(frame)} player-session restore is only available during shell restoration`,
|
|
2982
|
+
);
|
|
1481
2983
|
}
|
|
2984
|
+
const byPlayer = new Map<string, string | undefined>();
|
|
1482
2985
|
for (const [playerId, token] of Object.entries(tokens)) {
|
|
1483
2986
|
const binding = bindingFor(frame, playerId);
|
|
1484
|
-
|
|
2987
|
+
const previous = byPlayer.get(binding.playerId);
|
|
2988
|
+
if (previous !== undefined && previous !== token) {
|
|
2989
|
+
throw new Error(
|
|
2990
|
+
`${frameLabel(frame)} restored conflicting tokens for shared player ${JSON.stringify(binding.playerId)}`,
|
|
2991
|
+
);
|
|
2992
|
+
}
|
|
2993
|
+
byPlayer.set(binding.playerId, token);
|
|
2994
|
+
}
|
|
2995
|
+
for (const binding of frame.playerBindings.values()) {
|
|
2996
|
+
if (!byPlayer.has(binding.playerId)) byPlayer.set(binding.playerId, undefined);
|
|
2997
|
+
}
|
|
2998
|
+
for (const [playerId, token] of byPlayer) {
|
|
2999
|
+
const ledger = playerLedger.get(playerId);
|
|
3000
|
+
if (!ledger) {
|
|
3001
|
+
throw new Error(
|
|
3002
|
+
`${frameLabel(frame)} restored absent session player ${JSON.stringify(playerId)}`,
|
|
3003
|
+
);
|
|
3004
|
+
}
|
|
3005
|
+
if (token === undefined) delete ledger.resumeToken;
|
|
3006
|
+
else ledger.resumeToken = token;
|
|
1485
3007
|
}
|
|
1486
3008
|
},
|
|
1487
3009
|
});
|
|
1488
3010
|
|
|
1489
|
-
const
|
|
1490
|
-
|
|
3011
|
+
const closePlayerCallScope = (
|
|
3012
|
+
frame: EngagementFrame,
|
|
3013
|
+
scope: object,
|
|
3014
|
+
): Error | undefined => {
|
|
3015
|
+
if (frame.playerCallScope === scope) frame.playerCallScope = undefined;
|
|
3016
|
+
const missing: string[] = [];
|
|
3017
|
+
for (const [playerId, transaction] of playerTransactions) {
|
|
3018
|
+
if (transaction.frame !== frame || transaction.scope !== scope) continue;
|
|
3019
|
+
if (transaction.phase === 'calling') {
|
|
3020
|
+
transaction.abandoned = true;
|
|
3021
|
+
} else {
|
|
3022
|
+
playerTransactions.set(playerId, {
|
|
3023
|
+
phase: 'quarantined',
|
|
3024
|
+
frame: transaction.frame,
|
|
3025
|
+
roleId: transaction.roleId,
|
|
3026
|
+
turnId: transaction.turnId,
|
|
3027
|
+
signal: transaction.signal,
|
|
3028
|
+
scope: transaction.scope,
|
|
3029
|
+
reason:
|
|
3030
|
+
transaction.phase === 'awaitingCommit'
|
|
3031
|
+
? 'the runtime returned without committing a validated result'
|
|
3032
|
+
: transaction.reason,
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
if (!transaction.signal.aborted) missing.push(playerId);
|
|
3036
|
+
}
|
|
3037
|
+
return missing.length === 0
|
|
3038
|
+
? undefined
|
|
3039
|
+
: new Error(
|
|
3040
|
+
`${frameLabel(frame)} runtime returned without committing validated player result for ${missing.map((id) => JSON.stringify(id)).join(', ')}`,
|
|
3041
|
+
);
|
|
3042
|
+
};
|
|
3043
|
+
|
|
3044
|
+
const runFrameOperation = async <T>(
|
|
3045
|
+
frame: EngagementFrame,
|
|
3046
|
+
operation: () => Promise<T>,
|
|
3047
|
+
): Promise<T> => {
|
|
3048
|
+
if (frame.playerCallScope !== undefined) {
|
|
3049
|
+
throw new Error(`${frameLabel(frame)} runtime operations must not overlap`);
|
|
3050
|
+
}
|
|
3051
|
+
const scope = {};
|
|
3052
|
+
frame.playerCallScope = scope;
|
|
3053
|
+
let outcome:
|
|
3054
|
+
| { readonly ok: true; readonly value: T }
|
|
3055
|
+
| { readonly ok: false; readonly error: unknown };
|
|
3056
|
+
try {
|
|
3057
|
+
outcome = { ok: true, value: await operation() };
|
|
3058
|
+
} catch (error) {
|
|
3059
|
+
outcome = { ok: false, error };
|
|
3060
|
+
}
|
|
3061
|
+
const cleanupError = closePlayerCallScope(frame, scope);
|
|
3062
|
+
if (!outcome.ok) throw outcome.error;
|
|
3063
|
+
if (cleanupError !== undefined) throw cleanupError;
|
|
3064
|
+
return outcome.value;
|
|
3065
|
+
};
|
|
3066
|
+
|
|
3067
|
+
const frameSession = (frame: EngagementFrame) => ({
|
|
1491
3068
|
sessionId: frame.sessionId,
|
|
1492
3069
|
playbookId: frame.entry.id,
|
|
1493
3070
|
rootSessionId: frame.rootSessionId,
|
|
@@ -1498,9 +3075,21 @@ export function createPlaybookCaptainShell(
|
|
|
1498
3075
|
}
|
|
1499
3076
|
: {}),
|
|
1500
3077
|
depth: frame.depth,
|
|
3078
|
+
roleBindings: Object.fromEntries(
|
|
3079
|
+
[...frame.playerBindings].map(([roleId, binding]) => [
|
|
3080
|
+
roleId,
|
|
3081
|
+
{
|
|
3082
|
+
playerId: binding.playerId,
|
|
3083
|
+
promptIdentity: promptIdentity(binding),
|
|
3084
|
+
},
|
|
3085
|
+
]),
|
|
3086
|
+
),
|
|
1501
3087
|
playerSessions: playerSessionStore(frame),
|
|
1502
3088
|
ports: createPorts(frame),
|
|
1503
3089
|
});
|
|
3090
|
+
|
|
3091
|
+
const initFrame = async (frame: EngagementFrame): Promise<void> => {
|
|
3092
|
+
await frame.runtime.init(frameSession(frame));
|
|
1504
3093
|
};
|
|
1505
3094
|
|
|
1506
3095
|
const clearLeafLedger = (): void => {
|
|
@@ -1818,8 +3407,10 @@ export function createPlaybookCaptainShell(
|
|
|
1818
3407
|
// exception filed against an effect that never ran.
|
|
1819
3408
|
await requestVisibility(frame);
|
|
1820
3409
|
await setMode('engaged.driving', 'submit');
|
|
1821
|
-
const result = await
|
|
1822
|
-
|
|
3410
|
+
const result = await runFrameOperation(frame, () =>
|
|
3411
|
+
runEffect(() =>
|
|
3412
|
+
frame.runtime.handleBossInput({ text, signal }),
|
|
3413
|
+
),
|
|
1823
3414
|
);
|
|
1824
3415
|
frame.state = result.state;
|
|
1825
3416
|
return result;
|
|
@@ -1873,12 +3464,14 @@ export function createPlaybookCaptainShell(
|
|
|
1873
3464
|
}
|
|
1874
3465
|
let result: PlaybookRunResult;
|
|
1875
3466
|
try {
|
|
1876
|
-
result = await
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
3467
|
+
result = await runFrameOperation(parent, () =>
|
|
3468
|
+
runEffect(() =>
|
|
3469
|
+
parent.runtime.resumePlaybookCall({
|
|
3470
|
+
callId: parentLink.callId,
|
|
3471
|
+
result: effectiveResult,
|
|
3472
|
+
signal: context.signal,
|
|
3473
|
+
}),
|
|
3474
|
+
),
|
|
1882
3475
|
);
|
|
1883
3476
|
} catch (error) {
|
|
1884
3477
|
if (disposing || invocationSignal?.aborted) return;
|
|
@@ -1920,6 +3513,12 @@ export function createPlaybookCaptainShell(
|
|
|
1920
3513
|
if (frame.parent) {
|
|
1921
3514
|
await resumeParent(frame, callResultFor(frame, result), context);
|
|
1922
3515
|
} else {
|
|
3516
|
+
// CAPTAIN-20: the root is still alive here, so this is the one
|
|
3517
|
+
// authoritative boundary that can retain the Boss-facing meaning its
|
|
3518
|
+
// runtime publishes before disposal removes the frame. The opaque run
|
|
3519
|
+
// output remains runtime-to-runtime data and never becomes Captain
|
|
3520
|
+
// evidence (CAPPLAY-10).
|
|
3521
|
+
activeTurn?.settlementFacts.push(rootCompletionFact(frame));
|
|
1923
3522
|
await runEffect(() => disposeStack('final'));
|
|
1924
3523
|
}
|
|
1925
3524
|
return;
|
|
@@ -2296,7 +3895,9 @@ export function createPlaybookCaptainShell(
|
|
|
2296
3895
|
const pending = view.pendingQuestions.map(
|
|
2297
3896
|
(question) =>
|
|
2298
3897
|
digestLine`- (${quoteEvidence(question.questionId)}) ${quoteEvidence(
|
|
2299
|
-
question.
|
|
3898
|
+
question.asker.kind === 'captain'
|
|
3899
|
+
? 'Captain'
|
|
3900
|
+
: question.asker.roleId,
|
|
2300
3901
|
)} asks: ${quoteEvidence(question.question)}`,
|
|
2301
3902
|
);
|
|
2302
3903
|
lines.push(
|
|
@@ -2420,6 +4021,7 @@ export function createPlaybookCaptainShell(
|
|
|
2420
4021
|
try {
|
|
2421
4022
|
await trackTurnCall(settlement.context.emitReply(settlement.text));
|
|
2422
4023
|
} catch (error) {
|
|
4024
|
+
conversation = { kind: 'needsSeeding' };
|
|
2423
4025
|
const normalized = normalizeErrorCompact(error) ?? {
|
|
2424
4026
|
name: 'Error',
|
|
2425
4027
|
message: String(error),
|
|
@@ -2526,8 +4128,11 @@ export function createPlaybookCaptainShell(
|
|
|
2526
4128
|
* text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
|
|
2527
4129
|
* the Captain may need to repeat.
|
|
2528
4130
|
*/
|
|
2529
|
-
const
|
|
2530
|
-
|
|
4131
|
+
const conversationDigest = (
|
|
4132
|
+
records: readonly JournalRecord[],
|
|
4133
|
+
render: (records: readonly JournalRecord[]) => string,
|
|
4134
|
+
): string => {
|
|
4135
|
+
for (const record of records) {
|
|
2531
4136
|
if (
|
|
2532
4137
|
record.kind === 'action' &&
|
|
2533
4138
|
typeof record.payload === 'object' &&
|
|
@@ -2538,14 +4143,47 @@ export function createPlaybookCaptainShell(
|
|
|
2538
4143
|
if (typeof actionId === 'string') recordSuppliedIdentifier(actionId);
|
|
2539
4144
|
}
|
|
2540
4145
|
}
|
|
2541
|
-
return
|
|
4146
|
+
return render(records);
|
|
2542
4147
|
};
|
|
2543
4148
|
|
|
4149
|
+
const reseedDigest = (): string =>
|
|
4150
|
+
conversationDigest(journal, renderReseedDigest);
|
|
4151
|
+
|
|
4152
|
+
const catchUpDigest = (afterJournalSeq: number): string =>
|
|
4153
|
+
conversationDigest(
|
|
4154
|
+
journal.filter((record) => record.seq > afterJournalSeq),
|
|
4155
|
+
renderCatchUpDigest,
|
|
4156
|
+
);
|
|
4157
|
+
|
|
2544
4158
|
const markControlFailure = <E>(error: E): E => {
|
|
2545
|
-
|
|
4159
|
+
activeTurn?.controlFailures.add(error);
|
|
2546
4160
|
return error;
|
|
2547
4161
|
};
|
|
2548
4162
|
|
|
4163
|
+
const markSettingsRejection = <E>(error: E): E => markControlFailure(error);
|
|
4164
|
+
|
|
4165
|
+
const rememberSettingsPreflight = <E>(error: E): E => {
|
|
4166
|
+
activeTurn?.settingsPreflightFailures.add(error);
|
|
4167
|
+
return markSettingsRejection(error);
|
|
4168
|
+
};
|
|
4169
|
+
|
|
4170
|
+
const markConversationCatchUp = (): void => {
|
|
4171
|
+
if (conversation.kind === 'needsSeeding' || conversation.kind === 'needsCatchUp') {
|
|
4172
|
+
return;
|
|
4173
|
+
}
|
|
4174
|
+
conversation = {
|
|
4175
|
+
kind: 'needsCatchUp',
|
|
4176
|
+
resume: conversation.kind === 'pinned' ? conversation.token : false,
|
|
4177
|
+
afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
|
|
4178
|
+
};
|
|
4179
|
+
};
|
|
4180
|
+
|
|
4181
|
+
const markConversationUnsynchronized = (): void => {
|
|
4182
|
+
if (conversation.kind !== 'needsCatchUp') {
|
|
4183
|
+
conversation = { kind: 'needsSeeding' };
|
|
4184
|
+
}
|
|
4185
|
+
};
|
|
4186
|
+
|
|
2549
4187
|
/**
|
|
2550
4188
|
* CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
|
|
2551
4189
|
* engagement constructed, a stack disposed, an advertised action applied.
|
|
@@ -2600,6 +4238,7 @@ export function createPlaybookCaptainShell(
|
|
|
2600
4238
|
context: CaptainContext,
|
|
2601
4239
|
prompt: string,
|
|
2602
4240
|
resume: string | false,
|
|
4241
|
+
attempt: { providerBoundaryEntered: boolean },
|
|
2603
4242
|
): Promise<{
|
|
2604
4243
|
status: string;
|
|
2605
4244
|
finalText?: string;
|
|
@@ -2608,11 +4247,15 @@ export function createPlaybookCaptainShell(
|
|
|
2608
4247
|
}> => {
|
|
2609
4248
|
const queued = captainQueue.add(async () => {
|
|
2610
4249
|
context.signal.throwIfAborted();
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
4250
|
+
attempt.providerBoundaryEntered = true;
|
|
4251
|
+
const result = await classifySettingsCall(() =>
|
|
4252
|
+
context.callCaptain(prompt, {
|
|
4253
|
+
visibility: 'hidden',
|
|
4254
|
+
resume,
|
|
4255
|
+
...controlCallToolOptions(captainAdapter),
|
|
4256
|
+
settings: callSettings(captainAgent!),
|
|
4257
|
+
}),
|
|
4258
|
+
);
|
|
2616
4259
|
context.signal.throwIfAborted();
|
|
2617
4260
|
return result;
|
|
2618
4261
|
});
|
|
@@ -2633,8 +4276,17 @@ export function createPlaybookCaptainShell(
|
|
|
2633
4276
|
context: CaptainContext,
|
|
2634
4277
|
compose: (options: { reseedDigest?: string }) => string,
|
|
2635
4278
|
): Promise<DurableCallOutcome> => {
|
|
2636
|
-
const
|
|
4279
|
+
const startingConversation = conversation;
|
|
4280
|
+
const resume =
|
|
4281
|
+
startingConversation.kind === 'pinned'
|
|
4282
|
+
? startingConversation.token
|
|
4283
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
4284
|
+
? startingConversation.resume
|
|
4285
|
+
: false;
|
|
2637
4286
|
const seedFirstCall = conversation.kind === 'needsSeeding';
|
|
4287
|
+
const catchUpFirstCall = conversation.kind === 'needsCatchUp';
|
|
4288
|
+
const representedJournalSeq = journalSeq;
|
|
4289
|
+
const firstAttempt = { providerBoundaryEntered: false };
|
|
2638
4290
|
let result:
|
|
2639
4291
|
| { status: string; finalText?: string; resumeToken?: string; error?: string }
|
|
2640
4292
|
| undefined;
|
|
@@ -2643,14 +4295,42 @@ export function createPlaybookCaptainShell(
|
|
|
2643
4295
|
result = await rawDurableCall(
|
|
2644
4296
|
context,
|
|
2645
4297
|
compose(
|
|
2646
|
-
seedFirstCall
|
|
4298
|
+
seedFirstCall
|
|
4299
|
+
? { reseedDigest: reseedDigest() }
|
|
4300
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
4301
|
+
? {
|
|
4302
|
+
reseedDigest: catchUpDigest(
|
|
4303
|
+
startingConversation.afterJournalSeq,
|
|
4304
|
+
),
|
|
4305
|
+
}
|
|
4306
|
+
: {},
|
|
2647
4307
|
),
|
|
2648
4308
|
resume,
|
|
4309
|
+
firstAttempt,
|
|
2649
4310
|
);
|
|
2650
4311
|
} catch (error) {
|
|
2651
4312
|
if (context.signal.aborted) {
|
|
2652
|
-
|
|
2653
|
-
|
|
4313
|
+
if (firstAttempt.providerBoundaryEntered) {
|
|
4314
|
+
conversation = { kind: 'needsSeeding' };
|
|
4315
|
+
} else {
|
|
4316
|
+
markConversationUnsynchronized();
|
|
4317
|
+
}
|
|
4318
|
+
throw context.signal.reason ?? error;
|
|
4319
|
+
}
|
|
4320
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
4321
|
+
if (
|
|
4322
|
+
conversation.kind !== 'needsCatchUp' &&
|
|
4323
|
+
conversation.kind !== 'needsSeeding'
|
|
4324
|
+
) {
|
|
4325
|
+
conversation = {
|
|
4326
|
+
kind: 'needsCatchUp',
|
|
4327
|
+
resume:
|
|
4328
|
+
conversation.kind === 'pinned' ? conversation.token : false,
|
|
4329
|
+
afterJournalSeq:
|
|
4330
|
+
activeTurn?.captainSyncedJournalSeq ?? 0,
|
|
4331
|
+
};
|
|
4332
|
+
}
|
|
4333
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
2654
4334
|
}
|
|
2655
4335
|
failure = error;
|
|
2656
4336
|
}
|
|
@@ -2661,11 +4341,14 @@ export function createPlaybookCaptainShell(
|
|
|
2661
4341
|
result.resumeToken === undefined;
|
|
2662
4342
|
if (!unsynchronized) {
|
|
2663
4343
|
conversation = { kind: 'pinned', token: result!.resumeToken! };
|
|
4344
|
+
if (activeTurn) {
|
|
4345
|
+
activeTurn.captainSyncedJournalSeq = representedJournalSeq;
|
|
4346
|
+
}
|
|
2664
4347
|
return {
|
|
2665
4348
|
...(result!.finalText !== undefined
|
|
2666
4349
|
? { finalText: result!.finalText }
|
|
2667
4350
|
: {}),
|
|
2668
|
-
correctiveSpent: seedFirstCall,
|
|
4351
|
+
correctiveSpent: seedFirstCall || catchUpFirstCall,
|
|
2669
4352
|
};
|
|
2670
4353
|
}
|
|
2671
4354
|
// Only the model-side conversation is replaced: the stack, player
|
|
@@ -2677,16 +4360,21 @@ export function createPlaybookCaptainShell(
|
|
|
2677
4360
|
let reissued:
|
|
2678
4361
|
| { status: string; finalText?: string; resumeToken?: string; error?: string }
|
|
2679
4362
|
| undefined;
|
|
4363
|
+
const reissueAttempt = { providerBoundaryEntered: false };
|
|
2680
4364
|
try {
|
|
2681
4365
|
reissued = await rawDurableCall(
|
|
2682
4366
|
context,
|
|
2683
4367
|
compose({ reseedDigest: recap }),
|
|
2684
4368
|
false,
|
|
4369
|
+
reissueAttempt,
|
|
2685
4370
|
);
|
|
2686
4371
|
} catch (error) {
|
|
2687
4372
|
if (context.signal.aborted) {
|
|
2688
4373
|
conversation = { kind: 'needsSeeding' };
|
|
2689
|
-
throw error;
|
|
4374
|
+
throw context.signal.reason ?? error;
|
|
4375
|
+
}
|
|
4376
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
4377
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
2690
4378
|
}
|
|
2691
4379
|
throw markControlFailure(new CaptainContinuityError(error));
|
|
2692
4380
|
}
|
|
@@ -2699,6 +4387,7 @@ export function createPlaybookCaptainShell(
|
|
|
2699
4387
|
);
|
|
2700
4388
|
}
|
|
2701
4389
|
conversation = { kind: 'pinned', token: reissued.resumeToken };
|
|
4390
|
+
if (activeTurn) activeTurn.captainSyncedJournalSeq = journalSeq;
|
|
2702
4391
|
return {
|
|
2703
4392
|
...(reissued.finalText !== undefined
|
|
2704
4393
|
? { finalText: reissued.finalText }
|
|
@@ -2751,9 +4440,11 @@ export function createPlaybookCaptainShell(
|
|
|
2751
4440
|
|
|
2752
4441
|
const captainPorts = (): PlaybookPorts => ({
|
|
2753
4442
|
callPlayer: async () => {
|
|
4443
|
+
admitHostBoundary();
|
|
2754
4444
|
throw new Error('the session Captain has no players');
|
|
2755
4445
|
},
|
|
2756
4446
|
callCaptain: async (prompt, signal) => {
|
|
4447
|
+
admitHostBoundary();
|
|
2757
4448
|
if (!activeContext) {
|
|
2758
4449
|
throw new Error('the session Captain called out of a Boss turn');
|
|
2759
4450
|
}
|
|
@@ -2821,29 +4512,37 @@ export function createPlaybookCaptainShell(
|
|
|
2821
4512
|
return { status: 'ok' as const, finalText: 'ok' };
|
|
2822
4513
|
},
|
|
2823
4514
|
callJudge: async () => {
|
|
4515
|
+
admitHostBoundary();
|
|
2824
4516
|
throw new Error('the session Captain makes no judge call');
|
|
2825
4517
|
},
|
|
2826
4518
|
callPlaybook: async () => {
|
|
4519
|
+
admitHostBoundary();
|
|
2827
4520
|
throw new Error('the session Captain never calls a playbook');
|
|
2828
4521
|
},
|
|
2829
4522
|
// CAPTAIN-9: the session Captain's human status stream is suppressed while
|
|
2830
4523
|
// its structured telemetry is forwarded.
|
|
2831
|
-
emitStatus: async () => {
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
4524
|
+
emitStatus: async () => {
|
|
4525
|
+
admitHostEmission();
|
|
4526
|
+
},
|
|
4527
|
+
emitTelemetry: (event) => {
|
|
4528
|
+
if (!admitHostEmission()) return Promise.resolve();
|
|
4529
|
+
const emission = (async (): Promise<void> => {
|
|
4530
|
+
if (event.topic === 'playbook.trace') {
|
|
4531
|
+
const payload = payloadRecord(event.payload);
|
|
4532
|
+
if (payload?.type === 'captain.call.started') {
|
|
4533
|
+
const identity = payloadRecord(payload.payload);
|
|
4534
|
+
const stateId = identity?.stateId;
|
|
4535
|
+
servingCall =
|
|
4536
|
+
stateId === 'reporting'
|
|
4537
|
+
? 'closingReply'
|
|
4538
|
+
: stateId === 'answeringCommand'
|
|
4539
|
+
? 'commandReply'
|
|
4540
|
+
: 'decision';
|
|
4541
|
+
}
|
|
2844
4542
|
}
|
|
2845
|
-
|
|
2846
|
-
|
|
4543
|
+
await requireSession().emitTelemetry(event);
|
|
4544
|
+
})();
|
|
4545
|
+
return trackTurnCall(emission);
|
|
2847
4546
|
},
|
|
2848
4547
|
});
|
|
2849
4548
|
|
|
@@ -2922,6 +4621,15 @@ export function createPlaybookCaptainShell(
|
|
|
2922
4621
|
}
|
|
2923
4622
|
};
|
|
2924
4623
|
|
|
4624
|
+
const rootCompletionFact = (frame: EngagementFrame): string => {
|
|
4625
|
+
const published = leafStateDescription(frame);
|
|
4626
|
+
const description =
|
|
4627
|
+
published === undefined ? '' : compactEvidence(published);
|
|
4628
|
+
return description === ''
|
|
4629
|
+
? `${frameLabel(frame)} completed; its runtime published no result description.`
|
|
4630
|
+
: `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
|
|
4631
|
+
};
|
|
4632
|
+
|
|
2925
4633
|
const leafStateSummary = (): string | undefined => {
|
|
2926
4634
|
const leaf = leafFrame();
|
|
2927
4635
|
if (!leaf) return 'idle: no playbook is engaged';
|
|
@@ -3086,7 +4794,7 @@ export function createPlaybookCaptainShell(
|
|
|
3086
4794
|
message: String(error),
|
|
3087
4795
|
};
|
|
3088
4796
|
if (aborted) {
|
|
3089
|
-
|
|
4797
|
+
markConversationUnsynchronized();
|
|
3090
4798
|
if (turn?.outcomePending) {
|
|
3091
4799
|
turn.settlementFacts.push(
|
|
3092
4800
|
`The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`,
|
|
@@ -3404,9 +5112,6 @@ export function createPlaybookCaptainShell(
|
|
|
3404
5112
|
};
|
|
3405
5113
|
throw outcome.error;
|
|
3406
5114
|
}
|
|
3407
|
-
if (!frames.includes(leaf)) {
|
|
3408
|
-
facts.push(`${frameLabel(leaf)} finished and was disposed.`);
|
|
3409
|
-
}
|
|
3410
5115
|
const runFailed = drainRunFailureFacts(facts);
|
|
3411
5116
|
const summary = leafStateSummary();
|
|
3412
5117
|
turn.report = {
|
|
@@ -3489,7 +5194,11 @@ export function createPlaybookCaptainShell(
|
|
|
3489
5194
|
// receipt rather than acting twice.
|
|
3490
5195
|
const key = `turn-${turn.id}-apply-${actionId}`;
|
|
3491
5196
|
const outcome = await withCounting(leaf, async () =>
|
|
3492
|
-
|
|
5197
|
+
runFrameOperation(leaf, () =>
|
|
5198
|
+
runEffect(() =>
|
|
5199
|
+
leaf.runtime.apply!({ actionId, key, signal }),
|
|
5200
|
+
),
|
|
5201
|
+
),
|
|
3493
5202
|
);
|
|
3494
5203
|
if (outcome.error !== undefined) throw outcome.error;
|
|
3495
5204
|
const receipt = outcome.result!;
|
|
@@ -3581,9 +5290,14 @@ export function createPlaybookCaptainShell(
|
|
|
3581
5290
|
};
|
|
3582
5291
|
|
|
3583
5292
|
const controller: CaptainControllerPort = {
|
|
3584
|
-
submit: (selection, signal) =>
|
|
3585
|
-
|
|
3586
|
-
|
|
5293
|
+
submit: (selection, signal) => {
|
|
5294
|
+
admitHostBoundary();
|
|
5295
|
+
return settleSelection(selection, signal);
|
|
5296
|
+
},
|
|
5297
|
+
resolveParsedTurn: () => {
|
|
5298
|
+
admitHostBoundary();
|
|
5299
|
+
return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
|
|
5300
|
+
},
|
|
3587
5301
|
};
|
|
3588
5302
|
|
|
3589
5303
|
// -------------------------------------------------------------------------
|
|
@@ -3648,7 +5362,11 @@ export function createPlaybookCaptainShell(
|
|
|
3648
5362
|
// The durable Captain conversation did not receive the shell-authored
|
|
3649
5363
|
// fallback. Force its next call through the journal so it cannot interpret
|
|
3650
5364
|
// the Boss's follow-up without the reply the Boss was given this turn.
|
|
3651
|
-
|
|
5365
|
+
if (activeTurn?.settingsPreflightFailures.has(error)) {
|
|
5366
|
+
markConversationCatchUp();
|
|
5367
|
+
} else {
|
|
5368
|
+
markConversationUnsynchronized();
|
|
5369
|
+
}
|
|
3652
5370
|
// A rejected presentation may already have emitted bytes. It is therefore
|
|
3653
5371
|
// final for this turn even though the Promise did not prove it was shown.
|
|
3654
5372
|
if (activeTurn?.presentationAttempted === true) return;
|
|
@@ -3661,48 +5379,551 @@ export function createPlaybookCaptainShell(
|
|
|
3661
5379
|
});
|
|
3662
5380
|
};
|
|
3663
5381
|
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
5382
|
+
const enabledCatalog = () =>
|
|
5383
|
+
Object.freeze(
|
|
5384
|
+
entries.map((entry) =>
|
|
5385
|
+
Object.freeze({
|
|
5386
|
+
id: entry.id,
|
|
5387
|
+
command: enablementById.get(entry.id)!.command,
|
|
5388
|
+
intent: entry.intent,
|
|
5389
|
+
}),
|
|
5390
|
+
),
|
|
5391
|
+
);
|
|
5392
|
+
|
|
5393
|
+
const captainPlaybookSession = (
|
|
5394
|
+
id: string,
|
|
5395
|
+
) => ({
|
|
5396
|
+
sessionId: id,
|
|
5397
|
+
playbookId: INTERNAL_CAPTAIN_ID,
|
|
5398
|
+
rootSessionId: id,
|
|
5399
|
+
depth: 0,
|
|
5400
|
+
roleBindings: {},
|
|
5401
|
+
ports: captainPorts(),
|
|
5402
|
+
});
|
|
5403
|
+
|
|
5404
|
+
const playerLedgerRecord = (): Readonly<
|
|
5405
|
+
Record<string, PlayerLedgerSnapshotEntry>
|
|
5406
|
+
> =>
|
|
5407
|
+
Object.fromEntries(
|
|
5408
|
+
[...playerLedger].map(([playerId, entry]) => [
|
|
5409
|
+
playerId,
|
|
5410
|
+
{
|
|
5411
|
+
adapter: entry.adapter,
|
|
5412
|
+
...(entry.instruction === undefined
|
|
5413
|
+
? {}
|
|
5414
|
+
: { instruction: entry.instruction }),
|
|
5415
|
+
...(entry.permissions === undefined
|
|
5416
|
+
? {}
|
|
5417
|
+
: { permissions: entry.permissions }),
|
|
5418
|
+
...(entry.resumeToken === undefined
|
|
5419
|
+
? {}
|
|
5420
|
+
: { resumeToken: entry.resumeToken }),
|
|
5421
|
+
},
|
|
5422
|
+
]),
|
|
5423
|
+
);
|
|
5424
|
+
|
|
5425
|
+
const assertSnapshotMatchesEnablements = (
|
|
5426
|
+
snapshot: PlaybookCaptainShellSnapshot,
|
|
5427
|
+
enabled: ReadonlyMap<string, Enablement>,
|
|
5428
|
+
): void => {
|
|
5429
|
+
if (
|
|
5430
|
+
captainAgent === undefined ||
|
|
5431
|
+
!isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))
|
|
5432
|
+
) {
|
|
5433
|
+
throw new TypeError(
|
|
5434
|
+
'Captain shell snapshot Captain agent is incompatible with current config',
|
|
5435
|
+
);
|
|
5436
|
+
}
|
|
5437
|
+
const configuredPlayerIds = [...playerAgents.keys()].sort();
|
|
5438
|
+
const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
|
|
5439
|
+
if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
|
|
5440
|
+
throw new TypeError(
|
|
5441
|
+
'Captain shell snapshot player ledger does not match current referenced players',
|
|
5442
|
+
);
|
|
5443
|
+
}
|
|
5444
|
+
for (const playerId of configuredPlayerIds) {
|
|
5445
|
+
const saved = snapshot.playerSessions[playerId]!;
|
|
5446
|
+
const configured = playerAgents.get(playerId)!;
|
|
5447
|
+
const savedFixed = {
|
|
5448
|
+
adapter: saved.adapter,
|
|
5449
|
+
...(saved.instruction === undefined
|
|
5450
|
+
? {}
|
|
5451
|
+
: { instruction: saved.instruction }),
|
|
5452
|
+
...(saved.permissions === undefined
|
|
5453
|
+
? {}
|
|
5454
|
+
: { permissions: saved.permissions }),
|
|
5455
|
+
};
|
|
5456
|
+
if (!isDeepStrictEqual(savedFixed, fixedAgent(configured))) {
|
|
5457
|
+
throw new TypeError(
|
|
5458
|
+
`Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`,
|
|
5459
|
+
);
|
|
5460
|
+
}
|
|
5461
|
+
}
|
|
5462
|
+
if (snapshot.mode === 'chat') return;
|
|
5463
|
+
for (const frame of snapshot.frames) {
|
|
5464
|
+
const enablement = enabled.get(frame.playbookId);
|
|
5465
|
+
if (!enablement) {
|
|
5466
|
+
throw new TypeError(
|
|
5467
|
+
`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`,
|
|
5468
|
+
);
|
|
5469
|
+
}
|
|
5470
|
+
const configuredBindings = Object.fromEntries(
|
|
5471
|
+
[...enablement.roleBindings].map(([role, binding]) => [
|
|
5472
|
+
role,
|
|
5473
|
+
binding.playerId,
|
|
5474
|
+
]),
|
|
5475
|
+
);
|
|
5476
|
+
if (!isDeepStrictEqual(frame.options, enablement.options)) {
|
|
5477
|
+
throw new TypeError(
|
|
5478
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`,
|
|
5479
|
+
);
|
|
5480
|
+
}
|
|
5481
|
+
if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
|
|
5482
|
+
throw new TypeError(
|
|
5483
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`,
|
|
5484
|
+
);
|
|
5485
|
+
}
|
|
5486
|
+
}
|
|
5487
|
+
};
|
|
5488
|
+
|
|
5489
|
+
const safeCapturePoint = (): boolean => {
|
|
5490
|
+
if (
|
|
5491
|
+
lifecycle !== 'ready' ||
|
|
5492
|
+
terminallyDisposed ||
|
|
5493
|
+
!sessionEmissionsOpen ||
|
|
5494
|
+
!session ||
|
|
5495
|
+
session.signal.aborted ||
|
|
5496
|
+
!captainRuntime ||
|
|
5497
|
+
disposing ||
|
|
5498
|
+
shuttingDown ||
|
|
5499
|
+
activeContext !== undefined ||
|
|
5500
|
+
activeTurn !== undefined ||
|
|
5501
|
+
activeTurnHostCalls !== undefined ||
|
|
5502
|
+
activeTurnSummary !== undefined ||
|
|
5503
|
+
runFailureFacts !== undefined ||
|
|
5504
|
+
servingCall !== undefined ||
|
|
5505
|
+
decisionCall !== undefined ||
|
|
5506
|
+
playerTransactions.size !== 0 ||
|
|
5507
|
+
captainQueue.pending !== 0 ||
|
|
5508
|
+
captainQueue.size !== 0 ||
|
|
5509
|
+
(mode !== 'chat' && mode !== 'engaged.parked')
|
|
5510
|
+
) {
|
|
5511
|
+
return false;
|
|
5512
|
+
}
|
|
5513
|
+
if (
|
|
5514
|
+
(mode === 'chat' &&
|
|
5515
|
+
(frames.length !== 0 ||
|
|
5516
|
+
pendingChildParents.size !== 0 ||
|
|
5517
|
+
pendingBossQuestions !== undefined ||
|
|
5518
|
+
lastError !== undefined)) ||
|
|
5519
|
+
(mode === 'engaged.parked' && frames.length === 0)
|
|
5520
|
+
) {
|
|
5521
|
+
return false;
|
|
5522
|
+
}
|
|
5523
|
+
const expectedParents = new Set(frames.slice(0, -1));
|
|
5524
|
+
if (
|
|
5525
|
+
pendingChildParents.size !== expectedParents.size ||
|
|
5526
|
+
[...pendingChildParents].some((frame) => !expectedParents.has(frame))
|
|
5527
|
+
) {
|
|
5528
|
+
return false;
|
|
5529
|
+
}
|
|
5530
|
+
return frames.every((frame, index) => {
|
|
5531
|
+
const liveInvocation =
|
|
5532
|
+
frame.invocationSignal !== undefined || frame.abortListener !== undefined;
|
|
5533
|
+
return (
|
|
5534
|
+
frame.state !== undefined &&
|
|
5535
|
+
frame.state.status === 'active' &&
|
|
5536
|
+
frame.state.quiescent &&
|
|
5537
|
+
!frame.disposing &&
|
|
5538
|
+
frame.disposePromise === undefined &&
|
|
5539
|
+
frame.removal === undefined &&
|
|
5540
|
+
frame.inFlightHostCalls.size === 0 &&
|
|
5541
|
+
(index === 0
|
|
5542
|
+
? !liveInvocation
|
|
5543
|
+
: !liveInvocation ||
|
|
5544
|
+
(frame.invocationSignal !== undefined &&
|
|
5545
|
+
!frame.invocationSignal.aborted &&
|
|
5546
|
+
frame.abortListener !== undefined))
|
|
5547
|
+
);
|
|
5548
|
+
});
|
|
5549
|
+
};
|
|
5550
|
+
|
|
5551
|
+
const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
|
|
5552
|
+
if (
|
|
5553
|
+
!safeCapturePoint() ||
|
|
5554
|
+
!captainRuntime ||
|
|
5555
|
+
!captainSessionId ||
|
|
5556
|
+
!captainAgent
|
|
5557
|
+
) {
|
|
5558
|
+
return undefined;
|
|
5559
|
+
}
|
|
5560
|
+
try {
|
|
5561
|
+
if (
|
|
5562
|
+
typeof captainRuntime.exportSnapshot !== 'function' ||
|
|
5563
|
+
typeof captainRuntime.restore !== 'function'
|
|
5564
|
+
) {
|
|
5565
|
+
return undefined;
|
|
5566
|
+
}
|
|
5567
|
+
const captainSnapshot = captainRuntime.exportSnapshot();
|
|
5568
|
+
if (captainSnapshot === undefined) return undefined;
|
|
5569
|
+
const frameSnapshots: PlaybookCaptainFrameSnapshot[] = [];
|
|
5570
|
+
for (const frame of frames) {
|
|
5571
|
+
if (
|
|
5572
|
+
typeof frame.runtime.exportSnapshot !== 'function' ||
|
|
5573
|
+
typeof frame.runtime.restore !== 'function'
|
|
5574
|
+
) {
|
|
5575
|
+
return undefined;
|
|
5576
|
+
}
|
|
5577
|
+
const runtime = frame.runtime.exportSnapshot();
|
|
5578
|
+
if (
|
|
5579
|
+
runtime === undefined ||
|
|
5580
|
+
!isDeepStrictEqual(frame.state, runtime.state)
|
|
5581
|
+
) {
|
|
5582
|
+
return undefined;
|
|
5583
|
+
}
|
|
5584
|
+
frameSnapshots.push({
|
|
5585
|
+
playbookId: frame.entry.id,
|
|
5586
|
+
sessionId: frame.sessionId,
|
|
5587
|
+
rootSessionId: frame.rootSessionId,
|
|
5588
|
+
depth: frame.depth,
|
|
5589
|
+
...(frame.parent
|
|
5590
|
+
? {
|
|
5591
|
+
parentSessionId: frame.parent.frame.sessionId,
|
|
5592
|
+
parentCallId: frame.parent.callId,
|
|
5593
|
+
}
|
|
5594
|
+
: {}),
|
|
5595
|
+
options: frame.enablement.options,
|
|
5596
|
+
roleBindings: Object.fromEntries(
|
|
5597
|
+
[...frame.playerBindings].map(([role, binding]) => [
|
|
5598
|
+
role,
|
|
5599
|
+
binding.playerId,
|
|
5600
|
+
]),
|
|
5601
|
+
),
|
|
5602
|
+
runtime,
|
|
5603
|
+
});
|
|
5604
|
+
}
|
|
5605
|
+
const common = {
|
|
5606
|
+
schemaVersion: 3 as const,
|
|
5607
|
+
captain: {
|
|
5608
|
+
sessionId: captainSessionId,
|
|
5609
|
+
runtime: captainSnapshot,
|
|
5610
|
+
agent: fixedAgent(captainAgent),
|
|
5611
|
+
conversation,
|
|
5612
|
+
},
|
|
5613
|
+
playerSessions: playerLedgerRecord(),
|
|
5614
|
+
issuedSessionIds: [...issuedSessionIds],
|
|
5615
|
+
sequences: { turn: turnSequence, journal: journalSeq },
|
|
5616
|
+
journal,
|
|
5617
|
+
...(lastAction === undefined ? {} : { lastAction }),
|
|
5618
|
+
...(lastSettlementStatus === undefined
|
|
5619
|
+
? {}
|
|
5620
|
+
: { lastSettlementStatus }),
|
|
5621
|
+
};
|
|
5622
|
+
const candidate: PlaybookCaptainShellSnapshot =
|
|
5623
|
+
mode === 'chat'
|
|
5624
|
+
? { ...common, mode }
|
|
5625
|
+
: {
|
|
5626
|
+
...common,
|
|
5627
|
+
mode: 'engaged.parked',
|
|
5628
|
+
frames: frameSnapshots,
|
|
5629
|
+
...(pendingBossQuestions === undefined
|
|
5630
|
+
? {}
|
|
5631
|
+
: { pendingBossQuestions: pendingBossQuestions as JsonValue }),
|
|
5632
|
+
...(lastError === undefined ? {} : { lastError }),
|
|
5633
|
+
};
|
|
5634
|
+
const normalized = assertPlaybookCaptainShellSnapshot(candidate);
|
|
5635
|
+
assertSnapshotMatchesEnablements(normalized, enablementById);
|
|
5636
|
+
return normalized;
|
|
5637
|
+
} catch {
|
|
5638
|
+
return undefined;
|
|
5639
|
+
}
|
|
5640
|
+
};
|
|
5641
|
+
|
|
5642
|
+
const verifyRestoredRuntime = (
|
|
5643
|
+
runtime: PlaybookRuntime,
|
|
5644
|
+
expected: PlaybookRuntimeSnapshot,
|
|
5645
|
+
playbookId: string,
|
|
5646
|
+
allowSuspendedCall: boolean,
|
|
5647
|
+
): void => {
|
|
5648
|
+
const actual = runtime.exportSnapshot?.();
|
|
5649
|
+
if (actual === undefined) {
|
|
5650
|
+
throw new Error(
|
|
5651
|
+
`restored ${playbookId} runtime did not reach a safe snapshot boundary`,
|
|
5652
|
+
);
|
|
5653
|
+
}
|
|
5654
|
+
const normalized = assertPlaybookRuntimeSnapshot(
|
|
5655
|
+
actual,
|
|
5656
|
+
playbookId,
|
|
5657
|
+
allowSuspendedCall ? { allowSuspendedCall: true } : {},
|
|
5658
|
+
);
|
|
5659
|
+
for (const key of [
|
|
5660
|
+
'state',
|
|
5661
|
+
'roleResumeTokens',
|
|
5662
|
+
'sequences',
|
|
5663
|
+
'pendingBossQuestions',
|
|
5664
|
+
'suspendedCall',
|
|
5665
|
+
] as const) {
|
|
5666
|
+
if (!isDeepStrictEqual(normalized[key], expected[key])) {
|
|
5667
|
+
throw new Error(
|
|
5668
|
+
`restored ${playbookId} runtime changed snapshot field ${key}`,
|
|
5669
|
+
);
|
|
5670
|
+
}
|
|
5671
|
+
}
|
|
5672
|
+
};
|
|
5673
|
+
|
|
5674
|
+
const resetFailedRestore = async (): Promise<readonly unknown[]> => {
|
|
5675
|
+
const cleanupFailures: unknown[] = [];
|
|
5676
|
+
for (const frame of [...frames].reverse()) {
|
|
5677
|
+
frame.disposing = true;
|
|
5678
|
+
try {
|
|
5679
|
+
await frame.runtime.dispose();
|
|
5680
|
+
} catch (error) {
|
|
5681
|
+
cleanupFailures.push(error);
|
|
5682
|
+
}
|
|
5683
|
+
}
|
|
5684
|
+
if (captainRuntime) {
|
|
5685
|
+
shuttingDown = true;
|
|
5686
|
+
try {
|
|
5687
|
+
await captainRuntime.dispose();
|
|
5688
|
+
} catch (error) {
|
|
5689
|
+
cleanupFailures.push(error);
|
|
5690
|
+
}
|
|
5691
|
+
}
|
|
5692
|
+
frames.splice(0);
|
|
5693
|
+
pendingChildParents.clear();
|
|
5694
|
+
issuedSessionIds.clear();
|
|
5695
|
+
journal.splice(0);
|
|
5696
|
+
entries = [];
|
|
5697
|
+
byCommand = new Map();
|
|
5698
|
+
byId = new Map();
|
|
5699
|
+
enablementById = new Map();
|
|
5700
|
+
captainAgent = undefined;
|
|
5701
|
+
captainAdapter = undefined;
|
|
5702
|
+
playerAgents = new Map();
|
|
5703
|
+
playerLedger.clear();
|
|
5704
|
+
playerTransactions.clear();
|
|
5705
|
+
session = undefined;
|
|
5706
|
+
sessionEmissionsOpen = false;
|
|
5707
|
+
closedGateAttempted = false;
|
|
5708
|
+
captainRuntime = undefined;
|
|
5709
|
+
captainSessionId = undefined;
|
|
5710
|
+
conversation = { kind: 'unopened' };
|
|
5711
|
+
mode = 'chat';
|
|
5712
|
+
pendingBossQuestions = undefined;
|
|
5713
|
+
lastError = undefined;
|
|
5714
|
+
journalSeq = 0;
|
|
5715
|
+
turnSequence = 0;
|
|
5716
|
+
lastAction = undefined;
|
|
5717
|
+
lastSettlementStatus = undefined;
|
|
5718
|
+
shuttingDown = false;
|
|
5719
|
+
if (cleanupFailures.length > 0) {
|
|
5720
|
+
terminallyDisposed = true;
|
|
5721
|
+
lifecycle = 'closed';
|
|
5722
|
+
} else {
|
|
5723
|
+
lifecycle = 'fresh';
|
|
5724
|
+
}
|
|
5725
|
+
return cleanupFailures;
|
|
5726
|
+
};
|
|
5727
|
+
|
|
5728
|
+
const restoreShellSnapshot = async (
|
|
5729
|
+
initSession: CaptainSession,
|
|
5730
|
+
untrusted: PlaybookCaptainShellSnapshot,
|
|
5731
|
+
): Promise<void> => {
|
|
5732
|
+
if (lifecycle !== 'fresh' || terminallyDisposed) {
|
|
5733
|
+
throw new Error('Captain shell restore requires a fresh shell');
|
|
5734
|
+
}
|
|
5735
|
+
if (initSession.signal.aborted) {
|
|
5736
|
+
throw new Error('cannot restore an aborted Captain session');
|
|
5737
|
+
}
|
|
5738
|
+
lifecycle = 'restoring';
|
|
5739
|
+
try {
|
|
5740
|
+
const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
|
|
5741
|
+
const built = await buildEnablements(options, loadModule);
|
|
5742
|
+
captainAgent = built.captainAgent;
|
|
5743
|
+
captainAdapter = captainAgent.adapter;
|
|
5744
|
+
playerAgents = built.playerAgents;
|
|
5745
|
+
assertSnapshotMatchesEnablements(snapshot, built.enablementById);
|
|
5746
|
+
|
|
5747
|
+
installSession(initSession, false);
|
|
3669
5748
|
entries = built.entries;
|
|
3670
5749
|
byCommand = built.byCommand;
|
|
3671
5750
|
byId = built.byId;
|
|
3672
5751
|
enablementById = built.enablementById;
|
|
3673
|
-
for (const
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
captainSessionId = allocateSessionId();
|
|
5752
|
+
for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
|
|
5753
|
+
playerLedger.set(playerId, {
|
|
5754
|
+
adapter: saved.adapter,
|
|
5755
|
+
...(saved.instruction === undefined
|
|
5756
|
+
? {}
|
|
5757
|
+
: { instruction: saved.instruction }),
|
|
5758
|
+
...(saved.permissions === undefined
|
|
5759
|
+
? {}
|
|
5760
|
+
: { permissions: livePermissions(saved.permissions) }),
|
|
5761
|
+
...(saved.resumeToken === undefined
|
|
5762
|
+
? {}
|
|
5763
|
+
: { resumeToken: saved.resumeToken }),
|
|
5764
|
+
});
|
|
5765
|
+
}
|
|
5766
|
+
|
|
3689
5767
|
captainRuntime = createCaptainRuntime({
|
|
3690
|
-
enabledPlaybooks:
|
|
5768
|
+
enabledPlaybooks: enabledCatalog(),
|
|
3691
5769
|
controller,
|
|
3692
5770
|
});
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
5771
|
+
if (typeof captainRuntime.restore !== 'function') {
|
|
5772
|
+
throw new Error('session Captain runtime does not support restore');
|
|
5773
|
+
}
|
|
5774
|
+
|
|
5775
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
5776
|
+
for (const [index, frameSnapshot] of snapshot.frames.entries()) {
|
|
5777
|
+
const parentFrame = frames.at(-1);
|
|
5778
|
+
const frame = makeRestoredFrame(
|
|
5779
|
+
enablementById.get(frameSnapshot.playbookId)!,
|
|
5780
|
+
frameSnapshot,
|
|
5781
|
+
index === 0
|
|
5782
|
+
? undefined
|
|
5783
|
+
: {
|
|
5784
|
+
frame: parentFrame!,
|
|
5785
|
+
callId: frameSnapshot.parentCallId!,
|
|
5786
|
+
},
|
|
5787
|
+
);
|
|
5788
|
+
if (typeof frame.runtime.restore !== 'function') {
|
|
5789
|
+
throw new Error(
|
|
5790
|
+
`playbook ${frame.entry.id} runtime does not support restore`,
|
|
5791
|
+
);
|
|
5792
|
+
}
|
|
5793
|
+
frames.push(frame);
|
|
5794
|
+
if (parentFrame) pendingChildParents.add(parentFrame);
|
|
5795
|
+
}
|
|
5796
|
+
}
|
|
5797
|
+
|
|
5798
|
+
await captainRuntime.restore(
|
|
5799
|
+
captainPlaybookSession(snapshot.captain.sessionId),
|
|
5800
|
+
snapshot.captain.runtime,
|
|
5801
|
+
);
|
|
5802
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
5803
|
+
for (const [index, frame] of frames.entries()) {
|
|
5804
|
+
restoringPlayerSessionFrame = frame;
|
|
5805
|
+
try {
|
|
5806
|
+
await frame.runtime.restore!(
|
|
5807
|
+
frameSession(frame),
|
|
5808
|
+
snapshot.frames[index]!.runtime,
|
|
5809
|
+
);
|
|
5810
|
+
} finally {
|
|
5811
|
+
restoringPlayerSessionFrame = undefined;
|
|
5812
|
+
}
|
|
5813
|
+
}
|
|
5814
|
+
}
|
|
5815
|
+
if (closedGateAttempted) {
|
|
5816
|
+
throw new Error('a runtime attempted a host emission during restore');
|
|
5817
|
+
}
|
|
5818
|
+
verifyRestoredRuntime(
|
|
5819
|
+
captainRuntime,
|
|
5820
|
+
snapshot.captain.runtime,
|
|
5821
|
+
INTERNAL_CAPTAIN_ID,
|
|
5822
|
+
false,
|
|
5823
|
+
);
|
|
5824
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
5825
|
+
for (const [index, frame] of frames.entries()) {
|
|
5826
|
+
verifyRestoredRuntime(
|
|
5827
|
+
frame.runtime,
|
|
5828
|
+
snapshot.frames[index]!.runtime,
|
|
5829
|
+
frame.entry.id,
|
|
5830
|
+
true,
|
|
5831
|
+
);
|
|
5832
|
+
}
|
|
5833
|
+
if (
|
|
5834
|
+
!isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)
|
|
5835
|
+
) {
|
|
5836
|
+
throw new Error(
|
|
5837
|
+
'restored Captain-session player continuation changed during restore',
|
|
5838
|
+
);
|
|
5839
|
+
}
|
|
5840
|
+
}
|
|
5841
|
+
if (closedGateAttempted) {
|
|
5842
|
+
throw new Error('a runtime attempted a host emission during restore');
|
|
5843
|
+
}
|
|
5844
|
+
if (requireSession().signal.aborted) {
|
|
5845
|
+
throw new Error('Captain session aborted during restore');
|
|
5846
|
+
}
|
|
5847
|
+
for (const id of snapshot.issuedSessionIds) issuedSessionIds.add(id);
|
|
5848
|
+
journal.push(...snapshot.journal);
|
|
5849
|
+
journalSeq = snapshot.sequences.journal;
|
|
5850
|
+
turnSequence = snapshot.sequences.turn;
|
|
5851
|
+
conversation = snapshot.captain.conversation;
|
|
5852
|
+
captainSessionId = snapshot.captain.sessionId;
|
|
5853
|
+
lastAction = snapshot.lastAction;
|
|
5854
|
+
lastSettlementStatus = snapshot.lastSettlementStatus;
|
|
5855
|
+
mode = snapshot.mode;
|
|
5856
|
+
if (snapshot.mode === 'engaged.parked') {
|
|
5857
|
+
pendingBossQuestions = snapshot.pendingBossQuestions;
|
|
5858
|
+
lastError = snapshot.lastError;
|
|
5859
|
+
}
|
|
5860
|
+
lifecycle = 'ready';
|
|
5861
|
+
// The final commit is deliberately one non-throwing assignment.
|
|
5862
|
+
sessionEmissionsOpen = true;
|
|
5863
|
+
} catch (error) {
|
|
5864
|
+
const cleanupFailures = await resetFailedRestore();
|
|
5865
|
+
if (cleanupFailures.length > 0) {
|
|
5866
|
+
throw new AggregateError(
|
|
5867
|
+
[error, ...cleanupFailures],
|
|
5868
|
+
'Captain shell restore and cleanup failed',
|
|
5869
|
+
);
|
|
5870
|
+
}
|
|
5871
|
+
throw error;
|
|
5872
|
+
}
|
|
5873
|
+
};
|
|
5874
|
+
|
|
5875
|
+
return {
|
|
5876
|
+
async init(initSession: CaptainSession): Promise<void> {
|
|
5877
|
+
if (lifecycle !== 'fresh' || terminallyDisposed) {
|
|
5878
|
+
throw new Error('Captain shell requires a fresh instance for init');
|
|
5879
|
+
}
|
|
5880
|
+
if (initSession.signal.aborted) {
|
|
5881
|
+
throw new Error('cannot initialize an aborted Captain session');
|
|
5882
|
+
}
|
|
5883
|
+
lifecycle = 'initializing';
|
|
5884
|
+
try {
|
|
5885
|
+
installSession(initSession, true);
|
|
5886
|
+
const built = await buildEnablements(options, loadModule);
|
|
5887
|
+
entries = built.entries;
|
|
5888
|
+
byCommand = built.byCommand;
|
|
5889
|
+
byId = built.byId;
|
|
5890
|
+
enablementById = built.enablementById;
|
|
5891
|
+
captainAgent = built.captainAgent;
|
|
5892
|
+
captainAdapter = captainAgent.adapter;
|
|
5893
|
+
playerAgents = built.playerAgents;
|
|
5894
|
+
for (const [playerId, agent] of playerAgents) {
|
|
5895
|
+
playerLedger.set(playerId, fixedAgent(agent));
|
|
5896
|
+
}
|
|
5897
|
+
await setMode('chat', 'init');
|
|
5898
|
+
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
5899
|
+
// engagement stack, with its own playbook session id.
|
|
5900
|
+
captainSessionId = allocateSessionId();
|
|
5901
|
+
captainRuntime = createCaptainRuntime({
|
|
5902
|
+
enabledPlaybooks: enabledCatalog(),
|
|
5903
|
+
controller,
|
|
5904
|
+
});
|
|
5905
|
+
await captainRuntime.init(captainPlaybookSession(captainSessionId));
|
|
5906
|
+
lifecycle = 'ready';
|
|
5907
|
+
} catch (error) {
|
|
5908
|
+
terminallyDisposed = true;
|
|
5909
|
+
lifecycle = 'closed';
|
|
5910
|
+
throw error;
|
|
5911
|
+
}
|
|
3700
5912
|
},
|
|
3701
5913
|
|
|
5914
|
+
exportSnapshot: exportShellSnapshot,
|
|
5915
|
+
|
|
5916
|
+
restore: restoreShellSnapshot,
|
|
5917
|
+
|
|
3702
5918
|
async handleBossTurn(
|
|
3703
5919
|
turn: BossTurn,
|
|
3704
5920
|
context: CaptainContext,
|
|
3705
5921
|
): Promise<void> {
|
|
5922
|
+
if (lifecycle !== 'ready' || terminallyDisposed) {
|
|
5923
|
+
throw new Error(
|
|
5924
|
+
'init must be called first, or restore must complete before handling a Boss turn',
|
|
5925
|
+
);
|
|
5926
|
+
}
|
|
3706
5927
|
requireSession();
|
|
3707
5928
|
if (!captainRuntime) {
|
|
3708
5929
|
throw new Error('init must be called first');
|
|
@@ -3719,6 +5940,7 @@ export function createPlaybookCaptainShell(
|
|
|
3719
5940
|
const parsed = resolveCommandTurn(turn.prompt);
|
|
3720
5941
|
activeTurn = {
|
|
3721
5942
|
id: ++turnSequence,
|
|
5943
|
+
captainSyncedJournalSeq: journalSeq,
|
|
3722
5944
|
bossText: turn.prompt,
|
|
3723
5945
|
authoritativeText: parsed?.authoritativeText ?? turn.prompt,
|
|
3724
5946
|
...(parsed ? { resolution: parsed.resolution } : {}),
|
|
@@ -3726,6 +5948,8 @@ export function createPlaybookCaptainShell(
|
|
|
3726
5948
|
presentationAttempted: false,
|
|
3727
5949
|
settlementFacts: [],
|
|
3728
5950
|
effectThrows: new Set<unknown>(),
|
|
5951
|
+
controlFailures: new Set<unknown>(),
|
|
5952
|
+
settingsPreflightFailures: new Set<unknown>(),
|
|
3729
5953
|
suppliedIdentifiers: new Set<string>(),
|
|
3730
5954
|
outcomeRecorded: false,
|
|
3731
5955
|
};
|
|
@@ -3746,13 +5970,16 @@ export function createPlaybookCaptainShell(
|
|
|
3746
5970
|
new Error('the session Captain turn failed at its boundary'),
|
|
3747
5971
|
);
|
|
3748
5972
|
} else if (result.outcome === 'aborted') {
|
|
3749
|
-
|
|
5973
|
+
markConversationUnsynchronized();
|
|
3750
5974
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
3751
5975
|
activeTurn.settlementFacts.push(
|
|
3752
5976
|
'The Boss turn was aborted before it settled; no action was repeated automatically.',
|
|
3753
5977
|
);
|
|
3754
5978
|
journalOutcome([...activeTurn.settlementFacts]);
|
|
3755
5979
|
}
|
|
5980
|
+
if (context.signal.aborted) {
|
|
5981
|
+
throw context.signal.reason;
|
|
5982
|
+
}
|
|
3756
5983
|
} else if (
|
|
3757
5984
|
result.outcome !== 'suspended' &&
|
|
3758
5985
|
!context.signal.aborted &&
|
|
@@ -3771,10 +5998,10 @@ export function createPlaybookCaptainShell(
|
|
|
3771
5998
|
}
|
|
3772
5999
|
} catch (error) {
|
|
3773
6000
|
if (context.signal.aborted) {
|
|
3774
|
-
|
|
6001
|
+
markConversationUnsynchronized();
|
|
3775
6002
|
throw error;
|
|
3776
6003
|
}
|
|
3777
|
-
const controlFailure = activeTurn?.
|
|
6004
|
+
const controlFailure = activeTurn?.controlFailures.has(error) === true;
|
|
3778
6005
|
await settleTurnFailure(context, error);
|
|
3779
6006
|
if (activeTurn?.presentationError !== undefined) {
|
|
3780
6007
|
throw activeTurn.presentationError;
|
|
@@ -3784,7 +6011,7 @@ export function createPlaybookCaptainShell(
|
|
|
3784
6011
|
if (!controlFailure) throw error;
|
|
3785
6012
|
} finally {
|
|
3786
6013
|
if (context.signal.aborted) {
|
|
3787
|
-
|
|
6014
|
+
markConversationUnsynchronized();
|
|
3788
6015
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
3789
6016
|
activeTurn.settlementFacts.push(
|
|
3790
6017
|
'The Boss turn was aborted before it settled; no action was repeated automatically.',
|
|
@@ -3804,11 +6031,17 @@ export function createPlaybookCaptainShell(
|
|
|
3804
6031
|
},
|
|
3805
6032
|
|
|
3806
6033
|
async prepareDispose(): Promise<void> {
|
|
6034
|
+
if (lifecycle === 'initializing' || lifecycle === 'restoring') {
|
|
6035
|
+
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
6036
|
+
}
|
|
3807
6037
|
activeContext = undefined;
|
|
3808
6038
|
await teardown();
|
|
3809
6039
|
},
|
|
3810
6040
|
|
|
3811
6041
|
async dispose(): Promise<void> {
|
|
6042
|
+
if (lifecycle === 'initializing' || lifecycle === 'restoring') {
|
|
6043
|
+
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
6044
|
+
}
|
|
3812
6045
|
activeContext = undefined;
|
|
3813
6046
|
await teardown();
|
|
3814
6047
|
},
|
|
@@ -3817,6 +6050,8 @@ export function createPlaybookCaptainShell(
|
|
|
3817
6050
|
// CAPTAIN-16: dispose every active frame from leaf to root, then the
|
|
3818
6051
|
// session Captain last.
|
|
3819
6052
|
async function teardown(): Promise<void> {
|
|
6053
|
+
terminallyDisposed = true;
|
|
6054
|
+
lifecycle = 'disposing';
|
|
3820
6055
|
let failure: unknown;
|
|
3821
6056
|
try {
|
|
3822
6057
|
await disposeStack('dispose');
|
|
@@ -3833,6 +6068,10 @@ export function createPlaybookCaptainShell(
|
|
|
3833
6068
|
failure ??= error;
|
|
3834
6069
|
}
|
|
3835
6070
|
}
|
|
6071
|
+
// Quarantine is session-wide by design. Only terminal teardown may drop
|
|
6072
|
+
// its ownership after every frame host call and the Captain are drained.
|
|
6073
|
+
playerTransactions.clear();
|
|
6074
|
+
lifecycle = 'closed';
|
|
3836
6075
|
if (failure !== undefined) throw failure;
|
|
3837
6076
|
}
|
|
3838
6077
|
}
|